Skip to content

feat: job_type = lwf, the Liu–West filter - #985

Open
elyfmiller wants to merge 8 commits into
lanl:mainfrom
elyfmiller:pr/liu-west-filter
Open

elyfmiller wants to merge 8 commits into
lanl:mainfrom
elyfmiller:pr/liu-west-filter

Conversation

@elyfmiller

Copy link
Copy Markdown
Contributor

This adds job_type = lwf, a Liu–West filter that forecasts count data arriving one interval at a time from a mechanistic .bngl model. It keeps a weighted population of particles, each with its own free-parameter values and model state, and assimilates each new row instead of refitting. Its output is a forecasting sample of a model whose free parameters drift by the kernel, not a posterior. It runs on the segment integrator #973 asks for, which the exact sequential sampler planned there can reuse.

No ADR is proposed: the design decisions are recorded in the commit messages and in the three documentation subsections (the algorithm, the forecasting-sample stance, what it refuses).

What changed

Commits 1–3 change PyBNF outside the filter. Commits 1 and 2 are separable; commit 3 is what the filter needs from main().

  1. fix(bngsim). A model parameter named e, pi, log, atan2, ceil, floor or pow is read as the parameter, as BNG2.pl and bngsim read it, wherever PyBNF evaluates a model expression. This can change an existing fit of such a model.
  2. fix(noise). neg_bin at location = mean scores a negative prediction as the expected count 0. Before, a prediction below −r was scored wrongly, and one of exactly −r divided by zero. Scores of predictions above −r are unchanged.
  3. feat(core). pybnf/run_directories.py names what main() deletes, and two hooks, Algorithm.needs_cluster and Algorithm.resume_refusal, default to today's behaviour. -t/-s are written into the configuration before the algorithm is built.
  4. feat(bngsim): SegmentIntegrator (Add an IBIS sampler (job_type = ibis) that updates the exact posterior as observations arrive, absorbs revised observations, and finishes within wall_time_fit #973). Integrates one interval from a carried species state. A chain of segments gives one full simulation's outputs within integration error. It holds bngsim's logger at warning level while a segment integrates.
  5. feat(bngsim): initial_state_only_ids. Finds the free parameters that act on a .net model only through its initial state, conservatively. The filter never moves them.
  6. feat(filter): job_type = lwf, its lwf_* keys, a new registry family filter, and its tests.
  7. docs(filter). The algorithm section, the key reference and the glossary entry; the algorithm-development guide gains the analysis and filter families and the two hooks, and the troubleshooting page a note on a budget-stopped run.
  8. docs(filter). Tutorial lesson 50, which runs in CI.

What the filter is and is not

  • Each row: the free parameters move by the Liu–West kernel; each particle's carried state is integrated over the interval and weighed by the job's own cumulative neg_bin line on the increment, the same term an ordinary fit scores; the population is resampled systematically when the Weight ESS falls below a threshold. After the last row it forecasts lwf_forecast_intervals steps. lwf_continue = 1 assimilates only new rows and writes what an uninterrupted run writes, byte for byte.
  • A forecasting sample, not a posterior. The kernel keeps each moved parameter's weighted mean and variance, but each particle keeps the state its earlier values produced, so the weights are those of a model whose free parameters drift. The jitter h is a modelling choice. Every output file and the run summary say this. For the fixed-parameter posterior, use am; an exact sequential sampler is Add an IBIS sampler (job_type = ibis) that updates the exact posterior as observations arrive, absorbs revised observations, and finishes within wall_time_fit #973's subject.
  • No density on the parameters. After the prior draw the weights are the likelihood alone, and the move is a transition of the parameter process, not a proposal an acceptance ratio judges, so the working space owes no Jacobian and ADR-0003's one-scale rule for prior and proposal does not arise.
  • Independent Runs. lwf_independent_runs is neither n_starts (ADR-0071) nor the gradient methods' population_size: those keep the best of competing searches, which is why ADR-0071 scopes n_starts to the methods that do; independent runs are combined, not selected.
  • It does not follow an unmodelled change. In one in-house synthetic study (not peer-reviewed), an SIR model whose transmission rate drops at or just after the epidemic peak, the filter did not move to the new rate at any h from 0.02 to 0.9. Its median rate stayed close to a fixed-parameter maximum-likelihood fit of the same rows, and the change showed up as a falling dispersion instead. A change the user expects belongs in the model.

Errors, warnings and shared paths

  • Refused at load. Anything a configuration declares that the filter cannot honour is refused by name; a global key it merely does not read is reported as unused, as ADR-0014 has every job type do (table below). Each of the 83 raise sites in the new code has a test that reaches it and asserts that the error names its cause. The structural-key list the check uses is a copy of config.py's, guarded by a test.
  • The one run-time warning is the kernel's: when the weighted covariance of the moved parameters is singular, so no Cholesky factor exists, that row's move uses independent noise at each parameter's own scale, which keeps every marginal variance and shrinks that row's correlations by 1 − h²; the run says so once. The result is a defined kernel, not a wrong one, so it warns rather than stops.
  • Shared paths. An ordinary fit of the same configuration: with no move and no resample, the weights equal its likelihood (oracle). am: same objective, no distributional comparison, since the targets differ. Several Independent Runs in sequence and on a local cluster: byte-identical. A continued run: byte-identical to the uninterrupted one, in process, on a cluster and through main(). Multiple shooting and gradients: not used; a segment refuses an experiment whose start state an earlier action moved, where multiple shooting replays none of the list (ADR-0151, job_type = ms on a .net model integrates only the scored simulate() line and skips the rest of the action protocol (pre-equilibration, setParameter/setConcentration, carried state), so it fits and certifies the wrong experiment #910). The shared run loop and its _finalize_run tail: not used (design choice 6). BNG2.pl, network-free, SBML and RoadRunner: refused.

Tests

The PR adds 265 tests to the CI selection (6,444 on main at b5fa894, 6,709 here).

Oracles, none going through the code under test: weights equal an ordinary fit's likelihood (no move, no resample) and scipy's negative binomial; increments match a first-order conversion's closed form; the kernel keeps each mean and variance and scales each covariance by a_i a_j + h_i h_j, checked by hand; the prior draw passes a KS test; systematic resampling gives ⌊Nw⌋ or ⌈Nw⌉ copies; forecast counts have the negative binomial's mean and variance; a segment chain matches a closed form and one full simulation, and the first segment equals execute() byte for byte; continuation and cluster runs are byte-identical to uninterrupted sequential runs.

Run on macOS arm64 (Accelerate), numpy 2.5.3 (2.5.4 on the 3.13 and 3.14 legs), scipy 1.18.1, BioNetGen 2.9.2, with the dependency set CI's setup action installs for each leg:

  • Python 3.12.7, bngsim 0.16.0, pytest -m "not slow and not recovery": 6,691 passed, 12 skipped, 6 xfailed, 0 failed.
  • Python 3.13.15, bngsim 0.16.0, the same selection: 6,634 passed, 69 skipped (the jax tests, which CI's matrix legs do not install either), 6 xfailed, 0 failed.
  • Python 3.14.7 without bngsim, under PYBNF_NO_BNGSIM=1 as CI's third leg runs: 5,903 passed, 804 skipped, 2 xfailed, 0 failed.
  • ruff 0.15.14, tools/changelog.py check, and sphinx-build -W without bngsim as the docs job runs it: passed.
  • bngsim 0.15.1 (Python 3.12): every test file this PR touches (559 tests) passed.

Not run: Linux (OpenBLAS) and Windows.

Left out

v1 refuses by name: anything but one .bngl model on the bngsim .net path with method: ode; more than one experiment, data file or column; conditions, mutants, pre-equilibration, constraints, measurement models, normalization, time_error; any observation model but the cumulative neg_bin at location = mean with a fitted or fixed dispersion; rows not one step apart (a missing row is a nan row), and a count below 0 or not finite; on a continuation, a revised assimilated row; samples.txt, -r, -t/-s and the cluster keys. Best-fit outputs and output_inference_data are not written, and their keys are reported as unused. Choosing h from the data is not offered. Hand-edited values in a state file are not checked; its format, fingerprint, shapes, finiteness and rows are. A count below 0 is refused rather than excluded with a warning, which is what ADR-0090 has an ordinary fit do with the same value and what the filter does with a nan row: in a series of increments a negative row means an earlier cumulative total was revised, which an assimilated state file cannot take back, so the exclusion is the user's explicit choice, a row written as nan, rather than the filter's. No Results/<model>_bestfit.bngl is written: ADR-0048's artifact is the recorded minimum-objective point, and a weighted population has none to label, so save_best_data and embed_best_fit_data are reported as unused, as ADR-0014 has every job type do for a key it does not read.

Noticed, not changed

All three predate this PR; the first two are not reachable from an lwf configuration.

  • objective.py matches a simulated row to a data row with np.isclose at its default relative tolerance, so from t / step >= 1e5 on it picks the wrong row and scores the wrong interval, for any fit. The filter would reach it at row 100,000.
  • _sync_species_initial_concentrations reads ^ in a hand-written .net species initializer as Python's XOR (2^3 gives 1), silently. BNG2.pl hoists such expressions into parameters, so a .bngl model never meets it; only the Python API with a .net does.
  • main() deletes Results/ before the algorithm is built, so a run refused at load has already lost the previous run's outputs (state files are untouched). This applies to every job type.

Design choices

Each follows what PyBNF already does.

  1. Family. lwf registers in a family of its own, filter, as design did with analysis when it fit neither optimizers nor samplers: the family is what keeps a method out of the benchmark harness and PEtab job_type = all, and a forecasting sample is not a posterior.
  2. Wall time. As ADR-0093 decides for every method, a run the budget stops writes what an uninterrupted run writes, through its last assimilated row, with the forecast starting at that row; Results/stop_reason.txt is the only difference, and method_chain.json records the phase as wall_time_expired. The budget is checked between rows, so the overrun is at most one row of lwf_particles segments, each bounded by wall_time_sim. lwf_continue = 1 finishes the job from the end of the data.
  3. Degenerate update. A row no particle can explain ends the run with an error naming the row, after writing outputs through the last assimilated row and no forecast; no sibling method continues on an unusable state. Because main() records the fit phase after run() returns, such a run writes Results/LWF/ but no method_chain.json, as any run that ends in an error does (ADR-0107).
  4. Dispersion. dispersion = fit <r> and dispersion = fix_at <value> are both accepted, as the edition-2 noise grammar offers them; the whole-fit line and the column's must agree. Under fix_at the kernel moves the model's free parameters only, and the weights and the forecast draws use the fixed value.
  5. Independent Runs. The combined files stack each run's equal-weight resample, as the samplers pool their chains into samples.txt; no run is weighted by its evidence, which nothing in PyBNF does.
  6. Run loop. lwf overrides Algorithm.run, as hmc does, and like hmc does not call _finalize_run, which would report a missing best fit for a run that finished; it uses the shared stop-reason and Simulations-folder tails (ADR-0093, ADR-0110).
  7. Keys it does not read. A global key the filter ignores is reported as unused and ignored, in the words ADR-0014's check uses; refine and bootstrap other than 0, which main() would act on, and the cluster keys, since Independent Runs use the local cluster, are refused. A key no schema owns is the shared check's.
  8. bngsim's log. bngsim logs two lines per run, and the filter runs the model thousands of times per row, so the segment integrator holds bngsim's logger at warning level while a segment integrates and restores it after, as _bngsim_caps scopes its own handler.
Interactions under job_type = lwf
Option Status
Edition 1 Refused.
One .bngl model on the bngsim .net path Supported.
Several models; SBML, Antimony, .target; bngl_backend = bionetgen; network-free Refused (at load or by the segment integrator).
A .bngl whose actions are not only its network definition Refused at load (edition-2 rule, ADR-0152).
Zero or several experiment: lines; condition:, mutant, preequilibrate:, equil_t_end: Refused.
type: other than time_course; first data column not time; a time of inf or not after 0; a count below 0 or not finite Refused.
method: other than ode; several data: files; .con/.prop constraints; t_start:, t_end:, n_steps: Refused.
observable: <entity>, column: <header> Supported.
measurement_params:, formula observables, gml:, complex: Refused.
noise_model <obs> = neg_bin, dispersion = fit <r> or fix_at <value>, location = mean, cumulative, with the matching whole-fit line edition 2 requires Supported.
Other families, location = median, no cumulative, a dispersion that differs between the two lines, a second column Refused.
normalization, time_error Refused.
refine, bootstrap Refused unless 0: main() would act on them.
smoothing, noise_profiling, linear_profiling, output_inference_data, save_best_data, embed_best_fit_data, smooth_plot_points, ind_var_rounding, refine_method Reported as unused and ignored (ADR-0014).
Priors with reflecting bounds; finite support without them Supported; refused.
var / logvar, or a parameter: record with no prior Refused at load by the shared declaration rule (ADR-0118): the initial population is a prior draw.
initialization = lh / rand; other values; initialization_distribution = bounds Supported; refused; reported as unused, since the initial population is a prior draw (ADR-0030).
start_point, initial_value:, starting_params Refused: the initial population must be a prior draw, since after the first row the weights are likelihood times prior only when it is, and a seeded member would change what the sample means (ADR-0043).
max_iterations Reported as unused and ignored.
population_size 1 accepted; other values refused, naming lwf_independent_runs.
lwf_independent_runs, lwf_state_file, lwf_continue Supported.
random_seed Keyed streams; a continuation without it takes the state file's seed.
wall_time_fit; wall_time_sim; wall_time_gen Between rows; per segment; network generation.
parallel_count Refused with one run; with several, lowers the workers (default one per run, at most the CPUs the job holds, ADR-0089).
Other cluster keys; -t/-s; -r Refused (before any cluster starts; -r names lwf_continue).
Trajectory and best-fit outputs Not written; their keys are reported as unused.
Out-of-range lwf_* values; unknown, repeated or start-only lwf_parameter_jitter names; an lwf_jitter no moved parameter uses Refused.
A state file main() would delete, or an existing file that is not a state file Refused.
On a continuation: a missing, foreign or damaged state file, a changed setup (each item named), a revised or missing row Refused.
Keys of other job types; PEtab export of an lwf job Refused.

…e math constant

_build_safe_eval_namespace laid the math names over the model's
parameter values, on the premise that BNG2.pl reserves them all. It does
not: BNG2.pl 2.9.2 refuses 15 of the namespace's 22 names as parameter
names (exp, log10, log2, sqrt, abs, sin, cos, tan, asin, acos, atan, min,
max, if, rint) and accepts the other 7 (e, pi, log, atan2, ceil, floor,
pow); its own constants are _e and _pi. A model that declares e 4 and
seeds B() e starts from B = 4 under BNG2.pl and under bngsim alone, but
the lanl#450 sync in BngsimModel.execute() reseeded B with math.e. The
network-free path's _evaluate_bngl_params kept e out of its namespace
the same way, so [e = 4, k = 2*e] gave k = 2 math.e instead of 8.

One set, _BNG_RESERVED_EVAL_NAMES, now holds the names BNG2.pl refuses.
A model parameter takes precedence over every other math name, both in
_build_safe_eval_namespace (the lanl#450 sync, _eval_model_expression and
_eval_numeric) and in _evaluate_bngl_params. A reserved name still means
the built-in whatever the seed holds.

test_evaluate_bngl_params_param_named_like_builtin_does_not_shadow
pinned x = math.e for [e = 5, x = e]; it now pins the same rule with a
name BNG2.pl does reserve (sqrt). New tests read each of the seven names
as the parameter, evaluate a parameter block as BNG2.pl does, and, on
bngsim, check execute() for e and for pi against the seed by hand and
against bngsim alone, with and without a setConcentration expression.

Signed-off-by: Ely F. Miller <efm46@nau.edu>
… the expected count 0

Under location = mean, NegBinomial.data_fit took the prediction as the
mean in prob = r/(r + mean) and clipped prob to [1e-10, 1 - 1e-10]. A
negative prediction takes prob out of (0, 1]. In (-r, 0) the clip
happened to give the score of a zero prediction; at exactly -r it divided
by zero; below -r prob turned negative and was clipped to the other end,
so an observed 0 scored -r ln(1e-10), 46 at r = 2. A cumulative output
that falls by more than r over an interval gives such a prediction, as
does any model output below zero.

_mean now clamps a MEAN prediction at 0, as _mean_for_median already
clamps the median's target. No score of a prediction above -r changes,
and neither does any derivative: _mean_for_slope floored the mean above
0 before and still does.

The tests compare data_fit with scipy.stats.nbinom at the clipped prob,
for counts of 0 and 3: predictions of -1e6, -r and -1 score as the mean
0, and predictions of -1, 0 and 4 score as they did.

Signed-off-by: Ely F. Miller <efm46@nau.edu>
… cluster and -r

Three hooks in main() for a method that runs in the PyBNF process and
keeps its own state between runs. They have no user until the Liu–West
filter commit later in this series: no existing job type sets them, and
nothing changes for an existing job.

- pybnf/run_directories.py names the folders and files main() deletes
  before every run that is not a --resume. _prepare_run_directories
  reads them from there, so a method can refuse to keep a file in a
  place main() will delete.
- Algorithm.needs_cluster, True by default. main() starts the dask
  cluster, and hands run() its client, only when it is set.
- Algorithm.resume_refusal, None by default: a (message, hint) pair with
  which _resolve_continue_file refuses -r, and with which main() no
  longer offers to continue an in-progress pickled run.

The -t and -s flags are now written into the configuration before a
fresh run's algorithm is built (_apply_cluster_flags), and right after a
resumed one is loaded, so a method that cannot honour them can refuse
them before any cluster starts. Every existing job type reads them when
it starts its cluster, as before.

Tests: _apply_cluster_flags writes each given flag over its key and
nothing else, and _prepare_run_directories deletes exactly the names
run_directories lists, each spelled out in the test too, so a name
dropped from the shared lists is caught.

Signed-off-by: Ely F. Miller <efm46@nau.edu>
…es state (lanl#973)

A sequential method that carries each particle's species state from one
observation to the next needs one primitive: set a state, integrate one
interval under one parameter set, and read back the new state and the
outputs. The Liu–West filter later in this series and the IBIS sampler
of lanl#973 both need it, and lanl#973 names its property: a chain of segments
and one full simulation over the same times agree to solver tolerance.

pybnf/bngsim_model/segment.py adds SegmentIntegrator(model,
parameter_names, *, suffix=None, timeout=None), with initial_state(values)
and integrate(state, values, t0, t1, times=None) -> Segment(state, data),
and SegmentFailed. It is built from BngsimModel's own pieces, as
NetSegmentBackend is (lanl#577): the pickling hooks for an engine with the
codegen artifact attached, _codegen_kwargs, execute()'s initial-state
preamble with the lanl#450 sync, the simulate action's tolerances,
wall_time_sim and _build_data. net_model.py is unchanged. One engine is
reused across calls, and each call writes every parameter and then the
whole state, in that order, since writing a parameter re-derives the
species it seeds. An initial state is computed on a fresh clone, since
the seed sync pins a reused engine's reset(); that costs 0.05 to 0.15 ms
on 2- to 4-species networks and 2.7 ms on the 356-species EGFR network.
A call made while another runs is refused: eight threads sharing one
integrator returned wrong chains and, when reproduced, crashed the
interpreter. A copy or an unpickled integrator has engines of its own.

At construction it refuses, by name, what it cannot integrate the way
the ordinary path simulates: a model that is not a bngsim .net model; a
simulate action that is not ODE or sets continue, stop_if or
steady_state; an experiment that does not start from the seed state; a
model with conditions; and a parameter that is unknown, a function's
slot, or defined by an expression (a reused engine would carry the
bngsim lanl#188 override between parameter sets). Edition 2's later
experiments on one model, which reset parameters and species first, are
accepted.

The oracles do not go through the integrator: the closed form of A -> B,
also with its rate changed between segments and against the ordinary
path's setParameter and continue=>1 run; a chain against one full
simulation, the integrator's own and execute()'s, within 100 units of
rtol |y| + atol at rtol = 1e-6 and 1e-10; and the first segment against
execute(), byte for byte. Solver tolerance means the two runs' global
error, not a fixed multiple of rtol, since every segment restarts the
solver: on the Brusselator over [0, 100] at bngsim's defaults, a chain
of 10 segments differs from one run by 141 units and a chain of 1,000 by
4,471, while at 10 segments both are equally far from a reference
integrated at rtol = 1e-13.

Signed-off-by: Ely F. Miller <efm46@nau.edu>
…ly through its initial state

A method that carries each particle's species state gains nothing by
moving an id that only sets the initial state: the carried state already
holds its effect, and a moved value would only part the reported value
from the state it produced. The Liu–West filter, next in this series,
leaves such ids unmoved.

expressions.initial_state_only_ids(net_lines, ids, exclude=()) returns,
in order, each id that appears in a species initializer, itself or
through a parameter derived from it at any depth, and on no line outside
the parameters and species blocks. A block it does not know counts as a
later use, so an unknown use can only keep an id moving. Numbers are
read before names, so the e of 1e-9 is not a name. exclude names ids
never to report, such as a noise parameter the likelihood reads at every
row. It reads the network with the existing _net_blocks and
_net_param_definitions.

The unit tests use hand-written .net text: seeds through derived
parameters, an id that seeds and also reaches a rate law, uses in a
function, a rate and an unknown block, exclude, and a number with an
exponent. The oracle is the engine: from a carried state, one segment
under two values of each id is byte-identical exactly for the ids
reported.

Signed-off-by: Ely F. Miller <efm46@nau.edu>
job_type = lwf forecasts count data that arrive one interval at a time
from a mechanistic model. Each particle carries its free parameters, its
own model state and a weight. At each data row the Liu and West (2001)
kernel moves the free parameters, x <- a x + (1 - a) m + h L z with
a = sqrt(1 - h^2), in the logit of each box (lwf_bounds = logit) or in
sampling space folded back into the box (reflect); SegmentIntegrator
integrates each particle's state over the interval; the job's own
cumulative neg_bin line weighs each particle on the interval's
increment; and the population is resampled systematically when the
Weight ESS, 1 / sum(w^2), falls below lwf_resample_threshold times the
number of particles. After the last row it forecasts
lwf_forecast_intervals intervals. A free parameter that acts only
through the initial state is never moved.

The move is a transition of the parameters, so the output under
Results/LWF/ is a forecasting sample of a model whose free parameters
drift by the kernel, not the posterior of fixed parameters, and every
output file and the run summary say so. The filter integrates in the
PyBNF process, as hmc and ms do, so it sets needs_cluster to False;
lwf_independent_runs = N runs N Independent Runs, each from its own
prior draw, as tasks on PyBNF's cluster, and stacks their files. Every
random stream is keyed on the run, its purpose and the row's time, so
with lwf_continue = 1 each run assimilates only the rows after those its
state file holds and writes what one uninterrupted run writes. A state
file carries a fingerprint of everything the arithmetic depends on, and
a continuation under a configuration that differs, or over rows that
were revised, is refused naming what changed. main() refuses -r for lwf
through resume_refusal, naming lwf_continue. wall_time_fit stops a run
between rows and leaves a state file to continue from.

The filter needs edition 2, one bngsim .net model and one experiment
with one count column, its rows one step apart and the first one step
after t = 0. The column is declared by a neg_bin line with location =
mean, a fitted dispersion and the cumulative flag, and a whole-fit line
must say the same. Every key, declaration or value it would not honour
is refused by name when the configuration loads.

The oracles do not go through the filter's own arithmetic. With no move
and no resample, the weights are the likelihood the fit path's objective
gives an ordinary execute() of each particle; each weight is
scipy.stats.nbinom of the particle's own increment; on A -> B each
increment, forecast intervals included, is the closed form
A0 (exp(-k t0) - exp(-k t1)); the initial population passes a
Kolmogorov–Smirnov test against each prior; and the forecast counts have
the negative-binomial mean and variance. The kernel keeps each weighted
mean and variance and scales each covariance by a_i a_j + h_i h_j,
exactly, on draws built for it; systematic resampling gives each
particle floor(N w) or ceil(N w) copies; the fold into the box is
FreeParameter.set_value's. A continuation, and a wall-time stop followed
by a continuation, write byte for byte what the uninterrupted run
writes, and several runs on a dask LocalCluster write byte for byte
what the same runs write in sequence in this process. The refusal tests
check that each message names its cause.

Signed-off-by: Ely F. Miller <efm46@nau.edu>
docs/algorithms.rst gains a Liu–West filter section: the four steps of
an update with their formulas; why the output is a forecasting sample of
a model whose free parameters drift and not a posterior; the output
files; Independent Runs and the cluster; the random streams, the state
file and continuation; the wall-time budget; what the filter refuses;
and its references (Gordon et al. 1993, Kong et al. 1994, Kitagawa
1996, Carpenter et al. 1999, Liu and West 2001, Chopin 2002).

docs/config_keys.rst documents job_type = lwf and each lwf_* key with
its default and an example, and says how wall_time_fit stops the filter.
The docs index, the module reference and the CONTEXT.md glossary name
the filter and its family, filter.

Signed-off-by: Ely F. Miller <efm46@nau.edu>
examples/tutorial/50_liu_west_filter forecasts daily counts from an SIR
outbreak with job_type = lwf. make_data.py draws 42 negative-binomial
counts around the model's daily increases, integrating the model by hand
so it needs only numpy; the README runs the filter, reads its files and
continues the run with lwf_continue = 1 when a new row arrives. Lesson
50 joins the tutorial table and docs/tutorial.rst under Forecasting.

tests/test_tutorial_liu_west.py checks make_data.py's means against an
ordinary bngsim simulation of the lesson's model, that the committed
data file is the one the script writes, that a short run of the lesson
through pybnf writes the files the README lists, and that the README's
continuation writes byte for byte what an uninterrupted run writes. It
joins the pre-push bngsim test list.

Signed-off-by: Ely F. Miller <efm46@nau.edu>
elyfmiller added a commit to elyfmiller/flubnf that referenced this pull request Oct 13, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant