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,202 @@
|
|
|
1
|
+
"""Two-way ANOVA (balanced) + post-hoc tests (Tukey HSD, Dunnett).
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #24 core — scipy (BSD) only. The balanced factorial
|
|
4
|
+
closed form is validated in tests against the classic Montgomery
|
|
5
|
+
battery-life 3x3(n=4) worked example; post-hoc wraps scipy's exact
|
|
6
|
+
``tukey_hsd`` / ``dunnett`` (scipy >= 1.11, already the project floor).
|
|
7
|
+
|
|
8
|
+
Limitation (documented, tracked on the plan item): unbalanced designs
|
|
9
|
+
(Type II/III sums of squares) are not implemented here — the closed form
|
|
10
|
+
below requires an equal number of replicates per cell and raises otherwise.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from numpy.typing import NDArray
|
|
19
|
+
from scipy import stats as sps
|
|
20
|
+
|
|
21
|
+
from quantized.calc.stats import _f_cdf
|
|
22
|
+
|
|
23
|
+
__all__ = ["anova2", "dunnett_test", "tukey_hsd"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def anova2(cells: list[list[list[float]]], *, alpha: float = 0.05) -> dict[str, Any]:
|
|
27
|
+
"""Balanced two-way factorial ANOVA with interaction.
|
|
28
|
+
|
|
29
|
+
``cells[i][j]`` holds the n replicates of factor-A level i x factor-B
|
|
30
|
+
level j (same n everywhere; n >= 2 for an interaction term, n == 1
|
|
31
|
+
drops the interaction into the error — the classic additive model).
|
|
32
|
+
Returns one row per source (A, B, AxB, Error, Total) with SS/df/MS/F/p.
|
|
33
|
+
Reference: Montgomery, *Design and Analysis of Experiments*, ch. 5.
|
|
34
|
+
"""
|
|
35
|
+
a = len(cells)
|
|
36
|
+
b = len(cells[0]) if a else 0
|
|
37
|
+
if a < 2 or b < 2:
|
|
38
|
+
raise ValueError("anova2 needs at least 2 levels of each factor")
|
|
39
|
+
if any(len(row) != b for row in cells):
|
|
40
|
+
raise ValueError("every factor-A level needs the same factor-B levels")
|
|
41
|
+
n = len(cells[0][0])
|
|
42
|
+
if n < 1 or any(len(cell) != n for row in cells for cell in row):
|
|
43
|
+
raise ValueError("anova2 requires a balanced design (equal replicates per cell)")
|
|
44
|
+
|
|
45
|
+
y = np.asarray(cells, dtype=float) # (a, b, n)
|
|
46
|
+
if not np.all(np.isfinite(y)):
|
|
47
|
+
raise ValueError("anova2 requires finite data (no NaN/Inf)")
|
|
48
|
+
grand = float(y.mean())
|
|
49
|
+
cell_means = y.mean(axis=2) # (a, b)
|
|
50
|
+
a_means = y.mean(axis=(1, 2)) # (a,)
|
|
51
|
+
b_means = y.mean(axis=(0, 2)) # (b,)
|
|
52
|
+
|
|
53
|
+
ss_a = float(n * b * np.sum((a_means - grand) ** 2))
|
|
54
|
+
ss_b = float(n * a * np.sum((b_means - grand) ** 2))
|
|
55
|
+
ss_ab = float(
|
|
56
|
+
n * np.sum((cell_means - a_means[:, None] - b_means[None, :] + grand) ** 2)
|
|
57
|
+
)
|
|
58
|
+
ss_e = float(np.sum((y - cell_means[:, :, None]) ** 2))
|
|
59
|
+
ss_t = float(np.sum((y - grand) ** 2))
|
|
60
|
+
|
|
61
|
+
df_a, df_b, df_ab = a - 1, b - 1, (a - 1) * (b - 1)
|
|
62
|
+
if n == 1:
|
|
63
|
+
# No replicates: the interaction is not estimable — it becomes the error.
|
|
64
|
+
ss_e, df_e = ss_ab, df_ab
|
|
65
|
+
ss_ab, df_ab = 0.0, 0
|
|
66
|
+
else:
|
|
67
|
+
df_e = a * b * (n - 1)
|
|
68
|
+
|
|
69
|
+
def _row(name: str, ss: float, df: int) -> dict[str, Any]:
|
|
70
|
+
if df <= 0:
|
|
71
|
+
return {"source": name, "SS": ss, "df": df, "MS": None, "F": None, "p": None}
|
|
72
|
+
ms = ss / df
|
|
73
|
+
if name in ("Error", "Total") or df_e <= 0:
|
|
74
|
+
return {"source": name, "SS": ss, "df": df, "MS": ms, "F": None, "p": None}
|
|
75
|
+
f = ms / (ss_e / df_e)
|
|
76
|
+
return {
|
|
77
|
+
"source": name, "SS": ss, "df": df, "MS": ms, "F": f,
|
|
78
|
+
"p": 1.0 - _f_cdf(f, df, df_e),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
rows = [_row("A", ss_a, df_a), _row("B", ss_b, df_b)]
|
|
82
|
+
if df_ab > 0:
|
|
83
|
+
rows.append(_row("AxB", ss_ab, df_ab))
|
|
84
|
+
rows.append({"source": "Error", "SS": ss_e, "df": df_e,
|
|
85
|
+
"MS": ss_e / df_e if df_e else None, "F": None, "p": None})
|
|
86
|
+
rows.append({"source": "Total", "SS": ss_t, "df": a * b * n - 1,
|
|
87
|
+
"MS": None, "F": None, "p": None})
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
"table": rows,
|
|
91
|
+
"a_levels": a,
|
|
92
|
+
"b_levels": b,
|
|
93
|
+
"replicates": n,
|
|
94
|
+
"grand_mean": grand,
|
|
95
|
+
"alpha": alpha,
|
|
96
|
+
"interaction_estimable": n > 1,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _clean_groups(groups: list[NDArray[np.float64]], min_size: int) -> list[NDArray[np.float64]]:
|
|
101
|
+
cleaned = []
|
|
102
|
+
for g in groups:
|
|
103
|
+
gv = np.asarray(g, dtype=float).ravel()
|
|
104
|
+
gv = gv[np.isfinite(gv)]
|
|
105
|
+
if gv.size < min_size:
|
|
106
|
+
raise ValueError(f"every group needs at least {min_size} observations")
|
|
107
|
+
cleaned.append(gv)
|
|
108
|
+
return cleaned
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def tukey_hsd(groups: list[NDArray[np.float64]], *, alpha: float = 0.05) -> dict[str, Any]:
|
|
112
|
+
"""Tukey's honestly-significant-difference test on k independent groups.
|
|
113
|
+
|
|
114
|
+
All pairwise mean comparisons with familywise error control (exact
|
|
115
|
+
studentized-range distribution via scipy). Returns one row per pair
|
|
116
|
+
with the mean difference, p-value, and the (1-alpha) CI.
|
|
117
|
+
"""
|
|
118
|
+
cleaned = _clean_groups(groups, 2)
|
|
119
|
+
if len(cleaned) < 2:
|
|
120
|
+
raise ValueError("tukey_hsd needs at least 2 groups")
|
|
121
|
+
res = sps.tukey_hsd(*cleaned)
|
|
122
|
+
ci = res.confidence_interval(confidence_level=1.0 - alpha)
|
|
123
|
+
pairs = []
|
|
124
|
+
for i in range(len(cleaned)):
|
|
125
|
+
for j in range(i + 1, len(cleaned)):
|
|
126
|
+
pairs.append({
|
|
127
|
+
"i": i, "j": j,
|
|
128
|
+
"diff": float(np.mean(cleaned[i]) - np.mean(cleaned[j])),
|
|
129
|
+
"p": float(res.pvalue[i, j]),
|
|
130
|
+
"ciLow": float(ci.low[i, j]),
|
|
131
|
+
"ciHigh": float(ci.high[i, j]),
|
|
132
|
+
"significant": bool(res.pvalue[i, j] < alpha),
|
|
133
|
+
})
|
|
134
|
+
return {"pairs": pairs, "n_groups": len(cleaned), "alpha": alpha, "method": "Tukey HSD"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def dunnett_test(
|
|
138
|
+
groups: list[NDArray[np.float64]],
|
|
139
|
+
*,
|
|
140
|
+
control: int = 0,
|
|
141
|
+
alpha: float = 0.05,
|
|
142
|
+
alternative: str = "two-sided",
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""Dunnett's many-to-one test: every group vs the control group.
|
|
145
|
+
|
|
146
|
+
Familywise error control for treatment-vs-control designs (exact
|
|
147
|
+
multivariate-t via scipy >= 1.11). ``control`` indexes into ``groups``.
|
|
148
|
+
"""
|
|
149
|
+
cleaned = _clean_groups(groups, 2)
|
|
150
|
+
if len(cleaned) < 2:
|
|
151
|
+
raise ValueError("dunnett_test needs a control and at least 1 treatment")
|
|
152
|
+
if not 0 <= control < len(cleaned):
|
|
153
|
+
raise ValueError(f"control index {control} out of range")
|
|
154
|
+
ctrl = cleaned[control]
|
|
155
|
+
treatments = [g for k, g in enumerate(cleaned) if k != control]
|
|
156
|
+
idx = [k for k in range(len(cleaned)) if k != control]
|
|
157
|
+
res = sps.dunnett(*treatments, control=ctrl, alternative=alternative)
|
|
158
|
+
ci = res.confidence_interval(confidence_level=1.0 - alpha)
|
|
159
|
+
rows = []
|
|
160
|
+
for pos, k in enumerate(idx):
|
|
161
|
+
rows.append({
|
|
162
|
+
"group": k,
|
|
163
|
+
"diff": float(np.mean(cleaned[k]) - np.mean(ctrl)),
|
|
164
|
+
"statistic": float(res.statistic[pos]),
|
|
165
|
+
"p": float(res.pvalue[pos]),
|
|
166
|
+
"ciLow": float(ci.low[pos]),
|
|
167
|
+
"ciHigh": float(ci.high[pos]),
|
|
168
|
+
"significant": bool(res.pvalue[pos] < alpha),
|
|
169
|
+
})
|
|
170
|
+
return {
|
|
171
|
+
"comparisons": rows, "control": control, "alpha": alpha,
|
|
172
|
+
"alternative": alternative, "method": "Dunnett",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def adjust_pvalues(p_values: list[float], *, method: str = "holm") -> dict[str, Any]:
|
|
177
|
+
"""Multiple-comparison p-value adjustment.
|
|
178
|
+
|
|
179
|
+
``bonferroni`` (p·m, most conservative), ``holm`` (step-down Bonferroni —
|
|
180
|
+
uniformly more powerful, the sensible default), or ``bh`` (Benjamini-
|
|
181
|
+
Hochberg false-discovery-rate step-up). All clipped to [0, 1] and
|
|
182
|
+
monotonicity-enforced. Reference: Holm (1979); Benjamini & Hochberg (1995).
|
|
183
|
+
"""
|
|
184
|
+
if method not in ("bonferroni", "holm", "bh"):
|
|
185
|
+
raise ValueError(f'method must be "bonferroni", "holm", or "bh", got "{method}"')
|
|
186
|
+
p = np.asarray(p_values, dtype=float).ravel()
|
|
187
|
+
if p.size == 0:
|
|
188
|
+
raise ValueError("adjust_pvalues needs at least one p-value")
|
|
189
|
+
if np.any(~np.isfinite(p)) or np.any(p < 0) or np.any(p > 1):
|
|
190
|
+
raise ValueError("p-values must be finite and within [0, 1]")
|
|
191
|
+
m = p.size
|
|
192
|
+
order = np.argsort(p, kind="stable")
|
|
193
|
+
adj = np.empty(m, dtype=float)
|
|
194
|
+
if method == "bonferroni":
|
|
195
|
+
adj = np.asarray(np.minimum(p * m, 1.0), dtype=float)
|
|
196
|
+
elif method == "holm":
|
|
197
|
+
ranked = np.asarray(p[order] * (m - np.arange(m)), dtype=float)
|
|
198
|
+
adj[order] = np.minimum(np.maximum.accumulate(ranked), 1.0) # step-down
|
|
199
|
+
else: # bh
|
|
200
|
+
ranked = np.asarray(p[order] * m / (np.arange(m) + 1), dtype=float)
|
|
201
|
+
adj[order] = np.minimum(np.minimum.accumulate(ranked[::-1])[::-1], 1.0) # step-up
|
|
202
|
+
return {"adjusted": adj, "method": method, "m": int(m)}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Extended ANOVA: repeated-measures (within-subjects) + unbalanced factorial.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #24 remainder. numpy/scipy only (BSD) — no statsmodels dep.
|
|
4
|
+
|
|
5
|
+
- ``repeated_measures_anova`` — one-way within-subjects ANOVA over a
|
|
6
|
+
(subjects x conditions) matrix, partitioning out the between-subjects
|
|
7
|
+
variance so the condition effect is tested against the subject-by-
|
|
8
|
+
condition residual. Greenhouse-Geisser and Huynh-Feldt sphericity
|
|
9
|
+
corrections are reported alongside the uncorrected test.
|
|
10
|
+
|
|
11
|
+
- ``anova2_unbalanced`` — two-way factorial for *unequal* cell counts,
|
|
12
|
+
with Type II or Type III sums of squares computed by the nested-model
|
|
13
|
+
regression definition over effect-coded (sum-to-zero) design matrices,
|
|
14
|
+
which is what makes Type III well-defined and matches SAS / statsmodels.
|
|
15
|
+
On a *balanced* design all SS types coincide and equal the closed-form
|
|
16
|
+
``stats_anova2.anova2`` — the tests anchor on that exact equivalence.
|
|
17
|
+
|
|
18
|
+
- ``long_to_groups`` / ``long_to_cells`` — reshape a worksheet value
|
|
19
|
+
column plus one or two factor (label) columns into the structures the
|
|
20
|
+
ANOVA functions expect.
|
|
21
|
+
|
|
22
|
+
References: Montgomery, *Design and Analysis of Experiments*; Maxwell &
|
|
23
|
+
Delaney, *Designing Experiments and Analyzing Data* (within-subjects &
|
|
24
|
+
Type II/III SS).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
from numpy.typing import NDArray
|
|
33
|
+
|
|
34
|
+
from quantized.calc.stats import _f_cdf
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"anova2_unbalanced",
|
|
38
|
+
"long_to_cells",
|
|
39
|
+
"long_to_groups",
|
|
40
|
+
"repeated_measures_anova",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------
|
|
45
|
+
# Repeated-measures (within-subjects) one-way ANOVA
|
|
46
|
+
# --------------------------------------------------------------------------
|
|
47
|
+
def _sphericity_epsilons(y: NDArray[np.float64], n: int, k: int) -> dict[str, float]:
|
|
48
|
+
"""Greenhouse-Geisser and Huynh-Feldt epsilon from the condition covariance.
|
|
49
|
+
|
|
50
|
+
Transform the k conditions by a (k-1)-row orthonormal contrast matrix,
|
|
51
|
+
take the covariance ``T`` of those contrasts across subjects, and use its
|
|
52
|
+
eigenvalues: eps_GG = (sum lambda)^2 / ((k-1) sum lambda^2). Huynh-Feldt
|
|
53
|
+
inflates GG, clipped at 1. Both live in [1/(k-1), 1].
|
|
54
|
+
"""
|
|
55
|
+
# Orthonormal basis for the (k-1)-dim space orthogonal to the ones vector.
|
|
56
|
+
contrasts, _ = np.linalg.qr(
|
|
57
|
+
np.column_stack([np.ones(k), np.eye(k)[:, : k - 1]])
|
|
58
|
+
)
|
|
59
|
+
c = np.asarray(contrasts[:, 1:k].T, dtype=float) # (k-1, k), rows _|_ ones
|
|
60
|
+
# Covariance of the conditions across subjects (rows = subjects).
|
|
61
|
+
cov = np.asarray(np.cov(y, rowvar=False, ddof=1), dtype=float)
|
|
62
|
+
t = np.asarray(c @ cov @ c.T, dtype=float)
|
|
63
|
+
lam = np.asarray(np.linalg.eigvalsh(t), dtype=float)
|
|
64
|
+
lam = lam[lam > 0]
|
|
65
|
+
if lam.size == 0:
|
|
66
|
+
return {"gg": 1.0, "hf": 1.0}
|
|
67
|
+
eps_gg = float(lam.sum() ** 2 / ((k - 1) * np.sum(lam**2)))
|
|
68
|
+
eps_gg = min(1.0, max(1.0 / (k - 1), eps_gg))
|
|
69
|
+
num = n * (k - 1) * eps_gg - 2.0
|
|
70
|
+
den = (k - 1) * ((n - 1) - (k - 1) * eps_gg)
|
|
71
|
+
eps_hf = 1.0 if den == 0 else min(1.0, num / den)
|
|
72
|
+
return {"gg": eps_gg, "hf": max(eps_gg, eps_hf)}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def repeated_measures_anova(
|
|
76
|
+
data: list[list[float]] | NDArray[np.float64], *, alpha: float = 0.05
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""One-way repeated-measures ANOVA on a (subjects x conditions) matrix.
|
|
79
|
+
|
|
80
|
+
Every subject (row) is measured under every condition (column). The
|
|
81
|
+
condition effect is tested against the subject x condition residual::
|
|
82
|
+
|
|
83
|
+
SS_total = sum (x - grand)^2
|
|
84
|
+
SS_subjects = k * sum (subject_mean - grand)^2
|
|
85
|
+
SS_conditions = n * sum (condition_mean - grand)^2
|
|
86
|
+
SS_error = SS_total - SS_subjects - SS_conditions
|
|
87
|
+
|
|
88
|
+
with df_conditions = k-1 and df_error = (k-1)(n-1). Returns the ANOVA
|
|
89
|
+
table plus Greenhouse-Geisser / Huynh-Feldt corrected p-values (the
|
|
90
|
+
honest choice when compound symmetry is doubtful).
|
|
91
|
+
"""
|
|
92
|
+
y = np.asarray(data, dtype=float)
|
|
93
|
+
if y.ndim != 2 or y.shape[0] < 2 or y.shape[1] < 2:
|
|
94
|
+
raise ValueError("need a subjects x conditions matrix with >=2 subjects and >=2 conditions")
|
|
95
|
+
if not np.all(np.isfinite(y)):
|
|
96
|
+
raise ValueError("repeated_measures_anova requires finite data (no NaN/Inf)")
|
|
97
|
+
n, k = int(y.shape[0]), int(y.shape[1])
|
|
98
|
+
|
|
99
|
+
grand = float(y.mean())
|
|
100
|
+
subj_means = np.asarray(y.mean(axis=1), dtype=float)
|
|
101
|
+
cond_means = np.asarray(y.mean(axis=0), dtype=float)
|
|
102
|
+
ss_total = float(np.sum((y - grand) ** 2))
|
|
103
|
+
ss_subjects = float(k * np.sum((subj_means - grand) ** 2))
|
|
104
|
+
ss_conditions = float(n * np.sum((cond_means - grand) ** 2))
|
|
105
|
+
ss_error = ss_total - ss_subjects - ss_conditions
|
|
106
|
+
|
|
107
|
+
df_cond, df_subj, df_err = k - 1, n - 1, (k - 1) * (n - 1)
|
|
108
|
+
ms_cond = ss_conditions / df_cond
|
|
109
|
+
ms_err = ss_error / df_err if df_err > 0 else float("nan")
|
|
110
|
+
f = ms_cond / ms_err if ms_err > 0 else float("inf")
|
|
111
|
+
p = 1.0 - _f_cdf(f, df_cond, df_err)
|
|
112
|
+
|
|
113
|
+
eps = _sphericity_epsilons(y, n, k)
|
|
114
|
+
p_gg = 1.0 - _f_cdf(f, df_cond * eps["gg"], df_err * eps["gg"])
|
|
115
|
+
p_hf = 1.0 - _f_cdf(f, df_cond * eps["hf"], df_err * eps["hf"])
|
|
116
|
+
|
|
117
|
+
table = [
|
|
118
|
+
{"source": "Subjects", "SS": ss_subjects, "df": df_subj,
|
|
119
|
+
"MS": ss_subjects / df_subj, "F": None, "p": None},
|
|
120
|
+
{"source": "Conditions", "SS": ss_conditions, "df": df_cond,
|
|
121
|
+
"MS": ms_cond, "F": f, "p": p},
|
|
122
|
+
{"source": "Error", "SS": ss_error, "df": df_err, "MS": ms_err,
|
|
123
|
+
"F": None, "p": None},
|
|
124
|
+
{"source": "Total", "SS": ss_total, "df": n * k - 1, "MS": None,
|
|
125
|
+
"F": None, "p": None},
|
|
126
|
+
]
|
|
127
|
+
# partial eta-squared for the condition effect
|
|
128
|
+
eta_p = ss_conditions / (ss_conditions + ss_error) if (ss_conditions + ss_error) > 0 else 0.0
|
|
129
|
+
return {
|
|
130
|
+
"table": table,
|
|
131
|
+
"n_subjects": n,
|
|
132
|
+
"n_conditions": k,
|
|
133
|
+
"grand_mean": grand,
|
|
134
|
+
"alpha": alpha,
|
|
135
|
+
"partial_eta_sq": eta_p,
|
|
136
|
+
"sphericity": {
|
|
137
|
+
"greenhouse_geisser": eps["gg"],
|
|
138
|
+
"huynh_feldt": eps["hf"],
|
|
139
|
+
"p_greenhouse_geisser": p_gg,
|
|
140
|
+
"p_huynh_feldt": p_hf,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
# Unbalanced two-way factorial ANOVA (Type II / Type III SS)
|
|
147
|
+
# --------------------------------------------------------------------------
|
|
148
|
+
def _effect_code(codes: NDArray[np.intp], n_levels: int) -> NDArray[np.float64]:
|
|
149
|
+
"""Sum-to-zero (deviation) coding: (N, n_levels-1) columns.
|
|
150
|
+
|
|
151
|
+
Level j<last -> +1 in column j; the last level -> -1 in every column;
|
|
152
|
+
all other rows 0. Orthogonal for balanced data, which is what lets the
|
|
153
|
+
Type III drop-one-term contrast reduce to the classic SS there.
|
|
154
|
+
"""
|
|
155
|
+
n = codes.size
|
|
156
|
+
x = np.zeros((n, n_levels - 1), dtype=float)
|
|
157
|
+
for j in range(n_levels - 1):
|
|
158
|
+
x[codes == j, j] = 1.0
|
|
159
|
+
x[codes == n_levels - 1, :] = -1.0
|
|
160
|
+
return x
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sse(design: NDArray[np.float64], y: NDArray[np.float64]) -> float:
|
|
164
|
+
"""Residual sum of squares of an OLS fit (least squares, rank-safe)."""
|
|
165
|
+
beta, _, _, _ = np.linalg.lstsq(design, y, rcond=None)
|
|
166
|
+
resid = np.asarray(y - design @ beta, dtype=float)
|
|
167
|
+
return float(resid @ resid)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def anova2_unbalanced(
|
|
171
|
+
values: NDArray[np.float64],
|
|
172
|
+
factor_a: list[Any] | NDArray[Any],
|
|
173
|
+
factor_b: list[Any] | NDArray[Any],
|
|
174
|
+
*,
|
|
175
|
+
ss_type: int = 3,
|
|
176
|
+
alpha: float = 0.05,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
"""Two-way factorial ANOVA for unbalanced (unequal-n) designs.
|
|
179
|
+
|
|
180
|
+
``values`` are the observations; ``factor_a`` / ``factor_b`` are the
|
|
181
|
+
matching factor labels (any hashable). Sums of squares:
|
|
182
|
+
|
|
183
|
+
- ``ss_type=3`` (default): each term's SS = the increase in residual SS
|
|
184
|
+
when its effect-coded columns are dropped from the *full* model
|
|
185
|
+
(which keeps all other terms). Requires every A x B cell to be
|
|
186
|
+
non-empty; matches SAS / statsmodels Type III.
|
|
187
|
+
- ``ss_type=2``: main effects adjusted for each other but ignoring the
|
|
188
|
+
interaction (``SS(A | B)``, ``SS(B | A)``); the interaction term is
|
|
189
|
+
the same as Type III.
|
|
190
|
+
|
|
191
|
+
On a balanced design all SS types equal the closed-form
|
|
192
|
+
:func:`quantized.calc.stats_anova2.anova2`.
|
|
193
|
+
"""
|
|
194
|
+
if ss_type not in (2, 3):
|
|
195
|
+
raise ValueError("ss_type must be 2 or 3")
|
|
196
|
+
y = np.asarray(values, dtype=float).ravel()
|
|
197
|
+
fa = np.asarray(list(factor_a), dtype=object).ravel()
|
|
198
|
+
fb = np.asarray(list(factor_b), dtype=object).ravel()
|
|
199
|
+
if not (y.size == fa.size == fb.size):
|
|
200
|
+
raise ValueError("values, factor_a, factor_b must have the same length")
|
|
201
|
+
finite = np.isfinite(y)
|
|
202
|
+
y, fa, fb = y[finite], fa[finite], fb[finite]
|
|
203
|
+
if y.size < 4:
|
|
204
|
+
raise ValueError("anova2_unbalanced needs at least 4 finite observations")
|
|
205
|
+
|
|
206
|
+
a_levels = sorted({str(v) for v in fa})
|
|
207
|
+
b_levels = sorted({str(v) for v in fb})
|
|
208
|
+
a, b = len(a_levels), len(b_levels)
|
|
209
|
+
if a < 2 or b < 2:
|
|
210
|
+
raise ValueError("anova2_unbalanced needs at least 2 levels of each factor")
|
|
211
|
+
a_idx = np.array([a_levels.index(str(v)) for v in fa], dtype=np.intp)
|
|
212
|
+
b_idx = np.array([b_levels.index(str(v)) for v in fb], dtype=np.intp)
|
|
213
|
+
|
|
214
|
+
# every cell must be populated (Type III is undefined with an empty cell)
|
|
215
|
+
counts = np.zeros((a, b), dtype=int)
|
|
216
|
+
for ia, ib in zip(a_idx, b_idx, strict=True):
|
|
217
|
+
counts[ia, ib] += 1
|
|
218
|
+
if np.any(counts == 0):
|
|
219
|
+
raise ValueError("anova2_unbalanced requires at least one observation in every A x B cell")
|
|
220
|
+
|
|
221
|
+
n_obs = y.size
|
|
222
|
+
intercept = np.ones((n_obs, 1), dtype=float)
|
|
223
|
+
a_cols = _effect_code(a_idx, a) # (N, a-1)
|
|
224
|
+
b_cols = _effect_code(b_idx, b) # (N, b-1)
|
|
225
|
+
ab_cols = np.asarray( # (N, (a-1)(b-1)) products of the two codings
|
|
226
|
+
np.hstack([a_cols[:, [i]] * b_cols for i in range(a - 1)]), dtype=float
|
|
227
|
+
) if (a - 1) and (b - 1) else np.zeros((n_obs, 0), dtype=float)
|
|
228
|
+
|
|
229
|
+
full = np.hstack([intercept, a_cols, b_cols, ab_cols])
|
|
230
|
+
sse_full = _sse(full, y)
|
|
231
|
+
df_e = n_obs - (a * b)
|
|
232
|
+
if df_e <= 0:
|
|
233
|
+
raise ValueError("not enough observations to estimate the error term")
|
|
234
|
+
|
|
235
|
+
no_a = np.hstack([intercept, b_cols, ab_cols])
|
|
236
|
+
no_b = np.hstack([intercept, a_cols, ab_cols])
|
|
237
|
+
no_ab = np.hstack([intercept, a_cols, b_cols])
|
|
238
|
+
|
|
239
|
+
ss_ab = _sse(no_ab, y) - sse_full # identical for Type II and III
|
|
240
|
+
if ss_type == 3:
|
|
241
|
+
ss_a = _sse(no_a, y) - sse_full
|
|
242
|
+
ss_b = _sse(no_b, y) - sse_full
|
|
243
|
+
else: # Type II: main effect adjusted for the other main effect only
|
|
244
|
+
base = intercept
|
|
245
|
+
sse_b_only = _sse(np.hstack([base, b_cols]), y)
|
|
246
|
+
sse_a_only = _sse(np.hstack([base, a_cols]), y)
|
|
247
|
+
sse_a_and_b = _sse(no_ab, y)
|
|
248
|
+
ss_a = sse_b_only - sse_a_and_b
|
|
249
|
+
ss_b = sse_a_only - sse_a_and_b
|
|
250
|
+
|
|
251
|
+
df_a, df_b, df_ab = a - 1, b - 1, (a - 1) * (b - 1)
|
|
252
|
+
ms_e = sse_full / df_e
|
|
253
|
+
|
|
254
|
+
def _row(name: str, ss: float, df: int) -> dict[str, Any]:
|
|
255
|
+
ss = max(0.0, ss)
|
|
256
|
+
ms = ss / df
|
|
257
|
+
f = ms / ms_e
|
|
258
|
+
return {"source": name, "SS": ss, "df": df, "MS": ms, "F": f,
|
|
259
|
+
"p": 1.0 - _f_cdf(f, df, df_e)}
|
|
260
|
+
|
|
261
|
+
ss_t = float(np.sum((y - y.mean()) ** 2))
|
|
262
|
+
table = [
|
|
263
|
+
_row("A", ss_a, df_a),
|
|
264
|
+
_row("B", ss_b, df_b),
|
|
265
|
+
_row("AxB", ss_ab, df_ab),
|
|
266
|
+
{"source": "Error", "SS": sse_full, "df": df_e, "MS": ms_e, "F": None, "p": None},
|
|
267
|
+
{"source": "Total", "SS": ss_t, "df": n_obs - 1, "MS": None, "F": None, "p": None},
|
|
268
|
+
]
|
|
269
|
+
return {
|
|
270
|
+
"table": table,
|
|
271
|
+
"ss_type": ss_type,
|
|
272
|
+
"a_levels": a_levels,
|
|
273
|
+
"b_levels": b_levels,
|
|
274
|
+
"cell_counts": counts.tolist(),
|
|
275
|
+
"balanced": bool(np.all(counts == counts.flat[0])),
|
|
276
|
+
"n_obs": int(n_obs),
|
|
277
|
+
"alpha": alpha,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --------------------------------------------------------------------------
|
|
282
|
+
# Long-format reshapers (worksheet value + factor columns -> ANOVA inputs)
|
|
283
|
+
# --------------------------------------------------------------------------
|
|
284
|
+
def long_to_groups(
|
|
285
|
+
values: NDArray[np.float64], factor: list[Any] | NDArray[Any]
|
|
286
|
+
) -> dict[str, Any]:
|
|
287
|
+
"""Split a value column into per-level groups by a factor (label) column.
|
|
288
|
+
|
|
289
|
+
Returns ``{"levels": [...], "groups": [ndarray, ...]}`` (levels sorted
|
|
290
|
+
for determinism, non-finite values dropped) — the shape the k-sample
|
|
291
|
+
tests (one-way ANOVA, Kruskal-Wallis, ...) consume.
|
|
292
|
+
"""
|
|
293
|
+
v = np.asarray(values, dtype=float).ravel()
|
|
294
|
+
f = np.asarray(list(factor), dtype=object).ravel()
|
|
295
|
+
if v.size != f.size:
|
|
296
|
+
raise ValueError("values and factor must have the same length")
|
|
297
|
+
levels = sorted({str(x) for x in f})
|
|
298
|
+
groups = []
|
|
299
|
+
for lev in levels:
|
|
300
|
+
g = v[np.array([str(x) == lev for x in f])]
|
|
301
|
+
groups.append(np.asarray(g[np.isfinite(g)], dtype=float))
|
|
302
|
+
return {"levels": levels, "groups": groups}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def long_to_cells(
|
|
306
|
+
values: NDArray[np.float64],
|
|
307
|
+
factor_a: list[Any] | NDArray[Any],
|
|
308
|
+
factor_b: list[Any] | NDArray[Any],
|
|
309
|
+
) -> dict[str, Any]:
|
|
310
|
+
"""Reshape long-format value + two factor columns into a cell grid.
|
|
311
|
+
|
|
312
|
+
Returns ``a_levels``, ``b_levels``, and ``cells`` (``cells[i][j]`` is the
|
|
313
|
+
list of observations for A-level i x B-level j). When every cell holds
|
|
314
|
+
the same count, ``cells`` is directly usable by
|
|
315
|
+
:func:`quantized.calc.stats_anova2.anova2`; otherwise route the long
|
|
316
|
+
columns straight to :func:`anova2_unbalanced`.
|
|
317
|
+
"""
|
|
318
|
+
v = np.asarray(values, dtype=float).ravel()
|
|
319
|
+
fa = np.asarray(list(factor_a), dtype=object).ravel()
|
|
320
|
+
fb = np.asarray(list(factor_b), dtype=object).ravel()
|
|
321
|
+
if not (v.size == fa.size == fb.size):
|
|
322
|
+
raise ValueError("values, factor_a, factor_b must have the same length")
|
|
323
|
+
a_levels = sorted({str(x) for x in fa})
|
|
324
|
+
b_levels = sorted({str(x) for x in fb})
|
|
325
|
+
cells: list[list[list[float]]] = [[[] for _ in b_levels] for _ in a_levels]
|
|
326
|
+
for val, la, lb in zip(v, fa, fb, strict=True):
|
|
327
|
+
if not np.isfinite(val):
|
|
328
|
+
continue
|
|
329
|
+
cells[a_levels.index(str(la))][b_levels.index(str(lb))].append(float(val))
|
|
330
|
+
counts = [[len(c) for c in row] for row in cells]
|
|
331
|
+
flat = [n for row in counts for n in row]
|
|
332
|
+
return {
|
|
333
|
+
"a_levels": a_levels,
|
|
334
|
+
"b_levels": b_levels,
|
|
335
|
+
"cells": cells,
|
|
336
|
+
"cell_counts": counts,
|
|
337
|
+
"balanced": bool(flat) and all(n == flat[0] and n > 0 for n in flat),
|
|
338
|
+
}
|