Closed aornugent closed 1 year ago
https://github.com/traitecoevo/plant/pull/362/commits/7d18779d0bd094e9051820d06fbcc524d85f0a1a adds the first half of the mutant runner.
SCM objects now have an additional method called run_mutant
that iterates over the environment history, loading the vector of environments into the patch-level cache at each timestep.
In ode_interface.h
we overload derivs
with a specific template has_cache
that allows the ODE solver to pass in the RK step index when solving a Patch*. If the patch.use_cached_environment
flag is set, the system is passed to a distinct version of set_ode_state
which loads the appropriate environment from the cache.
The system runs, but I've not yet verified the results are correct. The next step is to develop the means to alter the list of strategies to pass in a mutant (i.e. a species with zero density). An example of the intended workflow is
# enable RK cache
ctrl <- scm_base_control()
ctrl$save_RK45_cache = T
# create SCM
types <- extract_RcppR6_template_types(p1, "Parameters")
scm <- do.call('SCM', types)(p1, e, ctrl)
# run the residents
scm$run()
# start the system schedule again
scm$reset()
## TODO:
scm$patch$strategies <- FF16_Strategy(mutant = T)
# solve using cached environment reflecting the residents' 'resource shadow'
scm$run_mutant()
*Note: overloading derivs
and maintaining version doesn't take an RK index is mostly only to support the functions in Ode_R
.
Ok, this now runs the one species case and converts the resident to a mutant.
# enable RK cache
ctrl <- scm_base_control()
ctrl$save_RK45_cache = T
# create SCM
types <- extract_RcppR6_template_types(p1, "Parameters")
scm <- do.call('SCM', types)(p1, e, ctrl)
# run the residents
scm$run()
# solve using cached environment reflecting the residents' resource shadow
scm$run_mutant()
Unfortunately we get a difference in net reproduction ratios (residents: 47.7
vs. mutant: 4.1
).
I notice in both cases that sometimes the Runga-Kutta solver gets run more than once per node_schedule
event. This appears to happen more frequently when solving the mutant.
SUPER exciting that it runs, well done!
Next step is get the right numbers. I have ideas on how to pursue this. More later.
Picking up this thread again. Plots of exp(height) and density for residents (black lines) and the final state of mutants (red dots) show small differences in patch state, where we'd expect them to be identical.
resident <- collect(scm)
scm$net_reproduction_ratios
mutant <- scm$run_mutant()
scm$net_reproduction_ratios
# uh-oh
# [1] 47.66136
# [1] 4.049069
# tidy species
# ..
ggplot(resident, aes(time, exp(log_density), group = node)) +
geom_line() +
geom_point(data = mutant, color = "red")
resident(resident, aes(time, exp(height), group = node)) +
geom_line() +
geom_point(data = mutant, color = "red")
NB: only showing endpoint because I don't have a collect
function for mutants yet.
Interestingly, the performance of the mutant seems to respond to the density of the resident.
birth_rates <- c(100, 50, 10, 5, 1, 0.1, 0.01, 0.001)
grid_search <- purrr::map(birth_rates, run_one_patch)
fitnesses <- purrr::map_df(grid_search,
~ purrr::pluck(., "fitnesses") %>%
tibble::tibble(., rownames = c("resident_rr", "mutant_rr"),
.name_repair = ~ c("net_reproduction_ratio", "invasion_type")),
.id = "grid_step")
tidyr::spread(fitnesses, invasion_type, net_reproduction_ratio) %>%
dplyr::mutate(birth_rate = birth_rates,
unit = "seeds_per_m2_yr") %>%
dplyr::group_by(grid_step, birth_rate, unit) %>%
dplyr::mutate(relative_mutant_fitness = scales::percent(mutant_rr / resident_rr))
# grid_step mutant_rr resident_rr birth_rate unit relative_mutant_fitness
# 1 1 0.00131 0.379 100 seeds_per_m2_yr 0%
# 2 2 0.00202 0.740 50 seeds_per_m2_yr 0%
# 3 3 0.479 4.07 10 seeds_per_m2_yr 12%
# 4 4 0.132 8.45 5 seeds_per_m2_yr 2%
# 5 5 4.05 47.7 1 seeds_per_m2_yr 8%
# 6 6 145. 584. 0.1 seeds_per_m2_yr 25%
# 7 7 4901. 6041. 0.01 seeds_per_m2_yr 81%
# 8 8 26291. 28712. 0.001 seeds_per_m2_yr 92%
# should probably use patchwork for plotting grids
purrr::pluck(grid_search, 8) %>%
{
ggplot(.$resident_state, aes(time, exp(log_density) * 10, group = node)) +
geom_line() +
geom_point(data = .$mutant_endpoint, color = "red")
ggplot(.$resident_state, aes(time, exp(height), group = node)) +
geom_line() +
geom_point(data = .$mutant_endpoint, color = "red")
}
The difference between 1 $seeds.m^{-2}.yr{-1}$ (shown in previous comment) and 0.001 $seeds.m^{-2}.yr{-1}$ is most obvious in mutant exp(height) which is now much closer to the largest, oldest residents. I'm not sure how I feel about the surge in resident density from nodes that were previously near extinct.
I've added two print statement for comparison between resident and mutant runs.
derivs are called from ode_step.h
and dispatch to one of two set_ode_state
signatures based on the use_cached_environment
flag.
Patches have two signatures for the set_ode_state method>
The first one takes in the step time and records that timestep in the environment
the other takes in the index and loads the environment from the cache (remembering that only objects that have the used_cached_environment
flag are routed to this version of the method).
I copied the console output for residents and mutants into logfiles. The console length threshold was 1,000 lines. The last thousand lines of the mutant console output covers fewer timesteps than the resident console output, suggesting that there's extra steps being taken somewhere.
Inspecting output of the final step, we see that the resident's step time and environment time are lagged, but otherwise match:
resident: step time 104.264; resident: etime 104
resident: step time 104.396; resident: etime 104.264
resident: step time 104.792; resident: etime 104.396
resident: step time 105.32; resident: etime 104.792
resident: step time 105.155; resident: etime 105.32
resident: step time 105.32; resident: etime 105.155
But the mutant's step times and environment times differ.
mutant: step time 105.015; mutant: etime 104; index 0
mutant: step time 105.053; mutant: etime 104.264; index 1
mutant: step time 105.168; mutant: etime 104.396; index 2
mutant: step time 105.32; mutant: etime 104.792; index 3
mutant: step time 105.272; mutant: etime 105.32; index 4
mutant: step time 105.32; mutant: etime 105.155; index 5
Also note that the mutant environment times match the resident environment times
I think this now works! After chasing my tail for a few weeks, I discovered that most of what I needed was already implemented (which often seems the case).
This diagram roughly summarises the changes made to enable saving resident ODE state during each RK45 step (green) and the new features added to enable a mutant run that follows the same ODE history (blue).
Great progress @aornugent ! Diagram is very helpful.
Testing on a wider parameter set, I sometimes get an error about step history not being sorted, I think because sometimes the ODE solver goes backwards when it takes a step that's too big? This suggests I might need to tweak exactly where caching occurs.
regarding step history being sorted, I'd guess the same as you, occasionally the solver decides to shorten the step and reruns. Suggested steps from here
You could possibly change where the casing occurs, OR alternatively,
a. when you rerun a step, clear the last cached items (ie 5-6 from a single RK step) so only the successful step is kept?
b. Similar to a, but add all the items for a single RK step (so 5-6 env calls and times) into. temporary bucket while the step is in progress, then only add this to the permanent cache when a step is successful?
That was an easy fix - I was indeed caching before ode_solver->step()
had checked for success.
This is now the output of scripts/mutant-method.r
grid_step mutant_rr resident_rr birth_rate unit rr_prop
<chr> <dbl> <dbl> <dbl> <chr> <chr>
1 0.315 0.316 100 seeds_per_m2_yr 100%
2 0.629 0.629 50 seeds_per_m2_yr 100%
3 3.48 3.48 10 seeds_per_m2_yr 100%
4 7.26 7.26 5 seeds_per_m2_yr 100%
5 41.4 41.4 1 seeds_per_m2_yr 100%
6 516. 516. 0.1 seeds_per_m2_yr 100%
7 5353. 5353. 0.01 seeds_per_m2_yr 100%
8 24649. 24649. 0.001 seeds_per_m2_yr 100%
Hoo boy, that's great!
Next step?
Latest commit is a bit rough and ready. The script mutant-timing.r
aims to evaluate whether running multiple mutants at once is faster than iterating through a pre-loaded competitive environment with one mutant at a time.
First, note the time taken to create a resident. Caching the environment at each ODE step appears to have only a minor overhead:
# residents | no-cache (sec) | cache-env (sec) |
---|---|---|
1 | 1.21 | 1.23 |
Now compare the effect of re-using the cached environment. This commit provides two approaches to node scheduling: mutants can either re-use the same schedule as the resident or introduce a node at every ODE step (hereafter: a mutant schedule). Re-using the resident schedule is not yet enabled for the many-mutants case so we can only compare these two approaches for 1x mutant.
There are generally more ODE steps than node introductions, meaning that the mutant schedule is larger than the resident schedule (e.g. a resident schedule with 140 nodes takes about 300 ODE steps). This difference is shown in the runtimes:
# mutants | resident-schedule (sec) | mutant-schedule (sec) |
---|---|---|
1 | 0.41 | 0.69 |
The increased resolution of the mutant schedule also leads to minor differences in fitness when running identical strategies:
# mutants | resident-schedule (reproduction ratio) | mutant-schedule (reproduction ratio) |
---|---|---|
1 | 1.045965 | 1.063594 |
Using the mutant schedule, initial testing shows that timing does not change between iterating 1 mutant at a time or running many mutants at once:
# mutants | one-at-a-time (sec) | many-at-once (sec) |
---|---|---|
1 | 0.67 | 0.66 |
10 | 6.85 | 6.65 |
100 | 66.90 | 69.33 |
note: times are one iteration only so small differences should be ignored
Note that the changes to is_resident
are experimental and may yet be reverted. The basic expectation of identical mutants and residents attaining equivalent fitness is met for the 1x mutant case (see above) but breaks when running multiple identical mutants - only the first mutant attains comparable fitness, and subsequent mutants have fitness that change between runs (even if they have identical traits).
All this is to say that the many-mutant case should only be used to evaluate timing and inform design - further work is required if we decide that running many mutants at once is advantageous or necessary.
Hi Andrew, Great work here. I think we're close. I've reviewed the code and have the following thoughts and insights
I think I know why the run many mutants simultaneously
is behaving weirdly (only first one returns reasonable values), which is that we never set the birth_rates for the extra mutants! See comments above. It's reasonable to always sets birth rates for mutants to 1. Just need to make an appropriate change in patch birth rates when you add mutants.
I was quite surprised how slow the run_mutant
method is. But I suspect we're wasting a lot of compute time copying around environment objects. If we making the main environment object in patch a pointer, we'll save all this, but it will require minor changes everywhere environment is called. We could attack this in a different PR if the whole interface is working ok.
Does caching or having the option to cache in the code slow the resident run much? Can you compare speed running resident on the develop branch, to this branch?
Hi Andrew,
New idea on issues running multiple mutants! See my commit above, this adds more examples.
it suggests that many mutants
I also sometimes get completely crashes due to memory allocation failures.
All of this suggests that it's an issue with memory allocation. When we run the sim for the first time, various memory is allocated. When we add mutants, we're not managing to adjust the size of relevant containers correctly. Having running a larger number of residents means the memory is already allocated at the beginning, so many mutant works. Make sense?
Oh boy - I fixed one issue. In net_reproduction_ratio_by_node_weighted we look up species' dispersal survival probability using the SCM's Parameters object.
The solution was to re-write run_mutant
to accept a parameters object instead and overwrite it in the SCM. This is destructive, meaning that we lose the ability to switch back to the resident, but it works!
I've added tests based on your last changes to mutant_timing.r
.
Well done, this looks workable. Ready to merge
All tests now passing locally. Let's check GH Actions. Assuming they pass, we're ready to start introducing pointers.
I've had some smallish success working his that locally (not yet pushed), but seems promising
:exclamation: No coverage uploaded for pull request base (
develop@a8fc78c
). Click here to learn what that means. Patch has no changes to coverable lines.:exclamation: Current head a2e0fac differs from pull request most recent head 0bbb2bc. Consider uploading reports for the commit 0bbb2bc to get more accurate results
:exclamation: Your organization is not using the GitHub App Integration. As a result you may experience degraded service beginning May 15th. Please install the Github App Integration for your organization. Read more.
:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.
Results of speed tests comparing three implementations of rescinders and mutant runs with
For each method, I ran in combinations with
What we can see from these runs is that
Running residents
Running mutants
(the benefit of pointers is not as big as I'd expected. In retrospect it seems about right. I thought it might be bigger because our previous speed comparison showed mutant and resindet being not that different. But I was misled, as we only ran a single resident for 1 year. Running multiple residents for 100 years makes the difference between mutant and resident more dramatic. But also, even for a short run, the new method will be 50% reduction of old, as don't need to run resident alongside. )
Here's code run on various branches
library(plant)
devtools::load_all()
p <- scm_base_parameters("FF16")
p$max_patch_lifetime <- 100
e <- make_environment("FF16")
ctrl <- scm_base_control()
lma <- c(0.05, 0.1, 0.2)
birth_rate <- 1
# 1 resident strategies
p1 <- expand_parameters(trait_matrix(lma[2], "lma"), p,
mutant = F,
birth_rate_list = rep(birth_rate, 1)
)
p1m1 <- expand_parameters(trait_matrix(lma[2], "lma"), p1,
mutant = T,
birth_rate_list = rep(birth_rate, 1)
)
p1m3 <- expand_parameters(trait_matrix(lma, "lma"), p1,
mutant = T,
birth_rate_list = rep(birth_rate, 3)
)
p1m21 <- expand_parameters(trait_matrix(rep(lma, 7), "lma"), p1,
mutant = T,
birth_rate_list = rep(birth_rate, 21)
)
types <- extract_RcppR6_template_types(p1, "Parameters")
scm <- do.call("SCM", types)(p1, e, ctrl)
cat("Residents 1: ", system.time(scm$run()), "\n")
scm <- do.call("SCM", types)(p1m1, e, ctrl)
cat("mutants 1: ", system.time(scm$run()), "\n")
scm <- do.call("SCM", types)(p1m3, e, ctrl)
cat("mutants 3: ", system.time(scm$run()), "\n")
scm <- do.call("SCM", types)(p1m21, e, ctrl)
cat("mutants 21: ", system.time(scm$run()), "\n")
# 3 resident strategies
p3 <- expand_parameters(trait_matrix(lma, "lma"), p,
mutant = F,
birth_rate_list = rep(birth_rate, 3)
)
p3m1 <- expand_parameters(trait_matrix(lma[2], "lma"), p3,
mutant = T,
birth_rate_list = rep(birth_rate, 1)
)
p3m3 <- expand_parameters(trait_matrix(lma, "lma"), p3,
mutant = T,
birth_rate_list = rep(birth_rate, 3)
)
p3m21 <- expand_parameters(trait_matrix(rep(lma, 7), "lma"), p3,
mutant = T,
birth_rate_list = rep(birth_rate, 21)
)
scm <- do.call("SCM", types)(p3, e, ctrl)
cat("Residents 1: ", system.time(scm$run()), "\n")
scm <- do.call("SCM", types)(p3m1, e, ctrl)
cat("mutants 1: ", system.time(scm$run()), "\n")
scm <- do.call("SCM", types)(p3m3, e, ctrl)
cat("mutants 3: ", system.time(scm$run()), "\n")
scm <- do.call("SCM", types)(p3m21, e, ctrl)
cat("mutants 21: ", system.time(scm$run()), "\n")
# 21 resident strategies
p21 <- expand_parameters(trait_matrix(rep(lma, 7), "lma"), p,
mutant = F,
birth_rate_list = rep(birth_rate, 21)
)
p21m1 <- expand_parameters(trait_matrix(lma[2], "lma"), p21,
mutant = T,
birth_rate_list = rep(birth_rate, 1)
)
p21m3 <- expand_parameters(trait_matrix(lma, "lma"), p21,
mutant = T,
birth_rate_list = rep(birth_rate, 3)
)
p21m21 <- expand_parameters(trait_matrix(rep(lma, 7), "lma"), p21,
mutant = T,
birth_rate_list = rep(birth_rate, 21)
)
scm <- do.call("SCM", types)(p21, e, ctrl)
cat("Residents 1: ", system.time(scm$run()), "\n")
scm <- do.call("SCM", types)(p21m1, e, ctrl)
cat("mutants 1: ", system.time(scm$run()), "\n")
scm <- do.call("SCM", types)(p21m3, e, ctrl)
cat("mutants 3: ", system.time(scm$run()), "\n")
scm <- do.call("SCM", types)(p21m21, e, ctrl)
cat("mutants 21: ", system.time(scm$run()), "\n")
p <- scm_base_parameters("FF16")
p$max_patch_lifetime <- 100
e <- make_environment("FF16")
ctrl <- scm_base_control()
ctrl$save_RK45_cache <- TRUE
lma <- c(0.05, 0.1, 0.2)
birth_rate <- 1
p1 <- expand_parameters(trait_matrix(lma[2], "lma"), p,
mutant = F,
birth_rate_list = rep(birth_rate, 1)
)
p3 <- expand_parameters(trait_matrix(lma, "lma"), p,
mutant = F,
birth_rate_list = rep(birth_rate, 3)
)
p21 <- expand_parameters(trait_matrix(rep(lma, 7), "lma"), p,
mutant = F,
birth_rate_list = rep(birth_rate, 21)
)
types <- extract_RcppR6_template_types(p1, "Parameters")
scm <- do.call("SCM", types)(p1, e, ctrl)
cat("Residents 1: ", system.time(scm$run()), "\n")
resident_rr1 <- scm$net_reproduction_ratios
cat("mutants 1: ", system.time(scm$run_mutant(p1)), "\n")
cat("mutants 3: ", system.time(scm$run_mutant(p3)), "\n")
cat("mutants 21: ", system.time(scm$run_mutant(p21)), "\n")
types <- extract_RcppR6_template_types(p3, "Parameters")
scm <- do.call("SCM", types)(p3, e, ctrl)
cat("Residents 3: ", system.time(scm$run()), "\n")
resident_rr3 <- scm$net_reproduction_ratios
cat("mutants 1: ", system.time(scm$run_mutant(p1)), "\n")
cat("mutants 3: ", system.time(scm$run_mutant(p3)), "\n")
cat("mutants 21: ", system.time(scm$run_mutant(p21)), "\n")
types <- extract_RcppR6_template_types(p21, "Parameters")
scm <- do.call("SCM", types)(p21, e, ctrl)
cat("Residents 21: ", system.time(scm$run()), "\n")
resident_rr21 <- scm$net_reproduction_ratios
# one mutants - same environment
cat("mutants 1: ", system.time(scm$run_mutant(p1)), "\n")
cat("mutants 3: ", system.time(scm$run_mutant(p3)), "\n")
cat("mutants 21: ", system.time(scm$run_mutant(p21)), "\n")
With become much more obvious when the residents
I'm yet to push the implementation with pointers. Just want to fine tune comments and code.
Jobs to do before merging
mutant =
, is_resident
from R side [Daniel started this]Daniel to push existing work on this branch then focus on 5, Andrew complete 1-4
Amazing work here!
This adds the capability to store environments at each step of the plant ODE solver, with the intention of estimating mutant fitness more efficiently by re-using the 'resource shadow' of that is calculated when solving the patch dynamics of residents.
I've added an 'environment_cache' to Patches and inserted caching calls into the solver that store the environment state at each sub-step of the Runga-Kutta ODE solver. I noticed in Rich's code the
enable_if
template that allows us to add methods to generic classes (like the ODE solver) based on the input type. When solving other objects (i.e. non-patches) this cache method does nothing and the solver continues as normal.If the
save_history
flag is set in the control object, the SCM will store the Patch environment cache at each step of the system. The result is a vector of vectors, each containing six (6) environment objects. Example code below.The next step is to add a
MutantRunner
class that re-uses the already solved environment. Exact design TBC.