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.
- analytic_prophet-0.1.0/CONTRIBUTING.md +74 -0
- analytic_prophet-0.1.0/LICENSE +21 -0
- analytic_prophet-0.1.0/MANIFEST.in +13 -0
- analytic_prophet-0.1.0/PKG-INFO +421 -0
- analytic_prophet-0.1.0/README.md +365 -0
- analytic_prophet-0.1.0/SECURITY.md +33 -0
- analytic_prophet-0.1.0/analytic_prophet/__init__.py +50 -0
- analytic_prophet-0.1.0/analytic_prophet/build.py +286 -0
- analytic_prophet-0.1.0/analytic_prophet/constants.py +68 -0
- analytic_prophet-0.1.0/analytic_prophet/forecaster.py +2365 -0
- analytic_prophet-0.1.0/analytic_prophet/layout.py +159 -0
- analytic_prophet-0.1.0/analytic_prophet/make_holidays.py +166 -0
- analytic_prophet-0.1.0/analytic_prophet/models.py +82 -0
- analytic_prophet-0.1.0/analytic_prophet/optimize.cpp +1167 -0
- analytic_prophet-0.1.0/analytic_prophet/optimizer.py +194 -0
- analytic_prophet-0.1.0/analytic_prophet/seasonality.py +266 -0
- analytic_prophet-0.1.0/analytic_prophet/serialize.py +267 -0
- analytic_prophet-0.1.0/analytic_prophet/trend.py +180 -0
- analytic_prophet-0.1.0/analytic_prophet.egg-info/PKG-INFO +421 -0
- analytic_prophet-0.1.0/analytic_prophet.egg-info/SOURCES.txt +78 -0
- analytic_prophet-0.1.0/analytic_prophet.egg-info/dependency_links.txt +1 -0
- analytic_prophet-0.1.0/analytic_prophet.egg-info/requires.txt +22 -0
- analytic_prophet-0.1.0/analytic_prophet.egg-info/top_level.txt +1 -0
- analytic_prophet-0.1.0/pyproject.toml +189 -0
- analytic_prophet-0.1.0/setup.cfg +4 -0
- analytic_prophet-0.1.0/setup.py +119 -0
- analytic_prophet-0.1.0/tests/test_add_seasonality.py +304 -0
- analytic_prophet-0.1.0/tests/test_auto_seasonalities.py +250 -0
- analytic_prophet-0.1.0/tests/test_backend_parity.py +227 -0
- analytic_prophet-0.1.0/tests/test_build_on_demand.py +248 -0
- analytic_prophet-0.1.0/tests/test_cache_root.py +94 -0
- analytic_prophet-0.1.0/tests/test_changepoint_placement.py +212 -0
- analytic_prophet-0.1.0/tests/test_ci.py +259 -0
- analytic_prophet-0.1.0/tests/test_component_decomposition.py +181 -0
- analytic_prophet-0.1.0/tests/test_conditional_seasonalities.py +356 -0
- analytic_prophet-0.1.0/tests/test_constructor.py +240 -0
- analytic_prophet-0.1.0/tests/test_convergence_tolerances.py +296 -0
- analytic_prophet-0.1.0/tests/test_country_holidays.py +237 -0
- analytic_prophet-0.1.0/tests/test_cpp_binding.py +230 -0
- analytic_prophet-0.1.0/tests/test_cpp_input_validation.py +202 -0
- analytic_prophet-0.1.0/tests/test_cpp_optimizer_convergence.py +244 -0
- analytic_prophet-0.1.0/tests/test_evaluation_harness.py +217 -0
- analytic_prophet-0.1.0/tests/test_evaluation_report.py +129 -0
- analytic_prophet-0.1.0/tests/test_extra_regressors.py +326 -0
- analytic_prophet-0.1.0/tests/test_fit_backend.py +153 -0
- analytic_prophet-0.1.0/tests/test_flat_growth.py +265 -0
- analytic_prophet-0.1.0/tests/test_forecast_intervals.py +167 -0
- analytic_prophet-0.1.0/tests/test_gradient_numerical.py +57 -0
- analytic_prophet-0.1.0/tests/test_holidays.py +421 -0
- analytic_prophet-0.1.0/tests/test_input_validation.py +298 -0
- analytic_prophet-0.1.0/tests/test_logistic_growth.py +357 -0
- analytic_prophet-0.1.0/tests/test_multiplicative_mode.py +305 -0
- analytic_prophet-0.1.0/tests/test_packaging.py +132 -0
- analytic_prophet-0.1.0/tests/test_parity_surface.py +270 -0
- analytic_prophet-0.1.0/tests/test_prior_scales.py +299 -0
- analytic_prophet-0.1.0/tests/test_prophet_agreement.py +286 -0
- analytic_prophet-0.1.0/tests/test_prophet_naming.py +165 -0
- analytic_prophet-0.1.0/tests/test_prophet_parameters.py +265 -0
- analytic_prophet-0.1.0/tests/test_refit_contract.py +447 -0
- analytic_prophet-0.1.0/tests/test_regressor_predictor.py +309 -0
- analytic_prophet-0.1.0/tests/test_release_hygiene.py +218 -0
- analytic_prophet-0.1.0/tests/test_report_figures.py +140 -0
- analytic_prophet-0.1.0/tests/test_seasonality_registry.py +392 -0
- analytic_prophet-0.1.0/tests/test_serialize.py +334 -0
- analytic_prophet-0.1.0/tests/test_short_series.py +478 -0
- analytic_prophet-0.1.0/tests/test_showcase_figure.py +177 -0
- analytic_prophet-0.1.0/tests/test_sigma_obs_cpp.py +131 -0
- analytic_prophet-0.1.0/tests/test_sigma_obs_estimation.py +105 -0
- analytic_prophet-0.1.0/tests/test_sparsity_reporting.py +95 -0
- analytic_prophet-0.1.0/tests/test_tier0_gate.py +121 -0
- analytic_prophet-0.1.0/tests/test_tier1_recovery.py +224 -0
- analytic_prophet-0.1.0/tests/test_tier2_accuracy.py +277 -0
- analytic_prophet-0.1.0/tests/test_tier3_cost.py +153 -0
- analytic_prophet-0.1.0/tests/test_trend_denormalization.py +78 -0
- analytic_prophet-0.1.0/tests/test_trend_uncertainty.py +250 -0
- analytic_prophet-0.1.0/tests/test_uncertainty_vectorization.py +160 -0
- analytic_prophet-0.1.0/tests/test_vectorized_uncertainty.py +162 -0
- analytic_prophet-0.1.0/tests/test_wheels.py +272 -0
- analytic_prophet-0.1.0/tools/collect_licences.py +82 -0
- 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
|
+
[](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
|
+

|
|
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).
|