From 3a5c0469641d5039c7735a019c618e0bc431d638 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Thu, 8 Aug 2024 21:30:45 +0100 Subject: [PATCH 01/18] Added early draft of labor model example. --- docs/literate/labor_model.jl | 326 +++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/literate/labor_model.jl diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl new file mode 100644 index 0000000..faee916 --- /dev/null +++ b/docs/literate/labor_model.jl @@ -0,0 +1,326 @@ +# # Labour Market Search and Matching +# ## Set-up +# +# First, we load the necessary libraries from AlgebraicJulia and elsewhere. + +using AlgebraicABMs, Catlab, AlgebraicRewriting, Random, Test, Plots, DataFrames, DataMigrations +import Distributions: Exponential, LogNormal +using AlgebraicRewriting: Migrate +using Pipe: @pipe + +ENV["JULIA_DEBUG"] = "AlgebraicABMs"; # hide +Random.seed!(123); # hide + +# We define our Schema "from scratch" by specifying the types of objects in our model and the mappings +# (or "homomorphisms") between them. + +@present FirmDemographySchema(FreeSchema) begin + Person::Ob + Job::Ob + Firm::Ob + Vacancy::Ob + + employee::Hom(Job, Person) + employer::Hom(Job, Firm) + advertised_by::Hom(Vacancy, Firm) +end + + +# We then create a Julia Type for instances of this schema. Re-running this line of code in the same REPL +# session will throw an error. +@acset_type FirmDemographyAge(FirmDemographySchema); + +# Having defined the schema, we will build our model(s) by constructing particular +# instances of this schema, and the transformations between them. This can be done +# by figuring out how our desired instance would be constructed in memory, and adding +# the parts "by hand" using the imperative interface provided in [...]. It's more +# convenient to start taking the "categorical" perspective, here, and use a notion of +# element based on the transformations between instances, rather than the implementation +# "under the hood". We can specify some basic elements of our instances by taking the +# freely constucted minimal example of each of our entities +# ("objects" - but not in the sense of Object-Oriented Programming). +# +# The "representable" Person and Firm are what we would expect - single instances of +# the relevant entity, and nothing else. +P = representable(FirmDemographyAge, :Person) +F = representable(FirmDemographyAge, :Firm) + +# The representable vacancy, however, comes with a function defined on it, and that +# function needs a target. To make a well-defined ACSet conforming to our schema, +# the representable Vacancy has to have both a vacancy and a firm, with a function +# mapping the former to the latter. +V = representable(FirmDemographyAge, :Vacancy) + +# The representable Job, in turn, has two functions pointing from it, so it has to +# include both a Person and a Firm. +J = representable(FirmDemographyAge, :Job) + +# Joining these instances together in the obvious way is known as taking their coproduct, +# and has been implemented using the "oplus" symbol. + +P⊕F + +P⊕F⊕V⊕J + +# The coproduct has a "unit", defined here using the imperative syntax, consisting of +# the "empty" instance of that schema. We follow convention by denoting it with the +# letter O, and define it using the imperative syntax. + +O = @acset FirmDemographyAge begin end + +# Instances that can be formed using oplus and the generic members of the objects are +# known as "coproducts of representables". One advantage of constructing our instances +# in this way is that we always know we're dealing with well-formed ACSets, which prevents +# cryptic errors further down the line. However we may want to be able to express +# situations where the same entity pays more than one role in an instance. We can still +# do this by constructing a free ACSet on a number of generic objects subject to equality +# constraints. The macro @acset_colim allows us to do this, if we give it a cached collection +# of all of the representables for our schema. + +yF = yoneda_cache(FirmDemographyAge) + + +employer_also_hiring = @acset_colim yF begin + j1::Job + v1::Vacancy + employer(j1) == advertised_by(v1) +end + +# Now that we are able to construct instances of our schema - which we can think of as +# states of (part of) the world at given points in time - we can define the types of +# change which can occur in our model. These will take the form of ACSet rewriting rules, +# which are a generalization of graph rewriting rules (since a graph can be defined as a +# relatively simple ACSet, or indeed CSet). We will use Double Pushout and Single Pushout +# rewriting, which both take an input pattern of the form L <- I -> R, where L is the input +# pattern to be matched in the existing state of the world ("Before"), R is the output +# pattern that should exist going forward ("After") and I is the pattern of items in the +# input match which should carry over into the output match. +# +# The rewrite rule is specified using a pair of ACSet transformations (I -> L and I -> R) +# of ACSets sharing the same schema, where both transformations have the same ACSet as their +# domain. While the ACSet Transformations can be built using the machinery available in [...], +# we will find in many cases that the transformations (also known as homomorphisms) between +# two relatively simple instances will be unique, so we only need to specify the domain +# and codomain ACSets and rely on homomorphism search to find the mapping we intend. In +# the case where the domain and codomain are the same, such as where the input pattern +# persists in its entirety, we can specify the "transformation" mapping everything to +# itself using id(). + +# One of the events that can occur in our model is that any firm which exists can post +# a vacancy. The input pattern is the representable firm, the firm persists, and in +# the output pattern, it has become part of a connected vacancy-firm pair. + +post_vacancy = Rule{:DPO}( + id(F), + homomorphism(F, V) +) + +withdraw_vacancy = Rule{:DPO}( + homomorphism(F, V), + id(F) +) + + +# People can appear out of nothing ... +birth = Rule{:DPO}( + id(O), + homomorphism(O, P) +) + +# ... and unto nothing they shall return. This rule uses Single Pushout rewriting, +# because we want to eliminate any jobs which point to the now-defunct person. +death = Rule{:SPO}( + homomorphism(O, P), + id(O) +) + +# Firms come and go in the same way, like Soho Italian restaurants in a Douglas Adams novel. + +firm_entry = Rule{:DPO}( + id(O), + homomorphism(O, F) +) + +rewrite(firm_entry, O) + +firm_exit = Rule{:SPO}( + homomorphism(O, F), + id(O) +) + +# To make an ABM, we wrap a rule in a named container with a probability distribution over +# how long it takes to "fire". A model is created from a list of these wrapped rules. To +# demonstrate, we make a trivial model to show a population converging to a steady state based +# on constant birth and mortality hazards. + +birth_abm_rule = ABMRule(:Birth, birth, ContinuousHazard(1/50)) +death_abm_rule = ABMRule(:Death, death, ContinuousHazard(1)) + +people_only_abm = ABM([birth_abm_rule, death_abm_rule]) + +# We'll need an initial state to run our ABM, in this case simply a number of people. +# We can form this using either of the interfaces for producing instances of our schema. + +start = O +for _ in 1:100 + start = start ⊕ P +end + +imperatively_assembled_start = @acset FirmDemographyAge begin end +for _ in 1:100 + add_part!(imperatively_assembled_start, :Person) +end + +@test start == imperatively_assembled_start + +# We now have everything we need to run an ABM. + +results = run!(people_only_abm, start; maxtime=100) + +# Most of us will be a little more comfortable handling the resulting ABM trajectory +# in the form of a dataframe. + +function unpack_results(results::AlgebraicABMs.ABMs.Traj) + event_times = cat([0.0], [e[1] for e in results.events]; dims=1) + states_of_the_world = cat([results.init], [codom(right(h)) for h in results.hist], dims=1) + DataFrame(time = event_times, state = states_of_the_world) +end + +function obj_counts(abm_state::FirmDemographyAge) + [k => length(v) + for (k, v) in + zip(keys(abm_state.parts), abm_state.parts) + ] |> NamedTuple +end + +function full_df(results::AlgebraicABMs.ABMs.Traj) + state_time_df = unpack_results(results) + obj_counts_df = obj_counts.(state_time_df.state) |> DataFrame + hcat(state_time_df, obj_counts_df) +end + +function plot_full_df(df::DataFrame) + Plots.plot(df.time, [df.Person, df.Firm, df.Job, df.Vacancy]; labels=["Person" "Firm" "Job" "Vacancy"]) +end + +full_df(results) + +# If we run the same ABM on an initial state which has Firms in it, they just sit there, untouched by +# either of the ABM rules. + +run!(people_only_abm, start⊕F⊕F; maxtime=100) |> + full_df |> + plot_full_df + +# To give the firms their own dynamics, we include two more ABM rules, using the firm entry and exit +# patterns defined above. We can do the same to generate a steady state of vacancies. + +people_and_firms_abm = ABM( + [people_only_abm.rules; [ + ABMRule(:FirmEntry, firm_entry, ContinuousHazard(1/10)), + ABMRule(:FirmExit, firm_exit, ContinuousHazard(1)), + ABMRule(:PostVacancy, post_vacancy, ContinuousHazard(1)), + ABMRule(:WithdrawVacancy, withdraw_vacancy, ContinuousHazard(1)) + ] + ] +) + +run!(people_and_firms_abm, start⊕F⊕F; maxtime=100) |> + full_df |> + plot_full_df + +# Hiring, however, presents a new challenge. We want to convert a +# person and vacancy-firm pair to a person and firm connected by a job, with the person +# and firm staying the same, and the vacancy disappearing. But we don't want to keep +# adding jobs to the same person indefinitely - in this case, we'll abstract from reality a little, +# and pretend that people can have at most one job. We enforce this using an "application condition" +# attached to our rule. This is formed by specifying a further homomorphism from R to another pattern, +# and specifying whether that further match is required or forbidden (false). In this case, we +# want to rule out situations where the Person and Firm-Vacancy pair are part of a larger pattern +# consisting of a Job-Person-Firm triple and a Firm-Vacancy pair - i.e. situations where the person we +# are matching already has a job. + +hire = Rule{:DPO}( + homomorphism(P⊕F, P⊕V), + homomorphism(P⊕F, J); + ac = [ + AppCond(homomorphism(P⊕V, J⊕V), false)#, # Limit one job per person + ] +) + + +# Separations occur when we match a connected Person-Firm-Job triple and the person +# and firm continue separately but the job disappears. +fire = Rule{:DPO}( + homomorphism(P⊕F, J), + id(P⊕F) +) + +# We assume at first that hiring and firing occur at constant rates. +constant_job_dynamics_abm = ABM( + [ + people_and_firms_abm.rules; + [ + ABMRule(:Hire, hire, ContinuousHazard(1)), + ABMRule(:Fire, fire, ContinuousHazard(1)) + ] + ] +) + + +# In particular, we care about how many people don't have jobs. +function number_unemployed(state_of_world::FirmDemographyAge) + length([ + p for p in state_of_world.parts.Person + if length(incident(state_of_world, p, :employee)) == 0 + ]) +end + +@pipe run!(constant_job_dynamics_abm, start⊕F⊕F; maxtime=100) |> + full_df(_) |> + transform(_, [:state, :Person] => ByRow((s, p) -> number_unemployed(s)/p * 100) => :unemployment_rate) |> + (x -> Plots.plot(x.time, x.unemployment_rate)) + +# Following the literature, we measure the interplay of supply and demand +# in the labour market using this "market tightness" ratio. + +function market_tightness(state_of_world::FirmDemographyAge) + length(state_of_world.parts.Vacancy)/number_unemployed(state_of_world) +end + +# This may come in handy for defining distributions +function logistic_function(L, k) + (x -> L / (1 + exp(-k*x))) +end + +# In order to make our model a little more interesting, we can make the likelihood of a worker +# filling a vacancy depend on supply and demand via a function of market tightness, as defined +# above. We define a time-independent function of the match which returns a distribution over +# firing times, to replace the (constant) function defined by ContinuousHazard. We can then use +# that as the firing distribution for our Hire rule. + +dependent_match_function = ( + m -> begin + state_of_world = codom(m) + v_over_u = market_tightness(state_of_world) + q = logistic_function(2, 1)(v_over_u) # decreasing function, elasticity between 0 and -1 + Exponential(1/q) + end +) |> ClosureState + +δ_u = 10*0.01600000001 +δ_v = 10*0.01200000001 + +full_abm = ABM([ + [r for r in constant_job_dynamics_abm.rules if r.name != :Hire]; + [ABMRule(:Hire, hire, dependent_match_function)] +]) + +@pipe run!(full_abm, start⊕F⊕F; maxtime=100) |> + full_df(_) |> + transform(_, [:state, :Person] => ByRow((s, p) -> number_unemployed(s)/p * 100) => :unemployment_rate) |> + transform(_, :state => ByRow((s) -> market_tightness(s)) => :market_tightness) |> + transform(_, :market_tightness => ByRow(x -> log(x)) => :ln_market_tightness) |> + transform(_, :unemployment_rate => ByRow(x -> log(x)) => :ln_unemployment_rate) |> + (x -> Plots.plot(x.unemployment_rate, x.market_tightness))(_) + \ No newline at end of file From 26a197b95f6cd1873dc969f2bbcc2ee60fa80040 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Thu, 22 Aug 2024 04:37:17 +0100 Subject: [PATCH 02/18] Got docs building under Linux, not currently running any simulations. --- docs/Project.toml | 3 + docs/literate/labor_model.jl | 121 +++++++++++++++++------------------ docs/make.jl | 1 + 3 files changed, 62 insertions(+), 63 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 0e8fff9..29105f6 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -4,6 +4,7 @@ AlgebraicPetri = "4f99eebe-17bf-4e98-b6a1-2c4f205a959b" AlgebraicRewriting = "725a01d3-f174-5bbd-84e1-b9417bad95d9" CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Catlab = "134e5e36-593f-5add-ad60-77f754baafbe" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" DataMigrations = "0c4ad18d-0c49-4bc2-90d5-5bca8f00d6ae" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" @@ -11,4 +12,6 @@ Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589" Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" +Pipe = "b98c9c47-44ae-5843-9183-064241ee97a0" +Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" StructEquality = "6ec83bb0-ed9f-11e9-3b4c-2b04cb4e219c" diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index faee916..171b194 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -4,6 +4,7 @@ # First, we load the necessary libraries from AlgebraicJulia and elsewhere. using AlgebraicABMs, Catlab, AlgebraicRewriting, Random, Test, Plots, DataFrames, DataMigrations +using Catlab: to_graphviz import Distributions: Exponential, LogNormal using AlgebraicRewriting: Migrate using Pipe: @pipe @@ -23,7 +24,9 @@ Random.seed!(123); # hide employee::Hom(Job, Person) employer::Hom(Job, Firm) advertised_by::Hom(Vacancy, Firm) -end +end; + +to_graphviz(FirmDemographySchema) # hide # We then create a Julia Type for instances of this schema. Re-running this line of code in the same REPL @@ -42,31 +45,38 @@ end # # The "representable" Person and Firm are what we would expect - single instances of # the relevant entity, and nothing else. -P = representable(FirmDemographyAge, :Person) -F = representable(FirmDemographyAge, :Firm) +P = representable(FirmDemographyAge, :Person); +P |> elements |> to_graphviz # hide +# +F = representable(FirmDemographyAge, :Firm); +F |> elements |> to_graphviz # hide + # The representable vacancy, however, comes with a function defined on it, and that # function needs a target. To make a well-defined ACSet conforming to our schema, # the representable Vacancy has to have both a vacancy and a firm, with a function # mapping the former to the latter. -V = representable(FirmDemographyAge, :Vacancy) +V = representable(FirmDemographyAge, :Vacancy); +V |> elements |> to_graphviz # hide # The representable Job, in turn, has two functions pointing from it, so it has to # include both a Person and a Firm. -J = representable(FirmDemographyAge, :Job) +J = representable(FirmDemographyAge, :Job); +J |> elements |> to_graphviz # hide # Joining these instances together in the obvious way is known as taking their coproduct, # and has been implemented using the "oplus" symbol. -P⊕F +P⊕F |> elements |> to_graphviz -P⊕F⊕V⊕J +P⊕F⊕V⊕J |> elements |> to_graphviz # The coproduct has a "unit", defined here using the imperative syntax, consisting of # the "empty" instance of that schema. We follow convention by denoting it with the # letter O, and define it using the imperative syntax. -O = @acset FirmDemographyAge begin end +O = @acset FirmDemographyAge begin end; +O |> elements |> to_graphviz # hide # Instances that can be formed using oplus and the generic members of the objects are # known as "coproducts of representables". One advantage of constructing our instances @@ -77,14 +87,16 @@ O = @acset FirmDemographyAge begin end # constraints. The macro @acset_colim allows us to do this, if we give it a cached collection # of all of the representables for our schema. -yF = yoneda_cache(FirmDemographyAge) +yF = yoneda_cache(FirmDemographyAge); employer_also_hiring = @acset_colim yF begin j1::Job v1::Vacancy employer(j1) == advertised_by(v1) -end +end; + +employer_also_hiring |> elements |> to_graphviz # hide # Now that we are able to construct instances of our schema - which we can think of as # states of (part of) the world at given points in time - we can define the types of @@ -113,69 +125,69 @@ end post_vacancy = Rule{:DPO}( id(F), homomorphism(F, V) -) +); withdraw_vacancy = Rule{:DPO}( homomorphism(F, V), id(F) -) +); # People can appear out of nothing ... birth = Rule{:DPO}( id(O), homomorphism(O, P) -) +); # ... and unto nothing they shall return. This rule uses Single Pushout rewriting, # because we want to eliminate any jobs which point to the now-defunct person. death = Rule{:SPO}( homomorphism(O, P), id(O) -) +); # Firms come and go in the same way, like Soho Italian restaurants in a Douglas Adams novel. firm_entry = Rule{:DPO}( id(O), homomorphism(O, F) -) +); -rewrite(firm_entry, O) +rewrite(firm_entry, O) |> elements |> to_graphviz firm_exit = Rule{:SPO}( homomorphism(O, F), id(O) -) +); # To make an ABM, we wrap a rule in a named container with a probability distribution over # how long it takes to "fire". A model is created from a list of these wrapped rules. To # demonstrate, we make a trivial model to show a population converging to a steady state based # on constant birth and mortality hazards. -birth_abm_rule = ABMRule(:Birth, birth, ContinuousHazard(1/50)) -death_abm_rule = ABMRule(:Death, death, ContinuousHazard(1)) +birth_abm_rule = ABMRule(:Birth, birth, ContinuousHazard(1/50)); +death_abm_rule = ABMRule(:Death, death, ContinuousHazard(1)); -people_only_abm = ABM([birth_abm_rule, death_abm_rule]) +people_only_abm = ABM([birth_abm_rule, death_abm_rule]); # We'll need an initial state to run our ABM, in this case simply a number of people. # We can form this using either of the interfaces for producing instances of our schema. -start = O -for _ in 1:100 - start = start ⊕ P -end +# start_acset = O +# for _ in 1:100 +# start_acset = start_acset ⊕ P +# end -imperatively_assembled_start = @acset FirmDemographyAge begin end -for _ in 1:100 - add_part!(imperatively_assembled_start, :Person) -end +# imperatively_assembled_start = @acset FirmDemographyAge begin end; +# for _ in 1:100 +# add_part!(imperatively_assembled_start, :Person) +# end; -@test start == imperatively_assembled_start +# @test start_acset == imperatively_assembled_start # We now have everything we need to run an ABM. -results = run!(people_only_abm, start; maxtime=100) +# results = run!(people_only_abm, start; maxtime=100) # Most of us will be a little more comfortable handling the resulting ABM trajectory # in the form of a dataframe. @@ -184,33 +196,30 @@ function unpack_results(results::AlgebraicABMs.ABMs.Traj) event_times = cat([0.0], [e[1] for e in results.events]; dims=1) states_of_the_world = cat([results.init], [codom(right(h)) for h in results.hist], dims=1) DataFrame(time = event_times, state = states_of_the_world) -end +end; function obj_counts(abm_state::FirmDemographyAge) [k => length(v) for (k, v) in zip(keys(abm_state.parts), abm_state.parts) ] |> NamedTuple -end +end; function full_df(results::AlgebraicABMs.ABMs.Traj) state_time_df = unpack_results(results) obj_counts_df = obj_counts.(state_time_df.state) |> DataFrame hcat(state_time_df, obj_counts_df) -end +end; function plot_full_df(df::DataFrame) Plots.plot(df.time, [df.Person, df.Firm, df.Job, df.Vacancy]; labels=["Person" "Firm" "Job" "Vacancy"]) -end +end; -full_df(results) +# full_df(results) # If we run the same ABM on an initial state which has Firms in it, they just sit there, untouched by # either of the ABM rules. -run!(people_only_abm, start⊕F⊕F; maxtime=100) |> - full_df |> - plot_full_df # To give the firms their own dynamics, we include two more ABM rules, using the firm entry and exit # patterns defined above. We can do the same to generate a steady state of vacancies. @@ -223,11 +232,8 @@ people_and_firms_abm = ABM( ABMRule(:WithdrawVacancy, withdraw_vacancy, ContinuousHazard(1)) ] ] -) +); -run!(people_and_firms_abm, start⊕F⊕F; maxtime=100) |> - full_df |> - plot_full_df # Hiring, however, presents a new challenge. We want to convert a # person and vacancy-firm pair to a person and firm connected by a job, with the person @@ -246,7 +252,7 @@ hire = Rule{:DPO}( ac = [ AppCond(homomorphism(P⊕V, J⊕V), false)#, # Limit one job per person ] -) +); # Separations occur when we match a connected Person-Firm-Job triple and the person @@ -254,7 +260,7 @@ hire = Rule{:DPO}( fire = Rule{:DPO}( homomorphism(P⊕F, J), id(P⊕F) -) +); # We assume at first that hiring and firing occur at constant rates. constant_job_dynamics_abm = ABM( @@ -265,7 +271,7 @@ constant_job_dynamics_abm = ABM( ABMRule(:Fire, fire, ContinuousHazard(1)) ] ] -) +); # In particular, we care about how many people don't have jobs. @@ -274,24 +280,20 @@ function number_unemployed(state_of_world::FirmDemographyAge) p for p in state_of_world.parts.Person if length(incident(state_of_world, p, :employee)) == 0 ]) -end +end; -@pipe run!(constant_job_dynamics_abm, start⊕F⊕F; maxtime=100) |> - full_df(_) |> - transform(_, [:state, :Person] => ByRow((s, p) -> number_unemployed(s)/p * 100) => :unemployment_rate) |> - (x -> Plots.plot(x.time, x.unemployment_rate)) # Following the literature, we measure the interplay of supply and demand # in the labour market using this "market tightness" ratio. function market_tightness(state_of_world::FirmDemographyAge) length(state_of_world.parts.Vacancy)/number_unemployed(state_of_world) -end +end; # This may come in handy for defining distributions function logistic_function(L, k) (x -> L / (1 + exp(-k*x))) -end +end; # In order to make our model a little more interesting, we can make the likelihood of a worker # filling a vacancy depend on supply and demand via a function of market tightness, as defined @@ -306,21 +308,14 @@ dependent_match_function = ( q = logistic_function(2, 1)(v_over_u) # decreasing function, elasticity between 0 and -1 Exponential(1/q) end -) |> ClosureState +) |> ClosureState; -δ_u = 10*0.01600000001 -δ_v = 10*0.01200000001 +δ_u = 10*0.01600000001; +δ_v = 10*0.01200000001; full_abm = ABM([ [r for r in constant_job_dynamics_abm.rules if r.name != :Hire]; [ABMRule(:Hire, hire, dependent_match_function)] ]) -@pipe run!(full_abm, start⊕F⊕F; maxtime=100) |> - full_df(_) |> - transform(_, [:state, :Person] => ByRow((s, p) -> number_unemployed(s)/p * 100) => :unemployment_rate) |> - transform(_, :state => ByRow((s) -> market_tightness(s)) => :market_tightness) |> - transform(_, :market_tightness => ByRow(x -> log(x)) => :ln_market_tightness) |> - transform(_, :unemployment_rate => ByRow(x -> log(x)) => :ln_unemployment_rate) |> - (x -> Plots.plot(x.unemployment_rate, x.market_tightness))(_) - \ No newline at end of file + diff --git a/docs/make.jl b/docs/make.jl index d2a57c8..d2111e6 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -57,6 +57,7 @@ makedocs( "generated/sir_petri.md", "generated/game_of_life.md", "generated/lotka_volterra.md", + "generated/labor_model.md" ], "Library Reference"=>"api.md", ] From e699eb83eb7b822304b28d25ee35efc67460b75d Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Thu, 22 Aug 2024 23:12:10 +0100 Subject: [PATCH 03/18] Responded to first batch of Kris's edits. --- docs/literate/labor_model.jl | 46 ++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 171b194..3a68b0f 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -1,4 +1,4 @@ -# # Labour Market Search and Matching + # # Labor Market Search and Matching # ## Set-up # # First, we load the necessary libraries from AlgebraicJulia and elsewhere. @@ -6,9 +6,9 @@ using AlgebraicABMs, Catlab, AlgebraicRewriting, Random, Test, Plots, DataFrames, DataMigrations using Catlab: to_graphviz import Distributions: Exponential, LogNormal -using AlgebraicRewriting: Migrate using Pipe: @pipe + ENV["JULIA_DEBUG"] = "AlgebraicABMs"; # hide Random.seed!(123); # hide @@ -29,8 +29,8 @@ end; to_graphviz(FirmDemographySchema) # hide -# We then create a Julia Type for instances of this schema. Re-running this line of code in the same REPL -# session will throw an error. +# We then create a Julia Type `FirmDemographyAge` for instances of this schema. Re-running this line of code in the same REPL +# session after making any changes to the definition of the schema will throw an error. @acset_type FirmDemographyAge(FirmDemographySchema); # Having defined the schema, we will build our model(s) by constructing particular @@ -41,7 +41,9 @@ to_graphviz(FirmDemographySchema) # hide # element based on the transformations between instances, rather than the implementation # "under the hood". We can specify some basic elements of our instances by taking the # freely constucted minimal example of each of our entities -# ("objects" - but not in the sense of Object-Oriented Programming). +# ("objects" - but not in the sense of Object-Oriented Programming). This creates a "generic" +# instance of the chosen entity, which in particular doesn't force any two objects to be the +# same when they don't have to be. # # The "representable" Person and Firm are what we would expect - single instances of # the relevant entity, and nothing else. @@ -52,10 +54,9 @@ F = representable(FirmDemographyAge, :Firm); F |> elements |> to_graphviz # hide -# The representable vacancy, however, comes with a function defined on it, and that -# function needs a target. To make a well-defined ACSet conforming to our schema, -# the representable Vacancy has to have both a vacancy and a firm, with a function -# mapping the former to the latter. +# The representable vacancy, however, can't be just a single vacancy and nothing else. +# To make a well-defined ACSet conforming to our schema, the representable Vacancy has +# to have both a vacancy and a firm, with a function mapping the former to the latter. V = representable(FirmDemographyAge, :Vacancy); V |> elements |> to_graphviz # hide @@ -64,28 +65,31 @@ V |> elements |> to_graphviz # hide J = representable(FirmDemographyAge, :Job); J |> elements |> to_graphviz # hide -# Joining these instances together in the obvious way is known as taking their coproduct, -# and has been implemented using the "oplus" symbol. - -P⊕F |> elements |> to_graphviz +# Joining these instances together by placing them "side by side" (i.e. not forcing any +# entities from different ACSets to be equal to each other in the result) is known as +# taking their coproduct, and has been implemented using the "oplus" symbol - $\oplus$. -P⊕F⊕V⊕J |> elements |> to_graphviz +generic_person_sidebyside_generic_firm = P⊕F +generic_person_sidebyside_generic_firm |> elements |> to_graphviz #hide +# +one_of_each_generic_thing = P⊕F⊕V⊕J +one_of_each_generic_thing |> elements |> to_graphviz #hide # The coproduct has a "unit", defined here using the imperative syntax, consisting of # the "empty" instance of that schema. We follow convention by denoting it with the # letter O, and define it using the imperative syntax. -O = @acset FirmDemographyAge begin end; +O = FirmDemographyAge(); O |> elements |> to_graphviz # hide # Instances that can be formed using oplus and the generic members of the objects are # known as "coproducts of representables". One advantage of constructing our instances # in this way is that we always know we're dealing with well-formed ACSets, which prevents # cryptic errors further down the line. However we may want to be able to express -# situations where the same entity pays more than one role in an instance. We can still +# situations where the same entity plays more than one role in an instance. We can still # do this by constructing a free ACSet on a number of generic objects subject to equality -# constraints. The macro @acset_colim allows us to do this, if we give it a cached collection -# of all of the representables for our schema. +# constraints (known as a "colimit of representables"). The macro `@acset_colim` allows +# us to do this, if we give it a cached collection of all of the representables for our schema. yF = yoneda_cache(FirmDemographyAge); @@ -103,12 +107,12 @@ employer_also_hiring |> elements |> to_graphviz # hide # change which can occur in our model. These will take the form of ACSet rewriting rules, # which are a generalization of graph rewriting rules (since a graph can be defined as a # relatively simple ACSet, or indeed CSet). We will use Double Pushout and Single Pushout -# rewriting, which both take an input pattern of the form L <- I -> R, where L is the input +# rewriting, which both take an input pattern of the form L ↢ I → R , where L is the input # pattern to be matched in the existing state of the world ("Before"), R is the output # pattern that should exist going forward ("After") and I is the pattern of items in the # input match which should carry over into the output match. # -# The rewrite rule is specified using a pair of ACSet transformations (I -> L and I -> R) +# The rewrite rule is specified using a pair of ACSet transformations (I $\rightarrowtail$ L and I → R) # of ACSets sharing the same schema, where both transformations have the same ACSet as their # domain. While the ACSet Transformations can be built using the machinery available in [...], # we will find in many cases that the transformations (also known as homomorphisms) between @@ -250,7 +254,7 @@ hire = Rule{:DPO}( homomorphism(P⊕F, P⊕V), homomorphism(P⊕F, J); ac = [ - AppCond(homomorphism(P⊕V, J⊕V), false)#, # Limit one job per person + AppCond(homomorphism(P⊕V, J⊕V), false) # Limit one job per person ] ); From 73fb11b823a5b541e76701d88eb9f1cf9296b594 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Fri, 23 Aug 2024 17:49:12 +0100 Subject: [PATCH 04/18] Fixed syntax error. --- docs/literate/labor_model.jl | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 3a68b0f..6b6145a 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -4,7 +4,7 @@ # First, we load the necessary libraries from AlgebraicJulia and elsewhere. using AlgebraicABMs, Catlab, AlgebraicRewriting, Random, Test, Plots, DataFrames, DataMigrations -using Catlab: to_graphviz +using Catlab: to_graphviz # hide import Distributions: Exponential, LogNormal using Pipe: @pipe @@ -157,12 +157,14 @@ firm_entry = Rule{:DPO}( homomorphism(O, F) ); -rewrite(firm_entry, O) |> elements |> to_graphviz +@assert rewrite(firm_entry, O) == F firm_exit = Rule{:SPO}( homomorphism(O, F), id(O) ); + +@assert rewrite(firm_exit, F) == O # To make an ABM, we wrap a rule in a named container with a probability distribution over # how long it takes to "fire". A model is created from a list of these wrapped rules. To @@ -177,21 +179,13 @@ people_only_abm = ABM([birth_abm_rule, death_abm_rule]); # We'll need an initial state to run our ABM, in this case simply a number of people. # We can form this using either of the interfaces for producing instances of our schema. -# start_acset = O -# for _ in 1:100 -# start_acset = start_acset ⊕ P -# end - -# imperatively_assembled_start = @acset FirmDemographyAge begin end; -# for _ in 1:100 -# add_part!(imperatively_assembled_start, :Person) -# end; - -# @test start_acset == imperatively_assembled_start +initial_state = @acset FirmDemographyAge begin + Person = 10 +end; # We now have everything we need to run an ABM. -# results = run!(people_only_abm, start; maxtime=100) +results = run!(people_only_abm, initial_state; maxtime=10) # Most of us will be a little more comfortable handling the resulting ABM trajectory # in the form of a dataframe. @@ -219,7 +213,11 @@ function plot_full_df(df::DataFrame) Plots.plot(df.time, [df.Person, df.Firm, df.Job, df.Vacancy]; labels=["Person" "Firm" "Job" "Vacancy"]) end; -# full_df(results) +function plot_full_df(results::AlgebraicABMs.ABMs.Traj) + plot_full_df(full_df(results)) +end; + +plot_full_df(results) # If we run the same ABM on an initial state which has Firms in it, they just sit there, untouched by # either of the ABM rules. @@ -277,7 +275,6 @@ constant_job_dynamics_abm = ABM( ] ); - # In particular, we care about how many people don't have jobs. function number_unemployed(state_of_world::FirmDemographyAge) length([ @@ -286,7 +283,6 @@ function number_unemployed(state_of_world::FirmDemographyAge) ]) end; - # Following the literature, we measure the interplay of supply and demand # in the labour market using this "market tightness" ratio. @@ -314,12 +310,9 @@ dependent_match_function = ( end ) |> ClosureState; -δ_u = 10*0.01600000001; -δ_v = 10*0.01200000001; - full_abm = ABM([ [r for r in constant_job_dynamics_abm.rules if r.name != :Hire]; [ABMRule(:Hire, hire, dependent_match_function)] -]) +]); From 9728ffd1a8e3dcc387db062e1f3f46c4cf813b77 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Fri, 23 Aug 2024 20:37:26 +0100 Subject: [PATCH 05/18] Added graphs at the end of Labor Market demo. --- docs/literate/labor_model.jl | 70 +++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 6b6145a..dd78d34 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -183,41 +183,41 @@ initial_state = @acset FirmDemographyAge begin Person = 10 end; -# We now have everything we need to run an ABM. +function sequence_of_states(results::AlgebraicABMs.ABMs.Traj) + cat([results.init], [codom(right(h)) for h in results.hist], dims=1) +end # hide -results = run!(people_only_abm, initial_state; maxtime=10) +function event_times(results::AlgebraicABMs.ABMs.Traj) + cat([0.0], [e[1] for e in results.events]; dims=1) +end # hide -# Most of us will be a little more comfortable handling the resulting ABM trajectory -# in the form of a dataframe. - -function unpack_results(results::AlgebraicABMs.ABMs.Traj) - event_times = cat([0.0], [e[1] for e in results.events]; dims=1) - states_of_the_world = cat([results.init], [codom(right(h)) for h in results.hist], dims=1) - DataFrame(time = event_times, state = states_of_the_world) -end; +function unpack_results(r::AlgebraicABMs.ABMs.Traj) + DataFrame( + time = event_times(r), + state = sequence_of_states(r) + ) +end; # hide function obj_counts(abm_state::FirmDemographyAge) [k => length(v) for (k, v) in zip(keys(abm_state.parts), abm_state.parts) ] |> NamedTuple -end; +end; # hide function full_df(results::AlgebraicABMs.ABMs.Traj) state_time_df = unpack_results(results) obj_counts_df = obj_counts.(state_time_df.state) |> DataFrame hcat(state_time_df, obj_counts_df) -end; +end; # hide function plot_full_df(df::DataFrame) Plots.plot(df.time, [df.Person, df.Firm, df.Job, df.Vacancy]; labels=["Person" "Firm" "Job" "Vacancy"]) -end; +end; # hide function plot_full_df(results::AlgebraicABMs.ABMs.Traj) plot_full_df(full_df(results)) -end; - -plot_full_df(results) +end; # hide # If we run the same ABM on an initial state which has Firms in it, they just sit there, untouched by # either of the ABM rules. @@ -315,4 +315,42 @@ full_abm = ABM([ [ABMRule(:Hire, hire, dependent_match_function)] ]); +# We can then construct an acset to reflect our starting state, and run a simulation for +# a fixed amount of simulation time (as we do in this case) or until a fixed number of +# events have happened. NB - pending an update to the Single Pushout rewriting capability +# ofAlgebraicABMs, we have temporarily deactivated the firm and person birth and death rules. + +partial_abm = ABM( + filter( + r -> !(r.name in [:Birth, :Death, :FirmEntry, :FirmExit]), + full_abm.rules + ) +); + +initial_state = @acset FirmDemographyAge begin + Person = 20 + Firm = 6 +end; + +initial_state |> elements |> to_graphviz # hide + +# +result = run!( + partial_abm, + initial_state, + maxtime = 100 +); + +plot_full_df(result) # hide + +# +function plot_beveridge_curve(results::AlgebraicABMs.ABMs.Traj) + states = sequence_of_states(results) + Plots.plot( + [number_unemployed(s)/nparts(s, :Person) for s in states], + [market_tightness(s) for s in states], + xlabel = "U rate", ylabel = "V/U" + ) +end # hide +plot_beveridge_curve(result) \ No newline at end of file From b5f5334eb834d49d113191678993aded53101f43 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Fri, 23 Aug 2024 21:10:30 +0100 Subject: [PATCH 06/18] More tweaks to labor model - renamed schema and acset, set parameters on vacancies. --- docs/literate/labor_model.jl | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index dd78d34..3b3ca3b 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -15,7 +15,7 @@ Random.seed!(123); # hide # We define our Schema "from scratch" by specifying the types of objects in our model and the mappings # (or "homomorphisms") between them. -@present FirmDemographySchema(FreeSchema) begin +@present SchLaborMarket(FreeSchema) begin Person::Ob Job::Ob Firm::Ob @@ -26,12 +26,12 @@ Random.seed!(123); # hide advertised_by::Hom(Vacancy, Firm) end; -to_graphviz(FirmDemographySchema) # hide +to_graphviz(SchLaborMarket) # hide # We then create a Julia Type `FirmDemographyAge` for instances of this schema. Re-running this line of code in the same REPL # session after making any changes to the definition of the schema will throw an error. -@acset_type FirmDemographyAge(FirmDemographySchema); +@acset_type LaborMarket(SchLaborMarket); # Having defined the schema, we will build our model(s) by constructing particular # instances of this schema, and the transformations between them. This can be done @@ -47,22 +47,22 @@ to_graphviz(FirmDemographySchema) # hide # # The "representable" Person and Firm are what we would expect - single instances of # the relevant entity, and nothing else. -P = representable(FirmDemographyAge, :Person); +P = representable(LaborMarket, :Person); P |> elements |> to_graphviz # hide # -F = representable(FirmDemographyAge, :Firm); +F = representable(LaborMarket, :Firm); F |> elements |> to_graphviz # hide # The representable vacancy, however, can't be just a single vacancy and nothing else. # To make a well-defined ACSet conforming to our schema, the representable Vacancy has # to have both a vacancy and a firm, with a function mapping the former to the latter. -V = representable(FirmDemographyAge, :Vacancy); +V = representable(LaborMarket, :Vacancy); V |> elements |> to_graphviz # hide # The representable Job, in turn, has two functions pointing from it, so it has to # include both a Person and a Firm. -J = representable(FirmDemographyAge, :Job); +J = representable(LaborMarket, :Job); J |> elements |> to_graphviz # hide # Joining these instances together by placing them "side by side" (i.e. not forcing any @@ -79,7 +79,7 @@ one_of_each_generic_thing |> elements |> to_graphviz #hide # the "empty" instance of that schema. We follow convention by denoting it with the # letter O, and define it using the imperative syntax. -O = FirmDemographyAge(); +O = LaborMarket(); O |> elements |> to_graphviz # hide # Instances that can be formed using oplus and the generic members of the objects are @@ -91,7 +91,7 @@ O |> elements |> to_graphviz # hide # constraints (known as a "colimit of representables"). The macro `@acset_colim` allows # us to do this, if we give it a cached collection of all of the representables for our schema. -yF = yoneda_cache(FirmDemographyAge); +yF = yoneda_cache(LaborMarket); employer_also_hiring = @acset_colim yF begin @@ -179,7 +179,7 @@ people_only_abm = ABM([birth_abm_rule, death_abm_rule]); # We'll need an initial state to run our ABM, in this case simply a number of people. # We can form this using either of the interfaces for producing instances of our schema. -initial_state = @acset FirmDemographyAge begin +initial_state = @acset LaborMarket begin Person = 10 end; @@ -231,7 +231,7 @@ people_and_firms_abm = ABM( ABMRule(:FirmEntry, firm_entry, ContinuousHazard(1/10)), ABMRule(:FirmExit, firm_exit, ContinuousHazard(1)), ABMRule(:PostVacancy, post_vacancy, ContinuousHazard(1)), - ABMRule(:WithdrawVacancy, withdraw_vacancy, ContinuousHazard(1)) + ABMRule(:WithdrawVacancy, withdraw_vacancy, ContinuousHazard(10)) ] ] ); @@ -276,7 +276,7 @@ constant_job_dynamics_abm = ABM( ); # In particular, we care about how many people don't have jobs. -function number_unemployed(state_of_world::FirmDemographyAge) +function number_unemployed(state_of_world::LaborMarket) length([ p for p in state_of_world.parts.Person if length(incident(state_of_world, p, :employee)) == 0 @@ -286,7 +286,7 @@ end; # Following the literature, we measure the interplay of supply and demand # in the labour market using this "market tightness" ratio. -function market_tightness(state_of_world::FirmDemographyAge) +function market_tightness(state_of_world::LaborMarket) length(state_of_world.parts.Vacancy)/number_unemployed(state_of_world) end; @@ -327,7 +327,7 @@ partial_abm = ABM( ) ); -initial_state = @acset FirmDemographyAge begin +initial_state = @acset LaborMarket begin Person = 20 Firm = 6 end; @@ -353,4 +353,4 @@ function plot_beveridge_curve(results::AlgebraicABMs.ABMs.Traj) ) end # hide -plot_beveridge_curve(result) \ No newline at end of file +plot_beveridge_curve(result) # hide \ No newline at end of file From aa98425794d65a3f4c5e0f2283f80adc6c4996a0 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Fri, 23 Aug 2024 23:49:09 +0100 Subject: [PATCH 07/18] More edits to Labor Market example. This version for review. --- docs/literate/labor_model.jl | 105 ++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 51 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 3b3ca3b..e33f481 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -12,6 +12,7 @@ using Pipe: @pipe ENV["JULIA_DEBUG"] = "AlgebraicABMs"; # hide Random.seed!(123); # hide +# ## Schema # We define our Schema "from scratch" by specifying the types of objects in our model and the mappings # (or "homomorphisms") between them. @@ -29,10 +30,11 @@ end; to_graphviz(SchLaborMarket) # hide -# We then create a Julia Type `FirmDemographyAge` for instances of this schema. Re-running this line of code in the same REPL +# We then create a Julia Type `LaborMarket` for instances of this schema. Re-running this line of code in the same REPL # session after making any changes to the definition of the schema will throw an error. @acset_type LaborMarket(SchLaborMarket); +# ## Constructing Instances # Having defined the schema, we will build our model(s) by constructing particular # instances of this schema, and the transformations between them. This can be done # by figuring out how our desired instance would be constructed in memory, and adding @@ -102,6 +104,7 @@ end; employer_also_hiring |> elements |> to_graphviz # hide +# ## Rules # Now that we are able to construct instances of our schema - which we can think of as # states of (part of) the world at given points in time - we can define the types of # change which can occur in our model. These will take the form of ACSet rewriting rules, @@ -176,48 +179,47 @@ death_abm_rule = ABMRule(:Death, death, ContinuousHazard(1)); people_only_abm = ABM([birth_abm_rule, death_abm_rule]); -# We'll need an initial state to run our ABM, in this case simply a number of people. -# We can form this using either of the interfaces for producing instances of our schema. - -initial_state = @acset LaborMarket begin - Person = 10 -end; +# -function sequence_of_states(results::AlgebraicABMs.ABMs.Traj) - cat([results.init], [codom(right(h)) for h in results.hist], dims=1) -end # hide - -function event_times(results::AlgebraicABMs.ABMs.Traj) - cat([0.0], [e[1] for e in results.events]; dims=1) -end # hide - -function unpack_results(r::AlgebraicABMs.ABMs.Traj) - DataFrame( - time = event_times(r), - state = sequence_of_states(r) - ) -end; # hide - -function obj_counts(abm_state::FirmDemographyAge) - [k => length(v) - for (k, v) in - zip(keys(abm_state.parts), abm_state.parts) - ] |> NamedTuple -end; # hide - -function full_df(results::AlgebraicABMs.ABMs.Traj) - state_time_df = unpack_results(results) - obj_counts_df = obj_counts.(state_time_df.state) |> DataFrame - hcat(state_time_df, obj_counts_df) -end; # hide - -function plot_full_df(df::DataFrame) - Plots.plot(df.time, [df.Person, df.Firm, df.Job, df.Vacancy]; labels=["Person" "Firm" "Job" "Vacancy"]) -end; # hide - -function plot_full_df(results::AlgebraicABMs.ABMs.Traj) - plot_full_df(full_df(results)) -end; # hide +function sequence_of_states(results::AlgebraicABMs.ABMs.Traj) # hide + cat([results.init], [codom(right(h)) for h in results.hist], dims=1) # hide +end # hide + +function event_times(results::AlgebraicABMs.ABMs.Traj) # hide + cat([0.0], [e[1] for e in results.events]; dims=1) # hide +end # hide + +function unpack_results(r::AlgebraicABMs.ABMs.Traj) # hide + DataFrame( # hide + time = event_times(r), # hide + state = sequence_of_states(r) # hide + ) # hide +end; # hide + +function obj_counts(abm_state::LaborMarket) # hide + [k => length(v) # hide + for (k, v) in # hide + zip(keys(abm_state.parts), abm_state.parts) # hide + ] |> NamedTuple # hide +end; # hide + +function full_df(results::AlgebraicABMs.ABMs.Traj) # hide + state_time_df = unpack_results(results) # hide + obj_counts_df = obj_counts.(state_time_df.state) |> DataFrame # hide + hcat(state_time_df, obj_counts_df) # hide +end; # hide + +function plot_full_df(df::DataFrame) # hide + Plots.plot( # hide + df.time, # hide + [df.Person, df.Firm, df.Job, df.Vacancy]; # hide + labels=["Person" "Firm" "Job" "Vacancy"] # hide + ) # hide +end; # hide + +function plot_full_df(results::AlgebraicABMs.ABMs.Traj) # hide + plot_full_df(full_df(results)) # hide +end; # hide # If we run the same ABM on an initial state which has Firms in it, they just sit there, untouched by # either of the ABM rules. @@ -231,7 +233,7 @@ people_and_firms_abm = ABM( ABMRule(:FirmEntry, firm_entry, ContinuousHazard(1/10)), ABMRule(:FirmExit, firm_exit, ContinuousHazard(1)), ABMRule(:PostVacancy, post_vacancy, ContinuousHazard(1)), - ABMRule(:WithdrawVacancy, withdraw_vacancy, ContinuousHazard(10)) + ABMRule(:WithdrawVacancy, withdraw_vacancy, ContinuousHazard(1)) ] ] ); @@ -315,6 +317,7 @@ full_abm = ABM([ [ABMRule(:Hire, hire, dependent_match_function)] ]); +# ## Running the Model # We can then construct an acset to reflect our starting state, and run a simulation for # a fixed amount of simulation time (as we do in this case) or until a fixed number of # events have happened. NB - pending an update to the Single Pushout rewriting capability @@ -344,13 +347,13 @@ result = run!( plot_full_df(result) # hide # -function plot_beveridge_curve(results::AlgebraicABMs.ABMs.Traj) - states = sequence_of_states(results) - Plots.plot( - [number_unemployed(s)/nparts(s, :Person) for s in states], - [market_tightness(s) for s in states], - xlabel = "U rate", ylabel = "V/U" - ) -end # hide +function plot_beveridge_curve(results::AlgebraicABMs.ABMs.Traj) # hide + states = sequence_of_states(results) # hide + Plots.plot( # hide + [number_unemployed(s)/nparts(s, :Person) for s in states], # hide + [market_tightness(s) for s in states], # hide + xlabel = "U rate", ylabel = "V/U" # hide + ) # hide +end # hide plot_beveridge_curve(result) # hide \ No newline at end of file From 48ffd97f1b8580884f4a5a28acf69dbf808a3f98 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Mon, 26 Aug 2024 19:33:30 +0100 Subject: [PATCH 08/18] A few edits to text in Labor Markets example. --- docs/literate/labor_model.jl | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index e33f481..01e85bb 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -30,8 +30,7 @@ end; to_graphviz(SchLaborMarket) # hide -# We then create a Julia Type `LaborMarket` for instances of this schema. Re-running this line of code in the same REPL -# session after making any changes to the definition of the schema will throw an error. +# We then create a Julia Type `LaborMarket` for instances of this schema. @acset_type LaborMarket(SchLaborMarket); # ## Constructing Instances @@ -77,9 +76,8 @@ generic_person_sidebyside_generic_firm |> elements |> to_graphviz #hide one_of_each_generic_thing = P⊕F⊕V⊕J one_of_each_generic_thing |> elements |> to_graphviz #hide -# The coproduct has a "unit", defined here using the imperative syntax, consisting of -# the "empty" instance of that schema. We follow convention by denoting it with the -# letter O, and define it using the imperative syntax. +# The coproduct has a "unit" consisting of the "empty" instance of that schema. +# We follow convention by denoting it with the letter O. O = LaborMarket(); O |> elements |> to_graphviz # hide From a982d43d3ebac36f6e798bbe88b3020d0abfefef Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Mon, 26 Aug 2024 19:40:37 +0100 Subject: [PATCH 09/18] A few more text edits to the labor market demo. --- docs/literate/labor_model.jl | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 01e85bb..790bfad 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -37,14 +37,12 @@ to_graphviz(SchLaborMarket) # hide # Having defined the schema, we will build our model(s) by constructing particular # instances of this schema, and the transformations between them. This can be done # by figuring out how our desired instance would be constructed in memory, and adding -# the parts "by hand" using the imperative interface provided in [...]. It's more -# convenient to start taking the "categorical" perspective, here, and use a notion of -# element based on the transformations between instances, rather than the implementation -# "under the hood". We can specify some basic elements of our instances by taking the -# freely constucted minimal example of each of our entities -# ("objects" - but not in the sense of Object-Oriented Programming). This creates a "generic" -# instance of the chosen entity, which in particular doesn't force any two objects to be the -# same when they don't have to be. +# the parts "by hand" using the imperative interface provided by `add_part` and related +# functions. As an alternative, we can specify some basic elements of our instances by +# taking the freely constucted minimal example of each of our entities ("objects" - but +# not in the sense of Object-Oriented Programming). This creates a "generic" instance +# of the chosen entity, which in particular doesn't force any two objects to be the same +# when they don't have to be. # # The "representable" Person and Firm are what we would expect - single instances of # the relevant entity, and nothing else. From 4979c25f23c4423edcca828bf340299483477a96 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Tue, 27 Aug 2024 18:55:00 +0100 Subject: [PATCH 10/18] More text edits to Labor Market example. --- docs/literate/labor_model.jl | 43 ++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 790bfad..38e4877 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -111,25 +111,31 @@ employer_also_hiring |> elements |> to_graphviz # hide # pattern that should exist going forward ("After") and I is the pattern of items in the # input match which should carry over into the output match. # -# The rewrite rule is specified using a pair of ACSet transformations (I $\rightarrowtail$ L and I → R) -# of ACSets sharing the same schema, where both transformations have the same ACSet as their -# domain. While the ACSet Transformations can be built using the machinery available in [...], -# we will find in many cases that the transformations (also known as homomorphisms) between +# The arrows in this pattern represent "ACSet transformations", which are structure-preserving +# morphisms between ACSets. The rewrite rule is specified using a pair of ACSet transformations +# (I $\rightarrowtail$ L and I → R) of ACSets sharing the same schema, where both transformations +# have the same ACSet as their domain. While the ACSet Transformations can be built using the +# machinery available in [Catlab.jl](https://github.com/AlgebraicJulia/Catlab.jl), we will find in +# many cases that the transformations (also known as homomorphisms) between # two relatively simple instances will be unique, so we only need to specify the domain # and codomain ACSets and rely on homomorphism search to find the mapping we intend. In # the case where the domain and codomain are the same, such as where the input pattern # persists in its entirety, we can specify the "transformation" mapping everything to -# itself using id(). +# itself using id(). If the left homomorphism is id(), then we won't delete any part +# of the pattern, if the right homomorphism is id(), then we won't add anything that wasn't +# there before. # One of the events that can occur in our model is that any firm which exists can post -# a vacancy. The input pattern is the representable firm, the firm persists, and in -# the output pattern, it has become part of a connected vacancy-firm pair. +# a vacancy. The input pattern is the representable firm (L = F), the firm persists (I = F), +# and in the output pattern, it has become part of a connected vacancy-firm pair (R = V). post_vacancy = Rule{:DPO}( id(F), homomorphism(F, V) ); +# Swapping the pattern has the effect of running the rule in reverse. + withdraw_vacancy = Rule{:DPO}( homomorphism(F, V), id(F) @@ -143,7 +149,9 @@ birth = Rule{:DPO}( ); # ... and unto nothing they shall return. This rule uses Single Pushout rewriting, -# because we want to eliminate any jobs which point to the now-defunct person. +# which uses "cascading deletes" to eliminate entities which are mapped by functions +# to an entity which gets deleted. We use SPO for this reule because we want to +# eliminate any jobs which point to the now-defunct person. death = Rule{:SPO}( homomorphism(O, P), id(O) @@ -156,8 +164,14 @@ firm_entry = Rule{:DPO}( homomorphism(O, F) ); +# If we apply the firm entry rule to an empty world-state, the result is a world that has +# exactly one firm and nothing else. + @assert rewrite(firm_entry, O) == F +# And likewise, in a world state with a single firm, if that firm exits we are left with +# the empty world state. + firm_exit = Rule{:SPO}( homomorphism(O, F), id(O) @@ -222,7 +236,7 @@ end; # hide # To give the firms their own dynamics, we include two more ABM rules, using the firm entry and exit -# patterns defined above. We can do the same to generate a steady state of vacancies. +# patterns defined above. We then add two more rules to generate a steady state of vacancies. people_and_firms_abm = ABM( [people_only_abm.rules; [ @@ -240,11 +254,12 @@ people_and_firms_abm = ABM( # and firm staying the same, and the vacancy disappearing. But we don't want to keep # adding jobs to the same person indefinitely - in this case, we'll abstract from reality a little, # and pretend that people can have at most one job. We enforce this using an "application condition" -# attached to our rule. This is formed by specifying a further homomorphism from R to another pattern, -# and specifying whether that further match is required or forbidden (false). In this case, we -# want to rule out situations where the Person and Firm-Vacancy pair are part of a larger pattern -# consisting of a Job-Person-Firm triple and a Firm-Vacancy pair - i.e. situations where the person we -# are matching already has a job. +# attached to our rule. This is formed by specifying a further homomorphism from L to another pattern, +# and specifying whether that further match is required or forbidden (false). One could say it puts the +# pattern in a larger context, and the boolean flag says whether this larger context is either mandatory +# or forbidden. In this case, we want to rule out situations where the Person and Firm-Vacancy pair are +# part of a larger pattern consisting of a Job-Person-Firm triple and a Firm-Vacancy pair - i.e. situations +# in which the person we are matching already has a job. hire = Rule{:DPO}( homomorphism(P⊕F, P⊕V), From a3b8c63410600add8b46159eb1bd623933deddfb Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Tue, 27 Aug 2024 22:24:39 +0100 Subject: [PATCH 11/18] Text edits and redefined unemployment measure in Labor Market example. --- docs/literate/labor_model.jl | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 38e4877..90952ff 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -289,11 +289,8 @@ constant_job_dynamics_abm = ABM( ); # In particular, we care about how many people don't have jobs. -function number_unemployed(state_of_world::LaborMarket) - length([ - p for p in state_of_world.parts.Person - if length(incident(state_of_world, p, :employee)) == 0 - ]) +function number_unemployed(LM::LaborMarket) + nparts(LM,:Person) - nparts(LM,:Job) end; # Following the literature, we measure the interplay of supply and demand From 45d10fb5d784f637d7b74e82cf584745c07cbd63 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Tue, 27 Aug 2024 22:50:07 +0100 Subject: [PATCH 12/18] One more fix to summmary functions in Labor Market example. --- docs/literate/labor_model.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 90952ff..e9b0bfe 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -297,7 +297,7 @@ end; # in the labour market using this "market tightness" ratio. function market_tightness(state_of_world::LaborMarket) - length(state_of_world.parts.Vacancy)/number_unemployed(state_of_world) + nparts(state_of_world, :Vacancy)/number_unemployed(state_of_world) end; # This may come in handy for defining distributions From c797a4b229c3c20c2e79ee98566857562ab272d0 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Wed, 28 Aug 2024 02:40:27 +0100 Subject: [PATCH 13/18] Update labor market model to use collection functions for ABMs (from other PR). --- docs/literate/labor_model.jl | 47 ++++++++++++++---------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index e9b0bfe..9f5971d 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -238,15 +238,12 @@ end; # hide # To give the firms their own dynamics, we include two more ABM rules, using the firm entry and exit # patterns defined above. We then add two more rules to generate a steady state of vacancies. -people_and_firms_abm = ABM( - [people_only_abm.rules; [ - ABMRule(:FirmEntry, firm_entry, ContinuousHazard(1/10)), - ABMRule(:FirmExit, firm_exit, ContinuousHazard(1)), - ABMRule(:PostVacancy, post_vacancy, ContinuousHazard(1)), - ABMRule(:WithdrawVacancy, withdraw_vacancy, ContinuousHazard(1)) - ] - ] -); +people_and_firms_abm = @pipe people_only_abm |> + copy(_) |> + push!(_, ABMRule(:FirmEntry, firm_entry, ContinuousHazard(1/10))) |> + push!(_, ABMRule(:FirmExit, firm_exit, ContinuousHazard(1))) |> + push!(_, ABMRule(:PostVacancy, post_vacancy, ContinuousHazard(1))) |> + push!(_, ABMRule(:WithdrawVacancy, withdraw_vacancy, ContinuousHazard(1))); # Hiring, however, presents a new challenge. We want to convert a @@ -278,15 +275,10 @@ fire = Rule{:DPO}( ); # We assume at first that hiring and firing occur at constant rates. -constant_job_dynamics_abm = ABM( - [ - people_and_firms_abm.rules; - [ - ABMRule(:Hire, hire, ContinuousHazard(1)), - ABMRule(:Fire, fire, ContinuousHazard(1)) - ] - ] -); +constant_job_dynamics_abm = @pipe people_and_firms_abm |> + copy(_) |> + push!(_, ABMRule(:Hire, hire, ContinuousHazard(1))) |> + push!(_, ABMRule(:Fire, fire, ContinuousHazard(1))) # In particular, we care about how many people don't have jobs. function number_unemployed(LM::LaborMarket) @@ -297,7 +289,7 @@ end; # in the labour market using this "market tightness" ratio. function market_tightness(state_of_world::LaborMarket) - nparts(state_of_world, :Vacancy)/number_unemployed(state_of_world) + length(state_of_world.parts.Vacancy)/number_unemployed(state_of_world) end; # This may come in handy for defining distributions @@ -320,10 +312,10 @@ dependent_match_function = ( end ) |> ClosureState; -full_abm = ABM([ - [r for r in constant_job_dynamics_abm.rules if r.name != :Hire]; - [ABMRule(:Hire, hire, dependent_match_function)] -]); +full_abm = @pipe constant_job_dynamics_abm |> + copy(_) |> + filter(r -> r.name != :Hire, _) |> # Get rid of old hire rule + push!(_, ABMRule(:Hire, hire, dependent_match_function)) # add new one # ## Running the Model # We can then construct an acset to reflect our starting state, and run a simulation for @@ -331,12 +323,9 @@ full_abm = ABM([ # events have happened. NB - pending an update to the Single Pushout rewriting capability # ofAlgebraicABMs, we have temporarily deactivated the firm and person birth and death rules. -partial_abm = ABM( - filter( - r -> !(r.name in [:Birth, :Death, :FirmEntry, :FirmExit]), - full_abm.rules - ) -); +partial_abm = filter(full_abm) do r + !(r.name in [:Birth, :Death, :FirmEntry, :FirmExit]) +end; initial_state = @acset LaborMarket begin Person = 20 From 84ef9552864b2511dbf6f6f04158aa15e6c49186 Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Wed, 28 Aug 2024 23:37:02 +0100 Subject: [PATCH 14/18] Added some intro text for background, no links yet apart from papers. --- docs/literate/labor_model.jl | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 9f5971d..ccda817 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -12,6 +12,19 @@ using Pipe: @pipe ENV["JULIA_DEBUG"] = "AlgebraicABMs"; # hide Random.seed!(123); # hide +# ## Background +# Inspired by the search-and-matching ABM implementation in [del Rio-Chanona et. al. (2022)](https://royalsocietypublishing.org/doi/suppl/10.1098/rsif.2020.0898), +# we demonstrate a very basic ABM of a [labor market seach-and-matching model](https://en.wikipedia.org/wiki/Search_and_matching_theory_(economics)). +# A state-of-the-world in our model will be represented by an ACSet on a schema that we define, and the model itself +# will consist of a set of rewrite rules for ACSets on that schema, each paired with a probability distribution over the +# waiting time before that rewrite "fires" on each of the matches it "recognizes" in the current state of the world. +# Running the model generates a trajectory of ACSets and times of state transitions. +# +# Since the ACSet rewrite rules are defined using ACSet instances and the homomorphisms (or ACSet Transformations) between +# them, it will be useful to revise AlgebraicRewriting.jl functions for creating them. In practice, the rules below +# are defined using identitiy morphisms on coproducts of representables and homomorphism search for unique homomorphisms +# between them. +# # ## Schema # We define our Schema "from scratch" by specifying the types of objects in our model and the mappings # (or "homomorphisms") between them. From 5c6d9561ebfe4b3957046174258975386d811c0e Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Thu, 29 Aug 2024 00:25:55 +0100 Subject: [PATCH 15/18] Added a few links to background. --- docs/literate/labor_model.jl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index ccda817..797cf4a 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -16,13 +16,14 @@ Random.seed!(123); # hide # Inspired by the search-and-matching ABM implementation in [del Rio-Chanona et. al. (2022)](https://royalsocietypublishing.org/doi/suppl/10.1098/rsif.2020.0898), # we demonstrate a very basic ABM of a [labor market seach-and-matching model](https://en.wikipedia.org/wiki/Search_and_matching_theory_(economics)). # A state-of-the-world in our model will be represented by an ACSet on a schema that we define, and the model itself -# will consist of a set of rewrite rules for ACSets on that schema, each paired with a probability distribution over the -# waiting time before that rewrite "fires" on each of the matches it "recognizes" in the current state of the world. +# will consist of a set of rewrite rules for [ACSets](https://github.com/AlgebraicJulia/ACSets.jl) on that schema, +# each paired with a probability distribution over the waiting time before that rewrite "fires" on each of the matches it "recognizes" in the current state of the world. # Running the model generates a trajectory of ACSets and times of state transitions. # # Since the ACSet rewrite rules are defined using ACSet instances and the homomorphisms (or ACSet Transformations) between -# them, it will be useful to revise AlgebraicRewriting.jl functions for creating them. In practice, the rules below -# are defined using identitiy morphisms on coproducts of representables and homomorphism search for unique homomorphisms +# them, it will be useful to revise [Catlab.jl](https://github.com/AlgebraicJulia/Catlab.jl) and +# [AlgebraicRewriting.jl](https://github.com/AlgebraicJulia/AlgebraicRewriting.jl) functions for creating them. +# In practice, the rules below are defined using identitiy morphisms on coproducts of representables and homomorphism search for unique homomorphisms # between them. # # ## Schema From b12307b462b8fd1aebe6761159c7bbb8eb768f1c Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Thu, 5 Sep 2024 17:51:56 +0100 Subject: [PATCH 16/18] Set parameters to generate a more compelling graph for the Labor Market demo. --- NamedTuple | 0 begin | 0 docs/Project.toml | 1 + docs/literate/labor_model.jl | 21 ++++++++------------- elements | 0 to_graphviz | 0 6 files changed, 9 insertions(+), 13 deletions(-) create mode 100644 NamedTuple create mode 100644 begin create mode 100644 elements create mode 100644 to_graphviz diff --git a/NamedTuple b/NamedTuple new file mode 100644 index 0000000..e69de29 diff --git a/begin b/begin new file mode 100644 index 0000000..e69de29 diff --git a/docs/Project.toml b/docs/Project.toml index 29105f6..48375c3 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -14,4 +14,5 @@ LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589" Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" Pipe = "b98c9c47-44ae-5843-9183-064241ee97a0" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" +Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" StructEquality = "6ec83bb0-ed9f-11e9-3b4c-2b04cb4e219c" diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 797cf4a..abac6d5 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -256,7 +256,7 @@ people_and_firms_abm = @pipe people_only_abm |> copy(_) |> push!(_, ABMRule(:FirmEntry, firm_entry, ContinuousHazard(1/10))) |> push!(_, ABMRule(:FirmExit, firm_exit, ContinuousHazard(1))) |> - push!(_, ABMRule(:PostVacancy, post_vacancy, ContinuousHazard(1))) |> + push!(_, ABMRule(:PostVacancy, post_vacancy, ContinuousHazard(1/10))) |> push!(_, ABMRule(:WithdrawVacancy, withdraw_vacancy, ContinuousHazard(1))); @@ -334,32 +334,27 @@ full_abm = @pipe constant_job_dynamics_abm |> # ## Running the Model # We can then construct an acset to reflect our starting state, and run a simulation for # a fixed amount of simulation time (as we do in this case) or until a fixed number of -# events have happened. NB - pending an update to the Single Pushout rewriting capability -# ofAlgebraicABMs, we have temporarily deactivated the firm and person birth and death rules. - -partial_abm = filter(full_abm) do r - !(r.name in [:Birth, :Death, :FirmEntry, :FirmExit]) -end; +# events have happened. initial_state = @acset LaborMarket begin - Person = 20 - Firm = 6 + Person = 50 + Firm = 10 end; initial_state |> elements |> to_graphviz # hide - # result = run!( - partial_abm, + full_abm, initial_state, - maxtime = 100 + maxtime = 20 ); + plot_full_df(result) # hide # function plot_beveridge_curve(results::AlgebraicABMs.ABMs.Traj) # hide - states = sequence_of_states(results) # hide + states = sequence_of_states(results)[100:length(results)] # hide Plots.plot( # hide [number_unemployed(s)/nparts(s, :Person) for s in states], # hide [market_tightness(s) for s in states], # hide diff --git a/elements b/elements new file mode 100644 index 0000000..e69de29 diff --git a/to_graphviz b/to_graphviz new file mode 100644 index 0000000..e69de29 From dce65ca785049165edd0242cb644b80cf603864f Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Wed, 16 Oct 2024 21:52:55 +0100 Subject: [PATCH 17/18] Fixed one typo in Labor Model. --- docs/literate/labor_model.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index abac6d5..43165c4 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -164,7 +164,7 @@ birth = Rule{:DPO}( # ... and unto nothing they shall return. This rule uses Single Pushout rewriting, # which uses "cascading deletes" to eliminate entities which are mapped by functions -# to an entity which gets deleted. We use SPO for this reule because we want to +# to an entity which gets deleted. We use SPO for this rule because we want to # eliminate any jobs which point to the now-defunct person. death = Rule{:SPO}( homomorphism(O, P), From 655e564ba9359884d93ac11d2cb7a51b6fa2c02e Mon Sep 17 00:00:00 2001 From: Owen Haaga Date: Wed, 4 Dec 2024 20:52:47 +0000 Subject: [PATCH 18/18] WIP - Responded to October comments from Kris Brown, but this is throwing an error with the versions of other libraries that I'm using. --- docs/literate/labor_model.jl | 72 ++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/docs/literate/labor_model.jl b/docs/literate/labor_model.jl index 43165c4..4790f7f 100644 --- a/docs/literate/labor_model.jl +++ b/docs/literate/labor_model.jl @@ -15,20 +15,20 @@ Random.seed!(123); # hide # ## Background # Inspired by the search-and-matching ABM implementation in [del Rio-Chanona et. al. (2022)](https://royalsocietypublishing.org/doi/suppl/10.1098/rsif.2020.0898), # we demonstrate a very basic ABM of a [labor market seach-and-matching model](https://en.wikipedia.org/wiki/Search_and_matching_theory_(economics)). -# A state-of-the-world in our model will be represented by an ACSet on a schema that we define, and the model itself -# will consist of a set of rewrite rules for [ACSets](https://github.com/AlgebraicJulia/ACSets.jl) on that schema, +# A "state of the world" in our model will be represented by an ACSet on a schema that we define, and the model itself +# will consist of a set of [rewrite rules](https://blog.algebraicjulia.org/post/2022/09/ai-planning-cset/) for [ACSets](https://github.com/AlgebraicJulia/ACSets.jl) on that schema, # each paired with a probability distribution over the waiting time before that rewrite "fires" on each of the matches it "recognizes" in the current state of the world. # Running the model generates a trajectory of ACSets and times of state transitions. # # Since the ACSet rewrite rules are defined using ACSet instances and the homomorphisms (or ACSet Transformations) between # them, it will be useful to revise [Catlab.jl](https://github.com/AlgebraicJulia/Catlab.jl) and # [AlgebraicRewriting.jl](https://github.com/AlgebraicJulia/AlgebraicRewriting.jl) functions for creating them. -# In practice, the rules below are defined using identitiy morphisms on coproducts of representables and homomorphism search for unique homomorphisms -# between them. # # ## Schema -# We define our Schema "from scratch" by specifying the types of objects in our model and the mappings -# (or "homomorphisms") between them. +# We define our Schema by specifying the types of entities in our model (tagged as "Ob" +# for Objects, but not in the sense of Object-Oriented Programming) and the types of +# arrows between them (tagged with "Hom" for Homomorphisms - although this word is used +# in a slightly different sense above and below). @present SchLaborMarket(FreeSchema) begin Person::Ob @@ -44,19 +44,21 @@ end; to_graphviz(SchLaborMarket) # hide -# We then create a Julia Type `LaborMarket` for instances of this schema. +# We then create a Julia Type `LaborMarket` for instances of this schema. Each instance +# will be a "state of the world" which is compatible with the corresponding schema. @acset_type LaborMarket(SchLaborMarket); # ## Constructing Instances # Having defined the schema, we will build our model(s) by constructing particular -# instances of this schema, and the transformations between them. This can be done -# by figuring out how our desired instance would be constructed in memory, and adding -# the parts "by hand" using the imperative interface provided by `add_part` and related -# functions. As an alternative, we can specify some basic elements of our instances by -# taking the freely constucted minimal example of each of our entities ("objects" - but -# not in the sense of Object-Oriented Programming). This creates a "generic" instance -# of the chosen entity, which in particular doesn't force any two objects to be the same -# when they don't have to be. +# instances ("states of the world" in our model) which conform to this schema, +# and the transformations between them. This can be done by figuring out how our desired +# instance would be constructed in memory, and adding the parts "by hand" using the +# imperative interface provided by `add_part` and related functions. + +# As an alternative, we can specify some basic elements of our instances by +# taking the freely constucted minimal example of each of our entities ("objects"). +# This creates a "generic" instance of the chosen entity, which in particular doesn't +# force any two parts to be the same when they don't have to be. # # The "representable" Person and Firm are what we would expect - single instances of # the relevant entity, and nothing else. @@ -135,9 +137,9 @@ employer_also_hiring |> elements |> to_graphviz # hide # and codomain ACSets and rely on homomorphism search to find the mapping we intend. In # the case where the domain and codomain are the same, such as where the input pattern # persists in its entirety, we can specify the "transformation" mapping everything to -# itself using id(). If the left homomorphism is id(), then we won't delete any part -# of the pattern, if the right homomorphism is id(), then we won't add anything that wasn't -# there before. +# itself using `id()`, the identity function. If the left homomorphism is `id()`, +# then we won't delete any part of the pattern, if the right homomorphism is `id()`, +# then we won't add anything that wasn't there before. # One of the events that can occur in our model is that any firm which exists can post # a vacancy. The input pattern is the representable firm (L = F), the firm persists (I = F), @@ -245,9 +247,22 @@ function plot_full_df(results::AlgebraicABMs.ABMs.Traj) # hide plot_full_df(full_df(results)) # hide end; # hide +result = run!( + people_only_abm, + O, + maxtime = 30 +); +plot_full_df(result) # hide + # If we run the same ABM on an initial state which has Firms in it, they just sit there, untouched by # either of the ABM rules. +result = run!( + people_only_abm, + F ⊕ F ⊕ F ⊕ F, + maxtime = 30 +); +plot_full_df(result) # hide # To give the firms their own dynamics, we include two more ABM rules, using the firm entry and exit # patterns defined above. We then add two more rules to generate a steady state of vacancies. @@ -292,7 +307,7 @@ fire = Rule{:DPO}( constant_job_dynamics_abm = @pipe people_and_firms_abm |> copy(_) |> push!(_, ABMRule(:Hire, hire, ContinuousHazard(1))) |> - push!(_, ABMRule(:Fire, fire, ContinuousHazard(1))) + push!(_, ABMRule(:Fire, fire, ContinuousHazard(1))); # In particular, we care about how many people don't have jobs. function number_unemployed(LM::LaborMarket) @@ -303,7 +318,7 @@ end; # in the labour market using this "market tightness" ratio. function market_tightness(state_of_world::LaborMarket) - length(state_of_world.parts.Vacancy)/number_unemployed(state_of_world) + nparts(state_of_world, :Vacancy)/number_unemployed(state_of_world) end; # This may come in handy for defining distributions @@ -329,7 +344,7 @@ dependent_match_function = ( full_abm = @pipe constant_job_dynamics_abm |> copy(_) |> filter(r -> r.name != :Hire, _) |> # Get rid of old hire rule - push!(_, ABMRule(:Hire, hire, dependent_match_function)) # add new one + push!(_, ABMRule(:Hire, hire, dependent_match_function)); # add new one # ## Running the Model # We can then construct an acset to reflect our starting state, and run a simulation for @@ -358,8 +373,19 @@ function plot_beveridge_curve(results::AlgebraicABMs.ABMs.Traj) # hide Plots.plot( # hide [number_unemployed(s)/nparts(s, :Person) for s in states], # hide [market_tightness(s) for s in states], # hide - xlabel = "U rate", ylabel = "V/U" # hide + xlabel = "U rate", ylabel = "V/U", # hide + legend = false # hide ) # hide end # hide -plot_beveridge_curve(result) # hide \ No newline at end of file +# The standard way to visualize the output of a labor market flow model +# is the "[Beveridge](https://en.wikipedia.org/wiki/William_Beveridge) +# [Curve](https://en.wikipedia.org/wiki/Beveridge_curve)", a plot of market tightness (measured by the +# ratio of vacancies to unemployment) against unemployment. + +plot_beveridge_curve(result) # hide + +# This simple model can be extended in a large number of different directions, +# and calibrated against available data to provide quantitative forecasts. Hopefully, +# the availability of the AlgebraicABMs library will encourage more people to explore +# these models and their implications! \ No newline at end of file