Author Archives: Frederik Banning

Julia ❤️ ABM #3: What a difference a day makes

By: Frederik Banning

Re-posted from: https://forem.julialang.org/fbanning/julia-abm-3-what-a-difference-a-day-makes-2k1l

In the previous issue we have laid out the basic events that happen in our model – both as verbal descriptions in plain English and as algorithmic representations in simple Julia code. Sally and Richard are now not merely static pieces of data anymore but they can act in three different ways: work, eat, and trade.

However, today’s lesson content will be a bit more technical in nature and deal with how we work with Julia. So far we had just written out the pieces of our modelling code in long blocks of for-loops that stood on their own and weren’t really connected to each other. While it works great for simple examples, this will become increasingly harder to read. Furthermore, it is relatively complicated to reason about which pieces of our code belong to which category of actions. To take care of this, today we will primarily learn about two things:

  1. Writing our code in plain text files and including them in a running Julia REPL.
  2. Encapsulating our code in functions takes care of this and provides us with more options to easily structure our model code and even improve its performance.

So let us not hesitate any longer and start the refactoring.

Continue reading on blog…

Brief remark on future posts:

From now on, future issues in the Julia ❤️ ABM series will primarily be published on my personal blog and new issues will afterwards be announced here on Julia’s Forem instance. This allows me to avoid the extra formatting work required to publish on here while still retaining the ability to get in touch with the community, especially through the comment section. This approach also gives me full control over how these posts are styled and structured and in the long run it also feels nice from an self-hosting/archiving perspective. Thanks for your understanding and happy reading. 🙂

Julia ♥ ABM #2: Work, Eat, Trade, Repeat

By: Frederik Banning

Re-posted from: https://forem.julialang.org/fbanning/julia-abm-2-work-eat-trade-repeat-3d7l

In agent-based modelling we want to advance our simulation in time and let the agents act in accordance with their plans and schedules. Let’s say we want to simulate five time steps. If you for example already have some experience in NetLogo, you might know that this is very simple to do there.

When using a general purpose programming language like Julia, however, we have to do things through code instead of relying on a ready-made graphical user interface (GUI). So if there are no buttons to click on, how do we do this then? This issue will first generally cover how to advance the model through loops, such that afterwards we can go into detail about the work, eat, and trade processes that all of our agents will face every day. Over its course, we will learn about some built-in functions that might come in handy, how to use conditional logic, and a lot of other things – so let’s jump right in.

Advancing the model

In contrast to NetLogo and other GUI-driven ABM frameworks, Julia requires a bit more manual coding work from us. How exactly our program should process the data and by that advance the state of our model is not controlled via buttons to click on but instead we explicitly define all model functionality by writing executable code. Maybe the simplest imaginable way our model state could be incrementally advanced is via a for-loop:

julia> for step in 1:5
           println(step)
       end
1
2
3
4
5

The loop above looks very simple but already does a few things. We use loops a lot when writing ABMs, therefore we will pick the concept apart and briefly explain the important aspects of them.

  • for x in y: This is the start of the loop. The command iterates over each item in the provided iterable y. Basically this can be any collection of items that can return its values one at a time. Like in the example above this could be a range of numbers 1:5 (= one to five in increments of one) or any other iterable like an Array, a Tuple, the key-value pairs of a Dict, et cetera.[1]
  • println(x): This is the heart of our for-loop. It is indented to visually set it apart from the surrounding code. Here we can do whatever we want and it will be repeated as many times as there are elements in y. In our example we call the println function which just prints the value of a given variable to the Julia REPL. Here we also created a temporary local variable x that only exists within the loop and changes its value (but not its name) with every iteration of the for-loop. This allows us to reference the current “state of the loop” and do awesome things with it.
  • end: As you already know, this is the Julia keyword that marks the end of the preceding block (e.g. an if-block or a for-loop).

As will often be the case in ABMs, in the above example we have gone through the body of the loop for multiple times. Every time we’ve done something with the available data, i.e. printing the current step. If you’re coming from a NetLogo background, what we’ve did above is comparable to writing the following procedures:

to setup
  reset-ticks
end

to go
  tick
  print ticks
end

And then setting up the setup and go buttons on the interface, clicking the setup button once and clicking the go button for five times:

NetLogo printing

Now how to loop over our agents which we’ve stored as separate variables so far? You might have already guessed it: We just store them in a Vector.[2]

julia> agents = [sally, richard]
2-element Vector{Agent}:
 Agent("Baker", 10, 10, 0, 0)
 Agent("Fisher", 10, 10, 0, 0)

julia> for agent in agents
           println(agent.job)
       end
Baker
Fisher

It’s also possible to not only retrieve agent data inside such loops but we can of course also include some conditional logic statements in there according to which we will alter the agent variables.

julia> for agent in agents
           if agent.job == "Baker"
               println("Sally's oven exploded and burnt down her whole pantry.")
               agent.pantry_bread = 0
               agent.pantry_fish = 0
           elseif agent.job == "Fisher"
               println("Richard was very fortunate today and caught ten fish!")
               agent.pantry_fish += 10
           end
       end
Sally's oven exploded and burnt down her whole pantry.
Richard was very fortunate today and caught ten fish!

The Julia REPL threw back both cases that we’ve included in our loop. The order by which these events are happening is determined by the order of Agents in our collection agents. The new values are saved as the agent variables , i.e. the fields of the two instances of our composite type.

julia> sally
Agent("Baker", 0, 0, 0, 0)

julia> richard
Agent("Fisher", 20, 10, 0, 0)

Of course this was just an illustrative example of what would be possible inside such a loop. But now that we’ve established how to programmatically let our agents experience events and how to change their variables in reaction to these events, we can reason about what they should do each day and build a proper loop for our model.

Work, eat, trade, repeat

