```python
from jupyterthemes import get_themes
from jupyterthemes.stylefx import set_nb_theme
themes = get_themes()
set_nb_theme(themes[1])
```

```python
# 1. magic for inline plot
# 2. magic to print version
# 3. magic so that the notebook will reload external python modules
# 4. a ipython magic to enable retina (high resolution) plots
# https://gist.github.com/minrk/3301035
%matplotlib inline
#%load_ext watermark
%load_ext autoreload
%autoreload 2
%config InlineBackend.figure_format = 'retina'

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.pipeline import Pipeline
from sklearn.datasets import load_iris
from sklearn.metrics import accuracy_score
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

#%watermark -a 'Ethen' -d -t -v -p numpy,pandas,matplotlib,sklearn
```

    The autoreload extension is already loaded. To reload it, use:
      %reload_ext autoreload

# Dimensionality Reduction

The starting point most data analysis problems is to perform some sort of exploratory data analysis on our raw data. This step is important because until we have basic understanding of the structure of our raw data, it might be hard to know whether the data is suitable for the task at hand or even derive insights that we can later share. During this exploratoy process, unsupervised methods such as dimensionality reduction can help us identify simpler and more compact representations of the original raw data to either aid our understanding or provide useful input to other stages of analysis. Here, we'll be focusing on a specific dimensionality reduction technique called **Principal Component Analysis (PCA)**.

Imagine that we're trying to understand some underlying phenomenon, in order to do so we measure various quantities potentially related to it. If we knew exactly what to measure in advance, we might be able to find some simple relationships in our data. But we typically don't, so we often measure anything that might be relevant and end up having irrelevant or redundant signals in our measurement. To make this a bit more concrete, we'll generate a toy 2-dimensional dataset to work with using the equation below.

$$x_2 = 500 + 20 \times x_1 + \epsilon$$

```python
np.random.seed(123)
x1 = np.arange(12, 56, 0.5) # Generate even spaces from 12 to 56, with 0.5 space between
e  = np.random.normal(0, 100, x1.shape[0]) # Generate noise/error
x2 = 500 + 20 * x1 + e
X = np.c_[x1, x2] # Translates slice objects to concatenation along the second axis

# change default style figure and font size
plt.style.use('fivethirtyeight')
plt.rcParams['figure.figsize'] = 8, 6
plt.rcParams['font.size'] = 12

def plot2var(m):
    plt.scatter(m[:, 0], m[:, 1], s = 40, alpha = 0.8)
    plt.xlabel('x1')
    plt.ylabel('x2')

plot2var(X)
```

We can clearly see from the plot that there's linear relationship between the two variables, thus we probably don't need to include both of them as $x_1$ can be easily explained by $x_2$ and vice versa.

## PCA

If it is the case that one of the features is considered redundant, we should be able to summarize the data with less characteristics (features). So, the way PCA tackles this problem is: Instead of simply picking out the useful features and discarding the others, it uses a linear combination of the existing features and constructs some new features that are good alternative representation of the original data. In our 2D toy dataset, PCA will try to pick the best single direction, or often referred to as **first principal components** in 2D, and project our points onto that single direction. So the next question becomes, out of the many possible lines in 2D, what line should we pick?

It turns out, there are two different answers to this question. First answer is that we are looking for some features that strongly differ across data points, thus, PCA looks for features that captures as much variation across data points as possible. The second answer is that we are looking for the features that would allow us to "reconstruct" the original features. Imagine that we come up with a feature that has no relation to the original ones; If we were to use this new feature, there is no way we can relate this to the original ones. So PCA looks for features that minimizes the reconstruction error. These two notions can be depicted in the graph below, where the black dots represents the original data point, the black line represents the projected line, the red dot on the left shows the points on the projected line and the red line on the right shows the reconstruction error.

Surprisingly, it turns out that these two aims are equivalent and PCA can kill two birds with one stone. To see why minimizing squared residuals is equivalent to maximizing variance consider the 2 dimensions visualization below.

Consider a datapoint $a_i$. The contribution of this specific data point to the total variance is $a_i^Ta_i$, or equivalently the squared Euclidean length $\lVert \mathbf{a}_i \lVert^2$. Applying the Pythagorean theorem shows that this total variance equals the sum of variance lost (the squared residual) and variance remaining. Thus, it is equivalent to either maximize remaining variance or minimize lost variance to find the principal components.

Before we go another deeper, let's build some intuition using the scikit-learn library. The following section standardizes the data, fits the PCA model and prints out some of the important informations.

```python
# we start by standardizing our dataset
# StandardScaler() class standardizes features by removing the mean and scaling to unit variance
# fit_transform is a combo of
#               && fit: : used for generating learning model parameters from training data
#               && transform: parameters generated from fit() method applied
#                             upon model to generate transformed data set.
X_std = StandardScaler().fit_transform(X) # n x 2 object

# call PCA specifying we only want the
# first two principal components (since
# we only have a 2d datset)
pca = PCA(n_components = 2)
pca.fit(X_std)

# important information
print('Components:\n ', pca.components_)
print('Explained Variance Ratio:\n ', pca.explained_variance_ratio_)
```

    Components:
      [[-0.70710678 -0.70710678]
     [ 0.70710678 -0.70710678]]
    Explained Variance Ratio:
      [ 0.95588995  0.04411005]

After fitting the PCA on the dataset, the fit learns some quantities from the data, most importantly the "components", which is the principal components (the new direction that our data points will be projected upon) and "explained variance ratio", which corresponds to the percentage of variance explained by each of the principal components. To get a better sense of what these numbers mean, let's visualize them over our standardized input data.

```python
def draw_vector(v0, v1):
    """draw principal components as vectors"""
    ax = plt.gca()
    arrowprops = dict(arrowstyle = '->',
                      linewidth = 2, edgecolor = 'black',
                      shrinkA = 0, shrinkB = 0)
    plt.annotate('', v1, v0, arrowprops = arrowprops)

# plot data
plt.scatter(X_std[:, 0], X_std[:, 1], s = 40, alpha = 0.8)
for length, vector in zip(pca.explained_variance_, pca.components_):
    # the larger the explained variance, the longer the
    # annotated line will be
    v = vector * 2 * np.sqrt(length)
    draw_vector(pca.mean_, pca.mean_ + v)

plt.axis('equal')
plt.show()
```

From the plot, we see that our PCA model gives us two principal components (the black line) and the length of the lines indicate of how important that principal component is in describing the variation of the original data. So if we were to choose 1 principal component to summarize our 2d dataset, it will be line that has the longest length (largest explained variance ratio). There're two things worth noting:

- The number of principal components matches the total number of features
- The first and second principal components are orthogonal to each other

The reason for this is that principal components transform the data into a new set of dimensions, and these new dimensions have to be equal to the original amount of dimensions. And just like the original $x$, $y$ axis that we're used to, they have to be orthogonal to each other. Let's now reduce the dimension of our 2d dataset into 1d by transforming our data onto the most important principal component and plot it along with the original data.

```python
# dimensionality reduction, keeping only
# the first principal component
pca = PCA(n_components = 1)
X_pca = pca.fit_transform(X_std)
print("original shape:   ", X.shape)
print("transformed shape:", X_pca.shape)

# inverse transform to obtain the projected data
# and compare with the original
X_new = pca.inverse_transform(X_pca)
plt.scatter(X_std[:, 0], X_std[:, 1], s = 40, alpha = 0.2)
plt.scatter(X_new[:, 0], X_new[:, 1], s = 40, alpha = 0.8)
plt.show()
```

In the plot above, the lighter points are the original data and the darker points are the projected version. Looking at the projections we see that the points projected onto the first principal component all seem close to their initial representations and at the same time, it also captures most of the variations in our original data points.

Hopefully, this elucidate what a PCA dimensionality reduction is doing. In a nutshell, PCA aims to find the directions or so called the principal components of maximum variance in high-dimensional data and project it onto a smaller dimensional subspace while retaining most of the information. In our 2d example, the linear relationship between $x_1$ and $x_2$ is mostly preserved using only 1 feature instead of 2.

## PCA From Scratch

Now that we have a high level understanding of what PCA is accomplishing, let's now formalize this with some notations. Recall that PCA is trying the find the direction such that the projection of the data on it has the highest variance. So given $X$, the centered data matrix, the projection, $Xw$ (dot product between the data point, $X$ and the projection weight, $w$), its variance can be computed as follows:

\begin{align}
\frac{1}{n-1}(\mathbf{Xw})^\top \mathbf{Xw} = \mathbf w^\top (\frac{1}{n-1}\mathbf X^\top\mathbf X) \mathbf w = \mathbf w^\top \mathbf{Cw}
\end{align}

Where $\mathbf{C}$ is the covariance matrix of the data $\mathbf{X}$. In case this looks unfamiliar, the covariance matrix is a $d \times d$ matrix ($d$ is the total number of dimensions, features) where each element represents the covariance between two features. The covariance between two features $x_j$ and $x_k$ is calculated as follows:

\begin{align}
\sigma_{jk} = \frac{1}{n-1} \sum_{i=1}^{n} \left( x_{ij} - \bar{x}_j \right) \left( x_{ik} - \bar{x}_k \right)
\end{align}

Where $\bar{x}_j$ is simply the mean of vector (feature) $\bar{x}_j = \sum \limits_{i=1}^n x_{ij}$.

We can also express the calculation of the covariance matrix via the following matrix equation:

\begin{align}
\mathbf{C} = \frac{1}{n-1} \big( (X - \bar{x})^\top( X - \bar{x}) \big)
\end{align}

Because we already assume to data point $X$ has been centered, thus the $X - \bar{x}$ part can be simplified into just $X$. And the formula above simply becomes:

\begin{align}
\mathbf{C} = \frac{1}{n-1} X^\top X
\end{align}

Next, apart from the original objective function $\mathbf w^\top \mathbf{Cw}$, we also introduce an additional constraint $\|\mathbf w\|=\mathbf w^\top \mathbf w=1$, saying our weight vector should have unit length. The intuition behind this is that: If we were to simply maximize the formula $\mathbf w^\top \mathbf{Cw}$, we can multiply $w$ by any number and the objective function will increase by the square of this number. So the problem becomes ill-defined since the maximum of this expression is infinite. Given these two pieces of information, our objective function for PCA becomes:

\begin{align}
&  \underset{\mathbf{w}}{\text{maximize}}
&& \mathbf w^\top \mathbf{Cw} \nonumber \\
&  \text{subject to}
&& \mathbf w^\top \mathbf w=1
\end{align}

This objective function can be solved by the Lagrange multiplier, minimizing the loss function:

\begin{align}
L &= \mathbf w^\top \mathbf{Cw}-\lambda(\mathbf w^\top \mathbf w-1)
\end{align}

