# Customer Segmentation: Estimate Individualized Responses to Incentives

Nowadays, business decision makers rely on estimating the causal effect of interventions to answer what-if questions about shifts in strategy, such as promoting specific product with discount, adding new features to a website or increasing investment from a sales team. However, rather than learning whether to take action for a specific intervention for all users, people are increasingly interested in understanding the different responses from different users to the two alternatives. Identifying the characteristics of users having the strongest response for the intervention could help make rules to segment the future users into different groups. This can help optimize the policy to use the least resources and get the most profit.

In this case study, we will use a personalized pricing example to explain how the [EconML](https://aka.ms/econml) and [DoWhy](https://github.com/microsoft/dowhy) libraries could fit into this problem and provide robust and reliable causal solutions.

### Summary

1. [Background](#background)
2. [Data](#data)
3. [Create Causal Model and Identify Causal Effect with DoWhy](#identify)
4. [Get Causal Effects with EconML](#estimate)
5. [Test Estimate Robustness with DoWhy](#robustness)
    1. [Add Random Common Cause](#random-common-cause)
    2. [Add Unobserved Common Cause](#unobserved-common-cause)
    3. [Replace Treatment with a Random (Placebo) Variable](#placebo-variable)
    4. [Remove a Random Subset of the Data](#subset)
6. [Understand Treatment Effects with EconML](#interpret)
7. [Make Policy Decisions with EconML](#policy)
8. [Conclusions](#conclusion)

# Background

The global online media market is growing fast over the years. Media companies are always interested in attracting more users into the market and encouraging them to buy more songs or become members. In this example, we'll consider a scenario where one experiment a media company is running is to give small discount (10%, 20% or 0) to their current users based on their income level in order to boost the likelihood of their purchase. The goal is to understand the **heterogeneous price elasticity of demand** for people with different income level, learning which users would respond most strongly to a small discount. Furthermore, their end goal is to make sure that despite decreasing the price for some consumers, the demand is raised enough to boost the overall revenue.

The EconML and DoWhy libraries complement each other in implementing this solution. On one hand, the DoWhy library can help [build a causal model, indentify the causal effect](#identify) and [test causal assumptions](#robustness). On the other hand, EconML’s `DML` based estimators can be used to take the discount variation in existing data, along with a rich set of user features, to [estimate heterogeneous price sensitivities](#estimate) that vary with multiple customer features. Then, the `SingleTreeCateInterpreter` provides a [presentation-ready summary](#interpret) of the key features that explain the biggest differences in responsiveness to a discount, and the `SingleTreePolicyInterpreter` recommends a [policy](#policy) on who should receive a discount in order to increase revenue (not only demand), which could help the company to set an optimal price for those users in the future.

```python
# Some imports to get us started
# Utilities
import os
import urllib.request
import numpy as np
import pandas as pd
from networkx.drawing.nx_pydot import to_pydot
from IPython.display import Image, display

# Generic ML imports
from sklearn.preprocessing import PolynomialFeatures
from sklearn.ensemble import GradientBoostingRegressor

# DoWhy imports
import dowhy
from dowhy import CausalModel

# EconML imports
from econml.dml import LinearDML, CausalForestDML
from econml.cate_interpreter import SingleTreeCateInterpreter, SingleTreePolicyInterpreter

import matplotlib.pyplot as plt

%matplotlib inline
```

# Data

The dataset* has ~10,000 observations and includes 9 continuous and categorical variables that represent user's characteristics and online behaviour history such as age, log income, previous purchase, previous online time per week, etc.

We define the following variables:

Feature Name|Type|Details
:--- |:---|:---
**account_age** |W| user's account age
**age** |W|user's age
**avg_hours** |W| the average hours user was online per week in the past
**days_visited** |W| the average number of days user visited the website per week in the past
**friend_count** |W| number of friends user connected in the account
**has_membership** |W| whether the user had membership
**is_US** |W| whether the user accesses the website from the US
**songs_purchased** |W| the average songs user purchased per week in the past
**income** |X| user's income
**price** |T| the price user was exposed during the discount season (baseline price * small discount)
**demand** |Y| songs user purchased during the discount season

**To protect the privacy of the company, we use the simulated data as an example here. The data is synthetically generated and the feature distributions don't correspond to real distributions. However, the feature names have preserved their names and meaning.*

The treatment and outcome are generated using the following functions:
$$
T =
\begin{cases}
  1 & \text{with } p=0.2,  \\
  0.9 & \text{with }p=0.3, & \text{if income}<1 \\
  0.8 & \text{with }p=0.5, \\
  \\
    1 & \text{with }p=0.7, \\
  0.9 & \text{with }p=0.2, & \text{if income}\ge1 \\
  0.8 & \text{with }p=0.1, \\
\end{cases}
$$

\begin{align}
\gamma(X) & = -3 - 14 \cdot \{\text{income}<1\} \\
\beta(X,W) & = 20 + 0.5 \cdot \text{avg_hours} + 5 \cdot \{\text{days_visited}>4\} \\
Y &= \gamma(X) \cdot T + \beta(X,W)
\end{align}

```python
# Import the sample pricing data
file_url = "https://msalicedatapublic.blob.core.windows.net/datasets/Pricing/pricing_sample.csv"
train_data = pd.read_csv(file_url)
```

```python
# Data sample
train_data.head()
```

      account_age
      age
      avg_hours
      days_visited
      friends_count
      has_membership
      is_US
      songs_purchased
      income
      price
      demand

      0
      3
      53
      1.834234
      2
      8
      1
      1
      4.903237
      0.960863
      1.0
      3.917117

      1
      5
      54
      7.171411
      7
      9
      0
      1
      3.330161
      0.732487
      1.0
      11.585706

      2
      3
      33
      5.351920
      6
      9
      0
      1
      3.036203
      1.130937
      1.0
      24.675960

      3
      2
      34
      6.723551
      0
      8
      0
      1
      7.911926
      0.929197
      1.0
      6.361776

      4
      4
      30
      2.448247
      5
      8
      1
      0
      7.148967
      0.533527
      0.8
      12.624123

```python
# Define estimator inputs
train_data["log_demand"]=np.log(train_data["demand"])
train_data["log_price"]=np.log(train_data["price"])

X = train_data[["income"]]  # features
```

```python
# Get test data
X_test = np.linspace(0, 5, 100).reshape(-1, 1)
X_test_data = pd.DataFrame(X_test, columns=["income"])
```

# Create Causal Model and Identify Causal Effect with DoWhy

We define the causal assumptions with DoWhy. For example, we can include features we believe as confounders and features we think will influence the heterogeneity of the effect. With these assumptions defined, DoWhy can generate a causal graph for us, and use that graph to first identify the causal effect.

```python
common_causes=["account_age","age","avg_hours","days_visited","friends_count","has_membership","is_US","songs_purchased","income"]
```

```python
model = CausalModel(
    data=train_data,
    treatment='log_price',
    outcome='log_demand',
    common_causes=common_causes,
    effect_modifiers='income'
)
```

    WARNING:dowhy.causal_model:Causal Graph not provided. DoWhy will construct a graph based on data inputs.
    INFO:dowhy.causal_graph:If this is observed data (not from a randomized experiment), there might always be missing confounders. Adding a node named "Unobserved Confounders" to reflect this.
    INFO:dowhy.causal_model:Model to find the causal effect of treatment ['log_price'] on outcome ['log_demand']

```python
# Visualize causal graph
try:
    # Try pretty printing the graph. Requires pydot and pygraphviz
    display(
        Image(to_pydot(model._graph._graph).create_png())
    )
except:
    # Fall back on default graph view
    model.view_model()
```

```python
identified_estimand = model.identify_effect(proceed_when_unidentifiable=True)
print(identified_estimand)
```

    INFO:dowhy.causal_identifier:Common causes of treatment and outcome:['U', 'friends_count', 'account_age', 'is_US', 'songs_purchased', 'avg_hours', 'days_visited', 'income', 'has_membership', 'age']
    WARNING:dowhy.causal_identifier:If this is observed data (not from a randomized experiment), there might always be missing confounders. Causal effect cannot be identified perfectly.
    INFO:dowhy.causal_identifier:Continuing by ignoring these unobserved confounders because proceed_when_unidentifiable flag is True.
    INFO:dowhy.causal_identifier:Instrumental variables for treatment and outcome:[]

    Estimand type: nonparametric-ate
    ### Estimand : 1
    Estimand name: backdoor
    Estimand expression:
         d
    ────────────(Expectation(log_demand|friends_count,account_age,is_US,songs_purc
    d[log_price]

    hased,avg_hours,days_visited,income,has_membership,age))

    Estimand assumption 1, Unconfoundedness: If U→{log_price} and U→log_demand then P(log_demand|log_price,friends_count,account_age,is_US,songs_purchased,avg_hours,days_visited,income,has_membership,age,U) = P(log_demand|log_price,friends_count,account_age,is_US,songs_purchased,avg_hours,days_visited,income,has_membership,age)
    ### Estimand : 2
    Estimand name: iv
    No such variable found!

# Get Causal Effects with EconML

Based on the identified causal effect above, we fit the model as follows using EconML:

\begin{align}
log(Y) & = \theta(X) \cdot log(T) + f(X,W) + \epsilon \\
log(T) & = g(X,W) + \eta
\end{align}

where $\epsilon, \eta$ are uncorrelated error terms.

The models we fit here aren't an exact match for the data generation function above, but if they are a good approximation, they will allow us to create a good discount policy.  Although the model is misspecified, we hope to see that our `DML` based estimators can still capture the right trend of $\theta(X)$ and that the recommended policy beats other baseline policies (such as always giving a discount) on revenue.  Because of the mismatch between the data generating process and the model we're fitting, there isn't a single true $\theta(X)$ (the true elasticity varies with not only X but also T and W), but given how we generate the data above, we can still calculate the range of true $\theta(X)$ to compare against.

```python
# Define underlying treatment effect function given DGP
def gamma_fn(X):
    return -3 - 14 * (X["income"] < 1)

def beta_fn(X):
    return 20 + 0.5 * (X["avg_hours"]) + 5 * (X["days_visited"] > 4)

def demand_fn(data, T):
    Y = gamma_fn(data) * T + beta_fn(data)
    return Y

def true_te(x, n, stats):
    if x < 1:
        subdata = train_data[train_data["income"] < 1].sample(n=n, replace=True)
    else:
        subdata = train_data[train_data["income"] >= 1].sample(n=n, replace=True)
    te_array = subdata["price"] * gamma_fn(subdata) / (subdata["demand"])
    if stats == "mean":
        return np.mean(te_array)
    elif stats == "median":
        return np.median(te_array)
    elif isinstance(stats, int):
        return np.percentile(te_array, stats)
```

```python
# Get the estimate and range of true treatment effect
truth_te_estimate = np.apply_along_axis(true_te, 1, X_test, 1000, "mean")  # estimate
truth_te_upper = np.apply_along_axis(true_te, 1, X_test, 1000, 95)  # upper level
truth_te_lower = np.apply_along_axis(true_te, 1, X_test, 1000, 5)  # lower level
```

    INFO:numexpr.utils:NumExpr defaulting to 8 threads.

## Parametric heterogeneity
First of all, we can try to learn a **linear projection of the treatment effect** assuming a polynomial form of $\theta(X)$. We use the `LinearDML` estimator. Since we don't have any priors on these models, we use a generic gradient boosting tree estimators to learn the expected price and demand from the data.

```python
lineardml_estimate = model.estimate_effect(
    identified_estimand,
    method_name="backdoor.econml.dml.LinearDML",
    control_value=0,
    treatment_value=1,
    target_units=X_test_data,
    method_params={
        "init_params": {"model_y": GradientBoostingRegressor(), "model_t": GradientBoostingRegressor(),"featurizer":PolynomialFeatures(degree=2, include_bias=False)},
        "fit_params": {"inference": "statsmodels"},
    },
)
```

    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)

```python
est = lineardml_estimate._estimator_object
# Get treatment effect and its confidence interval
te_pred = est.effect(X_test).flatten()
te_pred_interval = est.effect_interval(X_test)
```

```python
# Compare the estimate and the truth
plt.figure(figsize=(10, 6))
plt.plot(X_test.flatten(), te_pred, label="Sales Elasticity Prediction")
plt.plot(X_test.flatten(), truth_te_estimate, "--", label="True Elasticity")
plt.fill_between(
    X_test.flatten(),
    te_pred_interval[0].flatten(),
    te_pred_interval[1].flatten(),
    alpha=0.2,
    label="90% Confidence Interval",
)
plt.fill_between(
    X_test.flatten(),
    truth_te_lower,
    truth_te_upper,
    alpha=0.2,
    label="True Elasticity Range",
)
plt.xlabel("Income")
plt.ylabel("Songs Sales Elasticity")
plt.title("Songs Sales Elasticity vs Income")
plt.legend(loc="lower right")
```

From the plot above, it's clear to see that the true treatment effect is a **nonlinear** function of income, with elasticity around -1.75 when income is smaller than 1 and a small negative value when income is larger than 1. The model fits a quadratic treatment effect, which is not a great fit. But it still captures the overall trend: the elasticity is negative and people are less sensitive to the price change if they have higher income.

```python
# Get the final coefficient and intercept summary
est.summary(feature_names=X.columns)
```

Coefficient Results

      	     point_estimate	 stderr	  zstat	  pvalue	 ci_lower	 ci_upper

  income	        2.455	      0.081	  30.29	    0.0	    2.322	    2.588

  income^2	     -0.445	      0.028	 -15.923	   0.0	   -0.491	   -0.399

CATE Intercept Results

         	        point_estimate	 stderr	  zstat	  pvalue	 ci_lower	 ci_upper

  cate_intercept	      -3.04	      0.049	 -61.695	   0.0	   -3.121	   -2.959

A linear parametric conditional average treatment effect (CATE) model was fitted:
$Y = \Theta(X)\cdot T + g(X, W) + \epsilon$
where for every outcome $i$ and treatment $j$ the CATE $\Theta_{ij}(X)$ has the form:
$\Theta_{ij}(X) = \phi(X)' coef_{ij} + cate\_intercept_{ij}$
where $\phi(X)$ is the output of the `featurizer` or $X$ if `featurizer`=None. Coefficient Results table portrays the $coef_{ij}$ parameter vector for each outcome $i$ and treatment $j$. Intercept Results table portrays the $cate\_intercept_{ij}$ parameter.

`LinearDML` estimator can also return the summary of the coefficients and intercept for the final model, including point estimates, p-values and confidence intervals. From the table above, we notice that $income$ has positive effect and ${income}^2$ has negative effect, and both of them are statistically significant.

## Nonparametric Heterogeneity
Since we already know the true treatment effect function is nonlinear, let us fit another model using `CausalForestDML`, which assumes a fully **nonparametric estimation of the treatment effect**.

```python
forestdml_estimate = model.estimate_effect(
    identified_estimand,
    method_name="backdoor.econml.dml.CausalForestDML",
    control_value=0,
    treatment_value=1,
    target_units=X_test_data.values,
    method_params={
        "init_params": {"model_y": GradientBoostingRegressor(), "model_t": GradientBoostingRegressor()},
        "fit_params": {"inference": "blb"},
    },
)
```

    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)

```python
est = forestdml_estimate._estimator_object
# Get treatment effect and its confidence interval
te_pred = est.effect(X_test).flatten()
te_pred_interval = est.effect_interval(X_test)
```

```python
# Compare the estimate and the truth
plt.figure(figsize=(10, 6))
plt.plot(X_test.flatten(), te_pred, label="Sales Elasticity Prediction")
plt.plot(X_test.flatten(), truth_te_estimate, "--", label="True Elasticity")
plt.fill_between(
    X_test.flatten(),
    te_pred_interval[0].flatten(),
    te_pred_interval[1].flatten(),
    alpha=0.2,
    label="90% Confidence Interval",
)
plt.fill_between(
    X_test.flatten(),
    truth_te_lower,
    truth_te_upper,
    alpha=0.2,
    label="True Elasticity Range",
)
plt.xlabel("Income")
plt.ylabel("Songs Sales Elasticity")
plt.title("Songs Sales Elasticity vs Income")
plt.legend(loc="lower right")
```

We notice that this model fits much better than the `LinearDML`, the 90% confidence interval correctly covers the true treatment effect estimate and captures the variation when income is around 1. Overall, the model shows that people with low income are much more sensitive to the price changes than higher income people.

# Test Estimate Robustness with DoWhy

### Add Random Common Cause

How robust are our estimates to adding another confounder? We use DoWhy to test this!

```python
res_random = model.refute_estimate(
    identified_estimand, forestdml_estimate, method_name="random_common_cause"
)
print(res_random)
```

    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age+w_random | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)

    Refute: Add a Random Common Cause
    Estimated effect:-0.9563038314759685
    New effect:-0.9560871094372129

### Add Unobserved Common Cause

How robust are our estimates to unobserved confounders? Since we assume the model is under unconfoundedness, adding an unobserved confounder might bias the estimates. We use DoWhy to test this!

```python
res_unobserved = model.refute_estimate(
    identified_estimand,
    forestdml_estimate,
    method_name="add_unobserved_common_cause",
    confounders_effect_on_treatment="linear",
    confounders_effect_on_outcome="linear",
    effect_strength_on_treatment=0.1,
    effect_strength_on_outcome=0.1,
)
print(res_unobserved)
```

    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)

    Refute: Add an Unobserved Common Cause
    Estimated effect:-0.9563038314759685
    New effect:0.21074843948454486

### Replace Treatment with a Random (Placebo) Variable

What happens our estimates if we replace the treatment variable with noise? Ideally, the average effect would be wildly different than our original estimate. We use DoWhy to investigate!

```python
res_placebo = model.refute_estimate(
    identified_estimand, forestdml_estimate,
    method_name="placebo_treatment_refuter", placebo_type="permute",
    num_simulations=3
)
print(res_placebo)
```

    INFO:dowhy.causal_refuters.placebo_treatment_refuter:Refutation over 3 simulated datasets of permute treatment
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~placebo+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~placebo+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~placebo+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    WARNING:dowhy.causal_refuters.placebo_treatment_refuter:We assume a Normal Distribution as the sample has less than 100 examples.
                     Note: The underlying distribution may not be Normal. We assume that it approaches normal with the increase in sample size.

    Refute: Use a Placebo Treatment
    Estimated effect:-0.9563038314759685
    New effect:-0.005047228545726441
    p value:0.2182136935000839

### Remove a Random Subset of the Data

Do we recover similar estimates on subsets of the data? This speaks to the ability of our chosen estimator to generalize well. We use DoWhy to investigate this!

```python
res_subset = model.refute_estimate(
    identified_estimand, forestdml_estimate,
    method_name="data_subset_refuter", subset_fraction=0.8,
    num_simulations=3)
print(res_subset)
```

    INFO:dowhy.causal_refuters.data_subset_refuter:Refutation over 0.8 simulated datasets of size 8000.0 each
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    INFO:dowhy.causal_estimator:INFO: Using EconML Estimator
    INFO:dowhy.causal_estimator:b: log_demand~log_price+friends_count+account_age+is_US+songs_purchased+avg_hours+days_visited+income+has_membership+age | income
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    C:\ProgramData\Anaconda3\lib\site-packages\sklearn\utils\validation.py:72: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(**kwargs)
    WARNING:dowhy.causal_refuters.data_subset_refuter:We assume a Normal Distribution as the sample has less than 100 examples.
                     Note: The underlying distribution may not be Normal. We assume that it approaches normal with the increase in sample size.

    Refute: Use a subset of data
    Estimated effect:-0.9563038314759685
    New effect:-0.960035389266903
    p value:0.005564053202185781

# Understand Treatment Effects with EconML
EconML includes interpretability tools to better understand treatment effects. Treatment effects can be complex, but oftentimes we are interested in simple rules that can differentiate between users who respond positively, users who remain neutral and users who respond negatively to the proposed changes.

The EconML `SingleTreeCateInterpreter` provides interperetability by training a single decision tree on the treatment effects outputted by the any of the EconML estimators. In the figure below we can see in dark red users respond strongly to the discount and the in white users respond lightly to the discount.

```python
intrp = SingleTreeCateInterpreter(include_model_uncertainty=True, max_depth=2, min_samples_leaf=10)
intrp.interpret(est, X_test)
plt.figure(figsize=(25, 5))
intrp.plot(feature_names=X.columns, fontsize=12)
```

# Make Policy Decision with EconML
We want to make policy decisions to maximum the **revenue** instead of the demand. In this scenario,

\begin{align}
Rev & = Y \cdot T \\
    & = \exp^{log(Y)} \cdot T\\
    & = \exp^{(\theta(X) \cdot log(T) + f(X,W) + \epsilon)} \cdot T \\
    & = \exp^{(f(X,W) + \epsilon)} \cdot T^{(\theta(X)+1)}
\end{align}

With the decrease of price, revenue will increase only if $\theta(X)+1<0$. Thus, we set `sample_treatment_cast=-1` here to learn **what kinds of customers we should give a small discount to maximum the revenue**.

The EconML library includes policy interpretability tools such as `SingleTreePolicyInterpreter` that take in a treatment cost and the treatment effects to learn simple rules about which customers to target profitably. In the figure below we can see the model recommends to give discount for people with income less than $0.985$ and give original price for the others.

```python
intrp = SingleTreePolicyInterpreter(risk_level=0.05, max_depth=2, min_samples_leaf=1, min_impurity_decrease=0.001)
intrp.interpret(est, X_test, sample_treatment_costs=-1, treatment_names=["Discount", "No-Discount"])
plt.figure(figsize=(25, 5))
intrp.plot(feature_names=X.columns, fontsize=12)
```

Now, let us compare our policy with other baseline policies! Our model says which customers to give a small discount to, and for this experiment, we will set a discount level of 10% for those users. Because the model is misspecified we would not expect good results with large discounts. Here, because we know the ground truth, we can evaluate the value of this policy.

```python
# define function to compute revenue
def revenue_fn(data, discount_level1, discount_level2, baseline_T, policy):
    policy_price = baseline_T * (1 - discount_level1) * policy + baseline_T * (1 - discount_level2) * (1 - policy)
    demand = demand_fn(data, policy_price)
    rev = demand * policy_price
    return rev
```

```python
policy_dic = {}
# our policy above
policy = intrp.treat(X)
policy_dic["Our Policy"] = np.mean(revenue_fn(train_data, 0, 0.1, 1, policy))

## previous strategy
policy_dic["Previous Strategy"] = np.mean(train_data["price"] * train_data["demand"])

## give everyone discount
policy_dic["Give Everyone Discount"] = np.mean(revenue_fn(train_data, 0.1, 0, 1, np.ones(len(X))))

## don't give discount
policy_dic["Give No One Discount"] = np.mean(revenue_fn(train_data, 0, 0.1, 1, np.ones(len(X))))

## follow our policy, but give -10% discount for the group doesn't recommend to give discount
policy_dic["Our Policy + Give Negative Discount for No-Discount Group"] = np.mean(revenue_fn(train_data, -0.1, 0.1, 1, policy))

## give everyone -10% discount
policy_dic["Give Everyone Negative Discount"] = np.mean(revenue_fn(train_data, -0.1, 0, 1, np.ones(len(X))))
```

```python
# get policy summary table
res = pd.DataFrame.from_dict(policy_dic, orient="index", columns=["Revenue"])
res["Rank"] = res["Revenue"].rank(ascending=False)
res
```

      Revenue
      Rank

      Our Policy
      14.686241
      2.0

      Previous Strategy
      14.349342
      4.0

      Give Everyone Discount
      13.774469
      6.0

      Give No One Discount
      14.294606
      5.0

      Our Policy + Give Negative Discount for No-Discount Group
      15.564411
      1.0

      Give Everyone Negative Discount
      14.612670
      3.0

**We beat the baseline policies!** Our policy gets the highest revenue except for the one raising the price for the No-Discount group. That means our currently baseline price is low, but the way we segment the user does help increase the revenue!

# Conclusions

In this notebook, we have demonstrated the power of using EconML and DoWhy to:

* Estimate the treatment effect correctly even the model is misspecified
* Test causal assumptions and investigate the robustness of the resulting estimates
* Interpret the resulting individual-level treatment effects
* Make the policy decision beats the previous and baseline policies

To learn more about what EconML can do for you, visit our [website](https://aka.ms/econml), our [GitHub page](https://github.com/microsoft/EconML) or our [docummentation](https://econml.azurewebsites.net/).

To learn more about what DoWhy can do for you, visit the [GitHub page](https://github.com/microsoft/dowhy) or [documentation](https://microsoft.github.io/dowhy/index.html).

notebooks/CustomerScenarios/Case Study - Customer Segmentation at An Online Media Company - EconML + DoWhy.ipynb


# Script to check various properties of the data

### 0. Initialise packages and data

```python
# Call packages
import numpy as np
import pandas as pd
import scipy.stats as stats
import matplotlib.pyplot as plt
from util.formatting.mid_price_from_orderbook import save_mid_price
from sklearn import preprocessing
```

```python
# Retrieve and save midprice using prebuilt util in ABIDES
# N.B. Inputs here are (file_loc, output_loc)
save_mid_price("log/20210312BTC_no_mm_zi_focused/ORDERBOOK_BTC_FREQ_T.bz2", "log/20210312BTC_no_mm_zi_focused/")
```

    C:\Users\chris\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\reshape\reshape.py:192: FutureWarning: SparseDataFrame is deprecated and will be removed in a future version.
    Use a regular DataFrame whose columns are SparseArrays instead.

    See http://pandas.pydata.org/pandas-docs/stable/user_guide/sparse.html#migrating for more.

      return self.constructor(values, index=index, columns=columns)
    Processing order book:   0%|          | 0/480 [00:00<?, ?it/s]C:\Users\chris\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\frame.py:909: FutureWarning: SparseSeries is deprecated and will be removed in a future version.
    Use a Series with sparse values instead.

        >>> series = pd.Series(pd.SparseArray(...))

    See http://pandas.pydata.org/pandas-docs/stable/user_guide/sparse.html#migrating for more.

      s = klass(v, index=columns, name=k)
    Processing order book: 100%|██████████| 480/480 [00:00<00:00, 654.83it/s]

```python
# Read midprice data
mid_price = pd.read_pickle("data/BTC.bz2")
sim_mid_price = pd.read_pickle("log/20210312BTC_no_mm_zi_focused/ORDERBOOK_BTC_FREQ_T_mid_price.bz2")
# clean to fit timeframea
starttime = pd.to_datetime('2021-03-12 09:00:00')
endtime = pd.to_datetime('2021-03-12 16:00:00')

mid_price = mid_price[(mid_price.index >= starttime) & (mid_price.index <= endtime)]

```

```python
# query data from exchange agent
df = pd.read_pickle("log/20210312BTC_no_mm_zi_focused/ExchangeAgent0.bz2")

# grab trade info from the agent
df = df[df['EventType'] == 'LAST_TRADE']
if len(df) <= 0:
    print("There appear to be no simulated trades.")
    sys.exit()
df['PRICE'] = [y for x,y in df['Event'].str.split(',')]
df['SIZE'] = [x for x,y in df['Event'].str.split(',')]
df['PRICE'] = df['PRICE'].str.replace('$','').astype('float64')
df['SIZE'] = df['SIZE'].astype('float64')

# populate trade info into order stream
sim_order_stream = pd.DataFrame({
                            "Price": df.PRICE,
                            "Size": df.SIZE,
                            "Time": df.index
})

# Minutely resampling and renaming columns.
df = df[["PRICE","SIZE"]].resample("1T")
df_open = df["PRICE"].first().ffill()
df_close = df["PRICE"].last().ffill()
df_high = df["PRICE"].max().ffill()
df_low = df["PRICE"].min().ffill()
df_vol = df["SIZE"].sum()
ohlcv = pd.DataFrame({
    "open": df_open,
    "high": df_high,
    "low": df_low,
    "close": df_close,
    "volume": df_vol
})

sim_orders = ohlcv[["open", "volume"]]
```

```python
# Read order stream
order_stream = pd.read_csv("data/20210312btcusd/order.csv")

# Convert unix time
order_stream.index = pd.to_datetime(order_stream.time ,unit='ms')
order_stream.time = pd.to_datetime(order_stream.time ,unit='ms')

# clean to fit timeframea
order_stream = order_stream[(order_stream.index >= starttime) & (order_stream.index <= endtime)]
```

```python
order_resample = order_stream[["price", "qty"]].resample("1T")

orders_p = order_resample["price"].first().ffill()
orders_q = order_resample["qty"].sum()

orders = pd.DataFrame({
        "price": orders_p,
        "qty": orders_q
})
```

## Stylised facts to check

Note the following:

\begin{equation}
m_t = \frac{a_t + b_t}{2}
\end{equation}

\begin{equation}
r_{t,\Delta t} = ln(m_{t + \Delta t}) - ln(m_t)
\end{equation}

The graphs will be presented in the same order as Vyetrenko et al

### 1. Heavy tails and aggregation normality (high kurtosis)

--*This looks ehh*--

Maybe due to the market maker

Maybe due to the high precision when querying historic price

Maybe due to insufficient count of technical agents

```python
# calculate returns
returns = np.array((mid_price[1:]))/np.array((mid_price[:-1]))
sim_returns = np.array((sim_mid_price[1:]))/np.array((sim_mid_price[:-1]))

# log values
log_returns = np.log(returns)
sim_log_returns = np.log(sim_returns)

# plot
plt.hist(log_returns, 100, range = (-0.01, 0.01), alpha=0.5)
plt.hist(sim_log_returns, 100, range = (-0.01, 0.01), alpha=0.5)
plt.show()

len(sim_returns)
```

### 2. Absence of autocorrelations in periods greater than 20 minutes

We will set the asset return times scale $(\Delta t)$ to 1 minute and period $\tau$ to 30 minutes.

\begin{equation}
corr(r_{t + \tau, \Delta t}, r_{t, \Delta t})
\end{equation}

The graph shows a 30 minute batches

-- *Need more samples* --

```python
# set correlation period and asset return interval

# check correlation period of upto an hour
corr_period = range(1, 61)
asset_return_interval = 1

# Convert price data to asset returns data
returns = mid_price.diff(periods=asset_return_interval)[asset_return_interval:]
sim_returns = sim_mid_price.diff(periods=asset_return_interval)[asset_return_interval:]

# convert to log returns
corr = []
sim_corr = []

# initialise
i = 0
j = 0
batch_len = 20

# Convert asset returns data to correlation over set periods
while i + batch_len*2 <= len(returns):

    corr.append(np.corrcoef(returns[i:i+batch_len], returns[i+batch_len:i+batch_len*2])[0, 1])
    i += 1

while j + batch_len*2 <= len(sim_returns):

    sim_corr.append(np.corrcoef(sim_returns[j:j+batch_len], sim_returns[j+batch_len:j+batch_len*2])[0, 1])
    j += 1

# plot
plt.hist(corr, 50, range=(-1, 1), label="actual", alpha=0.5)
plt.hist(sim_corr, 50, range=(-1, 1), label="simulation", alpha=0.5)
plt.legend()
plt.show()

```

### 3. Volatility clustering
We will set the asset return times scale $(\Delta t)$ to 1 minute and $\tau$ to between 1 minute and 60 min

\begin{equation}
corr(r_{t + \tau, \Delta t}^2, r_{t, \Delta t}^2)
\end{equation}

*--We need more data--*

```python
# set correlation period and asset return interval

# check correlation period of upto an hour
corr_period = range(1, 61)
asset_return_interval = 1

corr = []
sim_corr = []

# Convert price data to asset returns data
returns = mid_price.diff(periods=1)[i:]
sim_returns = sim_mid_price.diff(periods=1)[i:]

# Convert asset returns data to correlation over set periods
for i in corr_period:

    corr.append(np.corrcoef(returns[0:-1*i]**2, returns[i:]**2)[0, 1])
    sim_corr.append(np.corrcoef(sim_returns[0:-1*i]**2, sim_returns[i:]**2)[0, 1])

# Plot correlation
plt.plot(corr, label="Actual")
plt.plot(sim_corr, label="Simulation")

plt.legend()
```

### 4. Volume/volatility correlation

not so good. We need a way to find actual volume as opposed to LOB volume

```python
# 30 minutes of volume and volatility collected per observation
corr_len = 30

# 100 samples of 30 minute volume/volatility to calculate correlation
size = 100

# append the 100 samples of correlation in "corr" variable
corr = []
sample_corr = []

"""
for actual data
"""

# select number of samples with a predetermined size
for batch_end in range(size, len(orders)):

    # initialise for each batch
    volume = []
    volatility = []

    # find correlation for each batch by calculating volume and volatility
    for i in range(batch_end - size, batch_end - corr_len):

        volume.append(orders.qty[i:i+corr_len].sum())
        volatility.append(orders.price[i:i+corr_len].std())

    corr.append(np.corrcoef(volume, volatility)[0,1])

plt.hist(corr, 50, alpha=0.5, label='Actual')

"""
for simulated data
"""

# select number of samples with a predetermined size
for batch_end in range(size, len(sim_orders)):

    # initialise for each batch
    sim_volume = []
    sim_volatility = []

    # find correlation for each batch by calculating volume and volatility
    for i in range(batch_end - size, batch_end - corr_len):

        sim_volume.append(sim_orders.volume[i:i+corr_len].sum())
        sim_volatility.append(sim_orders.open[i:i+corr_len].std())

    sim_corr.append(np.corrcoef(sim_volume, sim_volatility)[0,1])

plt.hist(sim_corr, 50, alpha=0.5, label='Simulation')
plt.legend()
plt.show()
```

### 5. Number of orders in a fixed time window

Number of orders in a fixed time window is approximated by gamma/lognormal distribution

```python
# initialise
interval = 5 #5 minute interval
order_count = []
sim_order_count = []
```

```python
"""
Actual data
"""

# identifty rough starting time - probably to the nearest minute
start_time = order_stream.index[0].ceil("min")
end_time = sim_order_stream.index[-1].floor("min")
time_delta = end_time - start_time
total_minutes = int(time_delta.total_seconds()/60)

# find out number of orders in 5 minute interval
# loop this by shifting interval by 1 minute at a time
for i in range(total_minutes - interval):

    int_start = start_time + pd.Timedelta(minutes=i)
    int_end = start_time + pd.Timedelta(minutes=5+i)

    order_count.append(len(order_stream.index[(order_stream.index > int_start) & (order_stream.index < int_end)]))

```

```python
"""
Simulated data
"""

# identifty rough starting time - probably to the nearest minute
sim_start_time = sim_order_stream.Time[0].ceil("min")
sim_end_time = sim_order_stream.Time[-1].floor("min")
sim_time_delta = sim_end_time - sim_start_time
sim_total_minutes = int(sim_time_delta.total_seconds()/60)

# find out number of orders in 5 minute interval
# loop this by shifting interval by 1 minute at a time
for i in range(sim_total_minutes - interval):

    sim_int_start = sim_start_time + pd.Timedelta(minutes=i)
    sim_int_end = sim_start_time + pd.Timedelta(minutes=5+i)

    sim_order_count.append(len(sim_order_stream.index[(sim_order_stream.index > sim_int_start) & (sim_order_stream.index < sim_int_end)]))
```

```python
# plot histogram of results
plt.hist(order_count, 100, density=1, label="Actual")
#plt.hist(sim_order_count, 100, density=1, label="Simulation")
plt.legend()

# plot weibull distribution0
plt.plot(order_count, stats.gamma.pdf(order_count, *stats.gamma.fit(order_count, 100)))

plt.show()

#plt.plot(order_count, stats.exponweib.pdf(order_count, *stats.exponweib.fit(order_count, 1, 1, scale=2, loc=0)))

```

### 6. Order interarrival times

Order inter-arrival times are suggested to be fit into exponential, lognormal, and Weibull distributions

```python
inter_arrival = order_stream.time.diff(1)[1:].dt.total_seconds()
sim_inter_arrival = sim_order_stream.Time.diff(1)[1:].dt.microseconds*10e-6

plt.hist(inter_arrival, bins=100, log=True, label="Actual", alpha=0.5)
plt.hist(sim_inter_arrival, bins=100, log=True, label="Simulation", alpha=0.5)
plt.legend()
plt.show()
```

### 7. Time correlation of order flow

Even in the BTC market, we see a spike during US market open

```python

order_hist = order_stream.groupby([pd.Grouper(key = 'time', freq='1T')]).sum()
sim_order_hist = sim_order_stream.groupby([pd.Grouper(key = 'Time', freq='5T')]).sum()

#plt.scatter(order_hist.index, preprocessing.scale(order_hist.Size))

plt.figure(figsize=(20,10))
plt.scatter(order_hist.index, order_hist.qty)
plt.axvline(x = pd.to_datetime("2021-03-12 13:30:00"))

#plt.scatter(sim_order_hist.index, sim_order_hist.Size)

```

```python
start_time = pd.to_datetime("2019-06-28 09:30:00")
current_count = []
for i in range(385):

    count1 = (order_stream.index >= start_time)
    count2 =  (order_stream.index < start_time + pd.to_timedelta(5, "T"))

    current_count.append(np.sum(count1*count2))

    start_time += pd.to_timedelta(1, "T")

plt.hist(preprocessing.scale(current_count), 20)

```

```python

```

- Gain/Loss asymmetry (Can measure this, but only pronounced for the equity market)

```python

# set correlation period and asset return interval

# check correlation period of upto an hour
asset_return_interval = [1]#,2,5,10,15,20,30,45,60]
vol_period = 240

volume_set = []
volatility_set = []

# Convert price data to asset returns data
for i in asset_return_interval:

    returns = ohlcv.open.diff(periods=i)[i:]
    volume  = ohlcv.volume[i:]

    for j in range(len(volume) - vol_period):

        volume_set.append(np.sum(volume[j:j+vol_period]))
        volatility_set.append(np.std(returns[j:j+vol_period]))

    print(np.corrcoef(volume_set, volatility_set))

```

    [[1.         0.83385796]
     [0.83385796 1.        ]]

- Returns/volatility negative correlation

```python

# set correlation period and asset return interval

# check correlation period of upto an hour
asset_return_interval = 15#,2,5,10,15,20,30,45,60]

returns_set = []
volatility_set = []

# Convert price data to asset returns data
returns_set = ohlcv.open.diff(periods=asset_return_interval)[asset_return_interval:]

for i in range(len(ohlcv) - asset_return_interval):

    volatility_set.append(np.std(ohlcv.open[i:i+asset_return_interval]))

print(np.corrcoef(returns_set, volatility_set))

```

    [[1.         0.46637954]
     [0.46637954 1.        ]]

- Asymmetric causal information flow (not sure how to measure this)

- Best Bid/Ask distributed according to Gama distribution

```python

```

```python
asset_return_interval = range(1,60)
kurtosis = []

# Convert price data to asset returns data
#fig, ax = plt.subplots(nrows=len(asset_return_interval), ncols=1, figsize=(5, len(asset_return_interval)*5))
axis = 0

for i in asset_return_interval:

    # calculate asset returns
    returns = mid_price.diff(periods=i)[i:]

    """
    # plot
    ax[axis].hist(returns, 50)
    axis += 1
    """

    # record kurtosis
    kurtosis.append(stats.kurtosis(returns, fisher=False))

plt.plot(asset_return_interval, kurtosis)
plt.hlines(3, xmin=0, xmax=60)
```

- Long range dependence (not sure how to measure this)

- Time correlation of order flow

  *definitely more orders at the start and end of market*

```python

```

- Order interarrival times are exponential/lognormal/Weibull

  *clearly exponential here*

```python
interarrival_times = order_stream.index[1:] - order_stream.index[:-1]
int_sec = (interarrival_times.total_seconds())

plt.hist(int_sec, 1000)
```

- Number of orders in a fixed time window is approximated by gamma/lognormal distribution

  *this is not so promissing. Should be lognormal*

```python

```

- Intermittency (Can't really measure this)

- Order sizes are Power-law distributed
- New limit order prices are power-law distributed
- Lifetime of orders are power-law distributed
- Lower spreads during higher Volume