Input Convex Neural Networks with Flux.jl

This tutorial shows how to embed an input convex neural network (ICNN) model from Flux.jl into JuMP.

See Input Convex Neural Networks with PyTorch for this tutorial using PyTorch, and see Input Supermodular Neural Networks with Flux.jl for a related form of network.

Required packages

This tutorial requires the following packages:

using JuMPimport ExaModelsimport Fluximport HiGHSimport Ipoptimport MathOptAIimport NLPModelsIpoptimport Plotsimport Randomimport SCS

Building the ICNN

Consider a neural network with the following structure:

\[\begin{aligned} z_1 & = \sigma_1(D_1 x + b_1) \\ z_k & = \sigma_k(W_{k-1} z_{k-1} + b_k + D_k x), \ \forall k = 2, \ldots, K \end{aligned}\]

If the weights $W$ are non-negative and $\sigma$ is a convex activation function then the output of the network $z_K$ is convex with respect to $x$, and we say that the network is an Input Convex Neural Network (ICNN).

struct InputConvexNN{T} <: MathOptAI.AbstractPredictor    D::Vector{Matrix{T}}    W::Vector{Matrix{T}}    b::Vector{Vector{T}}    σ::Vector{Function}endFlux.@layer(InputConvexNN, trainable = (D, W, b))function InputConvexNN(    dim_in::Int,    layers::Pair{Int,<:Function}...;    init = Flux.glorot_uniform,)    dims, K = first.(layers), length(layers)    D = [init(dims[k], dim_in) for k in 1:K]    W = [init(dims[k], dims[k-1]) for k in 2:K]    b = [init(dims[k]) for k in 1:K]    return InputConvexNN(D, W, b, Function[last(l) for l in layers])endfunction (nn::InputConvexNN)(x::AbstractVector)    z = nn.σ[1].(nn.D[1] * x .+ nn.b[1])    for k in 2:length(nn.D)        z = nn.σ[k].(Flux.softplus.(nn.W[k-1]) * z .+ nn.b[k] .+ nn.D[k] * x)    end    return zend

Here's an example:

chain = InputConvexNN(8, 2 => Flux.relu, 1 => Flux.relu)
InputConvexNN(
  2-element Vector{Matrix{Float32}},    # 24 parameters
  1-element Vector{Matrix{Float32}},    # 2 parameters
  2-element Vector{Vector{Float32}},    # 3 parameters
)                   # Total: 5 arrays, 29 parameters, 588 bytes.
chain(rand(8))
1-element Vector{Float64}:
 0.0

Building the Predictor

We need to implement add_predictor for InputConvexNN in order to be able to embed this network into JuMP.

function MathOptAI.add_predictor(    model::JuMP.AbstractModel,    nn::InputConvexNN,    x::Vector;    kwargs...,)    formulation = MathOptAI.PipelineFormulation(nn, Any[])    p = MathOptAI.Affine(nn.D[1], nn.b[1])    z, inner = MathOptAI.add_predictor(model, p, x)    push!(formulation.layers, inner)    z, inner = MathOptAI.add_predictor(model, nn.σ[1], z; kwargs...)    push!(formulation.layers, inner)    for k in 2:length(nn.D)        p = MathOptAI.Affine([Flux.softplus.(nn.W[k-1]) nn.D[k]], nn.b[k])        z, inner = MathOptAI.add_predictor(model, p, [z; x])        push!(formulation.layers, inner)        z, inner = MathOptAI.add_predictor(model, nn.σ[k], z; kwargs...)        push!(formulation.layers, inner)    end    return z, formulationend

With that, we are now ready to embed these networks into JuMP.

Embed ICNN into JuMP

Let us build a small ICNN first.

predictor = InputConvexNN(2, 3 => Flux.relu, 1 => Flux.relu)
InputConvexNN(
  2-element Vector{Matrix{Float32}},    # 8 parameters
  1-element Vector{Matrix{Float32}},    # 3 parameters
  2-element Vector{Vector{Float32}},    # 4 parameters
)                   # Total: 5 arrays, 15 parameters, 532 bytes.

We can now embed predictor into a JuMP model. We choose to embed the Flux.relu using ReLUSOS1:

model = Model()@variable(model, x[1:2])config = Dict(Flux.relu => MathOptAI.ReLUSOS1)z, formulation = MathOptAI.add_predictor(model, predictor, x; config);
z
1-element Vector{JuMP.VariableRef}:
 moai_ReLU[1]
formulation
Affine(A, b) [input: 2, output: 3]
├ variables [3]
│ ├ moai_Affine[1]
│ ├ moai_Affine[2]
│ └ moai_Affine[3]
└ constraints [3]
  ├ -0.3637503683567047 x[1] + 0.326200008392334 x[2] - moai_Affine[1] = 1.1433569192886353
  ├ 0.6929140686988831 x[1] - 0.7219537496566772 x[2] - moai_Affine[2] = 0.8120035529136658
  └ -0.37119945883750916 x[1] - 0.56111079454422 x[2] - moai_Affine[3] = -0.051543015986680984
