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

New functions: random_id_in_position & random_agent_in_position #842

Merged
merged 18 commits into from
Aug 10, 2023
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

- The `@agent` macro now supports fields with default and const values (through the special `constants` field). Since now the macro supports these features, using `@agent` is the only supported way to create agent types for Agents.jl.
- The `add_agent!` function supports adding an agent propagating keywords arguments.
- Two new functions `random_id_in_position` and `random_agent_in_position` can be used to select a random id/agent in a position in discrete spaces (even with filtering).
- A new argument `alloc` can be used to select a more performant version in relation to the expensiveness of the filtering for all
random methods selecting ids/agents/positions.

# v5.17

Expand Down
2 changes: 2 additions & 0 deletions docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ npositions
ids_in_position
id_in_position
agents_in_position
random_id_in_position
random_agent_in_position
fill_space!
has_empty_positions
empty_positions
Expand Down
33 changes: 24 additions & 9 deletions src/core/model_abstract.jl
Original file line number Diff line number Diff line change
Expand Up @@ -148,36 +148,51 @@ Return a random agent from the model.
random_agent(model) = model[rand(abmrng(model), allids(model))]

"""
random_agent(model, condition; optimistic=false) → agent
random_agent(model, condition; optimistic=true, alloc = false) → agent
Return a random agent from the model that satisfies `condition(agent) == true`.
The function generates a random permutation of agent IDs and iterates through
them. If no agent satisfies the condition, `nothing` is returned instead.

## Keywords
`optimistic = false` changes the algorithm used to be non-allocating but
`optimistic = true` changes the algorithm used to be non-allocating but
potentially more variable in performance. This should be faster if the condition
is `true` for a large proportion of the population (for example if the agents
are split into groups).
are split into groups).

`alloc` can be used to employ a different fallback strategy in case the
optimistic version doesn't find any agent satisfying the condition: if the filtering
condition is expensive an allocating fallback can be more performant.
"""
function random_agent(model, condition; optimistic=false)
function random_agent(model, condition; optimistic = true, alloc = false)
if optimistic
return optimistic_random_agent(model, condition)
return optimistic_random_agent(model, condition, alloc)
else
return sampling_with_condition_agents_single(allids(model), condition, model)
return fallback_random_agent(model, condition, alloc)
end
end

function optimistic_random_agent(model, condition; n_attempts = 3*nagents(model))
function optimistic_random_agent(model, condition, alloc; n_attempts = nagents(model))
rng = abmrng(model)
ids = allids(model)
@inbounds while n_attempts != 0
idx = rand(rng, ids)
condition(model[idx]) && return model[idx]
n_attempts -= 1
end
# Fallback after n_attempts tries to find an agent
return sampling_with_condition_agents_single(allids(model), condition, model)
return fallback_random_agent(model, condition, alloc)
end

function fallback_random_agent(model, condition, alloc)
if alloc
iter_ids = allids(model)
return sampling_with_condition_agents_single(iter_ids, condition, model)
else
iter_agents = allagents(model)
iter_filtered = Iterators.filter(agent -> condition(agent), iter_agents)
return resorvoir_sampling_single(iter_filtered, model)
end
end

# TODO: In the future, it is INVALID to access space, agents, etc., with the .field syntax.
# Instead, use the API functions such as `abmrng, abmspace`, etc.
# We just need to re-write the codebase to not use .field access.
Expand Down
58 changes: 40 additions & 18 deletions src/core/space_interaction_API.jl
Original file line number Diff line number Diff line change
Expand Up @@ -330,63 +330,85 @@ nearby_agents(a, model, r = 1; kwargs...) =
(model[id] for id in nearby_ids(a, model, r; kwargs...))

