Bambi, Count data and thresholds

Hi.

I’ve been tinkering away with some Bambi model development. I’m looking at some attendence data. We’re interested in modelling the number of times people attend, but also whether people are over a specific threshold, say ‘Which people attend over 5 times’. I’ve developed a pretty reasonable negative binomial model for the count data, but how best to approach the ‘threshold’ element?

In PYMC I’d be tempted to add a deterministic on like this;

import arviz as az
import bambi as bmb
import numpy as np
import pandas as pd
import pymc as pm

data=np.random.poisson(5,200)

with pm.Model() as PoisonTest:
    lm=pm.Exponential("lambda",1.0)
    y=pm.Poisson("y",mu=lm, observed=data)
    Threshold=pm.Deterministic("TestThreshold", y>5.0)
    idata=pm.sample()

idata.posterior.TestThreshold

but I’m trying to use Bambi to have access to the interperet features, which make presenting findings to less technical colleagues far easier.

So:

  1. Should I change this into two separate models for two separate questions: How often do people attend and How often do people attend over 5 days with the former being count data and the latter a binary regression.

  2. If keeping it all in one is a sensible approach how can I do this in Bambi? I’ve hunted around for an example but cant spot one which is quite the same.

Hi @Pegguins, you can do this in Bambi with a custom family with a custom observational model (i.e., likelihood). I prompted codex to generate the code for me. Up to the definition of the family is what you really need, all the rest is the always-willing-to-help bot using the new family in an example. Feel free to ask questions :slight_smile:

import numpy as np
import pandas as pd
import pymc as pm
import xarray as xr
from scipy.stats import poisson

import bambi as bmb


class PoissonWithThreshold(pm.Poisson):
    """A Poisson distribution that records ``response > threshold`` as a deterministic."""

    rv_op = pm.Poisson.rv_op

    threshold_name = "TestThreshold"

    def __new__(cls, name, mu, threshold, *args, **kwargs):
        dims = kwargs.get("dims")
        response = super().__new__(cls, name, mu, threshold, *args, **kwargs)
        pm.Deterministic(cls.threshold_name, response > threshold, dims=dims)
        return response

    @classmethod
    def dist(cls, mu, threshold, *args, **kwargs):
        """Return the unchanged Poisson distribution used for prediction and log-likelihood."""
        del threshold
        return super().dist(mu, *args, **kwargs)


likelihood = bmb.Likelihood(
    name="PoissonWithThreshold",
    params=["mu", "threshold"],
    parent="mu",
    dist=PoissonWithThreshold,
)

custom_poisson = bmb.Family(
    name="custom_poisson",
    likelihood=likelihood,
    link={"mu": "log", "threshold": "identity"},
)
custom_poisson.set_default_priors({"threshold": 5})


def probability_above_threshold(model, idata):
    """Return an ``interpret`` transform for ``P(Y > threshold | mu)``.

    This works with either a constant threshold or posterior draws from a threshold prior.
    """
    if "threshold" in idata.posterior:
        threshold = idata.posterior["threshold"]
    else:
        threshold = model.parameters["threshold"].prior

    def transform(mu):
        # For a count Y, Y > t is equivalent to Y > floor(t).
        return xr.apply_ufunc(poisson.sf, np.floor(threshold), mu)

    return transform


