Parameter and simulation monagement -- quickly define parameter and manage parameter sets with convenient results cache management.
LLM-guided setup for your project
Copy this to your coding agent:
Fetch and follow the instructions at https://github.com/InstituteforDiseaseModeling/parsimmon/blob/main/docs/llm_install.md
Parsimmon organizes simulations into four levels:
Manager -- owns a simulation function and a registry of studies
Study -- a set of base parameters + named branches
Branch -- parameter overrides (may include ranges for sweeps)
Trial -- one fully-resolved parameter dict, ready to run
A Manager binds your simulation function to one or more Studies. Each Study holds base defaults and zero or more Branches -- named groups of parameter overrides. When a branch contains ranges (e.g. psm.arange, psm.linspace), parsimmon expands them via Cartesian product into individual Trials.
The default study is the last one registered, or whichever is marked default=True.
# run_sim.py
import parsimmon as psm
from model import run, default_pars # your sim function + defaults
pm = psm.Manager(run, cache=True)
# base() returns your model's full parameter dict, expanded with overrides.
# default_pars() returns the model defaults; keyword arguments override them.
def base():
return default_pars(
sim={"n_agents": 5_000, "rand_seed": psm.arange(5)},
flags={"verbose": False},
)
@pm.study(extends=base)
def dose_response(st):
dose = psm.linspace(0.1, 1.0, 5)
st.branch("Control", {"treatment": False})
st.branch("Treatment", {"treatment": True, "dose": dose})
return st
@pm.study(extends=base, default=True)
def adherence(st):
uptake = psm.linspace(0.1, 1.0, 10)
enroll = psm.each([0.02, 0.05, 0.10, 0.20])
st.branch("Baseline", {"treatment": False})
st.branch("Treated", {"treatment": True, "adherence": {"uptake": uptake, "enroll_prob": enroll}})
return st
@adherence.analysis
def plot_results(results):
for branch_name, group in results.branches.items():
print(f"{branch_name}: {len(group)} runs")
if __name__ == "__main__":
pm.cli_run(jobs=4)python run_sim.py # run the default study
python run_sim.py dose_response # run by name
python run_sim.py -j 4 # 4 parallel workers
python run_sim.py -f # force re-run (ignore cache)
python run_sim.py -a adherence.uptake=0.5 # override a parameter (dot notation for nested)
python run_sim.py -l # list all registered studies
python run_sim.py -l [name] # list details for one study
python run_sim.py --print # print all studies (fully expanded)
python run_sim.py --print [name] # print one study
python run_sim.py -s # skip analysis after run
python run_sim.py --skip-processors # skip post-processor compute and processor analyses
python run_sim.py --clean # remove stale cache entries
python run_sim.py --clean [name] # remove stale entries for one study
python run_sim.py --certify # certify stale cache entries
python run_sim.py --certify [name] # certify one studyfrom run_sim import pm, adherence
results = pm.run(adherence) # run and get Results
results = pm.run(adherence, jobs=4) # parallel
results = pm.run("adherence") # by name
results = pm.run(adherence, force=True) # ignore cache
results = pm.results(adherence) # load from cache without running
pm.list() # print study tree
pm.print_study(adherence) # print one study (fully expanded)With jobs > 1, simulations run in a ProcessPoolExecutor. By default each
worker is recycled after every sim (max_tasks_per_child=1), so memory held
by native libraries (e.g. starsim/numpy state, allocator fragmentation) is
returned to the OS between runs instead of accumulating in a long-lived worker
until it OOMs.
pm = Manager(run, cache=True) # recycle every sim (default)
pm = Manager(run, cache=True, max_tasks_per_child=10) # recycle every 10 sims
pm = Manager(run, cache=True, max_tasks_per_child=None) # never recycle (keep workers alive)Recycling uses a non-fork start method (forkserver, falling back to
spawn), so the sim function and its module must be importable in a fresh
process — keep top-level script code under if __name__ == "__main__":. For
heavy sims the per-sim respawn cost is negligible; raise max_tasks_per_child
(or set it to None) if your sims are cheap and you would rather amortize
worker startup.
Enable caching by passing cache=True to Manager (the default is False). Results are stored under data/cache/ by default.
SimCache is a content-addressed memoizer you can use on its own, without a
Manager. Wrap any function; a call derives a key from the arguments, serves a
hit, or runs and stores the result. This suits scripting and calibration loops
where you want caching without the study/run machinery:
from parsimmon import SimCache
cache = SimCache("./cache") # dill files under ./cache
@cache
def run_sim(pars):
return expensive(pars)
run_sim({"beta": 0.3}) # runs and stores
run_sim({"beta": 0.3}) # identical pars: cache hit- Keys on the first argument only. A
run_sim(pars, meta)keys onpars; trailing arguments never split the cache. The key is namespaced by the function's qualified name, so two functions with equal arguments don't collide. - Staleness. When the wrapped function's source (or a project-local module it
imports) changes, its
fn_hashno longer matches the stored entry.on_stale="recompute"(default) reruns and overwrites,"use"returns the stale value,"error"raises. - Introspection. The wrapper exposes
.__wrapped__,.cache_key(*args), and.fn_hash. TheSimCacheexposes.backend,.get_fn_hash(key), and.certify(key, new_fn_hash).
Manager(cache=True) builds its own SimCache and derives keys with this same
scheme, so a study and a standalone decorator agree on what a given parameter set
hashes to.
- Cache key: function of the canonical parameter dict (order-independent, supports nested dicts and numpy arrays), namespaced by the simulation function's qualified name.
- fn_hash: computed from the AST of your simulation function and all project-local modules it imports. If the function changes, parsimmon detects staleness and prompts you to rerun.
- Deduplication: two studies that resolve to identical parameters share a single cached result.
- Layout: the backend is a flat key-value store. Each simulation's raw output is
data/cache/<key>.pkl; each post-processor's output isdata/cache/<key>.<processor>.pkl, independently loadable without the raw. The indexdata/cache/index.pklrecords parameters,fn_hash, and per-processor hashes for every key.fn_hashlives in the index (not the raw payload), so staleness checks never load a heavy result. Payloads are written atomically (temp file + rename), so an interrupted write never leaves a half-written payload that looks like a cache hit.
The fn_hash boundary determines what invalidates the cache:
HASHED (invalidates cache) NOT HASHED (safe to change)
+---------------------------------+ +-------------------------------+
| model.py | | run_sim.py (driver) |
| def run(pars, meta): ... | | pm = Manager(run, ...) |
| | | @pm.study |
| helpers.py (imported by model) | | def experiment(st): ... |
| def calc(...): ... | | |
| | | analysis functions |
| (any module model.py imports | | plotting code |
| from within your project) | | parameter definitions |
+---------------------------------+ +-------------------------------+
Keep your simulation function and its local imports in separate files from your driver/analysis code. Changes to the driver, parameter definitions, or analysis functions will not invalidate cached results.
The tree below shows which files are hashed. Everything model.py imports (recursively, within your project) is included. Driver files import model.py but are not themselves hashed.
# invalidates cache (tree shows imports -- all are hashed)
model.py
├── analyzers.py
│ └── utils.py
└── interventions.py
# safe to change
model_validation.py
├── model.py (imported, but model.py itself is unchanged)
└── plotlib.py
sensitivity_analysis.py
├── model.py
└── plotlib.py
calibration_zimbabwe.py
├── model.py
└── plotlib.py
The default backend serializes each entry to a dill file. Choose a backend when
constructing the SimCache a Manager sits on. Pass a builtin name, or any
MutableMapping instance (a dict gives an in-memory cache):
from parsimmon import Manager, SimCache
pm = Manager(run, cache=SimCache("data/cache")) # dill files (default)
pm = Manager(run, cache=SimCache(backend={})) # in-memory dictCustom serialization (e.g. sciris) or non-filesystem storage (SQLite, S3) is a
custom backend -- see Custom cache backends.
An in-memory dict backend is process-local: with jobs > 1 each worker gets a
pickled copy, so results written in workers do not reach the parent. Use it for
serial runs, or a filesystem/shared backend for parallel ones.
When raw results are large, you usually don't want to reload all of them just to compute a summary. A post-processor runs once per simulation and caches a reduced artifact alongside the raw result, so downstream analysis can iterate the summaries without ever touching the full results.
Register one with @study.process (the function name is the processor name, or
pass an explicit name). It receives (pars, result) and returns whatever reduced
value you want cached:
@pm.study
def sweep(st):
st.branch("G", {"seed": psm.arange(1000)})
return st
@sweep.process
def peak(pars, result):
return result["timeseries"].max() # one float, cached per run
@peak.analysis
def summarize(results): # receives the *processed* Results
print("max peak:", max(results)) # never loads a full result- Eager: processors run right after each simulation while the raw result is still in memory (in the same worker for parallel runs), then the raw is released.
- Add later: add a processor after a run and re-run — cached sims are not re-executed; a second pass loads each raw result in a worker, processes it, and caches the output. This is how you summarize a 5000-run sweep without OOMing.
- Independent: register as many processors as you like; each takes the raw
result independently. Editing a processor silently recomputes only its output
(the simulation is not re-run). A processor output also recomputes when the
simulation function changes, because its content hash folds in the raw's
fn_hash— an output can never read as current on top of a stale raw.
Access processed outputs through the processed namespace. processed.<name>
is a pure named lookup for one processor's output, served exactly like the raw
result, and returns an ordinary Results, so filtering, grouping, and P
queries all work:
results = pm.run("sweep")
list(results) # raw results (unchanged)
list(results.processed.peak) # the cached summaries
results.processed.peak.filter(results.P.seed > 500)Notes and constraints:
- Any backend: processor outputs are stored under their own keys, so any
MutableMappingbackend holds them, not just the file cache. - Picklable: under
jobs > 1, processors run in worker processes, so they must be importable module-level functions (not lambdas or closures), like the simulation function. - Skip compute: pass
do_processors=False(or--skip-processors) to skip processor compute and processor analyses while still running the simulation and any@study.analysis. This is independent ofdo_analysis. - Reserved names: processor names must match
[A-Za-z0-9_]+;rawandindexare reserved. - On error: a processor that raises aborts the run. The simulation's raw result is already cached, so the next run reuses it (the sim is not re-run) and re-derives the processor once you have fixed it.
A backend is a MutableMapping from string keys to values. Subclass
CacheBackend (or pass any MutableMapping, including a dict) for
non-filesystem storage or custom serialization; implement the five mapping
methods:
from parsimmon import CacheBackend, Manager, SimCache
class MyBackend(CacheBackend):
def __getitem__(self, key): ...
def __setitem__(self, key, value): ...
def __delitem__(self, key): ...
def __iter__(self): ...
def __len__(self): ...
pm = Manager(run, cache=SimCache(backend=MyBackend()))parsimmon stores raw results, processor outputs, and the index as ordinary entries under distinct string keys, so post-processors work on any backend.
| Method / Property | Description |
|---|---|
Manager(fn, path=None, cache=None, data_dir=None, plots_dir=None, max_tasks_per_child=1) |
Bind a simulation function. cache=True for default file cache. max_tasks_per_child recycles each parallel worker after that many sims (see Parallel execution & memory). |
@pm.study(extends=None, default=False) |
Register a study builder. Returns a decorated function with .analysis and .process sub-decorators. |
@study.process(name=None) |
Register a per-run post-processor (pars, result) -> value, cached alongside the raw result. Returns the processor, which carries its own .analysis sub-decorator. See Post-processing. |
pm.run(study, jobs=None, force=False, do_analysis=True, do_processors=True, overrides=None, max_tasks_per_child=...) |
Run a study (by name or function). Returns Results. do_processors=False skips post-processor compute and processor analyses. max_tasks_per_child overrides the Manager setting for this run. |
pm.cli_run(jobs=None, argv=None) |
CLI entry point. Parses sys.argv. |
pm.results(study=None) |
Load cached Results without running. |
pm.list(study=None) |
Print study tree with branch/job counts. |
pm.print_study(study=None) |
Print study (fully expanded parameters). |
pm.certify(study=None) |
Update stored fn_hash to match current code. |
pm.clean(study=None, deep=False) |
Remove stale (or all) cache entries. |
pm.add_argument(*args, **kwargs) |
Add custom argparse arguments to cli_run. |
pm.data_dir |
Resolved data directory path. |
pm.plots_dir |
Resolved plots directory path. |
| Method / Property | Description |
|---|---|
Study(base=None) |
Create a study, optionally seeded with a parameter dict. |
st.defaults(overrides) |
Deep-merge into base parameters. |
st.branch(name, overrides=None) |
Register a named branch with parameter overrides. |
st.clear(name_or_keys, keys=None) |
Remove keys from a branch or the base. |
st.print_study() |
Pretty-print all branches with ranges resolved. |
st.branches |
List of registered branch names. |
len(st) |
Total trial count across all branches. |
iter(st) |
Yields Trial objects. |
Trial is a NamedTuple yielded when iterating a Study:
| Field | Type | Description |
|---|---|---|
branch |
str |
Branch name. |
sim_id |
int |
1-based index across all branches. |
branch_id |
int |
1-based index within the branch. |
pars |
objdict |
Fully-resolved parameter dict. |
All available as psm.<fn> and Study.<fn>.
| Function | Description |
|---|---|
arange(*args, ndigits=3) |
Step range (mirrors np.arange). |
linspace(*args, ndigits=3) |
Evenly-spaced values (mirrors np.linspace). |
logspace(*args, ndigits=3) |
Log-spaced values (mirrors np.logspace). |
each(iterable) / iter(iterable) |
Wrap any sequence as a Cartesian sweep axis. |
zipped(iterable) |
Wrap a column expanded row-wise with sibling zipped columns instead of Cartesian-producted (see Sample sets). |
samples(records) |
Build {key: zipped(...)} columns from an iterable of parameter dicts. Splat into a branch. |
link(source, fn) |
Derived parameter. source can be a range, key string, another link, or a list. |
Ranges trigger Cartesian expansion when a Study is iterated. Pass ndigits=None to skip rounding.
each/arange axes multiply (Cartesian). For calibration you usually have N
arbitrary parameter vectors (random draws, a Latin hypercube) and want one
trial per vector, not the product. zipped columns co-iterate row-wise, and
samples builds them from a list of draw dicts:
draws = generate_lhs_draws(n=200) # your list[dict]; parsimmon never samples
@pm.study
def calib(st):
st.branch("lhs", psm.samples(draws)) # 200 row-wise trials, not 200**k
return st
results = pm.run(calib, jobs=12) # parallel, cached, workers recycledZipped columns combine with normal ranges by product, so a phase-2 reseed of the survivors is just one Cartesian axis on top of the zipped rows:
survivors = [pars for (pars, _), sim in results.items() if passes(sim)]
@pm.study
def reseed(st):
st.branch("reseed", {**psm.samples(survivors), "rand_seed": psm.arange(3)})
return st # len(survivors) draws × 3 seedsAll zipped columns in a branch must be equal length. Keys are not split on dots,
so a draw {"ng.beta": 0.3} reaches your sim function as a flat "ng.beta" key.
| Constructor | Description |
|---|---|
Results(path) |
Load from a cache directory (str or Path). |
Results(sim_cache) |
Load from a SimCache instance. |
pm.run(study) |
Returns Results after execution. |
pm.results(study) |
Returns Results from cache. |
| Method / Property | Description |
|---|---|
iter(results) |
Yields result values. Triggers loading. |
results.items() |
Yields ((pars, meta), value) tuples. Triggers loading. |
results.first() |
Returns ((pars, meta), value) for the first entry. Triggers loading. |
results.iter_params() |
Yields (pars, meta) without loading values. |
results[start:stop] |
Slice returns a new Results (no loading). |
len(results) |
Count without loading. |
results.filter(predicate) |
Filter by P expression or lambda pars, meta: .... |
results.groupby(*keys) |
Group by parameter keys or P expressions. |
results.P |
Query builder -- tab-completable, supports ==, !=, <, >, <=, >=, &, |, .unique(). |
results.branches |
objdict mapping branch name to Results. |
results.studies |
objdict mapping study name to Results. |
results.processed.<name> |
A new Results iterating the named post-processor's cached output. See Post-processing. |
results.<name> |
Attribute access filters by study or branch name. |
| Class / Method | Description |
|---|---|
SimCache(*args, backend="dill", on_stale="recompute", **kwargs) |
Content-addressed memoizer. Positional/keyword args forward to the backend (e.g. a directory for "dill"); backend is a builtin name or a MutableMapping instance. on_stale is "recompute", "use", or "error". |
cache(fn) |
Wrap a function; the returned wrapper caches on its first argument. |
wrapped.cache_key(*args) |
The key a call with these arguments would use. |
wrapped.fn_hash |
Source-chain hash of the wrapped function. |
SimCache.backend |
The underlying MutableMapping. |
SimCache.get_fn_hash(key) |
Stored fn_hash for a key, or None. |
SimCache.certify(key, new_fn_hash) |
Bless a stored entry as current without recomputing. |
CacheBackend |
Abstract base (MutableMapping) for custom backends. |
