#--package load--import numpy as npimport pandas as pdimport seaborn as snsimport matplotlib.pyplot as pltfrom sklearn import model_selection as msfrom sklearn import preprocessing as ppfrom sklearn import linear_modelfrom sklearn import ensemblefrom sklearn import metricsfrom econml.orf import DMLOrthoForest, DROrthoForestfrom econml.dml import CausalForestDMLfrom econml.sklearn_extensions.linear_model import WeightedLassoCVWrapper, WeightedLassofrom econml.dml import LinearDMLfrom econml.score import RScorer
Overview
You have been given a dataset consisting of a single file, [oj_large.csv]. The dataset is a subset of a dataset compiled during a large study by the Chicago Booth School of Business, which collaborated with a local supermarket chain called Dominick`s Finer Foods, to study the impact of prices, advertising, and demographics on the sales of a number of products. The dataset we are working with has over 29, 000 observations of the price of orange juice and sales for different brands at different Dominick`s stores. There is a dataset description here: [oj_dictionary.qmd]
The goal of this homework assignment is to use Causal Machine Learning to understand how different demographic factors influence something called the price elasticity of orange juice. You can read more about price elasticity here: [Wikipedia Price Elasticity of Demand]
Elasticity of demand is the relationship between a percentage change in sales and a percentage change in price:
This relationship is most natural when expressed in terms of the log transform of both sales and price, as the elasticity becomes the coefficient in a linear regression model relating the two:
The elasticity \(\epsilon\) can be dependent upon a variety of other factors. It can depend on the price itself (so that we don`t get a straight line relationship between the logs), it can depend on the type of product, the demographics of the shoppers and more.
The EconML package developed a vignette where they used Causal ML to show that \(\epsilon\) is a function of income, which is to say that the sales are more sensitive to price in stores where the median income is lower. You can find that vignette here and I recommend that you read it and use some code from it as a starting point (it covers more than OJ but it is there): [EconML OJ Vignette]. To read more about the package and its applications, see [pywhy EconML].The [tutorial for this package] is helpful as well.
brand: The brand of orange juice corresponding to this observation. Values are tropicana, minute.maid, and dominicks (one hot)
week: The week of the study that the data was collected (one hot)
logmove: The log of the the number of units sold during that week
feat: Binary indicator variable of whether or not there was an advertisement for orange juice
price: The price per unit during that week at that store
AGE60: Fraction of the population over age 60 in the region of the store
EDUC: The percentage of the population with a college degree
ETHNIC: The percentage of the population that is Black or Hispanic
INCOME: The log of the median income of the population
HHLARGE: The fraction of households with 5 or more people
WORKWOM: The percentage of women in the area with full time jobs
HVAL150: The percentage of households with more than $150, 000 of net worth
SSTRDIST: The average distance in miles to the nearest warehouse store
SSTRVOL: The ratio of sales of this store to the nearest warehouse store
CPDISTS: The average distance in miles to the nearest 5 supermarkets
CPWVOL5: The ratio of sales of this store to the average of the nearest 5 stores
Problem 1: Testing on Fake Data
(a)
It is standard practice in Causal Inference to test models on simulated response data based on the original covariates of the dataset before fitting to the original dataset. Use the same selection of confounders as in the original vignette (The W matrix), excluding week, store, price, INCOME, and logmove, applying One-Hot encoding/dummies to the brand variables to incorporate them into W. Apply the StandardScaler to W. Then put the variable INCOME into a matrix called “X” and standardize it. The matrix X contains _modifier_ variables whose effect on the elasticity will be studied.
Then you will simulate a relationship between your confounders and the price, you can use code like this: T_sim= 0.8 + W[:, support] @ coefs_T + noise, where support is sparse (most entries are 0, the rest are 1) and coefs_T is random (the 0.8 is just for scale).
Look to the simulation code earlier in the EconML vignette for inspiration.
The original vignette uses the code below to create the fake data:
About the confounders, Problem 1a asks to “use the same selection of confounders as the original”. The vignette selects 30 variables (n_w= 30) for the W matrix. Our original data has 17 columns, 18 after the one-hot. After our exclusions, we’re down to 13.
The “support” are the count of random variables (arrays) selected to affect the treatment and outcome. The vignette selects 5 random variables as support, out of 30. We’ll simulate the same proportion as support for this data since we only have 13.
Then we’ll use the same parameters as the original for randomized selection. We wont need a support_Y , we select 2 variables randomly. For coefs_Y we select random coefficient values for the strength of the randomly selected variables. I created separate noise variables for the sim treatment and sim outcome, following the structure of the vignette (and because identical noise might be counterproductive here).
#-- which confounders affect on sim treatment and outcome--support= np.random.choice(range(n_conf), size=support_size, replace=False)#--strength of confounder affect of treatment--coefs_T= np.random.uniform(0, 1, size= support_size)#--strength of confounder effect of outcome--coefs_Y= np.random.uniform(0, 1, size= support_size)#-- random sim prices--noise_T= np.random.uniform(-1, 1, size=W.shape[0])#-- random sim sales--noise_Y= np.random.uniform(-1, 1, size=W.shape[0])
Then we’ll simulate the relationship between the confounders and the treatment using the formula given which is essentially:
Now, simulate the values of the logmove (in a matrix called Y_sim) using your T_sim, your confounders W, and your modifier X. Make the relationship between Y_sim, T_sim, and X nonlinear using something like this: Y_sim= (-2.5 \ np.tanh(2.0\X))\T_sim + W[:, support] @ coefs_Y + noise, where coefs_Y is random (we are using the same support in both simulations).
We’ll add .ravel() to flatten our modifier in the formula for our simulated putcome (sales) which can be translated to:
Using the code from the vignette, fit a Causal Forest (CausalForestDML) and a linear model (LinearDML) to the simulated data. For both models, plot the predicted elasticity as a function of the INCOME, showing the confidence intervals and the real relationship. Also plot the predicted elasticity and the true elasticity for each simulated observation.
Report the true and estimated ATE with confidence intervals. Comment on the performance of both models on the simulated data.
The model estimation (in blue) follows the true elasticity (in red), so this suggests the Causal Forest model simulated the relationship between price and sales well. The trajectory of the line simulated shows this relationship is not a linear one. The confidence interval overlayed on the estimates is also very close, so we can say the model is pretty confident about its predictions, particularly in the center.
The performance of the LinearDML is not ideal. The estimates are linear but the true elasticity was simulated to be non-linear. This model would not be the best choice here.
The observations plots compare a diagnal line (as the ideal predictions) to the predicted elasticity. The CausalDML observations (orange dots) follow the line closely. On the other hand the LinearDML has overall more points with a large distance from the line.
The average treatement effect is negative (-0.162). The models both have an estimated ATE that are close to the true ATE. Neither of the model confidence interval include zero, but both intervals include the ATE value. This suggests both models do a decent job at estimating the average treatment effect. However, the CausalForestDML is better because the data was simulated as nonlinear.
Problem 2: Checking for Overlap
In order for Causal ML to be successful, there needs to be variation in the treatment variable for all combinations of the confounder variables. For a continuous treatment, it is important that there is residual variation left-over after the Causal Forest predicts the treatment using confounders. Keeping with the structure of the original vignette (same definition of W, X, Y, and T), use LassoCV to predict \(T\) using \(W\). Calculate and report the \(R^2\). Does this value of \(R^2\) support the suitability of this dataset for Causal Inference?
#-- real-- T= np.log(oj["price"])Y= oj["logmove"]#-- Lasso--lasso_t= linear_model.LassoCV(cv=5, random_state=646, max_iter=10000)lasso_t.fit(W, T)T_p= lasso_t.predict(W)rsq_t= metrics.r2_score(T, T_p)print(f"Treatment prediction R-squared: {rsq_t:.3f}")print(f"\n")print(f"The model explains {rsq_t*100:.1f}% of the variation in log price")
Treatment prediction R-squared: 0.574
The model explains 57.4% of the variation in log price
The R2 is less than 1.0. The model explains a little more than half of the variation in log price which leaves room for leftover residual variation. This leftover variation is what’s necessary for the causal model needs. Otherwise, if the price was caused by the confounders entirely, then there would be nothing more for the model to learn.
Problem 3: Fitting and Interpreting the Model
(a)
Perform a train-test split on the data. Repeat the fit in the vignette to the training set (you can copy their code with suitable modifications to make it work) using a CausalForestDML model to learn the effect of income on price elasticity. Plot the price elasticity versus income with confidence intervals. Calculate the average treatment effect and confidence intervals.
The CausalForestDML has a slightly higher score, so it is slightly better than the LinearDML. Both are close to zero, which means the effect may be different between groups, or heteregenous.
(c)
Compute a sensitivity check using the sensitivity_interval method of your fit model. This determines how strong an _unobserved confounder_ would have to be to change the results of your analysis in a meaningful way. The method recalculates confidence intervals for the _ATE_ based on two parameters c_t and c_y, which are the fraction of residual variance explained by the hypothetical confounder for the treatment and the target respectively.
One method for determining the range of c_t and c_y to explore by checking the values of c_t and c_y for existing confounders. If you were to do this check, you would find that the most important confounder is the feat variable (whether the item was advertised that week), and the range should be up to c_t=0.1 and c_y=0.3.
Compute the sensitivity check for the most extreme scenario, with c_t=0.1 and c_y=0.3. What are the resulting confidence intervals for the ATE? Do they contain 0?
The sensitivity interval does not include zero. So, this suggests that even in an “extreme senario” the average treatment estimate is negative. The models support the idea that price increases cause a decline in sales.
Problem 4: CATE for Brands and Income
The three brands of orange juice have different price points and are targeted at different customer segments, with dominicks as the discount brand, minute.maid as the mid-range brand, and tropicana as the premium brand. Move the brand variables from confounder matrix W to the modifier matrix X and refit the model.
Calculate the feature importance for all the modifiers and plot the elasticity as a function of income for each of the three brands. How do the elasticities differ by brand?
The brand column was one hot encoded at the start of the report, we’ll extract those.
brand_cols= [col for col in oj.columns if col.startswith("brand_")]brand_cols
['brand_minute.maid', 'brand_tropicana']
There are only two brand columns but there are three brands. This is because I used drop_first= True at the start of the report when I one-hot encoded. One of the juice brands is coded as 0, 0; Dominick’s.
The modifier plot shows income is the most important driver of price elasticity.
#-- elasticity for brands--#-- income grid--#--(changing to 30 for more smoothness and clarity in the plot-- )income_values= np.linspace(X_train_raw_b["income"].min(), X_train_raw_b["income"].max(), 30)#-- defining the brand encoding--dominicks_grid= pd.DataFrame({"income": income_values, "brand_minute.maid": 0, "brand_tropicana": 0})minute_maid_grid= pd.DataFrame({"income": income_values, "brand_minute.maid": 1, "brand_tropicana": 0})tropicana_grid= pd.DataFrame({"income": income_values, "brand_minute.maid": 0, "brand_tropicana": 1})
(To make the plot more readable, I modified the income grid to 30 in order to help smooth plotted lines. Because this is a forest model, the additional grid points add more steps into the visualization. A less granular model smoothes the plot and makes it a little easier to examine.)
The further from zero the price elasticity the more sensitive to changes in price. The plot shows all three brands have negative elasticity, so regardless of brand, the model estimates there is increased sensitivity to price changes.
Dominick’s brand seems to dip the lowest and is more sensitive to these changes. Tropicana and Minute Maid have an overall trend of decreased elasticity the higher the log median income of the population. Dominick’s is (was) a private label of a grocery store and an otherwise less expensive option. We learned earlier from the Modifier Plot, that income is an important driver of price elasticity and this plot supports that. Dominick’s being a budget store brand, Tropicana being premium label, it makes intuitive sense that the higher income buyers stick with their premium brand of choice.