def run_example(draws=500, tune=500):
    """Fit the custom family and obtain predictions with ``bmb.interpret``.

    Returns
    -------
    dict
        The data, fitted model, posterior draws, prediction summaries, and plots.
    """
    rng = np.random.default_rng(1234)
    x = rng.normal(size=150)
    data = pd.DataFrame({"x": x, "y": rng.poisson(np.exp(1.5 + 0.25 * x))})

    # Alternatives for the auxiliary parameter are:
    #
    # - Omit "threshold" to use the family's default constant, 5.
    # - Pass another constant with priors={"threshold": 7}.
    # - Pass a prior, as below, to propagate uncertainty about the threshold.
    threshold_prior = bmb.Prior("Normal", mu=5, sigma=0.75)
    model = bmb.Model(
        "y ~ x",
        data=data,
        family=custom_poisson,
        priors={"threshold": threshold_prior},
    )
    idata = model.fit(
        draws=draws,
        tune=tune,
        random_seed=1234,
    )

    x_grid = np.linspace(data["x"].min(), data["x"].max(), 30)
    conditional = {"x": x_grid}

    # Expected Poisson count, E[Y | x] = mu.
    mean_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
    )

    # Posterior predictive counts.
    count_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
        target="y",
    )

    # Transform the posterior expected count into P(Y > threshold | x), using the Poisson survival
    # function. This propagates uncertainty in both mu and threshold.
    probability_transform = probability_above_threshold(model, idata)
    over_threshold_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
        transforms={"y": probability_transform},
    )

    mean_plot = bmb.interpret.plot_predictions(
        model,
        idata,
        conditional=conditional,
    )
    over_threshold_plot = bmb.interpret.plot_predictions(
        model,
        idata,
        conditional=conditional,
        transforms={"y": probability_transform},
    )

    return {
        "data": data,
        "model": model,
        "idata": idata,
        "mean_predictions": mean_predictions,
        "count_predictions": count_predictions,
        "over_threshold_predictions": over_threshold_predictions,
        "mean_plot": mean_plot,
        "over_threshold_plot": over_threshold_plot,
    }


if __name__ == "__main__":
    example = run_example()
    print(example["mean_predictions"].summary)
    print(example["over_threshold_predictions"].summary)
    example["mean_plot"].show()
    example["over_threshold_plot"].show()

This is fantastic, thank you!

Initial runs playing with your code throws me an error if you supply a single threshold value in the priors:

image

I’ve had a dig at fixing this line, but cant spot what it should be changed to:

threshold = model.parameters["threshold"].prior

From what I can tell the parameters just dont exist in the model in this way. Do we need to extend the idata to contain the prior and take from there?

I’ve also attempted to edit your example into using a negative binomial as the base distribution as thats what I’m using. It seems to be working (aside the issue above) but I’ve done something wrong in the family definition as I now need to specify a prior for alpha every run, rather than letting the back end in Bambi grab a default for me. I’m also in a bit of a kerfuffle about handling the survival function given the difference in parameterisation between pymc and numpy.

Any help is greatly appreciated.

Mike

import numpy as np
import pandas as pd
import pymc as pm
import xarray as xr
from scipy.stats import nbinom
import bambi as bmb

class NegativeBinomialWithThreshold(pm.NegativeBinomial):
    """A NegativeBinomial distribution that records ``response > threshold`` as a deterministic."""
##Modified from fantastic forum post here: https://discourse.pymc.io/t/bambi-count-data-and-thresholds/17926
    rv_op = pm.NegativeBinomial.rv_op

    threshold_name = "TestThreshold"

    def __new__(cls, name,  mu, threshold, *args, **kwargs):
        dims = kwargs.get("dims")
        response = super().__new__(cls, name, mu, threshold, *args, **kwargs)
        pm.Deterministic(cls.threshold_name, response > threshold, dims=dims)
        return response

    @classmethod
    def dist(cls,  mu, threshold, *args, **kwargs):
        """Return the unchanged NegativeBinomial distribution used for prediction and log-likelihood."""
        del threshold
        return super().dist( mu, *args, **kwargs)


likelihood = bmb.Likelihood(
    name="NegativeBinomialWithThreshold",
    params=["mu", "threshold","alpha"],
    parent="mu",
    dist=NegativeBinomialWithThreshold,
)

custom_negativebinomial = bmb.Family(
    name="custom_negativebinomial",
    likelihood=likelihood,
    link={"mu": "log", "threshold": "identity"},
)
custom_negativebinomial.set_default_priors({"threshold": 5})

def probability_above_threshold(model, idata):
    """Return an ``interpret`` transform for ``P(Y > threshold | mu)``.

    This works with either a constant threshold or posterior draws from a threshold prior.
    """
    if "threshold" in idata.posterior:
        threshold = idata.posterior["threshold"]
    else:
        threshold = model.parameters["threshold"].prior
    alpha=idata.posterior["alpha"]
    def transform(mu):
        # For a count Y, Y > t is equivalent to Y > floor(t).
        return xr.apply_ufunc(nbinom.sf, np.floor(threshold), mu,1.0/(1.0+alpha*mu)) 
    #since scipi uses k n and p and we use mu and alpha need to transform the parameters here.
    ##This doesnt feel correct to me though. n=1.0/alpha in place of the mu?

    return transform

