Metadata-Version: 2.4
Name: analytic-prophet
Version: 0.1.0
Summary: Facebook Prophet's fitting engine with a hand-derived analytic gradient in place of Stan's automatic differentiation
Author: Adly Zaroui
License: MIT License
        
        Copyright (c) 2026 Adly Zaroui
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
Project-URL: Repository, https://github.com/adlyZaroui/analytic-prophet
Project-URL: Issues, https://github.com/adlyZaroui/analytic-prophet/issues
Keywords: forecasting,time-series,prophet,optimization
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: C++
Classifier: Topic :: Scientific/Engineering :: Mathematics
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.17
Requires-Dist: pandas>=1.0
Requires-Dist: scipy>=1.9
Provides-Extra: holidays
Requires-Dist: holidays; extra == "holidays"
Provides-Extra: compare
Requires-Dist: prophet>=1.1.2; extra == "compare"
Provides-Extra: test
Requires-Dist: analytic-prophet[holidays]; extra == "test"
Requires-Dist: pytest>=7; extra == "test"
Requires-Dist: pybind11; extra == "test"
Requires-Dist: pyyaml; extra == "test"
Requires-Dist: tomli; python_version < "3.11" and extra == "test"
Requires-Dist: matplotlib; extra == "test"
Provides-Extra: dev
Requires-Dist: analytic-prophet[compare,test]; extra == "dev"
Dynamic: license-file

# analytic-prophet

