A Simple Example: Optimizing a Paraboloid
We're going to duplicate the Paraboloid example from the OpenMDAO documentation, but implement the single ExplicitComponent
in Julia instead of Python. The goal of this tutorial is to minimize the paraboloid
\[f(x,y) = (x - 3.0)^2 + xy + (y + 4.0)^2 - 3.0\]
with respect to $x$ and $y$. The OpenMDAO docs say the answer is $x = \frac{20}{3} \approx 6.667$ and $y = -\frac{22}{3} \approx -7.333$. Let's find out!
The Python Implementation
One possible Python implementation of the above paraboloid is this:
import openmdao.api as om
class Paraboloid(om.ExplicitComponent):
"""
Evaluates the equation f(x,y) = (x-3)^2 + xy + (y+4)^2 - 3.
"""
def setup(self):
self.add_input('x', val=0.0)
self.add_input('y', val=0.0)
self.add_output('f_xy', val=0.0)
# Finite difference all partials.
self.declare_partials('*', '*', method='fd')
def compute(self, inputs, outputs):
"""
f(x,y) = (x-3)^2 + xy + (y+4)^2 - 3
Minimum at: x = 6.6667; y = -7.3333
"""
x = inputs['x']
y = inputs['y']
outputs['f_xy'] = (x - 3.0)**2 + x * y + (y + 4.0)**2 - 3.0
Not too bad. How do we do it in Julia?
The Julia Implementation
Like this, using OpenMDAOCore.jl
:
using OpenMDAOCore: OpenMDAOCore
struct Paraboloid <: OpenMDAOCore.AbstractExplicitComp
end
function OpenMDAOCore.setup(self::Paraboloid)
inputs = [OpenMDAOCore.VarData("x", val=0.0), OpenMDAOCore.VarData("y", val=0.0)]
outputs = [OpenMDAOCore.VarData("f_xy", val=0.0)]
partials = [OpenMDAOCore.PartialsData("*", "*", method="fd")]
return inputs, outputs, partials
end
function OpenMDAOCore.compute!(self::Paraboloid, inputs, outputs)
x = inputs["x"][1]
y = inputs["y"][1]
outputs["f_xy"][1] = (x - 3.0)^2 + x * y + (y + 4.0)^2 - 3.0
return nothing
end
What does all that mean? We'll go through it step by step.
Step 1: Preamble
using OpenMDAOCore: OpenMDAOCore
This line loads the OpenMDAOCore.jl Julia package. Julia uses two different keywords for loading code from Julia modules: using
and import
. The official Julia docs on Modules do a good job of explaining the difference. I like doing using Foo: Foo
because it brings the module name Foo
into the current scope, but not any of the names inside of Foo
, so it doesn't clutter the namespace. (The statement using Foo: Foo
is kind of like Julia's version of import foo
in Python, while just plain using Foo
is like Python's from foo import *
.)
Step 2: The Paraboloid
struct
struct Paraboloid <: OpenMDAOCore.AbstractExplicitComp
end
This bit of code defines a new type in Julia named Paraboloid
. The <:
is the subtype operator in Julia, so we are telling Julia that our new Paraboloid
type is a subtype of the AbstractExplicitComp
type defined in OpenMDAOCore
. This is the Julian equivalent of
class Paraboloid(om.ExplicitComponent):
in Python.
Step 3: OpenMDAOCore.setup
function OpenMDAOCore.setup(self::Paraboloid)
inputs = [OpenMDAOCore.VarData("x", val=0.0), OpenMDAOCore.VarData("y", val=0.0)]
outputs = [OpenMDAOCore.VarData("f_xy", val=0.0)]
partials = [OpenMDAOCore.PartialsData("*", "*", method="fd")]
return inputs, outputs, partials
end
This OpenMDAOCore.setup
method is the Julian equivalent of the ExplicitComponent.setup
method from the Python version of the paraboloid. The job of OpenMDAOCore.setup
is to take a single argument (an OpenMDAOCore.AbstractExplicitComp
or OpenMDAOCore.AbstractImplicitComp
) and return three things:
- A
Vector
ofVarData
structs
containing metadata for the inputs to the component - A
Vector
ofVarData
structs
containing metadata for the outputs of the component - A
Vector
ofPartialsData
structs
containing metadata for the partial derivatives of the component
These Julia Vector
s must always be returned in that order: inputs, outputs, partials. OpenMDAO.jl uses the VarData
entries in the inputs
and outputs
Vectors
to construct arguments to the Component.add_input
and Component.add_output
, respectively. And OpenMDAO.jl uses the PartialsData
entries in the partials
Vector
to construct arguments to Component.declare_partials
. The OpenMDAOCore.VarData
and OpenMDAOCore.PartialsData
docstrings have all the details.
Step 4: OpenMDAOCore.compute!
function OpenMDAOCore.compute!(self::Paraboloid, inputs, outputs)
x = inputs["x"][1]
y = inputs["y"][1]
outputs["f_xy"][1] = (x - 3.0)^2 + x * y + (y + 4.0)^2 - 3.0
return nothing
end
This OpenMDAOCore.compute!
method is the equivalent of the Paraboloid.compute
method from the Python version of the Paraboloid. Its job is to take a Paraboloid
struct
and a Dict
of inputs, calculate the outputs, and then store these outputs in the outputs
Dict
. The inputs
and outputs
Dict
entries are Julia arrays, similar to the NumPy arrays that OpenMDAO uses. (They are actually PyArray
s from the PythonCall
package, which are wrappers around the NumPy arrays that OpenMDAO creates for us.)
Now we need to figure out how to get that Julia code into OpenMDAO. How we do that depends on whether we're following the Python-Centric Approach or Julia-Centric Approach.
The Python-Centric Run Script
We'll use JuliaCall, provided by the PythonCall package, to import the Julia code from the previous section into Python. Then we can use the omjlcomps
Python package to create an OpenMDAO ExplicitComponent
from the Paraboloid
Julia struct
, and write a run script as usual.
import openmdao.api as om
# Create a new Julia module that will hold all the Julia code imported into this Python module.
import juliacall; jl = juliacall.newmodule("ParaboloidExample")
# This assumes the file with the Julia Paraboloid implementation is in the current directory and is named `paraboloid.jl`.
jl.include("paraboloid.jl")
# Now we have access to everything in `paraboloid.jl`.
# omjlcomps knows how to create an OpenMDAO ExplicitComponent from an OpenMDAOCore.AbstractExplicitComp
from omjlcomps import JuliaExplicitComp
comp = JuliaExplicitComp(jlcomp=jl.Paraboloid())
# Now everything else is the same as https://openmdao.org/newdocs/versions/latest/basic_user_guide/single_disciplinary_optimization/first_analysis.html
model = om.Group()
model.add_subsystem('parab_comp', comp)
prob = om.Problem(model)
prob.driver = om.ScipyOptimizeDriver()
prob.driver.options['optimizer'] = 'SLSQP'
prob.model.add_design_var('parab_comp.x')
prob.model.add_design_var('parab_comp.y')
prob.model.add_objective('parab_comp.f_xy')
prob.setup()
prob.set_val('parab_comp.x', 3.0)
prob.set_val('parab_comp.y', -4.0)
prob.run_model()
print(prob['parab_comp.f_xy']) # Should print `[-15.]`
prob.set_val('parab_comp.x', 5.0)
prob.set_val('parab_comp.y', -2.0)
prob.run_model()
print(prob.get_val('parab_comp.f_xy')) # Should print `[-5.]`
prob.run_driver()
print(f"f_xy = {prob.get_val('parab_comp.f_xy')}") # Should print `[-27.33333333]`
print(f"x = {prob.get_val('parab_comp.x')}") # Should print `[6.66666633]`
print(f"y = {prob.get_val('parab_comp.y')}") # Should print `[-7.33333367]`
The above Python run script should look pretty familiar if you have experience using OpenMDAO. The only difference from a pure-Python version is the little bit at the top that we use to create the JuliaExplicitComp
.
The Julia-Centric Run Script
Now let's see if we can write a Julia run script:
using OpenMDAO: om, make_component
prob = om.Problem()
# omjlcomps knows how to create an OpenMDAO ExplicitComponent from an OpenMDAOCore.AbstractExplicitComp
comp = make_component(Paraboloid())
model = om.Group()
model.add_subsystem("parab_comp", comp)
prob = om.Problem(model)
prob.driver = om.ScipyOptimizeDriver()
prob.driver.options["optimizer"] = "SLSQP"
prob.model.add_design_var("parab_comp.x")
prob.model.add_design_var("parab_comp.y")
prob.model.add_objective("parab_comp.f_xy")
prob.setup()
prob.set_val("parab_comp.x", 3.0)
prob.set_val("parab_comp.y", -4.0)
prob.run_model()
println(prob["parab_comp.f_xy"]) # Should print `[-15.]`
prob.set_val("parab_comp.x", 5.0)
prob.set_val("parab_comp.y", -2.0)
prob.run_model()
println(prob.get_val("parab_comp.f_xy")) # Should print `[-5.]`
prob.run_driver()
println("f_xy = $(prob.get_val("parab_comp.f_xy"))") # Should print `[-27.33333333]`
println("x = $(prob.get_val("parab_comp.x"))") # Should print `[6.66666633]`
println("y = $(prob.get_val("parab_comp.y"))") # Should print `[-7.33333367]`
[-15.]
[-5.]
Optimization terminated successfully (Exit mode 0)
Current function value: 18.016167304638337
Iterations: 24
Function evaluations: 24
Gradient evaluations: 24
Optimization Complete
-----------------------------------
=======
circuit
=======
NL: Newton 0 ; 0.00141407214 1
| LS: AG 1 ; 6.9707252e+152 1
| LS: AG 2 ; 8.51199079e+68 0.5
| LS: AG 3 ; 9.40605982e+26 0.25
| LS: AG 4 ; 988771.709 0.125
| LS: AG 5 ; 0.00130322117 0.0625
NL: Newton 1 ; 0.00130322117 0.921608691
| LS: AG 1 ; 5580826.07 1
| LS: AG 2 ; 13.3748871 0.5
| LS: AG 3 ; 0.0198015756 0.25
| LS: AG 4 ; 0.000828003297 0.125
NL: Newton 2 ; 0.000828003297 0.585545301
NL: Newton 3 ; 7.02986117e-06 0.00497135964
NL: Newton 4 ; 2.64550002e-08 1.87083809e-05
NL: Newton 5 ; 3.78431444e-13 2.67618202e-10
NL: Newton Converged
f_xy = [-27.33333333]
x = [6.66666633]
y = [-7.33333367]
(This example assumes that the definition of the Paraboloid
struct
is included in the same file. So concatenate those two code blocks if you'd like to run this yourself.) Good news—we got the expected answer!
Adding Derivatives
In the previous example we used OpenMDAO's finite difference method to approximate the paraboloid's partial derivatives. We can calculate them ourselves, though, just like in a Python OpenMDAO Component. Here's the implementation:
using OpenMDAOCore: OpenMDAOCore
struct ParaboloidUserPartials <: OpenMDAOCore.AbstractExplicitComp
end
function OpenMDAOCore.setup(self::ParaboloidUserPartials)
inputs = [OpenMDAOCore.VarData("x", val=0.0), OpenMDAOCore.VarData("y", val=0.0)]
outputs = [OpenMDAOCore.VarData("f_xy", val=0.0)]
partials = [OpenMDAOCore.PartialsData("*", "*")]
return inputs, outputs, partials
end
function OpenMDAOCore.compute!(self::ParaboloidUserPartials, inputs, outputs)
x = inputs["x"][1]
y = inputs["y"][1]
outputs["f_xy"][1] = (x - 3.0)^2 + x * y + (y + 4.0)^2 - 3.0
return nothing
end
function OpenMDAOCore.compute_partials!(self::ParaboloidUserPartials, inputs, partials)
x = inputs["x"][1]
y = inputs["y"][1]
partials["f_xy", "x"][1] = 2*(x - 3.0) + y
partials["f_xy", "y"][1] = x + 2*(y + 4.0)
return nothing
end
The implementation of ParaboloidUserPartials
is almost the same as Paraboloid
. The are only two differences:
- We've removed the
method="fd"
argument from the call to thePartialsData
constructor. This means themethod
argument will default to"exact"
(as shown in the docstring above), and OpenMDAO will expect we'll calculate the derivatives of this component ourselves. - We've implemented a
compute_partials!
method for our newParaboloidUserPartials
struct
. This is just like anExplicitComponent.compute_partials
method in a Python OpenMDAO component. Its job is to calculate the the derivatives of the outputs with respect to the inputs, of course.
So, we implemented a compute_partials!
method. But how do we know if they're right? The OpenMDAO Problem
class has a method called check_partials
that compares the user-defined partial derivatives to the finite difference method. Can we use that with an OpenMDAOCore.AbstractExplicitComp
? Let's try!
using OpenMDAO: om, make_component
prob = om.Problem()
# omjlcomps knows how to create an OpenMDAO ExplicitComponent from an OpenMDAOCore.AbstractExplicitComp
comp = make_component(ParaboloidUserPartials())
model = om.Group()
model.add_subsystem("parab_comp", comp)
prob = om.Problem(model)
prob.driver = om.ScipyOptimizeDriver()
prob.driver.options["optimizer"] = "SLSQP"
prob.model.add_design_var("parab_comp.x")
prob.model.add_design_var("parab_comp.y")
prob.model.add_objective("parab_comp.f_xy")
prob.setup(force_alloc_complex=true)
prob.set_val("parab_comp.x", 3.0)
prob.set_val("parab_comp.y", -4.0)
prob.run_model()
println(prob.check_partials(method="fd"))
{'parab_comp': {('f_xy', 'x'): {'J_fwd': array([[-4.]]), 'J_fd': array([[-3.999999]]), 'abs error': ErrorTuple(forward=9.99529788714426e-07, reverse=None, forward_reverse=None), 'magnitude': MagnitudeTuple(forward=4.0, reverse=None, fd=3.9999990004702113), 'rel error': ErrorTuple(forward=2.498825096198595e-07, reverse=None, forward_reverse=None)}, ('f_xy', 'y'): {'J_fwd': array([[3.]]), 'J_fd': array([[3.000001]]), 'abs error': ErrorTuple(forward=9.99620056063577e-07, reverse=None, forward_reverse=None), 'magnitude': MagnitudeTuple(forward=3.0, reverse=None, fd=3.000000999620056), 'rel error': ErrorTuple(forward=3.332065743278675e-07, reverse=None, forward_reverse=None)}}}
It worked! And the error is quite small.
What about the complex step method?
println(prob.check_partials(method="cs"))
{'parab_comp': {('f_xy', 'x'): {'J_fwd': array([[-4.]]), 'J_fd': array([[-4.]]), 'abs error': ErrorTuple(forward=0.0, reverse=None, forward_reverse=None), 'magnitude': MagnitudeTuple(forward=4.0, reverse=None, fd=4.0), 'rel error': ErrorTuple(forward=0.0, reverse=None, forward_reverse=None)}, ('f_xy', 'y'): {'J_fwd': array([[3.]]), 'J_fd': array([[3.]]), 'abs error': ErrorTuple(forward=0.0, reverse=None, forward_reverse=None), 'magnitude': MagnitudeTuple(forward=3.0, reverse=None, fd=3.0), 'rel error': ErrorTuple(forward=0.0, reverse=None, forward_reverse=None)}}}
It works! (The error is zero since the complex-step method is second-order accurate and we're differentiating a second-order polynomial.) Complex numbers are no problem for Julia, but just like Python, we need to be careful to write our compute_partials!
function in a complex-step-safe manner.
The Julia library FLOWMath has a collection of complex-step-safe functions.
Now, let's try an optimization:
prob.run_driver()
println("f_xy = $(prob.get_val("parab_comp.f_xy"))") # Should print `[-27.33333333]`
println("x = $(prob.get_val("parab_comp.x"))") # Should print `[6.66666633]`
println("y = $(prob.get_val("parab_comp.y"))") # Should print `[-7.33333367]`
Optimization terminated successfully (Exit mode 0)
Current function value: -27.333333333333
Iterations: 4
Function evaluations: 5
Gradient evaluations: 4
Optimization Complete
-----------------------------------
-----------------------------------------
Component: JuliaExplicitComp 'parab_comp'
-----------------------------------------
parab_comp: 'f_xy' wrt 'x'
Analytic Magnitude: 4.000000e+00
Fd Magnitude: 3.999999e+00 (fd:forward)
Absolute Error (Jan - Jfd) : 9.995298e-07
Relative Error (Jan - Jfd) / Jfd : 2.498825e-07
Raw Analytic Derivative (Jfor)
[[-4.]]
Raw FD Derivative (Jfd)
[[-3.999999]]
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
parab_comp: 'f_xy' wrt 'y'
Analytic Magnitude: 3.000000e+00
Fd Magnitude: 3.000001e+00 (fd:forward)
Absolute Error (Jan - Jfd) : 9.996201e-07
Relative Error (Jan - Jfd) / Jfd : 3.332066e-07
Raw Analytic Derivative (Jfor)
[[3.]]
Raw FD Derivative (Jfd)
[[3.000001]]
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-----------------------------------------
Component: JuliaExplicitComp 'parab_comp'
-----------------------------------------
parab_comp: 'f_xy' wrt 'x'
Analytic Magnitude: 4.000000e+00
Fd Magnitude: 4.000000e+00 (cs:None)
Absolute Error (Jan - Jfd) : 0.000000e+00
Relative Error (Jan - Jfd) / Jfd : 0.000000e+00
Raw Analytic Derivative (Jfor)
[[-4.]]
Raw FD Derivative (Jfd)
[[-4.]]
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
parab_comp: 'f_xy' wrt 'y'
Analytic Magnitude: 3.000000e+00
Fd Magnitude: 3.000000e+00 (cs:None)
Absolute Error (Jan - Jfd) : 0.000000e+00
Relative Error (Jan - Jfd) / Jfd : 0.000000e+00
Raw Analytic Derivative (Jfor)
[[3.]]
Raw FD Derivative (Jfd)
[[3.]]
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
f_xy = [-27.33333333]
x = [6.66666667]
y = [-7.33333333]
Still works, and we got the right answer.