# Estimating Price Elasticity of Orange Juice Demand

### Summary of Contents:
1. [Data](#data)
2. [A Gentle Start: Naive ML](#naive)
3. [Parametric Heterogeneity: Double Machine Learning](#dml)
4. [Nonparametric Heterogeneity: Orthogonal Random Forest](#orf)

**Important:** This notebook provides some room for experimentation in solving this problem. If you are looking for the end-to-end solution, look for the same file name with the "Complete" suffix added.

# 1. Data

We use Dominick’s dataset, [a popular historical dataset](https://www.chicagobooth.edu/research/kilts/datasets/dominicks) of store-level orange juice prices and sales provided by University of Chicago Booth School of Business.

The dataset has ~29,000 observations and includes the store level, weekly prices and sales of 3 different brands of orange juice over 7 years in Chicago area. The dataset also contains 17 continuous and categorical variables that encode store-level customer information such as the age, log income, education level, etc, as well as brand information. We define the following variables:

* Y: Orange Juice sales (log)
* T: Price (log)
* X: Income (feature of heterogeneity)
* W: Store-level features (education, age, etc.)
* $\theta(X)$: Effect of price on sales (Orange juice elasticity)

The goal is to learn the elasticity of orange juice as a function of income in the presence of high-dimensional controls. Thus, we model the world as follows:
$$
\begin{align}
Y & = \theta(X) \cdot T + f(W) + \epsilon\\
T &= g(W) + \eta
\end{align}
$$
where $\epsilon, \eta$ are uncorrelated error terms.

```python
# Helper imports
import os
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
import urllib.request
from itertools import product
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import Lasso, LassoCV, LinearRegression, \
                                 MultiTaskElasticNet, MultiTaskElasticNetCV
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
import statsmodels.api as sm

# EconML imports
from econml.bootstrap import BootstrapEstimator
from econml.dml import DMLCateEstimator, SparseLinearDMLCateEstimator
from econml.ortho_forest import ContinuousTreatmentOrthoForest, WeightedModelWrapper
from econml.utilities import cross_product

%matplotlib inline
```

```python
# Import the data
file_name = "oj_large.csv"

if not os.path.isfile(file_name):
    print("Downloading file (this might take a few seconds)...")
    urllib.request.urlretrieve("https://msalicedatapublic.blob.core.windows.net/datasets/OrangeJuice/oj_large.csv", file_name)
oj_data = pd.read_csv(file_name)
```

## 1.1 Data Exploration

```python
oj_data.head()
```

      store
      brand
      week
      logmove
      feat
      price
      AGE60
      EDUC
      ETHNIC
      INCOME
      HHLARGE
      WORKWOM
      HVAL150
      SSTRDIST
      SSTRVOL
      CPDIST5
      CPWVOL5

      0
      2
      tropicana
      40
      9.018695
      0
      3.87
      0.232865
      0.248935
      0.11428
      10.553205
      0.103953
      0.303585
      0.463887
      2.110122
      1.142857
      1.92728
      0.376927

      1
      2
      tropicana
      46
      8.723231
      0
      3.87
      0.232865
      0.248935
      0.11428
      10.553205
      0.103953
      0.303585
      0.463887
      2.110122
      1.142857
      1.92728
      0.376927

      2
      2
      tropicana
      47
      8.253228
      0
      3.87
      0.232865
      0.248935
      0.11428
      10.553205
      0.103953
      0.303585
      0.463887
      2.110122
      1.142857
      1.92728
      0.376927

      3
      2
      tropicana
      48
      8.987197
      0
      3.87
      0.232865
      0.248935
      0.11428
      10.553205
      0.103953
      0.303585
      0.463887
      2.110122
      1.142857
      1.92728
      0.376927

      4
      2
      tropicana
      50
      9.093357
      0
      3.87
      0.232865
      0.248935
      0.11428
      10.553205
      0.103953
      0.303585
      0.463887
      2.110122
      1.142857
      1.92728
      0.376927

```python
oj_data.shape
```

    (28947, 17)

```python
sns.scatterplot(x="price", y="logmove", hue="brand", data=oj_data)
plt.title("Demand vs price of OJ for different brands")
```

## 1.2 Data Preprocessing

```python
def process_data(data):
    # Prepare data
    Y = data['logmove'].values
    T = np.log(data["price"]).values
    scaler = StandardScaler()
    W1 = scaler.fit_transform(data[[c for c in data.columns if c not in ['price', 'logmove', 'brand', 'week', 'store','INCOME']]].values)
    W2 = pd.get_dummies(data['brand'], drop_first=True).values
    W = np.concatenate([W1, W2], axis=1)
    X = data[['INCOME']].values
    return Y, T, W, X

Y, T, W, X = process_data(oj_data)
```

```python
## Generate test data
min_income = 10.0
max_income = 11.1
delta = (max_income - min_income) / 100
X_test = np.arange(min_income, max_income + delta - 0.001, delta).reshape(-1, 1)
```

# 2. A Gentle Start: Naive ML

If we didn't know anything about causal inference, we might have tried some standard ML techniques to capture the treatment effect heterogeneity. In this section, we try a naive regression model $Y \sim T$:

$$
\begin{align}
Y &= \theta(X)\cdot T+c + \epsilon \\
\theta(X)&=a+b\cdot X + \eta
\end{align}
$$
where $\epsilon, \eta$ are independent errors.

```python
poly = PolynomialFeatures(1, include_bias=True)
XT = cross_product(poly.fit_transform(X), T)
```

```python
### DIY section!
# Fit a linear model Y ~ XT. Solution here:

# Calculate predictions for X_test using the coefficients `a` and `b` from the above model.
te_pred = None
```

```python
# Uncomment to plot results
"""
plt.plot(X_test, te_pred, label="OJ Elasticity")
plt.xlabel(r'Income')
plt.ylabel('Orange Juice Elasticity')
plt.legend()
plt.title("Orange Juice Elasticity vs Income")
plt.show()
"""
```

    '\nplt.plot(X_test, te_pred, label="OJ Elasticity")\nplt.xlabel(r\'Income\')\nplt.ylabel(\'Orange Juice Elasticity\')\nplt.legend()\nplt.title("Orange Juice Elasticity vs Income")\nplt.show()\n'

Intuitively, we expect higher income populations to be less price-sensitive and hence have larger elasticities. Is this true from the plot above?

In the next section, we apply `econml` estimators to see if we can remedy the issue we encountered before.

# 3. Parametric Heterogeneity: Double Machine Learning

We apply the `DMLCateEstimator` in `econml` package to estimate orange juice price elasticity
as a function of income.

## 3.1 Default setting: Linear heterogeneity

```python
est = DMLCateEstimator(model_y=RandomForestRegressor(n_estimators=100),
                       model_t=RandomForestRegressor(n_estimators=100))
est.fit(Y, T, X, W)
te_pred=est.effect(X_test)
```

```python
# Plot Orange Juice elasticity as a function of income
plt.plot(X_test, te_pred, label="OJ Elasticity")
plt.xlabel(r'Income')
plt.ylabel('Orange Juice Elasticity')
plt.legend()
plt.title("Orange Juice Elasticity vs Income")
plt.show()
```

```python
# Boostrap confidence interval
boot_est = BootstrapEstimator(DMLCateEstimator(model_y=RandomForestRegressor(n_estimators=100),
                                               model_t=RandomForestRegressor(n_estimators=100)),
                              n_bootstrap_samples=20)
boot_est.fit(Y, T, X, W)
te_pred_interval = boot_est.effect_interval(X_test, lower=1, upper=99)
```

    [Parallel(n_jobs=1)]: Using backend SequentialBackend with 1 concurrent workers.
    [Parallel(n_jobs=1)]: Done   1 out of   1 | elapsed:    7.8s remaining:    0.0s
    [Parallel(n_jobs=1)]: Done   2 out of   2 | elapsed:   16.4s remaining:    0.0s
    [Parallel(n_jobs=1)]: Done  20 out of  20 | elapsed:  2.7min finished
    [Parallel(n_jobs=1)]: Using backend SequentialBackend with 1 concurrent workers.
    [Parallel(n_jobs=1)]: Done   1 out of   1 | elapsed:    0.0s remaining:    0.0s
    [Parallel(n_jobs=1)]: Done   2 out of   2 | elapsed:    0.0s remaining:    0.0s
    [Parallel(n_jobs=1)]: Done  20 out of  20 | elapsed:    0.0s finished

```python
# Plot Orange Juice elasticity as a function of income
plt.plot(X_test.flatten(), te_pred, label="OJ Elasticity")
plt.fill_between(X_test.flatten(), te_pred_interval[0], te_pred_interval[1], alpha=.5, label="1-99% CI")
plt.xlabel(r'Income')
plt.ylabel('Orange Juice Elasticity')
plt.title("Orange Juice Elasticity vs Income")
plt.legend()
plt.show()
```

The estimates look more reasonable now! The slope has an upwards trend and the estimates are in the expected range $(-3, -2)$. Let's see what happens if we add some more structure to $\theta(X)$.

## 3.2 Polynomial heterogeneity

In this section, we assume a polynomial form for $\theta(X)$.

```python
est = DMLCateEstimator(model_y=RandomForestRegressor(n_estimators=100),
                       model_t=RandomForestRegressor(n_estimators=100),
                       featurizer=PolynomialFeatures(degree=3))
est.fit(Y, T, X, W)
te_pred=est.effect(X_test)
```

```python
# Plot Orange Juice elasticity as a function of income
plt.plot(X_test, te_pred, label="OJ Elasticity")
plt.xlabel(r'Income')
plt.ylabel('Orange Juice Elasticity')
plt.legend()
plt.title("Orange Juice Elasticity vs Income")
plt.show()
```

```python
### DIY section!
# Calculate the boostrap confidence intervals using the Bootstrap Estimator
# See section 3.1 for inspiration
te_pred_interval = None
```

```python
# Uncomment to plot results
"""
plt.plot(X_test.flatten(), te_pred, label="OJ Elasticity")
plt.fill_between(X_test.flatten(), te_pred_interval[0], te_pred_interval[1], alpha=.5, label="1-99% CI")
plt.xlabel(r'Income')
plt.ylabel('Orange Juice Elasticity')
plt.title("Orange Juice Elasticity vs Income")
plt.legend()
plt.show()
"""
```

    '\nplt.plot(X_test.flatten(), te_pred, label="OJ Elasticity")\nplt.fill_between(X_test.flatten(), te_pred_interval[0], te_pred_interval[1], alpha=.5, label="1-99% CI")\nplt.xlabel(r\'Income\')\nplt.ylabel(\'Orange Juice Elasticity\')\nplt.title("Orange Juice Elasticity vs Income")\nplt.legend()\nplt.show()\n'

## 3.3 Model Selection

Here we try different polynomial degrees in our final model to compare the model performance. The score of final model can be seen as the proxy of the score of the treatment effect when we don't know the true treatment effect. We can use this function to do model selection or parameter tuning.

```python
X_train, X_test, Y_train, Y_test, \
W_train, W_test, T_train, T_test = train_test_split(X, Y, W, T, test_size=0.3, random_state=42) # 42, the meaning of life
```

```python
### DIY section!
# Calculate scores for different polynomial featurizations (try polynomial degrees [1,2,3,4,5])
# Hint: the dml estimators have a method `score` that you can call like est.score(Y_test, T_test, X_test, W_test)
score = {}
```

```python
# Uncomment once you have the scores calculated
"""
score
"""
```

    '\nscore\n'

```python
# Uncomment once you have the scores calculated
"""
print("Best model selected by score: ", min(score, key=lambda x: score.get(x)))
"""
```

    '\nprint("Best model selected by score: ", min(score, key=lambda x: score.get(x)))\n'

# 4. Nonparametric Heterogeneity: Orthogonal Random Forest

Double Machine Learning is a great technique if we have some priors on what the treatment effect function should look like. However, if we want a fully non-parametric estimation of the treatment effect, we need to use different tools.

In this section, we apply the `ContinuousTreatmentOrthoForest` in `econml` to estimate orange juice price elasticity
as a function of income. This is a fully non-parametric model based on Random Forests.

```python
# Define some parameters
n_trees = 500
min_leaf_size = 50
max_depth = 20
subsample_ratio = 0.02
bootstrap = False
```

```python
# Define the estimator
est = ContinuousTreatmentOrthoForest(
        n_trees=n_trees, min_leaf_size=min_leaf_size, max_depth=max_depth,
        subsample_ratio=subsample_ratio, bootstrap=bootstrap,
        model_T=Lasso(alpha=0.1),
        model_Y=Lasso(alpha=0.1),
        model_T_final=WeightedModelWrapper(LassoCV(cv=3), sample_type="weighted"),
        model_Y_final=WeightedModelWrapper(LassoCV(cv=3), sample_type="weighted")
       )
```

```python
est.fit(Y, T, X, W)
```

    [Parallel(n_jobs=-1)]: Using backend LokyBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   13.8s
    [Parallel(n_jobs=-1)]: Done 500 out of 500 | elapsed:   14.4s finished
    [Parallel(n_jobs=-1)]: Using backend LokyBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.1s
    [Parallel(n_jobs=-1)]: Done 500 out of 500 | elapsed:    0.5s finished

    <econml.ortho_forest.ContinuousTreatmentOrthoForest at 0x1c401eca080>

```python
min_income = 10.0
max_income = 11.1
delta = (max_income - min_income) / 100
X_test = np.arange(min_income, max_income + delta - 0.001, delta).reshape(-1, 1)
```

```python
te_pred = est.effect(X_test)
```

    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   18.0s
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  2.6min finished

```python
# Plot Orange Juice elasticity as a function of income
plt.plot(X_test.flatten(), te_pred[:, 0], label="OJ Elasticity")
plt.xlabel(r'Income')
plt.ylabel('Orange Juice Elasticity')
plt.legend()
plt.title("Orange Juice Elasticity vs Income")
plt.show()
```

We note that the treatment effect has an upwards trend, but most of the variation is concentrated in the middle range of the $\log$(INCOME).

We can also use a bootstrap estimator to generate confidence intervals. Since Orthogonal Forests are non-parametric models, we expect the estimates to be less biased, but the variance to increase, i.e. the confidence intervals to be wider.

**Note:** In order to return results in a few minutes we're limiting the number of trees to 100 and the number of bootstrap samples to 10 in the code below, but for better estimates these numbers can be increased at the cost of increased runtime. We suggest you don't execute the following cells if you are running this notebooks on a resource-constrained machines.

```python
boot_est = BootstrapEstimator(ContinuousTreatmentOrthoForest(
        n_trees=100, min_leaf_size=min_leaf_size, max_depth=max_depth,
        subsample_ratio=subsample_ratio, bootstrap=bootstrap,
        model_T=Lasso(alpha=0.1),
        model_Y=Lasso(alpha=0.1),
        model_T_final=WeightedModelWrapper(LassoCV(cv=3), sample_type="weighted"),
        model_Y_final=WeightedModelWrapper(LassoCV(cv=3), sample_type="weighted")
       ), n_bootstrap_samples=10, n_jobs=-1)
```

```python
boot_est.fit(Y, T, X, W)
te_pred_interval = boot_est.effect_interval(X_test, lower=1, upper=99)
```

    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    1.0s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    1.1s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    1.2s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    1.3s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    1.3s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    1.3s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    1.5s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    1.6s
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    4.2s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    4.3s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    4.2s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    4.2s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    4.3s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    4.3s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    4.3s finished
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    4.5s finished
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.7s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.8s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.7s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.7s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.7s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.7s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.9s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.8s
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    3.8s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    3.8s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    4.0s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    3.8s finished
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    3.8s finished
    [Parallel(n_jobs=-1)]: Done   3 out of  10 | elapsed:    9.1s remaining:   21.4s
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    3.8s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    3.7s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    3.7s finished
    [Parallel(n_jobs=-1)]: Done   7 out of  10 | elapsed:    9.2s remaining:    3.9s
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.2s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.2s
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    0.9s finished
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    0.7s finished
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.1s
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:    0.1s
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    0.8s finished
    [Parallel(n_jobs=-1)]: Done 100 out of 100 | elapsed:    0.7s finished
    [Parallel(n_jobs=-1)]: Done  10 out of  10 | elapsed:   11.2s finished
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   57.8s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   57.8s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   57.9s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   58.3s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   59.2s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   59.3s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   59.9s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:  1.0min
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  6.7min finished
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  6.8min finished
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Using backend ThreadingBackend with 8 concurrent workers.
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  6.8min finished
    [Parallel(n_jobs=-1)]: Done   3 out of  10 | elapsed:  6.8min remaining: 15.9min
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  6.9min finished
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  6.9min finished
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  6.9min finished
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  7.0min finished
    [Parallel(n_jobs=-1)]: Done   7 out of  10 | elapsed:  7.0min remaining:  3.0min
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  7.0min finished
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   22.2s
    [Parallel(n_jobs=-1)]: Done  16 tasks      | elapsed:   24.0s
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  1.7min finished
    [Parallel(n_jobs=-1)]: Done 101 out of 101 | elapsed:  1.7min finished
    [Parallel(n_jobs=-1)]: Done  10 out of  10 | elapsed:  8.5min finished

```python
plt.plot(np.ndarray.flatten(X_test), te_pred[:, 0], label="OJ Elasticity")
plt.fill_between(np.ndarray.flatten(X_test),
                 te_pred_interval[0][:, 0],
                 te_pred_interval[1][:, 0], alpha=.5, label="1-99% CI")
plt.xlabel(r'Income')
plt.ylabel('Orange Juice Elasticity')
plt.title("Orange Juice Elasticity vs Income")
plt.legend()
plt.show()
```

```python

```

notebooks/Hands-on Part I -- Estimating Price Elasticity of Orange Juice Demand.ipynb


# Dynamic factors and coincident indices

Factor models generally try to find a small number of unobserved "factors" that influence a substantial portion of the variation in a larger number of observed variables, and they are related to dimension-reduction techniques such as principal components analysis. Dynamic factor models explicitly model the transition dynamics of the unobserved factors, and so are often applied to time-series data.

Macroeconomic coincident indices are designed to capture the common component of the "business cycle"; such a component is assumed to simultaneously affect many macroeconomic variables. Although the estimation and use of coincident indices (for example the [Index of Coincident Economic Indicators](http://www.newyorkfed.org/research/regional_economy/coincident_summary.html)) pre-dates dynamic factor models, in several influential papers Stock and Watson (1989, 1991) used a dynamic factor model to provide a theoretical foundation for them.

Below, we follow the treatment found in Kim and Nelson (1999), of the Stock and Watson (1991) model, to formulate a dynamic factor model, estimate its parameters via maximum likelihood, and create a coincident index.

## Macroeconomic data

The coincident index is created by considering the comovements in four macroeconomic variables (versions of these variables are available on [FRED](https://research.stlouisfed.org/fred2/); the ID of the series used below is given in parentheses):

- Industrial production (IPMAN)
- Real aggregate income (excluding transfer payments) (W875RX1)
- Manufacturing and trade sales (CMRMTSPL)
- Employees on non-farm payrolls (PAYEMS)

In all cases, the data is at the monthly frequency and has been seasonally adjusted; the time-frame considered is 1972 - 2005.

```python
%matplotlib inline

import numpy as np
import pandas as pd
import statsmodels.api as sm
import matplotlib.pyplot as plt

np.set_printoptions(precision=4, suppress=True, linewidth=120)
```

```python
from pandas_datareader.data import DataReader

# Get the datasets from FRED
start = '1979-01-01'
end = '2014-12-01'
indprod = DataReader('IPMAN', 'fred', start=start, end=end)
income = DataReader('W875RX1', 'fred', start=start, end=end)
sales = DataReader('CMRMTSPL', 'fred', start=start, end=end)
emp = DataReader('PAYEMS', 'fred', start=start, end=end)
# dta = pd.concat((indprod, income, sales, emp), axis=1)
# dta.columns = ['indprod', 'income', 'sales', 'emp']
```

**Note**: in a recent update on FRED (8/12/15) the time series CMRMTSPL was truncated to begin in 1997; this is probably a mistake due to the fact that CMRMTSPL is a spliced series, so the earlier period is from the series HMRMT and the latter period is defined by CMRMT.

This has since (02/11/16) been corrected, however the series could also be constructed by hand from HMRMT and CMRMT, as shown below (process taken from the notes in the Alfred xls file).

```python
# HMRMT = DataReader('HMRMT', 'fred', start='1967-01-01', end=end)
# CMRMT = DataReader('CMRMT', 'fred', start='1997-01-01', end=end)
```

```python
# HMRMT_growth = HMRMT.diff() / HMRMT.shift()
# sales = pd.Series(np.zeros(emp.shape[0]), index=emp.index)

# # Fill in the recent entries (1997 onwards)
# sales[CMRMT.index] = CMRMT

# # Backfill the previous entries (pre 1997)
# idx = sales.loc[:'1997-01-01'].index
# for t in range(len(idx)-1, 0, -1):
#     month = idx[t]
#     prev_month = idx[t-1]
#     sales.loc[prev_month] = sales.loc[month] / (1 + HMRMT_growth.loc[prev_month].values)
```

```python
dta = pd.concat((indprod, income, sales, emp), axis=1)
dta.columns = ['indprod', 'income', 'sales', 'emp']
dta.index.freq = dta.index.inferred_freq
```

```python
dta.loc[:, 'indprod':'emp'].plot(subplots=True, layout=(2, 2), figsize=(15, 6));
```

Stock and Watson (1991) report that for their datasets, they could not reject the null hypothesis of a unit root in each series (so the series are integrated), but they did not find strong evidence that the series were co-integrated.

As a result, they suggest estimating the model using the first differences (of the logs) of the variables, demeaned and standardized.

```python
# Create log-differenced series
dta['dln_indprod'] = (np.log(dta.indprod)).diff() * 100
dta['dln_income'] = (np.log(dta.income)).diff() * 100
dta['dln_sales'] = (np.log(dta.sales)).diff() * 100
dta['dln_emp'] = (np.log(dta.emp)).diff() * 100

# De-mean and standardize
dta['std_indprod'] = (dta['dln_indprod'] - dta['dln_indprod'].mean()) / dta['dln_indprod'].std()
dta['std_income'] = (dta['dln_income'] - dta['dln_income'].mean()) / dta['dln_income'].std()
dta['std_sales'] = (dta['dln_sales'] - dta['dln_sales'].mean()) / dta['dln_sales'].std()
dta['std_emp'] = (dta['dln_emp'] - dta['dln_emp'].mean()) / dta['dln_emp'].std()
```

## Dynamic factors

A general dynamic factor model is written as:

$$
\begin{align}
y_t & = \Lambda f_t + B x_t + u_t \\
f_t & = A_1 f_{t-1} + \dots + A_p f_{t-p} + \eta_t \qquad \eta_t \sim N(0, I)\\
u_t & = C_1 u_{t-1} + \dots + C_q u_{t-q} + \varepsilon_t \qquad \varepsilon_t \sim N(0, \Sigma)
\end{align}
$$

where $y_t$ are observed data, $f_t$ are the unobserved factors (evolving as a vector autoregression), $x_t$ are (optional) exogenous variables, and $u_t$ is the error, or "idiosyncratic", process ($u_t$ is also optionally allowed to be autocorrelated). The $\Lambda$ matrix is often referred to as the matrix of "factor loadings". The variance of the factor error term is set to the identity matrix to ensure identification of the unobserved factors.

This model can be cast into state space form, and the unobserved factor estimated via the Kalman filter. The likelihood can be evaluated as a byproduct of the filtering recursions, and maximum likelihood estimation used to estimate the parameters.

## Model specification

The specific dynamic factor model in this application has 1 unobserved factor which is assumed to follow an AR(2) process. The innovations $\varepsilon_t$ are assumed to be independent (so that $\Sigma$ is a diagonal matrix) and the error term associated with each equation, $u_{i,t}$ is assumed to follow an independent AR(2) process.

Thus the specification considered here is:

$$
\begin{align}
y_{i,t} & = \lambda_i f_t + u_{i,t} \\
u_{i,t} & = c_{i,1} u_{1,t-1} + c_{i,2} u_{i,t-2} + \varepsilon_{i,t} \qquad & \varepsilon_{i,t} \sim N(0, \sigma_i^2) \\
f_t & = a_1 f_{t-1} + a_2 f_{t-2} + \eta_t \qquad & \eta_t \sim N(0, I)\\
\end{align}
$$

where $i$ is one of: `[indprod, income, sales, emp ]`.

This model can be formulated using the `DynamicFactor` model built-in to statsmodels. In particular, we have the following specification:

- `k_factors = 1` - (there is 1 unobserved factor)
- `factor_order = 2` - (it follows an AR(2) process)
- `error_var = False` - (the errors evolve as independent AR processes rather than jointly as a VAR - note that this is the default option, so it is not specified below)
- `error_order = 2` - (the errors are autocorrelated of order 2: i.e. AR(2) processes)
- `error_cov_type = 'diagonal'` - (the innovations are uncorrelated; this is again the default)

Once the model is created, the parameters can be estimated via maximum likelihood; this is done using the `fit()` method.

**Note**: recall that we have demeaned and standardized the data; this will be important in interpreting the results that follow.

**Aside**: in their empirical example, Kim and Nelson (1999) actually consider a slightly different model in which the employment variable is allowed to also depend on lagged values of the factor - this model does not fit into the built-in `DynamicFactor` class, but can be accommodated by using a subclass to implement the required new parameters and restrictions - see Appendix A, below.

## Parameter estimation

Multivariate models can have a relatively large number of parameters, and it may be difficult to escape from local minima to find the maximized likelihood. In an attempt to mitigate this problem, I perform an initial maximization step (from the model-defined starting parameters) using the modified Powell method available in Scipy (see the minimize documentation for more information). The resulting parameters are then used as starting parameters in the standard LBFGS optimization method.

```python
# Get the endogenous data
endog = dta.loc['1979-02-01':, 'std_indprod':'std_emp']

# Create the model
mod = sm.tsa.DynamicFactor(endog, k_factors=1, factor_order=2, error_order=2)
initial_res = mod.fit(method='powell', disp=False)
res = mod.fit(initial_res.params, disp=False)
```

    /home/travis/build/statsmodels/statsmodels/statsmodels/base/model.py:568: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals
      ConvergenceWarning)

## Estimates

Once the model has been estimated, there are two components that we can use for analysis or inference:

- The estimated parameters
- The estimated factor

### Parameters

The estimated parameters can be helpful in understanding the implications of the model, although in models with a larger number of observed variables and / or unobserved factors they can be difficult to interpret.

One reason for this difficulty is due to identification issues between the factor loadings and the unobserved factors. One easy-to-see identification issue is the sign of the loadings and the factors: an equivalent model to the one displayed below would result from reversing the signs of all factor loadings and the unobserved factor.

Here, one of the easy-to-interpret implications in this model is the persistence of the unobserved factor: we find that exhibits substantial persistence.

```python
print(res.summary(separate_params=False))
```

                                                 Statespace Model Results
    =================================================================================================================
    Dep. Variable:     ['std_indprod', 'std_income', 'std_sales', 'std_emp']   No. Observations:                  431
    Model:                                 DynamicFactor(factors=1, order=2)   Log Likelihood               -2065.023
                                                              + AR(2) errors   AIC                           4166.047
    Date:                                                   Tue, 02 Feb 2021   BIC                           4239.237
    Time:                                                           06:55:41   HQIC                          4194.945
    Sample:                                                       02-01-1979
                                                                - 12-01-2014
    Covariance Type:                                                     opg
    ====================================================================================================
                                           coef    std err          z      P>|z|      [0.025      0.975]
    ----------------------------------------------------------------------------------------------------
    loading.f1.std_indprod              -0.8559      0.016    -52.146      0.000      -0.888      -0.824
    loading.f1.std_income               -0.2515      0.034     -7.425      0.000      -0.318      -0.185
    loading.f1.std_sales                -0.4757      0.024    -19.974      0.000      -0.522      -0.429
    loading.f1.std_emp                  -0.2721      0.027    -10.039      0.000      -0.325      -0.219
    sigma2.std_indprod                8.559e-15   1.38e-09   6.21e-06      1.000    -2.7e-09     2.7e-09
    sigma2.std_income                    0.9031      0.020     46.067      0.000       0.865       0.941
    sigma2.std_sales                     0.6022      0.035     17.302      0.000       0.534       0.670
    sigma2.std_emp                       0.3707      0.015     25.456      0.000       0.342       0.399
    L1.f1.f1                             0.2210      0.017     12.884      0.000       0.187       0.255
    L2.f1.f1                             0.2766      0.021     13.125      0.000       0.235       0.318
    L1.e(std_indprod).e(std_indprod)    -1.8926      0.002  -1148.990      0.000      -1.896      -1.889
    L2.e(std_indprod).e(std_indprod)    -1.0000   9.78e-06  -1.02e+05      0.000      -1.000      -1.000
    L1.e(std_income).e(std_income)      -0.1542      0.019     -8.279      0.000      -0.191      -0.118
    L2.e(std_income).e(std_income)      -0.0874      0.020     -4.265      0.000      -0.128      -0.047
    L1.e(std_sales).e(std_sales)        -0.4418      0.031    -14.375      0.000      -0.502      -0.382
    L2.e(std_sales).e(std_sales)        -0.2077      0.021     -9.977      0.000      -0.248      -0.167
    L1.e(std_emp).e(std_emp)             0.3046      0.034      8.978      0.000       0.238       0.371
    L2.e(std_emp).e(std_emp)             0.4799      0.029     16.622      0.000       0.423       0.536
    ====================================================================================================
    Ljung-Box (L1) (Q):     0.81, 0.05, 0.06, 5.99   Jarque-Bera (JB):   194.03, 9286.86, 20.12, 4165.97
    Prob(Q):                0.37, 0.83, 0.81, 0.01   Prob(JB):                    0.00, 0.00, 0.00, 0.00
    Heteroskedasticity (H): 0.80, 4.82, 0.47, 0.41   Skew:                       0.07, -0.98, 0.19, 0.86
    Prob(H) (two-sided):    0.19, 0.00, 0.00, 0.00   Kurtosis:                  6.28, 25.66, 3.99, 18.13
    ====================================================================================================

    Warnings:
    [1] Covariance matrix calculated using the outer product of gradients (complex-step).
    [2] Covariance matrix is singular or near-singular, with condition number 7.69e+16. Standard errors may be unstable.

### Estimated factors

While it can be useful to plot the unobserved factors, it is less useful here than one might think for two reasons:

1. The sign-related identification issue described above.
2. Since the data was differenced, the estimated factor explains the variation in the differenced data, not the original data.

It is for these reasons that the coincident index is created (see below).

With these reservations, the unobserved factor is plotted below, along with the NBER indicators for US recessions. It appears that the factor is successful at picking up some degree of business cycle activity.

```python
fig, ax = plt.subplots(figsize=(13,3))

# Plot the factor
dates = endog.index._mpl_repr()
ax.plot(dates, res.factors.filtered[0], label='Factor')
ax.legend()

# Retrieve and also plot the NBER recession indicators
rec = DataReader('USREC', 'fred', start=start, end=end)
ylim = ax.get_ylim()
ax.fill_between(dates[:-3], ylim[0], ylim[1], rec.values[:-4,0], facecolor='k', alpha=0.1);
```

## Post-estimation

Although here we will be able to interpret the results of the model by constructing the coincident index, there is a useful and generic approach for getting a sense for what is being captured by the estimated factor. By taking the estimated factors as given, regressing them (and a constant) each (one at a time) on each of the observed variables, and recording the coefficients of determination ($R^2$ values), we can get a sense of the variables for which each factor explains a substantial portion of the variance and the variables for which it does not.

In models with more variables and more factors, this can sometimes lend interpretation to the factors (for example sometimes one factor will load primarily on real variables and another on nominal variables).

In this model, with only four endogenous variables and one factor, it is easy to digest a simple table of the $R^2$ values, but in larger models it is not. For this reason, a bar plot is often employed; from the plot we can easily see that the factor explains most of the variation in industrial production index and a large portion of the variation in sales and employment, it is less helpful in explaining income.

```python
res.plot_coefficients_of_determination(figsize=(8,2));
```

## Coincident Index

As described above, the goal of this model was to create an interpretable series which could be used to understand the current status of the macroeconomy. This is what the coincident index is designed to do. It is constructed below. For readers interested in an explanation of the construction, see Kim and Nelson (1999) or Stock and Watson (1991).

In essence, what is done is to reconstruct the mean of the (differenced) factor. We will compare it to the coincident index on published by the Federal Reserve Bank of Philadelphia (USPHCI on FRED).

```python
usphci = DataReader('USPHCI', 'fred', start='1979-01-01', end='2014-12-01')['USPHCI']
usphci.plot(figsize=(13,3));
```

```python
dusphci = usphci.diff()[1:].values
def compute_coincident_index(mod, res):
    # Estimate W(1)
    spec = res.specification
    design = mod.ssm['design']
    transition = mod.ssm['transition']
    ss_kalman_gain = res.filter_results.kalman_gain[:,:,-1]
    k_states = ss_kalman_gain.shape[0]

    W1 = np.linalg.inv(np.eye(k_states) - np.dot(
        np.eye(k_states) - np.dot(ss_kalman_gain, design),
        transition
    )).dot(ss_kalman_gain)[0]

    # Compute the factor mean vector
    factor_mean = np.dot(W1, dta.loc['1972-02-01':, 'dln_indprod':'dln_emp'].mean())

    # Normalize the factors
    factor = res.factors.filtered[0]
    factor *= np.std(usphci.diff()[1:]) / np.std(factor)

    # Compute the coincident index
    coincident_index = np.zeros(mod.nobs+1)
    # The initial value is arbitrary; here it is set to
    # facilitate comparison
    coincident_index[0] = usphci.iloc[0] * factor_mean / dusphci.mean()
    for t in range(0, mod.nobs):
        coincident_index[t+1] = coincident_index[t] + factor[t] + factor_mean

    # Attach dates
    coincident_index = pd.Series(coincident_index, index=dta.index).iloc[1:]

    # Normalize to use the same base year as USPHCI
    coincident_index *= (usphci.loc['1992-07-01'] / coincident_index.loc['1992-07-01'])

    return coincident_index
```

Below we plot the calculated coincident index along with the US recessions and the comparison coincident index USPHCI.

```python
fig, ax = plt.subplots(figsize=(13,3))

# Compute the index
coincident_index = compute_coincident_index(mod, res)

# Plot the factor
dates = endog.index._mpl_repr()
ax.plot(dates, coincident_index, label='Coincident index')
ax.plot(usphci.index._mpl_repr(), usphci, label='USPHCI')
ax.legend(loc='lower right')

# Retrieve and also plot the NBER recession indicators
ylim = ax.get_ylim()
ax.fill_between(dates[:-3], ylim[0], ylim[1], rec.values[:-4,0], facecolor='k', alpha=0.1);
```

## Appendix 1: Extending the dynamic factor model

Recall that the previous specification was described by:

$$
\begin{align}
y_{i,t} & = \lambda_i f_t + u_{i,t} \\
u_{i,t} & = c_{i,1} u_{1,t-1} + c_{i,2} u_{i,t-2} + \varepsilon_{i,t} \qquad & \varepsilon_{i,t} \sim N(0, \sigma_i^2) \\
f_t & = a_1 f_{t-1} + a_2 f_{t-2} + \eta_t \qquad & \eta_t \sim N(0, I)\\
\end{align}
$$

Written in state space form, the previous specification of the model had the following observation equation:

$$
\begin{bmatrix}
y_{\text{indprod}, t} \\
y_{\text{income}, t} \\
y_{\text{sales}, t} \\
y_{\text{emp}, t} \\
\end{bmatrix} = \begin{bmatrix}
\lambda_\text{indprod} & 0 & 1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
\lambda_\text{income}  & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 & 0 \\
\lambda_\text{sales}   & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 \\
\lambda_\text{emp}     & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 \\
\end{bmatrix}
\begin{bmatrix}
f_t \\
f_{t-1} \\
u_{\text{indprod}, t} \\
u_{\text{income}, t} \\
u_{\text{sales}, t} \\
u_{\text{emp}, t} \\
u_{\text{indprod}, t-1} \\
u_{\text{income}, t-1} \\
u_{\text{sales}, t-1} \\
u_{\text{emp}, t-1} \\
\end{bmatrix}
$$

and transition equation:

$$
\begin{bmatrix}
f_t \\
f_{t-1} \\
u_{\text{indprod}, t} \\
u_{\text{income}, t} \\
u_{\text{sales}, t} \\
u_{\text{emp}, t} \\
u_{\text{indprod}, t-1} \\
u_{\text{income}, t-1} \\
u_{\text{sales}, t-1} \\
u_{\text{emp}, t-1} \\
\end{bmatrix} = \begin{bmatrix}
a_1 & a_2 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
1   & 0   & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
0   & 0   & c_{\text{indprod}, 1} & 0 & 0 & 0 & c_{\text{indprod}, 2} & 0 & 0 & 0 \\
0   & 0   & 0 & c_{\text{income}, 1} & 0 & 0 & 0 & c_{\text{income}, 2} & 0 & 0 \\
0   & 0   & 0 & 0 & c_{\text{sales}, 1} & 0 & 0 & 0 & c_{\text{sales}, 2} & 0 \\
0   & 0   & 0 & 0 & 0 & c_{\text{emp}, 1} & 0 & 0 & 0 & c_{\text{emp}, 2} \\
0   & 0   & 1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
0   & 0   & 0 & 1 & 0 & 0 & 0 & 0 & 0 & 0 \\
0   & 0   & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 \\
0   & 0   & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 \\
\end{bmatrix}
\begin{bmatrix}
f_{t-1} \\
f_{t-2} \\
u_{\text{indprod}, t-1} \\
u_{\text{income}, t-1} \\
u_{\text{sales}, t-1} \\
u_{\text{emp}, t-1} \\
u_{\text{indprod}, t-2} \\
u_{\text{income}, t-2} \\
u_{\text{sales}, t-2} \\
u_{\text{emp}, t-2} \\
\end{bmatrix}
+ R \begin{bmatrix}
\eta_t \\
\varepsilon_{t}
\end{bmatrix}
$$

the `DynamicFactor` model handles setting up the state space representation and, in the `DynamicFactor.update` method, it fills in the fitted parameter values into the appropriate locations.

The extended specification is the same as in the previous example, except that we also want to allow employment to depend on lagged values of the factor. This creates a change to the $y_{\text{emp},t}$ equation. Now we have:

$$
\begin{align}
y_{i,t} & = \lambda_i f_t + u_{i,t} \qquad & i \in \{\text{indprod}, \text{income}, \text{sales} \}\\
y_{i,t} & = \lambda_{i,0} f_t + \lambda_{i,1} f_{t-1} + \lambda_{i,2} f_{t-2} + \lambda_{i,2} f_{t-3} + u_{i,t} \qquad & i = \text{emp} \\
u_{i,t} & = c_{i,1} u_{i,t-1} + c_{i,2} u_{i,t-2} + \varepsilon_{i,t} \qquad & \varepsilon_{i,t} \sim N(0, \sigma_i^2) \\
f_t & = a_1 f_{t-1} + a_2 f_{t-2} + \eta_t \qquad & \eta_t \sim N(0, I)\\
\end{align}
$$

Now, the corresponding observation equation should look like the following:

$$
\begin{bmatrix}
y_{\text{indprod}, t} \\
y_{\text{income}, t} \\
y_{\text{sales}, t} \\
y_{\text{emp}, t} \\
\end{bmatrix} = \begin{bmatrix}
\lambda_\text{indprod} & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
\lambda_\text{income}  & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 & 0 \\
\lambda_\text{sales}   & 0 & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 \\
\lambda_\text{emp,1}   & \lambda_\text{emp,2} & \lambda_\text{emp,3} & \lambda_\text{emp,4} & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 \\
\end{bmatrix}
\begin{bmatrix}
f_t \\
f_{t-1} \\
f_{t-2} \\
f_{t-3} \\
u_{\text{indprod}, t} \\
u_{\text{income}, t} \\
u_{\text{sales}, t} \\
u_{\text{emp}, t} \\
u_{\text{indprod}, t-1} \\
u_{\text{income}, t-1} \\
u_{\text{sales}, t-1} \\
u_{\text{emp}, t-1} \\
\end{bmatrix}
$$

Notice that we have introduced two new state variables, $f_{t-2}$ and $f_{t-3}$, which means we need to update the  transition equation:

$$
\begin{bmatrix}
f_t \\
f_{t-1} \\
f_{t-2} \\
f_{t-3} \\
u_{\text{indprod}, t} \\
u_{\text{income}, t} \\
u_{\text{sales}, t} \\
u_{\text{emp}, t} \\
u_{\text{indprod}, t-1} \\
u_{\text{income}, t-1} \\
u_{\text{sales}, t-1} \\
u_{\text{emp}, t-1} \\
\end{bmatrix} = \begin{bmatrix}
a_1 & a_2 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
1   & 0   & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
0   & 1   & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
0   & 0   & 1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
0   & 0   & 0 & 0 & c_{\text{indprod}, 1} & 0 & 0 & 0 & c_{\text{indprod}, 2} & 0 & 0 & 0 \\
0   & 0   & 0 & 0 & 0 & c_{\text{income}, 1} & 0 & 0 & 0 & c_{\text{income}, 2} & 0 & 0 \\
0   & 0   & 0 & 0 & 0 & 0 & c_{\text{sales}, 1} & 0 & 0 & 0 & c_{\text{sales}, 2} & 0 \\
0   & 0   & 0 & 0 & 0 & 0 & 0 & c_{\text{emp}, 1} & 0 & 0 & 0 & c_{\text{emp}, 2} \\
0   & 0   & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
0   & 0   & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 & 0 \\
0   & 0   & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 \\
0   & 0   & 0 & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 \\
\end{bmatrix}
\begin{bmatrix}
f_{t-1} \\
f_{t-2} \\
f_{t-3} \\
f_{t-4} \\
u_{\text{indprod}, t-1} \\
u_{\text{income}, t-1} \\
u_{\text{sales}, t-1} \\
u_{\text{emp}, t-1} \\
u_{\text{indprod}, t-2} \\
u_{\text{income}, t-2} \\
u_{\text{sales}, t-2} \\
u_{\text{emp}, t-2} \\
\end{bmatrix}
+ R \begin{bmatrix}
\eta_t \\
\varepsilon_{t}
\end{bmatrix}
$$

This model cannot be handled out-of-the-box by the `DynamicFactor` class, but it can be handled by creating a subclass when alters the state space representation in the appropriate way.

First, notice that if we had set `factor_order = 4`, we would almost have what we wanted. In that case, the last line of the observation equation would be:

$$
\begin{bmatrix}
\vdots \\
y_{\text{emp}, t} \\
\end{bmatrix} = \begin{bmatrix}
\vdots &  &  &  &  &  &  &  &  &  &  & \vdots \\
\lambda_\text{emp,1}   & 0 & 0 & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 \\
\end{bmatrix}
\begin{bmatrix}
f_t \\
f_{t-1} \\
f_{t-2} \\
f_{t-3} \\
\vdots
\end{bmatrix}
$$

and the first line of the transition equation would be:

$$
\begin{bmatrix}
f_t \\
\vdots
\end{bmatrix} = \begin{bmatrix}
a_1 & a_2 & a_3 & a_4 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\
\vdots &  &  &  &  &  &  &  &  &  &  & \vdots \\
\end{bmatrix}
\begin{bmatrix}
f_{t-1} \\
f_{t-2} \\
f_{t-3} \\
f_{t-4} \\
\vdots
\end{bmatrix}
+ R \begin{bmatrix}
\eta_t \\
\varepsilon_{t}
\end{bmatrix}
$$

Relative to what we want, we have the following differences:

1. In the above situation, the $\lambda_{\text{emp}, j}$ are forced to be zero for $j > 0$, and we want them to be estimated as parameters.
2. We only want the factor to transition according to an AR(2), but under the above situation it is an AR(4).

Our strategy will be to subclass `DynamicFactor`, and let it do most of the work (setting up the state space representation, etc.) where it assumes that `factor_order = 4`. The only things we will actually do in the subclass will be to fix those two issues.

First, here is the full code of the subclass; it is discussed below. It is important to note at the outset that none of the methods defined below could have been omitted. In fact, the methods `__init__`, `start_params`, `param_names`, `transform_params`, `untransform_params`, and `update` form the core of all state space models in statsmodels, not just the `DynamicFactor` class.

```python
from statsmodels.tsa.statespace import tools
class ExtendedDFM(sm.tsa.DynamicFactor):
    def __init__(self, endog, **kwargs):
            # Setup the model as if we had a factor order of 4
            super(ExtendedDFM, self).__init__(
                endog, k_factors=1, factor_order=4, error_order=2,
                **kwargs)

            # Note: `self.parameters` is an ordered dict with the
            # keys corresponding to parameter types, and the values
            # the number of parameters of that type.
            # Add the new parameters
            self.parameters['new_loadings'] = 3

            # Cache a slice for the location of the 4 factor AR
            # parameters (a_1, ..., a_4) in the full parameter vector
            offset = (self.parameters['factor_loadings'] +
                      self.parameters['exog'] +
                      self.parameters['error_cov'])
            self._params_factor_ar = np.s_[offset:offset+2]
            self._params_factor_zero = np.s_[offset+2:offset+4]

    @property
    def start_params(self):
        # Add three new loading parameters to the end of the parameter
        # vector, initialized to zeros (for simplicity; they could
        # be initialized any way you like)
        return np.r_[super(ExtendedDFM, self).start_params, 0, 0, 0]

    @property
    def param_names(self):
        # Add the corresponding names for the new loading parameters
        #  (the name can be anything you like)
        return super(ExtendedDFM, self).param_names + [
            'loading.L%d.f1.%s' % (i, self.endog_names[3]) for i in range(1,4)]

    def transform_params(self, unconstrained):
            # Perform the typical DFM transformation (w/o the new parameters)
            constrained = super(ExtendedDFM, self).transform_params(
            unconstrained[:-3])

            # Redo the factor AR constraint, since we only want an AR(2),
            # and the previous constraint was for an AR(4)
            ar_params = unconstrained[self._params_factor_ar]
            constrained[self._params_factor_ar] = (
                tools.constrain_stationary_univariate(ar_params))

            # Return all the parameters
            return np.r_[constrained, unconstrained[-3:]]

    def untransform_params(self, constrained):
            # Perform the typical DFM untransformation (w/o the new parameters)
            unconstrained = super(ExtendedDFM, self).untransform_params(
                constrained[:-3])

            # Redo the factor AR unconstrained, since we only want an AR(2),
            # and the previous unconstrained was for an AR(4)
            ar_params = constrained[self._params_factor_ar]
            unconstrained[self._params_factor_ar] = (
                tools.unconstrain_stationary_univariate(ar_params))

            # Return all the parameters
            return np.r_[unconstrained, constrained[-3:]]

    def update(self, params, transformed=True, **kwargs):
        # Peform the transformation, if required
        if not transformed:
            params = self.transform_params(params)
        params[self._params_factor_zero] = 0

        # Now perform the usual DFM update, but exclude our new parameters
        super(ExtendedDFM, self).update(params[:-3], transformed=True, **kwargs)

        # Finally, set our new parameters in the design matrix
        self.ssm['design', 3, 1:4] = params[-3:]

```

So what did we just do?

**`__init__`**

The important step here was specifying the base dynamic factor model which we were operating with. In particular, as described above, we initialize with `factor_order=4`, even though we will only end up with an AR(2) model for the factor. We also performed some general setup-related tasks.

**`start_params`**

`start_params` are used as initial values in the optimizer. Since we are adding three new parameters, we need to pass those in. If we had not done this, the optimizer would use the default starting values, which would be three elements short.

**`param_names`**

`param_names` are used in a variety of places, but especially in the results class. Below we get a full result summary, which is only possible when all the parameters have associated names.

**`transform_params`** and **`untransform_params`**

The optimizer selects possibly parameter values in an unconstrained way. That's not usually desired (since variances cannot be negative, for example), and `transform_params` is used to transform the unconstrained values used by the optimizer to constrained values appropriate to the model. Variances terms are typically squared (to force them to be positive), and AR lag coefficients are often constrained to lead to a stationary model. `untransform_params` is used for the reverse operation (and is important because starting parameters are usually specified in terms of values appropriate to the model, and we need to convert them to parameters appropriate to the optimizer before we can begin the optimization routine).

Even though we do not need to transform or untransform our new parameters (the loadings can in theory take on any values), we still need to modify this function for two reasons:

1. The version in the `DynamicFactor` class is expecting 3 fewer parameters than we have now. At a minimum, we need to handle the three new parameters.
2. The version in the `DynamicFactor` class constrains the factor lag coefficients to be stationary as though it was an AR(4) model. Since we actually have an AR(2) model, we need to re-do the constraint. We also set the last two autoregressive coefficients to be zero here.

**`update`**

The most important reason we need to specify a new `update` method is because we have three new parameters that we need to place into the state space formulation. In particular we let the parent `DynamicFactor.update` class handle placing all the parameters except the three new ones in to the state space representation, and then we put the last three in manually.

```python
# Create the model
extended_mod = ExtendedDFM(endog)
initial_extended_res = extended_mod.fit(maxiter=1000, disp=False)
extended_res = extended_mod.fit(initial_extended_res.params, method='nm', maxiter=1000)
print(extended_res.summary(separate_params=False))
```

    Optimization terminated successfully.
             Current function value: 4.698612
             Iterations: 271
             Function evaluations: 471
                                                 Statespace Model Results
    =================================================================================================================
    Dep. Variable:     ['std_indprod', 'std_income', 'std_sales', 'std_emp']   No. Observations:                  431
    Model:                                 DynamicFactor(factors=1, order=4)   Log Likelihood               -2025.102
                                                              + AR(2) errors   AIC                           4096.203
    Date:                                                   Tue, 02 Feb 2021   BIC                           4189.724
    Time:                                                           06:56:16   HQIC                          4133.128
    Sample:                                                       02-01-1979
                                                                - 12-01-2014
    Covariance Type:                                                     opg
    ====================================================================================================
                                           coef    std err          z      P>|z|      [0.025      0.975]
    ----------------------------------------------------------------------------------------------------
    loading.f1.std_indprod              -0.6889      0.036    -19.109      0.000      -0.760      -0.618
    loading.f1.std_income               -0.2584      0.038     -6.726      0.000      -0.334      -0.183
    loading.f1.std_sales                -0.4390      0.024    -18.548      0.000      -0.485      -0.393
    loading.f1.std_emp                  -0.4166      0.039    -10.761      0.000      -0.493      -0.341
    sigma2.std_indprod                   0.2456      0.046      5.322      0.000       0.155       0.336
    sigma2.std_income                    0.8736      0.030     29.557      0.000       0.816       0.932
    sigma2.std_sales                     0.5347      0.034     15.522      0.000       0.467       0.602
    sigma2.std_emp                       0.2531      0.024     10.488      0.000       0.206       0.300
    L1.f1.f1                             0.3047      0.059      5.169      0.000       0.189       0.420
    L2.f1.f1                             0.3768      0.062      6.076      0.000       0.255       0.498
    L3.f1.f1                                  0   3.44e-10          0      1.000   -6.75e-10    6.75e-10
    L4.f1.f1                                  0   3.44e-10          0      1.000   -6.75e-10    6.75e-10
    L1.e(std_indprod).e(std_indprod)    -0.3203      0.113     -2.828      0.005      -0.542      -0.098
    L2.e(std_indprod).e(std_indprod)    -0.2254      0.090     -2.497      0.013      -0.402      -0.048
    L1.e(std_income).e(std_income)      -0.1730      0.022     -7.835      0.000      -0.216      -0.130
    L2.e(std_income).e(std_income)      -0.0936      0.044     -2.120      0.034      -0.180      -0.007
    L1.e(std_sales).e(std_sales)        -0.4896      0.046    -10.596      0.000      -0.580      -0.399
    L2.e(std_sales).e(std_sales)        -0.2268      0.050     -4.540      0.000      -0.325      -0.129
    L1.e(std_emp).e(std_emp)             0.2334      0.042      5.523      0.000       0.151       0.316
    L2.e(std_emp).e(std_emp)             0.4965      0.051      9.814      0.000       0.397       0.596
    loading.L1.f1.std_emp               -0.0725      0.038     -1.898      0.058      -0.147       0.002
    loading.L2.f1.std_emp                0.0005      0.036      0.013      0.990      -0.070       0.071
    loading.L3.f1.std_emp               -0.1737      0.028     -6.168      0.000      -0.229      -0.119
    ====================================================================================================
    Ljung-Box (L1) (Q):     0.11, 0.00, 1.04, 3.83   Jarque-Bera (JB):   231.63, 9688.75, 25.36, 3374.73
    Prob(Q):                0.74, 0.95, 0.31, 0.05   Prob(JB):                    0.00, 0.00, 0.00, 0.00
    Heteroskedasticity (H): 0.76, 5.03, 0.44, 0.45   Skew:                       0.22, -0.97, 0.24, 0.75
    Prob(H) (two-sided):    0.11, 0.00, 0.00, 0.00   Kurtosis:                  6.56, 26.15, 4.08, 16.63
    ====================================================================================================

    Warnings:
    [1] Covariance matrix calculated using the outer product of gradients (complex-step).
    [2] Covariance matrix is singular or near-singular, with condition number 1.35e+18. Standard errors may be unstable.

Although this model increases the likelihood, it is not preferred by the AIC and BIC measures which penalize the additional three parameters.

Furthermore, the qualitative results are unchanged, as we can see from the updated $R^2$ chart and the new coincident index, both of which are practically identical to the previous results.

```python
extended_res.plot_coefficients_of_determination(figsize=(8,2));
```

```python
fig, ax = plt.subplots(figsize=(13,3))

# Compute the index
extended_coincident_index = compute_coincident_index(extended_mod, extended_res)

# Plot the factor
dates = endog.index._mpl_repr()
ax.plot(dates, coincident_index, '-', linewidth=1, label='Basic model')
ax.plot(dates, extended_coincident_index, '--', linewidth=3, label='Extended model')
ax.plot(usphci.index._mpl_repr(), usphci, label='USPHCI')
ax.legend(loc='lower right')
ax.set(title='Coincident indices, comparison')

# Retrieve and also plot the NBER recession indicators
ylim = ax.get_ylim()
ax.fill_between(dates[:-3], ylim[0], ylim[1], rec.values[:-4,0], facecolor='k', alpha=0.1);
```


Anwendungen der linearen Algebra

FS 2021

Fachexperten: Cédric Huwyler, Joana Portmann

# Mini-Challenge 2 zum Thema *Page Rank*

In der Kompetenz *Mathematische Grundlagen* haben Sie Graphen als mathematische Struktur kennengelernt, insbesondere, dass diese mit Hilfe der linearen Algebra, mit einer sogenannten *Adjazenzmatrix* repräsentiert werden können. Wie Sie sich sicher vorstellen können, können mit Hilfe dieser Matrix auch interessante Erkenntnisse über den Graphen gewonnen werden, indem wir darauf die in diesem Semester aufgebauten Erkenntnisse anwenden. In dieser ersten Minichallenge setzen wir uns dazu mit einem wichtigen Problem der Graphentheorie und noch wichtigerem Problem in der *Sozialen Netzwerkanalyse* (siehe separate Kompetenz) auseinander:

Gegeben sei ein Graph mit Knoten und Kanten. Welche Knoten im Graph sind wichtig, welche Knoten weniger wichtig?

Die Beantwortung dieser Frage ist natürlich in ihrer Natur absolut subjektiv, was wichtig ist, ist eine sehr individuelle Entscheidung. So kann man zum Beispiel definieren, dass ein Knoten wichtig ist, wenn er viele Verbindungen zu anderen Knoten hat. Ist jemand, der auf Facebook möglichst viele Leute um ihre Freundschaft angefragt hat wirklich eine wichtige Person? Laut anderen Definitionen ist ein Knoten wichtig, wenn möglichst viele kürzeste Verbindungen zwischen anderen Knoten über ihn gehen oder wenn er zu möglichst vielen anderen Knoten ihr nächster Nachbar ist, und so weiter. Wie kann man also die Subjektivität dieser Definition übergehen? Ein einfacher Lösungsansatz ist hier *Rekursion*:

*Wichtig ist ein Knoten dann, wenn er mit wichtigen Knoten verbunden ist.*

Dieser Ansatz stellt ausschliesslich die Kanten zwischen den Knoten in den Vordergrund, also den Vernetzungsaspekt eines Knotes. Andere Knoteneigenschaften spielen keine Rolle.

Leute, die sehr viele wichtige andere Leute in ihrem Netzwerk haben, sind also wichtig. Was ist jetzt aber wichtig? Wir haben es rekursiv definiert und müssen dazu zuerst eine entsprechende rekursive Gleichung aufstellen und diese lösen. Genau das werden Sie in dieser Mini-Challenge tun.

In dieser Mini-Challenge werden Sie lernen, Graphenprobleme mit Matrizen und den Tools der linearen Algebra anzugehen und damit ihre Werkzeugkiste auf ein komplexes System anzuwenden. Dabei soll die Graphentheorie nicht in aller Tiefe erkundet werden (das werden sie in *Soziale Netzwerkanalyse* lernen), sondern vor allem die damit verbundene lineare Algebra.

**Musterlösung:**

Zu dieser Minichallenge existiert (bewusst) keine Musterlösung. Sie können Sie aber gerne zur Durchsicht und Kommentierung uns entweder in einer der Sprechstunden vorstellen oder mit Ihren Mitstudierenden auf Spaces diskutieren.

Wir laden zuerst die für diese Übung benötigten Module. **Hinweis:** Wir benutzen das Modul `networkx` für die graphische Darstellung von Graphen.

```python
import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
import networkx as nx
```

## Aufgabe 1: Repräsentation von Graphen mit Matrizen

### Ein kleines Beispiel

Graphen haben Sie bereits in *Mathematische Grundlagen* kennengelernt, hier trotzdem noch einmal eine kurze Zusammenfassung der Begrifflichkeiten:

Graphen bestehen aus **Knoten** (numerierte Kreise, Englisch: *node*) und **Kanten** (Pfeile / Verbindungen, Englisch: *edge*) und sind entweder **gerichtet** oder **ungerichtet**. Bei gerichteten Graphen kann der Kante nur in einer Richtung gefolgt werden, darum werden solche Kanten üblicherweise mit Pfeilen dargestellt. Sind die Kanten verschieden wichtig, so nennt man einen solchen Graphen **gewichtet** und visualisiert dies mit Pfeilen verschiedener Dicke. Hier ist ein Beispiel eines sehr einfachen, gerichteten, ungewichteten Graphen:

Es gibt zwei äquivalente mathematische Darstellungsarten mit Matrizen für den obigen Graphen:

1. Die **Adjazenzmatrix** ist eine Matrix $A \in \mathbb{R}^{n\times n}$ für einen Graphen mit $n$ Knoten, deren Elemente eins sind wo zwei Knoten verbunden sind und sonst null. Für das obige Beispiel ist

$$
A = \begin{pmatrix}  0 & 0 & 1 & 1 \\ 1 & 0 & 1 & 1 \\ 0 & 0 & 0 & 0 \\ 1 & 0 & 0 & 0 \end{pmatrix}
$$

In der ersten Zeile stehen zum Beispiel alle Verbindungen mit Ausgangsknoten 1 zum Endknoten $i$: Der Knoten 1 hat also Verbindungen zu den Knoten 3 und 4 und keine Verbindungen zum Knoten 2 (und keine Verbindung zu sich selbst; Graphen in denen Knoten zu sich selbst verbinden betrachten wir hier nicht, die Diagonale von $A$ wird also immer Null sein).

2. Die **Inzidenzmatrix** ist eine Matrix $E \in \mathbb{R}^{m\times2}$ für einen Graphen mit $m$ Kanten. In der ersten Spalte von $E$ steht jeweils die Nummer des Ausgangsknotens einer Verbindung, in der zweiten Spalte die Nummer des Endknotens, und das für alle $m$ Kanten:

$$
E = \begin{pmatrix} 1 & 3 \\ 1 & 4 \\ 2 & 1 \\ 2 & 3 \\ 2 & 4 \\ 4 & 1 \end{pmatrix}
$$

$A$ und $E$ haben u.a. folgende Eigenschaften:

* Für ungerichtete Graphen gilt $A^T = A$ und damit ist $A$ symmetrisch, für *gerichtete* Graphen (wie in diesem Beispiel) gilt diese Aussage jedoch nicht.
* $E$ hat genauso viele Zeilen, wie $A$ Elemente ungleich Null hat, also $\sum_{i,j=1}^n A_{ij} = m$.
* $A$ ist quadratisch und hat genauso viele Zeilen und Spalten wie die Anzahl Knoten, die in $E$ vorkommen.
* $A$ besitzt im Allgemeinen keine Inverse.

### Graph für die Aufgabenstellung

Der folgende gerichtete Graph sei nun gegeben:

Definieren Sie zuerst seine Inzidenzmatrix als $13\times2$-Matrix. Beachten Sie: der letzte Graph begann mit der Knotennumerierung bei 1, dieser beginnt bei 0.

```python
# IHR CODE STARTET HIER
E = np.array([[0,1], [0,6], [0, 7], [1,2], [1,7], [2,1], [2,7], [3,5], [3,7], [4,5], [5,6], [6,5], [7,6]])
# Dieses Assert-Statement sollte von Ihrer Matrix E erfüllt werden
assert( E.shape==(13,2) )
```

Die Inzidenzmatrix braucht zum Abtippen wesentlich weniger Konzentration als die Adjazenzmatrix, meistens ist die Adjazenzmatrix für Berechnungen aber spannender. Aus diesem Grund hätten wir gerne eine Funktion `adj( E )`, die aus einer beliebigen Inzidenzmatrix die Adjazenzmatrix berechnet und sie zurückgibt. Schreiben Sie eine solche Funktion.

**Hinweise**:
* Sie dürfen annehmen, dass die Knotennummerierung in der Inzidenzmatrix immer bei 0 beginnt und durchgehend ist.
* Es ist sehr hilfreich, zuerst die Anzahl Knoten und Kanten im Graphen zu bestimmen.

```python
# IHR CODE
def adj(E):
    n_nodes = np.max(E) + 1
    A = np.zeros([n_nodes, n_nodes])

    for i, j in E:
        A[i][j] = 1

    return A
```

Nun können Sie die zur obigen Inzidenzmatrix $E$ passende Adjazenzmatrix $A$ bestimmen. Passt sie zum obigen Graphen?

```python
A = adj( E )
print( A )
```

    [[0. 1. 0. 0. 0. 0. 1. 1.]
     [0. 0. 1. 0. 0. 0. 0. 1.]
     [0. 1. 0. 0. 0. 0. 0. 1.]
     [0. 0. 0. 0. 0. 1. 0. 1.]
     [0. 0. 0. 0. 0. 1. 0. 0.]
     [0. 0. 0. 0. 0. 0. 1. 0.]
     [0. 0. 0. 0. 0. 1. 0. 0.]
     [0. 0. 0. 0. 0. 0. 1. 0.]]

Ist $A$ invertierbar?

```python
# IHR CODE
np.linalg.inv(A)
```

Prüfen Sie als Unit-Test, ob die Anzahl Zeilen von $E$ mit der Summe der Elemente aus $A$ übereinstimmen:

```python
# IHR CODE
def test_AE_Matrix(A, E):
    len_E = len(E)
    n_el_A = np.sum(A)
    assert(len_E == n_el_A)

test_AE_Matrix(A, E)
```

Zur Visualisierung Ihrer Graphen stellen wir Ihnen eine Funktion zur Verfügung. Damit können Sie verifizieren, ob Sie die Inzidenzmatrix korrekt eingegeben und richtig in die Adjazenzmatrix umgewandelt haben:

```python
def plot_graph( A, knoten_gewichte=None ):
    """
    Funktion zur graphischen Darstellung eines Graphen.
    Benutzt das 'spring layout', eventuell muss die Funktion mehrere Male ausgeführt werden, bis eine schöne Darstellung
    des Graphen vorliegt.

    Arguments:
    A -- Adjazenzmatrix (shape (n_knoten,n_knoten))
    knoten_gewichte -- Liste mit Gewichte für jeden Knoten im Graphen (bei None erhalten alle Knoten die gleichen Gewichte)
    """

    if knoten_gewichte is None:
        knoten_gewichte = np.array( [1] * A.shape[0] )

    assert( len( knoten_gewichte) == A.shape[0] )

    knoten_gewichte = knoten_gewichte / np.mean( knoten_gewichte )

    plt.figure(figsize=(8,8))
    G = nx.DiGraph( A )
    pos = nx.layout.spring_layout(G)
    options = {
    'node_color': '#dd0000',
    'node_size': knoten_gewichte*2500,
    'width': 3,
    'arrowstyle': '-|>',
    'arrowsize': 12,
    }

    nx.draw_networkx(G, pos, arrows=True, **options )
    plt.axis("off")
    plt.show()
```

Natürlich werden die Knoten etwas anders angeordnet sein, als im obigen Bild. Möglicherweise müssen Sie die Funktion mehrmals ausführen, bis Sie einen schön dargestellten Plot bekommen:

```python
plot_graph( A )
```

## Aufgabe 2: Zentralitätsmasse auf gerichteten Graphen

Nun können wir beliebige Graphen mit Matrizen darstellen und können damit erste Berechnungen machen. Eine häufige Aufgabenstellung ist das Finden von wichtigen Knoten in einem Netzwerk. In dieser Aufgabe werden Sie zwei verschiedenartige Zentralitätsmasse kennenlernen.

### Teilaufgabe a) In- und Outdegree

Das einfachste Mass für Wichtigkeit ist die Anzahl Verbindungen, die ein Knoten nach aussen hat (*'Outdegree'*) und die Anzahl Verbindungen, die einen Knoten von aussen ansteuern (*'Indegree'*). Berechnen Sie im Folgenden über die Matrix $A$ den Indegree und den Outdegree aller Knoten. Geben Sie Ihr Resultat angemessen aus. Welche Knoten haben den grössten Out- respektive Indegree? (Tipp: Prüfen Sie ihre Resultate anhand der graphischen Darstellung nach)

```python
# IHR CODE
def in_out_degree(A):

    in_out_lst = np.zeros([A.shape[0], 2])

    for i, row in enumerate(A):
        for j, el in enumerate(row):
            if el == 1:
                in_out_lst[i][1] += 1
                in_out_lst[j][0] += 1
    return in_out_lst

in_out_degree(A)
```

    array([[0., 3.],
           [2., 2.],
           [1., 2.],
           [0., 2.],
           [0., 1.],
           [3., 1.],
           [3., 1.],
           [4., 1.]])

Knoten mit am meisten Eingangsverbindungen (Suchen Sie eine Funktion in Numpy, die nicht das Maximum, sondern den Ort des Maximums ausgibt):

```python
# IHR CODE
in_out = in_out_degree(A)

def max_inbound_index(in_out):
    inbounds = in_out[:,0]
    return np.argmax(inbounds)

max_inbound_index(in_out)
```

    7

Knoten mit am meisten Ausgangsverbindungen:

```python
# IHR CODE
# IHR CODE
in_out = in_out_degree(A)

def max_outbound_index(in_out):
    outbounds = in_out[:,1]
    return np.argmax(outbounds)

max_outbound_index(in_out)
```

    0

Mit der Ihnen zur Verfügung gestellten Funktion `plot_graph` können Sie die Knotengrössen nach Ausgangsgrad darstellen:

```python
outdegrees = in_out[:,1]
plot_graph( A, knoten_gewichte=outdegrees )
```

### Teilaufgabe b) Eigenvector Centrality aus numerischer Sicht: als Fixpunktverfahren

Das Konzept *'wer am meisten Verbindungen hat, ist am wichtigsten'* ist sehr einfach. Stellen Sie sich vor, jeder Knoten ist eine Webseite, die über die Pfeile auf andere Webseiten verbindet. Wenn wir zum Beispiel gefundene Suchresultate nach Wichtigkeit sortieren und einer Benutzerin präsentieren wollen, ist es für eine 'böswillige' Webseite sehr einfach zu tricksen: Einfach möglichst viele neue Webseiten erstellen, die auf die eigene Webseite zeigen und schon kommt man ganz oben (sogenannte [Link Farms](https://en.wikipedia.org/wiki/Link_farm)).

Im Folgenden möchten wir das bereits in der Einführung angesprochene, etwas abstraktere und rekursiv definierte Zentralitätsmass einführen:

**Wir definieren die Wichtigkeit eines Knotens als die Summe der Wichtigkeit aller Knoten, die auf diesen Knoten verbinden.**

Sei ein Graph $G$ mit $n$ Knoten und $m$ Kanten gegeben. Dann bezeichne $x_i$ die Wichtigkeit eines Knotens und für diese gilt:

$$ x_i = \sum_{k\in N(i)} x_k, $$

wobei $N(i)$ die *Nachbarschaft* des Knotens $i$ bezeichnet: eine Liste aller Knoten, die zum Knoten $i$ eine Verbindung haben. $N(6)$ ist also zum Beispiel $\{0,5,7\}$. Diese Formel ist ein Spezialfall der sogenannten [Eigenvector Centrality](https://en.wikipedia.org/wiki/Eigenvector_centrality) (welche etwas allgemeiner definiert ist, wir setzen für diesen konkreten Fall aber $\lambda=1$). In Teilaufgabe c) wird Ihnen klar, was dieses Wichtigkeitsmass mit Eigenvektoren zu tun hat.

Wir können $N(i)$ auch über die Adjazenzmatrix repräsentieren (überlegen Sie sich warum das so funktioniert und warum die Transponierte genommen werden muss, am besten führen Sie sich dazu noch einmal die Definition der Adjazenzmatrix zu Gemüte):

$$ x_i = \sum_{k} a_{ki} x_k = \sum_{k} a^T_{ik} x_k, $$

und mit der Definition der Matrixmultiplikation als Summenformel:

$$ \mathbf{x} = A^T \mathbf{x}. $$

Was für einen Schritt! Wir haben eine Summenformel als Matrizenmultiplikation geschrieben! Damit müssen wir erstens die Summe nicht mehr mit einem (in Python langsamen) `for`-Loop berechnen, sondern können die volle Power der Libraries für lineare Algebra benutzen (was durchaus eine Geschwindigkeitssteigerung um einen Faktor 10-100 ausmachen kann) und zweitens können wir nun unser Wissen über Matrizen einsetzen, statt die Summe auseinanderzunehmen.

Wir nennen diese Art Gleichung auch *Fixpunktgleichung*, da $\mathbf{x}$ unter der Anwendung von $A^T$ *fix* bleiben soll. Es bieten sich zwei Möglichkeiten, diese Gleichung nach $\mathbf{x}$ aufzulösen, eine numerische und eine analytische. Wir befassen uns zuerst mit der numerischen:

Fixpunktgleichungen können rekursiv gelöst werden, indem $x$ immer wieder für sich selbst eingesetzt wird:

$$ \mathbf{x} = A^T \mathbf{x} = A^T \left( A^T \mathbf{x} \right) = A^T \left( A^T \left( A^T \mathbf{x}\right)\right) = \dots. $$

$\mathbf{x}$ soll also ausgehend von einem zufälligen Startwert $\mathbf{x}_0$ solange von links mit $A^T$ multipliziert werden, bis es den Fixpunkt erreicht, wo $\mathbf{x}$ sich auch nach Anwendung von $A^T$ nicht mehr ändert und damit die Gleichung $\mathbf{x} = A^T \mathbf{x}$ erfüllt ist. Es kann je nach Matrix möglicherweise mehrere solche Fixpunkte geben und je nach gewähltem zufälligen Startpunkt erreicht man einen anderen oder auch keinen.

Im Folgenden ist ein Funktionsgerüst gegeben, das Sie vervollständigen sollen, um die Wichtigkeiten $\mathbf{x}$ im gegebenen Graphen rekursiv mit zu berechnen.

**Hinweise**:
* Initialisieren Sie am Anfang die Komponenten von $\mathbf{x}$ zufällig auf Werte zwischen 0 und 1, benutzen Sie dazu `np.random.rand(n)`, wobei $n$ die Dimension des Vektors $\mathbf{x}$ bezeichnet.
* Natürlich wird Ihr $\mathbf{x}$ nie genau auf einen Wert konvergieren, die relative Differenz $|\mathbf{x}_{n+1}-\mathbf{x}|/|\mathbf{x}|$ wird aber immer kleiner werden. Brechen Sie die Iteration ab, wenn ein gewisser Toleranzwert unterschritten wird.
* Blöderweise ist $||A|| > 1$ und damit divergiert $\mathbf{x} = A^T \mathbf{x}$. Da Wichtigkeiten als relative Grössen zu interpretieren sind (in Prozent), können Sie dieses Problem lösen, indem Sie $\mathbf{x}$ nach jeder Iteration so durch eine Zahl dividieren, dass nachher $\sum_i x_i = 1$. Schalten Sie diese Normalisierung ein und aus und beobachten Sie das Verhalten der Funktion.
* Zum Testen der Funktion kann es auch interessant sein, jeweils die Anzahl der benötigten Iterationen bis zum Erreichen des Toleranzwertes auszugeben.
* Lassen Sie Ihre Funktion mehrere Male auf dem Graphen laufen. Kommt für verschiedene zufällige Startwerte das gleiche Resultat heraus?

```python
def eigenvector_centrality( A, tol = 0.5):
    """
    Funktion zur iterativen Berechnung der Eigenvector Centrality, die ausgehend von einem zufällig initialisierten x
    durch wiederholte Multiplikation mit A.T den Fixpunkt von x = A.T x sucht.

    Das Verfahren läuft so lange, bis |xneu-x|/|x| kleiner als eine vorgegebene Toleranz tol ist, |x| steht
    dabei für die Länge des Vektors x.

    Arguments:
    A -- Adjazenzmatrix des Graphen (shape (n,n))
    tol -- Toleranz für Abbruch (hier könnten Sie einen guten Defaultwert setzen)

    Returns:
    x -- gefundener Fixpunkt für Wichtigkeiten der einzelnen Knoten
    """

    # IHR CODE STARTET HIER
    x = np.random.rand(A.shape[0])
    iteration_counter = 0

    def calc_centrality(x, iteration_counter):
        iteration_counter += 1
        x_new = np.dot(A.T, x)

        tol_calc = np.linalg.norm(x_new - x) / np.linalg.norm(x)

        x_new_norm = x_new / np.linalg.norm(x_new)

        if tol_calc < tol or iteration_counter > 10000:
            print(iteration_counter)
            return x_new_norm
        else:
            return calc_centrality(x_new, iteration_counter)

    return calc_centrality(x, iteration_counter)

```

Berechnen Sie nun die Eigenvector Centrality auf Ihrem Graphen und stellen Sie die Gewichtsverteilung grapisch mit `plot_graph` dar:

```python
# IHR CODE
weights = eigenvector_centrality(A, 0.01)
print(weights)
```

    162
    [0.         0.0049867  0.00371849 0.         0.         0.70220697
     0.71189248 0.00870519]

```python
# IHR CODE
plot_graph( A, knoten_gewichte=weights )
```

Was fällt Ihnen auf? Beschreiben Sie Ihre Beobachtungen. Warum ist die Situation so wie sie ist? Versuchen Sie sich vorzustellen, Sie sässen auf einem Knoten und würden jeweils zufällig eine der ausgehenden Verbindungen wählen und so auf den nächsten Knoten springen. Als Analogie können Sie sich Webseiten vorstellen, die aufeinander verlinken. Landen Sie am Ende Ihrer Reise bei gewissen Knoten öfters als bei anderen? Sind die Wichtigkeiten ähnlich wie der Eingangs- und Ausgangsgrad oder nicht?

-----

Knoten 5 und 6 erscheinen sehr wichtig zu sein, weil viele Verbindungen jeweils zu den zwei Knoten zeigen jedoch nur sehr wenige (wenn überhaupt) weg von ihnen. Je tiefer die Toleranz gewählt wird, desto klarer wird die Wichtigkeit dargestellt. Auch die Normalisierung von x trägt zu einer differenzierteren Wichtigkeit bei. Ja man würde am Ende immer bei den gleichen Knoten landen, da es von Ihnen keinen Verbindung mehr weg gibt. Die Wichtigkeit hängt stark mit den Eingangsgraden zusammen. Falls Knoten zusammen ein "Subnetz" bilden wo man nicht mehr "rauskommt" erhalten diese Knoten sehr viel Wichtigkeit.

-----

### Teilaufgabe c) Eigenvector Centrality aus analytischer Sicht: als Eigenvektorproblem

Teilaufgabe b) war eher aufwändig zum Lösen. Nun möchten wir uns der analytischen Lösung des Problems widmen. Man kann feststellen, dass mit

$$\mathbf{x} = A^T \mathbf{x}$$

$\mathbf{x}$ gerade ein Eigenvektor von $A^T$ zum Eigenwert 1 ist. Finden wir also Eigenvektoren von $A^T$ zum besagten Eigenwert, haben wir bereits die Lösung des Problems in der Hand.

Bestimmen Sie dazu das charakteristische Polynom $p_A(\lambda)$ von $A^T$ **mit SymPy** und der Determinantenformel und geben Sie es aus.
Berechnen Sie ebenfalls mit sympy die Lösungen von $p_A(\lambda) = 0$ und damit die Eigenwerte $\lambda_i$ und geben Sie sie aus:

(Mit SymPy sollten Sie sich schon ausführlich im Rahmen von *Grundlagen der linearen Algebra* auseinandergesetzt haben. Ansonsten finden Sie [hier](https://docs.sympy.org/latest/tutorial/index.html) das offizielle Tutorial.)

```python
λ = sp.Symbol('λ') # ja, Python kann Unicode!

# IHR CODE
charpol = sp.det(A.T - sp.eye(A.shape[0])*λ)
print(charpol)

sp.solve(charpol, λ)

```

    1.0*λ**8 - 2.0*λ**6 + 1.0*λ**4

    [-1.00000000000000, 0.0, 1.00000000000000]

Was ist die algebraische Vielfachheit des Eigenvektors $\lambda=1$?

-----

2

-----

Im Prinzip könnten Sie nun auch das Gleichungssystem $(A^T-1 \cdot I)\,\mathbf{x} = \mathbf{0}$ selbst lösen und den Eigenraum zum Eigenvektor 1 bestimmen (die anderen Eigenwerte von $A^T$ kommen ja für die obige Gleichung nicht in Frage). Wir überspringen diesen Schritt. Finden Sie die Eigenwerte und Eigenvektoren stattdessen mit `np.linalg.eig` und extrahieren Sie aus dem Resultat ausschliesslich die Eigen(basis)vektoren zum Eigenwert 1. Geben Sie diese aus.

**Hinweis**:
* Wegen numerischen Fehlern sind die Eigenwerte nicht genau ganze Zahlen. Benutzen Sie statt `ew==1` den Ausdruck `np.isclose(ew,1)` (`ew` ist dabei ein Vektor aus Eigenwerten).
* Runden Sie die Eigenvektoren *in der Ausgabe* geeignet, so dass Ihr Resultat einfacher ablesbar wird.
* Mit `help(np.linalg.eig)` erfahren Sie mehr über diese Funktion, **insbesondere wie die Eigenvektoren zu interpretieren sind**

```python
# IHR CODE
help(np.linalg.eig)

```

    Help on function eig in module numpy.linalg:

    eig(a)
        Compute the eigenvalues and right eigenvectors of a square array.

        Parameters
        ----------
        a : (..., M, M) array
            Matrices for which the eigenvalues and right eigenvectors will
            be computed

        Returns
        -------
        w : (..., M) array
            The eigenvalues, each repeated according to its multiplicity.
            The eigenvalues are not necessarily ordered. The resulting
            array will be of complex type, unless the imaginary part is
            zero in which case it will be cast to a real type. When `a`
            is real the resulting eigenvalues will be real (0 imaginary
            part) or occur in conjugate pairs

        v : (..., M, M) array
            The normalized (unit "length") eigenvectors, such that the
            column ``v[:,i]`` is the eigenvector corresponding to the
            eigenvalue ``w[i]``.

        Raises
        ------
        LinAlgError
            If the eigenvalue computation does not converge.

        See Also
        --------
        eigvals : eigenvalues of a non-symmetric array.
        eigh : eigenvalues and eigenvectors of a real symmetric or complex
               Hermitian (conjugate symmetric) array.
        eigvalsh : eigenvalues of a real symmetric or complex Hermitian
                   (conjugate symmetric) array.
        scipy.linalg.eig : Similar function in SciPy that also solves the
                           generalized eigenvalue problem.
        scipy.linalg.schur : Best choice for unitary and other non-Hermitian
                             normal matrices.

        Notes
        -----

        .. versionadded:: 1.8.0

        Broadcasting rules apply, see the `numpy.linalg` documentation for
        details.

        This is implemented using the ``_geev`` LAPACK routines which compute
        the eigenvalues and eigenvectors of general square arrays.

        The number `w` is an eigenvalue of `a` if there exists a vector
        `v` such that ``a @ v = w * v``. Thus, the arrays `a`, `w`, and
        `v` satisfy the equations ``a @ v[:,i] = w[i] * v[:,i]``
        for :math:`i \in \{0,...,M-1\}`.

        The array `v` of eigenvectors may not be of maximum rank, that is, some
        of the columns may be linearly dependent, although round-off error may
        obscure that fact. If the eigenvalues are all different, then theoretically
        the eigenvectors are linearly independent and `a` can be diagonalized by
        a similarity transformation using `v`, i.e, ``inv(v) @ a @ v`` is diagonal.

        For non-Hermitian normal matrices the SciPy function `scipy.linalg.schur`
        is preferred because the matrix `v` is guaranteed to be unitary, which is
        not the case when using `eig`. The Schur factorization produces an
        upper triangular matrix rather than a diagonal matrix, but for normal
        matrices only the diagonal of the upper triangular matrix is needed, the
        rest is roundoff error.

        Finally, it is emphasized that `v` consists of the *right* (as in
        right-hand side) eigenvectors of `a`.  A vector `y` satisfying
        ``y.T @ a = z * y.T`` for some number `z` is called a *left*
        eigenvector of `a`, and, in general, the left and right eigenvectors
        of a matrix are not necessarily the (perhaps conjugate) transposes
        of each other.

        References
        ----------
        G. Strang, *Linear Algebra and Its Applications*, 2nd Ed., Orlando, FL,
        Academic Press, Inc., 1980, Various pp.

        Examples
        --------
        >>> from numpy import linalg as LA

        (Almost) trivial example with real e-values and e-vectors.

        >>> w, v = LA.eig(np.diag((1, 2, 3)))
        >>> w; v
        array([1., 2., 3.])
        array([[1., 0., 0.],
               [0., 1., 0.],
               [0., 0., 1.]])

        Real matrix possessing complex e-values and e-vectors; note that the
        e-values are complex conjugates of each other.

        >>> w, v = LA.eig(np.array([[1, -1], [1, 1]]))
        >>> w; v
        array([1.+1.j, 1.-1.j])
        array([[0.70710678+0.j        , 0.70710678-0.j        ],
               [0.        -0.70710678j, 0.        +0.70710678j]])

        Complex-valued matrix with real e-values (but complex-valued e-vectors);
        note that ``a.conj().T == a``, i.e., `a` is Hermitian.

        >>> a = np.array([[1, 1j], [-1j, 1]])
        >>> w, v = LA.eig(a)
        >>> w; v
        array([2.+0.j, 0.+0.j])
        array([[ 0.        +0.70710678j,  0.70710678+0.j        ], # may vary
               [ 0.70710678+0.j        , -0.        +0.70710678j]])

        Be careful about round-off error!

        >>> a = np.array([[1 + 1e-9, 0], [0, 1 - 1e-9]])
        >>> # Theor. e-values are 1 +/- 1e-9
        >>> w, v = LA.eig(a)
        >>> w; v
        array([1., 1.])
        array([[1., 0.],
               [0., 1.]])

```python
# IHR CODE
print(A)
values, vectors = np.linalg.eig(A.T)

indices = [i for i, x in enumerate(values) if np.isclose(x, 1)]
print(np.isclose(values,1), indices)

print(values)

ew_1_vecs = []

for index in indices:
    ew_1_vecs.append(np.around(vectors[:,index], 2))

```

    [[0. 1. 0. 0. 0. 0. 1. 1.]
     [0. 0. 1. 0. 0. 0. 0. 1.]
     [0. 1. 0. 0. 0. 0. 0. 1.]
     [0. 0. 0. 0. 0. 1. 0. 1.]
     [0. 0. 0. 0. 0. 1. 0. 0.]
     [0. 0. 0. 0. 0. 0. 1. 0.]
     [0. 0. 0. 0. 0. 1. 0. 0.]
     [0. 0. 0. 0. 0. 0. 1. 0.]]
    [ True False  True False False False False False] [0, 2]
    [ 1.00000000e+00 -1.00000000e+00  1.00000000e+00 -1.00000000e+00
     -3.60555504e-17  0.00000000e+00  0.00000000e+00  0.00000000e+00]

```python
print(ew_1_vecs)
```

    [array([0.  , 0.  , 0.  , 0.  , 0.  , 0.71, 0.71, 0.  ]), array([ 0.  , -0.  , -0.  ,  0.  ,  0.  , -0.71, -0.71, -0.  ])]

Lösen alle Eigenvektoren die Gleichung $\mathbf{x} = A^T\mathbf{x}$ ? Nein nur Eigenvektoren zum Eigenwert 1

```python
# IHR CODE
for v in vectors:
    eigenvector_centrality(A)
```

```python
# IHR CODE
for vec in vectors:
    print(np.around(vec, 2))
```

    [0.   0.   0.   0.   0.   0.51 0.   0.  ]
    [ 0.   0.  -0.  -0.7 -0.   0.  -0.   0. ]
    [ 0.    0.   -0.    0.7   0.   -0.51 -0.    0.  ]
    [0. 0. 0. 0. 0. 0. 0. 0.]
    [0.   0.   0.   0.   0.   0.   0.   0.71]
    [ 0.71  0.71 -0.71  0.08 -0.71 -0.68 -0.71  0.  ]
    [ 0.71 -0.71 -0.71 -0.08 -0.   -0.   -0.   -0.71]
    [ 0.    0.   -0.   -0.    0.71  0.17  0.71  0.  ]

Sind die Eigenvektoren linear unabhängig? Was ist Dimension des Eigenraums $E_1$ und damit die geometrische Vielfachheit zum Eigenwert 1?

-----

 Nein die Eigenvektoren sind nicht linear unabhängig, die geometrische Vielfachheit = 2

-----

Normalisieren Sie zum Vergleich mit Ihren Resultaten aus Teilaufgabe b) die gefundene Basis aus linear unabhängigen Eigenvektoren von $A^T$ zum Eigenwert 1, indem Sie jeden Basisvektor durch die Summe seiner Elemente teilen. Zeigen Sie, dass Sie nun damit die gleichen Wichtigkeiten bekommen wie in Teilaufgabe b).

```python
# IHR CODE
norm_vecs = []
for vec in ew_1_vecs:
    norm_vecs.append(vec / np.linalg.norm(vec))

print(norm_vecs)
print(weights)

#plot_graph( A, knoten_gewichte=norm_vecs[1] )
```

    [array([0.        , 0.        , 0.        , 0.        , 0.        ,
           0.70710678, 0.70710678, 0.        ]), array([ 0.        , -0.        , -0.        ,  0.        ,  0.        ,
           -0.70710678, -0.70710678, -0.        ])]
    [0.         0.0049867  0.00371849 0.         0.         0.70220697
     0.71189248 0.00870519]

Weil die Wichtigkeiten ganz einfach als Eigenwertproblem berechenbar sind, hat dieses Wichtigkeitsmass den Namen **Eigenvector Centrality** bekommen. Es macht theoretisch Sinn, aber seine praktischen Probleme sind offensichtlich: fliesst wie zum Beispiel in diesem Graphen die Wichtigkeit immer in zwei Knoten, die sie sich dann gegenseitig zuschaufeln, gehen alle anderen Knoten leer aus und bekommen die Wichtigkeit 0. Es wäre aber durchaus nützlich, etwas differenziertere Wichtigkeiten zu erhalten.

## Aufgabe 3: Page Rank

Motiviert durch die Probleme der Eigenvector Centrality möchten wir den Gedankengang noch etwas weiterführen: Wenn ein Knoten die Wichtigkeit $x_k$ besitzt, aber 10 Verbindungen zu anderen Knoten hat, dann sollte jeder dieser anderen Knoten von ihm nur seinen Anteil dieser Wichtigkeit abbekommen, in diesem Fall $\frac{x_k}{10}$; damit bleibt die totale Wichtigkeit im System erhalten (statt dass sie wie vorher divergiert). Dazu modifizieren wir die Eigenvector Centrality folgendermassen:

$$ x_i = \sum_k A^T_{ik} \frac{x_k}{L(k)},  $$

wobei $L(k) = \sum_i A_{ki}$ die Anzahl der von Knoten $k$ ausgehenden Verbindungen bezeichnet. Den Ausgangsgrad $L(k)$ des Knotens $k$ können wir auch in die Matrix nehmen:

$$ x_i = \sum_k \frac{A^T_{ik}}{L(k)} x_k \equiv \sum_k H_{ik} x_k, \quad \text{mit} \quad H_{ik} = \frac{A^T_{ik}}{L(k)}.$$

### Teilaufgabe a) Berechnung von H

Berechnen Sie die Matrix $H$, indem Sie alle Elemente der Matrix $A$ durch ihren Ausgangsgrad dividieren (siehe Aufgabe 2a)) und geben Sie sie aus.

**Hinweis:** Die Spalten von $H$ sollten sich damit jeweils zu eins summieren, prüfen Sie dies mit einem `assert`-Statement.

```python
# IHR CODE
def normalize_Outgoing(A):
    return_A = A.copy()
    for i, row in enumerate(return_A):
        return_A[i] = np.around(row / sum(row), 2)

    return return_A

H = normalize_Outgoing(A)
print(H)
for i in range(H.shape[1]):
    print(np.sum(H[:,i]))
```

    [[0.   0.33 0.   0.   0.   0.   0.33 0.33]
     [0.   0.   0.5  0.   0.   0.   0.   0.5 ]
     [0.   0.5  0.   0.   0.   0.   0.   0.5 ]
     [0.   0.   0.   0.   0.   0.5  0.   0.5 ]
     [0.   0.   0.   0.   0.   1.   0.   0.  ]
     [0.   0.   0.   0.   0.   0.   1.   0.  ]
     [0.   0.   0.   0.   0.   1.   0.   0.  ]
     [0.   0.   0.   0.   0.   0.   1.   0.  ]]
    0.0
    0.8300000000000001
    0.5
    0.0
    0.0
    2.5
    2.33
    1.83

### Teilaufgabe b) Exakte Lösung

Zur Vermeidung von numerischen Instabilitäten fügen wir ohne weitere Erläuterungen einen sogenannten *Dämpfungsterm* zur Formel hinzu:

$$ x_i = (1-d) \frac{1}{n} + d \sum_k \frac{A^T_{ik}}{L(k)} x_k,$$

mit der Anzahl Knoten $n$ und dem Dämpfungsfaktor $d=0.85$. Die 'Dämpfung' sorgt dafür, dass kein $x_i$ null ist, sondern mindestens $\frac{1-d}{n}$. Das ist die ursprüngliche Formel, die Larry Page und Sergey Brin als *Google Page Rank* eingeführt haben $^1$. Mit der oben berechneten Matrix $H$ lautet sie:

$$  x_i = \frac{1-d}{n} + d \sum_k H_{ik} x_k.$$

Mit Matrizen und Vektoren geschrieben:

$$ \mathbf{x} = \frac{1-d}{n} \mathbf{1} + d \, H \, \mathbf{x},$$

wobei $\mathbf{1}$ einen Vektor aus $n$ Einsen bezeichnet.

Um die obige Gleichung nach $\mathbf{x}$ aufzulösen, müssen wir nach ein paar Umformungen die Marix $H$ invertieren, d.h. wir müssen die Adjazenzmatrix invertieren. Für die Adjazenzmatrizen grosser Graphen ist es nicht einfach, die Inverse zu berechnen.
In diesem Fall haben Sie aber Glück: Der Graph ist klein genug und es braucht nicht sehr viel Zeit, die Inverse einer 8x8-Matrix zu berechnen. Lösen Sie nun die obige Gleichung (von Hand) nach $\mathbf{x}$ auf und geben Sie über die gefundene Formel die Lösung $\mathbf{x}$ für diesen Graphen aus.

```python
d = 0.85
n = A.shape[0]

# IHR CODE

```

Auch hier können wir unseren Graphen mit den entsprechenden Knotengewichten wieder visualisieren:

```python
plot_graph( A, knoten_gewichte=x )
```

Im Unterschied zur Eigenvector Centrality sollten nun alle Knoten ein einigermassen 'vernünftiges' Gewicht haben.

## Aufgabe 4: Berechnung von Page Rank ohne Matrixinversion

Nicht immer ist eine Inversion der Matrix $H$ möglich oder effizient genug, bei sehr grossen Matrizen dauert eine Inversion sehr sehr lange und wird numerisch instabil (Die Matrixinversion wächst effizient implementiert mit $\mathcal{O}(n^{2.373})$, das Invertieren einer Matrix mit einer doppelt so grossen Anzahl Zeilen und Spalten braucht also $2^{2.373} \approx 5$ mal länger). Für Graphen mit vielen Knoten ist meistens eine iterative Annäherung wesentlich schneller und stabiler. Im Folgenden haben wir zwei Ansätze für Sie vorbereitet, die Page Rank von zwei verschiedenen Seiten betrachten.

### Teilaufgabe a) Iterative Lösung: Die Power-Methode

In Aufgabe 2b) haben Sie ein iteratives Verfahren kennengelernt (das als *Fixpunkt-Verfahren* bezeichnet wird).
Auch dieses Problem kann iterativ gelöst werden. Setzen wir $\mathbf{x}$ iterativ immer wieder in die Formel ein, so führt das zu folgendem Ergebnis (Übung: Versuchen Sie diese Formel selbst von Hand herzuleiten):

$$ \mathbf{x} = \frac{1-d}{n} \left(\sum_{k=0}^\infty d^k H^k\right) \mathbf{1},$$

wobei $\mathbf{1}$ wiederum ein Vektor aus Einsen ist. Natürlich können Sie nicht die ganze unendliche Summe berechnen, da aber $d<1$ können wir annehmen, dass die Reihe irgendwann konvergiert. Berechnen Sie mit dieser einfachen Formel den Page Rank des Graphen. Ist das Resultat mit dem Resultat aus Aufgabe 3b) vergleichbar? Wie viele Potenzen von $H$ sind nötig, um an das Resultat von 3b) heranzukommen?

```python
# IHR CODE

```

```python
# IHR CODE

```

### Teilaufgabe b) Random Surfer - Modell

Page Rank kann auch als *stochastischer Prozess* modelliert werden, mit dem sogenannten *Random Surfer -  Modell*:
Stellen Sie sich vor, Sie starteten auf einer beliebigen Seite im Web (als Knoten im Graphen modelliert). Nun haben Sie folgende Möglichkeiten:

(1) Sie klicken auf einen zufälligen Link auf der Webseite und gelangen dadurch auf eine nächste Webseite (Sie springen also über eine Verbindung vom aktuellen Knoten im Graphen auf einen nächsten).

(2) Sie entscheiden sich für eine andere Webseite, die Sie zufällig im Kopf haben und springen auf diese, ohne einer Verbindung vom aktuellen Knoten zu folgen (Sie springen also zufällig auf einen x-beliebigen Knoten des Graphen, wird im Page Rank - Jargon oft als *Teleportation* bezeichnet).

Variante (1) machen Sie hier mit einer Wahrscheinlichkeit von p=d=85% und Variante (2) mit einer Wahrscheinlichkeit von p=1-d=15%. Damit ist auch die Rolle des Dämpfungsterms ein wenig geklärt.

Hier ist der Algorithmus noch einmal zusammengefasst:

* Setze zufällig einen Knoten als Startknoten
* Führe `n_jumps` mal den folgenden Ablauf durch:
    * Bezeichne den aktuellen Knoten als $k$
    * Ziehe eine zufällige Zahl $z$ zwischen 0 und 1
    * Falls $z >= d$:
        * springe zu einem zufälligen neuen Knoten $k$ im System ('Teleportation')
    * Andernfalls:
        * erstelle aus der Adjazenzmatrix eine Liste $L$ mit allen Knoten, die mit Knoten $k$ verbunden sind
        * wähle zufällig einen neuen Knoten $k$ aus der Liste $L$ als Sprungziel

Implementieren Sie eine Funktion, die `n_jumps` Sprünge durchführt entsprechend der obigen Beschreibung und die realisierte Knotenfolge als Liste ausgibt.

**Hinweise**:
* Eine Zahl zwischen 0 und 1 können Sie mit `np.random.rand()` erzeugen
* Ein zufällige Ganzzahl zwischen 0 und $n-1$ können Sie mit `np.random.randint(n)` erzeugen
* Ein zufälliges Element aus einer Liste `L` können Sie mit `np.random.choice(L)` ziehen
* Erstellen Sie eine Liste mit den erreichten Knoten. Mit `l.append(k)` können Sie die Zahl `k` and die Liste `l` anhängen. Mit `l[-1]` greifen Sie auf das letzte Element einer Liste zu.

```python
def random_surfer( A, n_jumps, d=0.85 ):
    """
    Funktion, die einen Random Surfer simuliert. Der Surfer startet bei einem zufälligen Knoten und folgt mit
    einer Wahrscheinlichkeit von d entweder einer der Verbindungen oder 'teleportiert' andernfalls auf einen zufällig
    ausgewählten Knoten im Graphen.

    Arguments:
    A -- Adjazenzmatrix des Graphen (shape (n,n))
    n_jumps -- Anzahl durchzuführende Sprünge
    d -- Dämpfungsfaktor, wird meistens auf 0.85 gesetzt

    Returns:
    knoten -- eine Liste mit den Knoten, die in diesem Durchlauf erreicht wurden
    """

    # IHR CODE STARTET HIER

    # IHR CODE ENDET HIER

jumps = random_surfer( A, 10000 )
```

Der Page Rank jedes Knotens ist approximiert als seine relative Häufigkeit in der errechneten Knotenfolge. Die Funktion `np.unique( .., return_counts=True )` gibt Ihnen eine Liste der Knoten (`unique`) und der Anzahl mit der sie erreicht wurden (`counts`) zurück, womit Sie die relativen Häufigkeiten berechnen können:

```python
unique, counts = np.unique( jumps, return_counts=True )
list( counts / counts.sum() )
```

Sie sollten auch hier ungefähr das gleiche Resultat wie in Aufgabe 3b) bekommen.

## Ausblick

Damit haben Sie zwei äquivalente Modelle für das gleiche Problem kennengelernt: Das erste definiert die Wichtigkeit einer Webseite als die Summe der relativen Wichtigkeiten der sie verlinkenden Webseiten plus einem Dämpfungsterm für die numerische Stabilität und das zweite simuliert einen 'Surfer', der zufällig entweder einzelnen Links folgt oder auf eine beliebige andere Webseite springt. Viele mathematische Probleme können aus äquivalenten determinisitischen und stochastischen Blickwinkeln betrachtet werden (siehe zB lineare Regression). Die beiden Anschauungsarten erlauben oft sehr komplementäre Erkenntnisse über das betrachtete System.

## Referenzen

1. Page, Brin, Motwani, Winograd: The PageRank Citation Ranking: Bringing Order to the Web (1998) [PDF](http://ilpubs.stanford.edu:8090/422/1/1999-66.pdf).


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

```python
x_0=10

def update(x):
    dt=0.001
    dxdt=(-x)**3
    approx=x+dt*dxdt
    return approx

def run(x_0):
    x=x_0
    sol_lst=[]
    for step in range(0,1000):
        x=update(x)
        sol_lst.append(x)
        #print(x)
    return sol_lst

sol_lst=run(10)
plt.plot(sol_lst)
```

```python
from sympy.solvers import solve

from sympy import Symbol
rho= Symbol('rho')
I=Symbol('I')
beta=Symbol('beta')
solve(beta*I*(1-I)-rho*I, I)
```

    [0, (beta - rho)/beta]