Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added early draft of labor model example. #36

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3a5c046
Added early draft of labor model example.
ohaaga Aug 8, 2024
26a197b
Got docs building under Linux, not currently running any simulations.
ohaaga Aug 22, 2024
e699eb8
Responded to first batch of Kris's edits.
ohaaga Aug 22, 2024
73fb11b
Fixed syntax error.
ohaaga Aug 23, 2024
9728ffd
Added graphs at the end of Labor Market demo.
ohaaga Aug 23, 2024
b5f5334
More tweaks to labor model - renamed schema and acset, set parameters…
ohaaga Aug 23, 2024
aa98425
More edits to Labor Market example. This version for review.
ohaaga Aug 23, 2024
48ffd97
A few edits to text in Labor Markets example.
ohaaga Aug 26, 2024
a982d43
A few more text edits to the labor market demo.
ohaaga Aug 26, 2024
4979c25
More text edits to Labor Market example.
ohaaga Aug 27, 2024
a3b8c63
Text edits and redefined unemployment measure in Labor Market example.
ohaaga Aug 27, 2024
45d10fb
One more fix to summmary functions in Labor Market example.
ohaaga Aug 27, 2024
c797a4b
Update labor market model to use collection functions for ABMs (from …
ohaaga Aug 28, 2024
84ef955
Added some intro text for background, no links yet apart from papers.
ohaaga Aug 28, 2024
5c6d956
Added a few links to background.
ohaaga Aug 28, 2024
6e7dd7b
Merge branch 'main' into add_labor_model
ohaaga Aug 30, 2024
b12307b
Set parameters to generate a more compelling graph for the Labor Mark…
ohaaga Sep 5, 2024
dce65ca
Fixed one typo in Labor Model.
ohaaga Oct 16, 2024
655e564
WIP - Responded to October comments from Kris Brown, but this is thro…
ohaaga Dec 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ 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"
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"
357 changes: 357 additions & 0 deletions docs/literate/labor_model.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,357 @@
# # Labor 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
using Catlab: to_graphviz # hide
import Distributions: Exponential, LogNormal
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm this post doesn't talk about any other ways to define a schema so "from scratch" might not make sense (not clear what you're contrasting it with) - I think what you wrote makes sense without that qualifier.

# (or "homomorphisms") between them.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if 'mapping' or 'homomorphism' is the right term to use here (of course, they're both unambiguously fine to use among people who know what they're talking about!). To see my worry: if you ctrl+f "mapping" or "homomorphism" in the rest of the blog post, it gets used exclusively to mean C-Set homomorphism, which is very different from an arrow in the schema. Maybe when introducing it you could say that declaring a schema is declaring the types of objects/entities (denoted by Ob in the code below) and the functional relationships between them (denoted by Hom). There's a risk someone becomes confused ("why is it called Hom?") but I worry about the other kind of confusion more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gave it a try.


@present SchLaborMarket(FreeSchema) begin
Person::Ob
ohaaga marked this conversation as resolved.
Show resolved Hide resolved
Job::Ob
Firm::Ob
Vacancy::Ob

employee::Hom(Job, Person)
employer::Hom(Job, Firm)
advertised_by::Hom(Vacancy, Firm)
end;

to_graphviz(SchLaborMarket) # hide


# We then create a Julia Type `LaborMarket` for instances of this schema.
@acset_type LaborMarket(SchLaborMarket);

# ## Constructing Instances
# Having defined the schema, we will build our model(s) by constructing particular
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You talked about "state of the world" earlier, so here is a good time to mention that instances are how we represent states of the world. (perhaps even in the sentence that immediately precedes this section).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, let me know if you think this is overkill.

# 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
ohaaga marked this conversation as resolved.
Show resolved Hide resolved
# convenient to start taking the "categorical" perspective, here, and use a notion of
# element based on the transformations between instances, rather than the implementation
ohaaga marked this conversation as resolved.
Show resolved Hide resolved
# "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 "representable" Person and Firm are what we would expect - single instances of
# the relevant entity, and nothing else.
P = representable(LaborMarket, :Person);
P |> elements |> to_graphviz # hide
#
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(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(LaborMarket, :Job);
J |> elements |> to_graphviz # hide

# 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$.

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" 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
ohaaga marked this conversation as resolved.
Show resolved Hide resolved

# 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
ohaaga marked this conversation as resolved.
Show resolved Hide resolved
# 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 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 (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(LaborMarket);


employer_also_hiring = @acset_colim yF begin
j1::Job
v1::Vacancy
employer(j1) == advertised_by(v1)
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,
# 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.
#
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhere between this first and second paragraph of this section, you should introduce the concept (perhaps even at just an intuitive level) of what an "ACSet Transformation" is, since if I'm not mistaken I don't see it introduced anywhere else but it gets highly used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a line. May want to discuss audience expectations in a larger sense.

# 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
ohaaga marked this conversation as resolved.
Show resolved Hide resolved
# 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().
ohaaga marked this conversation as resolved.
Show resolved Hide resolved

# 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.
ohaaga marked this conversation as resolved.
Show resolved Hide resolved

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.
ohaaga marked this conversation as resolved.
Show resolved Hide resolved
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)
);

@assert rewrite(firm_entry, O) == F
ohaaga marked this conversation as resolved.
Show resolved Hide resolved

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
# 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]);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to show a plot here of the population just to make this point before going to the more complicated ABM?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great minds think alike. I'll add it back in once we have SPO.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up I don't see this added back in!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some graphs of the people only ABM (with and without firms).

#

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.


# To give the firms their own dynamics, we include two more ABM rules, using the firm entry and exit
ohaaga marked this conversation as resolved.
Show resolved Hide resolved
# 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))
]
]
);


# 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
ohaaga marked this conversation as resolved.
Show resolved Hide resolved
# 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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a semicolon here in order to prevent it from printing out an ABM (which looks massive/ugly because we don't pretty print that data structure)

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::LaborMarket)
ohaaga marked this conversation as resolved.
Show resolved Hide resolved
length([
p for p in state_of_world.parts.Person
if length(incident(state_of_world, p, :employee)) == 0
])
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::LaborMarket)
length(state_of_world.parts.Vacancy)/number_unemployed(state_of_world)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use nparts(state_of_world, :Vacancy)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

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;

full_abm = ABM([
[r for r in constant_job_dynamics_abm.rules if r.name != :Hire];
[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
# 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 LaborMarket 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) # 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain very briefly (provide a link) what Beveridge is?

Also a concluding sentence would be nice so that the piece doesn't abruptly end!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also the figure has a legend with "y1" as the name of the line - maybe you can rename it or remove the legend?
Screenshot 2024-10-24 at 5 52 17 PM

1 change: 1 addition & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down