MathOptAI.ReLUSOS1()
├ variables [6]
│ ├ moai_ReLU[1]
│ ├ moai_ReLU[2]
│ ├ moai_ReLU[3]
│ ├ moai_z[1]
│ ├ moai_z[2]
│ └ moai_z[3]
└ constraints [12]
  ├ moai_ReLU[1] ≥ 0
  ├ moai_z[1] ≥ 0
  ├ moai_Affine[1] - moai_ReLU[1] + moai_z[1] = 0
  ├ [moai_ReLU[1], moai_z[1]] ∈ MathOptInterface.SOS1{Float64}([1.0, 2.0])
  ├ moai_ReLU[2] ≥ 0
  ├ moai_z[2] ≥ 0
  ├ moai_Affine[2] - moai_ReLU[2] + moai_z[2] = 0
  ├ [moai_ReLU[2], moai_z[2]] ∈ MathOptInterface.SOS1{Float64}([1.0, 2.0])
  ├ moai_ReLU[3] ≥ 0
  ├ moai_z[3] ≥ 0
  ├ moai_Affine[3] - moai_ReLU[3] + moai_z[3] = 0
  └ [moai_ReLU[3], moai_z[3]] ∈ MathOptInterface.SOS1{Float64}([1.0, 2.0])
Affine(A, b) [input: 5, output: 1]
├ variables [1]
│ └ moai_Affine[1]
└ constraints [1]
  └ 0.5892834067344666 x[1] - 1.3519961833953857 x[2] + 1.2439357042312622 moai_ReLU[1] + 0.8406112790107727 moai_ReLU[2] + 1.2106335163116455 moai_ReLU[3] - moai_Affine[1] = -1.4760726690292358
MathOptAI.ReLUSOS1()
├ variables [2]
│ ├ moai_ReLU[1]
│ └ moai_z[1]
└ constraints [4]
  ├ moai_ReLU[1] ≥ 0
  ├ moai_z[1] ≥ 0
  ├ moai_Affine[1] - moai_ReLU[1] + moai_z[1] = 0
  └ [moai_ReLU[1], moai_z[1]] ∈ MathOptInterface.SOS1{Float64}([1.0, 2.0])

Epigraph formulations

The nice thing about ICNNs is that we can formulate their epigraph and avoid adding binary variables to the model. For that, we can use ReLUEpigraph.

Let's first train a model to predict the relationship $y = x^2$. (Note that this is a very basic training loop.)

Random.seed!(1234)chain = InputConvexNN(1, 10 => Flux.relu, 1 => Flux.relu)begin    X = -2.0f0:0.1f0:2.0f0    optimizer_state = Flux.setup(Flux.Adam(5e-2), chain)    for epoch in 1:1_000        _, gradient = Flux.withgradient(chain) do model            return sum((only(model([x])) - x^2)^2 for x in X)        end        Flux.update!(optimizer_state, chain, only(gradient))    endend

Now we can embed the trained network into a JuMP model:

model = Model(HiGHS.Optimizer)set_silent(model)@variable(model, x[1:1])config = Dict(Flux.relu => MathOptAI.ReLUEpigraph)y, _ = MathOptAI.add_predictor(model, chain, x; config)@objective(model, Min, only(y))model
A JuMP Model
├ solver: HiGHS
├ objective_sense: MIN_SENSE
│ └ objective_function_type: JuMP.VariableRef
├ num_variables: 23
├ num_constraints: 33
│ ├ JuMP.AffExpr in MOI.EqualTo{Float64}: 11
│ ├ JuMP.AffExpr in MOI.GreaterThan{Float64}: 11
│ └ JuMP.VariableRef in MOI.GreaterThan{Float64}: 11
└ Names registered in the model
  └ :x

Because we used the ReLUEpigraph predictor, there are no binary or integer variables in our model.

Moreover, we can show that the objective value y is convex with respect to x:

x_value, y_value = -2:0.1:2, Float64[]for xi in x_value    fix(x[1], xi)    optimize!(model)    # To prove we are solving an LP and not a MIP, require dual solutions.    assert_is_solved_and_feasible(model; dual = true)    push!(y_value, objective_value(model))endPlots.plot(x_value, y_value; xlabel = "x", ylabel = "y", label = "Trained")Plots.plot!(x_value, x_value .^ 2; label = "Target", linestyle = :dash)
Example block output

Conic Formulation

We can also use SoftPlusConicEpigraph in the activation functions. The resulting conic formulation can be solved using SCS or any other conic solver.

Random.seed!(1234)chain = InputConvexNN(1, 10 => Flux.softplus, 1 => Flux.softplus)begin    X = -2.0f0:0.1f0:2.0f0    optimizer_state = Flux.setup(Flux.Adam(5e-2), chain)    for epoch in 1:1000        _, gradient = Flux.withgradient(chain) do model            return sum((only(model([x])) - x^2)^2 for x in X)        end        Flux.update!(optimizer_state, chain, only(gradient))    endend

Next, we embed the neural network using SoftPlusConicEpigraph.

