Encapsulating uncertainty with Measurements.jl or MonteCarloMeasurements.jl

Uncertain numbers in Julia

The Julia language and its multiple dispatch system make it straightforward to incorporate uncertainty in any quantity represented by real number(s). There are two packages in particular that provide such functionality, both of which can be integrated trivially with Agents.jl (due to multiple dispatch). These are:

They both provide a numeric type that represents uncertainty in one way or the other. For example, let's assume we have two numbers that are normally distributed

xval = 1.0
yval = 5.0
σ = 0.25
0.25

then

import Measurements
x = Measurements.measurement(xval, σ)
y = Measurements.measurement(yval, σ)
sqrt(x^2 - 2x*y + y^2)

\[4.0 \pm 0.35\]

or

import MonteCarloMeasurements
using Distributions: Normal, Cosine # can use any distributions
x = MonteCarloMeasurements.Particles(100, Normal(xval, σ))
y = MonteCarloMeasurements.Particles(100, Normal(yval, σ))
sqrt(x^2 - 2x*y + y^2)
4.0 ± 0.327 MonteCarloMeasurements.Particles{Float64, 100}

For convience we will define two functions that will give the mean and std of an uncertain numeric type irrespectively of type used

meanval(x::Float64) = x
stdval(x::Float64) = 0.0 # no uncertainty for exact real number!

meanval(x::Measurements.Measurement) = Measurements.value(x)
stdval(x::Measurements.Measurement) = Measurements.uncertainty(x)

meanval(x::MonteCarloMeasurements.Particles) = MonteCarloMeasurements.pmean(x)
stdval(x::MonteCarloMeasurements.Particles) = MonteCarloMeasurements.pstd(x)
stdval (generic function with 3 methods)

Defining the Daisyworld model with uncertainty

Here, we'll modify the Daisyworld model to incorporate uncertainty in the initial temperature of the planet as well as the albedo of the daisies.

First, we define the daisy agent type as parameteric

using Agents

@agent struct Daisy{T}(GridAgent{2})
    breed::Symbol
    age::Int
    albedo::T
end

The type parameter T will represent varius numeric types. Now we need to define the rules of the Daisyworld. There is practically at all in this step from the original Daisyworld example! We only need to change using a logarithm (not defined for negative numbers) and ensure that we are using the mean of an uncertain number in boolean operations, as it is otherwise umbiguous which number to use to make the Boolean decision. The rest of the functions do not care about us wanting to use this uncertainty-representing numeric type. This is one of the beauties of generic programming in Julia!

We first define the individual daisy dynamics

function daisy_step!(agent::Daisy, model)
    agent.age += 1
    if agent.age ≥ model.max_age
        remove_agent!(agent, model)
        return
    end
    # if daisy stays alive, it may propagate an offspring
    pos = agent.pos
    temperature = meanval(model.temperature[pos...]) # can't use uncertainty in Boolean operations
    seed_threshold = (0.1457 * temperature - 0.0032 * temperature^2) - 0.6443
    if rand(abmrng(model)) < seed_threshold
        empty_near_pos = random_nearby_position(pos, model, 1, npos -> isempty(npos, model))
        if !isnothing(empty_near_pos)
            add_agent!(empty_near_pos, model, agent.breed, 0, agent.albedo)
        end
    end
end
daisy_step! (generic function with 1 method)

and then the dynamics of the Daisyworld

function daisyworld_step!(model)
    for p in positions(model)
        update_surface_temperature!(p, model)
        diffuse_temperature!(p, model)
    end
end

function update_surface_temperature!(pos, model)
    if isempty(pos, model) # no daisy
        absorbed_luminosity = (1 - model.surface_albedo) * model.solar_luminosity
    else
        daisy = model[id_in_position(pos, model)]
        absorbed_luminosity = (1 - daisy.albedo) * model.solar_luminosity
    end
    # Here we changed the rule to not use `log` because it isn't defined for negative numbers!
    # We also need to somehow extract a number from the uncertain number, because boolean
    # comparisons are not defined on uncertain numbers.
    local_heating = meanval(absorbed_luminosity) > 0 ? 72 *(2absorbed_luminosity - 1.8) + 80 : 80
    model.temperature[pos...] = (model.temperature[pos...] + local_heating) / 2
end

function diffuse_temperature!(pos, model)
    ratio = model.ratio # diffusion ratio
    npos = nearby_positions(pos, model)
    model.temperature[pos...] =
        (1 - ratio) * model.temperature[pos...] +
        sum(model.temperature[p...] for p in npos) * 0.125 * ratio
