# Logistic Regression and Model Comparison with Bambi and Arviz#

The adults dataset is comprised of census data from 1994 in United States.
The goal is to use demographic variables to predict whether an individual makes more than $50,000 per year. The following is a description of the variables in the dataset. • age: Individual’s age • workclass: Labor class. • fnlwgt: It is not specified, but we guess it is a final sampling weight. • education: Education level as a categorical variable. • educational_num: Education level as numerical variable. It does not reflect years of education. • marital_status: Marital status. • occupation: Occupation. • relationship: Relationship with the head of household. • race: Individual’s race. • sex: Individual’s sex. • capital_gain: Capital gain during unspecified period of time. • capital_loss: Capital loss during unspecified period of time. • hs_week: Hours of work per week. • native_country: Country of birth. • income: Income as a binary variable (either below or above 50K per year). We are only using the following variables in this example: income, sex, race, age, and hs_week. This subset is comprised of both categorical and numerical variables which allows us to visualize how to incorporate both types in a logistic regression model while helping to keep the analysis simpler. [1]:  import arviz as az import bambi as bmb import matplotlib.pyplot as plt import matplotlib.lines as mlines import numpy as np import pandas as pd import seaborn as sns import xarray as xr import warnings from scipy.special import expit as invlogit  [2]:  # Disable a FutureWarning in ArviZ at the moment of running the notebook az.style.use("arviz-darkgrid") warnings.simplefilter(action='ignore', category=FutureWarning)  [3]:  data = pd.read_table("data/adult.data", header=None, sep = ",", skipinitialspace=True, names=['age', 'workclass', 'fnlwgt', 'education', 'education_num', 'marital_status', 'occupation', 'relationship', 'race', 'sex', 'captial_gain', 'capital_loss', 'hs_week', 'native_country', 'income']) # Select only columns we're going to use data = data[["income", "sex", "race", "age", "hs_week"]]  [4]:  data.info() data.head()  <class 'pandas.core.frame.DataFrame'> RangeIndex: 32561 entries, 0 to 32560 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 income 32561 non-null object 1 sex 32561 non-null object 2 race 32561 non-null object 3 age 32561 non-null int64 4 hs_week 32561 non-null int64 dtypes: int64(2), object(3) memory usage: 1.2+ MB  [4]:  income sex race age hs_week 0 <=50K Male White 39 40 1 <=50K Male White 50 13 2 <=50K Male White 38 40 3 <=50K Male Black 53 40 4 <=50K Female Black 28 40 Categorical variables are presented as from type object. In this step we convert them to category. [5]:  categorical_cols = data.columns[data.dtypes == object].tolist() for col in categorical_cols: data[col] = data[col].astype("category") data.info()  <class 'pandas.core.frame.DataFrame'> RangeIndex: 32561 entries, 0 to 32560 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 income 32561 non-null category 1 sex 32561 non-null category 2 race 32561 non-null category 3 age 32561 non-null int64 4 hs_week 32561 non-null int64 dtypes: category(3), int64(2) memory usage: 604.7 KB  Instead of going straight to fitting models, we’re going to do a some exploratory analysis of the variables in the dataset. First we have some plots, and then some conclusions about the information in the plots. [6]:  # Just a utilitary function to truncate labels and avoid overlapping in plots def truncate_labels(ticklabels, width=8): def truncate(label, width): if len(label) > width - 3: return label[0 : (width - 4)] + "..." else: return label labels = [x.get_text() for x in ticklabels] labels = [truncate(lbl, width) for lbl in labels] return labels  [7]:  fig, axes = plt.subplots(3, 2, figsize=(12, 15)) sns.countplot(x="income", color="C0", data=data, ax=axes[0, 0], saturation=1) sns.countplot(x="sex", color="C0", data=data, ax=axes[0, 1], saturation=1); sns.countplot(x="race", color="C0", data=data, ax=axes[1, 0], saturation=1); axes[1, 0].set_xticklabels(truncate_labels(axes[1, 0].get_xticklabels())) axes[1, 1].hist(data["age"], bins=20); axes[1, 1].set_xlabel("Age") axes[1, 1].set_ylabel("Count") axes[2, 0].hist(data["hs_week"], bins=20); axes[2, 0].set_xlabel("Hours of work / week") axes[2, 0].set_ylabel("Count") axes[2, 1].axis('off');  Highlights • Approximately 25% of the people make more than 50K a year. • Two thirds of the subjects are males. • The great majority of the subjects are white, only a minority are black and the other categories are very infrequent. • The distribution of age is skewed to the right, as one might expect. • The distribution of hours of work per week looks weird at first sight. But what is a typical workload per week? You got it, 40 hours :). We only keep the races black and white to simplify the analysis. The other categories don’t appear very often in our data. Now, we see the distribution of income for the different levels of our explanatory variables. Numerical variables are binned to make the analysis possible. [8]:  data = data[data["race"].isin(["Black", "White"])] data["race"] = data["race"].cat.remove_unused_categories() age_bins = [17, 25, 35, 45, 65, 90] data["age_binned"] = pd.cut(data["age"], age_bins) hours_bins = [0, 20, 40, 60, 100] data["hs_week_binned"] = pd.cut(data["hs_week"], hours_bins)  [9]:  fig, axes = plt.subplots(3, 2, figsize=(12, 15)) sns.countplot(x="income", color="C0", data=data, ax=axes[0, 0]) sns.countplot(x="sex", hue="income", data=data, ax=axes[0, 1]) sns.countplot(x="race", hue="income", data=data, ax=axes[1, 0]) sns.countplot(x="age_binned", hue="income", data=data, ax=axes[1, 1]) sns.countplot(x="hs_week_binned", hue="income", data=data, ax=axes[2, 0]) axes[2, 1].axis("off");  Some quick and gross info from the plots • The probability of making more than \$50k a year is larger if you are a Male.

