├── 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 | --------------------------------------------------------------------------------