## Forecasting

```python
# Libraries

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

%matplotlib inline

# Databank
import mysql.connector
from sqlalchemy import create_engine, exc

import itertools ### Modul, welches bestimmte Schleifen ermöglicht
import warnings  ###Modul, welches uns erlaubt, bestimmte Warnungen ein- und auszublenden

#Sonstige Module und Funktionen
from time import gmtime, strftime #Für die aktuelle Zeit

```

```python
#Einstellen der Darstellungsoptionen

#Pandas - Mehr Zeilen und Spalten anzeigen
pd.set_option('display.max_rows', 500)
pd.set_option('display.max_columns', 500)

#Seaborn
sns.set_style("darkgrid")
plt.matplotlib.style.use('default')

my_colors = ["windows blue", "saffron", "hot pink", "algae green", "dusty purple", "greyish", "petrol", "denim blue", "lime"]
sns.set_palette(sns.xkcd_palette(my_colors))
colors = sns.xkcd_palette(my_colors)

#Warnings
warnings.filterwarnings("ignore")

#Connection-String für die Datenbank
str_db_typ = 'mysql+mysqlconnector://'
str_db_user = 'root'
str_db_password = ''
str_db_adr = '@localhost'
str_db_schema = ''
```

```python
### Sammlung aller selbstgeschriebenen Funktionen

#Mein Connection-String
def my_con_str(db_typ='mysql+mysqlconnector', db_user='root', db_passwort='', db_adr = 'localhost', db_schema = ''):
    '''Funktion, die einen DB-Connection-String zusammensetzt'''
    try:
        if len(db_schema) == 0:
            schema_sep = ''
            print('Achtung: Es wurde keine Datenbank angeben.')
        else:
            schema_sep ='/'
        con_str = '{0}://{1}{2}@{3}{4}{5}'.format(db_typ, db_user, db_passwort, db_adr, schema_sep, db_schema)
        print('Connection: {}'.format(con_str))
        return con_str
    except:
        print('Etwas ist schief gelaufen. Bitte alle Parameter überprüfen.')
        con_str = ''
        return con_str

#Anlegen einer neuen Datenbank, falls diese noch nicht da ist.
def my_create_schema(dbname, con):
    engine = create_engine(con)
    engine.execute('CREATE DATABASE IF NOT EXISTS {}'.format(dbname))
    return

#Meine Datenzusammenfassung
def my_df_summary(data):
    '''Eigene Funktion für die Summary'''
    try:
        dat = data.copy()
        df = pd.DataFrame([dat.min(), dat.max(), dat.mean(), dat.std(), dat.isna().sum(), dat.nunique(), dat.dtypes],
                     index=['Minimum', 'Maximum', 'Mittelwert', 'Stand. Abw.','#NA', '#Uniques', 'dtypes'])
        return df
    except:
        print('Es konnte keine Summary erstellt werden.')
        return data

```

### Datenbank einrichten

```python
con = my_con_str()
```

    Achtung: Es wurde keine Datenbank angeben.
    Connection: mysql+mysqlconnector://root@localhost

```python
my_create_schema('timeseries',con)
```

```python
con = my_con_str(db_schema='timeseries')
```

    Connection: mysql+mysqlconnector://root@localhost/timeseries

### Datensatz

```python
#Einlesen und anschauen des zu bearbeitenden Datensatzes
df = pd.read_excel(r'NYSE_sample.xlsx')
```

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

      Date
      AABA
      AAPL
      AMZN
      AXP
      BA
      CAT
      CSCO
      CVX
      DIS
      GE
      GOOGL
      GS
      HD
      IBM
      INTC
      JNJ
      JPM
      KO
      MCD
      MMM
      MRK
      MSFT
      NKE
      PFE
      PG
      TRV
      UNH
      UTX
      VZ
      WMT
      XOM

      0
      2006-01-03
      40.91
      10.68
      47.58
      52.58
      70.44
      57.80
      17.45
      59.08
      24.40
      35.37
      217.83
      128.87
      41.24
      82.06
      25.57
      61.63
      40.19
      20.45
      33.52
      79.11
      32.75
      26.84
      10.74
      23.78
      58.78
      45.99
      61.73
      56.53
      30.38
      46.23
      58.47

      1
      2006-01-04
      40.97
      10.71
      47.25
      51.95
      71.17
      59.27
      17.85
      58.91
      23.99
      35.32
      222.84
      127.09
      40.48
      81.95
      25.91
      62.58
      39.62
      20.41
      33.82
      78.71
      33.13
      26.97
      10.69
      24.55
      58.89
      46.50
      61.88
      56.19
      31.27
      46.32
      58.57

      2
      2006-01-05
      41.53
      10.63
      47.65
      52.50
      70.33
      59.27
      18.35
      58.19
      24.41
      35.23
      225.85
      127.04
      40.08
      82.50
      26.27
      62.32
      39.74
      20.51
      33.86
      77.99
      33.18
      26.99
      10.76
      24.58
      58.70
      46.95
      61.69
      55.98
      31.63
      45.69
      58.28

      3
      2006-01-06
      43.21
      10.90
      47.87
      52.68
      69.35
      60.45
      18.77
      59.25
      24.74
      35.47
      233.06
      128.84
      40.38
      84.95
      26.31
      62.60
      40.02
      20.70
      34.06
      78.63
      33.12
      26.91
      10.72
      24.85
      58.64
      47.21
      62.90
      56.16
      31.35
      45.88
      59.43

      4
      2006-01-09
      43.42
      10.86
      47.08
      53.99
      68.77
      61.55
      19.06
      58.95
      25.00
      35.38
      233.68
      130.39
      40.82
      83.73
      26.47
      62.99
      40.67
      20.80
      34.71
      79.02
      33.66
      26.86
      10.88
      24.85
      59.08
      47.23
      61.40
      56.80
      31.48
      45.71
      59.40

```python
df = df.iloc[:,0:6]
```

```python
df.info()
```

    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 3020 entries, 0 to 3019
    Data columns (total 6 columns):
     #   Column  Non-Null Count  Dtype
    ---  ------  --------------  -----
     0   Date    3020 non-null   object
     1   AABA    3019 non-null   float64
     2   AAPL    3019 non-null   float64
     3   AMZN    3019 non-null   float64
     4   AXP     3020 non-null   float64
     5   BA      3020 non-null   float64
    dtypes: float64(5), object(1)
    memory usage: 141.7+ KB

```python
df.describe()
```

      AABA
      AAPL
      AMZN
      AXP
      BA

      count
      3019.000000
      3019.000000
      3019.000000
      3020.000000
      3020.000000

      mean
      28.412726
      64.662892
      299.376231
      59.385315
      101.515868

      std
      13.258163
      44.536448
      279.980161
      19.371901
      47.480903

      min
      8.950000
      7.240000
      26.070000
      10.260000
      29.360000

      25%
      16.130000
      23.420000
      81.090000
      45.715000
      70.600000

      50%
      27.100000
      57.920000
      205.440000
      57.815000
      85.490000

      75%
      36.635000
      99.480000
      375.140000
      75.892500
      130.615000

      max
      72.930000
      176.420000
      1195.830000
      99.700000
      297.900000

```python
my_df_summary(df)
```

      Date
      AABA
      AAPL
      AMZN
      AXP
      BA

      Minimum
      2006-01-03
      8.95
      7.24
      26.07
      10.26
      29.36

      Maximum
      2017-12-29
      72.93
      176.42
      1195.83
      99.7
      297.9

      Mittelwert
      NaN
      28.4127
      64.6629
      299.376
      59.3853
      101.516

      Stand. Abw.
      NaN
      13.2582
      44.5364
      279.98
      19.3719
      47.4809

      #NA
      0
      1
      1
      1
      0
      0

      #Uniques
      3020
      1960
      2579
      2901
      2385
      2556

      dtypes
      object
      float64
      float64
      float64
      float64
      float64

```python
df.plot()
```

```python
#Oder grafisch
%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 6]
fig = df.plot(kind='line')
plt.title('Plot aller numerischen Werte', size=14)
plt.ylabel('Preis in USD')
plt.legend(fontsize=12)
plt.show()
```

### 1. Schritt: In welcher Frequenz liegen meine Zeitreihendaten vor?

```python
df_ = df.copy()

s_Dates = pd.to_datetime(df_.iloc[:,0], format='%Y-%m-%d', errors='ignore')

#Mit "Nummer" des Wochentages
df_NuDay = pd.DataFrame(s_Dates.dt.dayofweek.value_counts(dropna=False))
df_NuDay = df_NuDay.reset_index()
df_NuDay.columns=['#Tag', 'Anzahl']

#Mit "Namen" des Wochentages
df_NaDay = pd.DataFrame(s_Dates.dt.day_name().value_counts(dropna=False))
df_NaDay = df_NaDay.reset_index()
df_NaDay.columns=['Tag', 'Anzahl']
```

```python
df_NaDay
```

      Tag
      Anzahl

      0
      Wednesday
      622

      1
      Tuesday
      618

      2
      Thursday
      609

      3
      Friday
      605

      4
      Monday
      566

```python
#Oder grafisch
%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 4]
fig=sns.barplot(x='Tag', y='Anzahl', data=df_NaDay)
plt.title("Wie häufig sind die einzelnen Wochentage enthalten?", size=14)
plt.xlabel("")
plt.ylabel("Häufigkeiten")
plt.show()
```

### Aufbereiten der fehlenden Werte

```python
#Das muss Zeitreihe für Zeitreihe individuell gemacht werden!
#Schritt 0: Auswahl einer Zeitreihe
df_ = df.iloc[:,0:2].copy() #Hier wähle ich den Index (Spalte 0) und die erste Datenspalte (Spalte 1) aus.

# df.iloc[:,[0,4]].copy() #So würde ich bspw. die 4. Datenspalte (Spalte 5) auswählen.
```

```python
df_.head()
```

      Date
      AABA

      0
      2006-01-03
      40.91

      1
      2006-01-04
      40.97

      2
      2006-01-05
      41.53

      3
      2006-01-06
      43.21

      4
      2006-01-09
      43.42

```python
#Cleansing
#Schritt 1:
l_colnames = df_.columns.to_list()
l_colnames[0] = 'Date'
df_.columns = l_colnames

df_['Date'] = pd.to_datetime(df_['Date'], format='%Y-%m-%d', errors='ignore')
daterange = pd.date_range(start=min(df_['Date']), end=max(df_['Date']), freq='B')
df_ts = pd.DataFrame(daterange)
df_ts.columns = ['Date']
df_ts = df_ts.merge(df_, how='left', on='Date')

#Schritt 2
print('{} fehlende Werte werden durch den zuletzt gültigen Wert ersetzt.'.format(df_ts.iloc[:,1].isna().sum()))
df_ts = df_ts.fillna(method='ffill')
df_ts = df_ts.set_index('Date', drop=True)
```

    110 fehlende Werte werden durch den zuletzt gültigen Wert ersetzt.

```python
#Plotten der vollständigen Daten
%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 6]
fig = df_ts.plot(kind='line')
plt.title('Vollständige Zeitreihe', size=18)
plt.legend(fontsize=12)
plt.ylabel('Preis in USD', size=12)
plt.show()
```

### Ausreißer erkennen

```python
#Ausreißer Anzeigen - Für Zeitreihen NICHT entfernen.
#Ausreißer erkennt man in Zeitreihen an dem Verhalten der prozentualen Veränderungen zum jeweils vorherigen Wert.
%matplotlib inline
df_ = df.iloc[:,0:2].copy()
df_.iloc[:,1] = np.log(df_.iloc[:,1]) #Log-Differenzen sind die prozentualen Veränderungen
df_diff = df_.iloc[:,1].diff()

plt.rcParams['figure.figsize'] = [15, 2]

fig = sns.boxplot(data=df_diff, orient='h')

plt.title('Häufigkeiten der prozentualen Wertveränderungen', size=14)
plt.xlabel('')
plt.show()
```

