mlsurv 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. mlsurv/__init__.py +215 -0
  2. mlsurv/_calibration_thresholds.py +25 -0
  3. mlsurv/_iter_warn.py +396 -0
  4. mlsurv/_loky_patch.py +34 -0
  5. mlsurv/_patient_select.py +109 -0
  6. mlsurv/_shared.py +585 -0
  7. mlsurv/_warnings.py +280 -0
  8. mlsurv/cli/__init__.py +173 -0
  9. mlsurv/cli/__main__.py +4 -0
  10. mlsurv/cli/_artifacts.py +471 -0
  11. mlsurv/cli/_commands.py +1891 -0
  12. mlsurv/cli/_data.py +450 -0
  13. mlsurv/cli/_exit_codes.py +16 -0
  14. mlsurv/cli/_helpers.py +143 -0
  15. mlsurv/cli/_output.py +69 -0
  16. mlsurv/cli/_parsers.py +1368 -0
  17. mlsurv/cli/_plot.py +879 -0
  18. mlsurv/constants.py +317 -0
  19. mlsurv/evaluation/__init__.py +92 -0
  20. mlsurv/evaluation/_bootstrap_iteration.py +971 -0
  21. mlsurv/evaluation/_bootstrap_workers.py +240 -0
  22. mlsurv/evaluation/_calibrators.py +482 -0
  23. mlsurv/evaluation/_coefficients.py +257 -0
  24. mlsurv/evaluation/_concordance.py +533 -0
  25. mlsurv/evaluation/_config.py +113 -0
  26. mlsurv/evaluation/_ctd.py +113 -0
  27. mlsurv/evaluation/_diagnostics.py +335 -0
  28. mlsurv/evaluation/_explain.py +168 -0
  29. mlsurv/evaluation/_feature_summary.py +388 -0
  30. mlsurv/evaluation/_incremental.py +437 -0
  31. mlsurv/evaluation/_interactions.py +1501 -0
  32. mlsurv/evaluation/_paired_comparison.py +706 -0
  33. mlsurv/evaluation/_prediction_bootstrap.py +660 -0
  34. mlsurv/evaluation/_prediction_helpers.py +482 -0
  35. mlsurv/evaluation/_recalibration.py +499 -0
  36. mlsurv/evaluation/_shap.py +389 -0
  37. mlsurv/evaluation/_subpopulation.py +807 -0
  38. mlsurv/evaluation/_univariate_cox.py +101 -0
  39. mlsurv/evaluation/bootstrap.py +1266 -0
  40. mlsurv/evaluation/evaluate.py +2177 -0
  41. mlsurv/evaluation/helpers.py +108 -0
  42. mlsurv/evaluation/importance.py +871 -0
  43. mlsurv/evaluation/metrics.py +2882 -0
  44. mlsurv/exceptions.py +54 -0
  45. mlsurv/guidance.py +865 -0
  46. mlsurv/learner/__init__.py +19 -0
  47. mlsurv/learner/_benchmarking.py +970 -0
  48. mlsurv/learner/_config.py +76 -0
  49. mlsurv/learner/_core.py +1384 -0
  50. mlsurv/learner/_cv_training.py +706 -0
  51. mlsurv/learner/_eval_context.py +64 -0
  52. mlsurv/learner/_eval_times.py +119 -0
  53. mlsurv/learner/_evaluation.py +71 -0
  54. mlsurv/learner/_evaluation_bootstrap.py +424 -0
  55. mlsurv/learner/_evaluation_core.py +1143 -0
  56. mlsurv/learner/_evaluation_helpers.py +932 -0
  57. mlsurv/learner/_evaluation_interactions.py +1642 -0
  58. mlsurv/learner/_explain.py +144 -0
  59. mlsurv/learner/_export.py +399 -0
  60. mlsurv/learner/_format.py +275 -0
  61. mlsurv/learner/_patient_report.py +510 -0
  62. mlsurv/learner/_patient_report_html.py +587 -0
  63. mlsurv/learner/_patient_report_template.py +385 -0
  64. mlsurv/learner/_patient_resolve.py +91 -0
  65. mlsurv/learner/_persistence.py +457 -0
  66. mlsurv/learner/_prediction.py +590 -0
  67. mlsurv/learner/_recalibration.py +290 -0
  68. mlsurv/learner/_references.py +193 -0
  69. mlsurv/learner/_reports.py +237 -0
  70. mlsurv/learner/_setup.py +2133 -0
  71. mlsurv/learner/_state.py +253 -0
  72. mlsurv/learner/_studies.py +235 -0
  73. mlsurv/learner/_sweep.py +568 -0
  74. mlsurv/learner/_training.py +1453 -0
  75. mlsurv/learner/_tuning.py +1134 -0
  76. mlsurv/learner/_views.py +453 -0
  77. mlsurv/learner/_visualization.py +1337 -0
  78. mlsurv/logging_utils.py +254 -0
  79. mlsurv/models/__init__.py +445 -0
  80. mlsurv/models/_auto_defaults.py +369 -0
  81. mlsurv/models/_metadata.py +35 -0
  82. mlsurv/models/_overflow.py +250 -0
  83. mlsurv/models/_reference_adapter.py +127 -0
  84. mlsurv/models/base.py +763 -0
  85. mlsurv/models/coxnet.py +470 -0
  86. mlsurv/models/coxph.py +442 -0
  87. mlsurv/models/deephit.py +459 -0
  88. mlsurv/models/deepsurv.py +624 -0
  89. mlsurv/models/fssvm.py +412 -0
  90. mlsurv/models/gbsa.py +181 -0
  91. mlsurv/models/rsf.py +170 -0
  92. mlsurv/models/weibull_aft.py +342 -0
  93. mlsurv/models/xgbaft.py +770 -0
  94. mlsurv/models/xgbcox.py +339 -0
  95. mlsurv/preprocessing/__init__.py +46 -0
  96. mlsurv/preprocessing/_cox_fit.py +285 -0
  97. mlsurv/preprocessing/indicators.py +64 -0
  98. mlsurv/preprocessing/pipelines.py +281 -0
  99. mlsurv/preprocessing/selection.py +1201 -0
  100. mlsurv/preprocessing/utils.py +221 -0
  101. mlsurv/py.typed +0 -0
  102. mlsurv/reports/__init__.py +7 -0
  103. mlsurv/reports/_constants.py +90 -0
  104. mlsurv/reports/_figure_export.py +103 -0
  105. mlsurv/reports/_helpers.py +213 -0
  106. mlsurv/reports/_text.py +38 -0
  107. mlsurv/reports/generator.py +697 -0
  108. mlsurv/reports/renderers/__init__.py +11 -0
  109. mlsurv/reports/renderers/_figures.py +415 -0
  110. mlsurv/reports/renderers/_scripts.py +488 -0
  111. mlsurv/reports/renderers/_styles.py +800 -0
  112. mlsurv/reports/renderers/html.py +537 -0
  113. mlsurv/reports/renderers/manuscript.py +345 -0
  114. mlsurv/reports/renderers/markdown.py +388 -0
  115. mlsurv/reports/sections/__init__.py +50 -0
  116. mlsurv/reports/sections/_metric_glossary.py +70 -0
  117. mlsurv/reports/sections/_subsection_base.py +48 -0
  118. mlsurv/reports/sections/_trained_details.py +314 -0
  119. mlsurv/reports/sections/base.py +565 -0
  120. mlsurv/reports/sections/benchmarking.py +987 -0
  121. mlsurv/reports/sections/data_preprocessing.py +1918 -0
  122. mlsurv/reports/sections/environment.py +469 -0
  123. mlsurv/reports/sections/evaluation/__init__.py +10 -0
  124. mlsurv/reports/sections/evaluation/_base.py +35 -0
  125. mlsurv/reports/sections/evaluation/_calibration.py +413 -0
  126. mlsurv/reports/sections/evaluation/_discrimination.py +162 -0
  127. mlsurv/reports/sections/evaluation/_helpers.py +99 -0
  128. mlsurv/reports/sections/evaluation/_methodology.py +64 -0
  129. mlsurv/reports/sections/evaluation/_paired_comparison.py +196 -0
  130. mlsurv/reports/sections/evaluation/_performance.py +468 -0
  131. mlsurv/reports/sections/evaluation/_roc.py +224 -0
  132. mlsurv/reports/sections/evaluation/_section.py +233 -0
  133. mlsurv/reports/sections/evaluation/_stratification.py +678 -0
  134. mlsurv/reports/sections/feature_analysis/__init__.py +20 -0
  135. mlsurv/reports/sections/feature_analysis/_audit.py +255 -0
  136. mlsurv/reports/sections/feature_analysis/_coefficient_analysis.py +100 -0
  137. mlsurv/reports/sections/feature_analysis/_coefficients.py +140 -0
  138. mlsurv/reports/sections/feature_analysis/_correlation.py +251 -0
  139. mlsurv/reports/sections/feature_analysis/_hazard_ratios.py +198 -0
  140. mlsurv/reports/sections/feature_analysis/_helpers.py +128 -0
  141. mlsurv/reports/sections/feature_analysis/_importance.py +445 -0
  142. mlsurv/reports/sections/feature_analysis/_interactions.py +253 -0
  143. mlsurv/reports/sections/feature_analysis/_interactive_table.py +353 -0
  144. mlsurv/reports/sections/feature_analysis/_section.py +858 -0
  145. mlsurv/reports/sections/feature_analysis/_shap.py +102 -0
  146. mlsurv/reports/sections/limitations.py +1027 -0
  147. mlsurv/reports/sections/model_development.py +851 -0
  148. mlsurv/reports/sections/references.py +863 -0
  149. mlsurv/reports/sections/subpopulation_analysis.py +288 -0
  150. mlsurv/reports/sections/summary.py +1114 -0
  151. mlsurv/reports/sections/tripod_checklist.py +474 -0
  152. mlsurv/reports/sections/validation/__init__.py +10 -0
  153. mlsurv/reports/sections/validation/_base.py +41 -0
  154. mlsurv/reports/sections/validation/_bootstrap.py +200 -0
  155. mlsurv/reports/sections/validation/_calibration.py +323 -0
  156. mlsurv/reports/sections/validation/_discrimination.py +120 -0
  157. mlsurv/reports/sections/validation/_helpers.py +179 -0
  158. mlsurv/reports/sections/validation/_performance_drop.py +150 -0
  159. mlsurv/reports/sections/validation/_section.py +496 -0
  160. mlsurv/reports/sections/validation/_stratification.py +377 -0
  161. mlsurv/reports/sections/validation/_table1.py +351 -0
  162. mlsurv/results/__init__.py +65 -0
  163. mlsurv/results/_containers_extraction.py +321 -0
  164. mlsurv/results/_containers_helpers.py +410 -0
  165. mlsurv/results/_containers_printing.py +343 -0
  166. mlsurv/results/_explanation.py +46 -0
  167. mlsurv/results/_prediction.py +733 -0
  168. mlsurv/results/containers.py +1662 -0
  169. mlsurv/results/printing.py +1599 -0
  170. mlsurv/results/tables.py +396 -0
  171. mlsurv/tuning/__init__.py +72 -0
  172. mlsurv/tuning/base.py +1528 -0
  173. mlsurv/tuning/exceptions.py +24 -0
  174. mlsurv/tuning/optuna_tuner.py +89 -0
  175. mlsurv/tuning/search_spaces.py +592 -0
  176. mlsurv/tuning/storage.py +108 -0
  177. mlsurv/tuning/studies.py +389 -0
  178. mlsurv/tuning/tuners/__init__.py +72 -0
  179. mlsurv/tuning/tuners/coxnet.py +50 -0
  180. mlsurv/tuning/tuners/coxph.py +46 -0
  181. mlsurv/tuning/tuners/custom.py +55 -0
  182. mlsurv/tuning/tuners/deephit.py +57 -0
  183. mlsurv/tuning/tuners/deepsurv.py +56 -0
  184. mlsurv/tuning/tuners/fssvm.py +78 -0
  185. mlsurv/tuning/tuners/gbsa.py +29 -0
  186. mlsurv/tuning/tuners/rsf.py +30 -0
  187. mlsurv/tuning/tuners/weibull_aft.py +27 -0
  188. mlsurv/tuning/tuners/xgbaft.py +44 -0
  189. mlsurv/tuning/tuners/xgbcox.py +35 -0
  190. mlsurv/utils.py +1730 -0
  191. mlsurv/viz/__init__.py +179 -0
  192. mlsurv/viz/_context.py +348 -0
  193. mlsurv/viz/_data.py +1323 -0
  194. mlsurv/viz/_data_metrics.py +1295 -0
  195. mlsurv/viz/_features_coefficients.py +795 -0
  196. mlsurv/viz/_features_correlation.py +286 -0
  197. mlsurv/viz/_features_hazard_ratios.py +966 -0
  198. mlsurv/viz/_features_helpers.py +144 -0
  199. mlsurv/viz/_features_importance.py +623 -0
  200. mlsurv/viz/_features_importance_multi.py +840 -0
  201. mlsurv/viz/_metrics_helpers.py +846 -0
  202. mlsurv/viz/_metrics_multi.py +527 -0
  203. mlsurv/viz/_metrics_renderers.py +603 -0
  204. mlsurv/viz/_metrics_roc.py +884 -0
  205. mlsurv/viz/_metrics_time.py +1214 -0
  206. mlsurv/viz/_palettes.py +1055 -0
  207. mlsurv/viz/_plot_impl.py +84 -0
  208. mlsurv/viz/_plot_impl_calibration.py +726 -0
  209. mlsurv/viz/_plot_impl_importance.py +69 -0
  210. mlsurv/viz/_plot_impl_metric.py +513 -0
  211. mlsurv/viz/_plot_impl_metric_time.py +821 -0
  212. mlsurv/viz/_plot_impl_roc.py +804 -0
  213. mlsurv/viz/_plot_impl_secondary.py +840 -0
  214. mlsurv/viz/_plot_impl_shared.py +494 -0
  215. mlsurv/viz/_plot_impl_survival.py +400 -0
  216. mlsurv/viz/_plotly_helpers.py +106 -0
  217. mlsurv/viz/_resolve.py +489 -0
  218. mlsurv/viz/_survival_figure.py +203 -0
  219. mlsurv/viz/_survival_helpers.py +376 -0
  220. mlsurv/viz/_survival_logrank.py +192 -0
  221. mlsurv/viz/_survival_maxstat.py +458 -0
  222. mlsurv/viz/_survival_renderers.py +692 -0
  223. mlsurv/viz/_theme.py +401 -0
  224. mlsurv/viz/_time_format.py +50 -0
  225. mlsurv/viz/_validation.py +45 -0
  226. mlsurv/viz/benchmarking_scatter.py +274 -0
  227. mlsurv/viz/calibration.py +1333 -0
  228. mlsurv/viz/diagnostics.py +713 -0
  229. mlsurv/viz/distributions.py +372 -0
  230. mlsurv/viz/explain.py +278 -0
  231. mlsurv/viz/export.py +337 -0
  232. mlsurv/viz/features.py +420 -0
  233. mlsurv/viz/flowchart.py +252 -0
  234. mlsurv/viz/interactions.py +1080 -0
  235. mlsurv/viz/metrics.py +911 -0
  236. mlsurv/viz/missing.py +145 -0
  237. mlsurv/viz/shap.py +637 -0
  238. mlsurv/viz/survival.py +509 -0
  239. mlsurv/viz/sweep.py +160 -0
  240. mlsurv-0.1.0.dist-info/METADATA +297 -0
  241. mlsurv-0.1.0.dist-info/RECORD +245 -0
  242. mlsurv-0.1.0.dist-info/WHEEL +5 -0
  243. mlsurv-0.1.0.dist-info/entry_points.txt +2 -0
  244. mlsurv-0.1.0.dist-info/licenses/LICENSE +9 -0
  245. mlsurv-0.1.0.dist-info/top_level.txt +1 -0