"""
random_nearby_id(agent, model::ABM, r = 1, f = nothing; kwargs...) → id
random_nearby_id(agent, model::ABM, r = 1, f = nothing, alloc = false; kwargs...) → id
Return the `id` of a random agent near the position of the given `agent` using an optimized
algorithm from [Reservoir sampling](https://en.wikipedia.org/wiki/Reservoir_sampling#An_optimal_algorithm).
Return `nothing` if no agents are nearby.

The value of the argument `r` and possible keywords operate identically to [`nearby_ids`](@ref).

A filter function `f(id)` can be passed so that to restrict the sampling on only those ids for which
the function returns `true`.
the function returns `true`. The argument `alloc` can be used if the filtering condition
is expensive since in this case the allocating version can be more performant.
`nothing` is returned if no nearby id satisfies `f`.
"""
function random_nearby_id(a, model, r = 1, f = nothing; kwargs...)
function random_nearby_id(a, model, r = 1, f = nothing, alloc = false; kwargs...)
iter = nearby_ids(a, model, r; kwargs...)
if isnothing(f)
return resorvoir_sampling_single(iter, model)
else
return sampling_with_condition_single(iter, f, model)
if alloc
return sampling_with_condition_single(iter, f, model)
else
iter_filtered = Iterators.filter(id -> f(id), iter)
return resorvoir_sampling_single(iter_filtered, model)
end
end
end

"""
random_nearby_agent(agent, model::ABM, r = 1, f = nothing; kwargs...) → agent
random_nearby_agent(agent, model::ABM, r = 1, f = nothing, alloc = false; kwargs...) → agent
Return a random agent near the position of the given `agent` or `nothing` if no agent
is nearby.

The value of the argument `r` and possible keywords operate identically to [`nearby_ids`](@ref).

A filter function `f(agent)` can be passed so that to restrict the sampling on only those agents for which
the function returns `true`.
the function returns `true`. The argument `alloc` can be used if the filtering condition
is expensive since in this case the allocating version can be more performant.
`nothing` is returned if no nearby agent satisfies `f`.
"""
function random_nearby_agent(a, model, r = 1, f = nothing; kwargs...)
function random_nearby_agent(a, model, r = 1, f = nothing, alloc = false; kwargs...)
if isnothing(f)
id = random_nearby_id(a, model, r; kwargs...)
isnothing(id) && return nothing
return model[id]
else
iter = nearby_ids(a, model, r; kwargs...)
return sampling_with_condition_agents_single(iter, f, model)
iter_ids = nearby_ids(a, model, r; kwargs...)
if alloc
return sampling_with_condition_agents_single(iter_ids, f, model)
else
iter_filtered = Iterators.filter(id -> f(model[id]), iter_ids)
id = resorvoir_sampling_single(iter_filtered, model)
isnothing(id) && return nothing
return model[id]
end
end
end

"""
random_nearby_position(position, model::ABM, r=1, f = nothing; kwargs...) → position
random_nearby_position(position, model::ABM, r=1, f = nothing, alloc = false; kwargs...) → position
Return a random position near the given `position`.
Return `nothing` if the space doesn't allow for nearby positions.

The value of the argument `r` and possible keywords operate identically to [`nearby_positions`](@ref).

A filter function `f(pos)` can be passed so that to restrict the sampling on only those positions for which
the function returns `true`. In this case `nothing` is also returned if no nearby position
satisfies `f`.
the function returns `true`. The argument `alloc` can be used if the filtering condition
is expensive since in this case the allocating version can be more performant.
`nothing` is returned if no nearby position satisfies `f`.
"""
function random_nearby_position(pos, model, r=1, f = nothing; kwargs...)
function random_nearby_position(pos, model, r=1, f = nothing, alloc = false; kwargs...)
iter = nearby_positions(pos, model, r; kwargs...)
if isnothing(f)
return resorvoir_sampling_single(iter, model)
else
return sampling_with_condition_single(iter, f, model)
if alloc
return sampling_with_condition_single(iter, f, model)
else
iter_filtered = Iterators.filter(pos -> f(pos), iter)
return resorvoir_sampling_single(iter_filtered, model)
end
end
end

