Examples for Attractors.jl

Newton's fractal (basins of 2D map)

using Attractors
function newton_map(z, p, n)
    z1 = z[1] + im*z[2]
    dz1 = newton_f(z1, p[1])/newton_df(z1, p[1])
    z1 = z1 - dz1
    return SVector(real(z1), imag(z1))
end
newton_f(x, p) = x^p - 1
newton_df(x, p)= p*x^(p-1)

ds = DiscreteDynamicalSystem(newton_map, [0.1, 0.2], [3.0])
xg = yg = range(-1.5, 1.5; length = 400)
# Use non-sparse for using `basins_of_attraction`
mapper = AttractorsViaRecurrences(ds, (xg, yg);
    sparse = false, mx_chk_lost = 1000
)
basins, attractors = basins_of_attraction(mapper; show_progress = false)
basins
400×400 Matrix{Int32}:
 1  1  1  1  1  1  1  1  1  1  1  1  1  …  2  2  2  2  2  2  2  2  2  2  2  2
 1  1  1  1  1  1  1  1  1  1  1  1  1     2  2  2  2  2  2  2  2  2  2  2  2
 1  1  1  1  1  1  1  1  1  1  1  1  1     2  2  2  2  2  2  2  2  2  2  2  2
 1  1  1  1  1  1  1  1  1  1  1  1  1     2  2  2  2  2  2  2  2  2  2  2  2
 1  1  1  1  1  1  1  1  1  1  1  1  1     2  2  2  2  2  2  2  2  2  2  2  2
 1  1  1  1  1  1  1  1  1  1  1  1  1  …  2  2  2  2  2  2  2  2  2  2  2  2
 1  1  1  1  1  1  1  1  1  1  1  1  1     2  2  2  2  2  2  2  2  2  2  2  2
 1  1  1  1  1  1  1  1  1  1  1  1  1     2  2  2  2  2  2  2  2  2  2  2  2
 1  1  1  1  1  1  1  1  1  1  1  1  1     2  2  2  2  2  2  2  2  2  2  2  2
 1  1  1  1  1  1  1  1  1  1  1  1  1     2  2  2  2  2  2  2  2  2  2  2  2
 ⋮              ⋮              ⋮        ⋱        ⋮              ⋮           
 3  3  3  3  3  3  3  3  3  3  3  3  3     3  3  3  3  3  3  3  3  3  3  3  3
 3  3  3  3  3  3  3  3  3  3  3  3  3     3  3  3  3  3  3  3  3  3  3  3  3
 3  3  3  3  3  3  3  3  3  3  3  3  3     3  3  3  3  3  3  3  3  3  3  3  3
 3  3  3  3  3  3  3  3  3  3  3  3  3     3  3  3  3  3  3  3  3  3  3  3  3
 3  3  3  3  3  3  3  3  3  3  3  3  3  …  3  3  3  3  3  3  3  3  3  3  3  3
 3  3  3  3  3  3  3  3  3  3  3  3  3     3  3  3  3  3  3  3  3  3  3  3  3
 3  3  3  3  3  3  3  3  3  3  3  3  3     3  3  3  3  3  3  3  3  3  3  3  3
 3  3  3  3  3  3  3  3  3  3  3  3  3     3  3  3  3  3  3  3  3  3  3  3  3
 3  3  3  3  3  3  3  3  3  3  3  3  3     3  3  3  3  3  3  3  3  3  3  3  3
attractors
Dict{Int32, StateSpaceSet{2, Float64}} with 3 entries:
  2 => 2-dimensional StateSpaceSet{Float64} with 1 points
  3 => 2-dimensional StateSpaceSet{Float64} with 1 points
  1 => 2-dimensional StateSpaceSet{Float64} with 1 points

Now let's plot this as a heatmap

using CairoMakie
# Set up some code for plotting attractors
function scatter_attractors!(ax, attractors)
    for k ∈ keys(attractors)
        x, y = columns(attractors[k])
        scatter!(ax, vec(attractors[k]);
            color = Cycled(k), markersize = 20,
            strokewidth = 3, strokecolor = :white
        )
    end
end

generate_cmap(n) = cgrad(Main.COLORS[1:n], n; categorical = true)
ids = sort!(unique(basins))
cmap = generate_cmap(length(ids))

