prophet-laplace 0.0.1__tar.gz

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.
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter Cotton
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: prophet-laplace
3
+ Version: 0.0.1
4
+ Summary: Prophet in a laplace sandwich: same API, calibrated densities
5
+ Author: Peter Cotton
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/microprediction/prophet-laplace
8
+ Project-URL: Repository, https://github.com/microprediction/prophet-laplace
9
+ Project-URL: Sandwich explainer, https://skaters.microprediction.org/sandwich.html
10
+ Keywords: prophet,forecasting,calibration,time-series,skaters,laplace
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: prophet>=1.1
15
+ Requires-Dist: skaters>=0.13.0
16
+ Requires-Dist: pandas
17
+ Dynamic: license-file
18
+
19
+ # prophet-laplace
20
+
21
+ *"He's not the messiah. He's a very naughty boy."* ([why](https://medium.com/geekculture/is-facebooks-prophet-the-time-series-messiah-or-just-a-very-naughty-boy-8b71b136bc8c)) *This package makes him useful anyway.*
22
+
23
+ Prophet in a laplace sandwich: same API, calibrated densities.
24
+
25
+ ```python
26
+ from prophet_laplace import SandwichedProphet
27
+
28
+ m = SandwichedProphet(k=30) # accepts Prophet's kwargs too
29
+ m.fit(df) # ds, y (+ regressors as usual)
30
+ fc = m.predict(future) # yhat / yhat_lower / yhat_upper,
31
+ # mapped back exactly
32
+ ```
33
+
34
+ ## What it does
35
+
36
+ `SandwichedProphet` fits Prophet in the z-coordinates of a
37
+ [skaters](https://github.com/microprediction/skaters) laplace forecaster
38
+ (the Rosenblatt transform of each observation under the predictive issued
39
+ for it) and maps every forecast back through the exact inverse. Prophet
40
+ keeps its calendar decomposition, holidays, and extra regressors; the
41
+ sandwich supplies the volatility clock, repeated-value handling, and
42
+ tails whose stated probabilities come true.
43
+
44
+ ## Why
45
+
46
+ Measured on 921 non-price FRED series under a pre-registered protocol
47
+ (statements, frozen universe, and results in the skaters repository):
48
+
49
+ | | median one-step LL vs laplace | family-weighted (120 families) |
50
+ |------------|------------------------------|-------------------------------|
51
+ | Prophet raw | -0.755 nats | -4.60 nats |
52
+ | Prophet sandwiched | **-0.020 nats** | **-0.025 nats** |
53
+
54
+ The sandwich closes 97% of Prophet's density gap without retraining
55
+ anything. `predictive(step, z_mu, z_sigma)` exposes the full y-space
56
+ density (logpdf, cdf, quantile) for scoring and risk use.
57
+
58
+ The same construction lifts other forecasters and detectors too:
59
+ [skaters.microprediction.org/sandwich.html](https://skaters.microprediction.org/sandwich.html).
60
+
61
+ ## Status
62
+
63
+ v0: future frames only, horizons past `k` reuse the k-step transport
64
+ (disclosed approximation). The goal is to demonstrate interoperability,
65
+ then propose the option upstream.
@@ -0,0 +1,47 @@
1
+ # prophet-laplace
2
+
3
+ *"He's not the messiah. He's a very naughty boy."* ([why](https://medium.com/geekculture/is-facebooks-prophet-the-time-series-messiah-or-just-a-very-naughty-boy-8b71b136bc8c)) *This package makes him useful anyway.*
4
+
5
+ Prophet in a laplace sandwich: same API, calibrated densities.
6
+
7
+ ```python
8
+ from prophet_laplace import SandwichedProphet
9
+
10
+ m = SandwichedProphet(k=30) # accepts Prophet's kwargs too
11
+ m.fit(df) # ds, y (+ regressors as usual)
12
+ fc = m.predict(future) # yhat / yhat_lower / yhat_upper,
13
+ # mapped back exactly
14
+ ```
15
+
16
+ ## What it does
17
+
18
+ `SandwichedProphet` fits Prophet in the z-coordinates of a
19
+ [skaters](https://github.com/microprediction/skaters) laplace forecaster
20
+ (the Rosenblatt transform of each observation under the predictive issued
21
+ for it) and maps every forecast back through the exact inverse. Prophet
22
+ keeps its calendar decomposition, holidays, and extra regressors; the
23
+ sandwich supplies the volatility clock, repeated-value handling, and
24
+ tails whose stated probabilities come true.
25
+
26
+ ## Why
27
+
28
+ Measured on 921 non-price FRED series under a pre-registered protocol
29
+ (statements, frozen universe, and results in the skaters repository):
30
+
31
+ | | median one-step LL vs laplace | family-weighted (120 families) |
32
+ |------------|------------------------------|-------------------------------|
33
+ | Prophet raw | -0.755 nats | -4.60 nats |
34
+ | Prophet sandwiched | **-0.020 nats** | **-0.025 nats** |
35
+
36
+ The sandwich closes 97% of Prophet's density gap without retraining
37
+ anything. `predictive(step, z_mu, z_sigma)` exposes the full y-space
38
+ density (logpdf, cdf, quantile) for scoring and risk use.
39
+
40
+ The same construction lifts other forecasters and detectors too:
41
+ [skaters.microprediction.org/sandwich.html](https://skaters.microprediction.org/sandwich.html).
42
+
43
+ ## Status
44
+
45
+ v0: future frames only, horizons past `k` reuse the k-step transport
46
+ (disclosed approximation). The goal is to demonstrate interoperability,
47
+ then propose the option upstream.
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "prophet-laplace"
3
+ version = "0.0.1"
4
+ description = "Prophet in a laplace sandwich: same API, calibrated densities"
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.9"
8
+ authors = [{name = "Peter Cotton"}]
9
+ keywords = ["prophet", "forecasting", "calibration", "time-series", "skaters", "laplace"]
10
+ dependencies = ["prophet>=1.1", "skaters>=0.13.0", "pandas"]
11
+
12
+ [project.urls]
13
+ Homepage = "https://github.com/microprediction/prophet-laplace"
14
+ Repository = "https://github.com/microprediction/prophet-laplace"
15
+ "Sandwich explainer" = "https://skaters.microprediction.org/sandwich.html"
16
+
17
+ [build-system]
18
+ requires = ["setuptools>=68"]
19
+ build-backend = "setuptools.build_meta"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """prophet-laplace: Prophet in a laplace sandwich.
2
+
3
+ Same Prophet API, calibrated densities. A SandwichedProphet fits Prophet
4
+ in the z-coordinates of a laplace forecaster (the Rosenblatt transform of
5
+ each observation under the predictive issued for it) and maps every
6
+ forecast back through the exact inverse. Prophet keeps its calendar
7
+ machinery, holidays, and extra regressors; the sandwich supplies the
8
+ volatility clock, the lattice handling, and tails that keep their stated
9
+ rates.
10
+
11
+ Measured on 921 non-price FRED series (pre-registered, see the skaters
12
+ repository): raw Prophet trails laplace by 0.76 nats median one-step
13
+ log-likelihood (family-weighted 4.6); sandwiched, the gap is 0.02.
14
+ """
15
+ from prophet_laplace.sandwich import SandwichedProphet
16
+
17
+ __all__ = ["SandwichedProphet"]
18
+ __version__ = "0.0.1"
@@ -0,0 +1,116 @@
1
+ """The sandwich: laplace transform in front, Prophet inside, exact inverse behind."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+
6
+ import pandas as pd
7
+
8
+ from skaters import laplace
9
+ from skaters.tails import _phi_inv
10
+
11
+ _EPS = 1e-12
12
+ _SQRT2 = math.sqrt(2.0)
13
+
14
+
15
+ def _phi(z: float) -> float:
16
+ return 0.5 * math.erfc(-z / _SQRT2)
17
+
18
+
19
+ class _Predictive:
20
+ """A y-space predictive assembled from Prophet's z-space Gaussian and
21
+ laplace's h-step transport, with exact change-of-variables accounting."""
22
+
23
+ def __init__(self, transport, z_mu: float, z_sigma: float):
24
+ self._t = transport
25
+ self._mu = z_mu
26
+ self._sd = max(z_sigma, 1e-9)
27
+
28
+ def quantile(self, p: float) -> float:
29
+ z = self._mu + self._sd * _phi_inv(min(max(p, _EPS), 1.0 - _EPS))
30
+ u = min(max(_phi(z), _EPS), 1.0 - _EPS)
31
+ return self._t.quantile(u)
32
+
33
+ def logpdf(self, y: float) -> float:
34
+ u = min(max(self._t.cdf(y), _EPS), 1.0 - _EPS)
35
+ z = _phi_inv(u)
36
+ r = (z - self._mu) / self._sd
37
+ log_fz = -0.5 * r * r - math.log(self._sd) - 0.5 * math.log(2 * math.pi)
38
+ log_phi = -0.5 * z * z - 0.5 * math.log(2 * math.pi)
39
+ return log_fz + self._t.logpdf(y) - log_phi
40
+
41
+ def cdf(self, y: float) -> float:
42
+ u = min(max(self._t.cdf(y), _EPS), 1.0 - _EPS)
43
+ z = _phi_inv(u)
44
+ return _phi((z - self._mu) / self._sd)
45
+
46
+
47
+ class SandwichedProphet:
48
+ """Drop-in Prophet with laplace-sandwiched coordinates.
49
+
50
+ Prophet keeps its API, calendar machinery, holidays, and extra
51
+ regressors; it just operates in the z-coordinates of a laplace
52
+ forecaster (the Rosenblatt transform of each observation under the
53
+ predictive issued for it), and every forecast maps back through the
54
+ exact inverse.
55
+
56
+ ``k`` is the forecast horizon in observations; ``predict`` supports
57
+ future frames up to k steps past the training data (rows beyond k
58
+ reuse the k-step transport, a disclosed approximation).
59
+ """
60
+
61
+ def __init__(self, k: int = 1, **prophet_kwargs):
62
+ from prophet import Prophet
63
+ self.k = int(k)
64
+ prophet_kwargs.setdefault("interval_width", 0.6827)
65
+ self._m = Prophet(**prophet_kwargs)
66
+ self._interval_z = 1.0 # 0.6827 central interval = +-1 sd
67
+ self._fitted = False
68
+ self._pending = None # k-step transport past training end
69
+
70
+ def add_regressor(self, name: str, **kw) -> "SandwichedProphet":
71
+ self._m.add_regressor(name, **kw)
72
+ return self
73
+
74
+ def fit(self, df: pd.DataFrame, **fit_kwargs) -> "SandwichedProphet":
75
+ f = laplace(self.k)
76
+ state = None
77
+ pend = None # dists issued for the arriving y
78
+ zs = []
79
+ for y in df["y"].astype(float).tolist():
80
+ if pend is None:
81
+ zs.append(0.0)
82
+ else:
83
+ u = min(max(pend[0].cdf(y), _EPS), 1.0 - _EPS)
84
+ zs.append(_phi_inv(u))
85
+ pend, state = f(y, state)
86
+ self._pending = pend # transport for steps 1..k ahead
87
+ zdf = df.copy()
88
+ zdf["y"] = zs
89
+ self._m.fit(zdf, **fit_kwargs)
90
+ self._last_ds = pd.to_datetime(df["ds"]).max()
91
+ self._fitted = True
92
+ return self
93
+
94
+ def _transport(self, step: int):
95
+ return self._pending[min(step, self.k) - 1]
96
+
97
+ def predictive(self, step: int, z_mu: float, z_sigma: float) -> _Predictive:
98
+ return _Predictive(self._transport(step), z_mu, z_sigma)
99
+
100
+ def predict(self, future: pd.DataFrame) -> pd.DataFrame:
101
+ assert self._fitted, "call fit first"
102
+ ds = pd.to_datetime(future["ds"])
103
+ assert (ds > self._last_ds).all(), (
104
+ "sandwich v0 maps future rows only; pass a frame strictly "
105
+ "after the training data")
106
+ fc = self._m.predict(future)
107
+ sds = ((fc["yhat_upper"] - fc["yhat_lower"]) / 2.0
108
+ / self._interval_z).clip(lower=1e-9)
109
+ out = fc.copy()
110
+ for i in range(len(fc)):
111
+ pred = self.predictive(i + 1, float(fc["yhat"].iloc[i]),
112
+ float(sds.iloc[i]))
113
+ out.loc[out.index[i], "yhat"] = pred.quantile(0.5)
114
+ out.loc[out.index[i], "yhat_lower"] = pred.quantile(0.5 - 0.6827 / 2)
115
+ out.loc[out.index[i], "yhat_upper"] = pred.quantile(0.5 + 0.6827 / 2)
116
+ return out
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: prophet-laplace
3
+ Version: 0.0.1
4
+ Summary: Prophet in a laplace sandwich: same API, calibrated densities
5
+ Author: Peter Cotton
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/microprediction/prophet-laplace
8
+ Project-URL: Repository, https://github.com/microprediction/prophet-laplace
9
+ Project-URL: Sandwich explainer, https://skaters.microprediction.org/sandwich.html
10
+ Keywords: prophet,forecasting,calibration,time-series,skaters,laplace
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: prophet>=1.1
15
+ Requires-Dist: skaters>=0.13.0
16
+ Requires-Dist: pandas
17
+ Dynamic: license-file
18
+
19
+ # prophet-laplace
20
+
21
+ *"He's not the messiah. He's a very naughty boy."* ([why](https://medium.com/geekculture/is-facebooks-prophet-the-time-series-messiah-or-just-a-very-naughty-boy-8b71b136bc8c)) *This package makes him useful anyway.*
22
+
23
+ Prophet in a laplace sandwich: same API, calibrated densities.
24
+
25
+ ```python
26
+ from prophet_laplace import SandwichedProphet
27
+
28
+ m = SandwichedProphet(k=30) # accepts Prophet's kwargs too
29
+ m.fit(df) # ds, y (+ regressors as usual)
30
+ fc = m.predict(future) # yhat / yhat_lower / yhat_upper,
31
+ # mapped back exactly
32
+ ```
33
+
34
+ ## What it does
35
+
36
+ `SandwichedProphet` fits Prophet in the z-coordinates of a
37
+ [skaters](https://github.com/microprediction/skaters) laplace forecaster
38
+ (the Rosenblatt transform of each observation under the predictive issued
39
+ for it) and maps every forecast back through the exact inverse. Prophet
40
+ keeps its calendar decomposition, holidays, and extra regressors; the
41
+ sandwich supplies the volatility clock, repeated-value handling, and
42
+ tails whose stated probabilities come true.
43
+
44
+ ## Why
45
+
46
+ Measured on 921 non-price FRED series under a pre-registered protocol
47
+ (statements, frozen universe, and results in the skaters repository):
48
+
49
+ | | median one-step LL vs laplace | family-weighted (120 families) |
50
+ |------------|------------------------------|-------------------------------|
51
+ | Prophet raw | -0.755 nats | -4.60 nats |
52
+ | Prophet sandwiched | **-0.020 nats** | **-0.025 nats** |
53
+
54
+ The sandwich closes 97% of Prophet's density gap without retraining
55
+ anything. `predictive(step, z_mu, z_sigma)` exposes the full y-space
56
+ density (logpdf, cdf, quantile) for scoring and risk use.
57
+
58
+ The same construction lifts other forecasters and detectors too:
59
+ [skaters.microprediction.org/sandwich.html](https://skaters.microprediction.org/sandwich.html).
60
+
61
+ ## Status
62
+
63
+ v0: future frames only, horizons past `k` reuse the k-step transport
64
+ (disclosed approximation). The goal is to demonstrate interoperability,
65
+ then propose the option upstream.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/prophet_laplace/__init__.py
5
+ src/prophet_laplace/sandwich.py
6
+ src/prophet_laplace.egg-info/PKG-INFO
7
+ src/prophet_laplace.egg-info/SOURCES.txt
8
+ src/prophet_laplace.egg-info/dependency_links.txt
9
+ src/prophet_laplace.egg-info/requires.txt
10
+ src/prophet_laplace.egg-info/top_level.txt
11
+ tests/test_sandwich.py
@@ -0,0 +1,3 @@
1
+ prophet>=1.1
2
+ skaters>=0.13.0
3
+ pandas
@@ -0,0 +1 @@
1
+ prophet_laplace
@@ -0,0 +1,50 @@
1
+ import math
2
+ import random
3
+
4
+ import pandas as pd
5
+ import pytest
6
+
7
+ from prophet_laplace import SandwichedProphet
8
+
9
+
10
+ def _df(n=400, seed=3):
11
+ rng = random.Random(seed)
12
+ ds = pd.date_range("2020-01-01", periods=n, freq="D")
13
+ lvl, ys = 0.0, []
14
+ for t in range(n):
15
+ vol = 2.0 if (t // 60) % 2 else 0.5
16
+ lvl += rng.gauss(0, vol)
17
+ ys.append(lvl + 3.0 * math.sin(2 * math.pi * t / 7))
18
+ return pd.DataFrame({"ds": ds, "y": ys})
19
+
20
+
21
+ def test_fit_predict_roundtrip():
22
+ df = _df()
23
+ m = SandwichedProphet(k=5).fit(df)
24
+ future = pd.DataFrame(
25
+ {"ds": pd.date_range(df["ds"].max() + pd.Timedelta(days=1),
26
+ periods=5, freq="D")})
27
+ fc = m.predict(future)
28
+ assert len(fc) == 5
29
+ assert (fc["yhat_lower"] <= fc["yhat"]).all()
30
+ assert (fc["yhat"] <= fc["yhat_upper"]).all()
31
+ assert fc["yhat"].abs().max() < 1e6
32
+
33
+
34
+ def test_predictive_density_is_wellformed():
35
+ df = _df()
36
+ m = SandwichedProphet(k=1).fit(df)
37
+ pred = m.predictive(1, 0.0, 1.0)
38
+ qs = [pred.quantile(p) for p in (0.01, 0.25, 0.5, 0.75, 0.99)]
39
+ assert all(b >= a for a, b in zip(qs, qs[1:]))
40
+ y0 = qs[2]
41
+ assert math.isfinite(pred.logpdf(y0))
42
+ assert 0.0 <= pred.cdf(y0) <= 1.0
43
+ assert abs(pred.cdf(qs[1]) - 0.25) < 0.05
44
+
45
+
46
+ def test_past_frames_rejected():
47
+ df = _df()
48
+ m = SandwichedProphet(k=1).fit(df)
49
+ with pytest.raises(AssertionError):
50
+ m.predict(df[["ds"]].tail(3))