```python
i_iqr_faktor = 2
df_ = df.iloc[:,0:2].copy()
df_['Date'] = pd.to_datetime(df_['Date'], format='%Y-%m-%d', errors='ignore')
df_.iloc[:,1:] = np.log(df_.iloc[:,1:])
df_diff = df_.diff()

q25 = df_diff.iloc[:,1].quantile(0.25)
q75 = df_diff.iloc[:,1].quantile(0.75)

iqr = q75-q25

grenze_unten = q25 - (i_iqr_faktor*iqr)
grenze_oben = q75 + (i_iqr_faktor*iqr)
df_[((df_diff.iloc[:,1] < grenze_unten) | (df_diff.iloc[:,1] > grenze_oben))]

df_dates = df_[((df_diff.iloc[:,1] < grenze_unten) | (df_diff.iloc[:,1] > grenze_oben))]
df_dates = df_dates.reset_index()
df_dates = df_dates.iloc[:,0:2]

print('Bei den eingegebenen Daten und des IQR-Faktors sind Tage, an denen die Wertveränderung < {0:.2f} oder > {1:.2f} war, auffällig.'.format(grenze_unten, grenze_oben))
print('Dies tritt an {} Tagen auf: '.format(len(df_dates)))
df_dates
```

    Bei den eingegebenen Daten und des IQR-Faktors sind Tage, an denen die Wertveränderung < -0.05 oder > 0.06 war, auffällig.
    Dies tritt an 89 Tagen auf:

      index
      Date

      0
      10
      2006-01-18

      1
      73
      2006-04-19

      2
      136
      2006-07-19

      3
      179
      2006-09-19

      4
      265
      2007-01-24

      5
      323
      2007-04-18

      6
      335
      2007-05-04

      7
      450
      2007-10-17

      8
      457
      2007-10-26

      9
      458
      2007-10-29

      10
      465
      2007-11-07

      11
      508
      2008-01-10

      12
      517
      2008-01-24

      13
      521
      2008-01-30

      14
      523
      2008-02-01

      15
      554
      2008-03-18

      16
      586
      2008-05-02

      17
      587
      2008-05-05

      18
      614
      2008-06-12

      19
      630
      2008-07-07

      20
      672
      2008-09-04

      21
      682
      2008-09-18

      22
      684
      2008-09-22

      23
      689
      2008-09-29

      24
      692
      2008-10-02

      25
      696
      2008-10-08

      26
      697
      2008-10-09

      27
      699
      2008-10-13

      28
      700
      2008-10-14

      29
      701
      2008-10-15

      30
      702
      2008-10-16

      31
      705
      2008-10-21

      32
      710
      2008-10-28

      33
      712
      2008-10-30

      34
      718
      2008-11-07

      35
      721
      2008-11-12

      36
      722
      2008-11-13

      37
      725
      2008-11-18

      38
      726
      2008-11-19

      39
      729
      2008-11-24

      40
      732
      2008-11-28

      41
      733
      2008-12-01

      42
      734
      2008-12-02

      43
      740
      2008-12-10

      44
      761
      2009-01-12

      45
      764
      2009-01-15

      46
      772
      2009-01-28

      47
      781
      2009-02-10

      48
      785
      2009-02-17

      49
      790
      2009-02-24

      50
      805
      2009-03-17

      51
      823
      2009-04-13

      52
      833
      2009-04-27

      53
      872
      2009-06-22

      54
      898
      2009-07-29

      55
      1094
      2010-05-10

      56
      1144
      2010-07-21

      57
      1203
      2010-10-13

      58
      1308
      2011-03-15

      59
      1348
      2011-05-11

      60
      1396
      2011-07-20

      61
      1407
      2011-08-04

      62
      1409
      2011-08-08

      63
      1410
      2011-08-09

      64
      1412
      2011-08-11

      65
      1424
      2011-08-29

      66
      1431
      2011-09-08

      67
      1446
      2011-09-29

      68
      1449
      2011-10-04

      69
      1450
      2011-10-05

      70
      1468
      2011-10-31

      71
      1504
      2011-12-21

      72
      1664
      2012-08-10

      73
      1715
      2012-10-23

      74
      1896
      2013-07-17

      75
      2031
      2014-01-29

      76
      2085
      2014-04-16

      77
      2099
      2014-05-07

      78
      2126
      2014-06-16

      79
      2194
      2014-09-22

      80
      2283
      2015-01-29

      81
      2359
      2015-05-19

      82
      2454
      2015-10-02

      83
      2496
      2015-12-02

      84
      2517
      2016-01-04

      85
      2520
      2016-01-07

      86
      2546
      2016-02-16

      87
      2758
      2016-12-15

      88
      2877
      2017-06-08

```python
#Sind einzelne Jahre (Monate) besonders auffällig?
%matplotlib inline
df_dates['Jahr'] = df_dates['Date'].map(lambda x: x.strftime('%Y'))
plt.rcParams['figure.figsize'] = [15, 4]
df_dates.groupby('Jahr').size().plot(kind = 'bar')
plt.xlabel('Periode')
plt.ylabel('Anzahl')
plt.show()
```

```python
#Zu untersuchende Daten auswählen
df_ts_clean = df_ts[df_ts.index > '2013-12-31']
print('Insgesamt liegen {} zusammenhängende ähnliche Beobachtungen vor.'.format(len(df_ts_clean)))
```

    Insgesamt liegen 1043 zusammenhängende ähnliche Beobachtungen vor.

### Erzeugen eines Trainings- und eines Testdatensatzes

```python
#Zerteilung in Trainings- und Testdaten - Im Normalfall beginnt man mit dem Verhältnis 80:20
#Weil wir Tagesdaten haben und mit "klassischen" Zeitreihenanalyseverfahren schwerlich mehr als 60 Perioden mit hoher
#Genauigkeit schätzen kann, teilen wir 95:5.

i_split = int(0.95*len(df_ts_clean))

df_train, df_test = df_ts_clean.iloc[:i_split,:], df_ts_clean.iloc[i_split:,:]

print('Train und Test sind zusammen {} Einträge lang.'.format(len(df_train)+len(df_test)))
print('D.h., alle Forecasts müssen {} Perioden lang sein.'.format(len(df_test)))
```

    Train und Test sind zusammen 1043 Einträge lang.
    D.h., alle Forecasts müssen 53 Perioden lang sein.

```python
#Plot

plt.rcParams['figure.figsize'] = [15, 6]

plt.plot(df_train.index, df_train.values, label='Trainingsdaten')
plt.plot(df_test.index, df_test.values, label='Testdaten', color=colors[1])

plt.axvline(x = df_ts_clean.index[i_split], linewidth=2, color='grey', ls='--')
plt.legend(loc=2, fontsize=10)
plt.title('Aktienkurs (Close) von {} an der NYSE'.format(df_train.columns[0]), fontsize=14)
plt.xlabel('Zeit', fontsize=10)
plt.ylabel('Preis in USD', fontsize=10)
plt.show()
```

```python

```

## Prognose-Verfahren

## Naiver Forecast - Einfaches Fortschreiben des letzten Wertes

\begin{align}
  \hat{y}_{t+1} &= y_{t}
\end{align}

```python
vals = np.asarray(df_train.values)
y_hat = df_test.copy()
y_hat['naiv'] = vals[-1][0]
```

```python
%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 6]

plt.plot(df_train.index, df_train.values, label='Traininsdaten')
plt.plot(df_test.index, df_test.values, label='Testdaten')
plt.plot(y_hat.index, y_hat['naiv'], label='Naiver Forecast')
plt.legend(loc='best')
plt.title("Naiver Forecast")
plt.show()
```

### Wie lässt sich die Güte eines Forecasts bewerten?

### Root Mean Squared Error - RMSE

Tipp: https://en.wikipedia.org/wiki/Root-mean-square_deviation

\begin{align}
\text{RMSE}   \; &=  \sqrt{\frac{\sum_{t=1}^T (\hat{y}_t - y_t)^2}{T}}
\end{align}

Seltener schaut man auch einfach auch den durchschnittlichen Fehler.

\begin{align*}
\text{ME}   \; &=  \frac{\sum_{t=1}^T (\hat{y}_t - y_t)}{T}
\end{align*}

```python
#Importieren der Fehler-Schätzstatistiken aus sklearn
from sklearn.metrics import mean_squared_error
from math import sqrt #Importieren einer Wurzel-Funktion aus math
```

```python
rmse = sqrt(mean_squared_error(df_test, y_hat.naiv))
me = (df_test.iloc[:,0] - y_hat['naiv']).sum() / len(df_test)
print('Für den naiven Forecast ergeben sich ein ME: {0:.4f} und ein RMSE: {1:.4f}.'.format(me,rmse))
```

    Für den naiven Forecast ergeben sich ein ME: 3.2947 und ein RMSE: 3.7385.

Auch kann man die Güte es Forecasts an bestimmten Eigenschaften der Residuen (Schätzfehler) ablesen.

\begin{align*}
\text{Residuen}   \; &=  \hat{y}_t - y_t \quad = \epsilon_t
\end{align*}

```python
residuen = (df_test.iloc[:,0] - y_hat['naiv'])
print('Die Residuen haben für diesen Forecast folgene Standardabweichung: {0:.4f}.'.format(residuen.std()))
stdres = residuen.std()
```

    Die Residuen haben für diesen Forecast folgene Standardabweichung: 1.7835.

```python
%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 12]
plt.subplot(2,1,1)
residuen.hist(bins=50, density=True)
plt.subplot(2,1,2)
plt.plot(df_test.index, residuen.values, label='Residuen', linewidth=2)
plt.legend(loc=2)
plt.title("Residuen")
plt.show()
```

### Wie kann man Verfahren denn Vergleichen?

```python
#Anlegen einer Tabelle, um später die Güte verschiedener Verfahren miteinander vergleichen zu können.
#ACHTUNG: Mit dieser Zeile wird ein leerer DataFrame erzeugt.
df_Fehler = pd.DataFrame(columns=['Methode', 'ME', 'RMSE', 'StdRes'])
```

```python
#Einfügen der Güte-Maße
df_Fehler = df_Fehler.append({'Methode': 'Naives Fortschreiben', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)
```

```python
df_Fehler
```

      Methode
      ME
      RMSE
      StdRes

      0
      Naives Fortschreiben
      3.294717
      3.73847
      1.78354

## Moving Average (Gleitender Mittelwert)

```python
#Mit rolling und mean() kann man den gleitenden Mittelwert ganz einfach erzeugen.
#Mit einer Schleife und append schreibt man die Werte fort.

n = 60 #Bspw. den gleitenden Durchschnitt über alle Handelstage des letzten Quartals.
df_train.rolling(n).mean().iloc[-1][0]
```

    63.95716666666658

```python
n = 60
df_mav = df_train.copy()
for i in range(len(df_test)):
    df_mav = df_mav.append({df_mav.columns[0] : df_mav.rolling(n).mean().iloc[-1][0]}, ignore_index=True)
```

```python
y_hat = df_test.copy()
y_hat_mav = df_mav.iloc[-len(df_test):].copy()
y_hat['mav'] = y_hat_mav.values
```

```python
%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 6]

plt.plot(df_train.index, df_train.values, label='Trainingsdaten')
plt.plot(df_test.index, df_test.values, label='Testdaten')
plt.plot(df_test.index, y_hat['mav'].values, label='Moving Average Forecast')

plt.title('Gleitender Durchschnitt')
plt.legend(loc='best')
plt.show()
```

```python
rmse = sqrt(mean_squared_error(df_test, y_hat.mav))
me = (df_test.iloc[:,0] - y_hat['mav']).sum() / len(df_test)
print('Für den naiven Forecast ergeben sich ein ME: {0:.4f} und ein RMSE: {1:.4f}.'.format(me,rmse))

residuen = (df_test.iloc[:,0] - y_hat['mav'])
print('Die Residuen haben für diesen Forecast folgene Standardabweichung: {0:.4f}.'.format(residuen.std()))
stdres = residuen.std()
```

    Für den naiven Forecast ergeben sich ein ME: 4.6000 und ein RMSE: 4.8247.
    Die Residuen haben für diesen Forecast folgene Standardabweichung: 1.4691.

```python
df_Fehler = df_Fehler.append({'Methode': 'Moving Average', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)
```

```python
df_Fehler
```

      Methode
      ME
      RMSE
      StdRes

      0
      Naives Fortschreiben
      3.294717
      3.738470
      1.783540

      1
      Moving Average
      4.600020
      4.824714
      1.469149

```python
%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 12]
plt.suptitle('Moving Average')
plt.subplot(2,1,1)
residuen.hist(bins=50, density=True)
plt.subplot(2,1,2)
plt.plot(df_test.index, residuen.values, label='Residuen', linewidth=2)
plt.legend(loc=2)
plt.title("Residuen - Moving Average")
plt.show()
```