mlsurv/__init__.py ADDED
@@ -0,0 +1,215 @@
1
+ """
2
+ mlsurv: Low-code survival machine learning package.
3
+ """
4
+
5
+ import os
6
+ import tempfile
7
+ import warnings
8
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
9
+ from pathlib import Path
10
+
11
+ # Single source of truth: read the version from the installed package metadata
12
+ # (pyproject.toml) so it can never drift from the distribution version.
13
+ try:
14
+ __version__ = _pkg_version("mlsurv")
15
+ except PackageNotFoundError: # running from an uninstalled source checkout
16
+ __version__ = "0.0.0+unknown"
17
+
18
+
19
+ def _configure_matplotlib_cache_dir() -> None:
20
+ """Set a writable MPLCONFIGDIR when HOME is not writable."""
21
+ if os.environ.get("MPLCONFIGDIR"):
22
+ return
23
+
24
+ home_dir = Path.home()
25
+ mpl_dir = home_dir / ".matplotlib"
26
+
27
+ # If an existing cache dir is writable, keep matplotlib defaults.
28
+ if mpl_dir.exists() and os.access(mpl_dir, os.W_OK):
29
+ return
30
+
31
+ # If HOME itself is writable, matplotlib can create ~/.matplotlib.
32
+ if os.access(home_dir, os.W_OK):
33
+ return
34
+
35
+ try:
36
+ fallback = Path(tempfile.gettempdir()) / "mlsurv-matplotlib"
37
+ fallback.mkdir(parents=True, exist_ok=True)
38
+ os.environ["MPLCONFIGDIR"] = str(fallback)
39
+ except OSError:
40
+ from mlsurv._warnings import InternalWarning
41
+ warnings.warn(
42
+ "mlsurv could not create a writable matplotlib cache directory. "
43
+ "Set the MPLCONFIGDIR environment variable to a writable path "
44
+ "to avoid potential plotting errors.",
45
+ InternalWarning,
46
+ stacklevel=1,
47
+ )
48
+
49
+
50
+ # Register mlsurv's tiered warning filters — must be before any code
51
+ # that may emit warnings (e.g., matplotlib cache setup).
52
+ from mlsurv._warnings import configure_default_filters
53
+ configure_default_filters()
54
+
55
+
56
+ def _check_stdout_encoding() -> None:
57
+ """Warn if stdout encoding cannot represent Unicode used by mlsurv."""
58
+ import codecs
59
+ import sys
60
+ encoding = getattr(sys.stdout, "encoding", None)
61
+ if not encoding:
62
+ return
63
+ try:
64
+ canonical = codecs.lookup(encoding).name
65
+ except LookupError:
66
+ return
67
+ if canonical in ("cp1252", "ascii", "iso8859-1", "latin-1"):
68
+ from mlsurv._warnings import SuggestionWarning
69
+ warnings.warn(
70
+ f"stdout encoding is '{encoding}', which cannot represent all "
71
+ "characters used by mlsurv. Set the environment variable "
72
+ "PYTHONIOENCODING=utf-8 to avoid UnicodeEncodeError crashes.",
73
+ SuggestionWarning,
74
+ stacklevel=1,
75
+ )
76
+
77
+
78
+ _check_stdout_encoding()
79
+ _configure_matplotlib_cache_dir()
80
+
81
+ # Suppress loky resource_tracker KeyError tracebacks on Windows.
82
+ # Loky's tracker subprocess has a race condition where multiple workers
83
+ # UNREGISTER the same temp folder. We swap the module-level `main`
84
+ # reference so the subprocess imports our wrapper (mlsurv._loky_patch),
85
+ # which installs a selective sys.excepthook before delegating to the
86
+ # real main. Also disable memmapping globally since it provides
87
+ # negligible benefit for typical survival analysis datasets.
88
+ import joblib
89
+ joblib.parallel_config(max_nbytes=None)
90
+ from joblib.externals.loky.backend import resource_tracker as _rt_mod
91
+ from mlsurv._loky_patch import main as _patched_rt_main
92
+ _rt_mod.main = _patched_rt_main
93
+
94
+ # Suppress NumPy 2.x deprecation warning from sksurv calling np.trapz.
95
+ # Cannot fix upstream; narrow filter matches only sksurv.metrics subpackage.
96
+ warnings.filterwarnings(
97
+ "ignore",
98
+ message=r"`trapz` is deprecated",
99
+ category=DeprecationWarning,
100
+ module=r"sksurv\.metrics",
101
+ )
102
+
103
+ # Suppress lifelines emitting pandas-4.0 keyword-only deprecation warnings
104
+ # from internal Series.var/mean/std calls during fit. On lifelines
105
+ # v0.30.3 + pandas 3.0.2 this is ``pandas.errors.Pandas4Warning`` (a
106
+ # ``DeprecationWarning`` subclass), but earlier versions of lifelines have
107
+ # emitted equivalent messages as ``UserWarning``. Use ``category=Warning``
108
+ # (the common base) so the filter is robust across versions; the message +
109
+ # module narrowing keeps legitimate lifelines warnings (convergence,
110
+ # ill-conditioned design, etc.) flowing through.
111
+ warnings.filterwarnings(
112
+ "ignore",
113
+ message=r"Starting with pandas version 4\.0",
114
+ category=Warning,
115
+ module=r"lifelines\..*",
116
+ )
117
+
118
+ from mlsurv.utils import (
119
+ to_structured_array, load_survival_data,
120
+ compute_survival_time, SurvivalArray,
121
+ )
122
+
123
+ # Model wrappers (imported after definition)
124
+ from mlsurv.models import (
125
+ BaseSurvivalModel,
126
+ CoxPHModel,
127
+ CoxNetModel,
128
+ RSFModel,
129
+ GBSAModel,
130
+ FSSVMModel,
131
+ XGBCoxModel,
132
+ XGBAFTModel,
133
+ WeibullAFTModel,
134
+ )
135
+
136
+ # Result containers
137
+ from mlsurv.results import (
138
+ EvaluationResult,
139
+ BootstrapResult,
140
+ ModelResult,
141
+ ValidationResult,
142
+ BenchmarkResult,
143
+ )
144
+
145
+ # Preprocessing
146
+ from mlsurv.preprocessing import (
147
+ TopKCoxSelector,
148
+ )
149
+
150
+ # Guidance
151
+ from mlsurv.guidance import recommend_models, RecommendedModels
152
+
153
+ # Main API
154
+ from mlsurv.learner import SurvivalLearner
155
+ load_learner = SurvivalLearner.load_learner
156
+
157
+ # Reports
158
+ from mlsurv.reports import ReportGenerator
159
+
160
+ __all__ = [
161
+ # Version
162
+ "__version__",
163
+ # Main API
164
+ "SurvivalLearner",
165
+ "load_learner",
166
+ # Reports
167
+ "ReportGenerator",
168
+ # Utilities
169
+ "recommend_models",
170
+ "RecommendedModels",
171
+ "to_structured_array",
172
+ "load_survival_data",
173
+ "compute_survival_time",
174
+ "SurvivalArray",
175
+ # Models
176
+ "BaseSurvivalModel",
177
+ "CoxPHModel",
178
+ "CoxNetModel",
179
+ "RSFModel",
180
+ "GBSAModel",
181
+ "FSSVMModel",
182
+ "XGBCoxModel",
183
+ "XGBAFTModel",
184
+ "WeibullAFTModel",
185
+ # Results
186
+ "EvaluationResult",
187
+ "BootstrapResult",
188
+ "ModelResult",
189
+ "ValidationResult",
190
+ "BenchmarkResult",
191
+ # Preprocessing
192
+ "TopKCoxSelector",
193
+ ]
194
+
195
+ # Lazy import for DeepSurv (requires torch)
196
+ def __getattr__(name):
197
+ if name == "DeepSurvModel":
198
+ try:
199
+ from mlsurv.models.deepsurv import DeepSurvModel
200
+ return DeepSurvModel
201
+ except ImportError:
202
+ raise ImportError(
203
+ "DeepSurvModel requires torch and pycox. "
204
+ "Install with: pip install mlsurv[deep]"
205
+ )
206
+ if name == "DeepHitModel":
207
+ try:
208
+ from mlsurv.models.deephit import DeepHitModel
209
+ return DeepHitModel
210
+ except ImportError:
211
+ raise ImportError(
212
+ "DeepHitModel requires torch and pycox. "
213
+ "Install with: pip install mlsurv[deep]"
214
+ )
215
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,25 @@
1
+ """Shared calibration thresholds.
2
+
3
+ Single source of truth for the slope/p-value cutoffs used by both the
4
+ runtime ``MethodologicalWarning`` (``mlsurv.evaluation.evaluate``) and the
5
+ HTML report's poor-calibration flag (``mlsurv.reports.sections.limitations``).
6
+ Lives at the package root to avoid an upward dependency from ``evaluation``
7
+ into ``reports``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ # Calibration slope is the sole drift trigger. The band is deliberately wide:
13
+ # a slope inside [0.5, 1.5] (|slope-1| <= 0.5) is treated as adequately
14
+ # calibrated for deployment, so the flag fires only on genuinely large
15
+ # miscalibration that calibrating S(t|x) would meaningfully fix. Both
16
+ # CAL_SLOPE_LOW/HIGH and SEVERE_DRIFT_THRESHOLD describe the same |slope-1| > 0.5
17
+ # cutoff (kept as separate names for the two call sites that read them).
18
+ CAL_SLOPE_LOW = 0.5
19
+ CAL_SLOPE_HIGH = 1.5
20
+ SEVERE_DRIFT_THRESHOLD = 0.5
21
+
22
+ # GND (Greenwood-Nam-D'Agostino) p-value: reported as context in the drift
23
+ # message text only. It is NOT a standalone trigger -- it over-rejects on
24
+ # well-powered cohorts, so the slope band alone decides whether to flag.
25
+ P_THRESHOLD_SIGNIFICANT = 0.05
mlsurv/_iter_warn.py ADDED
@@ -0,0 +1,396 @@
1
+ """Coalescer for repeated IterativeWarnings.
2
+
3
+ Per-iteration loops emit warnings whose text varies per iteration (e.g.,
4
+ ``seed=...`` for failed bootstrap iterations, or ``label='X'`` for
5
+ small-subpopulation notices). Python's ``simplefilter("once", ...)`` cannot
6
+ deduplicate when message text differs, so stderr fills with hundreds of
7
+ near-identical lines.
8
+
9
+ Two modes are supported:
10
+
11
+ * ``mode="failures"`` (default): each record carries a caught
12
+ exception. Examples are formatted as
13
+ ``"{summary_label} iteration failed{prefix}: {message}"``; the aggregate
14
+ summary describes count + error-type diversity.
15
+
16
+ * ``mode="notices"``: each record carries a pre-rendered message
17
+ (no exception). Examples are emitted verbatim; the aggregate summary
18
+ reads ``"{n}/{n_total} ({pct}%) {summary_label}s {notice_phrase}: {keys}.{tail}"``,
19
+ where each ``key`` is the notice's ``display`` text (or ``group_key`` as
20
+ a fallback). ``group_key`` is reserved as a true identifier for future
21
+ grouping/dedup logic; ``display`` is the short summary-list text.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import warnings
27
+ from collections import OrderedDict
28
+ from dataclasses import dataclass
29
+ from typing import Iterable, Literal, Optional, Type
30
+
31
+ from ._warnings import IterativeWarning
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class IterativeFailure:
36
+ """A single per-iteration failure to be reported by the coalescer."""
37
+
38
+ exc_type_name: str
39
+ message: str
40
+ context_prefix: str = ""
41
+
42
+ @classmethod
43
+ def from_exception(
44
+ cls, exc: BaseException, *, prefix: str = ""
45
+ ) -> "IterativeFailure":
46
+ """Build a failure record from a caught exception."""
47
+ return cls(
48
+ exc_type_name=type(exc).__name__,
49
+ message=str(exc),
50
+ context_prefix=prefix,
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class IterativeNotice:
56
+ """A single per-iteration notice (non-failure) to be coalesced.
57
+
58
+ Fields:
59
+
60
+ * ``message`` — full per-iteration text, emitted verbatim when
61
+ ``max_emit > 0``.
62
+ * ``group_key`` — true identifier for the iteration (e.g. a label,
63
+ seed, or bucket name). Reserved for future grouping/dedup logic
64
+ inside the coalescer.
65
+ * ``display`` — short text used in the aggregate summary's
66
+ "affected items" list. Falls back to ``group_key`` when empty,
67
+ so callers that only need an identifier can keep passing
68
+ ``group_key=`` alone.
69
+ """
70
+
71
+ message: str
72
+ group_key: str = ""
73
+ display: str = ""
74
+
75
+
76
+ def coalesce_failures(
77
+ failures: Iterable[IterativeFailure],
78
+ *,
79
+ n_total: int,
80
+ summary_label: str,
81
+ max_emit: int = 3,
82
+ category: Type[Warning] = IterativeWarning,
83
+ stacklevel: int = 3,
84
+ tail_msg: str = "",
85
+ ) -> int:
86
+ """Emit warnings for ``failures`` according to the coalescer policy.
87
+
88
+ Policy:
89
+ 1. Group by ``exc_type_name`` (encounter order).
90
+ 2. Emit up to ``max_emit`` distinct error-type examples verbatim.
91
+ 3. Emit one aggregate summary describing total count and type diversity.
92
+
93
+ Empty ``failures`` emits nothing.
94
+
95
+ Parameters
96
+ ----------
97
+ failures : iterable of IterativeFailure
98
+ Per-iteration failure records to coalesce.
99
+ n_total : int
100
+ Total number of iterations attempted (denominator for the summary).
101
+ summary_label : str
102
+ Human-readable label for the iteration kind (e.g. ``"bootstrap"``).
103
+ max_emit : int, default=3
104
+ Maximum number of distinct error-type examples emitted verbatim
105
+ before the aggregate summary.
106
+ category : type of Warning, default=IterativeWarning
107
+ Warning class to emit.
108
+ stacklevel : int, default=3
109
+ ``stacklevel`` forwarded to :func:`warnings.warn`.
110
+ tail_msg : str, default=""
111
+ Optional text appended to the aggregate summary message.
112
+
113
+ Returns
114
+ -------
115
+ int
116
+ Number of failures, for caller convenience.
117
+
118
+ Raises
119
+ ------
120
+ ValueError
121
+ If ``n_total`` is less than 1, if ``max_emit`` is negative, or if
122
+ the number of failures exceeds ``n_total``.
123
+ """
124
+ if n_total < 1:
125
+ raise ValueError(f"n_total must be >= 1; got {n_total}")
126
+ if max_emit < 0:
127
+ raise ValueError(f"max_emit must be >= 0; got {max_emit}")
128
+ failures = list(failures)
129
+ n_failed = len(failures)
130
+ if n_failed == 0:
131
+ return 0
132
+ if n_failed > n_total:
133
+ raise ValueError(
134
+ f"len(failures)={n_failed} exceeds n_total={n_total}"
135
+ )
136
+
137
+ grouped: "OrderedDict[str, list[IterativeFailure]]" = OrderedDict()
138
+ for f in failures:
139
+ grouped.setdefault(f.exc_type_name, []).append(f)
140
+
141
+ for exc_type_name, group in list(grouped.items())[:max_emit]:
142
+ example = group[0]
143
+ prefix = f" ({example.context_prefix})" if example.context_prefix else ""
144
+ warnings.warn(
145
+ f"{summary_label} iteration failed{prefix}: {example.message}",
146
+ category,
147
+ stacklevel=stacklevel,
148
+ )
149
+
150
+ pct = n_failed / n_total * 100
151
+ tail = f" {tail_msg}" if tail_msg else ""
152
+ if len(grouped) == 1:
153
+ only_type = next(iter(grouped))
154
+ summary = (
155
+ f"{n_failed}/{n_total} ({pct:.0f}%) {summary_label} iterations failed; "
156
+ f"all shared the same error type ({only_type}).{tail}"
157
+ )
158
+ else:
159
+ breakdown = ", ".join(
160
+ f"{name} (n={len(group)})" for name, group in grouped.items()
161
+ )
162
+ summary = (
163
+ f"{n_failed}/{n_total} ({pct:.0f}%) {summary_label} iterations failed "
164
+ f"across {len(grouped)} distinct error types: {breakdown}.{tail}"
165
+ )
166
+ warnings.warn(summary, category, stacklevel=stacklevel)
167
+ return n_failed
168
+
169
+
170
+ def coalesce_notices(
171
+ notices: Iterable[IterativeNotice],
172
+ *,
173
+ n_total: int,
174
+ summary_label: str,
175
+ notice_phrase: str,
176
+ max_emit: int = 3,
177
+ category: Type[Warning] = IterativeWarning,
178
+ stacklevel: int = 3,
179
+ tail_msg: str = "",
180
+ ) -> int:
181
+ """Emit warnings for ``notices`` (non-failure repeated warnings).
182
+
183
+ Policy:
184
+ 1. Emit up to ``max_emit`` notice messages verbatim (encounter order).
185
+ 2. Emit one aggregate summary listing the affected ``group_key`` values.
186
+
187
+ Empty ``notices`` emits nothing.
188
+
189
+ Parameters
190
+ ----------
191
+ notices : iterable of IterativeNotice
192
+ Per-iteration notice records to coalesce.
193
+ n_total : int
194
+ Total number of iterations attempted (denominator for the summary).
195
+ summary_label : str
196
+ Human-readable label for the iteration kind (e.g. ``"bootstrap"``).
197
+ notice_phrase : str
198
+ Phrase describing the notice condition, used in the aggregate summary.
199
+ max_emit : int, default=3
200
+ Maximum number of notice messages emitted verbatim before the
201
+ aggregate summary.
202
+ category : type of Warning, default=IterativeWarning
203
+ Warning class to emit.
204
+ stacklevel : int, default=3
205
+ ``stacklevel`` forwarded to :func:`warnings.warn`.
206
+ tail_msg : str, default=""
207
+ Optional text appended to the aggregate summary message.
208
+
209
+ Returns
210
+ -------
211
+ int
212
+ Number of notices, for caller convenience.
213
+
214
+ Raises
215
+ ------
216
+ ValueError
217
+ If ``n_total`` is less than 1, if ``max_emit`` is negative, or if
218
+ the number of notices exceeds ``n_total``.
219
+ """
220
+ if n_total < 1:
221
+ raise ValueError(f"n_total must be >= 1; got {n_total}")
222
+ if max_emit < 0:
223
+ raise ValueError(f"max_emit must be >= 0; got {max_emit}")
224
+ notices = list(notices)
225
+ n = len(notices)
226
+ if n == 0:
227
+ return 0
228
+ if n > n_total:
229
+ raise ValueError(f"len(notices)={n} exceeds n_total={n_total}")
230
+
231
+ for notice in notices[:max_emit]:
232
+ warnings.warn(notice.message, category, stacklevel=stacklevel)
233
+
234
+ pct = n / n_total * 100
235
+ tail = f" {tail_msg}" if tail_msg else ""
236
+ keys = ", ".join(
237
+ (nt.display or nt.group_key)
238
+ for nt in notices
239
+ if (nt.display or nt.group_key)
240
+ )
241
+ keys_part = f": {keys}" if keys else ""
242
+ summary = (
243
+ f"{n}/{n_total} ({pct:.0f}%) {summary_label}s {notice_phrase}"
244
+ f"{keys_part}.{tail}"
245
+ )
246
+ warnings.warn(summary, category, stacklevel=stacklevel)
247
+ return n
248
+
249
+
250
+ class IterativeWarningCoalescer:
251
+ """Context-manager form of the coalescer functions.
252
+
253
+ Two modes:
254
+
255
+ * ``mode="failures"`` (default): call ``.record(exc, prefix=...)`` for
256
+ each caught exception. Dispatches to :func:`coalesce_failures`.
257
+ * ``mode="notices"``: call ``.record_notice(message, group_key=...)``
258
+ for each pre-rendered notice. Requires ``notice_phrase``. Dispatches
259
+ to :func:`coalesce_notices`.
260
+
261
+ Calling the wrong ``record*`` method for the active mode raises
262
+ ``RuntimeError``.
263
+
264
+ Example (failures)::
265
+
266
+ with IterativeWarningCoalescer(summary_label="bootstrap",
267
+ n_total=n_bootstrap) as cz:
268
+ for seed in seeds:
269
+ try:
270
+ ...
271
+ except (ValueError, RuntimeError) as exc:
272
+ cz.record(exc, prefix=f"seed={seed}")
273
+
274
+ Example (notices)::
275
+
276
+ with IterativeWarningCoalescer(
277
+ mode="notices",
278
+ summary_label="'stage' subpopulation",
279
+ notice_phrase="have insufficient events (< 30)",
280
+ n_total=len(labels),
281
+ category=SmallSubpopulationWarning,
282
+ ) as cz:
283
+ for label in labels:
284
+ if n_events < min_events:
285
+ cz.record_notice(msg, group_key=str(label))
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ *,
291
+ summary_label: str,
292
+ n_total: int,
293
+ max_emit: int = 3,
294
+ category: Type[Warning] = IterativeWarning,
295
+ stacklevel: int = 3,
296
+ tail_msg: str = "",
297
+ mode: Literal["failures", "notices"] = "failures",
298
+ notice_phrase: str = "",
299
+ ):
300
+ if n_total < 1:
301
+ raise ValueError(f"n_total must be >= 1; got {n_total}")
302
+ if max_emit < 0:
303
+ raise ValueError(f"max_emit must be >= 0; got {max_emit}")
304
+ if mode not in ("failures", "notices"):
305
+ raise ValueError(
306
+ f"mode must be 'failures' or 'notices'; got {mode!r}"
307
+ )
308
+ if mode == "notices" and not notice_phrase:
309
+ raise ValueError("notice_phrase is required when mode='notices'")
310
+ self.summary_label = summary_label
311
+ self.n_total = n_total
312
+ self.max_emit = max_emit
313
+ self.category = category
314
+ self.stacklevel = stacklevel
315
+ self.tail_msg = tail_msg
316
+ self.mode = mode
317
+ self.notice_phrase = notice_phrase
318
+ self._failures: list[IterativeFailure] = []
319
+ self._notices: list[IterativeNotice] = []
320
+ self._summarized = False
321
+
322
+ def record(self, exc: Exception, *, prefix: str = "") -> None:
323
+ if self.mode != "failures":
324
+ raise RuntimeError(
325
+ f"record() requires mode='failures'; coalescer is in "
326
+ f"mode={self.mode!r}. Use record_notice() instead."
327
+ )
328
+ self._failures.append(IterativeFailure.from_exception(exc, prefix=prefix))
329
+
330
+ def record_failure(self, failure: IterativeFailure) -> None:
331
+ if self.mode != "failures":
332
+ raise RuntimeError(
333
+ f"record_failure() requires mode='failures'; coalescer is in "
334
+ f"mode={self.mode!r}. Use record_notice() instead."
335
+ )
336
+ self._failures.append(failure)
337
+
338
+ def record_notice(
339
+ self, message: str, *, group_key: str = "", display: str = ""
340
+ ) -> None:
341
+ if self.mode != "notices":
342
+ raise RuntimeError(
343
+ f"record_notice() requires mode='notices'; coalescer is in "
344
+ f"mode={self.mode!r}. Use record() instead."
345
+ )
346
+ self._notices.append(
347
+ IterativeNotice(message=message, group_key=group_key, display=display)
348
+ )
349
+
350
+ def summarize(self) -> int:
351
+ if self._summarized:
352
+ return len(self._failures) + len(self._notices)
353
+ self._summarized = True
354
+ # Bump stacklevel by +2 to account for the summarize() and __exit__()
355
+ # frames the coalesce_* function will see above its own warnings.warn
356
+ # call. This way callers pass the same stacklevel they would use for a
357
+ # direct coalesce_*() call, and the warning still attributes to the
358
+ # caller of compute_subpopulation_metrics() (or whichever site uses
359
+ # the context-manager form).
360
+ bumped_stacklevel = self.stacklevel + 2
361
+ if self.mode == "failures":
362
+ return coalesce_failures(
363
+ self._failures,
364
+ n_total=self.n_total,
365
+ summary_label=self.summary_label,
366
+ max_emit=self.max_emit,
367
+ category=self.category,
368
+ stacklevel=bumped_stacklevel,
369
+ tail_msg=self.tail_msg,
370
+ )
371
+ return coalesce_notices(
372
+ self._notices,
373
+ n_total=self.n_total,
374
+ summary_label=self.summary_label,
375
+ notice_phrase=self.notice_phrase,
376
+ max_emit=self.max_emit,
377
+ category=self.category,
378
+ stacklevel=bumped_stacklevel,
379
+ tail_msg=self.tail_msg,
380
+ )
381
+
382
+ def __enter__(self) -> "IterativeWarningCoalescer":
383
+ return self
384
+
385
+ def __exit__(self, exc_type, exc, tb) -> Optional[bool]:
386
+ # Always attempt to summarize so a propagating exception in the
387
+ # with-block does not silently discard previously recorded
388
+ # records. If summarize() itself raises while a user exception
389
+ # is propagating, prefer the user exception — masking it with
390
+ # our own bookkeeping failure is the worse outcome.
391
+ try:
392
+ self.summarize()
393
+ except Exception:
394
+ if exc_type is None:
395
+ raise
396
+ return None
mlsurv/_loky_patch.py ADDED
@@ -0,0 +1,34 @@
1
+ """Wrapper for loky resource_tracker.main that suppresses KeyError tracebacks.
2
+
3
+ The loky resource tracker has a race condition on Windows where multiple
4
+ workers try to UNREGISTER the same temp folder, causing noisy KeyError
5
+ tracebacks on stderr. This wrapper installs a selective sys.excepthook
6
+ that suppresses only those specific errors.
7
+
8
+ Used by monkeypatching loky's resource_tracker.main reference at mlsurv
9
+ import time, so the tracker subprocess imports this wrapper instead of
10
+ the original.
11
+ """
12
+
13
+ import sys
14
+
15
+ # Save the ORIGINAL main before mlsurv.__init__ replaces it.
16
+ # This module is imported by __init__.py *before* the replacement happens,
17
+ # so _original_main always points to loky's real main function.
18
+ from joblib.externals.loky.backend.resource_tracker import (
19
+ main as _original_main,
20
+ )
21
+
22
+
23
+ def main(fd, verbose=False):
24
+ _orig_excepthook = sys.excepthook
25
+
26
+ def _quiet_excepthook(exc_type, exc_value, exc_tb):
27
+ if exc_type is KeyError and exc_tb is not None:
28
+ filename = getattr(exc_tb.tb_frame.f_code, "co_filename", "")
29
+ if "resource_tracker" in filename:
30
+ return
31
+ _orig_excepthook(exc_type, exc_value, exc_tb)
32
+
33
+ sys.excepthook = _quiet_excepthook
34
+ _original_main(fd, verbose)