Estimating the Region of Attraction

In this demonstration, we add awareness of the region of attraction (RoA) estimation task to our training.

We'll be examining the simple one-dimensional differential equation:

\[\frac{dx}{dt} = - x + x^3.\]

This system has a fixed point at $x = 0$ which has a RoA of $x \in (-1, 1)$, which we will attempt to identify.

We'll train in the larger domain $x \in [-2, 2]$.

Copy-Pastable Code

using NeuralPDE, Lux, NeuralLyapunov
using Optimization, OptimizationOptimisers, OptimizationOptimJL
using Random, StableRNGs

rng = StableRNG(0)
Random.seed!(200)

######################### Define dynamics and domain ##########################

f(x, p, t) = -x .+ x .^ 3
lb = [-2.0];
ub = [ 2.0];
fixed_point = [0.0];

####################### Specify neural Lyapunov problem #######################

# Define neural network discretization
dim_state = length(lb)
dim_hidden = 5
dim_output = 2
chain = [Lux.Chain(
             Dense(dim_state, dim_hidden, tanh),
             Dense(dim_hidden, dim_hidden, tanh),
             Dense(dim_hidden, 1, use_bias = false)
         ) for _ in 1:dim_output]
ps = Lux.initialparameters(rng, chain)

# Define training strategy
strategy = GridTraining(0.1)
discretization = PhysicsInformedNN(chain, strategy; init_params = ps)

# Define neural Lyapunov structure
structure = PositiveSemiDefiniteStructure(dim_output)
minimization_condition = DontCheckNonnegativity()

# Define Lyapunov decrease condition
decrease_condition = make_RoA_aware(AsymptoticStability())

# Construct neural Lyapunov specification
spec = NeuralLyapunovSpecification(
    structure,
    minimization_condition,
    decrease_condition
)

############################# Construct PDESystem #############################

@named pde_system = NeuralLyapunovPDESystem(
    f,
    lb,
    ub,
    spec
)

######################## Construct OptimizationProblem ########################

prob = discretize(pde_system, discretization)

########################## Solve OptimizationProblem ##########################

res = Optimization.solve(prob, OptimizationOptimisers.Adam(); maxiters = 300)
prob = Optimization.remake(prob, u0 = res.u)
res = Optimization.solve(prob, OptimizationOptimJL.BFGS(); maxiters = 300)

###################### Get numerical numerical functions ######################
net = discretization.phi
θ = res.u.depvar

V, V̇ = get_numerical_lyapunov_function(
    net,
    θ,
    structure,
    f,
    fixed_point
)