fig, ax = heatmap(xg, yg, basins;
    colormap = cmap, colorrange = (ids[1] - 0.5, ids[end]+0.5),
)
scatter_attractors!(ax, attractors)
fig

We could get only the fractions of the basins of attractions using basins_fractions, which is typically the more useful thing to do in a high dimensional system. In such cases it is also typically more useful to define a sampler that generates initial conditions on the fly instead of pre-defining some initial conditions (as is done in basins_of_attraction. This is simple to do:

grid = (xg, yg)
mapper = AttractorsViaRecurrences(ds, grid;
    sparse = false, mx_chk_lost = 1000
)

sampler, = statespace_sampler(;
    min_bounds = minimum.(grid), max_bounds = maximum.(grid)
)

basins = basins_fractions(mapper, sampler)
Dict{Int64, Float64} with 3 entries:
  2 => 0.33
  3 => 0.346
  1 => 0.324

in this case, to also get the attractors we simply extract them from the underlying storage of the mapper:

attractors = extract_attractors(mapper)
Dict{Int32, StateSpaceSet{2, Float64}} with 3 entries:
  2 => 2-dimensional StateSpaceSet{Float64} with 1 points
  3 => 2-dimensional StateSpaceSet{Float64} with 1 points
  1 => 2-dimensional StateSpaceSet{Float64} with 1 points

Fractality of 2D basins of the (4D) magnetic pendulum

In this section we will calculate the basins of attraction of the four-dimensional magnetic pendulum. We know that the attractors of this system are all individual fixed points on the (x, y) plane so we will only compute the basins there. We can also use this opportunity to highlight a different method, the AttractorsViaProximity which works when we already know where the attractors are. Furthermore we will also use a projected_integrator to project the 4D system onto a 2D plane, saving a lot of computational time!

Computing the basins

First we need to load in the magnetic pendulum from the predefined dynamical systems

using PredefinedDynamicalSystems
ds = Systems.magnetic_pendulum(d=0.2, α=0.2, ω=0.8, N=3)
4-dimensional CoupledODEs
 deterministic: true
 discrete time: false
 in-place:      false
 dynamic rule:  MagneticPendulum
 ODE solver:    Tsit5
 ODE kwargs:    (abstol = 1.0e-6, reltol = 1.0e-6)
 parameters:    PredefinedDynamicalSystems.MagneticPendulumParams([1.0, 1.0, 1.0], 0.2, 0.2, 0.8)
 time:          0.0
 state:         [0.7094575840693688, 0.704748136859158, 0.0, 0.0]

Then, we create a projected system on the x-y plane

psys = ProjectedDynamicalSystem(ds, [1, 2], [0.0, 0.0])
2-dimensional ProjectedDynamicalSystem
 deterministic:  true
 discrete time:  false
 in-place:       false
 dynamic rule:   MagneticPendulum
 projection:     [1, 2]
 complete state: [0.0, 0.0]
 parameters:     PredefinedDynamicalSystems.MagneticPendulumParams([1.0, 1.0, 1.0], 0.2, 0.2, 0.8)
 time:           0.0
 state:          [0.7094575840693688, 0.704748136859158]

For this systems we know the attractors are close to the magnet positions, so we can just do

attractors = Dict(i => StateSpaceSet([dynamic_rule(ds).magnets[i]]) for i in 1:3)
Dict{Int64, StateSpaceSet{2, Float64}} with 3 entries:
  2 => 2-dimensional StateSpaceSet{Float64} with 1 points
  3 => 2-dimensional StateSpaceSet{Float64} with 1 points
  1 => 2-dimensional StateSpaceSet{Float64} with 1 points

and then create a

mapper = AttractorsViaProximity(psys, attractors)
AttractorsViaProximity
 rule f:      AttractorsViaProximity
 type:        ProjectedDynamicalSystem
 ε:           0.8660254037844386
 Δt:          1
 Ttr:         100
 attractors:  Dict{Int64, StateSpaceSet{2, Float64}} with 3 entries:
                2 => 2-dimensional StateSpaceSet{Float64} with 1 points
                3 => 2-dimensional StateSpaceSet{Float64} with 1 points
                1 => 2-dimensional StateSpaceSet{Float64} with 1 points

and as before, get the basins of attraction

xg = yg = range(-4, 4; length = 101)
grid = (xg, yg)
basins, = basins_of_attraction(mapper, grid; show_progress = false)
ids = sort!(unique(basins))
cmap = generate_cmap(length(ids))
fig, ax = heatmap(xg, yg, basins;
    colormap = cmap, colorrange = (ids[1] - 0.5, ids[end]+0.5),
)
scatter_attractors!(ax, attractors)
fig

Computing the uncertainty exponent

Let's now calculate the uncertainty_exponent for this system as well. The calculation is straightforward:

ε, f_ε, α = uncertainty_exponent(basins)
fig, ax = lines(log.(ε), log.(f_ε))
ax.title = "α = $(round(α; digits=3))"
fig

The actual uncertainty exponent is the slope of the curve (α) and indeed we get an exponent near 0 as we know a-priory the basins have fractal boundaries for the magnetic pendulum.

Computing the tipping probabilities

We will compute the tipping probabilities using the magnetic pendulum's example as the "before" state. For the "after" state we will change the γ parameter of the third magnet to be so small, its basin of attraction will virtually disappear. As we don't know when the basin of the third magnet will disappear, we switch the attractor finding algorithm back to AttractorsViaRecurrences.

ds = Systems.magnetic_pendulum(d=0.2, α=0.2, ω=0.8, N=3, γs = [1.0, 1.0, 0.1])
psys = ProjectedDynamicalSystem(ds, [1, 2], [0.0, 0.0])
mapper = AttractorsViaRecurrences(psys, (xg, yg); Δt = 1)
basins_after, attractors_after = basins_of_attraction(
    mapper, (xg, yg); show_progress = false
)
# matching attractors is important!
match_attractor_ids!(attractors_after, attractors)

# now plot
ids = sort!(unique(basins_after))
cmap = generate_cmap(length(ids))
fig, ax = heatmap(xg, yg, basins_after;
    colormap = cmap, colorrange = (ids[1] - 0.5, ids[end]+0.5),
)
scatter_attractors!(ax, attractors_after)
fig
P = tipping_probabilities(basins, basins_after)
3×2 Matrix{Float64}:
 0.505987  0.494013
 0.452071  0.547929
 0.547684  0.452316

As you can see P has size 3×2, as after the change only 2 attractors have been identified in the system (3 still exist but our state space discretization isn't fine enough to find the 3rd because it has such a small basin). Also, the first row of P is 50% probability to each other magnet, as it should be due to the system's symmetry.

3D basins via recurrences

To showcase the true power of AttractorsViaRecurrences we need to use a system whose attractors span higher-dimensional space. An example is

ds = Systems.thomas_cyclical(b = 0.1665)
3-dimensional CoupledODEs
 deterministic: true
 discrete time: false
 in-place:      false
 dynamic rule:  thomas_rule
 ODE solver:    Tsit5
 ODE kwargs:    (abstol = 1.0e-6, reltol = 1.0e-6)
 parameters:    [0.1665]
 time:          0.0
 state:         [1.0, 0.0, 0.0]

which, for this parameter, contains 5 coexisting attractors. 3 of them are entangled periodic orbits that span across all three dimensions, and the remaining 2 are fixed points.

To compute the basins we define a three-dimensional grid and call on it basins_of_attraction.

# This computation takes about an hour
xg = yg = zg = range(-6.0, 6.0; length = 251)
mapper = AttractorsViaRecurrences(ds, (xg, yg, zg))
basins, attractors = basins_of_attraction(mapper)
attractors
Dict{Int16, StateSpaceSet{3, Float64}} with 5 entries:
  5 => 3-dimensional StateSpaceSet{Float64} with 1 points
  4 => 3-dimensional StateSpaceSet{Float64} with 379 points
  6 => 3-dimensional StateSpaceSet{Float64} with 1 points
  2 => 3-dimensional StateSpaceSet{Float64} with 538 points
  3 => 3-dimensional StateSpaceSet{Float64} with 537 points
  1 => 3-dimensional StateSpaceSet{Float64} with 1 points

The basins of attraction are very complicated. We can try to visualize them by animating the 2D slices at each z value, to obtain:

Then, we visualize the attractors to obtain:

In the animation above, the scattered points are the attractor values the function AttractorsViaRecurrences found by itself. Of course, for the periodic orbits these points are incomplete. Once the function's logic understood we are on an attractor, it stops computing. However, we also simulated lines, by evolving initial conditions colored appropriately with the basins output.

The animation was produced with the code:

using GLMakie
fig = Figure()
display(fig)
ax = fig[1,1] = Axis3(fig; title = "found attractors")
cmap = cgrad(:dense, 6; categorical = true)

for i in keys(attractors)
    tr = attractors[i]
    markersize = length(attractors[i]) > 10 ? 2000 : 6000
    marker = length(attractors[i]) > 10 ? :circle : :rect
    scatter!(ax, columns(tr)...; markersize, marker, transparency = true, color = cmap[i])
    j = findfirst(isequal(i), bsn)
    x = xg[j[1]]
    y = yg[j[2]]
    z = zg[j[3]]
    tr = trajectory(ds, 100, SVector(x,y,z); Ttr = 100)
    lines!(ax, columns(tr)...; linewidth = 1.0, color = cmap[i])
end

a = range(0, 2π; length = 200) .+ π/4

record(fig, "cyclical_attractors.mp4", 1:length(a)) do i
    ax.azimuth = a[i]
end

Basin fractions continuation in the magnetic pendulum

Perhaps the simplest application of continuation is to produce a plot of how the fractions of attractors change as we continuously change the parameter we changed above to calculate tipping probabilities.

Computing the fractions

This is what the following code does:

# initialize projected magnetic pendulum
using Attractors, PredefinedDynamicalSystems
using Random: Xoshiro
ds = Systems.magnetic_pendulum(; d = 0.3, α = 0.2, ω = 0.5)
xg = yg = range(-3, 3; length = 101)
ds = ProjectedDynamicalSystem(ds, 1:2, [0.0, 0.0])
# Choose a mapper via recurrences
mapper = AttractorsViaRecurrences(ds, (xg, yg); Δt = 1.0)
# What parameter to change, over what range
γγ = range(1, 0; length = 101)
prange = [[1, 1, γ] for γ in γγ]
pidx = :γs
# important to make a sampler that respects the symmetry of the system
sampler, = statespace_sampler(Xoshiro(1234); spheredims = 2, radius = 3.0)
# continue attractors and basins:
# `Inf` threshold fits here, as attractors move smoothly in parameter space
rsc = RecurrencesSeededContinuation(mapper; threshold = Inf)
fractions_curves, attractors_info = continuation(
    rsc, prange, pidx, sampler;
    show_progress = false, samples_per_parameter = 100
)
# Show some characteristic fractions:
fractions_curves[[1, 50, 101]]
3-element Vector{Dict{Int64, Float64}}:
 Dict(2 => 0.3, 3 => 0.33, 1 => 0.37)
 Dict(2 => 0.21359223300970873, 3 => 0.47572815533980584, 1 => 0.3106796116504854)
 Dict(3 => 0.47058823529411764, 1 => 0.5294117647058824)

Plotting the fractions

We visualize them using a predefined function that you can find in docs/basins_plotting.jl

# careful; `prange` isn't a vector of reals!
Main.basins_curves_plot(fractions_curves, γγ)

"Bifurcation curves"

A by-product of the analysis is that we can obtain bifurcation curves for free. However, only the stable branches can be obtained!

fig = Figure()
ax = Axis(fig[1,1]; xlabel = L"\gamma_3", ylabel = "fixed point")
# choose how to go from attractor to real number representation
function real_number_repr(attractor)
    p = attractor[1]
    return (p[1] + p[2])/2
end

for (i, γ) in enumerate(γγ)
    for (k, attractor) in attractors_info[i]
        scatter!(ax, γ, real_number_repr(attractor); color = Cycled(k))
    end
end
fig

as you can see, two of the three fixed points, and their stability, do not depend at all on the parameter value, since this parameter value tunes the magnetic strength of only the third magnet. Nevertheless, the fractions of basin of attraction of all attractors depend strongly on the parameter. This is a simple example that highlights excellently how this new approach we propose here should be used even if one has already done a standard linearized bifurcation analysis.

Extinction of a species in a multistable competition model

In this advanced example we utilize both RecurrencesSeededContinuation and aggregate_attractor_fractions in analyzing species extinction in a dynamical model of competition between multiple species. The final goal is to show the percentage of how much of the state space leads to the extinction or not of a pre-determined species, as we vary a parameter. The model however displays extreme multistability, a feature we want to measure and preserve before aggregating information into "extinct or not".

To measure and preserve this we will apply RecurrencesSeededContinuation as-is first. Then we can aggregate information. First we have

using Attractors, OrdinaryDiffEq
using PredefinedDynamicalSystems
using Random: Xoshiro
# arguments to algorithms
samples_per_parameter = 1000
total_parameter_values = 101
diffeq = (alg = Vern9(), reltol = 1e-9, abstol = 1e-9, maxiters = Inf)
recurrences_kwargs = (; Δt= 1.0, mx_chk_fnd_att=9, diffeq);
# initialize dynamical syste and sampler
ds = PredefinedDynamicalSystems.multispecies_competition() # 8-dimensional
ds = CoupledODEs(ODEProblem(ds), diffeq)
# define grid in state space
xg = range(0, 60; length = 300)
grid = ntuple(x -> xg, 8)
prange = range(0.2, 0.3; length = total_parameter_values)
pidx = :D
sampler, = statespace_sampler(Xoshiro(1234);
    min_bounds = minimum.(grid), max_bounds = maximum.(grid)
)
# initialize mapper
mapper = AttractorsViaRecurrences(ds, grid; recurrences_kwargs...)
# perform continuation of attractors and their basins
continuation = RecurrencesSeededContinuation(mapper; threshold = Inf)
fractions_curves, attractors_info = continuation(
    continuation, prange, pidx, sampler;
    show_progress = true, samples_per_parameter
);
Main.basins_curves_plot(fractions_curves, prange; separatorwidth = 1)

this example is not actually run when building the docs, because it takes about 60 minutes to complete depending on the computer; we load precomputed results instead

As you can see, the system has extreme multistability with 64 unique attractors (according to the default matching behavior in RecurrencesSeededContinuation; a stricter matching with less than Inf threshold would generate more "distinct" attractors). One could also isolate a specific parameter slice, and do the same as what we do in the Fractality of 2D basins of the (4D) magnetic pendulum example, to prove that the basin boundaries are fractal, thereby indeed confirming the paper title "Fundamental Unpredictability".

Regardless, we now want to continue our analysis to provide a figure similar to the above but only with two colors: fractions of attractors where a species is extinct or not. Here's how:

species = 3 # species we care about its existence

featurizer = (A) -> begin
    i = isextinct(A, species)
    return SVector(Int32(i))
end
isextinct(A, idx = unitidxs) = all(a -> a <= 1e-2, A[:, idx])

# `minneighbors = 1` is crucial for grouping single attractors
groupingconfig = GroupViaClustering(; min_neighbors=1, optimal_radius_method=0.5)

aggregated_fractions, aggregated_info = aggregate_attractor_fractions(
    fractions_curves, attractors_info, featurizer, groupingconfig
)

Main.basins_curves_plot(aggregated_fractions, prange;
    separatorwidth = 1, colors = ["green", "black"],
    labels = Dict(1 => "extinct", 2 => "alive"),
)

(in hindsight, the labels are reversed; attractor 1 is the alive one, but oh well)

Trivial featurizing and grouping for basins fractions

This is a rather trivial example showcasing the usage of AttractorsViaFeaturizing. Let us use once again the magnetic pendulum example. For it, we have a really good idea of what features will uniquely describe each attractor: the last points of a trajectory (which should be very close to the magnetic the trajectory converged to). To provide this information to the AttractorsviaFeaturizing we just create a julia function that returns this last point

using Attractors
using PredefinedDynamicalSystems

ds = Systems.magnetic_pendulum(d=0.2, α=0.2, ω=0.8, N=3)
psys = ProjectedDynamicalSystem(ds, [1, 2], [0.0, 0.0])

function featurizer(X, t)
    return X[end]
end

mapper = AttractorsViaFeaturizing(psys, featurizer; Ttr = 200, T = 1)

xg = yg = range(-4, 4; length = 101)

sampler, = statespace_sampler(; min_bounds = [-4,-4], max_bounds=[4,4])

fs = basins_fractions(mapper, sampler; show_progress = false)
Dict{Int64, Float64} with 3 entries:
  2 => 0.347
  3 => 0.315
  1 => 0.338

As expected, the fractions are each about 1/3 due to the system symmetry.

Featurizing and grouping across parameters (MCBB)

Here we showcase the example of the Monte Carlo Basin Bifurcation publication. For this, we will use GroupAcrossParametersContinuation while also providing a par_weight = 1 keyword. However, we will not use a network of 2nd order Kuramoto oscillators (as done in the paper by Gelbrecht et al.) because it is too costly to run on CI. Instead, we will use the Henon map and try to group attractors into period 1 (fixed point), period 3, and divergence to infinity. We will also use a pre-determined optimal radius for clustering, as we know a-priory the expected distances of features in feature space (due to the contrived form of the featurizer function below).

using Attractors, Random

b, a = -0.9, 1.4 # notice the non-default parameters
henon_rule(x, p, n) = SVector{2}(1.0 - p[1]*x[1]^2 + x[2], p[2]*x[1])
henon = DeterministicIteratedMap(henon_rule, zeros(2), [a,b])

function featurizer(a, t) # feature based on period!
    tol = 1e-5
    if abs(a[end-1,1] - a[end,1]) < tol
        # period 1
        return [1]
    elseif abs(a[end-3,1] - a[end,1]) < tol
        # period 3
        return [3]
    else
        return [100]
    end
end

henon
2-dimensional DeterministicIteratedMap
 deterministic: true
 discrete time: true
 in-place:      false
 dynamic rule:  henon_rule
 parameters:    [1.4, -0.9]
 time:          0
 state:         [0.0, 0.0]
clusterspecs = GroupViaClustering(optimal_radius_method = 1.0)
mapper = AttractorsViaFeaturizing(
    henon, featurizer, clusterspecs;
    T = 6, threaded = true, Ttr = 500,
)

continuation = GroupAcrossParameterContinuation(mapper; par_weight = 1.0)

ps = range(0.6, 1.1; length = 11)
pidx = 1
sampler, = statespace_sampler(Random.MersenneTwister(1234);
    min_bounds = [-2,-2], max_bounds = [2,2]
)

fractions_curves, clusters_info = Attractors.continuation(
    continuation, ps, pidx, sampler;
    samples_per_parameter = 100, show_progress = false
)
fractions_curves

Looking at the information of the "attractions" (here the clusters of the grouping procedure) makes it clear which label corresponds to which kind of attractor (fixed point, period 3, or divergence to infinity):

clusters_info

Using histograms and histogram distances as features

One of the aspects discussed in the original MCBB paper and implementation was the usage of histograms of the means of the variables of a dynamical system as the feature vector. This is useful in very high dimensional systems, such as oscillator networks, where the histogram of the means is significantly different in synchronized or unsychronized states.

This is possible to do with current interface without any modifications, by using two more packages: ComplexityMeasures.jl to compute histograms, and Distances.jl for the Kullback-Leibler divergence (or any other measure of distance in the space of probability distributions you fancy).

The only code we need to write to achieve this feature is a custom featurizer and providing an alternative distance to GroupViaClustering. The code would look like this:

using Distances: KLDivergence
using ComplexityMeasures: ValueHistogram, FixedRectangularBinning, probabilities

# you decide the binning for the histogram, but for a valid estimation of
# distances, all histograms must have exactly the same bins, and hence be
# computed with fixed ranges, i.e., using the `FixedRectangularBinning`
const binning = FixedRectangularBinning(range(-5, 5; length = 11))

function histogram_featurizer(A, t)
    ms = mean.(columns(A)) # vector of mean of each variable
    p = probabilities(ValueHistogram(binning), ms) # this is the histogram
    return vec(p) # because Distances.jl doesn't know `Probabilities`
end

gconfig = GroupViaClustering(;
    clust_distance_metric = KLDivergence(), # or any other PDF distance
)

You can then pass the histogram_featurizer and gconfig to an AttractorsViaFeaturizing and use the rest of the library as usual.