Skip to content

Commit ed901b9

Browse files
chore: stage final Cox review patch script
1 parent 7b58733 commit ed901b9

1 file changed

Lines changed: 313 additions & 0 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
"""Temporary exact-string patcher for the final PR80 review cycle."""
2+
3+
from pathlib import Path
4+
5+
6+
def replace_once(path, old, new):
7+
file_path = Path(path)
8+
text = file_path.read_text(encoding="utf-8")
9+
if new in text:
10+
return
11+
if old not in text:
12+
raise SystemExit(f"expected block not found in {path}")
13+
file_path.write_text(text.replace(old, new, 1), encoding="utf-8")
14+
15+
16+
replace_once(
17+
"statgpu/survival/_cox_score.py",
18+
"from statgpu.backends._utils import _require_real_array\n\n\ndef score(\n",
19+
'''from statgpu.backends._utils import _require_real_array
20+
21+
22+
_MAX_CONCORDANCE_PAIR_ENTRIES = 2_000_000
23+
24+
25+
def _concordance_batch_size(n_events: int, n_samples: int) -> int:
26+
"""Bound pairwise concordance temporaries to a small fixed workspace."""
27+
return max(
28+
1,
29+
min(
30+
int(n_events),
31+
_MAX_CONCORDANCE_PAIR_ENTRIES // max(int(n_samples), 1),
32+
),
33+
)
34+
35+
36+
def score(
37+
''',
38+
)
39+
replace_once(
40+
"statgpu/survival/_cox_score.py",
41+
" chunk_size = max(1, min(n_events, int(128e6 / max(n_samples, 1))))\n",
42+
" chunk_size = _concordance_batch_size(n_events, n_samples)\n",
43+
)
44+
45+
replace_once(
46+
"statgpu/survival/_risk_sets.py",
47+
'''def _validate_counting_process_inputs(
48+
X: Any,
49+
stop: Any,
50+
event: Any,
51+
start: Any,
52+
strata: Any,
53+
) -> None:
54+
''',
55+
'''def _validate_counting_process_inputs(
56+
X: Any,
57+
stop: Any,
58+
event: Any,
59+
start: Any,
60+
strata: Any,
61+
*,
62+
require_event: bool = True,
63+
) -> None:
64+
''',
65+
)
66+
replace_once(
67+
"statgpu/survival/_risk_sets.py",
68+
' if _scalar_int(_sum(event, backend, xp)) == 0:\n raise ValueError("at least one observed event is required")\n',
69+
' if require_event and _scalar_int(_sum(event, backend, xp)) == 0:\n raise ValueError("at least one observed event is required")\n',
70+
)
71+
replace_once(
72+
"statgpu/survival/_risk_sets.py",
73+
'''def prepare_counting_process_inputs(
74+
X: Any,
75+
stop: Any,
76+
event: Any,
77+
*,
78+
start: Optional[Any] = None,
79+
strata: Optional[Any] = None,
80+
) -> Tuple[Any, Any, Any, Any, Any]:
81+
''',
82+
'''def prepare_counting_process_inputs(
83+
X: Any,
84+
stop: Any,
85+
event: Any,
86+
*,
87+
start: Optional[Any] = None,
88+
strata: Optional[Any] = None,
89+
require_event: bool = True,
90+
) -> Tuple[Any, Any, Any, Any, Any]:
91+
''',
92+
)
93+
replace_once(
94+
"statgpu/survival/_risk_sets.py",
95+
" _validate_counting_process_inputs(X, stop, event, start, strata)\n",
96+
''' _validate_counting_process_inputs(
97+
X, stop, event, start, strata, require_event=bool(require_event)
98+
)
99+
''',
100+
)
101+
risk_path = Path("statgpu/survival/_risk_sets.py")
102+
risk_text = risk_path.read_text(encoding="utf-8")
103+
marker = "def counting_process_concordance("
104+
before, tail = risk_text.split(marker, 1)
105+
old_call = ''' X, stop, event, start, strata = prepare_counting_process_inputs(
106+
X, stop, event, start=start, strata=strata
107+
)
108+
'''
109+
new_call = ''' X, stop, event, start, strata = prepare_counting_process_inputs(
110+
X,
111+
stop,
112+
event,
113+
start=start,
114+
strata=strata,
115+
require_event=False,
116+
)
117+
'''
118+
if new_call not in tail:
119+
if old_call not in tail:
120+
raise SystemExit("concordance prepare call not found")
121+
tail = tail.replace(old_call, new_call, 1)
122+
risk_path.write_text(before + marker + tail, encoding="utf-8")
123+
124+
replace_once(
125+
"statgpu/survival/_cox_cv.py",
126+
''' compute_inference=bool(self.compute_inference),
127+
cov_type=cov_type_name,
128+
''',
129+
''' compute_inference=bool(self.compute_inference),
130+
compute_cindex=False,
131+
cov_type=cov_type_name,
132+
''',
133+
)
134+
135+
replace_once(
136+
"statgpu/linear_model/penalized/_penalized_cox.py",
137+
"from ._base import PenalizedGeneralizedLinearModel\n\n\nclass PenalizedCoxPHModel",
138+
'''from ._base import PenalizedGeneralizedLinearModel
139+
140+
141+
def _validate_boolean_control(value, name):
142+
"""Accept booleans or integer 0/1 without interpreting truthy strings."""
143+
if isinstance(value, (bool, np.bool_)):
144+
return
145+
if isinstance(value, (int, np.integer)) and int(value) in (0, 1):
146+
return
147+
raise ValueError(f"{name} must be a boolean or integer 0/1")
148+
149+
150+
class PenalizedCoxPHModel''',
151+
)
152+
replace_once(
153+
"statgpu/linear_model/penalized/_penalized_cox.py",
154+
''' ):
155+
if fit_intercept:
156+
raise ValueError(
157+
''',
158+
''' ):
159+
for name, value in (
160+
("fit_intercept", fit_intercept),
161+
("gpu_memory_cleanup", gpu_memory_cleanup),
162+
("compute_inference", compute_inference),
163+
("lla", lla),
164+
):
165+
_validate_boolean_control(value, name)
166+
if bool(fit_intercept):
167+
raise ValueError(
168+
''',
169+
)
170+
replace_once(
171+
"statgpu/linear_model/penalized/_penalized_cox.py",
172+
''' def set_params(self, **params):
173+
"""Set estimator parameters while preserving the no-intercept contract."""
174+
if params.get("fit_intercept", False):
175+
''',
176+
''' def set_params(self, **params):
177+
"""Set estimator parameters while preserving the no-intercept contract."""
178+
for name in (
179+
"fit_intercept",
180+
"gpu_memory_cleanup",
181+
"compute_inference",
182+
"lla",
183+
):
184+
if name in params:
185+
_validate_boolean_control(params[name], name)
186+
if bool(params.get("fit_intercept", False)):
187+
''',
188+
)
189+
190+
Path("dev/tests/test_pr80_complete_review_cycle.py").write_text(
191+
r'''"""Regression gates from the final complete PR80 review cycle."""
192+
193+
import numpy as np
194+
import pytest
195+
196+
from statgpu.linear_model import PenalizedCoxPHModel
197+
from statgpu.survival import CoxPH, CoxPHCV
198+
from statgpu.survival._cox_score import (
199+
_MAX_CONCORDANCE_PAIR_ENTRIES,
200+
_concordance_batch_size,
201+
)
202+
from statgpu.survival._risk_sets import counting_process_concordance
203+
204+
205+
def _fit_sample(seed=2401, n=36, p=2):
206+
rng = np.random.default_rng(seed)
207+
X = rng.normal(size=(n, p))
208+
stop = np.arange(1, n + 1, dtype=np.float64)
209+
event = np.ones(n, dtype=np.float64)
210+
event[::5] = 0.0
211+
event[0] = 1.0
212+
return X, stop, event
213+
214+
215+
def test_ordinary_concordance_batch_is_bounded():
216+
batch = _concordance_batch_size(100_000, 1_000)
217+
assert batch == 2_000
218+
assert batch * 1_000 <= _MAX_CONCORDANCE_PAIR_ENTRIES
219+
assert _concordance_batch_size(0, 1_000) == 1
220+
221+
222+
def test_all_censored_concordance_is_neutral_across_public_paths():
223+
X, stop, event = _fit_sample(p=1)
224+
fitted = CoxPH(
225+
compute_inference=False,
226+
compute_cindex=False,
227+
max_iter=80,
228+
tol=1e-7,
229+
).fit(X, stop, event)
230+
X_score = X[:6]
231+
stop_score = np.arange(1, 7, dtype=np.float64)
232+
censored = np.zeros(6, dtype=np.float64)
233+
234+
assert fitted.score(X_score, stop_score, censored) == 0.5
235+
assert fitted.score(
236+
X_score,
237+
stop_score,
238+
censored,
239+
start=np.zeros(6),
240+
strata=np.array([0, 0, 0, 1, 1, 1]),
241+
) == 0.5
242+
assert float(
243+
counting_process_concordance(
244+
fitted.coef_,
245+
X_score,
246+
stop_score,
247+
censored,
248+
start=np.zeros(6),
249+
strata=np.array([0, 0, 0, 1, 1, 1]),
250+
)
251+
) == 0.5
252+
253+
254+
def test_penalized_cox_all_censored_score_is_neutral():
255+
X, stop, event = _fit_sample(seed=2402, p=1)
256+
model = PenalizedCoxPHModel(
257+
penalty="l2",
258+
alpha=0.2,
259+
max_iter=80,
260+
tol=1e-6,
261+
compute_inference=False,
262+
).fit(X, np.column_stack((stop, event)))
263+
target = np.column_stack((stop[:5], np.zeros(5)))
264+
assert model.score(X[:5], target) == 0.5
265+
266+
267+
def test_coxphcv_final_refit_skips_hidden_training_concordance():
268+
X, stop, event = _fit_sample(seed=2403)
269+
model = CoxPHCV(
270+
penalties=np.array([1.0]),
271+
cv=2,
272+
random_state=0,
273+
compute_inference=False,
274+
max_iter=100,
275+
tol=1e-6,
276+
device="cpu",
277+
).fit(X, stop, event)
278+
assert model.estimator_.compute_cindex is False
279+
assert model.estimator_.concordance_ is None
280+
assert np.isfinite(model.score(X, stop, event))
281+
282+
283+
@pytest.mark.parametrize(
284+
"name",
285+
["fit_intercept", "gpu_memory_cleanup", "compute_inference", "lla"],
286+
)
287+
def test_penalized_cox_rejects_truthy_string_boolean_controls(name):
288+
with pytest.raises(ValueError, match=rf"{name} must be a boolean"):
289+
PenalizedCoxPHModel(**{name: "False"})
290+
291+
model = PenalizedCoxPHModel()
292+
with pytest.raises(ValueError, match=rf"{name} must be a boolean"):
293+
model.set_params(**{name: "False"})
294+
295+
296+
def test_penalized_cox_accepts_integer_boolean_controls_and_clones():
297+
pytest.importorskip("sklearn")
298+
from sklearn.base import clone
299+
300+
model = PenalizedCoxPHModel(
301+
fit_intercept=0,
302+
gpu_memory_cleanup=0,
303+
compute_inference=0,
304+
lla=1,
305+
)
306+
cloned = clone(model)
307+
assert cloned.fit_intercept == 0
308+
assert cloned.gpu_memory_cleanup == 0
309+
assert cloned.compute_inference == 0
310+
assert cloned.lla == 1
311+
''',
312+
encoding="utf-8",
313+
)

0 commit comments

Comments
 (0)