$ cd /home

Animating plots in Julia

Published on: 2025-01-28

TL;DR

I simulated how age and happiness could be confounded by marital status, and visualized the result with an animated plot using Makie and Observables.

The scenario and visualization are inspired by one of Richard McElreath’s video lectures.

Scenario

Are older people happier than younger people?

Hard to say!

I think it’s possible to come up with arguments for either side of the debate. Time to write a model, adjust for confounders, and see what we learn.

If we assume that happier people are more likely to marry, we might therefore reason that we should also adjust for marital status MM when modelling happiness HH as a function of age AA.

We’d be working with a straightforward linear regression:

H^=α+β1A+β2M+ε\hat{H} = \alpha + \beta_{1} A + \beta_{2} M + \varepsilon

This seems reasonable, right? What could go wrong?

Once again simplifying a lot, let’s assume that as an adult person ages, they have a fixed probability to marry for each year that passes, and that this probability in influenced by how happy that person is.

The probability for a person having married by time tt would then be:

p(Mt)=1(1f(H))tp(M_t) = 1 - (1 - f(H))^t

We’d expect happier individuals to marry at a younger age than unhappy individuals, which creates a spurious negative association between age and happiness. Oops!

Among married people, those who are particularly young are more likely to be very happy, since that marrying so early is unlikely otherwise. Similarly, among unmarried people, young people are also more likely to be happy — after all, it takes some time to get married even if you’re a happy person.

Now, if we model happiness as a function of age given the marital status, our model would infer that young people are happier than older people. That association isn’t really there, it’s entirely caused by the collider!

Marital status and happiness

There’s a lot of research on this topic, and the evidence is pointing towards marriage having a causal effect on happiness 1 , and that there’s little support for the selection scenario I have described above 2 .

Simulation

It was a lot of fun to recreate the animation that McElreath showed in his video lecture. It’s the first animation I created with Makie.jl and Observables — I can really recommend their tutorial on Observables if you want to learn more!

First things first, let’s set us up with the necessary packages.

using LogExpFunctions
using Distributions
using GLMakie
using DataStructures: CircularBuffer

Next, we need a way to track the age, happiness, and marital status of each individual.

mutable struct Person
    age::Float64
    happiness::Float64
    married::Bool
    function Person(; age=0.0, happiness)
        return new(age, happiness, false)
    end
end

If you’ve had a look at McElreath’s video, he created a neat, equally spaced grid of happiness and age values.

To achieve this, we have to make sure that each time we add a new “generation” to our cohort, it contains only a single person for each happiness value. I chose a range between -2 and 2 because I find that this makes it easier to reason about transforming happiness values to marriage probabilities.

default_range = -2:0.1:2

function addbirths!(cohort::CircularBuffer; happiness_range=default_range)
    for h in happiness_range
        push!(cohort, Person(happiness=h))
    end
    return nothing
end

Since we will only be plotting ages 0 to 65, we don’t need to track individuals older than 65 years. Using the CircularBuffer data structure makes this easy: if we initialize it to the right size, it’ll automatically drop individuals older than 65 years each time we add a new generation.

For each year that passes, we need to determine if an unmarried person married that year. The scaling I applied means that in a given year, an individual’s probability to marry ranges from 0 to 0.04, depending on the individual’s happiness.

function maybemarry!(person; scale=1.5, max_p=0.04)
	p = logistic(scale * person.happiness)
	person.married = rand(Bernoulli(p * max_p))
	return nothing
end

Let’s write two functions to determine who cannot marry at a given point in time: that’s minors, and those who are married already.

isminor(person) = person.age < 18
ismarried(person) = person.married

All that’s left now is to add a function to increment a person’s age by one year.

age!(person) = person.age += 1.0

Now we can bring together all the components we prepared into a single function that takes our simulation one step! forward in time.

function step!(cohort::CircularBuffer)
    addbirths!(cohort)
    for person in cohort
        if ismarried(person) || isminor(person)
            age!(person)
            continue
        end
        maybemarry!(person)
        age!(person)
    end
    return nothing
end

Next we create our cohort object and fill it with people.

cohort_size = length(default_range) * max_age
cohort = CircularBuffer{Person}(cohort_size)

for i in 1:100
	step!(cohort)
end

To animate our plot, we only need to adjust the color of each point depending on whether the person with that age-happiness combination is married at that time in the simulation.

palette = (
    married = colorant"#571F4E",
    unmarried = colorant"#C8C2D6",
    minor = colorant"#D9D9D9"
)

function colorise(person; palette=palette)
	if ismarried(person)
		return palette.married
	elseif isminor(person)
		return palette.minor
	end
	return palette.unmarried
end

We let Makie know that it should update the plot as described above by turning married_points into an Observable.

married_points = colorise.(cohort)
married_points = Observable(married_points)

Time to set up the plot! At this point, the plot is still static, reflecting the state of the cohort after the 100 time increments we added above.

fig = Figure()

ax = Axis(fig[1, 1], xlabel="Age (years)",
    ylabel="Happiness (z-standardised)")

age_vals = getproperty(x, :age)
happiness_vals = getproperty(x, :happiness)

scatter!(ax, age_vals, happiness_vals, color=married_points)

labels = ["Married", "Unmarried", "Minor"]
elements = [PolyElement(polycolor=c) for c in values(palette)]
Legend(fig[2, 1], elements, labels, "Marriage status", 
    orientation=:horizontal)

fig

To breathe life into the static plot, we need to write another stepping function, this time with the purpose of updating the Observable married_points. We update the values stored in an Observable with the special [] syntax.

function animstep!(cohort, married_points)
    step!(cohort)
    married_points[] = colorise.(cohort)
end

Now we’re good to go! Let’s animate the plot for 10 seconds.

framerate = 10
timestamps = 1:100

record(fig, "animation.mp4", timestamps; framerate=framerate) do _
    animstep!(cohort, married_points)
end

The simulation confirms our understanding of the collider: within each of the married and unmarried groups, younger people are happier than older people.

References

  1. Coombs, R. (1991). Marital Status and Personal Well-Being: A Literature Review. Family Relations. https://doi.org/$10.2307/585665
  2. Grover, Shawn; Helliwell, J. (2014). How’s Life at Home? New Evidence on Marriage and the Set Point for Happiness. Journal of Happiness Studies. https://doi.org/$10.1007/S10902-017-9941-3