Skip to content

Commit 727c14a

Browse files
authored
Merge pull request #33 from CausalInference/devel-1
Two of the earlier small tweaks from the R package - monitor log hazard ratio for boostrapping and print Hazard ratio
2 parents 1b07fa2 + 688c1fc commit 727c14a

16 files changed

Lines changed: 183 additions & 107 deletions

File tree

.github/workflows/autoformat.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
token: ${{ secrets.GITHUB_TOKEN }}
1414

1515
- name: Set up Python
16-
uses: actions/setup-python@v5
16+
uses: actions/setup-python@v6
1717
with:
1818
python-version: '3.11'
1919

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
steps:
2323
- uses: actions/checkout@v6
2424

25-
- uses: actions/setup-python@v5
25+
- uses: actions/setup-python@v6
2626
with:
2727
python-version: "3.x"
2828

.github/workflows/python-app.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ jobs:
1616
runs-on: ubuntu-latest
1717
strategy:
1818
matrix:
19-
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
19+
python-version: ["3.11", "3.12", "3.13", "3.14"]
2020

2121
steps:
2222
- uses: actions/checkout@v6
2323

2424
- name: Set up Python ${{ matrix.python-version }}
25-
uses: actions/setup-python@v5
25+
uses: actions/setup-python@v6
2626
with:
2727
python-version: ${{ matrix.python-version }}
2828

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
version = importlib.metadata.version("pySEQTarget")
1414
if not version:
15-
version = "0.12.0"
15+
version = "0.12.1"
1616
sys.path.insert(0, os.path.abspath("../"))
1717

1818
project = "pySEQTarget"

pySEQTarget/SEQopts.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ class SEQopts:
1919
:type bootstrap_CI_method: str
2020
:param cense_colname: Column name for censoring effect (LTFU, etc.)
2121
:type cense_colname: str
22-
:param cense_denominator: Override to specify denominator patsy formula for censoring models; "1" or "" indicate intercept only model
22+
:param cense_denominator: Override to specify denominator patsy formula for
23+
censoring models; "1" or "" indicate intercept only model
2324
:type cense_denominator: Optional[str] or None
2425
:param cense_numerator: Override to specify numerator patsy formula for censoring models
2526
:type cense_numerator: Optional[str] or None
@@ -55,7 +56,8 @@ class SEQopts:
5556
:type km_curves: bool
5657
:param ncores: Number of cores to use if running in parallel
5758
:type ncores: int
58-
:param numerator: Override to specify the outcome patsy formula for numerator models; "1" or "" indicate intercept only model
59+
:param numerator: Override to specify the outcome patsy formula for
60+
numerator models; "1" or "" indicate intercept only model
5961
:type numerator: str
6062
:param offload: Boolean to offload intermediate model data to disk
6163
:type offload: bool
@@ -87,7 +89,8 @@ class SEQopts:
8789
:type trial_include: bool
8890
:param visit_colname: Column name specifying visit number
8991
:type visit_colname: str
90-
:param weight_eligible_colnames: List of column names of length treatment_level to identify which rows are eligible for weight fitting
92+
:param weight_eligible_colnames: List of column names of length
93+
treatment_level to identify which rows are eligible for weight fitting
9194
:type weight_eligible_colnames: List[str]
9295
:param weight_fit_method: The fitting method to be used ["newton", "bfgs", "lbfgs", "nm"], default "newton"
9396
:type weight_fit_method: str
@@ -155,7 +158,7 @@ class SEQopts:
155158
weight_preexpansion: bool = False
156159
weighted: bool = False
157160