While Richard and Sally spend their time on very different things each day, the fundamental steps are still similar: Every day they go after their respective work which makes them hungry so they have to eat. Every third day, they meet to trade fish and bread with each other. The following subsections are about representing these actions as the core loops of our model and describing them in a bit more detail. We’ll start with a simplified version but in later issues we will go on and expand it to be a bit more sophisticated.

Work

As described in our model description, Richard has a chance to catch 0-3 fish per hour that he is out on the sea fishing. Because it would take a lot of time and effort to model the ecosystem of the sea and coastal area that he regularly goes to for fishing, we will opt for a stochastic approach to model his hourly fishing success. This can be easily done in Julia with the built-in rand function. If we don’t pass any arguments to it, it will just provide us with a random floating point number which was drawn from a uniform distribution between 0 and 1


x∼U(0,1)x \sim U(0,1) xU(0,1)

:

julia> rand()
0.21528920784298688

However, rand is the standard Julia interface for drawing a random element out of a given collection. This means that there are specialised methods for a lot of different use cases, which makes it simple for us to model randomised processes.

julia> rand(1:10)
6

julia> rand(["bark", "meow", "moo"])
"bark"

julia> rand(agents)
Agent("Baker", 10, 10, 0, 0)

Following this logic, we can define a simple list with numbers from 0-3 where the number of occurrences of each number then reflects the probability to draw it randomly.

julia> fishing_chances = [0, 0, 0, 0, 1, 1, 1, 2, 2, 3]
10-element Vector{Int64}:
 0
 0
 0
 0
 1
 1
 1
 2
 2
 3

Since Richard regularly doesn’t go fishing for only one hour but for a few hours at once, it would be nice if we could just make multiple draws from the distribution and sum up those numbers to find out how many fish Richard has caught this day. This is also possible with the rand function when we provide it with the amount of independent draws that we want to make. Let’s say Richard goes fishing for five hours.

julia> fish_per_hour = rand(fishing_chances, 5)
5-element Vector{Int64}:
 0
 0
 0
 1
 0

As you can see we have stored the resulting Vector{Int64} (i.e. a list of integer numbers) in a new variable called fish_per_hour. This not only allows us to find out how many fish he caught during which hour (e.g. the fourth hour) …

julia> fish_per_hour[4]
1

… but it also lets us easily calculate the total amount of caught fish via the sum function.

julia> sum(fish_per_hour)
1

Poor Richard. He went out fishing for five hours and only got one meager fish for all that effort.

The amount of hours he is out on the sea might depend on things like how filled his pantry currently is, how many fish he tries to set aside for Sally, and his previous luck during this day. Since some of these things depend on the other two core processes “eat” and “trade” which we will discuss further down, we can just currently assume that his daily fishing efforts depend on a random draw from a uniform distribution

fishing hours∼U(1,5)\text{fishing hours} \sim U(1,5) fishing hoursU(1,5)

as well.

Julia allows us to nest one call to a function into another and by that use the result of the inner call as input for the outer call. This may sound a bit complicated at first but is easy to demonstrate and even easier to intuitively understand when you see the results:

julia> rand(fishing_chances, rand(1:5))
1-element Vector{Int64}:
 3

julia> rand(fishing_chances, rand(1:5))
2-element Vector{Int64}:
 2
 0

The second result reads as follows: Richard went fishing for two hours (the length of the Vector). He caught two fish in the first hour and not a single one in the second hour (the elements of the Vector). Overall he caught two fish which means that he caught one fish per hour on average (the sum and mean of the Vector).

Richard spends the remainder of his work day on various other tasks. Again, this might depend on various factors but for now we will just assume that he works for at least one more hour and up to three hours after returning from sea. Can you guess how we will do this? (Hint: It may or may not have something to do with the rand function.)

Determine other work hours (click me)

rand(1:3)

Lucky for Sally that she’s not so dependent on the whims of the sea but can freely decide to produce up to 20 loaves of bread each day. How many she wants to bake may depend on various factors, just like it is the case for Richard’s fishing endeavours. For example, her current and future hunger levels, how many loaves she needs for trading with Richard, or maybe just her motivation on that day might factor into her decision. But we do not have all this information at the current stage and can only add these ideas later. So for now, we will also just make a simplifying assumption to randomise the amount of freshly baked bread as

fresh bread∼U(1,20)\text{fresh bread} \sim U(1,20) fresh breadU(1,20)

:

julia> rand(1:20)
15

During this day, Sally decided to bake 15 loaves of bread. As we know, she needs to work for at least eight hours even if she only bakes a single bread. But baking the maximum of 20 loaves will only take up 10 hours in total, so the increase in necessary working time per additional bread is quite small. How small exactly is something that we can find out with just a single Julia command again.

julia> hours_for_breads = LinRange(8, 10, 20)
20-element LinRange{Float64, Int64}:
 8.0,8.10526,8.21053,8.31579,8.42105,8.52632,8.63158,8.73684,,9.36842,9.47368,9.57895,9.68421,9.78947,9.89474,10.0

LinRange takes a starting number (8), an ending number (10), and the length (20) of the resulting range. From this range of numbers with equal distance between them (LinRange stands for “linear range”) we can now deduct how many hours Sally had to work to bake 15 fresh loaves of bread.

julia> hours_for_breads[15]
9.473684210526315

Great! So far we have established two simple algorithms to find out how many hours Sally and Richard work on a day and what the fruit of their labour might look like. Now let’s combine the above-mentioned processes into a single for-loop, just how we learned it at the beginning of this post.

for agent in agents
    if agent.job == "Baker"
        # bake bread and add to pantry
    elseif agent.job == "Fisher"
        # go fishing and add to pantry
    end
end

Can you finish this piece of code? Look at the explanations above and try it out by yourself.

Remember that you can add temporary variables like hours_for_breads to make it easier to reason about the flow of the code. If you prefer to write your code in a less verbose way, you can also opt for nesting function calls into each other, thus reducing the lines of code that you have to write. A word of warning though:

It is often better to be as explicit as possible about what you intend your code to do.
This is especially true if you want other people to read and understand your code but is just as important for your future self.

  • Pick “telling names” for your variables.
  • Stick to a coherent coding style.
  • Find the right balance between verbosity and conciseness.

Verbose & concise solutions (click me)

# verbose version with comments for individual subprocesses
for agent in agents
    if agent.job == "Baker"
        # determine how many loaves of bread Sally will bake this day
        fresh_breads = rand(1:20)
        # add freshly baked bread to Sally's pantry
        agent.pantry_bread += fresh_breads
        # get a list of working hours required to bake a certain amount of bread
        hours_for_breads = LinRange(8, 10, 20)
        # find out how many hours Sally needs to work for this
        baking_hours = hours_for_breads[fresh_breads]
    elseif agent.job == "Fisher"
        # make a random draw for Richard's fishing hours
        fishing_hours = rand(1:5)
        # create an array to represent the probability to catch fish
        fishing_chances = [0, 0, 0, 0, 1, 1, 1, 2, 2, 3]
        # make `fishing_hours` random draws from `fishing_chances` to get a list of fish per hour
        fresh_fish = rand(fishing_chances, fishing_hours)
        # add freshly caught fish to Richard's pantry
        agent.pantry_fish += sum(fresh_fish)
        # randomly determine the rest of Richard's working hours for tasks other than fishing
        other_work_hours = rand(1:3)
    end
end

# concise version with nesting and without comments
for agent in agents
    if agent.job == "Baker"
        fresh_breads = rand(1:20)
        agent.pantry_bread += fresh_breads
        baking_hours = LinRange(8, 10, 20)[fresh_breads]
    elseif agent.job == "Fisher"
        fishing_hours = rand(1:5)
        fresh_fish = rand([0, 0, 0, 0, 1, 1, 1, 2, 2, 3], fishing_hours)
        agent.pantry_fish += sum(fresh_fish)
        other_work_hours = rand(1:3)
    end
end

Eat

When our agents work, they get hungry and need to eat. In this respect, we will again start simple and say that both Sally and Richard need one loaf of bread (1 kg) and half a fish (0.5 kg) each day. With our newfound power of iteration via for-loops, we can do that for all our agents in one go:

julia> for agent in agents
           agent.hunger_bread += 1
           agent.hunger_fish += 0.5
       end
ERROR: InexactError: Int64(0.5)
Stacktrace:
 [1] Int64
   @ ./float.jl:812 [inlined]
 [2] convert
   @ ./number.jl:7 [inlined]
 [3] setproperty!(x::Agent, f::Symbol, v::Float64)
   @ Base ./Base.jl:43
 [4] top-level scope
   @ ./REPL[23]:3

Wait, we seem to not have anticipated that our agents might not always want to eat a whole fish or loaf of bread at once. We just tried to account for fractions of fish but our struct only allows for whole units of bread and fish because we chose Int as an inappropriate type for the pantry fields. Throwing away the rest of the fish is also not an option because it would be both very wasteful and disrespectful towards the animal.

Luckily the stacktrace (that’s the menacing looking message that Julia throws at us when there’s an error) tells us that it cannot convert the number 0.5 into an Int64.[3] It also tells us that it tried to set the agent variable to a Float64 (see [3] in the stacktrace). With this information, it’s easy to fix this small oversight from our preliminary modelling process by redefining our custom-made composite type Agent. The new struct should now look like this:

julia> mutable struct Agent
           job::String
           pantry_fish::Float64
           pantry_bread::Float64
           hunger_fish::Float64
           hunger_bread::Float64
       end

The new types of the fields are 64 bit floating point numbers, which are called Float64 in Julia, and are able to represent fractions of something as a number. If we’re talking about our food in terms of kilograms, then in our case an agent is now able to store half a kilo of bread via pantry_bread += 0.5 or represent their craving for half a pound of fish by setting hunger_fish = 0.25.

After redefining sally and richard with our freshly adjusted Agent struct and placing them in the list of agents, we can run the loop from above again:

julia> for agent in agents
           agent.hunger_bread += 1
           agent.hunger_fish += 0.5
       end

julia> agents
2-element Vector{Agent}:
 Agent("Baker", 10.0, 10.0, 0.5, 1.0)
 Agent("Fisher", 10.0, 10.0, 0.5, 1.0)

Et voilà, it worked just as expected. Naturally, the next step is about satisfying their hunger by eating something. This is in fact just as simple to code as the loop before. We subtract the amount of food to eat from the pantry and reduce the hunger level for it during:

julia> for agent in agents
           agent.pantry_bread -= 1.0
           agent.hunger_bread -= 1.0
           agent.pantry_fish -= 0.5
           agent.hunger_fish -= 0.5
       end

julia> agents
2-element Vector{Agent}:
 Agent("Baker", 9.5, 9.0, 0.0, 0.0)
 Agent("Fisher", 9.5, 9.0, 0.0, 0.0)

Now as a thought experiment, we could try to run this loop again. Don’t actually do it but instead try to reason about what you think will happen?

New state of agent variables (click me)

julia> agents
2-element Vector{Agent}:
 Agent("Baker", 9.0, 8.0, -0.5, -1.0)
 Agent("Fisher", 9.0, 8.0, -0.5, -1.0)

The amount of fish and bread in the pantry was reduced by the correct amount but the hunger levels of our agents became negative. The phenomenon of overeating is undoubtably a thing in real life but we do not want to include it in our model as it might conflict with our modelling question and would require further assumptions to make it believable.

To avoid negative hunger levels, we need to introduce a bit of conditional logic (if, elseif, else) in our loop to make sure that our agents only eat (i) as much as they need and (ii) as much as they have. We can account for these two conditions in two different ways:

julia> for agent in agents
           # completely satisfy hunger when there's enough food in pantry
           if agent.pantry_fish >= agent.hunger_fish
               agent.pantry_fish -= agent.hunger_fish
               agent.hunger_fish = 0
           end
           if agent.pantry_bread >= agent.hunger_bread
               agent.pantry_bread -= agent.hunger_bread
               agent.hunger_bread = 0
           end

           # partially satisfy hunger until pantry is empty
           if 0 < agent.pantry_fish <= agent.hunger_fish
               agent.hunger_fish -= agent.pantry_fish
               agent.pantry_fish = 0
           end
           if 0 < agent.pantry_bread <= agent.hunger_bread
               agent.hunger_bread -= agent.pantry_bread
               agent.pantry_bread = 0
           end
       end

Well, look at that. This is already quite nice! So far we’ve captured the fundamental concepts of working, getting hungry, and eating, all while keeping stock of the available food for each agent.

At some point, however, our agents will find themselves in a bit of a pickle: Sally the baker will run out of fish and Richard the fisher will run out of bread. Humans are great at cooperating with each other to fulfil their own needs. So in the next step, we will introduce a basic system of trade between our two hardworking agents.

Trade

On every third day, our two friends meet up at the oceanside for a nightcap and to exchange their produce with each other. According to how we currently modelled daily increases in hunger levels, both agents know that they need 3 bread and 1.5 fish for the timespan of three days, so that’s certainly the minimum of food that they want to have in their pantry after trading with each other. We will stick to our initial assumption for now and say that these trades are happening at a one to one ratio. Furthermore, we assume that they will not reserve any of their produce for themselves but are willing to trade as much with the other agent as they want or as they themselves have available.

In contrast to how we modelled working and eating before, we are not going to iterate over all agents this time around. Instead we know that bilateral trade happens between pairs of interacting agents and since we only have two agents in our little world, things are getting a bit easier to handle. We can just use the agents’ variable names sally and richard again to access and change their data. Don’t worry, the new values will automatically be reflected in the agents Vector as well.

For any trading to happen, we need to make sure that a few conditions apply:

  1. Sally has any bread and Richard has any fish.
  2. Sally has less than 1.5 fish in her pantry.
  3. Richard has less than 3 bread in his pantry.

Trading shall only cover the minimum amount of bread/fish that are needed and affordable by Sally and Richard while at the same time being available from the other agent.

To achieve this, we use the built-in min function which returns the minimum value of the arguments we pass to it. With it, we account for the three different limiting factors, namely the agent’s needs (want), their own available goods for trade (affordability), or their trade partner’s available goods (availability). Once we’ve determined from this that a trade happens and how many goods are traded, we can put the pieces together such that the algorithm for the trading in our model currently looks like this:

if sally.pantry_fish < 1.5
    sally_wants_fish = 1.5 - sally.pantry_fish
    traded_fish = min(sally_wants_fish, sally.pantry_bread, richard.pantry_fish)
    # trade ratio of 1:1 means that the amounts of traded fish and bread are equal
    traded_bread = traded_fish

    sally.pantry_bread -= traded_bread
    sally.pantry_fish += traded_fish
    richard.pantry_fish -= traded_fish
    richard.pantry_bread += traded_bread
end

if richard.pantry_bread < 3.0
    richard_wants_bread = 3.0 - richard.pantry_bread
    traded_bread = min(richard_wants_bread, sally.pantry_bread, richard.pantry_fish)
    traded_fish = traded_bread

    sally.pantry_bread -= traded_bread
    sally.pantry_fish += traded_fish
    richard.pantry_fish -= traded_fish
    richard.pantry_bread += traded_bread
end

What this sequence mimics, is the process of Sally first determining how many fish she lacks, then asking for a few fish from Richard up until either him or her do not have any more resources to trade. After this first round of trading, Richard checks if he now already has enough bread in his pantry. When that isn’t the case, he asks Sally for the remaining necessary loaves.

After we’ve figured out how to put this straight-forward trading into code, we will start to balance things out by adjusting the ratio at which the two goods are traded. Since the regular need for bread is twice as high as that for fish, we will now assume a new trading ratio of one fish to two loaves of bread (in other words: one fish is worth twice as much as one bread). Formalising this takes only very slightly more effort from our side to account for the newly introduced ratio’s influence on the limiting factors:

if sally.pantry_fish < 1.5
    sally_wants_fish = 1.5 - sally.pantry_fish
    traded_fish = min(sally_wants_fish, sally.pantry_bread / 2, richard.pantry_fish)
    traded_bread = 2 * traded_fish

    sally.pantry_bread -= traded_bread
    sally.pantry_fish += traded_fish
    richard.pantry_fish -= traded_fish
    richard.pantry_bread += traded_bread
    end
end

if richard.pantry_bread < 3.0
    richard_wants_bread = 3.0 - richard.pantry_bread
    traded_bread = min(richard_wants_bread, sally.pantry_bread, richard.pantry_fish * 2)
    traded_fish = traded_bread / 2

    sally.pantry_bread -= traded_bread
    sally.pantry_fish += traded_fish
    richard.pantry_fish -= traded_fish
    richard.pantry_bread += traded_bread
end

And as easy as that we have created a fundamental trading process that allows our agents to exchange their produce with each other.

Summary and outlook

In this issue of the “Julia ♥ ABM” series, we have learned a lot of fundamentally important things for creating ABMs in Julia, specifically how to…

  • advance our model with for-loops,
  • iterate over agents to access and change their variables,
  • make random draws with the built-in rand function,
  • create a linear range of numbers with equal distance between its steps,
  • not panic when encountering an error message and instead use the provided information to our advantage,
  • use logic statements to control the flow of our model, and
  • find the minimum of a given set of numbers with the built-in min function.

That’s already quite a lot to take in at once, so we will leave it here for the time being. In the next issue, we make the work, eat, and trade subprocesses resuable by encapsulating them in functions and continue by bundling subprocesses into one cohesive loop representing a day in the life of our agents.

[1]: Interested in learning a bit more about what you can do with collections in Julia? Have a look at the section on collections in the Julia docs and specifically also at the subsection on iterable collections. But to be quite frank, Julia doesn’t really do anything special when it comes to dealing with iterables, so it’s likely able to do everything you would expect it to. This means that, among a lot of other things, you could collect only unique items, find their extrema, and reduce the collection via a binary operator.

