|
| 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