158-
def __post_init__(self):
161+
def _validate_bools(self):
159162
bools = [
160163
"excused",
161164
"followup_class",
@@ -176,27 +179,27 @@ def __post_init__(self):
176179
if not isinstance(getattr(self, i), bool):
177180
raise TypeError(f"{i} must be a boolean value.")
178181

182+
def _validate_ranges(self):
179183
if not isinstance(self.bootstrap_nboot, int) or self.bootstrap_nboot < 0:
180184
raise ValueError("bootstrap_nboot must be a positive integer.")
181-
182185
if self.ncores < 1 or not isinstance(self.ncores, int):
183186
raise ValueError("ncores must be a positive integer.")
184-
185187
if not (0.0 <= self.bootstrap_sample <= 1.0):
186188
raise ValueError("bootstrap_sample must be between 0 and 1.")
187189
if not (0.0 < self.bootstrap_CI < 1.0):
188190
raise ValueError("bootstrap_CI must be between 0 and 1.")
189191
if not (0.0 <= self.selection_sample <= 1.0):
190192
raise ValueError("selection_sample must be between 0 and 1.")
191193

194+
def _validate_choices(self):
192195
if self.plot_type not in ["risk", "survival", "incidence"]:
193196
raise ValueError(
194197
"plot_type must be either 'risk', 'survival', or 'incidence'."
195198
)
196-
197199
if self.bootstrap_CI_method not in ["se", "percentile"]:
198200
raise ValueError("bootstrap_CI_method must be one of 'se' or 'percentile'")
199201

202+
def _normalize_formulas(self):
200203
for i in (
201204
"covariates",
202205
"numerator",
@@ -208,5 +211,11 @@ def __post_init__(self):
208211
if attr is not None and not isinstance(attr, list):
209212
setattr(self, i, "".join(attr.split()))
210213

214+
def __post_init__(self):
215+
self._validate_bools()
216+
self._validate_ranges()
217+
self._validate_choices()
218+
self._normalize_formulas()
219+
211220
if self.offload:
212221
os.makedirs(self.offload_dir, exist_ok=True)

pySEQTarget/SEQoutput.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@ def retrieve_data(
102102
) -> pl.DataFrame:
103103
"""
104104
Getter for data stored within ``SEQoutput``
105-
:param type: Data which you would like to access, ['km_data', 'hazard', 'risk_ratio', 'risk_difference', 'unique_outcomes', 'nonunique_outcomes', 'unique_switches', 'nonunique_switches']
105+
:param type: Data which you would like to access, ['km_data', 'hazard',
106+
'risk_ratio', 'risk_difference', 'unique_outcomes',
107+
'nonunique_outcomes', 'unique_switches', 'nonunique_switches']
106108
:type type: str
107109
"""
108110
match type:

pySEQTarget/analysis/__init__.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
from ._hazard import _calculate_hazard as _calculate_hazard
2-
from ._outcome_fit import _outcome_fit as _outcome_fit
3-
from ._risk_estimates import _risk_estimates as _risk_estimates
4-
from ._subgroup_fit import _subgroup_fit as _subgroup_fit
5-
from ._survival_pred import _calculate_survival as _calculate_survival
6-
from ._survival_pred import _clamp as _clamp
7-
from ._survival_pred import \
8-
_get_outcome_predictions as _get_outcome_predictions
9-
from ._survival_pred import _pred_risk as _pred_risk
1+
from ._hazard import _calculate_hazard
2+
from ._outcome_fit import _outcome_fit
3+
from ._risk_estimates import _risk_estimates
4+
from ._subgroup_fit import _subgroup_fit
5+
from ._survival_pred import _calculate_survival
6+
from ._survival_pred import _clamp
7+
from ._survival_pred import _get_outcome_predictions
8+
from ._survival_pred import _pred_risk
9+
10+
__all__ = [
11+
"_calculate_hazard",
12+
"_outcome_fit",
13+
"_risk_estimates",
14+
"_subgroup_fit",
15+
"_calculate_survival",
16+
"_clamp",
17+
"_get_outcome_predictions",
18+
"_pred_risk",
19+
]

pySEQTarget/analysis/_hazard.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ def _calculate_hazard(self):
2424

2525

2626
def _calculate_hazard_single(self, data, idx=None, val=None):
27-
full_hr = _hazard_handler(self, data, idx, 0, self._rng)
27+
full_log_hr = _hazard_handler(self, data, idx, 0, self._rng)
2828

29-
if full_hr is None or np.isnan(full_hr):
29+
if full_log_hr is None or np.isnan(full_log_hr):
3030
return _create_hazard_output(None, None, None, val, self)
3131

3232
if self.bootstrap_nboot > 0:
33-
boot_hrs = []
33+
boot_log_hrs = []
3434

3535
for boot_idx in range(len(self._boot_samples)):
3636
id_counts = self._boot_samples[boot_idx]
@@ -43,27 +43,27 @@ def _calculate_hazard_single(self, data, idx=None, val=None):
4343

4444
boot_data = pl.concat(boot_data_list)
4545

46-
boot_hr = _hazard_handler(self, boot_data, idx, boot_idx + 1, self._rng)
47-
if boot_hr is not None and not np.isnan(boot_hr):
48-
boot_hrs.append(boot_hr)
46+
boot_log_hr = _hazard_handler(self, boot_data, idx, boot_idx + 1, self._rng)
47+
if boot_log_hr is not None and not np.isnan(boot_log_hr):
48+
boot_log_hrs.append(boot_log_hr)
4949

50-
if len(boot_hrs) == 0:
51-
return _create_hazard_output(full_hr, None, None, val, self)
50+
if len(boot_log_hrs) == 0:
51+
return _create_hazard_output(np.exp(full_log_hr), None, None, val, self)
5252

5353
if self.bootstrap_CI_method == "se":
5454
from scipy.stats import norm
5555

5656
z = norm.ppf(1 - (1 - self.bootstrap_CI) / 2)
57-
se = np.std(boot_hrs)
58-
lci = full_hr - z * se
59-
uci = full_hr + z * se
57+
se = np.std(boot_log_hrs)
58+
lci = np.exp(full_log_hr - z * se)
59+
uci = np.exp(full_log_hr + z * se)
6060
else:
61-
lci = np.quantile(boot_hrs, (1 - self.bootstrap_CI) / 2)
62-
uci = np.quantile(boot_hrs, 1 - (1 - self.bootstrap_CI) / 2)
61+
lci = np.exp(np.quantile(boot_log_hrs, (1 - self.bootstrap_CI) / 2))
62+
uci = np.exp(np.quantile(boot_log_hrs, 1 - (1 - self.bootstrap_CI) / 2))
6363
else:
6464
lci, uci = None, None
6565

66-
return _create_hazard_output(full_hr, lci, uci, val, self)
66+
return _create_hazard_output(np.exp(full_log_hr), lci, uci, val, self)
6767

6868

6969
def _hazard_handler(self, data, idx, boot_idx, rng):
@@ -191,8 +191,8 @@ def _hazard_handler(self, data, idx, boot_idx, rng):
191191
formula=f"`{self.treatment_col}{self.indicator_baseline}`",
192192
)
193193

194-
hr = np.exp(cph.params_.values[0])
195-
return hr
194+
log_hr = cph.params_.values[0]
195+
return log_hr
196196
except Exception as e:
197197
print(f"Cox model fitting failed: {e}")
198198
return None
@@ -202,13 +202,13 @@ def _create_hazard_output(hr, lci, uci, val, self):
202202
if lci is not None and uci is not None:
203203
output = pl.DataFrame(
204204
{
205-
"Hazard": [hr if hr is not None else float("nan")],
205+
"Hazard ratio": [hr if hr is not None else float("nan")],
206206
"LCI": [lci],
207207
"UCI": [uci],
208208
}
209209
)
210210
else:
211-
output = pl.DataFrame({"Hazard": [hr if hr is not None else float("nan")]})
211+
output = pl.DataFrame({"Hazard ratio": [hr if hr is not None else float("nan")]})
212212

213213
if val is not None:
214214
output = output.with_columns(pl.lit(val).alias(self.subgroup_colname))

pySEQTarget/analysis/_outcome_fit.py

Lines changed: 40 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,44 @@
55
import statsmodels.formula.api as smf
66

77

8+
def _apply_spline_formula(formula, indicator_squared):
9+
spline = "cr(followup, df=3)"
10+
11+
formula = re.sub(r"(\w+)\s*\*\s*followup\b", rf"\1*{spline}", formula)
12+
formula = re.sub(r"\bfollowup\s*\*\s*(\w+)", rf"{spline}*\1", formula)
13+
formula = re.sub(
14+
rf"\bfollowup{re.escape(indicator_squared)}\b", "", formula
15+
)
16+
formula = re.sub(r"\bfollowup\b", "", formula)
17+
18+
formula = re.sub(r"\s+", " ", formula)
19+
formula = re.sub(r"\+\s*\+", "+", formula)
20+
formula = re.sub(r"^\s*\+\s*|\s*\+\s*$", "", formula).strip()
21+
22+
if formula:
23+
return f"{formula} + I({spline}**2)"
24+
return f"I({spline}**2)"
25+
26+
27+
def _cast_categories(self, df_pd):
28+
df_pd[self.treatment_col] = df_pd[self.treatment_col].astype("category")
29+
tx_bas = f"{self.treatment_col}{self.indicator_baseline}"
30+
df_pd[tx_bas] = df_pd[tx_bas].astype("category")
31+
32+
if self.followup_class and not self.followup_spline:
33+
df_pd["followup"] = df_pd["followup"].astype("category")
34+
squared_col = f"followup{self.indicator_squared}"
35+
if squared_col in df_pd.columns:
36+
df_pd[squared_col] = df_pd[squared_col].astype("category")
37+
38+
if self.fixed_cols:
39+
for col in self.fixed_cols:
40+
if col in df_pd.columns:
41+
df_pd[col] = df_pd[col].astype("category")
42+
43+
return df_pd
44+
45+
846
def _outcome_fit(
947
self,
1048
df: pl.DataFrame,
@@ -23,41 +61,10 @@ def _outcome_fit(
2361
if self.method == "censoring":
2462
df = df.filter(pl.col("switch") != 1)
2563

26-
df_pd = df.to_pandas()
27-
28-
df_pd[self.treatment_col] = df_pd[self.treatment_col].astype("category")
29-
tx_bas = f"{self.treatment_col}{self.indicator_baseline}"
30-
df_pd[tx_bas] = df_pd[tx_bas].astype("category")
31-
32-
if self.followup_class and not self.followup_spline:
33-
df_pd["followup"] = df_pd["followup"].astype("category")
34-
squared_col = f"followup{self.indicator_squared}"
35-
if squared_col in df_pd.columns:
36-
df_pd[squared_col] = df_pd[squared_col].astype("category")
64+
df_pd = _cast_categories(self, df.to_pandas())
3765

3866
if self.followup_spline:
39-
spline = "cr(followup, df=3)"
40-
41-
formula = re.sub(r"(\w+)\s*\*\s*followup\b", rf"\1*{spline}", formula)
42-
formula = re.sub(r"\bfollowup\s*\*\s*(\w+)", rf"{spline}*\1", formula)
43-
formula = re.sub(
44-
rf"\bfollowup{re.escape(self.indicator_squared)}\b", "", formula
45-
)
46-
formula = re.sub(r"\bfollowup\b", "", formula)
47-
48-
formula = re.sub(r"\s+", " ", formula)
49-
formula = re.sub(r"\+\s*\+", "+", formula)
50-
formula = re.sub(r"^\s*\+\s*|\s*\+\s*$", "", formula).strip()
51-
52-
if formula:
53-
formula = f"{formula} + I({spline}**2)"
54-
else:
55-
formula = f"I({spline}**2)"
56-
57-
if self.fixed_cols:
58-
for col in self.fixed_cols:
59-
if col in df_pd.columns:
60-
df_pd[col] = df_pd[col].astype("category")
67+
formula = _apply_spline_formula(formula, self.indicator_squared)
6168

6269
full_formula = f"{outcome} ~ {formula}"
6370

pySEQTarget/error/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
1-
from ._data_checker import _data_checker as _data_checker
2-
from ._param_checker import _param_checker as _param_checker
1+
from ._data_checker import _data_checker
2+
from ._param_checker import _param_checker
3+
4+
__all__ = [
5+
"_data_checker",
6+
"_param_checker",
7+
]

0 commit comments

Comments
 (0)