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