################################## Simulate ###################################
states = lb[]:0.001:ub[]
V_samples = vec(V(states'))
V̇_samples = vec(V̇(states'))

# Calculated RoA estimate
ρ = decrease_condition.ρ
RoA_states = states[vec(V(transpose(states))) .≤ ρ]
RoA = (first(RoA_states), last(RoA_states))

Detailed description

In this example, we set up the dynamics as a Julia function and don't bother specifying the symbols for the variables (so $x$ will be called the default state1 in the PDESystem).

f(x, p, t) = -x .+ x .^ 3
lb = [-2.0];
ub = [ 2.0];
fixed_point = [0.0];

Setting up the neural network using Lux and NeuralPDE training strategy is no different from any other physics-informed neural network problem. For more on that aspect, see the NeuralPDE documentation. Since we're only considering one dimension, training on a grid isn't so bad in this case.

using Lux, StableRNGs

# Stable random number generator for doc stability
rng = StableRNG(0)

# Define neural network discretization
dim_state = length(lb)
dim_hidden = 5
dim_output = 2
chain = [Lux.Chain(
             Dense(dim_state, dim_hidden, tanh),
             Dense(dim_hidden, dim_hidden, tanh),
             Dense(dim_hidden, 1, use_bias = false)
         ) for _ in 1:dim_output]
ps = Lux.initialparameters(rng, chain)
2-element Vector{@NamedTuple{layer_1::@NamedTuple{weight::Matrix{Float32}, bias::Vector{Float32}}, layer_2::@NamedTuple{weight::Matrix{Float32}, bias::Vector{Float32}}, layer_3::@NamedTuple{weight::Matrix{Float32}}}}:
 (layer_1 = (weight = [-0.45784864; -0.06867478; … ; 1.64411; 0.23668337;;], bias = [0.3418486, -0.978009, -0.04572296, -0.8118267, 0.055347443]), layer_2 = (weight = [-0.34389058 0.9279804 … 0.07996013 -1.0231869; -1.2034209 0.28792876 … 0.23100138 0.064495794; … ; -0.50130814 -0.688124 … -0.52472657 0.8011582; -0.9508799 -0.13354729 … 0.55746263 0.5449771], bias = [0.27231756, 0.33674875, -0.11881286, 0.13140164, -0.3838435]), layer_3 = (weight = [-0.2886456 0.13539186 … -0.14496835 -0.17473423],))
 (layer_1 = (weight = [2.2938614; -2.1249437; … ; 1.815136; 2.6265342;;], bias = [-0.9846766, -0.63826203, 0.51425433, -0.46349335, -0.036490917]), layer_2 = (weight = [-0.02004436 -1.2301451 … -1.0701928 0.8329293; 0.69851124 1.0166358 … -1.0654731 -0.87174803; … ; -1.1370962 1.029001 … -1.1567752 -0.55754757; -0.8275013 0.5202121 … -0.15313427 -0.98145705], bias = [-0.15087044, -0.12097594, -0.40972832, -0.246295, -0.043452278]), layer_3 = (weight = [-0.13673632 0.20193696 … -0.49816325 -0.41002852],))
using NeuralPDE

# Define training strategy
strategy = GridTraining(0.1)
discretization = PhysicsInformedNN(chain, strategy; init_params = ps)

We now define our Lyapunov candidate structure along with the form of the Lyapunov conditions we'll be using.

For this example, let's use the default Lyapunov candidate from PositiveSemiDefiniteStructure:

\[V(x) = \left( 1 + \lVert \phi(x) \rVert^2 \right) \log \left( 1 + \lVert x \rVert^2 \right),\]

which structurally enforces positive definiteness. We therefore use DontCheckNonnegativity().

We only require asymptotic stability in this example, but we use make_RoA_aware to only penalize positive values of $\dot{V}(x)$ when $V(x) \le 1$.

using NeuralLyapunov

# Define neural Lyapunov structure
structure = PositiveSemiDefiniteStructure(dim_output)
minimization_condition = DontCheckNonnegativity()

# Define Lyapunov decrease condition
decrease_condition = make_RoA_aware(AsymptoticStability())

# Construct neural Lyapunov specification
spec = NeuralLyapunovSpecification(
    structure,
    minimization_condition,
    decrease_condition
)

# Construct PDESystem
@named pde_system = NeuralLyapunovPDESystem(
    f,
    lb,
    ub,
    spec
)

\[ \begin{align} max\left( 0, \frac{2 \mathtt{state1} \left( - \mathtt{state1} + \mathtt{state1}^{3} \right) \left( 1 + \left( \mathtt{{\varphi}1}\left( \mathtt{state1} \right) \right)^{2} + \left( \mathtt{{\varphi}2}\left( \mathtt{state1} \right) \right)^{2} \right)}{1 + \mathtt{state1}^{2}} + 1 \cdot 10^{-6} \mathtt{state1}^{2} + \left( - \mathtt{state1} + \mathtt{state1}^{3} \right) \left( 2 \frac{\mathrm{d} \mathtt{{\varphi}1}\left( \mathtt{state1} \right)}{\mathrm{d}state1} \mathtt{{\varphi}1}\left( \mathtt{state1} \right) + 2 \frac{\mathrm{d} \mathtt{{\varphi}2}\left( \mathtt{state1} \right)}{\mathrm{d}state1} \mathtt{{\varphi}2}\left( \mathtt{state1} \right) \right) \log\left( 1 + \mathtt{state1}^{2} \right) \right) \left( 1 - \left( 1 + \left( \mathtt{{\varphi}1}\left( \mathtt{state1} \right) \right)^{2} + \left( \mathtt{{\varphi}2}\left( \mathtt{state1} \right) \right)^{2} \right) \log\left( 1 + \mathtt{state1}^{2} \right) \geq 0 \right) &= 0 \end{align} \]

Now, we solve the PDESystem using NeuralPDE the same way we would any PINN problem.

prob = discretize(pde_system, discretization)

import Optimization, OptimizationOptimisers, OptimizationOptimJL

res = Optimization.solve(prob, OptimizationOptimisers.Adam(); maxiters = 300)
prob = Optimization.remake(prob, u0 = res.u)
res = Optimization.solve(prob, OptimizationOptimJL.BFGS(); maxiters = 300)

net = discretization.phi
θ = res.u.depvar
ComponentVector{Float32,SubArray...}(φ1 = (layer_1 = (weight = Float32[-0.45784864; -0.06867478; … ; 1.64411; 0.23668337;;], bias = Float32[0.3418486, -0.978009, -0.04572296, -0.8118267, 0.055347443]), layer_2 = (weight = Float32[-0.34389058 0.9279804 … 0.07996013 -1.0231869; -1.2034209 0.28792876 … 0.23100138 0.064495794; … ; -0.50130814 -0.688124 … -0.52472657 0.8011582; -0.9508799 -0.13354729 … 0.55746263 0.5449771], bias = Float32[0.27231756, 0.33674875, -0.11881286, 0.13140164, -0.3838435]), layer_3 = (weight = Float32[-0.2886456 0.13539186 … -0.14496835 -0.17473423])), φ2 = (layer_1 = (weight = Float32[2.2938614; -2.1249437; … ; 1.815136; 2.6265342;;], bias = Float32[-0.9846766, -0.63826203, 0.51425433, -0.46349335, -0.036490917]), layer_2 = (weight = Float32[-0.02004436 -1.2301451 … -1.0701928 0.8329293; 0.69851124 1.0166358 … -1.0654731 -0.87174803; … ; -1.1370962 1.029001 … -1.1567752 -0.55754757; -0.8275013 0.5202121 … -0.15313427 -0.98145705], bias = Float32[-0.15087044, -0.12097594, -0.40972832, -0.246295, -0.043452278]), layer_3 = (weight = Float32[-0.13673632 0.20193696 … -0.49816325 -0.41002852])))

We can use the result of the optimization problem to build the Lyapunov candidate as a Julia function, then sample on a finer grid than we trained on to find the estimated region of attraction.

V, V̇ = get_numerical_lyapunov_function(
    net,
    θ,
    structure,
    f,
    fixed_point
)

# Sample
states = lb[]:0.001:ub[]
V_samples = vec(V(states'))
V̇_samples = vec(V̇(states'))

# Calculate RoA estimate
ρ = decrease_condition.ρ
RoA_states = states[vec(V(transpose(states))) .≤ ρ]
RoA = (first(RoA_states), last(RoA_states))

# Print statistics
println("V(0.,0.) = ", V(fixed_point))
println("V ∋ [", min(V(fixed_point), minimum(V_samples)), ", ", maximum(V_samples), "]")
println(
    "V̇ ∋ [",
    minimum(V̇_samples),
    ", ",
    max(V̇(fixed_point), maximum(V̇_samples)),
    "]",
)
println("True region of attraction: (-1, 1)")
println("Estimated region of attraction: ", RoA)
┌ Warning: Mixed-Precision `matmul_cpu_fallback!` detected and Octavian.jl cannot be used for this set of inputs (C [Matrix{Float64}]: A [Base.ReshapedArray{Float32, 2, SubArray{Float32, 1, Vector{Float32}, Tuple{UnitRange{Int64}}, true}, Tuple{}}] x B [Matrix{Float64}]). Falling back to generic implementation. This may be slow.
@ LuxLib.Impl ~/.julia/packages/LuxLib/Kj0os/src/impl/matmul.jl:190
┌ Warning: Mixed-Precision `matmul_cpu_fallback!` detected and Octavian.jl cannot be used for this set of inputs (C [Matrix{Float64}]: A [Base.ReshapedArray{Float32, 2, SubArray{Float32, 1, Vector{Float32}, Tuple{UnitRange{Int64}}, true}, Tuple{}}] x B [Matrix{Float64}]). Falling back to generic implementation. This may be slow.
@ LuxLib.Impl ~/.julia/packages/LuxLib/Kj0os/src/impl/matmul.jl:190
┌ Warning: Mixed-Precision `matmul_cpu_fallback!` detected and Octavian.jl cannot be used for this set of inputs (C [Matrix{Float64}]: A [Base.ReshapedArray{Float32, 2, SubArray{Float32, 1, Vector{Float32}, Tuple{UnitRange{Int64}}, true}, Tuple{}}] x B [Matrix{Float64}]). Falling back to generic implementation. This may be slow.
@ LuxLib.Impl ~/.julia/packages/LuxLib/Kj0os/src/impl/matmul.jl:190
V(0.,0.) = 0.0
┌ Warning: Mixed-Precision `matmul_cpu_fallback!` detected and Octavian.jl cannot be used for this set of inputs (C [Matrix{Float64}]: A [Base.ReshapedArray{Float32, 2, SubArray{Float32, 1, Vector{Float32}, Tuple{UnitRange{Int64}}, true}, Tuple{}}] x B [Matrix{Float64}]). Falling back to generic implementation. This may be slow.
@ LuxLib.Impl ~/.julia/packages/LuxLib/Kj0os/src/impl/matmul.jl:190
V ∋ [0.0, 3.5251504663249973]
V̇ ∋ [-0.721374428820775, 11.087438260235606]
True region of attraction: (-1, 1)
Estimated region of attraction: (-0.788, 0.821)

The estimated region of attraction is within the true region of attraction.

using Plots

p_V = plot(states, V_samples, label = "V", xlabel = "x", linewidth=2);
p_V = hline!([ρ], label = "V = ρ", legend = :top);
p_V = vspan!(collect(RoA); label = "Estimated Region of Attraction", color = :gray, fillstyle = :/);
p_V = vspan!([-1, 1]; label = "True Region of Attraction", opacity = 0.2, color = :green);

p_V̇ = plot(states, V̇_samples, label = "dV/dt", xlabel = "x", linewidth=2);
p_V̇ = hline!([0.0], label = "dV/dt = 0", legend = :top);
p_V̇ = vspan!(collect(RoA); label = "Estimated Region of Attraction", color = :gray, fillstyle = :/);
p_V̇ = vspan!([-1, 1]; label = "True Region of Attraction", opacity = 0.2, color = :green);

plt = plot(p_V, p_V̇)
Example block output