Skip to main content

Starsim 4: the full changelog

Every difference between Starsim 3 (3.7.4) and Starsim 4, in detail. The summary is in CHANGELOG.md, and the guide to moving code from Starsim 3, job by job, is v3_v4.md. Changes that may result in differences in model output are flagged with the term “Regression”; changes that may require updates to downstream code are flagged with the term “Migration”.

Starsim 4 adds a way to write a model as declared states and transitions, which can then be run as an ODE, as a stochastic compartmental model, or as agents. Models written as code (ssl.AgentSIR, custom step() methods, etc.) still run, but their stocks (the counts of states) are recorded at the start of each step rather than after it, so they are one index later than in Starsim 3, and their infections are dated at the end of the step (see Regression below), so they agree with Starsim 3 statistically, not number for number. To reproduce published Starsim 3 numbers exactly, use pip install starsim==3.7.4.

Vocabulary and renames

Names were renamed where they meant different things in different places, or where a short form hid what they were (#1547). None of the old names is kept as an alias: a removed name or keyword raises an error that names what replaces it, and python -m starsim.converters.migrate rewrites most of the Starsim 3 ones (and lists, by file and line, what needs a person).

The sim and its settings

  • Migration: ss.Sim takes the model as its first arguments (ss.Sim(sir), or a list of modules), and the sim’s settings as keywords. pars= takes the sim’s settings and modules, as in Starsim 3, alongside the model’s parameters, told apart by name: ss.Sim(sir, pars=dict(start=2000, dur=10, n_agents=1000, beta=0.1)). A name is a setting only if no parameter of the model has it (so pars=fit.pars works with a parameter called dur), a keyword given outright wins over the same setting in pars=, and a name that is neither raises a KeyError; ss.Sim(pars=ss.SimPars(...)) works as ss.Sim(ss.SimPars(...)) does. A parameter of the model given as a keyword of the sim (ss.Sim(sir, beta=0.1)) raises, saying to give it in pars=. A dict in place of the model (ss.Sim(dict(...))) still gives the settings.
  • Migration: pop_scale= is removed: give the number of people, total_pop=, with n_agents=; sim.pop_scale (read-only) is their ratio, and sim.pars.pop_scale is sim.pop_scale. n_agents= is for agents: in a compartmental run it is an error (give total_pop=).
  • Migration: rescale=True or rescale=dict(threshold=0.05, factor=1.2) (were rescale_threshold= and rescale_factor=); another key is an error.
  • Migration: aging= (was use_aging=), record_people= (was people_results=), and workers= (was n_cpus= of ss.parallel() and ss.MultiSim). birth_rate= and death_rate= of the sim are removed: give demographics=[ss.Births(birth_rate=...), ss.Deaths(death_rate=...)], or ss.birth(rate=...) and ss.death(rate=...) (below); demographics=True gives declared births and deaths at 20 and 10 per 1,000 a year.
  • Migration: custom= of the sim is removed: give modules of no standard kind with modules= (they are in sim.custom); step_order='given' steps the modules in the order given.
  • Migration: copy_inputs= is removed: a sim always copies the modules it is given, together, when it is made (copy_modules=False runs the modules given themselves). Read the modules, and their results, from the sim (sim.diseases.sir, sim.results.sir); reading a result from the module given says where it is. A sim no longer rewrites the functions given to it: a function reads modules from the sim it is given (lambda sim: sim.diseases.sir.I.n).
  • Migration: sim.run() takes only until= and verbose=, and always returns the sim: run(profile=True) is sim.profile(kind='loop'), run(shrink=True) is sim.shrink(), and check_method_calls= is removed (as is the option). run(until=) includes the step that starts at until.
  • Migration: verbose is one integer for every solver: 0 prints nothing, 1 (the default) a line saying what runs, the notes, and a progress bar, and 2 everything (above 2 is everything too); verbose=-1 is verbose=0. A fraction, as Starsim 3’s default 0.1, is still accepted.
  • Migration: sim.summary is removed: sim.summarize() is a dataframe with a row per result and a value column, the same table as msim.summarize() (use .loc, or sim.summarize('sir.infection') for one result); how='max' and how='min' are new, and an unknown how= is an error.
  • Migration: sim.profile() takes kind=: 'line' (the default) times each line of the functions followed, 'function' each function, and 'loop' each call of the integration loop. sim.to_yaml() and the unused verbose= of sim.to_json() are removed.
  • Migration: the internal methods of ss.Sim, ss.Result and ss.MultiSim are private, as are the phases of Sim.init() that no package overrides (init_people(), init_results(), and the like stay public); ss.Pars and ss.SimPars stay public, for packages that subclass them.

Running many sims

  • Migration: ss.parallel() is the one way to run many sims: ss.parallel(s1, s2, n_runs=10), labeled by a dict rather than keywords (ss.parallel({'Baseline': s1, 'Vaccine': s2})), so that its keywords are only how to run them. workers=1 runs them one after another in this process (parallel= and MultiSim(debug=True) are removed). ss.parallel(sim, pars=dict(beta=[0.2, 0.3])) is a sweep (replacing ss.multi_run(iterpars=)), with each set of values a scenario of its own.
  • Migration: ss.MultiSim is what the runs give and the class to subclass: ss.MultiSim(sim) (or ss.parallel(sim)) of one sim with no n_runs= runs it once (Starsim 3 ran it 4 times; give n_runs=4), and base_sim= and initialize= are removed (msim.init_sims()); a subclass changes each run’s sim in make_run() and adds its own run options with run_options. ss.single_run() and ss.multi_run() are removed.
  • Migration: a MultiSim is its results: msim.results.<module>.<result> is an ss.Result with a run axis first, however the runs were made, with select(run=, scenario=, <swept parameter>=), quantiles(), median(), mean(), diff(), resample(), to_df(), and plot(); these reduce each scenario separately, where Starsim 3 pooled every sim. msim.reduce(), msim.mean(), msim.median(), msim.reset(), and ss.MultiResult are removed, and raise an error that says what to write instead.
  • Migration: msim.compare() is msim.summarize(each=True) (a column for each run, with t= for the values at one time point), msim.plot_compare() is removed, and msim.brief() is print(msim). msim.show() returns the summary as text that shows itself (ss.display.Text), so msim.show(output=True) is text = msim.show() (output= raises a TypeError saying so).

Results

  • Migration: the count of each state is the result named after the state, for every module: sim.results.alive (was n_alive), res.infected for a module written as code (was n_infected), and sim.results.sir.I for a declared disease. A flow has one name, its transition’s (sim.results.sir.infection), and its running total is .cum, worked out when asked for: new_infections and cum_infections are infection and infection.cum, for diseases written as code too, and a data column called new_infections no longer matches anything.
  • Migration: the sim’s new_deaths, cum_deaths, and new_emigrants are died, died.cum, and emigrated, so that they don’t shadow a disease’s death flow; ssl.HIV has the flow death. The pop_scale result is removed.
  • Migration: ss.Births and ss.Deaths have the flows birth and death (were new), with totals birth.cum and death.cum (were cumulative).
  • Migration: ss.Pregnancy’s results are its flows and states: conception, birth, miscarriage, and stillbirth (were pregnancies, births, miscarriages, and stillbirths), preterm, very_preterm, and unborn (were n_preterm, n_very_preterm, and n_unborn), and maternal_death and neonatal_death (were plural). ssl.FetalHealth’s results are birth, lbw, vlbw, sga, and svn.
  • Migration: Result.col() and Result.by_column() are removed: a 2D result’s column is res['alpha'], or res.select(column='alpha').
  • Migration: Result.annualize() is res.resample('year') (a flow is summed, a stock taken at the start of each year, a ratio averaged). res.to_df() and results.to_df() are to_df(), which always includes the results of the modules (descend= is removed), with the times as the column time; Results.flatten() always keeps the case of keys (keep_case= is removed), and ss.utils.match_result_keys() is removed.
  • Migration: update_results() is replaced by record_states(), called at the start of every step (after start_step(), before any module’s step) for the results that are the state then (self.results.n_on_art[self.ti] = self.on_art.sum()), and record_events(), called at the end (after the deaths, before the analyzers) for those about what happened in the step (doses, births, new cases). A result that needs both (births over the people alive) goes in record_events(), reading the stock from its result, or in finalize_results(). A flow can also be counted where it happens, in step(). A class that still defines update_results() raises a TypeError when it is created, saying how to split it; a result written in finish_step() comes after record_events(), so count it in step().
  • Migration: every result has a kind (kind='flow', 'stock', 'ratio', or None for one with no meaning over time), from kind= or from its name (new_... is a flow, n_... and cum_... are stocks, ...prevalence is a ratio); otherwise it is guessed from the dtype, as in Starsim 3, but with a warning (ss.Result('cases') is taken as a ratio). The kind decides how it is scaled, compared with data, summarized, and resampled, and whether it has a running total (.cum); scale= and summarize_by= override what the kind sets. ss.Result() raises on an unknown keyword.

Diseases written as code

  • Migration: ss.Disease is the base of every disease, declared, written as code (Starsim 3’s class X(ss.Disease), with super().__init__(), define_pars(), define_states(), and step(), works unchanged), or a mix of both. A subclass that declares no states, transitions, or outcomes is written as code; one with no states and no step() or step_state() raises at init.
  • Migration: ss.AgentDisease (was ss.Infection) is a disease written as code with transmission over the sim’s networks, which runs only as agents. Each piece of it is a short method on plain arrays to override: transmissibility() and susceptibility() (new), route_betas() (the beta of each network, as in Starsim 3), transmit() (who is infected; was infect(), and replaces validate_beta(), infect_route(), compute_transmission(), and finalize_infections()), and set_prognoses(). transmit(rel_trans=..., rel_sus=..., beta=...) is the same transmission as a declared disease’s run as agents, over every network and route, with the modifiers applied, and returns the new cases (recorded with their sources and routes in the infection log). New infections are counted by infect(uids) and the step, not by set_prognoses(), so infecting people from code is self.infect(uids), for every disease, declared or written as code. An override of transmit() can call super().transmit(rel_trans=..., rel_sus=...) once for each of its own arrays (e.g. each strain).
  • Migration: an override of step_die(uids) is on_death(uids), a hook of every kind of module (beside the new on_birth()); people.step_die() is the step that resolves the deaths.
  • Migration: set_outcomes() gives the newly infected their outcomes, as in Starsim 3, sending those infected before birth (age 0 or less, e.g. over ss.PrenatalNet) to set_congenital(uids, sources) and the rest to set_prognoses(); set_congenital() now defaults to set_prognoses() (in Starsim 3 it did nothing), so a disease overrides it only for outcomes at birth. Starsim 3’s congenital outcomes on ss.Infection (prenatal_death_keys and the like) are in ssl.mnch.CongenitalDisease, which schedules the outcomes at birth.
  • Migration: an ss.AgentDisease with a nonzero beta and no network or mixing pool raises an error, rather than running with no transmission; one with no beta runs, transmitting nothing.
  • Migration: ss.NCD is ssl.NCD, and ss.InfectionLog is ss.diseases.InfectionLog; ss.dynamics_by_age is removed (stratify the sim by age, or write an analyzer).

Distributions

  • Migration: the keywords of the distributions are their moments, where they have them: ss.normal(mean=, std=) (was loc=, scale=), ss.expon(mean=) (was scale=), ss.poisson(mean=) (was lam=), ss.weibull(shape=, scale=) (was c=), and ss.gamma(shape=, scale=) (was a=) or ss.gamma(mean=, std=); the order by position is unchanged. ss.nbinom() can also be given by its mean and dispersion (ss.nbinom(mean=20, n=0.5), the variance being mean + mean²/n). The parameters of every distribution can be arrays or lists (ss.normal(mean=[3.5, 4.5], std=[0.9, 0.8])), including those given by their moments (ss.gamma(mean=, std=), ss.beta_dist(mean=, std=)).
  • Migration: ss.lognormal(mean=, std=) or ss.lognormal(log_mean=, log_std=) (were ss.lognorm_ex() and ss.lognorm_im(mean=, sigma=), with the same draws); ss.beta_dist(mean=, std=) (was ss.beta_mean(mean, var): a standard deviation, not a variance); ss.constant(value=) (was v=).
  • Migration: ss.rand_raw, ss.scale_types, ss.link_dists, ss.make_dist, ss.dist_list, and ss.Dists are removed from the top level (the last four are in ss.dists and ss.distributions); Dist.state_int is removed, and ss.choose_n().rvs() raises for round= or reset=. rvs(uids, repeats=True) draws independently for UIDs that repeat in a call.

Time

  • Migration: ss.Duration is the class of a duration (was ss.dur), and ss.dur(3) with no unit (which was years) is ss.years(3); ss.Duration(3) needs a unit. ss.datedur is removed: whole ss.months(n) and ss.years(n) added to a date follow the calendar (ss.datedur(months=1) is ss.months(1)), and monthly dates from the 31st stay at the end of each month.
  • Migration: the Starsim 2 names ss.rate, ss.time_prob, and ss.rate_prob, deprecated in Starsim 3, are removed (ss.peryear(), ss.probperyear(), or ss.freqperyear() for a count per unit time). The time vocabulary is otherwise as in Starsim 3: ss.days to ss.years, ss.perday to ss.peryear, ss.probperday to ss.probperyear, ss.freqperday to ss.freqperyear, and the one-unit instances ss.day to ss.year; a probability per period is ss.prob(0.1, 'day') (and prints so). A frequency times a duration is a number of events, a rate times a duration a probability, and adding one to the other is an error.
  • Migration: the time between two dates (ss.date(2010) - ss.date(1990)) is ss.days (here ss.days(7305)). ss.days(1) + 1 raises, as ss.perday(1) + 1 does; float() of a rate or duration is refused (its number is .value). ss.date('2020.5') is an error, since a string with a decimal point could be the middle of the year or May (ss.date(2020.5) is the middle of the year, and ss.date('2020-05') is May). .days of a whole number of days is an int, and ss.days(14).weeks is exactly 2.
  • Migration: stop= is a time, read as the start is, and the length of the run is dur=, of the sim or an ss.Timeline (ss.Sim(start=2000, dur=10), a bare number in years in a sim of calendar years and else in the sim’s unit, or start='2020-01-01', dur=ss.days(90)); any two of start=, stop= and dur= define the third (ss.Sim(stop=2035, dur=10) starts in 2025), as in Starsim 3, and all three may be given if they agree, else a ValueError (with a start, Starsim 3 dropped the dur). In a sim with a calendar, stop=ss.years(10) is an error that suggests dur=ss.years(10). sim.new(stop=) replaces a dur=, and sim.new(dur=) a stop=.
  • Migration: the options date_sep, time_eps and warn_convert are removed; the step-count warning is ss.options.max_steps (0 turns it off).

Networks

  • Migration: ss.MFNet (and ss.SexualNetwork) is ss.SexualNet, between men and women by default; ss.MSMNet is ss.SexualNet(between='mm'), whose defaults are those of ss.SexualNet (give participation=0.1, dur=ss.lognormal(mean=ss.years(2), std=ss.years(1)) for Starsim 3’s). Its name is sexualnet unless given one, so a beta keyed 'mf' is keyed 'sexual'. dur= (was duration=, also as net.pars.dur); rel_part_rates= is removed (its multiplier goes in participation=). ss.SexualNet forms partnerships itself; a subclass that forms its own overrides step().
  • Migration: ss.RandomExactNet is ss.RandomNet(method='exact'), ss.RandomSafeNet is ss.RandomNet(method='safe') (with n_contacts twice its n_edges), and uniform_targets=True is method='uniform'. ss.RandomNet(dur=) is in the sim’s unit when a bare number, and its contacts last one step by default (Starsim 3’s dur=0).
  • Migration: ss.StaticNet, ss.ClusterNet, and ss.HybridNet moved to the library (ssl.); ssl.HybridNet makes schools and workplaces clusters by default (clustered=False for random networks) and takes n_contacts= (was contacts=). ssl.ErdosRenyiNet is removed (ssl.StaticNet(p=) is a fixed random graph), ssl.StaticNet keeps a graph it is given as nx_graph (was graph), and ssl.HouseholdNet(update_freq=) is removed (give the network its own dt=). ss.MaternalNet is ss.PrenatalNet (named prenatalnet, so a beta keyed maternalnet is now prenatalnet).
  • Migration: who takes part in a network is who=, a condition over people (was age_range=): ss.RandomNet(who=(ss.person.age >= 15) & (ss.person.age < 50)), ssl.ClusterNet(who=), and net.eligible(who=). ss.AgeGroup is removed: a group of a mixing pool is a condition on people, e.g. (ss.person.age >= 5) & (ss.person.age < 15).
  • Migration: a route takes no beta= (ss.RandomNet, ssl.ClusterNet, ssl.HybridNet, ss.MixingPool, ss.networks.MixingPools): a route’s transmissibility is the disease’s, by route (ss.SIS(beta=dict(randomnet=0.05, h=0.15))); ssl.HybridNet’s Covasim weights are ssl.HybridNet.rel_betas (dict(h=3, s=0.6, w=0.6, c=0.3)), and ssl.HybridNet.betas(beta=ss.perday(0.016)) is a disease’s beta by layer (ss.SIR(beta=ssl.HybridNet.betas())); ssl.HybridNet(beta=) raises, naming it. ss.MixingPool(diseases=) is removed (a beta of 0 keeps a disease off it). The weight of each edge is weight (was beta: self.append(p1=, p2=, weight=), net.edges.weight).
  • Migration: mixing pools take source=/target= (were src=/dst=) and n_contacts= (was contacts=); ss.MixingPools is ss.networks.MixingPools, and ss.Route is ss.networks.Route. Network.validate(force=) is removed (it did nothing); net_beta() is called for a disease written as code as net_beta(disease_beta=None, inds=None, disease=None).
  • Regression: an edge transmits with probability 1 − (1 − p)^(rel_trans·rel_sus·weight·modifiers), where p is the network’s chance for the step (net_beta()), rather than Starsim 3’s p·rel_trans·rel_sus·weight: every factor scales the hazard, so a contact infects at most once in a step, and an effect gives the same answer whether it is in beta, rel_sus, or a modifier. Results differ from Starsim 3 where a factor is far from 1 and the chance per contact is large; they agree as the chance per step goes to 0. A chance of 1 per contact stays 1 under any factor above 0. As an ODE on a network, each transmission (e.g. a reinfection at a relative risk) saturates by its own rate, and each source state by its own coefficient, as agents do.
  • Migration: a beta pair on ss.SexualNet (Starsim 3’s beta={'mf': [m2f, f2m]}) is ss.peract(m2f=..., f2m=...), a probability per act in each direction (net.directions lists them); a mother-to-child network transmits one way, so a pair [b, 0] on it is b. A beta pair in a declared disease raises.

Demographics and pregnancy

  • Migration: rate_per= (was rate_units=, now 1000 rather than 1e-3, or 1) and columns= (was metadata=dict(data_cols=...)) on ss.Births, ss.Deaths and ss.Pregnancy.
  • Migration: ss.Pregnancy is declared (below), with Starsim 3’s arguments, renamed where they were unclear: p_female= (was sex_ratio=, as on ss.People and ss.birth()), children= (was embryos_per_pregnancy=), loss_rate= (was p_loss=, now a hazard, e.g. ss.probperweek(p)), and preterm=ss.preterm(threshold=, very_threshold=, rel=, by_age=) (were preterm_threshold=, very_preterm_threshold=, rr_ptb=, and rr_ptb_age=); fertility_rate= also takes a table of rates, a place (fertility_rate='Nigeria'), or a function. p_maternal_death= is the chance of a maternal death at delivery (agents only), an ss.bernoulli that can be set during a run (preg.pars.p_maternal_death.set(p=0.002)), as in Starsim 3; the new maternal_death_rate= is the hazard of a maternal death while pregnant, and both can be given (maternal_death= raises, naming the two). Its parameters are named as its keywords (preg.pars.p_maternal_death, preg.pars.preterm.by_age). slot_scale=, min_slots=, p_survive_maternal_death=, and trimesters= are removed, since nothing read them (the gestational age is preg.P.time_in). The old keywords raise, naming the new ones.
  • Migration: Starsim 3’s pregnancy written as code is removed, with its methods (make_embryos(), process_delivery(), …: a subclass that overrides them does nothing, and the migration script lists them) and its arrays: preg.pregnant is preg.P, ti_pregnant is ti_P, the scheduled delivery ti_delivery is ti_exit_P (fractional: np.ceil(preg.ti_exit_P) is Starsim 3’s step), tri1_uids and the like, gestation, n_miscarriages, n_stillbirths, and carrying_multiple are removed (the gestational age is preg.P.time_in), and preg.breastfeeding is preg.lactating. Its seams are its rates (fertility_rate=, loss_rate=, infant_death_rate=, and conception_rate()), ss.modify() of its transitions, and its events.
  • Migration: ss.PregnancyPars is removed (ss.Pregnancy().pars has the parameters). add_conception_callback(fn) is preg.add_callback(preg.conception, fn), and add_delivery_callback(fn) is preg.add_callback(preg.birth, fn), called with the newborns (their mothers are sim.people.parent[newborns]).
  • Migration: People.find_children() and the pregnancy’s find_unborn_children() are removed (ss.uids(ppl.auids[np.isin(ppl.parent, parents)]); fetuses as agents are preg.fetal), and ss.parse_age_range() and ss.utils.apply_age_range() moved to STIsim (an age band is a condition, (ppl.age >= 15) & (ppl.age < 25), or a stratification). People.filter() and ss.Filter are removed (use the states as arrays and conditions, e.g. (ppl.female & (ppl.age > 5)).uids).
  • Migration: sim.people.get_person(5) (was sim.people.person(5)); ss.Person is ss.people.Person. ss.People.age and the like are not conditions: a condition on people written with no people made is ss.person.age < 15 (also .female, .alive).
  • Migration: ssl.mnch.treat_pregnant(disease=, coverage=, who=, when=, growth_reversal=, timing_reversal=) is a delivery (was start_year=, end_year=, and its own probabilities), and ssl.mnch.NeonatalSepsis(p_at_birth=) (was init_prev=, which also infected 30% of the people at the start; now nobody).

Interventions and products

  • Migration: Starsim 3’s interventions and products are removed (ss.routine_vx, ss.campaign_vx, ss.simple_vx, ss.routine_screening, ss.campaign_screening, ss.routine_triage, ss.campaign_triage, ss.treat_num, ss.Vx, ss.Dx, ss.Tx, and the base classes ss.RoutineDelivery, ss.CampaignDelivery, ss.BaseTest, ss.BaseScreening, ss.BaseTriage, ss.BaseTreatment, and ss.BaseVaccination). A delivery of a product replaces them: ss.vaccinate(), ss.screen(), and ss.treat() (below), of the products ss.Vaccine(efficacy=), ss.Diagnostic(), and ss.Treatment(cure=). Who is eligible is who= (was eligibility=), and when is when=, start= and stop= (were years=, start_year=, and end_year=): when=['2020'] for a campaign, when=ss.schedule(every=ss.years(1), start='2005') for yearly rounds; prob=p is coverage=. A product written as code is a subclass of ss.Product with administer(self, uids), returning its outcomes (e.g. dict(positive=uids)).
  • Migration: an intervention written as code takes who= (was eligibility=, which raises a TypeError naming who=): a condition on people, a state by its path, or a function of the sim (and of the intervention, as self) returning UIDs or a mask, read as a delivery’s who= is. check_eligibility() still returns the UIDs of those alive it holds for (everyone alive with no who=), and can still be overridden: Isolate(who='flu.I'), then uids = self.check_eligibility() in step().
  • Migration: ss.seasonality is removed: ss.modify(dis.rel_trans, multiply=ss.seasonal(amplitude=a, peak=p)), or beta=ss.seasonal(mean=..., amplitude=..., peak=...).
  • Migration: ssl.ART(coverage=, who=, when=, art_delay=) is a delivery (was ART(year, coverage)), and ssl.HIV(death_rate=) (was p_death=, a rate).

Calibration

  • Migration: Starsim 3’s calibration is removed (ss.Calibration, ss.CalibComponent, and its likelihoods ss.BetaBinomial, ss.Binomial, ss.DirichletMultinomial, ss.GammaPoisson, and ss.Normal, and linear_interp, linear_accum, and step_containing): give bounds on the parameters (ss.par(0.1, bounds=[0.01, 0.3])), the data on the sim (ss.Sim(..., data=df), with columns named after results), and call sim.fit() (below). A likelihood is a distribution, by series (sim.fit(likelihood=dict(prevalence=ss.normal(std=0.02)))) or on an ss.outcome(). ss.Samples and ss.Dataset are removed (ss.parallel(sim, n_runs=n) gives the runs). seaborn is no longer installed with Starsim.

Namespace and options

  • Migration: internal helpers are in their submodules, not at the top level: ss.modules.Base, ss.loop.Loop, ss.arrays.IndexArr and BaseArr, ss.modules.module_map, module_types and find_modules, ss.results.validate_sim_data, ss.utils.standardize_data and shrink, ss.networks.find_contacts and standardize_netkey, ss.settings.load_fonts, and ss.debugtools.metadata, diff_sims, check_sims_match, Profile, Diagnostics, and the mock_* functions. ss.Debugger, ss.check_version(), ss.check_requires(), and Module.requires are removed. The API index (llms.txt) lists an explicit set of supported names, marking those for extending Starsim.
  • Migration: import starsim no longer imports starsim.library: import starsim.library as ssl, then ssl.X (was ss.library.X).
  • Migration: ss.uids.cat() and concat() are concatenate(); Pars.dict_update() and Arr.notnanvals are removed.
  • Migration: the options license, numba_indexing, check_method_calls, install_fonts and sep are removed, as is ss.options.set(use=); new options are threads (Starsim’s own threads, for the passes over agents and the particles of a particle filter, STARSIM_THREADS; 0, the default, for as many as Numba has), blas_threads (BLAS’s, STARSIM_BLAS_THREADS), kernels ('auto', 'rust', 'numba' or 'python'; STARSIM_KERNELS), jax, keep_times, max_steps, max_years, max_joint, and uncompiled_warning.
  • Migration: Starsim 4’s errors and warnings: ss.errors keeps 5 error classes (CapabilityError, AlreadyRunError, DistNotInitializedError, DistSeedRepeatError, BooleanOperationError) and a few warnings (ImportIncomplete, LossyExport, NotConverged, AtBound); every other error is a built-in exception (ValueError, KeyError, TypeError, …). Errors name current spellings only, and “did you mean” lists only the closest names.
  • Migration: a name of Starsim 3’s that Starsim 4 does not have raises an error saying what replaces it; these are all in ss.v3_deprecations, by class, method, and name. ss.SimPars is defined in starsim/simpars.py.

New features

Declared models: states and transitions

  • ss.Disease can be built from ss.State and ss.Transition objects, written with operators (S >> I @ beta*I) or explicitly (ss.Transition(S, I, rate=beta*I), with source=, dest=, rate=, dur=, p=, stages=, who=, when=, move=, increment=, min_stay=, acquisition=, and per_strain=), in four forms that build the same objects: a class with states and transitions in its body, ss.Disease(...) with them as arguments (positionally, as a list, or as transitions=, outcomes= and results=), define_states() and define_transitions() in __init__() (for structure that depends on the arguments, e.g. transitions made in a loop), or ss.Sim(infection, recovery), which makes one disease whose results are also at the top (sim.results.I).
  • States: ss.states('S I R'), ss.states(3), or ss.states(S=0.99, I=0.01, R=0) with the fraction who start in each; include_sum=True also gives their total (S, I, R, N = ss.states('S I R', include_sum=True)), so that a rate can be written with the fraction, beta*I/N, which is beta*I.frac. A rate is read as written: a bare state (beta*I) is contact, through a network or contact matrix if there is one; beta*I.frac (or beta*I/N) the fraction of people in I; and beta*I.n the number, density-dependent. A contact matrix acts on all three; a network only on contact, so a sim with networks refuses a transmission at a rate on the fraction or number, rather than ignoring them. Transmission at a rate on the fraction or number is a transmission like any other: named infection if it is the only one, with beta as its parameter, and 1% infected at the start by default. The first argument of ss.State is its label (S = ss.State('Susceptible')); its name is taken from the variable or keyword it is given as (name= sets it otherwise), and a state that already has a name, given under a keyword of another name (ss.Disease(..., first=S)), raises rather than being renamed. ss.unborn is where births come from, ss.dead where deaths go, and ss.outside where people arrive from and leave to alive (counted as arrivals and emigrants), and where an amount that is not people comes from or goes to (e.g. shedding, ss.outside >> W). States that no transition connects to the first state (a water reservoir, mosquitoes) are counts, not people, and are real numbers under every solver; their init= can read the number of people (init=dict(Sv=m*N)).
  • Where people start is default_state=, a state or its name (default_state=S; in a class, default_state = 'N'), else the state named S or susceptible, else the one state transmission infects people from, else the state nothing moves people into; describe() says when it was guessed. A transmission’s starting values: init_prev= is always a fraction (above 1 raises, pointing to init_n=), init_n=10 seeds 10 people through the transmission (a parameter that can be fitted), init=dict(I=0.01) and init_n=dict(I=10) are by state, and given to an instance they replace those of its class. With none, a disease with a transmission seeds 1% (marked as a guess). init_prev= takes a function of the sim (and the agents), or an ss.by over people’s own properties.
  • Rates: a rate can be written as the whole flow, as most frameworks write it (S >> I @ beta*S*I/N is beta*I.frac, frequency-dependent; beta*S*I is beta*I.n, density-dependent), and can use NumPy functions of counts (np.clip(0.5*I.frac, 0, 0.02)), comparisons (Q.n < 20), a set of states (Ih/(Ia | Is | Ih); | and + both mean a set of states), and properties of people (rate=ss.peryear(1)*expit(b0 + b_urban*ss.person.urban), as agents). In a rate, nothing divided by anything is nothing (the infectious fraction of an empty group is 0). A rate on another disease’s state counts that disease’s state. An incidence in a rate raises an error that says what to write instead.
  • Transitions: a duration after @ is a time in the state (I >> R @ ss.days(10)), a number given as a duration is the mean of an exponential (describe() marks it), and a distribution is used as given (as Erlang stages in a compartmental run); dur=0 is immediate under every solver. A split is a dict of destinations and probabilities (I >> {ss.dead: p_death, R: None} @ dur, None for the rest); each destination has a flow of its own (res.cin.X, also by group), which can be the source of an outcome and a data column. Separate transitions out of a state with p= are branches, decided as people enter it, each with its own duration; p=None marks the one that takes the rest, and branches that don’t sum to 1 without one raise. After @, a dict gives a transition’s keywords (I >> R @ dict(dur=ss.days(8), p=None)). Several sources are a sum or a set ((Ia | Iw) >> R). min_stay= waits for a minimum stay in the source; X.time_in (the time in a state) can be read in a rate or in p= of a split out of it; acquisition=True says outright whether a transition is an acquisition. A transition with no rate, duration, p= or trigger is an error when the disease is built; @ 0 is the intentional form (e.g. a cure only a treatment drives). In a class body, a name starting with an underscore is a private intermediate.
  • Triggers: ss.Transition(PP, NP, when=ss.on(preg.delivery, delay=ss.months(6))) (of which @ ss.on(...) is the shorthand) moves people when an event happens to them: a transition (tr, tr.to(B)), entering or leaving a state (X.enter, (A | B).exit), or an outcome of a product (when=drug.failed), under every solver. who= restricts any transition to some people. A trigger on an event of a module that isn’t in the sim (e.g. ss.ref('pregnancy.birth') with no ss.Pregnancy) never fires, with a note.
  • Events and memory: every transition, delivery, outcome, product and ss.Event has .ever, .n_times, and .since (who=vx.ever & (vx.since >= ss.months(6))); clocks are X.time_in and X.progress; X.stays is how long each person who has been through a state stayed there (e.g. np.mean(seir.E.stays)). Counters: increment='parity' on transitions, deliveries and transfers. Attributes: ss.attr(dist, when=..., combine='max') (a value per person, set at an event) and ss.lifelong(func) (a value per person for life, e.g. by sex). The times of agents are ss.TimeArr (float32 step numbers, NaN for never, with .years): ti_x.due() gives whose time has come, ti_x.isclose(ti) whose time is at the step nearest ti, and ti_x.eq(t) exact equality, while ==, != and isin() raise, since a time drawn between steps never equals one exactly. A function given as a rate, parameter, condition, coverage, who=, or init_prev= takes any of self (its module), sim, and uids, by name, as a distribution’s does.
  • Conditions: .n of a state or condition is the number it holds for under every solver ((hiv.L | hiv.A).n), with .frac and .total (the whole population, every place); conditions are written against the people and modules themselves ((ss.person.age > 65) & sir.S) and evaluated as the sim runs, or are functions of the sim, and can combine a function with a condition. ss.ref('hiv.I') names a state by its path.
  • ss.par() is a named parameter with bounds, a prior, a label, a source and a doc (beta = ss.par(ss.perday(0.3), bounds=[0.1, 1])); bounds make it fittable. A value may vary in time (ss.timeseries(), ss.seasonal()), by group (ss.by(age=[...])), or between people (a distribution, which as agents gives each agent their own value). ss.pars() takes such values in place of a rate or duration.
  • A declared disease can write any of its transmissions as code: its own transmit(transition) gives who is infected in the step (as agents, UIDs; as compartments, the number in each group), and super().transmit(transition) is what the solver would do. ss.move(sir.I >> sir.R, p=0.5) (also p=, n=, uids=, who=, and disease.move()) is a one-off move from code, the same under every solver.
  • A disease with transmission has the results infected (the number in its infected states) and prevalence (their fraction of everyone in its states), unless it defines its own. The infected states are those of the force of infection (E and I in S >> E @ beta*I), or those the model names: ss.State('Severe', infected=True), ss.states('S E I H R', infected='E I H'), or infected = E + I + H. A disease without transmission (e.g. a staged cancer) has neither unless it says which states are infected. A transition divided by something in a result is its flow (incidence = infection/S), by group too, and ss.outcome() declares reported quantities (with a weight, delay, dur=, reporting period, and likelihood), stratified like the flows they come from.
  • Display: describe() (in sections, in color in a terminal, as tables in a notebook) shows each parameter’s bounds, each outcome’s definition, each stratification’s split, distributions as they were given, and in plain words what each transition does and which solver can run it, and why; diagram() draws a Matplotlib figure, with values on the arrows (diagram('text') gives a Mermaid flowchart, which draws itself in a notebook and in Quarto); equations() writes the model, saying what its symbols stand for; sim.diagram() and sim.equations() show every disease of a sim, and how they act on each other. sim.describe() also says what runs where, why a compartmental run is taken a step at a time if it is, and each rate per step (it had verbose=, and sim.kernel_reason() and sim.kernel_only(), which are removed). describe(), equations(), sim.what_modifies(), and sim.check_dt() return text (ss.display.Text) that shows itself; sim.check_dt() says whether the step is too long for the model’s rates.
  • Reproduction numbers: disease.R0() (printed when a run starts, with R at the start when people start immune), disease.Rt() (R at each time point of a run, from a run of any solver); density-dependent transmission and vector models have an R0.
  • For modules written as code: categorical states (ss.CatArr, read and set by category), schedules (ss.schedule(): dates, a cadence with every=, or a function of the date; a lone duration by position is an error asking for every= or at=, and ss.schedule() with no arguments is due at every step), and on_birth() and on_death() hooks in any module.
  • ss.Sim(keep_times=True) (or ss.options.keep_times) keeps the times of entering every state for one sim; by default a declared disease run as agents keeps only those it needs.

Solvers

  • ss.Sim(solver=...) runs a declared model as an ODE, a chain binomial, by Gillespie’s method, by tau-leaping, or as agents: ss.solvers.ode(), binomial(), gillespie(), tau(), and abm(), each with only its own options (solver=ss.solvers.ode(integrator='euler', substeps=4, dwells='exact'), ss.solvers.binomial(substeps=1), ss.solvers.abm(step='wholestep')); a name (solver='ode') gives that solver with its defaults. The default, 'auto', uses an ODE if every part of the model has a compartmental form, and agents otherwise, and says why (ss.solvers.choose_solver(), ss.solvers.why_not(), and ss.solvers.solver_table(), the one table of rules). sim.solver is the solver that runs (isinstance(sim.solver, ss.solvers.abm)), and sim.pars.solver is 'auto' or a solver.
  • As agents, ss.solvers.abm(step='midstep') (the default) lets people who arrive in a state during a step face its rates for the rest of it; step='wholestep' takes rates over whole steps, for those in the state at the start of each step, as code with ss.bernoulli() does (faster, a little less exact at a coarse step); and step='events' (a prototype) takes agents event by event in continuous time within each step, exact for values constant within a step, with its own random numbers per agent and a Rust copy. A module’s own solver is ss.Disease(..., solver=ss.solvers.abm(step=...)), and a class can suggest one (suggested_solver = ss.solvers.abm(step='wholestep')), which a sim’s solver that names a step overrides.
  • The ODE takes RK4 steps, with substeps where compartments turn over fast, and a step too fast for RK4 by Dormand–Prince 5(4); a fixed delay (ss.constant as a dwell) is a conveyor of whole steps, exact rather than approximate. The chain binomial takes substeps so that about a tenth of a compartment at most leaves in one (substeps=1 for whole steps, as discrete-time models are written). Gillespie takes tau leaps when an exact run would take more than about a second, with a note, and places that do not affect each other are always run exactly.
  • Every compartmental run is taken by compiled kernels (Numba, or their Rust copy), whole where it can be (sim.describe() says why not); the kernels compile once per install (starsim init compile, or ss.compile()), say so as they start (ss.compile() with one bar of its progress), and require_kernels=True raises rather than falling back to Python. @ss.jit compiles users’ own code with Numba, and raises when it can’t (nopython=False runs it as Python).
  • approximations='note', 'allow' or 'error' says what to do when a solver approximates part of the model (e.g. a non-exponential dwell as Erlang stages, a trait at its mean, a network as homogeneous mixing); the notes are kept in sim.notes.
  • Code beside a declared model: an analyzer, a plain ss.Module, a function given as a module, and a declared disease’s own step() run beside the compartments without changing them from an ODE; a function that needs agents says so when it runs, and compartmental = False on a module says it needs agents.
  • Code means the same under every solver: people.n (the same as people.alive.n) is the number of people alive (as agents, the people they stand for), as .n of any state is (sir.I.n, and a state of a module written as code too), and sir.I.frac, (sir.I | sir.R).frac and (sir.I & (ss.person.age < 15)).n work as agents as they do as compartments. people.alive.sum() and .count() count agents, so they raise in a compartmental run, naming people.n. len(people) is the number of rows: the agents, or in a compartmental run, the groups.
  • Code can add and remove people under every solver: ss.move(sir.I, ss.dead, p=0.1) (or to ss.outside, who leave), and ss.move(ss.unborn, sir.S, n=100) (or from ss.outside, who arrive). They leave or join every disease: as agents, agents die at the end of the step or are made in each other disease’s first state; in a compartmental run, diseases tracked jointly lose and gain the same people, and a disease run apart loses them from its states in proportion, by group (exact on average, since its states do not depend on the others’). Added people are placed in groups as declared births and arrivals are.
  • ss.Transition(..., noise=) is drawn by tau-leaping (for each leap) and as agents (once a step for each group) too, not only by the chain binomial; only the exact (Gillespie) solver refuses it.
  • The class that tracks diseases jointly in a compartmental run is ss.solvers.JointStates (in solvers/joint_states.py; was Crossing, in crossing.py), kept in sim.joint_states (was sim.crossings) and engine.joint (was engine.cross).
  • Migration: people.n_alive (the number of agents alive) was removed, with an error saying what replaces it: people.n, or people.alive.count() for agents.

Stratification, places, and mixing

  • sim.stratify(age=[0, 15, 65]) (or sir.stratify(...), or stratify= of ss.Sim and ss.Disease, a dict of dimensions) splits a model into groups by a property of people, with aging between age groups (age edges may be durations), results by group (res.by('age'), res.select(age='0-14')), and values by level (ss.by(risk=[1, 3]), for one or several dimensions, a table, or an array with named axes, with arithmetic). Fractions are the levels as a dict (risk={'low': 0.8, 'high': 0.2}); other options are in ss.levels(levels, states=, who=, kind=, mobility=, fractions=), e.g. a dimension of some states only, or some groups only. ss.transfer() moves people between levels at a rate or with a duration, and sim.groups holds the groups.
  • Contact matrices: ss.contacts(M, ages=[0, 15, 65]) for a matrix by age (row i the contacts of a person in group i, as a contact survey has it), ss.contacts(risk=M) for another stratification, ss.contacts(age=A, area=B) for a product of one matrix per stratification, dims=['sex', 'risk'] for one matrix over several, and ss.contacts(10) for homogeneous mixing; ss.contacts('Kenya', layer='home') gives a country’s matrices, from Prem et al. (2021), bundled, or Prem et al. (2017) where 2021 lacks the country.
  • Places: a stratification by place (sir.stratify(patch=ss.geo.geography(...))) mixes people within each place, with commuting, visiting, or migration between places from a movement model (ss.geo.gravity(), ss.geo.radiation(), with away= for the fraction of time away) or a matrix (ss.geo.Mobility()), or with the force of infection itself moving between places, as in LASER (kind='force'); ss.geo.register() is a register of people moved between places on given dates, as in SimInf. As agents, a contact matrix, a mixing pool and a random network act within each place. ss.geo.geography() takes a country and level, a GeoDataFrame, or a .shp, .geojson, or .gpkg file; res.map() draws a result on a map of its places (at given dates, at several times, or as an animation), and geo.plot() draws a geography. Movement can be fitted (ss.geo.Mobility(away=ss.par(...)), and the gravity model’s parameters).
  • Diseases tracked jointly: a condition in one disease on a state of another (ss.modify(hiv.rel_trans, multiply=3, who=syph.I)) works in a compartmental run too, with the states of the two tracked jointly, as a dimension of the sim for each (hiv.results.I.by('syph')).

Strains

  • ss.Disease(strains=ss.Strains(...)) gives a disease strains, which people can carry several of at once (max_strains=2, with sets such as 'DS+INH'), with fractions= for the fractions seeded; someone who carries several passes on each in proportion to its infectiousness. A transition can change a person’s strains (move=DS >> (DS, RIF), strains.change()), per_strain='each' gives an outcome for each strain carried and per_strain='first' each strain its own rate, and ss.immunity(due_to=..., protection=..., combine=) gives the protection between strains, including from memory of past strains, under the compartmental solvers too. ss.Delivery(what=S >> E, strain='alpha') imports a strain. Strains with courses of their own are one disease per strain, connected by a connector; ssl.Dengue() is a set of serotypes, with results sim.results.den1 and so on.
  • A disease written as code can have strains too: ss.AgentDisease(strains=ss.Strains(...)) (or define_strains(), or strains= to a subclass that passes its keywords to update_pars(), e.g. ssl.AgentSIR(strains=...)). Each person carries one strain at a time (dis.strain), passed on at each strain’s rel_trans from a source drawn among those who reached them, with protection from ss.immunity(due_to=..., ...) (or the disease’s own strain_sus()), by the same code and random numbers as a declared disease’s strains; strain_value(key, uids) reads a strain’s values, and infect(uids, strain='b') infects with one from code.

Partnerships and networks

  • ss.DegreeNet() is a network of partnerships that form and dissolve, described by targets for the number of partners people have at a time (n_contacts, also by level as EpiModel’s nodefactor, concurrency, max_contacts), the fraction of partnerships within a level of each of several properties (match=dict(risk=0.9, region=0.7)), and how long partnerships last, as in EpiModel; net.check() compares the network it makes with the targets. ss.SexualNet is its subclass, adding debut and participation, so both take the same targets, between men and women, men (between='mm'), or two groups. With acts=, beta is a probability per act (ss.peract), the same at any time step. memory= keeps the partnerships that ended within it, so contact tracing reaches past partners.
  • ss.SexualNet and lasting ss.DegreeNet run under the ODE, binomial, and Gillespie solvers by the partnership approximation (close to agents for risk groups with short partnerships, too low with concurrency); ss.RandomNet and other individual-level networks run as homogeneous mixing, each edge transmitting at most once in a step, as it does as agents.
  • ss.RandomNet(remove_self_loops=, memory=), ss.RandomNet(n_contacts=0) makes no edges, ssl.StaticNet() with no graph makes its random graph without NetworkX, and the library adds ssl.SmallWorldNet(n_contacts=, rewire=), ssl.ScaleFreeNet(n_contacts=), ssl.GridNet, and ssl.GeoNet; ssl.HouseholdNet’s households evolve by default only if the sim has ss.Pregnancy, so ssl.HouseholdNet(dhs_data='default') works alone. Network.mean_contacts(); networks can be given by name (networks=dict(main=..., casual=...)).

Interventions

  • Deliveries: ss.vaccinate(), ss.screen(), and ss.treat() (and ss.Delivery(), for a move or no product) deliver a product to people: in rounds (when=['2020'] or when=ss.schedule(every=ss.years(1), start='2005', stop=...)), routinely (with no when=: each person is offered it once, at the first step they are eligible), at an age (who=ss.at_age(ss.months(9))), or on an event (when=sir.infection, when=screening.positive, or when=ss.on(event, delay=...)), within a window if given start= and stop= (shorthand for when=ss.schedule(start=, stop=), and with any when=, e.g. ss.screen(test, when=onset, start=2001, stop=2010), Starsim 3’s on=onset, start=2001), to those in who= (a condition, or a function of the sim; one that reads only the time raises, pointing to start= and stop=), with coverage= (a fraction, an ss.by, a typed rate, or a function) and capacity= (a number, a rate, or ss.capacity(n, priority=..., queue=...) for who is served first and whether the rest wait). They make their own product from its keywords (ss.vaccinate(efficacy=0.9, coverage=0.8, when=['2025-02-01']), ss.treat(cure=0.95, ...), ss.screen(sensitivity=0.9, ...)), or take one shared by several deliveries. Each runs under every solver.
  • Care cascades: a follow-up is a delivery of its own, triggered by an outcome (ss.treat(drug, when=screening.positive)), and runs after it in the same step; a delayed one can be in a cycle of triggers (a test repeated a year after each negative result). trace= delivers to the contacts, in a network, of the people reached (contact tracing, partner notification; agents only), skip_reached_within= skips people reached less than that long ago, strain= imports a strain, and increment= counts the people reached.
  • Products: ss.Vaccine(efficacy=, leaky=True) (leaky by default; leaky=False fully protects a fraction), with efficacy=dict(hpv16=0.95, hpv18=0.9) by strain, doses=, waning (waning= a function of the time since delivery, or ss.boxcar(plateau=, decay=)), and vx.held as a condition; ss.Diagnostic(sensitivity=, specificity=, outcomes=dict(high_grade=..., ascus=..., normal=None), result_delay=, detects=), whose outcomes (positive, negative, and each result) are events; ss.Treatment(cure=, moves=), where cure= is a probability, a dict by state, or a dict by transition (cure={I + CIN >> S: 0.9}), and moves= moves people along named transitions (often undriven) of one or several diseases. Products act on diseases written as code too (tests detect infected, cures call clear_infection(), vaccines scale rel_sus and rel_trans).
  • Events: ss.Event(what=, who=, when=, start=, stop=, n=, p=, strain=, increment=) is something that happens to people (a move between states, on a schedule or when another event happens); ss.Delivery is an event with what interventions need.

Modifiers and conditions

  • ss.modify(target, multiply=, add=, set=, who=, when=, start=, stop=, until=) changes a named quantity (a parameter, a transition’s rate or one destination’s chance, sir.rel_sus, sir.rel_trans, a network’s contacts, a contact matrix) for everyone or the people in who=, at the times in when= (a schedule, or a time series as its amount: multiply=ss.timeseries({'2020-03-15': 0.5})) or between start= and stop= (ss.modify(school, multiply=0, start=ss.days(200), stop=ss.days(260)); a window given both ways raises), keeping it on for someone for until= once its condition holds; efficacy=, frac= and network= give a method’s use on one route (condoms, microbicides, needle programs). A modifier on a transition chosen with p= changes its probability, the other branches sharing the rest. Modifiers from several modules combine on a base value that none overwrites. sim.what_modifies() lists what acts on a quantity, and current_value() gives a value with its modifiers applied.
  • ss.modify() works on modules written as code: on their rel_sus and rel_trans, and on their parameters, which the module reads from self.pars (or self.current_value('x', uids) where it differs between people, listing it in allow_modifiers). A condition can name the arrays of a module written as code (who=hiv.cd4 < 200).
  • tr.incidence(over=, by=) is a condition on how many went through a transition over a window, as a fraction of the people at the same level of a stratification (MEmilio’s dynamic NPIs). A modifier, change or product that nothing would apply raises when the sim is built.

Demographics

  • Births, deaths, and migration of everyone are declared once for the population, ss.Sim(demographics=[ss.birth(rate=...), ss.death(rate=...)]), and run under every solver; the same people are born into and die from every disease. A rate of everyone is read as ss.Births and ss.Deaths read theirs (per 1,000 if above 1, else per person), and a place by name (ss.birth(rate='Kenya')) is ss.Births or ss.Deaths with its UN rates. A state’s births and deaths are transitions: ss.birth(state, rate=, when=, children=, p_female=), ss.death(state, rate=, dur=, lifetable=) (a life table is a death rate for each age band, by each person’s age, under every solver), ss.immigration(state, n=) (a number of people per unit time, at ages like the population’s), ss.emigration(), and ss.migration(rate=) (net migration of everyone at a rate of either sign, e.g. ss.migration(rate='Albania') for the UN’s rates).
  • ss.Births(birth_rate='Nigeria'), ss.Deaths(death_rate='Nigeria'), and ss.Pregnancy(fertility_rate='Nigeria') read a country’s UN rates (ss.data.rates(place, 'fertility')); tables take AgeGrp or AgeGrpStart, and life tables by year and sex. ss.Births and ss.Deaths note when the data’s years are more than 5 apart during the run.
  • ss.Pregnancy is declared: the states NP and P, with conception, abortion, losses by gestational age, delivery, maternal and infant deaths, preterm births (ss.preterm()), burn-in, tfr and asfr, parity, lactation (preg.lactating, read by ss.BreastfeedingNet), and the events conception, delivery, birth, and loss, under every solver; fetuses are agents when something reads them (fetal_agents='auto', ss.mother()). ss.mother(cond, include_born=True) reads a condition on the mother of anyone who has one. ss.PrenatalNet and ss.PostnatalNet need an ss.Pregnancy; it warns when given with ss.Births or ss.birth().
  • A death has one cause per person, kept in people.cause_of_death.

Results, summaries, and plots

  • sim.summarize() and msim.summarize() are tables of each result (over the runs, the median, a 95% interval, the mean, and the standard deviation, by scenario), in sensible units without rounding; msim.summarize(baseline='No intervention') and msim.diff('Baseline') give each scenario’s difference from a baseline, run by run.
  • res.select(age=[0, 1]) sums levels; select(to=, edge=, disease=) holds through take(), medians, quantiles, resample(), and .cum, across runs; res.by(dim) across runs is a result with a column for each level; sim.results.find(name) finds a result however it is spelled ('sis.infection.cum'); sim.results.I finds a result in a sim’s only disease, and msim.results.I in each sim’s one disease.
  • Results by group are kept at every step while they fit in about 100 MB a module, and beyond that at coarser periods, with a note (group_results=['I', 'cases'] keeps only the named ones). With dynamic rescaling, the people no agent stands for yet are counted (sim.results.unrepresented), so every ratio is of the whole population from the start.
  • sim.plot() and msim.plot() are drawn by one function: msim.plot() shows the median and a 95% interval of each result for each scenario, each scenario in the same color in every axes; both mark when interventions act (show_interventions=True), match data columns to results as fit() does, and show a flow against data at a longer period as its totals over the data’s periods. Plots return the figure when not shown, plot into a figure given with fig=/ax=, take filename= and log=True, and leave out results that never change; a module’s plot() takes 'states', 'flows', 'all', or names of results.
  • sim.to_synthetic_data('cases', resample='week') draws data from a run, as Poisson counts, in the form ss.Sim(data=...) takes (ss.SimData).
  • A run prints one line saying what it runs (Sim: sir, 10,000 people, as an ODE, days 0 to 100), R0, its notes, a progress bar redrawn in place, and a closing line with the time taken and each disease’s infections and peak; a MultiSim shows its progress. Dates print as 2000-01-07.

Fitting and inference

  • sim.fit() fits every parameter that has bounds (ss.par(..., bounds=), wherever it is used, including networks, the movement between places, a modifier’s amount, a value per level, typed values, and sim settings such as n_agents) to every result that has data, returning an ss.fitting.Fit. A method’s settings are given with it: method=ss.fitting.method('pmcmc', particles=500, chains=2), and a list of methods runs staged fitting. Methods: SciPy’s 'nelder-mead', 'l-bfgs-b' (with gradients in JAX), 'powell', and 'slsqp'; 'nuts' (the No-U-Turn sampler, BlackJAX), 'pmcmc' (particle MCMC on a stochastic compartmental sim, with ss.fitting.ParticleFilter), 'if2' (iterated filtering), 'abc-smc' (samples=), and 'optuna' (for agents). A fit with likelihoods for every series (on an ss.outcome(), or likelihood=) maximizes the likelihood, fitting the likelihood’s own parameters too; otherwise it minimizes loss=. Some series with a likelihood and some without is an error.
  • fit.samples, each parameter’s median, 95% interval, R-hat, and effective sample size, fit.diagnostics, fit.plot(residuals=True), fit.project(scenarios) (runs with the posterior’s draws, each with its own seed), and notes of parameters the data cannot tell apart; ss.fitting.profile(sim, 'beta') gives a profile likelihood, ss.fitting.compare_models() compares models on held-out data, sim.sensitivity() gives partial rank correlations, ss.fitting.loglik() scores a sim that has run, and sim.optimize(ss.fitting.maximize('all_vs', at=2030)) (or ss.fitting.minimize()) reallocates spending between programs. ss.parallel(sim, priors=True) draws the parameters from their priors.
  • NUTS and particle MCMC draw until the chains agree (R-hat at most 1.01) and every parameter has 400 effective draws (draws='auto', the default; ess=, rhat=, max_draws=, batch=), rather than a fixed number; a number for draws= takes exactly that many. NUTS learns a dense mass matrix for up to 50 parameters (the correlations between them too, which fitted parameters often have) and warms up for 300 draws. R-hat and the effective sample size are computed in NumPy (matching BlackJAX’s), so they need no JAX.
  • Whatever Starsim compiles (Numba’s kernels, JAX’s runs, gradients and samplers, the Rust kernels when built) is said with an estimate of how long it takes, and then how long it took.
  • Data by stratum is given in long form, with columns named after dimensions; data at a longer period than the step (weekly cases from a daily model) are compared as totals over the data’s periods.
  • From a review of fitting and inference (#1591):
    • The JAX log-density checks, when it is built, that the numbers it takes as linear in the fitted parameters (initial values, fractions, inflows) are linear, and refuses a model where they are not, rather than fitting an approximation (#1575).
    • Optuna scores each trial of a stochastic sim by the particle filter’s log-likelihood (200 particles, particles=) where the filter can run the sim, rather than by the mean log-likelihood of 4 runs; fit.measure says what was minimized; the default number of trials is 50 per fitted parameter, at least 100 (#1578).
    • method='pmcmc' samples a deterministic sim (an ODE, including one JAX can’t run) by random-walk Metropolis on the exact likelihood, and is the suggested posterior for an ODE without gradients, whose default best fit is L-BFGS-B by finite differences for more than two parameters (#1576).
    • method='abc-smc' is refused for a deterministic sim, and its convergence is not assessed (fit.converged is None), with the final tolerance, acceptance rate and number of generations reported instead (#1577).
    • NUTS chains start from draws of the Laplace approximation (its covariance doubled) at the best optima, and particle MCMC chains from different points of the scan, so that R-hat can see chains that fail to mix (#1581).
    • Particle MCMC and ABC-SMC keep a run with each draw (fit.trajectories), which fit.project() continues from the last data point; restart=True runs each draw from the start (#1579).
    • ss.fitting.compare_models() scores a stochastic model by the mean likelihood of n_runs= runs, not one run (#1580).
    • ss.fitting.prior_draws() draws from the prior truncated to the bounds, not clipped to them (#1582); ABC-SMC no longer makes 10⁸ seeds up front (#1583); ss.fitting.ParticleFilter restarts a sim that has already run, rather than filtering from its end; fitting a sim with a product’s effect (e.g. ss.vaccinate()) no longer fails when looking for parameters seen only as a product.
    • ss.Transition(..., noise=sigma): gamma white noise on a rate (as pomp’s rgammawn), drawn each step by the chain binomial in the kernels, so the particle filter, IF2 and particle MCMC see it; He et al. (2010) runs declared through the built-in filter. The camdl importer translates overdispersed() as noise=, translates events that add people as an inflow, and lists everything else it doesn’t translate (#1587, #1588).
    • Data on a flow by period whose last row starts at stop (so its period runs past the end of the run, a sign of data labeled by the period’s end) warn, rather than being dropped silently (#1589).
    • Slow developer tests are marked slow (pytest -m slow), replacing STARSIM_SLOW, with a particle-filter test against a brute-force likelihood (#1586); [jax] requires blackjax>=1.7 (#1584).

JAX and gradients

  • An ODE can be run in JAX, which sim.gradient() (derivatives of results with respect to parameters), sim.log_density(), gradient fits, NUTS, and ss.parallel() with many sets of values use without being asked; JAX is optional (pip install "starsim[jax]"), with ss.options.jax for the platform (the CPU by default, which runs these long loops of small steps several times faster than a GPU). What JAX compiles is kept for later sessions, in ~/.cache/starsim/jax.

Runs and scenarios

  • ss.parallel() runs sims with different steps, plotting each on its own time axis; sim.run() on a finished sim runs it again from the start, with any parameters changed since, and sim.restart(pars=, rand_seed=) takes it back to its start; sim.new() is a copy of the sim as it was when init() started, plus the changes given to it (for subclassed sims too, keeping sim.stratify()). Many runs of a compartmental sim are taken in the kernels in one call (ss.parallel(sim, n_runs=), pars=, priors=), and msim.runs has a rand_seed column.

Importing and exporting

  • ss.import_from(file) reads a model from EMULSION, odin (odin2 too), SBML, SimInf, flepiMoP, summer2, Atomica (with program books), camdl, EpiModel, epipack, epydemix, epymorph, or MEmilio, by writing it as Starsim code (sim.imported.code) and running it; it prints how each rate was read, and lists what it could not translate (ss.errors.ImportIncomplete, with a ready request for an AI assistant with the convert-model skill in sim.imported.prompt). ss.export_to(sim, 'sbml', 'sir.xml') writes the compartmental form of a sim for each of these frameworks (and Starsim’s own JSON, which ss.import_from('model.json') reads, running no code from the file), listing what the format cannot carry (ss.errors.LossyExport); each has been run in its framework and compared with Starsim’s (ss.converters.tested gives the versions). ss.converters.to_hub(msim) writes a forecast hub submission.
  • ss.load() and ss.save() are for Starsim’s own objects (sim.save() works with no filename, and after a run under every solver).

Named data

  • ss.population('Kenya', year=2024) gives a country’s population (size, age structure, and sex ratio, from the UN World Population Prospects), for agents or as the size and age split of a compartmental model; ss.geo.geography('Kenya', level=1) gives its administrative units, and ss.contacts('Kenya', layer='home') its contact matrix by age; a US state by name ('United States:Massachusetts') works too. ss.data.population() and ss.data.contacts() return dataframes labeled by age group, with the source in .attrs['ref']. These come from starsim_data, installed with Starsim.

Library

  • starsim.library (ssl) holds simple models of HIV, cholera, Ebola, measles, malaria, TB, COVID (by age, with MEmilio’s values for Germany), influenza, RSV, pertussis, dengue, typhoid, polio, syphilis, and gonorrhea, mostly declared; maternal and newborn health (ssl.mnch); more networks; and health economics (ssl.Costs, ssl.DALYs, ssl.discount(), and ssl.ICER). Starsim 3’s SIR, SIS, and SEIR, written as code, are ssl.AgentSIR, ssl.AgentSIS, and ssl.AgentSEIR.

Command line, AI skills, and docs

  • starsim init sets Starsim up: starsim init compile compiles the kernels ahead of time, starsim init ai (or starsim ai install) links the skills for AI coding agents into the project (--global for a short skill for all projects), and starsim init dev sets up a clone for development. starsim convert model.R converts a model between Starsim and another framework, or between two others (starsim import and starsim export are its shorthands; --ai has an agent do it), starsim help RandomNet prints a docstring or finds the pages that mention a term, and starsim ai "how would I model a leaky vaccine?" asks an AI coding agent. The command starts at once, importing Starsim only when needed. The skills are in starsim/.agents/skills/, installed with Starsim; they replace the starsim_ai plugin.
  • python -m starsim.converters.migrate <path> rewrites Starsim 3 code for Starsim 4 in place (also the code cells of .ipynb, .qmd, and .md files; --check only lists), and lists what needs a person; the migrate-starsim3 skill starts from it.
  • Docs: “Starsim 4 in ten minutes”, the migration guide (docs/migration/v3_v4.md), the tutorials and user guide ported to Starsim 4, and a “Coming from” page for each of 13 frameworks; llms.txt lists every tutorial and user guide page, with a summary and a link, and the whole API. Each built-in module’s docstring says whether it keeps common random numbers.
  • The test suite is split into the core tests (tests/test_*.py, about 400, under a minute in parallel) and the slower developer tests (tests/devtests/).

Changes that may affect results

Time and results

  • Regression: each time point is a period (#1548: 2020 means the whole of 2020). A step is taken from every time point, the last included, as in Starsim 3, so a sim from 0 to T in steps of 1 has T + 1 time points and takes T + 1 steps. Row ti of a result is the step from time point ti: a stock (I[ti]) is the state at its start, and a flow (recovery[ti]) the total over it, so R[ti+1] - R[ti] == recovery[ti]. Starsim 3 recorded stocks at the end of the step but labeled them with its start, so Starsim 3’s infected[ti] is Starsim 4’s infected[ti + 1]; infected[0] is now the number before any step, and the state after the last step is not recorded (sim.people is one step past the last row). Flows are as before.
  • Regression: during step ti, in every module and every phase, sim.ti == ti and sim.now is time point ti (the step’s start, at which it reads inputs that change in time); the first step is ti == 0, as in Starsim 3. Code in a step reads this step’s stocks at [sim.ti] (was [sim.ti - 1]), and the last step’s flows at [sim.ti - 1]. Analyzers run at the end of each step and see the people as at the next time point. sim.started says whether a run has begun.
  • Regression: a disease written as code dates an infection at the end of its step (self.ti + 1), as a declared disease does, and its seeds by the step before the first (self.ti == -1 in init_post()), where Starsim 3 dated them by the first step. ssl.AgentSIR, AgentSIS, AgentSEIR and ssl.HIV set ti_infected from it and schedule each course in whole steps as the agent engine does (rounding at random by each agent’s phase, keeping the mean), so infected agents are counted one step longer than in Starsim 3, an infection lasting one step transmits, and they agree with ss.SIR/ss.SEIR run as agents. Infection logs record infections at the end of the step.
  • Regression: with coarse steps, a model can differ by more than one step: in Starsim 3 a person could pass through several states within one step, each taken for a whole step; in Starsim 4 people infected in a step face the exits of the state they enter from the next step on (unless the exit is immediate), and someone moved by a rate during a step faces half a step of the rates out of the state they arrive in. For tbsim at dt=0.1 years, latent TB is 36% higher and active TB 13% higher than in Starsim 3; at dt=0.005 they agree.
  • Regression: res.resample() labels each period by its start (1 January for a year); a flow is summed over the period, a stock takes its value at the start (Starsim 3’s annualize() gave the mean), a ratio is averaged, and a running total (.cum, inclusive) takes its value at the end. Only whole periods are kept (a monthly run from 2000 to 2020 has 20 years), and a sim’s results resampled together are one dataframe of the periods they all have. Data at a time is read as the start of its period, so data dated by the end of each period (“week ending”) needs its dates moved back one period, while data by calendar year is compared with the year it names.
  • Regression: a result’s scale follows its kind by default (flows and stocks scale with the population, ratios don’t), so a coded prevalence is no longer scaled; a result whose kind is guessed warns. With agents of different weights (people.scale), results are weighted.
  • Regression: a bare number given as a time is a calendar year from 1800 on (was 1900), for long burn-ins, in a sim with dates as in one with numeric years, and otherwise a time in the sim’s unit counted from 0; one above 1000 and below 1800 warns that it is ambiguous. Whether a sim has a calendar is decided once, from its start. A duration given as a time (when=ss.days(10), start=ss.days(200), sim.run(until=ss.years(5)), ss.schedule(every=ss.years(1), start=ss.months(6))) is that long after the sim’s start in a sim with a calendar, as in Starsim 3, and a time on the sim’s axis from 0 in a sim with none; one of 1800 years or more (ss.years(2005), as in sim.t.tvec) is that calendar year. A periodic ss.timeseries keyed by durations (period=ss.years(1), e.g. monthly values) is read within each cycle from the sim’s start. A time outside the run is an error, and a time at the stop is applied in the last step. With no start, a bare stop is a year only if the unit is years: ss.Sim(unit='day', stop=3650) runs 3,650 days from 0.
  • Regression: the unit of a model’s bare numbers comes from the model (unit=, else its typed values, else dates), not from dt or a typed stop; bare numbers alongside typed values in different units raise, asking for unit=, and a sim written only as code honors unit=. A sim whose diseases are all declared counts time from 0 by default; a run in days with no stop lasts 100 days (50 in other units).
  • Regression: delivery rounds and dates on the step grid are timed by date with a tolerance, so yearly rounds on weekly steps no longer drift, and whole days are added to dates as days.

Diseases

  • Regression: ss.SIR, ss.SIS and ss.SEIR are declared textbook models (exponential dur_inf and dur_exp, p_death=0) with a respiratory infection’s defaults, in days: ss.SIR has beta=ss.perday(0.3) and dur_inf=ss.days(10) (R0 = 3 without a network; with one, beta is per contact), ss.SEIR adds dur_exp=ss.days(3), and ss.SIS has beta=ss.perday(0.2) and no waning immunity (R0 = 2). Starsim 3’s were in years (0.1 a year and a lognormal of 6 years for ss.SIR, with p_death=0.01), and are ssl.AgentSIR, ssl.AgentSIS (with waning= and imm_boost=), and ssl.AgentSEIR. A bare number given to them is in the unit of the model’s typed values, so ss.SIR(beta=0.1, dur_inf=10) is now per day and 10 days; dur_inf=[mean, std] raises; init_prev= is a fraction (Starsim 3’s ss.bernoulli(0.01) is 0.01, ss.choose_n(20) is init_n=20); and their results are named after the states (S, I, R, infection, recovery, death, infected, prevalence). ss.demo() gives its SIR beta=ss.perday(0.03) on its random network. The migration script flags each use.
  • Regression: in a declared disease, a duration given as a number is the mean of an exponential, as in an ODE, where Starsim 3’s library diseases used a lognormal; give the distribution to keep it. A single value for a distribution’s main parameter (dur_inf=ss.days(10) for a lognormal) keeps its other parameters, and warns when it leaves any that shape it.
  • Regression: ssl.Measles, ssl.Cholera and ssl.Ebola are declared (any solver, no network needed): beta is a rate per day (per contact on a network), their durations are the distributions they had (approximated by Erlang stages in compartmental runs; a single duration given is an exponential’s mean), each probability a number (p_death=0.005), and their results are by state. An instance takes extra states, transitions, and outcomes beside its class’s (ssl.Measles(cases=ss.outcome(0.5*ssl.Measles.onset))).
  • Regression: in Starsim 3, beta=0.05 was a rate per year but beta={'randomnet': 0.05} a probability per step; in Starsim 4 both are rates per unit time, so a dict copied verbatim is too low by a factor of about 1/dt (give ss.prob(0.05, 'month')). A beta for each route may give default= for the routes it does not name.
  • Regression: the time in a state given to code reads correctly: preg.P.time_in < ss.weeks(13) is a duration in the sim’s unit, NaN outside the state, and a declared disease’s ti_X is the index of the step the move happened in (ti_X == self.ti is “entered this step”).
  • Regression: ss.Pregnancy gives women 46 to 49 the 45–49 rate, reads losses per week of pregnancy, applies the relative risk of preterm birth to the preterm fraction, leaves the unborn out of the people counts and out of seeding, loses a pregnancy to a death of another cause, and counts women pregnant at the start in n_pregnancies; ss.Deaths no longer applies to fetuses, and the deaths that a module’s on_death() requests (such as the fetuses of a mother who dies) happen in the same step, with their causes; deaths claimed by two modules in a step are counted once, with one cause chosen at random.
  • Regression: ssl.FetalHealth’s means over a step’s births are NaN on a step with none (they were 0), and the mean of a result over a period leaves out NaN time points.

Demographics

  • Regression: ss.Births, ss.Deaths, and ss.Pregnancy read a rate as per 1,000 people a year if its typical value (one rate per year, or the median of a table or time series) is above 1, and per person otherwise, whether bare, typed, or a table: ss.Births(birth_rate=20), ss.peryear(20), and ss.peryear(0.02) are the same, as are the UN’s death rates by age per person and the same rates times 1,000. So ss.Births(birth_rate=0.02) is per person (in Starsim 3, 0.02 per 1,000); a rate above 1 is per 1,000, as in Starsim 3; rate_per=1000 or rate_per=1 says which. The defaults are 20, 10, and 100 per 1,000 a year, and the crude birth and death rates (cbr, cmr) are per 1,000 people a year, of the people alive at the start of each step.
  • Regression: ss.Births and ss.Deaths hold each period’s rate until the next (not the nearest year); ss.Births picks parents from women aged 15 to 49 (Starsim 3 picked them uniformly from everyone alive), and its newborns are placed by each dimension’s split. A newborn’s sex follows the sex drawn at birth; the traits of people made by code with people.grow() and no age= are drawn at the next step, from the age the code set (they were drawn from a default age).
  • Regression: people age in a model whose births are declared, and age only with births or deaths (or aging=True); with no population given, ages are the world’s.

Networks and transmission

  • Regression: on a network with acts (ss.SexualNet, Starsim 3’s ss.MFNet), rel_trans, rel_sus, source weights and modifiers multiply the hazard of each act, for a disease written as code as for a declared one (beta 0.03 with rel_trans=10 gives 0.263 per act), rather than the chance over all acts; net_beta() is given the scaled chance for each edge. Its acts are a rate (lognormal, mean 80 a year), kept as real acts per step, so results no longer depend on the time step, and a typed rate as beta on such a network raises. Mother-to-child networks transmit one way.
  • Regression: ss.SexualNet (Starsim 3’s ss.MFNet and ss.MSMNet) draws the age of debut for anyone who has none on each step (newborns, and people who arrive at any age, who in Starsim 3 never joined), and whether a person takes part when they reach their debut, so a participation probability by age is read at that age (it was drawn at birth); long runs with births have more partnerships. mean_degree and concurrency are per person, no longer multiplied by the population scale. ss.SexualNet(dur=5) (one value) is a mean, with partnerships ending at a constant rate, and a bare number is in the sim’s unit.
  • Regression: ss.networks.MixingPools(n_contacts=) is read as a contact survey, row i the contacts of a person in target group i (Starsim 3’s was [source, target]: transpose it); a number in it (with or without distributions elsewhere in the matrix) is the same for everyone in the group, as is a number in ssl.HybridNet(n_contacts=), as in ss.RandomNet (each was a Poisson mean). ss.networks.MixingPools(n_contacts=ss.poisson(M)) gives Starsim 3’s per-person Poisson contacts, with no pool for each pair of groups, as does ss.contacts(ss.poisson(M), ages=[...]), or for a country, ss.contacts(ss.poisson(ss.data.contacts('Kenya'))); a negative number of contacts drawn is an error.
  • Regression: ss.MixingPool with a disease written as code multiplies the hazard by the infectious fraction before turning it into a probability, as a declared disease does (it gave 0.33 instead of 0.4 per day for β = 0.4 per day).
  • Regression: infectious people placed at the start transmit for their whole stay on a network or a mixing pool (they lost the first step, and with a one-step stay never transmitted).

Random numbers and runs

  • Regression: as agents, people who arrive by a declared inflow into a state (e.g. ss.immigration(a.I)) are put in that disease’s state only; another disease’s state of the same name (b.I) got them too, rather than its first state.
  • Regression: as agents, ss.move(..., n=) rounds the number of agents at random (it was to the nearest), so a fraction of an agent each step moves the right number of people on average.
  • Regression: random draws differ from Starsim 3, so results with a seed agree statistically, not number for number: declared models as agents draw by stream, step, and round of moves, keyed by each transition’s name (STARSIM_CHECK_DRAWS=1 checks that no agent is drawn twice from a stream in a round), and each stochastic compartmental run draws from a generator of its own module, in an order set by the names of its states and transitions. Each run of ss.parallel() draws from its own seed, and each posterior or prior draw in a sweep is a run with its own seed.
  • Regression: ss.Sim(even_draws=True) spaces each call’s random numbers evenly for agents, which reduces the variance between runs (about 3 to 10 times in final size for a simple SIR), though outbreaks die out less often than they should and means can shift.

Simplification and removals

  • Migration: one mechanism for each job: sim.new() for a copy with changes (edits to an initialized sim belong to that run), ss.parallel() for many sims, sim.fit() for calibration, ss.modify() for changes to a quantity (replacing connectors and interventions that write arrays, which erase what other modules set), and deliveries for interventions. Agents and diseases written as code share one transmission, so the same model written either way gives the same results.
  • Migration: sim.run() on a finished sim runs it again from the start (Starsim 3 raised AlreadyRunError, which now names sim.new() where a run can’t be repeated), and sim.init() on a sim already initialized returns it.
  • n_agents=None means not given (the default); n_agents=0 raises a clear error. A number of agents that is not whole is rounded to the nearest (it was truncated).
  • A module named after its variable keeps that name (sim.<name>, e.g. sim.sir, finds every module), a second module with a default name is numbered (clip_edges_1) with no warning, and a module named like a setting of the sim no longer overwrites it. An unknown keyword to any module names the module, and points to define_pars(); a name that clashes with a method or attribute of the module raises.
  • sim.current_scale is public, module lists take one level of nesting, and networks= and diseases= accept only networks and diseases.
  • Docstrings, notes and error messages no longer mention Starsim 3, outside the migration tools.
  • pip install starsim includes the compiled Rust kernels on all major platforms; import starsim limits BLAS to one thread (ss.options.blas_threads), no longer imports scipy.stats, and loads fonts once; starsim_data and openpyxl (for Atomica’s workbooks) are installed with Starsim. Until Starsim is compiled, importing it says so (STARSIM_UNCOMPILED_WARNING=0 to hide).

Bugfixes relative to Starsim 3

  • ss.debugtools.diff_sims(die=True) raises when the keys differ, not only when values do.
  • A Poisson draw with a NaN input or a mean too large for exact draws gives NaN (poisson(1e19) gave -2^63); so does the new ss.binom().
  • Several ss.infection_log analyzers share the infection log (the first to finalize took it from the disease, so the others got None); for a disease with strains, the log has the strain transmitted, and disease.infection_log.to_df(ti=...) gives the entries of one step.
  • sim.loop.insert() after sim.init() runs the function once per step (it ran twice).
  • ss.parallel() on a subclassed sim (fp.Sim, sti.Sim, hpv.Sim) rebuilds the subclass, or raises, and settings changed after construction (sim.pars.stop = 30) survive it; it works with diseases and sims subclassed in a script, starts its workers by fork or (once JAX or Numba’s threads have started) forkserver, and runs BLAS on one thread in its workers.
  • ss.MultiSim(sims) of sims that have run has their results across runs (msim.results) without running them again.
  • sim.save() works after a run: modules, distributions and networks no longer keep the sim or per-agent arrays, and sim.shrink() measures each module without the sim, other modules, and results (a disease written as code no longer holds the sim after it).
  • A bare dt beside calendar-year start and stop is in years, with a warning, when the model’s typed values are in another unit; ss.choice and ss.histogram take unit=, and ss.choice takes typed values (a=ss.weeks([...])), so either can be a dwell time.
  • A distribution with a unit draws in that unit outside a sim (ss.erlang(k=4, mean=ss.days(10)).rvs(1000) is in days), plot_hist() labels its axis with the unit (and works on a distribution not yet initialized), and its repr shows what it was made from.
  • A module’s automatic results (ss.BoolState) are counted in one pass, which makes a daily sim of several modules written as code about 2× faster.
  • Greek letters render in plots; a plot in a notebook no longer also prints Figure(640x480); tables show once in a notebook; Timeline.update() takes dur.

Performance relative to Starsim 3

  • The kernels exist in Numba and in Rust (starsim/solvers/rust/), with the same arguments and the same numbers, bit for bit: the passes over agents, the compartmental kernels, the draws, and the small kernels of arrays, networks and distributions. Compiled ahead of time, Rust’s need no compiling on a first run and are as fast as Numba’s or faster; ss.options.kernels='auto' uses them once built, and tests/test_kernel_sets.py checks that the sets agree.
  • Large agent-based sims are faster: dead agents are removed without sorting every agent’s ID, arr.true(), arr.false(), and arr.count() read the active agents’ values without gathering them first, the deaths in a step are counted in one pass, deaths from a life table are filed under the step they happen in when drawn, and the passes over the population are split between threads above 200,000 agents (ss.options.threads; the workers of ss.parallel() and of a fit use one). Starsim 3 sims give the same results, no slower.
  • Agent-based sims use less memory: people.auids are 32-bit integers (ss.uids keeps 32-bit integers as they are), people.uid takes no memory (a UID is its index), people.slot, people.parent, people.scale, and a disease’s rel_sus and rel_trans take none until something sets them (ss.Arr(shared=True)), a People not yet in a sim holds no arrays per agent, arrays grow by 20% at a time rather than 50%, a declared disease run as agents keeps the time of entering a state only if something reads it, and a distribution no longer keeps the UIDs or slots of its last draw. LASER’s England and Wales model uses about 48 bytes per agent at the peak.
  • ss.uids set operations skip NumPy’s sort when both sides are sorted with no repeats (& and - 2–26× faster, | 1.3–13×), ss.uids.unique() is 5–10 times faster than np.unique(), Arr arithmetic is 5–13× faster at 100,000 agents, and reading a result is a plain dict lookup.
  • Dist.rvs() is about twice as fast for a small draw, and checks the parameters of functions of the agents once; ss.histogram draws through its CDF directly; ss.choice takes a row of p per agent (FPsim’s method choice 27–126 times faster).
  • Transmission over a network whose edges have not changed since the last step (e.g. ssl.StaticNet) visits only the edges of the people infectious, or of those susceptible, whichever are fewer (ss.Network.edge_index()), and so does contact tracing, with the same results; networks whose edges last one step no longer remove the dead’s edges each step (1.3–1.4× faster a step at 1M agents with births and deaths).
  • People.age_bins() works out only the agents who left their age bin; sim.init() no longer walks results, timelines, or the copy of the inputs in its search for distributions and time parameters; msim.to_df() is 17 times faster.
  • Declared models run as agents keep pace with the same models written as code: an SIS on a network is within about 5% of the version written as code, STIsim’s six declared diseases take about 1.1 times as long as written as code, and STIsim’s SEIS case takes 3.8 s at 5,000 agents, as with Starsim 3 and STIsim 1.7.