def run_example(draws=500, tune=500):
    """Fit the custom family and obtain predictions with ``bmb.interpret``.

    Returns
    -------
    dict
        The data, fitted model, posterior draws, prediction summaries, and plots.
    """
    rng = np.random.default_rng(1234)
    x = rng.normal(size=150)
    data = pd.DataFrame({"x": x, "y": rng.negative_binomial(len(x),0.5+x/1500)})

    # Alternatives for the auxiliary parameter are:
    #
    # - Omit "threshold" to use the family's default constant, 5.
    # - Pass another constant with priors={"threshold": 7}.
    # - Pass a prior, as below, to propagate uncertainty about the threshold.
    #Due to edits alpha prior is currently required. Needs investigating.
    threshold_prior = bmb.Prior("Normal", mu=5, sigma=0.75)
    alpha_prior=bmb.Prior("Exponential", lam=1)

    model = bmb.Model(
        "y ~ x",
        data=data,
        family=custom_negativebinomial,
        priors={"threshold": threshold_prior, "alpha":alpha_prior},
    )
    idata = model.fit(
        draws=draws,
        tune=tune,
        random_seed=1234,
    )

    x_grid = np.linspace(data["x"].min(), data["x"].max(), 30)
    conditional = {"x": x_grid}

    # Expected Poisson count, E[Y | x] = mu.
    mean_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
    )

    # Posterior predictive counts.
    count_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
        target="y",
    )
    
    # Transform the posterior expected count into P(Y > threshold | x), using the Poisson survival
    # function. This propagates uncertainty in both mu and threshold.
    probability_transform = probability_above_threshold(model, idata)
    over_threshold_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
        transforms={"y": probability_transform},
    )

    mean_plot = bmb.interpret.plot_predictions(
        model,
        idata,
        conditional=conditional,
    )
    over_threshold_plot = bmb.interpret.plot_predictions(
        model,
        idata,
        conditional=conditional,
        transforms={"y": probability_transform},
    )

    return {
        "data": data,
        "model": model,
        "idata": idata,
        "mean_predictions": mean_predictions,
        "count_predictions": count_predictions,
        "over_threshold_predictions": over_threshold_predictions,
        "mean_plot": mean_plot,
        "over_threshold_plot": over_threshold_plot,
    }


if __name__ == "__main__":
    example = run_example()
    print(example["mean_predictions"].summary)
    print(example["over_threshold_predictions"].summary)
    example["mean_plot"].show()
    example["over_threshold_plot"].show()

I forgot to mention I am using a development version of Bambi. Could you install from main and try again?

Uninstalled and reinstalled bambi from pip. Have version 0.20.0. Ran this code;

import numpy as np
import pandas as pd
import pymc as pm
import xarray as xr
from scipy.stats import poisson

import bambi as bmb


class PoissonWithThreshold(pm.Poisson):
    """A Poisson distribution that records ``response > threshold`` as a deterministic."""

    rv_op = pm.Poisson.rv_op

    threshold_name = "TestThreshold"

    def __new__(cls, name, mu, threshold, *args, **kwargs):
        dims = kwargs.get("dims")
        response = super().__new__(cls, name, mu, threshold, *args, **kwargs)
        pm.Deterministic(cls.threshold_name, response > threshold, dims=dims)
        return response

    @classmethod
    def dist(cls, mu, threshold, *args, **kwargs):
        """Return the unchanged Poisson distribution used for prediction and log-likelihood."""
        del threshold
        return super().dist(mu, *args, **kwargs)


likelihood = bmb.Likelihood(
    name="PoissonWithThreshold",
    params=["mu", "threshold"],
    parent="mu",
    dist=PoissonWithThreshold,
)

custom_poisson = bmb.Family(
    name="custom_poisson",
    likelihood=likelihood,
    link={"mu": "log", "threshold": "identity"},
)
custom_poisson.set_default_priors({"threshold": 5})


def probability_above_threshold(model, idata):
    """Return an ``interpret`` transform for ``P(Y > threshold | mu)``.

    This works with either a constant threshold or posterior draws from a threshold prior.
    """
    if "threshold" in idata.posterior:
        threshold = idata.posterior["threshold"]
    else:
        threshold = model.parameters["threshold"].prior

    def transform(mu):
        # For a count Y, Y > t is equivalent to Y > floor(t).
        return xr.apply_ufunc(poisson.sf, np.floor(threshold), mu)

    return transform