• A person also has more probability of making more than \$50k/yr if she/he is White. • For age, we see the probability of making more than \$50k a year increases as the variable increases, up to a point where it starts to decrease.

• Also, the more hours a person works per week, the higher the chance of making more than \$50k/yr. There’s a big jump in that probability when the hours of work per week jump from the (20, 40] bin to the (40, 60] one. Some data preparation before fitting our model. Here we standardize numerical variables age and hs_week because it may help sampler convergence. Also, we compute their second and third power. These powers will be sequantialy added to the model. [10]:  age_mean = np.mean(data["age"]) age_std = np.std(data["age"]) hs_mean = np.mean(data["hs_week"]) hs_std = np.std(data["hs_week"]) data["age"] = (data["age"] - age_mean) / age_std data["age2"] = data["age"] ** 2 data["age3"] = data["age"] ** 3 data["hs_week"] = (data["hs_week"] - hs_mean) / hs_std data["hs_week2"] = data["hs_week"] ** 2 data["hs_week3"] = data["hs_week"] ** 3 data = data.drop(columns=["age_binned", "hs_week_binned"])  This is how our data looks like before fitting the models. [11]:  data.head()  [11]:  income sex race age hs_week age2 age3 hs_week2 hs_week3 0 <=50K Male White 0.024207 -0.037250 0.000586 0.000014 0.001388 -0.000052 1 <=50K Male White 0.827984 -2.222326 0.685557 0.567630 4.938734 -10.975479 2 <=50K Male White -0.048863 -0.037250 0.002388 -0.000117 0.001388 -0.000052 3 <=50K Male Black 1.047195 -0.037250 1.096618 1.148374 0.001388 -0.000052 4 <=50K Female Black -0.779569 -0.037250 0.607728 -0.473766 0.001388 -0.000052 ## The model# We will use a logistic regression model to estimate the probability of making more than \$50K as a function of age, hours of work per week, sex, race and education level.

If we have a binary response variable $$Y$$ and a set of predictors or explanatory variables $$X_1, X_2, \cdots, X_p$$ the logistic regression model can be defined as follows:

$\log{\left(\frac{\pi}{1 - \pi}\right)} = \beta_0 + \beta_1 X_1 + \beta_2 X_2 + \cdots + \beta_p X_p$

where $$\pi = P(Y = 1)$$ (a.k.a. probability of success) and $$\beta_0, \beta_1, \cdots \beta_p$$ are unknown parameters. The term on the left side is the logarithm of the odds ratio or simply known as the log-odds. With little effort, the expression can be re-arranged to express our probability of interest, $$\pi$$, as a function of the betas and the predictors.

$\pi = \frac{e^{\beta_0 + \beta_1 X_1 + \cdots + \beta_p X_p}}{1 + e^{\beta_0 + \beta_1 X_1 + \cdots + \beta_p X_p}} = \frac{1}{1 + e^{-(\beta_0 + \beta_1 X_1 + \cdots + \beta_p X_p)}}$