Tipp: Wer sich intensiver mit der Mathematik hinter den meisten bekannten und etablierten Verfahren zur Zeitreihenen-Analyse beschäftigen möchte, kann sich u.a. hier einlesen:

https://www.stat.berkeley.edu/~arturof/Teaching/STAT248/lab10_part1.html

### Exponentielle Glättung

Einfache (naive) exponentielle Glättung

Für die einfache exponentielle Glättung gilt:

\begin{align}
  \hat{y}_{t+1} &= \hat{y}_{t} + \alpha ( y_t - \hat{y}_{t})
\end{align}

Weil $\alpha$ zwischen 0 und 1 liegen muss, kann man die Gleichung umschreiben:

\begin{align}
  \hat{y}_{t+1} &=  \alpha y_t + (1-\alpha) \hat{y}_{t}
\end{align}

Und das lässt sich schreiben als:

\begin{align}
  \hat{y}_{t+1} &= \alpha y_{t} + \alpha(1-\alpha) y_{t-1} + \alpha(1-\alpha)^2 y_{t-2} + \cdots + \alpha(1-\alpha)^{t-1} y_{1} + \alpha(1-\alpha)^{t} \hat{y}_{1}
\end{align}

```python
import statsmodels.api as sm
from statsmodels.tsa.api import ExponentialSmoothing, SimpleExpSmoothing, Holt
```

```python
#Forecasts mit verschiedenen Glättungsparametern erzeugen
fit1 = SimpleExpSmoothing(df_train).fit(smoothing_level=0.25,optimized=False)
fcast1 = fit1.forecast(len(df_test)).rename(r'$\alpha=0.25$')

fit2 = SimpleExpSmoothing(df_train).fit(smoothing_level=0.50,optimized=False)
fcast2 = fit2.forecast(len(df_test)).rename(r'$\alpha=0.50$')

fit3 = SimpleExpSmoothing(df_train).fit()
fcast3 = fit3.forecast(len(df_test)).rename(r'$\alpha=%s$'%round(fit3.model.params['smoothing_level'],1))
```

```python
%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 6]

fcast1.plot(legend=True, color=colors[0], ls='--')
fit1.fittedvalues.plot(color=colors[0])

fcast2.plot(legend=True, color=colors[1], ls='--')
fit2.fittedvalues.plot(color=colors[1])

fcast3.plot(legend=True, color=colors[2], ls='--')
fit3.fittedvalues.plot(color=colors[2])

plt.title('Anpassung der einfachen exponentiellen Glättung auf {}'.format(df_train.columns[0]), fontsize=14)
plt.xlabel('Zeit', fontsize=10)
plt.ylabel('Preis in USD', fontsize=10)

plt.show()
```

```python
fit3.params
```

    {'smoothing_level': 0.9779823062246565,
     'smoothing_slope': nan,
     'smoothing_seasonal': nan,
     'damping_slope': nan,
     'initial_level': 40.42145456370888,
     'initial_slope': nan,
     'initial_seasons': array([], dtype=float64),
     'use_boxcox': False,
     'lamda': None,
     'remove_bias': False}

```python
rmse = sqrt(mean_squared_error(df_test, fcast1.values))
me = (df_test.iloc[:,0] - fcast1.values).sum() / len(df_test)
residuen = (df_test.iloc[:,0] - fcast1.values)
stdres = residuen.std()
print('Forecast 1   -   RMSE: {0:.4f}, ME: {1:.4f}, StdRes: {2:.4f}'.format(rmse, me, stdres))
```

    Forecast 1   -   RMSE: 2.9550, ME: 2.3688, StdRes: 1.7835

```python
df_Fehler = df_Fehler.append({'Methode': 'EinfExpGlätt 0.25', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)
```

```python
rmse = sqrt(mean_squared_error(df_test, fcast2.values))
me = (df_test.iloc[:,0] - fcast2.values).sum() / len(df_test)
residuen = (df_test.iloc[:,0] - fcast2.values)
stdres = residuen.std()
print('Forecast 2   -   RMSE: {0:.4f}, ME: {1:.4f}, StdRes: {2:.4f}'.format(rmse, me, stdres))
```

    Forecast 2   -   RMSE: 3.2135, ME: 2.6843, StdRes: 1.7835

```python
df_Fehler = df_Fehler.append({'Methode': 'EinfExpGlätt 0.5', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)
```

```python
rmse = sqrt(mean_squared_error(df_test, fcast3.values))
me = (df_test.iloc[:,0] - fcast3.values).sum() / len(df_test)
residuen = (df_test.iloc[:,0] - fcast3.values)
stdres = residuen.std()
print('Forecast 3   -   RMSE: {0:.4f}, ME: {1:.4f}, StdRes: {2:.4f}'.format(rmse, me, stdres))
```

    Forecast 3   -   RMSE: 3.7143, ME: 3.2672, StdRes: 1.7835

```python
df_Fehler = df_Fehler.append({'Methode': 'EinfExpGlätt 1', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)
```

```python
df_Fehler
```

      Methode
      ME
      RMSE
      StdRes

      0
      Naives Fortschreiben
      3.294717
      3.738470
      1.783540

      1
      Moving Average
      4.600020
      4.824714
      1.469149

      2
      EinfExpGlätt 0.25
      2.368758
      2.954997
      1.783540

      3
      EinfExpGlätt 0.5
      2.684340
      3.213515
      1.783540

      4
      EinfExpGlätt 1
      3.267222
      3.714261
      1.783540

Exponentielle Glättung mit 'Level' und 'Trend' (Holt-Verfahren)

Das Holt-Verfahren nennt man auch zweifache exponentielle Glättung:

\begin{align*}
\text{Level: }   \; \quad \ell_t &=  \alpha y_t + (1-\alpha) (\ell_{t-1} + b_{t-1}) \\ \\
\text{Growth: }   \; \quad b_t &= \beta^* (\ell_t - \ell_{t-1}) + (1-\beta^*) b_{t-1} \\ \\
\text{Forecast: } \; \hat{y}_{t+h|t} &= \ell_t + b_t h \\ \\
\end{align*}

Manchmal wird der Trend auch gedämpft.

\begin{align*}
\text{Level: }   \; \quad \ell_t &=  \alpha y_t + (1-\alpha) (\ell_{t-1} + \phi b_{t-1}) \\ \\
\text{Growth: }   \; \quad b_t &= \beta^* (\ell_t - \ell_{t-1}) + (1-\beta^*) b_{t-1} \\ \\
\text{Forecast: } \; \hat{y}_{t+h|t} &= \ell_t + (\phi + \phi^2 + \cdots + \phi^h) b_t h \\ \\
\end{align*}

```python
#Schätzen der zweifachen exponentiellen Glättung
fit4 = Holt(df_train.iloc[:,0]).fit(smoothing_level=0.8, smoothing_slope=0.2, optimized=False)
fcast4 = fit4.forecast(len(df_test)).rename("Holt's linear trend")

fit5 = Holt(df_train.iloc[:,0], exponential=True).fit(smoothing_level=0.8, smoothing_slope=0.2, optimized=False)
fcast5 = fit5.forecast(len(df_test)).rename("Exponential trend")

fit6 = Holt(df_train.iloc[:,0], damped=True).fit()
fcast6 = fit6.forecast(len(df_test)).rename("Additive damped trend")
```

```python
fit6.params
```

    {'smoothing_level': 0.9044915581422307,
     'smoothing_slope': 1.0,
     'smoothing_seasonal': nan,
     'damping_slope': 0.04365336398134816,
     'initial_level': 40.46649733865921,
     'initial_slope': 0.0,
     'initial_seasons': array([], dtype=float64),
     'use_boxcox': False,
     'lamda': None,
     'remove_bias': False}

```python
#Plotten der Ergebnisse

%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 6]

fcast4.plot(legend=True, color=colors[3], ls='--')
fit4.fittedvalues.plot(color=colors[3])

fcast5.plot(legend=True, color=colors[4], ls='--')
fit5.fittedvalues.plot(color=colors[4])

fcast6.plot(legend=True, color=colors[5], ls='--')
fit6.fittedvalues.plot(color=colors[5])

plt.title('Anpassung der zweifachen exponentiellen Glättung auf {}'.format(df_train.columns[0]), fontsize=14)
plt.xlabel('Zeit', fontsize=10)
plt.ylabel('Preis in USD', fontsize=10)

plt.show()
```

```python
rmse = sqrt(mean_squared_error(df_test, fcast4.values))
me = (df_test.iloc[:,0] - fcast4.values).sum() / len(df_test)
residuen = (df_test.iloc[:,0] - fcast4.values)
stdres = residuen.std()
print('Forecast 4   -   RMSE: {0:.4f}, ME: {1:.4f}, StdRes: {2:.4f}'.format(rmse, me, stdres))

df_Fehler = df_Fehler.append({'Methode': 'Holt LT', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)

rmse = sqrt(mean_squared_error(df_test, fcast5.values))
me = (df_test.iloc[:,0] - fcast5.values).sum() / len(df_test)
residuen = (df_test.iloc[:,0] - fcast5.values)
stdres = residuen.std()
print('Forecast 5   -   RMSE: {0:.4f}, ME: {1:.4f}, StdRes: {2:.4f}'.format(rmse, me, stdres))

df_Fehler = df_Fehler.append({'Methode': 'Holt ET', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)

rmse = sqrt(mean_squared_error(df_test, fcast6.values))
me = (df_test.iloc[:,0] - fcast6.values).sum() / len(df_test)
residuen = (df_test.iloc[:,0] - fcast6.values)
stdres = residuen.std()
print('Forecast 6   -   RMSE: {0:.4f}, ME: {1:.4f}, StdRes: {2:.4f}'.format(rmse, me, stdres))

df_Fehler = df_Fehler.append({'Methode': 'Holt add, damped trend', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)
```

    Forecast 4   -   RMSE: 9.0572, ME: 8.1390, StdRes: 4.0116
    Forecast 5   -   RMSE: 8.6140, ME: 7.7632, StdRes: 3.7684
    Forecast 6   -   RMSE: 3.6790, ME: 3.2271, StdRes: 1.7836

```python
df_Fehler
```

      Methode
      ME
      RMSE
      StdRes

      0
      Naives Fortschreiben
      3.294717
      3.738470
      1.783540

      1
      Moving Average
      4.600020
      4.824714
      1.469149

      2
      EinfExpGlätt 0.25
      2.368758
      2.954997
      1.783540

      3
      EinfExpGlätt 0.5
      2.684340
      3.213515
      1.783540

      4
      EinfExpGlätt 1
      3.267222
      3.714261
      1.783540

      5
      Holt LT
      8.138998
      9.057173
      4.011575

      6
      Holt ET
      7.763224
      8.613972
      3.768397

      7
      Holt add, damped trend
      3.227057
      3.679006
      1.783596

Exponentielle Glättung mit 'Level', 'Trend' und 'Saisoneffekten' (Holt-Winters)

Das Holt-Winters-Verfahren nennt man (spezielle) dreifache exponentielle Glättung:

\begin{align*}
\text{Level: }   \; \quad \ell_t  &=  \alpha \frac{y_t}{s_{t-m}} + (1-\alpha) (\ell_{t-1} + b_{t-1}) \\\\
\text{Growth: }   \; \quad b_t  &= \beta^* (\ell_t - \ell_{t-1}) + (1-\beta^*) b_{t-1} \\\\
\text{Seasonal: } \; \quad  s_t &= \gamma  \frac{y_t}{\ell_{t-1} + b_{t-1}} + (1-\gamma) s_{t-m} \\\\
\text{Forecast: }\;  \hat{y}_{t+h|t}  &= (\ell_t + b_t h ) s_{t-m+h_m^+} \\
\end{align*}

```python
#Schätzen von Holt-Winters
fit7 = ExponentialSmoothing(df_train.iloc[:,0], seasonal_periods=5, trend='add', seasonal='add').fit()
fcast7 = fit7.forecast(len(df_test)).rename("Holt-Winters Additive")

fit8 = ExponentialSmoothing(df_train.iloc[:,0], seasonal_periods=5, trend='add', seasonal='mul').fit()
fcast8 = fit8.forecast(len(df_test)).rename("Holt-Winters Multiplikativ")

```

