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.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
}
|