Configuración del modelo#

Este cuaderno proporciona detalles sobre la configuración del modelo en PyMC-Marketing. Esta API ofrece una forma muy flexible de expresar los Priors y las verosimilitudes del modelo para que podamos tener suficiente expresividad para construir modelos personalizados.

Configuración#

import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
from pymc_extras.prior import Prior

from pymc_marketing.model_config import ModelConfigError, parse_model_config

az.style.use("arviz-darkgrid")
plt.rcParams["figure.figsize"] = [12, 7]
plt.rcParams["figure.dpi"] = 100

%load_ext autoreload
%autoreload 2
%config InlineBackend.figure_format = "retina"
seed = sum(map(ord, "Create priors to reflect your marketing beliefs"))
rng = np.random.default_rng(seed)

Distribuciones Previas#

La clase Prior es nuestra forma de expresar distribuciones y relaciones entre ellas.

Uso básico#

Cada distribución anterior necesitará un nombre de distribución que provenga de PyMC. Vea la lista completa en la documentación aquí.

scalar_distribution = Prior("Normal")
scalar_distribution
Prior("Normal")

Si no, se generará una excepción.

try:
    Prior("UnknownDistribution")
except Exception as e:
    print(e)
PyMC doesn't have a distribution of name 'UnknownDistribution'

Se pueden pasar parámetros específicos como argumentos de palabra clave, pero no son obligatorios si la distribución PyMC tiene valores predeterminados.

scalar_distribution = Prior("Normal", mu=0, sigma=3.5)
scalar_distribution
Prior("Normal", mu=0, sigma=3.5)

Se realizará una verificación en la inicialización contra la distribución PyMC.

try:
    Prior("Normal", mu=0, b=1)
except Exception as e:
    print(e)
Parameters {'b', 'mu'} are not a subset of the pymc distribution parameters {'mu', 'sigma', 'tau'}

Sin embargo, hay algunas limitaciones a eso. Tenga en cuenta esta parametrización inválida a continuación:

invalid_distribution = Prior("Normal", mu=1, sigma=1, tau=2)

El método create_variable se utiliza para crear variables a partir de esta distribución y no es hasta entonces que se produce un error.

with pm.Model():
    try:
        invalid_distribution.create_variable("mu")
    except Exception as e:
        print(e)
Can't pass both tau and sigma

¡Esto se aplica también a los valores incorrectos!

invalid_distribution = Prior("Normal", mu=1, sigma=-1)
invalid_distribution
Prior("Normal", mu=1, sigma=-1)

El parámetro dims se utiliza para expresar las dimensiones de la distribución. Esta variable no forma parte de un modelo, por lo que los valores no se reflejan en la distribución misma.

vector_distribution = Prior("Normal", dims="channel")
vector_distribution
Prior("Normal", dims="channel")

Si hay dimensiones, entonces las coordenadas deben existir en el modelo más grande. Pero existen por separado y pueden definirse de antemano.

Truco

Cada instancia de Prior son simplemente instrucciones para crear una variable, ¡así que se pueden usar múltiples veces!

coords = {"channel": ["C1", "C2"]}
with pm.Model(coords=coords) as model:
    alpha = vector_distribution.create_variable("alpha")
    beta = vector_distribution.create_variable("beta")

pm.model_to_graphviz(model)
../../_images/a9f1ff3294e31737db96eb5b15ffe06e9746c3be6329948f79b59415e0fd0e02.svg

Si las coordenadas no se especificaron, esto causaría un error de PyMC.

with pm.Model() as model:
    try:
        vector_distribution.create_variable("var")
    except Exception as e:
        print(e)
"Dimensions {'channel'} are unknown to the model and cannot be used to specify a `shape`."

Las variables también pueden volverse arbitrariamente grandes al proporcionar dimensiones adicionales en una tupla.

matrix_distribution = Prior("Normal", dims=("channel", "geo"))
matrix_distribution
Prior("Normal", dims=("channel", "geo"))
tensor_distribution = Prior("Normal", dims=("channel", "geo", "store"))
tensor_distribution
Prior("Normal", dims=("channel", "geo", "store"))

Variables Jerárquicas#

Las variables jerárquicas se pueden definir utilizando distribuciones como los parámetros de otra distribución. Las distribuciones parentales generalmente tendrán una dimensionalidad mayor que cada uno de los parámetros.

