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,196 @@
|
|
|
1
|
+
"""Distribution fitting with goodness-of-fit + t-test power / sample size.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #28 — new capability beyond MATLAB parity, scipy (BSD) only
|
|
4
|
+
(statsmodels not required: power uses the exact noncentral-t formulation).
|
|
5
|
+
Validated in tests against published values (Cohen's power tables / G*Power)
|
|
6
|
+
and closed-form MLE identities.
|
|
7
|
+
|
|
8
|
+
Conventions: positive-support families (lognormal / weibull / gamma /
|
|
9
|
+
exponential) are fitted with ``loc`` fixed at 0 — the 2-parameter forms
|
|
10
|
+
instrument-data practice (and Origin) use. KS p-values are flagged
|
|
11
|
+
approximate when parameters were estimated from the same sample
|
|
12
|
+
(the Lilliefors situation).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from numpy.typing import NDArray
|
|
22
|
+
from scipy import stats as sps
|
|
23
|
+
|
|
24
|
+
__all__ = ["fit_distribution", "fit_distributions", "required_n", "t_test_power"]
|
|
25
|
+
|
|
26
|
+
# family -> (scipy dist, fit kwargs, n free params, positive support required)
|
|
27
|
+
_FAMILIES: dict[str, tuple[Any, dict[str, float], int, bool]] = {
|
|
28
|
+
"normal": (sps.norm, {}, 2, False),
|
|
29
|
+
"lognormal": (sps.lognorm, {"floc": 0.0}, 2, True),
|
|
30
|
+
"weibull": (sps.weibull_min, {"floc": 0.0}, 2, True),
|
|
31
|
+
"gamma": (sps.gamma, {"floc": 0.0}, 2, True),
|
|
32
|
+
"exponential": (sps.expon, {"floc": 0.0}, 1, True),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_KINDS = ("one-sample", "paired", "two-sample")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fit_distribution(x: NDArray[np.float64], dist: str = "normal") -> dict[str, Any]:
|
|
39
|
+
"""MLE-fit one distribution family; return params + log-likelihood + AIC
|
|
40
|
+
+ a KS goodness-of-fit test against the fitted distribution.
|
|
41
|
+
|
|
42
|
+
``params`` holds scipy's (shape, loc, scale) plus friendly aliases
|
|
43
|
+
(normal: mu/sigma; lognormal: mu/sigma of ln x; weibull: shape/scale;
|
|
44
|
+
gamma: shape/scale; exponential: rate).
|
|
45
|
+
"""
|
|
46
|
+
if dist not in _FAMILIES:
|
|
47
|
+
raise ValueError(f"dist must be one of {sorted(_FAMILIES)}, got {dist!r}")
|
|
48
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
49
|
+
xv = xv[np.isfinite(xv)]
|
|
50
|
+
if xv.size < 5:
|
|
51
|
+
raise ValueError("fit_distribution needs at least 5 finite observations")
|
|
52
|
+
family, fit_kw, n_params, positive = _FAMILIES[dist]
|
|
53
|
+
if positive and np.any(xv <= 0):
|
|
54
|
+
raise ValueError(f"{dist} requires strictly positive data")
|
|
55
|
+
|
|
56
|
+
fitted = family.fit(xv, **fit_kw)
|
|
57
|
+
loglike = float(np.sum(family.logpdf(xv, *fitted)))
|
|
58
|
+
aic = 2.0 * n_params - 2.0 * loglike
|
|
59
|
+
frozen = family(*fitted)
|
|
60
|
+
ks = sps.kstest(xv, frozen.cdf)
|
|
61
|
+
|
|
62
|
+
shapes = [float(v) for v in fitted[:-2]]
|
|
63
|
+
loc, scale = float(fitted[-2]), float(fitted[-1])
|
|
64
|
+
params: dict[str, float] = {"loc": loc, "scale": scale}
|
|
65
|
+
if shapes:
|
|
66
|
+
params["shape"] = shapes[0]
|
|
67
|
+
if dist == "normal":
|
|
68
|
+
params.update(mu=loc, sigma=scale)
|
|
69
|
+
elif dist == "lognormal":
|
|
70
|
+
params.update(mu=math.log(scale), sigma=shapes[0])
|
|
71
|
+
elif dist == "exponential":
|
|
72
|
+
params.update(rate=1.0 / scale)
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"dist": dist,
|
|
76
|
+
"params": params,
|
|
77
|
+
"loglike": loglike,
|
|
78
|
+
"aic": aic,
|
|
79
|
+
"n_params": n_params,
|
|
80
|
+
"ks_d": float(ks.statistic),
|
|
81
|
+
"ks_p": float(ks.pvalue),
|
|
82
|
+
"ks_p_approximate": True, # params estimated from the same sample
|
|
83
|
+
"N": int(xv.size),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fit_distributions(
|
|
88
|
+
x: NDArray[np.float64], dists: list[str] | None = None
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""Fit several families and rank them by AIC (lowest first).
|
|
91
|
+
|
|
92
|
+
Families whose support the data violates (or that fail to converge) are
|
|
93
|
+
reported under ``skipped`` with the reason, never silently dropped.
|
|
94
|
+
"""
|
|
95
|
+
names = list(_FAMILIES) if dists is None else dists
|
|
96
|
+
fits: list[dict[str, Any]] = []
|
|
97
|
+
skipped: list[dict[str, str]] = []
|
|
98
|
+
for name in names:
|
|
99
|
+
try:
|
|
100
|
+
fits.append(fit_distribution(x, name))
|
|
101
|
+
except ValueError as exc:
|
|
102
|
+
skipped.append({"dist": name, "reason": str(exc)})
|
|
103
|
+
except Exception as exc: # noqa: BLE001 — scipy fit convergence failures
|
|
104
|
+
skipped.append({"dist": name, "reason": f"fit failed: {exc}"})
|
|
105
|
+
if not fits:
|
|
106
|
+
raise ValueError("no distribution family could be fitted")
|
|
107
|
+
fits.sort(key=lambda f: f["aic"])
|
|
108
|
+
return {"fits": fits, "best": fits[0]["dist"], "skipped": skipped}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _power_from_n(kind: str, d: float, n: int, alpha: float, tails: int) -> float:
|
|
112
|
+
"""Exact noncentral-t power for a t-test at the given per-group n."""
|
|
113
|
+
if kind == "two-sample":
|
|
114
|
+
df = 2 * n - 2
|
|
115
|
+
ncp = abs(d) * math.sqrt(n / 2.0)
|
|
116
|
+
else: # one-sample / paired
|
|
117
|
+
df = n - 1
|
|
118
|
+
ncp = abs(d) * math.sqrt(n)
|
|
119
|
+
if df < 1:
|
|
120
|
+
return 0.0
|
|
121
|
+
if tails == 2:
|
|
122
|
+
tc = float(sps.t.ppf(1.0 - alpha / 2.0, df))
|
|
123
|
+
return float(1.0 - sps.nct.cdf(tc, df, ncp) + sps.nct.cdf(-tc, df, ncp))
|
|
124
|
+
tc = float(sps.t.ppf(1.0 - alpha, df))
|
|
125
|
+
return float(1.0 - sps.nct.cdf(tc, df, ncp))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def t_test_power(
|
|
129
|
+
effect_size: float,
|
|
130
|
+
n: int,
|
|
131
|
+
*,
|
|
132
|
+
kind: str = "two-sample",
|
|
133
|
+
alpha: float = 0.05,
|
|
134
|
+
tails: int = 2,
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
"""Power of a t-test at Cohen's d = ``effect_size`` and per-group ``n``.
|
|
137
|
+
|
|
138
|
+
Exact noncentral-t computation (what G*Power does): ncp = d·sqrt(n/2)
|
|
139
|
+
with df = 2n-2 for two-sample; ncp = d·sqrt(n), df = n-1 for
|
|
140
|
+
one-sample/paired. Reference: Cohen, *Statistical Power Analysis*, ch. 2.
|
|
141
|
+
"""
|
|
142
|
+
if kind not in _KINDS:
|
|
143
|
+
raise ValueError(f"kind must be one of {_KINDS}, got {kind!r}")
|
|
144
|
+
if tails not in (1, 2):
|
|
145
|
+
raise ValueError("tails must be 1 or 2")
|
|
146
|
+
if n < 2:
|
|
147
|
+
raise ValueError("n must be >= 2")
|
|
148
|
+
if not 0 < alpha < 1:
|
|
149
|
+
raise ValueError("alpha must be in (0, 1)")
|
|
150
|
+
power = _power_from_n(kind, effect_size, n, alpha, tails)
|
|
151
|
+
return {
|
|
152
|
+
"power": power,
|
|
153
|
+
"effect_size": float(effect_size),
|
|
154
|
+
"n": int(n),
|
|
155
|
+
"kind": kind,
|
|
156
|
+
"alpha": alpha,
|
|
157
|
+
"tails": tails,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def required_n(
|
|
162
|
+
effect_size: float,
|
|
163
|
+
power: float = 0.8,
|
|
164
|
+
*,
|
|
165
|
+
kind: str = "two-sample",
|
|
166
|
+
alpha: float = 0.05,
|
|
167
|
+
tails: int = 2,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
"""Smallest per-group n reaching the target power (doubling + bisection)."""
|
|
170
|
+
if kind not in _KINDS:
|
|
171
|
+
raise ValueError(f"kind must be one of {_KINDS}, got {kind!r}")
|
|
172
|
+
if not 0 < power < 1:
|
|
173
|
+
raise ValueError("power must be in (0, 1)")
|
|
174
|
+
if effect_size == 0:
|
|
175
|
+
raise ValueError("effect_size must be nonzero")
|
|
176
|
+
lo, hi = 2, 4
|
|
177
|
+
while _power_from_n(kind, effect_size, hi, alpha, tails) < power:
|
|
178
|
+
hi *= 2
|
|
179
|
+
if hi > 2**22:
|
|
180
|
+
raise ValueError("required n exceeds 4e6 — effect size too small")
|
|
181
|
+
while lo < hi:
|
|
182
|
+
mid = (lo + hi) // 2
|
|
183
|
+
if _power_from_n(kind, effect_size, mid, alpha, tails) >= power:
|
|
184
|
+
hi = mid
|
|
185
|
+
else:
|
|
186
|
+
lo = mid + 1
|
|
187
|
+
achieved = _power_from_n(kind, effect_size, lo, alpha, tails)
|
|
188
|
+
return {
|
|
189
|
+
"n": int(lo),
|
|
190
|
+
"achieved_power": achieved,
|
|
191
|
+
"target_power": power,
|
|
192
|
+
"effect_size": float(effect_size),
|
|
193
|
+
"kind": kind,
|
|
194
|
+
"alpha": alpha,
|
|
195
|
+
"tails": tails,
|
|
196
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Generalized Linear Models: logistic and Poisson regression with inference.
|
|
2
|
+
|
|
3
|
+
GAP_PLAN #30 (stats extra) — GLM via statsmodels (BSD-3). Result-dict shapes
|
|
4
|
+
match ``stats_multivar.multiple_regression`` so downstream code (routes, report
|
|
5
|
+
sheets) treats linear/logistic/Poisson alike. Rows with any non-finite value
|
|
6
|
+
are dropped (listwise deletion). Requires ``pip install quantized[stats]``.
|
|
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__ = ["logistic_regression", "poisson_regression"]
|
|
17
|
+
|
|
18
|
+
_EPS = float(np.finfo(float).tiny)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _check_statsmodels() -> None:
|
|
22
|
+
"""Raise a clear error if statsmodels is not installed."""
|
|
23
|
+
try:
|
|
24
|
+
import statsmodels # noqa: F401
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
"GLM methods require statsmodels. Install with: pip install quantized[stats]"
|
|
28
|
+
) from exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _as_matrix(columns: list[NDArray[np.float64]] | NDArray[np.float64]) -> NDArray[np.float64]:
|
|
32
|
+
"""Stack same-length 1-D columns into an (n, k) matrix (validates lengths)."""
|
|
33
|
+
if isinstance(columns, np.ndarray) and columns.ndim == 2:
|
|
34
|
+
return np.asarray(columns, dtype=float)
|
|
35
|
+
cols = [np.asarray(c, dtype=float).ravel() for c in columns]
|
|
36
|
+
if len(cols) < 1:
|
|
37
|
+
raise ValueError("need at least one column")
|
|
38
|
+
n = cols[0].size
|
|
39
|
+
if any(c.size != n for c in cols):
|
|
40
|
+
raise ValueError("all columns must have the same length")
|
|
41
|
+
return np.column_stack(cols)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _binomial_deviance(y: NDArray[np.float64], mu: NDArray[np.float64]) -> float:
|
|
45
|
+
"""Bernoulli/binomial deviance: D = 2·Σ[y·log(y/μ) + (1-y)·log((1-y)/(1-μ))].
|
|
46
|
+
|
|
47
|
+
``statsmodels``' discrete-choice ``LogitResults`` does not expose a
|
|
48
|
+
``.deviance`` scalar (only per-observation ``resid_dev``), unlike the
|
|
49
|
+
``genmod.GLM`` family models — compute it directly from the standard
|
|
50
|
+
formula (matches ``sm.GLM(..., family=Binomial()).deviance`` exactly).
|
|
51
|
+
"""
|
|
52
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
53
|
+
t1 = np.where(y > 0, y * np.log(y / mu), 0.0)
|
|
54
|
+
t2 = np.where(y < 1, (1.0 - y) * np.log((1.0 - y) / (1.0 - mu)), 0.0)
|
|
55
|
+
return float(2.0 * np.sum(t1 + t2))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _poisson_deviance(y: NDArray[np.float64], mu: NDArray[np.float64]) -> float:
|
|
59
|
+
"""Poisson deviance: D = 2·Σ[y·log(y/μ) - (y - μ)].
|
|
60
|
+
|
|
61
|
+
``statsmodels``' discrete-choice ``PoissonResults`` does not expose a
|
|
62
|
+
``.deviance`` scalar — compute it directly (matches
|
|
63
|
+
``sm.GLM(..., family=Poisson()).deviance`` exactly).
|
|
64
|
+
"""
|
|
65
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
66
|
+
term = np.where(y > 0, y * np.log(y / mu), 0.0)
|
|
67
|
+
return float(2.0 * np.sum(term - (y - mu)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def logistic_regression(
|
|
71
|
+
predictors: list[NDArray[np.float64]] | NDArray[np.float64],
|
|
72
|
+
y: NDArray[np.float64],
|
|
73
|
+
*,
|
|
74
|
+
alpha: float = 0.05,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
"""Logistic regression ``logit(p) = b0 + b1·x1 + … + bk·xk`` with inference.
|
|
77
|
+
|
|
78
|
+
``y`` must be binary (0/1). ``predictors`` is a list of k same-length
|
|
79
|
+
columns (or an (n, k) matrix); the intercept is always included. Returns
|
|
80
|
+
coefficients (intercept first), standard errors, z-stats/p-values, 95%
|
|
81
|
+
confidence intervals, AIC, and deviance — same key vocabulary as
|
|
82
|
+
``multiple_regression``. Rows containing any non-finite value are dropped
|
|
83
|
+
(listwise deletion).
|
|
84
|
+
|
|
85
|
+
Reference: logistic GLM via statsmodels ``Logit(...).fit(disp=0)``
|
|
86
|
+
(Newton-Raphson IRLS).
|
|
87
|
+
"""
|
|
88
|
+
_check_statsmodels()
|
|
89
|
+
import statsmodels.api as sm
|
|
90
|
+
|
|
91
|
+
xmat0 = _as_matrix(predictors)
|
|
92
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
93
|
+
if yv.size != xmat0.shape[0]:
|
|
94
|
+
raise ValueError(f"y length {yv.size} != predictor length {xmat0.shape[0]}")
|
|
95
|
+
|
|
96
|
+
# Listwise NaN deletion
|
|
97
|
+
keep = np.isfinite(yv) & np.all(np.isfinite(xmat0), axis=1)
|
|
98
|
+
xmat0, yv = xmat0[keep], yv[keep]
|
|
99
|
+
n, k = xmat0.shape
|
|
100
|
+
|
|
101
|
+
if n < k + 2:
|
|
102
|
+
raise ValueError(f"need at least {k + 2} complete rows for {k} predictors")
|
|
103
|
+
|
|
104
|
+
# Check binary
|
|
105
|
+
unique_y = np.unique(yv)
|
|
106
|
+
is_binary = (
|
|
107
|
+
np.array_equal(unique_y, [0.0, 1.0])
|
|
108
|
+
or np.array_equal(unique_y, [0.0])
|
|
109
|
+
or np.array_equal(unique_y, [1.0])
|
|
110
|
+
)
|
|
111
|
+
if not is_binary:
|
|
112
|
+
raise ValueError("logistic regression requires binary y (0/1)")
|
|
113
|
+
|
|
114
|
+
# Add constant, fit
|
|
115
|
+
xmat = sm.add_constant(xmat0)
|
|
116
|
+
model = sm.Logit(yv, xmat)
|
|
117
|
+
result = model.fit(disp=0)
|
|
118
|
+
|
|
119
|
+
coeffs = np.asarray(result.params, dtype=float)
|
|
120
|
+
se = np.asarray(result.bse, dtype=float)
|
|
121
|
+
z_stats = np.asarray(result.tvalues, dtype=float)
|
|
122
|
+
p_values = np.asarray(result.pvalues, dtype=float)
|
|
123
|
+
|
|
124
|
+
# 95% CIs (z_crit ≈ 1.96 for alpha=0.05)
|
|
125
|
+
z_crit = 1.959964 # scipy.stats.norm.ppf(1 - alpha / 2)
|
|
126
|
+
ci_low = np.asarray(coeffs - z_crit * se, dtype=float)
|
|
127
|
+
ci_high = np.asarray(coeffs + z_crit * se, dtype=float)
|
|
128
|
+
|
|
129
|
+
# Predictions for pseudo-R²
|
|
130
|
+
y_pred = np.asarray(result.predict(xmat), dtype=float)
|
|
131
|
+
|
|
132
|
+
# McFadden's pseudo-R² = 1 - (LL_full / LL_null)
|
|
133
|
+
null_model = sm.Logit(yv, sm.add_constant(np.ones(n))).fit(disp=0)
|
|
134
|
+
ll_full = float(result.llf)
|
|
135
|
+
ll_null = float(null_model.llf)
|
|
136
|
+
# ll_null is a log-likelihood (log of a probability mass ≤ 1), so it is
|
|
137
|
+
# always ≤ 0 — guard the magnitude while preserving sign. `max(ll_null,
|
|
138
|
+
# _EPS)` (the previous form) picks the tiny *positive* _EPS whenever
|
|
139
|
+
# ll_null is negative (virtually always), which inverts the sign of the
|
|
140
|
+
# ratio and silently saturates pseudoR2 to 1.0 for every non-degenerate
|
|
141
|
+
# fit — verified against the McFadden pseudo-R² statsmodels itself
|
|
142
|
+
# reports (0.374 on the spector logistic reference case).
|
|
143
|
+
ll_null_safe = -max(abs(ll_null), _EPS)
|
|
144
|
+
pseudo_r2 = 1.0 - (ll_full / ll_null_safe)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"coeffs": coeffs, # intercept first
|
|
148
|
+
"se": se,
|
|
149
|
+
"zStats": z_stats,
|
|
150
|
+
"pValues": p_values,
|
|
151
|
+
"ciLow": ci_low,
|
|
152
|
+
"ciHigh": ci_high,
|
|
153
|
+
"pseudoR2": float(np.clip(pseudo_r2, 0.0, 1.0)),
|
|
154
|
+
"AIC": float(result.aic),
|
|
155
|
+
"deviance": _binomial_deviance(yv, y_pred),
|
|
156
|
+
"yPred": y_pred,
|
|
157
|
+
"N": n,
|
|
158
|
+
"alpha": alpha,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def poisson_regression(
|
|
163
|
+
predictors: list[NDArray[np.float64]] | NDArray[np.float64],
|
|
164
|
+
y: NDArray[np.float64],
|
|
165
|
+
*,
|
|
166
|
+
alpha: float = 0.05,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Poisson regression ``log(μ) = b0 + b1·x1 + … + bk·xk`` with inference.
|
|
169
|
+
|
|
170
|
+
``y`` must be non-negative integers (counts). ``predictors`` is a list of
|
|
171
|
+
k same-length columns (or an (n, k) matrix); the intercept is always
|
|
172
|
+
included. Returns coefficients (intercept first), standard errors,
|
|
173
|
+
z-stats/p-values, 95% confidence intervals, AIC, and deviance — same key
|
|
174
|
+
vocabulary as ``multiple_regression``. Rows containing any non-finite
|
|
175
|
+
value are dropped (listwise deletion).
|
|
176
|
+
|
|
177
|
+
Reference: Poisson GLM via statsmodels ``Poisson(...).fit(disp=0)``
|
|
178
|
+
(Fisher scoring / IRLS).
|
|
179
|
+
"""
|
|
180
|
+
_check_statsmodels()
|
|
181
|
+
import statsmodels.api as sm
|
|
182
|
+
|
|
183
|
+
xmat0 = _as_matrix(predictors)
|
|
184
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
185
|
+
if yv.size != xmat0.shape[0]:
|
|
186
|
+
raise ValueError(f"y length {yv.size} != predictor length {xmat0.shape[0]}")
|
|
187
|
+
|
|
188
|
+
# Listwise NaN deletion
|
|
189
|
+
keep = np.isfinite(yv) & np.all(np.isfinite(xmat0), axis=1)
|
|
190
|
+
xmat0, yv = xmat0[keep], yv[keep]
|
|
191
|
+
n, k = xmat0.shape
|
|
192
|
+
|
|
193
|
+
if n < k + 2:
|
|
194
|
+
raise ValueError(f"need at least {k + 2} complete rows for {k} predictors")
|
|
195
|
+
|
|
196
|
+
# Check non-negative counts
|
|
197
|
+
if np.any(yv < 0.0) or not np.allclose(yv, np.round(yv)):
|
|
198
|
+
raise ValueError("Poisson regression requires non-negative integer y")
|
|
199
|
+
|
|
200
|
+
# Add constant, fit
|
|
201
|
+
xmat = sm.add_constant(xmat0)
|
|
202
|
+
model = sm.Poisson(yv, xmat)
|
|
203
|
+
result = model.fit(disp=0)
|
|
204
|
+
|
|
205
|
+
coeffs = np.asarray(result.params, dtype=float)
|
|
206
|
+
se = np.asarray(result.bse, dtype=float)
|
|
207
|
+
z_stats = np.asarray(result.tvalues, dtype=float)
|
|
208
|
+
p_values = np.asarray(result.pvalues, dtype=float)
|
|
209
|
+
|
|
210
|
+
# 95% CIs
|
|
211
|
+
z_crit = 1.959964
|
|
212
|
+
ci_low = np.asarray(coeffs - z_crit * se, dtype=float)
|
|
213
|
+
ci_high = np.asarray(coeffs + z_crit * se, dtype=float)
|
|
214
|
+
|
|
215
|
+
# Predictions
|
|
216
|
+
y_pred = np.asarray(result.predict(xmat), dtype=float)
|
|
217
|
+
|
|
218
|
+
# McFadden's pseudo-R²
|
|
219
|
+
null_model = sm.Poisson(yv, sm.add_constant(np.ones(n))).fit(disp=0)
|
|
220
|
+
ll_full = float(result.llf)
|
|
221
|
+
ll_null = float(null_model.llf)
|
|
222
|
+
# ll_null is a log-likelihood (log of a probability mass ≤ 1), so it is
|
|
223
|
+
# always ≤ 0 — guard the magnitude while preserving sign. `max(ll_null,
|
|
224
|
+
# _EPS)` (the previous form) picks the tiny *positive* _EPS whenever
|
|
225
|
+
# ll_null is negative (virtually always), which inverts the sign of the
|
|
226
|
+
# ratio and silently saturates pseudoR2 to 1.0 for every non-degenerate
|
|
227
|
+
# fit — verified against the McFadden pseudo-R² statsmodels itself
|
|
228
|
+
# reports (0.374 on the spector logistic reference case).
|
|
229
|
+
ll_null_safe = -max(abs(ll_null), _EPS)
|
|
230
|
+
pseudo_r2 = 1.0 - (ll_full / ll_null_safe)
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
"coeffs": coeffs,
|
|
234
|
+
"se": se,
|
|
235
|
+
"zStats": z_stats,
|
|
236
|
+
"pValues": p_values,
|
|
237
|
+
"ciLow": ci_low,
|
|
238
|
+
"ciHigh": ci_high,
|
|
239
|
+
"pseudoR2": float(np.clip(pseudo_r2, 0.0, 1.0)),
|
|
240
|
+
"AIC": float(result.aic),
|
|
241
|
+
"deviance": _poisson_deviance(yv, y_pred),
|
|
242
|
+
"yPred": y_pred,
|
|
243
|
+
"N": n,
|
|
244
|
+
"alpha": alpha,
|
|
245
|
+
}
|