[2]: A Vector is a specialised form of an Array which has just one dimension. Generally speaking, Vectors are subtypes of Arrays:

julia> Vector <: Array
true

julia> Vector{Int} <: Array{Int, 1}
true

which means that all the methods that work on Arrays will also work on Vectors but not necessarily vice versa. This is quite nice for us because we can be pretty sure that the interface to interact with an Array will be the same across its subtypes. This relieves us of a bit of mental burden when it comes to how to interact with various types. A Matrix, for example, is a multi-dimensional Array and we can retrieve its size in exactly the same way as we would do it for a Vector.

julia> v = [1, 2, 3]
3-element Vector{Int64}:
 1
 2
 3

julia> size(v)
(3,)

julia> a = [[1, 2, 3] [4, 5, 6]]
3×2 Matrix{Int64}:
 1  4
 2  5
 3  6

julia> size(a)
(3, 2)

We’ve already briefly talked about Julia’s typing system in Issue 1, so if you’ve missed or skipped that piece of information, feel free to check back there and read up on the basics.

[3]: There are quite a lot of different types of errors that Julia can confront us with. In this specific case, it was an InexactError which tells us that there is not an exact way to represent a fraction 1/2 or a decimal number 0.5 as an integer number. Other types of errors that you are very likely to encounter over the course of your Julia journey are UndefVarError (trying to reference a variable before defining it), MethodError (trying to use a function that is lacking a specific method for the combination of arguments that you passed), UndefKeywordError (trying to use a function which requires you to specify a value for one of its keyword arguments), and many more. If you’re interested in learning more about these, the Julia documentation also has a short section on errors and how to work with them.

Julia ♥ ABM #1: Starting from scratch

By: Frederik Banning

Re-posted from: https://forem.julialang.org/fbanning/issue-1-starting-from-scratch-1ibo

Let’s start from scratch. From this point onward, I’ll assume you’re already familiar with both agent-based modelling as a method[1] as well as basic familiarity with Julia as a programming language, i.e. you have it installed and understand the fundamentals of its syntax[2]. This series on agent-based modelling in Julia will be structured somewhat analogously to the regular modelling process that every agent-based modeller knows in some form or another: We start from the description of a situation, proceed with formulating a research question, and then identify relevant aspects. Afterwards we will formalise them as agent variables and model parameters and then put them into code.

You might wonder why we don’t immediately go to the coding part and just make some exemplary statements about agent behaviour and model evolution. Surely, that would be easier to just explain things, right? While certainly true at its core, I think there are two important points in favour of the approach chosen for this series:

  1. It’s harder to remember facts than it is to remember stories. Providing yet another bunch of blogposts about the technical details of Julia will likely just lead to readers skipping to the parts that they are currently having problems with. It would be somewhat redundant with well-written documentation. Instead, telling a story over the course of this series will hopefully increase the amount of things you can remember in the long run.[3]
  2. Modelling is always a subjective process and depends heavily on the choices made by the modeller. It is only an opinionated representation of the real world and its interdependencies. What I will present to you is just my interpretation of the situation. If you want to do things differently, you’re fully able to do so.

Model background

A day in the life

Our starting point will be a very simple example of two agents with heterogeneous properties that interact with each other on a regular basis. We will extend on this initial description over the course of the series, introducing new aspects, and making assumptions about how the world that they live in works.

Somehow we need to differentiate between our two agents, so we give them names: Sally and Richard. Their names, however, aren’t their only distinctive features. Humans are very diverse, hence we will proceed with a short description of each of them.

Richard is 33 years old and loves his job as a fisherman. Very early in the morning he happily rows out with his boat for a few hours of line fishing. Some days he’s lucky and catches multiple fish per hour (sometimes even up to three) while on other days it seems as if he’s cursed by Glaucus and the fish just won’t bite. In the end this means that Richard’s daily fishing efforts can take anything between one and five hours depending on his luck. After getting back home, he proceeds with different tasks for another one to three hours. Sometimes he takes care of maintenance work on his boat or fishing rod. When he doesn’t need to repair anything, he reads up on newly published reports regarding the overfishing of the seas and which types of fish can still be caught with good conscience. His fishing work is intense but he also has a decent amount of freely assignable time over the day which is less stressful and energy-sapping. In the evening he likes to knit sweaters and listen to records of his favourite Death Metal band.

Sally is 56 years old and loves her job as a baker. However, she has to deal with it all day, every day. It is a bit tiresome to get up early, prepare the dough, heat the oven, and so on but it allows her to produce a steady output of bread every day with the only limiting factor being the size of her oven which can fit up to 20 loaves at once. She has to do these steps, no matter how much bread she wants to produce, thus, her overall workload only changes slightly each day and her working times regularly end up between eight and ten hours, depending linearily on the amount of bread she bakes. When her work day is over, she likes to go trailrunning in the nearby mountains or to just enjoy having a Piña Colada in front of favourite bar (owned by Willy, a retired Barista from New York) near the oceanside.

As time goes by and they go about their work, their stocks in bread or fish increase and also decrease because they need to eat. In the short run, we may want to assume that one can live on bread and fish alone. But to be quite honest, nobody would like to eat only bread or fish every day. So both Sally and Richard have a deep desire to get some of the goods that only the other person produces.

Simply put, they have a regularly occurring need for both bread and fish that has to be satisfied. If this need is neglected over a longer period of time, their work productivity will decline. To avoid this undesirable situation, they take the opportunity to trade with each other every three days whenever they meet at the ocean side for a nightcap. When they do so, Richard trades with Sally for a few loaves of bread and she gets a few fish from Richard in return. As a starting point, let us assume that one fish is worth the same to them as one loaf of bread, i.e. they are willing to trade their goods in a 1:1 ratio, probably just because they know and like each other.

