pkft 0.1.0a1__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.
Potentially problematic release.
This version of pkft might be problematic. Click here for more details.
- pkft/__init__.py +3 -0
- pkft/api.py +114 -0
- pkft/curves.py +56 -0
- pkft/data/__init__.py +0 -0
- pkft/data/demo-trayler-2020.csv +49 -0
- pkft/mixture.py +194 -0
- pkft/models.py +62 -0
- pkft/static/app.css +338 -0
- pkft/static/app.js +492 -0
- pkft/static/index.html +94 -0
- pkft/static/pkft-logo.svg +7 -0
- pkft-0.1.0a1.dist-info/METADATA +132 -0
- pkft-0.1.0a1.dist-info/RECORD +17 -0
- pkft-0.1.0a1.dist-info/WHEEL +5 -0
- pkft-0.1.0a1.dist-info/entry_points.txt +2 -0
- pkft-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- pkft-0.1.0a1.dist-info/top_level.txt +1 -0
pkft/__init__.py
ADDED
pkft/api.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""FastAPI tier. Browsers and agents read the same endpoints."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, HTTPException
|
|
6
|
+
from fastapi.responses import FileResponse, PlainTextResponse
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
|
|
9
|
+
from pkft import curves as curve_lib
|
|
10
|
+
from pkft import mixture as mix
|
|
11
|
+
from pkft.models import BicEntry, FitRequest, FitResponse, Peak, PeakCurve
|
|
12
|
+
|
|
13
|
+
app = FastAPI(title="pkft", version="1.0.0")
|
|
14
|
+
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.get("/health")
|
|
18
|
+
def health() -> dict:
|
|
19
|
+
return {"status": "ok"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.get("/demo")
|
|
23
|
+
def demo() -> FileResponse:
|
|
24
|
+
path = Path(__file__).parent / "data" / "demo-trayler-2020.csv"
|
|
25
|
+
return FileResponse(path, media_type="text/csv", filename="demo-trayler-2020.csv")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fit_response(request: FitRequest) -> FitResponse:
|
|
29
|
+
values = [o.value for o in request.observations]
|
|
30
|
+
# Input errors reach the fit in one-sigma units.
|
|
31
|
+
errors = [o.standard_error / request.input_sigma for o in request.observations]
|
|
32
|
+
try:
|
|
33
|
+
result = mix.fit_mixture(values, errors, peak_count=request.peak_count)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
36
|
+
|
|
37
|
+
z = mix.Z_VALUES[request.confidence]
|
|
38
|
+
peaks: list[Peak] = []
|
|
39
|
+
curve_objs: list[PeakCurve] = []
|
|
40
|
+
for i, (mean, standard_error, proportion, proportion_error) in enumerate(
|
|
41
|
+
zip(result["means"], result["mean_standard_errors"], result["proportions"], result["proportion_standard_errors"]),
|
|
42
|
+
start=1,
|
|
43
|
+
):
|
|
44
|
+
uncertainty = float(z * float(standard_error))
|
|
45
|
+
legend = (
|
|
46
|
+
f"{i},{mix.format_significant_digits(float(mean), request.significant_digits)},"
|
|
47
|
+
f"{mix.format_significant_digits(uncertainty, request.significant_digits)}"
|
|
48
|
+
)
|
|
49
|
+
peaks.append(
|
|
50
|
+
Peak(
|
|
51
|
+
peak=i,
|
|
52
|
+
value=float(mean),
|
|
53
|
+
standard_error=float(standard_error),
|
|
54
|
+
uncertainty=uncertainty,
|
|
55
|
+
proportion=float(proportion),
|
|
56
|
+
proportion_standard_error=float(proportion_error),
|
|
57
|
+
legend=legend,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
grid, curve = curve_lib.peak_curve(float(mean), max(float(standard_error), 1e-12), float(proportion), request.sigma_range)
|
|
61
|
+
curve_objs.append(PeakCurve(peak=i, x_values=[float(v) for v in grid], y_values=[float(v) for v in curve]))
|
|
62
|
+
|
|
63
|
+
table = (
|
|
64
|
+
[BicEntry(peak_count=r["peak_count"], bic=r["bic"], log_likelihood=r["log_likelihood"]) for r in result["bic_table"]]
|
|
65
|
+
if result["bic_table"] is not None
|
|
66
|
+
else None
|
|
67
|
+
)
|
|
68
|
+
return FitResponse(
|
|
69
|
+
peak_count=result["peak_count"],
|
|
70
|
+
bic=result["bic"],
|
|
71
|
+
log_likelihood=result["log_likelihood"],
|
|
72
|
+
confidence=request.confidence,
|
|
73
|
+
method=result["method"],
|
|
74
|
+
peaks=peaks,
|
|
75
|
+
curves=curve_objs,
|
|
76
|
+
bic_table=table,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.post("/fit", response_model=FitResponse)
|
|
81
|
+
def fit(request: FitRequest) -> FitResponse:
|
|
82
|
+
return _fit_response(request)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.post("/validate", response_model=FitRequest)
|
|
86
|
+
def validate(request: FitRequest) -> FitRequest:
|
|
87
|
+
return request
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.get("/peaks.csv")
|
|
91
|
+
def peaks_csv_placeholder() -> PlainTextResponse:
|
|
92
|
+
# Real CSV download is client-side from the last /fit response;
|
|
93
|
+
# this route exists so a stale bookmark explains itself.
|
|
94
|
+
return PlainTextResponse("Run POST /fit first; the UI offers Download Peaks (csv).\n", media_type="text/plain")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.get("/")
|
|
98
|
+
def index() -> FileResponse:
|
|
99
|
+
return FileResponse(Path(__file__).parent / "static" / "index.html")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.get("/mixture")
|
|
103
|
+
def mixture_page() -> FileResponse:
|
|
104
|
+
return FileResponse(Path(__file__).parent / "static" / "index.html")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
import uvicorn
|
|
109
|
+
|
|
110
|
+
uvicorn.run("pkft.api:app", host="127.0.0.1", port=8000, reload=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
pkft/curves.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Density-curve helpers mirroring the R ``p.data`` / ``p.pks`` overlays.
|
|
2
|
+
|
|
3
|
+
All curves are plain Gaussians evaluated in numpy so the FastAPI tier and the
|
|
4
|
+
vanilla-JS frontend (which reimplements ``normal_pdf`` in JS) agree. Counts
|
|
5
|
+
follow the R convention: per-observation PDFs are divided by ``n`` so the
|
|
6
|
+
summed PDF integrates to ~1.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normal_pdf(x: np.ndarray, mean: float, standard_deviation: float) -> np.ndarray:
|
|
15
|
+
x = np.asarray(x, dtype=float)
|
|
16
|
+
if standard_deviation <= 0:
|
|
17
|
+
raise ValueError("standard deviation must be > 0")
|
|
18
|
+
return np.exp(-0.5 * ((x - mean) / standard_deviation) ** 2) / (standard_deviation * math.sqrt(2.0 * math.pi))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def grid_for(values: np.ndarray, errors: np.ndarray, sigma_range: float, n: int = 400) -> np.ndarray:
|
|
22
|
+
lo = float(np.min(values) - sigma_range * np.mean(errors))
|
|
23
|
+
hi = float(np.max(values) + sigma_range * np.mean(errors))
|
|
24
|
+
if not math.isfinite(lo) or not math.isfinite(hi) or lo == hi:
|
|
25
|
+
lo, hi = float(np.min(values)) - 1.0, float(np.max(values)) + 1.0
|
|
26
|
+
return np.linspace(lo, hi, n)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sumpdf(x: np.ndarray, values: np.ndarray, errors: np.ndarray) -> np.ndarray:
|
|
30
|
+
"""Mean of per-observation Gaussians (R ``d.s`` / ``sumd``)."""
|
|
31
|
+
x = np.asarray(x, dtype=float)
|
|
32
|
+
total = np.zeros_like(x, dtype=float)
|
|
33
|
+
for v, e in zip(values, errors):
|
|
34
|
+
total += normal_pdf(x, float(v), float(e))
|
|
35
|
+
return total / max(len(values), 1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def normal_curve(x: np.ndarray, values: np.ndarray, errors: np.ndarray) -> np.ndarray:
|
|
39
|
+
"""Single Gaussian at the sample mean/sd (R ``d.n`` overlay)."""
|
|
40
|
+
if len(values) < 2:
|
|
41
|
+
return np.zeros_like(np.asarray(x, dtype=float))
|
|
42
|
+
m = float(np.mean(values))
|
|
43
|
+
sd = float(np.std(values, ddof=1))
|
|
44
|
+
if sd <= 0:
|
|
45
|
+
return np.zeros_like(np.asarray(x, dtype=float))
|
|
46
|
+
return normal_pdf(x, m, sd)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def peak_curve(mean: float, standard_error: float, proportion: float, sigma_range: float) -> tuple[np.ndarray, np.ndarray]:
|
|
50
|
+
"""Weighted component curve over :math:`\\mu \\pm \\mathrm{sigrange} \\cdot \\mathrm{se}` (R ``d.norm``)."""
|
|
51
|
+
step = max(standard_error / 10.0, 1e-12)
|
|
52
|
+
lo, hi = mean - sigma_range * standard_error, mean + sigma_range * standard_error
|
|
53
|
+
if lo == hi:
|
|
54
|
+
lo, hi = mean - 1.0, mean + 1.0
|
|
55
|
+
x = np.arange(lo, hi + step / 2.0, step)
|
|
56
|
+
return x, normal_pdf(x, mean, max(standard_error, 1e-12)) * proportion
|
pkft/data/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
sample,value,standard_error
|
|
2
|
+
CV-13,17.634,0.016
|
|
3
|
+
CV-13,17.629,0.025
|
|
4
|
+
CV-13,17.603,0.029
|
|
5
|
+
CV-13,17.599,0.019
|
|
6
|
+
CV-13,17.592,0.020
|
|
7
|
+
1CO,17.119,0.113
|
|
8
|
+
1CO,17.119,0.113
|
|
9
|
+
1CO,17.180,0.133
|
|
10
|
+
1CO,17.240,0.103
|
|
11
|
+
1CO,17.351,0.104
|
|
12
|
+
1CO,17.401,0.123
|
|
13
|
+
1CO,17.431,0.113
|
|
14
|
+
1CO,17.492,0.123
|
|
15
|
+
1CO,17.613,0.133
|
|
16
|
+
KARG-15-08,17.118,0.155
|
|
17
|
+
KARG-15-08,17.040,0.022
|
|
18
|
+
KARG-15-08,17.032,0.039
|
|
19
|
+
KARG-15-08,17.024,0.224
|
|
20
|
+
KARG-15-08,17.020,0.015
|
|
21
|
+
KARG-15-08,17.009,0.031
|
|
22
|
+
KARG-15-08,16.989,0.089
|
|
23
|
+
KARG-15-08,16.972,0.099
|
|
24
|
+
KARG-15-08,16.969,0.041
|
|
25
|
+
KARG-15-08,16.942,0.041
|
|
26
|
+
KARG-15-08,16.929,0.041
|
|
27
|
+
KARG-15-08,16.922,0.087
|
|
28
|
+
KARG-15-09,17.028,0.032
|
|
29
|
+
KARG-15-09,16.997,0.023
|
|
30
|
+
KARG-15-01,16.854,0.012
|
|
31
|
+
KARG-15-01,16.850,0.014
|
|
32
|
+
KARG-15-01,16.849,0.016
|
|
33
|
+
KARG-15-01,16.846,0.021
|
|
34
|
+
KARG-15-01,16.836,0.015
|
|
35
|
+
Toba-Blanca,16.883,0.018
|
|
36
|
+
Toba-Blanca,16.859,0.038
|
|
37
|
+
Toba-Blanca,16.823,0.030
|
|
38
|
+
CV-10,16.877,0.029
|
|
39
|
+
CV-10,16.833,0.025
|
|
40
|
+
CV-10,16.760,0.034
|
|
41
|
+
CV-10,16.751,0.057
|
|
42
|
+
1CO3,16.397,0.088
|
|
43
|
+
1CO3,16.468,0.098
|
|
44
|
+
1CO3,16.601,0.086
|
|
45
|
+
1CO3,16.644,0.081
|
|
46
|
+
1CO3,16.656,0.083
|
|
47
|
+
1CO3,16.667,0.113
|
|
48
|
+
1CO3,16.678,0.079
|
|
49
|
+
1CO3,16.685,0.082
|
pkft/mixture.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Uncertainty-aware discrete mixture (Galbraith & Laslett 1993, IsoplotR ``peakfit`` spirit).
|
|
2
|
+
|
|
3
|
+
Each observation :math:`x_i` carries a known analytical variance :math:`s_i^2`.
|
|
4
|
+
Component :math:`j` is a discrete age :math:`\\mu_j` with mixing proportion :math:`\\pi_j`;
|
|
5
|
+
the log-likelihood is :math:`\\ell = \\sum_i \\log \\sum_j \\pi_j \\, N(x_i \\mid \\mu_j, s_i^2)`.
|
|
6
|
+
There is no per-component overdispersion term, so -- unlike a standard GMM --
|
|
7
|
+
the model cannot collapse to zero variance and needs only numpy.
|
|
8
|
+
|
|
9
|
+
``peak_count=0`` means automatic selection over 1..min(5, n) by BIC with
|
|
10
|
+
:math:`p = 2k - 1` free parameters (:math:`k` means + :math:`k-1` proportions), matching the
|
|
11
|
+
IsoplotR ``k='auto'`` convention (maximum 5). Results are not bit-identical
|
|
12
|
+
to IsoplotR (different optimiser/initialisation) but maximise the same
|
|
13
|
+
likelihood.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
Z_VALUES = {
|
|
21
|
+
"90%": 1.6448536269514729,
|
|
22
|
+
"95%": 1.959963984540054,
|
|
23
|
+
"98%": 2.32634787404084,
|
|
24
|
+
"99%": 2.5758293035489004,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
AUTO_PEAK_MAX = 5
|
|
28
|
+
PEAK_HARD_MAX = 25
|
|
29
|
+
N_INIT = 10
|
|
30
|
+
MAX_ITER = 500
|
|
31
|
+
TOL = 1e-8
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _norm_pdf(x: np.ndarray, mean: float, variance: np.ndarray) -> np.ndarray:
|
|
35
|
+
return np.exp(-0.5 * ((x - mean) ** 2) / variance) / np.sqrt(2.0 * math.pi * variance)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fit_fixed_count(
|
|
39
|
+
values: np.ndarray,
|
|
40
|
+
errors: np.ndarray,
|
|
41
|
+
peak_count: int,
|
|
42
|
+
seed: int = 0,
|
|
43
|
+
n_init: int = N_INIT,
|
|
44
|
+
) -> dict:
|
|
45
|
+
count = values.shape[0]
|
|
46
|
+
if peak_count < 1:
|
|
47
|
+
raise ValueError("peak_count must be >= 1")
|
|
48
|
+
if peak_count > count:
|
|
49
|
+
raise ValueError(f"peak_count={peak_count} exceeds observation count n={count}")
|
|
50
|
+
if np.any(errors <= 0):
|
|
51
|
+
raise ValueError("all standard errors must be > 0")
|
|
52
|
+
|
|
53
|
+
variances = errors**2
|
|
54
|
+
rng = np.random.default_rng(seed)
|
|
55
|
+
sort_order = np.argsort(values)
|
|
56
|
+
sorted_values = values[sort_order]
|
|
57
|
+
|
|
58
|
+
best_log_likelihood = -math.inf
|
|
59
|
+
best: dict | None = None
|
|
60
|
+
|
|
61
|
+
for init in range(n_init):
|
|
62
|
+
if init == 0:
|
|
63
|
+
# Quantile spread: deterministic, reproducible start.
|
|
64
|
+
positions = np.linspace(0, count - 1, peak_count).round().astype(int)
|
|
65
|
+
means = sorted_values[positions].astype(float) + rng.normal(0, 1e-9, size=peak_count)
|
|
66
|
+
else:
|
|
67
|
+
choice = rng.choice(count, size=peak_count, replace=False)
|
|
68
|
+
means = values[choice].astype(float) + rng.normal(0, np.mean(errors) * 0.05, size=peak_count)
|
|
69
|
+
proportions = np.full(peak_count, 1.0 / peak_count)
|
|
70
|
+
previous_log_likelihood = -math.inf
|
|
71
|
+
responsibilities = np.full((count, peak_count), 1.0 / peak_count)
|
|
72
|
+
for _ in range(MAX_ITER):
|
|
73
|
+
# E-step
|
|
74
|
+
component_likelihoods = np.empty((count, peak_count))
|
|
75
|
+
for j in range(peak_count):
|
|
76
|
+
component_likelihoods[:, j] = proportions[j] * _norm_pdf(values, means[j], variances)
|
|
77
|
+
row_totals = component_likelihoods.sum(axis=1, keepdims=True)
|
|
78
|
+
row_totals = np.maximum(row_totals, 1e-300)
|
|
79
|
+
responsibilities = component_likelihoods / row_totals
|
|
80
|
+
log_likelihood = float(np.sum(np.log(row_totals)))
|
|
81
|
+
# M-step
|
|
82
|
+
component_counts = responsibilities.sum(axis=0) # (k,)
|
|
83
|
+
# Guard against empty components: reseed to a random point.
|
|
84
|
+
for j in range(peak_count):
|
|
85
|
+
if component_counts[j] < 1e-12:
|
|
86
|
+
means[j] = float(rng.choice(values))
|
|
87
|
+
component_counts[j] = 1e-12
|
|
88
|
+
proportions = component_counts / count
|
|
89
|
+
weights = responsibilities / variances[:, None] # (n, k)
|
|
90
|
+
means = (weights * values[:, None]).sum(axis=0) / weights.sum(axis=0)
|
|
91
|
+
if abs(log_likelihood - previous_log_likelihood) < TOL * max(1.0, abs(log_likelihood)):
|
|
92
|
+
previous_log_likelihood = log_likelihood
|
|
93
|
+
break
|
|
94
|
+
previous_log_likelihood = log_likelihood
|
|
95
|
+
# Final likelihood at converged parameters.
|
|
96
|
+
component_likelihoods = np.empty((count, peak_count))
|
|
97
|
+
for j in range(peak_count):
|
|
98
|
+
component_likelihoods[:, j] = proportions[j] * _norm_pdf(values, means[j], variances)
|
|
99
|
+
log_likelihood = float(np.sum(np.log(np.maximum(component_likelihoods.sum(axis=1), 1e-300))))
|
|
100
|
+
if log_likelihood > best_log_likelihood:
|
|
101
|
+
best_log_likelihood = log_likelihood
|
|
102
|
+
best = {
|
|
103
|
+
"means": means.copy(),
|
|
104
|
+
"proportions": proportions.copy(),
|
|
105
|
+
"responsibilities": responsibilities.copy(),
|
|
106
|
+
"log_likelihood": log_likelihood,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
assert best is not None
|
|
110
|
+
# Standard errors: means from weighted precision, proportions from multinomial.
|
|
111
|
+
weights = best["responsibilities"] / variances[:, None]
|
|
112
|
+
mean_standard_errors = 1.0 / np.sqrt(np.maximum(weights.sum(axis=0), 1e-300))
|
|
113
|
+
proportion_standard_errors = np.sqrt(np.maximum(best["proportions"] * (1.0 - best["proportions"]) / count, 0.0))
|
|
114
|
+
# Sort by ascending mean for stable peak numbering.
|
|
115
|
+
sort_order = np.argsort(best["means"])
|
|
116
|
+
return {
|
|
117
|
+
"means": best["means"][sort_order],
|
|
118
|
+
"proportions": best["proportions"][sort_order],
|
|
119
|
+
"mean_standard_errors": mean_standard_errors[sort_order],
|
|
120
|
+
"proportion_standard_errors": proportion_standard_errors[sort_order],
|
|
121
|
+
"log_likelihood": best["log_likelihood"],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def bic(log_likelihood: float, peak_count: int, count: int) -> float:
|
|
126
|
+
"""Bayes information criterion; :math:`p = 2k - 1` free parameters."""
|
|
127
|
+
params = 2 * peak_count - 1
|
|
128
|
+
return -2.0 * log_likelihood + params * math.log(count)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fit_mixture(
|
|
132
|
+
values: list[float] | np.ndarray,
|
|
133
|
+
errors: list[float] | np.ndarray,
|
|
134
|
+
peak_count: int = 0,
|
|
135
|
+
seed: int = 0,
|
|
136
|
+
) -> dict:
|
|
137
|
+
"""Fit 1..k discrete components. ``peak_count=0`` auto-selects by BIC over 1..min(5, n)."""
|
|
138
|
+
values_array = np.asarray(values, dtype=float)
|
|
139
|
+
error_array = np.asarray(errors, dtype=float)
|
|
140
|
+
count = values_array.shape[0]
|
|
141
|
+
if count == 0:
|
|
142
|
+
raise ValueError("no observations")
|
|
143
|
+
if np.any(~np.isfinite(values_array)) or np.any(~np.isfinite(error_array)):
|
|
144
|
+
raise ValueError("values and errors must be finite")
|
|
145
|
+
if np.any(error_array <= 0):
|
|
146
|
+
raise ValueError("all standard errors must be > 0")
|
|
147
|
+
if peak_count < 0 or peak_count > PEAK_HARD_MAX:
|
|
148
|
+
raise ValueError(f"peak_count must be in 0..{PEAK_HARD_MAX}")
|
|
149
|
+
if peak_count > count:
|
|
150
|
+
raise ValueError(f"peak_count={peak_count} exceeds observation count n={count}")
|
|
151
|
+
|
|
152
|
+
if peak_count == 0:
|
|
153
|
+
candidates = list(range(1, min(AUTO_PEAK_MAX, count) + 1))
|
|
154
|
+
table = []
|
|
155
|
+
fits = {}
|
|
156
|
+
for candidate in candidates:
|
|
157
|
+
fit = _fit_fixed_count(values_array, error_array, candidate, seed=seed)
|
|
158
|
+
score = bic(fit["log_likelihood"], candidate, count)
|
|
159
|
+
table.append({"peak_count": candidate, "bic": score, "log_likelihood": fit["log_likelihood"]})
|
|
160
|
+
fits[candidate] = fit
|
|
161
|
+
best_count = min(table, key=lambda r: r["bic"])["peak_count"]
|
|
162
|
+
out = fits[best_count]
|
|
163
|
+
return {
|
|
164
|
+
"peak_count": best_count,
|
|
165
|
+
"bic": next(r["bic"] for r in table if r["peak_count"] == best_count),
|
|
166
|
+
"log_likelihood": out["log_likelihood"],
|
|
167
|
+
"means": out["means"],
|
|
168
|
+
"proportions": out["proportions"],
|
|
169
|
+
"mean_standard_errors": out["mean_standard_errors"],
|
|
170
|
+
"proportion_standard_errors": out["proportion_standard_errors"],
|
|
171
|
+
"bic_table": table,
|
|
172
|
+
"method": "discrete-mixture-em(auto-bic)",
|
|
173
|
+
}
|
|
174
|
+
fit = _fit_fixed_count(values_array, error_array, peak_count, seed=seed)
|
|
175
|
+
return {
|
|
176
|
+
"peak_count": peak_count,
|
|
177
|
+
"bic": bic(fit["log_likelihood"], peak_count, count),
|
|
178
|
+
"log_likelihood": fit["log_likelihood"],
|
|
179
|
+
"means": fit["means"],
|
|
180
|
+
"proportions": fit["proportions"],
|
|
181
|
+
"mean_standard_errors": fit["mean_standard_errors"],
|
|
182
|
+
"proportion_standard_errors": fit["proportion_standard_errors"],
|
|
183
|
+
"bic_table": None,
|
|
184
|
+
"method": "discrete-mixture-em",
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def format_significant_digits(value: float, significant_digits: int) -> str:
|
|
189
|
+
"""Format to ``significant_digits`` significant digits (R ``sigdig`` convention)."""
|
|
190
|
+
if not math.isfinite(value):
|
|
191
|
+
return str(value)
|
|
192
|
+
if value == 0:
|
|
193
|
+
return "0"
|
|
194
|
+
return f"{value:.{significant_digits}g}"
|
pkft/models.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Pydantic models for peak-fit requests and responses."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
ConfidenceLevel = Literal["90%", "95%", "98%", "99%"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Observation(BaseModel):
|
|
11
|
+
sample: str | None = None
|
|
12
|
+
value: float = Field(description="Measured value in the data's own units")
|
|
13
|
+
standard_error: float = Field(gt=0, description="Analytical standard error (one sigma) in the same units as value")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FitRequest(BaseModel):
|
|
17
|
+
observations: list[Observation] = Field(min_length=1)
|
|
18
|
+
peak_count: int = Field(default=0, ge=0, le=25, description="Number of peaks; 0 asks for automatic selection by BIC (compares 1..5 peaks)")
|
|
19
|
+
significant_digits: int = Field(default=2, ge=1, le=6)
|
|
20
|
+
confidence: ConfidenceLevel = "95%"
|
|
21
|
+
input_sigma: int = Field(
|
|
22
|
+
default=1,
|
|
23
|
+
ge=1,
|
|
24
|
+
le=3,
|
|
25
|
+
description="Sigma level the input errors are quoted at (1, 2, or 3); each error is divided by this to reach one-sigma errors",
|
|
26
|
+
)
|
|
27
|
+
sigma_range: float = Field(default=3.0, ge=0.5, le=10.0, description="Half-width of returned peak curves in standard errors")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Peak(BaseModel):
|
|
31
|
+
peak: int
|
|
32
|
+
value: float
|
|
33
|
+
standard_error: float = Field(description="One-sigma standard error of the peak value")
|
|
34
|
+
uncertainty: float = Field(
|
|
35
|
+
description="Half-width of the confidence interval at the requested confidence level (z times the one-sigma standard error)"
|
|
36
|
+
)
|
|
37
|
+
proportion: float = Field(ge=0, le=1)
|
|
38
|
+
proportion_standard_error: float = Field(ge=0)
|
|
39
|
+
legend: str = Field(description="CSV line 'peak,value,uncertainty' rounded to significant digits")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PeakCurve(BaseModel):
|
|
43
|
+
peak: int
|
|
44
|
+
x_values: list[float]
|
|
45
|
+
y_values: list[float]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BicEntry(BaseModel):
|
|
49
|
+
peak_count: int
|
|
50
|
+
bic: float
|
|
51
|
+
log_likelihood: float
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FitResponse(BaseModel):
|
|
55
|
+
peak_count: int
|
|
56
|
+
bic: float
|
|
57
|
+
log_likelihood: float
|
|
58
|
+
confidence: ConfidenceLevel
|
|
59
|
+
method: str
|
|
60
|
+
peaks: list[Peak]
|
|
61
|
+
curves: list[PeakCurve]
|
|
62
|
+
bic_table: list[BicEntry] | None = None
|