├── tests
├── __init__.py
├── models
│ ├── __init__.py
│ ├── multivariate_model
│ │ ├── __init__.py
│ │ ├── test_changepoint.py
│ │ ├── test_multiindex.py
│ │ └── test_frame_to_array.py
│ └── test_to_positive.py
├── sktime
│ ├── __init__.py
│ ├── test_sktime_check_estimator.py
│ ├── test_base.py
│ ├── test_utils.py
│ ├── test_expand_column_per_level.py
│ └── _utils.py
├── distributions
│ ├── __init__.py
│ ├── test_gamma.py
│ ├── test_beta.py
│ └── test_hurdle.py
├── effects
│ ├── __init__.py
│ ├── test_linear.py
│ ├── test_all_target_likelihoods.py
│ ├── test_hill.py
│ ├── test_fourier.py
│ ├── test_hurdle_split_effects.py
│ ├── test_exact_likelihood.py
│ ├── test_log.py
│ ├── test_chain.py
│ └── test_lift_test_likelihood.py
├── engine
│ ├── test_all_optimizers.py
│ ├── test_map.py
│ ├── test_all_inference_engines.py
│ └── test_utils.py
├── conftest.py
├── utils
│ ├── test_plotting.py
│ ├── test_regex.py
│ └── test_deprecation.py
├── experimental
│ └── test_simulate.py
├── trend
│ └── test_flat.py
├── budget_optimization
│ ├── test_objectives.py
│ ├── test_parametrization_transformations.py
│ └── test_constraints.py
└── datasets
│ └── test_loaders.py
├── docs
├── CNAME
├── .gitignore
├── styles.css
├── static
│ ├── mmm.png
│ ├── avatar.webp
│ ├── favicon.ico
│ ├── logo-removebg.png
│ ├── avatar-removebg.png
│ ├── logotext-removebg.png
│ ├── prophetverse-logo.png
│ └── prophetverse-universe.png
├── _brand.yml
├── tutorials
│ ├── index.qmd
│ └── tuning.qmd
├── reference
│ ├── BetaTargetLikelihood.qmd
│ ├── NormalTargetLikelihood.qmd
│ ├── GammaTargetLikelihood.qmd
│ ├── MaximizeKPI.qmd
│ ├── MaximizeROI.qmd
│ ├── MinimizeBudget.qmd
│ ├── NegativeBinomialTargetLikelihood.qmd
│ ├── MultivariateNormal.qmd
│ ├── _styles-quartodoc.css
│ ├── ChainedEffects.qmd
│ ├── FlatTrend.qmd
│ ├── LinearEffect.qmd
│ ├── SharedBudgetConstraint.qmd
│ ├── TotalBudgetConstraint.qmd
│ ├── MinimumTargetResponse.qmd
│ ├── LogEffect.qmd
│ ├── GeometricAdstockEffect.qmd
│ ├── HillEffect.qmd
│ ├── ExactLikelihood.qmd
│ ├── LiftExperimentLikelihood.qmd
│ ├── LinearFourierSeasonality.qmd
│ ├── _sidebar.yml
│ ├── WeibullAdstockEffect.qmd
│ ├── MichaelisMentenEffect.qmd
│ └── PiecewiseLogisticTrend.qmd
├── mmm
│ ├── _sidebar.yml
│ └── adstock.qmd
└── howto
│ └── index.qmd
├── src
└── prophetverse
│ ├── logger.py
│ ├── exc.py
│ ├── experimental
│ ├── __init__.py
│ └── simulate.py
│ ├── datasets
│ ├── synthetic
│ │ ├── __init__.py
│ │ ├── _composite_effect_example.py
│ │ └── _squared_exogenous.py
│ ├── __init__.py
│ ├── _mmm
│ │ ├── dataset1_posterior_samples.json
│ │ └── dataset2_branding_posterior_samples.json
│ └── loaders.py
│ ├── effects
│ ├── trend
│ │ ├── __init__.py
│ │ ├── base.py
│ │ └── flat.py
│ ├── target
│ │ ├── __init__.py
│ │ └── base.py
│ ├── forward.py
│ ├── identity.py
│ ├── constant.py
│ ├── __init__.py
│ ├── coupled.py
│ ├── log.py
│ ├── ignore_input.py
│ ├── hill.py
│ └── michaelis_menten.py
│ ├── engine
│ ├── optimizer
│ │ └── __init__.py
│ ├── __init__.py
│ ├── utils.py
│ ├── prior.py
│ └── base.py
│ ├── sktime
│ └── __init__.py
│ ├── distributions
│ ├── __init__.py
│ ├── _inverse_functions.py
│ ├── hurdle_distribution.py
│ ├── reparametrization.py
│ └── truncated_discrete.py
│ ├── utils
│ ├── __init__.py
│ ├── numpyro.py
│ ├── deprecation.py
│ ├── algebric_operations.py
│ ├── plotting.py
│ ├── regex.py
│ └── frame_to_array.py
│ ├── budget_optimization
│ ├── __init__.py
│ ├── objectives.py
│ └── constraints.py
│ ├── _model.py
│ └── __init__.py
├── Pipfile
├── codecov.yml
├── .github
├── dependabot.yml
├── release.yml
├── release-drafter.yml
└── workflows
│ ├── ci.yaml
│ ├── release.yml
│ └── docs.yml
└── pyproject.toml
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | prophetverse.com
--------------------------------------------------------------------------------
/tests/models/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/sktime/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/distributions/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/models/multivariate_model/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/effects/__init__.py:
--------------------------------------------------------------------------------
1 | """Test effects."""
2 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | /.quarto/
2 |
3 | **/*.quarto_ipynb
4 |
--------------------------------------------------------------------------------
/tests/models/multivariate_model/test_changepoint.py:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/docs/styles.css:
--------------------------------------------------------------------------------
1 | /* in styles.css */
2 |
3 | .card {
4 | border-radius: 0.375rem;
5 | }
--------------------------------------------------------------------------------
/src/prophetverse/logger.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | logger = logging.getLogger("prophetverse")
4 |
--------------------------------------------------------------------------------
/docs/static/mmm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/felipeangelimvieira/prophetverse/HEAD/docs/static/mmm.png
--------------------------------------------------------------------------------
/docs/static/avatar.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/felipeangelimvieira/prophetverse/HEAD/docs/static/avatar.webp
--------------------------------------------------------------------------------
/docs/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/felipeangelimvieira/prophetverse/HEAD/docs/static/favicon.ico
--------------------------------------------------------------------------------
/docs/static/logo-removebg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/felipeangelimvieira/prophetverse/HEAD/docs/static/logo-removebg.png
--------------------------------------------------------------------------------
/docs/static/avatar-removebg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/felipeangelimvieira/prophetverse/HEAD/docs/static/avatar-removebg.png
--------------------------------------------------------------------------------
/docs/static/logotext-removebg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/felipeangelimvieira/prophetverse/HEAD/docs/static/logotext-removebg.png
--------------------------------------------------------------------------------
/docs/static/prophetverse-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/felipeangelimvieira/prophetverse/HEAD/docs/static/prophetverse-logo.png
--------------------------------------------------------------------------------
/src/prophetverse/exc.py:
--------------------------------------------------------------------------------
1 | """Collection of exceptions."""
2 |
3 |
4 | class ConvergenceError(Exception): # noqa: D101
5 | pass
6 |
--------------------------------------------------------------------------------
/docs/static/prophetverse-universe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/felipeangelimvieira/prophetverse/HEAD/docs/static/prophetverse-universe.png
--------------------------------------------------------------------------------
/docs/_brand.yml:
--------------------------------------------------------------------------------
1 | color:
2 | palette:
3 | dark-grey: "#222222"
4 | white: "#ffffff"
5 | purple: "#4051b5"
6 | background: white
7 | foreground: dark-grey
8 | primary: purple
9 |
--------------------------------------------------------------------------------
/src/prophetverse/experimental/__init__.py:
--------------------------------------------------------------------------------
1 | from prophetverse import budget_optimization
2 | from . import simulate
3 |
4 | __all__ = [
5 | "budget_optimization",
6 | "simulate",
7 | ]
8 |
--------------------------------------------------------------------------------
/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | url = "https://pypi.org/simple"
3 | verify_ssl = true
4 | name = "pypi"
5 |
6 | [packages]
7 |
8 | [dev-packages]
9 |
10 | [requires]
11 | python_version = "3.11"
12 |
--------------------------------------------------------------------------------
/docs/tutorials/index.qmd:
--------------------------------------------------------------------------------
1 | ---
2 | title: Tutorials
3 | ---
4 |
5 | In this section, you can find a collection of tutorials that will help you understand the basics
6 | of Prophetverse for forecasting.
--------------------------------------------------------------------------------
/docs/reference/BetaTargetLikelihood.qmd:
--------------------------------------------------------------------------------
1 | # BetaTargetLikelihood { #prophetverse.effects.BetaTargetLikelihood }
2 |
3 | ```python
4 | effects.BetaTargetLikelihood(self, noise_scale=0.05, epsilon=1e-05)
5 | ```
--------------------------------------------------------------------------------
/docs/reference/NormalTargetLikelihood.qmd:
--------------------------------------------------------------------------------
1 | # NormalTargetLikelihood { #prophetverse.effects.NormalTargetLikelihood }
2 |
3 | ```python
4 | effects.NormalTargetLikelihood(self, noise_scale=0.05)
5 | ```
6 |
7 |
--------------------------------------------------------------------------------
/docs/reference/GammaTargetLikelihood.qmd:
--------------------------------------------------------------------------------
1 | # GammaTargetLikelihood { #prophetverse.effects.GammaTargetLikelihood }
2 |
3 | ```python
4 | effects.GammaTargetLikelihood(self, noise_scale=0.05, epsilon=1e-05)
5 | ```
6 |
7 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | require_ci_to_pass: false
3 | status:
4 | project:
5 | default:
6 | target: 95%
7 | patch:
8 | default:
9 | target: 95%
10 | informational: true
11 |
--------------------------------------------------------------------------------
/docs/reference/MaximizeKPI.qmd:
--------------------------------------------------------------------------------
1 | # MaximizeKPI { #prophetverse.budget_optimization.objectives.MaximizeKPI }
2 |
3 | ```python
4 | budget_optimization.objectives.MaximizeKPI(self)
5 | ```
6 |
7 | Maximize the KPI objective function.
--------------------------------------------------------------------------------
/docs/reference/MaximizeROI.qmd:
--------------------------------------------------------------------------------
1 | # MaximizeROI { #prophetverse.budget_optimization.objectives.MaximizeROI }
2 |
3 | ```python
4 | budget_optimization.objectives.MaximizeROI(self)
5 | ```
6 |
7 | Maximize return on investment (ROI) objective function.
--------------------------------------------------------------------------------
/docs/reference/MinimizeBudget.qmd:
--------------------------------------------------------------------------------
1 | # MinimizeBudget { #prophetverse.budget_optimization.objectives.MinimizeBudget }
2 |
3 | ```python
4 | budget_optimization.objectives.MinimizeBudget(self, scale=1)
5 | ```
6 |
7 | Minimize budget constraint objective function.
--------------------------------------------------------------------------------
/docs/reference/NegativeBinomialTargetLikelihood.qmd:
--------------------------------------------------------------------------------
1 | # NegativeBinomialTargetLikelihood { #prophetverse.effects.NegativeBinomialTargetLikelihood }
2 |
3 | ```python
4 | effects.NegativeBinomialTargetLikelihood(self, noise_scale=0.05, epsilon=1e-05)
5 | ```
6 |
7 |
--------------------------------------------------------------------------------
/docs/reference/MultivariateNormal.qmd:
--------------------------------------------------------------------------------
1 | # MultivariateNormal { #prophetverse.effects.MultivariateNormal }
2 |
3 | ```python
4 | effects.MultivariateNormal(
5 | self,
6 | noise_scale=0.05,
7 | correlation_matrix_concentration=1,
8 | )
9 | ```
10 |
11 | Base class for effects.
--------------------------------------------------------------------------------
/docs/mmm/_sidebar.yml:
--------------------------------------------------------------------------------
1 | website:
2 | sidebar:
3 | - contents:
4 | - contents:
5 | - text: Introduction
6 | href: mmm/index.qmd
7 | - mmm/fitting_and_calibration.qmd
8 | - mmm/budget_allocation.qmd
9 | section: Tutorials
10 | id: mmm
11 | - id: dummy-sidebar
12 |
--------------------------------------------------------------------------------
/src/prophetverse/datasets/synthetic/__init__.py:
--------------------------------------------------------------------------------
1 | """Synthetic datasets for testing and examples."""
2 |
3 | from ._composite_effect_example import load_composite_effect_example
4 | from ._squared_exogenous import load_synthetic_squared_exogenous
5 |
6 | __all__ = ["load_composite_effect_example", "load_synthetic_squared_exogenous"]
7 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/trend/__init__.py:
--------------------------------------------------------------------------------
1 | """Module for trend models in prophetverse."""
2 |
3 | from .base import TrendEffectMixin
4 | from .flat import FlatTrend
5 | from .piecewise import PiecewiseLinearTrend, PiecewiseLogisticTrend
6 |
7 | __all__ = [
8 | "TrendEffectMixin",
9 | "FlatTrend",
10 | "PiecewiseLinearTrend",
11 | "PiecewiseLogisticTrend",
12 | ]
13 |
--------------------------------------------------------------------------------
/tests/models/test_to_positive.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import pytest
3 |
4 | from prophetverse.effects.target.univariate import _build_positive_smooth_clipper
5 |
6 |
7 | @pytest.mark.parametrize("x", [1e10, 1e3, 1, -1, -1e3, -1e10])
8 | def test__to_positive(x):
9 | _to_positive = _build_positive_smooth_clipper(1e-5)
10 | x_positive = _to_positive(x)
11 |
12 | assert jnp.all(x_positive > 0)
13 |
--------------------------------------------------------------------------------
/src/prophetverse/engine/optimizer/__init__.py:
--------------------------------------------------------------------------------
1 | """Optimizers module."""
2 |
3 | from .optimizer import (
4 | AdamOptimizer,
5 | BaseOptimizer,
6 | CosineScheduleAdamOptimizer,
7 | LBFGSSolver,
8 | _NumPyroOptim,
9 | )
10 |
11 | __all__ = [
12 | "AdamOptimizer",
13 | "BaseOptimizer",
14 | "CosineScheduleAdamOptimizer",
15 | "_LegacyNumpyroOptimizer",
16 | "_NumPyroOptim",
17 | "_OptimizerFromCallable",
18 | "LBFGSSolver",
19 | ]
20 |
--------------------------------------------------------------------------------
/src/prophetverse/sktime/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Implementation of the sktime API for the prophetverse library.
3 |
4 | This module provides a set of classes that implement the sktime API for the
5 | prophetverse library.
6 | """
7 |
8 | from .multivariate import HierarchicalProphet
9 | from .univariate import Prophet, ProphetGamma, ProphetNegBinomial, Prophetverse
10 |
11 | __all__ = [
12 | "Prophet",
13 | "ProphetGamma",
14 | "ProphetNegBinomial",
15 | "Prophetverse",
16 | "HierarchicalProphet",
17 | ]
18 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "pip"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
7 | commit-message:
8 | prefix: "[MNT] [Dependabot]"
9 | include: "scope"
10 | labels:
11 | - "maintenance"
12 | - package-ecosystem: "github-actions"
13 | directory: "/"
14 | schedule:
15 | interval: "daily"
16 | commit-message:
17 | prefix: "[MNT] [Dependabot]"
18 | include: "scope"
19 | labels:
20 | - "maintenance"
--------------------------------------------------------------------------------
/src/prophetverse/distributions/__init__.py:
--------------------------------------------------------------------------------
1 | """Custom numpyro distributions.
2 |
3 | This module contains custom distributions that can be used as likelihoods or priors
4 | for models.
5 | """
6 |
7 | from .reparametrization import GammaReparametrized, BetaReparametrized
8 | from .hurdle_distribution import HurdleDistribution
9 | from .truncated_discrete import TruncatedDiscrete
10 |
11 | __all__ = [
12 | "GammaReparametrized",
13 | "BetaReparametrized",
14 | "HurdleDistribution",
15 | "TruncatedDiscrete",
16 | ]
17 |
--------------------------------------------------------------------------------
/docs/mmm/adstock.qmd:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Using Adstock effect"
3 | description: "Recipe: use likelihood='beta' for (0,1) targets"
4 | ---
5 |
6 |
7 | ```{python}
8 | from prophetverse import WeibullAdstockEffect, GeometricAdstockEffect, ChainedEffects
9 |
10 |
11 | from prophetverse.datasets._mmm.dataset1 import get_dataset
12 |
13 |
14 | y, X, _, _, _ = get_dataset()
15 |
16 | ```
17 |
18 | ```{python}
19 |
20 | X_search = X[["ad_spend_search"]]
21 | ```
22 |
23 |
24 | ```{python}
25 |
26 | adstock = WeibullAdstockEffect()
27 | ```
--------------------------------------------------------------------------------
/docs/reference/_styles-quartodoc.css:
--------------------------------------------------------------------------------
1 | /*
2 | This file generated automatically by quartodoc version 0.9.1.
3 | Modifications may be overwritten by quartodoc build. If you want to
4 | customize styles, create a new .css file to avoid losing changes.
5 | */
6 |
7 |
8 | /* styles for parameter tables, etc.. ----
9 | */
10 |
11 | .doc-section dt code {
12 | background: none;
13 | }
14 |
15 | .doc-section dt {
16 | /* background-color: lightyellow; */
17 | display: block;
18 | }
19 |
20 | .doc-section dl dd {
21 | margin-left: 3rem;
22 | }
23 |
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | changelog:
2 | exclude:
3 | authors:
4 | - octocat
5 | - dependabot
6 | categories:
7 | - title: '🚀 Features'
8 | labels:
9 | - 'feature'
10 | - 'enhancement'
11 | - title: '🐛 Bug Fixes'
12 | labels:
13 | - 'fix'
14 | - 'bugfix'
15 | - 'bug'
16 | - title: '🧰 Maintenance'
17 | label:
18 | - 'chore'
19 | - 'maintenance'
20 | - 'refactor'
21 | - 'documentation'
22 | - title: Other Changes
23 | labels:
24 | - "*"
--------------------------------------------------------------------------------
/src/prophetverse/effects/target/__init__.py:
--------------------------------------------------------------------------------
1 | """Target effects for Prophetverse models."""
2 |
3 | from .base import BaseTargetEffect
4 | from .multivariate import MultivariateNormal
5 | from .univariate import (
6 | NormalTargetLikelihood,
7 | GammaTargetLikelihood,
8 | BetaTargetLikelihood,
9 | NegativeBinomialTargetLikelihood,
10 | )
11 |
12 |
13 | __all__ = [
14 | "NormalTargetLikelihood",
15 | "GammaTargetLikelihood",
16 | "BetaTargetLikelihood",
17 | "NegativeBinomialTargetLikelihood",
18 | "MultivariateNormal",
19 | ]
20 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name-template: 'v$NEXT_PATCH_VERSION'
2 | tag-template: 'v$NEXT_PATCH_VERSION'
3 | categories:
4 | - title: '🚀 Features'
5 | labels:
6 | - 'feature'
7 | - 'enhancement'
8 | - title: '🐛 Bug Fixes'
9 | labels:
10 | - 'fix'
11 | - 'bugfix'
12 | - 'bug'
13 | - title: '🧰 Maintenance'
14 | label:
15 | - 'chore'
16 | - 'maintenance'
17 | - 'refactor'
18 | - 'documentation'
19 |
20 | template: |
21 | ## What's New
22 |
23 | $CHANGES
24 |
25 | All contributors: $CONTRIBUTORS
--------------------------------------------------------------------------------
/docs/reference/ChainedEffects.qmd:
--------------------------------------------------------------------------------
1 | # ChainedEffects { #prophetverse.effects.ChainedEffects }
2 |
3 | ```python
4 | effects.ChainedEffects(self, steps)
5 | ```
6 |
7 | Chains multiple effects sequentially, applying them one after the other.
8 |
9 | ## Parameters {.doc-section .doc-section-parameters}
10 |
11 | | Name | Type | Description | Default |
12 | |--------|--------------------|-----------------------------------------------|------------|
13 | | steps | List\[BaseEffect\] | A list of effects to be applied sequentially. | _required_ |
--------------------------------------------------------------------------------
/src/prophetverse/engine/__init__.py:
--------------------------------------------------------------------------------
1 | """Module for the inference engines."""
2 |
3 | from .base import BaseInferenceEngine
4 | from .map import MAPInferenceEngine, MAPInferenceEngineError
5 | from .mcmc import MCMCInferenceEngine
6 | from .prior import PriorPredictiveInferenceEngine
7 | from .vi import VIInferenceEngine, VIInferenceEngineError
8 |
9 | __all__ = [
10 | "BaseInferenceEngine",
11 | "MAPInferenceEngine",
12 | "MAPInferenceEngineError",
13 | "MCMCInferenceEngine",
14 | "PriorPredictiveInferenceEngine",
15 | "VIInferenceEngine",
16 | "VIInferenceEngineError",
17 | ]
18 |
--------------------------------------------------------------------------------
/src/prophetverse/distributions/_inverse_functions.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | from scipy.stats import nbinom, poisson
3 |
4 |
5 | def _inverse_poisson(dist, u):
6 | rate = np.asarray(dist.rate)
7 | u = np.asarray(u)
8 |
9 | density = poisson(rate)
10 | return density.ppf(u)
11 |
12 |
13 | def _inverse_neg_binom(dist, u):
14 | concentration = np.asarray(dist.concentration)
15 | mean = np.asarray(dist.mean)
16 | u = np.asarray(u)
17 |
18 | n = concentration
19 | p = concentration / (concentration + mean)
20 |
21 | density = nbinom(n=n, p=p)
22 | return density.ppf(u)
23 |
--------------------------------------------------------------------------------
/src/prophetverse/datasets/__init__.py:
--------------------------------------------------------------------------------
1 | """Datasets for ProphetVerse."""
2 |
3 | from .loaders import (
4 | load_forecastingdata,
5 | load_pedestrian_count,
6 | load_peyton_manning,
7 | load_tensorflow_github_stars,
8 | load_tourism,
9 | )
10 | from .synthetic import load_composite_effect_example, load_synthetic_squared_exogenous
11 |
12 | __all__ = [
13 | "load_forecastingdata",
14 | "load_pedestrian_count",
15 | "load_peyton_manning",
16 | "load_tensorflow_github_stars",
17 | "load_tourism",
18 | "load_synthetic_squared_exogenous",
19 | "load_composite_effect_example",
20 | ]
21 |
--------------------------------------------------------------------------------
/tests/sktime/test_sktime_check_estimator.py:
--------------------------------------------------------------------------------
1 | """Test the sktime contract for Prophet and HierarchicalProphet."""
2 |
3 | import pytest # noqa: F401
4 | from sktime.utils.estimator_checks import check_estimator, parametrize_with_checks
5 |
6 | from prophetverse.sktime import HierarchicalProphet, Prophetverse
7 |
8 | PROPHET_MODELS = [Prophetverse, HierarchicalProphet]
9 |
10 |
11 | @parametrize_with_checks(PROPHET_MODELS)
12 | def test_sktime_api_compliance(obj, test_name):
13 | """Test the sktime contract for Prophet and HierarchicalProphet."""
14 | check_estimator(obj, tests_to_run=test_name, raise_exceptions=True)
15 |
--------------------------------------------------------------------------------
/tests/engine/test_all_optimizers.py:
--------------------------------------------------------------------------------
1 | import numpyro
2 | from skbase.testing.test_all_objects import BaseFixtureGenerator, QuickTester
3 |
4 | from prophetverse.engine.optimizer.optimizer import BaseOptimizer
5 |
6 |
7 | class OptimizerFixtureGenerator(BaseFixtureGenerator):
8 |
9 | object_type_filter = BaseOptimizer
10 | package_name = "prophetverse.engine"
11 |
12 |
13 | class TestAllOptimizers(OptimizerFixtureGenerator, QuickTester):
14 |
15 | def test_optimizer_output_class(self, object_instance):
16 | assert isinstance(
17 | object_instance.create_optimizer(), numpyro.optim._NumPyroOptim
18 | )
19 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | """Configure tests and declare global fixtures."""
2 |
3 | import pandas as pd
4 | import pytest
5 |
6 | # warnings.filterwarnings("ignore")
7 |
8 |
9 | def pytest_sessionstart(session):
10 | """Avoid NaNs in tests."""
11 | # numpyro.enable_x64()
12 |
13 |
14 | @pytest.fixture(name="effects_sample_data")
15 | def sample_data():
16 | """Sample data used at effects tests."""
17 | return pd.DataFrame(
18 | {
19 | "x1": range(10),
20 | "x2": range(10, 20),
21 | "log_x1": [0.1 * i for i in range(10)],
22 | "lin_x2": [0.2 * i for i in range(10, 20)],
23 | }
24 | )
25 |
--------------------------------------------------------------------------------
/docs/reference/FlatTrend.qmd:
--------------------------------------------------------------------------------
1 | # FlatTrend { #prophetverse.effects.FlatTrend }
2 |
3 | ```python
4 | effects.FlatTrend(self, changepoint_prior_scale=0.1)
5 | ```
6 |
7 | Flat trend model.
8 |
9 | The mean of the target variable is used as the prior location for the trend.
10 |
11 | ## Parameters {.doc-section .doc-section-parameters}
12 |
13 | | Name | Type | Description | Default |
14 | |-------------------------|--------|---------------------------------------------------------------------------------|-----------|
15 | | changepoint_prior_scale | float | The scale of the prior distribution on the trend changepoints. Defaults to 0.1. | `0.1` |
--------------------------------------------------------------------------------
/tests/utils/test_plotting.py:
--------------------------------------------------------------------------------
1 | from prophetverse.utils.plotting import plot_prior_predictive
2 | from prophetverse import LinearEffect
3 | import pandas as pd
4 | import jax.numpy as jnp
5 | import pytest
6 |
7 |
8 | @pytest.mark.parametrize("mode", ["time", "x"])
9 | def test_plot_prior_predictive(mode):
10 | """Test the plot_prior_predictive function."""
11 | instance = LinearEffect()
12 | X = pd.DataFrame(
13 | {"x": [1, 2, 3, 4, 5]}, index=pd.date_range("2023-01-01", periods=5)
14 | )
15 | y = X * 2
16 | fig, ax = plot_prior_predictive(
17 | instance,
18 | X=X,
19 | y=y,
20 | predicted_effects={"trend": jnp.array([[2], [4], [6], [8], [10]])},
21 | mode=mode,
22 | )
23 |
24 | assert fig is not None
25 | assert ax is not None
26 | assert len(ax.lines) > 0
27 | assert len(ax.collections) > 0
28 |
--------------------------------------------------------------------------------
/src/prophetverse/utils/__init__.py:
--------------------------------------------------------------------------------
1 | """Utilities module."""
2 |
3 | from .frame_to_array import (
4 | convert_dataframe_to_tensors,
5 | convert_index_to_days_since_epoch,
6 | iterate_all_series,
7 | series_to_tensor,
8 | series_to_tensor_or_array,
9 | )
10 | from .multiindex import (
11 | get_bottom_series_idx,
12 | get_multiindex_loc,
13 | loc_bottom_series,
14 | reindex_time_series,
15 | )
16 | from .regex import exact, no_input_columns, starts_with
17 |
18 | __all__ = [
19 | "get_bottom_series_idx",
20 | "get_multiindex_loc",
21 | "loc_bottom_series",
22 | "iterate_all_series",
23 | "convert_index_to_days_since_epoch",
24 | "series_to_tensor",
25 | "series_to_tensor_or_array",
26 | "convert_dataframe_to_tensors",
27 | "reindex_time_series",
28 | "exact",
29 | "starts_with",
30 | "no_input_columns",
31 | ]
32 |
--------------------------------------------------------------------------------
/docs/reference/LinearEffect.qmd:
--------------------------------------------------------------------------------
1 | # LinearEffect { #prophetverse.effects.LinearEffect }
2 |
3 | ```python
4 | effects.LinearEffect(
5 | self,
6 | effect_mode='multiplicative',
7 | prior=None,
8 | broadcast=False,
9 | )
10 | ```
11 |
12 | Represents a linear effect in a hierarchical prophet model.
13 |
14 | ## Parameters {.doc-section .doc-section-parameters}
15 |
16 | | Name | Type | Description | Default |
17 | |-------------|---------------------|-----------------------------------------------------------------------|--------------------|
18 | | prior | Distribution | A numpyro distribution to use as prior. Defaults to dist.Normal(0, 1) | `None` |
19 | | effect_mode | effects_application | Either "multiplicative" or "additive" by default "multiplicative". | `'multiplicative'` |
--------------------------------------------------------------------------------
/tests/engine/test_map.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import pytest
3 | from numpyro.infer.svi import SVIRunResult
4 |
5 | from prophetverse.engine import MAPInferenceEngine, MAPInferenceEngineError
6 | from prophetverse.engine.optimizer import AdamOptimizer
7 |
8 |
9 | def test_raises_error_when_nan_loss():
10 |
11 | bad_svi_result = SVIRunResult(
12 | params={"param1": jnp.array([1, 2, 3])},
13 | state=None,
14 | losses=jnp.array([1, 2, jnp.nan]),
15 | )
16 |
17 | good_svi_result = SVIRunResult(
18 | params={"param1": jnp.array([1, 2, 3])}, state=None, losses=jnp.array([1, 2, 3])
19 | )
20 |
21 | inf_engine = MAPInferenceEngine(optimizer=AdamOptimizer())
22 |
23 | assert inf_engine.raise_error_if_nan_loss(good_svi_result) is None
24 | with pytest.raises(MAPInferenceEngineError):
25 | inf_engine.raise_error_if_nan_loss(bad_svi_result)
26 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/target/base.py:
--------------------------------------------------------------------------------
1 | from prophetverse.effects.base import BaseEffect
2 |
3 |
4 | class BaseTargetEffect(BaseEffect):
5 | """Base class for effects."""
6 |
7 | _tags = {
8 | # Supports multivariate data? Can this
9 | # Effect be used with Multiariate prophet?
10 | "hierarchical_prophet_compliant": False,
11 | # If no columns are found, should
12 | # _predict be skipped?
13 | "requires_X": False,
14 | # Should only the indexes related to the forecasting horizon be passed to
15 | # _transform?
16 | "filter_indexes_with_forecating_horizon_at_transform": True,
17 | # Should the effect be applied to the target variable?
18 | "applies_to": "y",
19 | }
20 |
21 | def _transform(self, X, fh):
22 | if X is not None:
23 | return super()._transform(X, fh)
24 | return None
25 |
--------------------------------------------------------------------------------
/tests/models/multivariate_model/test_multiindex.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pandas as pd
3 | import pytest
4 | from jax import numpy as jnp
5 |
6 | from prophetverse.utils.multiindex import reindex_time_series
7 |
8 |
9 | def test_reindex_timeseries():
10 |
11 | df = pd.DataFrame(
12 | data=np.random.randn(10, 2),
13 | index=pd.MultiIndex.from_product(
14 | [["A", "B"], pd.date_range(start="2021-01-01", periods=5, freq="D")],
15 | names=["group", "time"],
16 | ),
17 | columns=["value1", "value2"],
18 | )
19 |
20 | output = reindex_time_series(
21 | df, pd.date_range(start="2021-01-01", periods=10, freq="D")
22 | )
23 |
24 | assert output.index.equals(
25 | pd.MultiIndex.from_product(
26 | [["A", "B"], pd.date_range(start="2021-01-01", periods=10, freq="D")],
27 | names=["group", "time"],
28 | )
29 | )
30 |
--------------------------------------------------------------------------------
/docs/reference/SharedBudgetConstraint.qmd:
--------------------------------------------------------------------------------
1 | # TotalBudgetConstraint { #prophetverse.budget_optimization.constraints.TotalBudgetConstraint }
2 |
3 | ```python
4 | budget_optimization.constraints.TotalBudgetConstraint(
5 | self,
6 | channels=None,
7 | total=None,
8 | )
9 | ```
10 |
11 | Shared budget constraint.
12 |
13 | This constraint ensures that the sum of the budgets for the specified
14 | channels is equal to the total budget.
15 |
16 | ## Parameters {.doc-section .doc-section-parameters}
17 |
18 | | Name | Type | Description | Default |
19 | |----------|--------|--------------------------------------------------------------------------|-----------|
20 | | channels | list | List of channels to be constrained. If None, all channels are used. | `None` |
21 | | total | float | Total budget. If None, the total budget is computed from the input data. | `None` |
--------------------------------------------------------------------------------
/docs/reference/TotalBudgetConstraint.qmd:
--------------------------------------------------------------------------------
1 | # TotalBudgetConstraint { #prophetverse.budget_optimization.constraints.TotalBudgetConstraint }
2 |
3 | ```python
4 | budget_optimization.constraints.TotalBudgetConstraint(
5 | self,
6 | channels=None,
7 | total=None,
8 | )
9 | ```
10 |
11 | Shared budget constraint.
12 |
13 | This constraint ensures that the sum of the budgets for the specified
14 | channels is equal to the total budget.
15 |
16 | ## Parameters {.doc-section .doc-section-parameters}
17 |
18 | | Name | Type | Description | Default |
19 | |----------|--------|--------------------------------------------------------------------------|-----------|
20 | | channels | list | List of channels to be constrained. If None, all channels are used. | `None` |
21 | | total | float | Total budget. If None, the total budget is computed from the input data. | `None` |
--------------------------------------------------------------------------------
/src/prophetverse/budget_optimization/__init__.py:
--------------------------------------------------------------------------------
1 | from .optimizer import BudgetOptimizer
2 | from .objectives import (
3 | MinimizeBudget,
4 | MaximizeKPI,
5 | MaximizeROI,
6 | )
7 | from .constraints import (
8 | TotalBudgetConstraint,
9 | MinimumTargetResponse,
10 | SharedBudgetConstraint,
11 | )
12 | from .parametrization_transformations import (
13 | IdentityTransform,
14 | InvestmentPerChannelTransform,
15 | TotalInvestmentTransform,
16 | InvestmentPerChannelAndSeries,
17 | InvestmentPerSeries,
18 | )
19 |
20 | __all__ = [
21 | "BudgetOptimizer",
22 | "MinimizeBudget",
23 | "MaximizeKPI",
24 | "MaximizeROI",
25 | "TotalBudgetConstraint",
26 | "MinimumTargetResponse",
27 | "IdentityTransform",
28 | "InvestmentPerChannelTransform",
29 | "TotalInvestmentTransform",
30 | "SharedBudgetConstraint",
31 | "InvestmentPerChannelAndSeries",
32 | "InvestmentPerSeries",
33 | ]
34 |
--------------------------------------------------------------------------------
/docs/reference/MinimumTargetResponse.qmd:
--------------------------------------------------------------------------------
1 | # MinimumTargetResponse { #prophetverse.budget_optimization.constraints.MinimumTargetResponse }
2 |
3 | ```python
4 | budget_optimization.constraints.MinimumTargetResponse(
5 | self,
6 | target_response,
7 | constraint_type='ineq',
8 | )
9 | ```
10 |
11 | Minimum target response constraint.
12 |
13 | This constraint ensures that the target response is greater than or equal
14 | to a specified value. This imposes a restriction on the **output** of the
15 | model, instead of the input.
16 |
17 | ## Parameters {.doc-section .doc-section-parameters}
18 |
19 | | Name | Type | Description | Default |
20 | |-----------------|--------|--------------------------------------------------------------------------------------|------------|
21 | | target_response | float | Target response value. The model output must be greater than or equal to this value. | _required_ |
--------------------------------------------------------------------------------
/docs/reference/LogEffect.qmd:
--------------------------------------------------------------------------------
1 | # LogEffect { #prophetverse.effects.LogEffect }
2 |
3 | ```python
4 | effects.LogEffect(
5 | self,
6 | effect_mode='multiplicative',
7 | scale_prior=None,
8 | rate_prior=None,
9 | )
10 | ```
11 |
12 | Represents a log effect as effect = scale * log(rate * data + 1).
13 |
14 | ## Parameters {.doc-section .doc-section-parameters}
15 |
16 | | Name | Type | Description | Default |
17 | |-------------|--------------------------|--------------------------------------------------------------------|--------------------|
18 | | scale_prior | Optional\[Distribution\] | The prior distribution for the scale parameter., by default Gamma | `None` |
19 | | rate_prior | Optional\[Distribution\] | The prior distribution for the rate parameter., by default Gamma | `None` |
20 | | effect_mode | effects_application | Either "additive" or "multiplicative", by default "multiplicative" | `'multiplicative'` |
--------------------------------------------------------------------------------
/src/prophetverse/utils/numpyro.py:
--------------------------------------------------------------------------------
1 | import numpyro
2 | from typing import Any
3 |
4 |
5 | class CacheMessenger(numpyro.primitives.Messenger):
6 | """
7 | A Messenger that remembers the first value drawn at each sample site,
8 | and on subsequent visits just returns that cached value.
9 | """
10 |
11 | def __init__(self):
12 | super().__init__(fn=None)
13 | self._cache: dict[str, Any] = {}
14 |
15 | def process_message(self, msg):
16 | # only intercept actual sample sites
17 | if msg["type"] == "sample" and msg["name"] in self._cache:
18 | # short‐circuit: return the cached value
19 |
20 | for k, v in self._cache[msg["name"]].items():
21 | msg[k] = v
22 | # Avoid errors in tracers above due to duplicated names
23 | msg["name"] = msg["name"] + "_cached_" + str(id(msg)) + ":ignore"
24 |
25 | def postprocess_message(self, msg):
26 | # after a real sample has been taken, cache it
27 | if msg["type"] == "sample":
28 | if msg["name"] not in self._cache:
29 | self._cache[msg["name"]] = msg
30 |
--------------------------------------------------------------------------------
/docs/reference/GeometricAdstockEffect.qmd:
--------------------------------------------------------------------------------
1 | # GeometricAdstockEffect { #prophetverse.effects.GeometricAdstockEffect }
2 |
3 | ```python
4 | effects.GeometricAdstockEffect(
5 | self,
6 | decay_prior=None,
7 | raise_error_if_fh_changes=False,
8 | normalize=False,
9 | )
10 | ```
11 |
12 | Represents a Geometric Adstock effect in a time series model.
13 |
14 | ## Parameters {.doc-section .doc-section-parameters}
15 |
16 | | Name | Type | Description | Default |
17 | |--------------------------|--------------|-----------------------------------------------------------------------------|------------|
18 | | decay_prior | Distribution | Prior distribution for the decay parameter (controls the rate of decay). | `None` |
19 | | rase_error_if_fh_changes | bool | Whether to raise an error if the forecasting horizon changes during predict | _required_ |
20 | | normalize | bool | If True, scales the geometric carryover so a unit impulse sums to 1 (multiplies by (1 - decay)); keeps backwards compatibility when False. | `False` |
--------------------------------------------------------------------------------
/tests/engine/test_all_inference_engines.py:
--------------------------------------------------------------------------------
1 | import numpyro
2 | from skbase.testing.test_all_objects import BaseFixtureGenerator, QuickTester
3 |
4 | from prophetverse.engine.base import BaseInferenceEngine
5 |
6 |
7 | def _model(obs):
8 |
9 | mean = numpyro.sample("mean", numpyro.distributions.Normal(0, 1))
10 | return numpyro.sample("y", numpyro.distributions.Normal(mean, 1), obs=obs)
11 |
12 |
13 | class InferenceEngineFixtureGenerator(BaseFixtureGenerator):
14 | object_type_filter = BaseInferenceEngine
15 | exclude_objects = ["InferenceEngine"]
16 | package_name = "prophetverse.engine"
17 |
18 |
19 | class TestAllInferenceEngines(InferenceEngineFixtureGenerator, QuickTester):
20 |
21 | def test_inference_converges(self, object_instance):
22 | import jax.numpy as jnp
23 | import numpy as np
24 |
25 | obs = jnp.array(np.random.normal(0, 1, 100))
26 | object_instance.infer(_model, obs=obs)
27 |
28 | assert isinstance(object_instance.posterior_samples_, dict)
29 | assert "mean" in object_instance.posterior_samples_
30 | assert jnp.isfinite(object_instance.posterior_samples_["mean"].mean().item())
31 |
--------------------------------------------------------------------------------
/src/prophetverse/engine/utils.py:
--------------------------------------------------------------------------------
1 | """Utils for inference engines."""
2 |
3 | from typing import Dict
4 |
5 | import jax.numpy as jnp
6 | import numpy as np
7 |
8 | from prophetverse.exc import ConvergenceError
9 |
10 |
11 | def assert_mcmc_converged(summary: Dict[str, Dict[str, jnp.ndarray]], max_r_hat: float):
12 | """Assert that an MCMC program has converged.
13 |
14 | Parameters
15 | ----------
16 | summary: Dict
17 | MCMC trace summary.
18 |
19 | max_r_hat: float
20 | Maximum allowed r_hat.
21 |
22 | Returns
23 | -------
24 | Nothing.
25 |
26 | Raises
27 | ------
28 | ConvergenceError
29 | """
30 | for name, parameter_summary in summary.items():
31 | # NB: some variables have deterministic elements (s.a. samples from LKJCov).
32 | mask = np.isnan(parameter_summary["n_eff"])
33 | r_hat = parameter_summary["r_hat"][~mask]
34 |
35 | if (r_hat <= max_r_hat).all():
36 | continue
37 |
38 | # TODO: might be better to print entire
39 | # summary instead of just which parameter didn't converge
40 | raise ConvergenceError(f"Parameter '{name}' did not converge! R_hat: {r_hat}")
41 |
42 | return
43 |
--------------------------------------------------------------------------------
/docs/reference/HillEffect.qmd:
--------------------------------------------------------------------------------
1 | # HillEffect { #prophetverse.effects.HillEffect }
2 |
3 | ```python
4 | effects.HillEffect(
5 | self,
6 | effect_mode='multiplicative',
7 | half_max_prior=None,
8 | slope_prior=None,
9 | max_effect_prior=None,
10 | offset_slope=0.0,
11 | input_scale=1.0,
12 | base_effect_name='trend',
13 | )
14 | ```
15 |
16 | Represents a Hill effect in a time series model.
17 |
18 | ## Parameters {.doc-section .doc-section-parameters}
19 |
20 | | Name | Type | Description | Default |
21 | |------------------|---------------------|------------------------------------------------------------|--------------------|
22 | | half_max_prior | Distribution | Prior distribution for the half-maximum parameter | `None` |
23 | | slope_prior | Distribution | Prior distribution for the slope parameter | `None` |
24 | | max_effect_prior | Distribution | Prior distribution for the maximum effect parameter | `None` |
25 | | effect_mode | effects_application | Mode of the effect (either "additive" or "multiplicative") | `'multiplicative'` |
--------------------------------------------------------------------------------
/docs/reference/ExactLikelihood.qmd:
--------------------------------------------------------------------------------
1 | # ExactLikelihood { #prophetverse.effects.ExactLikelihood }
2 |
3 | ```python
4 | effects.ExactLikelihood(self, effect_name, reference_df, prior_scale)
5 | ```
6 |
7 | Wrap an effect and applies a normal likelihood to its output.
8 |
9 | This class uses an input as a reference for the effect, and applies a normal
10 | likelihood to the output of the effect.
11 |
12 | ## Parameters {.doc-section .doc-section-parameters}
13 |
14 | | Name | Type | Description | Default |
15 | |--------------|--------------|--------------------------------------------------------------------------------------------------------------------|------------|
16 | | effect_name | str | The effect to use in the likelihood. | _required_ |
17 | | reference_df | pd.DataFrame | A dataframe with the reference values. Should be in sktime format, and must have the same index as the input data. | _required_ |
18 | | prior_scale | float | The scale of the prior distribution for the likelihood. | _required_ |
--------------------------------------------------------------------------------
/src/prophetverse/utils/deprecation.py:
--------------------------------------------------------------------------------
1 | """Utilities to handle deprecation."""
2 |
3 | import warnings
4 |
5 |
6 | def deprecation_warning(obj_name, current_version, extra_message=""):
7 | """
8 | Generate a deprecation warning for an object.
9 |
10 | Parameters
11 | ----------
12 | obj_name (str): The name of the object to be deprecated.
13 | current_version (str): The current version in the format 'major.minor.patch'.
14 |
15 | Returns
16 | -------
17 | str: A deprecation warning message.
18 | """
19 | try:
20 | # Parse the current version into components
21 | major, minor, patch = map(int, current_version.split("."))
22 | # Calculate the deprecation version (1 minor releases ahead)
23 | new_minor = minor + 1
24 | deprecation_version = f"{major}.{new_minor}.0"
25 | # Return the deprecation warning
26 | warnings.warn(
27 | f"Warning: '{obj_name}' is deprecated and will be removed in version"
28 | f" {deprecation_version}. Please update your code to avoid issues. "
29 | f"{extra_message}",
30 | FutureWarning,
31 | stacklevel=2,
32 | )
33 | except ValueError:
34 | raise ValueError("Invalid version format. Expected 'major.minor.patch'.")
35 |
--------------------------------------------------------------------------------
/docs/reference/LiftExperimentLikelihood.qmd:
--------------------------------------------------------------------------------
1 | # LiftExperimentLikelihood { #prophetverse.effects.LiftExperimentLikelihood }
2 |
3 | ```python
4 | effects.LiftExperimentLikelihood(
5 | self,
6 | effect,
7 | lift_test_results,
8 | prior_scale,
9 | likelihood_scale=1,
10 | )
11 | ```
12 |
13 | Wrap an effect and applies a normal likelihood to its output.
14 |
15 | This class uses an input as a reference for the effect, and applies a normal
16 | likelihood to the output of the effect.
17 |
18 | ## Parameters {.doc-section .doc-section-parameters}
19 |
20 | | Name | Type | Description | Default |
21 | |-------------------|--------------|---------------------------------------------------------------------------------------------------------------------|------------|
22 | | effect | BaseEffect | The effect to wrap. | _required_ |
23 | | lift_test_results | pd.DataFrame | A dataframe with the lift test results. Should be in sktime format, and must have the same index as the input data. | _required_ |
24 | | prior_scale | float | The scale of the prior distribution for the likelihood. | _required_ |
--------------------------------------------------------------------------------
/docs/reference/LinearFourierSeasonality.qmd:
--------------------------------------------------------------------------------
1 | # LinearFourierSeasonality { #prophetverse.effects.LinearFourierSeasonality }
2 |
3 | ```python
4 | effects.LinearFourierSeasonality(
5 | self,
6 | sp_list,
7 | fourier_terms_list,
8 | freq,
9 | prior_scale=1.0,
10 | effect_mode='additive',
11 | linear_effect=None,
12 | )
13 | ```
14 |
15 | Linear Fourier Seasonality effect.
16 |
17 | Compute the linear seasonality using Fourier features.
18 |
19 | ## Parameters {.doc-section .doc-section-parameters}
20 |
21 | | Name | Type | Description | Default |
22 | |--------------------|---------------|----------------------------------------------------------------------------|--------------|
23 | | sp_list | List\[float\] | List of seasonal periods. | _required_ |
24 | | fourier_terms_list | List\[int\] | List of number of Fourier terms to use for each seasonal period. | _required_ |
25 | | freq | str | Frequency of the time series. Example: "D" for daily, "W" for weekly, etc. | _required_ |
26 | | prior_scale | float | Scale of the prior distribution for the effect, by default 1.0. | `1.0` |
27 | | effect_mode | str | Either "multiplicative" or "additive" by default "additive". | `'additive'` |
--------------------------------------------------------------------------------
/src/prophetverse/utils/algebric_operations.py:
--------------------------------------------------------------------------------
1 | """Functions that perform algebraic operations."""
2 |
3 | from typing import Union
4 |
5 | import jax.numpy as jnp
6 | from numpy.typing import NDArray
7 |
8 |
9 | def matrix_multiplication(
10 | data: Union[jnp.ndarray, NDArray], coefficients: Union[jnp.ndarray, NDArray]
11 | ) -> Union[jnp.ndarray, NDArray]:
12 | """Perform matrix multiplication between two matrixes.
13 |
14 | Parameters
15 | ----------
16 | data : Union[jnp.ndarray, NDArray]
17 | Array to be multiplied.
18 | coefficients : Union[jnp.ndarray, NDArray]
19 | Array of coefficients used at matrix multiplication.
20 |
21 | Returns
22 | -------
23 | Union[jnp.ndarray, NDArray]
24 | Matrix multiplication between data and coefficients.
25 | """
26 | return data @ coefficients.reshape((-1, 1))
27 |
28 |
29 | def _exponent_safe(data: Union[jnp.ndarray, NDArray], exponent: float) -> jnp.ndarray:
30 | """Exponentiate an array without numerical errors replacing zeros with ones.
31 |
32 | Parameters
33 | ----------
34 | data : Union[jnp.ndarray, NDArray]
35 | Array to be exponentiated.
36 | exponent : float
37 | Expoent numerical value.
38 |
39 | Returns
40 | -------
41 | jnp.ndarray
42 | Exponentiated array.
43 | """
44 | exponent_safe = jnp.where(data == 0, 1, data) ** exponent
45 | return jnp.where(data == 0, 0, exponent_safe)
46 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/forward.py:
--------------------------------------------------------------------------------
1 | """Constant effect module."""
2 |
3 | import jax.numpy as jnp
4 | import numpyro
5 | import numpyro.distributions as dist
6 | from numpyro.distributions import Distribution
7 |
8 | from prophetverse.effects.base import BaseEffect
9 |
10 |
11 | class Forward(BaseEffect):
12 | """Forward effect.
13 |
14 | Forwards a previously fitted effect.
15 |
16 | Parameters
17 | ----------
18 | prior : Distribution, optional
19 | The prior distribution for the constant coefficient, by default None
20 | which corresponds to a standard normal distribution.
21 | """
22 |
23 | _tags = {
24 | "requires_X": False,
25 | }
26 |
27 | def __init__(self, effect_name: str) -> None:
28 | self.effect_name = effect_name
29 | super().__init__()
30 |
31 | def _predict( # type: ignore[override]
32 | self, data: jnp.ndarray, predicted_effects: dict, *args, **kwargs
33 | ) -> jnp.ndarray:
34 | """Forwards the effect
35 |
36 | Parameters
37 | ----------
38 | constant_vector : jnp.ndarray
39 | A constant vector with the size of the series time indexes
40 |
41 | Returns
42 | -------
43 | jnp.ndarray
44 | The forecasted trend
45 | """
46 | # Alias for clarity
47 |
48 | return predicted_effects[self.effect_name]
49 |
50 | @classmethod
51 | def get_test_params(cls, parameter_set="default"):
52 | return [{"effect_name": "trend"}]
53 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/identity.py:
--------------------------------------------------------------------------------
1 | """Store input to predicted_effect"""
2 |
3 | from typing import Any, Dict, Optional
4 |
5 | import jax.numpy as jnp
6 | import pandas as pd
7 |
8 | from prophetverse.effects.base import BaseEffect
9 |
10 | __all__ = ["Identity"]
11 |
12 |
13 | class Identity(BaseEffect):
14 | """
15 | Return the input as the predicted effect.
16 |
17 | This effect simply returns the input data during prediction without any
18 | modification.
19 | """
20 |
21 | _tags = {
22 | "requires_X": True, # Default value, will be overridden in __init__
23 | "applies_to": "X",
24 | }
25 |
26 | def __init__(self):
27 |
28 | super().__init__()
29 |
30 | def _transform(self, X, fh):
31 | if X is None:
32 | raise ValueError("Input X cannot be None in _transform method.")
33 | return super()._transform(X, fh)
34 |
35 | def _predict(
36 | self,
37 | data: Any,
38 | predicted_effects: Dict[str, jnp.ndarray],
39 | *args,
40 | **kwargs,
41 | ) -> jnp.ndarray:
42 | """Return the data
43 |
44 | Parameters
45 | ----------
46 | data : Any
47 | Data obtained from the transformed method (ignored).
48 |
49 | predicted_effects : Dict[str, jnp.ndarray]
50 | A dictionary containing the predicted effects (ignored).
51 |
52 | Returns
53 | -------
54 | jnp.ndarray
55 | Zero.
56 | """
57 | return data
58 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: Python CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - 'src/**'
9 | - 'tests/**'
10 | pull_request:
11 | branches:
12 | - main
13 | - develop
14 | - release/*
15 | paths:
16 | - 'src/**'
17 | - 'tests/**'
18 | workflow_dispatch:
19 |
20 | concurrency:
21 | group: ${{ github.workflow }}-${{ github.ref }}
22 | cancel-in-progress: true
23 |
24 | jobs:
25 | build:
26 |
27 | strategy:
28 | fail-fast: false
29 | matrix:
30 | python-version: ['3.10', '3.11', '3.12', '3.13']
31 | os: [ubuntu-latest, macos-latest, windows-latest]
32 |
33 | runs-on: ${{ matrix.os }}
34 |
35 | steps:
36 | - uses: actions/checkout@v4
37 |
38 | - name: Set up Python
39 | uses: actions/setup-python@v6
40 | with:
41 | python-version: ${{ matrix.python-version }}
42 |
43 | - name: Install dependencies
44 | run: |
45 | python -m pip install --upgrade pip
46 | pip install ".[dev]"
47 |
48 | - name: Set PYTHONPATH
49 | run: echo "PYTHONPATH=$GITHUB_WORKSPACE/src" >> $GITHUB_ENV
50 |
51 | - name: Test with pytest
52 | run: python -m pytest --cov=prophetverse --cov-report=xml -m "not smoke" --durations=10
53 |
54 | - name: Upload coverage reports to Codecov
55 | uses: codecov/codecov-action@v5.5.1
56 | with:
57 | files: ./coverage.xml
58 | fail_ci_if_error: true
59 | env:
60 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
61 |
--------------------------------------------------------------------------------
/src/prophetverse/datasets/synthetic/_composite_effect_example.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pandas as pd
3 |
4 |
5 | def load_composite_effect_example():
6 | """
7 | Load a synthetic time series with a composite effect.
8 |
9 | Returns
10 | -------
11 | pd.DataFrame
12 | DataFrame containing the time series data.
13 | """
14 | rng = np.random.default_rng(0)
15 | timeindex = pd.period_range(
16 | start="2010-01-01", freq="D", periods=365 * 7, name="time"
17 | )
18 |
19 | t = np.arange(len(timeindex))
20 |
21 | w = np.ones(100) / 100
22 | trend = np.ones(len(t)) * t / 20 + 10
23 |
24 | seasonality = (
25 | np.sin(2 * np.pi * t / 365.25) * 0.7
26 | + np.sin(2 * np.pi * t / 365.25 * 2) * 1
27 | # + np.sin(2 * np.pi * t / 365.25 * 3) * 0.5
28 | # + np.sin(2 * np.pi * t / 365.25 * 4) * 0.5
29 | ) * 0.8 + 1
30 |
31 | exog = np.clip(rng.normal(0.1, 1, size=len(t)), 0, None)
32 | # rolling mean
33 | w = np.ones(15) / 15
34 | exog = np.convolve(exog, w, mode="same")
35 | exog -= np.min(exog)
36 | exog_effect = exog * 0.5
37 | noise = rng.normal(0, 0.1, size=len(t))
38 | y = pd.DataFrame(
39 | data={
40 | "target": trend * (1 + exog_effect + seasonality + noise)
41 | + trend * exog * (seasonality - seasonality.min() + 1) * 2
42 | },
43 | index=timeindex,
44 | )
45 |
46 | X = pd.DataFrame(
47 | {
48 | "investment": exog,
49 | },
50 | index=timeindex,
51 | )
52 | return y, X
53 |
--------------------------------------------------------------------------------
/tests/experimental/test_simulate.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import numpy as np
3 | import pandas as pd
4 | import pytest
5 |
6 | from prophetverse.experimental.simulate import simulate
7 | from prophetverse.sktime import HierarchicalProphet, Prophetverse
8 |
9 |
10 | @pytest.mark.parametrize(
11 | "model,do",
12 | [
13 | (HierarchicalProphet(), {"exogenous_variables_effect/coefs": jnp.array([1])}),
14 | (Prophetverse(), {"exogenous_variables_effect/coefs": jnp.array([1])}),
15 | ],
16 | )
17 | def test_simulate(model, do):
18 | # NB: the new inference engine behaviour causes issues with convergence, so we fix by seeding for now, but
19 | # we might need to override r_hat in `simulate` if the inference engine has that property for a more sustainable fix
20 | np.random.seed(123)
21 |
22 | fh = pd.period_range(start="2022-01-01", periods=50, freq="M")
23 | X = pd.DataFrame(index=fh, data={"x1": list(range(len(fh)))})
24 | samples, model = simulate(model=model, fh=fh, X=X, do=do, return_model=True)
25 | assert isinstance(samples, pd.DataFrame)
26 | assert (
27 | samples.index.get_level_values(0).nunique()
28 | == model.inference_engine_.num_samples
29 | )
30 | assert samples.index.get_level_values(-1).nunique() == len(fh)
31 |
32 | expected_intervention = jnp.arange(len(fh)) * model._scale
33 | assert jnp.allclose(
34 | samples["exogenous_variables_effect"].values,
35 | jnp.tile(
36 | expected_intervention,
37 | (model.inference_engine_.num_samples,),
38 | ).flatten(),
39 | )
40 |
--------------------------------------------------------------------------------
/docs/reference/_sidebar.yml:
--------------------------------------------------------------------------------
1 | website:
2 | sidebar:
3 | - contents:
4 | - reference/index.qmd
5 | - contents:
6 | - reference/Prophetverse.qmd
7 | - reference/HierarchicalProphet.qmd
8 | section: Sktime
9 | - contents:
10 | - reference/LinearEffect.qmd
11 | - reference/LinearFourierSeasonality.qmd
12 | - reference/LogEffect.qmd
13 | - reference/HillEffect.qmd
14 | - reference/ChainedEffects.qmd
15 | - reference/GeometricAdstockEffect.qmd
16 | section: Exogenous effects
17 | - contents:
18 | - reference/LiftExperimentLikelihood.qmd
19 | - reference/ExactLikelihood.qmd
20 | section: MMM Likelihoods
21 | - contents:
22 | - reference/PiecewiseLinearTrend.qmd
23 | - reference/PiecewiseLogisticTrend.qmd
24 | - reference/FlatTrend.qmd
25 | section: Trends
26 | - contents:
27 | - reference/MultivariateNormal.qmd
28 | - reference/NormalTargetLikelihood.qmd
29 | - reference/GammaTargetLikelihood.qmd
30 | - reference/NegativeBinomialTargetLikelihood.qmd
31 | section: Target Likelihoods
32 | - contents:
33 | - reference/BudgetOptimizer.qmd
34 | section: Budget Optimization
35 | - contents:
36 | - reference/TotalBudgetConstraint.qmd
37 | - reference/MinimumTargetResponse.qmd
38 | section: Budget Constraints
39 | - contents:
40 | - reference/MinimizeBudget.qmd
41 | - reference/MaximizeKPI.qmd
42 | - reference/MaximizeROI.qmd
43 | section: Objective Functions
44 | - contents: []
45 | section: Budget Parametrizations
46 | id: reference
47 | - id: dummy-sidebar
48 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/constant.py:
--------------------------------------------------------------------------------
1 | """Constant effect module."""
2 |
3 | import jax.numpy as jnp
4 | import numpyro
5 | import numpyro.distributions as dist
6 | from numpyro.distributions import Distribution
7 |
8 | from prophetverse.effects.base import BaseEffect
9 |
10 |
11 | class Constant(BaseEffect):
12 | """Constant effect.
13 |
14 | Implements a constant effect.
15 |
16 | Parameters
17 | ----------
18 | prior : Distribution, optional
19 | The prior distribution for the constant coefficient, by default None
20 | which corresponds to a standard normal distribution.
21 | """
22 |
23 | _tags = {
24 | "requires_X": False,
25 | }
26 |
27 | def __init__(self, prior: Distribution = None) -> None:
28 | self.prior = prior
29 | super().__init__()
30 |
31 | self._prior = prior
32 | if self._prior is None:
33 | self._prior = dist.Normal(0, 1)
34 |
35 | def _transform(self, X, fh):
36 | return jnp.ones((len(fh), 1))
37 |
38 | def _predict( # type: ignore[override]
39 | self, data: jnp.ndarray, predicted_effects: dict, *args, **kwargs
40 | ) -> jnp.ndarray:
41 | """Apply the trend.
42 |
43 | Parameters
44 | ----------
45 | constant_vector : jnp.ndarray
46 | A constant vector with the size of the series time indexes
47 |
48 | Returns
49 | -------
50 | jnp.ndarray
51 | The forecasted trend
52 | """
53 | # Alias for clarity
54 |
55 | coefficient = numpyro.sample("constant_coefficient", self._prior)
56 |
57 | return coefficient * jnp.ones_like(data)
58 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release Workflow
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | verify-and-publish:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Check out code
12 | uses: actions/checkout@v2
13 |
14 | - name: Set up Python
15 | uses: actions/setup-python@v6
16 | with:
17 | python-version: 3.x
18 |
19 | - name: Install Poetry
20 | run: pip install poetry
21 |
22 | - name: Extract tag version
23 | run: echo "TAG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
24 |
25 | - name: Extract tag version without pre-release identifiers
26 | run: |
27 | TAG_VERSION="${GITHUB_REF#refs/tags/v}"
28 | CLEAN_TAG_VERSION=$(echo $TAG_VERSION | awk -F- '{print $1}')
29 | echo $CLEAN_TAG_VERSION
30 | echo "CLEAN_TAG_VERSION=$CLEAN_TAG_VERSION" >> $GITHUB_ENV
31 |
32 | - name: Get current package version
33 | run: |
34 | PACKAGE_VERSION=$(poetry version --short)
35 | echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV
36 |
37 | - name: Verify versions match
38 | run: |
39 | if [ "$CLEAN_TAG_VERSION" != "$PACKAGE_VERSION" ]; then
40 | echo "Error: Tag version does not match the pyproject.toml version"
41 | exit 1
42 | fi
43 |
44 | - name: Set version to match the release tag
45 | run: |
46 | echo "Setting package version to $TAG_VERSION"
47 | poetry version "$TAG_VERSION"
48 |
49 | - name: Publish to PyPI
50 | env:
51 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
52 | run: |
53 | poetry build
54 | poetry publish
55 |
--------------------------------------------------------------------------------
/tests/engine/test_utils.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import numpyro
3 | import pytest
4 | from jax.random import PRNGKey
5 | from numpyro.diagnostics import summary
6 | from numpyro.distributions import LKJCholesky, Normal
7 | from numpyro.infer import MCMC, NUTS
8 |
9 | from prophetverse.engine.utils import assert_mcmc_converged
10 | from prophetverse.exc import ConvergenceError
11 |
12 |
13 | @pytest.fixture
14 | def rng_key():
15 | return PRNGKey(123)
16 |
17 |
18 | @pytest.fixture
19 | def shape():
20 | return 4, 1_000
21 |
22 |
23 | @pytest.fixture
24 | def converged_summary(rng_key, shape):
25 | samples = {
26 | "alpha": Normal().sample(rng_key, shape),
27 | "beta": LKJCholesky(4).sample(rng_key, shape),
28 | "sigma": Normal().expand((2, 2)).to_event(2).sample(rng_key, shape),
29 | }
30 |
31 | return summary(samples, group_by_chain=len(shape) > 1)
32 |
33 |
34 | @pytest.fixture
35 | def not_converged_summary(rng_key, shape):
36 | def model():
37 | numpyro.sample("beta", LKJCholesky(4))
38 | return
39 |
40 | kernel = NUTS(model)
41 | mcmc = MCMC(kernel, num_warmup=5, num_samples=5, num_chains=4, progress_bar=False)
42 | mcmc.run(rng_key)
43 |
44 | samples = mcmc.get_samples(group_by_chain=True)
45 |
46 | _summary = summary(samples, group_by_chain=True)
47 | for name in _summary:
48 | _summary[name]["r_hat"] = _summary[name]["r_hat"] * np.inf
49 | return _summary
50 |
51 |
52 | def test_assert_converged(converged_summary):
53 |
54 | assert_mcmc_converged(converged_summary, max_r_hat=1.1)
55 |
56 |
57 | def test_assert_error(not_converged_summary):
58 |
59 | with pytest.raises(ConvergenceError):
60 | assert_mcmc_converged(not_converged_summary, max_r_hat=1.1)
61 |
--------------------------------------------------------------------------------
/tests/distributions/test_gamma.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from jax import random
3 | from numpyro import distributions as dist
4 |
5 | from prophetverse.distributions import GammaReparametrized
6 |
7 |
8 | @pytest.mark.parametrize(
9 | "loc, scale",
10 | [
11 | (1.0, 1.0),
12 | (10.0, 1.0),
13 | (5.0, 2.0),
14 | ],
15 | )
16 | def test_gamma_reparametrized_init(loc, scale):
17 | dist_test = GammaReparametrized(loc, scale)
18 | assert dist_test.loc == loc
19 | assert dist_test.scale == scale
20 | assert isinstance(dist_test, GammaReparametrized)
21 |
22 |
23 | @pytest.mark.parametrize(
24 | "loc, scale",
25 | [
26 | (1.0, 1.0),
27 | (10.0, 1.0),
28 | (5.0, 2.0),
29 | ],
30 | )
31 | def test_gamma_reparametrized_moments(loc, scale):
32 | dist_test = GammaReparametrized(loc, scale)
33 | mean_expected = loc
34 | var_expected = scale**2
35 | assert dist_test.mean == pytest.approx(mean_expected)
36 | assert dist_test.variance == pytest.approx(var_expected)
37 |
38 |
39 | def test_gamma_reparametrized_sample_shape():
40 | key = random.PRNGKey(0)
41 | dist_test = GammaReparametrized(10.0, 1.0)
42 | samples = dist_test.sample(key, (100,))
43 | assert samples.shape == (100,)
44 |
45 |
46 | @pytest.mark.parametrize("value", [0.5, 1.5, 3.5])
47 | def test_gamma_reparametrized_log_prob(value):
48 | loc = 10.0
49 | scale = 2.0
50 | dist_test = GammaReparametrized(loc, scale)
51 | rate = loc / (scale**2)
52 | concentration = loc * rate
53 | dist_standard = dist.Gamma(rate=rate, concentration=concentration)
54 | log_prob_reparam = dist_test.log_prob(value)
55 | log_prob_standard = dist_standard.log_prob(value)
56 | assert log_prob_reparam == pytest.approx(log_prob_standard)
57 |
--------------------------------------------------------------------------------
/docs/reference/WeibullAdstockEffect.qmd:
--------------------------------------------------------------------------------
1 | # WeibullAdstockEffect { #prophetverse.effects.WeibullAdstockEffect }
2 |
3 | ```python
4 | effects.WeibullAdstockEffect(
5 | self,
6 | scale_prior=None,
7 | concentration_prior=None,
8 | max_lag=None,
9 | raise_error_if_fh_changes=False,
10 | )
11 | ```
12 |
13 | Represents a Weibull Adstock effect in a time series model.
14 |
15 | The Weibull adstock applies a convolution of the input with a Weibull probability density function, allowing for more flexible carryover patterns compared to geometric adstock.
16 |
17 | ## Parameters {.doc-section .doc-section-parameters}
18 |
19 | | Name | Type | Description | Default |
20 | |--------------------------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------|------------|
21 | | scale_prior | Distribution | Prior distribution for the scale parameter of the Weibull distribution. If None, defaults to GammaReparametrized(2, 1). | `None` |
22 | | concentration_prior | Distribution | Prior distribution for the concentration (shape) parameter of the Weibull distribution. If None, defaults to GammaReparametrized(2, 1). | `None` |
23 | | max_lag | int | Maximum lag to consider for the adstock effect. If None, automatically determined based on the Weibull distribution parameters. | `None` |
24 | | raise_error_if_fh_changes| bool | Whether to raise an error if the forecasting horizon changes during predict | `False` |
--------------------------------------------------------------------------------
/docs/reference/MichaelisMentenEffect.qmd:
--------------------------------------------------------------------------------
1 | # MichaelisMentenEffect { #prophetverse.effects.MichaelisMentenEffect }
2 |
3 | ```python
4 | effects.MichaelisMentenEffect(
5 | self,
6 | effect_mode='multiplicative',
7 | max_effect_prior=None,
8 | half_saturation_prior=None,
9 | base_effect_name='trend',
10 | )
11 | ```
12 |
13 | Represents a Michaelis-Menten effect in a time series model.
14 |
15 | The Michaelis-Menten equation is commonly used in biochemistry to describe
16 | enzyme kinetics, but it's also useful for modeling saturation effects in
17 | time series analysis. The effect follows the equation:
18 |
19 | effect = (max_effect * data) / (half_saturation + data)
20 |
21 | Where:
22 | - max_effect is the maximum effect value (Vmax in biochemistry)
23 | - half_saturation is the value at which effect = max_effect/2 (Km in biochemistry)
24 | - data is the input variable (substrate concentration in biochemistry)
25 |
26 | ## Parameters {.doc-section .doc-section-parameters}
27 |
28 | | Name | Type | Description | Default |
29 | |-----------------------|--------------------------|--------------------------------------------------------------------|--------------------|
30 | | max_effect_prior | Optional\[Distribution\] | Prior distribution for the maximum effect parameter | `None` |
31 | | half_saturation_prior | Optional\[Distribution\] | Prior distribution for the half-saturation parameter | `None` |
32 | | effect_mode | effects_application | Either "additive" or "multiplicative", by default "multiplicative" | `'multiplicative'` |
33 | | base_effect_name | str | Name of the base effect to multiply with (if multiplicative) | `'trend'` |
--------------------------------------------------------------------------------
/tests/trend/test_flat.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import numpyro
3 | import pandas as pd
4 | import pytest
5 | from numpy.testing import assert_almost_equal
6 |
7 | from prophetverse.effects.trend.flat import (
8 | FlatTrend, # Assuming this is the import path
9 | )
10 |
11 |
12 | @pytest.fixture
13 | def trend_model():
14 | return FlatTrend(changepoint_prior_scale=0.1)
15 |
16 |
17 | @pytest.fixture
18 | def timeseries_data():
19 | date_rng = pd.date_range(start="1/1/2022", end="1/10/2022", freq="D")
20 | df = pd.DataFrame(date_rng, columns=["date"])
21 | df["data"] = jnp.arange(len(date_rng))
22 | df = df.set_index("date")
23 | return df
24 |
25 |
26 | def test_initialization(trend_model):
27 | assert trend_model.changepoint_prior_scale == 0.1
28 |
29 |
30 | def test_initialize(trend_model, timeseries_data):
31 | trend_model.fit(X=None, y=timeseries_data)
32 | expected_loc = timeseries_data["data"].mean()
33 | assert_almost_equal(trend_model.changepoint_prior_loc, expected_loc)
34 |
35 |
36 | def test_fit(trend_model, timeseries_data):
37 | idx = timeseries_data.index
38 | trend_model.fit(X=None, y=timeseries_data)
39 | result = trend_model.transform(X=pd.DataFrame(index=timeseries_data.index), fh=idx)
40 | assert result.shape == (len(idx), 1)
41 | assert jnp.all(result == 1)
42 |
43 |
44 | def test_compute_trend(trend_model, timeseries_data):
45 | idx = timeseries_data.index
46 | trend_model.fit(X=None, y=timeseries_data)
47 | constant_vector = trend_model.transform(
48 | X=pd.DataFrame(index=timeseries_data.index), fh=idx
49 | )
50 |
51 | with numpyro.handlers.seed(rng_seed=0):
52 | trend_result = trend_model.predict(constant_vector, None)
53 |
54 | assert jnp.unique(trend_result).shape == (1,)
55 | assert trend_result.shape == (len(idx), 1)
56 |
--------------------------------------------------------------------------------
/tests/effects/test_linear.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import numpyro
3 | import pytest
4 | from numpyro import distributions as dist
5 | from numpyro.handlers import seed
6 |
7 | from prophetverse.effects.linear import LinearEffect
8 |
9 |
10 | @pytest.fixture
11 | def linear_effect_multiplicative():
12 | return LinearEffect(prior=dist.Delta(1.0), effect_mode="multiplicative")
13 |
14 |
15 | @pytest.fixture
16 | def linear_effect_additive():
17 | return LinearEffect(prior=dist.Delta(1.0), effect_mode="additive")
18 |
19 |
20 | def test_initialization_defaults():
21 | linear_effect = LinearEffect()
22 | assert isinstance(linear_effect._prior, dist.Normal)
23 | assert linear_effect._prior.loc == 0
24 | assert linear_effect._prior.scale == 0.1
25 | assert linear_effect.effect_mode == "multiplicative"
26 |
27 |
28 | def test__predict_multiplicative(linear_effect_multiplicative):
29 | trend = jnp.array([1.0, 2.0, 3.0]).reshape((-1, 1))
30 | data = jnp.array([[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]])
31 |
32 | with seed(numpyro.handlers.seed, 0):
33 | result = linear_effect_multiplicative.predict(
34 | data=data, predicted_effects={"trend": trend}
35 | )
36 |
37 | expected_result = trend * (data @ jnp.array([1.0, 1.0]).reshape((-1, 1)))
38 |
39 | assert jnp.allclose(result, expected_result)
40 |
41 |
42 | def test__predict_additive(linear_effect_additive):
43 | trend = jnp.array([1.0, 2.0, 3.0]).reshape((-1, 1))
44 | data = jnp.array([[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]])
45 |
46 | with seed(numpyro.handlers.seed, 0):
47 | result = linear_effect_additive.predict(
48 | data=data, predicted_effects={"trend": trend}
49 | )
50 |
51 | expected_result = data @ jnp.array([1.0, 1.0]).reshape((-1, 1))
52 |
53 | assert jnp.allclose(result, expected_result)
54 |
--------------------------------------------------------------------------------
/src/prophetverse/utils/plotting.py:
--------------------------------------------------------------------------------
1 | import matplotlib.pyplot as plt
2 | import numpy as np
3 | import pandas as pd
4 | from prophetverse.effects.base import BaseEffect
5 |
6 |
7 | def plot_prior_predictive(
8 | effect: BaseEffect,
9 | X=None,
10 | y=None,
11 | predicted_effects=None,
12 | num_samples=1000,
13 | coverage=0.95,
14 | matplotlib_kwargs=None,
15 | mode="time",
16 | ):
17 |
18 | if (X is not None) and (X.index.nlevels > 1): # pragma : no cover
19 | raise ValueError("This utility does not work with panel data yet.")
20 |
21 | if (y is not None) and (y.index.nlevels > 1):
22 | raise ValueError("This utility does not work with panel data yet.")
23 |
24 | samples = effect.sample_prior(
25 | X=X,
26 | y=y,
27 | num_samples=num_samples,
28 | predicted_effects=predicted_effects,
29 | as_pandas=True,
30 | seed=42,
31 | ).unstack(level=0)
32 |
33 | idx = samples.index.get_level_values(-1).unique()
34 |
35 | if isinstance(idx, pd.PeriodIndex):
36 | idx = idx.to_timestamp()
37 |
38 | alpha = (1 - coverage) / 2
39 | quantiles = [alpha, 1 - alpha]
40 |
41 | prior_samples = samples.quantile(quantiles, axis=1).T
42 |
43 | matplotlib_kwargs = {} if matplotlib_kwargs is None else matplotlib_kwargs
44 |
45 | fig, ax = plt.subplots(**matplotlib_kwargs)
46 |
47 | if mode == "time":
48 | _x = idx
49 | argsort = np.arange(len(idx))
50 | else:
51 | _x = X[mode].values.flatten()
52 | argsort = np.argsort(_x)
53 |
54 | ax.fill_between(
55 | x=_x[argsort],
56 | y1=prior_samples[quantiles[0]].values.flatten()[argsort],
57 | y2=prior_samples[quantiles[1]].values.flatten()[argsort],
58 | alpha=0.2,
59 | )
60 |
61 | ax.plot(_x[argsort], samples.mean(axis=1).iloc[argsort])
62 |
63 | return fig, ax
64 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/__init__.py:
--------------------------------------------------------------------------------
1 | """Effects that define relationships between variables and the target."""
2 |
3 | from .adstock import GeometricAdstockEffect, WeibullAdstockEffect
4 | from .base import BaseEffect
5 | from .ignore_input import IgnoreInput
6 | from .chain import ChainedEffects
7 | from .exact_likelihood import ExactLikelihood
8 | from .fourier import LinearFourierSeasonality
9 | from .hill import HillEffect
10 | from .lift_likelihood import LiftExperimentLikelihood
11 | from .linear import LinearEffect
12 | from .log import LogEffect
13 | from .michaelis_menten import MichaelisMentenEffect
14 | from .target.multivariate import MultivariateNormal
15 | from .target.univariate import (
16 | NormalTargetLikelihood,
17 | GammaTargetLikelihood,
18 | NegativeBinomialTargetLikelihood,
19 | BetaTargetLikelihood,
20 | )
21 | from .constant import Constant
22 | from .forward import Forward
23 | from .trend import PiecewiseLinearTrend, PiecewiseLogisticTrend, FlatTrend
24 | from .operations import MultiplyEffects, SumEffects
25 | from .identity import Identity
26 | from .coupled import CoupledExactLikelihood
27 | from .constant import Constant
28 |
29 | __all__ = [
30 | "BaseEffect",
31 | "IgnoreInput",
32 | "HillEffect",
33 | "LinearEffect",
34 | "LogEffect",
35 | "MichaelisMentenEffect",
36 | "ExactLikelihood",
37 | "LiftExperimentLikelihood",
38 | "LinearFourierSeasonality",
39 | "GeometricAdstockEffect",
40 | "WeibullAdstockEffect",
41 | "ChainedEffects",
42 | "MultivariateNormal",
43 | "NormalTargetLikelihood",
44 | "GammaTargetLikelihood",
45 | "NegativeBinomialTargetLikelihood",
46 | "BetaTargetLikelihood",
47 | "PiecewiseLinearTrend",
48 | "PiecewiseLogisticTrend",
49 | "FlatTrend",
50 | "Forward",
51 | "MultiplyEffects",
52 | "SumEffects",
53 | "Constant",
54 | "Identity",
55 | "CoupledExactLikelihood",
56 | "Constant",
57 | ]
58 |
--------------------------------------------------------------------------------
/tests/effects/test_all_target_likelihoods.py:
--------------------------------------------------------------------------------
1 | from skbase.testing.test_all_objects import TestAllObjects, BaseFixtureGenerator
2 | from prophetverse.effects.target.base import BaseTargetEffect
3 | import jax.numpy as jnp
4 | import numpyro
5 | from sktime.utils._testing.series import _make_series
6 |
7 |
8 | class TargetEffectFixtureGenerator(BaseFixtureGenerator):
9 | """Fixture for testing all target likelihoods."""
10 |
11 | package_name = "prophetverse.effects.target"
12 | object_type_filter = BaseTargetEffect
13 |
14 |
15 | class TestAllTargetEffects(TargetEffectFixtureGenerator, TestAllObjects):
16 |
17 | valid_tags = [
18 | "capability:panel",
19 | "capability:multivariate_input",
20 | "requires_X",
21 | "applies_to",
22 | "filter_indexes_with_forecating_horizon_at_transform",
23 | "requires_fit_before_transform",
24 | "fitted_named_object_parameters",
25 | "named_object_parameters",
26 | "feature:panel_hyperpriors",
27 | "hierarchical_prophet_compliant",
28 | ]
29 |
30 | def test_applies_to_tag(self, object_instance):
31 | assert object_instance.get_tag("applies_to", None) == "y"
32 |
33 | def test_fit_transform_predict_sites(self, object_instance):
34 | """Test site names and no exceptions"""
35 | y = _make_series(50).to_frame("value")
36 | object_instance.fit(y=y, X=None)
37 |
38 | data = object_instance.transform(y, fh=y.index)
39 |
40 | predicted_effects = {
41 | "trend": jnp.ones(len(y)) * 0.5,
42 | }
43 | with numpyro.handlers.seed(rng_seed=42):
44 | with numpyro.handlers.trace() as trace:
45 | predictions = object_instance.predict(data, predicted_effects)
46 |
47 | assert "obs" in trace
48 | assert trace["obs"]["is_observed"]
49 | assert all(trace["obs"]["value"].flatten() == y.values.flatten())
50 | assert "mean" in trace
51 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/coupled.py:
--------------------------------------------------------------------------------
1 | """Coupled effects"""
2 |
3 | from typing import Any, Dict
4 |
5 | import jax.numpy as jnp
6 | import numpyro
7 | import numpyro.distributions as dist
8 | import pandas as pd
9 |
10 | from prophetverse.utils.frame_to_array import series_to_tensor_or_array
11 |
12 | from .base import BaseEffect
13 |
14 | __all__ = ["CoupledExactLikelihood"]
15 |
16 |
17 | class CoupledExactLikelihood(BaseEffect):
18 | """Link two effects via a Normal likelihood"""
19 |
20 | _tags = {"requires_X": False, "hierarchical_prophet_compliant": False}
21 |
22 | def __init__(
23 | self,
24 | source_effect_name: str,
25 | target_effect_name: str,
26 | prior_scale: float,
27 | ):
28 | self.source_effect_name = source_effect_name
29 | self.target_effect_name = target_effect_name
30 | self.prior_scale = prior_scale
31 | assert prior_scale > 0, "prior_scale must be greater than 0"
32 | super().__init__()
33 |
34 | def _fit(self, y: pd.DataFrame, X: pd.DataFrame, scale: float = 1):
35 | self.timeseries_scale = scale
36 |
37 | def _transform(self, X: pd.DataFrame, fh: pd.Index) -> Dict[str, Any]:
38 | return {"data": None}
39 |
40 | def _predict(
41 | self, data: Dict, predicted_effects: Dict[str, jnp.ndarray], *args, **kwargs
42 | ) -> jnp.ndarray:
43 | source = predicted_effects[self.source_effect_name]
44 | target = predicted_effects[self.target_effect_name]
45 | numpyro.sample(
46 | f"coupled_exact_likelihood:{self.target_effect_name}",
47 | dist.Normal(source, self.prior_scale),
48 | obs=target,
49 | )
50 | return target
51 |
52 | @classmethod
53 | def get_test_params(cls, parameter_set: str = "default"):
54 | return [
55 | {
56 | "source_effect_name": "trend",
57 | "target_effect_name": "trend",
58 | "prior_scale": 0.1,
59 | }
60 | ]
61 |
--------------------------------------------------------------------------------
/docs/howto/index.qmd:
--------------------------------------------------------------------------------
1 | ---
2 | title: How-to
3 | description: How to create more advanced patterns
4 | ---
5 |
6 | In this documentation section, you will find how you can create more advanced patterns
7 |
8 |
9 | ```{=html}
10 |
11 |
12 |
13 |
14 |
**Custom Time Series Component**
15 |
Learn how to craft a custom time series component and unlock powerful forecasting enhancements.
16 |
→ Custom Effect
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
Custom Trend
25 |
Follow a hands-on example to create a custom trend component and integrate it seamlessly into your forecasts.
26 |
→ Custom Trend
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
Composition of Time Series Components
35 |
Discover how to combine multiple time series components for more nuanced and accurate forecasting models.
36 |
→ Composite Exogenous Effect
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
Beta % Forecast
45 |
Recipe: model a bounded percentage using a Beta likelihood.
46 |
→ Beta % Forecast
47 |
48 |
49 |
50 |
51 | ```
--------------------------------------------------------------------------------
/tests/budget_optimization/test_objectives.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import pytest
3 | from prophetverse.budget_optimization.objectives import (
4 | MaximizeROI,
5 | MaximizeKPI,
6 | MinimizeBudget,
7 | )
8 |
9 |
10 | class DummyOptimizer:
11 | def __init__(self, predictive_array, horizon_idx):
12 | # enforce ndarray types
13 | self._arr = jnp.array(predictive_array)
14 | self.horizon_idx_ = jnp.array(horizon_idx)
15 |
16 | def predictive_(self, x):
17 | return self._arr
18 |
19 |
20 | def test_maximize_roi_basic():
21 | x = jnp.array([1.0, 2.0])
22 | # predictive returns two samples over three horizons
23 | pred = jnp.array(
24 | [
25 | [[1.0], [2.0], [3.0]],
26 | [[1.0], [2.0], [3.0]],
27 | ]
28 | )
29 | optimizer = DummyOptimizer(pred, jnp.array([1, 2]))
30 | obj = MaximizeROI()
31 | # mean over samples => [1,2,3]; pick idx [1,2] => [2,3], sum=5; spend=1+2=3 => -5/3
32 | result = float(obj._objective(x, optimizer))
33 | assert result == pytest.approx(-5.0 / 3.0)
34 |
35 |
36 | def test_maximize_kpi_basic():
37 | x = jnp.array([0.5, 1.5])
38 | # two samples over two horizons
39 | pred = jnp.array(
40 | [
41 | [[2.0], [4.0]],
42 | [[2.0], [4.0]],
43 | ]
44 | )
45 | optimizer = DummyOptimizer(pred, jnp.array([0, 1]))
46 | obj = MaximizeKPI()
47 | # mean=>[2,4]; pick [0,1]=>[2,4]; sum(axis=0) over last dim yields [2,4], then sum()=6 => -6
48 | result = float(obj._objective(x, optimizer))
49 | assert result == pytest.approx(-6.0)
50 |
51 |
52 | def test_minimize_budget_basic():
53 | x = jnp.array([1.0, 1.0, 3.0])
54 |
55 | pred = jnp.array(
56 | [
57 | [1.0, 2.0, 3.0],
58 | [1.0, 2.0, 3.0],
59 | ]
60 | )
61 | optimizer = DummyOptimizer(pred, jnp.array([1, 2]))
62 | obj = MinimizeBudget()
63 | # sum(x)=5
64 | result = float(obj._objective(x, optimizer))
65 | assert result == pytest.approx(5.0)
66 |
--------------------------------------------------------------------------------
/src/prophetverse/datasets/_mmm/dataset1_posterior_samples.json:
--------------------------------------------------------------------------------
1 | {
2 | "trend/offset": [
3 | 9999.87712924706
4 | ],
5 | "trend/changepoint_coefficients": [
6 | 9711.858371033915,
7 | 169.1096312483628,
8 | 1524.7922167673978,
9 | 454.13515337460717,
10 | -40.74636411144731,
11 | 255.93906331106825,
12 | 262.9987037379233,
13 | 442.11182195136325,
14 | -915.9070687299196,
15 | -540.4821459924445,
16 | -317.5289654620647,
17 | 585.3663808321184,
18 | -188.107930485118,
19 | 2073.4376453864265,
20 | 119.19632431036808,
21 | 899.0892312351363,
22 | 2662.2662411107594,
23 | 788.0192088648768
24 | ],
25 | "yearly_seasonality/coefs": [
26 | 0.0060877260491184184,
27 | -0.013609998156106392,
28 | 0.05190504056846946,
29 | -0.02863691411266535,
30 | -0.004229766558144349,
31 | -0.08444327716186356
32 | ],
33 | "weekly_seasonality/coefs": [
34 | -0.01765222971114911,
35 | 0.0004570187078290861,
36 | -0.0022330583834297346,
37 | 0.0052626692699564446,
38 | -0.02369986940563256,
39 | 0.006664146232619787
40 | ],
41 | "monthly_seasonality/coefs": [
42 | 0.00011973567107168396,
43 | 0.06285272931292027,
44 | 0.032378026547478456,
45 | 0.013657870054235539,
46 | 0.03033638960632393,
47 | -0.08104159802231667,
48 | 0.01885639091301188,
49 | 0.03244363958566386,
50 | -0.06156147765043078,
51 | 0.008281988985543847
52 | ],
53 | "ad_spend_search/half_max": 50900.278668289622,
54 | "ad_spend_search/slope": 2.997783480866924,
55 | "ad_spend_search/max_effect": 9999990.9999999907,
56 | "ad_spend_social_media/saturation/half_max": 40191.355816297553,
57 | "ad_spend_social_media/saturation/slope": 1.5001793528982645,
58 | "ad_spend_social_media/saturation/max_effect": 2999900.99999999367,
59 | "ad_spend_social_media/adstock/decay": 0.7,
60 | "noise_scale": 0.035900993335426706
61 | }
--------------------------------------------------------------------------------
/src/prophetverse/_model.py:
--------------------------------------------------------------------------------
1 | import numpyro
2 | from prophetverse.effects.base import BaseEffect
3 | from typing import Any, Dict, Optional
4 | import jax.numpy as jnp
5 | from prophetverse.utils.numpyro import CacheMessenger
6 |
7 |
8 | def wrap_with_cache_messenger(model):
9 |
10 | def wrapped(*args, **kwargs):
11 | with CacheMessenger():
12 | return model(*args, **kwargs)
13 |
14 | return wrapped
15 |
16 |
17 | def model(
18 | y,
19 | trend_model: BaseEffect,
20 | trend_data: Dict[str, jnp.ndarray],
21 | target_model: BaseEffect,
22 | target_data: Dict[str, jnp.ndarray],
23 | data: Optional[Dict[str, jnp.ndarray]] = None,
24 | exogenous_effects: Optional[Dict[str, BaseEffect]] = None,
25 | **kwargs,
26 | ):
27 | """
28 | Define the Prophet-like model for univariate timeseries.
29 |
30 | Parameters
31 | ----------
32 | y (jnp.ndarray): Array of time series data.
33 | trend_model (BaseEffect): Trend model.
34 | trend_data (dict): Dictionary containing the data needed for the trend model.
35 | data (dict): Dictionary containing the exogenous data.
36 | exogenous_effects (dict): Dictionary containing the exogenous effects.
37 | noise_scale (float): Noise scale.
38 | """
39 |
40 | with CacheMessenger():
41 | predicted_effects: Dict[str, jnp.ndarray] = {}
42 |
43 | with numpyro.handlers.scope(prefix="trend"):
44 | trend = trend_model(data=trend_data, predicted_effects=predicted_effects)
45 |
46 | predicted_effects["trend"] = numpyro.deterministic("trend", trend)
47 |
48 | # Exogenous effects
49 | if exogenous_effects is not None:
50 | for exog_effect_name, exog_effect in exogenous_effects.items():
51 | transformed_data = data[exog_effect_name] # type: ignore[index]
52 |
53 | with numpyro.handlers.scope(prefix=exog_effect_name):
54 | effect = exog_effect(transformed_data, predicted_effects)
55 |
56 | effect = numpyro.deterministic(exog_effect_name, effect)
57 | predicted_effects[exog_effect_name] = effect
58 |
59 | target_model.predict(target_data, predicted_effects)
60 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/trend/base.py:
--------------------------------------------------------------------------------
1 | """Module containing the base class for trend models."""
2 |
3 | import pandas as pd
4 |
5 | from prophetverse.utils.frame_to_array import convert_index_to_days_since_epoch
6 |
7 |
8 | class TrendEffectMixin:
9 | """
10 | Mixin class for trend models.
11 |
12 | Trend models are effects applied to the trend component of a time series.
13 |
14 | Attributes
15 | ----------
16 | t_scale: float
17 | The time scale of the trend model.
18 | t_start: float
19 | The starting time of the trend model.
20 | n_series: int
21 | The number of series in the time series data.
22 | """
23 |
24 | _tags = {
25 | "requires_X": False,
26 | "hierarchical_prophet_compliant": True,
27 | "capability:multivariate_input": True,
28 | }
29 |
30 | def _fit(self, y: pd.DataFrame, X: pd.DataFrame, scale: float = 1) -> None:
31 | """Initialize the effect.
32 |
33 | Set the time scale, starting time, and number of series attributes.
34 |
35 | Parameters
36 | ----------
37 | y : pd.DataFrame
38 | The timeseries dataframe
39 |
40 | X : pd.DataFrame
41 | The DataFrame to initialize the effect.
42 | """
43 | # Set time scale
44 | t_days = convert_index_to_days_since_epoch(
45 | y.index.get_level_values(-1).unique()
46 | )
47 | self.t_scale = (t_days[1:] - t_days[:-1]).mean()
48 | self.t_start = t_days.min() / self.t_scale
49 | if y.index.nlevels > 1:
50 | self.n_series = y.index.droplevel(-1).nunique()
51 | else:
52 | self.n_series = 1
53 |
54 | def _index_to_scaled_timearray(self, idx):
55 | """
56 | Convert the index to a scaled time array.
57 |
58 | Parameters
59 | ----------
60 | idx: int
61 | The index to be converted.
62 |
63 | Returns
64 | -------
65 | float
66 | The scaled time array value.
67 | """
68 | if idx.nlevels > 1:
69 | idx = idx.get_level_values(-1).unique()
70 |
71 | t_days = convert_index_to_days_since_epoch(idx)
72 | return (t_days) / self.t_scale - self.t_start
73 |
--------------------------------------------------------------------------------
/src/prophetverse/datasets/_mmm/dataset2_branding_posterior_samples.json:
--------------------------------------------------------------------------------
1 | {
2 | "trend/offset": [
3 | 9999.87712924706
4 | ],
5 | "trend/changepoint_coefficients": [
6 | 9711.858371033915,
7 | 169.1096312483628,
8 | 1524.7922167673978,
9 | 454.13515337460717,
10 | -40.74636411144731,
11 | 255.93906331106825,
12 | 262.9987037379233,
13 | 442.11182195136325,
14 | -915.9070687299196,
15 | -540.4821459924445,
16 | -317.5289654620647,
17 | 585.3663808321184,
18 | -188.107930485118,
19 | 2073.4376453864265,
20 | 119.19632431036808,
21 | 899.0892312351363,
22 | 2662.2662411107594,
23 | 788.0192088648768
24 | ],
25 | "yearly_seasonality/coefs": [
26 | 0.0060877260491184184,
27 | -0.013609998156106392,
28 | 0.05190504056846946,
29 | -0.02863691411266535,
30 | -0.004229766558144349,
31 | -0.08444327716186356
32 | ],
33 | "weekly_seasonality/coefs": [
34 | -0.01765222971114911,
35 | 0.0004570187078290861,
36 | -0.0022330583834297346,
37 | 0.0052626692699564446,
38 | -0.02369986940563256,
39 | 0.006664146232619787
40 | ],
41 | "monthly_seasonality/coefs": [
42 | 0.00011973567107168396,
43 | 0.06285272931292027,
44 | 0.032378026547478456,
45 | 0.013657870054235539,
46 | 0.03033638960632393,
47 | -0.08104159802231667,
48 | 0.01885639091301188,
49 | 0.03244363958566386,
50 | -0.06156147765043078,
51 | 0.008281988985543847
52 | ],
53 | "latent/awareness/half_max": 50900.278668289622,
54 | "latent/awareness/slope": 2.997783480866924,
55 | "latent/awareness/max_effect": 9999990.9999999907,
56 | "ad_spend_social_media/hill/saturation/half_max": 40191.355816297553,
57 | "ad_spend_social_media/hill/saturation/slope": 1.5001793528982645,
58 | "ad_spend_social_media/hill/saturation/max_effect": 0.8,
59 | "ad_spend_social_media/adstock/decay": 0.7,
60 | "awareness_to_sales/linear/constant_coefficient": 0.8,
61 | "awareness_to_sales/adstock/scale": 2,
62 | "awareness_to_sales/adstock/concentration": 1.5,
63 | "noise_scale": 0.035900993335426706
64 | }
--------------------------------------------------------------------------------
/src/prophetverse/utils/regex.py:
--------------------------------------------------------------------------------
1 | """Regex utilities to facilitate the definition of columns for effects."""
2 |
3 | __all__ = ["starts_with", "exact", "no_input_columns", "ends_with", "contains"]
4 |
5 | no_input_columns = r"^$"
6 |
7 |
8 | def starts_with(prefixes):
9 | """
10 | Return a regular expression pattern that matches strings starting given prefixes.
11 |
12 | Parameters
13 | ----------
14 | prefixes: list
15 | A list of strings representing the prefixes to match.
16 |
17 | Returns
18 | -------
19 | str
20 | A regular expression pattern that matches strings starting with any of the
21 | given prefixes.
22 | """
23 | if isinstance(prefixes, str):
24 | prefixes = [prefixes]
25 | return rf"^(?:{'|'.join(prefixes)})"
26 |
27 |
28 | def exact(string):
29 | """
30 | Return a regular expression pattern that matches the exact given string.
31 |
32 | Parameters
33 | ----------
34 | string: str
35 | The string to match exactly.
36 |
37 | Returns
38 | -------
39 | str
40 | A regular expression pattern that matches the exact given string.
41 | """
42 | return rf"^{string}$"
43 |
44 |
45 | def ends_with(suffixes):
46 | """
47 | Return a regular expression pattern that matches strings ending with given suffixes.
48 |
49 | Parameters
50 | ----------
51 | suffixes: str or list of str
52 | A string or a list of strings representing the suffixes to match.
53 |
54 | Returns
55 | -------
56 | str
57 | A regular expression pattern that matches strings ending with any of the
58 | given suffixes.
59 | """
60 | if isinstance(suffixes, str):
61 | suffixes = [suffixes]
62 | return rf"(?:{'|'.join(suffixes)})$"
63 |
64 |
65 | def contains(patterns):
66 | """
67 | Return a regular expression pattern that matches strings containing given patterns.
68 |
69 | Parameters
70 | ----------
71 | patterns: str or list of str
72 | A string or a list of strings, where each string is a pattern to be searched for.
73 |
74 | Returns
75 | -------
76 | str
77 | A regular expression pattern that matches strings containing any of the
78 | given patterns.
79 | """
80 | if isinstance(patterns, str):
81 | patterns = [patterns]
82 | return rf"(?:{'|'.join(patterns)})"
83 |
--------------------------------------------------------------------------------
/tests/effects/test_hill.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import numpyro
3 | import pytest
4 | from numpyro import distributions as dist
5 | from numpyro.handlers import seed
6 |
7 | from prophetverse.effects.hill import HillEffect
8 | from prophetverse.utils.algebric_operations import _exponent_safe
9 |
10 |
11 | @pytest.fixture
12 | def hill_effect_multiplicative():
13 | return HillEffect(
14 | half_max_prior=dist.Delta(0.5),
15 | slope_prior=dist.Delta(1.0),
16 | max_effect_prior=dist.Delta(1.5),
17 | effect_mode="multiplicative",
18 | )
19 |
20 |
21 | @pytest.fixture
22 | def hill_effect_additive():
23 | return HillEffect(
24 | half_max_prior=dist.Delta(0.5),
25 | slope_prior=dist.Delta(1.0),
26 | max_effect_prior=dist.Delta(1.5),
27 | effect_mode="additive",
28 | )
29 |
30 |
31 | def test_initialization_defaults():
32 | hill_effect = HillEffect()
33 | assert isinstance(hill_effect._half_max_prior, dist.Gamma)
34 | assert isinstance(hill_effect._slope_prior, dist.HalfNormal)
35 | assert isinstance(hill_effect._max_effect_prior, dist.Gamma)
36 | assert hill_effect.effect_mode == "multiplicative"
37 |
38 |
39 | def test__predict_multiplicative(hill_effect_multiplicative):
40 | trend = jnp.array([1.0, 2.0, 3.0]).reshape((-1, 1))
41 | data = jnp.array([0.5, 1.0, 1.5]).reshape((-1, 1))
42 |
43 | with seed(numpyro.handlers.seed, 0):
44 | result = hill_effect_multiplicative.predict(
45 | data=data, predicted_effects={"trend": trend}
46 | )
47 |
48 | half_max, slope, max_effect = 0.5, 1.0, 1.5
49 | x = _exponent_safe(data / half_max, -slope)
50 | expected_effect = max_effect / (1 + x)
51 | expected_result = trend * expected_effect
52 |
53 | assert jnp.allclose(result, expected_result)
54 |
55 |
56 | def test__predict_additive(hill_effect_additive):
57 | trend = jnp.array([1.0, 2.0, 3.0]).reshape((-1, 1))
58 | data = jnp.array([0.5, 1.0, 1.5]).reshape((-1, 1))
59 |
60 | with seed(numpyro.handlers.seed, 0):
61 | result = hill_effect_additive.predict(
62 | data=data, predicted_effects={"trend": trend}
63 | )
64 |
65 | half_max, slope, max_effect = 0.5, 1.0, 1.5
66 | x = _exponent_safe(data / half_max, -slope)
67 | expected_result = max_effect / (1 + x)
68 |
69 | assert jnp.allclose(result, expected_result)
70 |
--------------------------------------------------------------------------------
/src/prophetverse/distributions/hurdle_distribution.py:
--------------------------------------------------------------------------------
1 | """Hurdle distribution implementation."""
2 |
3 | import jax
4 | import jax.numpy as jnp
5 | from numpyro.distributions import Bernoulli, Distribution, constraints
6 | from numpyro.distributions.util import validate_sample
7 |
8 | from prophetverse.distributions.truncated_discrete import TruncatedDiscrete
9 |
10 |
11 | class HurdleDistribution(Distribution):
12 | """A Hurdle distribution.
13 |
14 | This distribution models data with an excess of zeros. It is a mixture of a
15 | point mass at zero and a positive distribution for values greater than zero.
16 | This implementation creates the positive distribution by internally truncating
17 | a given base distribution at `low=0`.
18 |
19 | Parameters
20 | ----------
21 | prob_gt_zero: jnp.ndarray
22 | Probability of observing a value greater than zero.
23 |
24 | positive_dist: TruncatedDiscrete
25 | Truncated distribution to use for positive values.
26 | """
27 |
28 | arg_constraints = {"prob_gt_zero": constraints.unit_interval}
29 | support = constraints.nonnegative
30 | has_rsample = False
31 |
32 | pytree_data_fields = ("prob_gt_zero", "positive_dist")
33 |
34 | def __init__(
35 | self,
36 | prob_gt_zero: jnp.ndarray,
37 | positive_dist: TruncatedDiscrete,
38 | validate_args=None,
39 | ):
40 | self.prob_gt_zero = prob_gt_zero
41 | self.positive_dist = positive_dist
42 |
43 | batch_shape = jnp.broadcast_shapes(
44 | jnp.shape(prob_gt_zero), self.positive_dist.batch_shape
45 | )
46 |
47 | super().__init__(
48 | batch_shape=batch_shape,
49 | event_shape=self.positive_dist.event_shape,
50 | validate_args=validate_args,
51 | )
52 |
53 | @validate_sample
54 | def log_prob(self, value): # noqa: D102
55 | log_prob_zero = jnp.log1p(-self.prob_gt_zero)
56 | log_prob_positive = jnp.log(self.prob_gt_zero) + self.positive_dist.log_prob(
57 | value
58 | )
59 |
60 | is_zero = value == 0
61 | return jnp.where(is_zero, log_prob_zero, log_prob_positive)
62 |
63 | def sample(self, key, sample_shape=()): # noqa: D102
64 | key_hurdle, key_positive = jax.random.split(key)
65 | is_positive = Bernoulli(probs=self.prob_gt_zero).sample(
66 | key_hurdle, sample_shape
67 | )
68 |
69 | positive_values = self.positive_dist.sample(key_positive, sample_shape)
70 |
71 | return jnp.where(is_positive, positive_values, 0)
72 |
--------------------------------------------------------------------------------
/tests/sktime/test_base.py:
--------------------------------------------------------------------------------
1 | import numpyro.distributions as dist
2 | import pytest
3 |
4 | from prophetverse.effects import LinearEffect
5 | from prophetverse.effects.trend import PiecewiseLinearTrend
6 | from prophetverse.engine import MAPInferenceEngine
7 | from prophetverse.sktime.base import BaseProphetForecaster
8 |
9 |
10 | @pytest.fixture
11 | def base_effects_bayesian_forecaster():
12 | return BaseProphetForecaster(
13 | exogenous_effects=[
14 | ("effect1", LinearEffect(prior=dist.Normal(10, 2)), r"(x1).*"),
15 | ]
16 | )
17 |
18 |
19 | @pytest.mark.parametrize(
20 | "param_setting_dict",
21 | [
22 | dict(
23 | exogenous_effects=[
24 | ("effect1", LinearEffect(prior=dist.Laplace(0, 1)), r"(x1).*")
25 | ]
26 | ),
27 | dict(effect1__prior=dist.Laplace(0, 1)),
28 | ],
29 | )
30 | def test_set_params(base_effects_bayesian_forecaster, param_setting_dict):
31 | base_effects_bayesian_forecaster.set_params(**param_setting_dict)
32 |
33 | prior = base_effects_bayesian_forecaster.exogenous_effects[0][1].prior
34 | assert isinstance(prior, dist.Laplace)
35 | assert prior.loc == 0 and prior.scale == 1
36 | assert len(base_effects_bayesian_forecaster.exogenous_effects) == 1
37 | assert len(base_effects_bayesian_forecaster.exogenous_effects[0]) == 3
38 |
39 |
40 | def test_rshift_operator(base_effects_bayesian_forecaster):
41 |
42 | baseprophet = BaseProphetForecaster()
43 | trend = PiecewiseLinearTrend(
44 | changepoint_interval=10, changepoint_range=90, changepoint_prior_scale=1
45 | )
46 |
47 | # We need to create a custom Normal distribution for testing purposes
48 | class _Normal(dist.Normal):
49 |
50 | def __eq__(self, other):
51 | return isinstance(other, dist.Normal) and (
52 | self.loc == other.loc and self.scale == other.scale
53 | )
54 |
55 | effect = ("effect1", LinearEffect(prior=_Normal(10, 2)), r"(x1).*")
56 | effect_list = [("effect2", LinearEffect(prior=_Normal(10, 2)), r"(x1).*")]
57 | engine = MAPInferenceEngine()
58 |
59 | rshift_instance = baseprophet >> trend >> effect >> effect_list >> engine
60 | expected_instance = BaseProphetForecaster(
61 | trend=trend,
62 | exogenous_effects=[effect, *effect_list],
63 | inference_engine=engine,
64 | )
65 |
66 | assert rshift_instance == expected_instance
67 |
68 |
69 | def test_samples_unset():
70 | model = BaseProphetForecaster()
71 |
72 | with pytest.raises(AttributeError):
73 | samples = model.posterior_samples_
74 |
--------------------------------------------------------------------------------
/tests/budget_optimization/test_parametrization_transformations.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pandas as pd
3 | import jax.numpy as jnp
4 | import pytest
5 |
6 | from prophetverse.budget_optimization.parametrization_transformations import (
7 | IdentityTransform,
8 | InvestmentPerChannelTransform,
9 | TotalInvestmentTransform,
10 | )
11 |
12 |
13 | def test_identitytransform_scalar_and_array():
14 | t = IdentityTransform()
15 | # scalar
16 | assert t.transform(42) == 42
17 | assert t.inverse_transform(42) == 42
18 | # array
19 | arr = jnp.array([1.0, 2.0, 3.0])
20 | out = t.transform(arr)
21 | assert isinstance(out, jnp.ndarray)
22 | assert jnp.array_equal(out, arr)
23 | assert jnp.array_equal(t.inverse_transform(arr), arr)
24 |
25 |
26 | def test_investment_per_channel_roundtrip():
27 | # 2 days, 2 channels
28 | X = pd.DataFrame([[1.0, 3.0], [2.0, 4.0]], index=[0, 1], columns=["a", "b"])
29 | t = InvestmentPerChannelTransform()
30 | t.fit(X, horizon=[0, 1], columns=["a", "b"])
31 | x0 = X.values.flatten()
32 | xt = t.transform(x0)
33 | x_rec = t.inverse_transform(xt)
34 | assert x_rec.shape == x0.shape
35 | assert np.allclose(x_rec, x0)
36 |
37 |
38 | def test_investment_per_channel_transform_sums():
39 | X = pd.DataFrame([[5.0, 7.0], [9.0, 11.0]], index=["d1", "d2"], columns=["a", "b"])
40 | t = InvestmentPerChannelTransform()
41 | t.fit(X, horizon=["d1", "d2"], columns=["a", "b"])
42 | x_flat = X.values.flatten()
43 | sums = t.transform(x_flat)
44 | assert sums.shape == (2,)
45 | assert np.allclose(sums, np.array([14.0, 18.0]))
46 |
47 |
48 | def test_investment_per_channel_inverse_scaling():
49 | # daily shares: col a -> [1/2, 1/2], col b -> [9/10, 1/10]
50 | X = pd.DataFrame([[1.0, 9.0], [1.0, 1.0]], index=["d1", "d2"], columns=["a", "b"])
51 | t = InvestmentPerChannelTransform()
52 | t.fit(X, horizon=["d1", "d2"], columns=["a", "b"])
53 | xt = np.array([2.0, 4.0])
54 | result = t.inverse_transform(xt)
55 | expected = np.array([1.0, 3.6, 1.0, 0.4]) # Day1: [1, 3.6], Day2: [1, 0.4]
56 | assert result.shape == (4,)
57 | assert np.allclose(result, expected)
58 |
59 |
60 | def test_total_investment_transform():
61 | # 2 days, 2 channels
62 | X = pd.DataFrame([[1.0, 3.0], [2.0, 4.0]], index=[0, 1], columns=["a", "b"])
63 | t = TotalInvestmentTransform()
64 | t.fit(X, horizon=[0, 1], columns=["a", "b"])
65 | x0 = X.values.flatten()
66 | xt = t.transform(x0)
67 | assert xt == X.values.sum()
68 | x_rec = t.inverse_transform(xt)
69 | assert x_rec.shape == x0.shape
70 | assert np.allclose(x_rec, x0)
71 |
--------------------------------------------------------------------------------
/src/prophetverse/engine/prior.py:
--------------------------------------------------------------------------------
1 | from numpyro.infer import Predictive
2 | from prophetverse.engine.base import BaseInferenceEngine
3 | from numpyro import handlers
4 | from jax import random
5 |
6 |
7 | class PriorPredictiveInferenceEngine(BaseInferenceEngine):
8 | """
9 | Prior‐only inference engine for prior predictive checks.
10 | Samples parameters from their priors and runs the model.
11 | """
12 |
13 | _tags = {"inference_method": "prior_predictive"}
14 |
15 | def __init__(
16 | self, num_samples=1000, rng_key=None, substitute=None, return_sites=None
17 | ):
18 | self.num_samples = num_samples
19 | self.substitute = substitute
20 | self.return_sites = return_sites
21 | super().__init__(rng_key)
22 |
23 | def _infer(self, **kwargs):
24 |
25 | _, trace_key, predictive_key = random.split(self._rng_key, 3)
26 |
27 | model = self.model_
28 | if self.substitute is not None:
29 | model = handlers.substitute(model, self.substitute)
30 |
31 | trace = handlers.trace(handlers.seed(model, trace_key)).get_trace(**kwargs)
32 | self.trace_ = trace
33 |
34 | sample_sites = [
35 | site_name
36 | for site_name in trace.keys()
37 | if trace[site_name]["type"] == "sample"
38 | and not trace[site_name]["is_observed"]
39 | ]
40 |
41 | prior_predictive = Predictive(
42 | model,
43 | num_samples=self.num_samples,
44 | exclude_deterministic=True,
45 | return_sites=sample_sites,
46 | )
47 | self.posterior_samples_ = prior_predictive(predictive_key, **kwargs)
48 |
49 | if "obs" in self.posterior_samples_:
50 | # Remove the observed data from the samples
51 | del self.posterior_samples_["obs"]
52 | return self
53 |
54 | def _predict(self, **kwargs):
55 | """
56 | Draw samples from the prior predictive distribution.
57 | """
58 | _, predictive_key = random.split(self._rng_key)
59 | model = self.model_
60 |
61 | if self.return_sites is None:
62 | return_sites = None
63 | elif self.return_sites == "all":
64 | return_sites = list(self.trace_.keys())
65 | else:
66 | return_sites = self.return_sites
67 |
68 | predictive = Predictive(
69 | model,
70 | posterior_samples=self.posterior_samples_,
71 | num_samples=self.num_samples,
72 | return_sites=return_sites,
73 | )
74 |
75 | self.samples_predictive_ = predictive(predictive_key, **kwargs)
76 | return self.samples_predictive_
77 |
--------------------------------------------------------------------------------
/tests/sktime/test_utils.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import numpy as np
3 | import pandas as pd
4 | import pytest
5 | from sktime.transformations.hierarchical.aggregate import Aggregator
6 | from sktime.utils._testing.hierarchical import _bottom_hier_datagen
7 |
8 | from prophetverse.utils import (
9 | convert_dataframe_to_tensors,
10 | convert_index_to_days_since_epoch,
11 | get_bottom_series_idx,
12 | get_multiindex_loc,
13 | iterate_all_series,
14 | loc_bottom_series,
15 | series_to_tensor,
16 | )
17 |
18 | NUM_LEVELS = 2
19 | NUM_BOTTOM_NODES = 3
20 |
21 |
22 | @pytest.fixture
23 | def multiindex_df():
24 | agg = Aggregator()
25 | y = _bottom_hier_datagen(
26 | no_bottom_nodes=NUM_BOTTOM_NODES,
27 | no_levels=NUM_LEVELS,
28 | random_seed=123,
29 | )
30 | y = agg.fit_transform(y)
31 |
32 | return y
33 |
34 |
35 | def test_get_bottom_series_idx(multiindex_df):
36 | idx = get_bottom_series_idx(multiindex_df)
37 | assert isinstance(idx, pd.Index)
38 |
39 | assert len(idx) == NUM_BOTTOM_NODES
40 |
41 |
42 | def test_get_multiindex_loc(multiindex_df):
43 | df = get_multiindex_loc(multiindex_df, [("l2_node01", "l1_node01")])
44 |
45 | pd.testing.assert_frame_equal(
46 | df, multiindex_df.loc[pd.IndexSlice["l2_node01", "l1_node01", :],]
47 | )
48 |
49 |
50 | def test_loc_bottom_series(multiindex_df):
51 | df = loc_bottom_series(multiindex_df)
52 |
53 | pd.testing.assert_frame_equal(
54 | df,
55 | multiindex_df.loc[
56 | pd.IndexSlice[("l2_node01",), ("l1_node01", "l1_node02", "l1_node03"), :],
57 | ],
58 | )
59 |
60 |
61 | def test_iterate_all_series(multiindex_df):
62 | generator = iterate_all_series(multiindex_df)
63 | all_series = list(generator)
64 |
65 | assert len(all_series) == len(multiindex_df.index.droplevel(-1).unique())
66 |
67 | for idx, series in all_series:
68 | assert isinstance(idx, tuple)
69 | assert isinstance(series, pd.DataFrame)
70 |
71 |
72 | def test_convert_index_to_days_since_epoch():
73 | idx = pd.date_range(start="1/1/2020", periods=5)
74 | result = convert_index_to_days_since_epoch(idx)
75 | assert isinstance(result, np.ndarray)
76 | assert len(result) == len(idx)
77 |
78 |
79 | def test_series_to_tensor(multiindex_df):
80 | result = series_to_tensor(multiindex_df)
81 | assert isinstance(result, jnp.ndarray)
82 |
83 |
84 | def test_convert_dataframe_to_tensors(multiindex_df):
85 |
86 | result = convert_dataframe_to_tensors(multiindex_df)
87 | assert isinstance(result, tuple)
88 | assert isinstance(result[0], jnp.ndarray)
89 | assert isinstance(result[1], jnp.ndarray)
90 |
--------------------------------------------------------------------------------
/tests/effects/test_fourier.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import numpyro
3 | import pandas as pd
4 | import pytest
5 | from sktime.transformations.series.fourier import FourierFeatures
6 |
7 | from prophetverse.effects import LinearEffect, LinearFourierSeasonality
8 |
9 |
10 | @pytest.fixture
11 | def exog_data():
12 | return pd.DataFrame(
13 | {
14 | "date": pd.date_range("2021-01-01", periods=10),
15 | "value": range(10),
16 | }
17 | ).set_index("date")
18 |
19 |
20 | @pytest.fixture
21 | def fourier_effect_instance():
22 | return LinearFourierSeasonality(
23 | sp_list=[365.25],
24 | fourier_terms_list=[3],
25 | freq="D",
26 | prior_scale=1.0,
27 | effect_mode="additive",
28 | )
29 |
30 |
31 | def test_linear_fourier_seasonality_initialization(fourier_effect_instance):
32 | assert fourier_effect_instance.sp_list == [365.25]
33 | assert fourier_effect_instance.fourier_terms_list == [3]
34 | assert fourier_effect_instance.freq == "D"
35 | assert fourier_effect_instance.prior_scale == 1.0
36 | assert fourier_effect_instance.effect_mode == "additive"
37 |
38 |
39 | def test_linear_fourier_seasonality_fit(fourier_effect_instance, exog_data):
40 | fourier_effect_instance.fit(X=exog_data, y=None)
41 | assert hasattr(fourier_effect_instance, "fourier_features_")
42 | assert hasattr(fourier_effect_instance, "linear_effect_")
43 | assert isinstance(fourier_effect_instance.fourier_features_, FourierFeatures)
44 | assert isinstance(fourier_effect_instance.linear_effect_, LinearEffect)
45 |
46 |
47 | def test_linear_fourier_seasonality_transform(fourier_effect_instance, exog_data):
48 | fh = exog_data.index.get_level_values(-1).unique()
49 | fourier_effect_instance.fit(X=exog_data, y=None)
50 | transformed = fourier_effect_instance.transform(X=exog_data, fh=fh)
51 |
52 | fourier_transformed = fourier_effect_instance.fourier_features_.transform(exog_data)
53 | assert isinstance(transformed["data"], jnp.ndarray)
54 | assert transformed["data"].shape == fourier_transformed.shape
55 |
56 |
57 | def test_linear_fourier_seasonality_predict(fourier_effect_instance, exog_data):
58 | fh = exog_data.index.get_level_values(-1).unique()
59 | fourier_effect_instance.fit(X=exog_data, y=None)
60 | trend = jnp.array([1.0] * len(exog_data))
61 | data = fourier_effect_instance.transform(exog_data, fh=fh)
62 | with numpyro.handlers.seed(numpyro.handlers.seed, 0):
63 | prediction = fourier_effect_instance.predict(
64 | data, predicted_effects={"trend": trend}
65 | )
66 | assert prediction is not None
67 | assert isinstance(prediction, jnp.ndarray)
68 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/log.py:
--------------------------------------------------------------------------------
1 | """Definition of Log Effect class."""
2 |
3 | from typing import Dict, Optional
4 |
5 | import jax.numpy as jnp
6 | import numpyro
7 | from numpyro import distributions as dist
8 | from numpyro.distributions import Distribution
9 |
10 | from prophetverse.effects.base import (
11 | EFFECT_APPLICATION_TYPE,
12 | BaseAdditiveOrMultiplicativeEffect,
13 | )
14 |
15 | __all__ = ["LogEffect"]
16 |
17 |
18 | class LogEffect(BaseAdditiveOrMultiplicativeEffect):
19 | """Represents a log effect as effect = scale * log(rate * data + 1).
20 |
21 | Parameters
22 | ----------
23 | scale_prior : Optional[Distribution], optional
24 | The prior distribution for the scale parameter., by default Gamma
25 | rate_prior : Optional[Distribution], optional
26 | The prior distribution for the rate parameter., by default Gamma
27 | effect_mode : effects_application, optional
28 | Either "additive" or "multiplicative", by default "multiplicative"
29 | """
30 |
31 | def __init__(
32 | self,
33 | effect_mode: EFFECT_APPLICATION_TYPE = "multiplicative",
34 | scale_prior: Optional[Distribution] = None,
35 | rate_prior: Optional[Distribution] = None,
36 | ):
37 | self.scale_prior = scale_prior
38 | self.rate_prior = rate_prior
39 | super().__init__(effect_mode=effect_mode)
40 |
41 | self._scale_prior = (
42 | self.scale_prior if scale_prior is not None else dist.Gamma(1, 1)
43 | )
44 | self._rate_prior = (
45 | self.rate_prior if rate_prior is not None else dist.Gamma(1, 1)
46 | )
47 |
48 | def _predict( # type: ignore[override]
49 | self,
50 | data: jnp.ndarray,
51 | predicted_effects: Dict[str, jnp.ndarray],
52 | *args,
53 | **kwargs
54 | ) -> jnp.ndarray:
55 | """Apply and return the effect values.
56 |
57 | Parameters
58 | ----------
59 | data : Any
60 | Data obtained from the transformed method.
61 |
62 | predicted_effects : Dict[str, jnp.ndarray], optional
63 | A dictionary containing the predicted effects, by default None.
64 |
65 | Returns
66 | -------
67 | jnp.ndarray
68 | An array with shape (T,1) for univariate timeseries, or (N, T, 1) for
69 | multivariate timeseries, where T is the number of timepoints and N is the
70 | number of series.
71 | """
72 | scale = numpyro.sample("log_scale", self._scale_prior)
73 | rate = numpyro.sample("log_rate", self._rate_prior)
74 |
75 | effect = scale * jnp.log(jnp.clip(rate * data + 1, 1e-8, None))
76 |
77 | return effect
78 |
--------------------------------------------------------------------------------
/tests/effects/test_hurdle_split_effects.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import jax.numpy as jnp
3 |
4 | from prophetverse.effects.target.hurdle import HurdleTargetLikelihood
5 |
6 |
7 | @pytest.mark.smoke
8 | def test_split_no_patterns():
9 | model = HurdleTargetLikelihood(gate_effect_names=None, gate_effect_only=None)
10 | predicted_effects = {
11 | "a": jnp.array([1.0]),
12 | "b": jnp.array([2.0]),
13 | }
14 | gate_only, common, rest = model._split_gate_effects(predicted_effects)
15 | assert gate_only == {}
16 | assert common == {}
17 | assert set(rest.keys()) == {"a", "b"}
18 |
19 |
20 | @pytest.mark.smoke
21 | def test_split_gate_only_subset():
22 | model = HurdleTargetLikelihood(
23 | gate_effect_names=".*", # everything potentially gate/common
24 | gate_effect_only="zero__.*", # only names starting with zero__ go to gate_only
25 | )
26 | predicted_effects = {
27 | "zero__a": jnp.array([1.0]),
28 | "zero__b": jnp.array([2.0]),
29 | "c": jnp.array([3.0]),
30 | }
31 | gate_only, common, rest = model._split_gate_effects(predicted_effects)
32 | assert set(gate_only.keys()) == {"zero__a", "zero__b"}
33 | # remaining (matching gate_effect_names but not gate_effect_only) become common
34 | assert set(common.keys()) == {"c"}
35 | assert rest == {}
36 |
37 |
38 | @pytest.mark.smoke
39 | def test_split_common_only():
40 | model = HurdleTargetLikelihood(
41 | gate_effect_names="gate_.*", # these become common
42 | gate_effect_only=None,
43 | )
44 | predicted_effects = {
45 | "gate_a": jnp.array([1.0]),
46 | "gate_b": jnp.array([2.0]),
47 | "x": jnp.array([3.0]),
48 | }
49 | gate_only, common, rest = model._split_gate_effects(predicted_effects)
50 | assert gate_only == {}
51 | assert set(common.keys()) == {"gate_a", "gate_b"}
52 | assert set(rest.keys()) == {"x"}
53 |
54 |
55 | @pytest.mark.smoke
56 | def test_split_precedence_gate_only_over_common():
57 | model = HurdleTargetLikelihood(
58 | gate_effect_names=["gate_only_a", "common_.*"],
59 | gate_effect_only=["gate_only_a", "gate_only_b"],
60 | )
61 | predicted_effects = {
62 | "gate_only_a": jnp.array([1.0]), # matches both lists -> should go to gate_only
63 | "gate_only_b": jnp.array([2.0]), # only gate_only list
64 | "common_x": jnp.array([3.0]), # matches common pattern
65 | "other": jnp.array([4.0]), # matches none
66 | }
67 | gate_only, common, rest = model._split_gate_effects(predicted_effects)
68 | assert set(gate_only.keys()) == {"gate_only_a", "gate_only_b"}
69 | assert set(common.keys()) == {"common_x"}
70 | assert set(rest.keys()) == {"other"}
71 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/trend/flat.py:
--------------------------------------------------------------------------------
1 | """Flat trend model."""
2 |
3 | from typing import Any, Dict
4 |
5 | import jax.numpy as jnp
6 | import numpyro
7 | import numpyro.distributions as dist
8 | import pandas as pd
9 |
10 | from prophetverse.effects.base import BaseEffect
11 | from prophetverse.distributions import GammaReparametrized
12 |
13 | from .base import TrendEffectMixin
14 |
15 |
16 | class FlatTrend(TrendEffectMixin, BaseEffect):
17 | """Flat trend model.
18 |
19 | The mean of the target variable is used as the prior location for the trend.
20 |
21 | Parameters
22 | ----------
23 | changepoint_prior_scale : float, optional
24 | The scale of the prior distribution on the trend changepoints. Defaults to 0.1.
25 | """
26 |
27 | def __init__(self, changepoint_prior_scale: float = 0.1) -> None:
28 | self.changepoint_prior_scale = changepoint_prior_scale
29 | super().__init__()
30 |
31 | def _fit(self, y: pd.DataFrame, X: pd.DataFrame, scale: float = 1):
32 | """Initialize the effect.
33 |
34 | Set the prior location for the trend.
35 |
36 | Parameters
37 | ----------
38 | y : pd.DataFrame
39 | The timeseries dataframe
40 |
41 | X : pd.DataFrame
42 | The DataFrame to initialize the effect.
43 | """
44 | self.changepoint_prior_loc = y.mean().values
45 |
46 | def _transform(self, X: pd.DataFrame, fh: pd.Index) -> dict:
47 | """Prepare input data (a constant factor in this case).
48 |
49 | Parameters
50 | ----------
51 | idx : pd.PeriodIndex
52 | the timeseries time indexes
53 |
54 | Returns
55 | -------
56 | dict
57 | dictionary containing the input data for the trend model
58 | """
59 | idx = X.index
60 | return jnp.ones((len(idx), 1))
61 |
62 | def _predict( # type: ignore[override]
63 | self, data: jnp.ndarray, predicted_effects: dict, *args, **kwargs
64 | ) -> jnp.ndarray:
65 | """Apply the trend.
66 |
67 | Parameters
68 | ----------
69 | constant_vector : jnp.ndarray
70 | A constant vector with the size of the series time indexes
71 |
72 | Returns
73 | -------
74 | jnp.ndarray
75 | The forecasted trend
76 | """
77 | # Alias for clarity
78 | constant_vector = data
79 |
80 | coefficient = numpyro.sample(
81 | "trend_flat_coefficient",
82 | GammaReparametrized(
83 | loc=self.changepoint_prior_loc,
84 | scale=self.changepoint_prior_scale,
85 | ),
86 | )
87 |
88 | return constant_vector * coefficient
89 |
--------------------------------------------------------------------------------
/docs/reference/PiecewiseLogisticTrend.qmd:
--------------------------------------------------------------------------------
1 | # PiecewiseLogisticTrend { #prophetverse.effects.PiecewiseLogisticTrend }
2 |
3 | ```python
4 | effects.PiecewiseLogisticTrend(
5 | self,
6 | changepoint_interval=25,
7 | changepoint_range=0.8,
8 | changepoint_prior_scale=0.001,
9 | offset_prior_scale=10,
10 | capacity_prior=None,
11 | squeeze_if_single_series=True,
12 | remove_seasonality_before_suggesting_initial_vals=True,
13 | global_rate_prior_loc=None,
14 | offset_prior_loc=None,
15 | )
16 | ```
17 |
18 | Piecewise logistic trend model.
19 |
20 | This logistic trend differs from the original Prophet logistic trend in that it
21 | considers a capacity prior distribution. The capacity prior distribution is used
22 | to estimate the maximum value that the time series trend can reach.
23 |
24 | It uses internally the piecewise linear trend model, and then applies a logistic
25 | function to the output of the linear trend model.
26 |
27 |
28 | The initial values (global rate and global offset) are suggested using the maximum
29 | and minimum values of the time series data.
30 |
31 | ## Parameters {.doc-section .doc-section-parameters}
32 |
33 | | Name | Type | Description | Default |
34 | |---------------------------------------------------|-------------------|----------------------------------------------------------------------------------------------------------|-----------|
35 | | changepoint_interval | int | The interval between changepoints. | `25` |
36 | | changepoint_range | int | The range of the changepoints. | `0.8` |
37 | | changepoint_prior_scale | dist.Distribution | The prior scale for the changepoints. | `0.001` |
38 | | offset_prior_scale | float | The prior scale for the offset. Default is 0.1. | `10` |
39 | | squeeze_if_single_series | bool | If True, squeeze the output if there is only one series. Default is True. | `True` |
40 | | remove_seasonality_before_suggesting_initial_vals | bool | If True, remove seasonality before suggesting initial values, using sktime's detrender. Default is True. | `True` |
41 | | capacity_prior | dist.Distribution | The prior distribution for the capacity. Default is a HalfNormal distribution with loc=1.05 and scale=1. | `None` |
--------------------------------------------------------------------------------
/tests/effects/test_exact_likelihood.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import numpyro.distributions as dist
3 | import pandas as pd
4 | import pytest
5 | from numpyro import handlers
6 |
7 | from prophetverse.effects import ExactLikelihood, LinearEffect
8 |
9 |
10 | @pytest.fixture
11 | def exact_likelihood_results():
12 | return pd.DataFrame(
13 | data={"test_results": [1, 2, 3, 4, 5, 6]},
14 | index=pd.date_range("2021-01-01", periods=6),
15 | )
16 |
17 |
18 | @pytest.fixture
19 | def inner_effect():
20 | return LinearEffect(prior=dist.Delta(2))
21 |
22 |
23 | @pytest.fixture
24 | def exact_likelihood_effect_instance(exact_likelihood_results):
25 | return ExactLikelihood(
26 | effect_name="exog",
27 | reference_df=exact_likelihood_results,
28 | prior_scale=1.0,
29 | )
30 |
31 |
32 | @pytest.fixture
33 | def X():
34 | return pd.DataFrame(
35 | data={"exog": [10, 20, 30, 40, 50, 60]},
36 | index=pd.date_range("2021-01-01", periods=6),
37 | )
38 |
39 |
40 | @pytest.fixture
41 | def y(X):
42 | return pd.DataFrame(index=X.index, data=[1] * len(X))
43 |
44 |
45 | def test_exact_likelihood_initialization(
46 | exact_likelihood_effect_instance, exact_likelihood_results
47 | ):
48 | assert exact_likelihood_effect_instance.reference_df.equals(
49 | exact_likelihood_results
50 | )
51 | assert exact_likelihood_effect_instance.prior_scale == 1.0
52 |
53 |
54 | def test_exact_likelihood_fit(X, exact_likelihood_effect_instance):
55 |
56 | exact_likelihood_effect_instance.fit(y=y, X=X, scale=1)
57 | assert exact_likelihood_effect_instance.timeseries_scale == 1
58 | assert exact_likelihood_effect_instance._is_fitted
59 |
60 |
61 | def test_exact_likelihood_transform_train(X, y, exact_likelihood_effect_instance):
62 | fh = y.index.get_level_values(-1).unique()
63 | exact_likelihood_effect_instance.fit(X=X, y=y)
64 | transformed = exact_likelihood_effect_instance.transform(
65 | X,
66 | fh=fh,
67 | )
68 | assert "observed_reference_value" in transformed
69 | assert transformed["observed_reference_value"] is not None
70 |
71 |
72 | def test_exact_likelihood_predict(X, y, exact_likelihood_effect_instance):
73 | fh = X.index.get_level_values(-1).unique()
74 |
75 | exog = jnp.array([1, 2, 3, 4, 5, 6]).reshape((-1, 1))
76 | exact_likelihood_effect_instance.fit(X=X, y=y)
77 | data = exact_likelihood_effect_instance.transform(X=X, fh=fh)
78 |
79 | exec_trace = handlers.trace(exact_likelihood_effect_instance.predict).get_trace(
80 | data=data, predicted_effects={"exog": exog}
81 | )
82 |
83 | assert len(exec_trace) == 1
84 |
85 | trace_likelihood = exec_trace["exact_likelihood:ignore"]
86 | assert trace_likelihood["type"] == "sample"
87 | assert jnp.all(trace_likelihood["value"] == exog)
88 | assert trace_likelihood["is_observed"]
89 |
--------------------------------------------------------------------------------
/src/prophetverse/budget_optimization/objectives.py:
--------------------------------------------------------------------------------
1 | from prophetverse.budget_optimization.base import (
2 | BaseOptimizationObjective,
3 | )
4 | import jax.numpy as jnp
5 |
6 | __all__ = [
7 | "MaximizeROI",
8 | "MaximizeKPI",
9 | "MinimizeBudget",
10 | ]
11 |
12 |
13 | class MaximizeROI(BaseOptimizationObjective):
14 | """
15 | Maximize return on investment (ROI) objective function.
16 | """
17 |
18 | _tags = {
19 | "name": "MaxROI",
20 | "backend": "scipy",
21 | }
22 |
23 | def __init__(self):
24 | super().__init__()
25 |
26 | def _objective(self, x: jnp.ndarray, budget_optimizer):
27 | """
28 | Compute objective function value from `obs` site
29 |
30 | Parameters
31 | ----------
32 | obs : jnp.ndarray
33 | Observed values
34 |
35 | Returns
36 | -------
37 | float
38 | Objective function value
39 | """
40 | obs = budget_optimizer.predictive_(x)
41 | obs = obs.mean(axis=0).squeeze(-1)
42 | obs_horizon = obs[..., budget_optimizer.horizon_idx_]
43 | total_return = obs_horizon.sum()
44 | spend = x.sum()
45 |
46 | return -total_return / spend
47 |
48 |
49 | class MaximizeKPI(BaseOptimizationObjective):
50 | """
51 | Maximize the KPI objective function.
52 | """
53 |
54 | def __init__(self):
55 | super().__init__()
56 |
57 | def _objective(self, x: jnp.ndarray, budget_optimizer):
58 | """
59 | Compute objective function value from `obs` site
60 |
61 | Parameters
62 | ----------
63 | obs : jnp.ndarray
64 | Observed values
65 |
66 | Returns
67 | -------
68 | float
69 | Objective function value
70 | """
71 | obs = budget_optimizer.predictive_(x)
72 | obs = obs.mean(axis=0).squeeze(-1)
73 | obs_horizon = obs[..., budget_optimizer.horizon_idx_]
74 | obs_horizon = obs_horizon.sum(axis=0)
75 |
76 | value = -obs_horizon.sum()
77 | return value
78 |
79 |
80 | class MinimizeBudget(BaseOptimizationObjective):
81 | """
82 | Minimize budget constraint objective function.
83 | """
84 |
85 | def __init__(self, scale=1):
86 | self.scale = scale
87 | super().__init__()
88 |
89 | def _objective(self, x: jnp.ndarray, budget_optimizer):
90 | """
91 | Compute objective function value from `obs` site
92 |
93 | Parameters
94 | ----------
95 | obs : jnp.ndarray
96 | Observed values
97 |
98 | Returns
99 | -------
100 | float
101 | Objective function value
102 | """
103 | total_investment = x.sum() / self.scale
104 |
105 | return total_investment
106 |
--------------------------------------------------------------------------------
/src/prophetverse/distributions/reparametrization.py:
--------------------------------------------------------------------------------
1 | """Gamma reparametrized distribution."""
2 |
3 | from numpyro import distributions as dist
4 | from numpyro.distributions import constraints
5 | from numpyro.distributions.util import promote_shapes
6 | import jax.numpy as jnp
7 |
8 |
9 | class GammaReparametrized(dist.Gamma):
10 | """
11 | A reparametrized version of the Gamma distribution.
12 |
13 | This distribution is reparametrized in terms of loc and scale instead of rate and
14 | concentration. This makes it easier to specify priors for the parameters.
15 |
16 | Parameters
17 | ----------
18 | loc : float or jnp.ndarray
19 | The location parameter of the distribution.
20 | scale : float or jnp.ndarray
21 | The scale parameter of the distribution.
22 | """
23 |
24 | arg_constraints = {
25 | "loc": constraints.positive,
26 | "scale": constraints.positive,
27 | }
28 | support = constraints.positive
29 | reparametrized_params = ["loc", "scale"]
30 |
31 | def __init__(self, loc, scale=1.0, *, validate_args=None):
32 |
33 | self.loc, self.scale = promote_shapes(loc, scale)
34 |
35 | rate = loc / (scale**2)
36 | concentration = loc * rate
37 |
38 | super().__init__(
39 | rate=rate, concentration=concentration, validate_args=validate_args
40 | )
41 |
42 |
43 | class BetaReparametrized(dist.Beta):
44 | """Beta distribution parameterized by mean (``loc``) and variance factor (``factor``).
45 |
46 | So smaller factor -> smaller variance (higher concentration). As factor → 1 the
47 | variance approaches the Bernoulli upper bound μ(1-μ); as factor → 0 the variance
48 | shrinks to 0.
49 |
50 | Parameters
51 | ----------
52 | loc : float or jnp.ndarray
53 | Mean μ in (0,1).
54 | factor : float or jnp.ndarray, default 0.2
55 | Variance factor in (0,1). Var = μ(1-μ)*factor.
56 | epsilon : float, optional
57 | Numerical slack used to keep arguments strictly inside valid domain.
58 | safe : bool, optional
59 | If True (default) automatically clamps factor to (epsilon, 1-epsilon).
60 | validate_args : bool, optional
61 | If True and safe=False, raises for invalid inputs.
62 | """
63 |
64 | arg_constraints = {
65 | "loc": constraints.unit_interval,
66 | "factor": constraints.positive,
67 | }
68 | support = constraints.unit_interval
69 | reparametrized_params = ["loc", "factor"]
70 |
71 | def __init__(self, loc, factor=0.2, *, validate_args=None):
72 |
73 | self.loc = loc
74 | self.factor = factor
75 |
76 | var = loc * (1 - loc) / (1 + 1 / factor)
77 | alpha = loc**2 * ((1 - loc) / var - 1 / loc)
78 | beta = alpha * (1 / loc - 1)
79 |
80 | super().__init__(
81 | concentration1=alpha, concentration0=beta, validate_args=validate_args
82 | )
83 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "prophetverse"
3 | version = "0.11.0"
4 | description = "A multiverse of prophet models, for forecasting and Marketing Mix Modeling."
5 | authors = ["Felipe Angelim "]
6 | readme = "README.md"
7 | packages = [{ include = "prophetverse", from="src"}]
8 |
9 | [tool.poetry.dependencies]
10 | python = ">=3.10, <3.14"
11 | sktime = ">=0.30.0"
12 | numpyro = ">=0.19.0"
13 | optax = ">=0.2.4"
14 | graphviz = ">=0.20.3,<0.22.0"
15 | scikit-base = "^0.12.0"
16 | skpro = ">=2.9.2,<3.0.0"
17 |
18 | ipykernel = { version = ">=6.26.0,<7.0.0", optional = true }
19 | pytest = { version = ">=8.0.0,<9.0.0", optional = true }
20 | sphinx = { version = ">=7.2.6,<8.0.0", optional = true }
21 | matplotlib = { version = ">=3.8.2,<4.0.0", optional = true }
22 | mkdocs = { version = ">=1.5.3,<2.0.0", optional = true }
23 | mkdocstrings-python = { version = ">=1.9.0,<2.0.0", optional = true }
24 | mkdocs-jupyter = { version = ">=0.24.6,<0.26.0", optional = true }
25 | pymdown-extensions = { version = ">=10.7.1,<11.0.0", optional = true }
26 | mkdocs-material = { version = ">=9.5.14,<10.0.0", optional = true }
27 | pytest-cov = { version = ">=5.0.0,<8.0.0", optional = true }
28 | pre-commit = { version = ">=3.7.1,<5.0.0", optional = true }
29 | commitlint = { version = ">=1.0.0,<2.0.0", optional = true }
30 | black = { version = ">=24.4.2,<26.0.0", optional = true }
31 | isort = { version = ">=5.13.2,<7.0.0", optional = true }
32 | pydocstyle = { version = ">=6.3.0,<7.0.0", optional = true }
33 | mypy = { version = ">=1.10.0,<2.0.0", optional = true }
34 | pylint = { version = ">=3.2.2,<4.0.0", optional = true }
35 | mkdocstrings = { version = ">=0.28.1,<0.31.0", optional = true }
36 | jupytext = {version = "^1.16.3", optional = true}
37 | markdown-katex = {version = "^202406.1035", optional = true}
38 | python-markdown-math = {version = "^0.8", optional = true}
39 | tabulate = {version = "^0.9.0", optional = true}
40 | mike = {version = "^2.1.3", optional = true}
41 | mkdocs-ipymd = {version = "^0.0.4", optional = true}
42 | seaborn = {version = "^0.13.2", optional = true}
43 | statsmodels = {version = "^0.14.4", optional = true}
44 | quartodoc = {version = ">=0.9.1,<0.12.0", optional = true}
45 | jupyterlab = {version = "^4.4.2", optional = true}
46 | pyyaml = {version = "^6.0.2", optional = true}
47 |
48 |
49 | [tool.poetry.extras]
50 | dev = [
51 | "ipykernel",
52 | "pytest",
53 | "matplotlib",
54 | "pytest-cov",
55 | "pre-commit",
56 | "commitlint",
57 | "isort",
58 | "black",
59 | "pydocstyle",
60 | "mypy",
61 | "pylint",
62 | "seaborn",
63 | "statsmodels",
64 | "quartodoc",
65 | "jupyterlab",
66 | "pyyaml"
67 | ]
68 |
69 | [tool.poetry.group.dev.dependencies]
70 |
71 |
72 |
73 | [tool.pytest.ini_options]
74 | log_cli = true
75 | markers = [
76 | "ci: marks tests for Continuous Integration",
77 | "smoke: marks tests for smoke testing",
78 | ]
79 |
80 |
81 | [build-system]
82 | requires = ["poetry-core"]
83 | build-backend = "poetry.core.masonry.api"
84 |
85 | [tool.pre-commit]
86 |
--------------------------------------------------------------------------------
/tests/effects/test_log.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import numpyro
3 | import pytest
4 | from numpyro import distributions as dist
5 | from numpyro.handlers import seed
6 |
7 | from prophetverse.effects.log import LogEffect
8 |
9 |
10 | @pytest.fixture
11 | def log_effect_multiplicative():
12 | return LogEffect(
13 | scale_prior=dist.Delta(0.5),
14 | rate_prior=dist.Delta(2.0),
15 | effect_mode="multiplicative",
16 | )
17 |
18 |
19 | @pytest.fixture
20 | def log_effect_additive():
21 | return LogEffect(
22 | scale_prior=dist.Delta(0.5),
23 | rate_prior=dist.Delta(2.0),
24 | effect_mode="additive",
25 | )
26 |
27 |
28 | def test_initialization_defaults():
29 | log_effect = LogEffect()
30 | assert isinstance(log_effect._scale_prior, dist.Gamma)
31 | assert isinstance(log_effect._rate_prior, dist.Gamma)
32 | assert log_effect.effect_mode == "multiplicative"
33 |
34 |
35 | def test__predict_multiplicative(log_effect_multiplicative):
36 | trend = jnp.array([1.0, 2.0, 3.0]).reshape((-1, 1))
37 | data = jnp.array([1.0, 2.0, 3.0]).reshape((-1, 1))
38 |
39 | with seed(numpyro.handlers.seed, 0):
40 | result = log_effect_multiplicative.predict(
41 | data=data, predicted_effects={"trend": trend}
42 | )
43 |
44 | scale, rate = 0.5, 2.0
45 | expected_effect = scale * jnp.log(rate * data + 1)
46 | expected_result = trend * expected_effect
47 |
48 | assert jnp.allclose(result, expected_result)
49 |
50 |
51 | def test__predict_additive(log_effect_additive):
52 | trend = jnp.array([1.0, 2.0, 3.0]).reshape((-1, 1))
53 | data = jnp.array([1.0, 2.0, 3.0]).reshape((-1, 1))
54 |
55 | with seed(numpyro.handlers.seed, 0):
56 | result = log_effect_additive.predict(
57 | data=data, predicted_effects={"trend": trend}
58 | )
59 |
60 | scale, rate = 0.5, 2.0
61 | expected_result = scale * jnp.log(rate * data + 1)
62 |
63 | assert jnp.allclose(result, expected_result)
64 |
65 |
66 | def test__predict_with_zero_data(log_effect_multiplicative):
67 | trend = jnp.array([1.0, 2.0, 3.0])
68 | data = jnp.array([0.0, 0.0, 0.0])
69 |
70 | with seed(numpyro.handlers.seed, 0):
71 | result = log_effect_multiplicative.predict(
72 | data=data, predicted_effects={"trend": trend}
73 | )
74 |
75 | scale, rate = 0.5, 2.0
76 | expected_effect = scale * jnp.log(rate * data + 1)
77 | expected_result = trend * expected_effect
78 |
79 | assert jnp.allclose(result, expected_result)
80 |
81 |
82 | def test__predict_with_empty_data(log_effect_multiplicative):
83 | trend = jnp.array([])
84 | data = jnp.array([])
85 |
86 | with seed(numpyro.handlers.seed, 0):
87 | result = log_effect_multiplicative.predict(
88 | data=data, predicted_effects={"trend": trend}
89 | )
90 |
91 | scale, rate = 0.5, 2.0
92 | expected_effect = scale * jnp.log(rate * data + 1)
93 | expected_result = trend * expected_effect
94 |
95 | assert jnp.allclose(result, expected_result)
96 |
--------------------------------------------------------------------------------
/tests/sktime/test_expand_column_per_level.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pandas as pd
3 | import pytest
4 |
5 | from prophetverse.sktime._expand_column_per_level import ExpandColumnPerLevel
6 |
7 |
8 | def create_test_dataframe():
9 | """Helper function to create a test DataFrame with a multi-level index."""
10 | index = pd.MultiIndex.from_tuples(
11 | [
12 | ("series1", "2020-01"),
13 | ("series1", "2020-02"),
14 | ("series2", "2020-01"),
15 | ("series2", "2020-02"),
16 | ],
17 | names=["series", "date"],
18 | )
19 | return pd.DataFrame(
20 | {"value1": [1, 2, 3, 4], "value2": [4, 3, 2, 1], "other": [10, 20, 30, 40]},
21 | index=index,
22 | )
23 |
24 |
25 | def test_fit_identifies_matched_columns():
26 | """
27 | Test that the fit method correctly identifies columns that match the provided regular expressions.
28 | """
29 | X = create_test_dataframe()
30 | transformer = ExpandColumnPerLevel(columns_regex=["value"])
31 | transformer.fit(X)
32 |
33 | assert "value1" in transformer.matched_columns_
34 | assert "value2" in transformer.matched_columns_
35 | assert "other" not in transformer.matched_columns_
36 |
37 | X = X.loc[("series1")]
38 |
39 | transformer = ExpandColumnPerLevel(columns_regex=["value"])
40 | transformer.fit(X)
41 |
42 | assert "value1" in transformer.matched_columns_
43 | assert "value2" in transformer.matched_columns_
44 | assert "other" not in transformer.matched_columns_
45 |
46 | def test_transform_expands_columns():
47 | """
48 | Test that the transform method correctly expands columns based on the multi-level index.
49 | """
50 | X = create_test_dataframe()
51 | transformer = ExpandColumnPerLevel(columns_regex=["value"])
52 | transformer.fit(X)
53 | X_transformed = transformer.transform(X)
54 |
55 | # Check for new columns
56 | expected_columns = [
57 | "value1_dup_series1",
58 | "value1_dup_series2",
59 | "value2_dup_series1",
60 | "value2_dup_series2",
61 | "other",
62 | ]
63 | assert all(col in X_transformed.columns for col in expected_columns)
64 |
65 |
66 | def test_transform_preserves_original_data():
67 | """
68 | Test that the transform method preserves the original data in the newly expanded columns.
69 | """
70 | X = create_test_dataframe()
71 | transformer = ExpandColumnPerLevel(columns_regex=["value"])
72 | transformer.fit(X)
73 | X_transformed = transformer.transform(X)
74 |
75 | # Check data preservation
76 | assert X_transformed["value1_dup_series1"].iloc[0] == 1
77 | assert X_transformed["value2_dup_series1"].iloc[0] == 4
78 | assert X_transformed["value1_dup_series2"].iloc[2] == 3
79 | assert X_transformed["value2_dup_series2"].iloc[2] == 2
80 |
81 | # Check for zero filling
82 | assert X_transformed["value1_dup_series1"].iloc[2] == 0
83 | assert X_transformed["value2_dup_series1"].iloc[2] == 0
84 |
85 | assert (X_transformed.values == 0).sum() == 8
86 |
--------------------------------------------------------------------------------
/tests/effects/test_chain.py:
--------------------------------------------------------------------------------
1 | """Pytest for Chained Effects class."""
2 |
3 | import jax.numpy as jnp
4 | import numpyro
5 | import pandas as pd
6 | import pytest
7 | from numpyro import handlers
8 |
9 | from prophetverse.effects.base import BaseEffect
10 | from prophetverse.effects.chain import ChainedEffects
11 |
12 |
13 | class MockEffect(BaseEffect):
14 | def __init__(self, value):
15 | self.value = value
16 | super().__init__()
17 |
18 | self._transform_called = False
19 |
20 | def _transform(self, X, fh):
21 | self._transform_called = True
22 | return super()._transform(X, fh)
23 |
24 | def _predict(self, data, predicted_effects, *args, **kwargs):
25 | param = numpyro.sample("param", numpyro.distributions.Delta(self.value))
26 | return data * param
27 |
28 |
29 | @pytest.fixture
30 | def index():
31 | return pd.date_range("2021-01-01", periods=6)
32 |
33 |
34 | @pytest.fixture
35 | def y(index):
36 | return pd.DataFrame(index=index, data=[1] * len(index))
37 |
38 |
39 | @pytest.fixture
40 | def X(index):
41 | return pd.DataFrame(
42 | data={"exog": [10, 20, 30, 40, 50, 60]},
43 | index=index,
44 | )
45 |
46 |
47 | def test_chained_effects_fit_transform(X, y):
48 | """Test the fit method of ChainedEffects."""
49 | effects = [("effect1", MockEffect(2)), ("effect2", MockEffect(3))]
50 | chained = ChainedEffects(steps=effects)
51 |
52 | scale = 1
53 | chained.fit(y=y, X=X, scale=scale)
54 | # Ensure no exceptions occur in fit
55 |
56 | # Test transform
57 | transformed = chained.transform(X, fh=X.index)
58 | expected = MockEffect(2).transform(X, fh=X.index)
59 | assert jnp.allclose(transformed, expected), "Chained transform output mismatch."
60 |
61 |
62 | def test_chained_effects_predict(X, y):
63 | """Test the predict method of ChainedEffects."""
64 | effects = [("effect1", MockEffect(2)), ("effect2", MockEffect(3))]
65 | chained = ChainedEffects(steps=effects)
66 | chained.fit(y=y, X=X, scale=1)
67 | data = chained.transform(X, fh=X.index)
68 | predicted_effects = {}
69 | with handlers.trace() as trace:
70 | predicted = chained.predict(data, predicted_effects)
71 |
72 | with numpyro.handlers.trace() as exec_trace:
73 | predicted = chained.predict(data, predicted_effects)
74 | expected = data * 2 * 3
75 | assert jnp.allclose(predicted, expected), "Chained predict output mismatch."
76 |
77 | assert "effect1/param" in trace, "Missing effect_0 trace."
78 | assert "effect2/param" in trace, "Missing effect_1 trace."
79 |
80 | assert trace["effect1/param"]["value"] == 2, "Incorrect effect_0 trace value."
81 | assert trace["effect2/param"]["value"] == 3, "Incorrect effect_1 trace value."
82 |
83 |
84 | def test_get_params():
85 | effects = [("effect1", MockEffect(2)), ("effect2", MockEffect(3))]
86 | chained = ChainedEffects(steps=effects)
87 |
88 | params = chained.get_params()
89 |
90 | assert params["effect1__value"] == 2, "Incorrect effect_0 param."
91 | assert params["effect2__value"] == 3, "Incorrect effect_1 param."
92 |
--------------------------------------------------------------------------------
/src/prophetverse/engine/base.py:
--------------------------------------------------------------------------------
1 | """Numpyro inference engines for prophet models.
2 |
3 | The classes in this module take a model, the data and perform inference using Numpyro.
4 | """
5 |
6 | import jax
7 | from skbase.base import BaseObject
8 |
9 |
10 | class BaseInferenceEngine(BaseObject):
11 | """
12 | Class representing an inference engine for a given model.
13 |
14 | Parameters
15 | ----------
16 | model : Callable
17 | The model to be used for inference.
18 | rng_key : Optional[jax.random.PRNGKey]
19 | The random number generator key. If not provided, a default key with value 0
20 | will be used.
21 |
22 | Attributes
23 | ----------
24 | model : Callable
25 | The model used for inference.
26 | rng_key : jax.random.PRNGKey
27 | The random number generator key.
28 | """
29 |
30 | _tags = {
31 | "object_type": "inference_engine",
32 | }
33 |
34 | def __init__(self, rng_key=None):
35 | self.rng_key = rng_key
36 |
37 | if rng_key is None:
38 | rng_key = jax.random.PRNGKey(0)
39 | self._rng_key = rng_key
40 |
41 | # pragma: no cover
42 | def infer(self, model, **kwargs):
43 | """
44 | Perform inference using the specified model.
45 |
46 | Parameters
47 | ----------
48 | **kwargs
49 | Additional keyword arguments to be passed to the model.
50 |
51 | Returns
52 | -------
53 | The result of the inference.
54 | """
55 | self.model_ = model
56 | self._infer(**kwargs)
57 |
58 | # pragma: no cover
59 | def _infer(self, **kwargs): # pragma: no cover
60 | """
61 | Perform inference using the specified model.
62 |
63 | Parameters
64 | ----------
65 | **kwargs
66 | Additional keyword arguments to be passed to the model.
67 |
68 | Returns
69 | -------
70 | The result of the inference.
71 | """
72 | raise NotImplementedError("infer method must be implemented in subclass")
73 |
74 | # pragma: no cover
75 | def predict(self, **kwargs):
76 | """
77 | Generate predictions using the specified model.
78 |
79 | Parameters
80 | ----------
81 | **kwargs
82 | Additional keyword arguments to be passed to the model.
83 |
84 | Returns
85 | -------
86 | The predictions generated by the model.
87 | """
88 | return self._predict(**kwargs)
89 |
90 | # pragma: no cover
91 | def _predict(self, **kwargs): # pragma: no cover
92 | """
93 | Generate predictions using the specified model.
94 |
95 | Parameters
96 | ----------
97 | **kwargs
98 | Additional keyword arguments to be passed to the model.
99 |
100 | Returns
101 | -------
102 | The predictions generated by the model.
103 | """
104 | raise NotImplementedError("predict method must be implemented in subclass")
105 |
--------------------------------------------------------------------------------
/src/prophetverse/__init__.py:
--------------------------------------------------------------------------------
1 | from .sktime import Prophetverse
2 | from .effects import (
3 | # Trend effects
4 | FlatTrend,
5 | PiecewiseLinearTrend,
6 | PiecewiseLogisticTrend,
7 | # Target likelihoods
8 | NormalTargetLikelihood,
9 | MultivariateNormal,
10 | GammaTargetLikelihood,
11 | NegativeBinomialTargetLikelihood,
12 | BetaTargetLikelihood,
13 | # Exogenous effects
14 | MultiplyEffects,
15 | MichaelisMentenEffect,
16 | HillEffect,
17 | LinearEffect,
18 | LinearFourierSeasonality,
19 | LiftExperimentLikelihood,
20 | ExactLikelihood,
21 | GeometricAdstockEffect,
22 | WeibullAdstockEffect,
23 | ChainedEffects,
24 | Forward,
25 | IgnoreInput,
26 | Identity,
27 | Constant,
28 | CoupledExactLikelihood,
29 | )
30 | from .engine import (
31 | MAPInferenceEngine,
32 | MCMCInferenceEngine,
33 | PriorPredictiveInferenceEngine,
34 | VIInferenceEngine,
35 | )
36 |
37 | from .engine.optimizer import (
38 | LBFGSSolver,
39 | CosineScheduleAdamOptimizer,
40 | AdamOptimizer,
41 | )
42 |
43 |
44 | from .budget_optimization import BudgetOptimizer
45 | from .budget_optimization.constraints import (
46 | TotalBudgetConstraint,
47 | SharedBudgetConstraint,
48 | MinimumTargetResponse,
49 | )
50 | from .budget_optimization.objectives import MaximizeKPI, MaximizeROI, MinimizeBudget
51 |
52 | from .budget_optimization.parametrization_transformations import (
53 | InvestmentPerChannelAndSeries,
54 | InvestmentPerChannelTransform,
55 | TotalInvestmentTransform,
56 | InvestmentPerSeries,
57 | IdentityTransform,
58 | )
59 |
60 | __all__ = [
61 | "Prophetverse",
62 | # Effects
63 | "FlatTrend",
64 | "PiecewiseLinearTrend",
65 | "PiecewiseLogisticTrend",
66 | "NormalTargetLikelihood",
67 | "MultivariateNormal",
68 | "GammaTargetLikelihood",
69 | "NegativeBinomialTargetLikelihood",
70 | "MultiplyEffects",
71 | "MichaelisMentenEffect",
72 | "HillEffect",
73 | "LinearEffect",
74 | "LinearFourierSeasonality",
75 | "LiftExperimentLikelihood",
76 | "ExactLikelihood",
77 | "GeometricAdstockEffect",
78 | "WeibullAdstockEffect",
79 | "ChainedEffects",
80 | "BetaTargetLikelihood",
81 | "IgnoreInput",
82 | "Identity",
83 | "Constant",
84 | "CoupledExactLikelihood",
85 | # Engine
86 | "MAPInferenceEngine",
87 | "MCMCInferenceEngine",
88 | "PriorPredictiveInferenceEngine",
89 | "VIInferenceEngine",
90 | # Optimizers
91 | "LBFGSSolver",
92 | "CosineScheduleAdamOptimizer",
93 | "AdamOptimizer",
94 | # Budget Optimization
95 | "BudgetOptimizer",
96 | "TotalBudgetConstraint",
97 | "SharedBudgetConstraint",
98 | "MinimumTargetResponse",
99 | "MaximizeKPI",
100 | "MaximizeROI",
101 | "MinimizeBudget",
102 | "InvestmentPerChannelAndSeries",
103 | "InvestmentPerChannelTransform",
104 | "TotalInvestmentTransform",
105 | "InvestmentPerSeries",
106 | "IdentityTransform",
107 | "Forward",
108 | ]
109 |
--------------------------------------------------------------------------------
/tests/effects/test_lift_test_likelihood.py:
--------------------------------------------------------------------------------
1 | import jax.numpy as jnp
2 | import numpyro.distributions as dist
3 | import pandas as pd
4 | import pytest
5 | from numpyro import handlers
6 |
7 | from prophetverse.effects import LiftExperimentLikelihood, LinearEffect
8 |
9 |
10 | @pytest.fixture
11 | def lift_test_results():
12 | index = pd.date_range("2021-01-01", periods=2)
13 | return pd.DataFrame(
14 | index=index, data={"x_start": [1, 2], "x_end": [10, 20], "lift": [2, 4]}
15 | )
16 |
17 |
18 | @pytest.fixture
19 | def inner_effect():
20 | return LinearEffect(prior=dist.Delta(2), effect_mode="additive")
21 |
22 |
23 | @pytest.fixture
24 | def lift_experiment_likelihood_effect_instance(lift_test_results, inner_effect):
25 | return LiftExperimentLikelihood(
26 | effect=inner_effect,
27 | lift_test_results=lift_test_results,
28 | prior_scale=1.0,
29 | )
30 |
31 |
32 | @pytest.fixture
33 | def X():
34 | return pd.DataFrame(
35 | data={"exog": [10, 20, 30, 40, 50, 60]},
36 | index=pd.date_range("2021-01-01", periods=6),
37 | )
38 |
39 |
40 | @pytest.fixture
41 | def y(X):
42 | return pd.DataFrame(index=X.index, data=[1] * len(X))
43 |
44 |
45 | def test_lift_experiment_likelihood_initialization(
46 | lift_experiment_likelihood_effect_instance, lift_test_results
47 | ):
48 | assert lift_experiment_likelihood_effect_instance.lift_test_results.equals(
49 | lift_test_results
50 | )
51 | assert lift_experiment_likelihood_effect_instance.prior_scale == 1.0
52 |
53 |
54 | def test_lift_experiment_likelihood_fit(X, lift_experiment_likelihood_effect_instance):
55 |
56 | lift_experiment_likelihood_effect_instance.fit(y=y, X=X, scale=1)
57 | assert lift_experiment_likelihood_effect_instance.timeseries_scale == 1
58 | assert lift_experiment_likelihood_effect_instance.effect_._is_fitted
59 |
60 |
61 | def test_lift_experiment_likelihood_transform_train(
62 | X, y, lift_experiment_likelihood_effect_instance, lift_test_results
63 | ):
64 | fh = y.index.get_level_values(-1).unique()
65 | lift_experiment_likelihood_effect_instance.fit(X=X, y=y)
66 | transformed = lift_experiment_likelihood_effect_instance.transform(
67 | X,
68 | fh=fh,
69 | )
70 | assert "observed_lift" in transformed
71 | assert len(transformed["observed_lift"]) == len(lift_test_results)
72 |
73 |
74 | def test_lift_experiment_likelihood_predict(
75 | X, y, lift_experiment_likelihood_effect_instance
76 | ):
77 | fh = X.index.get_level_values(-1).unique()
78 |
79 | exog = jnp.array([1, 2, 3, 4, 5, 6]).reshape((-1, 1))
80 | lift_experiment_likelihood_effect_instance.fit(X=X, y=y)
81 | data = lift_experiment_likelihood_effect_instance.transform(X=X, fh=fh)
82 |
83 | exec_trace = handlers.trace(
84 | lift_experiment_likelihood_effect_instance.predict
85 | ).get_trace(data=data, predicted_effects={"exog": exog})
86 |
87 | assert "lift_experiment:ignore" in exec_trace
88 | trace_likelihood = exec_trace["lift_experiment:ignore"]
89 | assert trace_likelihood["type"] == "sample"
90 | assert trace_likelihood["is_observed"]
91 |
--------------------------------------------------------------------------------
/tests/sktime/_utils.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pandas as pd
3 | import pytest
4 | from numpyro import distributions as dist
5 | from sktime.forecasting.base import ForecastingHorizon
6 | from sktime.split import temporal_train_test_split
7 | from sktime.transformations.hierarchical.aggregate import Aggregator
8 | from sktime.utils._testing.hierarchical import _bottom_hier_datagen, _make_hierarchical
9 |
10 | from prophetverse.effects.linear import LinearEffect
11 | from prophetverse.sktime.multivariate import HierarchicalProphet
12 |
13 |
14 | EXTRA_FORECAST_FUNCS = [
15 | "predict_interval",
16 | "predict_components",
17 | "predict_component_samples",
18 | "predict_samples",
19 | ]
20 |
21 |
22 | def execute_fit_predict_test(forecaster, y, X, test_size=4):
23 |
24 | y_train, y_test, X_train, X_test = _split_train_test(y, X, test_size=test_size)
25 |
26 | fh = list(range(1, test_size + 1))
27 | forecaster.fit(y_train, X_train)
28 | y_pred = forecaster.predict(X=X_test, fh=fh)
29 |
30 | if y.index.nlevels == 1:
31 | n_series = 1
32 | else:
33 | n_series = len(y.index.droplevel(-1).unique())
34 | assert isinstance(y_pred, pd.DataFrame)
35 | assert y_pred.shape[0] == len(fh) * n_series
36 | assert y_pred.shape[1] == 1
37 | assert all(y_pred.index == y_test.index)
38 |
39 |
40 | def _split_train_test(y, X, test_size=4):
41 |
42 | dataset = temporal_train_test_split(y, X, test_size=test_size)
43 | if X is not None:
44 | y_train, y_test, X_train, X_test = dataset
45 | else:
46 | y_train, y_test = dataset
47 | X_train, X_test = None, None
48 |
49 | return y_train, y_test, X_train, X_test
50 |
51 |
52 | def make_random_X(y):
53 | return pd.DataFrame(
54 | np.random.rand(len(y), 3), columns=["x1", "x2", "x3"], index=y.index
55 | )
56 |
57 |
58 | def make_None_X(y):
59 | return None
60 |
61 |
62 | def make_empty_X(y):
63 | return pd.DataFrame(index=y.index)
64 |
65 |
66 | def make_y(hierarchy_levels):
67 | if hierarchy_levels == 0:
68 | y = _make_hierarchical(
69 | hierarchy_levels=(1,), max_timepoints=12, min_timepoints=12
70 | ).droplevel(0)
71 | else:
72 | y = _make_hierarchical(
73 | hierarchy_levels=hierarchy_levels, max_timepoints=12, min_timepoints=12
74 | )
75 | y = Aggregator().fit_transform(y)
76 | # convert level -1 to pd.periodIndex
77 | y.index = y.index.set_levels(y.index.levels[-1].to_period("D"), level=-1)
78 | return y
79 |
80 |
81 | def execute_extra_predict_methods_tests(forecaster, y, X, test_size=4):
82 |
83 | y_train, y_test, X_train, X_test = _split_train_test(y, X, test_size=test_size)
84 |
85 | fh = y_test.index.get_level_values(-1).unique()
86 | forecaster.fit(y_train, X_train)
87 |
88 | n_series = y_train.index.droplevel(-1).nunique()
89 | for forecast_func in EXTRA_FORECAST_FUNCS:
90 | preds = getattr(forecaster, forecast_func)(X=X, fh=fh)
91 | assert preds is not None
92 | assert isinstance(preds, pd.DataFrame)
93 |
94 | # TODO: Add more checks for the shape of the predictions
95 |
--------------------------------------------------------------------------------
/tests/budget_optimization/test_constraints.py:
--------------------------------------------------------------------------------
1 | from prophetverse.budget_optimization.constraints import (
2 | SharedBudgetConstraint,
3 | MinimumTargetResponse,
4 | )
5 |
6 | import pytest
7 | import numpy as np
8 | import pandas as pd
9 | import jax.numpy as jnp
10 |
11 |
12 | def test_shared_budget_constraint_default_channels():
13 | # two channels 'a','b' over 2 time points => total budget = 3+7+4+6 = 20
14 | X = pd.DataFrame(
15 | [[3, 7], [4, 6]],
16 | index=pd.Index([0, 1], name="horizon"),
17 | columns=pd.Index(["a", "b"]),
18 | )
19 | horizon = pd.Index([0, 1])
20 | columns = X.columns.tolist()
21 | c = SharedBudgetConstraint()
22 | spec = c(X, horizon, columns)
23 | # flattened budgets
24 | x = jnp.array([3, 7, 4, 6]).astype(jnp.float32)
25 | assert spec["type"] == "eq"
26 | # residual = total - sum(x) = 20 - 20 = 0
27 | assert spec["fun"](x) == pytest.approx(0)
28 | # gradient = d(20 - sum)/dx_i = -1 for each element
29 | grad = spec["jac"](x)
30 | assert np.allclose(np.array(grad), -1.0)
31 |
32 |
33 | def test_shared_budget_constraint_custom_channels():
34 | # only channel 'a' => total = 3+4 = 7
35 | X = pd.DataFrame(
36 | [[3, 7], [4, 6]],
37 | index=pd.Index([0, 1], name="horizon"),
38 | columns=pd.Index(["a", "b"]),
39 | )
40 | c = SharedBudgetConstraint(channels=["a"])
41 | spec = c(X, pd.Index([0, 1]), X.columns.tolist())
42 | x = jnp.array([3, 7, 4, 6]).astype(jnp.float32) # budgets for 'a' over 2 points
43 | # residual = 7 - (3+4) = 0
44 | assert spec["fun"](x) == pytest.approx(0)
45 | # gradient = -1 on each entry
46 | assert np.allclose(np.array(spec["jac"](x)), -1.0)
47 |
48 |
49 | class DummyOptimizer:
50 | def __init__(self, out, horizon_idx=None):
51 | # out: array of shape (n_draws, total_horizon_len)
52 | self._out = out
53 | self.horizon_idx_ = (
54 | horizon_idx if horizon_idx is not None else jnp.array([0, 1, 2])
55 | )
56 |
57 | def predictive_(self, x):
58 | return self._out
59 |
60 |
61 | def test_minimum_target_response_satisfied_and_unsatisfied():
62 | # build X with last level as horizon values [0,1,2]
63 | idx = pd.MultiIndex.from_product([["obs"], [0, 1, 2]], names=["obs_id", "horizon"])
64 | X = pd.DataFrame(np.zeros((3, 1)), index=idx, columns=["dummy"])
65 | horizon = [1, 2]
66 | # predictive_ returns ones => mean over draws = ones => sum at horizon=1+1=2
67 | out = np.ones((5, 3, 1))
68 | opt = DummyOptimizer(out, horizon_idx=jnp.array([1, 2]))
69 | c1 = MinimumTargetResponse(target_response=1.0)
70 | spec1 = c1(X, horizon, None)
71 | val1 = spec1["fun"](None, opt)
72 | assert val1 == pytest.approx(2.0 - 1.0) # >=0
73 |
74 | # now target higher => unsatisfied
75 | c2 = MinimumTargetResponse(target_response=3.0)
76 | spec2 = c2(X, horizon, None)
77 | val2 = spec2["fun"](None, opt)
78 | assert val2 == pytest.approx(2.0 - 3.0) # negative
79 |
80 | # gradient should be zero since predictive_ ignores x
81 | grad = spec1["jac"](jnp.zeros((4,)), opt)
82 | assert np.allclose(np.array(grad), 0.0)
83 |
--------------------------------------------------------------------------------
/tests/utils/test_regex.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import pytest
4 |
5 | from prophetverse.utils.regex import contains, ends_with
6 |
7 |
8 | @pytest.mark.parametrize(
9 | "suffix, string, expected_match",
10 | [
11 | ("xyz", "wxyz", True),
12 | ("xyz", "xyzabc", False),
13 | ("", "abc", True),
14 | ("", "", True),
15 | ],
16 | )
17 | def test_ends_with_single_suffix(suffix, string, expected_match):
18 | """Test ends_with with a single suffix."""
19 | pattern = ends_with(suffix)
20 | if expected_match:
21 | assert re.search(pattern, string), f"Expected '{string}' to end with '{suffix}'"
22 | else:
23 | assert not re.search(
24 | pattern, string
25 | ), f"Expected '{string}' not to end with '{suffix}'"
26 |
27 |
28 | @pytest.mark.parametrize(
29 | "suffixes, string, expected_match",
30 | [
31 | (["xyz", "abc"], "wxyz", True),
32 | (["xyz", "abc"], "defabc", True),
33 | (["xyz", "abc"], "xyz123", False),
34 | (["xyz", "abc"], "abc456", False),
35 | (["xyz", ""], "wxyz", True),
36 | (["xyz", ""], "any_string_matches_empty", True),
37 | (["xyz", ""], "", True),
38 | ],
39 | )
40 | def test_ends_with_multiple_suffixes(suffixes, string, expected_match):
41 | """Test ends_with with multiple suffixes."""
42 | pattern = ends_with(suffixes)
43 | if expected_match:
44 | assert re.search(pattern, string), f"Expected '{string}' to end with one of {suffixes}"
45 | else:
46 | assert not re.search(
47 | pattern, string
48 | ), f"Expected '{string}' not to end with any of {suffixes}"
49 |
50 |
51 | @pytest.mark.parametrize(
52 | "substring, string, expected_match",
53 | [
54 | ("123", "abc123xyz", True),
55 | ("123", "abcxyz", False),
56 | ("", "abc", True),
57 | ("", "", True),
58 | ],
59 | )
60 | def test_contains_single_pattern(substring, string, expected_match):
61 | """Test contains with a single pattern."""
62 | pattern = contains(substring)
63 | if expected_match:
64 | assert re.search(
65 | pattern, string
66 | ), f"Expected '{string}' to contain '{substring}'"
67 | else:
68 | assert not re.search(
69 | pattern, string
70 | ), f"Expected '{string}' not to contain '{substring}'"
71 |
72 |
73 | @pytest.mark.parametrize(
74 | "patterns, string, expected_match",
75 | [
76 | (["123", "abc"], "xyz123def", True),
77 | (["123", "abc"], "defabcghi", True),
78 | (["123", "abc"], "xyzdef", False),
79 | (["123", ""], "xyz123def", True),
80 | (["123", ""], "any_string_matches_empty", True),
81 | (["123", ""], "", True),
82 | ],
83 | )
84 | def test_contains_multiple_patterns(patterns, string, expected_match):
85 | """Test contains with multiple patterns."""
86 | pattern = contains(patterns)
87 | if expected_match:
88 | assert re.search(
89 | pattern, string
90 | ), f"Expected '{string}' to contain one of {patterns}"
91 | else:
92 | assert not re.search(
93 | pattern, string
94 | ), f"Expected '{string}' not to contain any of {patterns}"
95 |
--------------------------------------------------------------------------------
/tests/datasets/test_loaders.py:
--------------------------------------------------------------------------------
1 | import pandas as pd
2 |
3 | # Import the functions to test
4 | from prophetverse.datasets import (
5 | load_composite_effect_example,
6 | load_pedestrian_count,
7 | load_peyton_manning,
8 | load_synthetic_squared_exogenous,
9 | load_tensorflow_github_stars,
10 | load_tourism,
11 | )
12 |
13 |
14 | def test_load_tourism():
15 | # Test default loading
16 | y = load_tourism()
17 | assert isinstance(y, pd.DataFrame), "load_tourism should return a DataFrame"
18 | assert not y.empty, "DataFrame should not be empty"
19 | assert isinstance(y.index, pd.MultiIndex), "Index should be a MultiIndex"
20 | expected_levels = ["Region", "Quarter"]
21 | assert y.index.names == expected_levels, f"Index levels should be {expected_levels}"
22 |
23 | # Test with different groupby parameters
24 | y_region = load_tourism(groupby="Region")
25 | assert y_region.index.names == [
26 | "Region",
27 | "Quarter",
28 | ], "Index levels should match groupby parameter"
29 |
30 | y_state_purpose = load_tourism(groupby=["State", "Purpose"])
31 | assert y_state_purpose.index.names == [
32 | "State",
33 | "Purpose",
34 | "Quarter",
35 | ], "Index levels should match groupby parameters"
36 |
37 |
38 | def test_load_peyton_manning():
39 | df = load_peyton_manning()
40 | assert isinstance(df, pd.DataFrame), "load_peyton_manning should return a DataFrame"
41 | assert not df.empty, "DataFrame should not be empty"
42 | assert "y" in df.columns, "DataFrame should contain 'y' column"
43 | assert isinstance(df.index, pd.PeriodIndex), "Index should be a PeriodIndex"
44 | assert df.index.freq == "D", "Index frequency should be daily"
45 |
46 |
47 | def test_load_tensorflow_github_stars():
48 | y = load_tensorflow_github_stars()
49 | assert isinstance(
50 | y, pd.DataFrame
51 | ), "load_tensorflow_github_stars should return a DataFrame"
52 | assert not y.empty, "DataFrame should not be empty"
53 | assert "day-stars" in y.columns, "DataFrame should contain 'day-stars' column"
54 | assert isinstance(y.index, pd.PeriodIndex), "Index should be a PeriodIndex"
55 | assert y.index.freq == "D", "Index frequency should be daily"
56 |
57 |
58 | def test_load_pedestrian_count():
59 | y = load_pedestrian_count()
60 |
61 | assert y.index.nlevels == 2, "Index should have 2 levels"
62 | assert (
63 | y.index.get_level_values(1).freq == "h"
64 | ), "Second level should have hourly frequency"
65 | assert list(y.index.names) == ["series_name", "timestamp"]
66 |
67 |
68 | def test_load_composite_effect_example():
69 | y, X = load_composite_effect_example()
70 | assert isinstance(y, pd.DataFrame), "y should be a DataFrame"
71 | assert isinstance(X, pd.DataFrame), "X should be a DataFrame"
72 | assert not y.empty, "y should not be empty"
73 | assert not X.empty, "X should not be empty"
74 |
75 |
76 | def test_load_squared_exogenous():
77 | y, X = load_synthetic_squared_exogenous()
78 | assert isinstance(y, pd.DataFrame), "y should be a DataFrame"
79 | assert isinstance(X, pd.DataFrame), "X should be a DataFrame"
80 | assert not y.empty, "y should not be empty"
81 | assert not X.empty, "X should not be empty"
82 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/ignore_input.py:
--------------------------------------------------------------------------------
1 | """BypassEffect implementation - ignores inputs and returns zeros."""
2 |
3 | from typing import Any, Dict, Optional
4 |
5 | import jax.numpy as jnp
6 | import pandas as pd
7 |
8 | from prophetverse.effects.base import BaseEffect
9 |
10 | __all__ = ["IgnoreInput"]
11 |
12 |
13 | class IgnoreInput(BaseEffect):
14 | """Effect that ignores all inputs and returns zeros during prediction.
15 |
16 | This effect can be used as a placeholder or to disable specific effects
17 | without removing them from the model configuration.
18 |
19 | The effect ignores all input data and always returns zeros with the
20 | appropriate shape for the forecast horizon.
21 |
22 | Parameters
23 | ----------
24 | raise_error : bool, optional
25 | If True, validates that X is empty (has no columns) during fit.
26 | If False, ignores X completely. Default is False.
27 | """
28 |
29 | _tags = {
30 | "requires_X": True, # Default value, will be overridden in __init__
31 | "applies_to": "X",
32 | }
33 |
34 | def __init__(self, raise_error: bool = False):
35 | """Initialize the BypassEffect.
36 |
37 | Parameters
38 | ----------
39 | validate_empty_input : bool, optional
40 | If True, validates that X is empty (has no columns) during fit.
41 | If False, ignores X completely. Default is False.
42 | """
43 | self.raise_error = raise_error
44 | super().__init__()
45 |
46 | def _fit(self, y: pd.DataFrame, X: pd.DataFrame, scale: float = 1.0):
47 | """Fit the effect. If validation is enabled, check that X is empty.
48 |
49 | Parameters
50 | ----------
51 | y : pd.DataFrame
52 | The target time series data.
53 | X : pd.DataFrame
54 | The exogenous variables DataFrame.
55 | scale : float, optional
56 | The scale factor, by default 1.0.
57 |
58 | Raises
59 | ------
60 | ValueError
61 | If validate_empty_input is True and X is not empty (has columns).
62 | """
63 | if self.raise_error and X is not None and len(X.columns) > 0:
64 | raise ValueError(
65 | f"IgnoreInput with raise_error=True requires X to be empty "
66 | f"(no columns), but X has {len(X.columns)} columns: {list(X.columns)}"
67 | )
68 |
69 | def _transform(self, X, fh):
70 | if X is None:
71 | return None
72 | return super()._transform(X, fh)
73 |
74 | def _predict(
75 | self,
76 | data: Any,
77 | predicted_effects: Dict[str, jnp.ndarray],
78 | *args,
79 | **kwargs,
80 | ) -> jnp.ndarray:
81 | """Return zero.
82 |
83 | Parameters
84 | ----------
85 | data : Any
86 | Data obtained from the transformed method (ignored).
87 |
88 | predicted_effects : Dict[str, jnp.ndarray]
89 | A dictionary containing the predicted effects (ignored).
90 |
91 | Returns
92 | -------
93 | jnp.ndarray
94 | Zero.
95 | """
96 | if data is None:
97 | raise ValueError("Data cannot be None in _predict method.")
98 | return jnp.zeros((data.shape[0], 1)).astype(jnp.float32)
99 |
--------------------------------------------------------------------------------
/src/prophetverse/experimental/simulate.py:
--------------------------------------------------------------------------------
1 | """Simulate data from a model, and intervene optionally."""
2 |
3 | from typing import Dict, Optional, Union
4 |
5 | import jax.numpy as jnp
6 | import numpy as np
7 | import numpyro
8 | import pandas as pd
9 | from jax.random import PRNGKey
10 |
11 | from prophetverse.sktime.base import BaseProphetForecaster
12 | from prophetverse._model import model as model_func
13 | from prophetverse.effects.target.base import BaseTargetEffect
14 |
15 | from numpyro.primitives import Messenger
16 |
17 |
18 | class IgnoreObservedSites(Messenger):
19 | def __init__(self):
20 | """
21 | obs_map: a dict mapping site names -> values
22 | """
23 | super().__init__(fn=None)
24 |
25 | def process_message(self, msg):
26 | # only intercept real sample sites that have no value yet
27 |
28 | if msg["type"] == "sample":
29 | # ...but tell NumPyro it's NOT an observed site
30 | msg["is_observed"] = False
31 | msg["obs"] = None
32 |
33 |
34 | def simulate(
35 | model: BaseProphetForecaster,
36 | fh: pd.Index,
37 | X: Optional[pd.DataFrame] = None,
38 | y: Optional[pd.DataFrame] = None,
39 | do: Optional[Dict[str, Union[jnp.ndarray, float]]] = None,
40 | return_model=False,
41 | ):
42 | """
43 | Simulate data from a model.
44 |
45 | **EXPERIMENTAL FEATURE**
46 | This feature allow to do prior predictive checks and to intervene to
47 | obtain simulated data.
48 |
49 | Parameters
50 | ----------
51 | model : BaseProphetForecaster
52 | The probabilistic model to perform inference on.
53 | fh : pd.Index
54 | The forecasting horizon as a pandas Index.
55 | X : pd.DataFrame, optional
56 | The input DataFrame containing the exogenous variables.
57 | y : pd.DataFrame, optional
58 | The timeseries dataframe. This is used by effects that implement `_fit` and
59 | use the target timeseries to initialize some parameters. If not provided,
60 | a dummy y will be created.
61 | do : Dict, optional
62 | A dictionary with the variables to intervene and their values.
63 | num_samples : int, optional
64 | The number of samples to generate. Defaults to 10.
65 | return_model : bool, optional
66 | If True, the fitted model will be returned. Defaults to False.
67 | Returns
68 | -------
69 | Union[Dict, Tuple]
70 | If return_model=False, a dictionary with the simulated data. Otherwise,
71 | a tuple (simulated_data, model).
72 | """
73 | # Fit model, creating a dummy y if it is not provided
74 | if y is None:
75 | if X is None:
76 | index = fh
77 | else:
78 | index, _ = X.index.reindex(fh, level=-1)
79 | y = pd.DataFrame(
80 | index=index, data=np.random.rand(len(index)) * 10, columns=["dummy"]
81 | )
82 |
83 | model = model.clone()
84 | model.fit(X=X, y=y)
85 |
86 | with IgnoreObservedSites():
87 | if do is not None:
88 | with numpyro.handlers.do(data=do):
89 | components = model.predict_component_samples(X=X, fh=fh)
90 | else:
91 | components = model.predict_component_samples(X=X, fh=fh)
92 | if return_model:
93 | return components, model
94 | return components
95 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/hill.py:
--------------------------------------------------------------------------------
1 | """Definition of Hill Effect class."""
2 |
3 | from typing import Dict, Optional, Any
4 |
5 | import jax.numpy as jnp
6 | import numpyro
7 | from numpyro import distributions as dist
8 | from numpyro.distributions import Distribution
9 |
10 | from prophetverse.effects.base import (
11 | EFFECT_APPLICATION_TYPE,
12 | BaseAdditiveOrMultiplicativeEffect,
13 | )
14 | from prophetverse.utils.algebric_operations import _exponent_safe
15 |
16 | __all__ = ["HillEffect"]
17 |
18 |
19 | class HillEffect(BaseAdditiveOrMultiplicativeEffect):
20 | """Represents a Hill effect in a time series model.
21 |
22 | Parameters
23 | ----------
24 | half_max_prior : Distribution, optional
25 | Prior distribution for the half-maximum parameter
26 | slope_prior : Distribution, optional
27 | Prior distribution for the slope parameter
28 | max_effect_prior : Distribution, optional
29 | Prior distribution for the maximum effect parameter
30 | effect_mode : effects_application, optional
31 | Mode of the effect (either "additive" or "multiplicative")
32 | """
33 |
34 | def __init__(
35 | self,
36 | effect_mode: EFFECT_APPLICATION_TYPE = "multiplicative",
37 | half_max_prior: Optional[Distribution] = None,
38 | slope_prior: Optional[Distribution] = None,
39 | max_effect_prior: Optional[Distribution] = None,
40 | offset_slope: Optional[float] = 0.0,
41 | input_scale: Optional[float] = 1.0,
42 | base_effect_name="trend",
43 | ):
44 | self.half_max_prior = half_max_prior
45 | self.slope_prior = slope_prior
46 | self.max_effect_prior = max_effect_prior
47 |
48 | self._half_max_prior = (
49 | self.half_max_prior if half_max_prior is not None else dist.Gamma(1, 1)
50 | )
51 | self._slope_prior = (
52 | self.slope_prior if slope_prior is not None else dist.HalfNormal(10)
53 | )
54 | self._max_effect_prior = (
55 | self.max_effect_prior if max_effect_prior is not None else dist.Gamma(1, 1)
56 | )
57 | self.offset_slope = offset_slope
58 | self.input_scale = input_scale
59 |
60 | super().__init__(effect_mode=effect_mode, base_effect_name=base_effect_name)
61 |
62 | def _predict(
63 | self,
64 | data: jnp.ndarray,
65 | predicted_effects: Dict[str, jnp.ndarray],
66 | *args,
67 | **kwargs
68 | ) -> jnp.ndarray:
69 | """Apply and return the effect values.
70 |
71 | Parameters
72 | ----------
73 | data : Any
74 | Data obtained from the transformed method.
75 |
76 | predicted_effects : Dict[str, jnp.ndarray]
77 | A dictionary containing the predicted effects
78 |
79 | Returns
80 | -------
81 | jnp.ndarray
82 | An array with shape (T,1) for univariate timeseries.
83 | """
84 | half_max = numpyro.sample("half_max", self._half_max_prior) * self.input_scale
85 | slope = numpyro.sample("slope", self._slope_prior) + self.offset_slope
86 | max_effect = numpyro.sample("max_effect", self._max_effect_prior)
87 |
88 | data = jnp.clip(data, 1e-9, None)
89 | x = _exponent_safe(data / half_max, -slope)
90 | effect = max_effect / (1 + x)
91 | return effect
92 |
--------------------------------------------------------------------------------
/tests/distributions/test_beta.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from jax import random
3 | from numpyro import distributions as dist
4 |
5 | from prophetverse.distributions import BetaReparametrized
6 |
7 |
8 | @pytest.mark.parametrize(
9 | "loc,factor",
10 | [
11 | (0.2, 0.5),
12 | (0.5, 0.5),
13 | (0.8, 0.5),
14 | (0.3, 1.0),
15 | (0.7, 1.0),
16 | (0.5, 2.0),
17 | ],
18 | )
19 | def test_beta_reparametrized_init_attributes(loc, factor):
20 | d = BetaReparametrized(loc, factor)
21 | assert isinstance(d, BetaReparametrized)
22 | assert d.loc == loc
23 | assert d.factor == factor
24 |
25 |
26 | @pytest.mark.parametrize(
27 | "loc,factor",
28 | [
29 | (0.2, 0.5),
30 | (0.5, 0.5),
31 | (0.8, 0.5),
32 | (0.3, 1.0),
33 | (0.7, 1.0),
34 | (0.5, 2.0),
35 | ],
36 | )
37 | def test_beta_reparametrized_positive_concentrations(loc, factor):
38 | d = BetaReparametrized(loc, factor)
39 | alpha = d.concentration1
40 | beta = d.concentration0
41 | # A valid Beta distribution must have positive concentration parameters
42 | assert alpha > 0, "Alpha (concentration1) must be positive"
43 | assert beta > 0, "Beta (concentration0) must be positive"
44 |
45 |
46 | @pytest.mark.parametrize(
47 | "loc,factor",
48 | [
49 | (0.2, 0.5),
50 | (0.5, 0.5),
51 | (0.8, 0.5),
52 | (0.3, 1.0),
53 | (0.7, 1.0),
54 | (0.5, 2.0),
55 | ],
56 | )
57 | def test_beta_reparametrized_mean_matches_loc(loc, factor):
58 | d = BetaReparametrized(loc, factor)
59 | alpha = d.concentration1
60 | beta = d.concentration0
61 | mean = alpha / (alpha + beta)
62 | assert mean == pytest.approx(loc, rel=1e-5, abs=1e-5)
63 |
64 |
65 | @pytest.mark.parametrize(
66 | "loc,factor",
67 | [
68 | (0.2, 0.5),
69 | (0.5, 0.5),
70 | (0.8, 0.5),
71 | (0.3, 1.0),
72 | (0.7, 1.0),
73 | (0.5, 2.0),
74 | ],
75 | )
76 | def test_beta_reparametrized_variance_bounds(loc, factor):
77 | d = BetaReparametrized(loc, factor)
78 | alpha = d.concentration1
79 | beta = d.concentration0
80 | var = (alpha * beta) / (((alpha + beta) ** 2) * (alpha + beta + 1.0))
81 | # For any valid Beta: 0 < var < loc(1-loc)
82 | assert var > 0, "Variance must be positive"
83 | assert var < loc * (1 - loc) - 1e-12, "Variance must be < loc(1-loc)"
84 |
85 |
86 | def test_beta_reparametrized_sample_shape_and_support():
87 | key = random.PRNGKey(0)
88 | d = BetaReparametrized(0.4, 0.5)
89 | samples = d.sample(key, (1000,))
90 | assert samples.shape == (1000,)
91 | # All samples must lie in (0,1)
92 | assert (samples > 0).all() and (samples < 1).all()
93 |
94 |
95 | @pytest.mark.parametrize("value", [0.1, 0.3, 0.6, 0.9])
96 | def test_beta_reparametrized_log_prob_matches_manual(value):
97 | loc = 0.4
98 | factor = 0.5
99 | d = BetaReparametrized(loc, factor)
100 | alpha = d.concentration1
101 | beta_param = d.concentration0
102 | # Use a standard Beta with the same inferred concentrations
103 | ref = dist.Beta(concentration1=alpha, concentration0=beta_param)
104 | lp1 = d.log_prob(value)
105 | lp2 = ref.log_prob(value)
106 | assert lp1 == pytest.approx(lp2)
107 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Quarto Documentation
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | tags: [v*]
7 | workflow_dispatch:
8 | pull_request:
9 |
10 | concurrency:
11 | group: ${{ github.workflow }}-${{ github.ref }}
12 | cancel-in-progress: true
13 |
14 | permissions:
15 | actions: write
16 | contents: write # needed for gh-pages
17 |
18 | jobs:
19 | build-docs:
20 | name: Build and Deploy Documentation
21 | runs-on: ubuntu-latest
22 |
23 | steps:
24 | - name: Checkout code
25 | uses: actions/checkout@v4
26 |
27 | - name: Set up Python
28 | uses: actions/setup-python@v6
29 | with:
30 | python-version: '3.11'
31 |
32 | - name: Install dependencies
33 | run: |
34 | python -m pip install --upgrade pip
35 | pip install ".[dev]"
36 |
37 | - name: Set PYTHONPATH
38 | run: echo "PYTHONPATH=$GITHUB_WORKSPACE/src" >> $GITHUB_ENV
39 |
40 | - name: Install Quarto
41 | uses: quarto-dev/quarto-actions/setup@v2
42 |
43 | - name: Check Quarto installation
44 | run: |
45 | quarto check
46 |
47 | - name: Render Quarto site
48 | run: |
49 | quarto render docs
50 |
51 | # Deploy Preview for PRs
52 | - name: Publish PR Preview
53 | if: github.event_name == 'pull_request'
54 | uses: peaceiris/actions-gh-pages@v4
55 | with:
56 | github_token: ${{ secrets.GITHUB_TOKEN }}
57 | publish_dir: ./docs/_site
58 | destination_dir: previews/PR${{ github.event.number }}
59 | env:
60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
61 |
62 | # Deploy Dev Site from main
63 | - name: Publish Dev Site
64 | if: github.event_name == 'push' && github.ref == 'refs/heads/main'
65 | uses: peaceiris/actions-gh-pages@v4
66 | with:
67 | github_token: ${{ secrets.GITHUB_TOKEN }}
68 | publish_dir: ./docs/_site
69 | destination_dir: dev
70 | env:
71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
72 |
73 | # Deploy Versioned Release
74 | - name: Publish Versioned Site
75 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
76 | uses: peaceiris/actions-gh-pages@v4
77 | with:
78 | github_token: ${{ secrets.GITHUB_TOKEN }}
79 | publish_dir: ./docs/_site
80 | destination_dir: ${{ github.ref_name }}
81 | env:
82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
83 |
84 | - name: Create 'latest' alias for stable release
85 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
86 | run: |
87 | version="${GITHUB_REF#refs/tags/}"
88 | echo "Detected version: $version"
89 | mkdir -p ./latest
90 | cp -r ./docs/_site/* ./latest/
91 |
92 | - name: Publish stable release to 'latest'
93 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
94 | uses: peaceiris/actions-gh-pages@v4
95 | with:
96 | github_token: ${{ secrets.GITHUB_TOKEN }}
97 | publish_dir: ./latest
98 | destination_dir: latest
99 | env:
100 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
101 |
--------------------------------------------------------------------------------
/src/prophetverse/distributions/truncated_discrete.py:
--------------------------------------------------------------------------------
1 | """Truncated discrete distribution."""
2 |
3 | import jax
4 | import jax.numpy as jnp
5 | import numpyro.distributions as dist
6 | from jax import random
7 | from numpyro.distributions import constraints
8 | from numpyro.distributions.distribution import Distribution
9 | from numpyro.distributions.util import validate_sample
10 |
11 | from ._inverse_functions import _inverse_neg_binom, _inverse_poisson
12 |
13 | REGISTRY = {
14 | dist.Poisson: _inverse_poisson,
15 | dist.NegativeBinomial2: _inverse_neg_binom,
16 | }
17 |
18 |
19 | class TruncatedDiscrete(Distribution):
20 | """Wrapper to create a zero-truncated version of a discrete distribution.
21 |
22 | Takes a base distribution (like Poisson or NegativeBinomial) and
23 | modifies its log_prob and sample methods to enforce a support of {1, 2, 3, ...}.
24 |
25 | Parameters
26 | ----------
27 | base_dist: numpyro.distributions.Distribution
28 | A discrete distribution with non-negative support.
29 |
30 | low: int, default=0
31 | The lower bound of the support. The distribution will only consider
32 | values greater than this bound.
33 | """
34 |
35 | has_rsample = False
36 | pytree_data_fields = ("base_dist",)
37 | pytree_aux_fields = ("_icdf", "low")
38 |
39 | def __init__(self, base_dist, low: int = 0, *, validate_args=None):
40 | if base_dist.support is not constraints.nonnegative_integer:
41 | raise ValueError("ZeroTruncated only works with discrete distributions!")
42 |
43 | if base_dist.__class__ not in REGISTRY:
44 | raise ValueError(
45 | f"Base distribution '{base_dist.__class__.__name__}' not supported!"
46 | )
47 |
48 | super().__init__(
49 | batch_shape=base_dist.batch_shape,
50 | event_shape=base_dist.event_shape,
51 | validate_args=validate_args,
52 | )
53 |
54 | self.base_dist = base_dist
55 | self.low = low
56 | self._icdf = REGISTRY[base_dist.__class__]
57 |
58 | @property
59 | def support(self): # noqa: D102
60 | return constraints.integer_greater_than(self.low)
61 |
62 | @validate_sample
63 | def log_prob(self, value): # noqa: D102
64 | is_invalid = value <= self.low
65 |
66 | log_prob_base = self.base_dist.log_prob(value)
67 |
68 | log_prob_at_zero = self.base_dist.log_prob(self.low)
69 | log_normalizer = jnp.log1p(-jnp.exp(log_prob_at_zero))
70 |
71 | log_prob_truncated = log_prob_base - log_normalizer
72 |
73 | return jnp.where(is_invalid, -jnp.inf, log_prob_truncated)
74 |
75 | def sample(self, key, sample_shape=()): # noqa: D102
76 | shape = sample_shape + self.batch_shape
77 |
78 | dtype = jnp.result_type(float)
79 | finfo = jnp.finfo(dtype)
80 | minval = finfo.tiny
81 |
82 | u = random.uniform(key, shape=shape, minval=minval)
83 |
84 | return self.icdf(u)
85 |
86 | def icdf(self, u): # noqa: D102
87 | result_shape = jax.ShapeDtypeStruct(u.shape, jnp.result_type(float))
88 |
89 | low_cdf = self.base_dist.cdf(self.low)
90 | normalizer = 1.0 - low_cdf
91 | x = normalizer * u + low_cdf
92 |
93 | result = jax.pure_callback(
94 | self._icdf,
95 | result_shape,
96 | *(self.base_dist, x),
97 | )
98 | return result.astype(jnp.result_type(int))
99 |
--------------------------------------------------------------------------------
/src/prophetverse/effects/michaelis_menten.py:
--------------------------------------------------------------------------------
1 | """Definition of Michaelis-Menten Effect class."""
2 |
3 | from typing import Dict, Optional
4 |
5 | import jax.numpy as jnp
6 | import numpyro
7 | from numpyro import distributions as dist
8 | from numpyro.distributions import Distribution
9 |
10 | from prophetverse.effects.base import (
11 | EFFECT_APPLICATION_TYPE,
12 | BaseAdditiveOrMultiplicativeEffect,
13 | )
14 |
15 | __all__ = ["MichaelisMentenEffect"]
16 |
17 |
18 | class MichaelisMentenEffect(BaseAdditiveOrMultiplicativeEffect):
19 | """Represents a Michaelis-Menten effect in a time series model.
20 |
21 | The Michaelis-Menten equation is commonly used in biochemistry to describe
22 | enzyme kinetics, but it's also useful for modeling saturation effects in
23 | time series analysis. The effect follows the equation:
24 |
25 | effect = (max_effect * data) / (half_saturation + data)
26 |
27 | Where:
28 | - max_effect is the maximum effect value (Vmax in biochemistry)
29 | - half_saturation is the value at which effect = max_effect/2 (Km in biochemistry)
30 | - data is the input variable (substrate concentration in biochemistry)
31 |
32 | Parameters
33 | ----------
34 | max_effect_prior : Distribution, optional
35 | Prior distribution for the maximum effect parameter
36 | half_saturation_prior : Distribution, optional
37 | Prior distribution for the half-saturation parameter
38 | effect_mode : effects_application, optional
39 | Mode of the effect (either "additive" or "multiplicative")
40 | """
41 |
42 | def __init__(
43 | self,
44 | effect_mode: EFFECT_APPLICATION_TYPE = "multiplicative",
45 | max_effect_prior: Optional[Distribution] = None,
46 | half_saturation_prior: Optional[Distribution] = None,
47 | base_effect_name="trend",
48 | ):
49 | self.max_effect_prior = max_effect_prior
50 | self.half_saturation_prior = half_saturation_prior
51 |
52 | self._max_effect_prior = (
53 | self.max_effect_prior if max_effect_prior is not None else dist.Gamma(1, 1)
54 | )
55 | self._half_saturation_prior = (
56 | self.half_saturation_prior if half_saturation_prior is not None else dist.Gamma(1, 1)
57 | )
58 |
59 | super().__init__(effect_mode=effect_mode, base_effect_name=base_effect_name)
60 |
61 | def _predict(
62 | self,
63 | data: jnp.ndarray,
64 | predicted_effects: Dict[str, jnp.ndarray],
65 | *args,
66 | **kwargs
67 | ) -> jnp.ndarray:
68 | """Apply and return the effect values.
69 |
70 | Parameters
71 | ----------
72 | data : Any
73 | Data obtained from the transformed method.
74 |
75 | predicted_effects : Dict[str, jnp.ndarray]
76 | A dictionary containing the predicted effects
77 |
78 | Returns
79 | -------
80 | jnp.ndarray
81 | An array with shape (T,1) for univariate timeseries.
82 | """
83 | max_effect = numpyro.sample("max_effect", self._max_effect_prior)
84 | half_saturation = numpyro.sample("half_saturation", self._half_saturation_prior)
85 |
86 | # Clip data to avoid numerical issues with very small values
87 | data = jnp.clip(data, 1e-9, None)
88 |
89 | # Apply Michaelis-Menten equation: effect = (max_effect * data) / (half_saturation + data)
90 | effect = (max_effect * data) / (half_saturation + data)
91 |
92 | return effect
--------------------------------------------------------------------------------
/tests/models/multivariate_model/test_frame_to_array.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pandas as pd
3 | import pytest
4 | from jax import numpy as jnp
5 | from sktime.transformations.hierarchical.aggregate import Aggregator
6 | from sktime.utils._testing.hierarchical import _make_hierarchical
7 |
8 | from prophetverse.utils import (
9 | convert_dataframe_to_tensors,
10 | convert_index_to_days_since_epoch,
11 | get_bottom_series_idx,
12 | get_multiindex_loc,
13 | iterate_all_series,
14 | loc_bottom_series,
15 | series_to_tensor,
16 | )
17 |
18 |
19 | # Sample data preparation
20 | @pytest.fixture
21 | def sample_hierarchical_data():
22 | levels = [("A", i) for i in range(3)] + [("B", i) for i in range(3)]
23 | idx = pd.MultiIndex.from_tuples(levels, names=["Level1", "Level2"])
24 | data = np.random.randn(6, 2)
25 | return pd.DataFrame(data, index=idx, columns=["Feature1", "Feature2"])
26 |
27 |
28 | @pytest.fixture
29 | def sample_time_index():
30 | dates = pd.date_range(start="2020-01-01", periods=10, freq="D")
31 | return pd.PeriodIndex(dates, freq="D")
32 |
33 |
34 | # Test for getting bottom series index
35 | def test_get_bottom_series_idx(sample_hierarchical_data):
36 | idx = get_bottom_series_idx(sample_hierarchical_data)
37 | assert idx.equals(pd.Index(["A", "B"]))
38 |
39 |
40 | # Test for locating data using multiindex
41 | def test_get_multiindex_loc(sample_hierarchical_data):
42 | result = get_multiindex_loc(sample_hierarchical_data, [("A", 1)])
43 | assert (
44 | not result.empty and len(result) == 1
45 | ), "Should return 1 rows matching the multi-index ('A', 1)"
46 |
47 |
48 | # Test for fetching bottom series
49 | @pytest.mark.parametrize("hierarchical_levels", [(2, 4, 4), (2,)])
50 | def test_loc_bottom_series(hierarchical_levels):
51 | hierarchical_data = _make_hierarchical(hierarchical_levels)
52 | aggregated = Aggregator(flatten_single_levels=False).fit_transform(
53 | hierarchical_data
54 | )
55 | result = loc_bottom_series(aggregated)
56 | pd.testing.assert_frame_equal(result.sort_index(), hierarchical_data.sort_index())
57 |
58 |
59 | def test_get_bottom_series_idx_raises_error():
60 | y = pd.DataFrame(data=[1, 2, 3], index=[1, 2, 3], columns=["A"])
61 | with pytest.raises(ValueError):
62 | get_bottom_series_idx(y)
63 |
64 |
65 | # Test for iterating all series
66 | def test_iterate_all_series(sample_hierarchical_data):
67 | for _, series in iterate_all_series(sample_hierarchical_data):
68 | assert len(series) == 3, "Each iterated series should have 2 features"
69 |
70 |
71 | # Test for converting index to days since epoch
72 | def test_convert_index_to_days_since_epoch(sample_time_index):
73 | result = convert_index_to_days_since_epoch(sample_time_index)
74 | assert result.shape == (10,), "Should convert 10 dates into days since epoch"
75 |
76 |
77 | # Test for converting a DataFrame series to a JAX tensor
78 | def test_series_to_tensor(sample_hierarchical_data):
79 | result = series_to_tensor(sample_hierarchical_data)
80 | assert isinstance(result, jnp.ndarray) and result.shape == (
81 | 2,
82 | 3,
83 | 2,
84 | ), "Shape should reflect the reshaping into a 3D tensor"
85 |
86 |
87 | # Test for converting DataFrame to tensors
88 | def test_convert_dataframe_to_tensors(sample_hierarchical_data):
89 | t_arrays, df_as_arrays = convert_dataframe_to_tensors(sample_hierarchical_data)
90 | assert isinstance(t_arrays, jnp.ndarray) and isinstance(
91 | df_as_arrays, jnp.ndarray
92 | ), "Should convert both time and data arrays to JAX tensors"
93 |
--------------------------------------------------------------------------------
/tests/utils/test_deprecation.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from prophetverse.utils.deprecation import deprecation_warning
4 |
5 |
6 | def test_deprecation_warning_with_extra_message():
7 | obj_name = "my_function"
8 | current_version = "1.2.3"
9 | extra_message = "Use 'new_function' instead."
10 |
11 | expected_major = 1
12 | expected_minor = 2 + 1
13 | expected_patch = 0
14 | expected_deprecation_version = f"{expected_major}.{expected_minor}.{expected_patch}"
15 |
16 | expected_message = (
17 | f"Warning: '{obj_name}' is deprecated and will be removed in version"
18 | f" {expected_deprecation_version}. Please update your code to avoid issues. "
19 | f"{extra_message}"
20 | )
21 |
22 | with pytest.warns(FutureWarning) as record:
23 | deprecation_warning(obj_name, current_version, extra_message)
24 |
25 | assert len(record) == 1
26 | assert str(record[0].message) == expected_message
27 |
28 |
29 | def test_deprecation_warning_without_extra_message():
30 | obj_name = "old_function"
31 | current_version = "2.5.0"
32 |
33 | expected_major = 2
34 | expected_minor = 5 + 1
35 | expected_patch = 0
36 | expected_deprecation_version = f"{expected_major}.{expected_minor}.{expected_patch}"
37 |
38 | expected_message = (
39 | f"Warning: '{obj_name}' is deprecated and will be removed in version"
40 | f" {expected_deprecation_version}. Please update your code to avoid issues. "
41 | )
42 |
43 | with pytest.warns(FutureWarning) as record:
44 | deprecation_warning(obj_name, current_version)
45 |
46 | assert len(record) == 1
47 | assert str(record[0].message) == expected_message
48 |
49 |
50 | def test_deprecation_warning_invalid_version_format():
51 | obj_name = "invalid_function"
52 | invalid_version = "invalid.version"
53 |
54 | with pytest.raises(ValueError) as exc_info:
55 | deprecation_warning(obj_name, invalid_version)
56 |
57 | assert "Invalid version format. Expected 'major.minor.patch'." in str(
58 | exc_info.value
59 | )
60 |
61 |
62 | def test_deprecation_warning_high_minor_version():
63 | obj_name = "edge_function"
64 | current_version = "3.99.1"
65 |
66 | expected_major = 3
67 | expected_minor = 99 + 1
68 | expected_patch = 0
69 | expected_deprecation_version = f"{expected_major}.{expected_minor}.{expected_patch}"
70 |
71 | expected_message = (
72 | f"Warning: '{obj_name}' is deprecated and will be removed in version"
73 | f" {expected_deprecation_version}. Please update your code to avoid issues. "
74 | )
75 |
76 | with pytest.warns(FutureWarning) as record:
77 | deprecation_warning(obj_name, current_version)
78 |
79 | assert len(record) == 1
80 | assert str(record[0].message) == expected_message
81 |
82 |
83 | def test_deprecation_warning_non_integer_version():
84 | obj_name = "float_function"
85 | current_version = "1.2.3.4"
86 |
87 | with pytest.raises(ValueError) as exc_info:
88 | deprecation_warning(obj_name, current_version)
89 |
90 | assert "Invalid version format. Expected 'major.minor.patch'." in str(
91 | exc_info.value
92 | )
93 |
94 |
95 | def test_deprecation_warning_incomplete_version():
96 | obj_name = "incomplete_function"
97 | current_version = "1.2"
98 |
99 | with pytest.raises(ValueError) as exc_info:
100 | deprecation_warning(obj_name, current_version)
101 |
102 | assert "Invalid version format. Expected 'major.minor.patch'." in str(
103 | exc_info.value
104 | )
105 |
--------------------------------------------------------------------------------
/src/prophetverse/budget_optimization/constraints.py:
--------------------------------------------------------------------------------
1 | """Constraints for Budget Optimizer"""
2 |
3 | import numpy as np
4 | from prophetverse.budget_optimization.base import (
5 | BaseConstraint,
6 | )
7 | import jax
8 | import jax.numpy as jnp
9 | import pandas as pd
10 | from jax import grad
11 |
12 | __all__ = [
13 | "TotalBudgetConstraint",
14 | ]
15 |
16 |
17 | class TotalBudgetConstraint(BaseConstraint):
18 | """Shared budget constraint.
19 |
20 | This constraint ensures that the sum of the budgets for the specified
21 | channels is equal to the total budget.
22 |
23 | Parameters
24 | ----------
25 | channels : list, optional
26 | List of channels to be constrained. If None, all channels are used.
27 | total : float, optional
28 | Total budget. If None, the total budget is computed from the input data.
29 | """
30 |
31 | def __init__(self, channels=None, total=None):
32 | self.channels = channels
33 | self.total = total
34 | self.constraint_type = "eq"
35 | super().__init__()
36 |
37 | def __call__(self, X: pd.DataFrame, horizon: pd.Index, columns: list):
38 | """
39 | Return optimization constraint definition.
40 | """
41 |
42 | channels = self.channels
43 | if channels is None:
44 | channels = columns
45 |
46 | total = self.total
47 | if total is None:
48 | mask = X.index.get_level_values(-1).isin(horizon)
49 | total = X.loc[mask, columns].sum(axis=0).sum()
50 |
51 | channel_idx = [columns.index(ch) for ch in channels]
52 |
53 | def func(x_array: jnp.ndarray, *args):
54 | """
55 | Return >=0 if the sum of the budgets for the specified channels.
56 | """
57 | x_array = x_array.reshape(-1, len(channels))
58 | channels_budget = x_array[:, channel_idx]
59 | val = total - np.sum(channels_budget)
60 | return val
61 |
62 | return {"type": self.constraint_type, "fun": func, "jac": grad(func)}
63 |
64 |
65 | # TODO: remove in future version
66 | SharedBudgetConstraint = TotalBudgetConstraint
67 |
68 |
69 | class MinimumTargetResponse(BaseConstraint):
70 | """Minimum target response constraint.
71 |
72 | This constraint ensures that the target response is greater than or equal
73 | to a specified value. This imposes a restriction on the **output** of the
74 | model, instead of the input.
75 |
76 | Parameters
77 | ----------
78 | target_response : float
79 | Target response value. The model output must be greater than or equal
80 | to this value.
81 | """
82 |
83 | def __init__(self, target_response: float, constraint_type="ineq"):
84 | self.target_response = target_response
85 | self.constraint_type = constraint_type
86 | super().__init__()
87 |
88 | def __call__(self, X, horizon, columns):
89 |
90 | fh: pd.Index = X.index.get_level_values(-1).unique()
91 | X = X.copy()
92 |
93 | # Get the indexes of `horizon` in fh
94 | horizon_idx = jnp.array([fh.get_loc(h) for h in horizon])
95 |
96 | def func(x_array, budget_optimizer, *args):
97 | """
98 | Return >=0 if the target response is greater than or equal to the
99 | specified value.
100 | """
101 | obs = budget_optimizer.predictive_(x_array)
102 | out = obs.mean(axis=0).squeeze(-1)
103 | out = out[..., budget_optimizer.horizon_idx_].sum()
104 | out = out - self.target_response
105 |
106 | return out
107 |
108 | return {
109 | "type": self.constraint_type,
110 | "fun": func,
111 | "jac": grad(func),
112 | }
113 |
--------------------------------------------------------------------------------
/tests/distributions/test_hurdle.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import jax.numpy as jnp
3 | import jax.random as jrnd
4 | from numpyro.distributions import NegativeBinomial2, Poisson
5 |
6 | from prophetverse.distributions import TruncatedDiscrete, HurdleDistribution
7 |
8 |
9 | @pytest.mark.parametrize(
10 | "distribution",
11 | [
12 | ("poisson", {"rate": 1.0}),
13 | ("negative-binomial", {"mean": 1.0, "concentration": 1.0}),
14 | ],
15 | )
16 | def test_zero_truncated_distribution(distribution):
17 | dist_name, params = distribution
18 |
19 | if dist_name == "poisson":
20 | base_dist = Poisson(**params)
21 | elif dist_name == "negative-binomial":
22 | base_dist = NegativeBinomial2(**params)
23 |
24 | new_dist = TruncatedDiscrete(base_dist)
25 | samples = new_dist.sample(jrnd.key(123), (1_000,))
26 |
27 | bad_log_prob = new_dist.log_prob(0)
28 | assert bad_log_prob == -float("inf"), "Log probability at zero should be -inf"
29 |
30 | assert (samples.min() > 0).all()
31 | assert (new_dist.log_prob(samples) > base_dist.log_prob(samples)).all()
32 |
33 |
34 | @pytest.mark.parametrize(
35 | "prob_gt_zero, distribution, params, value",
36 | [
37 | (0.3, "poisson", {"rate": 1.5}, 1),
38 | (0.7, "poisson", {"rate": 2.0}, 2),
39 | (0.4, "negative-binomial", {"mean": 1.2, "concentration": 1.5}, 1),
40 | (0.6, "negative-binomial", {"mean": 2.5, "concentration": 2.0}, 3),
41 | ],
42 | )
43 | def test_hurdle_log_prob_positive(prob_gt_zero, distribution, params, value):
44 | if distribution == "poisson":
45 | base_dist = Poisson(**params)
46 | else:
47 | base_dist = NegativeBinomial2(**params)
48 |
49 | truncated = TruncatedDiscrete(base_dist)
50 | hurdle = HurdleDistribution(jnp.array(prob_gt_zero), truncated)
51 |
52 | # Expected: log(p) + truncated.log_prob(value)
53 | expected = jnp.log(prob_gt_zero) + truncated.log_prob(value)
54 | assert hurdle.log_prob(value) == pytest.approx(float(expected))
55 |
56 |
57 | @pytest.mark.parametrize("prob_gt_zero", [0.1, 0.3, 0.5, 0.8])
58 | def test_hurdle_log_prob_zero(prob_gt_zero):
59 | base_dist = Poisson(rate=1.5)
60 | truncated = TruncatedDiscrete(base_dist)
61 | hurdle = HurdleDistribution(jnp.array(prob_gt_zero), truncated)
62 |
63 | lp_zero = hurdle.log_prob(0)
64 | expected = jnp.log1p(-prob_gt_zero)
65 | assert lp_zero == pytest.approx(float(expected))
66 |
67 |
68 | @pytest.mark.parametrize(
69 | "prob_gt_zero, distribution, params",
70 | [
71 | (0.2, "poisson", {"rate": 1.0}),
72 | (0.6, "poisson", {"rate": 2.5}),
73 | (0.4, "negative-binomial", {"mean": 1.5, "concentration": 1.2}),
74 | (0.7, "negative-binomial", {"mean": 2.0, "concentration": 2.5}),
75 | ],
76 | )
77 | def test_hurdle_sample(prob_gt_zero, distribution, params):
78 | if distribution == "poisson":
79 | base_dist = Poisson(**params)
80 | else:
81 | base_dist = NegativeBinomial2(**params)
82 |
83 | truncated = TruncatedDiscrete(base_dist)
84 | hurdle = HurdleDistribution(jnp.array(prob_gt_zero), truncated)
85 |
86 | key = jrnd.key(321)
87 | n = 5_000
88 | samples = hurdle.sample(key, (n,))
89 |
90 | # Support check
91 | assert (samples >= 0).all()
92 |
93 | # Empirical zero frequency approximates (1 - p)
94 | zero_freq = (samples == 0).mean()
95 | expected_zero = 1.0 - prob_gt_zero
96 | # Allow a tolerance ~3 standard errors: sqrt(p*(1-p)/n)
97 | se = (expected_zero * (1 - expected_zero) / n) ** 0.5
98 | assert abs(float(zero_freq) - expected_zero) < 3.5 * se + 0.01 # minimum slack
99 |
100 | # Positive samples (if any) should be > 0
101 | positives = samples[samples > 0]
102 | if positives.size > 0:
103 | assert (positives > 0).all()
104 |
--------------------------------------------------------------------------------
/src/prophetverse/datasets/synthetic/_squared_exogenous.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pandas as pd
3 |
4 | __all__ = ["load_synthetic_squared_exogenous"]
5 |
6 |
7 | def _generate_dataset(
8 | n_periods: int,
9 | seasonality_period: int,
10 | trend_slope: float,
11 | exogenous_range: tuple,
12 | noise_std: float = 1.0,
13 | seed: int = 0,
14 | ) -> tuple[pd.Series, pd.DataFrame]:
15 | """
16 | Generate a simple synthetic time series in sktime format.
17 |
18 | The series is composed of seasonality, trend,
19 | and exogenous variables.
20 |
21 | Parameters
22 | ----------
23 | n_periods : int
24 | Number of time periods to simulate.
25 | seasonality_period : int
26 | Period of the seasonal component.
27 | trend_slope : float
28 | Slope of the linear trend.
29 | exogenous_range : tuple
30 | Range (min, max) for the exogenous variable values.
31 | noise_std : float, optional
32 | Standard deviation of the Gaussian noise, by default 1.0.
33 | seed : int, optional
34 | Random seed for reproducibility, by default None.
35 |
36 | Returns
37 | -------
38 | tuple[pd.Series, pd.DataFrame]
39 | y : pd.Series
40 | Target variable in sktime format with time index.
41 | X : pd.DataFrame
42 | Exogenous variables and components in sktime format with time index.
43 |
44 | Examples
45 | --------
46 | >>> y, X = generate_sktime_time_series(100, 12, 0.5, (1, 10), 0.5, seed=42)
47 | >>> y.head()
48 | time
49 | 0 0.838422
50 | 1 1.488498
51 | 2 2.230748
52 | 3 2.930336
53 | 4 3.724452
54 | Name: target, dtype: float64
55 | >>> X.head()
56 | seasonality trend exogenous noise
57 | time
58 | 0 0.000000 0.000000 5.749081 0.211731
59 | 1 0.258819 0.500000 6.901429 0.326080
60 | 2 0.500000 1.000000 6.463987 0.460959
61 | 3 0.707107 1.500000 5.197317 0.676962
62 | 4 0.866025 2.000000 3.312037 0.546416
63 | """
64 | rng = np.random.default_rng(seed)
65 |
66 | # Time index
67 | time_index = pd.period_range(
68 | start="2010-01-01", freq="D", periods=n_periods, name="time"
69 | )
70 |
71 | # Seasonal component
72 | seasonality = np.sin(2 * np.pi * np.arange(n_periods) / seasonality_period)
73 |
74 | _t = np.arange(n_periods)
75 | _t = _t - _t.mean()
76 | _t = _t / n_periods * 20
77 | # Linear trend
78 | trend = trend_slope / (1 + np.exp(-_t))
79 |
80 | # Exogenous variable
81 | exogenous = rng.uniform(*exogenous_range, size=n_periods)
82 |
83 | # Logarithmic effect of exogenous variable
84 | exog_effect = 2 * (exogenous - 5) ** 2 # Adding 1 to avoid log(0)
85 |
86 | # Noise
87 | noise = rng.normal(scale=noise_std, size=n_periods)
88 |
89 | # Target variable
90 | target = seasonality + trend + exog_effect + noise
91 |
92 | # Construct y and X
93 | y = pd.Series(data=target, index=time_index, name="target").to_frame()
94 | X = pd.DataFrame(
95 | data={
96 | "exogenous": exogenous,
97 | },
98 | index=time_index,
99 | )
100 |
101 | return y, X
102 |
103 |
104 | def load_synthetic_squared_exogenous():
105 | """Load the synthetic log exogenous dataset.
106 |
107 | This dataset is just for documentation purposes.
108 |
109 | Returns
110 | -------
111 | pd.DataFrame
112 | The synthetic target variable
113 | pd.DataFrame
114 | The synthetic exogenous variable
115 |
116 | """
117 | return _generate_dataset(
118 | n_periods=700,
119 | seasonality_period=365.25,
120 | trend_slope=10,
121 | exogenous_range=(1, 10),
122 | noise_std=2,
123 | seed=42,
124 | )
125 |
--------------------------------------------------------------------------------
/docs/tutorials/tuning.qmd:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Tuning Prophetverse with sktime"
3 | description: "*This guide explains how to optimize Prophetverse model hyperparameters using sktime's tuning classes (e.g., GridSearchCV).*"
4 | ---
5 |
6 | ```{python}
7 | # | echo: False
8 | import warnings
9 |
10 | warnings.filterwarnings("ignore", category=UserWarning)
11 | ```
12 |
13 | ## Overview
14 |
15 | Prophetverse is compatible with sktime’s tuning framework. You can define a parameter grid for components (such as trend and seasonality) and then use cross-validation tools (e.g., GridSearchCV) to search for the best parameters.
16 |
17 | ## Example: Using GridSearchCV with Prophetverse
18 |
19 | 1. Import necessary modules and load your dataset.
20 | 2. Define the hyperparameter grid for components (e.g., changepoint_interval and changepoint_prior_scale in the trend).
21 | 3. Create a Prophetverse instance with initial settings.
22 | 4. Wrap the model with sktime’s GridSearchCV and run the tuning process.
23 |
24 | ### Loading the data
25 | ```{python}
26 | import pandas as pd
27 | from sktime.forecasting.model_selection import ForecastingGridSearchCV
28 | from prophetverse.sktime import Prophetverse
29 | from prophetverse.effects.trend import PiecewiseLinearTrend
30 | from prophetverse.effects.fourier import LinearFourierSeasonality
31 | from prophetverse.engine import MAPInferenceEngine
32 | from prophetverse.utils import no_input_columns
33 |
34 | # Load example dataset (replace with your own data as needed)
35 | from prophetverse.datasets.loaders import load_peyton_manning
36 |
37 | y = load_peyton_manning()
38 | y.head()
39 | ```
40 |
41 |
42 | ### Setting the model
43 |
44 | We create our model instance, before passing it to tuning.
45 | ```{python}
46 |
47 | # Create the initial Prophetverse model.
48 | model = Prophetverse(
49 | trend=PiecewiseLinearTrend(
50 | changepoint_interval=500,
51 | changepoint_prior_scale=0.00001,
52 | changepoint_range=-250,
53 | ),
54 | exogenous_effects=[
55 | (
56 | "seasonality",
57 | LinearFourierSeasonality(
58 | freq="D",
59 | sp_list=[7, 365.25],
60 | fourier_terms_list=[3, 10],
61 | prior_scale=0.1,
62 | effect_mode="multiplicative",
63 | ),
64 | no_input_columns,
65 | ),
66 | ],
67 | inference_engine=MAPInferenceEngine(),
68 | )
69 | model
70 | ```
71 |
72 | ### Define the searcher
73 |
74 | In sktime, the tuner is also an estimator/forecaster, so we can use the same interface as for any other sktime forecaster. We can use `GridSearchCV` to search for the best parameters in a given parameter grid.
75 |
76 | ```{python}
77 | # Set up cv strategy
78 | from sktime.split import ExpandingWindowSplitter
79 |
80 | cv = ExpandingWindowSplitter(fh=[1, 2, 3], step_length=1000, initial_window=1000)
81 |
82 | param_grid = {
83 | "trend__changepoint_interval": [300, 700],
84 | "trend__changepoint_prior_scale": [0.0001, 0.00001],
85 | "seasonality__prior_scale": [0.1],
86 | }
87 |
88 |
89 | # Set up GridSearchCV with 3-fold cross-validation.
90 | grid_search = ForecastingGridSearchCV(
91 | model,
92 | param_grid=param_grid,
93 | cv=cv
94 | )
95 | grid_search
96 | ```
97 |
98 | Now, we can call fit.
99 |
100 | ```{python}
101 | # Run the grid search.
102 | grid_search.fit(y=y, X=None)
103 |
104 | # Display the best parameters found.
105 | print("Best parameters:", grid_search.best_params_)
106 | ```
107 |
108 | We can also see the performance of each parameter combination:
109 |
110 |
111 | ```{python}
112 | grid_search.cv_results_
113 | ```
114 |
115 | Optionally, extract the best model from the grid search results.
116 | ```{python}
117 | best_model = grid_search.best_forecaster_
118 | best_model
119 | ```
120 |
--------------------------------------------------------------------------------
/src/prophetverse/datasets/loaders.py:
--------------------------------------------------------------------------------
1 | """Loaders for the datasets used in the examples."""
2 |
3 | from pathlib import Path
4 | from typing import Union
5 |
6 | import pandas as pd
7 | from sktime.datasets import load_forecastingdata
8 | from sktime.transformations.hierarchical.aggregate import Aggregator
9 |
10 | DATASET_MODULE_PATH = Path(__file__).parent
11 |
12 |
13 | def load_tourism(groupby: Union[list[str], str] = "Region"):
14 | """
15 | Load the tourism dataset from Athanasopoulos et al. (2011).
16 |
17 | Parameters
18 | ----------
19 | groupby : list[str] or str, optional
20 | The columns to group by. Defaults to "Region".
21 |
22 | Returns
23 | -------
24 | pd.DataFrame
25 | The tourism dataset.
26 |
27 | See Also
28 | --------
29 | [1] Athanasopoulos, G., Hyndman, R. J., Song, H., & Wu, D. C. (2011).
30 | The tourism forecasting competition. International Journal of Forecasting,
31 | 27(3), 822-844.
32 | """
33 | if isinstance(groupby, str):
34 | groupby = [groupby]
35 |
36 | groupby = [g.lower() for g in groupby]
37 |
38 | # Verify if the groupby columns are valid
39 | valid_columns = ["region", "purpose", "state", "quarter"]
40 | diff = set(groupby) - set(valid_columns)
41 | assert not diff, f"Invalid columns: {diff}"
42 |
43 | data = pd.read_csv(DATASET_MODULE_PATH / "raw/tourism.csv")
44 | data["Quarter"] = pd.PeriodIndex(data["Quarter"], freq="Q")
45 | data = data.set_index(["Region", "Purpose", "State", "Quarter"])[["Trips"]]
46 | data = data.sort_index()
47 |
48 | idxs_to_groupby = [g.capitalize() for g in groupby]
49 |
50 | y = (
51 | Aggregator(flatten_single_levels=False)
52 | .fit_transform(data)
53 | .groupby(level=[*idxs_to_groupby, -1])
54 | .sum()
55 | )
56 |
57 | return y
58 |
59 |
60 | def load_peyton_manning():
61 | """Load the Peyton Manning dataset (used in Prophet's documentation).
62 |
63 | Returns
64 | -------
65 | pd.DataFrame
66 | the Peyton Manning dataset.
67 | """
68 | df = pd.read_csv(
69 | "https://raw.githubusercontent.com/facebook/prophet/main/"
70 | "examples/example_wp_log_peyton_manning.csv"
71 | )
72 | df["ds"] = pd.to_datetime(df["ds"]).dt.to_period("D")
73 | return df.set_index("ds")
74 |
75 |
76 | def load_tensorflow_github_stars():
77 | """Load the TensorFlow GitHub stars dataset.
78 |
79 | Returns
80 | -------
81 | pd.DataFrame
82 | The TensorFlow GitHub stars dataset.
83 | """
84 | df = pd.read_csv(
85 | DATASET_MODULE_PATH / "raw/tensorflow_tensorflow-stars-history.csv"
86 | )
87 | df["date"] = pd.to_datetime(df["date"], format="%d-%m-%Y").dt.to_period("D")
88 | df = df.set_index("date")
89 | df = df.sort_index()
90 | y = df[["day-stars"]]
91 |
92 | return y
93 |
94 |
95 | def load_pedestrian_count():
96 | """Load the pedestrian count dataset.
97 |
98 | Returns
99 | -------
100 | pd.DataFrame
101 | The pedestrian count dataset.
102 | """
103 |
104 | def _parse_data(df):
105 |
106 | dfs = []
107 | # iterrows
108 | for _, row in df.iterrows():
109 |
110 | _df = pd.DataFrame(
111 | data={
112 | "pedestrian_count": row["series_value"],
113 | "timestamp": pd.period_range(
114 | row["start_timestamp"],
115 | periods=len(row["series_value"]),
116 | freq="H",
117 | ),
118 | },
119 | )
120 | _df["series_name"] = row["series_name"]
121 | dfs.append(_df)
122 |
123 | return pd.concat(dfs).set_index(["series_name", "timestamp"])
124 |
125 | df, _ = load_forecastingdata("pedestrian_counts_dataset")
126 | return _parse_data(df)
127 |
--------------------------------------------------------------------------------
/src/prophetverse/utils/frame_to_array.py:
--------------------------------------------------------------------------------
1 | """Utilities for converting a pandas DataFrame to JAX/Numpy arrays."""
2 |
3 | from typing import Tuple
4 |
5 | import numpy as np
6 | import pandas as pd
7 | from jax import numpy as jnp
8 |
9 | from .multiindex import iterate_all_series
10 |
11 | NANOSECONDS_TO_SECONDS = 1000 * 1000 * 1000
12 |
13 | __all__ = [
14 | "convert_index_to_days_since_epoch",
15 | "series_to_tensor",
16 | "extract_timetensor_from_dataframe",
17 | "convert_dataframe_to_tensors",
18 | ]
19 |
20 |
21 | def convert_index_to_days_since_epoch(idx: pd.Index) -> np.array:
22 | """
23 | Convert a pandas Index to days since epoch.
24 |
25 | Parameters
26 | ----------
27 | idx : pd.Index
28 | The pandas Index.
29 |
30 | Returns
31 | -------
32 | np.ndarray
33 | The converted array of days since epoch.
34 | """
35 | t = idx
36 |
37 | if not (isinstance(t, pd.PeriodIndex) or isinstance(t, pd.DatetimeIndex)):
38 | return t.values
39 |
40 | if isinstance(t, pd.PeriodIndex):
41 | t = t.to_timestamp()
42 |
43 | return t.to_numpy(dtype=np.int64) // NANOSECONDS_TO_SECONDS / (3600 * 24.0)
44 |
45 |
46 | def series_to_tensor(y: pd.DataFrame) -> jnp.ndarray:
47 | """
48 | Convert all series of a hierarchical time series to a JAX tensor.
49 |
50 | Parameters
51 | ----------
52 | y : pd.DataFrame
53 | The hierarchical time series.
54 |
55 | Returns
56 | -------
57 | jnp.ndarray
58 | The JAX tensor representing all series.
59 | """
60 | names = []
61 | array = []
62 | series_len = None
63 |
64 | if y.index.nlevels == 1:
65 | return jnp.array(y.values).reshape((1, -1, len(y.columns)))
66 |
67 | for idx, series in iterate_all_series(y):
68 | if series_len is None:
69 | series_len = len(series)
70 | if len(series) != series_len:
71 | raise ValueError(
72 | f"Series {idx} has length {len(series)}, but expected {series_len}"
73 | )
74 |
75 | names.append(idx)
76 | array.append(series.values.reshape((-1, len(y.columns))))
77 | return jnp.array(array)
78 |
79 |
80 | def series_to_tensor_or_array(y: pd.DataFrame) -> jnp.ndarray:
81 | """
82 | Convert hierarchical (univariate) to three (two) dimensional JAX tensor.
83 |
84 | Parameters
85 | ----------
86 | y : pd.DataFrame
87 | The hierarchical time series.
88 |
89 | Returns
90 | -------
91 | jnp.ndarray
92 | The JAX tensor or the array representing all series.
93 | """
94 | if y.index.nlevels == 1:
95 | return jnp.array(y.values)
96 | return series_to_tensor(y)
97 |
98 |
99 | def extract_timetensor_from_dataframe(df: pd.DataFrame) -> jnp.array:
100 | """
101 | Extract the time array from a pandas DataFrame.
102 |
103 | Parameters
104 | ----------
105 | df : pd.DataFrame
106 | The DataFrame.
107 |
108 | Returns
109 | -------
110 | jnp.ndarray
111 | The JAX tensor representing the time array.
112 | """
113 | return series_to_tensor(
114 | pd.DataFrame(
115 | index=df.index,
116 | data={
117 | "t": convert_index_to_days_since_epoch(df.index.get_level_values(-1))
118 | },
119 | )
120 | )
121 |
122 |
123 | def convert_dataframe_to_tensors(df: pd.DataFrame) -> Tuple[jnp.array, jnp.array]:
124 | """
125 | Convert a pandas DataFrame to JAX tensors.
126 |
127 | Parameters
128 | ----------
129 | df : pd.DataFrame
130 | The DataFrame.
131 |
132 | Returns
133 | -------
134 | tuple
135 | A tuple containing the time arrays and the DataFrame arrays as JAX tensors.
136 | """
137 | t_arrays = extract_timetensor_from_dataframe(df)
138 |
139 | df_as_arrays = series_to_tensor(df)
140 |
141 | return t_arrays, df_as_arrays
142 |
--------------------------------------------------------------------------------