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 ADDED
@@ -0,0 +1,3 @@
1
+ """pkft: Gaussian mixture peak finding for 1D data with uncertainties, served over FastAPI."""
2
+
3
+ __version__ = "1.0.0"
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