```python
#Plotten der Ergebnisse

%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 6]

fcast7.plot(legend=True, color=colors[1], ls='--')
fit7.fittedvalues.plot(color=colors[2])

fcast8.plot(legend=True, color=colors[3], ls='--')
fit8.fittedvalues.plot(color=colors[4])

plt.title('Anpassung der dreifachen exponentiellen Glättung auf {}'.format(df_train.columns[0]), fontsize=14)
plt.xlabel('Zeit', fontsize=10)
plt.ylabel('Preis in USD', fontsize=10)

plt.show()
```

```python
rmse = sqrt(mean_squared_error(df_test, fcast7.values))
me = (df_test.iloc[:,0] - fcast7.values).sum() / len(df_test)
residuen = (df_test.iloc[:,0] - fcast7.values)
stdres = residuen.std()
print('Forecast 7   -   RMSE: {0:.4f}, ME: {1:.4f}, StdRes: {2:.4f}'.format(rmse, me, stdres))

df_Fehler = df_Fehler.append({'Methode': 'Holt Winters add', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)

rmse = sqrt(mean_squared_error(df_test, fcast8.values))
me = (df_test.iloc[:,0] - fcast8.values).sum() / len(df_test)
residuen = (df_test.iloc[:,0] - fcast8.values)
stdres = residuen.std()
print('Forecast 8   -   RMSE: {0:.4f}, ME: {1:.4f}, StdRes: {2:.4f}'.format(rmse, me, stdres))

df_Fehler = df_Fehler.append({'Methode': 'Holt Winters mult', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)

```

    Forecast 7   -   RMSE: 2.3152, ME: 1.6727, StdRes: 1.6160
    Forecast 8   -   RMSE: 2.3310, ME: 1.6947, StdRes: 1.6158

```python
df_Fehler
```

      Methode
      ME
      RMSE
      StdRes

      0
      Naives Fortschreiben
      3.294717
      3.738470
      1.783540

      1
      Moving Average
      4.600020
      4.824714
      1.469149

      2
      EinfExpGlätt 0.25
      2.368758
      2.954997
      1.783540

      3
      EinfExpGlätt 0.5
      2.684340
      3.213515
      1.783540

      4
      EinfExpGlätt 1
      3.267222
      3.714261
      1.783540

      5
      Holt LT
      8.138998
      9.057173
      4.011575

      6
      Holt ET
      7.763224
      8.613972
      3.768397

      7
      Holt add, damped trend
      3.227057
      3.679006
      1.783596

      8
      Holt Winters add
      1.672665
      2.315189
      1.616035

      9
      Holt Winters mult
      1.694724
      2.331032
      1.615823

## Faktor-Dekomposition
### Wie bekomme ich heraus, ob und welche Saisonalität in den Daten vorliegt?

```python
%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 8]
sm.tsa.seasonal_decompose(df_train).plot()
plt.show()
```

```python

```

## Autoregressive Prozesse AR, ARMA und ARIMA

Tipp: Eine Einführung in ARIMA-Modelle findet sich hier:

https://towardsdatascience.com/unboxing-arima-models-1dc09d2746f8

Autoregressive Prognose-Modelle

Bei ARIMA-Modellen wird eine Zeitreihe durch einen verzögerten Term der zu erklärenden Variable (AR) und einen Moving-Average-Term (MA) erklärt. Unter bestimmten Umständen muss das Modell insgesamt als integrierte Gleichung (I; ein- oder mehrfach differenziert) optimiert werden.

Dafür gilt es, die optimale Anzahl an Verzögerungen (p), das optimale Momentum (q) und, ggf., das optimale Differenz-Niveau (d) gefunden werden.

\begin{align*}
\text{Forecast:} \; \quad \hat{y}_{t} &=  \mu + \phi_{1}y{t-1}+\cdots+\phi_{p}y_{t-p}-\theta_{1}\epsilon_{t-1}-\cdots-\theta_{q}\epsilon_{t-q} \\\\
\text{Für d = 0:} \; \quad y_t &= Y_t \\\\
\text{Für d = 1:} \; \quad y_t &= Y_t - Y_{t-1} \\\\
\text{Für d = 2:} \; \quad y_t &= (Y_t - Y_{t-1})-(Y_{t-1} - Y_{t-2})
\end{align*}

```python
from statsmodels.tsa.arima_model import ARIMA
warnings.filterwarnings("ignore") # specify to ignore warning messages
```

```python
#Eigene Auto-Arima-Funktion
#Erzeugen einer Liste von allen Parametern, die getestet werden sollen.
p = range(0, 6)
d = range(0, 3)
q = range(0, 5)
pdq = list(itertools.product(p, d, q))
pdq
```

    [(0, 0, 0),
     (0, 0, 1),
     (0, 0, 2),
     (0, 0, 3),
     (0, 0, 4),
     (0, 1, 0),
     (0, 1, 1),
     (0, 1, 2),
     (0, 1, 3),
     (0, 1, 4),
     (0, 2, 0),
     (0, 2, 1),
     (0, 2, 2),
     (0, 2, 3),
     (0, 2, 4),
     (1, 0, 0),
     (1, 0, 1),
     (1, 0, 2),
     (1, 0, 3),
     (1, 0, 4),
     (1, 1, 0),
     (1, 1, 1),
     (1, 1, 2),
     (1, 1, 3),
     (1, 1, 4),
     (1, 2, 0),
     (1, 2, 1),
     (1, 2, 2),
     (1, 2, 3),
     (1, 2, 4),
     (2, 0, 0),
     (2, 0, 1),
     (2, 0, 2),
     (2, 0, 3),
     (2, 0, 4),
     (2, 1, 0),
     (2, 1, 1),
     (2, 1, 2),
     (2, 1, 3),
     (2, 1, 4),
     (2, 2, 0),
     (2, 2, 1),
     (2, 2, 2),
     (2, 2, 3),
     (2, 2, 4),
     (3, 0, 0),
     (3, 0, 1),
     (3, 0, 2),
     (3, 0, 3),
     (3, 0, 4),
     (3, 1, 0),
     (3, 1, 1),
     (3, 1, 2),
     (3, 1, 3),
     (3, 1, 4),
     (3, 2, 0),
     (3, 2, 1),
     (3, 2, 2),
     (3, 2, 3),
     (3, 2, 4),
     (4, 0, 0),
     (4, 0, 1),
     (4, 0, 2),
     (4, 0, 3),
     (4, 0, 4),
     (4, 1, 0),
     (4, 1, 1),
     (4, 1, 2),
     (4, 1, 3),
     (4, 1, 4),
     (4, 2, 0),
     (4, 2, 1),
     (4, 2, 2),
     (4, 2, 3),
     (4, 2, 4),
     (5, 0, 0),
     (5, 0, 1),
     (5, 0, 2),
     (5, 0, 3),
     (5, 0, 4),
     (5, 1, 0),
     (5, 1, 1),
     (5, 1, 2),
     (5, 1, 3),
     (5, 1, 4),
     (5, 2, 0),
     (5, 2, 1),
     (5, 2, 2),
     (5, 2, 3),
     (5, 2, 4)]

```python
ts = df_train.copy()
```

```python
AIC = []
ARIMA_model = []
i = 0

for param in pdq:
    try:
        mod = ARIMA(ts, order=param)
        results = mod.fit()
        print()
        print(i, ": ",'ARIMA{} - AIC:{}'.format(param, results.aic), end='\r')
        AIC.append(results.aic)
        ARIMA_model.append([param])
    except:
        continue
    i = i + 1
```

    0 :  ARIMA(0, 0, 0) - AIC:7013.454578932967
    1 :  ARIMA(0, 0, 1) - AIC:5771.249334363882
    2 :  ARIMA(0, 1, 0) - AIC:2151.2132474557666
    3 :  ARIMA(0, 1, 1) - AIC:2152.659153522236
    4 :  ARIMA(0, 1, 2) - AIC:2154.6282701671657
    5 :  ARIMA(0, 1, 3) - AIC:2154.2940952408876
    6 :  ARIMA(0, 1, 4) - AIC:2156.2916346441207
    7 :  ARIMA(0, 2, 0) - AIC:2855.776649047264
    8 :  ARIMA(0, 2, 1) - AIC:2155.557809569962
    9 :  ARIMA(0, 2, 2) - AIC:2156.873683836948
    10 :  ARIMA(0, 2, 3) - AIC:2158.8664849005554
    11 :  ARIMA(0, 2, 4) - AIC:2158.185413456307
    12 :  ARIMA(1, 0, 0) - AIC:2161.715398545889
    13 :  ARIMA(1, 1, 0) - AIC:2152.652069831722
    14 :  ARIMA(1, 1, 1) - AIC:2154.05849222667
    15 :  ARIMA(1, 1, 2) - AIC:2156.00943161475
    16 :  ARIMA(1, 1, 3) - AIC:2156.283781964605
    17 :  ARIMA(1, 1, 4) - AIC:2158.1436481289193
    18 :  ARIMA(1, 2, 0) - AIC:2554.3582284510103
    19 :  ARIMA(1, 2, 1) - AIC:2156.868439790889
    20 :  ARIMA(1, 2, 2) - AIC:2158.318046812955
    21 :  ARIMA(1, 2, 4) - AIC:2159.039955302013
    22 :  ARIMA(2, 0, 0) - AIC:2163.274614068158
    23 :  ARIMA(2, 1, 0) - AIC:2154.6011925086386
    24 :  ARIMA(2, 1, 1) - AIC:2156.015511628101
    25 :  ARIMA(2, 1, 2) - AIC:2156.9449701306753
    26 :  ARIMA(2, 1, 3) - AIC:2157.324687034196
    27 :  ARIMA(2, 1, 4) - AIC:2159.308318725509
    28 :  ARIMA(2, 2, 0) - AIC:2460.2116081875092
    29 :  ARIMA(2, 2, 1) - AIC:2158.8482822691212
    30 :  ARIMA(2, 2, 4) - AIC:2156.52216639959
    31 :  ARIMA(3, 0, 0) - AIC:2165.1799595355556
    32 :  ARIMA(3, 1, 0) - AIC:2154.555067840616
    33 :  ARIMA(3, 1, 1) - AIC:2156.5490428831517
    34 :  ARIMA(3, 1, 2) - AIC:2157.348646689716
    35 :  ARIMA(3, 1, 3) - AIC:2153.1014330217354
    36 :  ARIMA(3, 1, 4) - AIC:2154.1306360821527
    37 :  ARIMA(3, 2, 0) - AIC:2387.4182292152723
    38 :  ARIMA(3, 2, 1) - AIC:2158.5533428297467
    39 :  ARIMA(3, 2, 2) - AIC:2158.862946838637
    40 :  ARIMA(3, 2, 3) - AIC:2159.023606585547
    41 :  ARIMA(3, 2, 4) - AIC:2163.844070173267
    42 :  ARIMA(4, 0, 0) - AIC:2165.367570202199
    43 :  ARIMA(4, 1, 0) - AIC:2156.5530637506677
    44 :  ARIMA(4, 1, 1) - AIC:2157.5206766635756
    45 :  ARIMA(4, 1, 2) - AIC:2159.348635885285
    46 :  ARIMA(4, 1, 3) - AIC:2154.070742005638
    47 :  ARIMA(4, 1, 4) - AIC:2147.873205912918
    48 :  ARIMA(4, 2, 0) - AIC:2351.2852861599386
    49 :  ARIMA(4, 2, 1) - AIC:2160.5327629534695
    50 :  ARIMA(4, 2, 2) - AIC:2160.8313532391257
    51 :  ARIMA(4, 2, 3) - AIC:2162.708666584628
    52 :  ARIMA(4, 2, 4) - AIC:2157.925120733335
    53 :  ARIMA(5, 0, 0) - AIC:2167.3500849145366
    54 :  ARIMA(5, 1, 0) - AIC:2158.134017272393
    55 :  ARIMA(5, 1, 1) - AIC:2158.0045903186533
    56 :  ARIMA(5, 1, 2) - AIC:2157.36639751832
    57 :  ARIMA(5, 1, 3) - AIC:2155.7195524462245
    58 :  ARIMA(5, 1, 4) - AIC:2152.439435093944
    59 :  ARIMA(5, 2, 0) - AIC:2337.485641686427
    60 :  ARIMA(5, 2, 1) - AIC:2161.9573835202627