Question? Which question?

Although it is nice to just start coding and see whatever comes out of our endeavours, an agent-based model should aim to answer a specific question. We will now formulate one for the purpose of this exercise:

What is the minimum amount of work that Sally and Richard can do every day while still being able to satisfy their craving for fish and bread through mutual trade?

Given this question, we can now try to identify the aspects from the description above that are relevant to our planned ABM. Maybe first give this a try yourself before reading on. Which pieces of the provided information are interesting for us? How many agents will our model be comprised of? What are important environmental factors that have to be reflected in the model? Which information is incomplete and requires us to make assumptions?

One interpretation of the model’s background described above could look as follows:

  • Our model will contain two agents, Sally and Richard. What they do in their free time is unimportant for our base model for now and only their jobs and their working times are relevant.
  • How many hours they work per day determines the amount of food they need to eat each day.
    • Sally works 8-10 hours each day (medium exertion) and can freely decide to bake 1-20 loaves of bread each day.
    • Richard goes fishing for 1-5 hours (high exertion) and does other work for 1-3 hours (low exertion). During his fishing hours, Richard has an independent chance to catch 0-3 fish each hour.
  • Both agents have variables tracking their stock of fish and bread as well as their hunger for each good.
  • If their aggregate hunger level stays above a certain threshold for a prolonged period of time, agents reduce their work efforts, thus producing less bread and spending less time on fishing.
  • There is an opportunity at the end of every third day to trade their current stocks in fish and bread with each other.
  • Most of what are about processes and do not need to be stored as agent variables. We can therefore identify five distinct agent variables to keep track of: job, pantry_fish, pantry_bread, hunger_fish, hunger_bread.

Wait, wasn’t this supposed to be a Julia tutorial?

Phew. Those were a lot of words about plenty of things and so far not a single thought was wasted on actual code. To avoid losing some precious readers, this is probably as good a time as any to finally start talking about Julia.

A few preparations

Feel free to create a new folder for this series to code along to the examples (call it whatever you want, e.g. “julia-loves-abm”). Open your terminal, navigate to this folder and start Julia from there by running julia. Now execute the following:

julia> using Pkg

julia> Pkg.activate(".")
  Activating new project at `~/Code/julia-loves-abm`

Congratulations, you’ve just mastered the highly sought after skill of creating a fresh Julia project environment with the same name as the folder you’ve started the Julia instance from. You should activate this environment every time you continue working on this project as it will allow Julia to know which packages you have installed and which dependencies or versions should be respected.

As is so often the case in programming, there’s also another way to do the same thing. If you just type ] in the julia> prompt you will enter the built-in pkg> mode:

(@v1.7) pkg> 

This is very easy and approachable as you do not need to run using Pkg before doing this. The pkg> mode is just always available to you. It allows you to quickly use some commands like activate . (to change environment where dot refers to the current working directory) and status (to check installed packages and their versions):

(@v1.7) pkg> activate .
  Activating project at `~/Code/julia-loves-abm`

(julia-loves-abm) pkg> status
      Status `~/Code/julia-loves-abm/Project.toml` (empty project)

As you can see, we’ve switched from the base environment @v1.7 to a newly created one which is automatically assigned the name of our current working directory julia-loves-abm. The status command tells us that the project environment is currently empty, meaning that we haven’t added any extra functionality through Julia packages. For now, you can safely ignore most of the details about environments but just keep in mind that they exist as they will be of great use to us at a later stage.

Creating agents

Let’s remember our story from above. We have two people, so it seems straightforward to initialise two variables called sally and richard that represent them.[4] A little bit earlier we’ve looked at the relevant aspects of their everyday lives, telling us what to formalise as agent variables. There are a few unifying features about them, for example they each have a job which allows them to produce a certain kind of food (fish or bread respectively). Since it’s important for what happens in the model, it needs to be reflected in the code which we could simply do by using a String (a sequence of characters like letters and whitespace) describing their job:

julia> sally = "Baker"
"Baker"

julia> richard = "Fisher"
"Fisher"

Now that’s already an (admittedly pretty crude) representation of what our two agents are. Whenever we call one of the variables, its evaluation will tell us the agent’s job. But we also want to keep track of their stock of food and their hunger levels. This confronts us with a decision about a more appropriate data structure to use for storing all the different kinds of information about Richard and Sally.

Lined up

Maybe the simplest approach would be to use a simple collection like an Array or a Tuple with the values of the agent variables in it. Let’s assume that Richard and Sally are both not hungry in the beginning of our simulation and that they each have a starting stock of 10 fish as well as 10 loaves of bread.

julia> sally = ("Baker", 10, 10, 0, 0)
("Baker", 10, 10, 0, 0)

julia> richard = ("Fisher", 10, 10, 0, 0)
("Fisher", 10, 10, 0, 0)

It becomes immediately obvious that this approach is not very practical. While we can reason about what "Fisher" and "Baker" stands for, it is pretty hard to know what exactly the numbers mean without having the verbal description from above at hand.

Give me names

Indeed, it would be nice if we could label all the entries so that it is clear what they mean. We might want to opt for a NamedTuple instead which allows to provide names to the fields.

julia> sally = (job = "Baker", pantry_fish = 10, pantry_bread = 10, hunger_fish = 0, hunger_bread = 0)
(job = "Baker", pantry_fish = 10, pantry_bread = 10, hunger_fish = 0, hunger_bread = 0)

julia> richard = (job = "Fisher", pantry_fish = 10, pantry_bread = 10, hunger_fish = 0, hunger_bread = 0)
(job = "Fisher", pantry_fish = 10, pantry_bread = 10, hunger_fish = 0, hunger_bread = 0)

Ah, much better. Now we don’t have to remember the order of the data in the Tuple but can easily access the agent data by fieldnames:

julia> sally.job
"Baker"

julia> richard.pantry_bread
10

Again, sally.job is a String like in our initial approach of just assigning a string literal to each agent which described their job. However, richard.pantry_bread is also easily accessible now and shows us that he currently owns 10 loaves of bread.

