From 5d209d940250dcb74fb9303e49ec73f0093befaf Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Thu, 5 Feb 2026 16:17:29 -0800 Subject: [PATCH 01/12] Add failing test for new point estimator metadata Updates the CLI test for run-model to check the contents of the resulting model JSON for a key describing the point estimator function to use for summarizing the central point of the posterior distributions. The default estimator is the median. --- test/test_cli.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_cli.py b/test/test_cli.py index 37660b8..06431ff 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,3 +1,4 @@ +import json import shutil import subprocess from pathlib import Path @@ -58,3 +59,8 @@ def test_run_model_creates_results(): run_cli(command) result_file = export_dir / "results.json" assert result_file.exists(), "Results JSON file not created." + + with open(result_file, "r", encoding="utf-8") as fh: + model = json.load(fh) + + assert model["metadata"].get("ps_point_estimator") == "median" From 771a949d584222023d167e458a26e72f3225a6a9 Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Thu, 5 Feb 2026 16:25:39 -0800 Subject: [PATCH 02/12] Save the default point estimator in model output --- evofr/posterior/posterior_helpers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/evofr/posterior/posterior_helpers.py b/evofr/posterior/posterior_helpers.py index 0df2aab..eedfab7 100644 --- a/evofr/posterior/posterior_helpers.py +++ b/evofr/posterior/posterior_helpers.py @@ -299,6 +299,9 @@ def get_sites_variants_tidy( ps_keys.append(f"HDI_{round(p * 100)}_lower") metadata["ps"] = ps_keys + # Save the requested point estimator function. + metadata["ps_point_estimator"] = "median" + metadata["sites"] = sites if name: metadata["location"] = [name] From 07a5d8c0fadbc6d52edd6f3a8e4fde0ed86bb787 Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Thu, 5 Feb 2026 16:28:46 -0800 Subject: [PATCH 03/12] Define point estimator function in the config --- test/configs/run_model.yaml | 1 + test/test_cli.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/test/configs/run_model.yaml b/test/configs/run_model.yaml index a81ee0a..3218315 100644 --- a/test/configs/run_model.yaml +++ b/test/configs/run_model.yaml @@ -14,3 +14,4 @@ export: sites: ["freq", "ga"] dated: [True, False] forecasts: [False, False] + ps_point_estimator: "mean" diff --git a/test/test_cli.py b/test/test_cli.py index 06431ff..a41ee08 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -63,4 +63,4 @@ def test_run_model_creates_results(): with open(result_file, "r", encoding="utf-8") as fh: model = json.load(fh) - assert model["metadata"].get("ps_point_estimator") == "median" + assert model["metadata"].get("ps_point_estimator") == "mean" From 895a09d6af86d374058a4b0f5a28de08c7cbeb1b Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Thu, 5 Feb 2026 16:39:10 -0800 Subject: [PATCH 04/12] Pass config's point estimator to export function --- evofr/commands/run_model.py | 1 + evofr/posterior/posterior_helpers.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/evofr/commands/run_model.py b/evofr/commands/run_model.py index e9e01d3..6f3a0dc 100644 --- a/evofr/commands/run_model.py +++ b/evofr/commands/run_model.py @@ -156,6 +156,7 @@ def export_results(posterior, export_config): forecasts=forecasts, ps=[0.5, 0.8, 0.95], # Default percentiles name=posterior.name, + ps_point_estimator=export_config.get("ps_point_estimator", "median"), ) results["metadata"]["updated"] = pd.to_datetime(date.today()).isoformat() diff --git a/evofr/posterior/posterior_helpers.py b/evofr/posterior/posterior_helpers.py index eedfab7..ba3fc8b 100644 --- a/evofr/posterior/posterior_helpers.py +++ b/evofr/posterior/posterior_helpers.py @@ -288,19 +288,23 @@ def get_sites_variants_tidy( forecasts: List[bool], ps, name: Optional[str] = None, + ps_point_estimator: Optional[str] = "median", ): # Save metadata metadata = dict() # Make keys for probability levels - ps_keys = ["median"] + ps_keys = [ + "median", + "mean", + ] for p in ps: ps_keys.append(f"HDI_{round(p * 100)}_upper") ps_keys.append(f"HDI_{round(p * 100)}_lower") metadata["ps"] = ps_keys # Save the requested point estimator function. - metadata["ps_point_estimator"] = "median" + metadata["ps_point_estimator"] = ps_point_estimator metadata["sites"] = sites if name: From 21931099504b812d83d5dd6005346e3eefecbd37 Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Thu, 5 Feb 2026 16:58:10 -0800 Subject: [PATCH 05/12] Check for means in the data --- test/test_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_cli.py b/test/test_cli.py index a41ee08..cf6cad8 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -64,3 +64,4 @@ def test_run_model_creates_results(): model = json.load(fh) assert model["metadata"].get("ps_point_estimator") == "mean" + assert any(record["ps"] == "mean" for record in model["data"]) From de10b9aef6ef9b483d997a486f5bc0d4be7ad847 Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Thu, 5 Feb 2026 17:00:40 -0800 Subject: [PATCH 06/12] Calculate means for posteriors --- evofr/posterior/posterior_helpers.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/evofr/posterior/posterior_helpers.py b/evofr/posterior/posterior_helpers.py index ba3fc8b..a8723ed 100644 --- a/evofr/posterior/posterior_helpers.py +++ b/evofr/posterior/posterior_helpers.py @@ -34,6 +34,11 @@ def get_quantile(samples: Dict, p, site): return jnp.quantile(samples[site], q=q, axis=0) +def get_mean(samples: Dict, site): + """Returns mean value across all samples for a site""" + return jnp.mean(samples[site], axis=0) + + def get_median(samples: Dict, site): """Returns median value across all samples for a site""" return jnp.median(samples[site], axis=0) @@ -339,6 +344,7 @@ def tidy_site_date(site, forecast): # Loop over entries of median and med, quants = get_quantiles(samples, ps, site) med, quants = np.array(med), np.array(quants) + means = np.array(get_mean(samples, site)) entries = [] T, N_variants = med.shape @@ -370,6 +376,14 @@ def tidy_site_date(site, forecast): # Add median entry entries.append(entry_med) + # Create mean entry + entry_mean = entry.copy() + entry_mean["value"] = np.around(means[index, v], decimals=3) + entry_mean["ps"] = "mean" + + # Add mean entry + entries.append(entry_mean) + # Loop over intervals of interest for i, p in enumerate(ps): entry_lower = entry.copy() From 4b3d6457e339fae831e3807ce095ec18b05a7521 Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Thu, 5 Feb 2026 17:03:43 -0800 Subject: [PATCH 07/12] Add means for non-dated entries --- evofr/posterior/posterior_helpers.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/evofr/posterior/posterior_helpers.py b/evofr/posterior/posterior_helpers.py index a8723ed..551bb08 100644 --- a/evofr/posterior/posterior_helpers.py +++ b/evofr/posterior/posterior_helpers.py @@ -405,6 +405,7 @@ def tidy_site_flat(site): # Loop over entries of median and med, quants = get_quantiles(samples, ps, site) med, quants = np.array(med), np.array(quants) + means = np.array(get_mean(samples, site)) entries = [] N_variants = med.shape[0] @@ -428,6 +429,14 @@ def tidy_site_flat(site): # Add median entry entries.append(entry_med) + # Create mean entry + entry_mean = entry.copy() + entry_mean["value"] = np.around(means[v], decimals=3) + entry_mean["ps"] = "mean" + + # Add mean entry + entries.append(entry_mean) + # Loop over intervals of interest for i, p in enumerate(ps): entry_lower = entry.copy() From 6546d63284041c4112e42d7c56ffb1740ebee422 Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Fri, 6 Feb 2026 09:37:40 -0800 Subject: [PATCH 08/12] Test dated and "flat" tidy output --- test/test_cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_cli.py b/test/test_cli.py index cf6cad8..ddb56b5 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -64,4 +64,5 @@ def test_run_model_creates_results(): model = json.load(fh) assert model["metadata"].get("ps_point_estimator") == "mean" - assert any(record["ps"] == "mean" for record in model["data"]) + assert any((record["ps"] == "mean") & (record["site"] == "freq") for record in model["data"]) + assert any((record["ps"] == "mean") & (record["site"] == "ga") for record in model["data"]) From 7a203bc6f14ecfeb564369c1daed382d699b2eae Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Fri, 6 Feb 2026 13:46:13 -0800 Subject: [PATCH 09/12] Support scalars in combined metadata tidy dicts Adds support for scalar values in the metadata dictionaries passed to the `combine_sites_tidy` function and adds a test for this support. This function is not used internally by evofr, but it is used externally by forecasts-flu and forecasts-ncov. Without this change, the new "ps_point_estimator" key gets converted from a string like "mean" to a list of single characters like `['m', 'e', 'a', 'n']`. --- evofr/posterior/posterior_helpers.py | 5 +++- test/posterior/__init__.py | 0 test/posterior/test_posterior_helpers.py | 32 ++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 test/posterior/__init__.py create mode 100644 test/posterior/test_posterior_helpers.py diff --git a/evofr/posterior/posterior_helpers.py b/evofr/posterior/posterior_helpers.py index 551bb08..24856b2 100644 --- a/evofr/posterior/posterior_helpers.py +++ b/evofr/posterior/posterior_helpers.py @@ -469,7 +469,10 @@ def combine_sites_tidy(tidy_dicts): for tidy_dict in tidy_dicts: for key, value in tidy_dict["metadata"].items(): - metadata[key].extend([v for v in value if v not in metadata[key]]) + if isinstance(value, list): + metadata[key].extend([v for v in value if v not in metadata[key]]) + else: + metadata[key] = value # Loop over data entries = [] diff --git a/test/posterior/__init__.py b/test/posterior/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/posterior/test_posterior_helpers.py b/test/posterior/test_posterior_helpers.py new file mode 100644 index 0000000..58f8ca5 --- /dev/null +++ b/test/posterior/test_posterior_helpers.py @@ -0,0 +1,32 @@ +from evofr.posterior.posterior_helpers import combine_sites_tidy + + +def test_combine_sites_tidy(): + tidy_dicts = [ + { + "metadata": { + "location": ["Africa"], + "ps_point_estimator": "mean", + }, + "data": [ + { + "record": 1, + } + ], + }, + { + "metadata": { + "location": ["Europe"], + "ps_point_estimator": "mean", + }, + "data": [ + { + "record": 2, + } + ], + } + ] + combined_dict = combine_sites_tidy(tidy_dicts) + assert sorted(combined_dict["metadata"]["location"]) == ["Africa", "Europe"] + assert combined_dict["metadata"]["ps_point_estimator"] == "mean" + assert len(combined_dict["data"]) == 2 From b646da212cde8849cc2879c56b67b2ba0f5f103b Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Mon, 9 Feb 2026 10:47:23 -0800 Subject: [PATCH 10/12] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9516da3..e0b0ce8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "evofr" -version = "0.1.27" +version = "0.2.0" description = "Tools for evolutionary forecasting." authors = ["marlinfiggins "] license = "AGPL-3.0" From 70b6a3acdce6f95d75f73eba9579e91aa0fbb6dd Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Mon, 9 Feb 2026 10:47:29 -0800 Subject: [PATCH 11/12] Add change log --- CHANGES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..1e7aeb6 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,14 @@ +This is the changelog for evofr. +All notable changes in a release will be documented in this file. + +This changelog is intended for _humans_ and follows many of the principles from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +Versions for this project follow the [Semantic Versioning rules](https://semver.org/spec/v2.0.0.html). +Each heading below is a version released to [PyPI](https://pypi.org/project/evofr/) and the date it was released. +The "__NEXT__" heading below describes changes in the unreleased development source code and as such may not be routinely kept up to date. + +# __NEXT__ + +# 0.2.0 (February 9, 2026) + + - Support alternate point estimators for posterior distributions of frequencies and growth advantages (e.g., mean or median). See [#65](https://github.com/blab/evofr/pull/65) for more. From 3593a0d0e7640aaf6704dc63ee44213179904b92 Mon Sep 17 00:00:00 2001 From: John Huddleston Date: Mon, 9 Feb 2026 10:54:16 -0800 Subject: [PATCH 12/12] Fix change log header --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 1e7aeb6..a92dde6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,4 +11,6 @@ The "__NEXT__" heading below describes changes in the unreleased development sou # 0.2.0 (February 9, 2026) +## Features + - Support alternate point estimators for posterior distributions of frequencies and growth advantages (e.g., mean or median). See [#65](https://github.com/blab/evofr/pull/65) for more.