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,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
+ }