To see all the fieldnames and the types of their values, we again call the typeof function on one of the agents:

julia> typeof(richard)
NamedTuple{(:job, :pantry_fish, :pantry_bread, :hunger_fish, :hunger_bread), Tuple{String, Int64, Int64, Int64, Int64}}

This tells us that richard is now depicted as a NamedTuple comprised of the fields (:job, :pantry_fish, :pantry_bread, :hunger_fish, :hunger_bread) and their values Tuple{String, Int64, Int64, Int64, Int64}. It is fundamentally the same as in the Tuple case before but enhanced with the information about which value means what.

Change is inevitable

But as we know, data in ABMs regularly change over the course of the simulation. And since a NamedTuple is immutable[5] by nature, this seems to be a really bad choice as a data structure for our agents. If we attempt to change one of the values, we get an error:

julia> sally.pantry_bread = 9
ERROR: setfield!: immutable struct of type NamedTuple cannot be changed
Stacktrace:
 [1] setproperty!(x::NamedTuple{(:job, :pantry_fish, :pantry_bread, :hunger_fish, :hunger_bread), Tuple{String, Int64, Int64, Int64, Int64}}, f::Symbol, v::Int64)
   @ Base ./Base.jl:43
 [2] top-level scope
   @ REPL[37]:1

So to change the value of any of the fields, we would need to reuse some of the old values and insert the new value in the appropriate field.

julia> sally = (sally..., pantry_bread = 9)
(job = "Baker", pantry_fish = 10, pantry_bread = 9, hunger_fish = 0, hunger_bread = 0)

While this approach does have its advantages (e.g. no accidental changes in agent-related data), it can quickly get a bit cumbersome to always overwrite the old variable with a new one. So it’s probably better to completely ditch the idea of using NamedTuples then.

The next best idea that might come to mind would be to use a Dict instead:

julia> sally = Dict(
           :job => "Baker", 
           :pantry_fish => 10, 
           :pantry_bread => 10, 
           :hunger_fish => 0, 
           :hunger_bread => 0
       )
Dict{Symbol, Any} with 5 entries:
  :pantry_fish   => 10
  :hunger_bread => 0
  :job          => "Baker"
  :hunger_fish  => 0
  :pantry_bread  => 10

julia> richard = Dict(
           :job => "Fisher", 
           :pantry_fish => 10, 
           :pantry_bread => 10, 
           :hunger_fish => 0, 
           :hunger_bread => 0
       )
Dict{Symbol, Any} with 5 entries:
  :pantry_fish   => 10
  :hunger_bread => 0
  :job          => "Fisher"
  :hunger_fish  => 0
  :pantry_bread  => 10

The keys and values of dictionaries can be comprised of just about any type that you can think of. Hence you have to take care to use Symbols as keys of the dictionary (or maybe Strings, if you prefer that), because unlike NamedTuples, Dicts don’t just automatically convert the fieldnames into Symbols (which always start with a colon :).

To retrieve data from our agents, we use the regular syntax for dictionaries:

julia> sally[:job]
"Baker"

julia> richard[:pantry_bread]
10

Changing the value of an agent variable is now as easy as writing:

julia> sally[:pantry_bread] -= 1
9

julia> sally
Dict{Symbol, Any} with 5 entries:
  :pantry_fish   => 10
  :hunger_bread => 0
  :job          => "Baker"
  :hunger_fish  => 0
  :pantry_bread  => 9

