analytic-prophet 0.1.0__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.
Files changed (80) hide show
  1. analytic_prophet-0.1.0/CONTRIBUTING.md +74 -0
  2. analytic_prophet-0.1.0/LICENSE +21 -0
  3. analytic_prophet-0.1.0/MANIFEST.in +13 -0
  4. analytic_prophet-0.1.0/PKG-INFO +421 -0
  5. analytic_prophet-0.1.0/README.md +365 -0
  6. analytic_prophet-0.1.0/SECURITY.md +33 -0
  7. analytic_prophet-0.1.0/analytic_prophet/__init__.py +50 -0
  8. analytic_prophet-0.1.0/analytic_prophet/build.py +286 -0
  9. analytic_prophet-0.1.0/analytic_prophet/constants.py +68 -0
  10. analytic_prophet-0.1.0/analytic_prophet/forecaster.py +2365 -0
  11. analytic_prophet-0.1.0/analytic_prophet/layout.py +159 -0
  12. analytic_prophet-0.1.0/analytic_prophet/make_holidays.py +166 -0
  13. analytic_prophet-0.1.0/analytic_prophet/models.py +82 -0
  14. analytic_prophet-0.1.0/analytic_prophet/optimize.cpp +1167 -0
  15. analytic_prophet-0.1.0/analytic_prophet/optimizer.py +194 -0
  16. analytic_prophet-0.1.0/analytic_prophet/seasonality.py +266 -0
  17. analytic_prophet-0.1.0/analytic_prophet/serialize.py +267 -0
  18. analytic_prophet-0.1.0/analytic_prophet/trend.py +180 -0
  19. analytic_prophet-0.1.0/analytic_prophet.egg-info/PKG-INFO +421 -0
  20. analytic_prophet-0.1.0/analytic_prophet.egg-info/SOURCES.txt +78 -0
  21. analytic_prophet-0.1.0/analytic_prophet.egg-info/dependency_links.txt +1 -0
  22. analytic_prophet-0.1.0/analytic_prophet.egg-info/requires.txt +22 -0
  23. analytic_prophet-0.1.0/analytic_prophet.egg-info/top_level.txt +1 -0
  24. analytic_prophet-0.1.0/pyproject.toml +189 -0
  25. analytic_prophet-0.1.0/setup.cfg +4 -0
  26. analytic_prophet-0.1.0/setup.py +119 -0
  27. analytic_prophet-0.1.0/tests/test_add_seasonality.py +304 -0
  28. analytic_prophet-0.1.0/tests/test_auto_seasonalities.py +250 -0
  29. analytic_prophet-0.1.0/tests/test_backend_parity.py +227 -0
  30. analytic_prophet-0.1.0/tests/test_build_on_demand.py +248 -0
  31. analytic_prophet-0.1.0/tests/test_cache_root.py +94 -0
  32. analytic_prophet-0.1.0/tests/test_changepoint_placement.py +212 -0
  33. analytic_prophet-0.1.0/tests/test_ci.py +259 -0
  34. analytic_prophet-0.1.0/tests/test_component_decomposition.py +181 -0
  35. analytic_prophet-0.1.0/tests/test_conditional_seasonalities.py +356 -0
  36. analytic_prophet-0.1.0/tests/test_constructor.py +240 -0
  37. analytic_prophet-0.1.0/tests/test_convergence_tolerances.py +296 -0
  38. analytic_prophet-0.1.0/tests/test_country_holidays.py +237 -0
  39. analytic_prophet-0.1.0/tests/test_cpp_binding.py +230 -0
  40. analytic_prophet-0.1.0/tests/test_cpp_input_validation.py +202 -0
  41. analytic_prophet-0.1.0/tests/test_cpp_optimizer_convergence.py +244 -0
  42. analytic_prophet-0.1.0/tests/test_evaluation_harness.py +217 -0
  43. analytic_prophet-0.1.0/tests/test_evaluation_report.py +129 -0
  44. analytic_prophet-0.1.0/tests/test_extra_regressors.py +326 -0
  45. analytic_prophet-0.1.0/tests/test_fit_backend.py +153 -0
  46. analytic_prophet-0.1.0/tests/test_flat_growth.py +265 -0
  47. analytic_prophet-0.1.0/tests/test_forecast_intervals.py +167 -0
  48. analytic_prophet-0.1.0/tests/test_gradient_numerical.py +57 -0
  49. analytic_prophet-0.1.0/tests/test_holidays.py +421 -0
  50. analytic_prophet-0.1.0/tests/test_input_validation.py +298 -0
  51. analytic_prophet-0.1.0/tests/test_logistic_growth.py +357 -0
  52. analytic_prophet-0.1.0/tests/test_multiplicative_mode.py +305 -0
  53. analytic_prophet-0.1.0/tests/test_packaging.py +132 -0
  54. analytic_prophet-0.1.0/tests/test_parity_surface.py +270 -0
  55. analytic_prophet-0.1.0/tests/test_prior_scales.py +299 -0
  56. analytic_prophet-0.1.0/tests/test_prophet_agreement.py +286 -0
  57. analytic_prophet-0.1.0/tests/test_prophet_naming.py +165 -0
  58. analytic_prophet-0.1.0/tests/test_prophet_parameters.py +265 -0
  59. analytic_prophet-0.1.0/tests/test_refit_contract.py +447 -0
  60. analytic_prophet-0.1.0/tests/test_regressor_predictor.py +309 -0
  61. analytic_prophet-0.1.0/tests/test_release_hygiene.py +218 -0
  62. analytic_prophet-0.1.0/tests/test_report_figures.py +140 -0
  63. analytic_prophet-0.1.0/tests/test_seasonality_registry.py +392 -0
  64. analytic_prophet-0.1.0/tests/test_serialize.py +334 -0
  65. analytic_prophet-0.1.0/tests/test_short_series.py +478 -0
  66. analytic_prophet-0.1.0/tests/test_showcase_figure.py +177 -0
  67. analytic_prophet-0.1.0/tests/test_sigma_obs_cpp.py +131 -0
  68. analytic_prophet-0.1.0/tests/test_sigma_obs_estimation.py +105 -0
  69. analytic_prophet-0.1.0/tests/test_sparsity_reporting.py +95 -0
  70. analytic_prophet-0.1.0/tests/test_tier0_gate.py +121 -0
  71. analytic_prophet-0.1.0/tests/test_tier1_recovery.py +224 -0
  72. analytic_prophet-0.1.0/tests/test_tier2_accuracy.py +277 -0
  73. analytic_prophet-0.1.0/tests/test_tier3_cost.py +153 -0
  74. analytic_prophet-0.1.0/tests/test_trend_denormalization.py +78 -0
  75. analytic_prophet-0.1.0/tests/test_trend_uncertainty.py +250 -0
  76. analytic_prophet-0.1.0/tests/test_uncertainty_vectorization.py +160 -0
  77. analytic_prophet-0.1.0/tests/test_vectorized_uncertainty.py +162 -0
  78. analytic_prophet-0.1.0/tests/test_wheels.py +272 -0
  79. analytic_prophet-0.1.0/tools/collect_licences.py +82 -0
  80. analytic_prophet-0.1.0/tools/fetch_headers.sh +47 -0