```python
print('Das AIC nimmt mit {} für das Modell ARIMA{} den kleinsten Wert an.'.format(min(AIC), ARIMA_model[AIC.index(min(AIC))][0]))
```

    Das AIC nimmt mit 2147.873205912918 für das Modell ARIMA(4, 1, 4) den kleinsten Wert an.

```python
mod = ARIMA(ts,order=ARIMA_model[AIC.index(min(AIC))][0])
fit9 = mod.fit()
```

```python
#Achtung, wenn d != 0, dann werden Differenzen vorhergesagt.
fcast9 = fit9.predict(start=len(ts), end=len(ts)+len(df_test)-1, dynamic=False)
fcast9.head()
```

    2017-10-18    0.112075
    2017-10-19    0.178367
    2017-10-20    0.254370
    2017-10-23    0.075667
    2017-10-24    0.274277
    Freq: B, dtype: float64

```python
y_hat_ = fcast9.copy()
y_hat_[0] = y_hat_[0] + ts.iloc[-1][0]
y_hat_ = np.cumsum(y_hat_.values)
fcast9[:] = y_hat_
```

```python
#Plotten der Ergebnisse

%matplotlib inline
plt.rcParams['figure.figsize'] = [15, 6]

plt.plot(df_train.index, df_train.values, label='Trainingsdaten')
plt.plot(fcast9.index, fcast9.values, label='ARIMA', color=colors[2])

plt.legend(loc=2)
plt.title('Anpassung von autoregressiven Modellen auf {}'.format(df_train.columns[0]), fontsize=14)
plt.xlabel('Zeit', fontsize=10)
plt.ylabel('Preis in USD', fontsize=10)

plt.show()
```

```python
rmse = sqrt(mean_squared_error(df_test, fcast9.values))
me = (df_test.iloc[:,0] - fcast9.values).sum() / len(df_test)
residuen = (df_test.iloc[:,0] - fcast9.values)
stdres = residuen.std()
print('Forecast 7   -   RMSE: {0:.4f}, ME: {1:.4f}, StdRes: {2:.4f}'.format(rmse, me, stdres))

df_Fehler = df_Fehler.append({'Methode': 'ARIMA(4,1,4)', 'ME': me, 'RMSE': rmse, 'StdRes': stdres},
                             ignore_index=True)
```

    Forecast 7   -   RMSE: 2.6475, ME: 2.0076, StdRes: 1.7424

```python
df_Fehler
```

      Methode
      ME
      RMSE
      StdRes

      0
      Naives Fortschreiben
      3.294717
      3.738470
      1.783540

      1
      Moving Average
      4.600020
      4.824714
      1.469149

      2
      EinfExpGlätt 0.25
      2.368758
      2.954997
      1.783540

      3
      EinfExpGlätt 0.5
      2.684340
      3.213515
      1.783540

      4
      EinfExpGlätt 1
      3.267222
      3.714261
      1.783540

      5
      Holt LT
      8.138998
      9.057173
      4.011575

      6
      Holt ET
      7.763224
      8.613972
      3.768397

      7
      Holt add, damped trend
      3.227057
      3.679006
      1.783596

      8
      Holt Winters add
      1.672665
      2.315189
      1.616035

      9
      Holt Winters mult
      1.694724
      2.331032
      1.615823

      10
      ARIMA(0,1,0)
      2.007619
      2.647510
      1.742428

      11
      ARIMA(4,1,4)
      2.007619
      2.647510
      1.742428

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```


##### Copyright 2020 The TensorFlow Authors.

```python
#@title Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
```

# MNIST classification

    View on TensorFlow.org

    Run in Google Colab

    View source on GitHub

    Download notebook

This tutorial builds a quantum neural network (QNN) to classify a simplified version of MNIST, similar to the approach used in Farhi et al. The performance of the quantum neural network on this classical data problem is compared with a classical neural network.

## Setup

```python
try:
    # %tensorflow_version only works in Colab.
    %tensorflow_version 2.x
except Exception:
    pass
```

Install TensorFlow Quantum:

```python
!pip install tensorflow-quantum
```

Now import TensorFlow and the module dependencies:

```python
import tensorflow as tf
import tensorflow_quantum as tfq

import cirq
import sympy
import numpy as np
import seaborn as sns
import collections

# visualization tools
%matplotlib inline
import matplotlib.pyplot as plt
from cirq.contrib.svg import SVGCircuit
```

## 1. Load the data

In this tutorial you will build a binary classifier to distinguish between the digits 3 and 6, following Farhi et al. This section covers the data handling that:

- Loads the raw data from Keras.
- Filters the dataset to only 3s and 6s.
- Downscales the images so they fit can fit in a quantum computer.
- Removes any contradictory examples.
- Converts the binary images to Cirq circuits.
- Converts the Circ circuits to TensorFlow Quantum circuits.

### 1.1 Load the raw data

Load the MNIST dataset distributed with Keras.

```python
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()

# Rescale the images from [0,255] to the [0.0,1.0] range.
x_train, x_test = x_train[..., np.newaxis]/255.0, x_test[..., np.newaxis]/255.0

print("Number of original training examples:", len(x_train))
print("Number of original test examples:", len(x_train))
```

Filter the dataset to keep just the 3s and 6s,  remove the other classes. At the same time convert the label, `y`, to boolean: `True` for `3` and `False` for 6.

```python
def filter_36(x, y):
    keep = (y == 3) | (y == 6)
    x, y = x[keep], y[keep]
    y = y == 3
    return x,y
```

```python
x_train, y_train = filter_36(x_train, y_train)
x_test, y_test = filter_36(x_test, y_test)

print("Number of filtered training examples:", len(x_train))
print("Number of filtered test examples:", len(x_test))
```

Show the first example:

```python
print(y_train[0])

plt.imshow(x_train[0, :, :, 0])
plt.colorbar()
```

### 1.2 Downscale the images

An image size of 28x28 is much too large for current quantum computers. Resize the image down to 4x4:

```python
x_train_small = tf.image.resize(x_train, (4,4)).numpy()
x_test_small = tf.image.resize(x_test, (4,4)).numpy()
```

Again, display the first training example—after resize:

```python
print(y_train[0])

plt.imshow(x_train_small[0,:,:,0], vmin=0, vmax=1)
plt.colorbar()
```

### 1.3 Remove contradictory examples

From section *3.3 Learning to Distinguish Digits* of Farhi et al., filter the dataset to remove images that are labeled as belonging to both classes.

This is not a standard machine-learning procedure, but is included in the interest of following the paper.

```python
def remove_contradicting(xs, ys):
    mapping = collections.defaultdict(set)
    # Determine the set of labels for each unique image:
    for x,y in zip(xs,ys):
       mapping[tuple(x.flatten())].add(y)

    new_x = []
    new_y = []
    for x,y in zip(xs, ys):
      labels = mapping[tuple(x.flatten())]
      if len(labels) == 1:
          new_x.append(x)
          new_y.append(list(labels)[0])
      else:
          # Throw out images that match more than one label.
          pass

    num_3 = sum(1 for value in mapping.values() if True in value)
    num_6 = sum(1 for value in mapping.values() if False in value)
    num_both = sum(1 for value in mapping.values() if len(value) == 2)

    print("Number of unique images:", len(mapping.values()))
    print("Number of 3s: ", num_3)
    print("Number of 6s: ", num_6)
    print("Number of contradictory images: ", num_both)
    print()
    print("Initial number of examples: ", len(xs))
    print("Remaining non-contradictory examples: ", len(new_x))

    return np.array(new_x), np.array(new_y)
```

The resulting counts do not closely match the reported values, but the exact procedure is not specified.

It is also worth noting here that applying filtering contradictory examples at this point does not totally prevent the model from receiving contradictory training examples: the next step binarizes the data which will cause more collisions.

```python
x_train_nocon, y_train_nocon = remove_contradicting(x_train_small, y_train)
```

### 1.3 Encode the data as quantum circuits

To process images using a quantum computer, Farhi et al. proposed representing each pixel with a qubit, with the state depending on the value of the pixel. The first step is to convert to a binary encoding.

```python
THRESHOLD = 0.5

x_train_bin = np.array(x_train_nocon > THRESHOLD, dtype=np.float32)
x_test_bin = np.array(x_test_small > THRESHOLD, dtype=np.float32)
```

The qubits at pixel indices with values that exceed a threshold, are rotated through an $X$ gate.

```python
def convert_to_circuit(image):
    """Encode truncated classical image into quantum datapoint."""
    values = np.ndarray.flatten(image)
    qubits = cirq.GridQubit.rect(4, 4)
    circuit = cirq.Circuit()
    for i, value in enumerate(values):
        if value:
            circuit.append(cirq.X(qubits[i]))
    return circuit

x_train_circ = [convert_to_circuit(x) for x in x_train_bin]
x_test_circ = [convert_to_circuit(x) for x in x_test_bin]
```

Here is the circuit created for the first example (circuit diagrams do not show qubits with zero gates):

```python
SVGCircuit(x_train_circ[0])
```

Compare this circuit to the indices where the image value exceeds the threshold:

```python
bin_img = x_train_bin[0,:,:,0]
indices = np.array(np.where(bin_img)).T
indices
```

Convert these `Cirq` circuits to tensors for `tfq`:

```python
x_train_tfcirc = tfq.convert_to_tensor(x_train_circ)
x_test_tfcirc = tfq.convert_to_tensor(x_test_circ)
```

## 2. Quantum neural network

There is little guidance for a quantum circuit structure that classifies images. Since the classification is based on the expectation of the readout qubit, Farhi et al. propose using two qubit gates, with the readout qubit always acted upon. This is similar in some ways to running small a Unitary RNN across the pixels.

### 2.1 Build the model circuit

This following example shows this layered approach. Each layer uses *n* instances of the same gate, with each of the data qubits acting on the readout qubit.

Start with a simple class that will add a layer of these gates to a circuit:

```python
class CircuitLayerBuilder():
    def __init__(self, data_qubits, readout):
        self.data_qubits = data_qubits
        self.readout = readout

    def add_layer(self, circuit, gate, prefix):
        for i, qubit in enumerate(self.data_qubits):
            symbol = sympy.Symbol(prefix + '-' + str(i))
            circuit.append(gate(qubit, self.readout)**symbol)
```

Build an example circuit layer to see how it looks:

```python
demo_builder = CircuitLayerBuilder(data_qubits = cirq.GridQubit.rect(4,1),
                                   readout=cirq.GridQubit(-1,-1))

circuit = cirq.Circuit()
demo_builder.add_layer(circuit, gate = cirq.XX, prefix='xx')
SVGCircuit(circuit)
```

Now build a two-layered model, matching the data-circuit size, and include the preparation and readout operations.

```python
def create_quantum_model():
    """Create a QNN model circuit and readout operation to go along with it."""
    data_qubits = cirq.GridQubit.rect(4, 4)  # a 4x4 grid.
    readout = cirq.GridQubit(-1, -1)         # a single qubit at [-1,-1]
    circuit = cirq.Circuit()

    # Prepare the readout qubit.
    circuit.append(cirq.X(readout))
    circuit.append(cirq.H(readout))

    builder = CircuitLayerBuilder(
        data_qubits = data_qubits,
        readout=readout)

    # Then add layers (experiment by adding more).
    builder.add_layer(circuit, cirq.XX, "xx1")
    builder.add_layer(circuit, cirq.ZZ, "zz1")

    # Finally, prepare the readout qubit.
    circuit.append(cirq.H(readout))

    return circuit, cirq.Z(readout)
