From 0baf5f48b9baa6761c3fef36d22a298c5f11ecdd Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 10 Jul 2024 20:09:54 +0200 Subject: [PATCH 1/8] created inverse_scaled_logistic_saturation and the corresponding class --- pymc_marketing/mmm/__init__.py | 2 + pymc_marketing/mmm/components/saturation.py | 35 ++++++++++++++++ pymc_marketing/mmm/transformers.py | 46 +++++++++++++++++++++ tests/mmm/components/test_saturation.py | 3 ++ 4 files changed, 86 insertions(+) diff --git a/pymc_marketing/mmm/__init__.py b/pymc_marketing/mmm/__init__.py index 2d75a1b07..dafc319f2 100644 --- a/pymc_marketing/mmm/__init__.py +++ b/pymc_marketing/mmm/__init__.py @@ -23,6 +23,7 @@ ) from pymc_marketing.mmm.components.saturation import ( HillSaturation, + InverseScaledLogisticSaturation, LogisticSaturation, MichaelisMentenSaturation, SaturationTransformation, @@ -45,6 +46,7 @@ "GeometricAdstock", "HillSaturation", "LogisticSaturation", + "InverseScaledLogisticSaturation", "MMM", "MMMModelBuilder", "MichaelisMentenSaturation", diff --git a/pymc_marketing/mmm/components/saturation.py b/pymc_marketing/mmm/components/saturation.py index d93f8ca60..0d2f31063 100644 --- a/pymc_marketing/mmm/components/saturation.py +++ b/pymc_marketing/mmm/components/saturation.py @@ -76,6 +76,7 @@ def function(self, x, b): from pymc_marketing.mmm.components.base import Transformation from pymc_marketing.mmm.transformers import ( hill_saturation, + inverse_scaled_logistic_saturation, logistic_saturation, michaelis_menten, tanh_saturation, @@ -201,6 +202,39 @@ def function(self, x, lam, beta): } +class InverseScaledLogisticSaturation(SaturationTransformation): + """Wrapper around inverse scaled logistic saturation function. + + For more information, see :func:`pymc_marketing.mmm.transformers.inverse_scaled_logistic_saturation`. + + .. plot:: + :context: close-figs + + import matplotlib.pyplot as plt + import numpy as np + from pymc_marketing.mmm import InverseScaledLogisticSaturation + + rng = np.random.default_rng(0) + + adstock = InverseScaledLogisticSaturation() + prior = adstock.sample_prior(random_seed=rng) + curve = adstock.sample_curve(prior) + adstock.plot_curve(curve, sample_kwargs={"rng": rng}) + plt.show() + + """ + + lookup_name = "inverse_scaled_logistic" + + def function(self, x, lam, beta): + return beta * inverse_scaled_logistic_saturation(x, lam) + + default_priors = { + "lam": Prior("Gamma", alpha=3, beta=1), + "beta": Prior("HalfNormal", sigma=2), + } + + class TanhSaturation(SaturationTransformation): """Wrapper around tanh saturation function. @@ -339,6 +373,7 @@ class HillSaturation(SaturationTransformation): cls.lookup_name: cls for cls in [ LogisticSaturation, + InverseScaledLogisticSaturation, TanhSaturation, TanhSaturationBaselined, MichaelisMentenSaturation, diff --git a/pymc_marketing/mmm/transformers.py b/pymc_marketing/mmm/transformers.py index 5c036445c..bdf80c7f4 100644 --- a/pymc_marketing/mmm/transformers.py +++ b/pymc_marketing/mmm/transformers.py @@ -478,6 +478,52 @@ def logistic_saturation(x, lam: npt.NDArray[np.float64] | float = 0.5): return (1 - pt.exp(-lam * x)) / (1 + pt.exp(-lam * x)) +def inverse_scaled_logistic_saturation( + x, lam: npt.NDArray[np.float64] | float = 0.5, eps: float = np.log(3) +): + """Inverse scaled logistic saturation transformation. + + .. math:: + f(x) = \\frac{1 - e^{-x*\epsilon/\lambda}}{1 + e^{-x*\epsilon/\lambda}} + + .. plot:: + :context: close-figs + + import matplotlib.pyplot as plt + import numpy as np + import arviz as az + from pymc_marketing.mmm.transformers import inverse_scaled_logistic_saturation + plt.style.use('arviz-darkgrid') + lam = np.array([0.25, 0.5, 1, 2, 4]) + x = np.linspace(0, 5, 100) + ax = plt.subplot(111) + for l in lam: + y = inverse_scaled_logistic_saturation(x, lam=l).eval() + plt.plot(x, y, label=f'lam = {l}') + plt.xlabel('spend', fontsize=12) + plt.ylabel('f(spend)', fontsize=12) + box = ax.get_position() + ax.set_position([box.x0, box.y0, box.width * 0.8, box.height]) + ax.legend(loc='center left', bbox_to_anchor=(1, 0.5)) + plt.show() + + Parameters + ---------- + x : tensor + Input tensor. + lam : float or array-like, optional, by default 0.5 + Saturation parameter. + eps : float or array-like, optional, by default ln(3) + Scaling parameter. + + Returns + ------- + tensor + Transformed tensor. + """ # noqa: W605 + return logistic_saturation(x, eps / lam) + + class TanhSaturationParameters(NamedTuple): """Container for tanh saturation parameters. diff --git a/tests/mmm/components/test_saturation.py b/tests/mmm/components/test_saturation.py index fc78b3628..cea753912 100644 --- a/tests/mmm/components/test_saturation.py +++ b/tests/mmm/components/test_saturation.py @@ -22,6 +22,7 @@ from pymc_marketing.mmm.components.saturation import ( HillSaturation, + InverseScaledLogisticSaturation, LogisticSaturation, MichaelisMentenSaturation, TanhSaturation, @@ -40,6 +41,7 @@ def model() -> pm.Model: def saturation_functions(): return [ LogisticSaturation(), + InverseScaledLogisticSaturation(), TanhSaturation(), TanhSaturationBaselined(), MichaelisMentenSaturation(), @@ -93,6 +95,7 @@ def test_support_for_lift_test_integrations(saturation) -> None: @pytest.mark.parametrize( "name, saturation_cls", [ + ("inverse_scaled_logistic", InverseScaledLogisticSaturation), ("logistic", LogisticSaturation), ("tanh", TanhSaturation), ("tanh_baselined", TanhSaturationBaselined), From ddc4a96c6c9aeb3e8c699d1972dd6d42d2907125 Mon Sep 17 00:00:00 2001 From: amello Date: Thu, 11 Jul 2024 19:14:34 +0200 Subject: [PATCH 2/8] add tests for inverse_scaled_logistic_saturation, improve descriptions and update default values --- pymc_marketing/mmm/components/saturation.py | 2 +- pymc_marketing/mmm/transformers.py | 5 +++- tests/mmm/test_transformers.py | 27 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/pymc_marketing/mmm/components/saturation.py b/pymc_marketing/mmm/components/saturation.py index 0d2f31063..e4871eb18 100644 --- a/pymc_marketing/mmm/components/saturation.py +++ b/pymc_marketing/mmm/components/saturation.py @@ -230,7 +230,7 @@ def function(self, x, lam, beta): return beta * inverse_scaled_logistic_saturation(x, lam) default_priors = { - "lam": Prior("Gamma", alpha=3, beta=1), + "lam": Prior("Gamma", alpha=0.3, beta=0.6), "beta": Prior("HalfNormal", sigma=2), } diff --git a/pymc_marketing/mmm/transformers.py b/pymc_marketing/mmm/transformers.py index bdf80c7f4..57b29ea37 100644 --- a/pymc_marketing/mmm/transformers.py +++ b/pymc_marketing/mmm/transformers.py @@ -482,6 +482,9 @@ def inverse_scaled_logistic_saturation( x, lam: npt.NDArray[np.float64] | float = 0.5, eps: float = np.log(3) ): """Inverse scaled logistic saturation transformation. + It offers a more intuitive alternative to logistic_saturation, + allowing for lambda to be interpreted as the half saturation point, + when using default values for lam and eps. .. math:: f(x) = \\frac{1 - e^{-x*\epsilon/\lambda}}{1 + e^{-x*\epsilon/\lambda}} @@ -514,7 +517,7 @@ def inverse_scaled_logistic_saturation( lam : float or array-like, optional, by default 0.5 Saturation parameter. eps : float or array-like, optional, by default ln(3) - Scaling parameter. + Scaling parameter. ln(3) results in halfway saturation for lam = 0.5 Returns ------- diff --git a/tests/mmm/test_transformers.py b/tests/mmm/test_transformers.py index aa1586991..e7c763b4e 100644 --- a/tests/mmm/test_transformers.py +++ b/tests/mmm/test_transformers.py @@ -28,6 +28,7 @@ delayed_adstock, geometric_adstock, hill_saturation, + inverse_scaled_logistic_saturation, logistic_saturation, michaelis_menten, tanh_saturation, @@ -343,6 +344,32 @@ def test_logistic_saturation_min_max_value(self, x, lam): assert y_eval.max() <= 1 assert y_eval.min() >= 0 + def test_inverse_scaled_logistic_saturation_lam_half(self): + x = np.array([0.5] * 100) + y = inverse_scaled_logistic_saturation(x=x, lam=0.5, eps=np.ln(3)) + expected = np.array([0.5] * 100) + np.testing.assert_almost_equal( + y.eval(), + expected, + decimal=5, + err_msg="The function does not behave as expected at lambda 0.5.", + ) + + @pytest.mark.parametrize( + "x, lam", + [ + (np.ones(shape=(100)), 0.5), + (np.linspace(start=0.0, stop=1.0, num=50), 10), + (np.linspace(start=200, stop=1000, num=50), 0.001), + (np.zeros(shape=(100)), 1), + ], + ) + def test_inverse_scaled_logistic_saturation_min_max_value(self, x, lam): + y = inverse_scaled_logistic_saturation(x=x, lam=lam) + y_eval = y.eval() + assert y_eval.max() <= 1 + assert y_eval.min() >= 0 + @pytest.mark.parametrize( "x, b, c", [ From 8e5ca1728360b157470a0b42512c28565a7339b7 Mon Sep 17 00:00:00 2001 From: amello Date: Sat, 13 Jul 2024 14:21:41 +0200 Subject: [PATCH 3/8] simplify test and correct eps explanation --- pymc_marketing/mmm/transformers.py | 2 +- tests/mmm/test_transformers.py | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/pymc_marketing/mmm/transformers.py b/pymc_marketing/mmm/transformers.py index 57b29ea37..eb1674773 100644 --- a/pymc_marketing/mmm/transformers.py +++ b/pymc_marketing/mmm/transformers.py @@ -517,7 +517,7 @@ def inverse_scaled_logistic_saturation( lam : float or array-like, optional, by default 0.5 Saturation parameter. eps : float or array-like, optional, by default ln(3) - Scaling parameter. ln(3) results in halfway saturation for lam = 0.5 + Scaling parameter. ln(3) results in halfway saturation Returns ------- diff --git a/tests/mmm/test_transformers.py b/tests/mmm/test_transformers.py index e7c763b4e..d86b5ecaa 100644 --- a/tests/mmm/test_transformers.py +++ b/tests/mmm/test_transformers.py @@ -355,16 +355,9 @@ def test_inverse_scaled_logistic_saturation_lam_half(self): err_msg="The function does not behave as expected at lambda 0.5.", ) - @pytest.mark.parametrize( - "x, lam", - [ - (np.ones(shape=(100)), 0.5), - (np.linspace(start=0.0, stop=1.0, num=50), 10), - (np.linspace(start=200, stop=1000, num=50), 0.001), - (np.zeros(shape=(100)), 1), - ], - ) def test_inverse_scaled_logistic_saturation_min_max_value(self, x, lam): + x = np.array([0, 1, 100, 500, 5000]) + lam = np.array([...])[:, None] y = inverse_scaled_logistic_saturation(x=x, lam=lam) y_eval = y.eval() assert y_eval.max() <= 1 From a1d7ca95ade356c7a3dfe07baf4946ea4a962223 Mon Sep 17 00:00:00 2001 From: amello Date: Sat, 13 Jul 2024 14:25:36 +0200 Subject: [PATCH 4/8] add small correction to eps explanation --- pymc_marketing/mmm/transformers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymc_marketing/mmm/transformers.py b/pymc_marketing/mmm/transformers.py index eb1674773..52a486a84 100644 --- a/pymc_marketing/mmm/transformers.py +++ b/pymc_marketing/mmm/transformers.py @@ -517,7 +517,7 @@ def inverse_scaled_logistic_saturation( lam : float or array-like, optional, by default 0.5 Saturation parameter. eps : float or array-like, optional, by default ln(3) - Scaling parameter. ln(3) results in halfway saturation + Scaling parameter. ln(3) results in halfway saturation at lam Returns ------- From 17049cd73ac2c0f3b0b031feb11898e4f8edf7ab Mon Sep 17 00:00:00 2001 From: amello Date: Mon, 15 Jul 2024 07:19:44 +0200 Subject: [PATCH 5/8] fix np.log error --- pymc_marketing/mmm/transformers.py | 4 ++-- tests/mmm/test_transformers.py | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pymc_marketing/mmm/transformers.py b/pymc_marketing/mmm/transformers.py index 52a486a84..58fa6c50b 100644 --- a/pymc_marketing/mmm/transformers.py +++ b/pymc_marketing/mmm/transformers.py @@ -483,8 +483,8 @@ def inverse_scaled_logistic_saturation( ): """Inverse scaled logistic saturation transformation. It offers a more intuitive alternative to logistic_saturation, - allowing for lambda to be interpreted as the half saturation point, - when using default values for lam and eps. + allowing for lambda to be interpreted as the half saturation point + when using default value for eps. .. math:: f(x) = \\frac{1 - e^{-x*\epsilon/\lambda}}{1 + e^{-x*\epsilon/\lambda}} diff --git a/tests/mmm/test_transformers.py b/tests/mmm/test_transformers.py index d86b5ecaa..488d561b6 100644 --- a/tests/mmm/test_transformers.py +++ b/tests/mmm/test_transformers.py @@ -346,7 +346,7 @@ def test_logistic_saturation_min_max_value(self, x, lam): def test_inverse_scaled_logistic_saturation_lam_half(self): x = np.array([0.5] * 100) - y = inverse_scaled_logistic_saturation(x=x, lam=0.5, eps=np.ln(3)) + y = inverse_scaled_logistic_saturation(x=x, lam=0.5, eps=np.log(3)) expected = np.array([0.5] * 100) np.testing.assert_almost_equal( y.eval(), @@ -355,9 +355,12 @@ def test_inverse_scaled_logistic_saturation_lam_half(self): err_msg="The function does not behave as expected at lambda 0.5.", ) - def test_inverse_scaled_logistic_saturation_min_max_value(self, x, lam): + def test_inverse_scaled_logistic_saturation_min_max_value(self): x = np.array([0, 1, 100, 500, 5000]) - lam = np.array([...])[:, None] + lam = np.array([0.01, 0.25, 0.75, 1.5, 5.0, 10.0, 15.0])[:, None] + x = pt.as_tensor_variable(x) + lam = pt.as_tensor_variable(lam) + y = inverse_scaled_logistic_saturation(x=x, lam=lam) y_eval = y.eval() assert y_eval.max() <= 1 From ec0ddfaeab7dfc36bbcbd772b53f7ac250cfe133 Mon Sep 17 00:00:00 2001 From: amello Date: Tue, 16 Jul 2024 14:55:59 +0200 Subject: [PATCH 6/8] change default parameters for inverse scaled log saturation --- pymc_marketing/mmm/components/saturation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymc_marketing/mmm/components/saturation.py b/pymc_marketing/mmm/components/saturation.py index e4871eb18..fb9afa046 100644 --- a/pymc_marketing/mmm/components/saturation.py +++ b/pymc_marketing/mmm/components/saturation.py @@ -230,7 +230,7 @@ def function(self, x, lam, beta): return beta * inverse_scaled_logistic_saturation(x, lam) default_priors = { - "lam": Prior("Gamma", alpha=0.3, beta=0.6), + "lam": Prior("Gamma", alpha=0.5, beta=1), "beta": Prior("HalfNormal", sigma=2), } From bcaa6d4dc117b6141aa6774d10fd54fa2d74d8a2 Mon Sep 17 00:00:00 2001 From: amello Date: Tue, 16 Jul 2024 18:49:05 +0200 Subject: [PATCH 7/8] simplify tests for inverse scaled log saturation --- tests/mmm/test_transformers.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/mmm/test_transformers.py b/tests/mmm/test_transformers.py index 488d561b6..eaf2e1e3f 100644 --- a/tests/mmm/test_transformers.py +++ b/tests/mmm/test_transformers.py @@ -358,8 +358,6 @@ def test_inverse_scaled_logistic_saturation_lam_half(self): def test_inverse_scaled_logistic_saturation_min_max_value(self): x = np.array([0, 1, 100, 500, 5000]) lam = np.array([0.01, 0.25, 0.75, 1.5, 5.0, 10.0, 15.0])[:, None] - x = pt.as_tensor_variable(x) - lam = pt.as_tensor_variable(lam) y = inverse_scaled_logistic_saturation(x=x, lam=lam) y_eval = y.eval() From 1fe88a26ffc743a95c0ae07a9c1b7df439e88884 Mon Sep 17 00:00:00 2001 From: amello Date: Wed, 17 Jul 2024 22:11:03 +0200 Subject: [PATCH 8/8] fix test_inverse_scaled_logistic_saturation_lam_half --- tests/mmm/test_transformers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/mmm/test_transformers.py b/tests/mmm/test_transformers.py index eaf2e1e3f..f9c47ddc9 100644 --- a/tests/mmm/test_transformers.py +++ b/tests/mmm/test_transformers.py @@ -345,14 +345,14 @@ def test_logistic_saturation_min_max_value(self, x, lam): assert y_eval.min() >= 0 def test_inverse_scaled_logistic_saturation_lam_half(self): - x = np.array([0.5] * 100) - y = inverse_scaled_logistic_saturation(x=x, lam=0.5, eps=np.log(3)) - expected = np.array([0.5] * 100) + x = np.array([0.01, 0.1, 0.5, 1, 100]) + y = inverse_scaled_logistic_saturation(x=x, lam=x) + expected = np.array([0.5] * len(x)) np.testing.assert_almost_equal( y.eval(), expected, decimal=5, - err_msg="The function does not behave as expected at lambda 0.5.", + err_msg="The function does not behave as expected at the default value for eps", ) def test_inverse_scaled_logistic_saturation_min_max_value(self):