We need to specify a prior and a likelihood in order to draw samples from the posterior distribution. We could use sociological knowledge about the effects of age and education on income, but instead, let’s use the default prior specification in Bambi.

The likelihood is the product of $$n$$ Bernoulli trials, $$\prod_{i=1}^{n}{p_i^y(1-p_i)^{1-y_i}}$$ where $$p_i = P(Y=1)$$.

In our case, we have

$\begin{split}Y = \left\{ \begin{array}{ll} 1 & \textrm{if the person makes more than 50K per year} \\ 0 & \textrm{if the person makes less than 50K per year} \end{array} \right.\end{split}$
$\pi = P(Y=1)$

But this is a Bambi example, right? Let’s see how Bambi can helps us to build a logistic regression model.

## Model 1:#

$\log{\left(\frac{\pi}{1 - \pi}\right)} = \beta_0 + \beta_1 X_1 + \beta_2 X_2 + \beta_3 X_3 + \beta_4 X_4$

Where:

$X_1 = \displaystyle :nbsphinx-math:frac{text{Age} - text{Age}_{text{mean}}}{text{Age}_{text{std}}} \ X_2 = \displaystyle :nbsphinx-math:frac{text{Hours_week} - text{Hours_week}_{text{mean}}}{text{Hours_week}_{text{std}}} \ X_3 = :nbsphinx-math:left{ \right. \ X_4 = :nbsphinx-math:left{ \right. \$

[12]:

model1 = bmb.Model("income['>50K'] ~ sex + race + age + hs_week", data, family="bernoulli")
fitted1 = model1.fit( draws=1000)

Modeling the probability that income==>50K
Auto-assigning NUTS sampler...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [hs_week, age, race, sex, Intercept]

100.00% [4000/4000 01:14<00:00 Sampling 2 chains, 0 divergences]
Sampling 2 chains for 1_000 tune and 1_000 draw iterations (2_000 + 2_000 draws total) took 75 seconds.

[13]:

az.plot_trace(fitted1);
az.summary(fitted1)

[13]:

mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
Intercept -2.637 0.063 -2.745 -2.514 0.002 0.001 1202.0 1154.0 1.0
sex 1.019 0.037 0.948 1.085 0.001 0.001 2237.0 1431.0 1.0
race 0.631 0.059 0.527 0.744 0.002 0.001 1361.0 1070.0 1.0
age 0.579 0.016 0.547 0.607 0.000 0.000 2096.0 1321.0 1.0
hs_week 0.506 0.015 0.477 0.533 0.000 0.000 2133.0 1515.0 1.0

## Model 2#

$\log{\left(\frac{\pi}{1 - \pi}\right)} = \beta_0 + \beta_1 X_1 + \beta_2 X_1^2 + \beta_3 X_2 + \beta_4 X_2^2 + \beta_5 X_3 + \beta_6 X_4$

Where:

$X_1 = \displaystyle :nbsphinx-math:frac{text{Age} - text{Age}_{text{mean}}}{text{Age}_{text{std}}} \ X_2 = \displaystyle :nbsphinx-math:frac{text{Hours_week} - text{Hours_week}_{text{mean}}}{text{Hours_week}_{text{std}}} \ X_3 = :nbsphinx-math:left{ \right. \ X_4 = :nbsphinx-math:left{ \right.$

[14]:

model2 = bmb.Model("income['>50K'] ~ sex + race + age + age2 + hs_week + hs_week2", data, family="bernoulli")
fitted2 = model2.fit(draws=1000)

Modeling the probability that income==>50K
Auto-assigning NUTS sampler...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [hs_week2, hs_week, age2, age, race, sex, Intercept]

100.00% [4000/4000 01:32<00:00 Sampling 2 chains, 0 divergences]
Sampling 2 chains for 1_000 tune and 1_000 draw iterations (2_000 + 2_000 draws total) took 93 seconds.

[15]:

az.plot_trace(fitted2);
az.summary(fitted2)

[15]:

mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
Intercept -2.286 0.063 -2.404 -2.166 0.002 0.001 1238.0 995.0 1.0
sex 1.007 0.037 0.938 1.076 0.001 0.001 1773.0 1355.0 1.0
race 0.704 0.059 0.597 0.818 0.002 0.001 1287.0 1017.0 1.0
age 1.069 0.023 1.023 1.111 0.001 0.000 2026.0 1376.0 1.0
age2 -0.538 0.018 -0.571 -0.503 0.000 0.000 1905.0 1383.0 1.0
hs_week 0.499 0.022 0.460 0.543 0.001 0.000 1711.0 1538.0 1.0
hs_week2 -0.088 0.008 -0.103 -0.071 0.000 0.000 1807.0 1534.0 1.0

## Model 3#

$\log{\left(\frac{\pi}{1 - \pi}\right)} = \beta_0 + \beta_1 X_1 + \beta_2 X_1^2 + \beta_3 X_1^3 + \beta_4 X_2 + \beta_5 X_2^2 + \beta_6 X_2^3 + \beta_7 X_3 + \beta_8 X_4$

Where:

$X_1 = \displaystyle :nbsphinx-math:frac{text{Age} - text{Age}_{text{mean}}}{text{Age}_{text{std}}} \ X_2 = \displaystyle :nbsphinx-math:frac{text{Hours_week} - text{Hours_week}_{text{mean}}}{text{Hours_week}_{text{std}}} \ X_3 = :nbsphinx-math:left{ \right. \ X_4 = :nbsphinx-math:left{ \right.$

[16]:

model3 = bmb.Model(
"income['>50K'] ~ age + age2 + age3 + hs_week + hs_week2 + hs_week3 + sex + race",
data,
family="bernoulli"
)
fitted3 = model3.fit(draws=1000)

Modeling the probability that income==>50K
Auto-assigning NUTS sampler...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [race, sex, hs_week3, hs_week2, hs_week, age3, age2, age, Intercept]

100.00% [4000/4000 02:00<00:00 Sampling 2 chains, 0 divergences]
Sampling 2 chains for 1_000 tune and 1_000 draw iterations (2_000 + 2_000 draws total) took 120 seconds.

[17]:

az.plot_trace(fitted3);
az.summary(fitted3)

[17]:

mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
Intercept -2.143 0.063 -2.254 -2.025 0.002 0.001 1602.0 1374.0 1.0
age 0.964 0.025 0.919 1.011 0.000 0.000 2502.0 1442.0 1.0
age2 -0.894 0.031 -0.954 -0.835 0.001 0.001 1402.0 1433.0 1.0
age3 0.175 0.011 0.155 0.196 0.000 0.000 1499.0 1392.0 1.0
hs_week 0.613 0.024 0.569 0.659 0.001 0.000 1959.0 1547.0 1.0
hs_week2 -0.011 0.010 -0.029 0.009 0.000 0.000 1729.0 1542.0 1.0
hs_week3 -0.035 0.003 -0.041 -0.028 0.000 0.000 1471.0 1527.0 1.0
sex 0.984 0.036 0.917 1.053 0.001 0.001 2557.0 1284.0 1.0
race 0.678 0.059 0.565 0.781 0.001 0.001 1761.0 1554.0 1.0

## Model comparison#

We can perform a Bayesian model comparison very easily with az.compare(). Here we pass a dictionary with the InferenceData objects that Model.fit() returned and az.compare() returns a data frame that is ordered from best to worst according to the criteria used. By default, ArviZ uses loo, which is an estimation of leave one out cross validation. Another option is the widely applicable information criterion (WAIC). For more information about the information criterias available and other options within the function see the docs.

[18]:

models_dict = {
"model1": fitted1,
"model2": fitted2,
"model3": fitted3
}
df_compare = az.compare(models_dict)
df_compare

/home/tomas/anaconda3/envs/bmb/lib/python3.8/site-packages/arviz/stats/stats.py:146: UserWarning: The default method used to estimate the weights for each model,has changed from BB-pseudo-BMA to stacking
warnings.warn(

[18]:

rank loo p_loo d_loo weight se dse warning loo_scale
model3 0 -13986.976694 9.479734 0.000000 1.000000e+00 89.302596 0.000000 False log
model2 1 -14154.769931 7.805731 167.793237 6.222986e-08 91.369264 19.868924 False log
model1 2 -14916.092983 5.081731 929.116289 0.000000e+00 91.061317 38.937370 False log
[19]:

az.plot_compare(df_compare, insample_dev=False);


There is a difference in the point estimations (empty circles) between the model with cubic terms (model 3) and the model with quadratic terms (model 2) but there is some overlap between their interval estimations. This time, we are going to select model 2 and do some extra little work with it because from previous experience with this dataset we know there is no substantial difference between them, and model 2 is simpler. However, as we mention in the final remarks, this is not the best you can achieve with this dataset. If you want, you could also try to add other predictors, such as education level and see how it impacts in the model comparison :).

## Probability estimation#

In this section we plot age vs the probability of making more than 50K a year given different profiles.

[20]:

posterior = fitted2.posterior


We set hours of work per week at 40 hours and assign a grid from 18 to 75 age. They’re standardized because they were standardized when we fitted the model.

[21]:

HS_WEEK = (40 - hs_mean) / hs_std
AGE = (np.linspace(18, 75) - age_mean) / age_std
AGE = xr.DataArray(AGE, dims=["age_dim"])


Now we compute the intercepts for the four combinations between sex and race.

[22]:

int_fem_black = posterior["Intercept"]
int_mal_black = int_fem_black + posterior["sex"]
int_fem_white = int_fem_black + posterior["race"]
int_mal_white = int_mal_black + posterior["race"]


The contribution of hours of work per week.

[23]:

hs_contr = HS_WEEK * posterior["hs_week"]
hs_contr += HS_WEEK ** 2 * posterior["hs_week2"]


And the linear predictor for all the ages in the grid.

[24]:

age_contr = AGE * posterior["age"] + AGE ** 2 * posterior["age2"]


Then we just store all the linear predictors in a list called eta.

[25]:

eta_base = hs_contr + age_contr
eta_fem_black = int_fem_black + eta_base
eta_mal_black = int_mal_black + eta_base
eta_fem_white = int_fem_white + eta_base
eta_mal_white = int_mal_white + eta_base

etas = [eta_fem_black, eta_mal_black, eta_fem_white, eta_mal_white]
age = AGE * age_std + age_mean


And finally, the plot :)

Here we use az.plot_hdi() to get Highest Density Interval plots. We get two bands for each profile. One corresponds to an hdi probability of 0.94 (the default) and the other to an hdi probability of 0.5.

[26]:

colors = [f"C{i}" for i in range(4)]
labels = ["Black - Female", "Black - Male", "White - Female", "White - Male"]
handles = []

fig, ax = plt.subplots()

for eta, color, label in zip(etas, colors, labels):
az.plot_hdi(age, invlogit(eta), ax=ax, color=color)
az.plot_hdi(age, invlogit(eta), ax=ax, color=color, hdi_prob=0.5)
handles.append(mlines.Line2D([], [], color=color, label=label, lw=3))

ax.set_xlabel("Age")
ax.set_ylabel("P(Income > $50K)") ax.legend(handles=handles, loc="upper left");  The highest posterior density bands show how the probability of earning more than 50K changes with age for a given profile. In all the cases, we see the probability of making more than$50K increases with age until approximately age 52, when the probability begins to drop off. We can interpret narrow portions of a curve as places where we have low uncertainty and spread out portions of the bands as places where we have somewhat higher uncertainty about our coefficient values.

### Final remarks#

In this notebook we’ve seen how easy it is to incorporate ArviZ into a Bambi workflow to perform model comparison based on information criterias such as LOO and WAIC. However, an attentive reader might have seen that the highest density interval plot never shows a predicted probability greater than 0.5 (which is not good if we expect to predict that at least some people working 40hrs/wk make more than \\$50k/yr). You can increase the hours of work per week for the profiles we’ve used and the HDIs will show larger values. But we won’t be seeing the whole picture.

Although we’re using some demographic variables such as sex and race, the cells resulting from the combinations of their levels are still very heterogeneous. For example, we are mixing individuals of all educational levels. A possible next step is to incorporate education into the different models we compared. If any of the readers (yes, you!) is interested in doing so, here there are some notes that may help

• Education is an ordinal categorical variable with a lot of levels.

• Explore the conditional distribution of income given education levels.

• See what are the counts/proportions of people within each education level.

• Collapse categories (but respect the ordinality!). Try to end up with 5 or less categories if possible.

• Start with a model with only age, sex, race, hs_week and education. Then incorporate higher order terms (second and third powers for example). Don’t go beyond fourth powers.

• Look for a nice activity to do while the sampler does its job. We know it’s going to take a couple of hours to fit all those models :)

And finally, please feel free to open a new issue if you think there’s something that we can improve.

[27]:

%load_ext watermark
%watermark -n -u -v -iv -w

Last updated: Mon Apr 19 2021

Python implementation: CPython
Python version       : 3.8.5
IPython version      : 7.18.1

arviz     : 0.11.2
bambi     : 0.4.1
numpy     : 1.20.1
pandas    : 1.2.2
json      : 2.0.9
xarray    : 0.16.1
seaborn   : 0.11.0
matplotlib: 3.3.3

Watermark: 2.1.0