def run_example(draws=500, tune=500):
    """Fit the custom family and obtain predictions with ``bmb.interpret``.

    Returns
    -------
    dict
        The data, fitted model, posterior draws, prediction summaries, and plots.
    """
    rng = np.random.default_rng(1234)
    x = rng.normal(size=150)
    data = pd.DataFrame({"x": x, "y": rng.poisson(np.exp(1.5 + 0.25 * x))})

    # Alternatives for the auxiliary parameter are:
    #
    # - Omit "threshold" to use the family's default constant, 5.
    # - Pass another constant with priors={"threshold": 7}.
    # - Pass a prior, as below, to propagate uncertainty about the threshold.
    threshold_prior = bmb.Prior("Normal", mu=5, sigma=0.75)
    model = bmb.Model(
        "y ~ x",
        data=data,
        family=custom_poisson,
        priors={"threshold": 7},
    )
    idata = model.fit(
        draws=draws,
        tune=tune,
        random_seed=1234,
    )

    x_grid = np.linspace(data["x"].min(), data["x"].max(), 30)
    conditional = {"x": x_grid}

    # Expected Poisson count, E[Y | x] = mu.
    mean_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
    )

    # Posterior predictive counts.
    count_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
        target="y",
    )

    # Transform the posterior expected count into P(Y > threshold | x), using the Poisson survival
    # function. This propagates uncertainty in both mu and threshold.
    probability_transform = probability_above_threshold(model, idata)
    over_threshold_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
        transforms={"y": probability_transform},
    )

    mean_plot = bmb.interpret.plot_predictions(
        model,
        idata,
        conditional=conditional,
    )
    over_threshold_plot = bmb.interpret.plot_predictions(
        model,
        idata,
        conditional=conditional,
        transforms={"y": probability_transform},
    )

    return {
        "data": data,
        "model": model,
        "idata": idata,
        "mean_predictions": mean_predictions,
        "count_predictions": count_predictions,
        "over_threshold_predictions": over_threshold_predictions,
        "mean_plot": mean_plot,
        "over_threshold_plot": over_threshold_plot,
    }


if __name__ == "__main__":
    print("bambi version "+bmb.__version__)
    example = run_example()
    print(example["mean_predictions"].summary)
    print(example["over_threshold_predictions"].summary)
    example["mean_plot"].show()
    example["over_threshold_plot"].show()

and got output;

To install the version from “main” you need something like:

python -m pip install --upgrade "git+https://github.com/bambinos/bambi.git@main"

It wasn’t published yet

Sorry about that, I’m very much a coder for results sake kind of person!

Installed bambi version 0.20.1.dev2+g3f795e07c and the version you originally posted now runs with no issues when I change the threshold prior to any given value.

The negative binomial attempt I made still throws an error over alpha if I dont provide a prior, so I’ve obviously not done that properly.

No. The standard way to formulate event probabilities is the way you have formulated your PyMC model. That is, you have a model that predicts how many times someone attends, then you create a deterministic indicator variable in the model checking just this, then the probability is just the posterior mean of that indicator variable.

I have no idea how do any of this in Bambi, though.

You have to set a default prior to it, something like:

custom_negativebinomial.set_default_priors({"threshold": 5, "alpha": bmb.Prior("HalfNormal", sigma=1)})

I’ve released Bambi 0.21.0, so you can now use pip install bambi to use a released version instead of a version installed from the git repo.

Thank you all for the help. Apologies for the delay, been on holiday.

For anyone looking in future this is the full example with negative binomial likelihood, editing from the poisson version above. In testing with some dummy data seems to produce sensible figures.

import numpy as np
import pandas as pd
import pymc as pm
import xarray as xr
from scipy.stats import nbinom
import bambi as bmb

class NegativeBinomialWithThreshold(pm.NegativeBinomial):
    """A NegativeBinomial distribution that records ``response > threshold`` as a deterministic."""