hierarchical_variable = Prior(
    "Normal",
    mu=Prior("Normal"),
    sigma=Prior("HalfNormal"),
    dims="channel",
)

Podemos utilizar el método to_graph para visualizar la variable con coordenadas ficticias.

Nota

¡No hay necesidad de preocuparse por la nomenclatura de variables! Los parámetros hijos se nombrarán automáticamente en función del padre.

hierarchical_variable.to_graph()
../../_images/355652fb869a4dea6546506d24db8c65d5ff2c32ca5367d6f712fca7064d6170.svg

Advertencia

La validez de los valores de los parámetros no será verificada.

Puede haber un parámetro sigma negativo en una distribución Normal, ya sea que provenga de un valor o de otra distribución.

Prior("Normal", mu=1, sigma=-1)
Prior("Normal", mu=1, sigma=-1)
Prior("Normal", sigma=Prior("Normal"))
Prior("Normal", sigma=Prior("Normal"))

Truco

Las reparametrizaciones del modelo pueden ayudar con la convergencia del modelo dependiendo de la posterior del modelo.

Para la distribución Normal, se admite la parametrización no centrada común con la bandera centered.

non_centered_hierarchical_variable = Prior(
    "Normal",
    mu=Prior("Normal"),
    sigma=Prior("HalfNormal"),
    dims="channel",
    # Flag for non-centered
    centered=False,
)
non_centered_hierarchical_variable.to_graph()
../../_images/2c5b8bd8239aeeae169815d27850310555378d9529a3b60fd695bbf12a591532.svg

Otras distribuciones también pueden ser jerárquicas. Simplemente use distribuciones para los parámetros de una distribución padre. Por ejemplo, la distribución Beta tiene dos parámetros positivos, alpha y beta, que pueden reflejarse como distribuciones HalfNormal.

zero_to_one_variable = Prior(
    "Beta",
    alpha=Prior("HalfNormal"),
    beta=Prior("HalfNormal"),
    dims="channel",
)
zero_to_one_variable.to_graph()
../../_images/7de2d1391aa0d588617fbf76f77997680ed661cde27eaf64c2d0492c5a0cd257.svg

Transformaciones#

La variable transform se puede utilizar para cualquiera de las distribuciones para cambiar su dominio.

Estas transformaciones se tomarán de pytensor.tensor o pm.math en ese orden.

A continuación se presenta una distribución Normal jerárquica no centrada que se somete a una función sigmoide con el fin de cambiar el dominio a (0, 1).

hierarchical_zero_to_one_distribution = Prior(
    "Normal",
    mu=Prior("Normal"),
    sigma=Prior("HalfNormal"),
    dims="channel",
    centered=False,
    transform="sigmoid",
)
hierarchical_zero_to_one_distribution.to_graph()
../../_images/4e799890a83f10c20c9d10aa22b9318232af72ba6a8d3d4fadf8b88d1275ed8b.svg

Visualización previa#

Para distribuciones escalares, utilice el atributo preliz del modelo para visualizar.

Truco

Se deberán proporcionar valores predeterminados para los parámetros o se producirá un error.

beta_distribution = Prior("Beta", alpha=1, beta=4)

beta_distribution.preliz.plot_pdf();

Truco

Utilizar el método constrain puede ayudar a centrarse en un previo. Es un envoltorio alrededor de la función maxent de PreliZ.

constrained_distribution = Prior("Normal").constrain(lower=4, upper=6)
constrained_distribution
Prior("Normal", mu=4.999999933423391, sigma=0.5102139704250529)

Para mantener un valor de parámetro fijo, pásalo a la instancia de Prior. En el siguiente ejemplo, la media está fija en 2.

constrained_distribution = Prior("Gamma", mu=2).constrain(lower=1, upper=10)
constrained_distribution
Prior("Gamma", mu=2.0, sigma=0.7030365085992409)

Para distribuciones más complicadas, considere utilizar el método sample_prior para acceder al previo. O las funciones de PreliZ como explorador predictivo.

Nota

Cualquier coordenada deberá ser pasada como en pm.Model.

complicated_distribution = Prior(
    "Normal",
    mu=Prior("Normal", sigma=1),
    sigma=Prior("Normal", mu=-1, sigma=0.15, transform="exp"),
    centered=False,
    dims="channel",
    transform="sigmoid",
)