[![tests](https://github.com/adlyZaroui/analytic-prophet/actions/workflows/tests.yml/badge.svg)](https://github.com/adlyZaroui/analytic-prophet/actions/workflows/tests.yml)

A reimplementation of [Facebook Prophet](https://github.com/facebook/prophet)'s fitting
engine that replaces Stan with a hand-derived, closed-form gradient and a small C++ core.

It fits the same model, and it fits it better: our optimum is ahead of Prophet's by its
own objective at every size measured, and on held-out M4 series our forecasts are more
accurate. Fitting is faster and uses less memory, which is what the analytic gradient was
for.

**Status: early development.** The model is feature-complete against Prophet's, but there
is no MCMC and no plotting, so this is not yet a drop-in replacement. See
[what this is not](#what-this-is-not).

![three held-out forecasts: the largest advantage, the median, and one Prophet wins](evaluation/results/figures/showcase.png)

**What this shows, and what it does not.** Three M4 series, forecast past a cutoff
neither model saw. Each coloured line is continuous through the cutoff: to its left the
model's fit to data it was shown, to its right its forecast. The actual values over the
horizon are drawn in black, and the bands are the nominal 80% intervals.

They are **chosen by rule, not by eye.** Of Tier 2's 36 series, the ranking is taken over
the **11 where a Prophet-shaped model fits at all** — both implementations within 10%
sMAPE held out — because a panel where both miss badly shows the difficulty of the series
rather than the difference between two optimizers. Within those: the series where our
cross-validated RMSE beats Prophet's by the most, the one at the median of that ranking,
and the one where Prophet beats us by the most.

**The top panel is the mechanism; the bottom two are the typical case.** Prophet's
optimizer stops short on the non-differentiable objective, and that costs most where the
trend is doing the work — a regime change, as in the top panel, where the L1 kink is
load-bearing. Elsewhere both implementations fit nearly the same model and the two lines
sit on top of each other. Across all 36 series the median RMSE advantage is **0.45%**, and
past two years of history predictions differ by **0.17–0.59%** of the series scale. A
reader who runs this on their own data should expect the bottom two panels, not the top
one.

**Neither implementation's intervals are well calibrated.** On this corpus they contain
about a third of the held-out points they claim four fifths of — mean coverage **0.356**
for ours and **0.341** for Prophet's. That is a property of the model on long horizons, it
is shared, and it is larger than anything separating the two.

Regenerate it with `python evaluation/showcase.py`; the output is byte-identical because
both sides are seeded.

```bash
pip install analytic-prophet        # once published — see below
```

The wheels carry the compiled core, so there is nothing to build: no compiler, no Eigen,
no LBFGSpp. Linux and macOS, Python 3.9–3.14 — the versions and platforms CI actually
runs the suite on. Windows is not built, because nothing here has ever been tested there;
it falls back to the source distribution, which does need a C++17 compiler.

> **Not on PyPI yet** ([#97](https://github.com/adlyZaroui/analytic-prophet/issues/97)).
> The wheels, their verification and the release workflow are in place and run from a
> tag; the publish step waits on a maintainer's approval and on the name being
> registered. Until then, clone:

```bash
git clone https://github.com/adlyZaroui/analytic-prophet
cd analytic-prophet
brew install eigen lbfgspp          # or equivalent; header-only, nothing is linked
pip install -e '.[dev]'             # see the caveat below if you are on macOS
```

> **The install is optional, and on macOS it can succeed without working.** `pytest` and
> everything in this repository run from a fresh clone with no install at all, because
> `pyproject.toml` puts the right directories on `pythonpath`. The editable install is
> only for importing `analytic_prophet` from somewhere else — and on macOS with Python
> 3.13+ it reports success and then does not import, because setuptools writes the
> editable `.pth` with `UF_HIDDEN` and 3.13 hardened `site` to skip hidden `.pth` files.
> `tests/test_packaging.py::test_an_editable_install_actually_imports` is what catches it.
> [More on it below](#building-and-testing).

```python
import pandas as pd
from analytic_prophet import AnalyticProphet

df = pd.read_csv("tests/data/peyton_manning.csv")     # columns: ds, y

model = AnalyticProphet(seasonality_mode="multiplicative")
model.fit(df)                                          # the compiled core, built on first use
# model.fit(df, backend="python")                      # the readable reference path, no compiler

future = model.make_future_dataframe(periods=90)
forecast = model.predict(future)
forecast[["ds", "yhat", "yhat_lower", "yhat_upper"]].tail()
```

---

## What it does

**No Stan.** Prophet ships a compiled Stan model and reaches it through `cmdstanpy`,
which spawns a subprocess for every fit. This carries neither. The model is Python, the
arithmetic is a small C++ extension compiled on demand from one source file, and nothing
is linked beyond two header-only libraries.

**No Stan toolchain to deploy.** `pip install` needs no cmdstan, no model compilation, no
subprocess at fit time. The C++ core is built on demand: the first `fit(df)` on a machine
compiles `optimize.cpp` — about ten seconds, and it says so rather than appearing to hang
— then caches the result and reuses it forever after
([#108](https://github.com/adlyZaroui/analytic-prophet/issues/108)). The cache is keyed by
a digest of the source and the compile command, so editing `optimize.cpp` rebuilds and
nothing else does.

Everything this project caches hangs off **one root**, by the same rule on every
platform: `$ANALYTIC_PROPHET_CACHE`, else `$XDG_CACHE_HOME/analytic-prophet`, else
`~/.cache/analytic-prophet`. The compiled core goes in `build/` and the M4 corpus the
evaluation suite downloads goes in `m4/`, so one variable moves both and deleting the
root is how you start over ([#111](https://github.com/adlyZaroui/analytic-prophet/issues/111)).

Without a compiler the build raises, naming the one thing that is missing — and
`fit(df, backend="python")` needs no compiler at all. The tests that need the toolchain
*skip* rather than fail when it is absent.

**An analytic gradient instead of automatic differentiation.** This is the point of the
project. Reverse-mode autodiff tapes a forward pass and reverses over it; the model is
small and entirely explicit, so the gradient can be written down instead. Both
consequences are measured rather than assumed — fitting is **1.4–10× faster** than
Prophet and the fit's peak memory is **about a third** of Prophet's at T = 2905, with the
gap widening as the series grows, which is what a retained tape predicts. Predicting is
faster on both of the paths described below — **1.8×** on the approximate one and **2.6×**
on the exact one.
→ [cost](evaluation/results/report.md#tier-3--what-it-costs)

**Prophet's non-differentiable objective, handled.** The Laplace prior on the changepoint
rates puts `Σ|δ|/τ` in the posterior, which is not differentiable at `δ = 0` — exactly
where the optimum sits, because that prior is what drives most rates to zero. Prophet's
own optimizer stops short there, and so did three others until the objective was
reformulated. Splitting `δ` into non-negative parts makes the problem smooth with simple
bounds, and the same solution.
→ [the argument and the evidence](docs/non-smooth-objective.md)

**A better optimum, by Prophet's own objective.** Scored under Stan's `log_prob` on
identical changepoints, so only the optimizer differs:

| T | Prophet `lp__` | this implementation |
|---|---|---|
| 300 | 813.351 | **815.337** |
| 1000 | 2852.768 | **2855.528** |
| 2905 | 8004.798 | **8005.159** |

→ [the correctness gate](evaluation/results/report.md#tier-0--are-the-two-fitting-the-same-model)

**Better forecasts, held out.** 36 M4 series, rolling-origin evaluation on cutoffs from
Prophet's own `generate_cutoffs` and scored by its own `performance_metrics`, so neither
the splits nor the definitions are ours:

| | median difference | p |
|---|---|---|
| MAE | −1.914 | 0.0063 |
| RMSE | −2.967 | 0.0183 |
| MAPE | −0.0007 | 0.0013 |
| coverage | **+0.0026** | 0.0025 |
| interval width | +1.350 | 0.470 |

More accurate points, and **higher** coverage at statistically indistinguishable width —
negative is better for the error rows, positive for coverage.
→ [forecast accuracy](evaluation/results/report.md#tier-2--does-the-better-map-point-forecast-better)

**Two uncertainty samplers, and Prophet's default is the approximate one.** This is
worth knowing before comparing any interval or any prediction time.
`Prophet.predict(vectorized=True)` is its default, and it is **not** a faster form of
`vectorized=False` — it is a different computation. Prophet's own two paths disagree by
about **1.4%** on the interval bounds. This implementation offers both, under the same
argument and the same default:

```python
forecast = model.predict(future)                      # approximate, as Prophet defaults to
forecast = model.predict(future, vectorized=False)    # exact, and slower
```

`yhat` is identical either way — only the interval is sampled. The approximation replaces
the Poisson process over the horizon with one coin per timestep and integrates the trend
by a double cumulative sum; the exact sampler places changepoints in continuous time and
evaluates the piecewise-linear trend from its definition. Under logistic growth the exact
sampler runs regardless, and `model.predicted_vectorized` records which one did.
→ [all four paths timed](evaluation/results/report.md#tier-3--what-it-costs)

**Feature-complete against Prophet's model.** Linear, logistic and flat growth;
seasonality selected from the history by Prophet's own rule, with per-component Fourier
order, prior scale, mode and condition; holidays, country holidays and extra regressors;
additive and multiplicative modes throughout.

**A Prophet-compatible API.** The constructor takes Prophet's arguments, and the names
match — `changepoint_prior_scale`, `changepoints_t`, `params`, `make_all_seasonality_features`.
What is *not* implemented is **rejected rather than silently ignored**, so a ported script
fails where it is actually wrong instead of at the first `AttributeError`.

**Save and load**, `[fc]` Prophet's own API:

```python
from analytic_prophet.serialize import model_to_json, model_from_json

with open("model.json", "w") as handle:
    handle.write(model_to_json(model))
```

A round-tripped model predicts bit-identically, and carries no handle to the compiled
extension — so a model fitted on one machine loads on one that has never built it.

**Refitting is allowed**, where `Prophet.fit` refuses a second call. A refit is
equivalent to a fresh instance carrying the same user configuration, fit on the new data.
That is a divergence, so it is a stated contract rather than an accident.
→ [deviations](docs/deviations.md#refitting-is-allowed-and-a-refit-means-something-specific)

**Two backends that agree to 1.5e-8.** `fit(df)` runs the compiled core, which is the
deliverable, so a script ported from Prophet keeps its fit call and gets it.
`fit(df, backend="python")` runs the readable pure-Python reference. Both solve the same
reformulated problem and follow Prophet's algorithm rule — Newton below 100 observations,
L-BFGS at or above, one Newton retry when L-BFGS fails.

Arguments belonging to the backend you did not select are **rejected rather than
ignored**, so `fit(df, analytic=False)` says that `analytic` is the Python backend's
rather than quietly running the compiled one.

**Verified against Stan's own density.** The objective is checked to *be* Prophet's, not
to resemble it: `CmdStanModel.log_prob` evaluated at our parameters must differ from ours
by a constant, and it does to 1e-12.
→ [verification](docs/model.md#verification-the-objective-is-stans-objective)

**A reproducible evaluation suite.** Four tiers — a correctness gate, parameter recovery
on synthetic data with known truth, held-out forecast accuracy, and cost — with committed
results and a generated report. One command regenerates everything.
→ [evaluation/](evaluation/)

---

## What this is not

- **No MCMC.** MAP estimation only; `mcmc_samples > 0` is rejected rather than ignored.
- **No plotting.** No `plot` or `plot_components`.
- **Not a drop-in, and here is how far off.** Of Prophet's 40 public methods, 13 are the
  same, 8 are module-level functions here rather than methods, 15 are absent on purpose
  and 4 are gaps — enumerated member by member, with the attributes a fit sets, in
  [how far from a drop-in](docs/deviations.md#how-far-from-a-drop-in-enumerated).
- **The intervals are not well calibrated — in either implementation.** On the M4 corpus
  the nominal 80% interval contains about a third of the points it claims four fifths of
  — mean coverage **0.341** for Prophet and **0.356** for this implementation. That is a property of the model on long
  horizons and volatile series, it is shared, and it is larger than anything separating
  the two. Nothing above should be read without it.

---

## Documentation

| | |
|---|---|
| [The model](docs/model.md) | what is fitted, term for term, and the proof that it is Stan's objective |
| [The non-smooth objective](docs/non-smooth-objective.md) | the central argument: where Prophet's optimizer stops short, and why |
| [Deviations from Prophet](docs/deviations.md) | deliberate divergences, and the gaps still open |
| [Evaluation report](evaluation/results/report.md) | every measured number, generated from committed results |
| [Benchmarks](benchmark/) | the fast micro-benchmarks, for running against a change |
| [Evaluation suite](evaluation/) | the claim-level study and its methodology |
| [Changelog](CHANGELOG.md) | what was wrong, and how it was found |

---

## Layout

```
analytic_prophet/
    __init__.py       re-exports the package's surface
    forecaster.py     the model
    constants.py      the numbers the model is defined by
    layout.py         where each parameter sits in the flat vector
    seasonality.py    Fourier basis, registry, selection rule
    make_holidays.py  [fc] prophet/make_holidays.py, plus the design columns
    trend.py          the three growth modes and their derivatives
    optimizer.py      projected Newton, and the stopping tolerances
    models.py         [fc] prophet/models.py — the compiled backend's loader
    build.py          compiling optimize.cpp on demand, and caching it
    serialize.py      [fc] prophet/serialize.py — save and load
    optimize.cpp      that backend
.github/workflows/    CI: the suite on every push, the tiers on request
docs/                 the model, the argument, the deviations
tests/                the suite, plus the Peyton Manning series under data/
benchmark/            fast micro-benchmarks, for running against a change
evaluation/           the claim-level study, and its generated report
```

`forecaster.py`, `models.py` and `make_holidays.py` take Prophet's own names.
**The other four have no Prophet counterpart, which is the point:** Stan supplies the
parameter layout, the derivatives and the optimizer there. Writing them down is what this
project is, so they get files you can open.

The C++ source sits *inside* the package rather than beside it because it is the
implementation, not a build input to it — where Prophet hands the problem to Stan, this
hands it to a gradient written out by hand.

One rule the layout imposes, for anyone adding a test: **patch a name where it is looked
up, not where it is defined.** `analytic_prophet/__init__.py` says why.

---

## Building and testing

Requires a C++17 compiler and two header-only libraries:

```bash
brew install eigen lbfgspp          # or equivalent
pip install -e '.[dev]'
pytest                              # the whole suite, about three minutes
```

`.[test]` is the same without `prophet`, which only the comparisons need. The count is
deliberately not written down here: CI reports it, and a number in prose goes stale
between the commit that adds tests and the one that remembers to update it.

`pytest` alone is enough — `pyproject.toml` puts the repo root and `benchmark/` on
`pythonpath` along with `evaluation/`, so a fresh clone runs the suite with **no install and no `PYTHONPATH`**.
`pip install -e .` is for importing the package from elsewhere; nothing in the repo
depends on it.

> **`pip install -e .` on macOS with Python 3.13+ can install successfully and still not
> import.** setuptools writes the editable `.pth` with macOS's `UF_HIDDEN` flag set, and
> Python 3.13 hardened `site.addpackage` to **skip hidden `.pth` files**. The install
> reports success, `pip show` is happy, the metadata resolves — and `import
> analytic_prophet` raises `ModuleNotFoundError` from any directory but the repo root.
> `chflags nohidden .venv/lib/python3.*/site-packages/__editable__*` clears it, though
> something re-applies the flag here, so the fix does not stick.
> `tests/test_packaging.py::test_an_editable_install_actually_imports` is what catches
> this: it skips when the package is not installed and fails with the diagnosis when it
> is installed and broken.

Nothing is linked: the extension needs Eigen and LBFGSpp headers only. The test suite
compiles `analytic_prophet/optimize.cpp` into a temporary directory on the fly, which is
why no binary is checked in. Tests that need the toolchain **skip** rather than fail when
it is absent.

`prophet` itself is deliberately not a dependency — every comparison against the original
needs it, and it pulls `cmdstanpy` plus a compiled Stan model. The agreement tests skip
without it and the benchmarks print an install hint, so `pip install prophet` is only
needed to run those. `holidays` is required for `add_country_holidays` and imported
lazily, so nothing else needs it.

### Continuous integration

Two workflows, under [`.github/workflows/`](.github/workflows):

- **`tests.yml`**, on every push and pull request: the suite across Python 3.9–3.14,
  which is what gives `requires-python = ">=3.9"` any basis — before it, the suite had
  only ever run on one version. One further job installs `prophet` and runs the
  comparisons against the original; it is the only one that pays for cmdstan.
- **`evaluation.yml`**, manual or monthly: `evaluation/run.py` and a regenerated report,
  uploaded as an artifact rather than committed. Tier 0 is a gate and fails the job.
  These tiers are deliberately not per-push — Tier 2 alone is about eleven minutes and
  needs the network.

A skip is the right answer on a laptop without a compiler and the wrong one on a runner
that installed Eigen on purpose, where it would mean CI reported **green for a run that
never built the C++ core**. So the strictness is the caller's: `pytest --require-cpp` and
`--require-prophet` turn those skips into failures, and every CI job passes them. Every
run also prints what it skipped, grouped by reason, into the job summary — "green" has to
be readable.

---

## Licence

See [LICENSE](LICENSE).