Expand Down Expand Up @@ -427,11 +449,11 @@ end
# Reservoir sampling function (https://en.wikipedia.org/wiki/Reservoir_sampling)
function resorvoir_sampling_single(iter, model)
res = iterate(iter)
isnothing(res) && return nothing # `iterate` returns `nothing` when it ends
isnothing(res) && return nothing # `iterate` returns `nothing` when it ends
rng = abmrng(model)
w = max(rand(rng), eps()) # rand returns in range [0,1)
w = rand(rng)
while true
choice, state = res # random position to return, and the state of the iterator
choice, state = res # random position to return, and the state of the iterator
skip_counter = floor(log(rand(rng)) / log(1 - w)) # skip entries in the iterator
while skip_counter != 0
skip_res = iterate(iter, state)
Expand All @@ -441,6 +463,6 @@ function resorvoir_sampling_single(iter, model)
end
res = iterate(iter, state)
isnothing(res) && return choice
w *= max(rand(rng), eps())
w *= rand(rng)
end
end
52 changes: 51 additions & 1 deletion src/spaces/discrete.jl
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ agents are stored in a field `stored_ids` of the space.
=#

export positions, npositions, ids_in_position, agents_in_position,
empty_positions, random_empty, has_empty_positions, empty_nearby_positions
empty_positions, random_empty, has_empty_positions, empty_nearby_positions,
random_id_in_position, random_agent_in_position


positions(model::ABM) = positions(model.space)
Expand Down Expand Up @@ -137,6 +138,55 @@ function empty_nearby_positions(pos, model, r = 1; kwargs...)
)
end

"""
random_id_in_position(pos, model::ABM, [f, alloc = false]) → id
Return a random id in the position specified in `pos`.

A filter function `f(id)` can be passed so that to restrict the sampling on only those agents for which
the function returns `true`. The argument `alloc` can be used if the filtering condition is expensive
since in this case the allocating version can be more performant.
`nothing` is returned if no nearby position satisfies `f`.
"""
function random_id_in_position(pos, model)
ids = ids_in_position(pos, model)
isempty(ids) && return nothing
return rand(abmrng(model), ids)
end
function random_id_in_position(pos, model, f, alloc = false)
iter_ids = ids_in_position(pos, model)
if alloc
return sampling_with_condition_single(iter_ids, f, model)
else
iter_filtered = Iterators.filter(id -> f(id), iter_ids)
return resorvoir_sampling_single(iter_filtered, model)
end
end

"""
random_agent_in_position(pos, model::ABM, [f, alloc = false]) → agent
Return a random agent in the position specified in `pos`.

A filter function `f(agent)` can be passed so that to restrict the sampling on only those agents for which
the function returns `true`. The argument `alloc` can be used if the filtering condition is expensive
since in this case the allocating version can be more performant.
`nothing` is returned if no nearby position satisfies `f`.
"""
function random_agent_in_position(pos, model)
id = random_id_in_position(pos, model)
isnothing(id) && return nothing
return model[id]
end
function random_agent_in_position(pos, model, f, alloc = false)
iter_ids = ids_in_position(pos, model)
if alloc
return sampling_with_condition_agents_single(iter_ids, f, model)
else
iter_filtered = Iterators.filter(id -> f(model[id]), iter_ids)
id = resorvoir_sampling_single(iter_filtered, model)
isnothing(id) && return nothing
return model[id]
end
end