coords = {
    "channel": ["C1", "C2"],
}
prior = complicated_distribution.sample_prior(
    coords=coords, samples=1_000, random_seed=rng
)
prior
Sampling: [var_raw_mu, var_raw_offset, var_raw_sigma_raw]
<xarray.Dataset>
Dimensions:            (chain: 1, draw: 1000, channel: 2)
Coordinates:
  * chain              (chain) int64 0
  * draw               (draw) int64 0 1 2 3 4 5 6 ... 994 995 996 997 998 999
  * channel            (channel) <U2 'C1' 'C2'
Data variables:
    var                (chain, draw, channel) float64 0.336 0.3785 ... 0.7889
    var_raw            (chain, draw, channel) float64 -0.6811 -0.496 ... 1.318
    var_raw_mu         (chain, draw) float64 -0.527 1.038 1.526 ... 0.8435 1.136
    var_raw_offset     (chain, draw, channel) float64 -0.4456 0.08964 ... 0.5605
    var_raw_sigma      (chain, draw) float64 0.3459 0.4046 ... 0.4709 0.3259
    var_raw_sigma_raw  (chain, draw) float64 -1.062 -0.905 ... -0.7531 -1.121
Attributes:
    created_at:                 2024-09-09T13:18:06.084229+00:00
    arviz_version:              0.20.0.dev0
    inference_library:          pymc
    inference_library_version:  5.15.1

Las variables anteriores se pueden visualizar de la manera que mejor funcione. Aquí hay un KDE de las variables para ver las distribuciones marginales.

fig, axes = plt.subplots(
    nrows=2, ncols=1, figsize=(10, 9), sharex=True, sharey=True, layout="constrained"
)

az.plot_posterior(prior, var_names=["variable"], grid=(2, 1), ax=axes)
fig.suptitle("Prior Distribution", fontsize=18, fontweight="bold");

¡Utilice cualquier flujo de trabajo para dar sentido a la distribución previa! Por ejemplo, la distribución conjunta que muestra que estos dos canales están correlacionados debido a la generación jerárquica.

def plot_correlation(df: pd.DataFrame, x: str = "C1", y: str = "C2") -> plt.Axes:
    corr = df.loc[:, [x, y]].corr().iloc[0, 1]
    title = f"Joint distribution between {x} and {y} ({corr = :.3f})"
    return df.plot.scatter(x=x, y=y, title=title)


ax = prior["variable"].to_series().unstack().pipe(plot_correlation)
padding = 0.025
bounds = (0 - padding, 1 + padding)
ax.set(xlim=bounds, ylim=bounds);

Transmisión Automática#

La transmisión de los niveles se manejará automáticamente.

Por ejemplo, el mu de la variable necesita ser transpuesto para trabajar con las dimensiones ("channel", "geo").

Con toda esta funcionalidad, podemos ver que las distribuciones previas pueden volverse bastante expresivas.

def create_2d_variable(mu_dims, sigma_dims) -> Prior:
    mu = Prior(
        "Normal",
        mu=Prior("Normal"),
        sigma=Prior("HalfNormal"),
        dims=mu_dims,
    )
    sigma = Prior(
        "Normal",
        mu=Prior("Normal"),
        sigma=Prior("HalfNormal"),
        centered=False,
        dims=sigma_dims,
        transform="exp",
    )
    return Prior(
        "Normal",
        mu=mu,
        sigma=sigma,
        dims=("channel", "geo"),
    )


variable_2d = create_2d_variable(mu_dims="channel", sigma_dims="geo")
variable_2d.to_graph()
../../_images/9c4e5a4c996312fd4c490be68e1b47895fb594e3ed50e47051588684716c7093.svg

Y el usuario puede dedicar más tiempo a reflexionar sobre las suposiciones de las variables en lugar de la lógica para asegurar que las dimensiones funcionen.

different_assumptions_2d = create_2d_variable(mu_dims="channel", sigma_dims="channel")

different_assumptions_2d.to_graph()
../../_images/53e952c9da3045536ebe5fab4d76fd56643b8ed122af4da08aa124270b8679ce.svg

Serialización#

Los métodos to_dict y from_dict pueden ser útiles para el almacenamiento de las distribuciones.