model = Model(SCS.Optimizer)set_silent(model)@variable(model, x[1:1])config = Dict(Flux.softplus => MathOptAI.SoftPlusConicEpigraph)y, _ = MathOptAI.add_predictor(model, chain, x; config)@objective(model, Min, only(y))model
A JuMP Model
├ solver: SCS
├ objective_sense: MIN_SENSE
│ └ objective_function_type: JuMP.VariableRef
├ num_variables: 45
├ num_constraints: 44
│ ├ JuMP.AffExpr in MOI.EqualTo{Float64}: 11
│ ├ JuMP.AffExpr in MOI.LessThan{Float64}: 11
│ └ Vector{JuMP.AffExpr} in MOI.ExponentialCone: 22
└ Names registered in the model
  └ :x

Let's draw the same plot to see the differences in fit with softplus.

x_value, y_value = -2:0.1:2, Float64[]for xi in x_value    fix(x[1], xi)    optimize!(model)    # To prove we are solving an LP and not a MIP, require dual solutions.    assert_is_solved_and_feasible(model; dual = true)    push!(y_value, objective_value(model))endPlots.plot(x_value, y_value; xlabel = "x", ylabel = "y", label = "Trained")Plots.plot!(x_value, x_value .^ 2; label = "Target", linestyle = :dash)
Example block output

Nonlinear Formulation

We can also use SoftPlusEpigraph in the activation functions. The resulting global nonlinear formulation can be solved using Ipopt or any other nonlinear solver.

model = Model(Ipopt.Optimizer)set_silent(model)@variable(model, x[1:1])config = Dict(Flux.softplus => MathOptAI.SoftPlusEpigraph)y, _ = MathOptAI.add_predictor(model, chain, x; config)@objective(model, Min, only(y))model
A JuMP Model
├ solver: Ipopt
├ objective_sense: MIN_SENSE
│ └ objective_function_type: JuMP.VariableRef
├ num_variables: 23
├ num_constraints: 33
│ ├ JuMP.NonlinearExpr in MOI.GreaterThan{Float64}: 11
│ ├ JuMP.AffExpr in MOI.EqualTo{Float64}: 11
│ └ JuMP.VariableRef in MOI.GreaterThan{Float64}: 11
└ Names registered in the model
  └ :x

Let's draw the same plot to see the differences in fit with softplus.

x_value, y_value = -2:0.1:2, Float64[]for xi in x_value    fix(x[1], xi)    optimize!(model)    # To prove we are solving an LP and not a MIP, require dual solutions.    assert_is_solved_and_feasible(model; dual = true)    push!(y_value, objective_value(model))endPlots.plot(x_value, y_value; xlabel = "x", ylabel = "y", label = "Trained")Plots.plot!(x_value, x_value .^ 2; label = "Target", linestyle = :dash)
Example block output

ExaModels

We can do a similar thing with ExaModels:

function MathOptAI.add_predictor(    core::ExaModels.ExaCore,    nn::InputConvexNN,    x::ExaModels.Variable;    kwargs...,)    formulation = MathOptAI.PipelineFormulation(nn, Any[])    p = MathOptAI.Affine(nn.D[1], nn.b[1])    (core, z), inner = MathOptAI.add_predictor(core, p, x)    push!(formulation.layers, inner)    (core, z), inner = MathOptAI.add_predictor(core, nn.σ[1], z; kwargs...)    push!(formulation.layers, inner)    for k in 2:length(nn.D)        p = MathOptAI.Affine(Flux.softplus(nn.W[k-1]), nn.b[k])        (core, y), inner = MathOptAI.add_predictor(core, p, z)        push!(formulation.layers, inner)        # This part is slightly complicated because it's hard to represent        # [z; x] in ExaModels. Instead, we add the `x` terms separately. Note        # the `-D` because ExaModels does `y - (Ax + b) == 0`.        c, (m, n) = only(inner.constraints), size(nn.D[k])        D = [(i, j, -nn.D[k][i, j]) for i in 1:m for j in 1:n]        core, _ = ExaModels.add_con!(core, c, i => v * x[j] for (i, j, v) in D)        (core, z), inner = MathOptAI.add_predictor(core, nn.σ[k], y; kwargs...)        push!(formulation.layers, inner)    end    return (core, z), formulationendfunction solve_fixed(chain, x_value)    core = ExaModels.ExaCore(; concrete = Val(true))    core, x = ExaModels.add_var(core, 1; lvar = x_value, uvar = x_value)    config = Dict(Flux.softplus => MathOptAI.SoftPlusEpigraph)    (core, y), _ = MathOptAI.add_predictor(core, chain, x; config)    core, _ = ExaModels.add_obj(core, y[i] for i in 1:1)    model = ExaModels.ExaModel(core)    result = NLPModelsIpopt.ipopt(model; print_level = 0)    @assert result.status  (:first_order, :acceptable)    return result.objectiveend
solve_fixed (generic function with 1 method)

Let's draw the same plot to see the differences in fit with softplus.

x_value = -2:0.1:2y_value = Float64[solve_fixed(chain, xi) for xi in x_value]Plots.plot(x_value, y_value; xlabel = "x", ylabel = "y", label = "Trained")Plots.plot!(x_value, x_value .^ 2; label = "Target", linestyle = :dash)
Example block output

This page was generated using Literate.jl.