add vectorized ops for solver logic and testing to Dev#592
add vectorized ops for solver logic and testing to Dev#592MacdonaldJoshuaCaleb wants to merge 49 commits intodevfrom
Conversation
* Removed redundent import from unit tests, * Applied black formatter.
6b58c58 to
c24fe30
Compare
pearsonca
left a comment
There was a problem hiding this comment.
A few broad questions to start out with + some software engineering asks (type annotation + some docstring)
| # === 3. Binomial Stochastic (NumPy) === | ||
|
|
||
|
|
||
| def compute_transition_amounts_numpy_binomial(source_numbers, total_rates, dt): |
There was a problem hiding this comment.
does this deal with competing rates? i.e. same source, different sink
There was a problem hiding this comment.
This function does not handle competing rates correctly when multiple transitions share the same source state. It draws each transition independently based on its own binomial probability, without adjusting for overlapping source pools.
I'm currently assuming non-overlapping transitions or trusting that the input transitions are already disjoint per source. If we want to enforce probabilistic consistency under shared-source competition, we’d need to replace this with a grouped multinomial draw per source–node pair.
There was a problem hiding this comment.
i think eventually we want to deal with competing rates.
in the interim, a fast-fail => informative error when there are competing rates would be ideal
| @njit(parallel=True, fastmath=True) | ||
| def compute_transition_amounts_parallel( | ||
| source_numbers: np.ndarray, total_rates: np.ndarray, dt: float | ||
| ) -> np.ndarray: | ||
| """ | ||
| Compute deterministic transition amounts in parallel using Euler integration logic. | ||
|
|
||
| This is used when the workload exceeds a defined threshold and parallelization | ||
| is expected to provide a speedup. | ||
|
|
||
| Args: | ||
| source_numbers: Array of shape (n_transitions, n_nodes), source population counts. | ||
| total_rates: Array of shape (n_transitions, n_nodes), transition rates per unit time. | ||
| dt: Time step size. | ||
|
|
||
| Returns: | ||
| Array of shape (n_transitions, n_nodes) with computed transition amounts. | ||
| """ | ||
| n_transitions, n_nodes = total_rates.shape | ||
| amounts = np.zeros((n_transitions, n_nodes)) | ||
| for t_idx in prange(n_transitions): | ||
| for node in range(n_nodes): | ||
| rate = 1 - np.exp(-dt * total_rates[t_idx, node]) | ||
| amounts[t_idx, node] = source_numbers[t_idx, node] * rate | ||
| return amounts |
There was a problem hiding this comment.
This is more of a question than a comment per se since I'm not familiar with numba, but is there a reason with this function body could not just be:
source_numbers * (1.0 - np.exp(-dt * total_rates))At least, that's how I would write this with just numpy. Does numba do some sort of optimization of the looping here? And if so is there documentation somewhere describing that? Or does numba not play nice with numpy's builtin vectorization?
There was a problem hiding this comment.
might need to switch to an element-wise op, but i'd expect so
There was a problem hiding this comment.
numpy.exp is element wise, if that's what you're concerned about. scipy.linalg.expm computes a matrix exponential.
There was a problem hiding this comment.
i meant the * op - or is that by default the by-element op?
There was a problem hiding this comment.
i meant the
*op - or is that by default the by-element op?
Yes, that's also element wise. Matrix multiplication is done via either numpy.matmul or the @ operator.
There was a problem hiding this comment.
yeah unlike matlab where operations are default matrix wise most python ops are default element wise, regarding @TimothyWillard question, yes this has to do with how numba optimizes looping
There was a problem hiding this comment.
yeah unlike matlab where operations are default matrix wise most python ops are default element wise, regarding @TimothyWillard question, yes this has to do with how numba optimizes looping
Gotcha, also might make sense to note that in this function's docstring along with an example asserting equivalence?
| @pytest.fixture(scope="module") | ||
| def modelinfo_from_config(tmp_path_factory): | ||
| tmp_path = tmp_path_factory.mktemp("model_input") | ||
|
|
||
| original_dir = Path(__file__).resolve() | ||
| while original_dir.name != "flepimop": | ||
| original_dir = original_dir.parent | ||
| tutorial_dir = original_dir.parent / "examples/tutorials" | ||
|
|
||
| input_dir = tmp_path / "model_input" | ||
| input_dir.mkdir() | ||
|
|
||
| input_files = [ | ||
| "geodata_sample_2pop.csv", | ||
| "ic_2pop.csv", | ||
| "mobility_sample_2pop.csv", | ||
| ] | ||
| for fname in input_files: | ||
| shutil.copyfile(tutorial_dir / "model_input" / fname, input_dir / fname) | ||
|
|
||
| config_file = "config_sample_2pop_modifiers.yml" | ||
| config_path = tmp_path / config_file | ||
| shutil.copyfile(tutorial_dir / config_file, config_path) | ||
|
|
||
| config = confuse.Configuration("TestModel", __name__) | ||
| config.set_file(str(config_path)) | ||
|
|
||
| return ( | ||
| ModelInfo( | ||
| config=config, | ||
| config_filepath=str(config_path), | ||
| path_prefix=str(tmp_path), | ||
| setup_name="sample_2pop", | ||
| seir_modifiers_scenario="Ro_all", | ||
| ), | ||
| config, | ||
| ) |
There was a problem hiding this comment.
Nice use of pytest's fixtures for DRY, could this be moved into gempyor.testing (contains testing utilities)? Maybe also could wrap setup_example_from_tutorials to get rid of input file setup. Same applies to the other fixture.
| } | ||
|
|
||
|
|
||
| def test_fastmath_equivalence_prod(model_and_inputs): |
There was a problem hiding this comment.
I think it's unnecessary to fully document unit tests (args, return, etc.), but a one line comment of the goal plus typing is helpful.
| fn_safe = njit(fastmath=False)(fn_fast.py_func) | ||
| out1 = fn_safe(arr) | ||
| out2 = fn_fast(arr) | ||
| assert np.allclose(out1, out2, rtol=1e-5, atol=1e-7) |
There was a problem hiding this comment.
More of a question then a comment, but why are we adjusting numpy's default tolerances? I know there's some guidance in the documentation, https://numpy.org/doc/2.1/reference/generated/numpy.allclose.html, on regimes where the defaults aren't suitable. Is this one of those?
There was a problem hiding this comment.
I loosened the atol slightly here to accommodate floating-point differences arising from fastmath=True, particularly under accumulation or exponential transforms. The goal is to catch meaningful deviations while allowing for safe reordering-related variance. The chosen values (rtol=1e-5, atol=1e-7) strike a balance: they're stricter than common simulation tolerances, but loose enough to avoid false positives due to fused operations or reassociation. One might argue that it should be lessened even more, even at this tolerance - would it make a menagiful difference to actual simulations? thoughts here @pearsonca?
There was a problem hiding this comment.
Ah, makes sense. In that case might be helpful to use @pytest.mark.parameterize to scale the array being tested to make sure these tolerances hold up across input sizes. Also can probably do away with the model_and_inputs fixture request because we just need some two dimensional array as an input for prod_along_axis0.
| probs = 1.0 - np.exp(-dt * total_rates) | ||
| draws = np.random.binomial(source_numbers.astype(np.int32), probs) | ||
| return draws.astype(np.float32) |
There was a problem hiding this comment.
What's up with the casting to float/int 32 here?
There was a problem hiding this comment.
it's unneeded, removed
There was a problem hiding this comment.
Looks like it's still present?
|
|
||
|
|
||
| @njit(fastmath=True) | ||
| def prod_along_axis0(arr_2d: np.ndarray) -> np.ndarray: |
There was a problem hiding this comment.
There was a problem hiding this comment.
numpy's prod isn't compatible with numba @pearsonca
There was a problem hiding this comment.
Ah, makes sense. Maybe a note in the documentation would be helpful?
There was a problem hiding this comment.
There was a problem hiding this comment.
(entirely possibly i'm misunderstanding what "supported" here means)
There was a problem hiding this comment.
When I tried to use it numba threw an error. I'll double check the documentation and make sure it wasn't just me being dumb
There was a problem hiding this comment.
i do see "supported without any arguments" which suggests the axis part doesn't work - i.e. can't just do it at top level - but could still use for the innermost loop, tho?
|
| Field | Description |
|---|---|
config_path |
Path to YAML config (used for metadata) |
ic_file |
Path to initial condition CSV |
compartment_labels |
Ordered list like ["S", "E", "I", "R"] |
n_nodes |
Number of subpopulations |
node_col, comp_col, count_col |
CSV column names |
allow_missing_* |
Flags for skipping vs raising on unknowns |
allowed_node_labels |
Optional whitelist of valid node labels |
proportional_ic |
Stub for future support (not implemented yet) |
initial_array |
Constructed output, excluded from serialization |
compartment_to_index, index_to_compartment |
Bidirectional lookup tables |
Already Tested
- Shape, type, and content of the
initial_array - Validation of missing compartments and unknown node labels
- Proper aggregation of duplicate
(node, compartment)entries - Pydantic validation behavior
- Support for string- and int-labeled nodes
- Flexibility in column naming
Roadmap: Replacing ModelInfo
ModelBuilder is the foundation for a broader refactor that will decompose and replace the monolithic ModelInfo class and its tangled dependencies. This modularization aims to make the entire system:
- Simpler to test
- More declarative/configurable
- Easier to extend for new models/scenarios
Planned Object Graph
ModelBuilder
├── initial_array
├── compartment_to_index
├── mobility_matrix (future)
├── population_vector (future)
├── CompartmentModel (future)
├── ParameterBuilder (future)
└── TimeGrid (future)
This approach also removes the need to pass around confuse.Configuration objects, breaking the brittle runtime config dependency tree.
Describe your changes
This pull request introduces a fully vectorized and Numba-accelerated backend for epidemic simulation logic, located in vectorization_experiments.py. These functions serve as a performance-oriented alternative to the current solver implementation, which relies heavily on nested for-loops and dynamic memory access. The new backend explicitly separates the following components for modular testing and future optimization:
All operations are performed over preallocated NumPy arrays and are compatible with fastmath=True in Numba. This enables large performance gains while maintaining model fidelity.
A corresponding test suite is added under
tests/steps_rk4/test_vector_build.py. It validates shape agreements, parameter–transition consistency, correct handling of symbolic expressions, and deterministic solver behavior. All tests now pass with real 2-population configuration files as input.The backend is intended to be drop-in compatible with the current model parsing logic and configuration structure, with no required changes to existing user-facing YAML or CSV files.
A follow-up step will involve benchmarking this vectorized backend against the legacy solver to quantify speedup and memory improvements. We should also discuss where and how to surface this backend in the main package interface, e.g., as a solver="vectorized" option in run_simulation.
RHS function architecture notes:
rhs(t, y)by capturing the static structure of the simulation (transitions, mobility, proportions, etc.) into a closure.build_rhs(...)and reused across all time steps — it is not rebuilt at each time point.Static components:
The following inputs are assumed time-invariant and can be precomputed during simulation object initialization (or deserialization) and reused:
t,y) are passed at evaluation time only.Simulation), these would naturally be stored as attributes(
self.transitions,self.mobility_data, etc.), andrhs_fncould be built once and cached in the constructor for reuse.Does this pull request make any user interface changes?
No changes to user-facing files or CLI behavior are introduced. This PR is strictly additive: it introduces a new experimental backend and test suite without modifying the current simulation pipeline.