variable_2d_dict = variable_2d.to_dict()
variable_2d_dict
{'dist': 'Normal',
 'kwargs': {'mu': {'dist': 'Normal',
   'kwargs': {'mu': {'dist': 'Normal'}, 'sigma': {'dist': 'HalfNormal'}},
   'dims': ('channel',)},
  'sigma': {'dist': 'Normal',
   'kwargs': {'mu': {'dist': 'Normal'}, 'sigma': {'dist': 'HalfNormal'}},
   'centered': False,
   'dims': ('geo',),
   'transform': 'exp'}},
 'dims': ('channel', 'geo')}
Prior.from_dict(variable_2d_dict).to_graph()
../../_images/9c4e5a4c996312fd4c490be68e1b47895fb594e3ed50e47051588684716c7093.svg

Usar en PyMC-Marketing#

Las distribuciones se expresarán de esta manera a lo largo del paquete, incluyendo pero no limitándose a:

Compatibilidad hacia atrás#

El método from_dict creará una instancia de Prior a partir del formato anterior.

Por ejemplo, tome esta configuración anterior:

old_model_config = {
    "alpha": {
        "dist": "Normal",
        "kwargs": {
            "mu": 0,
            "sigma": 1,
        },
    },
    "beta": {
        "dist": "Laplace",
        "kwargs": {
            "mu": 1,
            "b": 0.5,
        },
    },
}

Esto se puede analizar con el constructor Prior.from_dict para cada clave. ¡Mucho más conciso también!

new_model_config = {
    name: Prior.from_dict(key) for name, key in old_model_config.items()
}

new_model_config
{'alpha': Prior("Normal", mu=0, sigma=1),
 'beta': Prior("Laplace", mu=1, b=0.5)}
new_model_config["alpha"].to_dict()
{'dist': 'Normal', 'kwargs': {'mu': 0, 'sigma': 1}}

La función parse_model_config hará exactamente esto y se utiliza internamente. También proporciona algunas advertencias de desaprobación.

parse_model_config(old_model_config)
{'alpha': Prior("Normal", mu=0, sigma=1),
 'beta': Prior("Laplace", mu=1, b=0.5)}

Así como la capacidad de detectar algunos errores durante el análisis.

invalid_model_config = {
    "alpha": {
        "dist": "InvalidDistribution",
    },
    "beta": {
        "dist": "Normal",
        "kwargs": {"mu": "one", "sigma": "two"},
    },
    "gamma": {
        "dist": "HalfNormal",
        "kwargs": {"mu": 1},
    },
}

try:
    parse_model_config(invalid_model_config)
except ModelConfigError as e:
    print(e)
3 errors occurred while parsing model configuration. Errors: Parameter alpha: PyMC doesn't have a distribution of name 'InvalidDistribution', Parameter beta: Parameters must be one of the following types: (int, float, np.array, Prior, pt.TensorVariable). Incorrect parameters: {'mu': <class 'str'>, 'sigma': <class 'str'>}, Parameter gamma: Parameters {'mu'} are not a subset of the pymc distribution parameters {'sigma', 'tau'}

Apéndice: Validación#

Detrás de escena, la clase Prior utiliza validate_call de Pydantic para asegurar que los parámetros sean válidos.

try:
    Prior()
except Exception as e:
    print(e)
1 validation error for __init__
distribution
  Missing required argument [type=missing_argument, input_value=ArgsKwargs(<unprintable tuple object>), input_type=ArgsKwargs]
    For further information visit https://errors.pydantic.dev/2.9/v/missing_argument
try:
    Prior(1)
except Exception as e:
    print(e)
1 validation error for __init__
1
  Input should be a valid string [type=string_type, input_value=1, input_type=int]
    For further information visit https://errors.pydantic.dev/2.9/v/string_type
%load_ext watermark
%watermark -n -u -v -iv -w -p pymc_marketing,pytensor
Last updated: Mon Sep 09 2024

Python implementation: CPython
Python version       : 3.11.5
IPython version      : 8.16.1

pymc_marketing: 0.8.0
pytensor      : 2.22.1

pandas    : 2.1.2
matplotlib: 3.8.4
arviz     : 0.20.0.dev0
numpy     : 1.24.4
pymc      : 5.15.1

Watermark: 2.4.3