Logistic Regression and Model Comparison with Bambi and Arviz¶
Adults dataset¶
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:
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.
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
But this is a Bambi example, right? Let’s see how Bambi can helps us to build a logistic regression model.
Model 1:¶
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...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [hs_week, age, race, sex, Intercept]
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¶
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...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [hs_week2, hs_week, age2, age, race, sex, Intercept]
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¶
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...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [race, sex, hs_week3, hs_week2, hs_week, age3, age2, age, Intercept]
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