To see the current keys of a dictionary, you can just start typing the name of the dictionary followed by [: and then press Tab twice[6]:

julia> sally[:
:hunger_bread :hunger_fish   :job           :pantry_bread   :pantry_fish

Again, there are often multiple ways to do the same thing when coding and none of them is more or less correct than the other. Another way to retrieve the current set of keys of a dictionary is to call the keys function on it:

julia> keys(sally)
KeySet for a Dict{Symbol, Any} with 5 entries. Keys:
  :pantry_fish
  :hunger_bread
  :job
  :hunger_fish
  :pantry_bread

While Tab completion is a nice way to interactively explore the current state of your agents, having a KeySet also allows you to go through the agent variables one after another in a programmatic way. Which of these approaches you will choose heavily depends on the current use case you are facing. Generally though, it’s just good to have some options available.

Work smart, not hard

Luckily we currently only have two agents in our model, so it’s not really that problematic to create them one by one. In bigger models, however, we often want to create a relatively high number of agents and that could then quickly get a bit tedious to do one by one. Here’s a general word of advice about coding:

If you have to write something repeatedly, there’s probably a better way to do it. 🙂

Indeed, we can build a custom type for our agents, allowing us to predefine the structure of the data that we want to store. So instead of having to spell out the agent variables each and every time we want to create a new agent, we can just tell Julia to use our custom struct to lay out and label the data that we provide to it. The keyword to create such a data structure (also referred to as a composite type) is struct but we also have to prepend it with mutable to make sure that we are able to change its values (see the problem about immutability described above).

julia> mutable struct Agent
           job::String
           pantry_fish::Int
           pantry_bread::Int
           hunger_fish::Int
           hunger_bread::Int
       end

As you can see, we also had to explicitly define the types for each field of the struct as these are not automatically inferred like when creating a NamedTuple or a Dict. Without going into detail about the variety of types, we just use what we already know. From our previous attempt to create our agents as NamedTuples, we could see that the values we provided to it have been interpreted as String and Int64 types. In the definition of the struct above, we’ve simply used this knowledge and also changed Int64 for the more generalised form Int.[7]

We can now initialise Richard and Sally as two variables of our custom-made and highly specific Agent type:

julia> richard = Agent("Fisher",  10, 10, 0, 0)
Agent("Fisher", 10, 10, 0, 0)

julia> sally   = Agent( "Baker",  10, 10, 0, 0)
Agent("Baker", 10, 10, 0, 0)

This newly created type also allows us to directly access the agent variables. We can do this just like we did it in the case of a NamedTuple:

julia> richard.pantry_fish
10

julia> sally.pantry_bread
10

Changing the values is also possible and just as easy as it was in the case of using a Dict:

julia> richard.pantry_fish -= 1
9

julia> richard
Agent("Fisher", 9, 10, 0, 0)

One of the major downsides of defining our own composite type is that we have to restart our Julia session to change anything about it. Say we would like to add a new field to our agent struct that describes in one convenient number how satisfied they currently are with their life:

julia> mutable struct Agent
           job::String
           pantry_fish::Int
           pantry_bread::Int
           hunger_fish::Int
           hunger_bread::Int
           life_satisfaction::Int
       end
ERROR: invalid redefinition of constant Agent
Stacktrace:
 [1] top-level scope
   @ REPL[2]:1

This might seem inconvenient at first glance but in reality it doesn’t happen all too often. Indeed, we have been smart modellers and took enough time to first deliberate about what we actually want to model and which agent variables are important for answering the underlying question of our model.

Great! Now that we’ve settled on a convenient way how to represent our agents in code, our next step will be dealing with the tasks that our agents do every day and how they affect the filling of their pantries and their hunger levels.

[1]: Explaining agent-based modelling in detail is way out of the scope of this series. If you are unfamiliar with the method but generally interested in learning more about it, you may want to start reading this text book or this introductory article or watch this video series by Complexity Explorer.

[2]: Should you find yourself wondering how to install Julia, it’s really as simple as downloading it from the Julia language website and running the installer. There are also other ways to setup your Julia installation but the aforementioned method works just as fine as anything else. Just to provide an example, my preferred way is Juliaup as a platform-independent tool to manage multiple Julia versions and keep them up to date. Once you’re all set up, you might want to check out the Getting Started section of the official Julia documentation or follow an introductory video course to learn about syntax and basic usage. Done that? Let’s get back to ABM stuff then. 🙂

[3]: I sincerely believe that teaching by telling a simple story is very approachable for most people. Of course there’s room for everything on the world wide web and no approach to learning is inherently better or worse than any other. It very much depends on your preferred style of learning, your previous knowledge, and maybe even your current state of mood. For example, if you already know how to work with Julia and are already very knowledgeable in the arcane arts of agent-based modelling, you might as well just skip this introductory series and go straight to the documentation of Agents.jl and read/work through the well-written tutorial and examples.

[4]: It’s the Julian way to use lower case names with underscores for our variables. This is often referred to as snake_case. There are some more style recommendations that established themselves over time in the Julia community which we will try to adhere to as closely as possible. If you want to read up on the idiomatic Julia coding style, have a look at the official Julia style guide. In the end, however, it doesn’t matter too much as long as you don’t have to share your code with others. In the latter case it is highly recommended to try and stick to a unified coding style as it allows others (not only colleagues but maybe even strangers at some point) to more easily read and understand your code and potentially comment on it, extend it, fix it, et cetera.

[5]: Immutability means that a variable cannot be changed after its creation. This applies to both its value(s) and its composition. Here’s the exemplary case of trying to modify one of the elements in a Tuple and attempting to add a new element to it:

julia> t = (1,2,3)
(1, 2, 3)

julia> t[1] = 1
ERROR: MethodError: no method matching setindex!(::Tuple{Int64, Int64, Int64}, ::Int64, ::Int64)
Stacktrace:
[1] top-level scope
@ REPL[74]:1

julia> t[end+1] = t[end] + 1
ERROR: MethodError: no method matching setindex!(::Tuple{Int64, Int64, Int64}, ::Int64, ::Int64)
Stacktrace:
[1] top-level scope
@ REPL[73]:1

However, this does not mean that we cannot redefine the variable t to refer to something else:

julia> t = 1
1

[6]: This Tab completion also works with a lot of other things, for example NamedTuples. Just write its variable name followed by a single dot:

julia> nt = (a = 1, b = 2)
(a = 1, b = 2)

julia> nt.
a b

[7]: Although definitely not necessary at this stage, you might be interested in what all these types mean. Let’s dive a bit deeper. You might wonder why it is called Int64 and not just Number, Real or Integer. Simply put, every Integer is a Real but not every Real is an Integer. Julia provides us with an easy way to find out about this type hierarchy:

julia> supertypes(Integer)
(Integer, Real, Number, Any)

To go up through the hierarchy, you can read this tuple of types from left to right. If you want to explore it in the opposite direction, there’s also a way to do this:

julia> subtypes(Real)
4-element Vector{Any}:
AbstractFloat
AbstractIrrational
Integer
Rational

As you can see, Integer is necessarily a subtype of Real. We can also programmatically test this with a specific syntax in Julia by writing:

julia> Integer <: Real
true

Now an Int64 is a specific subtype of a signed Integer number with a size of 64 bit.

julia> subtypes(Integer)
3-element Vector{Any}:
Bool
Signed
Unsigned

Being Signed means that the Integer uses a bit of memory to store its mathematical sign. This means the resulting number can take both positive and negative values.

julia> subtypes(Signed)
6-element Vector{Any}:
BigInt
Int128
Int16
Int32
Int64
Int8

There’s no general type called Int in here but only types with predetermined size, e.g. 8 or 64 bit. If we use Int as a type for our Agent struct, Julia asserts that we want the possible size of the Int to be as big as possible. Thus, it is automatically determined by the underlying architecture of our computer (most modern computers are built on 64 bit). When we create an instance of our Agent struct, those fields typed as Int will indeed be of type Int64. This is nice to take into account for the hypothetical case of somebody with a 32 bit computer trying to run our model which is then possible precisely because we didn’t restrict the size of the Int too much (e.g. to always use Int64).

And while all of this is actually very interesting, we luckily don’t have to worry about it in greater detail for now. If you still want to read more about Julia’s type system, have a look at the well-written section of the official docs. Let’s get back to work on our ABM. 🙂