end
diffuse_temperature! (generic function with 1 method)

Now as per usual in Agents.jl we will define a function that creates the daisyworld and populates it with some daisies. The starting temperature

using Random: Xoshiro
using StatsBase: sample

function daisyworld(;
        griddims = (30, 30),
        max_age = 25,
        init_white = 0.3, # % cover of the world surface of white breed
        init_black = 0.3, # % cover of the world surface of black breed
        albedo_white = 0.75,
        albedo_black = 0.25,
        surface_albedo = 0.4,
        solar_luminosity = 1.0,
        seed = 165,
        starting_temperature = 0.0,
    )

    rng = Xoshiro(seed)
    space = GridSpaceSingle(griddims)
    properties = (; # named tuple
        max_age, surface_albedo, solar_luminosity,
        ratio = 0.5, temperature = fill(starting_temperature, griddims)
    )

    T = typeof(albedo_black)
    model = StandardABM(Daisy{T}, space; properties, rng, agent_step! = daisy_step!, model_step! = daisyworld_step!)

    # populate the model with random white daisies
    grid = collect(positions(model))
    L = length(grid)
    white_positions = sample(rng, grid, round(Int, init_white*L); replace = false)
    for wp in white_positions
        add_agent!(wp, model, :white, rand(abmrng(model), 0:max_age), albedo_white)
    end
    # and black daisies
    possible_black = setdiff(grid, white_positions)
    black_positions = sample(rng, possible_black, Int(init_black*L); replace = false)
    for bp in black_positions
        add_agent!(bp, model, :black, rand(abmrng(model), 0:max_age), albedo_black)
    end

    for p in positions(model)
        update_surface_temperature!(p, model)
    end

    return model
end
daisyworld (generic function with 1 method)

Running Daisyworld without uncertainty

There isn't anything new here; we are doing the same as in the original Daisyworld example. Let's define a convenience plotting function first

using Statistics: mean
using CairoMakie

function run_plot_daisyworld(; steps = 500, kw...)

    abm = daisyworld(; kw...)

    function temp_mean(abm)
        T = abm.temperature
        return mean(meanval(t) for t in T)
    end
    function temp_std(abm)
        T = abm.temperature
        return mean(stdval(t) for t in T)
    end
    black_daisies(abm) = count(a -> a.breed == :black, allagents(abm))
    white_daisies(abm) = count(a -> a.breed == :white, allagents(abm))
    mdata = [temp_mean, temp_std, black_daisies, white_daisies]

    adf, mdf = run!(abm, steps; mdata)
    t = 0:steps

    # plot daisy populations
    fig, ax = lines(t, mdf.black_daisies; color = "black", label = "black")
    lines!(ax, t, mdf.white_daisies; color = "gray", linestyle = :dash, label = "white")
    axislegend(ax)
    hidexdecorations!(ax, grid = false)
    ax.ylabel = "daisy populations"
    ntype = typeof(first(allagents(abm)).albedo)
    ax.title = "Numeric type: $(ntype)"
    # plot planet temperature
    axt, = lines(fig[2, 1], t, mdf.temp_mean; color = "red")
    band!(axt, t, mdf.temp_mean .- mdf.temp_std, mdf.temp_mean .+ mdf.temp_std; color = ("red", 0.25))
    axt.ylabel = "temperature"
    axt.xlabel = "time"
    return fig
end

run_plot_daisyworld()
Example block output

Right, this looks great! As expected, there is no band plot shown in the temperature axis as there is no uncertainty yet. Let's change that!

Running Daisyworld with uncertainty

All we have to do to enable uncertainty is change the daisy albedos and starting temperature into numbers with uncertainty. This is as simple as changing three keywords:

run_plot_daisyworld(;
    starting_temperature = Measurements.measurement(0.0, 1.0),
    albedo_white = Measurements.measurement(0.75, 0.1),
    albedo_black = Measurements.measurement(0.25, 0.1),
)
Example block output

Well that's great! It also works with the other type of uncertainty:

run_plot_daisyworld(;
    starting_temperature = MonteCarloMeasurements.Particles(100, Normal(0.0, 1.0)),
    albedo_white = MonteCarloMeasurements.Particles(100, Normal(0.75, 0.2)),
    albedo_black = MonteCarloMeasurements.Particles(100, Cosine(0.25, 0.1)),
)
Example block output

We can see that:

  1. The daisy populations do not change regardless of if we use uncertainty or not. This is expected as the uncertainty itself does not actually decide any model dynamics.
  2. The uncertainty in surface temperature does not increase over time.