From c249668c56932305daee02eaf26f774390ac3c5c Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Wed, 25 Feb 2026 08:35:45 +0000 Subject: [PATCH 01/17] Delete unnecessary import in example --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 5972e06..6329603 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,6 @@ The primary API, `SEQuential` uses a dataclass system to handle function input. From the user side, this amounts to creating a dataclass, `SEQopts`, and then feeding this into `SEQuential`. If you forgot to add something at class instantiation, you can, in some cases, add them when you call their respective class method. ```python -import polars as pl from pySEQTarget import SEQuential, SEQopts from pySEQTarget.data import load_data From 495457be250246bc42fe80087e5489164324eb76 Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Wed, 25 Feb 2026 08:38:48 +0000 Subject: [PATCH 02/17] Bump version --- docs/conf.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index c366c95..5917a79 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,7 +12,7 @@ version = importlib.metadata.version("pySEQTarget") if not version: - version = "0.12.2" + version = "0.12.3" sys.path.insert(0, os.path.abspath("../")) project = "pySEQTarget" diff --git a/pyproject.toml b/pyproject.toml index 99330f5..f480e67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pySEQTarget" -version = "0.12.2" +version = "0.12.3" description = "Sequentially Nested Target Trial Emulation" readme = "README.md" license = {text = "MIT"} From e91209f34483b28ca3c14ec33def7b4fc1130954 Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 13:48:32 +0000 Subject: [PATCH 03/17] Amend bootstrap_method to bootstrap_CI_method --- pySEQTarget/SEQuential.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pySEQTarget/SEQuential.py b/pySEQTarget/SEQuential.py index 7e3b551..168cea6 100644 --- a/pySEQTarget/SEQuential.py +++ b/pySEQTarget/SEQuential.py @@ -176,7 +176,7 @@ def bootstrap(self, **kwargs) -> None: "bootstrap_nboot", "bootstrap_sample", "bootstrap_CI", - "bootstrap_method", + "bootstrap_CI_method", } for key, value in kwargs.items(): if key in allowed: From 0e9e107a147b556fd87ca417ce50455df05d38d3 Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 13:49:35 +0000 Subject: [PATCH 04/17] Amend self.hazard to self.hazard_estimate --- pySEQTarget/initialization/_outcome.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pySEQTarget/initialization/_outcome.py b/pySEQTarget/initialization/_outcome.py index 4ec0fc9..db6fb81 100644 --- a/pySEQTarget/initialization/_outcome.py +++ b/pySEQTarget/initialization/_outcome.py @@ -6,7 +6,7 @@ def _outcome(self) -> str: ["followup*dose", f"followup*dose{self.indicator_squared}"] ) - if self.hazard or not self.km_curves: + if self.hazard_estimate or not self.km_curves: interaction = interaction_dose = None tv_bas = ( From c40f067fb01ed883faacbced6aa47ce9fb9f8dc1 Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 13:50:36 +0000 Subject: [PATCH 05/17] Amend f"{v}_bas" to f"{v}{self.indicator_baseline}" --- pySEQTarget/initialization/_outcome.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pySEQTarget/initialization/_outcome.py b/pySEQTarget/initialization/_outcome.py index db6fb81..cd693f5 100644 --- a/pySEQTarget/initialization/_outcome.py +++ b/pySEQTarget/initialization/_outcome.py @@ -10,7 +10,7 @@ def _outcome(self) -> str: interaction = interaction_dose = None tv_bas = ( - "+".join([f"{v}_bas" for v in self.time_varying_cols]) + "+".join([f"{v}{self.indicator_baseline}" for v in self.time_varying_cols]) if self.time_varying_cols else None ) From fe12074878489f6317db108d93ce8b230b88d0b3 Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 13:50:59 +0000 Subject: [PATCH 06/17] Typo fix arugment to argument --- pySEQTarget/SEQuential.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pySEQTarget/SEQuential.py b/pySEQTarget/SEQuential.py index 168cea6..22efef8 100644 --- a/pySEQTarget/SEQuential.py +++ b/pySEQTarget/SEQuential.py @@ -274,7 +274,7 @@ def survival(self, **kwargs) -> None: if key in allowed: setattr(self, key, val) else: - raise ValueError(f"Unknown or misplaced arugment: {key}") + raise ValueError(f"Unknown or misplaced argument: {key}") if not hasattr(self, "outcome_model") or not self.outcome_model: raise ValueError( @@ -315,7 +315,7 @@ def plot(self, **kwargs) -> None: if key in allowed: setattr(self, key, val) else: - raise ValueError(f"Unknown or misplaced arugment: {key}") + raise ValueError(f"Unknown or misplaced argument: {key}") self.km_graph = _survival_plot(self) def collect(self) -> SEQoutput: From 6f4874187ebd61a9cb6532df2c0bdd0293d1d895 Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 13:51:11 +0000 Subject: [PATCH 07/17] Typo fix preform to perform --- pySEQTarget/SEQopts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pySEQTarget/SEQopts.py b/pySEQTarget/SEQopts.py index a6d3982..54c74d7 100644 --- a/pySEQTarget/SEQopts.py +++ b/pySEQTarget/SEQopts.py @@ -9,7 +9,7 @@ class SEQopts: """ Parameter builder for ``pySEQTarget.SEQuential`` analysis - :param bootstrap_nboot: Number of bootstraps to preform + :param bootstrap_nboot: Number of bootstraps to perform :type bootstrap_nboot: int :param bootstrap_sample: Subsampling proportion of ID-Trials gathered for each bootstrapping iteration :type bootstrap_sample: float From 36b0fd0f44b839c826c239ddcbd9fe677dc08bc5 Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 13:51:30 +0000 Subject: [PATCH 08/17] Amend indicator_baseline to indicator_squared --- pySEQTarget/SEQopts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pySEQTarget/SEQopts.py b/pySEQTarget/SEQopts.py index 54c74d7..45085f2 100644 --- a/pySEQTarget/SEQopts.py +++ b/pySEQTarget/SEQopts.py @@ -51,7 +51,7 @@ class SEQopts: :param indicator_baseline: How to indicate baseline columns in models :type indicator_baseline: str :param indicator_squared: How to indicate squared columns in models - :type indicator_baseline: str + :type indicator_squared: str :param km_curves: Boolean to create survival, risk, and incidence (if applicable) estimates :type km_curves: bool :param ncores: Number of cores to use if running in parallel From b5ace46671d0f1cda03a06120c153273d271d98f Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 13:55:05 +0000 Subject: [PATCH 09/17] Add a check for pefect separation --- pySEQTarget/error/__init__.py | 2 ++ pySEQTarget/error/_check_separation.py | 23 +++++++++++++++++++++++ pySEQTarget/weighting/_weight_fit.py | 8 +++++++- 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 pySEQTarget/error/_check_separation.py diff --git a/pySEQTarget/error/__init__.py b/pySEQTarget/error/__init__.py index fb19cae..bf1d614 100644 --- a/pySEQTarget/error/__init__.py +++ b/pySEQTarget/error/__init__.py @@ -1,7 +1,9 @@ +from ._check_separation import _check_separation from ._data_checker import _data_checker from ._param_checker import _param_checker __all__ = [ + "_check_separation", "_data_checker", "_param_checker", ] diff --git a/pySEQTarget/error/_check_separation.py b/pySEQTarget/error/_check_separation.py new file mode 100644 index 0000000..c30b5fc --- /dev/null +++ b/pySEQTarget/error/_check_separation.py @@ -0,0 +1,23 @@ +import warnings + +import numpy as np + + +def _check_separation(model_fit, label="model"): + """ + Check for perfect or quasi-complete separation in a fitted logistic regression model. + Issues a warning if large (|coef| > 25) or non-finite coefficients are detected, + as these are reliable indicators of separation in logistic regression. + """ + params = np.array(model_fit.params).flatten() + + has_large = np.any(np.abs(params) > 25) + has_nonfinite = np.any(~np.isfinite(params)) + + if has_large or has_nonfinite: + warnings.warn( + f"Possible perfect or quasi-complete separation detected in {label}. " + "The resulting weights may be unreliable.", + UserWarning, + stacklevel=2, + ) diff --git a/pySEQTarget/weighting/_weight_fit.py b/pySEQTarget/weighting/_weight_fit.py index 9a362ec..967b30b 100644 --- a/pySEQTarget/weighting/_weight_fit.py +++ b/pySEQTarget/weighting/_weight_fit.py @@ -1,6 +1,8 @@ import statsmodels.api as sm import statsmodels.formula.api as smf +from ..error._check_separation import _check_separation + def _get_subset_for_level( self, WDT, level_idx, level, tx_lag_col, exclude_followup_zero=False @@ -43,7 +45,9 @@ def _fit_pair( for rhs, out in zip(formula_attr, output_attrs): formula = f"{outcome}~{rhs}" model = smf.glm(formula, WDT, family=sm.families.Binomial()) - setattr(self, out, model.fit(disp=0, method=self.weight_fit_method)) + fitted = model.fit(disp=0, method=self.weight_fit_method) + _check_separation(fitted, label=out.replace("_model", "").replace("_", " ")) + setattr(self, out, fitted) def _fit_LTFU(self, WDT): @@ -97,6 +101,7 @@ def _fit_numerator(self, WDT): else: model = smf.mnlogit(formula, DT_subset) model_fit = model.fit(disp=0, method=self.weight_fit_method) + _check_separation(model_fit, label=f"numerator (level {level})") fits.append(model_fit) self.numerator_model = fits @@ -131,6 +136,7 @@ def _fit_denominator(self, WDT): else: model = smf.mnlogit(formula, DT_subset) model_fit = model.fit(disp=0, method=self.weight_fit_method) + _check_separation(model_fit, label=f"denominator (level {level})") fits.append(model_fit) self.denominator_model = fits From 1855bc2f261883d50fbca7aa91d5785f6590f538 Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 13:58:05 +0000 Subject: [PATCH 10/17] Add tests for the check of perfect separation --- tests/test_check_separation.py | 69 ++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_check_separation.py diff --git a/tests/test_check_separation.py b/tests/test_check_separation.py new file mode 100644 index 0000000..bb24ac8 --- /dev/null +++ b/tests/test_check_separation.py @@ -0,0 +1,69 @@ +import warnings +from types import SimpleNamespace + +import numpy as np +import pytest + +from pySEQTarget.error._check_separation import _check_separation + + +def _mock_model(params): + """Create a minimal mock model with a .params attribute.""" + return SimpleNamespace(params=np.array(params)) + + +def test_no_warning_for_normal_coefficients(): + model = _mock_model([0.5, -1.2, 3.0, -0.01]) + with warnings.catch_warnings(): + warnings.simplefilter("error") + _check_separation(model) # should not raise + + +def test_warns_for_large_positive_coefficient(): + model = _mock_model([0.5, 26.0]) + with pytest.warns(UserWarning, match="separation"): + _check_separation(model) + + +def test_warns_for_large_negative_coefficient(): + model = _mock_model([0.5, -30.0]) + with pytest.warns(UserWarning, match="separation"): + _check_separation(model) + + +def test_warns_for_inf_coefficient(): + model = _mock_model([1.0, np.inf]) + with pytest.warns(UserWarning, match="separation"): + _check_separation(model) + + +def test_warns_for_neg_inf_coefficient(): + model = _mock_model([1.0, -np.inf]) + with pytest.warns(UserWarning, match="separation"): + _check_separation(model) + + +def test_warns_for_nan_coefficient(): + model = _mock_model([1.0, np.nan]) + with pytest.warns(UserWarning, match="separation"): + _check_separation(model) + + +def test_boundary_coefficient_does_not_warn(): + # Exactly 25 should not trigger (threshold is strictly > 25) + model = _mock_model([25.0, -25.0]) + with warnings.catch_warnings(): + warnings.simplefilter("error") + _check_separation(model) + + +def test_label_appears_in_warning(): + model = _mock_model([100.0]) + with pytest.warns(UserWarning, match="censoring numerator"): + _check_separation(model, label="censoring numerator") + + +def test_default_label_appears_in_warning(): + model = _mock_model([np.inf]) + with pytest.warns(UserWarning, match="model"): + _check_separation(model) From 7278c4c716c746ada62793ebf57f27d63b7215eb Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 14:09:05 +0000 Subject: [PATCH 11/17] Handle no-variation weight models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip weight model fitting when the outcome has no variation (all 0s or 1s), storing None as a sentinel and assigning a weight of 1 — the correct value when treatment is structurally determined. Guards cense/visit prediction blocks against None models in _weight_predict. --- pySEQTarget/weighting/_weight_fit.py | 9 +++++ pySEQTarget/weighting/_weight_pred.py | 47 +++++++++++++++------------ 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/pySEQTarget/weighting/_weight_fit.py b/pySEQTarget/weighting/_weight_fit.py index 967b30b..e6b39d0 100644 --- a/pySEQTarget/weighting/_weight_fit.py +++ b/pySEQTarget/weighting/_weight_fit.py @@ -43,6 +43,9 @@ def _fit_pair( WDT = WDT[WDT[_eligible_col] == 1] for rhs, out in zip(formula_attr, output_attrs): + if len(WDT[outcome].unique()) < 2: + setattr(self, out, None) + continue formula = f"{outcome}~{rhs}" model = smf.glm(formula, WDT, family=sm.families.Binomial()) fitted = model.fit(disp=0, method=self.weight_fit_method) @@ -95,6 +98,9 @@ def _fit_numerator(self, WDT): is_binary = sorted(self.treatment_level) == [0, 1] and self.method == "censoring" for i, level in enumerate(self.treatment_level): DT_subset = _get_subset_for_level(self, WDT, i, level, tx_lag_col) + if len(DT_subset[predictor].unique()) < 2: + fits.append(None) + continue # Use logit for binary 0/1 censoring, mnlogit otherwise if is_binary: model = smf.logit(formula, DT_subset) @@ -130,6 +136,9 @@ def _fit_denominator(self, WDT): DT_subset = _get_subset_for_level( self, WDT, i, level, "tx_lag", exclude_followup_zero=exclude_followup_zero ) + if len(DT_subset[predictor].unique()) < 2: + fits.append(None) + continue # Use logit for binary 0/1 censoring, mnlogit otherwise if is_binary: model = smf.logit(formula, DT_subset) diff --git a/pySEQTarget/weighting/_weight_pred.py b/pySEQTarget/weighting/_weight_pred.py index 7b1c20c..54a0f25 100644 --- a/pySEQTarget/weighting/_weight_pred.py +++ b/pySEQTarget/weighting/_weight_pred.py @@ -170,33 +170,38 @@ def _weight_predict(self, WDT): if self.cense_colname is not None: cense_num_model = self._offloader.load_model(self.cense_numerator_model) cense_denom_model = self._offloader.load_model(self.cense_denominator_model) - p_num = _predict_model(self, cense_num_model, WDT).flatten() - p_denom = _predict_model(self, cense_denom_model, WDT).flatten() - WDT = WDT.with_columns( - [ - pl.Series("cense_numerator", p_num), - pl.Series("cense_denominator", p_denom), - ] - ).with_columns( - (pl.col("cense_numerator") / pl.col("cense_denominator")).alias("_cense") - ) + if cense_num_model is not None and cense_denom_model is not None: + p_num = _predict_model(self, cense_num_model, WDT).flatten() + p_denom = _predict_model(self, cense_denom_model, WDT).flatten() + WDT = WDT.with_columns( + [ + pl.Series("cense_numerator", p_num), + pl.Series("cense_denominator", p_denom), + ] + ).with_columns( + (pl.col("cense_numerator") / pl.col("cense_denominator")).alias("_cense") + ) + else: + WDT = WDT.with_columns(pl.lit(1.0).alias("_cense")) else: WDT = WDT.with_columns(pl.lit(1.0).alias("_cense")) if self.visit_colname is not None: visit_num_model = self._offloader.load_model(self.visit_numerator_model) visit_denom_model = self._offloader.load_model(self.visit_denominator_model) - p_num = _predict_model(self, visit_num_model, WDT).flatten() - p_denom = _predict_model(self, visit_denom_model, WDT).flatten() - - WDT = WDT.with_columns( - [ - pl.Series("visit_numerator", p_num), - pl.Series("visit_denominator", p_denom), - ] - ).with_columns( - (pl.col("visit_numerator") / pl.col("visit_denominator")).alias("_visit") - ) + if visit_num_model is not None and visit_denom_model is not None: + p_num = _predict_model(self, visit_num_model, WDT).flatten() + p_denom = _predict_model(self, visit_denom_model, WDT).flatten() + WDT = WDT.with_columns( + [ + pl.Series("visit_numerator", p_num), + pl.Series("visit_denominator", p_denom), + ] + ).with_columns( + (pl.col("visit_numerator") / pl.col("visit_denominator")).alias("_visit") + ) + else: + WDT = WDT.with_columns(pl.lit(1.0).alias("_visit")) else: WDT = WDT.with_columns(pl.lit(1.0).alias("_visit")) From e8ef1fb5f4cc9caba48a8e3e6399a41957f2339d Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 14:13:40 +0000 Subject: [PATCH 12/17] Add tests for no variation models --- tests/test_no_variation.py | 115 +++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/test_no_variation.py diff --git a/tests/test_no_variation.py b/tests/test_no_variation.py new file mode 100644 index 0000000..e756af2 --- /dev/null +++ b/tests/test_no_variation.py @@ -0,0 +1,115 @@ +import numpy as np +import pandas as pd +from types import SimpleNamespace + +from pySEQTarget.weighting._weight_fit import _fit_denominator, _fit_numerator, _fit_pair + + +def _mock_self(**overrides): + attrs = { + "weight_preexpansion": False, + "excused": False, + "method": "censoring", + "treatment_col": "tx", + "indicator_baseline": "_bas", + "numerator": "1", + "denominator": "1", + "treatment_level": [0, 1], + "weight_fit_method": "newton", + "weight_lag_condition": True, + "weight_eligible_colnames": [None, None], + "excused_colnames": [None, None], + "cense_colname": "ltfu", + } + attrs.update(overrides) + return SimpleNamespace(**attrs) + + +def _make_data(n=60): + """Both treatment levels have variation in the predictor.""" + block = np.array([0] * (n // 2) + [1] * (n // 2)) + return pd.concat( + [ + pd.DataFrame({"tx": block, "tx_lag": np.zeros(n, int), "followup": np.arange(1, n + 1)}), + pd.DataFrame({"tx": block, "tx_lag": np.ones(n, int), "followup": np.arange(1, n + 1)}), + ], + ignore_index=True, + ) + + +def _make_data_no_variation_level0(n=60): + """Level-0 subset (tx_lag=0) has all tx=0; level-1 subset has mixed tx.""" + block = np.array([0] * (n // 2) + [1] * (n // 2)) + return pd.concat( + [ + pd.DataFrame({"tx": np.zeros(n, int), "tx_lag": np.zeros(n, int), "followup": np.arange(1, n + 1)}), + pd.DataFrame({"tx": block, "tx_lag": np.ones(n, int), "followup": np.arange(1, n + 1)}), + ], + ignore_index=True, + ) + + +# ── _fit_numerator ──────────────────────────────────────────────────────────── + + +def test_fit_numerator_stores_none_when_no_variation(): + obj = _mock_self() + _fit_numerator(obj, _make_data_no_variation_level0()) + assert obj.numerator_model[0] is None + assert obj.numerator_model[1] is not None + + +def test_fit_numerator_fits_when_variation_exists(): + obj = _mock_self() + _fit_numerator(obj, _make_data()) + assert obj.numerator_model[0] is not None + assert obj.numerator_model[1] is not None + + +# ── _fit_denominator ────────────────────────────────────────────────────────── + + +def test_fit_denominator_stores_none_when_no_variation(): + obj = _mock_self() + _fit_denominator(obj, _make_data_no_variation_level0()) + assert obj.denominator_model[0] is None + assert obj.denominator_model[1] is not None + + +def test_fit_denominator_fits_when_variation_exists(): + obj = _mock_self() + _fit_denominator(obj, _make_data()) + assert obj.denominator_model[0] is not None + assert obj.denominator_model[1] is not None + + +# ── _fit_pair (cense / visit models) ───────────────────────────────────────── + + +def test_fit_pair_stores_none_when_no_variation(): + n = 60 + df = pd.DataFrame({"ltfu": np.zeros(n, int), "followup": np.arange(n)}) + obj = _mock_self() + _fit_pair( + obj, df, "cense_colname", + ["1", "1"], + ["cense_numerator_model", "cense_denominator_model"], + ) + assert obj.cense_numerator_model is None + assert obj.cense_denominator_model is None + + +def test_fit_pair_fits_when_variation_exists(): + n = 60 + df = pd.DataFrame({ + "ltfu": np.array([0] * (n // 2) + [1] * (n // 2)), + "followup": np.arange(n), + }) + obj = _mock_self() + _fit_pair( + obj, df, "cense_colname", + ["1", "1"], + ["cense_numerator_model", "cense_denominator_model"], + ) + assert obj.cense_numerator_model is not None + assert obj.cense_denominator_model is not None From e1ecb0d8a77f628699cdad2c0c923b422e7fc3fe Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 14:20:11 +0000 Subject: [PATCH 13/17] Format with black --- docs/conf.py | 1 + pySEQTarget/SEQuential.py | 35 +++++++++++++---- pySEQTarget/analysis/__init__.py | 8 +++- pySEQTarget/weighting/__init__.py | 3 +- pySEQTarget/weighting/_weight_pred.py | 8 +++- tests/test_no_variation.py | 56 +++++++++++++++++++++------ 6 files changed, 86 insertions(+), 25 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 5917a79..af3dca3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,6 +4,7 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html import importlib.metadata + # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import os diff --git a/pySEQTarget/SEQuential.py b/pySEQTarget/SEQuential.py index 22efef8..36e7ca5 100644 --- a/pySEQTarget/SEQuential.py +++ b/pySEQTarget/SEQuential.py @@ -7,20 +7,39 @@ import numpy as np import polars as pl -from .analysis import (_calculate_hazard, _calculate_survival, _clamp, - _outcome_fit, _pred_risk, _risk_estimates, - _subgroup_fit) +from .analysis import ( + _calculate_hazard, + _calculate_survival, + _clamp, + _outcome_fit, + _pred_risk, + _risk_estimates, + _subgroup_fit, +) from .error import _data_checker, _param_checker from .expansion import _binder, _diagnostics, _dynamic, _random_selection from .helpers import Offloader, _col_string, _format_time, bootstrap_loop -from .initialization import (_cense_denominator, _cense_numerator, - _denominator, _numerator, _outcome) +from .initialization import ( + _cense_denominator, + _cense_numerator, + _denominator, + _numerator, + _outcome, +) from .plot import _survival_plot from .SEQopts import SEQopts from .SEQoutput import SEQoutput -from .weighting import (_fit_denominator, _fit_LTFU, _fit_numerator, - _fit_visit, _offload_weights, _weight_bind, - _weight_predict, _weight_setup, _weight_stats) +from .weighting import ( + _fit_denominator, + _fit_LTFU, + _fit_numerator, + _fit_visit, + _offload_weights, + _weight_bind, + _weight_predict, + _weight_setup, + _weight_stats, +) class SEQuential: diff --git a/pySEQTarget/analysis/__init__.py b/pySEQTarget/analysis/__init__.py index f39b8c1..e28c1a5 100644 --- a/pySEQTarget/analysis/__init__.py +++ b/pySEQTarget/analysis/__init__.py @@ -2,8 +2,12 @@ from ._outcome_fit import _outcome_fit from ._risk_estimates import _risk_estimates from ._subgroup_fit import _subgroup_fit -from ._survival_pred import (_calculate_survival, _clamp, - _get_outcome_predictions, _pred_risk) +from ._survival_pred import ( + _calculate_survival, + _clamp, + _get_outcome_predictions, + _pred_risk, +) __all__ = [ "_calculate_hazard", diff --git a/pySEQTarget/weighting/__init__.py b/pySEQTarget/weighting/__init__.py index 6f3c036..c1167e2 100644 --- a/pySEQTarget/weighting/__init__.py +++ b/pySEQTarget/weighting/__init__.py @@ -1,7 +1,6 @@ from ._weight_bind import _weight_bind from ._weight_data import _weight_setup -from ._weight_fit import (_fit_denominator, _fit_LTFU, _fit_numerator, - _fit_visit) +from ._weight_fit import _fit_denominator, _fit_LTFU, _fit_numerator, _fit_visit from ._weight_offload import _offload_weights from ._weight_pred import _weight_predict from ._weight_stats import _weight_stats diff --git a/pySEQTarget/weighting/_weight_pred.py b/pySEQTarget/weighting/_weight_pred.py index 54a0f25..c865e2e 100644 --- a/pySEQTarget/weighting/_weight_pred.py +++ b/pySEQTarget/weighting/_weight_pred.py @@ -179,7 +179,9 @@ def _weight_predict(self, WDT): pl.Series("cense_denominator", p_denom), ] ).with_columns( - (pl.col("cense_numerator") / pl.col("cense_denominator")).alias("_cense") + (pl.col("cense_numerator") / pl.col("cense_denominator")).alias( + "_cense" + ) ) else: WDT = WDT.with_columns(pl.lit(1.0).alias("_cense")) @@ -198,7 +200,9 @@ def _weight_predict(self, WDT): pl.Series("visit_denominator", p_denom), ] ).with_columns( - (pl.col("visit_numerator") / pl.col("visit_denominator")).alias("_visit") + (pl.col("visit_numerator") / pl.col("visit_denominator")).alias( + "_visit" + ) ) else: WDT = WDT.with_columns(pl.lit(1.0).alias("_visit")) diff --git a/tests/test_no_variation.py b/tests/test_no_variation.py index e756af2..0bbabfc 100644 --- a/tests/test_no_variation.py +++ b/tests/test_no_variation.py @@ -2,7 +2,11 @@ import pandas as pd from types import SimpleNamespace -from pySEQTarget.weighting._weight_fit import _fit_denominator, _fit_numerator, _fit_pair +from pySEQTarget.weighting._weight_fit import ( + _fit_denominator, + _fit_numerator, + _fit_pair, +) def _mock_self(**overrides): @@ -30,8 +34,20 @@ def _make_data(n=60): block = np.array([0] * (n // 2) + [1] * (n // 2)) return pd.concat( [ - pd.DataFrame({"tx": block, "tx_lag": np.zeros(n, int), "followup": np.arange(1, n + 1)}), - pd.DataFrame({"tx": block, "tx_lag": np.ones(n, int), "followup": np.arange(1, n + 1)}), + pd.DataFrame( + { + "tx": block, + "tx_lag": np.zeros(n, int), + "followup": np.arange(1, n + 1), + } + ), + pd.DataFrame( + { + "tx": block, + "tx_lag": np.ones(n, int), + "followup": np.arange(1, n + 1), + } + ), ], ignore_index=True, ) @@ -42,8 +58,20 @@ def _make_data_no_variation_level0(n=60): block = np.array([0] * (n // 2) + [1] * (n // 2)) return pd.concat( [ - pd.DataFrame({"tx": np.zeros(n, int), "tx_lag": np.zeros(n, int), "followup": np.arange(1, n + 1)}), - pd.DataFrame({"tx": block, "tx_lag": np.ones(n, int), "followup": np.arange(1, n + 1)}), + pd.DataFrame( + { + "tx": np.zeros(n, int), + "tx_lag": np.zeros(n, int), + "followup": np.arange(1, n + 1), + } + ), + pd.DataFrame( + { + "tx": block, + "tx_lag": np.ones(n, int), + "followup": np.arange(1, n + 1), + } + ), ], ignore_index=True, ) @@ -91,7 +119,9 @@ def test_fit_pair_stores_none_when_no_variation(): df = pd.DataFrame({"ltfu": np.zeros(n, int), "followup": np.arange(n)}) obj = _mock_self() _fit_pair( - obj, df, "cense_colname", + obj, + df, + "cense_colname", ["1", "1"], ["cense_numerator_model", "cense_denominator_model"], ) @@ -101,13 +131,17 @@ def test_fit_pair_stores_none_when_no_variation(): def test_fit_pair_fits_when_variation_exists(): n = 60 - df = pd.DataFrame({ - "ltfu": np.array([0] * (n // 2) + [1] * (n // 2)), - "followup": np.arange(n), - }) + df = pd.DataFrame( + { + "ltfu": np.array([0] * (n // 2) + [1] * (n // 2)), + "followup": np.arange(n), + } + ) obj = _mock_self() _fit_pair( - obj, df, "cense_colname", + obj, + df, + "cense_colname", ["1", "1"], ["cense_numerator_model", "cense_denominator_model"], ) From d751848d172c8826cb1cc2b33e38e77e867bc7bc Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 14:20:44 +0000 Subject: [PATCH 14/17] Format with isort --- docs/conf.py | 1 - pySEQTarget/SEQuential.py | 35 +++++++------------------------ pySEQTarget/analysis/__init__.py | 8 ++----- pySEQTarget/weighting/__init__.py | 3 ++- tests/test_no_variation.py | 10 ++++----- 5 files changed, 16 insertions(+), 41 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index af3dca3..5917a79 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,7 +4,6 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html import importlib.metadata - # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import os diff --git a/pySEQTarget/SEQuential.py b/pySEQTarget/SEQuential.py index 36e7ca5..22efef8 100644 --- a/pySEQTarget/SEQuential.py +++ b/pySEQTarget/SEQuential.py @@ -7,39 +7,20 @@ import numpy as np import polars as pl -from .analysis import ( - _calculate_hazard, - _calculate_survival, - _clamp, - _outcome_fit, - _pred_risk, - _risk_estimates, - _subgroup_fit, -) +from .analysis import (_calculate_hazard, _calculate_survival, _clamp, + _outcome_fit, _pred_risk, _risk_estimates, + _subgroup_fit) from .error import _data_checker, _param_checker from .expansion import _binder, _diagnostics, _dynamic, _random_selection from .helpers import Offloader, _col_string, _format_time, bootstrap_loop -from .initialization import ( - _cense_denominator, - _cense_numerator, - _denominator, - _numerator, - _outcome, -) +from .initialization import (_cense_denominator, _cense_numerator, + _denominator, _numerator, _outcome) from .plot import _survival_plot from .SEQopts import SEQopts from .SEQoutput import SEQoutput -from .weighting import ( - _fit_denominator, - _fit_LTFU, - _fit_numerator, - _fit_visit, - _offload_weights, - _weight_bind, - _weight_predict, - _weight_setup, - _weight_stats, -) +from .weighting import (_fit_denominator, _fit_LTFU, _fit_numerator, + _fit_visit, _offload_weights, _weight_bind, + _weight_predict, _weight_setup, _weight_stats) class SEQuential: diff --git a/pySEQTarget/analysis/__init__.py b/pySEQTarget/analysis/__init__.py index e28c1a5..f39b8c1 100644 --- a/pySEQTarget/analysis/__init__.py +++ b/pySEQTarget/analysis/__init__.py @@ -2,12 +2,8 @@ from ._outcome_fit import _outcome_fit from ._risk_estimates import _risk_estimates from ._subgroup_fit import _subgroup_fit -from ._survival_pred import ( - _calculate_survival, - _clamp, - _get_outcome_predictions, - _pred_risk, -) +from ._survival_pred import (_calculate_survival, _clamp, + _get_outcome_predictions, _pred_risk) __all__ = [ "_calculate_hazard", diff --git a/pySEQTarget/weighting/__init__.py b/pySEQTarget/weighting/__init__.py index c1167e2..6f3c036 100644 --- a/pySEQTarget/weighting/__init__.py +++ b/pySEQTarget/weighting/__init__.py @@ -1,6 +1,7 @@ from ._weight_bind import _weight_bind from ._weight_data import _weight_setup -from ._weight_fit import _fit_denominator, _fit_LTFU, _fit_numerator, _fit_visit +from ._weight_fit import (_fit_denominator, _fit_LTFU, _fit_numerator, + _fit_visit) from ._weight_offload import _offload_weights from ._weight_pred import _weight_predict from ._weight_stats import _weight_stats diff --git a/tests/test_no_variation.py b/tests/test_no_variation.py index 0bbabfc..86a607b 100644 --- a/tests/test_no_variation.py +++ b/tests/test_no_variation.py @@ -1,12 +1,10 @@ +from types import SimpleNamespace + import numpy as np import pandas as pd -from types import SimpleNamespace -from pySEQTarget.weighting._weight_fit import ( - _fit_denominator, - _fit_numerator, - _fit_pair, -) +from pySEQTarget.weighting._weight_fit import (_fit_denominator, + _fit_numerator, _fit_pair) def _mock_self(**overrides): From 507faacc4e270593f0fe42ecdfc53779f1eab32e Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 14:30:41 +0000 Subject: [PATCH 15/17] Bump actions/upload-artifact to v7 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4c0f474..37dea86 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,7 +33,7 @@ jobs: python -m build - name: Upload distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-dists path: dist/ From 2ea27ab8d07616767a318169c43416f52ef4e946 Mon Sep 17 00:00:00 2001 From: Tom Palmer Date: Mon, 2 Mar 2026 14:30:54 +0000 Subject: [PATCH 16/17] Bump actions/download-artifact to v8 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 37dea86..483c77c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -59,7 +59,7 @@ jobs: steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: release-dists path: dist/ From bc484420a20f70b6d2d1f8725a358717ff680932 Mon Sep 17 00:00:00 2001 From: Ryan O'Dea <70209371+ryan-odea@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:57:48 +0100 Subject: [PATCH 17/17] hopefully resolved needing to update version twice --- .readthedocs.yaml | 4 +++- docs/conf.py | 7 ++++--- docs/requirements.txt | 8 -------- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index fc64a37..638b505 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -19,4 +19,6 @@ sphinx: # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: - - requirements: docs/requirements.txt \ No newline at end of file + - requirements: docs/requirements.txt + - method: pip + path: . \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 5917a79..4a3648d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,9 +10,10 @@ import sys from datetime import date -version = importlib.metadata.version("pySEQTarget") -if not version: - version = "0.12.3" +try: + version = importlib.metadata.version("pySEQTarget") +except importlib.metadata.PackageNotFoundError: + version = "unknown" sys.path.insert(0, os.path.abspath("../")) project = "pySEQTarget" diff --git a/docs/requirements.txt b/docs/requirements.txt index bcbda13..91237a9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,11 +3,3 @@ piccolo_theme sphinx-autodoc-typehints sphinx-copybutton myst-parser -numpy -polars -tqdm -statsmodels -matplotlib -pyarrow -lifelines -pySEQTarget \ No newline at end of file