```

```python
model_circuit, model_readout = create_quantum_model()
```

### 2.2 Wrap the model-circuit in a tfq-keras model

Build the Keras model with the quantum components. This model is fed the "quantum data", from `x_train_circ`, that encodes the classical data. It uses a *Parametrized Quantum Circuit* layer, `tfq.layers.PQC`, to train the model circuit, on the quantum data.

To classify these images, Farhi et al. proposed taking the expectation of a readout qubit in a parameterized circuit. The expectation returns a value between 1 and -1.

```python
# Build the Keras model.
model = tf.keras.Sequential([
    # The input is the data-circuit, encoded as a tf.string
    tf.keras.layers.Input(shape=(), dtype=tf.string),
    # The PQC layer returns the expected value of the readout gate, range [-1,1].
    tfq.layers.PQC(model_circuit, model_readout),
])
```

Next, describe the training procedure to the model, using the `compile` method.

Since the the expected readout is in the range `[-1,1]`, optimizing the hinge loss is a somewhat natural fit.

Note: Another valid approach would be to shift the output range to `[0,1]`, and treat it as the probability the model assigns to class `3`. This could be used with a standard a `tf.losses.BinaryCrossentropy` loss.

To use the hinge loss here you need to make two small adjustments. First convert the labels, `y_train`, from boolean to `[-1,1]`, as expected by the hinge loss.

```python
y_train_hinge = 2.0*y_train-1.0
y_test_hinge = 2.0*y_test-1.0
```

Second, use a custiom `hinge_accuracy` metric that correctly handles `[-1, 1]` as the `y_true` labels argument.
`tf.losses.BinaryAccuracy(threshold=0.0)` expects `y_true` to be a boolean, and so can't be used with hinge loss).

```python
def hinge_accuracy(y_true, y_pred):
    y_true = tf.squeeze(y_true) > 0.0
    y_pred = tf.squeeze(y_pred) > 0.0
    result = tf.cast(y_true == y_pred, tf.float32)

    return tf.reduce_mean(result)
```

```python
model.compile(
    loss=tf.keras.losses.Hinge(),
    optimizer=tf.keras.optimizers.Adam(),
    metrics=[hinge_accuracy])
```

```python
print(model.summary())
```

### Train the quantum model

Now train the model—this takes about 45 min. If you don't want to wait that long, use a small subset of the data (set `NUM_EXAMPLES=500`, below). This doesn't really affect the model's progress during training (it only has 32 parameters, and doesn't need much data to constrain these). Using fewer examples just ends training earlier (5min), but runs long enough to show that it is making progress in the validation logs.

```python
EPOCHS = 3
BATCH_SIZE = 32

NUM_EXAMPLES = len(x_train_tfcirc)
```

```python
x_train_tfcirc_sub = x_train_tfcirc[:NUM_EXAMPLES]
y_train_hinge_sub = y_train_hinge[:NUM_EXAMPLES]
```

Training this model to convergence should achieve >85% accuracy on the test set.

```python
qnn_history = model.fit(
      x_train_tfcirc_sub, y_train_hinge_sub,
      batch_size=32,
      epochs=EPOCHS,
      verbose=1,
      validation_data=(x_test_tfcirc, y_test_hinge))

qnn_results = model.evaluate(x_test_tfcirc, y_test)
```

Note: The training accuracy reports the average over the epoch. The validation accuracy is evaluated at the end of each epoch.

## 3. Classical neural network

While the quantum neural network works for this simplified MNIST problem, a basic classical neural network can easily outperform a QNN on this task. After a single epoch, a classical neural network can achieve >98% accuracy on the holdout set.

In the following example, a classical neural network is used for for the 3-6 classification problem using the entire 28x28 image instead of subsampling the image. This easily converges to nearly 100% accuracy of the test set.

```python
def create_classical_model():
    # A simple model based off LeNet from https://keras.io/examples/mnist_cnn/
    model = tf.keras.Sequential()
    model.add(tf.keras.layers.Conv2D(32, [3, 3], activation='relu', input_shape=(28,28,1)))
    model.add(tf.keras.layers.Conv2D(64, [3, 3], activation='relu'))
    model.add(tf.keras.layers.MaxPooling2D(pool_size=(2, 2)))
    model.add(tf.keras.layers.Dropout(0.25))
    model.add(tf.keras.layers.Flatten())
    model.add(tf.keras.layers.Dense(128, activation='relu'))
    model.add(tf.keras.layers.Dropout(0.5))
    model.add(tf.keras.layers.Dense(1))
    return model

model = create_classical_model()
model.compile(loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
              optimizer=tf.keras.optimizers.Adam(),
              metrics=['accuracy'])

model.summary()
```

```python
model.fit(x_train,
          y_train,
          batch_size=128,
          epochs=1,
          verbose=1,
          validation_data=(x_test, y_test))

cnn_results = model.evaluate(x_test, y_test)
```

The above model has nearly 1.2M parameters. For a more fair comparison, try a 37-parameter model, on the subsampled images:

```python
def create_fair_classical_model():
    # A simple model based off LeNet from https://keras.io/examples/mnist_cnn/
    model = tf.keras.Sequential()
    model.add(tf.keras.layers.Flatten(input_shape=(4,4,1)))
    model.add(tf.keras.layers.Dense(2, activation='relu'))
    model.add(tf.keras.layers.Dense(1))
    return model

model = create_fair_classical_model()
model.compile(loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
              optimizer=tf.keras.optimizers.Adam(),
              metrics=['accuracy'])

model.summary()
```

```python
model.fit(x_train_bin,
          y_train_nocon,
          batch_size=128,
          epochs=20,
          verbose=2,
          validation_data=(x_test_bin, y_test))

fair_nn_results = model.evaluate(x_test_bin, y_test)
```

## 4. Comparison

Higher resolution input and a more powerful model make this problem easy for the CNN. While a classical model of similar power (~32 parameters) trains to a similar accuracy in a fraction of the time. One way or the other, the classical neural network easily outperforms the quantum neural network. For classical data, it is difficult to beat a classical neural network.

```python
qnn_accuracy = qnn_results[1]
cnn_accuracy = cnn_results[1]
fair_nn_accuracy = fair_nn_results[1]

sns.barplot(["Quantum", "Classical, full", "Classical, fair"],
            [qnn_accuracy, cnn_accuracy, fair_nn_accuracy])