@@ -0,0 +1,74 @@
1
+ # Contributing
2
+
3
+ This project has a specific way of working. It is not written down anywhere else, so a
4
+ contributor has no way to infer it — which is what this file is for.
5
+
6
+ ## The loop
7
+
8
+ Every change in this repository has gone through the same sequence:
9
+
10
+ 1. **Read the issue.** Issues here carry the measurement and the reasoning, not just the
11
+ request. Several have been closed by arguing they were ill-posed; one was closed
12
+ because the concept it asked about ("the right number of changepoints") turned out not
13
+ to be well defined. Reading it is the first step, and disagreeing with it is allowed.
14
+ 2. **Branch.** One branch per issue, from `main`. Never stack a branch on another branch:
15
+ it has been done twice here, and both times the work merged into a sibling branch
16
+ instead of `main` and needed a second PR to rescue it.
17
+ 3. **Fix it.**
18
+ 4. **Run the whole suite** — `pytest` — and **run the benchmarks**. The suite does not run
19
+ the benchmark scripts, so an import error in `benchmark/` passes every test and fails
20
+ the moment somebody runs one. That has happened.
21
+ 5. **Open a pull request** describing what was measured, not only what was changed.
22
+ 6. **Do not merge your own PR.**
23
+
24
+ ## What a change is expected to carry
25
+
26
+ - **A test that fails before it and passes after.** Where the change is a claim about
27
+ behaviour, the test asserts the behaviour; where it is a claim about a number, the test
28
+ recomputes the number from committed data rather than restating it.
29
+ - **Mutation-checking for anything whose job is to catch drift.** A guard that cannot be
30
+ shown to fail is a guard nobody has tested. Break it deliberately, watch it fail, put it
31
+ back.
32
+ - **The reason, in the code.** Comments here say *why*, and `[fc]` marks a decision copied
33
+ from Prophet's `forecaster.py` — which is most of them, because the point of the project
34
+ is to reproduce that model exactly. If you diverge, say so and say why; `docs/deviations.md`
35
+ is where deliberate divergences are recorded, and there is a test that the list stays true.
36
+ - **Honest reporting.** If a benchmark got slower, the PR says so. If a number moved, every
37
+ document quoting it moves with it.
38
+
39
+ ## Running things
40
+
41
+ ```bash
42
+ pytest # the whole suite, no install needed
43
+ pytest --require-cpp # fail rather than skip if the C++ core cannot build
44
+ python benchmark/benchmark_fit_time.py
45
+ python evaluation/run.py # the claim-level study; minutes to hours
46
+ ```
47
+
48
+ The C++ core builds itself on first use and caches the result. `pytest` needs no install:
49
+ `pyproject.toml` puts the repo root, `benchmark/` and `evaluation/` on `pythonpath`.
50
+
51
+ Tests that need a toolchain **skip** rather than fail when it is absent, so a green run on
52
+ a machine without a compiler is green for less than it looks. CI passes `--require-cpp` and
53
+ `--require-prophet` to turn those skips into failures, and prints what was skipped and why.
54
+
55
+ ## Measurements
56
+
57
+ Numbers in this repository are reproducible or they are not claims. The evaluation suite
58
+ seeds both implementations — including Prophet's intervals, which come from numpy's global
59
+ generator — writes its results to `evaluation/results/`, and commits them, so re-running is
60
+ a reviewable diff rather than an act of faith. The committed results were produced with the
61
+ Prophet version pinned in `pyproject.toml`; changing it means regenerating them.
62
+
63
+ If you change something that moves a published number, regenerate the affected tier and
64
+ update every document that quotes it. There are tests that recompute the README's figures
65
+ from the committed results, so a stale number fails the suite rather than sitting there.
66
+
67
+ ## Scope
68
+
69
+ - **No MCMC**, and no plotting of the model. Both are stated non-goals, not oversights.
70
+ - **Prophet's names**, everywhere there is a counterpart. `docs/deviations.md` lists the two
71
+ that have none and why, and `tests/test_prophet_naming.py` fails if that stops being true.
72
+ - **Reject rather than ignore.** An argument this implementation cannot honour raises; it
73
+ does not quietly do nothing. That rule is why the constructor refuses `mcmc_samples` and
74
+ why `fit` refuses a backend's arguments under the other backend.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adly Zaroui
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ # What a source distribution carries beyond the package itself.
2
+ #
3
+ # `tools/` is the part that matters: fetch_headers.sh and collect_licences.py
4
+ # are how a wheel is built, and an sdist that cannot reproduce the wheel build
5
+ # is an sdist nobody can check (#97). Windows has no wheel by decision, so the
6
+ # sdist is the supported route there and needs them.
7
+ include LICENSE
8
+ include README.md
9
+ include CONTRIBUTING.md
10
+ include SECURITY.md
11
+ include tools/fetch_headers.sh
12
+ include tools/collect_licences.py
13
+ recursive-include analytic_prophet *.cpp
@@ -0,0 +1,421 @@
1
+ Metadata-Version: 2.4
2
+ Name: analytic-prophet
3
+ Version: 0.1.0
4
+ Summary: Facebook Prophet's fitting engine with a hand-derived analytic gradient in place of Stan's automatic differentiation
5
+ Author: Adly Zaroui
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Adly Zaroui
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Repository, https://github.com/adlyZaroui/analytic-prophet
28
+ Project-URL: Issues, https://github.com/adlyZaroui/analytic-prophet/issues
29
+ Keywords: forecasting,time-series,prophet,optimization
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Intended Audience :: Science/Research
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: C++
35
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
36
+ Requires-Python: >=3.9
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE
39
+ Requires-Dist: numpy>=1.17
40
+ Requires-Dist: pandas>=1.0
41
+ Requires-Dist: scipy>=1.9
42
+ Provides-Extra: holidays
43
+ Requires-Dist: holidays; extra == "holidays"
44
+ Provides-Extra: compare
45
+ Requires-Dist: prophet>=1.1.2; extra == "compare"
46
+ Provides-Extra: test
47
+ Requires-Dist: analytic-prophet[holidays]; extra == "test"
48
+ Requires-Dist: pytest>=7; extra == "test"
49
+ Requires-Dist: pybind11; extra == "test"
50
+ Requires-Dist: pyyaml; extra == "test"
51
+ Requires-Dist: tomli; python_version < "3.11" and extra == "test"
52
+ Requires-Dist: matplotlib; extra == "test"
53
+ Provides-Extra: dev
54
+ Requires-Dist: analytic-prophet[compare,test]; extra == "dev"
55
+ Dynamic: license-file
56
+
57
+ # analytic-prophet
58
+
59
+ [![tests](https://github.com/adlyZaroui/analytic-prophet/actions/workflows/tests.yml/badge.svg)](https://github.com/adlyZaroui/analytic-prophet/actions/workflows/tests.yml)
60
+
61
+ A reimplementation of [Facebook Prophet](https://github.com/facebook/prophet)'s fitting
62
+ engine that replaces Stan with a hand-derived, closed-form gradient and a small C++ core.
63
+
64
+ It fits the same model, and it fits it better: our optimum is ahead of Prophet's by its
65
+ own objective at every size measured, and on held-out M4 series our forecasts are more
66
+ accurate. Fitting is faster and uses less memory, which is what the analytic gradient was
67
+ for.
68
+
69
+ **Status: early development.** The model is feature-complete against Prophet's, but there
70
+ is no MCMC and no plotting, so this is not yet a drop-in replacement. See
71
+ [what this is not](#what-this-is-not).
72
+
73
+ ![three held-out forecasts: the largest advantage, the median, and one Prophet wins](evaluation/results/figures/showcase.png)
74
+
75
+ **What this shows, and what it does not.** Three M4 series, forecast past a cutoff
76
+ neither model saw. Each coloured line is continuous through the cutoff: to its left the
77
+ model's fit to data it was shown, to its right its forecast. The actual values over the
78
+ horizon are drawn in black, and the bands are the nominal 80% intervals.
79
+
80
+ They are **chosen by rule, not by eye.** Of Tier 2's 36 series, the ranking is taken over
81
+ the **11 where a Prophet-shaped model fits at all** — both implementations within 10%
82
+ sMAPE held out — because a panel where both miss badly shows the difficulty of the series
83
+ rather than the difference between two optimizers. Within those: the series where our
84
+ cross-validated RMSE beats Prophet's by the most, the one at the median of that ranking,
85
+ and the one where Prophet beats us by the most.
86
+
87
+ **The top panel is the mechanism; the bottom two are the typical case.** Prophet's
88
+ optimizer stops short on the non-differentiable objective, and that costs most where the
89
+ trend is doing the work — a regime change, as in the top panel, where the L1 kink is
90
+ load-bearing. Elsewhere both implementations fit nearly the same model and the two lines
91
+ sit on top of each other. Across all 36 series the median RMSE advantage is **0.45%**, and
92
+ past two years of history predictions differ by **0.17–0.59%** of the series scale. A
93
+ reader who runs this on their own data should expect the bottom two panels, not the top
94
+ one.
95
+
96
+ **Neither implementation's intervals are well calibrated.** On this corpus they contain
97
+ about a third of the held-out points they claim four fifths of — mean coverage **0.356**
98
+ for ours and **0.341** for Prophet's. That is a property of the model on long horizons, it
99
+ is shared, and it is larger than anything separating the two.
100
+
101
+ Regenerate it with `python evaluation/showcase.py`; the output is byte-identical because
102
+ both sides are seeded.
103
+
104
+ ```bash
105
+ pip install analytic-prophet # once published — see below
106
+ ```
107
+
108
+ The wheels carry the compiled core, so there is nothing to build: no compiler, no Eigen,
109
+ no LBFGSpp. Linux and macOS, Python 3.9–3.14 — the versions and platforms CI actually
110
+ runs the suite on. Windows is not built, because nothing here has ever been tested there;
111
+ it falls back to the source distribution, which does need a C++17 compiler.
112
+
113
+ > **Not on PyPI yet** ([#97](https://github.com/adlyZaroui/analytic-prophet/issues/97)).
114
+ > The wheels, their verification and the release workflow are in place and run from a
115
+ > tag; the publish step waits on a maintainer's approval and on the name being
116
+ > registered. Until then, clone:
117
+
118
+ ```bash
119
+ git clone https://github.com/adlyZaroui/analytic-prophet
120
+ cd analytic-prophet
121
+ brew install eigen lbfgspp # or equivalent; header-only, nothing is linked
122
+ pip install -e '.[dev]' # see the caveat below if you are on macOS
123
+ ```
124
+
125
+ > **The install is optional, and on macOS it can succeed without working.** `pytest` and
126
+ > everything in this repository run from a fresh clone with no install at all, because
127
+ > `pyproject.toml` puts the right directories on `pythonpath`. The editable install is
128
+ > only for importing `analytic_prophet` from somewhere else — and on macOS with Python
129
+ > 3.13+ it reports success and then does not import, because setuptools writes the
130
+ > editable `.pth` with `UF_HIDDEN` and 3.13 hardened `site` to skip hidden `.pth` files.
131
+ > `tests/test_packaging.py::test_an_editable_install_actually_imports` is what catches it.
132
+ > [More on it below](#building-and-testing).
133
+
134
+ ```python
135
+ import pandas as pd
136
+ from analytic_prophet import AnalyticProphet
137
+
138
+ df = pd.read_csv("tests/data/peyton_manning.csv") # columns: ds, y
139
+
140
+ model = AnalyticProphet(seasonality_mode="multiplicative")
141
+ model.fit(df) # the compiled core, built on first use
142
+ # model.fit(df, backend="python") # the readable reference path, no compiler
143
+
144
+ future = model.make_future_dataframe(periods=90)
145
+ forecast = model.predict(future)
146
+ forecast[["ds", "yhat", "yhat_lower", "yhat_upper"]].tail()
147
+ ```
148
+
149
+ ---
150
+
151
+ ## What it does
152
+
153
+ **No Stan.** Prophet ships a compiled Stan model and reaches it through `cmdstanpy`,
154
+ which spawns a subprocess for every fit. This carries neither. The model is Python, the
155
+ arithmetic is a small C++ extension compiled on demand from one source file, and nothing
156
+ is linked beyond two header-only libraries.
157
+
158
+ **No Stan toolchain to deploy.** `pip install` needs no cmdstan, no model compilation, no
159
+ subprocess at fit time. The C++ core is built on demand: the first `fit(df)` on a machine
160
+ compiles `optimize.cpp` — about ten seconds, and it says so rather than appearing to hang
161
+ — then caches the result and reuses it forever after
162
+ ([#108](https://github.com/adlyZaroui/analytic-prophet/issues/108)). The cache is keyed by
163
+ a digest of the source and the compile command, so editing `optimize.cpp` rebuilds and
164
+ nothing else does.
165
+
166
+ Everything this project caches hangs off **one root**, by the same rule on every
167
+ platform: `$ANALYTIC_PROPHET_CACHE`, else `$XDG_CACHE_HOME/analytic-prophet`, else
168
+ `~/.cache/analytic-prophet`. The compiled core goes in `build/` and the M4 corpus the
169
+ evaluation suite downloads goes in `m4/`, so one variable moves both and deleting the
170
+ root is how you start over ([#111](https://github.com/adlyZaroui/analytic-prophet/issues/111)).
171
+
172
+ Without a compiler the build raises, naming the one thing that is missing — and
173
+ `fit(df, backend="python")` needs no compiler at all. The tests that need the toolchain
174
+ *skip* rather than fail when it is absent.
175
+
176
+ **An analytic gradient instead of automatic differentiation.** This is the point of the
177
+ project. Reverse-mode autodiff tapes a forward pass and reverses over it; the model is
178
+ small and entirely explicit, so the gradient can be written down instead. Both
179
+ consequences are measured rather than assumed — fitting is **1.4–10× faster** than
180
+ Prophet and the fit's peak memory is **about a third** of Prophet's at T = 2905, with the
181
+ gap widening as the series grows, which is what a retained tape predicts. Predicting is
182
+ faster on both of the paths described below — **1.8×** on the approximate one and **2.6×**
183
+ on the exact one.
184
+ → [cost](evaluation/results/report.md#tier-3--what-it-costs)
185
+
186
+ **Prophet's non-differentiable objective, handled.** The Laplace prior on the changepoint
187
+ rates puts `Σ|δ|/τ` in the posterior, which is not differentiable at `δ = 0` — exactly
188
+ where the optimum sits, because that prior is what drives most rates to zero. Prophet's
189
+ own optimizer stops short there, and so did three others until the objective was
190
+ reformulated. Splitting `δ` into non-negative parts makes the problem smooth with simple
191
+ bounds, and the same solution.
192
+ → [the argument and the evidence](docs/non-smooth-objective.md)
193
+
194
+ **A better optimum, by Prophet's own objective.** Scored under Stan's `log_prob` on
195
+ identical changepoints, so only the optimizer differs:
196
+
197
+ | T | Prophet `lp__` | this implementation |
198
+ |---|---|---|
199
+ | 300 | 813.351 | **815.337** |
200
+ | 1000 | 2852.768 | **2855.528** |
201
+ | 2905 | 8004.798 | **8005.159** |
202
+
203
+ → [the correctness gate](evaluation/results/report.md#tier-0--are-the-two-fitting-the-same-model)
204
+
205
+ **Better forecasts, held out.** 36 M4 series, rolling-origin evaluation on cutoffs from
206
+ Prophet's own `generate_cutoffs` and scored by its own `performance_metrics`, so neither
207
+ the splits nor the definitions are ours:
208
+
209
+ | | median difference | p |
210
+ |---|---|---|
211
+ | MAE | −1.914 | 0.0063 |
212
+ | RMSE | −2.967 | 0.0183 |
213
+ | MAPE | −0.0007 | 0.0013 |
214
+ | coverage | **+0.0026** | 0.0025 |
215
+ | interval width | +1.350 | 0.470 |
216
+
217
+ More accurate points, and **higher** coverage at statistically indistinguishable width —
218
+ negative is better for the error rows, positive for coverage.
219
+ → [forecast accuracy](evaluation/results/report.md#tier-2--does-the-better-map-point-forecast-better)
220
+
221
+ **Two uncertainty samplers, and Prophet's default is the approximate one.** This is
222
+ worth knowing before comparing any interval or any prediction time.
223
+ `Prophet.predict(vectorized=True)` is its default, and it is **not** a faster form of
224
+ `vectorized=False` — it is a different computation. Prophet's own two paths disagree by
225
+ about **1.4%** on the interval bounds. This implementation offers both, under the same
226
+ argument and the same default:
227
+
228
+ ```python
229
+ forecast = model.predict(future) # approximate, as Prophet defaults to
230
+ forecast = model.predict(future, vectorized=False) # exact, and slower
231
+ ```
232
+
233
+ `yhat` is identical either way — only the interval is sampled. The approximation replaces
234
+ the Poisson process over the horizon with one coin per timestep and integrates the trend
235
+ by a double cumulative sum; the exact sampler places changepoints in continuous time and
236
+ evaluates the piecewise-linear trend from its definition. Under logistic growth the exact
237
+ sampler runs regardless, and `model.predicted_vectorized` records which one did.
238
+ → [all four paths timed](evaluation/results/report.md#tier-3--what-it-costs)
239
+
240
+ **Feature-complete against Prophet's model.** Linear, logistic and flat growth;
241
+ seasonality selected from the history by Prophet's own rule, with per-component Fourier
242
+ order, prior scale, mode and condition; holidays, country holidays and extra regressors;
243
+ additive and multiplicative modes throughout.
244
+
245
+ **A Prophet-compatible API.** The constructor takes Prophet's arguments, and the names
246
+ match — `changepoint_prior_scale`, `changepoints_t`, `params`, `make_all_seasonality_features`.
247
+ What is *not* implemented is **rejected rather than silently ignored**, so a ported script
248
+ fails where it is actually wrong instead of at the first `AttributeError`.
249
+
250
+ **Save and load**, `[fc]` Prophet's own API:
251
+
252
+ ```python
253
+ from analytic_prophet.serialize import model_to_json, model_from_json
254
+
255
+ with open("model.json", "w") as handle:
256
+ handle.write(model_to_json(model))
257
+ ```
258
+
259
+ A round-tripped model predicts bit-identically, and carries no handle to the compiled
260
+ extension — so a model fitted on one machine loads on one that has never built it.
261
+
262
+ **Refitting is allowed**, where `Prophet.fit` refuses a second call. A refit is
263
+ equivalent to a fresh instance carrying the same user configuration, fit on the new data.
264
+ That is a divergence, so it is a stated contract rather than an accident.
265
+ → [deviations](docs/deviations.md#refitting-is-allowed-and-a-refit-means-something-specific)
266
+
267
+ **Two backends that agree to 1.5e-8.** `fit(df)` runs the compiled core, which is the
268
+ deliverable, so a script ported from Prophet keeps its fit call and gets it.
269
+ `fit(df, backend="python")` runs the readable pure-Python reference. Both solve the same
270
+ reformulated problem and follow Prophet's algorithm rule — Newton below 100 observations,
271
+ L-BFGS at or above, one Newton retry when L-BFGS fails.
272
+
273
+ Arguments belonging to the backend you did not select are **rejected rather than
274
+ ignored**, so `fit(df, analytic=False)` says that `analytic` is the Python backend's
275
+ rather than quietly running the compiled one.
276
+
277
+ **Verified against Stan's own density.** The objective is checked to *be* Prophet's, not
278
+ to resemble it: `CmdStanModel.log_prob` evaluated at our parameters must differ from ours
279
+ by a constant, and it does to 1e-12.
280
+ → [verification](docs/model.md#verification-the-objective-is-stans-objective)
281
+
282
+ **A reproducible evaluation suite.** Four tiers — a correctness gate, parameter recovery
283
+ on synthetic data with known truth, held-out forecast accuracy, and cost — with committed
284
+ results and a generated report. One command regenerates everything.
285
+ → [evaluation/](evaluation/)
286
+
287
+ ---
288
+
289
+ ## What this is not
290
+
291
+ - **No MCMC.** MAP estimation only; `mcmc_samples > 0` is rejected rather than ignored.
292
+ - **No plotting.** No `plot` or `plot_components`.
293
+ - **Not a drop-in, and here is how far off.** Of Prophet's 40 public methods, 13 are the
294
+ same, 8 are module-level functions here rather than methods, 15 are absent on purpose
295
+ and 4 are gaps — enumerated member by member, with the attributes a fit sets, in
296
+ [how far from a drop-in](docs/deviations.md#how-far-from-a-drop-in-enumerated).
297
+ - **The intervals are not well calibrated — in either implementation.** On the M4 corpus
298
+ the nominal 80% interval contains about a third of the points it claims four fifths of
299
+ — mean coverage **0.341** for Prophet and **0.356** for this implementation. That is a property of the model on long
300
+ horizons and volatile series, it is shared, and it is larger than anything separating
301
+ the two. Nothing above should be read without it.
302
+
303
+ ---
304
+
305
+ ## Documentation
306
+
307
+ | | |
308
+ |---|---|
309
+ | [The model](docs/model.md) | what is fitted, term for term, and the proof that it is Stan's objective |
310
+ | [The non-smooth objective](docs/non-smooth-objective.md) | the central argument: where Prophet's optimizer stops short, and why |
311
+ | [Deviations from Prophet](docs/deviations.md) | deliberate divergences, and the gaps still open |
312
+ | [Evaluation report](evaluation/results/report.md) | every measured number, generated from committed results |
313
+ | [Benchmarks](benchmark/) | the fast micro-benchmarks, for running against a change |
314
+ | [Evaluation suite](evaluation/) | the claim-level study and its methodology |
315
+ | [Changelog](CHANGELOG.md) | what was wrong, and how it was found |
316
+
317
+ ---
318
+
319
+ ## Layout
320
+
321
+ ```
322
+ analytic_prophet/
323
+ __init__.py re-exports the package's surface
324
+ forecaster.py the model
325
+ constants.py the numbers the model is defined by
326
+ layout.py where each parameter sits in the flat vector
327
+ seasonality.py Fourier basis, registry, selection rule
328
+ make_holidays.py [fc] prophet/make_holidays.py, plus the design columns
329
+ trend.py the three growth modes and their derivatives
330
+ optimizer.py projected Newton, and the stopping tolerances
331
+ models.py [fc] prophet/models.py — the compiled backend's loader
332
+ build.py compiling optimize.cpp on demand, and caching it
333
+ serialize.py [fc] prophet/serialize.py — save and load
334
+ optimize.cpp that backend
335
+ .github/workflows/ CI: the suite on every push, the tiers on request
336
+ docs/ the model, the argument, the deviations
337
+ tests/ the suite, plus the Peyton Manning series under data/
338
+ benchmark/ fast micro-benchmarks, for running against a change
339
+ evaluation/ the claim-level study, and its generated report
340
+ ```
341
+
342
+ `forecaster.py`, `models.py` and `make_holidays.py` take Prophet's own names.
343
+ **The other four have no Prophet counterpart, which is the point:** Stan supplies the
344
+ parameter layout, the derivatives and the optimizer there. Writing them down is what this
345
+ project is, so they get files you can open.
346
+
347
+ The C++ source sits *inside* the package rather than beside it because it is the
348
+ implementation, not a build input to it — where Prophet hands the problem to Stan, this
349
+ hands it to a gradient written out by hand.
350
+
351
+ One rule the layout imposes, for anyone adding a test: **patch a name where it is looked
352
+ up, not where it is defined.** `analytic_prophet/__init__.py` says why.
353
+
354
+ ---
355
+
356
+ ## Building and testing
357
+
358
+ Requires a C++17 compiler and two header-only libraries:
359
+
360
+ ```bash
361
+ brew install eigen lbfgspp # or equivalent
362
+ pip install -e '.[dev]'
363
+ pytest # the whole suite, about three minutes
364
+ ```
365
+
366
+ `.[test]` is the same without `prophet`, which only the comparisons need. The count is
367
+ deliberately not written down here: CI reports it, and a number in prose goes stale
368
+ between the commit that adds tests and the one that remembers to update it.
369
+
370
+ `pytest` alone is enough — `pyproject.toml` puts the repo root and `benchmark/` on
371
+ `pythonpath` along with `evaluation/`, so a fresh clone runs the suite with **no install and no `PYTHONPATH`**.
372
+ `pip install -e .` is for importing the package from elsewhere; nothing in the repo
373
+ depends on it.
374
+
375
+ > **`pip install -e .` on macOS with Python 3.13+ can install successfully and still not
376
+ > import.** setuptools writes the editable `.pth` with macOS's `UF_HIDDEN` flag set, and
377
+ > Python 3.13 hardened `site.addpackage` to **skip hidden `.pth` files**. The install
378
+ > reports success, `pip show` is happy, the metadata resolves — and `import
379
+ > analytic_prophet` raises `ModuleNotFoundError` from any directory but the repo root.
380
+ > `chflags nohidden .venv/lib/python3.*/site-packages/__editable__*` clears it, though
381
+ > something re-applies the flag here, so the fix does not stick.
382
+ > `tests/test_packaging.py::test_an_editable_install_actually_imports` is what catches
383
+ > this: it skips when the package is not installed and fails with the diagnosis when it
384
+ > is installed and broken.
385
+
386
+ Nothing is linked: the extension needs Eigen and LBFGSpp headers only. The test suite
387
+ compiles `analytic_prophet/optimize.cpp` into a temporary directory on the fly, which is
388
+ why no binary is checked in. Tests that need the toolchain **skip** rather than fail when
389
+ it is absent.
390
+
391
+ `prophet` itself is deliberately not a dependency — every comparison against the original
392
+ needs it, and it pulls `cmdstanpy` plus a compiled Stan model. The agreement tests skip
393
+ without it and the benchmarks print an install hint, so `pip install prophet` is only
394
+ needed to run those. `holidays` is required for `add_country_holidays` and imported
395
+ lazily, so nothing else needs it.
396
+
397
+ ### Continuous integration
398
+
399
+ Two workflows, under [`.github/workflows/`](.github/workflows):
400
+
401
+ - **`tests.yml`**, on every push and pull request: the suite across Python 3.9–3.14,
402
+ which is what gives `requires-python = ">=3.9"` any basis — before it, the suite had
403
+ only ever run on one version. One further job installs `prophet` and runs the
404
+ comparisons against the original; it is the only one that pays for cmdstan.
405
+ - **`evaluation.yml`**, manual or monthly: `evaluation/run.py` and a regenerated report,
406
+ uploaded as an artifact rather than committed. Tier 0 is a gate and fails the job.
407
+ These tiers are deliberately not per-push — Tier 2 alone is about eleven minutes and
408
+ needs the network.
409
+
410
+ A skip is the right answer on a laptop without a compiler and the wrong one on a runner
411
+ that installed Eigen on purpose, where it would mean CI reported **green for a run that
412
+ never built the C++ core**. So the strictness is the caller's: `pytest --require-cpp` and
413
+ `--require-prophet` turn those skips into failures, and every CI job passes them. Every
414
+ run also prints what it skipped, grouped by reason, into the job summary — "green" has to
415
+ be readable.
416
+
417
+ ---
418
+
419
+ ## Licence
420
+
421
+ See [LICENSE](LICENSE).