If this looks unfamiliar, check out the following tutorial on Lagrange multiplier. [Khan Academy: Lagrange multipliers, examples](https://www.khanacademy.org/math/multivariable-calculus/applications-of-multivariable-derivatives/constrained-optimization/a/lagrange-multipliers-examples). Next, to solve for $w$, we set the partial derivative of $L$ with respect to $w$ to 0.

\begin{align}
\frac{\partial L}{\partial \mathbf w}
& \implies \mathbf{Cw} - \lambda \mathbf w = 0 \\
& \implies \mathbf{Cw} = \lambda \mathbf w
\end{align}

Hopefully the formula above looks familar, since it's essentially an eigendecomposition problem. The notion of eigendecomposition is basically trying to solve the equation:

\begin{align}
Ax &= \lambda x
\end{align}

In our case, $A$ is our covariance matrix correponding to $\mathbf C$; $x$ is our eigenvector, correponding to $\mathbf w$ and $\lambda$ is our eigenvalue correponding to explained variance. After solving the equation above, we'll obtain eigenvector and eigenvalue pairs, where very eigenvector has a corresponding eigenvalue. An eigenvector is essentially the direction of each principal component and the eigenvalue is a number, telling us how much variance there is in the data in that direction, in other words, how spread out the data is on the line. For those interested, the following blog walks through the calculating eigenvalue and eigenvectors from scratch. [Blog: What are eigenvectors and eigenvalues?](http://www.visiondummy.com/2014/03/eigenvalues-eigenvectors/)

Given all of that, let's see how this works in code. Just to summarize, the general framework for computing PCA is as follows:

- Standardize the data
- Obtain the Eigenvectors and Eigenvalues from the covariance matrix
- Sort eigenvalues in descending order and choose the $k$ eigenvectors that correspond to the $k$ largest eigenvalues where $k$ is the number of dimensions of the new feature subspace
- Projection onto the new feature space. During this step we will take the top $k$ eigenvectors and use it to transform the original dataset $X$ to obtain a k-dimensional feature subspace $X'$

For the section below, we will be working with the famous "Iris" dataset. The iris dataset is a 150×4 matrix where the columns are the different features (sepal length in cm, sepal width in cm, petal length in cm, petal width in cm) and every every row represents a separate flower sample. The three classes in the Iris dataset are: Iris-setosa (n=50), Iris-versicolor (n=50), Iris-virginica (n=50). We'll use histograms to get a feeling of how the 3 different flower classes are distributes along the 4 different features.

```python
import pandas

filename = 'IMDB-Movie-Data.csv'
features = ['Rank','Title','Genre','Description','Director','Actors','Year','Runtime (Minutes)',
            'Rating','Votes','Revenue (Millions)']

###,'Metascore']
data = pandas.read_csv(filename)#, names = features)
target = ['Metascore']

# Select only numeric columns - Integers and floats as this would simplify the data handling
data = data.select_dtypes(include=['int64','float'])

# Replace every NaN values with 0
data.fillna(value=0,axis=1,inplace=True)

print(data.shape)
#print(data)

features_numeric =  ['Rank','Year','Runtime (Minutes)',
            'Rating','Votes','Revenue (Millions)']
X = data[features_numeric]
y = data[target]

```

    (1000, 7)

```python
'''iris = load_iris()
X = iris['data']
y = iris['target']

label_dict = {0: 'Iris-Setosa',
              1: 'Iris-Versicolor',
              2: 'Iris-Virgnica'}

feature_dict = {0: 'sepal length [cm]',
                1: 'sepal width [cm]',
                2: 'petal length [cm]',
                3: 'petal width [cm]'}

fig = plt.figure(figsize = (10, 6))
for feature in range(len(feature_dict)):
    plt.subplot(2, 2, feature + 1)
    for index, label in label_dict.items():
        plt.hist(X[y == index, feature], label = label,
                 bins = 10, alpha = 0.3)
        plt.xlabel(feature_dict[feature])

plt.legend(loc = 'upper right', fancybox = True, fontsize = 12)
plt.tight_layout()
plt.show()
'''

```

    "iris = load_iris()\nX = iris['data']\ny = iris['target']\n\nlabel_dict = {0: 'Iris-Setosa',\n              1: 'Iris-Versicolor',\n              2: 'Iris-Virgnica'}\n\nfeature_dict = {0: 'sepal length [cm]',\n                1: 'sepal width [cm]',\n                2: 'petal length [cm]',\n                3: 'petal width [cm]'}\n\nfig = plt.figure(figsize = (10, 6))\nfor feature in range(len(feature_dict)):\n    plt.subplot(2, 2, feature + 1)\n    for index, label in label_dict.items():\n        plt.hist(X[y == index, feature], label = label,\n                 bins = 10, alpha = 0.3)\n        plt.xlabel(feature_dict[feature])\n        \nplt.legend(loc = 'upper right', fancybox = True, fontsize = 12)\nplt.tight_layout()\nplt.show()\n"

### Standardize

In general, it's important to standardize the data prior to a PCA on the covariance matrix depends on the measurement scales of the original features. Since PCA yields a feature subspace that maximizes the variance along the axes, it makes sense to standardize the data, especially if it was measured on different scales so different features will have equal contribution in terms of their scales.

```python
# subtract the mean and divide the
# standard deviation of each features
mean = np.mean(X, axis = 0)
print(mean.shape)

scale = np.std(X, axis = 0)
print(scale.shape)

X_std = (X - mean) / scale
print(X_std.shape)
```

    (6,)
    (6,)
    (1000, 6)

### Eigendecomposition - Computing Eigenvectors and Eigenvalues

The eigenvectors and eigenvalues of a covariance matrix is the core of PCA: The eigenvectors (principal components) determine the directions of the new feature space, and the eigenvalues determine their magnitude (variance explained along the principal components).

```python
# for those unfamiliar with the @ syntax it
# is equivalent of .dot, the dot product
vec_mean = np.mean(X_std, axis = 0)
vec_diff = X_std - vec_mean

print(type(vec_mean))
print(type(X_std.shape[0]))

#cov_mat  = vec_diff.T @ vec_diff / (X_std.shape[0] - 1)
cov_mat  = np.dot(vec_diff.T,vec_diff / (X_std.shape[0] - 1)) # Use of NumPy library

print('Covariance matrix \n {}'.format(cov_mat))

# note that since we already standardized the data,
# meaning the mean vector for each features, vec_mean
# will be 0 (really small numbers if you were to print them out),
# hence we don't have to substract the mean to standardize them
cov_mat = np.dot(X_std.T,X_std / (X_std.shape[0] - 1)) # Use of NumPy library
print('Covariance matrix \n {}'.format(cov_mat))

# equivalently, we could have used the np.cov function:
# since each row represents a variable for np.cov,
# we'll need to transpose the matrix
print('NumPy covariance matrix: \n {}'.format(np.cov(X_std.T)))
```

    <class 'pandas.core.series.Series'>
    <class 'int'>
    Covariance matrix
     [[ 1.001001   -0.26186703 -0.22196124 -0.21977498 -0.28416019 -0.26280783]
     [-0.26186703  1.001001   -0.16506487 -0.21143019 -0.41231614 -0.18178375]
     [-0.22196124 -0.16506487  1.001001    0.39260685  0.40746921  0.30030461]
     [-0.21977498 -0.21143019  0.39260685  1.001001    0.51204937  0.25197305]
     [-0.28416019 -0.41231614  0.40746921  0.51204937  1.001001    0.66584133]
     [-0.26280783 -0.18178375  0.30030461  0.25197305  0.66584133  1.001001  ]]
    Covariance matrix
     [[ 1.001001   -0.26186703 -0.22196124 -0.21977498 -0.28416019 -0.26280783]
     [-0.26186703  1.001001   -0.16506487 -0.21143019 -0.41231614 -0.18178375]
     [-0.22196124 -0.16506487  1.001001    0.39260685  0.40746921  0.30030461]
     [-0.21977498 -0.21143019  0.39260685  1.001001    0.51204937  0.25197305]
     [-0.28416019 -0.41231614  0.40746921  0.51204937  1.001001    0.66584133]
     [-0.26280783 -0.18178375  0.30030461  0.25197305  0.66584133  1.001001  ]]
    NumPy covariance matrix:
     [[ 1.001001   -0.26186703 -0.22196124 -0.21977498 -0.28416019 -0.26280783]
     [-0.26186703  1.001001   -0.16506487 -0.21143019 -0.41231614 -0.18178375]
     [-0.22196124 -0.16506487  1.001001    0.39260685  0.40746921  0.30030461]
     [-0.21977498 -0.21143019  0.39260685  1.001001    0.51204937  0.25197305]
     [-0.28416019 -0.41231614  0.40746921  0.51204937  1.001001    0.66584133]
     [-0.26280783 -0.18178375  0.30030461  0.25197305  0.66584133  1.001001  ]]

After obtaining the covariance matrix, we perform an eigendecomposition on it to obtain the eigenvalues and eigenvectors.

```python
# since computing this by hand can be quite tedious
# we'll simply use the pre-implemented function
eig_vals, eig_vecs = np.linalg.eig(cov_mat)
print('Eigenvectors \n%s' % eig_vecs)
print('\nEigenvalues \n%s' % eig_vals)
```

    Eigenvectors
    [[-0.25328006  0.69463719  0.15887324 -0.6261548   0.17812879 -0.06548263]
     [-0.25915764 -0.70772768  0.29437762 -0.58269442  0.06848615 -0.03282575]
     [ 0.40521367 -0.07139879  0.00179283  0.02465733  0.74895567 -0.51880469]
     [ 0.42957808 -0.00183903 -0.23825262 -0.34855051 -0.59170564 -0.53580962]
     [ 0.55695788  0.08905989  0.77997331  0.00944185 -0.13055157  0.23743427]
     [ 0.45798362 -0.05973167 -0.47220252 -0.38237781  0.18837705  0.61806893]]

    Eigenvalues
    [ 2.56099095  1.27736564  0.22892249  0.48750649  0.62744714  0.8237733 ]

### Choosing Top K Eigenvectors

Recall that the typical goal of a PCA is to reduce the dimensionality of the original feature space by projecting it onto a smaller subspace. In order to decide which eigenvector(s) can dropped without losing too much information for the construction of lower-dimensional subspace, we need to inspect the corresponding eigenvalues. The idea is: eigenvectors with the lowest eigenvalues bear the least information about the distribution of the data and those are the ones can be dropped. In order to do so, the common approach is to rank the eigenvalues from highest to lowest in order choose the top $k$ eigenvectors.

After sorting the eigenpairs, the next question is "how many principal components are we going to choose for our new feature subspace?" A useful measure is the so-called “explained variance,” which can be calculated from the eigenvalues. The explained variance tells us how much extra information (variance) does each of the principal components contribute.

```python
eig_vals_total = np.sum(eig_vals)
var_exp = eig_vals / eig_vals_total
cum_var_exp = np.cumsum(var_exp)
print('Variance Explained: ', var_exp)
print('Cumulative Variance Explained: ', cum_var_exp)

plt.bar(range(var_exp.shape[0]), var_exp, alpha = 0.5,
        align = 'center', label = 'individual explained variance')
plt.step(range(var_exp.shape[0]), cum_var_exp,
         where = 'mid', label = 'cumulative explained variance')
plt.ylabel('Explained variance ratio')
plt.xlabel('Principal components')
plt.ylim(0, 1.1)
plt.legend(loc = 'best')
plt.tight_layout()
```

The plot above clearly shows that most of the variance (72.77% of the variance to be precise) can be explained by the first principal component alone. The second principal component still bears some information (23.03%) while the third and fourth principal components can safely be dropped without losing to much information. Together, the first two principal components explains 95.8% of the variance. Or in other words, it contain 95.8% of the information (95% or 99% are comon threshold that people use).

### Projection Onto the New Feature Space

In the last step, we will use the 4×2-dimensional eigenvectors to transform our samples onto the new subspace via the equation $Y=XW$, where Y will be our 150×2 matrix of transformed samples (we reduce the dimension from the original 4 down to 2).

```python
def plot_iris_pca(X_pca, y):
    """a scatter plot of the 2-dimensional iris data"""
    markers = 's', 'x', 'o'
    colors = list(plt.rcParams['axes.prop_cycle'])
    target = np.unique(y)
    for idx, (t, m) in enumerate( zip(target, markers) ):
        subset = X_pca[y == t]
        plt.scatter(subset[:, 0], subset[:, 1], s = 50,
                    c = colors[idx]['color'], label = t, marker = m)

    plt.xlabel('PC 1')
    plt.ylabel('PC 2')
    plt.legend(loc = 'lower left')
    plt.tight_layout()
    plt.show()
```

```python
X_std_pca = X_std.dot(eig_vecs[:, 0:2])
#plot_iris_pca(X_std_pca, y)
```

We'll put all of this into a single class, train it and confirm the result with scikit-learn's PCA model by printing out the explained variance ratio.

```python
class PCAModel:
    """
    Principal component analysis (PCA)

    Parameters
    ----------
    n_components : int
        top number of principal components to keep
    """

    def __init__(self, n_components):
        self.n_components = n_components

    def fit(self, X):
        # standardize
        X = X.copy()
        self.mean = np.mean(X, axis = 0)
        self.scale = np.std(X, axis = 0)
        X = (X - self.mean) / self.scale

        # eigendecomposition
        eig_vals, eig_vecs = np.linalg.eig(np.cov(X.T))
        self.components = eig_vecs[:, :self.n_components]
        var_exp = eig_vals / np.sum(eig_vals)
        self.explained_variance_ratio = var_exp[:self.n_components]
        return self

    def transform(self, X):
        X = X.copy()
        X = (X - self.mean) / self.scale
        X_pca = X @ self.components
        return X_pca
```

```python
# implementation from scratch
pca_model = PCAModel(n_components = 2).fit(X)

# using library to confirm results
scaler = StandardScaler()
X_std = scaler.fit_transform(X)
pca = PCA(n_components = 2).fit(X_std)

# print explained ratio to see if the matches
print('library: ', pca.explained_variance_ratio_)
print('from scratch: ', pca_model.explained_variance_ratio)
```

    library:  [ 0.42640499  0.21268138]
    from scratch:  [ 0.42640499  0.21268138]

## Takeaway

Let's wrap this up with a summary of PCA and some of its applications. In order to identify patterns in our data, we often look for variation across observations to distinguish them from one another. Hence it seems reasonable to be able to find a succinct representation that best captures the variation in our initial data. PCA, in particular, look to explain our data via its maximum directions of variance. By compressing a higher dimensional dataset into lower one, while still retaining most of the variance this allows us to:

- Perform Visualization: PCA summarizes our data along the principal components (or eigenvectors), which explains most the variance. Thus we can reduce the dataset to 2 or 3 dimensions and visualize our data's distribution. This can be helpful when we're performing clustering algorithm that needs to choose the cluster number beforehand.
- Speed up machine learning algorithms: When dealing with Big Data, we might want to decompose the features into a lower dimension, without a significant loss in variance. By performing dimensionality reduction methods, we can reduce the amount of features we have to speed up the training algorithm and save memory. For this part, we'll use the Iris dataset again and show that by keeping the data's top 3 principal components we can obtain the same level of accuracy as keeping all 4 features.

```python
# split 30% of the iris data into a test set for evaluation
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.3,
                                                    random_state = 1)

# create the pipeline, where we'll
# standardize the data, perform PCA and
# fit the logistic regression
pipe1 = Pipeline([
    ('standardize', StandardScaler()),
    ('pca', PCA(n_components = 3)),
    ('logistic', LogisticRegression(random_state = 1))
])
pipe1.fit(X_train, y_train)
y_pred1 = pipe1.predict(X_test)

# pipeline without PCA
pipe2 = Pipeline([
    ('standardize', StandardScaler()),
    ('logistic', LogisticRegression(random_state = 1))
])
pipe2.fit(X_train, y_train)
y_pred2 = pipe2.predict(X_test)

# access the prediction accuracy
print('PCA Accuracy %.3f' % accuracy_score(y_test, y_pred1))
print('Accuracy %.3f' % accuracy_score(y_test, y_pred2))
```

    /home/nbuser/anaconda3_410/lib/python3.5/site-packages/sklearn/utils/validation.py:526: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      y = column_or_1d(y, warn=True)
    /home/nbuser/anaconda3_410/lib/python3.5/site-packages/sklearn/utils/validation.py:526: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      y = column_or_1d(y, warn=True)

    PCA Accuracy 0.057
    Accuracy 0.057

# Reference

- [Youtube: Dimensionality Reduction](https://www.youtube.com/playlist?list=PLnnr1O8OWc6aVexn2BY0qjklobY6TUEIy)
- [Blog: What are eigenvectors and eigenvalues?](http://www.visiondummy.com/2014/03/eigenvalues-eigenvectors/)
- [Blog: Principal Component Analysis 4 Dummies](https://georgemdallas.wordpress.com/2013/10/30/principal-component-analysis-4-dummies-eigenvectors-eigenvalues-and-dimension-reduction/)
- [Blog: Everything you did and didn't know about PCA](http://alexhwilliams.info/itsneuronalblog/2016/03/27/pca/)
- [Blog: Principal Component Analysis in 3 Simple Steps](http://sebastianraschka.com/Articles/2015_pca_in_3_steps.html)
- [Notebook: In Depth: Principal Component Analysis](http://nbviewer.jupyter.org/github/jakevdp/PythonDataScienceHandbook/blob/95664b978439df948b2cd2f5f5b4e31f28b30394/notebooks/05.09-Principal-Component-Analysis.ipynb)
- [StackExchange: Making sense of principal component analysis, eigenvectors & eigenvalues](http://stats.stackexchange.com/questions/2691/making-sense-of-principal-component-analysis-eigenvectors-eigenvalues)
- [StackExchange: What is an intuitive explanation for how PCA turns from a geometric problem (with distances) to a linear algebra problem (with eigenvectors)?](http://stats.stackexchange.com/questions/217995/what-is-an-intuitive-explanation-for-how-pca-turns-from-a-geometric-problem-wit)


Modelos Matemáticos de Ingeniería

Sesión 05 - PDEs y su clasificación

2021/02

MEDELLÍN - COLOMBIA

***

***Docente:*** Carlos Alberto Álvarez Henao, I.C. D.Sc.

***e-mail:*** carlosalvarezh@gmail.com

***skype:*** carlos.alberto.alvarez.henao

***Linkedin:*** https://www.linkedin.com/in/carlosalvarez5/

***github:*** https://github.com/carlosalvarezh/Dinamica

***Herramienta:*** [Jupyter](http://jupyter.org/)

***Kernel:*** Python 3.9

***

***Nota:*** *Este notebook fue creada basado en el original del blog post por [Raúl E. López Briega](http://relopezbriega.com.ar/) en [Matemáticas, análisis de datos y python](http://relopezbriega.github.io). El contenido esta bajo la licencia BSD.* Todo el crédito y agradecimientos al Dr. Raúl López.

Tabla de Contenidos

1  Introducción

2  ¿Qué es una ecuación en derivadas parciales?

2.1  Clasificación de ecuaciones en derivadas parciales

2.2  Tipos de ecuaciones lineales:

2.3  ¿Cómo resolver ecuaciones en derivadas parciales?

2.4  Solución de ecuaciones en derivadas parciales básicas

2.5  La condición inicial y la condición de frontera

2.6  Interpretación geométrica de EDP de primer orden

2.6.1  Coeficientes constantes

2.6.2  Coeficientes variables

3  Resolviendo ecuaciones en derivadas parciales con Python

3.1  Separación de variables

3.2  Métodos numéricos - Método de Elementos Finitos

## Introducción

Nuestra comprensión de los procesos fundamentales de la naturaleza se basa en gran medida en [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales). Ejemplos de ello son las vibraciones de los sólidos, la dinámica de los fluidos, la difusión de los productos químicos, la propagación del calor, la estructura de las moléculas, las interacciones entre fotones y electrones, y la radiación de ondas electromagnéticas. Las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) también juegan un papel central en las matemáticas modernas, especialmente en la [geometría](https://es.wikipedia.org/wiki/Geometr%C3%ADa) y el [análisis](https://es.wikipedia.org/wiki/An%C3%A1lisis_matem%C3%A1tico); lo que las convierte en una herramienta de suma utilidad que debemos conocer.

**_Nota_**: Este artículo corresponde a la tercer entrega de mi serie de artículos sobre [Cálculo](https://es.wikipedia.org/wiki/C%C3%A1lculo_infinitesimal) con [Python](https://www.python.org/); los anteriores fueron: [Introducción al Cálculo](http://relopezbriega.github.io/blog/2015/12/02/introduccion-al-calculo-con-python/) y [Ecuaciones Diferenciales con Python](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/), los cuales es recomendable haber leído previamente.

## ¿Qué es una ecuación en derivadas parciales?

Una [Ecuación en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) es una ecuación que, como su nombre lo indica, contiene [derivadas parciales](https://es.wikipedia.org/wiki/Derivada_parcial). A diferencia de lo que habíamos visto con las [ecuaciones diferenciales ordinarias](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/), en donde la función incógnita depende **solo** de *una variable*; en las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales), o [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) para abreviar, la función incógnita va a depender de **dos o más** *variables independientes* $x, y, \dots$. Generalmente a la función incógnita la vamos a expresar como $u(x, y, \dots)$ y a sus derivadas parciales como $\partial u / \partial x = u_x$ o  $\partial u / \partial y = u_y$ dependiendo de sobre que variable estemos derivando. Entonces una [Ecuación en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) va a ser la identidad que relaciona a las *variables independientes* ($x, y, \dots$), con la *variable dependiente* $u$ (nuestra función incógnita), y las [derivadas parciales](https://es.wikipedia.org/wiki/Derivada_parcial) de $u$. Lo podríamos expresar de la siguiente forma:

$$F(x, y, u(x, y), u_x(x, y), u_y(x, y)) = F(x, y, u, u_x, u_y) = 0$$

Al igual de como pasaba con las [ecuaciones diferenciales ordinarias](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/), el ***orden*** de una [Ecuación en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) va a estar dado por la mayor [derivada](https://es.wikipedia.org/wiki/Derivada) presente. Por lo tanto, el caso que expresamos más arriba corresponde a una [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales), con dos *variables independientes*, de *primer orden*. Si quisiéramos expresar una [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de *segundo orden*, podríamos hacerlo de la siguiente manera:

$$F(x, y, u, u_x, u_y, u_{xx}, u_{xy}, u_{yy})=0.$$

### Clasificación de ecuaciones en derivadas parciales

La clasificación de las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) va a ser algo fundamental, ya que la teoría y los métodos para poder solucionarlas van a depender de la *clase* de ecuación con la que estemos tratando. Las clasificaciones más importantes que debemos tener en cuenta, son:

1. **El *orden* de la [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales):** Como ya mencionamos, el mismo va a estar dado por el *orden* de la mayor [derivada](https://es.wikipedia.org/wiki/Derivada) presente.

2. **Número de variables:** Esta clasificación va a estar dada por la cantidad de *variables independientes* que contenga la [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales).

3. **Linealidad:** Esta es una de las clasificaciones más importantes, vamos a poder clasificar a las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) en [lineales](https://es.wikipedia.org/wiki/Lineal) o [no lineales](https://es.wikipedia.org/wiki/No_linealidad). En las [lineales](https://es.wikipedia.org/wiki/Lineal), la *variable dependiente* $u$ y todas sus derivadas, van a aparecer en una forma *lineal*, es decir, que van a tener *grado* uno (no van a estar elevadas al cuadrado, o multiplicadas entre sí). Más precisamente, una [Ecuación en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de segundo orden con dos variables, va a tomar la siguiente forma:

$$Au_{xx} + Bu_{xy} + Cu_{yy} + Du_x + Eu_y + Fu = G$$

en donde $A, B, C, D, E, F,$ y $G$ pueden ser *constantes* o una *función* dada de $x$ e $y$. Por ejemplo:

$u_{tt} = e^tu_{xx} + \sin t$,    sería una ecuación [lineal](https://es.wikipedia.org/wiki/Lineal).

$uu_{xx} + u_y = 0$,  sería una ecuación [no lineal](https://es.wikipedia.org/wiki/No_linealidad).

$u_{xx} + yu_{yy} + u_x = 0$, sería una ecuación [lineal](https://es.wikipedia.org/wiki/Lineal).

$xu_x + yu_y + u^2 = 0$, sería una ecuación [no lineal](https://es.wikipedia.org/wiki/No_linealidad).

### Tipos de ecuaciones lineales:

Asimismo, a las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) [lineales](https://es.wikipedia.org/wiki/Lineal) de segundo orden las vamos a poder subdividir en las siguientes categorías:

* **[Ecuaciones parabólicas](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_parab%C3%B3lica_en_derivadas_parciales)**: Las cuales van a describir el *[flujo del calor](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_del_calor)* y el proceso de *[difusión](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_de_difusi%C3%B3n)*. Éstas ecuaciones van a satisfacer la condición $B^2 -4AC = 0$.

* **[Ecuaciones hiperbólicas](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_hiperb%C3%B3lica_en_derivadas_parciales)**: Las cuales describen los *[sistemas de vibración](https://es.wikipedia.org/wiki/Vibraci%C3%B3n)* y los *[movimientos de ondas](https://es.wikipedia.org/wiki/Onda)*. Satisfacen la condición $B^2 -4AC > 0$.

* **[Ecuaciones Elípticas](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_el%C3%ADptica_en_derivadas_parciales)**: Las cuales describen los fenómenos de *[estados estacionarios](https://es.wikipedia.org/wiki/Estado_estacionario)* y satisfacen la condición $B^2 -4AC < 0$.

4. **Homogeneidad:** Otra clasificación que podemos utilizar, es la de homogeneidad. Una ecuación va a ser *homogénea*, si el lado derecho de la ecuación, $G(x, y)$, es idénticamente cero para todo $x$ e $y$. En caso contrario, se la llama *no homogénea*.

5. **Tipos de coeficientes:** Por último, podemos clasificar a las [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de acuerdo a sus coeficientes $A, B, C, D, E, $ y $F$, si los mismos son *constantes*, se dice que la ecuación es de *coeficientes constantes*, en caso contrario será de *coeficientes variables*.

### ¿Cómo resolver ecuaciones en derivadas parciales?

Existen varios métodos que podemos utilizar para intentar resolver las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales), la principal idea detrás la mayoría de estos métodos es la transformar a estas ecuaciones en [ecuaciones diferenciales ordinarias](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/) ([EDO](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_diferencial_ordinaria)), o en alguna [ecuación algebraica](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_algebraica); las cuales son más sencillas de resolver. Algunos los métodos que podemos utilizar son:

1. **El [método de separación de variables](https://es.wikipedia.org/wiki/M%C3%A9todo_de_separaci%C3%B3n_de_variables):** Este método es uno de los más importantes y más productivos a la hora de resolver a las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales). La idea es reducir a la [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de $n$ variables, en $n$ [EDOs](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_diferencial_ordinaria).

2. **El método de la [transformada integral](https://es.wikipedia.org/wiki/Transformada_integral):** Este método es similar al que ya vimos al resolver [EDOs](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/). La idea es aplicar una [transformada integral](https://es.wikipedia.org/wiki/Transformada_integral) para reducir una [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de $n$ variables, en otra de $n - 1$ variables. De esta forma una [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de 2 variables, puede ser transformada en una [EDO](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_diferencial_ordinaria).

3. **El método del [cambio de coordenadas](https://es.wikipedia.org/wiki/Sistema_de_coordenadas#Cambios_de_coordenadas):** Este método intenta cambiar a la [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) original en una [EDO](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_diferencial_ordinaria) o en otra [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) más sencilla de resolver por medio del cambio de coordenadas del problema.

4. **El método de [perturbación](https://es.wikipedia.org/wiki/Teor%C3%ADa_perturbacional):** Este método aplica la [teoría perturbacional](https://es.wikipedia.org/wiki/Teor%C3%ADa_perturbacional) para intentar cambiar un problema de [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) [no lineal](https://es.wikipedia.org/wiki/No_linealidad) en una serie de problemas [lineales](https://es.wikipedia.org/wiki/Lineal) que se aproximan al [no lineal](https://es.wikipedia.org/wiki/No_linealidad) original.

5. **El método de expansión de [autofunciones](https://es.wikipedia.org/wiki/Autofunci%C3%B3n):** Este método intentan encontrar la solución de una [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) como una suma infinita de [autofunciones](https://es.wikipedia.org/wiki/Autofunci%C3%B3n). Estas [autofunciones](https://es.wikipedia.org/wiki/Autofunci%C3%B3n) son halladas por medio de la resolución del problema del [valor propio](https://es.wikipedia.org/wiki/Vector_propio_y_valor_propio) que corresponde al problema original.

6. **El método de las [ecuaciones integrales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_integral)**: Este método convierte a la [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) en una [ecuación integral](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_integral); la cual luego es resuelta aplicando ciertas técnicas particulares que se aplican a ese tipo ecuaciones.

7. **Métodos numéricos**: La mayoría de las técnicas para resolver numéricamente a las [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) se basan en la idea
de discretizar el problema en cada variable independiente que se produce en la [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales), y de esta forma, reformular el problema en una forma algebraica. Esto usualmente resulta en problemas de [álgebra lineal](https://es.wikipedia.org/wiki/%C3%81lgebra_lineal) de gran escala. Dos  de las técnicas principales para reformular las [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) a una forma algebraica son, los [métodos de diferencias finitas](https://es.wikipedia.org/wiki/M%C3%A9todo_de_las_diferencias_finitas) (MDF), donde las [derivadas](https://es.wikipedia.org/wiki/Derivada) del problema son aproximadas por medio de la fórmula de [diferencias finitas](https://es.wikipedia.org/wiki/Diferencia_finita); y los [métodos de los elementos finitos](https://es.wikipedia.org/wiki/M%C3%A9todo_de_los_elementos_finitos) (MEF), en donde la función incógnita se escribe como combinación lineal de funciones de base simple que pueden ser derivadas e integradas fácilmente. En muchos casos, estos métodos numéricos van a ser las únicas herramientas que podamos utilizar para resolver a las [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales).

### Solución de ecuaciones en derivadas parciales básicas

Como resolver este tipo de ecuaciones es una tarea realmente complicada, vamos a empezar por resolver analíticamente las más fáciles para ganar confianza y así luego poder pasar a ecuaciones más complicadas.

La más simple de las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) que nos podemos encontrar es:

$$u_x = 0 \text{, en donde } u = u(x, y)$$

Esta ecuación nos dice que la [derivada parcial](https://es.wikipedia.org/wiki/Derivada_parcial) de $u$ con respecto a $x$ es cero, lo que significa que $u$ no depende de $x$. Por lo tanto, la solución de esta ecuación va a ser $u=f(y)$, en donde $f$ es una función arbitraria de una variable. Por ejemplo, $u = y^2 - y$ podría ser una posible solución.

Subiendo un poco más la complejidad, podemos pasar a una [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de segundo orden, como la siguiente:

$$u_{xx} = 0$$

En este caso, podemos integrar una vez $u_{xx}$ para obtener $u_x(x, y) = f(y)$. Si volvemos a integrar este resultado, podemos arribar a la solución final $u(x, y) = f(y)x + g(y)$, en donde $f$ y $g$ son dos funciones arbitrarias.

Por último, si quisiéramos resolver la siguiente [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales):

$$u_{xy} = 0$$

Primero integramos con respecto a $x$ tomando a $y$ como fija, de esta forma obtenemos $u_y(x, y) = f(y)$. Luego podemos integrar con respecto a $y$ tomando a $x$ como fija, y llegamos a la solución:

$$u(x, y) = F(y) + g(x) \text{, en donde } F' = f$$

Como podemos ver de estos ejemplos, las soluciones analíticas de las [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) dependen de **funciones arbitrarias** (en lugar de *constantes* arbitrarias como era el caso de las [EDO](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/)). Por lo tanto vamos a necesitar condiciones auxiliares para poder determinar una única solución.

### La condición inicial y la condición de frontera

Al igual que nos pasaba cuando vimos las [ecuaciones diferenciales ordinarias](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/); las [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) pueden tener muchas soluciones, pero a nosotros nos va a interesar encontrar la solución para un caso particular; para lograr esto, debemos imponer unas condiciones auxiliares al problema original. Estas condiciones van a estar motivadas por la [Física](https://es.wikipedia.org/wiki/F%C3%ADsica) del problema que estemos analizando y pueden llevar a ser de dos tipos diferentes: [condiciones iniciales](https://en.wikipedia.org/wiki/Initial_condition) y [condiciones de frontera](https://es.wikipedia.org/wiki/Problema_de_condici%C3%B3n_de_frontera).

La [condición inicial](https://en.wikipedia.org/wiki/Initial_condition) va a establecer el estado del problema al momento de tiempo cero, $t_0$. Por ejemplo para el problema de *[difusión](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_de_difusi%C3%B3n)*, la [condición inicial](https://en.wikipedia.org/wiki/Initial_condition) va a ser:

$$u(x, t_0) = \phi(x)$$

donde $\phi(x)= \phi(x, y, z)$ es una función que puede representar el estado de concentración inicial. Para el problema del *[flujo del calor](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_del_calor)*, $\phi(x)$ va a representar la temperatura inicial.

La [condición de frontera](https://es.wikipedia.org/wiki/Problema_de_condici%C3%B3n_de_frontera) nos va a delimitar el *dominio* en el que nuestra [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) es válida. Así por ejemplo, volviendo al problema de *[difusión](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_de_difusi%C3%B3n)*, el *dominio* en el que nuestra [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) es válida, puede estar delimitado por la superficie del objeto que contiene al líquido. Existen varios tipos de [condiciones de frontera](https://es.wikipedia.org/wiki/Problema_de_condici%C3%B3n_de_frontera), de las cuales las más importantes son:

* La **[condición de frontera de Dirichlet](https://es.wikipedia.org/wiki/Condici%C3%B3n_de_frontera_de_Dirichlet)**, en dónde los valores válidos de la función incógnita $u$ son especificados.

* La **[condición de frontera de Neumann](https://es.wikipedia.org/wiki/Condici%C3%B3n_de_frontera_de_Neumann)**, en donde los valores válidos especificados son dados para alguna de las derivadas de $u$.

* La **[condición de frontera de Robin](https://es.wikipedia.org/wiki/Condici%C3%B3n_de_frontera_de_Robin)**, en donde los valores válidos son especificados por una [combinación lineal](https://es.wikipedia.org/wiki/Combinaci%C3%B3n_lineal) de una función y las derivadas de $u$.

### Interpretación geométrica de EDP de primer orden

Las [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de primer orden poseen una interpretación geométrica la cual nos puede facilitar alcanzar una solución general para ellas.

#### Coeficientes constantes

Tomemos la siguiente ecuación de coeficientes constantes:

$au_x + bu_y = 0$, en donde $a$ y $b$ son *constantes* y ambas no pueden ser cero.

En esta ecuación la cantidad  $au_x + bu_y$ es la derivada direccional de $u$ en la dirección del [vector](https://es.wikipedia.org/wiki/Vector) $V = (a, b) = ai + bj$. Como esta cantidad tiene que ser cero, esto significa que $u (x, y)$ debe ser constante en la dirección de $V$. El [vector](https://es.wikipedia.org/wiki/Vector) $(b, -a)$ es ortogonal a $V$, por lo tanto, las líneas paralelas a $V$ (ver el gráfico más abajo) tienen las ecuaciones $bx - ay$ constantes. (Se las llama las *líneas características*.) Entonces, la solución es constante en cada una de esas líneas. Por lo tanto, $u (x, y)$ depende de $bx - ay$ solamente. De esta forma podemos llegar a la solución general para este tipo de ecuaciones, que va a ser:

$u(x, y) = f(bx - ay)$, en donde $f$ es una función arbitraria de una variable.

Entonces, si por ejemplo, quisiéramos resolver la [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales), $4u_x - 3u_y= 0$, con la [condición de frontera](https://es.wikipedia.org/wiki/Problema_de_condici%C3%B3n_de_frontera) que $u(0, y) = y^3$. Podemos aplicar la solución general que obtuvimos arriba y llegar al resultado $u(x, y) = f(-3x - 4y)$. Ahora, solo nos faltaría aplicar la condición para poder determinar cual es la función arbitraria $f$. Si sustituimos $x=0$ en nuestra solución, obtenemos $y^3 = f(-4y)$. Si decimos que $z = -4y$, entonces nuestra función es $f(z) = -z^3 / 64$. Por lo tanto la solución de nuestra [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) que satisface la [condición de frontera](https://es.wikipedia.org/wiki/Problema_de_condici%C3%B3n_de_frontera) es $u(x, y) = (3x + 4y)^3 / 64$.

#### Coeficientes variables

Consideremos ahora la siguiente [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de coeficientes variables:

$$u_x + yu_y = 0$$

Esta ecuación es similar a la que vimos anteriormente, con la diferencia de que ahora tenemos al coeficiente variable $y$. Utilizando la misma intuición geométrica que usamos antes, podemos ver que aquí también la derivada direccional de $u$ en el vector $v=(1, y)$ es constante, pero esta vez el vector no es constante, sino que es variable con $y$. Las curvas que tienen a $v$ como su vector tangente, tienen pendiente $y/1$, es decir:

$$\frac{dy}{dx} = \frac{y}{1}$$

Podemos resolver esta ecuación como una [EDO](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_diferencial_ordinaria) y así obtener:

$$y = Ce^x \text{, o lo que es lo mismo } e^{-x}y = C$$.

La solución de nuestra [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) entonces va a ser constante en estas *curvas características* (ver gráfico); y van a responder a la siguiente solución general:

$u(x, y) = f(e^{-x}y)$, en donde $f$ es una función arbitraria.

## Resolviendo ecuaciones en derivadas parciales con Python

Es tiempo de nuevamente recurrir a nuestros queridos paquetes científicos de [Python](https://www.python.org/), [NumPy](http://www.numpy.org/), [Matplotlib](http://matplotlib.org/), [SymPy](http://www.sympy.org/es/) y [SciPy](http://www.scipy.org/) para ayudarnos a resolver las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales). Así como en el caso de las [Ecuaciones diferenciales ordinarias](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/) vimos que existía dentro del paquete [SymPy](http://www.sympy.org/es/), el solucionador genérico `sympy.dsolve`; para el caso de las [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales), vamos a tener al solucionador [`sympy.pdsolve`](http://docs.sympy.org/dev/modules/solvers/pde.html). Aunque en este caso, es mucho más limitado que su versión para [EDO](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/); ya que solo vamos a poder resolver [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de primer orden. Veamos como funciona:

```python
# <!-- collapse=True -->
# importando modulos necesarios
%matplotlib inline

import matplotlib.pyplot as plt
import matplotlib as mpl
import numpy as np
import sympy

# imprimir con notación matemática.
sympy.init_printing(use_latex='mathjax')
```

```python
# Defino las variables
x, y, u, z = sympy.symbols('x y u z')
f = sympy.Function('f')
```

```python
# Defino la EDP 4u_x - 3u_y = 0
u = f(x, y)
u_x = u.diff(x)
u_y = u.diff(y)
eq = sympy.Eq(4*u_x - 3*u_y)
eq
```

$$4 \frac{\partial}{\partial x} f{\left (x,y \right )} - 3 \frac{\partial}{\partial y} f{\left (x,y \right )} = 0$$

```python
# Resuelvo la ecuación
sympy.pdsolve(eq)
```

$$f{\left (x,y \right )} = F{\left (- 3 x - 4 y \right )}$$

```python
# Defino la EDP u_x + yu_y = 0
u = f(x, y)
u_x = u.diff(x)
u_y = u.diff(y)
eq2 = sympy.Eq(u_x + y*u_y)
eq2
```

$$y \frac{\partial}{\partial y} f{\left (x,y \right )} + \frac{\partial}{\partial x} f{\left (x,y \right )} = 0$$

```python
# Resuelvo la ecuación
sympy.pdsolve(eq2)
```

$$f{\left (x,y \right )} = F{\left (y e^{- x} \right )}$$

```python
# Calsificación de EDP.
sympy.classify_pde(eq2)
```

    ('1st_linear_variable_coeff',)

Como podemos comprobar, obtuvimos los mismos resultados utilizando [`sympy.pdsolve`](http://docs.sympy.org/dev/modules/solvers/pde.html) que en nuestro análisis manual con la interpretación geométrica. Otra limitación que vamos a tener al trabajar con [`sympy.pdsolve`](http://docs.sympy.org/dev/modules/solvers/pde.html) es que no podemos aplicar nuestras condiciones auxiliares al problema, por lo que para despejar la función arbitraria, deberíamos hacer un trabajo manual. Asimismo, [SymPy](http://www.sympy.org/es/) también nos ofrece la función `classify_pde` la cual nos ayuda a saber con que tipo de [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) estamos tratando. (Recordemos que `pdsolve` solo puede resolver [EDPs](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) de primer orden).

### Separación de variables

Otra forma en que nos podemos ayudar de [SymPy](http://www.sympy.org/es/) para resolver [EDPs](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales), es utilizando el [método de separación de variables](https://es.wikipedia.org/wiki/M%C3%A9todo_de_separaci%C3%B3n_de_variables). La característica esencial de esta técnica es transformar la [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) en un conjunto de [EDOs](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/) (las cuales podemos solucionar con la ayuda de [SymPy](http://www.sympy.org/es/)). De esta forma, la solución requerida de la [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) se expresa como
un producto $u (x, y) = X (x) Y (y) \ne 0$, o como una suma $u (x, y) = X (x) + Y (y)$, donde $X (x)$ e $Y (y)$ son funciones de $x$ e $y$, respectivamente. Muchos de los problemas significativos en [ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) pueden ser resueltos por este método. Para ilustrar como funciona esta técnica, veamos un ejemplo. Vamos a resolver la siguiente [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales).

$$y^2u_x^2 + x^2u_y^2 = (xyu)^2$$

que cumple con la condición $u(x, 0) = 3e^{x^2/4}$.

Podemos entonces asumir que $u(x, y) = f(x) g(y) \ne 0$ es una *solución separable* de ella; por tanto lo reemplazamos en la ecuación para obtener:

$$y^2(f'(x) g(y))^2 + x^2(f(x) g'(y))^2 = x^2 y^2 (f(x) g(y))^2$$

lo que es equivalente a decir;

$$\frac{1}{x^2}\left(\frac{f'(x)}{f(x)}\right)^2 + \frac{1}{y^2}\left(\frac{g'(y)}{g(y)}\right)^2 = 1$$

Luego, ayudándonos de la *constante de separación* $\lambda^2$, podemos separar a esta ecuación en dos [EDOs](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/), del siguiente modo; primero igualamos la ecuación anterior a $\lambda^2$:

$$\frac{1}{x^2}\left(\frac{f'(x)}{f(x)}\right)^2 = 1 - \frac{1}{y^2}\left(\frac{g'(y)}{g(y)}\right)^2 = \lambda^2$$

y luego separamos ambas ecuaciones para obtener:

$$\frac{1}{x}\frac{f'(x)}{f(x)} = \lambda \\ \frac{g'(x)}{yg(y)}= \sqrt{1 - \lambda^2}$$

Ahora podemos utilizar `sympy.dsolve` para resolver ambas [EDOs](http://relopezbriega.github.io/blog/2016/01/10/ecuaciones-diferenciales-con-python/):

```python
# EDO n° 1
edo1 = sympy.Eq((1 / x) * (f(x).diff(x)/f(x)) - z)
edo1
```

$$- z + \frac{\frac{d}{d x} f{\left (x \right )}}{x f{\left (x \right )}} = 0$$

```python
# Resolviendo EDO n° 1
sympy.dsolve(edo1)
```

$$f{\left (x \right )} = C_{1} e^{\frac{x^{2} z}{2}}$$

```python
# EDO n° 2
edo2 = sympy.Eq((f(y).diff(y)) / (y*f(y)) - sympy.sqrt(1 - z**2))
edo2
```

$$- \sqrt{- z^{2} + 1} + \frac{\frac{d}{d y} f{\left (y \right )}}{y f{\left (y \right )}} = 0$$

```python
# Resolviendo EDO n° 2
sympy.dsolve(edo2)
```

$$\left [ f{\left (y \right )} = C_{1} e^{- \frac{1}{2} \sqrt{y^{4} \left(- z^{2} + 1\right)}}, \quad f{\left (y \right )} = C_{1} e^{\frac{1}{2} \sqrt{y^{4} \left(- z^{2} + 1\right)}}\right ]$$

Entonces ahora podemos utilizar estos resultados para armar la solución final a nuestra [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) original, el cual va a ser:

$$u(x, y) = C e^{\frac{\lambda}{2}x^2 + \frac{1}{2}y^2\sqrt{1 - \lambda^2}}$$

En donde $C = C_1 C_2$ es una *constante arbitraria*.

Por último, utilizando la condición $u(x, 0) = 3e^{x^2/4}$, podemos despejar tanto a $C$ ($C=3$) como a $\lambda$ ($\lambda = 1/2$) y arribar a la solución final:

$$u(x, y) = 3 e^{\frac{1}{4}\left(x^2 + y^2\sqrt{3}\right)}$$

Si bien debemos realizar un trabajo manual previo, aun así [SymPy](http://www.sympy.org/es/) sigue siendo de gran ayuda para facilitarnos llegar a la solución final.

### Métodos numéricos - Método de Elementos Finitos

Por último, para cerrar este artículo, veamos como podemos aplicar el [métodos de los elementos finitos](https://es.wikipedia.org/wiki/M%C3%A9todo_de_los_elementos_finitos) (MEF) con [Python](https://www.python.org/). Para esto nos vamos ayudar de la librería [FEniCS](http://fenicsproject.org/), la cual es un *framework* para resolver numéricamente problemas generales de [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) utilizando el [métodos de los elementos finitos](https://es.wikipedia.org/wiki/M%C3%A9todo_de_los_elementos_finitos). Para instalar esta librería en [Ubuntu](http://www.ubuntulinux.org/), pueden utilizar los siguientes comandos:

```
sudo add-apt-repository ppa:fenics-packages/fenics
sudo apt-get update
sudo apt-get install fenics
```
Deben tener en cuenta que por ahora solo funciona con [Python 2](https://www.python.org/downloads/release/python-2711/). La interfaz principal que vamos a utilizar para trabajar con este *framework* nos la proporcionan las librerías `dolfin` y `mshr`; las cuales debemos importar para poder trabajar con el. Una vez importadas, podemos configurar algunos de sus parámetros para lograr el comportamiento deseado.

```python
# importando modulos de fenics
import dolfin
import mshr

dolfin.parameters["reorder_dofs_serial"] = False
dolfin.parameters["allow_extrapolation"] = True
```

El problema que vamos a resolver con la ayuda de [FEniCS](http://fenicsproject.org/), va a ser la siguiente [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales):

$$u_{xx} + u_{yy} = 0$$

Con las siguientes [condiciones de frontera](https://es.wikipedia.org/wiki/Problema_de_condici%C3%B3n_de_frontera):

$$u(x=0) = 3 ; \ u(x=1)=-1 ; \ u(y=0) = -5 ; \ u(y=1) = 5$$

El primer paso en la solución de una [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) utilizando el [métodos de los elementos finitos](https://es.wikipedia.org/wiki/M%C3%A9todo_de_los_elementos_finitos),  es definir una *[malla](https://es.wikipedia.org/wiki/Malla_poligonal)* que describa la discretización del *dominio* del problema. Para este caso, vamos a utilizar la función `RectangleMesh` que nos ofrece [FEniCS](http://fenicsproject.org/).

```python
# Discretizando el problema
N1 = N2 = 75
mesh = dolfin.RectangleMesh(dolfin.Point(0, 0), dolfin.Point(1, 1), N1, N2)
```

El siguiente paso es definir una representación del [espacio funcional](https://es.wikipedia.org/wiki/Espacio_funcional) para las funciones de ensayo y prueba. Para esto vamos a utilizar la clase `FunctionSpace`. El constructor de esta clase tiene al menos tres argumentos: un objeto de *[malla](https://es.wikipedia.org/wiki/Malla_poligonal)*, el nombre del tipo de función base, y el grado de la función base. En este caso, vamos a utilizar la función de [Lagrange](https://es.wikipedia.org/wiki/Interpolaci%C3%B3n_polin%C3%B3mica_de_Lagrange).

```python
# Funciones bases
V = dolfin.FunctionSpace(mesh, 'Lagrange', 1)
u = dolfin.TrialFunction(V)
v = dolfin.TestFunction(V)
```

    DEBUG:FFC:Reusing form from cache.

Ahora debemos definir a nuestra [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) en su [formulación débil](https://es.wikipedia.org/wiki/Formulaci%C3%B3n_d%C3%A9bil_de_una_ecuaci%C3%B3n_diferencial) equivalente para poder tratarla como un problema de [álgebra lineal](https://es.wikipedia.org/wiki/%C3%81lgebra_lineal) que podamos resolver con el [MEF](https://es.wikipedia.org/wiki/M%C3%A9todo_de_los_elementos_finitos).

```python
# Formulación debil de la EDP
a = dolfin.inner(dolfin.nabla_grad(u), dolfin.nabla_grad(v)) * dolfin.dx
f = dolfin.Constant(0.0)
L = f * v * dolfin.dx
```

Por último, solo nos falta definir las [condiciones de frontera](https://es.wikipedia.org/wiki/Problema_de_condici%C3%B3n_de_frontera).

```python
# Defino condiciones de frontera
def u0_top_boundary(x, on_boundary):
    return on_boundary and abs(x[1]-1) < 1e-8

def u0_bottom_boundary(x, on_boundary):
    return on_boundary and abs(x[1]) < 1e-8

def u0_left_boundary(x, on_boundary):
    return on_boundary and abs(x[0]) < 1e-8

def u0_right_boundary(x, on_boundary):
    return on_boundary and abs(x[0]-1) < 1e-8
```

```python
# Definiendo condiciones de frontera de Dirichlet
bc_t = dolfin.DirichletBC(V, dolfin.Constant(5), u0_top_boundary)
bc_b = dolfin.DirichletBC(V, dolfin.Constant(-5), u0_bottom_boundary)
bc_l = dolfin.DirichletBC(V, dolfin.Constant(3), u0_left_boundary)
bc_r = dolfin.DirichletBC(V, dolfin.Constant(-1), u0_right_boundary)

# Lista de condiciones de frontera
bcs = [bc_t, bc_b, bc_r, bc_l]
```

Con esta especificación de las [condiciones de frontera](https://es.wikipedia.org/wiki/Problema_de_condici%C3%B3n_de_frontera), ya estamos listos para resolver nuestra [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) utilizando la función `dolfin.solve`. El vector resultante, luego lo podemos convertir a una matriz de [NumPy](http://www.numpy.org/) y utilizarla para graficar la solución con [Matplotlib](http://matplotlib.org/).

```python
# Resolviendo la EDP
u_sol = dolfin.Function(V)
dolfin.solve(a == L, u_sol, bcs)
```

    DEBUG:FFC:Reusing form from cache.
    DEBUG:FFC:Reusing form from cache.

```python
# graficando la solución
u_mat = u_sol.vector().array().reshape(N1+1, N2+1)

x = np.linspace(0, 1, N1+2)
y = np.linspace(0, 1, N1+2)
X, Y = np.meshgrid(x, y)

fig, ax = plt.subplots(1, 1, figsize=(8, 6))

c = ax.pcolor(X, Y, u_mat, vmin=-5, vmax=5, cmap=mpl.cm.get_cmap('RdBu_r'))
cb = plt.colorbar(c, ax=ax)
ax.set_xlabel(r"$x_1$", fontsize=18)
ax.set_ylabel(r"$x_2$", fontsize=18)
cb.set_label(r"$u(x_1, x_2)$", fontsize=18)
fig.tight_layout()
```

Para profundizar en como utilizar el *framework* [FEniCS](http://fenicsproject.org/), les recomiendo que visiten la [documentación](http://fenicsproject.org/documentation/tutorial/index.html) del sitio, que tienen varios ejemplos.

Con esto concluyo este artículo. Obviamente, no es más que una introducción al fascinante y complejo mundo de las [Ecuaciones en derivadas parciales](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales), cada clase de [EDP](https://es.wikipedia.org/wiki/Ecuaci%C3%B3n_en_derivadas_parciales) es un mundo en sí mismo y quedaron muchos temas sin tratar; los cuales tal vez profundice en algún otro artículo. Espero que les pueda servir como referencia y lo hayan encontrado instructivo.

Saludos!

*Este post fue escrito utilizando Jupyter notebook. Pueden descargar este [notebook](https://github.com/relopezbriega/relopezbriega.github.io/blob/master/downloads/pyPDE.ipynb) o ver su version estática en [nbviewer](http://nbviewer.ipython.org/github/relopezbriega/relopezbriega.github.io/blob/master/downloads/pyPDE.ipynb).*

```python

```


```python
import numpy as np
import threading
import time
from queue import Queue
import sys
import matplotlib.pyplot as plt
import statistics as stat

import sympy as sp
from sympy.solvers.solveset import linsolve

waiting_queue = []
channels = []
all_accepted = 0
all_rejected = 0
all_done = 0
all_requests = 0
all_cancelled = 0

current_loop = 0
next_message_loop = -1

last_value = 0

def print_system_state():
    print("Current loop: {}\n".format(current_loop))
    print("Next request at loop: {}\n".format(next_message_loop))
    print("\n")

    print("All done: {}\n".format(all_done))
    print("All accepted: {}\n".format(all_accepted))
    print("All rejected: {}\n".format(all_rejected))
    print("All cancelled: {}\n".format(all_cancelled))

    print("\n")

    print("Messages ({}):\n".format(len(waiting_queue)))
    for val in list(waiting_queue).__reversed__():
        print("{} ".format(val))

    print("\nProcess:\n")
    for channel in channels:
        print("{}: {}. Done at loop: {}\n".format(channel.number, channel.value, channel.resume_on_loop))

def generate_next_exponential(lamb):
    bsv = np.random.uniform()
    return - 1 / lamb * np.log(1 - bsv)

class Channel():

    def __init__(self, number, on_accepted, on_rejected, on_done):
        self.number = number
        self.on_accepted = on_accepted
        self.on_rejected = on_rejected
        self.on_done = on_done
        self.accept_prob = accept_prob
        self.intensity = channel_intensity

        self.working = False
        self.resume_on_loop = -1
        self.value = None

    def should_accept(self, p):
        return np.random.choice([True, False], p=[p, 1 - p])

    def set_data(self, val):
        dist = generate_next_exponential(self.intensity)
        self.resume_on_loop = current_loop + dist
        self.working = True
        self.value = val

    def on_new_loop(self):
        if self.resume_on_loop != -1 and current_loop >= self.resume_on_loop:
            self.working = False
            self.resume_on_loop = -1
            val = self.value
            self.value = None

            accept = self.should_accept(self.accept_prob)
            if accept:
                self.on_accepted(self.number, val)
            else:
                self.on_rejected(self.number, val)

            self.on_done(self.number, val)

def on_channel_accept(number, val):
    global all_accepted
    all_accepted += 1

    request_live_end[val] = current_loop

def on_channel_reject(number, val):
    global all_rejected, all_cancelled
    all_rejected += 1

    rejected_count[val] = rejected_count.get(val, 0) + 1
    put_in_waiting_queue(val)

def on_channel_done(number, val):
    global all_done, waiting_queue
    all_done += 1

    refresh_channels()

def put_in_waiting_queue(val):
    global waiting_queue, all_cancelled
    if len(waiting_queue) == waiting_queue_limit:
        all_cancelled += 1
        if request_live_start.get(val, None) is not None:
            request_live_end[val] = current_loop
        return

    if request_live_start.get(val, None) is None:
        request_live_start[val] = current_loop

    request_in_queue_start[val] = current_loop + request_in_queue_start.get(val, 0)

    waiting_queue.append(val)
    return

def start_channels():
    running_channels = []

    for i in range(channel_count):
        channel = Channel(i, on_channel_accept, on_channel_reject, on_channel_done)
        running_channels.append(channel)

    return running_channels

def refresh_channels():
    global channels, waiting_queue

    for channel in channels:
        channel.on_new_loop()
        if not channel.working and len(waiting_queue) != 0:
            first = waiting_queue[0]
            waiting_queue = waiting_queue[1:]
            channel.set_data(first)
            request_in_queue_end[first] = current_loop + request_in_queue_end.get(first, 0)

def try_create_new_request():
    global current_loop, next_message_loop, last_value, all_requests

    if current_loop >= next_message_loop:
        last_value += 1
        all_requests += 1
        put_in_waiting_queue(last_value)

        diff = generate_next_exponential(request_intensity)
        next_message_loop = current_loop + diff

finish_state_count = []
finish_state_prob_in_time = []
requests_count_in_system_in_time = []
waiting_queue_len_in_time = []
working_channels_count_in_time = []
request_live_start = {}
request_live_end = {}
request_in_queue_start = {}
request_in_queue_end = {}
rejected_count = {}
unique_requests_in_time = []
accepted_count_in_time = []
prev_accepted = 0

def init_statistic():
    global waiting_queue_limit, finish_state_count, channel_count

    finish_state_count = [0 for i in range(channel_count + waiting_queue_limit + 1)]

def take_statistic():
    global finish_state_count, channels, waiting_queue, prev_accepted

    requests = 0
    working_channels_count = 0
    for channel in channels:
        if channel.working:
            requests += 1

    working_channels_count = requests
    requests += len(waiting_queue)

    finish_state_count[requests] += 1

    finish_state_prob = []
    for c in finish_state_count:
        finish_state_prob.append(c / sum(finish_state_count))
    finish_state_prob_in_time.append(finish_state_prob)

    requests_count_in_system_in_time.append(requests)
    waiting_queue_len_in_time.append(len(waiting_queue))
    working_channels_count_in_time.append(working_channels_count)

    unique_requests = 0
    for channel in channels:
        if rejected_count.get(channel.value, 0) == 0:
            unique_requests += 1
    for val in waiting_queue:
        if rejected_count.get(val, 0) == 0:
            unique_requests += 1
    unique_requests_in_time.append(unique_requests)

    accepted_count_in_time.append(all_accepted)

def make_finish_state_statistic(to_print):
    global finish_state_count, channel_count

    s = sum(finish_state_count)
    max_diff = 0
    practice_finish_state = []
    theor_finish_state = []
    for i in range(len(finish_state_count)):
        pract = finish_state_count[i] / s
        theor = get_finish_n_state_statistic(i)
        diff = np.abs(pract - theor)

        practice_finish_state.append(pract)
        theor_finish_state.append(theor)

        if diff > max_diff:
            max_diff = diff

        if to_print:
            print("State {}: {} - {}, {}".format(i, pract, theor, diff))

    if to_print:
        print("Max diff: {}".format(max_diff))

    return practice_finish_state, theor_finish_state, max_diff

def get_finish_n_state_statistic(i):
    return theor_state_prob[i]

    # changed_channel_intensity = channel_intensity * accept_prob
    # if i != channel_count + waiting_queue_limit:
    #     changed_channel_intensity *= accept_prob
    #
    # if i != channel_count * waiting_queue_limit - 1:
    #     changed_channel_intensity =
    #
    # if i > channel_count:
    #     theor = request_intensity ** i / (np.math.factorial(channel_count) * channel_count ** (i - channel_count) * changed_channel_intensity ** i)
    # else:
    #     theor = request_intensity ** i / (np.math.factorial(i) * changed_channel_intensity ** i)
    #
    # f = 0
    # for j in range(channel_count + 1):
    #     f += request_intensity ** j / (np.math.factorial(j) * changed_channel_intensity ** j)
    #
    # s = 0
    # for j in range(channel_count + 1, channel_count + waiting_queue_limit + 1):
    #     s += request_intensity ** j / (np.math.factorial(channel_count) * channel_count ** (j - channel_count) * changed_channel_intensity ** j)
    #
    # return theor / (f + s)

def make_bandwidth_statistic(pract_finish_state, theor_finish_state, to_print):
    print('')

    pract_relative_bandwidth = 1 - pract_finish_state[-1]
    theor_relative_bandwidth = 1 - theor_finish_state[-1]
    pract_absolute_bandwidth = request_intensity * pract_relative_bandwidth
    theor_absolute_bandwidth = request_intensity * theor_relative_bandwidth

    print('Relative bandwidth: {} - {}, {}'.format(pract_relative_bandwidth, theor_relative_bandwidth, abs(pract_relative_bandwidth - theor_relative_bandwidth)))
    print('Absolute bandwidth: {} - {}, {}'.format(pract_absolute_bandwidth, theor_absolute_bandwidth, abs(pract_absolute_bandwidth - theor_absolute_bandwidth)))

def make_cancellation_statistic():
    print('')

    pract_cancellations = all_cancelled / all_requests
    theor_cancellations = theor_state_prob[-1] + theor_state_prob[-2]*(1 - accept_prob)

    print("Cancellations: {} - {}, {}".format(pract_cancellations, theor_cancellations, abs(pract_cancellations - theor_cancellations)))

def make_average_waiting_queue_length(to_print):
    pract_average = stat.mean(waiting_queue_len_in_time)
    theor_average = 0
    for i in range(1, waiting_queue_limit + 1):
        theor_average += i * theor_state_prob[channel_count + i]

    if to_print:
        print('')
        print("Average waiting queue length: {} - {}, {}".format(pract_average, theor_average, abs(pract_average - theor_average)))
    return theor_average, pract_average

def make_average_working_channels_count(to_print):
    pract_average = stat.mean(working_channels_count_in_time)
    theor_average = 0
    for i in range(channel_count + 1):
        theor_average += i * theor_state_prob[i]
    for i in range(1, waiting_queue_limit + 1):
        theor_average += channel_count * theor_state_prob[channel_count + i]

    if to_print:
        print('')
        print("Average working channels count: {} - {}, {}".format(pract_average, theor_average, abs(pract_average - theor_average)))
    return theor_average

def make_average_requests_in_system_count(to_print, average_waiting, average_channels):
    pract_average = stat.mean(requests_count_in_system_in_time)
    theor_average = average_waiting + average_channels

    if to_print:
        print('')
        print("Average requests count in system: {} - {}, {}".format(pract_average, theor_average, abs(pract_average - theor_average)))
    return theor_average, pract_average

def make_average_request_live_in_system(to_print, average_request_system_count, average_request_system_count_pract):
    request_live_diff = []
    for val, start in request_live_start.items():
        request_live_diff.append(request_live_end[val] - start)

    pract_average = average_request_system_count_pract / request_intensity
    theor_average = average_request_system_count / (request_intensity)

    if to_print:
        print('')
        print("Average requests live time in system: {} - {}, {}".format(pract_average, theor_average,
                                                                 abs(pract_average - theor_average)))

    return pract_average

def make_average_request_live_in_queue(average_request_queue_count, average_request_queue_count_pract):
    print('')

    request_live_diff = []
    for val, start in request_in_queue_start.items():
        if request_in_queue_end.get(val, None) is not None:
            request_live_diff.append(request_in_queue_end[val] - start)

    pract_average = average_request_queue_count_pract / (request_intensity)
    theor_average = average_request_queue_count / (request_intensity)

    print("Average requests live time in queue: {} - {}, {}".format(pract_average, theor_average,
                                                                 abs(pract_average - theor_average)))

def make_average_uniqe_requests(average_request_system_count):
    print('')

    pract_average = stat.mean(unique_requests_in_time)
    theor_average = average_request_system_count * accept_prob

    print("Average uniqe requests in system: {} - {}, {}".format(pract_average, theor_average,
                                                                 abs(pract_average - theor_average)))

def reset():
    global waiting_queue, channels, all_accepted, all_rejected, all_done, all_cancelled, current_loop, next_message_loop
    global finish_state_count, finish_state_prob_in_time, requests_count_in_system_in_time, waiting_queue_len_in_time
    global working_channels_count_in_time, request_live_start, request_live_end, request_in_queue_start
    global request_in_queue_end, rejected_count, unique_requests_in_time, accepted_count_in_time

    waiting_queue = []
    channels = []
    all_accepted = 0
    all_rejected = 0
    all_done = 0
    all_cancelled = 0
    current_loop = 0
    next_message_loop = -1

    finish_state_count = []
    finish_state_prob_in_time = []
    requests_count_in_system_in_time = []
    waiting_queue_len_in_time = []
    working_channels_count_in_time = []
    request_live_start = {}
    request_live_end = {}
    request_in_queue_start = {}
    request_in_queue_end = {}
    rejected_count = {}
    unique_requests_in_time = []
    accepted_count_in_time = []

def run(loop_limit, to_print=True, finish_state_statistic_only=False, system_live_time_only=False):
    global channels, current_loop, next_message_loop

    reset()

    init_statistic()
    channels = start_channels()

    if to_print:
        print_system_state()

    diff = generate_next_exponential(request_intensity)
    next_message_loop = current_loop + diff

    while True:
        current_loop += 1
        if current_loop == loop_limit:
            break

        refresh_channels()

        take_statistic()

        try_create_new_request()

        #print_system_state()
        #time.sleep(1)

    for val in waiting_queue:
        request_live_end[val] = current_loop
        request_in_queue_end[val] = current_loop + request_in_queue_end.get(val, 0)

    for channel in channels:
        if channel.working:
            request_live_end[channel.value] = current_loop

    if finish_state_statistic_only:
        return make_finish_state_statistic(to_print)

    if system_live_time_only:
        average_waiting = make_average_waiting_queue_length(to_print)
        average_channels = make_average_working_channels_count(to_print)
        average_system, average_system_pract = make_average_requests_in_system_count(to_print, average_waiting, average_channels)
        return make_average_request_live_in_system(to_print, average_system, average_system_pract)

    pract_finish_state, theor_finish_state, max_diff = make_finish_state_statistic(to_print)
    make_bandwidth_statistic(pract_finish_state, theor_finish_state, to_print)
    make_cancellation_statistic()
    average_waiting, average_waiting_pract = make_average_waiting_queue_length(to_print)
    average_channels = make_average_working_channels_count(to_print)
    average_system, average_system_pract = make_average_requests_in_system_count(to_print, average_waiting, average_channels)
    make_average_request_live_in_system(to_print, average_system, average_system_pract)
    make_average_request_live_in_queue(average_waiting, average_waiting_pract)

def run_max_diff_finish_state_test():
    x_array = range(100)
    y_array = []
    for x in x_array:
        y_array.append(run(loop_limit=10000, to_print=False, finish_state_statistic_only=True)[2])

    plt.plot(x_array, y_array)
    plt.show()

def run_live_time_test():
    x_array = range(10000, 150000, 10000)
    y_array = []
    for x in x_array:
        print('Done', x)
        y_array.append(run(loop_limit=x, to_print=False, system_live_time_only=True))

    plt.plot(x_array, y_array)
    plt.show()

def show_stationary(states = None):
    x = range(len(finish_state_prob_in_time))
    if states is None:
        states = range(len(finish_state_prob_in_time[0]))
    for state in states:
        y = []
        ty = []
        for t in range(len(finish_state_prob_in_time)):
            y.append(finish_state_prob_in_time[t][state])
            ty.append(theor_state_prob[state])
        plt.plot(x, y)
        plt.plot(x, ty)
    plt.show()

def run_max_diff_finish_state_to_loop_limit_test():
    x_array = range(100, 1000, 100)
    y_array = []
    for x in x_array:
        test = []
        for i in range(100):
            test.append(run(loop_limit=x, to_print=False, finish_state_statistic_only=True)[2])
        y_array.append(stat.mean(test))

    plt.plot(x_array, y_array)
    plt.show()

def solve_theor_states_prob(with_addition=True):
    symbols = [sp.Symbol('p' + str(i)) for i in range(channel_count + waiting_queue_limit + 1)]

    system = []
    system.append(-request_intensity * symbols[0] + accept_prob * channel_intensity * symbols[1])
    for i in range(1, channel_count):
        system.append(
            request_intensity * symbols[i - 1] - (request_intensity + i * accept_prob * channel_intensity) * symbols[
                i] + (i + 1) * accept_prob * channel_intensity * symbols[i + 1])
    for i in range(channel_count, channel_count + waiting_queue_limit - 1):
        system.append(
            request_intensity * symbols[i - 1] - (request_intensity + channel_count * accept_prob * channel_intensity) *
            symbols[i] + channel_count * accept_prob * channel_intensity * symbols[i + 1])

    system.append(
        request_intensity * symbols[-3] - (request_intensity + channel_count * accept_prob * channel_intensity) *
        symbols[-2] + channel_count * channel_intensity * symbols[-1])

    if with_addition:
        system.append(request_intensity * symbols[-2] - channel_count * channel_intensity * symbols[-1])

    last = symbols[0]
    for i in range(1, channel_count + waiting_queue_limit + 1):
        last += symbols[i]
    last -= 1
    system.append(last)

    print(system)

    res = list(linsolve(system, *symbols))
    if len(res) == 0 and with_addition:
        return solve_theor_states_prob(False)
    return list(res[0])

def calculate_theor_states_prob():
    theor_state_prob = [0] * (channel_count + waiting_queue_limit + 1)

    if accept_prob == 0:
        theor_state_prob[-2] = channel_count * channel_intensity / (channel_count * channel_intensity + request_intensity)
        theor_state_prob[-1] = 1 - theor_state_prob[-2]
    else:
        first_sum = 0
        for i in range(channel_count + 1):
            first_sum += request_intensity ** i / (np.math.factorial(i) * (channel_intensity * accept_prob) ** i)
        second_sum = 0
        for i in range(channel_count + 1, channel_count + waiting_queue_limit):
            second_sum += request_intensity ** i / (np.math.factorial(channel_count) * channel_count ** (i - channel_count) * (channel_intensity * accept_prob) ** i)
        res_sum = first_sum + second_sum + request_intensity ** (channel_count + waiting_queue_limit) / (np.math.factorial(channel_count) * channel_count ** waiting_queue_limit * channel_intensity ** (channel_count + waiting_queue_limit))

        theor_state_prob[0] = 1 / res_sum
        for i in range(1, channel_count + 1):
            theor_state_prob[i] = request_intensity ** i / (np.math.factorial(i) * (channel_intensity * accept_prob) ** i) * theor_state_prob[0]

        for i in range(channel_count + 1, channel_count + waiting_queue_limit):
            theor_state_prob[i] = request_intensity ** i / (np.math.factorial(channel_count) * channel_count ** (i - channel_count) * (request_intensity * accept_prob) ** i) * theor_state_prob[0]

        theor_state_prob[channel_count + waiting_queue_limit] = request_intensity ** (channel_count + waiting_queue_limit) / (np.math.factorial(channel_count) * channel_count ** waiting_queue_limit * request_intensity ** (channel_count + waiting_queue_limit) * accept_prob ** (channel_count + waiting_queue_limit - 1)) * theor_state_prob[0]

    return theor_state_prob

request_intensity = 0.01
channel_intensity = 0.005
channel_count = 2
waiting_queue_limit = 3
accept_prob = 0.3

theor_state_prob = calculate_theor_states_prob()
run(10000)

plt.plot(range(len(accepted_count_in_time)), accepted_count_in_time)

plt.show()

run_live_time_test()

show_stationary()
```

```python

```


# Erlotinib PK Analysis

Reproduces the Erlotinib PK analyses reported in [[1](ref)] based on the data also reported [[1](ref)].

 [1]  Eigenmann et. al., Combining Nonclinical Experiments with Translational PKPD Modeling to Differentiate Erlotinib and Gefitinib, Mol Cancer Ther (2016)

## The study overview:

LXFA 677 patient-derived tumours were implanted an mice and then treated with three different Erlotinib dosing startegies.

- Startegy one: daily oral dose of 100 mg/kg/day from day 3-8 and 14-16 (some adverse effects forced a drug holiday)
- Startegy two: daily oral dose of 25 mg/kg/day from day 3-16
- Startegy two: daily oral dose of 6.25 mg/kg/day from day 3-16

EDTA Plasma samples were taken

- Startegy one: on day 14
- Startegy two: on day 10 and 16
- Startegy two: on day 10 and 16

## 1. Reproduce PK Analysis

### 1.1 Explore data

Measurements were

#### 1.1.2 Import data

```python
import os
import pandas as pd

# Import data
path = os.getcwd()
data_raw = pd.read_csv(path + '/data/PK_LXF_erlo.csv', sep=';')

# Filter relevant information
data = data_raw[['#ID', 'TIME', 'Y', 'DOSE GROUP', 'DOSE', 'BW']]

# Convert TIME and Y to numeric values (currently strings)
data['TIME'] = pd.to_numeric(data['TIME'], errors='coerce')
data['Y'] = pd.to_numeric(data['Y'], errors='coerce')

# Sort TIME values (for plotting convenience)
data.sort_values(by='TIME', inplace=True)

# Filter NaNs
data = data[data['Y'].notnull()]

# Show data
data
```

      #ID
      TIME
      Y
      DOSE GROUP
      DOSE
      BW

      412
      139
      9.992361
      0.952
      6.25
      .
      22.8

      141
      52
      9.995833
      8.060
      25.00
      .
      25.9

      435
      161
      10.010417
      551.000
      6.25
      .
      23.9

      187
      108
      10.010417
      2380.000
      25.00
      .
      25.1

      118
      34
      10.018750
      3500.000
      25.00
      .
      27.9

      391
      131
      10.019444
      1290.000
      6.25
      .
      23.3

      368
      119
      10.043056
      1050.000
      6.25
      .
      21.1

      164
      91
      10.043750
      4340.000
      25.00
      .
      26.5

      233
      129
      10.103472
      2270.000
      25.00
      .
      27.5

      302
      31
      10.103472
      848.000
      6.25
      .
      25.3

      210
      122
      10.208333
      828.000
      25.00
      .
      23.4

      345
      98
      10.208333
      175.000
      6.25
      .
      25.9

      458
      162
      10.411806
      55.700
      6.25
      .
      22.6

      256
      163
      10.415278
      1350.000
      25.00
      .
      23.4

      324
      38
      11.000000
      0.952
      6.25
      .
      26.8

      281
      167
      11.002083
      0.952
      25.00
      .
      26.6

      60
      67
      14.013194
      4410.000
      100.00
      .
      26.5

      11
      6
      14.020833
      8460.000
      100.00
      .
      23.0

      40
      28
      14.039583
      15700.000
      100.00
      .
      23.4

      100
      134
      14.104861
      5980.000
      100.00
      .
      22.8

      80
      128
      14.415972
      6100.000
      100.00
      .
      29.7

      123
      34
      15.993750
      0.952
      25.00
      .
      27.1

      440
      161
      15.997917
      0.952
      6.25
      .
      26.1

      398
      131
      16.010417
      1370.000
      6.25
      .
      25.0

      263
      163
      16.011111
      3140.000
      25.00
      .
      23.5

      375
      119
      16.022222
      1970.000
      6.25
      .
      22.1

      286
      167
      16.022917
      3000.000
      25.00
      .
      27.4

      309
      31
      16.040278
      1240.000
      6.25
      .
      27.8

      217
      122
      16.040972
      3540.000
      25.00
      .
      23.5

      329
      38
      16.102083
      1290.000
      6.25
      .
      27.1

      171
      91
      16.102778
      1970.000
      25.00
      .
      26.7

      240
      129
      16.209722
      1070.000
      25.00
      .
      27.9

      465
      162
      16.212500
      107.000
      6.25
      .
      24.7

      194
      108
      16.416667
      326.000
      25.00
      .
      27.1

      352
      98
      16.418056
      32.800
      6.25
      .
      27.5

      419
      139
      16.993750
      0.952
      6.25
      .
      23.8

      148
      52
      16.999306
      0.952
      25.00
      .
      26.4

#### 1.1.2 Sort data into dosing groups

Each dose group has been dosed daily from day 2 to 16. PK samples have been taken on day 10 and 16.

```python
# Get group identifiers
groups = data['DOSE GROUP'].unique()

# Sort into groups
data_one = data[data['DOSE GROUP'] == groups[0]]
data_two = data[data['DOSE GROUP'] == groups[1]]
data_three = data[data['DOSE GROUP'] == groups[2]]

# Show different dose groups
groups
```

    array([  6.25,  25.  , 100.  ])

#### 1.1.3 Visualise dose group one

```python
import matplotlib.pyplot as plt

# Display dose group
print(groups[0])

# Get unique animal IDs
ids = data_one['#ID'].unique()

# Plot measurements
fig = plt.figure(figsize=(8, 6))

for i in ids:
    # Mask for individual
    mask = data_one['#ID'] == i
    time = data_one[mask]['TIME']
    volume = data_one[mask]['Y']

    # Filter out Nan values
    mask = volume.notnull()
    time = time[mask]
    volume = volume[mask]

    # Create semi log plot
    plt.scatter(time, volume, edgecolor='black')

# Set x lim
plt.xlim([-1.5, 31.5])

# Set y lim (to match with other PK figures)
plt.ylim([5E-1, 2E4])

# Set y axis to logscale
plt.yscale('log')

# Label axes
plt.xlabel('Time in [day]')
plt.ylabel('EDTA plasma concentration in [mg/L]')

plt.show()
```

#### 1.1.4 Visualise dose group two

```python
import matplotlib.pyplot as plt

# Display dose group
print(groups[1])

# Get unique animal IDs
ids = data_two['#ID'].unique()

# Plot measurements
fig = plt.figure(figsize=(8, 6))

for i in ids:
    # Mask for individual
    mask = data_two['#ID'] == i
    time = data_two[mask]['TIME']
    volume = data_two[mask]['Y']

    # Filter out Nan values
    mask = volume.notnull()
    time = time[mask]
    volume = volume[mask]

    # Create semi log plot
    plt.scatter(time, volume, edgecolor='black')

# Set x lim
plt.xlim([-1.5, 31.5])

# Set y lim (to match with other PK figures)
plt.ylim([5E-1, 2E4])

# Set y axis to logscale
plt.yscale('log')

# Label axes
plt.xlabel('Time in [day]')
plt.ylabel('Plasma concentration in [mg/L]')

plt.show()
```

#### 1.1.5 Visualise dose group two

```python
import matplotlib.pyplot as plt

# Display dose group
print(groups[2])

# Get unique animal IDs
ids = data_three['#ID'].unique()

# Plot measurements
fig = plt.figure(figsize=(8, 6))

for i in ids:
    # Mask for individual
    mask = data_three['#ID'] == i
    time = data_three[mask]['TIME']
    volume = data_three[mask]['Y']

    # Filter out Nan values
    mask = volume.notnull()
    time = time[mask]
    volume = volume[mask]

    # Create semi log plot
    plt.scatter(time, volume, edgecolor='black')

# Set x lim
plt.xlim([-1.5, 31.5])

# Set y lim (to match with other PK figures)
plt.ylim([5E-1, 2E4])

# Set y axis to logscale
plt.yscale('log')

# Label axes
plt.xlabel('Time in [day]')
plt.ylabel('Plasma concentration in [mg/L]')

plt.show()
```

### 1.2 Build Structural Model (pints.ForwardModel)

#### 1.2.1 Build Myokit Model

```python
import myokit

from pkpd import model as m

# Build 1 compartmental PK model with default parameters
model = m.create_one_comp_pk_model()

# Validate model
model.validate()

# Check units
model.check_units(mode=myokit.UNIT_TOLERANT)

# Print model
print(model.code())
```

    [[model]]
    # Initial values
    central.amount = 0

    [central]
    dot(amount) = -k_e * amount
        in [mg]
    conc = amount / volume
        in [mg/L]
    k_e = 0
        in [1/day]
    time = 0 bind time
        in [day]
    volume = 1
        in [L]

#### 1.2.2 Set oral administration

```python
import myokit

class DosingRegimen(object):
    def __init__(self):
        self._amount = None
        self._duration = None
        self._periodicity = 0
        self._multiplier = 0
        self._dosing_regimen = None
        self._indirect_admin = False
        self._dosing_compartment = 'central'

    def __call__(self, model, amount, duration=None, periodicity=0, multiplier=0):
        """
        Returns the myokit.Model with appropriate structural adjustments for dosing,
        and a myokit.Protocol with the specified schedule.
        """
        if

    def set_amdin(indirect=False):
        self.indirect_admin = indirect

def create_dosing_regimen(model, compartment, administration, amount, duration=None, periodicity=0, multiplier=0):
    """
    Returns a myokit.Protocol object with the specified dosing regimen, and alters the structural model,
    by addition of bolus injection or dosing compartment.

    model -- myokit.Model
    compartment -- compartment with bolus injection or connection to dosing compartment
    administration -- type of administration: Injection into compartment or dosing compartment
    amount -- Applied dose
    duration -- How long did it take to apply the dose, if None duration is set to the smallest numerically stable duration,
                i.e. such that duration > 1e-6 and amount / duration < some upper value.
    periodicity -- In which intervals is dose applied. If 0 dose is applied once.
    multplier -- How often is dose applied? If 0, it's applied once for non-period and indefinitely for periodic schedules.
    """
    if not isinstance(model, myokit.Model):
        raise ValueError
    if not model.has_component(compartment):
        raise ValueError
    if administration != 'dosing compartment':
        raise NotImplementedError

    # Set dosing compartment
    dose_comp = model.get(compartment)
    if administration == 'dosing compartment':
        dose_comp = model.add_component('dose')

    # Add dose rate and regimen to dose compartment
    dose_rate = dose_comp.add_variable('dose_rate')
    regimen = dose_comp.add_variable('regimen')

    # define dosing regimen
    duration = 0.001  # [1 / d] how long does it take for dose to be in dosing compartment?
    dose_rate.set_rhs(25 * 0.2 / duration)  # 100 mg / kg / d let's estimate weight of mouse 0.2 kg
    dosing_regimen = myokit.Protocol()
    dosing_regimen.schedule(level=1, start=0, duration=duration)
```

#### 1.2.3 Build pints model

```python
# Build pints model
pass
```

### 1.3 Build population model

This is the Hierarchical Log Prior of the model

\begin{align}
\text{p}(\psi | \theta )\text{p}(\theta )
\end{align}

Should take a number of means and variances to construct $\text{p}(\theta )$ (Gaussian for means of $\text{p}(\psi | \theta )$ and half cauchy for variances of $\text{p}(\psi | \theta )$.

Think about whether this can be generalised for any structure.

Potentially best way: build a Hierarchical Log Posterior by combining a Likelihood and prior similar to PosteriorLogLikelihood, but appending the input params of the prior instead to the model params (this is the p(y|psi)p(psi|theta) bit). You have to provide posteriors for the population level paramers.

In that way a posterior can be constructed by passing any problem, any population distribution and any priors of the population distirbution parameters!

```python
# Build hierarchical model
pass
```

### 1.4 Build error model

This can be done by just using Loglikelihood from pints

```python
pass
```

### Run inference

```python
pass
```

### Visualise prediction

```python

```