# Retain Customers with Time to Event Modeling Driven Intervention
## Pinpointing Customer Churn at the Right Time using XGBoost

It usually costs more to acquire a customer than it does to retain a customer.

Focusing on customer retention enables companies to maximize customer revenue over their lifetime.

This blog post will show you how to train a model to predict both the risk **and the time** of a customer attrition event using [XGBoost](https://github.com/dmlc/xgboost).

Combined with a [production-level end-to-end Machine Learning pipeline](https://towardsdatascience.com/deploying-a-scalable-end-to-end-customer-churn-prediction-solution-with-aws-cbf3536be996) like [Customer Churn Pipeline on AWS
](https://github.com/awslabs/aws-customer-churn-pipeline) that has time to event churn modeling baked in, this allows for timely interventions to stop attrition.

Customer attrition or simply [churn](https://en.wikipedia.org/wiki/Customer_attrition) is when a customer 'leaves' before maximal revenue is achieved. Stopping these events to retain revenue is so established that churn-based models are one of the very first machine learning solutions that go into production.

However, these models are seldom done optimally as they rely on binary classification flags (churn yes or no). Churn classification models do not tell **WHEN** a customer is likely to leave but only indicate that it’s going to happen within a certain number of days or months.

This blog post will present how to measure churn risk **during a customer's lifecycle** to find the **point in time** churn intervention is needed.

## Paradigm Shift: from Event Only to Timely Intervention

A good reason churn models could be better is that many are set on arbitrary time threshold for a fixed binary outcome.
This means that time is held constant!
For example, setting an arbitrary threshold that after 40 days of inactivity a customer churns.

Flagging customers based on such heuristics leads to slippage, mainly:

* Customers churning before the threshold.
* Customers that churn far, far after the threshold.
* Ignores customer lifetime differences

It is probably a mistake to treat a customer that is at risk of leaving in 40 days the same as a customer that remains for over a 100 days. Traditional churn modeling does not make this differentiation.

For example, in the chart below, Customer B is captured accurately by the model because they leave at the exact point in time that the threshold was set (40 days). Customer A actually churns after the threshold and they are lost because the model is not able to account for them. Customer C did the opposite, staying on far longer than the data time window. They will most likely churn but we cannot model for when with a classification model.

The only point in time here is the "within 40 days" threshold. As it fails to account for time, we have no clear idea at what point a marketing intervention is needed and it causes preventable customer attrition.

### Re-framing the Problem to Know *When*

Rather then use a binary classifier, we are going to re-frame the problem as time-dependent one. This enables us to intervene at the right time to stop customer attrition before it happens.

No longer relying on thresholds, we now set churn as continuous time conditioned event.

As the below graph shows, we now know the **time** that attrition **risk** is most likely to happen.

No longer is time held constant, we now track risk **over time** to determine when a marketing intervention is needed to retain the customer.

If we model for both the time and event, the right moment to intervene and prevent attrition is apparent.

A modeling technique called [Survival Analysis](https://en.wikipedia.org/wiki/Survival_analysis) allows for us to do this and with the advent of modern Machine Learning, it's now a trivial task.

A dive deep of Survival Analysis and the maths behind, is out scope and we encourage you to look at all the [great posts on Medium's Toward Data Science for more information](https://towardsdatascience.com/search?q=survival).

```python
%reload_ext autoreload
%autoreload 2
%matplotlib inline

import xgboost as xgb
import shap
import sksurv.metrics as surv_metrics
from sksurv.datasets import get_x_y
from lifelines import KaplanMeierFitter
from lifelines.plotting import plot_lifetimes
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt
from sklearn.compose import ColumnTransformer
from sklearn.exceptions import DataConversionWarning
from sklearn.impute import SimpleImputer
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

plt.rcParams['figure.figsize'] = [7.2, 4.8]
pd.set_option("display.float_format", lambda x: "%.4f" % x)

sns.set_style('darkgrid')

SEED = 123
```

## A Quick Glimpse at the Data

In this example, you will use a synthetic churn dataset for an imaginary telecommunications company with the outcome Churn? flagged as as either True (churned) or False (did not churn). Features include customer details such as plan and usage information. The churn dataset is publicly available and mentioned in the book [Discovering Knowledge in Data by Daniel T. Larose](https://www.amazon.com/dp/0470908742/). It is attributed by the author to the University of California Irvine Repository of Machine Learning Datasets.

```python
df = pd.read_csv("../../data/churn.txt")

# clean up the column names
new_col_names = [s.lower().replace(" ","_").replace("'","") for s in df.columns.tolist()]
df.columns = new_col_names

# remove id and set area code as categorical
del df['phone']
df['area_code'] = df['area_code'].astype('category')

# denoting churn and duration
df["event"] = np.where(df["churn?"] == "False.", 0, 1)
df = df.rename(columns={"account_length": "duration"})

del df['churn?']

df = df.dropna()
df = df.drop_duplicates()
```

```python
df.head()
```

      state
      duration
      area_code
      intl_plan
      vmail_plan
      vmail_message
      day_mins
      day_calls
      day_charge
      eve_mins
      eve_calls
      eve_charge
      night_mins
      night_calls
      night_charge
      intl_mins
      intl_calls
      intl_charge
      custserv_calls
      event

      0
      PA
      163
      806
      no
      yes
      300
      8.1622
      3
      7.5792
      3.9330
      4
      6.5086
      4.0658
      100
      5.1116
      4.9282
      6
      5.6732
      3
      1

      1
      SC
      15
      836
      yes
      no
      0
      10.0190
      4
      4.2263
      2.3250
      0
      9.9726
      7.1410
      200
      6.4362
      3.2217
      6
      2.5597
      8
      0

      2
      MO
      131
      777
      no
      yes
      300
      4.7085
      3
      4.7682
      4.5375
      3
      4.5667
      5.3632
      100
      5.1425
      7.1390
      2
      6.2542
      4
      0

      3
      WY
      75
      878
      yes
      yes
      700
      1.2687
      3
      2.5676
      2.5287
      5
      2.3336
      3.7736
      450
      3.8144
      2.2458
      6
      1.0807
      6
      0

      4
      WY
      146
      878
      yes
      no
      0
      2.6962
      3
      5.9089
      6.0153
      3
      3.6704
      3.7517
      250
      2.7968
      6.9055
      4
      7.1343
      6
      1

Examining our targets further shows that, there are a total of 5,000 records of which 49.9% end up churning.

The dataset is balanced on the target. In real world data, this is not always the case and a churn event could be 1% out of millions records.

There's strategies to remedy that but it's out of scope for this blog post.

Looking at the duration, represented as the Account Length (our time component), it shows that the median time is 102 days, which is close to the average of 101 days.

```python
print("Total Records:",df.shape[0],"\n")
print("Percent Churn Rate:",df.event.mean())
print("")
print("Duration Intervals")
print(df['duration'].describe())
```

    Total Records: 5000

    Percent Churn Rate: 0.4996

    Duration Intervals
    count   5000.0000
    mean     101.6758
    std       57.5968
    min        1.0000
    25%       52.0000
    50%      102.0000
    75%      151.0000
    max      200.0000
    Name: duration, dtype: float64

For Survival models data is different form traditional classification problem and requires:

* A Censor - For our purposes these are customers who've yet to churn. [Read about right censoring here](https://en.wikipedia.org/wiki/Survival_analysis#Censoring).
* Duration - The duration or time $t$ of the customer's activity. In this case, it's `Account Length` in days.
* Event - The binary target, in this case if they terminated their phone plan marked by `Churn?`.

We can plot the first 10 customers on timeline to understand how [right censored](https://en.wikipedia.org/wiki/Censoring_(statistics)#:~:text=Right%20censoring%20%E2%80%93%20a%20data%20point,subjects%20remaining%20are%20right%2Dcensored.) data works and the problem is framed.

```python
ax = plot_lifetimes(df.head(10)['duration'], df.head(10)['event'])

_=ax.set_xlabel("Duration: Account Length (days)")
_=ax.set_ylabel("Customer Number")
_=ax.set_title("Observed Customer Attrition")
```

In the above plot, the red lines indicates when a customer has left with the dots indicating the specific point in time.
Blue lines are customers that are still active up to the time measured on the x-axis in Duration.

Here we see that customer number 8 did not attrit until up to 195 days, with customer numbers 0 and 4 leaving in 163 and 146 days respectively.  All other customers are still active.

Notice how all customers are set on the same time scale because the data is **analytically aligned**.

Each customer might have come in at different times but we've set the days as the same. This is what allowed us to right-censor the data on the churn event.

Real world data needs both censoring and aligning before modeling can begin.

### The Risk of Churn

A more informative approach might be to estimate the [Survival Function](https://en.wikipedia.org/wiki/Survival_function) or the time in days a customer has until they attrit.

For this purpose, we will use a [Kaplan Meier Estimator](http://en.wikipedia.org/wiki/Kaplan%E2%80%93Meier_estimator) to calculate how long until attrition occurs.

The estimator is defined as:

$$\hat{S}(t) = \prod_{t_i \lt t} \frac{n_i - d_i}{n_i}$$

Where 𝑑𝑖 are the number of churn events at time 𝑡 and 𝑛𝑖 is the number of customers at risk of churn just prior to time 𝑡.

We will use the great [python package lifelines](https://github.com/CamDavidsonPilon/lifelines) to do plot the Survival Function as the function is a component of the final churn model.

```python
kmf = KaplanMeierFitter()

kmf.fit(df['duration'], event_observed=df['event'])

kmf.plot_survival_function()
_=plt.title('Survival Function for Telco Churn');
_=plt.xlabel("Duration: Account Length (days)")
_=plt.ylabel("Churn Risk (Percent Churned)")
_=plt.axvline(x=kmf.median_survival_time_, color='r', linestyle='--')
```

Let's look at the median survival time. This is the point by which half of customers have churned out.

According to this graph, where it's marked by the red dotted line, by about 152 days half of customers churn.

This is helpful because it gives *overall* baseline when intervention is needed.

However, for each individual customer this is uninformative.

What is missing is **the point in time** in which churn risk is highest for *each* customer.

For that we will create a model using [Cox's Proportional Hazard](https://en.wikipedia.org/wiki/Survival_analysis#Cox_proportional_hazards_(PH)_regression_analysis) which uses a log-risk function $h(x)$.

The Hazard function is conditioned on rate of a customers remaining until time t or later, this allows to estimate the risk of churn **overtime**.

This will enable us to score **each customer** and anticipate when a marketing intervention is needed.

However, before we proceed to that, we need to preprocess the data.

## Data Splitting and Preprocessing

First we will split the data into training and testing. We'll use the testing set as the validation for the example.
In practice, you want all three of these splits so that you don't tune to the validation set.

Next, we take the numeric features and categorical features and then preprocess them for downstream modeling.

In the case of categories, we will first impute with the constant and then simply one-hot encode them.

In the case of numerics, we will fill with the median then standardize them between values of 0 and 1.

This is all wrapped into Sklearn's [Pipeline](https://scikit-learn.org/stable/modules/generated/sklearn.pipeline.Pipeline.html) and [ColumnTransformer](https://scikit-learn.org/stable/modules/generated/sklearn.compose.ColumnTransformer.html?highlight=columntransformer#sklearn.compose.ColumnTransformer) for simplicity's sake.

As part of the [Churn Pipeline](https://github.com/awslabs/aws-customer-churn-pipeline) all these steps are included with the final preprocessor saved for use at inference time.

```python
df_train, df_test = train_test_split(df , test_size=0.20, random_state=SEED)
```

```python
numerical_idx = (
        df_train.select_dtypes(exclude=["object", "category"])
        .drop(['event','duration'],1)
        .columns.tolist()
    )

categorical_idx = df_train.select_dtypes(exclude=["float", "int"]).columns.tolist()

numeric_transformer = Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler()),
        ]
    )

categorical_transformer = Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="constant", fill_value="missing")),
            ("onehot", OneHotEncoder(sparse=False, handle_unknown="ignore")),
        ]
    )

preprocessor = ColumnTransformer(
        [
            ("numerical", numeric_transformer, numerical_idx),
            ("categorical", categorical_transformer, categorical_idx),
        ],
        remainder="passthrough",
    )

train_features = preprocessor.fit_transform(df_train.drop(['event','duration'],1))
test_features = preprocessor.transform(df_test.drop(['event','duration'],1))
```

### Converting the Traget for XGBoost

We will use the [DMatrix](https://xgboost.readthedocs.io/en/latest/python/python_api.html#xgboost.DMatrix) format for XGBoost to run through the regular, non-scikit API.

In the case of using the survival function - this requires a transformation of the predictor.

For vanilla XGBoost this entails setting duration as the target and then making it positive for events and negative for non-events.

Rather than having a tuple of event and duration, this gives your positive/negative single continuous variable as the target.

Lastly extract feature names from our pipeline for later use in model explainability.

For more about how Survival works in XGBoost see this [tutorial](https://xgboost.readthedocs.io/en/latest/tutorials/aft_survival_analysis.html).

```python
def survival_y_cox(dframe:pd.DataFrame) -> np.array:
    """Returns array of outcome encoded for XGB"""
    y_survival = []

    for idx, row in dframe[["duration", "event"]].iterrows():
        if row["event"]:
            # uncensored
            y_survival.append(int(row["duration"]))
        else:
            # right censored
            y_survival.append(-int(row["duration"]))
    return np.array(y_survival)
```

```python
feature_names = np.hstack((np.array(numerical_idx),
                           preprocessor.transformers_[1][1]['onehot'].get_feature_names()))

dm_train = xgb.DMatrix(
    train_features, label=survival_y_cox(df_train), feature_names=feature_names
)

dm_test = xgb.DMatrix(
    test_features, label=survival_y_cox(df_test), feature_names=feature_names
)
```

## More on the Hazard Function

The hazard function provides customer attrition **risk** - telling us when churn is most likely to happen.

Whereas the Survival function S(t) returns the probability of churn beyond a point of time $S(t) = P(T > t)\$, the Hazard function h(t) instead gives an approximate probability of that customer stays up to the time t such that:

$$h(t) = \lim_{\Delta t \rightarrow 0} \frac{P(t \leq T < t + \Delta t \mid T \geq t)}{\Delta t} \geq 0 .\$$

On a side note, with the Hazard function, it is also possible to get the survival function because:

$\begin{equation*}
S(t) = \exp\left(- \int_{0}^{t} h(u) du \right)
\end{equation*}$

## Gradient Boosting and Cox’s Partial Likelihood

In the case of Gradient Boosting multiple base learners are combined to obtain boosted overall ensemble of learners defined as an additive model of:

$\begin{equation}
f(\mathbf{x}) = \sum_{m=1}^M \beta_m g(\mathbf{x}; {\theta}_m),
\end{equation}$

For the Survival analysis case, the objective is to maximize the log partial likelihood function, but replacing the traditional linear model $f(x)$ with the additive model:

$\begin{equation}
  \arg \min_{f} \quad \sum_{i=1}^n \delta_i \left[ f(\mathbf{x}_i)
- \log \left( \sum_{j \in \mathcal{R}_i} \exp(f(\mathbf{x}_j)) \right) \right]
\end{equation}$

For more on this see this [excellent tutorial for python library Scikit-Survival](https://scikit-survival.readthedocs.io/en/latest/user_guide/boosting.html).

The normal parameters all apply here with the exception that we've changed the `objective` to `surivial:cox` this will allow for training a boosted survival tree.

```python
params = {
    "eta": 0.1,
    "max_depth": 3,
    "objective": "survival:cox",
    "tree_method": "hist",
    "subsample": 0.8,
    "seed": 123
}
bst = xgb.train(
    params,
    dm_train,
    num_boost_round=300,
    evals=[(dm_train, "train"), (dm_test, "test")],
    verbose_eval=int(1e1),
    early_stopping_rounds=10
)
```

    [0]	train-cox-nloglik:7.25501	test-cox-nloglik:5.86755
    [10]	train-cox-nloglik:7.00306	test-cox-nloglik:5.59962
    [20]	train-cox-nloglik:6.91366	test-cox-nloglik:5.49806
    [30]	train-cox-nloglik:6.86444	test-cox-nloglik:5.44991
    [40]	train-cox-nloglik:6.83705	test-cox-nloglik:5.43687
    [50]	train-cox-nloglik:6.81648	test-cox-nloglik:5.42424
    [60]	train-cox-nloglik:6.79757	test-cox-nloglik:5.42385
    [70]	train-cox-nloglik:6.78127	test-cox-nloglik:5.41936
    [80]	train-cox-nloglik:6.75929	test-cox-nloglik:5.41047
    [90]	train-cox-nloglik:6.74249	test-cox-nloglik:5.40701
    [100]	train-cox-nloglik:6.73142	test-cox-nloglik:5.40504
    [110]	train-cox-nloglik:6.71863	test-cox-nloglik:5.40143
    [120]	train-cox-nloglik:6.70495	test-cox-nloglik:5.39802
    [130]	train-cox-nloglik:6.69283	test-cox-nloglik:5.39483
    [140]	train-cox-nloglik:6.68125	test-cox-nloglik:5.39092
    [150]	train-cox-nloglik:6.67124	test-cox-nloglik:5.39253
    [151]	train-cox-nloglik:6.67063	test-cox-nloglik:5.39344

### A Note On Predictions

Predictions for this model are are returned on the hazard ratio scale.

(i.e., as HR = exp(marginal_prediction) in the proportional hazard function h(t) = h0(t) * HR).

This means that the output can come out either as the exponentiated marginal prediction or as the non-exponentiated version.

For predicting when churn is most likely to occur we will want the exponentiated version as it intuits to a probability (even though it's not really one technically speaking).

For more on this see [how output happens see](https://datascience.stackexchange.com/questions/52097/what-is-the-outcome-of-a-cox-regression-in-xgboost).

```python
%%capture

df_test.loc[:,"preds"] = bst.predict(dm_test, output_margin=True)
df_test.loc[:, "preds_exp"] = bst.predict(dm_test, output_margin=False)
```

We can then take the scores Hazard scores or the probability of churn conditioned on time $t$ (in this case Account Length), and look at overall when churn is most likely to occur.

Bucketing the values into time periods shows that the highest churn risk happens at days 53 to 62.

Following this period, the most likely times for attrition are at days 80 to 102.

In practice, you should ignore the final bar from days 191 to 200 as this is the point of truncation.

```python
_=df_test.groupby(pd.qcut(df_test['duration'], q=20))['preds_exp'].median().plot(kind="bar")
```

# Evaluating Performance

In the case of Surivavl Models [Harrell’s Concordance Index](https://statisticaloddsandends.wordpress.com/2019/10/26/what-is-harrells-c-index/) and [Brier Score](https://en.wikipedia.org/wiki/Brier_score) are commonly used to evaluate survival models.

### Harrell’s Concordance Index (Harrell et al. 1982)

The **concordance index** or **C-index** is a generalization of the [area under the ROC curve (AUC)](https://en.wikipedia.org/wiki/Receiver_operating_characteristic) that can take into account censored data.

It should be thought of as a goodness of fit measure for models which baselines the model’s ability to correctly provide a reliable ranking of the survival times based on the individual risk scores.

**Values of *C = 0.5* indicate that risk scores are no better than a coin flip.**

This is expressed as as the # of concordiant pairs / (# of concordiant pairs + # of discordiant pairs) or:

$$\begin{equation}
    \text{C-index} = \frac{ \sum_{i, j} \mathbb{1}_{T_j < T_i} \cdot \mathbb{1}_{\eta_j > \eta_i} \cdot \delta_j }{\sum_{i, j} \mathbb{1}_{T_j < T_i}\cdot \delta_j }
\end{equation}$$

with:
$\eta_i$, the risk score of a unit $i$
$\mathbb{1}_{ T_j < T_i } = 1$ if $T_j < T_i$ else $0$
$\mathbb{1}_{ \eta_j > \eta_i } =  1$ if  $\eta_j > \eta_i$ else $0$

### Brier Score (Brier 1950)

The Brier score is used to evaluate the accuracy of a predicted survival function at a given time $t$ it represents the average squared distances between the observed survival status and the predicted survival probability and is always a number between 0 and 1, with 0 being the best possible value.

**The lower the Brier score is for a set of predictions, the better the predictions are calibrated.**
**An acceptable baseline is 0.25**

Given a dataset of N samples,  $$\forall i \in  [\![1, N ]\!],  \left(\vec{x}_i, \delta_i, T_i \right)$$ is the format of a datapoint, and the predicted survival function is $$\hat{S}(t, \vec{x}_i), \forall t \in \mathbb{R^+}$$

In the absence of right censoring, the Brier score can be calculated such that:
        $$\begin{equation}
            BS(t) = \frac{1}{N} \sum_{i = 1}^{N} (\mathbb{1}_{ T_i > t } - \hat{S}(t, \vec{x}_i))^2
        \end{equation}$$

However, if the dataset contains samples that are right censored, then it is necessary to adjust the score by weighting.

That's where [Scikit-Survival's Brier Score metric](https://scikit-survival.readthedocs.io/en/stable/api/generated/sksurv.metrics.brier_score.html) comes to the rescue.

As stated in their docs, the time-dependent Brier score is the mean squared error at time point t

$\mathrm{BS}^c(t) = \frac{1}{n} \sum_{i=1}^n I(y_i \leq t \land \delta_i = 1)
\frac{(0 - \hat{\pi}(t | \mathbf{x}_i))^2}{\hat{G}(y_i)} + I(y_i > t)
\frac{(1 - \hat{\pi}(t | \mathbf{x}_i))^2}{\hat{G}(t)}$

where $\hat{\pi}(t | \mathbf{x})$ is the predicted probability of
remaining event-free up to time point $t$ for a feature vector $x$,
and $1/\hat{G}(t)$ is an inverse probability of censoring weight, estimated by
the Kaplan-Meier estimator.

That means the measurement is now adjusted for right censored data, and is thus more accurate.

Let's score the model!

```python
_, y_train = get_x_y(df_train,['event','duration'],pos_label=True)
_, y_test = get_x_y(df_test,['event','duration'],pos_label=True)

del _

print("CIC")
print(
    surv_metrics.concordance_index_ipcw(
       y_train,
       y_test,
      df_test['preds'],
        tau=100 # within 100 days
    )
)

print("")
print("Brier Score")

times, score = surv_metrics.brier_score(
y_train,y_test, df_test['preds'], df_test['duration'].max() - 1
)
print(score)

```

    CIC
    (0.7514910949902487, 177342, 58706, 0, 1218)

    Brier Score
    [0.37630957]

These results are okay given that the data was not prepared with survival analysis in mind :)

The Concordance Index comes out to 0.74 which is better than simple random chance.

The Brier Score is 0.33 which is not so great. Ideally, we'd want it to be 0.25 or lower as the [Pysurvival documentation indicates](https://square.github.io/pysurvival/metrics/brier_score.html).

There are other ways to evaluate this further.

For example, the [Scikit-survival](https://github.com/sebp/scikit-survival) package offers a wide variety of metrics for evaluation such as the [Time dependent Area under the ROC](https://scikit-survival.readthedocs.io/en/stable/user_guide/evaluating-survival-models.html#Time-dependent-Area-under-the-ROC) and more.

# Model Explainability with SHAP

The good news is that [SHAP (SHapley Additive exPlanations](https://github.com/slundberg/shap)  provides a high-speed exact algorithm for tree-based ensembles to help explain feature importance within the model.

Specifically, it will allow for use to understand what raises and lowers attrition risk for customers.

```python
explainer = shap.TreeExplainer(bst, feature_names=feature_names)
shap_values = explainer.shap_values(test_features)

shap.summary_plot(shap_values, pd.DataFrame(test_features, columns=feature_names))
```

Moreover, we now have an explanation at the customer level of how each churn risk score is calculated.
This can help inform intervention strategies choices. For example, night charges, night calls and evening minutes all drive customer attrition. Adding in the daily minutes (as a positive predictor) shows that clearly customers making calls in the evening or at night are at higher risk. This could go into an intervention strategy by setting intervention communications to go out to at risk customers during the night and evening.

This provides details that are easy to show for business users and ways in which to further analyze what features drive customer attrition.

```python
idx_sample = 128
shap.force_plot(
    explainer.expected_value,
    shap_values[idx_sample, :],
    pd.DataFrame(test_features, columns=feature_names).iloc[idx_sample, :],
    matplotlib=True,
)

print(f"The real label is Churn={y_test[idx_sample][0]}")
```

Lastly, since this is a tree-based model we can also plot what the tree(s) look like.

Though we set up to 100 trees to train, early stopping set in and the best iteration for the run was 67 trees.

Let's just take the first tree and look at it's splits to judge to understand how inference is run.

```python
xgb.plot_tree(bst, rankdir="LR", num_trees=0)
fig = plt.gcf()
fig.set_size_inches(150, 100)
fig.savefig("tree.png", bbox_inches="tight")
```

Unsurprisingly, the most predictive features are the ones that it split on first.

In this case, that means night charge, night calls, day minutes and evening minutes.

Obviously, this tree also is a great way to demonstrate what your model is doing for business users to understand.

# The Final Check

For our final check we will treat the problem as a classifier just because we can.

Since the model itself is conditioned on time, these aren't really good metrics to evaluate how *technically* well it's doing.

We are simply running this for the naysayers and to show that it works.

```python
from sklearn import metrics

y_preds = df_test.preds.apply(lambda x : np.exp(x))
y_pred = np.where(y_preds > 0.5, 1, 0)

print(f"Accuracy score: {metrics.accuracy_score(df_test.event, y_pred)}")
print(f"Area Under the Curve {metrics.roc_auc_score(df_test.event, y_pred)}")
print("")
print(metrics.classification_report(df_test.event, y_pred))
```

    Accuracy score: 0.932
    Area Under the Curve 0.9339674490815218

                  precision    recall  f1-score   support

               0       0.97      0.90      0.93       527
               1       0.89      0.97      0.93       473

        accuracy                           0.93      1000
       macro avg       0.93      0.93      0.93      1000
    weighted avg       0.94      0.93      0.93      1000

Amazingly, the model gets better results then the [reported 86% accuracy when this dataset first appeared in the blogosphere in 2017](https://aws.amazon.com/blogs/machine-learning/predicting-customer-churn-with-amazon-machine-learning/).

Not only do we have accuracy but we can identify **WHEN**.
This allows for timely marketing intervention to retain customers.

## Conclusion

This blog post showed how to train a churn model with a time component.

Using the Survival Analysis with Cox Proportional Hazard allows us circumvent customer attrition by pinpointing when it's risk is the highest.

This allows for a proactive, point in time intervention to stop customers from leaving.

Now that you have model a it's time to work on productionizing like with the [Customer Churn Pipeline on AWS](https://github.com/awslabs/aws-customer-churn-pipeline) which includes a template for Time to Event modeling for churn.

Once the pipeline is configured, you can run inference on records over time accumulating scores with batch inference to make intervention decisions.

```python
scores = np.random.normal(size=30).reshape(3,10)
scores_df = pd.DataFrame(scores
                         , index=['Customer A', 'Customer B',' Customer C']
                         , columns=[10,20,30,40,50,60,70,80,90,100])
scores_df
```

      10
      20
      30
      40
      50
      60
      70
      80
      90
      100

      Customer A
      1.0816
      -0.2466
      0.9721
      0.1023
      -0.9279
      -0.3164
      -1.2483
      -0.0722
      1.1304
      2.0311

      Customer B
      -0.7480
      -1.6873
      3.0243
      0.4243
      -0.3449
      0.6505
      0.7276
      -0.4460
      0.6338
      -1.2768

      Customer C
      0.0329
      0.0069
      1.4743
      -0.1613
      -1.6458
      1.0538
      0.4379
      0.0361
      -2.1493
      -0.7862

(The above show a table accumulated results of batch jobs by customer over days. There is now a churn risk history of the customer to monitor and flag when to intervene)

The use of Machine Learning for Survival Analysis is a great way to frame problems such as churn.
We encourage you to look at the references links below for all the different techniques that are available.

## References

[Lifelines, Cameron Davidson-Pilon](https://github.com/CamDavidsonPilon/lifelines)

[XGBoost: A Scalable Tree Boosting System, Chen & Guestrin 2016](https://github.com/dmlc/xgboost)

[PySurvival: Open source package for Survival Analysis modeling, Fotso 2019](https://github.com/square/pysurvival/)

[scikit-survival: A Library for Time-to-Event Analysis Built on Top of scikit-learn, Sebastian Polsterl 2020](https://github.com/sebp/scikit-survival)

[SHAP (SHapley Additive exPlanations), Lundberg 2017](https://github.com/slundberg/shap)

```python

```


```
!pip install qiskit
from qiskit import Aer, execute, QuantumCircuit
from qiskit.visualization import plot_histogram
```

    Collecting qiskit
      Downloading https://files.pythonhosted.org/packages/60/ac/bb82ae1ad427023368147343c4e24a0f33c02f79098f089d76cafce8348e/qiskit-0.20.0.tar.gz
    Collecting qiskit-terra==0.15.1
      Downloading https://files.pythonhosted.org/packages/82/de/3fa11306015106790d4cbd9da8df4f890e2605b19365c1c2b5ef206fe845/qiskit_terra-0.15.1-cp36-cp36m-manylinux2010_x86_64.whl (8.3MB)
         |████████████████████████████████| 8.3MB 4.4MB/s
    Collecting qiskit-aer==0.6.1
      Downloading https://files.pythonhosted.org/packages/e8/76/3a52f423f9ac5adbc4353105e21b5dae3d4f8729f9baa4c6788aa7fff33c/qiskit_aer-0.6.1-cp36-cp36m-manylinux2010_x86_64.whl (16.5MB)
         |████████████████████████████████| 16.5MB 202kB/s
    Collecting qiskit-ibmq-provider==0.8.0
      Downloading https://files.pythonhosted.org/packages/26/b1/4f0bdde330136f74c84b7313c36109e4c61062321df0e5252852bf123618/qiskit_ibmq_provider-0.8.0-py3-none-any.whl (161kB)
         |████████████████████████████████| 163kB 47.7MB/s
    Collecting qiskit-ignis==0.4.0
      Downloading https://files.pythonhosted.org/packages/d9/46/cad273535d470ed85378abcc757b1a26aa082dff91c8e273b212399c75ed/qiskit_ignis-0.4.0-py3-none-any.whl (172kB)
         |████████████████████████████████| 174kB 48.4MB/s
    Collecting qiskit-aqua==0.7.5
      Downloading https://files.pythonhosted.org/packages/80/86/3ba6bcfa241563c8ea82c2f48f2a9157d5b7e268896969ab49ca761b857e/qiskit_aqua-0.7.5-py3-none-any.whl (1.9MB)
         |████████████████████████████████| 1.9MB 45.8MB/s
    Requirement already satisfied: scipy>=1.4 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.1->qiskit) (1.4.1)
    Requirement already satisfied: python-dateutil>=2.8.0 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.1->qiskit) (2.8.1)
    Requirement already satisfied: psutil>=5 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.1->qiskit) (5.4.8)
    Requirement already satisfied: numpy>=1.17 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.1->qiskit) (1.18.5)
    Requirement already satisfied: jsonschema>=2.6 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.1->qiskit) (2.6.0)
    Collecting sympy>=1.3
      Downloading https://files.pythonhosted.org/packages/e0/1f/8cbbf698e853019ac3dc5d60ca8f6be4ace4542b2f05f7b62949617fc98e/sympy-1.6.2-py3-none-any.whl (5.8MB)
         |████████████████████████████████| 5.8MB 34.3MB/s
    Requirement already satisfied: dill>=0.3 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.1->qiskit) (0.3.2)
    Collecting contextvars>=2.4; python_version < "3.7"
      Downloading https://files.pythonhosted.org/packages/83/96/55b82d9f13763be9d672622e1b8106c85acb83edd7cc2fa5bc67cd9877e9/contextvars-2.4.tar.gz
    Collecting python-constraint>=1.4
      Downloading https://files.pythonhosted.org/packages/37/8b/5f1bc2734ca611943e1d6733ee244238679f6410a10cd45ede55a61a8402/python-constraint-1.4.0.tar.bz2
    Requirement already satisfied: networkx>=2.2; python_version > "3.5" in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.1->qiskit) (2.5)
    Collecting fastjsonschema>=2.10
      Downloading https://files.pythonhosted.org/packages/c7/47/e860ab658dc2da844e6fdb76b12f6b3e14fbcb62fdd04f3e1c572bca20e1/fastjsonschema-2.14.5-py3-none-any.whl
    Collecting ply>=3.10
      Downloading https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl (49kB)
         |████████████████████████████████| 51kB 6.2MB/s
    Collecting retworkx>=0.4.0
      Downloading https://files.pythonhosted.org/packages/6c/80/d8b6add5142ed20ad822b2cadb30bb3cfe57e34ff73a0b56cf75e8343c71/retworkx-0.4.0-cp36-cp36m-manylinux2010_x86_64.whl (956kB)
         |████████████████████████████████| 962kB 41.1MB/s
    Requirement already satisfied: cython>=0.27.1 in /usr/local/lib/python3.6/dist-packages (from qiskit-aer==0.6.1->qiskit) (0.29.21)
    Collecting pybind11>=2.4
      Downloading https://files.pythonhosted.org/packages/89/e3/d576f6f02bc75bacbc3d42494e8f1d063c95617d86648dba243c2cb3963e/pybind11-2.5.0-py2.py3-none-any.whl (296kB)
         |████████████████████████████████| 296kB 42.7MB/s
    Collecting nest-asyncio!=1.1.0,>=1.0.0
      Downloading https://files.pythonhosted.org/packages/a0/44/f2983c5be9803b08f89380229997e92c4bdd7a4a510ccee565b599d1bdc8/nest_asyncio-1.4.0-py3-none-any.whl
    Collecting requests-ntlm>=1.1.0
      Downloading https://files.pythonhosted.org/packages/03/4b/8b9a1afde8072c4d5710d9fa91433d504325821b038e00237dc8d6d833dc/requests_ntlm-1.1.0-py2.py3-none-any.whl
    Requirement already satisfied: urllib3>=1.21.1 in /usr/local/lib/python3.6/dist-packages (from qiskit-ibmq-provider==0.8.0->qiskit) (1.24.3)
    Requirement already satisfied: requests>=2.19 in /usr/local/lib/python3.6/dist-packages (from qiskit-ibmq-provider==0.8.0->qiskit) (2.23.0)
    Collecting websockets<8,>=7; python_version <= "3.6"
      Downloading https://files.pythonhosted.org/packages/43/71/8bfa882b9c502c36e5c9ef6732969533670d2b039cbf95a82ced8f762b80/websockets-7.0-cp36-cp36m-manylinux1_x86_64.whl (63kB)
         |████████████████████████████████| 71kB 9.6MB/s
    Requirement already satisfied: scikit-learn>=0.17 in /usr/local/lib/python3.6/dist-packages (from qiskit-ignis==0.4.0->qiskit) (0.22.2.post1)
    Requirement already satisfied: setuptools>=40.1.0 in /usr/local/lib/python3.6/dist-packages (from qiskit-ignis==0.4.0->qiskit) (49.6.0)
    Collecting dlx
      Downloading https://files.pythonhosted.org/packages/54/c0/b8fb5bb727e983b6f5251433ef941b48f38c65bb0bd6ec509e9185bcd406/dlx-1.0.4.tar.gz
    Requirement already satisfied: h5py in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (2.10.0)
    Collecting quandl
      Downloading https://files.pythonhosted.org/packages/1b/29/185269dbd2e2698c8098b35c52ce73a2c52cf76163e709f9f7789d03ebbb/Quandl-3.5.2-py2.py3-none-any.whl
    Collecting docplex
      Downloading https://files.pythonhosted.org/packages/9b/76/ebf5b75a25c4357c9a4758cae7fea778dd7f282b83c2f22e150dc7e3c852/docplex-2.15.194.tar.gz (582kB)
         |████████████████████████████████| 583kB 36.7MB/s
    Requirement already satisfied: fastdtw in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (0.3.4)
    Collecting yfinance
      Downloading https://files.pythonhosted.org/packages/c2/31/8b374a12b90def92a4e27d0fc595fc43635f395984e36a075244d98bd265/yfinance-0.1.54.tar.gz
    Requirement already satisfied: pandas in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (1.0.5)
    Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.6/dist-packages (from python-dateutil>=2.8.0->qiskit-terra==0.15.1->qiskit) (1.15.0)
    Requirement already satisfied: mpmath>=0.19 in /usr/local/lib/python3.6/dist-packages (from sympy>=1.3->qiskit-terra==0.15.1->qiskit) (1.1.0)
    Collecting immutables>=0.9
      Downloading https://files.pythonhosted.org/packages/99/e0/ea6fd4697120327d26773b5a84853f897a68e33d3f9376b00a8ff96e4f63/immutables-0.14-cp36-cp36m-manylinux1_x86_64.whl (98kB)
         |████████████████████████████████| 102kB 10.8MB/s
    Requirement already satisfied: decorator>=4.3.0 in /usr/local/lib/python3.6/dist-packages (from networkx>=2.2; python_version > "3.5"->qiskit-terra==0.15.1->qiskit) (4.4.2)
    Collecting ntlm-auth>=1.0.2
      Downloading https://files.pythonhosted.org/packages/ff/84/97c550164b54942b0e908c31ef09d9469f3ba4cd7332a671e2125732f63b/ntlm_auth-1.5.0-py2.py3-none-any.whl
    Collecting cryptography>=1.3
      Downloading https://files.pythonhosted.org/packages/c0/9c/647e559a6e8be493dc2a7a5d15d26cb501ca60ec299b356f23839a673a83/cryptography-3.1-cp35-abi3-manylinux2010_x86_64.whl (2.6MB)
         |████████████████████████████████| 2.6MB 42.1MB/s
    Requirement already satisfied: chardet<4,>=3.0.2 in /usr/local/lib/python3.6/dist-packages (from requests>=2.19->qiskit-ibmq-provider==0.8.0->qiskit) (3.0.4)
    Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.6/dist-packages (from requests>=2.19->qiskit-ibmq-provider==0.8.0->qiskit) (2020.6.20)
    Requirement already satisfied: idna<3,>=2.5 in /usr/local/lib/python3.6/dist-packages (from requests>=2.19->qiskit-ibmq-provider==0.8.0->qiskit) (2.10)
    Requirement already satisfied: joblib>=0.11 in /usr/local/lib/python3.6/dist-packages (from scikit-learn>=0.17->qiskit-ignis==0.4.0->qiskit) (0.16.0)
    Collecting inflection>=0.3.1
      Downloading https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl
    Requirement already satisfied: more-itertools in /usr/local/lib/python3.6/dist-packages (from quandl->qiskit-aqua==0.7.5->qiskit) (8.4.0)
    Requirement already satisfied: multitasking>=0.0.7 in /usr/local/lib/python3.6/dist-packages (from yfinance->qiskit-aqua==0.7.5->qiskit) (0.0.9)
    Requirement already satisfied: pytz>=2017.2 in /usr/local/lib/python3.6/dist-packages (from pandas->qiskit-aqua==0.7.5->qiskit) (2018.9)
    Requirement already satisfied: cffi!=1.11.3,>=1.8 in /usr/local/lib/python3.6/dist-packages (from cryptography>=1.3->requests-ntlm>=1.1.0->qiskit-ibmq-provider==0.8.0->qiskit) (1.14.2)
    Requirement already satisfied: pycparser in /usr/local/lib/python3.6/dist-packages (from cffi!=1.11.3,>=1.8->cryptography>=1.3->requests-ntlm>=1.1.0->qiskit-ibmq-provider==0.8.0->qiskit) (2.20)
    Building wheels for collected packages: qiskit, contextvars, python-constraint, dlx, docplex, yfinance
      Building wheel for qiskit (setup.py) ... done
      Created wheel for qiskit: filename=qiskit-0.20.0-cp36-none-any.whl size=3023 sha256=a70ae629a595fe7f16fd4bda2afeab6f1e0d342fc77525a0ad1b2ce85577c1f9
      Stored in directory: /root/.cache/pip/wheels/72/97/19/dd8931ed9c3115e216522129025614effb4494c96aad7fea0d
      Building wheel for contextvars (setup.py) ... done
      Created wheel for contextvars: filename=contextvars-2.4-cp36-none-any.whl size=7666 sha256=fdd57297896b037215156705419f150337282655fa73ff2299a775bb1f2089ef
      Stored in directory: /root/.cache/pip/wheels/a5/7d/68/1ebae2668bda2228686e3c1cf16f2c2384cea6e9334ad5f6de
      Building wheel for python-constraint (setup.py) ... done
      Created wheel for python-constraint: filename=python_constraint-1.4.0-py2.py3-none-any.whl size=24080 sha256=dbd833c568aa95220e39671fd01de7372d5d9ba255b0ec5a30da79dc414fa9aa
      Stored in directory: /root/.cache/pip/wheels/34/31/15/7b070b25d0a549d20ce2e9fe6d727471c2c61ef904720fd40c
      Building wheel for dlx (setup.py) ... done
      Created wheel for dlx: filename=dlx-1.0.4-cp36-none-any.whl size=5710 sha256=2795f17a9a92c9e2bee74110ffa298eafdf1191c4385928a39b1c58edee53c92
      Stored in directory: /root/.cache/pip/wheels/bb/ba/15/fdd0deb104df3254912998150ba9245668db06b00af5912d1a
      Building wheel for docplex (setup.py) ... done
      Created wheel for docplex: filename=docplex-2.15.194-cp36-none-any.whl size=645115 sha256=c763d18bdc73004b14739732172414a3696f25bd053564b65e06cc892ff686d7
      Stored in directory: /root/.cache/pip/wheels/ce/fa/5d/8f4d5fed1b8edb72e453cb2ac6fb75c6776b49d7174eb70457
      Building wheel for yfinance (setup.py) ... done
      Created wheel for yfinance: filename=yfinance-0.1.54-py2.py3-none-any.whl size=22409 sha256=da50bb1314bcef2211640a3e5f103e28b3895159f2cb89335d2870c01adab7d8
      Stored in directory: /root/.cache/pip/wheels/f9/e3/5b/ec24dd2984b12d61e0abf26289746c2436a0e7844f26f2515c
    Successfully built qiskit contextvars python-constraint dlx docplex yfinance
    Installing collected packages: sympy, immutables, contextvars, python-constraint, fastjsonschema, ply, retworkx, qiskit-terra, pybind11, qiskit-aer, nest-asyncio, ntlm-auth, cryptography, requests-ntlm, websockets, qiskit-ibmq-provider, qiskit-ignis, dlx, inflection, quandl, docplex, yfinance, qiskit-aqua, qiskit
      Found existing installation: sympy 1.1.1
        Uninstalling sympy-1.1.1:
          Successfully uninstalled sympy-1.1.1
    Successfully installed contextvars-2.4 cryptography-3.1 dlx-1.0.4 docplex-2.15.194 fastjsonschema-2.14.5 immutables-0.14 inflection-0.5.1 nest-asyncio-1.4.0 ntlm-auth-1.5.0 ply-3.11 pybind11-2.5.0 python-constraint-1.4.0 qiskit-0.20.0 qiskit-aer-0.6.1 qiskit-aqua-0.7.5 qiskit-ibmq-provider-0.8.0 qiskit-ignis-0.4.0 qiskit-terra-0.15.1 quandl-3.5.2 requests-ntlm-1.1.0 retworkx-0.4.0 sympy-1.6.2 websockets-7.0 yfinance-0.1.54

```
#Thinking about making a full adder --
## Three Qubit inputs - A, B and Cin

half_adder1 = QuantumCircuit(7,2)
half_adder1.barrier()

#***************************** INPUT BELOW **********************8

half_adder1.x(0)
half_adder1.x(1)
half_adder1.x(2)

#*******************************************************************

half_adder1.cx(0,3)
half_adder1.cx(1,3)
half_adder1.ccx(0,1,4)
half_adder1.barrier()

half_adder1.ccx(3,2,5)
#4 and 5 Or in 6
half_adder1.barrier()

half_adder1.x(4)
half_adder1.x(5)
half_adder1.ccx(4,5,6)
half_adder1.x(6)

half_adder1.cx(2,3)
half_adder1.barrier()
half_adder1.measure(6,1)

half_adder1.measure(3,0)

counts = execute(half_adder1, Aer.get_backend("qasm_simulator")).result().get_counts()
plot_histogram(counts)

```

```
half_adder1.draw()
```

<pre style="word-wrap: normal;white-space: pre;background: #fff0;line-height: 1.1;font-family: "Courier New",Courier,monospace">      ░ ┌───┐                ░       ░                 ░
q_0: ─░─┤ X ├──■─────────■───░───────░─────────────────░───────
      ░ ├───┤  │         │   ░       ░                 ░
q_1: ─░─┤ X ├──┼────■────■───░───────░─────────────────░───────
      ░ ├───┤  │    │    │   ░       ░                 ░
q_2: ─░─┤ X ├──┼────┼────┼───░───■───░───■─────────────░───────
      ░ └───┘┌─┴─┐┌─┴─┐  │   ░   │   ░ ┌─┴─┐           ░    ┌─┐
q_3: ─░──────┤ X ├┤ X ├──┼───░───■───░─┤ X ├───────────░────┤M├
      ░      └───┘└───┘┌─┴─┐ ░   │   ░ ├───┤           ░    └╥┘
q_4: ─░────────────────┤ X ├─░───┼───░─┤ X ├──■────────░─────╫─
      ░                └───┘ ░ ┌─┴─┐ ░ ├───┤  │        ░     ║
q_5: ─░──────────────────────░─┤ X ├─░─┤ X ├──■────────░─────╫─
      ░                      ░ └───┘ ░ └───┘┌─┴─┐┌───┐ ░ ┌─┐ ║
q_6: ─░──────────────────────░───────░──────┤ X ├┤ X ├─░─┤M├─╫─
      ░                      ░       ░      └───┘└───┘ ░ └╥┘ ║
c: 2/═════════════════════════════════════════════════════╩══╩═
                                                          1  0 </pre>

Above was a full-adder

```

```


On Galerkin approximations for the QG equations

Supplementary material for subsection on the Eady model

Cesar B. Rocha*

, William R. Young, and Ian Grooms

Winter 2015

*Scripps Institution of Oceanography, University of California, San Diego, 9500 Gilman Dr. MC 0213, La Jolla, CA/USA, <crocha@ucsd.edu>

```python
from __future__ import division

import numpy as np
from numpy import pi, sqrt,cos

import matplotlib.pyplot as plt
plt.rcParams.update({'font.size': 25, 'legend.handlelength'  : 1.25})
%matplotlib inline

import seaborn as sns
#sns.set(style="darkgrid")
sns.set_context("paper", font_scale=5, rc={"lines.linewidth": 1.5})
```

### Loading data

We computed and saved the eigenvalue and eigenfunction using Approximation A with $\mathrm{N}=64$ using the linear_eady notebook. We now load the data and plot the wavestructure in the $(x,z)$ plane.

```python
data_path = 'outputs/eady_A_k8_64_efunc.npz'
eady = np.load(data_path)
```

```python
kappa = eady['kappa']
qn = eady['efunc']
N = eady['Nmodes']
cr = eady['c'].real
```

Seting up the domain grid in $(x,z)$ space

```python
# vertical coordinate
dz = 1./N   # vertical resolution
z = np.arange(-dz/2,-1.-dz/2.,-dz)  # level array

# horizontal coordinate
x = np.linspace(0,np.pi,100)

# grid
X,Z = np.meshgrid(x,z)
```

The eigenfunctions are the modal coefficients $\,\breve{q}_n$. We use the standard modes $\mathsf{p}_0 = 1$ and $\mathsf{p}_n = \sqrt{2}\,\cos(n \pi z)$, $n>1$ to compute the eigenstructure in physical space:

\begin{equation}
\hat{q}(z) = \sum_{n=0}^{\mathrm{N}} \breve{q}_n\, \mathsf{p}_n \,\,\,.
\end{equation}

In approximation A, the modal streamfunction $\breve{\psi}_n$ is related to the PV by

\begin{equation}
\breve{\psi}_n = \alpha_n\, \breve{q}_n = - (\kappa^2 + (n \pi)^2)^{-1}\,\,\breve{q}_n\,\,.
\end{equation}

The streamfunction $\hat{\psi}$ is then

\begin{equation}
\hat{q}(z) = \sum_{n=0}^{\mathrm{N}} \breve{q}_n\, \mathsf{p}_n \,.
\end{equation}

\begin{equation}
\hat{\psi}(z) = \sum_{n=0}^{\mathrm{N}} \breve{\psi}_n\, \mathsf{p}_n \,.
\end{equation}

```python
def alpha_n(n,kappa):
    """ Compute the inverse Helmholtz operator in
             Fourier space """
    return -1./( kappa**2 + (n*pi)**2 )
```

```python
for i in range(N):
    if i == 0 :
        q = qn[0]*np.ones(z.size)
        psi = qn[0]*np.ones(z.size)*alpha_n(0,kappa)
    else:
        q+= sqrt(2)*qn[i]*cos(i*pi*z)
        psi+= sqrt(2)*qn[i]*cos(i*pi*z)*alpha_n(i,kappa)
```

Note that $q$ and $\psi$ are complex. The wavestructure is then

\begin{equation}
\psi(x,z) = |\psi(z)| \exp({ik\,x + P_{\psi}(z)})\,, \qquad \text{and}\qquad q(x,z) = |q(z)|\, \exp({ik\,x + P_{q}(z)})
\end{equation}

where vertical bars denote the magnitude and the phases are

\begin{equation}
P_{\psi}(z) = \text{tan$^{-1}$}\frac{\text{Im}(\hat{\psi})}{\text{Re}(\hat{\psi})}\,, \qquad \text{and}\qquad P_{q}(z) = \text{tan$^{-1}$}\frac{\text{Im}(\hat{q})}{\text{Re}(\hat{q})}
\end{equation}

```python
qabs = np.abs(q)
qphase = np.arctan2(q.imag,q.real)
psiabs = np.abs(psi)
psiphase = np.arctan2(psi.imag,psi.real)
```

```python
qabs = qabs.repeat(x.size).reshape(z.size,x.size)
qphase = qphase.repeat(x.size).reshape(z.size,x.size)
psiabs = psiabs.repeat(x.size).reshape(z.size,x.size)
psiphase = psiphase.repeat(x.size).reshape(z.size,x.size)
```

```python
PV = qabs*np.cos(kappa*X + qphase)
PSI = psiabs*np.cos(kappa*X + psiphase)
```

```python
plt.figure(figsize=(11,8))
plt.contour(X,Z,PSI,colors='k')
plt.contourf(X,Z,PV,np.linspace(-2.,2.,9),cmap='RdBu_r',extend='both')
#plt.text(-0.325,zc,r' $z_c \rightarrow$',fontsize=25)
cb = plt.colorbar(extend='both',shrink=.9)
cb.ax.text(.0,1.075,'PV',rotation=0,fontsize=25)
plt.text(1.725, -.9, r"Eady Problem, $\kappa = 8$, Approximation A", size=25, rotation=0.,\
            ha="center", va="center",\
            bbox = dict(boxstyle="round",ec='k',fc='w'))
plt.ylim(-1.,0)
plt.xticks([0.,pi/4,pi/2,3*pi/4,pi],[r'$0$',r'$\pi/4$',r'$\pi/2$',\
        r'$3\,\pi/4$',r'$\pi$'])
plt.ylabel('$z/H$')
plt.xlabel(r'$x/L_d$')
#plt.savefig('figs/wave-structure_pv_psi_kappa_8_A_N64.eps')
```

```python

```


Massimo Nocentini

April 19, 2018: splitting from Pascal notebook

Abstract

Theory of matrix functions, with applications to generation matrix $\mathcal{H}$.

```python
from sympy import *
from sympy.abc import n, i, N, x, lamda, phi, z, j, r, k, a, alpha, beta

from commons import *
from matrix_functions import *
from sequences import *
import functions_catalog

init_printing()
```

```python
d,h = IndexedBase('d'), IndexedBase('h')
alpha, beta, gamma = symbols(r'\alpha \beta \gamma')
```

# Generation matrix $\mathcal{H}$

see https://en.wikipedia.org/wiki/Pascal_matrix

```python
m = 16
H = define(Symbol(r'\mathcal{{H}}_{{ {} }}'.format(m)),
           Matrix(m, m, lambda n, k: 0 if n==m/2 or n!=k+1 else -h[-(k-(m/S(2))+2)] if n<m/2 else h[k-(m/S(2))]))
H
```

```python
f = Function('E')
f_expt = define(let=f(z), be=exp(z))
f_expt
```

```python
eigendata = spectrum(H)
eigendata
```

```python
data, eigenvals, multiplicities = eigendata.rhs
```

```python
Phi_polynomials = component_polynomials(eigendata, early_eigenvals_subs=True)
Phi_polynomials
```

```python
g_expt = Hermite_interpolation_polynomial(f_expt, eigendata, Phi_polynomials)
#g_expt
```

```python
g_expt = g_expt.subs(eigenvals)
g_expt
```

```python
with lift_to_matrix_function(g_expt) as G_expt:
    H_expt = G_expt(H)
H_expt
```

```python
production_matrix(H_expt.rhs[7:,7:])
```

```python
H_expt.rhs.subs({h[0]:1,h[1]:1,h[2]:1, h[3]:1,h[4]:1,h[5]:1,h[6]:1})
```

```python
H_expt.rhs.subs({h[0]:1,h[1]:2,h[2]:3, h[3]:4,h[4]:5,h[5]:6,h[6]:7})
```

```python
H_expt.rhs.subs({h[0]:1,h[1]:1,h[2]:2, h[3]:3,h[4]:5,h[5]:8,h[6]:13})
```

```python
H_expt.rhs.subs({h[0]:1,h[1]:1,h[2]:2, h[3]:5,h[4]:14,h[5]:42})
```

```python
H_expt.rhs.subs({h[0]:factorial(0),h[1]:factorial(1),h[2]:factorial(2),
                 h[3]:factorial(3),h[4]:factorial(4), h[5]:factorial(5),
                 h[6]:factorial(6)})
```

```python
H_expt.rhs.subs({h[0]:2**0,h[1]:2**1,h[2]:2**2, h[3]:2**3,h[4]:2**4,h[5]:2**5,h[6]:2**6})
```

```python
H_expt.rhs.subs({h[0]:1,h[1]:3,h[2]:6, h[3]:10,h[4]:15,h[5]:21,h[6]:28})
```

# Areated generation matrix $\mathcal{H}$

```python
m = 16
H = define(Symbol(r'\mathcal{{H}}_{{ {} }}'.format(m)),
           Matrix(m, m, lambda n, k: 0 if n==m/2 or n!=k+2 else -h[-(k-(m/S(2))+3)] if n<m/2 else h[k-(m/S(2))+1]))
H
```

```python
f = Function('E')
f_expt = define(let=f(z), be=exp(z))
f_expt
```

```python
eigendata = spectrum(H)
eigendata
```

```python
data, eigenvals, multiplicities = eigendata.rhs
```

```python
Phi_polynomials = component_polynomials(eigendata, early_eigenvals_subs=True)
Phi_polynomials
```

```python
g_expt = Hermite_interpolation_polynomial(f_expt, eigendata, Phi_polynomials)
#g_expt
```

```python
g_expt = g_expt.subs(eigenvals)
g_expt
```

```python
with lift_to_matrix_function(g_expt) as G_expt:
    H_expt = G_expt(H)
H_expt
```

```python
H_expt.rhs.subs({h[0]:1,h[1]:2,h[2]:3, h[3]:4,h[4]:5,h[5]:6,h[6]:7})
```

---

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.


# Demo of IPython Notebook

## David Pine

```
2+3
```

    5

```
import numpy as np
import matplotlib.pyplot as plt
```

```
np.sin(np.pi/6)
```

    0.49999999999999994

```
plt.plot([1,2,3,2,3,4,3,4,5])
```

```

```

```
# Calculates time, gallons of gas used, and cost of gasoline for
# a trip

distance = float(raw_input("Input distance of trip in miles: "))
mpg = 30.               # car mileage
speed = 60.             # average speed
costPerGallon = 4.10    # price of gas

time = distance/speed
gallons = distance/mpg
cost = gallons*costPerGallon

print("\nDuration of trip = {0:0.1f} hours".format(time))
print("Gasoline used = {0:0.1f} gallons (@ {1:0.0f} mpg)"
      .format(gallons, mpg))
print("Cost of gasoline = ${0:0.2f} (@ ${1:0.2f}/gallon)"
      .format(cost, costPerGallon))
```

    Input distance of trip in miles: 450

    Duration of trip = 7.5 hours
    Gasoline used = 15.0 gallons (@ 30 mpg)
    Cost of gasoline = $61.50 (@ $4.10/gallon)

The total distance $x$ traveled during a trip can be
obtained by integrating the velocity $v(t)$ over the
duration $T$ of the trip:
\begin{align}
    x = \int_0^T v(t)\, dt
\end{align}

```
!cat LiamSelinaData.txt
```

    Date: 2013-09-16
    Data taken by Liam and Selena
    frequency (Hz) amplitude (mm)  amp error (mm)
        0.7500        13.52         0.32
        1.7885        12.11         0.92
        2.8269        14.27         0.73
        3.8654        16.60         2.06
        4.9038        22.91         1.75
        5.9423        35.28         0.91
        6.9808        60.99         0.99
        8.0192        33.38         0.36
        9.0577        17.78         2.32
       10.0962        10.99         0.21
       11.1346         7.47         0.48
       12.1731         6.72         0.51
       13.2115         4.40         0.58
       14.2500         4.07         0.63

```

```


# Evaluate Wigner-D matrix elements using Leaver's method

Here we will try to use Leaver's power series representation for the spheroidal harmonics to evaluate the Wigdner-D elements. The connection here is that the spin wieghted spheroidal harmonics reduce to the spin weighted spherical harmonics which are equal to the Wigner-D functions after an appropriate association between parameters is made.

* https://en.wikipedia.org/wiki/Wigner_D-matrix#cite_note-5

L. London Nov 2021

```latex
%%latex
\begin{align}
    m' \; &= \;  s \\
    D^{\ell}{ms} \; &= \; (-1)^{-s+m} \, \sqrt{ \frac{4\pi}{2\ell+1}} {_{-s}}Y^{\ell}_{m}(\beta,\alpha)\;e^{-is\gamma}
\end{align}
```

\begin{align}
    m' \; &= \;  s \\
    D^{\ell}{ms} \; &= \; (-1)^{-s+m} \, \sqrt{ \frac{4\pi}{2\ell+1}} {_{-s}}Y^{\ell}_{m}(\beta,\alpha)\;e^{-is\gamma}
\end{align}

```python
# Setup ipython environment
%load_ext autoreload
%autoreload 2
%matplotlib inline
# Setup the notebook's environment
import numpy as np
from numpy import *
from matplotlib.pyplot import *
from positive import *
```

    (positive)>> Applying custom matplotlib settings.

```python
?sYlm
```

    Signature: sYlm(s, l, m, theta, phi, return_mesh=False, leaver=False)
    Docstring: <no docstring>
    File:      ~/JOKI/Libs/KOALA/positive_dev/positive/maths.py
    Type:      function

```python
?wdelement
```

    Signature:
    wdelement(
        ll,
        mp,
        mm,
        alpha,
        beta,
        gamma,
        leaver=False,
        smalld_splines=None,
    )
    Docstring: <no docstring>
    File:      ~/JOKI/Libs/KOALA/positive_dev/positive/maths.py
    Type:      function

```python
#
l = 3
m = 3
mp = -2

#
alpha = pi/4
gamma = -0.3
beta = pi/5#linspace(0,pi,1e3)

#
smalld_splines = wigner_smalld_splines(l)
```

```python
%timeit -n 1000 wdelement(l,mp,m,alpha,beta,gamma,smalld_splines=smalld_splines);
```

    80.3 µs ± 12.6 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

```python
# %timeit -n 500 wdelement(l,mp,m,alpha,beta,gamma,leaver=True);
```

```python
%timeit -n 1000 wdelement(l,mp,m,alpha,beta,gamma,leaver=False);
```

    224 µs ± 18.6 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

```python
print(224.0/80.3)
```

    2.7895392278953923

```python
fig,ax = subplots(6,5,figsize=4*figaspect(0.618))
ax = ax.flatten()

alpha = pi/4
gamma = -0.3
k = 0
beta = linspace(0,pi,1e3)
for l in range(3,5):
    smalld_splines = wigner_smalld_splines(l)
    for m in range(-l,l+1):
        for mp in range(-l,l+1):
            if k<len(ax):
                sca(ax[k])
                plot( beta, wdelement(l,mp,m,alpha,beta,gamma,leaver=True).imag, ls='-',lw=2,color='k',alpha=0.4 )
                plot( beta, wdelement(l,mp,m,alpha,beta,gamma,leaver=False).imag, ls='--',lw=2 )
                plot( beta, wdelement(l,mp,m,alpha,beta,gamma,smalld_splines=smalld_splines).imag, ls=':',lw=8,  color='g',alpha=0.4,zorder=-10 )
                axis('off')
                title("$(%i,%i,%i)$"%(l,m,mp),size=18)
            else:
                break
            k += 1
```

```python
fig,ax = subplots(6,5,figsize=4*figaspect(0.618))
ax = ax.flatten()

alpha = pi/4
gamma = -0.3
k = 0
beta = linspace(0,pi)
for l in range(3,5):
    smalld_splines = wigner_smalld_splines(l)
    for m in range(-l,l+1):
        for mp in range(-l,l+1):
            if k<len(ax):
                sca(ax[k])
                plot( beta, wdelement(l,mp,m,alpha,beta,gamma,leaver=True).real, ls='-',lw=2,color='k',alpha=1 )
                plot( beta, wdelement(l,mp,m,alpha,beta,gamma,leaver=False).real, ls='--',lw=2,color='orange' )
                plot( beta, wdelement(l,mp,m,alpha,beta,gamma,smalld_splines=smalld_splines).real, ls=':',lw=8,  color='dodgerblue',alpha=0.5,zorder=-10 )
                axis('off')
                title("$(%i,%i,%i)$"%(l,m,mp),size=18)
            else:
                break
            k += 1
```

```python

```


```python
!pip install pandas
import sympy as sym
import numpy as np
import pandas as pd
%matplotlib inline
import matplotlib.pyplot as plt

sym.init_printing()
sym.__version__

```

    Requirement already satisfied: pandas in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (0.23.4)
    Requirement already satisfied: numpy>=1.9.0 in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from pandas) (1.16.4)
    Requirement already satisfied: python-dateutil>=2.5.0 in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from pandas) (2.8.2)
    Requirement already satisfied: pytz>=2011k in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from pandas) (2021.1)
    Requirement already satisfied: six>=1.5 in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from python-dateutil>=2.5.0->pandas) (1.16.0)

    '1.4'

## Correlación

La correlación entre las señales $f(t)$ y $g(t)$ es una operación que indica cuán parecidas son las dos señales entre sí.

\begin{equation}
(f \; \circ \; g)(\tau) = h(\tau) = \int_{-\infty}^{\infty} f(t) \cdot g(t + \tau) \; dt
\end{equation}

Observe que la correlación y la convolución tienen estructura similares.

\begin{equation}
f(t) * g(t) = \int_{-\infty}^{\infty} f(\tau) \cdot g(t - \tau) \; d\tau
\end{equation}

## Señales periódicas

La señal $y(t)$ es periódica si cumple con la condición $y(t+nT)=y(t)$ para todo $n$ entero. En este caso, $T$ es el periodo de la señal.

La señal seno es la oscilación más pura que se puede expresar matemáticamente. Esta señal surge al considerar la proyección de un movimiento circular uniforme.

## Serie de Fourier

Si se combinan apropiadamente un conjunto de oscilaciones puras, como combinaciones lineales de señales desplazadas y escaladas en tiempo y amplitud, podría recrearse cualquiér señal periódica. Esta idea da lugar a las series de Fourier.

\begin{equation}
y(t) = \sum_{n=0}^{\infty} C_n \cdot cos(n \omega_0 t - \phi_n)
\end{equation}

La señal $y(t)$ es igual a una combinación de infinitas señales coseno, cada una con una amplitud $C_n$, una frecuencia $n \omega_0$ y un desfase $\phi_n$.

También puede expresarse como:

\begin{equation}
y(t) = \sum_{n=0}^{\infty} A_n \cdot cos(n \omega_0 t) + B_n \cdot sin(n \omega_0 t)
\end{equation}

La serie queda definida si se encuentran los valores apropiados de $A_n$ y $B_n$ para todos los valores de $n$.

Observe que:
- $A_n$ debe ser más grande si $y(t)$ se "parece" más a un cos.
- $B_n$ debe ser más grande si $y(t)$ se "parece" más a un sin.

\begin{equation}
y(t) = \sum_{n=0}^{\infty} A_n \cdot cos(n \omega_0 t) + B_n \cdot sin(n \omega_0 t)
\end{equation}

\begin{equation}
(f \; \circ \; g)(\tau) = \int_{-\infty}^{\infty} f(t) \cdot g(t + \tau) \; dt
\end{equation}

\begin{equation}
(y \; \circ \; sin_n)(\tau) = \int_{-\infty}^{\infty} y(t) \cdot sin(n \omega_0(t + \tau)) \; dt
\end{equation}

Considerando:
- $\tau=0$ para no incluir desfases.
- la señal $y(t)$ es periódica con periodo $T$.

\begin{equation}
(y \; \circ \; sin_n)(0) = \frac{1}{T} \int_{0}^{T} y(t) \cdot sin(n \omega_0 t) \; dt
\end{equation}

Esta expresión puede interpretarse como el parecido de una señal $y(t)$ a la señal $sin$ con crecuencia $n \omega_0$ promediado a lo largo de un periodo sin desfase del seno.

Retomando la idea inicial

\begin{equation}
y(t) = \sum_{n=0}^{\infty} A_n \cdot cos(n \omega_0 t) + B_n \cdot sin(n \omega_0 t)
\end{equation}

donde
\begin{equation}
A_n = \frac{1}{T} \int_{0}^{T} y(t) \cdot cos(n \omega_0 t) \; dt
\end{equation}

\begin{equation}
B_n = \frac{1}{T} \int_{0}^{T} y(t) \cdot sin(n \omega_0 t) \; dt
\end{equation}

Se recomienda al estudiante que encuentre la relación entre las Series anteriores y la siguiente alternativa para representar la Series de Fourier.

\begin{equation}
y(t) = \sum_{n=-\infty}^{\infty} C_n \cdot e^{j n \omega_0 t}
\end{equation}

donde

\begin{equation}
C_n = \frac{1}{T} \int_{0}^{T} y(t) \cdot e^{j n \omega_0 t} \; dt
\end{equation}

Los valores $C_n$ son el espectro de la señal periódica $y(t)$ y son una representación en el dominio de la frecuencia.

**Ejemplo # 1**

La señal $y(t) = sin(2 \pi t)$ es en sí misma una oscilación pura de periodo $T=1$.

```python
# Se define y como el seno de t
t = sym.symbols('t', real=True)
#T = sym.symbols('T', real=True)
T = 1
nw = sym.symbols('n', real=True)
delta = sym.DiracDelta(nw)
w0 = 2 * sym.pi / T

y = t**2
#vy = 4*sym.sin(w0*t + 0.5) - 10
# y = sym.sin(w0*t)
# y = (t-0.5)*(t-0.5)
y

```

Aunque la sumatoria de las series de Fourier incluye infinitos términos, solamente se tomaran las primeras 3 componentes.

```python
n_max = 5

y_ser = 0
C = 0
ns = range(-n_max,n_max+1)
espectro = pd.DataFrame(index = ns,
                        columns= ['C','C_np','C_real','C_imag','C_mag','C_ang'])

for n in espectro.index:
    C_n = (1/T)*sym.integrate(y*sym.exp(-1j*n*w0*t), (t,0,T)).evalf()
    C = C + C_n*delta.subs(nw,nw-n)
    y_ser = y_ser + C_n*sym.exp(1j*n*w0*t)
    espectro['C'][n]=C_n
    C_r = float(sym.re(C_n))
    C_i = float(sym.im(C_n))
    espectro['C_real'][n] = C_r
    espectro['C_imag'][n] = C_i
    espectro['C_np'][n] = complex(C_r + 1j*C_i)
    espectro['C_mag'][n] = np.absolute(espectro['C_np'][n])
    espectro['C_ang'][n] = np.angle(espectro['C_np'][n])
espectro
```

      C
      C_np
      C_real
      C_imag
      C_mag
      C_ang

      -5
      0.00202642367284676 - 0.0318309886183791*I
      (0.0020264236728467556-0.03183098861837907j)
      0.00202642
      -0.031831
      0.0318954
      -1.50722

      -4
      0.00316628698882306 - 0.0397887357729738*I
      (0.0031662869888230555-0.039788735772973836j)
      0.00316629
      -0.0397887
      0.0399145
      -1.49139

      -3
      0.00562895464679654 - 0.0530516476972984*I
      (0.005628954646796543-0.05305164769729844j)
      0.00562895
      -0.0530516
      0.0533494
      -1.46509

      -2
      0.0126651479552922 - 0.0795774715459477*I
      (0.012665147955292222-0.07957747154594767j)
      0.0126651
      -0.0795775
      0.080579
      -1.41297

      -1
      0.0506605918211689 - 0.159154943091895*I
      (0.05066059182116889-0.15915494309189535j)
      0.0506606
      -0.159155
      0.167023
      -1.26263

      0
      0.333333333333333
      (0.3333333333333333+0j)
      0.333333
      0
      0.333333
      0

      1
      0.0506605918211689 + 0.159154943091895*I
      (0.05066059182116889+0.15915494309189535j)
      0.0506606
      0.159155
      0.167023
      1.26263

      2
      0.0126651479552922 + 0.0795774715459477*I
      (0.012665147955292222+0.07957747154594767j)
      0.0126651
      0.0795775
      0.080579
      1.41297

      3
      0.00562895464679654 + 0.0530516476972984*I
      (0.005628954646796543+0.05305164769729844j)
      0.00562895
      0.0530516
      0.0533494
      1.46509

      4
      0.00316628698882306 + 0.0397887357729738*I
      (0.0031662869888230555+0.039788735772973836j)
      0.00316629
      0.0397887
      0.0399145
      1.49139

      5
      0.00202642367284676 + 0.0318309886183791*I
      (0.0020264236728467556+0.03183098861837907j)
      0.00202642
      0.031831
      0.0318954
      1.50722

La señal reconstruida con un **n_max** componentes

```python
y_ser
```

```python
plt.rcParams['figure.figsize'] = 7, 2
#g1 = sym.plot(y, (t,0,1), ylabel=r'Amp',show=False,line_color='blue',legend=True, label = 'y(t) original')
#g2 = sym.plot(sym.re(y_ser), (t,-1,2), ylabel=r'Amp',show=False,line_color='red',legend=True, label = 'y(t) reconstruida')
g1 = sym.plot(y, (t,0,1), ylabel=r'Amp',show=False,line_color='blue')
g2 = sym.plot(sym.re(y_ser), (t,-1,2), ylabel=r'Amp',show=False,line_color='red')

g1.extend(g2)
g1.show()
```

```python
C
```

```python
plt.rcParams['figure.figsize'] = 7, 4
plt.stem(espectro.index,espectro['C_mag'])

```

**Ejercicio**

Use las siguientes funciones para definir un periodo de una señal periódica con periodo $T=1$:

\begin{equation}
y_1(t) = \begin{cases}
-1 &  0 \leq t < 0.5 \\
1 &  0.5 \leq t < 1
\end{cases}
\end{equation}

\begin{equation}
y_2(t) = t
\end{equation}

\begin{equation}
y_3(t) = 3 sin(2 \pi t)
\end{equation}

Varíe la cantidad de componentes que reconstruyen cada función y analice la reconstrucción obtenida y los valores de $C_n$

```python

```

```python

```


# Exercise 6, answers

## Problem 1

```python
from pyomo.environ import *

model = ConcreteModel()
#Three variables
model.x = Var([1,2,3])
#Objective function including powers and logarithm
model.OBJ = Objective(expr = log(model.x[1]**2+1)+model.x[2]**4
                      +model.x[1]*model.x[3]) #Objective function
model.constr = Constraint(expr = model.x[1]**3-model.x[2]**2>=1)
model.box1 = Constraint(expr = model.x[1]>=0)
model.box2 = Constraint(expr = model.x[3]>=0)

from pyomo.opt import SolverFactory #Import interfaces to solvers

opt = SolverFactory("ipopt") #Use ipopt

res = opt.solve(model, tee=True) #Solve the  problem and print the output

print "Optimal solutions is "
model.x.display()
print "Objective value at the optimal solution is "
model.OBJ.display()
```

    ******************************************************************************
    This program contains Ipopt, a library for large-scale nonlinear optimization.
     Ipopt is released as open source code under the Eclipse Public License (EPL).
             For more information visit http://projects.coin-or.org/Ipopt
    ******************************************************************************

    This is Ipopt version 3.12, running with linear solver mumps.
    NOTE: Other linear solvers might be more efficient (see Ipopt documentation).

    Number of nonzeros in equality constraint Jacobian...:        0
    Number of nonzeros in inequality constraint Jacobian.:        4
    Number of nonzeros in Lagrangian Hessian.............:        3

    Total number of variables............................:        3
                         variables with only lower bounds:        0
                    variables with lower and upper bounds:        0
                         variables with only upper bounds:        0
    Total number of equality constraints.................:        0
    Total number of inequality constraints...............:        3
            inequality constraints with only lower bounds:        3
       inequality constraints with lower and upper bounds:        0
            inequality constraints with only upper bounds:        0

    iter    objective    inf_pr   inf_du lg(mu)  ||d||  lg(rg) alpha_du alpha_pr  ls
       0  0.0000000e+00 1.00e+00 5.00e-01  -1.0 0.00e+00    -  0.00e+00 0.00e+00   0
       1  2.2129049e-06 1.00e+00 1.09e+02  -1.0 1.01e+00    -  1.00e+00 9.80e-03h  1
       2  2.4328193e-06 1.00e+00 1.55e+05  -1.0 1.00e+00    -  1.39e-01 9.90e-05h  1
       3  1.9533370e-02 9.97e-01 9.82e+04  -1.0 1.60e+05    -  6.81e-08 9.04e-07h  1
       4  8.5283692e-01 0.00e+00 2.91e+06  -1.0 1.57e+01    -  1.18e-02 6.25e-02f  5
       5  7.4508982e-01 0.00e+00 1.19e+07  -1.0 1.19e-01   8.0 3.17e-04 1.00e+00f  1
       6  7.3522284e-01 0.00e+00 6.57e+05  -1.0 9.61e-03   7.5 7.73e-01 1.00e+00h  1
       7  7.3514688e-01 0.00e+00 9.59e+02  -1.0 7.35e-05   7.0 1.00e+00 1.00e+00h  1
       8  7.3514746e-01 0.00e+00 3.58e+00  -1.0 9.67e-07   6.6 1.00e+00 1.00e+00h  1
       9  7.6476859e-01 0.00e+00 3.06e-02  -1.0 2.25e-02    -  1.00e+00 1.00e+00f  1
    iter    objective    inf_pr   inf_du lg(mu)  ||d||  lg(rg) alpha_du alpha_pr  ls
      10  6.9848511e-01 0.00e+00 1.98e-03  -2.5 5.37e-02    -  1.00e+00 1.00e+00f  1
      11  6.9344736e-01 0.00e+00 1.59e-05  -3.8 4.78e-03    -  1.00e+00 1.00e+00h  1
      12  6.9315086e-01 0.00e+00 5.53e-08  -5.7 4.18e-04    -  1.00e+00 1.00e+00h  1
      13  6.9314717e-01 0.00e+00 8.49e-12  -8.6 5.40e-06    -  1.00e+00 1.00e+00h  1

    Number of Iterations....: 13

                                       (scaled)                 (unscaled)
    Objective...............:   6.9314717223847255e-01    6.9314717223847255e-01
    Dual infeasibility......:   8.4893203577962595e-12    8.4893203577962595e-12
    Constraint violation....:   0.0000000000000000e+00    0.0000000000000000e+00
    Complementarity.........:   2.5092981987187852e-09    2.5092981987187852e-09
    Overall NLP error.......:   2.5092981987187852e-09    2.5092981987187852e-09

    Number of objective function evaluations             = 20
    Number of objective gradient evaluations             = 14
    Number of equality constraint evaluations            = 0
    Number of inequality constraint evaluations          = 20
    Number of equality constraint Jacobian evaluations   = 0
    Number of inequality constraint Jacobian evaluations = 14
    Number of Lagrangian Hessian evaluations             = 13
    Total CPU secs in IPOPT (w/o function evaluations)   =      0.004
    Total CPU secs in NLP function evaluations           =      0.000

    EXIT: Optimal Solution Found.

    Ipopt 3.12: Optimal Solution Found
    Optimal solutions is
    x : Size=3, Index=x_index, Domain=Reals
        Key : Lower : Value              : Upper : Fixed : Stale
          1 :  None :     0.999999999169 :  None : False : False
          2 :  None :                0.0 :  None : False : False
          3 :  None : -7.49070198136e-09 :  None : False : False
    Objective value at the optimal solution is
    OBJ : Size=1, Index=None, Active=True
        Key  : Active : Value
        None :   True : 0.693147172238

## Problem 2

The set Pareto optimal solutions is $\{(t,1-t):t\in[0,1]\}$.

Let us denote set of Pareto optimal solutions by $PS$ and show that $PS=\{(t,1-t):t\in[0,1]\}$.

$PS\supset\{(t,1-t):t\in[0,1]\}$:

Let's assume that there exists $t\in[0,1]$, which is not Pareto optimal. Then there exists $x=(x_1,x_2)\in\mathbb R^2$ and $t\in[0,1]$ such that
$$
\left\{
\begin{align}
\|x-(1,0)\|^2<\|(t,1-t)-(1,0) \|^2,\text{ and}\\
\|x-(0,1)\|^2\leq\|(t,1-t)-(0,1) \|^2
\end{align}
\right.
$$
or
$$
\left\{
\begin{align}
\|x-(1,0)\|^2\leq\|(t,1-t)-(1,0) \|^2,\text{ and}\\
\|x-(0,1)\|^2<\|(t,1-t)-(0,1)\|^2.
\end{align}
\right.
$$

But in both cases

$$
\sqrt{2} = \|(1,0)-(0,1)\|\\
\leq \|(1,0)-x\|+\|x-(0,1)\|\\
< \|(t,1-t)-(1,0) \|+\|(t,1-t)-(0,1) \|\\
= \|(1,0)-(0,1)\| =\sqrt{2}.
$$
because the point $(t,1-t)$ is on the straight line from $(1,0)$ to $(0,1)$.

Thus, neither one of the requirements of non-Pareto optimality can hold. Thus, the point is Pareto optimal.

$PS\subset\{(t,1-t):t\in[0,1]\}$:

Let's assume a Pareto optimal solution $x$. This follows from the triangle inequality.

## Problem 3

Ideal:

To solve
$$
\min \|x-(1,0)\|^2\\
\text{s.t. }x\in \mathbb R^2.
$$
The solution of this problem is naturally $x = (1,0)$ and the minimum is $0$. Minimizing the second objective give $x=(0,1)$ and the minimum is again $0$. Thus, the ideal is $(0,0)$.

Now, the problem has just two objectives and thus, we get the components of the nadir by optimizing
$$
\min f_1(x)\\
\text{s.t. }f_2(x)\leq z^{ideal}_2
$$
and
$$
\min f_2(x)\\
\text{s.t. }f_1(x)\leq z^{ideal}_1.
$$

The solution of this problem is Pareto optimal because of the epsilon constraint method and also because the other one of the objectives is at the minimum and the other one cannot be grown with growing the other. Thus, the components of the nadir are at least the optimal values of the above optimization problems.

On the other hand, the components of the nadir have to be at most the optimal values of the above optimization problems, because if this was not the case, then the solution would not be Pareto optimal.

By solving these optimization problems, we get nadir (2,2).

## Problem 4

```python
def prob(x):
    return [(x[0]-1)**2+x[1]**2,x[0]**2+(x[1]-1)**2]
```

Let's do this using Pyomo:

```python
from pyomo.environ import *
from pyomo.opt import SolverFactory #Import interfaces to solvers

def weighting_method_pyomo(f,w):
    points = []
    for wi in w:
        model = ConcreteModel()
        model.x = Var([0,1])
        #weighted sum
        model.obj = Objective(expr = wi[0]*f(model.x)[0]+wi[1]*f(model.x)[1])
        opt = SolverFactory("ipopt") #Use ipopt
        #Combination of expression and function
        res=opt.solve(model) #Solve the problem
        points.append([model.x[0].value,model.x[1].value]) #We should check for optimality...
    return points
```

```python
w = np.random.random((500,2)) #500 random weights
repr = weighting_method_pyomo(prob,w)
```

**Plot the solutions in the objective space**

```python
import matplotlib.pyplot as plt
f_repr_ws = [prob(repri) for repri in repr]
fig = plt.figure()
plt.scatter([z[0] for z in f_repr_ws],[z[1] for z in f_repr_ws])
plt.show()
```

**Plot the solutions in the decision space**

```python
import matplotlib.pyplot as plt
fig = plt.figure()
plt.scatter([x[0] for x in repr],[x[1] for x in repr])
plt.show()
```

**What do we notice?**

In this problem, the weighting method works. This is because the objective functions are convex.

Working here means that the method produces an even representation of the whole Pareto optimal set.


```python
from IPython.display import Image
from IPython.core.display import HTML
from sympy import *; x,h,y,t = symbols("x h y t")
Image(url= "https://i.imgur.com/2gXTDIm.png")
```

```python
expr =2*x+1
def F(x):
    return expr
plot(integrate(expr,x))
```

```python
(integrate(expr,x)+2).subs(x,9)  # added constant '2' to make f(1) = 4 by making changing integral to x**2+x+2
```

$\displaystyle 92$

```python
Image(url= "https://i.imgur.com/46t6xMf.png")
```

```python

```


# Quantum Integer Programming (QuIP) 47-779. Fall 2020, CMU

This notebook contains material from the Quantum Integer Programming Lecture at CMU Fall 2020 by David Bernal (bernalde at cmu.edu), Sridhar Tayur (stayur at cmu.edu) and Davide Venturelli; the content is available on **[Github](https://github.com/bernalde/QuIP)**. The text is released under the **[CC-BY-NC-ND-4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode) license, and code is released under the **[MIT license](https://opensource.org/licenses/MIT).*

This notebook makes simple computations of Graver basis. Because of the complexity of these computation, we suggest that for more complicated problems you install the excellent **[4ti2](https://4ti2.github.io/)** software, an open-source implementation of several routines useful for the study of integer programming through algebraic geometry. It can be used as a stand-alone library and call it from C++ or from Python. In python, there are two ways of accessing it, either through **[Sage](https://www.sagemath.org/)** (which is an open-source mathematics software) or directly compiling it and installing a thing **[Python wrapper](https://github.com/alfsan/Py4ti2)**

Run in **[Google Colab](https://colab.research.google.com/github/bernalde/QuIP/blob/master/notebooks/Notebook%206%20-%20GAMA.ipynb)**

## Introduction to GAMA
The Graver Augmentation Multiseed Algorithm (GAMA) was proposed by **[two](https://arxiv.org/abs/1902.04215)** **[papers](https://arxiv.org/abs/1907.10930)** by Alghassi, Dridi and Tayur from the CMU Quantum Computing group.
The three main ingredients of this Algorithm, designed to solve integer programs with linear constraints and nonlinear objective, are:
- Computing the Graver basis (or a subset of it) of an integer program.
- Performing an augmentation.
- Given that only for certain objective functions the Graver augmentation is guaranteed to find a global optimal solution, then the algorithm is initialized in several points.

This algorithm can be adapted to take advantage of Quantum Computers by leveraging them as black-box Ising/QUBO problem solvers. In particular, obtaining several feasible solution points for the augmentation and computing the Kernel of the constraint matrix can be posed as QUBO pproblems. After botaining these solutions, other routines implemented in classical computers are used to find the solution to the optimization problems, making this a hybrid quantum-classical algorithm.

## Introduction to Graver basis computation
In a previous notebook, we covered the computation of Graver basis, which are defined as
$$
\mathcal{G}(\mathbf{A}) = \bigcup_j \mathcal{H}_j(\mathbf{A})
$$
where $\mathcal{H}_j(\mathbf{A})$ are the minimal Hilbert basis of $\mathbf{A}$ in each orthant.

Equivalently we can define the Graver basis as the $\sqsubseteq$-minimal set of a lattice
$$
\mathcal{L}(\mathbf{A})=\{ \mathbf{x}:\mathbf{A}\mathbf{x}=\mathbf{0}, \mathbf{x} \in \mathbb{Z}^n
\} \setminus \{ \mathbf{0} \}
$$

Here we won't interact with the Quantum Computer, but we will obtain the Graver basis of a problem using the package 4ti2.
This notebook studies the behavior of the search algorithm in the case that we only have available a subset of the Graver basis.

```python
# If using this on Google collab, if not we can import 4ti2
try:
  import google.colab
  IN_COLAB = True
except:
  IN_COLAB = False

# Let's start with Pyomo
if not IN_COLAB:
    from Py4ti2int32 import *
```

```python
from sympy import *
import matplotlib.pyplot as plt
import numpy as np
from typing import Callable
import itertools
import random
import time
```

```python
# Define rules to choose augmentation element, either the best one (argmin) or the first one that is found
def argmin(iterable):
    return min(enumerate(iterable), key=lambda x: x[1])

def greedy(iterable):
    for i, val in enumerate(iterable):
        if val[1] != 0:
            return i, val
    else:
        return i, val
```

```python
# Bisection rules for finding best step size
def bisection(g: np.ndarray, fun: Callable, x: np.ndarray, x_lo: np.ndarray = None, x_up: np.ndarray = None, laststep: np.ndarray = None) -> (float, int):
    if np.array_equal(g, laststep):
        return (fun(x), 0)
    if x_lo is None:
        x_lo = np.zeros_like(x)
    if x_up is None:
        x_up = np.ones_like(x)*max(x)*2

    u = max(x_up) - min(x_lo)
    l = -(max(x_up) - min(x_lo))
    for i, gi in enumerate(g):
        if gi >= 1:
            if np.floor((x_up[i] - x[i]) / gi) < u:
                u = int(np.floor((x_up[i] - x[i]) / gi))
            if np.ceil((x_lo[i] - x[i]) / gi) > l:
                l = int(np.ceil((x_lo[i] - x[i]) / gi))
        elif gi <= -1:
            if np.ceil((x_up[i] - x[i]) / gi) > l:
                l = int(np.ceil((x_up[i] - x[i]) / gi))
            if np.floor((x_lo[i] - x[i]) / gi) < u:
                u = int(np.floor((x_lo[i] - x[i]) / gi))
    alpha = u

    while u - l > 1:
        if fun(x + l*g) < fun(x + u*g):
            alpha = l
        else:
            alpha = u
        p1 = int(np.floor((l+u)/2) - 1)
        p2 = int(np.floor((l+u)/2))
        p3 = int(np.floor((l+u)/2) + 1)
        if fun(x + p1*g) < fun(x + p2*g):
            u = int(np.floor((l+u)/2))
        elif fun(x + p3*g) < fun(x + p2*g):
            l = int(np.floor((l+u)/2) + 1)
        else:
            alpha = p2
            break

    if fun(x + l*g) < fun(x + u*g) and fun(x + l*g) < fun(x + alpha*g):
        alpha = l
    elif fun(x + u*g) < fun(x + alpha*g):
        alpha = u

    return (fun(x + alpha*g), alpha)
```

```python
# We can just have a single step move (works well with greedy approach)
def single_move(g: np.ndarray, fun: Callable, x: np.ndarray, x_lo: np.ndarray = None, x_up: np.ndarray = None, laststep: np.ndarray = None) -> (float, int):
    if x_lo is None:
        x_lo = np.zeros_like(x)
    if x_up is None:
        x_up = np.ones_like(x)*max(x)*2

    alpha = 0

    if (x + g <= x_up).all() and (x + g >= x_lo).all():
        if fun(x + g) < fun(x):
            alpha = 1
    elif (x - g <= x_up).all() and (x - g >= x_lo).all():
        if fun(x - g) < fun(x) and fun(x - g) < fun(x + g):
            alpha = -1

    return (fun(x + alpha*g), alpha)
```

We will be solving EXAMPLE 4 in the code, which corresponds to the Case 2 in the original GAMA paper. The problem is derived from finance and deals with the maximization of expected returns on an investments and the minimization of the variance.
$$
\min -\sum_{i=1}^n \mu_ix_i + \sqrt{\frac{1-\epsilon}{\epsilon}\sum_{i=1}^n \sigma_i^2 x_i^2} \\
s.t. Ax = b, x \in \{-2,-1,-0,1,2 \}^n
$$
This particular instance of convex INLP has $n=25, \epsilon=0.01, \mu_i = \text{Rand}[0,1], \sigma_i = \text{Rand}[0,\mu_i]$.
The matrix $A$ is a matrix with 1's and 0's and the $b$ vector is half of the sum of the rows of $A$, in this case $b = (9,8,7,5,5)^\top$.

The Graver basis of this matrix $A$ has 29789 elements, which in a standard laptop using 4ti2 takes ~5 seeconds to compute. If you are in Colab you will import it from a file.

```python
EXAMPLE = 4

# Each example defines a problem min f(x)=cx s.t. Ax=b, x_lo <= x <= x_up, x integer

if EXAMPLE == 1:
    # Fist example - Illustrative example Alghassi et al. Graver Bases via Quantum Annealing
    # with application to non-linear integer programs
    A = np.array([[1, 1, 1, 1], [1, 5, 10, 25]])
    b = np.array([[21],[156]])
    # Objective is to minimize distance to 5
    x0 = np.array([1,15,3,2])
    x_lo = np.zeros_like(x0)
    x_up = 15*np.ones_like(x0)
elif EXAMPLE == 2:
    # Second example
    A = np.array([[1, 1, 1, 1], [1, 2, 3, 4]])
    b = np.array([[10], [21]])
    c = np.array([0, 1, 0, 2])
    x0 = np.array([1,8,0,1])
    x_lo = np.zeros_like(x0)
    x_up = 21*np.ones_like(x0)
elif EXAMPLE == 3:
    # Third example
    A = np.array([[1, 1, 1, 1], [0, 1, 2, 3]])
    b = np.array([[10],[15]])
    c = np.array([1, 3, 14, 17])
    x0 = np.array([3,0,6,1])
    x_lo = np.zeros_like(x0)
    x_up = 10*np.ones_like(x0)
elif EXAMPLE == 4:
    # Fourth example
    A = np.array([[1,1,1,1,1,1,1,1,1,1,0,1,0,1,0,1,0,1,1,1,0,1,0,1,0],
    [1,1,1,1,0,1,0,1,0,0,1,0,0,0,1,0,0,1,0,1,1,1,1,1,1],
    [0,1,0,0,0,1,0,1,0,1,1,0,1,1,0,1,1,0,0,1,0,0,1,1,1],
    [0,0,0,0,0,0,0,1,0,1,1,1,0,1,1,1,1,0,0,1,0,0,0,0,0],
    [0,1,1,1,1,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,1,0,1,0]])
    b = np.array([[9], [8], [7], [5], [5]])
    # Objective is quadratic expression
    x0 = np.array([1, 1, 1, 1, -1, 1, 1, 1, 1, 1, 1, 1, -2,
               1, 0, -1, 0, 1, -1, 1, -2, -2, 1, 1, 1])
    x_lo = -2*np.ones_like(x0)
    x_up = 2*np.ones_like(x0)
    # Parameters of objective function
    epsilon = 0.01
    mu = np.random.rand(len(x0))
    sigma = np.multiply(np.random.rand(len(x0)),mu)
else:
    print("Example not implemented")

if not IN_COLAB:
    start = time.process_time()
    r = graver("mat", A.tolist())
    print(time.process_time() - start, 'seconds to compute Graver basis')
    np.save(r'graver.npy', r)
else:
    if EXAMPLE == 1:
        # Taken from paper but the same as computed with 4ti2
        r = [[5, -6, 0, 1], [5, -9, 4, 0], [0, 3, -4, 1], [5, -3, -4, 2], [5, 0, -8, 3]]
        # r = np.load(r'graver.npy')
    elif EXAMPLE == 2:
        r = [[1, -2, 1, 0], [2, -3, 0, 1], [1, -1, -1, 1], [0, 1, -2, 1], [1, 0, -3, 2]]
        # r = np.load(r'graver.npy')
    elif EXAMPLE == 3:
        r = [[2, -3, 0, 1], [1, -2, 1, 0], [1, -1, -1, 1], [0, 1, -2, 1], [1, 0, -3, 2]]
        # r = np.load(r'graver.npy')
    elif EXAMPLE == 4:
        print("The Graver basis has 29789 elements in this case")
        r = np.load(r'graver.npy')
    else:
        print("Example not implemented")

r = np.array(r)
```

    6.078125 seconds to compute Graver basis

```python
# Objective function definition
def f(x):
    if EXAMPLE == 1:
        return np.sum(np.abs(x - 5))
    elif EXAMPLE == 4:
        return -np.dot(mu, x) + np.sqrt(((1-epsilon)/epsilon)*np.dot(sigma**2, x**2))
    else:
        return np.dot(c,x)

# Constraints definition
def const(x):
    return np.array_equiv(np.dot(A,x),b.T) or np.array_equiv(np.dot(A,x),b)

```

```python
def augmentation(grav = r, func = f, x = x0, x_lo = x_lo, x_up = x_up, OPTION: int = 1, VERBOSE: bool = True, itermax: int = 1000) -> (int, float, np.ndarray):
    # Let's perform the augmentation and return the number of steps and the best solution
    # OPTION = 1 # Best augmentation, select using bisection rule
    # OPTION = 2 # Greedy augmentation, select using bisection rule
    # OPTION = 3 # Greedy augmentation, select using first found

    dist = 1
    gprev = None
    k = 1
    if VERBOSE:
        print("Initial point:", x)
        print("Objective function:",func(x))
    while dist != 0 and k < itermax:
        if OPTION == 1:
            g1, (obj, dist) = argmin(
                bisection(g=e, fun=func, x=x, laststep=gprev, x_lo=x_lo, x_up=x_up) for e in grav)
        elif OPTION == 2:
            g1, (obj, dist) = greedy(
                bisection(g=e, fun=func, x=x, laststep=gprev, x_lo=x_lo, x_up=x_up) for e in grav)
        elif OPTION == 3:
            g1, (obj, dist) = greedy(
                single_move(g=e, fun=func, x=x, x_lo=x_lo, x_up=x_up) for e in grav)
        else:
            print("Option not implemented")
            break
        x = x + grav[g1]*dist
        gprev = grav[g1]
        if VERBOSE:
            print("Iteration ", k)
            print(g1, (obj, dist))
            print("Augmentation direction:", gprev)
            print("Distanced moved:", dist)
            print("Step taken:", grav[g1]*dist)
            print("Objective function:", obj)
            print(func(x))
            print("Current point:", x)
            print("Are constraints satisfied?", const(x))
        else:
            if k%50 == 0:
                print(k)
                print(obj)
        k += 1
    return(k,obj,x)
```

First, we will prove our augmentation strategies, either best or greedy, and for that last case either computing the best step or a single move. In the order that were mentioned, the augmentation will take more iterations, but each one of the augmentation steps or iterations is going to be cheaper.

```python
print('Best-augmentation: Choosing among the best step that each element of G can do (via bisection), the one that reduces the most the objective')
start = time.process_time()
iter,f_obj,xf = augmentation(func = f, OPTION=1,VERBOSE=False)
print(time.process_time() - start, 'seconds')
print(iter, 'iterations')
print('solution:',f_obj,xf)

print('Greedy-best-augmentation: Choosing among the best step that each element of G can do (via bisection), the first one encountered that reduces the objective')
start = time.process_time()
iter,f_obj,xf = augmentation(func = f, OPTION=2,VERBOSE=False)
print(time.process_time() - start, 'seconds')
print(iter, 'iterations')
print('solution:',f_obj,xf)

print('Greedy-augmentation: Choosing among the first element of G that with a single step reduces the objective')
start = time.process_time()
iter,f_obj,xf = augmentation(func = f, OPTION=3,VERBOSE=False)
print(time.process_time() - start, 'seconds')
print(iter, 'iterations')
print('solution:',f_obj,xf)
```

    Best-augmentation: Choosing among the best step that each element of G can do (via bisection), the one that reduces the most the objective
    31.859375 seconds
    8 iterations
    solution: -2.3115754068186884 [ 2  2  0  0  0  2 -2  1  1 -1  0  2  0  2  0  0  1  0  0  0  1  0  1  0
     -1]
    Greedy-best-augmentation: Choosing among the best step that each element of G can do (via bisection), the first one encountered that reduces the objective
    4.8125 seconds
    41 iterations
    solution: -2.3115754068186884 [ 2  2  0  0  0  2 -2  1  1 -1  0  2  0  2  0  0  1  0  0  0  1  0  1  0
     -1]
    Greedy-augmentation: Choosing among the first element of G that with a single step reduces the objective
    1.015625 seconds
    39 iterations
    solution: -2.235801061439658 [ 2  2  0  0  0  2 -1  1  1 -1  0  2  0  2  0  0  1  0 -1  0  1  0  1  0
     -1]

Now we can highlight aother feature of the algorithm, computing starting feasible solutions. For this case we formulate a QUBO and solve it via annealing.
In this particular case, we will not encode the integer variables and will only look feasible solutions in the case that $x \in \{0,1 \}^n$

```python
def get_feasible(A, b, sampler, samples=20):

    AA = np.dot(A.T, A)
    h = -2.0*np.dot(b.T, A)
    Q = AA + np.diag(h[0])
    offset = np.dot(b.T, b) + 0.0

    # Define Binary Quadratic Model
    bqm = dimod.BinaryQuadraticModel.from_numpy_matrix(mat=Q, offset=offset)

    response = sampler.sample(bqm, num_reads=samples)

    response = response.aggregate()

    filter_idx = [i for i, e in enumerate(response.record.energy) if e == 0.0]

    feas_sols = response.record.sample[filter_idx]

    return feas_sols
```

```python
# Let's install with dimod and neal
if IN_COLAB:
    !pip install dimod
    !pip install dwave-neal
```

```python
# Import the Dwave packages dimod and neal
import dimod
import neal
```

We will use simulated annealing to solve this QUBO. In this case we want a broad sample of different feasible solutions, so solving this QUBO via MIP is not a great idea.

```python
simAnnSampler = neal.SimulatedAnnealingSampler()
```

```python
feas_sols = get_feasible(A, b, sampler=simAnnSampler, samples = 20)
print(len(feas_sols), ' feasible solutions found.')
```

    20  feasible solutions found.

We take 20 samples using simulated annealing and notice that most (if not all of them) are feasible and different. Let's now apply the augmentation procedure to each one of them and record the final objective and the number of iterations it takes. Here we will use the 3rd augmentation strategy (Greedy) because of runtime.

```python
init_obj = np.zeros((len(feas_sols),1))
iters_full = np.zeros((len(feas_sols),1))
final_obj_full = np.zeros((len(feas_sols),1))
times_full = np.zeros((len(feas_sols),1))
for i,sol in enumerate(feas_sols):
    if not const(sol):
        print("Infeasible")
        pass
    init_obj[i] = f(sol)
    start = time.process_time()
    iter, f_obj, xf = augmentation(grav = r, func = f, x = sol, OPTION=3,VERBOSE=False)
    times_full[i] = time.process_time() - start
    iters_full[i] = iter
    final_obj_full[i] = f_obj
```

We record the initial objective function, the one after doing the augmentation, and the number of augmentation steps.

```python
fig, ax1 = plt.subplots()

ax2 = ax1.twinx()

ax1.plot(init_obj, marker='o', ls='-', label='Initial')
ax1.plot(final_obj_full, marker='*', ls='-', label='Final')
ax1.set_ylabel('Objective function')
ax1.set_xlabel('Feasible solution')

color = 'tab:green'
ax2.set_ylabel('iterations', color=color)
ax2.plot(iters_full, color=color, marker='s', ls='')
ax2.tick_params(axis='y', labelcolor=color)
ax1.legend()
```

Notice that we reach the global optimal solution regardless of the initial point, and that even if the initial objective was closer to the optimal objective function, it might take more iterations to reach the optimum.

Now let's try an extreme case, where we only have 10 of the elements of the Graver basis.

```python
n_draws = 10  # for 10 random indices
index = np.random.choice(r.shape[0], n_draws, replace=False)

iters_few = np.zeros((len(feas_sols),1))
final_obj_few = np.zeros((len(feas_sols),1))
times_few = np.zeros((len(feas_sols),1))
for i, sol in enumerate(feas_sols):
    if not const(sol):
        print("Infeasible")
        pass
    start = time.process_time()
    iter, f_obj, xf = augmentation(grav = r[index], x = sol, func = f, OPTION=3,VERBOSE=False)
    times_few[i] = time.process_time() - start
    iters_few[i] = iter
    final_obj_few[i] = f_obj
```

```python
fig, ax1 = plt.subplots()

ax2 = ax1.twinx()

ax1.plot(init_obj, marker='o', ls='-', label='Initial')
ax1.plot(final_obj_few, marker='*', ls='-', label='Final')
ax1.set_ylabel('Objective function')
ax1.set_xlabel('Feasible solution')

color = 'tab:green'
ax2.set_ylabel('iterations', color=color)
ax2.plot(iters_few, color=color, marker='s', ls='')
ax2.tick_params(axis='y', labelcolor=color)
ax1.legend()
```

Here we can barely improve the objective and can only perform a few iteration before we cannot improve the solution. But if we compare the runtimes in both cases we find that...

```python
fig, ax1 = plt.subplots()

ax1.plot(times_full, marker='o', ls='-', label='Full Graver')
ax1.plot(times_few, marker='*', ls='-', label='10 Graver elements')
ax1.set_ylabel('Time [s]')
ax1.set_xlabel('Feasible solution')
plt.legend()
```

The time to do augmentation only having 10 choices is minimal. We can seach for a sweet spot in between, with good solutions and little time.

```python
N = 10 # Discretization of the fractions of Graver considered
iters = np.zeros((len(feas_sols),N-1))
final_obj = np.zeros((len(feas_sols),N-1))
times = np.zeros((len(feas_sols),N-1))
for j in range(1,N):

    n_draws = r.shape[0]//N*j  # for 10 random indices
    index = np.random.choice(r.shape[0], n_draws, replace=False)

    for i, sol in enumerate(feas_sols):
        if not const(sol):
            print("Infeasible")
            pass
        start = time.process_time()
        iter, f_obj, xf = augmentation(grav = r[index], x = sol, func = f, OPTION=3,VERBOSE=False)
        times[i,j-1] = time.process_time() - start
        iters[i,j-1] = iter
        final_obj[i,j-1] = f_obj
```

```python
fig, ax1 = plt.subplots()
x_label = [str(int(100*i/N)/100) + '|G|' for i in range(1,N)]
x_label.insert(0,'10 samples')
x_label.append('|G|')
x_label_obj = x_label[:]
x_label_obj.insert(0,'initial')

ax1.boxplot(np.hstack((init_obj, final_obj_few, final_obj, final_obj_full)))
ax1.set_xticklabels(x_label_obj)
plt.ylabel('Objective value')
```

```python
fig, ax1 = plt.subplots()

ax1.boxplot(np.hstack((times_few, times, times_full)))
ax1.set_xticklabels(x_label)
plt.ylabel('Time [s]')
```

```python
fig, ax1 = plt.subplots()

ax1.boxplot(np.hstack((iters_few, iters, iters_full)))
ax1.set_xticklabels(x_label)
plt.ylabel('Iterations')
```

```python

```