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