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,289 @@
1
+ """Multivariate statistics: multiple linear regression + correlation matrices.
2
+
3
+ ORIGIN_GAP_PLAN #27 — new capability beyond MATLAB parity, numpy/scipy only.
4
+ Result-dict key names mirror ``calc.stats.lin_regress`` so downstream code
5
+ (routes, future report sheets) treats simple and multiple regression alike;
6
+ the single-predictor case is validated against ``lin_regress`` (itself
7
+ golden-verified vs MATLAB) in ``tests/test_calc_stats_multivar.py``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+ from numpy.typing import NDArray
17
+
18
+ from quantized.calc.stats import _f_cdf, _t_cdf, _t_inv
19
+
20
+ _EPS = float(np.finfo(float).tiny)
21
+
22
+
23
+ def _as_matrix(columns: list[NDArray[np.float64]] | NDArray[np.float64]) -> NDArray[np.float64]:
24
+ """Stack same-length 1-D columns into an (n, k) matrix (validates lengths)."""
25
+ if isinstance(columns, np.ndarray) and columns.ndim == 2:
26
+ return np.asarray(columns, dtype=float)
27
+ cols = [np.asarray(c, dtype=float).ravel() for c in columns]
28
+ if len(cols) < 1:
29
+ raise ValueError("need at least one column")
30
+ n = cols[0].size
31
+ if any(c.size != n for c in cols):
32
+ raise ValueError("all columns must have the same length")
33
+ return np.column_stack(cols)
34
+
35
+
36
+ def multiple_regression(
37
+ predictors: list[NDArray[np.float64]] | NDArray[np.float64],
38
+ y: NDArray[np.float64],
39
+ *,
40
+ alpha: float = 0.05,
41
+ ) -> dict[str, Any]:
42
+ """Multiple linear regression ``y = b0 + b1·x1 + … + bk·xk`` with inference.
43
+
44
+ ``predictors`` is a list of k same-length columns (or an (n, k) matrix);
45
+ the intercept is always included. Returns coefficients (intercept first),
46
+ standard errors, t-stats/p-values, confidence intervals, R²/adjusted R²,
47
+ the overall F-test, RMSE, residuals and fitted values — the same key
48
+ vocabulary as ``lin_regress``. Rows containing any non-finite value are
49
+ dropped (listwise deletion).
50
+ """
51
+ xmat0 = _as_matrix(predictors)
52
+ yv = np.asarray(y, dtype=float).ravel()
53
+ if yv.size != xmat0.shape[0]:
54
+ raise ValueError(f"y length {yv.size} != predictor length {xmat0.shape[0]}")
55
+ keep = np.isfinite(yv) & np.all(np.isfinite(xmat0), axis=1)
56
+ xmat0, yv = xmat0[keep], yv[keep]
57
+ n, k = xmat0.shape
58
+ if n < k + 2:
59
+ raise ValueError(f"need at least {k + 2} complete rows for {k} predictors")
60
+
61
+ xmat = np.column_stack([np.ones(n), xmat0])
62
+ xtx = xmat.T @ xmat
63
+ try:
64
+ coeffs = np.asarray(np.linalg.solve(xtx, xmat.T @ yv), dtype=float)
65
+ except np.linalg.LinAlgError as exc:
66
+ raise ValueError(
67
+ "regression is singular — a predictor is constant or predictors are collinear"
68
+ ) from exc
69
+ y_fit = np.asarray(xmat @ coeffs, dtype=float)
70
+ residuals = np.asarray(yv - y_fit, dtype=float)
71
+ df = n - (k + 1)
72
+
73
+ ss_res = float(np.sum(residuals**2))
74
+ ss_tot = float(np.sum((yv - np.mean(yv)) ** 2))
75
+ ss_reg = ss_tot - ss_res
76
+ r2 = 1.0 - ss_res / max(ss_tot, _EPS)
77
+ _adj_denom = ss_tot / (n - 1)
78
+ r2_adj = 1.0 - (ss_res / df) / _adj_denom if _adj_denom != 0.0 else float("nan")
79
+ mse = ss_res / df
80
+ f_stat = (ss_reg / k) / max(mse, _EPS)
81
+
82
+ cov_b = mse * np.linalg.inv(xtx)
83
+ se = np.asarray(np.sqrt(np.maximum(np.diag(cov_b), 0.0)), dtype=float)
84
+ t_stats = np.asarray(coeffs / np.maximum(se, _EPS), dtype=float)
85
+ p_values = np.asarray(2.0 * (1.0 - _t_cdf(np.abs(t_stats), df)), dtype=float)
86
+ t_crit = _t_inv(1.0 - alpha / 2.0, df)
87
+
88
+ return {
89
+ "coeffs": coeffs, # intercept first, then one per predictor
90
+ "se": se,
91
+ "tStats": t_stats,
92
+ "pValues": p_values,
93
+ "ciLow": np.asarray(coeffs - t_crit * se, dtype=float),
94
+ "ciHigh": np.asarray(coeffs + t_crit * se, dtype=float),
95
+ "R2": r2,
96
+ "R2adj": r2_adj,
97
+ "fStat": f_stat,
98
+ "fPvalue": 1.0 - _f_cdf(f_stat, k, df),
99
+ "RMSE": math.sqrt(mse),
100
+ "residuals": residuals,
101
+ "yFit": y_fit,
102
+ "N": n,
103
+ "df": df,
104
+ "alpha": alpha,
105
+ }
106
+
107
+
108
+ def _rankdata(x: NDArray[np.float64]) -> NDArray[np.float64]:
109
+ """Average ranks (ties share the mean rank) — Spearman's transform."""
110
+ order = np.argsort(x, kind="stable")
111
+ ranks = np.empty(x.size, dtype=float)
112
+ sx = x[order]
113
+ i = 0
114
+ while i < x.size:
115
+ j = i
116
+ while j + 1 < x.size and sx[j + 1] == sx[i]:
117
+ j += 1
118
+ ranks[order[i : j + 1]] = 0.5 * (i + j) + 1.0
119
+ i = j + 1
120
+ return ranks
121
+
122
+
123
+ def correlation_matrix(
124
+ columns: list[NDArray[np.float64]] | NDArray[np.float64],
125
+ *,
126
+ method: str = "pearson",
127
+ ) -> dict[str, Any]:
128
+ """Pairwise correlation matrix with significance.
129
+
130
+ ``method='pearson'`` (linear) or ``'spearman'`` (rank). p-values from the
131
+ exact t-transform ``t = r·sqrt((n-2)/(1-r²))`` with n-2 dof (the classic
132
+ test, as in MATLAB corrcoef); the diagonal is r=1, p=1 by convention.
133
+ Rows containing any non-finite value are dropped (listwise deletion).
134
+ """
135
+ if method not in ("pearson", "spearman"):
136
+ raise ValueError(f'method must be "pearson" or "spearman", got "{method}"')
137
+ data = _as_matrix(columns)
138
+ data = data[np.all(np.isfinite(data), axis=1)]
139
+ n, k = data.shape
140
+ if k < 2:
141
+ raise ValueError("correlation needs at least 2 columns")
142
+ if n < 3:
143
+ raise ValueError("correlation needs at least 3 complete rows")
144
+ if method == "spearman":
145
+ data = np.column_stack([_rankdata(data[:, j]) for j in range(k)])
146
+
147
+ r = np.asarray(np.corrcoef(data, rowvar=False), dtype=float)
148
+ # t-transform off-diagonal; clamp so |r|=1 gives p=0 instead of a 0-division.
149
+ rr = np.clip(r, -1.0, 1.0)
150
+ denom = np.maximum(1.0 - rr**2, _EPS)
151
+ # |r|==1 floors denom to _EPS, so (n-2)/denom overflows to +inf for larger
152
+ # n. That is intentional: sqrt(inf)=inf and _t_cdf(inf)=1 give the exact
153
+ # p=0. Suppress the expected overflow rather than raise the floor — a bigger
154
+ # floor would, for dof=1 (heavy Cauchy tail), turn that exact 0 into a
155
+ # spurious ~1e-7.
156
+ with np.errstate(over="ignore", divide="ignore"):
157
+ t = np.abs(rr) * np.sqrt((n - 2) / denom)
158
+ p = np.asarray(2.0 * (1.0 - _t_cdf(t, n - 2)), dtype=float)
159
+ np.fill_diagonal(p, 1.0)
160
+
161
+ return {"r": r, "p": p, "N": n, "method": method}
162
+
163
+
164
+ def partial_correlation(
165
+ columns: list[NDArray[np.float64]] | NDArray[np.float64],
166
+ ) -> dict[str, Any]:
167
+ """Partial correlation of every pair, controlling for ALL other columns.
168
+
169
+ Computed from the precision matrix: ``r_ij·rest = -P_ij / sqrt(P_ii·P_jj)``
170
+ with ``P = inv(corrcoef)`` (pseudo-inverse for near-singular inputs).
171
+ """
172
+ data = _as_matrix(columns)
173
+ data = data[np.all(np.isfinite(data), axis=1)]
174
+ n, k = data.shape
175
+ if k < 3:
176
+ raise ValueError("partial correlation needs at least 3 columns (a control variable)")
177
+ if n < k + 2:
178
+ raise ValueError(f"partial correlation needs at least {k + 2} complete rows")
179
+ r = np.corrcoef(data, rowvar=False)
180
+ prec = np.asarray(np.linalg.pinv(r), dtype=float)
181
+ d = np.sqrt(np.maximum(np.outer(np.diag(prec), np.diag(prec)), _EPS))
182
+ partial = np.asarray(-prec / d, dtype=float)
183
+ np.fill_diagonal(partial, 1.0)
184
+ return {"r": partial, "N": n, "controlled": k - 2}
185
+
186
+
187
+ def _subset_criterion(
188
+ x: NDArray[np.float64], y: NDArray[np.float64], idx: list[int], criterion: str
189
+ ) -> float:
190
+ """AIC/BIC of the OLS model using predictor columns ``idx`` (RSS form).
191
+
192
+ AIC = n·ln(SSE/n) + 2p, BIC = n·ln(SSE/n) + p·ln(n), p = len(idx) + 1
193
+ (intercept). Constant-only model when ``idx`` is empty. +inf for a
194
+ singular subset so the search simply never selects it.
195
+ """
196
+ n = y.size
197
+ if idx:
198
+ try:
199
+ fit = multiple_regression(x[:, idx], y)
200
+ except ValueError:
201
+ return float("inf")
202
+ sse = float(np.sum(np.asarray(fit["residuals"]) ** 2))
203
+ else:
204
+ sse = float(np.sum((y - np.mean(y)) ** 2))
205
+ sse = max(sse, _EPS)
206
+ p = len(idx) + 1
207
+ penalty = 2.0 * p if criterion == "aic" else p * math.log(n)
208
+ return n * math.log(sse / n) + penalty
209
+
210
+
211
+ def stepwise_regression(
212
+ predictors: list[NDArray[np.float64]] | NDArray[np.float64],
213
+ y: NDArray[np.float64],
214
+ *,
215
+ criterion: str = "aic",
216
+ direction: str = "forward",
217
+ ) -> dict[str, Any]:
218
+ """Stepwise predictor selection over :func:`multiple_regression`.
219
+
220
+ ``direction='forward'`` starts from the intercept-only model and adds the
221
+ predictor that most improves the criterion; ``'backward'`` starts from the
222
+ full model and drops the worst; ``'both'`` is forward with a drop pass
223
+ after every addition. Stops when no single move improves the criterion.
224
+ Returns the selected column indices (original order), the search history,
225
+ and the refitted final model (empty selection = intercept-only, model None).
226
+ """
227
+ if criterion not in ("aic", "bic"):
228
+ raise ValueError(f'criterion must be "aic" or "bic", got "{criterion}"')
229
+ if direction not in ("forward", "backward", "both"):
230
+ raise ValueError(f'direction must be forward/backward/both, got "{direction}"')
231
+ xmat = _as_matrix(predictors)
232
+ yv = np.asarray(y, dtype=float).ravel()
233
+ if yv.size != xmat.shape[0]:
234
+ raise ValueError(f"y length {yv.size} != predictor length {xmat.shape[0]}")
235
+ keep = np.isfinite(yv) & np.all(np.isfinite(xmat), axis=1)
236
+ xmat, yv = xmat[keep], yv[keep]
237
+ k = xmat.shape[1]
238
+
239
+ selected = list(range(k)) if direction == "backward" else []
240
+ current = _subset_criterion(xmat, yv, selected, criterion)
241
+ history: list[dict[str, Any]] = [
242
+ {"action": "start", "index": None, "criterion": current}
243
+ ]
244
+
245
+ def best_add() -> tuple[int, float] | None:
246
+ cands = [
247
+ (j, _subset_criterion(xmat, yv, sorted([*selected, j]), criterion))
248
+ for j in range(k)
249
+ if j not in selected
250
+ ]
251
+ if not cands:
252
+ return None
253
+ j, c = min(cands, key=lambda t: t[1])
254
+ return (j, c) if c < current else None
255
+
256
+ def best_drop() -> tuple[int, float] | None:
257
+ cands = [
258
+ (j, _subset_criterion(xmat, yv, [i for i in selected if i != j], criterion))
259
+ for j in selected
260
+ ]
261
+ if not cands:
262
+ return None
263
+ j, c = min(cands, key=lambda t: t[1])
264
+ return (j, c) if c < current else None
265
+
266
+ for _ in range(4 * k + 4): # generous bound; each move strictly improves
267
+ move = best_drop() if direction == "backward" else best_add()
268
+ action = "drop" if direction == "backward" else "add"
269
+ if move is None and direction == "both":
270
+ move, action = best_drop(), "drop"
271
+ if move is None:
272
+ break
273
+ j, current = move
274
+ if action == "add":
275
+ selected = sorted([*selected, j])
276
+ else:
277
+ selected = [i for i in selected if i != j]
278
+ history.append({"action": action, "index": j, "criterion": current})
279
+
280
+ model = multiple_regression(xmat[:, selected], yv) if selected else None
281
+ return {
282
+ "selected": selected,
283
+ "criterion": criterion,
284
+ "criterion_value": current,
285
+ "direction": direction,
286
+ "history": history,
287
+ "model": model,
288
+ "n_candidates": k,
289
+ }
@@ -0,0 +1,157 @@
1
+ """ROC curves and AUC (pure numpy, no external dependencies).
2
+
3
+ GAP_PLAN #30 — Receiver Operating Characteristic analysis, optimal threshold
4
+ selection via Youden index, and Area Under the Curve via trapezoidal rule.
5
+ All computed directly from numpy; equivalent to sklearn.metrics but
6
+ license-agnostic (public domain).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from numpy.typing import NDArray
15
+
16
+ __all__ = ["roc_curve", "auc", "youden_optimal_threshold"]
17
+
18
+ _EPS = float(np.finfo(float).eps)
19
+
20
+
21
+ def roc_curve(
22
+ y_true: NDArray[np.float64],
23
+ y_score: NDArray[np.float64],
24
+ ) -> dict[str, Any]:
25
+ """ROC curve: false-positive rate vs true-positive rate at all thresholds.
26
+
27
+ ``y_true`` is binary (0/1). ``y_score`` is a continuous prediction score
28
+ (e.g., predicted probability). Returns x (FPR), y (TPR), threshold values,
29
+ and the AUC. Rows with any non-finite values are dropped (listwise
30
+ deletion).
31
+
32
+ Reference: Standard ROC curve definition, trapezoidal AUC.
33
+ """
34
+ yt = np.asarray(y_true, dtype=float).ravel()
35
+ ys = np.asarray(y_score, dtype=float).ravel()
36
+
37
+ if yt.size != ys.size:
38
+ raise ValueError(f"y_true length {yt.size} != y_score length {ys.size}")
39
+
40
+ # Listwise NaN deletion
41
+ keep = np.isfinite(yt) & np.isfinite(ys)
42
+ yt, ys = yt[keep], ys[keep]
43
+ n = yt.size
44
+
45
+ if n < 2:
46
+ raise ValueError("need at least 2 complete rows")
47
+
48
+ # Check binary
49
+ unique_y = np.unique(yt)
50
+ if not (np.array_equal(unique_y, [0.0, 1.0]) or np.array_equal(unique_y, [0.0]) or
51
+ np.array_equal(unique_y, [1.0])):
52
+ raise ValueError("y_true must be binary (0/1)")
53
+
54
+ n_pos = float(np.sum(yt))
55
+ n_neg = float(n - n_pos)
56
+
57
+ if n_pos < 1 or n_neg < 1:
58
+ raise ValueError("need at least one positive and one negative example")
59
+
60
+ # Sort by score, descending (process thresholds from high to low)
61
+ order = np.argsort(-ys)
62
+ yt_sorted = yt[order]
63
+
64
+ # Cumulative true positives and false positives
65
+ tp = np.cumsum(yt_sorted)
66
+ fp = np.cumsum(1.0 - yt_sorted)
67
+
68
+ # Add origin (0, 0)
69
+ tp = np.concatenate([[0.0], tp])
70
+ fp = np.concatenate([[0.0], fp])
71
+
72
+ # Normalize to rates
73
+ tpr = tp / max(n_pos, _EPS)
74
+ fpr = fp / max(n_neg, _EPS)
75
+
76
+ # Threshold values (take from sorted scores, with +inf for the first point)
77
+ thresholds = np.concatenate([[np.inf], ys[order]])
78
+
79
+ # AUC via trapezoidal rule
80
+ auc_val = float(np.trapezoid(tpr, fpr))
81
+
82
+ return {
83
+ "fpr": fpr,
84
+ "tpr": tpr,
85
+ "thresholds": thresholds,
86
+ "auc": auc_val,
87
+ "N": n,
88
+ "nPositive": int(n_pos),
89
+ "nNegative": int(n_neg),
90
+ }
91
+
92
+
93
+ def auc(
94
+ fpr: NDArray[np.float64],
95
+ tpr: NDArray[np.float64],
96
+ ) -> float:
97
+ """Area under the ROC curve (trapezoidal rule).
98
+
99
+ ``fpr`` and ``tpr`` are typically the outputs from ``roc_curve()``.
100
+ Equivalent to Mann-Whitney U statistic (probability that a randomly
101
+ chosen positive scores higher than a randomly chosen negative).
102
+
103
+ Reference: trapezoidal integration.
104
+ """
105
+ fpr_v = np.asarray(fpr, dtype=float).ravel()
106
+ tpr_v = np.asarray(tpr, dtype=float).ravel()
107
+
108
+ if fpr_v.size != tpr_v.size:
109
+ raise ValueError(f"fpr length {fpr_v.size} != tpr length {tpr_v.size}")
110
+
111
+ if fpr_v.size < 2:
112
+ raise ValueError("need at least 2 points to compute AUC")
113
+
114
+ return float(np.trapezoid(tpr_v, fpr_v))
115
+
116
+
117
+ def youden_optimal_threshold(
118
+ fpr: NDArray[np.float64],
119
+ tpr: NDArray[np.float64],
120
+ thresholds: NDArray[np.float64],
121
+ ) -> dict[str, Any]:
122
+ """Youden J statistic to find the optimal classification threshold.
123
+
124
+ Youden J = TPR - FPR (maximize true positive detection while minimizing
125
+ false positives). Returns the optimal threshold, the J value, and the
126
+ corresponding FPR/TPR.
127
+
128
+ Reference: Youden index (Youden, W.J. 1950).
129
+ """
130
+ fpr_v = np.asarray(fpr, dtype=float).ravel()
131
+ tpr_v = np.asarray(tpr, dtype=float).ravel()
132
+ thresh_v = np.asarray(thresholds, dtype=float).ravel()
133
+
134
+ if not (fpr_v.size == tpr_v.size == thresh_v.size):
135
+ raise ValueError("fpr, tpr, and thresholds must have the same length")
136
+
137
+ if fpr_v.size < 2:
138
+ raise ValueError("need at least 2 points")
139
+
140
+ # Compute Youden J
141
+ j = tpr_v - fpr_v
142
+
143
+ # Best threshold: maximum J (ties broken by smallest threshold, i.e., closest to 0)
144
+ j_max = np.max(j)
145
+ candidates = np.where(np.isclose(j, j_max, atol=_EPS * 10))[0]
146
+ best_idx = candidates[np.argmin(thresh_v[candidates])]
147
+
148
+ optimal_threshold = float(thresh_v[best_idx])
149
+ optimal_fpr = float(fpr_v[best_idx])
150
+ optimal_tpr = float(tpr_v[best_idx])
151
+
152
+ return {
153
+ "optimalThreshold": optimal_threshold,
154
+ "youdenJ": float(j_max),
155
+ "fpr": optimal_fpr,
156
+ "tpr": optimal_tpr,
157
+ }