#######################################################################################
# Discrete space extra agent adding stuff
Expand Down
3 changes: 0 additions & 3 deletions src/spaces/graph.jl
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,6 @@ ids_in_position(n::Integer, model::ABM{<:GraphSpace}) = model.space.stored_ids[n
# Neighbors
#######################################################################################
function nearby_ids(pos::Int, model::ABM{<:GraphSpace}, r = 1; kwargs...)
if r == 0
return ids_in_position(pos, model)
end
np = nearby_positions(pos, model, r; kwargs...)
vcat(model.space.stored_ids[pos], model.space.stored_ids[np]...)
end
Expand Down
46 changes: 40 additions & 6 deletions test/grid_space_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -200,30 +200,63 @@ using StableRNGs
@testset "Random nearby" begin
abm = ABM(GridAgent{2}, SpaceType((10, 10), periodic=periodic); rng = StableRNG(42))
fill_space!(abm)
if SpaceType == GridSpace
fill_space!(abm)
end
# test random_id_in_position
if SpaceType == GridSpace
pos = abm[1].pos
valid_ids = ids_in_position(pos, abm)
random_id = random_id_in_position(pos, abm)
@test random_id in valid_ids
t_1(id) = id != abm[1].id
for alloc in (true, false)
random_id = random_id_in_position(pos, abm, t_1, alloc)
@test !isnothing(random_id) && random_id != abm[1].pos
end
end
# test random_agent_in_position
if SpaceType == GridSpace
pos = abm[1].pos
valid_agents = agents_in_position(pos, abm)
random_a = random_agent_in_position(pos, abm)
@test random_a in valid_agents
t_2(a) = a != abm[1]
for alloc in (true, false)
random_a = random_agent_in_position(pos, abm, t_2, alloc)
@test !isnothing(random_a) && random_a != abm[1]
end
end
# test random_nearby_id
nearby_id = random_nearby_id(abm[1], abm, 5)
valid_ids = collect(nearby_ids(abm[1], abm, 5))
@test nearby_id in valid_ids
some_ids = valid_ids[1:3]
f(id) = id in some_ids
filtered_nearby_id = random_nearby_id(abm[1], abm, 5, f)
@test filtered_nearby_id in some_ids
for alloc in (true, false)
filtered_nearby_id = random_nearby_id(abm[1], abm, 5, f, alloc)
@test filtered_nearby_id in some_ids
end
# test random_nearby_position
valid_positions = collect(nearby_positions(abm[1].pos, abm, 3))
nearby_position = random_nearby_position(abm[1].pos, abm, 3)
@test nearby_position in valid_positions
some_positions = valid_positions[3:5]
g(pos) = pos in some_positions
filtered_nearby_position = random_nearby_position(abm[1].pos, abm, 3, g)
@test filtered_nearby_position in some_positions
for alloc in (true, false)
filtered_nearby_position = random_nearby_position(abm[1].pos, abm, 3, g, alloc)
@test filtered_nearby_position in some_positions
end
# test random_nearby_agent
valid_agents = collect(nearby_agents(abm[1], abm, 2))
nearby_agent = random_nearby_agent(abm[1], abm, 2)
@test nearby_agent in valid_agents
some_agents = valid_agents[2:4]
h(agent) = agent in some_agents
filtered_nearby_agent = random_nearby_agent(abm[1], abm, 2, h)
@test filtered_nearby_agent in some_agents
for alloc in (true, false)
filtered_nearby_agent = random_nearby_agent(abm[1], abm, 2, h, alloc)
@test filtered_nearby_agent in some_agents
end
# test methods after removal of all agents
remove_all!(abm)
a = add_agent!((1, 1), abm)
Expand All @@ -233,6 +266,7 @@ using StableRNGs
add_agent!((2,1), abm)
rand_nearby_ids = Set([random_nearby_id(a, abm, 2) for _ in 1:100])
@test length(rand_nearby_ids) == 2

end
end

Expand Down
12 changes: 8 additions & 4 deletions test/randomness_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,14 @@ end
@test typeof(a) <: Union{Daisy,Land}

c1(a) = a isa Land
a = random_agent(model, c1)
@test a.id == 999
for alloc in (true, false)
a = random_agent(model, c1; alloc = alloc)
@test a.id == 999
end

c2(a) = a isa Float64
a = random_agent(model, c2)
@test isnothing(a)
for alloc in (true, false)
a = random_agent(model, c2; alloc = alloc)
@test isnothing(a)
end
end