```


# IllinoisGRMHD: Basic equations and modules

## Authors: Leo Werneck & Zach Etienne

**This module is currently under development**

## This module introduces the basic equations solved by IllinoisGRMHD and provides an introduction to the modules inside the code.

## Introduction:

[`IllinoisGRMHD`](http://arxiv.org/abs/1501.07276) solves the equations of General Relativistic MagnetoHydroDynamics (GRMHD) using a high-resolution shock capturing scheme. It is a rewrite of the Illinois Numerical Relativity (ILNR) group's GRMHD code, and generates results that agree to roundoff error with that original code. Its feature set coincides with the features of the ILNR group's recent code (ca. 2009-2014), which was used in their modeling of the following systems:

1. Magnetized circumbinary disk accretion onto binary black holes
2. Magnetized black hole-neutron star mergers
3. Magnetized Bondi flow, Bondi-Hoyle-Littleton accretion
4. White dwarf-neutron star mergers

`IllinoisGRMHD` is particularly good at modeling GRMHD flows into black holes without the need for excision. Its [HARM-based conservative-to-primitive solver](https://arxiv.org/abs/astro-ph/0512420) has also been modified to check the physicality of conservative variables prior to primitive inversion, and move them into the physical range if they become unphysical.

Currently IllinoisGRMHD consists of

1. the Piecewise Parabolic Method (PPM) for reconstruction,
2. the Harten, Lax, van Leer (HLL/HLLE) approximate Riemann solver, and
3. a modified HARM Conservative-to-Primitive solver.

`IllinoisGRMHD` evolves the vector potential $A_{\mu}$ (on staggered grids) instead of the magnetic fields ($B^i$) directly, to guarantee that the magnetic fields will remain divergenceless even at AMR boundaries. On uniform resolution grids, this vector potential formulation produces results equivalent to those generated using the standard, staggered flux-CT scheme. This scheme is based on that of [Del Zanna *et al.* (2003)](https://arxiv.org/abs/astro-ph/0210618).

### Required and recommended citations:
* **(Required)** Etienne, Z. B., Paschalidis, V., Haas R., Mösta P., and Shapiro, S. L. IllinoisGRMHD: an open-source, user-friendly GRMHD code for dynamical spacetimes. Class. Quantum Grav. 32 (2015) 175009. ([arxiv:1501.07276](http://arxiv.org/abs/1501.07276)).
* **(Required)** Noble, S. C., Gammie, C. F., McKinney, J. C., Del Zanna, L. Primitive Variable Solvers for Conservative General Relativistic Magnetohydrodynamics. Astrophysical Journal, 641, 626 (2006) ([astro-ph/0512420](https://arxiv.org/abs/astro-ph/0512420)).
* **(Recommended)** Del Zanna, L., Bucciantini N., Londrillo, P. An efficient shock-capturing central-type scheme for multidimensional relativistic flows - II. Magnetohydrodynamics. A&A 400 (2) 397-413 (2003). DOI: 10.1051/0004-6361:20021641 ([astro-ph/0210618](https://arxiv.org/abs/astro-ph/0210618)).

# Table of Contents
$$\label{toc}$$

This module is organized as follows

1. [Step 1](#basic_equations): **The equations of GRMHD**
1. [Step 2](#suitable_equations): **Recasting the equations of GRMHD into a more useful form**
    1. [Step 2.1](#einstein_equations): Einstein's equations
    1. [Step 2.2](#conserv_of_baryon_number): Conservation of baryon number
    1. [Step 2.3](#conserv_of_em_tensor): Conservation of energy-momentum
    1. [Step 2.4](#em_evo_equations): The evolution equations for the electromagnetic field
    1. [Step 2.5](#equation_of_state): The equation of state (EOS)
1. [Step 3](#latex_pdf_output): **Output this notebook to $\LaTeX$-formatted PDF file**

# Step 1: The equations of GRMHD \[Back to [top](#toc)\]
$$\label{basic_equations}$$

The basic equations solved by `IllinoisGRMHD` are

1. Einstein's equations: $R^{\mu\nu} + \frac{1}{2}g^{\mu\nu}R = 8\pi T^{\mu\nu} = 8\pi \left(T^{\mu\nu}_{\rm matter} + T^{\mu\nu}_{\rm EM}\right)$ ;
1. Conservation of baryon number: $\nabla_{\mu}\left(\rho_{0} u^{\mu}\right) = 0$ ;
1. Conservation of energy-momentum: $\nabla_{\mu}T^{\mu\nu} = \nabla_{\mu}\left(T^{\mu\nu}_{\rm matter} + T^{\mu\nu}_{\rm EM}\right) = 0$ ;
1. Dual of homogeneous Maxwell's equations: $\nabla_{\nu} F^{*\mu\nu}=\frac{1}{\sqrt{-g}}\partial_{\nu}\left(\sqrt{-g}F^{*\mu\nu}\right)=0$ ,

where $g_{\mu\nu}$ is the ADM 4-metric, $R_{\mu\nu}$ and $R$ are the Ricci tensor and scalar, respectively, constructed from $g_{\mu\nu}$, $\nabla_{\mu}$ is the covariant derivative compatible with $g_{\mu\nu}$, $g\equiv\det\left(g_{\mu\nu}\right)$, $F^{\mu\nu}$ is the Faraday tensor and $F^{*\mu\nu}=\frac{1}{2}\epsilon^{\mu\nu\rho\sigma}F_{\mu\nu}$ its dual ($\epsilon^{\mu\nu\rho\sigma}$ is the Levi-Civita symbol).

The final equation that must be solved is the equation of state (EOS) for the matter. Currently `IllinoisGRMHD` implements a hybrid EOS of the form (**add citation**)

5. Equation of State (EOS): $P(\rho_{0},\epsilon) = P_{\rm cold}(\rho_{0}) + \left(\Gamma_{\rm th}-1\right) \rho_{0} \left[\epsilon-\epsilon_{\rm cold}(\rho_{0})\right]$ ,

where P is the pressure, $\epsilon$ the specific internal energy (the subscript cold indicate the cold components of these quantities), and $\Gamma_{\rm th}$ is a constant parameter which determines the conversion efficiency of kinetic to thermal energy at shocks.

# Step 2: Recasting the equations of GRMHD into a more useful form \[Back to [top](#toc)\]
$$\label{suitable_equations}$$

In this step we will write down the equations used by `IllinoisGRMHD` in the form they are implemented. To give an example, the GRMHD equations are written in the *conservative form*

$$
\partial_{t}\boldsymbol{C} + \boldsymbol{\nabla}\cdot\boldsymbol{F} = \boldsymbol{S}\ ,
$$

where $\boldsymbol{C} = \left\{\rho_{\star},\tilde\tau,\tilde{S}_{i},\tilde{B}^{i}\right\}$ is the vector of conservative variables, $\boldsymbol{F}$ is the flux vector, and $\boldsymbol{S}$ the vector of source terms (explict expressions for the components of $\boldsymbol{C}$, $\boldsymbol{F}$, and $\boldsymbol{S}$ can be found below).

## Step 2.1: Einstein's equations \[Back to [top](#toc)\]
$$\label{einstein_equations}$$

`IllinoisGRMHD` solves Einstein's field equations (with $G_{\rm N}=1=c$) in the presence of matter sources,

$$
G^{\mu\nu} = 8\pi T^{\mu\nu}\ ,
$$

where $G^{\mu\nu} \equiv R^{\mu\nu} + \frac{1}{2}g^{\mu\nu}R$ is the Einstein tensor and the total energy-momentum tensor, $T^{\mu\nu}$, is the sum of the matter and electromagnetic energy-momentum tensors:

$$
T^{\mu\nu} = T^{\mu\nu}_{\rm matter} + T^{\mu\nu}_{\rm EM}\ .
$$

Einstein's field equations are solved using the BSSN formalism (see [this tutorial module](../../Tutorial-BSSN_formulation.ipynb) for an overview).

## Step 2.2: Conservation of baryon number \[Back to [top](#toc)\]
$$\label{conserv_of_baryon_number}$$

The conservation of baryon number equation is written as (cf. eqs. (6), (7), and (18) in [Etienne *et al.*](https://arxiv.org/pdf/1501.07276.pdf))

$$
\boxed{\partial_{t}\rho_{\star} + \partial_{j}\left(\rho_{\star}v^{j}\right) = 0}\ ,
$$

where $\rho_{\star}\equiv \alpha \sqrt{\gamma}\rho_{0}u^{0}$ and $v^{i}\equiv u^{i}/u^{0}$.

## Step 2.3: Conservation of energy-momentum \[Back to [top](#toc)\]
$$\label{conserv_of_em_tensor}$$

In the ideal MHD limit, we can write down the total energy momentum tensor as (cf. eq. (8) in [Etienne *et al.*](https://arxiv.org/pdf/1501.07276.pdf) and the discussion before it)

$$
T^{\mu \nu} = (\rho_0 h +b^2) u^{\mu} u^{\nu} + \left( P + \frac{b^2}{2}\ .
\right) g^{\mu \nu} - b^{\mu} b^{\nu}
$$

The spatial components of the energy-momentum conservation equation give (cf. eq. (18) of https://arxiv.org/pdf/astro-ph/0503420.pdf and eqs. (35) and (36) of [Duez et al.](https://arxiv.org/pdf/astro-ph/0503420.pdf))

$$
\boxed{\partial_{t}\tilde{S}_{i} + \partial_{j}\left(\alpha\sqrt{\gamma} T^{j}_{\ i}\right) = \frac{1}{2}\sqrt{\gamma} T^{\alpha\beta}\partial_{i}g_{\alpha\beta}}\ ,
$$

where

$$
\tilde{S}_{i} = \sqrt{\gamma}S_{i} = \alpha \sqrt{\gamma} T^{0}_{\ i} = \left(\rho_{\star}h + \alpha u^{0}\sqrt{\gamma}b^{2}\right)u_{i} - \alpha \sqrt{\gamma} b^{0} b^{i}\ .
$$

The time component of the energy-momentum conservation equation gives (cf. eq. (18) of [Etienne *et al.*](https://arxiv.org/pdf/1501.07276.pdf))

$$
\boxed{\partial_{t}\tilde{\tau} + \partial_{i}\left(\alpha^{2}\sqrt{\gamma}T^{0i} - \rho_{\star}v^{i}\right) = s}\ ,
$$

where

\begin{align}
\tilde{\tau} &= \sqrt{\gamma}n_{\mu}n_{\nu} - \rho_{\star} = \alpha^{2}\sqrt{\gamma}T^{00} - \rho_{\star}\ ,\\
s &= -\alpha \sqrt{\gamma} T^{\mu\nu} \nabla_{\nu} n_{\mu} = \alpha\sqrt{\gamma}\left[\left(T^{00}\beta^{i}\beta^{j} + 2T^{0i}\beta^{j} + T^{ij}\right)K_{ij} - \left(T^{00}\beta^{i} + T^{0i}\right)\partial_{i}\alpha\right]\ ,
\end{align}

with $n_{\mu} = \left(\alpha,0,0,0\right)$ being the normal vector and $K_{ij}$ the extrinsic curvature.

## Step 2.4: The evolution equations for the electromagnetic field \[Back to [top](#toc)\]
$$\label{em_evo_equations}$$

From the spatial components of the dual of Maxwell's equations,

$$
\nabla_{\nu} F^{*\mu\nu}=\frac{1}{\sqrt{-g}}\partial_{\nu}\left(\sqrt{-g}F^{*\mu\nu}\right)=0\ ,
$$

we get the magnetic induction equation, which in conservative form may be written as (cf. eq. (12) [Etienne *et al.*](https://arxiv.org/pdf/1501.07276.pdf))

$$
\partial_{t}\tilde{B}^{i} + \partial_{j}\left(v^{j}\tilde{B}^{i} - v^{i}\tilde{B}^{j}\right) = 0\ ,
$$

where $\tilde{B}^{i} = \sqrt{\gamma}B^{i}$. We must also guarantee that no magnetic mopoles form, via the constraint

$$
\partial_{i}\tilde{B}^{i} = 0\ ,
$$

which is the time component of the dual of Maxwell's equations. Satisfying this constraint equation while evolving the magnetic field forward in time via the evolution equation above turns out to be a nontrivial endeavor, *particularly* on AMR grids. Instead, we choose to evolve the magnetic 4-vector potential $\mathcal{A}_{\mu}$ instead of the magnetic fields directly, so that

\begin{align}
\mathcal{A}_{\mu} &= \Phi n_{\mu} + A_{\mu}\ ,\\
\tilde{B}^{i} &= \tilde{\epsilon}^{ijk}\partial_{j}A_{k}\ ,
\end{align}

where $A_{\mu}$ is purely spatial ($A_{\mu}n^{\mu} = 0$) and $\Phi$ is the EM scalar potential.

Special finite difference operators for the vector potential are defined in IllinoisGRMHD so that the divergence of a curl is zero to roundoff error, which implies that the divergence of $\tilde{B}^{i}$ (as defined above) is zero and the condition $\partial_{i}\tilde{B}^{i}=0$ is satisfied automatically, *even on AMR grids*.

In terms of $A_{i}$, the induction equation becomes

$$
\boxed{\partial_t A_i = \tilde\epsilon_{ijk} v^j \tilde{B}^k - \partial_i (\alpha \Phi - \beta^j A_j)}\ .
$$

The final equation comes from choosing the covariant version of the "[generalized Lorenz gauge condition](https://arxiv.org/pdf/1207.3354.pdf)" that was introduced by the Illinois Relativity group,

$$
\nabla_{\mu}\mathcal{A}^{\mu} = \xi n_{\mu} \mathcal{A}^{\mu}\ ,
$$

where $\xi$ is a parameter with dimensions 1/Length, chosen so that the CFL condition remains satisfied. This gauge choice yields the additional evolution equation

$$
\boxed{\partial_t [\sqrt{\gamma} \Phi] + \partial_j (\alpha \sqrt{\gamma} A^j - \beta^j [\sqrt{\gamma}\Phi]) = -\xi\alpha \sqrt{\gamma}\Phi}\ .
$$

Note that in `IllinoisGRMHD` the evolved variable is $\sqrt{\gamma}\Phi$, not $\Phi$.

## Step 2.5: The equation of state (EOS) \[Back to [top](#toc)\]
$$\label{equation_of_state}$$

`IllinoisGRMHD` currently implements a hybrid EOS of the form

$$
\boxed{P(\rho_{0},\epsilon) = P_{\rm cold}(\rho_{0}) + \left(\Gamma_{\rm th}-1\right) \rho_{0} \left[\epsilon-\epsilon_{\rm cold}(\rho_{0})\right]}\ .
$$

The function $\epsilon_{\rm cold}$ is related to $P_{\rm cold}$ by the fist law of thermodynamics,

$$
\epsilon_{\rm cold}(\rho_{0}) = \int \frac{P_{\rm cold}(\rho_{0})}{\rho_{0}^{2}}d\rho_{0}\ .
$$

Currently, all functions within `IllinoisGRMHD` support piecewise-defined $P_{\rm cold}(\rho_{0})$ (the so-called "piecewise polytrope" EOS) with up to nine different polytropic indices, except for the conservatives-to-primitives solver, which currently supports only one.

In typical runs of the code, particularly on the ones presented in the [release paper](https://arxiv.org/pdf/1501.07276.pdf), the $\Gamma$-law EOS, $P = (\Gamma - 1)\rho_{0}\epsilon$, is adopted. This corresponds to setting $P_{\rm cold} = (\Gamma - 1)\rho_{0}\epsilon_{\rm cold}$ in the boxed equation above, which is equivalent to $P_{\rm cold} = \kappa\rho_{0}^{\Gamma}$ (with constant $\kappa$), and $\Gamma_{\rm th} = \Gamma$. In the absence of shocks, $\epsilon = \epsilon_{\rm cold}$ so that $P = P_{\rm cold}$.

# Step 3: Output this notebook to $\LaTeX$-formatted PDF file \[Back to [top](#toc)\]
$$\label{latex_pdf_output}$$

```python
#!jupyter nbconvert --to latex --template ../../latex_nrpy_style.tplx Tutorial-IllinoisGRMHD__Overview.ipynb
#!pdflatex -interaction=batchmode Tutorial-IllinoisGRMHD__Overview.tex
#!pdflatex -interaction=batchmode Tutorial-IllinoisGRMHD__Overview.tex
#!pdflatex -interaction=batchmode Tutorial-IllinoisGRMHD__Overview.tex
!rm -f Tut*.out Tut*.aux Tut*.log
```

```python

