quantized-lab 0.8.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 (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,261 @@
1
+ """Survival analysis: Kaplan-Meier, log-rank test, Cox PH model.
2
+
3
+ GAP_PLAN #30 (stats extra) — survival methods via lifelines (MIT). Requires
4
+ ``pip install quantized[stats]``. Rows with any non-finite time/event are
5
+ dropped (listwise deletion).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+
15
+ __all__ = ["kaplan_meier", "logrank_test", "cox_proportional_hazards"]
16
+
17
+
18
+ def _check_lifelines() -> None:
19
+ """Raise a clear error if lifelines is not installed."""
20
+ try:
21
+ import lifelines # noqa: F401
22
+ except ImportError as exc:
23
+ raise RuntimeError(
24
+ "Survival methods require lifelines. Install with: pip install quantized[stats]"
25
+ ) from exc
26
+
27
+
28
+ def kaplan_meier(
29
+ time: NDArray[np.float64],
30
+ event: NDArray[np.float64],
31
+ ) -> dict[str, Any]:
32
+ """Kaplan-Meier survival curve with Greenwood confidence intervals.
33
+
34
+ ``time`` is the follow-up duration (must be ≥ 0). ``event`` is the binary
35
+ event indicator (0 = censored, 1 = event). Returns the survival curve at
36
+ each unique event time, Greenwood CIs, the number at risk and events at
37
+ each time, and the median survival time (1st time where S(t) ≤ 0.5).
38
+ Rows with any non-finite values are dropped (listwise deletion).
39
+
40
+ Reference: Kaplan-Meier estimator via lifelines ``KaplanMeierFitter``.
41
+ """
42
+ _check_lifelines()
43
+ from lifelines import KaplanMeierFitter
44
+
45
+ tv = np.asarray(time, dtype=float).ravel()
46
+ ev = np.asarray(event, dtype=float).ravel()
47
+
48
+ if tv.size != ev.size:
49
+ raise ValueError(f"time length {tv.size} != event length {ev.size}")
50
+
51
+ # Listwise NaN deletion
52
+ keep = np.isfinite(tv) & np.isfinite(ev)
53
+ tv, ev = tv[keep], ev[keep]
54
+ n = tv.size
55
+
56
+ if n < 2:
57
+ raise ValueError("need at least 2 complete rows")
58
+
59
+ # Check time ≥ 0, event ∈ {0, 1}
60
+ if np.any(tv < 0.0):
61
+ raise ValueError("time must be non-negative")
62
+ if not np.all((ev == 0.0) | (ev == 1.0)):
63
+ raise ValueError("event must be binary (0/1)")
64
+
65
+ # Fit KM
66
+ kmf = KaplanMeierFitter()
67
+ kmf.fit(tv, ev, label="KM")
68
+
69
+ # Extract results at unique event times
70
+ survival_func = kmf.survival_function_
71
+ times = np.asarray(survival_func.index, dtype=float)
72
+ surv = np.asarray(survival_func.iloc[:, 0], dtype=float)
73
+
74
+ # Confidence intervals (lifelines computes 95% by default)
75
+ ci_lower = np.asarray(kmf.confidence_interval_survival_function_.iloc[:, 0], dtype=float)
76
+ ci_upper = np.asarray(kmf.confidence_interval_survival_function_.iloc[:, 1], dtype=float)
77
+
78
+ # Number at risk and events at each time
79
+ at_risk = np.asarray(kmf.event_table["at_risk"].values, dtype=float)
80
+ events = np.asarray(kmf.event_table["observed"].values, dtype=float)
81
+
82
+ # Median survival: 1st time where S(t) ≤ 0.5
83
+ median_time = float(kmf.median_survival_time_)
84
+ if np.isnan(median_time):
85
+ median_time = float("nan")
86
+
87
+ return {
88
+ "times": times,
89
+ "survival": surv,
90
+ "ciLow": ci_lower,
91
+ "ciHigh": ci_upper,
92
+ "atRisk": at_risk,
93
+ "events": events,
94
+ "medianSurvival": median_time,
95
+ "N": n,
96
+ }
97
+
98
+
99
+ def logrank_test(
100
+ time1: NDArray[np.float64],
101
+ event1: NDArray[np.float64],
102
+ time2: NDArray[np.float64],
103
+ event2: NDArray[np.float64],
104
+ ) -> dict[str, Any]:
105
+ """Log-rank test comparing two survival curves.
106
+
107
+ Groups are indexed 1 (``time1``, ``event1``) and 2 (``time2``, ``event2``).
108
+ Returns the test statistic, chi-squared distribution with 1 dof, the
109
+ p-value, and the observed/expected events in each group. Rows with any
110
+ non-finite values are dropped (listwise deletion).
111
+
112
+ Reference: log-rank test via lifelines ``logrank_test``.
113
+ """
114
+ _check_lifelines()
115
+ from lifelines.statistics import logrank_test as logrank_test_fn
116
+
117
+ t1v = np.asarray(time1, dtype=float).ravel()
118
+ e1v = np.asarray(event1, dtype=float).ravel()
119
+ t2v = np.asarray(time2, dtype=float).ravel()
120
+ e2v = np.asarray(event2, dtype=float).ravel()
121
+
122
+ if t1v.size != e1v.size:
123
+ raise ValueError(f"group 1: time length {t1v.size} != event length {e1v.size}")
124
+ if t2v.size != e2v.size:
125
+ raise ValueError(f"group 2: time length {t2v.size} != event length {e2v.size}")
126
+
127
+ # Listwise NaN deletion per group
128
+ keep1 = np.isfinite(t1v) & np.isfinite(e1v)
129
+ keep2 = np.isfinite(t2v) & np.isfinite(e2v)
130
+ t1v, e1v = t1v[keep1], e1v[keep1]
131
+ t2v, e2v = t2v[keep2], e2v[keep2]
132
+
133
+ if t1v.size < 2 or t2v.size < 2:
134
+ raise ValueError("each group needs at least 2 complete rows")
135
+
136
+ # Check constraints
137
+ if np.any(t1v < 0.0) or np.any(t2v < 0.0):
138
+ raise ValueError("time must be non-negative")
139
+ if not (np.all((e1v == 0.0) | (e1v == 1.0)) and np.all((e2v == 0.0) | (e2v == 1.0))):
140
+ raise ValueError("event must be binary (0/1)")
141
+
142
+ # Fit
143
+ result = logrank_test_fn(t1v, t2v, e1v, e2v)
144
+
145
+ # Extract results
146
+ stat = float(result.test_statistic)
147
+ p_value = float(result.p_value)
148
+ obs1 = float(np.sum(e1v))
149
+ obs2 = float(np.sum(e2v))
150
+
151
+ return {
152
+ "statistic": stat,
153
+ "pValue": p_value,
154
+ "dof": 1.0,
155
+ "observedGroup1": obs1,
156
+ "observedGroup2": obs2,
157
+ "N1": t1v.size,
158
+ "N2": t2v.size,
159
+ }
160
+
161
+
162
+ def cox_proportional_hazards(
163
+ time: NDArray[np.float64],
164
+ event: NDArray[np.float64],
165
+ predictors: list[NDArray[np.float64]] | NDArray[np.float64],
166
+ ) -> dict[str, Any]:
167
+ """Cox proportional-hazards model with inference.
168
+
169
+ ``time`` is follow-up duration, ``event`` is the binary event indicator.
170
+ ``predictors`` is a list of k same-length columns (or an (n, k) matrix).
171
+ Returns regression coefficients, standard errors, z-stats/p-values, 95%
172
+ CIs, concordance index, log-likelihood, and AIC. Rows with any non-finite
173
+ values are dropped (listwise deletion).
174
+
175
+ Requires lifelines (MIT).
176
+
177
+ Reference: Cox PH via lifelines ``CoxPHFitter``.
178
+ """
179
+ _check_lifelines()
180
+ import pandas as pd
181
+ from lifelines import CoxPHFitter
182
+
183
+ tv = np.asarray(time, dtype=float).ravel()
184
+ ev = np.asarray(event, dtype=float).ravel()
185
+
186
+ if tv.size != ev.size:
187
+ raise ValueError(f"time length {tv.size} != event length {ev.size}")
188
+
189
+ # Parse predictors
190
+ if isinstance(predictors, np.ndarray) and predictors.ndim == 2:
191
+ xmat = np.asarray(predictors, dtype=float)
192
+ else:
193
+ cols = [np.asarray(c, dtype=float).ravel() for c in predictors]
194
+ if len(cols) < 1:
195
+ raise ValueError("need at least one predictor")
196
+ if tv.size != cols[0].size:
197
+ raise ValueError(f"predictor column length {cols[0].size} != time length {tv.size}")
198
+ xmat = np.column_stack(cols)
199
+
200
+ n, k = xmat.shape
201
+
202
+ if k < 1:
203
+ raise ValueError("need at least 1 predictor")
204
+
205
+ # Listwise NaN deletion
206
+ keep = np.isfinite(tv) & np.isfinite(ev) & np.all(np.isfinite(xmat), axis=1)
207
+ tv, ev, xmat = tv[keep], ev[keep], xmat[keep]
208
+ n_clean = tv.size
209
+
210
+ if n_clean < k + 2:
211
+ raise ValueError(f"need at least {k + 2} complete rows for {k} predictors")
212
+
213
+ # Check constraints
214
+ if np.any(tv < 0.0):
215
+ raise ValueError("time must be non-negative")
216
+ if not np.all((ev == 0.0) | (ev == 1.0)):
217
+ raise ValueError("event must be binary (0/1)")
218
+
219
+ # Build DataFrame (lifelines wants column names)
220
+ cols_dict = {f"x{i}": xmat[:, i] for i in range(k)}
221
+ cols_dict["T"] = tv
222
+ cols_dict["E"] = ev
223
+ df = pd.DataFrame(cols_dict)
224
+
225
+ # Fit Cox model
226
+ cph = CoxPHFitter()
227
+ cph.fit(df, duration_col="T", event_col="E")
228
+
229
+ # Extract results. cph.params_/standard_errors_ are pandas Series (not
230
+ # ndarrays), so .values is safe here — unlike statsmodels' discrete-choice
231
+ # results (see stats_glm.py), which return bare ndarrays and broke on
232
+ # .values. cph.summary already carries correctly-computed z and two-sided
233
+ # p (verified against the lifelines rossi.csv reference: p=0.0474 for
234
+ # z=-1.983 etc.) — read them directly rather than recomputing/transforming.
235
+ coeffs = np.asarray(cph.params_.values, dtype=float)
236
+ se = np.asarray(cph.standard_errors_.values, dtype=float)
237
+ z_stats = np.asarray(cph.summary["z"].values, dtype=float)
238
+ p_values = np.asarray(cph.summary["p"].values, dtype=float)
239
+
240
+ # CIs on the linear (log-hazard / coefficient) scale — lifelines' summary
241
+ # already carries these directly ("coef lower/upper 95%"); no need to
242
+ # round-trip through the exponentiated hazard-ratio columns.
243
+ ci_low = np.asarray(cph.summary["coef lower 95%"].values, dtype=float)
244
+ ci_high = np.asarray(cph.summary["coef upper 95%"].values, dtype=float)
245
+
246
+ concordance = float(cph.concordance_index_)
247
+
248
+ return {
249
+ "coeffs": coeffs,
250
+ "se": se,
251
+ "zStats": z_stats,
252
+ "pValues": p_values,
253
+ "ciLow": ci_low,
254
+ "ciHigh": ci_high,
255
+ "concordanceIndex": concordance,
256
+ "logLikelihood": float(cph.log_likelihood_),
257
+ # CoxPHFitter is semi-parametric — .AIC_ raises StatError ("does not
258
+ # exist... you probably want .AIC_partial_"); use the partial AIC.
259
+ "AIC": float(cph.AIC_partial_),
260
+ "N": n_clean,
261
+ }
@@ -0,0 +1,380 @@
1
+ """Nonparametric hypothesis tests + normality/variance assumption checks.
2
+
3
+ Thin, typed wrappers around ``scipy.stats`` (BSD) returning uniform result
4
+ dicts, in the style of ``calc.stats``. New capability beyond MATLAB parity
5
+ (ORIGIN_GAP_PLAN #25 + the wrapper half of #26); validated against
6
+ hand-derivable exact small-sample values in ``tests/test_calc_stats_tests.py``.
7
+
8
+ All functions are pure: ndarrays in, plain dicts out. No FastAPI imports.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import warnings
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+ from numpy.typing import NDArray
18
+ from scipy import stats as sps
19
+
20
+ _ALTERNATIVES = ("two-sided", "less", "greater")
21
+
22
+
23
+ def _check_alternative(alternative: str) -> str:
24
+ if alternative not in _ALTERNATIVES:
25
+ raise ValueError(f"alternative must be one of {_ALTERNATIVES}, got {alternative!r}")
26
+ return alternative
27
+
28
+
29
+ def _clean(x: NDArray[np.float64]) -> NDArray[np.float64]:
30
+ x = np.asarray(x, dtype=float).ravel()
31
+ return x[np.isfinite(x)]
32
+
33
+
34
+ def mann_whitney(
35
+ x: NDArray[np.float64],
36
+ y: NDArray[np.float64],
37
+ alternative: str = "two-sided",
38
+ ) -> dict[str, Any]:
39
+ """Mann-Whitney U test (independent two-sample rank test).
40
+
41
+ Exact p-value for small samples without ties, normal approximation
42
+ otherwise (scipy ``method='auto'``). Reference: Conover, *Practical
43
+ Nonparametric Statistics*, 3rd ed., ch. 5.
44
+ """
45
+ x, y = _clean(x), _clean(y)
46
+ _check_alternative(alternative)
47
+ if x.size < 1 or y.size < 1:
48
+ raise ValueError("mann_whitney needs at least one observation per group")
49
+ res = sps.mannwhitneyu(x, y, alternative=alternative, method="auto")
50
+ return {
51
+ "U": float(res.statistic),
52
+ "p": float(res.pvalue),
53
+ "n1": int(x.size),
54
+ "n2": int(y.size),
55
+ "alternative": alternative,
56
+ "method": "Mann-Whitney U",
57
+ }
58
+
59
+
60
+ def wilcoxon_signed_rank(
61
+ x: NDArray[np.float64],
62
+ y: NDArray[np.float64] | None = None,
63
+ mu: float = 0.0,
64
+ alternative: str = "two-sided",
65
+ ) -> dict[str, Any]:
66
+ """Wilcoxon signed-rank test (paired two-sample, or one-sample vs ``mu``).
67
+
68
+ Zero differences are dropped (Wilcoxon's original treatment). Exact
69
+ p-value for small samples without ties. Reference: Conover ch. 5.7.
70
+ """
71
+ x = _clean(x)
72
+ _check_alternative(alternative)
73
+ if y is not None:
74
+ y = np.asarray(y, dtype=float).ravel()
75
+ if y.size != x.size:
76
+ raise ValueError("wilcoxon_signed_rank: x and y must be the same length")
77
+ d = x - y
78
+ else:
79
+ d = x - mu
80
+ d = d[np.isfinite(d)]
81
+ if not np.any(d != 0.0):
82
+ raise ValueError("wilcoxon_signed_rank: all differences are zero")
83
+ res = sps.wilcoxon(d, alternative=alternative, zero_method="wilcox", method="auto")
84
+ return {
85
+ "W": float(res.statistic),
86
+ "p": float(res.pvalue),
87
+ "n": int(np.count_nonzero(d)),
88
+ "alternative": alternative,
89
+ "method": "Wilcoxon signed-rank",
90
+ }
91
+
92
+
93
+ def kruskal_wallis(groups: list[NDArray[np.float64]]) -> dict[str, Any]:
94
+ """Kruskal-Wallis H test (one-way ANOVA on ranks, k independent groups).
95
+
96
+ H is chi-squared distributed with k-1 degrees of freedom under H0.
97
+ Reference: Kruskal & Wallis, JASA 47 (1952) 583.
98
+ """
99
+ cleaned = [_clean(g) for g in groups]
100
+ if len(cleaned) < 2:
101
+ raise ValueError("kruskal_wallis needs at least 2 groups")
102
+ if any(g.size < 1 for g in cleaned):
103
+ raise ValueError("kruskal_wallis: every group needs at least one observation")
104
+ res = sps.kruskal(*cleaned)
105
+ return {
106
+ "H": float(res.statistic),
107
+ "p": float(res.pvalue),
108
+ "df": len(cleaned) - 1,
109
+ "n_groups": len(cleaned),
110
+ "N": int(sum(g.size for g in cleaned)),
111
+ "method": "Kruskal-Wallis H",
112
+ }
113
+
114
+
115
+ def friedman(groups: list[NDArray[np.float64]]) -> dict[str, Any]:
116
+ """Friedman test (repeated measures on ranks; k treatments x n blocks).
117
+
118
+ Each entry of ``groups`` is one treatment measured over the same n
119
+ blocks (equal lengths required). Reference: Friedman, JASA 32 (1937) 675.
120
+ """
121
+ arrs = [np.asarray(g, dtype=float).ravel() for g in groups]
122
+ if len(arrs) < 3:
123
+ raise ValueError("friedman needs at least 3 treatments (scipy restriction)")
124
+ n = arrs[0].size
125
+ if n < 2 or any(a.size != n for a in arrs):
126
+ raise ValueError("friedman: all treatments need the same number (>=2) of blocks")
127
+ res = sps.friedmanchisquare(*arrs)
128
+ return {
129
+ "chi2": float(res.statistic),
130
+ "p": float(res.pvalue),
131
+ "df": len(arrs) - 1,
132
+ "n_treatments": len(arrs),
133
+ "n_blocks": n,
134
+ "method": "Friedman chi-square",
135
+ }
136
+
137
+
138
+ def sign_test(
139
+ x: NDArray[np.float64],
140
+ y: NDArray[np.float64] | None = None,
141
+ mu: float = 0.0,
142
+ alternative: str = "two-sided",
143
+ ) -> dict[str, Any]:
144
+ """Sign test (paired or one-sample vs ``mu``) via exact binomial test.
145
+
146
+ Ignores zero differences; p from Binomial(n_pos + n_neg, 1/2).
147
+ ``alternative='greater'`` tests median > mu (i.e. an excess of positive
148
+ differences). Reference: Conover ch. 3.4.
149
+ """
150
+ x = _clean(x)
151
+ _check_alternative(alternative)
152
+ if y is not None:
153
+ y = np.asarray(y, dtype=float).ravel()
154
+ if y.size != x.size:
155
+ raise ValueError("sign_test: x and y must be the same length")
156
+ d = x - y
157
+ else:
158
+ d = x - mu
159
+ d = d[np.isfinite(d) & (d != 0.0)]
160
+ if d.size == 0:
161
+ raise ValueError("sign_test: no nonzero differences")
162
+ n_pos = int(np.count_nonzero(d > 0))
163
+ n = int(d.size)
164
+ res = sps.binomtest(n_pos, n, 0.5, alternative=alternative)
165
+ return {
166
+ "n_pos": n_pos,
167
+ "n_neg": n - n_pos,
168
+ "n": n,
169
+ "p": float(res.pvalue),
170
+ "alternative": alternative,
171
+ "method": "sign test (exact binomial)",
172
+ }
173
+
174
+
175
+ def shapiro_wilk(x: NDArray[np.float64]) -> dict[str, Any]:
176
+ """Shapiro-Wilk normality test. Valid for 3 <= n <= 5000.
177
+
178
+ Reference: Shapiro & Wilk, Biometrika 52 (1965) 591.
179
+ """
180
+ x = _clean(x)
181
+ if x.size < 3:
182
+ raise ValueError("shapiro_wilk needs at least 3 observations")
183
+ if x.size > 5000:
184
+ raise ValueError("shapiro_wilk p-value unreliable above n=5000; subsample first")
185
+ res = sps.shapiro(x)
186
+ return {
187
+ "W": float(res.statistic),
188
+ "p": float(res.pvalue),
189
+ "N": int(x.size),
190
+ "method": "Shapiro-Wilk",
191
+ }
192
+
193
+
194
+ def anderson_darling(x: NDArray[np.float64]) -> dict[str, Any]:
195
+ """Anderson-Darling normality test (parameters estimated from the data).
196
+
197
+ Returns the A^2 statistic with critical values at fixed significance
198
+ levels instead of a p-value (Stephens' tabulation, as in scipy).
199
+ Reference: Stephens, JASA 69 (1974) 730.
200
+ """
201
+ x = _clean(x)
202
+ if x.size < 3:
203
+ raise ValueError("anderson_darling needs at least 3 observations")
204
+ # keep the classic critical-value-table behavior (scipy >= 1.17 warns
205
+ # pending a p-value API migration; revisit when we require that API)
206
+ with warnings.catch_warnings():
207
+ warnings.simplefilter("ignore", FutureWarning)
208
+ res = sps.anderson(x, dist="norm")
209
+ crit = np.asarray(res.critical_values, dtype=float)
210
+ sig = np.asarray(res.significance_level, dtype=float)
211
+ a2 = float(res.statistic)
212
+ return {
213
+ "A2": a2,
214
+ "critical_values": crit.tolist(),
215
+ "significance_levels_pct": sig.tolist(),
216
+ "reject_at_5pct": bool(a2 > crit[sig == 5.0][0]) if np.any(sig == 5.0) else None,
217
+ "N": int(x.size),
218
+ "method": "Anderson-Darling (normal)",
219
+ }
220
+
221
+
222
+ def levene(groups: list[NDArray[np.float64]], center: str = "median") -> dict[str, Any]:
223
+ """Levene test for equal variances across k groups.
224
+
225
+ ``center='median'`` is the robust Brown-Forsythe variant (default);
226
+ ``'mean'`` is the classic Levene test. Reference: Brown & Forsythe,
227
+ JASA 69 (1974) 364.
228
+ """
229
+ if center not in ("median", "mean", "trimmed"):
230
+ raise ValueError("center must be 'median', 'mean', or 'trimmed'")
231
+ cleaned = [_clean(g) for g in groups]
232
+ if len(cleaned) < 2 or any(g.size < 2 for g in cleaned):
233
+ raise ValueError("levene needs >=2 groups with >=2 observations each")
234
+ res = sps.levene(*cleaned, center=center)
235
+ return {
236
+ "W": float(res.statistic),
237
+ "p": float(res.pvalue),
238
+ "center": center,
239
+ "n_groups": len(cleaned),
240
+ "method": "Brown-Forsythe" if center == "median" else "Levene",
241
+ }
242
+
243
+
244
+ def ks_normal(
245
+ x: NDArray[np.float64],
246
+ loc: float | None = None,
247
+ scale: float | None = None,
248
+ ) -> dict[str, Any]:
249
+ """One-sample Kolmogorov-Smirnov test against a normal distribution.
250
+
251
+ When ``loc``/``scale`` are omitted they are estimated from the sample
252
+ (mean, ddof=1 std); the p-value is then only approximate (Lilliefors
253
+ situation) and flagged via ``params_estimated``. Prefer Shapiro-Wilk
254
+ for pure normality checks; use this when loc/scale are known a priori.
255
+ """
256
+ x = _clean(x)
257
+ if x.size < 3:
258
+ raise ValueError("ks_normal needs at least 3 observations")
259
+ estimated = loc is None or scale is None
260
+ loc_v = float(np.mean(x)) if loc is None else float(loc)
261
+ scale_v = float(np.std(x, ddof=1)) if scale is None else float(scale)
262
+ if scale_v <= 0:
263
+ raise ValueError("ks_normal: scale must be positive")
264
+ # frozen-cdf form: the string form ("norm" + args) trips a broken
265
+ # ndtr fast path in some scipy releases
266
+ res = sps.kstest(x, sps.norm(loc=loc_v, scale=scale_v).cdf)
267
+ return {
268
+ "D": float(res.statistic),
269
+ "p": float(res.pvalue),
270
+ "loc": loc_v,
271
+ "scale": scale_v,
272
+ "params_estimated": estimated,
273
+ "N": int(x.size),
274
+ "method": "Kolmogorov-Smirnov (normal)",
275
+ }
276
+
277
+
278
+ def ks_two_sample(
279
+ x: NDArray[np.float64],
280
+ y: NDArray[np.float64],
281
+ alternative: str = "two-sided",
282
+ ) -> dict[str, Any]:
283
+ """Two-sample Kolmogorov-Smirnov test (same-distribution null)."""
284
+ x, y = _clean(x), _clean(y)
285
+ _check_alternative(alternative)
286
+ if x.size < 1 or y.size < 1:
287
+ raise ValueError("ks_two_sample needs at least one observation per sample")
288
+ res = sps.ks_2samp(x, y, alternative=alternative)
289
+ return {
290
+ "D": float(res.statistic),
291
+ "p": float(res.pvalue),
292
+ "n1": int(x.size),
293
+ "n2": int(y.size),
294
+ "alternative": alternative,
295
+ "method": "Kolmogorov-Smirnov (two-sample)",
296
+ }
297
+
298
+
299
+ def recommend_test(
300
+ groups: list[NDArray[np.float64]],
301
+ *,
302
+ paired: bool = False,
303
+ alpha: float = 0.05,
304
+ ) -> dict[str, Any]:
305
+ """The "which test?" decision tree (ORIGIN_GAP_PLAN #26 chooser logic).
306
+
307
+ Checks per-group normality (Shapiro-Wilk) and, for >= 2 groups, equal
308
+ variances (Brown-Forsythe), then recommends the parametric test when the
309
+ assumptions hold and the nonparametric analogue otherwise. Returns the
310
+ recommendation (name + /api/stats endpoint), the checks it ran, and
311
+ plain-language reasons — so a UI can show its work.
312
+ """
313
+ if not groups:
314
+ raise ValueError("recommend_test needs at least one group")
315
+ cleaned = [_clean(g) for g in groups]
316
+ if any(g.size < 3 for g in cleaned):
317
+ raise ValueError("every group needs at least 3 observations")
318
+ if paired:
319
+ if len(cleaned) != 2:
320
+ raise ValueError("paired analysis needs exactly 2 groups")
321
+ if cleaned[0].size != cleaned[1].size:
322
+ raise ValueError("paired groups must have the same length")
323
+
324
+ reasons: list[str] = []
325
+ checks: dict[str, Any] = {"alpha": alpha}
326
+
327
+ # Normality: for a paired design the DIFFERENCES are what must be normal.
328
+ if paired:
329
+ targets = [cleaned[0] - cleaned[1]]
330
+ labels = ["differences"]
331
+ else:
332
+ targets = cleaned
333
+ labels = [f"group {i}" for i in range(len(cleaned))]
334
+ norm_ps: list[float] = []
335
+ for lab, t in zip(labels, targets, strict=True):
336
+ p = shapiro_wilk(t)["p"] if t.size <= 5000 else float("nan")
337
+ norm_ps.append(p)
338
+ if np.isnan(p):
339
+ reasons.append(f"{lab}: n>5000, Shapiro skipped (assumed normal by CLT)")
340
+ checks["shapiro_p"] = norm_ps
341
+ all_normal = all(np.isnan(p) or p >= alpha for p in norm_ps)
342
+ if not all_normal:
343
+ bad = [labels[i] for i, p in enumerate(norm_ps) if not np.isnan(p) and p < alpha]
344
+ reasons.append(f"non-normal at alpha={alpha}: {', '.join(bad)}")
345
+ else:
346
+ reasons.append("normality not rejected for any group (Shapiro-Wilk)")
347
+
348
+ equal_var = True
349
+ if not paired and len(cleaned) >= 2:
350
+ lev = levene(cleaned)
351
+ checks["levene_p"] = lev["p"]
352
+ equal_var = lev["p"] >= alpha
353
+ reasons.append(
354
+ f"equal variances {'not rejected' if equal_var else 'REJECTED'} "
355
+ f"(Brown-Forsythe p={lev['p']:.3g})"
356
+ )
357
+
358
+ k = len(cleaned)
359
+ if k == 1:
360
+ rec = ("one-sample t-test", "/api/stats/ttest") if all_normal else (
361
+ "Wilcoxon signed-rank (one-sample)", "/api/stats/wilcoxon")
362
+ elif paired:
363
+ rec = ("paired t-test", "/api/stats/ttest") if all_normal else (
364
+ "Wilcoxon signed-rank (paired)", "/api/stats/wilcoxon")
365
+ elif k == 2:
366
+ rec = ("Welch two-sample t-test", "/api/stats/ttest") if all_normal else (
367
+ "Mann-Whitney U", "/api/stats/mann-whitney")
368
+ else:
369
+ rec = ("one-way ANOVA (+ Tukey HSD post-hoc)", "/api/stats/anova") if (
370
+ all_normal and equal_var) else ("Kruskal-Wallis H", "/api/stats/kruskal")
371
+
372
+ return {
373
+ "recommendation": rec[0],
374
+ "endpoint": rec[1],
375
+ "parametric": all_normal and (equal_var or paired or k == 1 or k == 2),
376
+ "n_groups": k,
377
+ "paired": paired,
378
+ "checks": checks,
379
+ "reasons": reasons,
380
+ }