##Modified from fantastic forum post here: https://discourse.pymc.io/t/bambi-count-data-and-thresholds/17926
    rv_op = pm.NegativeBinomial.rv_op

    threshold_name = "TestThreshold"

    def __new__(cls, name,  mu, threshold, *args, **kwargs):
        dims = kwargs.get("dims")
        response = super().__new__(cls, name, mu, threshold, *args, **kwargs)
        pm.Deterministic(cls.threshold_name, response > threshold, dims=dims)
        return response

    @classmethod
    def dist(cls,  mu, threshold, *args, **kwargs):
        """Return the unchanged NegativeBinomial distribution used for prediction and log-likelihood."""
        del threshold
        return super().dist( mu, *args, **kwargs)


likelihood = bmb.Likelihood(
    name="NegativeBinomialWithThreshold",
    params=["mu", "threshold","alpha"],
    parent="mu",
    dist=NegativeBinomialWithThreshold,
)

custom_negativebinomial = bmb.Family(
    name="custom_negativebinomial",
    likelihood=likelihood,
    link={"mu": "log", "threshold": "identity"},
)
custom_negativebinomial.set_default_priors({"threshold": 5, "alpha": bmb.Prior("HalfNormal", sigma=1)})

def probability_above_threshold(model, idata):
    """Return an ``interpret`` transform for ``P(Y > threshold | mu)``.

    This works with either a constant threshold or posterior draws from a threshold prior.
    """
    if "threshold" in idata.posterior:
        threshold = idata.posterior["threshold"]
    else:
        threshold = model.parameters["threshold"].prior
    alpha=idata.posterior["alpha"]
    def transform(mu):
        # For a count Y, Y > t is equivalent to Y > floor(t).
        n = 1.0 / alpha
        p = 1.0 / (1.0 + alpha * mu)
        return xr.apply_ufunc(nbinom.sf, np.floor(threshold), n,p) 


    return transform

def run_example(draws=500, tune=500):
    """Fit the custom family and obtain predictions with ``bmb.interpret``.

    Returns
    -------
    dict
        The data, fitted model, posterior draws, prediction summaries, and plots.
    """
    rng = np.random.default_rng(1234)
    x = rng.normal(size=150)
    data = pd.DataFrame({"x": x, "y": rng.negative_binomial(len(x),0.5+x/1500)})

    # Alternatives for the auxiliary parameter are:
    #
    # - Omit "threshold" to use the family's default constant, 5.
    # - Pass another constant with priors={"threshold": 7}.
    # - Pass a prior, as below, to propagate uncertainty about the threshold.
    #Due to edits alpha prior is currently required. Needs investigating.
    threshold_prior = bmb.Prior("Normal", mu=5, sigma=0.75)
    alpha_prior=bmb.Prior("Exponential", lam=1)

    model = bmb.Model(
        "y ~ x",
        data=data,
        family=custom_negativebinomial,
        priors={"threshold": threshold_prior, "alpha":alpha_prior},
    )
    idata = model.fit(
        draws=draws,
        tune=tune,
        random_seed=1234,
    )

    x_grid = np.linspace(data["x"].min(), data["x"].max(), 30)
    conditional = {"x": x_grid}

    # Expected Poisson count, E[Y | x] = mu.
    mean_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
    )

    # Posterior predictive counts.
    count_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
        target="y",
    )
    
    # Transform the posterior expected count into P(Y > threshold | x), using the Poisson survival
    # function. This propagates uncertainty in both mu and threshold.
    probability_transform = probability_above_threshold(model, idata)
    over_threshold_predictions = bmb.interpret.predictions(
        model,
        idata,
        conditional=conditional,
        transforms={"y": probability_transform},
    )

    mean_plot = bmb.interpret.plot_predictions(
        model,
        idata,
        conditional=conditional,
    )
    over_threshold_plot = bmb.interpret.plot_predictions(
        model,
        idata,
        conditional=conditional,
        transforms={"y": probability_transform},
    )

    return {
        "data": data,
        "model": model,
        "idata": idata,
        "mean_predictions": mean_predictions,
        "count_predictions": count_predictions,
        "over_threshold_predictions": over_threshold_predictions,
        "mean_plot": mean_plot,
        "over_threshold_plot": over_threshold_plot,
    }


if __name__ == "__main__":
    example = run_example()
    print(example["mean_predictions"].summary)
    print(example["over_threshold_predictions"].summary)
    example["mean_plot"].show()
    example["over_threshold_plot"].show()