```


```python
import mmf_setup;mmf_setup.nbinit()
```

    <IPython.core.display.Javascript object>

This cell contains some definitions for equations and some CSS for styling
  the notebook. If things look a bit strange, please try the following:

Choose "Trust Notebook" from the "File" menu.

Re-execute this cell.

Reload the notebook.

# Overview

Here we demonstrate how gravitational wave data from a set of observations might constrain nuclear parameters.  These results were generated as follows:

1. A unified equation of state (EoS) was defined in terms of $N_p = 18$ parameters $\vect{p}$ as follows:
   * An outer crust is fixed by tabulated data. (No parameters.)
   * A compressible liquid drop model (CLDM) modeling spherical nuclei embedded in a gas of neutrons and electrons. (2 parameters)
   * A homogeneous interior with a homogeneous nuclear equation of state $\mathcal{E}(n_n, n_p)$ in conjunction with $\beta$-equilibrium. (13 parameters = 4 neutron matter parameters + 3 proton polaron parameters + 6 symmetric nuclear matter parameters)
   * A core characterized by the speed of sound (3 parameters).
2. The Tolman-Oppenheimer-Volkoff (TOV) equations were solved for non-rotating neutron stars, giving the neutron star masses $M(P_c, \vect{p})$, radii $R(P_c, \vect{p})$, second Love number $k_2(P_c, \vect{p})$ as functions of the central pressure $P_c$ and the 18 parameters $\vect{p}$.
3. Numerical derivatives of these functions were obtained using Richardson extrapolation.  These are tabulated for a set of $N_m$ masses spanning a range of neutron stars.  These derivatives are stored in dimensionless form:

   $$
     \frac{p_i}{M_j} \pdiff{M_j}{p_i} = p_j \pdiff \ln M_j.
   $$

4. Using a post-Newtonian analysis, these derivatives are used to obtain a set of Fisher information matrices $\mat{F}$ corresponding to the expected aLIGO signal sensitivity for all pairs of masses $m_1$ and $m_2$.  This is summarized in a $N_m\times N_m\times N_p\times N_p$ array where each entry $F[i, j,:,:]$ corresponds to the expected Fisher information matrix (FIM) obtained from the inspiral of a binary with masses $m_i$ and $m_j$ at a fiducial distance of $D_0=40$Mpc.  For an event at distance $D$, this must be weighted by a factor $(D_0/D)^2$.
5. From this set of FIM, a collective FIM can be obtained by summing over a sample population of binaries, weighed appropriately.
6. Nuclear uncertainties can be input directly with an information matrix:

   $$
     \mat{F}_{\text{nuclear}} = \mat{C}^{-1}
     \approx \diag\left(\frac{1}{\sigma_{0}^{2}}, \frac{1}{\sigma_{1}^2}, \cdots\right)
   $$

   where $\sigma_i$ are the estimated 1-$\sigma$ relative Gaussian errors for the $i$th parameter $p_i$.  If parameter covariances are known, they can be introduced directly into the covariance matrix $\mat{C}$.  *(All of this analysis assumes that errors are small enough and Gaussian so that linear error analysis is a reasonable approximation.  As above, the tabulated FIM is scaled by the parameter values to be dimensionless, so the errors $\sigma_i$ represent the relative errors in parameter.)*
7. From this combined FIM, we perform various analyses such as a principal component analysis, looking at the largest eigenvalues and eigenvectors of $\mat{F}$, or computing the parameter covariance matrix $\mat{C} = \mat{F}^{-1}$.

The summary of this analysis for a sample equation of state roughly matching th$ ALF4 equation of state is presented in the following notebook:

* [`ALF4.ipynb`](ALF4.ipynb)

*(As additional equations of state are added, they will be analyzed in appropriately named notebooks.)*

# Equation of State

## Outer Crust

The outer crust is simply tabulated from results like those of Negele and Vautherin.  There are no parameters introduced here.

## CLDM

Once the nuclear equation of state $\mathcal{E}(n_n, n_p)$ is specified (see below), we make a unified transition from the outer crust to homogeneous matter using a compressible liquid drop model with the following components:

* A spherical nucleus in a spherical Wigner-Seitz cell.  (Energy density $\mathcal{E}(n_n^(i), n_p^(i))$ where $n_{n,p}^{i}$ are the average densities of the protons and neutrons inside the drop.)
* A surrounding neutron gas.  (Described by $\mathcal{E}(n_n^{o}, 0)$ where $n_n^{o}$ is the density of the neutron gas.)
* A homogeneous electron cloud.  (Described as a free Fermi gas, relativistic if needed, maintaining beta-equilibrium by establishing zero total charge.)
* A surface term.

The only parameters that enter this approximation are those describing the surface of the drop.   For this we use the approach of [Lattimer:1985] (also in [Steiner:2012a]) which is characterized in terms of the proton fraction $x = n_p/n_b$ and the isospin asymmetry $\beta = (n_n - n_p)/(n_n + n_p)$:

$$
  \sigma = \sigma_0\mathcal{B}(x), \qquad
  \mathcal{B}(x) = \frac{16+b}{x^{-3} + b + \frac{1}{(1-x)^3}}, \qquad
  C_{\mathrm{sym}} = \frac{\sigma_{\delta}}{\sigma_0} = \frac{96}{b+16}.
$$

This introduces two parameters `sigma_delta`$=\sigma_\delta$ and `sigma_0`$=\sigma_0$.  Note that for small asymmetries $x\approx 1$, $\beta \approx 0$, we have:

$$
  \sigma = \sigma_0\left(
    1 -
    \frac{C_{\mathrm{sym}}}
         {C_{\mathrm{sym}} - \frac{12}{2 - \frac{1}{(1+\beta)^3} - \frac{1}{(1-\beta)^3}}}
  \right)
  \approx
  \sigma_0\left(
    1 - C_{\mathrm{sym}}\beta^2 + \order(\beta^4)
  \right)
$$

Thus, we see that there are two independent parameters in this expression.  We fix the parameter $\sigma_0$ so that the CLDM matches the baryon density $n_B$ and energy-density $\mathcal{E}(n_B)$ at the upper end of the tabulated set of outer-crust data, ensuring a smooth transition (at most, second-order).

Following [Steiner:2012a], we introduce one more parameter `C_C`$=\mathcal{C}$ which is a suppression factor in the Coulomb energy intended to model the diffuseness of the proton distribution.  Finally, one could in principle vary the dimension `d_C`$=d$ of the Wigner-Seitz cell to allow for a crude estimate of pasta.  We have tried this in some cases, but generally leave $d=3$.

Once these parameters are fixed, and $\sigma_0$ is found, we must find the transition from a droplet to homogeneous matter by finding the point where the chemical potentials and pressures are equal.  This ensures that the transition from the CLDM to homogeneous matter remains convex.  To summarize, we have the following parameters:

**CLDM**
* `C_C`: Coulomb suppression factor $\mathcal{C}$.
* `d_C`: Dimension $d$ of the cells.  (Generally held fixed at $d=3$.)
* `sigma_delta`: Surface tension isospin dependence $\sigma_\delta$ [MeV/fm$^2$].

[Lattimer:1985]: http://dx.doi.org/10.1016/0375-9474(85)90006-5 (J.M. Lattimer, C.J. Pethick, D.G. Ravenhall, and D.Q. Lamb, "Physical properties of hot, dense matter: The general case", Nucl. Phys. A 432(3), 646 - 742 (1985) )

[Steiner:2012a]: http://dx.doi.org/10.1103/PhysRevC.85.055804 (Andrew W. Steiner, "Deep crustal heating in a multicomponent accreted neutron star crust", Phys. Rev. C 85, 055804 (2012) )

## Homogeneous Matter

As motivated by our work with fitting a nuclear energy density functional (NEDF) called SeaLL1:

* [Bulgac, Forbes, Jin, Perez, and Schunck: A Minimal Nuclear Energy Density Functional (accepted for PRC)](https://arxiv.org/abs/1708.08771)

we acknowledge that the connection between neutron matter and symmetric nuclear matter might be quite weak.  We thus base our parameterization of homogeneous matter on an expansion in proton fraction starting from pure neutron matter.  For pure neutron matter, we use the following energy per particle $E_n$ [MeV] as a function of neutron density $n_n$ [1/fm$^3$]:

$$
   E_n(n_n) = \frac{\mathcal{E}_{n}(n_n)}{n_n}
   = m_nc^2 + a\left(\frac{n_n}{\bar{n}_0}\right)^{\alpha} + b\left(\frac{n_n}{\bar{n}_0}\right)^{\beta}
$$

where $m_n=939.565$MeV/$c^2$ is the neutron mass, $\bar{n}_0 = 0.16$/fm$^3$ is a constant (approximately the nucleon saturation density) and $a$, $b$, $\alpha$, and $\beta$ are four EoS parameters `a`, `alpha`, `b`, and `beta` in the code.

Adding a small proton fraction, we extend this using the following form:

\begin{align}
       \text{energy-per-particle}&& E_{np}(n_n, n_p) &= (1-x_p)E_n(n_n) + x_p\left(m_pc^2 + \Sigma^p(n_B)\right)
       + \frac{(2\pi^2)^{2/3}}{2m^*}x_p^{5/3}n_B^{2/3} +x_p^2f_2(n_B) + x_p^3f_3(n_B)+\cdots\\
       &&n_B &= n_n + n_p, \qquad x_p = \frac{n_p}{n_B},&&\\
       \text{proton self-energy}&&\Sigma^p(n_B) &= \mu_p\frac{n_B}{\bar{n}_0}\frac{2u_p-\frac{n_B}{n_0}}{2u_p - 1}&&
\end{align}

where $m_p = 938.272$MeV/$c^2$ is the bare proton mass, and $m^*$ is the effective proton mass.  The properties of the proton polaron are characterized by the parameters $\mu_p$ [MeV], the proton chemical potential at $n_B = \bar{n}_0$ and $u_p$ which specifies at which $n_B = u_p\bar{n}_0$ the proton self-energy $\Sigma^p(n_B)$ reaches its minimum value as shown below:

```python
%pylab inline --no-import-all
import constants as u
n_0 = 0.16/u.fm**3
mu_p = -104.5*u.MeV
u_p = 3.136
n_B = np.linspace(0, (u_p+1)*n_0)
Sigma_p = mu_p*n_B/n_0*(2*u_p - n_B/n_0)/(2*u_p-1)
plt.figure(figsize=(8,5))
plt.plot(n_B, Sigma_p)
plt.grid(True)
plt.xticks([0, n_0, u_p*n_0], ['0', r'$\bar{n}_0$', r'$u_p\bar{n}_0$'])
plt.yticks([0, mu_p], ['0', r'$\mu_p$'])
plt.xlabel(r'$n_B$')
plt.ylabel(r'$\Sigma^p(n_B)$')
plt.title("Demonstration of the parametrization of $\Sigma^p(n_B)$")
```

The expansion above depends on the functions $f_2(n_B)$, $f_3(n_B)$ etc. which we fix by matching the energy-per-particle of symmetric nuclear matter:

$$
  E_{np}(n_n, n_p) = \varepsilon_0 + \frac{K_0}{2}(\delta\rho)^2
  + \left((S_2 + L_2(\delta\rho) + \frac{K_2}{2}(\delta\rho)^2\right)\delta^2\\
  \delta = \frac{n_n-n_p}{n_B}, \qquad
  \delta\rho = \frac{n_B - n_0}{3n_0}.
$$

This introduces the following nuclear parameters: the saturation density $n_0$ [$1/$fm$^{3}$], the saturation energy-per-particle $\varepsilon_0$ [MeV], the symmetric incompressibility $K_0$ [MeV], and the following symmetry parameters at second order in the iso-spin asymmetry $\delta$: the symmetry energy $S_2$ [MeV], its slope $L_2$ [MeV] and its incompressibility $K_2$ [MeV].  Demanding that $E_{np}(n_n, n_p)$ match this expression order-by-order in the powers of $x_p$ up to order $x_p^5$ fixes the functions $f_2(n_B)$ through $f_5(n_B)$, completing the definition of the homogeneous equation of state.  To summarize, we have the following parameters:

**Nuclear Matter near Saturation**
* `n_0`: Saturation density $n_0$ [$1/$fm$^3$].
* `e_0`: Saturation energy density $\varepsilon_0$ [MeV].
* `K_0`: Symmetric incompressibility $K_0$ [MeV].
* `S_2`: Second-order symmetry energy $S_2$ [MeV].
* `L_2`: Second-order slope of the symmetry energy $L_2$ [MeV].
* `K_2`: Second-order incompressibility $K_2$ [MeV].

**Pure Neutron Matter**
* `a`: Coefficient $a$ [MeV] of the low-density polytrope.
* `alpha`: Power $\alpha$ of the low-density polytrope.
* `b`: Coefficient $b$ [MeV] of the high-density polytrope.
* `beta`: Power $\beta$ of the high-density polytrope.

**Proton Polaron**
* `mu_p0`: Proton chemical potential at the saturation density $\mu_p(\bar{n}_0)$ [MeV].
* `u_p`: Location $u_p\bar{n}_0$ of the minimum of the polaron dispersion.
* `m_eff_m_p`: Effective mass `m_p^*/m_p` of the proton in the polaron dispersion.

From these and the energy-density of a free lepton gas, we compute the equation of state by enforcing $\beta$-equilibrium through the condition of bulk charge neutrality and chemical equilibrium.

## Core

Finally, we require a parametrization of the inner core, which we characterize by the behaviour of the square of the speed-of-sound $C = c_s^2/c^2$.  We parametrize this as a polynomial starting at an energy density $\mathcal{E}_c$ up to a transition energy-density $\mathcal{E}_t$ as a function of the energy density $\mathcal{E} = n_BE_{np}(n_B)$.  Above $\mathcal{E}_t$ we simply adopt the asymptotic form $C_0 = 1/3$ predicted by quark matter.

In our code we specify that the polynomial form for $\mathcal{E}_c \leq \mathcal{E}\leq \mathcal{E}_t$ satisfy:

1. $C(\mathcal{E})$ is quadratic.
2. $C(\mathcal{E}_c) = C_c$ where $C_c$ is determined from the homogeneous equation of state.
3. $C(\mathcal{E}_\max) = C_\max$ is the maximum of the polynomial.  The two parameters $\mathcal{E}_\max$ and $C_\max$ are free.

These uniquely fix the equation of state of the core in terms of the following parameters:

**Core**
* `E_c`: Transition energy-density $\mathcal{E}_c$ [MeV/fm$^3$] at which we transition from the homogeneous equation of state to the core.
* `C_max`: Maximum value $C_\max$.
* `E_max`: Energy-density $\mathcal{E}_\max$ [MeV/fm$^3$] at which the polynomial obtains its maximum value.