# USNCCM15 Short Course

# SC15-009: Recent Advances in Physics-Informed Deep Learning

# Hands-on tutorial on solving PDEs on graphs with physics-informed neural networks
## Instructor: Paris Perdikaris, pgp@seas.upenn.edu
## Code repository: https://github.com/PredictiveIntelligenceLab/USNCCM15-Short-Course-Recent-Advances-in-Physics-Informed-Deep-Learning

**The goal of this tutorial is to provide a step-by-step introduction to Physics Informed Neural Networks (PINNs) for solving PDEs on graph topologies. As an example, we will focus on coservation laws describing blood flow in one-dimensional  arterial networks. Specifically, we will see how PINNs allow us to blend non-invasive measurement techniques such 4D flow MRI with computational fluid dynamics models, towards providing a cheap and effective tool for characterizing velocity and pressure pulse wave propagation in human systemic arteries.**

### Motivation
Recent advances in clinical measurement and computational modeling techniques introduce new capabilities for monitoring the human cardiovascular system from different perspectives such as disease surveys \cite{rose1982cardiovascular}, bio-medical image processing \cite{kak2002principles,haris2014technique}, computational mathematics \cite{formaggia2010cardiovascular, figueroa2006coupled, reymond2009validation,grinberg2011modeling}, bio-physics \cite{stefanovska1999physics, ma2018relation}, etc. These studies reveal the crucial role played by blood flow, arterial wall mechanics and pressure wave propagation, and how their interplay directly characterizes the functionality of the cardiovascular system both in health and disease (e.g., hypertensive disorders \cite{o1995mechanical}).

Understanding the inner workings of the cardiovascular system has been central to many studies involving clinical, interventional or computational approaches. For instance, Chan {\it et. al.} \cite{chan2007hybrid} proposed placing sensors in the human body  to achieve real time measuring of arterial velocity and wall displacement to monitor the health of patients. Although the collected {\it in-vivo} measurements can be highly accurate, such interventional techniques are sometimes expensive and suffer from limitations that are not easy to address, e.g., difficulties of placing probes in cerebral or uteroplacental arteries \cite{kett2002adverse}. These limitations motivate the use of non-invasive measurement techniques such as bio-medical imaging, advances in which currently define the clinical standard of care \cite{edelstein1980spin, antiga2008image, yushkevich2006user, SCI:Seg3D}. To this end, one of the most commonly used techniques is Doppler ultrasound velocimetry \cite{aaslid1982noninvasive} that enables the recovery of blood velocity wave propagation. Using information related to the  pulsatility of the flow \cite{mitchell2011arterial}, clinicians are able to infer quantitative information about the pressure in the arterial vessels, e.g., large pulsatility is caused by large pressure \cite{ma2018relation}. Rather than Doppler ultrasound, more recent studies leverage advances in 4D flow Magnetic Resonance Imaging (MRI) \cite{markl20124d} to recover the full three-dimensional velocity flow field. This method provides a more detailed representation of the flow, and also a larger spatial coverage that can capture quantities like vessel tortuosity in a more accurate manner. Moreover, structural characteristics like the cross-sectional area and vessel diameter can be recovered by segmenting 2D cine images \cite{plein2001steady} from MRI measurements. However, critical variables such as the pressure cannot be directly measured by a non-invasive technique \cite{barker1985non}, and accurate measurements are only accessible by inserting a catheter equipped with sensors inside the vessel of interest.

### Open challenges
These difficulties of directly measuring quantities of interest like the pressure in an {\it in-vivo} and non-invasive manner have motivated the use of computer simulations and computational fluid dynamics models to predict them {\it in-silico}. Advances in algorithms and computing resources now allow us to perform detailed flow simulations in complex patient-specific arterial topologies using three-dimensional (3D) and/or one-dimensional (1D) formulations of the unsteady Navier-Stokes equations \cite{formaggia2010cardiovascular, figueroa2006coupled, reymond2009validation,grinberg2011modeling, urquiza2006multidimensional, sherwin2003one}. Such tools have been successfully validated against both {\it in-vitro} experiments \cite{matthys2007pulse} as well as {\it in-vivo} clinical data \cite{revie2013validation}, and provide a valuable platform for parametric sensitivity studies \cite{chen2013simulation}. Despite their predictive power, computational models have still not made their way into clinical practice primarily due to their high computational cost and the tedious procedures needed for their practical deployment, e.g., mesh generation, parameter calibration, etc. For instance, such models require the precise subscription of boundary conditions that effectively capture the downstream flow dynamics in small arteries and arterioles via the use of Windkessel or structured tree models \cite{westerhof2009,grinberg2008outflow,olufsen1999structured,perdikaris2015effective}. Inaccurate calibration of the parameters associated with these boundary conditions is often the cause of brittleness in the resulting predictions, thus limiting the translational impact of computational models to the clinical domain.

### Modeling blood flow in arterial networks with PINNs
In contrast to traditional computational fluid dynamics models, PINNs do not suffer from the cumbersome generation of computational meshes, nor they requires the precise prescription of boundary conditions. Instead, they can directly use noisy and scattered measurements obtained by medical imaging at points other than the boundaries to accurately predict the pressure and recover flow information. Furthermore, as flow conditions in general do not vary greatly from one patient to another, pre-trained neural network models can be rapidly adapted to a new patient case. After a PINN model is trained, all quantities of interest (blood velocity, wall displacement and pressure) can be predicted for every temporal or spatial point in the network.

# Modeling blood flow in arterial networks
Pulse wave propagation in arterial networks can be effectively modeled using one-dimensional (1D) reduced order models \cite{sherwin2003one, reymond2009validation, reymond2011validationPS}. In order to achieve the order reduction, a series of assumptions need to be made  \cite{sherwin2003computational}. First, we assume that the local curvature is small enough such that the geometry can be described using a Cartesian coordinate $x$. Moreover, the fluid is incompressible and Newtonian, since we are considering geometries consisting of large arteries, so the density and dynamic viscosity are constant. Lastly, the structural properties of the artery are preserved at a cross-section. Following \cite{sherwin2003computational, formaggia2010cardiovascular} we consider a reduced form of the incompressible Navier-Stokes equations. Conservation of mass and momentum can be expressed as a hyperbolic conservation law \cite{sherwin2003computational} that describes the evolution of blood velocity and cross-sectional area \cite{sherwin2003one, formaggia2010cardiovascular}. In order to close the system, a third equation accounting for the relation between the pressure and the area is used, which is derived by assuming a thin wall tube and using Laplace's law \cite{sherwin2003computational}.
This reduced order model provides an accurate representation of the underlying transport processes and its effectiveness in correctly capturing pulse wave propagation phenomena has been validated against both {\it in-vitro} and {\it in-vivo} data \cite{matthys2007pulse,reymond2009validation, reymond2011validationPS}.

The system derived by the above analysis takes the form \cite{sherwin2003one, formaggia2010cardiovascular}

\begin{equation}
\begin{split}
       & \frac{\partial A}{\partial t} + \frac{\partial A u }{\partial x} = 0, \\
    & \frac{\partial u }{\partial t} + u \frac{\partial u}{\partial x} + \frac{1}{\rho} \frac{\partial p }{\partial x}= 0, \\
    & p = p_{ext} + \beta ( \sqrt{A} - \sqrt{A_0}),
\end{split}
\label{equ:systemOfEquations}
\end{equation}

where  $A(x,t)$, $u(x,t)$ and $p(x,t)$ represent the cross-sectional area, the velocity and pressure, respectively. In addition, $x$ and $t$ represent the spatial and temporal coordinate in each vessel, respectively. Also, $\beta = \dfrac{\sqrt{\pi} h_0 E}{(1 - \nu^2)A_0}$ and $A_0$ denotes the vessel's sectional area at equilibrium, $h_0$ the wall thickness, $E$ the Young's modulus, $p_{ext}$ the external pressure, $\nu$ the Poisson ratio and $\rho$ is the density of the blood. Values for $h_0$, $E$ and $\nu$ are typically mined from the literature, while $p_{ext}$ and $A_0$ are typically set to the diastolic pressure and wall displacement, respectively.

## PINNs formulation
Physics informed neural networks parametrize the solution of partial differential equations using a deep neural network that is trained to match the measurements of the system, while being constrained to approximately satisfy the underlying physical  laws. In our particular case, we define one neural network $f(x,t; \theta^j)$ to represent the solution of the above conservation laws for vessel $\# j$ in our arterial network. Here $\theta^j$ denotes the parameters of the neural network for vessel $\# j$.

In correspondence to this setup, the following residuals can be defined:
\begin{equation}
\begin{split}
       & r_{A}(x,t) :=  \frac{\partial A}{\partial t} + \frac{\partial A u }{\partial x}, \\
    & r_{u}(x,t) :=  \frac{\partial u }{\partial t} + u \frac{\partial u}{\partial x} + \frac{1}{\rho} \frac{\partial p }{\partial x},\\
    & r_{p}(x,t) :=  p - p_{ext} - \beta ( \sqrt{A} - \sqrt{A_0})
\end{split}
\label{equ:PINNS_residual}
\end{equation}

These residuals can be then used as constrains during the training of the neural networks $f(x,t; \theta^j)$ in order to encourage them to produce physically consistent predictions. In addition to minimizing the residuals, the neural networks are also constrained to fit any available non-invasive clinical measurements that may be noisy and scattered in space and time.

## Importance of non-dimensionalization

In the above system, the order of magnitude of the different physical quantities, velocity, cross-sectional area and pressure, have a significant relative difference, e.g., $ P\sim 10^{6}$ $ Pa$, $A  \sim 10^{-5}$ $m^2$ and $u \sim 10$ $m/s$, which casts great difficulty on the training of the neural network  \cite{glorot2010understanding}. Consequently, a direct application of the PINN methodology cannot handle this issue. For overcoming this problem, we employ a  non-dimensionalization and normalization technique with the purpose of scaling the input and the output of the neural networks in a proper scale (e.g., $(\hat{A}, \hat{u}, \hat{p}, x_*, t_*) \in [0, 1]$) and normalizing the spatial and temporal coordinates to have zero mean and unit variance for training the neural networks more efficiently. For the purpose of non-dimensionalization we introduce the characteristic variables, which are commonly used in multi-scale physics \cite{famiglietti1994multiscale} in order to simplify the equations. For this problem we need a characteristic length $L$ and a characteristic velocity $U$.

We will choose the characteristic length to be the square root of the mean of the equilibrium cross-sectional area of the network vessels. In order to choose the characteristic velocity we make use of the physiological condition that the wave speed in a vessel has to be one order of magnitude larger than the length \cite{sherwin2003one}, given that, in the normalized length case $c = 10$. Thus:

$$L = \sqrt{ \frac{1}{N} \sum_{j=1}^{D}= (A^j_0)}  ,\quad\quad\quad U = 10,$$
where $j = 1, ... , D$. At this point we define the quantities:
\begin{equation}\label{equ:non_dimensionalization}
    \hat{u} = \dfrac{u}{U},\quad \hat{A} = \dfrac{A}{A^o},\quad \hat{p} = \dfrac{p}{p_0},\quad x_* = \dfrac{x}{L},\quad t_* = \dfrac{t}{T},
\end{equation}
where $p_0 = \rho U^2$, $T = \dfrac{L}{U}$ and $A^o = L^2$.

Now, the input of the neural network has support $(x_*,t_*) \in [0,1]$. It is shown that normalizing the input to have zero mean and unit variance makes the training of the neural network more efficient as it prevents gradient saturation and provides stable updates \cite{glorot2010understanding}.
In this step, we will apply this technique to $x_*$ and $t_*$, in the vessel $\# j$, and get:

\begin{equation}\label{equ:normalization}
    \hat{x}^j = \frac{x_*^j - \mu^j_{x_*}}{\sigma^j_{x_*}},\quad \hat{t} = \frac{t_* - \mu_{t^*}}{\sigma_{t_*}},
\end{equation}
where $\mu^j_{x_*}$, $\mu_{t_*}$ the mean value and $\sigma^j_{x_*}$, $\sigma_{t_*}$ the standard deviation of the spatial and temporal coordinates for the vessel $\# j$, respectively, and $\hat{x}^j$, $\hat{t}$ the scaled inputs.
Using the variables stated above, we derive the updated system of equations (take vessel $\# j$ as example):
\begin{equation}
\begin{split} \label{equ:NormSystem}
       & \frac{1}{\sigma_{t_*}}\frac{\partial \hat{A}^j}{\partial \hat{t}} + \frac{1}{\sigma^j_{x_*}}\hat{A}^j\frac{\partial \hat{u}^j}{\partial \hat{x}^j} + \frac{1}{\sigma^j_{x_*}}\hat{u}^j\frac{\partial \hat{A}^j}{\partial \hat{x}^j} = 0, \\
    & \frac{1}{\sigma_{t_*}}\frac{\partial \hat{u}^j}{\partial \hat{t}} + \frac{1}{\sigma^j_{x_*}}\hat{u}^j\frac{\partial \hat{u}^j}{\partial\hat{x}^j} + \frac{1}{\sigma^j_{x_*}}\frac{\partial \hat{p}^j}{\partial \hat{x}^j} = 0 ,\\
    & \hat{p}^j = \frac{1}{p_0} (p_{ext} + \beta ( \sqrt{\hat{A}^j A^o} - \sqrt{A_0})), \quad \quad j = 1, \dots, D.
\end{split}
\end{equation}

In this non-dimensional and normalized form, all the variables and inputs are scaled to order $O(1)$. This is what effectively enables the training of our physics-informed neural networks in this complex setting. Likewise, at the predicting stage, we first scale the inputs by the characteristic variables, then normalize them. Finally, we revert the predicted quantities $(\hat{A}^j, \hat{u}^j, \hat{p}^j)$ back to their original form $(A^j, u^j, p^j)$ by multiplying with the characteristic variables i.e. $p = \hat{p} p_0$.

## Loss functions
### 1. Measurements:
This part of the loss function corresponds to fitting the clinical measurements obtained for some of the vessels in the network.
In this particular case, this term encourages the output of the neural network $A$ and $u$ to match the measurements of area and velocity  obtained by a clinical procedure (e.g., segmenting 2D cine images and Doppler ultrasound \cite{aaslid1982noninvasive}). This part of the loss function has the form (take vessel $\# j$ as example):
\begin{align}
    \mathcal{L}_{\textrm{measurement}}^j = \frac{1}{N_u^j} \sum_{i=1}^{N_u^j} ( u^j (x_i, t_i) - u^j(x_i, t_i; \theta^j))^2 + \frac{1}{N_A^j}\sum_{i=1}^{N_A^j} ( A^j (x_i, t_i) - A^j(x_i, t_i; \theta^j))^2, \quad \quad j = 1, \dots, D_M
\end{align}
where $N_A^j$, $N_u^j$ represent the number of measurements for $A$ and $u$ in vessel $\# j$ respectively. Also, $D_M$ denote total number of vessels in which we have measurements. In the above equation $u^j (x_i, t_i; \theta^j) $ and $ A^j(x_i, t_i; \theta^j)$ represent the outputs given the parameters of the neural network for vessel $\# j$. Minimizing this term will encourage the neural networks to fit the available measurements.

### 2. PDE residual loss:
This part of the loss function corresponds to the collocation points used to enforce the PDE constraints. Typically these points are chosen by randomly sampling within the arterial domains using a  latin-hypercube sampling strategy. Over these collocation points, we impose the physical constraints by encouraging the PDE residuals to be equal to zero. The partial derivatives in the residual expression can be computed using  automatic differentiation \cite{paszke2017automatic, abadi2016tensorflow}. This objective is imposed to encourage the neural networks to find a particular set of parameters that make their predictions consistent with  the underlying differential equations, which translates to having the minimum residual. By satisfying this condition together with matching the measurements at particular points, we can obtain a model that is capable of inferring the solution at any spatial coordinate of the domain and any time. This collocation loss function takes the following form (take vessel $\# j$ as example):
\begin{align}
    \mathcal{L}_{\textrm{residual}}^j = \frac{1}{N_r^j} \sum_{i=1}^{N_r^j} ( r_A^j(x_i, t_i ; \theta^j))^2 + \frac{1}{N_r^j} \sum_{i=1}^{N_r^j} ( r_u^j(x_i, t_i ; \theta^j))^2 + \frac{1}{N_r^j} \sum_{i=1}^{N_r^j} ( r_p^j(x_i, t_i ; \theta^j))^2, \quad \quad j = 1, \dots, D
\end{align}
where $N_r^j$ represent the number of collocation points in vessel $\# j$, meaning many blue dots we have in figure (\ref{fig:domain_1bifurcation}). Also, $D$ denote the total number of vessels in our arterial network. The terms $r_A^j(x_i, t_i ; \theta^j)$, $r_u^j(x_i, t_i ; \theta^j)$ and $r_p^j(x_i, t_i ; \theta^j)$ represent the residual of area, velocity and pressure defined, respectively.

### 3. Interfaces:
One-dimensional models can be extended to treat splitting and merging arteries by imposing proper boundary conditions \cite{sherwin2003computational}. In this case, one needs to provide  boundary conditions for each artery at the interface points to ensure conservation. For each vessel $j$, the area, velocity, pressure and density are denoted as $[A^{j}, u^{j}, p^{j}]$, the spatial and temporal variables are denoted as $[x_{i}, t_{i}]$. The boundary conditions at the bifurcation points are derived by the conservation of momentum and mass \cite{sherwin2003computational} for the parent and daughter vessels:
\begin{equation}
\begin{split}
    &A_{1}u_{1} =  A_{2}u_{2} +  A_{3}u_{3},\\
    &p_{1} + \frac{1}{2} \rho u_{1}^2  = p_{2} + \frac{1}{2} \rho u_{2}^2, \\
    &p_{1} + \frac{1}{2} \rho u_{1}^2 = p_{3} + \frac{1}{2} \rho u_{3}^2.
\end{split}
\label{equ:continuity}
\end{equation}

The interface loss function ensures the information of the measurements in one domain can be propagated throughout the neighboring domains. In the original formulation of Sherwin {\it et. al.} \cite{sherwin2003computational} a Riemann problem was solved on the interfaces due to the hyperbolic nature of the wave propagation equations. Here we have experimented with including a constraint ensuring the propagation of characteristic variables at interfaces, but we did not observe any substantial improvement in terms of accuracy and computational efficiency, thus we choose to not include these additional  constraints in the neural networks' loss function.

### Total loss: putting it all together
In the previous sections, we provided details on how to construct the individual terms of the loss function corresponding to the measurements, physical constraints and continuity, that the model should satisfy. Now we will show how to combine these individual terms to create the joint form of the loss function, together with some examples to clarify the procedure. The loss function of our physics informed neural networks (PINNs) takes the form:
\begin{align}\label{equ:total_loss}
    \mathcal{L} = \sum_{j=1}^{D_{M}} \mathcal{L}_{\textrm{measurement}}^j + \sum_{j=1}^{D} \mathcal{L}_{\textrm{residual}}^j + \sum_{k=1}^{D_{I}} \mathcal{L}_{\textrm{interface}}^k,
\end{align}
according to the definition in previous section, $D$ represent the total number of domains, $D_{M}\subset D$ denotes the indices of domains where we have data and $D_{I}$ correspond to the number of interfaces in the arterial networks. The index $j$ denotes the identity of the domain under consideration and the index $k$ is helping as keep track of the interface points. Index $k$ does not refer to specific neural network, because $A$, $u$ and $p$ on the bifurcation points are calculated through the father and daughter vessels by their respective network. This complete form of the loss function that encourages the model to learn the underlining physics of the problem by using measurements inside the domain, not necessarily at the boundaries, and by respecting the imposed physical constraints.

For example, for a newtork consisting of three arteries in one splitting bifurcation, the total loss takes the form:
\begin{align}
\mathcal{L} = \sum_{j=1}^{3} \mathcal{L}_{\textrm{measurement}}^j + \sum_{k=1}^{1} \mathcal{L}_{\textrm{interface}}^k + \sum_{j=1}^{3} \mathcal{L}_{\textrm{residual}}^j ,
\end{align}
where the first summation corresponds to the domains where we have measurements for the velocity and cross-sectional area. The second summation encourages the model to satisfy the continuity equation on the bifurcation point. The third summation represents the physics-informed constraints in all three vessels. The second term is written as a summation, for consistency, but in this case, $k=1$, so there is only one component of $ \mathcal{L}^k_{\textrm{interface}}$.

## Non-invasive clinical measurements with 4D Flow MRI
Temporal measurements are collected from 4D flow MRI for the averaged axial blood velocity and the lumen cross-sectional area.

## Tensorlfow implementation for a real world carotid bifurcation

This example is about the use of this method in the case of a real carotid bifurcation. The input of the model is the velocity and cross-sectional area over time at the inlet and outlets of the domain, and the outputs are predictions of pressure over the whole domain ( we do not provide training data for the pressure), and the velocity and area at a testing point (aorta3). This example is more involved than the previous discussed code as it contains 4 vessels having 2 bifurcations. Also, in this example the equilibrium cross-sectional area is not constant, but changes linearly in space. Thus, we define the $A_0$ and $\beta$ as linear functions in the analysis. In this case we have to define two bifurcationpoints and treat them as described above.

```python
# Load required libraries
import tensorflow as tf
import numpy as np
import timeit
from tqdm import tqdm
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

np.random.seed(1234)
tf.set_random_seed(1234)
```

    /Users/paris/tensorflow/lib/python3.7/importlib/_bootstrap.py:219: RuntimeWarning: compiletime version 3.6 of module 'tensorflow.python.framework.fast_tensor_util' does not match runtime version 3.7
      return f(*args, **kwds)

```python
# The main solver class
class OneDBioPINN:
    # Initialize the class
    def __init__(self, X_measurement_aorta1, X_measurement_carotid,
                       X_measurement_aorta3, X_measurement_aorta4,
                       T_measurement, T_initial,
                       A_training_aorta1,  U_training_aorta1,
                       A_training_carotid, U_training_carotid,
                       A_training_aorta3,  U_training_aorta3,
                       A_training_aorta4,  U_training_aorta4,
                       X_residual_aorta1,
                       X_residual_carotid,
                       X_residual_aorta3,
                       X_residual_aorta4,
                       T_residual,layers,bif_points):

        # Reference vessel areas
        self.A_01 = 2.293820e-04
        self.A_02 = 2.623127e-05
        self.A_03 = 2.411245e-04

        # Blood density and characteristic velocity
        self.rho = 1060.
        self.U = 1e+1

        # Charactiristic variables for non-dimensionalization
        self.L = np.sqrt(0.333*(self.A_01 + self.A_02 + self.A_03))
        self.T = self.L/self.U
        self.p0 = self.rho*self.U**2

        # Non-dimensionalize
        self.A0 = self.L**2

        X_measurement_aorta1 = X_measurement_aorta1/self.L
        X_measurement_carotid = X_measurement_carotid/self.L
        X_measurement_aorta3 = X_measurement_aorta3/self.L
        X_measurement_aorta4 = X_measurement_aorta4/self.L

        X_residual_aorta1 = X_residual_aorta1/self.L
        X_residual_carotid = X_residual_carotid/self.L
        X_residual_aorta3 = X_residual_aorta3/self.L
        X_residual_aorta4 = X_residual_aorta4/self.L

        T_measurement  = T_measurement/self.T
        T_residual  = T_residual/self.T
        T_initial  = T_initial/self.T

        # Normalize inputs
        self.Xmean1, self.Xstd1 = X_residual_aorta1.mean(0), X_residual_aorta1.std(0)
        self.Xmean2, self.Xstd2 = X_residual_carotid.mean(0), X_residual_carotid.std(0)
        self.Xmean3, self.Xstd3 = X_residual_aorta3.mean(0), X_residual_aorta3.std(0)
        self.Xmean4, self.Xstd4 = X_residual_aorta4.mean(0), X_residual_aorta4.std(0)

        self.Tmean, self.Tstd = T_residual.mean(0), T_residual.std(0)

        # Jacobians
        self.jac_x1 = 1.0/self.Xstd1
        self.jac_x2 = 1.0/self.Xstd2
        self.jac_x3 = 1.0/self.Xstd3
        self.jac_x4 = 1.0/self.Xstd4

        self.jac_t = 1.0/self.Tstd

        # Store normalized/non-dimensionalized variables
        self.X_f1 = (X_residual_aorta1 - self.Xmean1)/self.Xstd1
        self.X_u1 = (X_measurement_aorta1 - self.Xmean1)/self.Xstd1

        self.X_f2 = (X_residual_carotid - self.Xmean2)/self.Xstd2
        self.X_u2 = (X_measurement_carotid - self.Xmean2)/self.Xstd2

        self.X_f3 = (X_residual_aorta3 - self.Xmean3)/self.Xstd3
        self.X_u3 = (X_measurement_aorta3 - self.Xmean3)/self.Xstd3

        self.X_f4 = (X_residual_aorta4 - self.Xmean4)/self.Xstd4
        self.X_u4 = (X_measurement_aorta4 - self.Xmean4)/self.Xstd4

        self.T_u = (T_measurement - self.Tmean)/self.Tstd
        self.T_f = (T_residual - self.Tmean)/self.Tstd
        self.T_i = (T_initial - self.Tmean)/self.Tstd

        self.A_u1 = A_training_aorta1
        self.u_u1 = U_training_aorta1

        self.A_u2 = A_training_carotid
        self.u_u2 = U_training_carotid

        self.A_u3 = A_training_aorta3
        self.u_u3 = U_training_aorta3

        self.A_u4 = A_training_aorta4
        self.u_u4 = U_training_aorta4

        X1_fm = bif_points[0]/self.L
        X2_fm = bif_points[0]/self.L
        X3_fm1 = bif_points[0]/self.L
        X3_fm2 = bif_points[1]/self.L

        bif_p1 = (X1_fm - self.Xmean1)/self.Xstd1
        bif_p2 = (X2_fm - self.Xmean2)/self.Xstd2
        bif_p31 = (X3_fm1 - self.Xmean3)/self.Xstd3
        bif_p32 = (X3_fm2 - self.Xmean3)/self.Xstd3
        bif_p4 = (X3_fm2 - self.Xmean4)/self.Xstd4

        X1max = bif_p1[0]
        X2min = bif_p2[0]
        X3min = bif_p31[0]
        X3max = bif_p32[0]
        X4min = bif_p4[0]

        # Store neural net layer dimensions
        self.layers = layers

        # Initialize network weights and biases
        self.weights1, self.biases1 = self.initialize_NN(layers)
        self.weights2, self.biases2 = self.initialize_NN(layers)
        self.weights3, self.biases3 = self.initialize_NN(layers)
        self.weights4, self.biases4 = self.initialize_NN(layers)

        # Define placeholders and computational graph
        self.learning_rate = tf.placeholder(tf.float32, shape=[])

        self.X1_fm = tf.constant([X1max], shape = [1024,1], dtype=tf.float32)
        self.X2_fm = tf.constant([X2min], shape = [1024,1], dtype=tf.float32)
        self.X3_fml = tf.constant([X3min], shape = [1024,1], dtype=tf.float32)
        self.X3_fmu = tf.constant([X3max], shape = [1024,1], dtype=tf.float32)
        self.X4_fm = tf.constant([X4min], shape = [1024,1], dtype=tf.float32)

        self.A_u_tf1 = tf.placeholder(tf.float32, shape=(None, self.A_u1.shape[1]))
        self.u_u_tf1 = tf.placeholder(tf.float32, shape=(None, self.u_u1.shape[1]))

        self.A_u_tf2 = tf.placeholder(tf.float32, shape=(None, self.A_u2.shape[1]))
        self.u_u_tf2 = tf.placeholder(tf.float32, shape=(None, self.u_u2.shape[1]))

        self.A_u_tf3 = tf.placeholder(tf.float32, shape=(None, self.A_u3.shape[1]))
        self.u_u_tf3 = tf.placeholder(tf.float32, shape=(None, self.u_u3.shape[1]))

        self.A_u_tf4 = tf.placeholder(tf.float32, shape=(None, self.A_u4.shape[1]))
        self.u_u_tf4 = tf.placeholder(tf.float32, shape=(None, self.u_u4.shape[1]))

        self.X_u_tf1 = tf.placeholder(tf.float32, shape=(None, self.X_u1.shape[1]))
        self.X_u_tf2 = tf.placeholder(tf.float32, shape=(None, self.X_u2.shape[1]))
        self.X_u_tf3 = tf.placeholder(tf.float32, shape=(None, self.X_u3.shape[1]))
        self.X_u_tf4 = tf.placeholder(tf.float32, shape=(None, self.X_u4.shape[1]))

        self.t_u_tf = tf.placeholder(tf.float32,  shape=(None, self.T_u.shape[1]))
        self.t_i_tf = tf.placeholder(tf.float32,  shape=(None, self.T_i.shape[1]))

        self.X_f_tf1 = tf.placeholder(tf.float32, shape=(None, self.X_f1.shape[1]))
        self.X_f_tf2 = tf.placeholder(tf.float32, shape=(None, self.X_f2.shape[1]))
        self.X_f_tf3 = tf.placeholder(tf.float32, shape=(None, self.X_f3.shape[1]))
        self.X_f_tf4 = tf.placeholder(tf.float32, shape=(None, self.X_f4.shape[1]))

        self.t_f_tf = tf.placeholder(tf.float32, shape=(None, self.T_f.shape[1]))

        # Neural net predictions
        self.A_u_pred1, self.u_u_pred1, _  = self.neural_net_aorta1(self.X_u_tf1, self.t_u_tf)
        self.A_u_pred2, self.u_u_pred2, _  = self.neural_net_carotid(self.X_u_tf2, self.t_u_tf)
        self.A_u_pred3, self.u_u_pred3, _  = self.neural_net_aorta3(self.X_u_tf3, self.t_i_tf)
        self.A_u_pred4, self.u_u_pred4, _  = self.neural_net_aorta4(self.X_u_tf4, self.t_u_tf)

        self.A_f_pred1, self.u_f_pred1, self.p_f_pred1  = self.neural_net_aorta1(self.X_f_tf1, self.t_f_tf)
        self.A_f_pred2, self.u_f_pred2, self.p_f_pred2  = self.neural_net_carotid(self.X_f_tf2, self.t_f_tf)
        self.A_f_pred3, self.u_f_pred3, self.p_f_pred3  = self.neural_net_aorta3(self.X_f_tf3, self.t_f_tf)
        self.A_f_pred4, self.u_f_pred4, self.p_f_pred4  = self.neural_net_aorta4(self.X_f_tf4, self.t_f_tf)

        # Compute PDE residuals
        self.r_A1, self.r_u1, self.r_p1  = self.pinn_aorta1(self.X_f_tf1, self.t_f_tf)
        self.r_A2, self.r_u2, self.r_p2  = self.pinn_carotid(self.X_f_tf2, self.t_f_tf)
        self.r_A3, self.r_u3, self.r_p3  = self.pinn_aorta3(self.X_f_tf3, self.t_f_tf)
        self.r_A4, self.r_u4, self.r_p4  = self.pinn_aorta4(self.X_f_tf4, self.t_f_tf)

        # Compute loss functions
        self.loss_A1, self.loss_u1                 = self.compute_measurement_loss_aorta1(self.A_u_pred1, self.u_u_pred1)
        self.loss_rA1, self.loss_ru1, self.loss_rp1 = self.compute_residual_loss_aorta1 (self.r_A1, self.r_u1, self.r_p1)

        self.loss_A2, self.loss_u2                 = self.compute_measurement_loss_carotid(self.A_u_pred2, self.u_u_pred2)
        self.loss_rA2, self.loss_ru2, self.loss_rp2 = self.compute_residual_loss_carotid (self.r_A2, self.r_u2, self.r_p2)

        self.loss_A3, self.loss_u3                 = self.compute_measurement_loss_aorta3(self.A_u_pred3, self.u_u_pred3)
        self.loss_rA3, self.loss_ru3, self.loss_rp3 = self.compute_residual_loss_aorta3 (self.r_A3, self.r_u3, self.r_p3)

        self.loss_A4, self.loss_u4                 = self.compute_measurement_loss_aorta4(self.A_u_pred4, self.u_u_pred4)
        self.loss_rA4, self.loss_ru4, self.loss_rp4 = self.compute_residual_loss_aorta4 (self.r_A4, self.r_u4, self.r_p4)

        self.loss_interface  = self.compute_interface_loss()

        self.loss_A = self.loss_A1 + self.loss_A2 + self.loss_A3 + self.loss_A4
        self.loss_u = self.loss_u1 + self.loss_u2 + self.loss_u3 + self.loss_u4

        self.loss_measurements = self.loss_A + self.loss_u

        self.loss_ru = self.loss_ru1 + self.loss_ru2 + self.loss_ru3 + self.loss_ru4
        self.loss_rA = self.loss_rA1 + self.loss_rA2 + self.loss_rA3 + self.loss_rA4
        self.loss_rp = self.loss_rp1 + self.loss_rp2 + self.loss_rp3 + self.loss_rp4
        self.loss_residual = self.loss_rA + self.loss_ru + self.loss_rp

        # Total loss
        self.loss = self.loss_interface + self.loss_residual  + self.loss_measurements

        # Define optimizer
        self.optimizer  = tf.train.AdamOptimizer(self.learning_rate)

        self.train_op = self.optimizer.minimize(self.loss)
        config = tf.ConfigProto(log_device_placement=True)
        config.gpu_options.allow_growth = False
        # Define Tensorflow session
        self.sess = tf.Session(config=config)

        # Initialize Tensorflow variables
        self.merged = tf.summary.merge_all()

        # Logger
        self.summary_writer = tf.summary.FileWriter('./logs', self.sess.graph)
        self.saver = tf.train.Saver()
        self.loss_value_log = []
        self.loss_A_log  = []
        self.loss_u_log = []
        self.loss_r_log = []
        self.loss_rp_log = []
        self.loss_c_log = []

        init = tf.global_variables_initializer()
        self.sess.run(init)

    # Initialize network weights and biases using Xavier initialization
    def initialize_NN(self, layers):
        weights = []
        biases = []
        num_layers = len(layers)
        for l in range(0,num_layers-1):
            W = self.xavier_init(size=[layers[l], layers[l+1]])
            b = tf.Variable(tf.zeros([1,layers[l+1]], dtype=tf.float32), dtype=tf.float32)
            weights.append(W)
            biases.append(b)
        return weights, biases

    def xavier_init(self, size):
        in_dim = size[0]
        out_dim = size[1]
        xavier_stddev = np.sqrt(2/(in_dim + out_dim))
        return tf.Variable(tf.truncated_normal([in_dim, out_dim], stddev=xavier_stddev), dtype=tf.float32)

    # Neural net forward pass
    def neural_net(self, H, weights, biases, layers):
        num_layers = len(layers)
        for l in range(0,num_layers-2):
            W = weights[l]
            b = biases[l]
            H = tf.tanh(tf.add(tf.matmul(H, W), b))
        W = weights[-1]
        b = biases[-1]
        Y = tf.add(tf.matmul(H, W), b)
        return Y

    def neural_net_aorta1(self, x, t):
        Au = self.neural_net(tf.concat([x,t],1),self.weights1,self.biases1,self.layers)
        A = Au[:,0:1]
        u = Au[:,1:2]
        p = Au[:,2:3]
        return tf.exp(A), u, p

    def neural_net_carotid(self, x, t):
        Au = self.neural_net(tf.concat([x,t],1),self.weights2,self.biases2,self.layers)
        A = Au[:,0:1]
        u = Au[:,1:2]
        p = Au[:,2:3]
        return tf.exp(A), u, p

    def neural_net_aorta3(self, x, t):
        Au = self.neural_net(tf.concat([x,t],1),self.weights3,self.biases3,self.layers)
        A = Au[:,0:1]
        u = Au[:,1:2]
        p = Au[:,2:3]
        return tf.exp(A), u, p

    def neural_net_aorta4(self, x, t):
        Au = self.neural_net(tf.concat([x,t],1),self.weights4,self.biases4,self.layers)
        A = Au[:,0:1]
        u = Au[:,1:2]
        p = Au[:,2:3]
        return tf.exp(A), u, p

    def compute_interface_loss(self):

         A1, u1, p1 = self.neural_net_aorta1(self.X1_fm,self.t_f_tf) # A*, u*, p*

         A2, u2, p2 = self.neural_net_carotid(self.X2_fm,self.t_f_tf) # A*, u*, p*

         A3, u3, p3 = self.neural_net_aorta3(self.X3_fml,self.t_f_tf) # A*, u*, p*

         A3u, u3u, p3u = self.neural_net_aorta3(self.X3_fmu,self.t_f_tf) # A*, u*, p*

         A4, u4, p4 = self.neural_net_aorta4(self.X4_fm,self.t_f_tf) # A*, u*, p*

         Q1 = A1*u1
         Q2 = A2*u2
         Q3 = A3*u3

         loss_mass = tf.reduce_mean(tf.square((Q1 - Q2 - Q3)))

         p_1 = p1 + (0.5*u1**2)
         p_2 = p2 + (0.5*u2**2)
         p_3 = p3 + (0.5*u3**2)

         loss_press = tf.reduce_mean(tf.square( p_1 - p_2)) + tf.reduce_mean(tf.square( p_1 - p_3))

         loss_C = tf.reduce_mean(tf.square((u3u - u4))) + \
                             tf.reduce_mean(tf.square((A3u - A4))) + tf.reduce_mean(tf.square( p3u - p4))

         return  loss_mass + loss_press + loss_C

    # Linear tapering for the reference cross-sectional area
    def get_equilibrium_cross_sectional_area_aorta_1(self, x):
        x = self.L*(self.Xstd1*x + self.Xmean1)
        X1 = 0.
        X2 = 0.04964
        denom = X2-X1
        x1 = 2.293820e-04
        x2 = 2.636589e-04
        numer =  x2 - x1
        alpha = numer/denom
        beta = x1 - alpha*X1
        y = alpha*x + beta
        return y

    def get_equilibrium_cross_sectional_area_carotid(self, x):
        x = self.L*(self.Xstd2*x + self.Xmean2)
        X1 = 0.04964
        X2 = 0.10284
        denom = X2-X1
        x1 = 2.636589e-04
        x2 = 2.623127e-05
        numer =  x2 - x1
        alpha = numer/denom
        beta = x1 - alpha*X1
        y = alpha*x + beta
        return y

    def get_equilibrium_cross_sectional_area_aorta_3(self, x):
        x = self.L*(self.Xstd3*x + self.Xmean3)
        X1 = 0.04964
        X2 = 0.1383
        denom = X2-X1
        x1 = 2.636589e-04
        x2 = 2.177177e-04
        numer =  x2 - x1
        alpha = numer/denom
        beta = x1 - alpha*X1
        y = alpha*x + beta
        return y

    def get_equilibrium_cross_sectional_area_aorta_4(self, x):
        x = self.L*(self.Xstd4*x + self.Xmean4)
        X1 = 0.1383
        X2 = 0.17056
        denom = X2-X1
        x1 = 2.177177e-04
        x2 = 2.411245e-04
        numer =  x2 - x1
        alpha = numer/denom
        beta = x1 - alpha*X1
        y = alpha*x + beta
        return y

    def get_beta_aorta_1(self, x):
        x = self.L*(self.Xstd1*x + self.Xmean1)
        X1 = 0.
        X2 = 0.04964
        denom = X2-X1
        x1 = 2.472667e+06
        x2 = 2.151208e+06
        numer =  x2 - x1
        alpha = numer/denom
        beta = x1 - alpha*X1
        y = alpha*x + beta
        return y

    def get_beta_carotid(self, x):
        x = self.L*(self.Xstd2*x + self.Xmean2)
        X1 = 0.04964
        X2 = 0.10284
        denom = X2-X1
        x1 =2.151208e+06
        x2 = 9.459836e+06
        numer =  x2 - x1
        alpha = numer/denom
        beta = x1 - alpha*X1
        y = alpha*x + beta
        return y

    def get_beta_aorta_3(self, x):
        x = self.L*(self.Xstd3*x + self.Xmean3)
        X1 = 0.04964
        X2 = 0.1383
        denom = X2-X1
        x1 = 2.151208e+06
        x2 = 2.800526e+06
        numer =  x2 - x1
        alpha = numer/denom
        beta = x1 - alpha*X1
        y = alpha*x + beta
        return y

    def get_beta_aorta_4(self, x):
        x = self.L*(self.Xstd4*x + self.Xmean4)
        X1 = 0.1383
        X2 = 0.17056
        denom = X2-X1
        x1 = 2.800526e+06
        x2 = 2.528670e+06
        numer =  x2 - x1
        alpha = numer/denom
        beta = x1 - alpha*X1
        y = alpha*x + beta
        return y

    # Compute residuals
    def pinn_aorta1(self, x, t):

        A, u, p = self.neural_net_aorta1(x,t) # \hat{A}, \hat{u}, \hat{p}

        A_01 = self.get_equilibrium_cross_sectional_area_aorta_1(x)
        beta1 = self.get_beta_aorta_1(x)

        r_p  = 10000. + beta1*(tf.sqrt(A*self.A0) - tf.sqrt(A_01))

        p_x = tf.gradients(p, x)[0]*self.jac_x1

        A_t = tf.gradients(A, t)[0]*self.jac_t
        A_x = tf.gradients(A, x)[0]*self.jac_x1

        u_t = tf.gradients(u, t)[0]*self.jac_t
        u_x = tf.gradients(u, x)[0]*self.jac_x1

        r_A = A_t + u*A_x + A*u_x
        r_u = u_t + p_x + u*u_x

        return r_A, r_u, r_p

    def pinn_carotid(self, x, t):

        A, u, p = self.neural_net_carotid(x,t) # \hat{A}, \hat{u}, \hat{p}

        A_02 = self.get_equilibrium_cross_sectional_area_carotid(x)
        beta2 = self.get_beta_carotid(x)

        r_p  = 8.5e+3 + beta2*(tf.sqrt(A*self.A0) - tf.sqrt(A_02))

        p_x = tf.gradients(p, x)[0]*self.jac_x2

        A_t = tf.gradients(A, t)[0]*self.jac_t
        A_x = tf.gradients(A, x)[0]*self.jac_x2

        u_t = tf.gradients(u, t)[0]*self.jac_t
        u_x = tf.gradients(u, x)[0]*self.jac_x2

        r_A = A_t + u*A_x +  A*u_x
        r_u = u_t + p_x + u*u_x

        return r_A, r_u, r_p

    def pinn_aorta3(self, x, t):

        A, u, p = self.neural_net_aorta3(x,t) # \hat{A}, \hat{u}, \hat{p}

        A_03 = self.get_equilibrium_cross_sectional_area_aorta_3(x)
        beta3 = self.get_beta_aorta_3(x)

        r_p  = 10000. + beta3*(tf.sqrt(A*self.A0) - tf.sqrt(A_03))

        p_x = tf.gradients(p, x)[0]*self.jac_x3

        A_t = tf.gradients(A, t)[0]*self.jac_t
        A_x = tf.gradients(A, x)[0]*self.jac_x3

        u_t = tf.gradients(u, t)[0]*self.jac_t
        u_x = tf.gradients(u, x)[0]*self.jac_x3

        r_A = A_t + u*A_x + A*u_x
        r_u = u_t + p_x + u*u_x

        return r_A, r_u, r_p

    def pinn_aorta4(self, x, t):

        A, u, p = self.neural_net_aorta4(x,t) # \hat{A}, \hat{u}, \hat{p}

        A_04 = self.get_equilibrium_cross_sectional_area_aorta_4(x)
        beta4 = self.get_beta_aorta_4(x)

        r_p  = 10000. + beta4*(tf.sqrt(A*self.A0) - tf.sqrt(A_04))

        p_x = tf.gradients(p, x)[0]*self.jac_x4

        A_t = tf.gradients(A, t)[0]*self.jac_t
        A_x = tf.gradients(A, x)[0]*self.jac_x4

        u_t = tf.gradients(u, t)[0]*self.jac_t
        u_x = tf.gradients(u, x)[0]*self.jac_x4

        r_A = A_t + u*A_x + A*u_x
        r_u = u_t + p_x + u*u_x

        return r_A, r_u, r_p

    # Compute residual losses
    def compute_residual_loss_aorta1(self, r_A, r_u, r_p):
        loss_rA = tf.reduce_mean(tf.square(r_A))
        loss_ru = tf.reduce_mean(tf.square(r_u))

        loss_rp = tf.reduce_mean(tf.square((self.p_f_pred1 - r_p*(1/self.p0))))

        return  loss_rA, loss_ru, loss_rp

    def compute_residual_loss_carotid(self, r_A, r_u, r_p):
        loss_rA = tf.reduce_mean(tf.square(r_A))
        loss_ru = tf.reduce_mean(tf.square(r_u))

        loss_rp = tf.reduce_mean(tf.square((self.p_f_pred2 - r_p*(1/self.p0))))

        return  loss_rA, loss_ru, loss_rp

    def compute_residual_loss_aorta3(self, r_A, r_u, r_p):
        loss_rA = tf.reduce_mean(tf.square(r_A))
        loss_ru = tf.reduce_mean(tf.square(r_u))

        loss_rp = tf.reduce_mean(tf.square((self.p_f_pred3 - r_p*(1/self.p0))))

        return  loss_rA, loss_ru, loss_rp

    def compute_residual_loss_aorta4(self, r_A, r_u, r_p):
        loss_rA = tf.reduce_mean(tf.square(r_A))
        loss_ru = tf.reduce_mean(tf.square(r_u))

        loss_rp = tf.reduce_mean(tf.square((self.p_f_pred4 - r_p*(1/self.p0))))

        return  loss_rA, loss_ru, loss_rp

    def compute_measurement_loss_aorta1(self, A_u, u_u):

        loss_A = tf.reduce_mean(tf.square((self.A_u1 - A_u*self.A0)/self.A0))
        loss_u = tf.reduce_mean(tf.square((self.u_u1 - u_u*self.U)/self.U))

        return loss_A, loss_u

    def compute_measurement_loss_carotid(self, A_u, u_u):

        loss_A = tf.reduce_mean(tf.square((self.A_u2 - A_u*self.A0)/self.A0))
        loss_u = tf.reduce_mean(tf.square((self.u_u2 - u_u*self.U)/self.U))

        return loss_A, loss_u

    def compute_measurement_loss_aorta3(self, A_u, u_u):

        loss_A = tf.reduce_mean(tf.square((self.A_u3 - A_u*self.A0)/self.A0))
        loss_u = tf.reduce_mean(tf.square((self.u_u3 - u_u*self.U)/self.U))

        return loss_A, loss_u

    def compute_measurement_loss_aorta4(self, A_u, u_u):

        loss_A = tf.reduce_mean(tf.square((self.A_u4 - A_u*self.A0)/self.A0))
        loss_u = tf.reduce_mean(tf.square((self.u_u4 - u_u*self.U)/self.U))

        return loss_A, loss_u

    # Fetch a mini-batch of data for stochastic gradient updates
    def fetch_minibatch(self, X1_f, X2_f, X3_f ,X4_f, t_f, N_f_batch):
        N_f = X1_f.shape[0]
        idx_f = np.random.choice(N_f, N_f_batch, replace=False)
        X1_f_batch = X1_f[idx_f,:]
        X2_f_batch = X2_f[idx_f,:]
        X3_f_batch = X3_f[idx_f,:]
        X4_f_batch = X4_f[idx_f,:]

        t_f_batch = t_f[idx_f,:]
        return  X1_f_batch, X2_f_batch, X3_f_batch, X4_f_batch, t_f_batch

    # Trains the model by minimizing the MSE loss using mini-batch stochastic gradient descent
    def train(self, nIter = 20000, batch_size = 1024, learning_rate = 1e-3):

#         start_time = timeit.default_timer()
        for it in tqdm(range(nIter)):

            # Fetch a mini-batch of training data
            X1_f_batch, X2_f_batch, X3_f_batch, X4_f_batch, T_f_batch = self.fetch_minibatch(self.X_f1,
                                                                                self.X_f2,
                                                                                self.X_f3,
                                                                                self.X_f4,
                                                                                self.T_f,
                                                                                N_f_batch = batch_size)
            # Define a dictionary for associating placeholders with data
            tf_dict = {self.X_u_tf1: self.X_u1,
                       self.X_u_tf2: self.X_u2,
                       self.X_u_tf3: self.X_u3,
                       self.X_u_tf4: self.X_u4,
                       self.X_f_tf1: X1_f_batch,
                       self.X_f_tf2: X2_f_batch,
                       self.X_f_tf3: X3_f_batch,
                       self.X_f_tf4: X4_f_batch,
                       self.t_f_tf:  T_f_batch,
                       self.t_u_tf:  self.T_u,
                       self.t_i_tf:  self.T_i,
                       self.A_u_tf1: self.A_u1, self.u_u_tf1: self.u_u1,
                       self.A_u_tf2: self.A_u2, self.u_u_tf2: self.u_u2,
                       self.A_u_tf3: self.A_u3, self.u_u_tf3: self.u_u3,
                       self.A_u_tf4: self.A_u4, self.u_u_tf4: self.u_u4,
                       self.learning_rate: learning_rate}

            # Run the Tensorflow session to minimize the loss
            self.sess.run(self.train_op, tf_dict)

            # Print
            if it % 50 == 0:
#                 elapsed = timeit.default_timer() - start_time
                loss_value, loss_A, loss_u, loss_r, loss_rp, loss_c  = self.sess.run([self.loss,
                                                                                self.loss_A,
                                                                                self.loss_u,
                                                                                self.loss_ru+self.loss_rA,
                                                                                self.loss_rp,
                                                                                self.loss_interface], tf_dict)
                self.loss_value_log.append(loss_value)
                self.loss_A_log.append(loss_A)
                self.loss_u_log.append(loss_u)
                self.loss_r_log.append(loss_r)
                self.loss_rp_log.append(loss_rp)
                self.loss_c_log.append(loss_c)
#                 print('It: %d, Loss: %.3e, Loss_A: %.3e, Loss_u: %.3e, Loss_r: %.3e, Loss_p: %.3e\
#                                            Loss_c: %.3e, Time: %.2f' %
#                       (it, loss_value, loss_A, loss_u, loss_r, loss_rp, loss_c, elapsed))
#                 start_time = timeit.default_timer()

    # Evaluates predictions at test points
    def predict_aorta1(self, X1, t):
        # non-dimensionalize inputs
        X1 = X1/self.L
        t  = t/self.T
        # normalize inputs
        X1 = (X1 - self.Xmean1)/self.Xstd1
        t = (t - self.Tmean)/self.Tstd
        # Create tf dictionary
        tf_dict1 = {self.X_f_tf1: X1, self.t_f_tf: t}
        # Evaluate predictions
        A_star1 = self.sess.run(self.A_f_pred1, tf_dict1)
        u_star1 = self.sess.run(self.u_f_pred1, tf_dict1)
        p_star1 = self.sess.run(self.p_f_pred1, tf_dict1)
        # de-normalize outputs
        A_star1 = A_star1*self.A0
        u_star1 = u_star1*self.U
        p_star1 = p_star1*self.p0

        return A_star1, u_star1, p_star1

    def predict_carotid(self, X2, t):
        X2 = X2/self.L
        t  = t/self.T

        X2 = (X2 - self.Xmean2)/self.Xstd2

        t = (t - self.Tmean)/self.Tstd
        tf_dict2 = {self.X_f_tf2: X2, self.t_f_tf: t}

        A_star2 = self.sess.run(self.A_f_pred2, tf_dict2)
        u_star2 = self.sess.run(self.u_f_pred2, tf_dict2)
        p_star2 = self.sess.run(self.p_f_pred2, tf_dict2)

        A_star2 = A_star2*self.A0
        u_star2 = u_star2*self.U
        p_star2 = p_star2*self.p0

        return A_star2, u_star2, p_star2

    def predict_aorta3(self, X3, t):
        X3 = X3/self.L
        t  = t/self.T

        X3 = (X3 - self.Xmean3)/self.Xstd3
        t = (t - self.Tmean)/self.Tstd

        tf_dict3 = {self.X_f_tf3: X3, self.t_f_tf: t}

        A_star3 = self.sess.run(self.A_f_pred3, tf_dict3)
        u_star3 = self.sess.run(self.u_f_pred3, tf_dict3)
        p_star3 = self.sess.run(self.p_f_pred3, tf_dict3)

        A_star3 = A_star3*self.A0
        u_star3 = u_star3*self.U
        p_star3 = p_star3*self.p0

        return A_star3, u_star3, p_star3

    def predict_aorta4(self, X4, t):
        X4 = X4/self.L
        t  = t/self.T

        X4 = (X4 - self.Xmean4)/self.Xstd4
        t = (t - self.Tmean)/self.Tstd

        tf_dict4 = {self.X_f_tf4: X4, self.t_f_tf: t}

        A_star4 = self.sess.run(self.A_f_pred4, tf_dict4)
        u_star4 = self.sess.run(self.u_f_pred4, tf_dict4)
        p_star4 = self.sess.run(self.p_f_pred4, tf_dict4)

        A_star4 = A_star4*self.A0
        u_star4 = u_star4*self.U
        p_star4 = p_star4*self.p0

        return A_star4, u_star4, p_star4
```

### Helper functions
In addition to the main model class we need some additional helper functions to set up the problem and correctly parse the clinical data.

```python
# Helper functions for setting up the problem
def get_equilibrium_cross_sectional_area_aorta_1(x):
    X1 = 0.0
    X2 = 0.04964
    denom = X2-X1
    x1 = 2.293820e-04
    x2 = 2.636589e-04
    numer =  x2 - x1
    alpha = numer/denom
    beta = x1 - alpha*X1
    y = alpha*x + beta
    return y

def get_equilibrium_cross_sectional_area_carotid(x):
    X1 = 0.04964
    X2 = 0.10284
    denom = X2-X1
    x1 = 2.636589e-04
    x2 = 2.623127e-05
    numer =  x2 - x1
    alpha = numer/denom
    beta = x1 - alpha*X1
    y = alpha*x + beta
    return y

def get_equilibrium_cross_sectional_area_aorta_3(x):
    X1 = 0.04964
    X2 = 0.1383
    denom = X2-X1
    x1 = 2.636589e-04
    x2 = 2.177177e-04
    numer =  x2 - x1
    alpha = numer/denom
    beta = x1 - alpha*X1
    y = alpha*x + beta
    return y

def get_equilibrium_cross_sectional_area_aorta_4(x):
    X1 = 0.1383
    X2 = 0.17056
    denom = X2-X1
    x1 = 2.177177e-04
    x2 = 2.411245e-04
    numer =  x2 - x1
    alpha = numer/denom
    beta = x1 - alpha*X1
    y = alpha*x + beta
    return y
```

### Load the training data
Now we are ready to load the training data and set up the simulation:

```python
# Define the number of spatio-temporal domain points to evaluate the residual
# of the system of equations.

N_f =  2000

aorta1_velocity = np.load("../data/Aorta1_U.npy", allow_pickle=True).item()
aorta2_velocity = np.load("../data/Aorta2_U.npy", allow_pickle=True).item()
aorta4_velocity = np.load("../data/Aorta4_U.npy", allow_pickle=True).item()
carotid_velocity= np.load("../data/LCommonCarotid_U.npy", allow_pickle=True).item()

aorta1_area = np.load("../data/Aorta1_A.npy", allow_pickle=True).item()
aorta2_area = np.load("../data/Aorta2_A.npy", allow_pickle=True).item()
aorta4_area = np.load("../data/Aorta4_A.npy", allow_pickle=True).item()
carotid_area = np.load("../data/LCommonCarotid_A.npy", allow_pickle=True).item()

test_aorta3_velocity = np.load("../data/Aorta3_U.npy", allow_pickle=True).item()
test_aorta3_area = np.load("../data/Aorta3_A.npy", allow_pickle=True).item()

t = aorta1_velocity['t']*1e-3

velocity_measurements_aorta1 = aorta1_velocity["U"]*1e-2
velocity_measurements_carotid = carotid_velocity["U"]*1e-2
velocity_measurements_aorta4 = aorta4_velocity["U"]*1e-2

area_measurements_aorta1 = aorta1_area["A"]*1e-6
area_measurements_carotid = carotid_area["A"]*1e-6
area_measurements_aorta4 = aorta4_area["A"]*1e-6

velocity_testpoint_aorta3 = test_aorta3_velocity["U"]*1e-2
area_testpoint_aorta3 = test_aorta3_area["A"]*1e-6

u_test1 = aorta2_velocity['U']*1e-2
A_test1 = aorta2_area['A']*1e-6

# Number of measurements

N_u = t.shape[0]

layers = [2, 100, 100, 100, 100, 100, 100, 3]

lower_bound_t = t.min(0)
upper_bound_t = t.max(0)

lower_bound_vessel_1 = 0.0
upper_bound_vessel_1 = 0.04964

lower_bound_vessel_2 = 0.04964
upper_bound_vessel_2 = 0.10284

lower_bound_vessel_3 = 0.04964
upper_bound_vessel_3 = 0.1383

lower_bound_vessel_4 = 0.1383
upper_bound_vessel_4 = 0.17056

# Spatial/temporal coordinates for initial conditions
X_initial_aorta1 = np.linspace(lower_bound_vessel_1,upper_bound_vessel_1,N_u)[:,None]
X_initial_carotid = np.linspace(lower_bound_vessel_2,upper_bound_vessel_2,N_u)[:,None]
X_initial_aorta3 = np.linspace(lower_bound_vessel_3,upper_bound_vessel_3,N_u)[:,None]
X_initial_aorta4 = np.linspace(lower_bound_vessel_4,upper_bound_vessel_4,N_u)[:,None]

T_initial  = lower_bound_t*np.ones((N_u))[:,None]

# Spatial/temporal coordinates for boundary conditions
X_boundary_aorta1 = lower_bound_vessel_1*np.ones((N_u))[:,None]
X_boundary_carotid = upper_bound_vessel_2*np.ones((N_u))[:,None]
X_boundary_aorta3 = upper_bound_vessel_3*np.ones((N_u))[:,None]
X_boundary_aorta4 = upper_bound_vessel_4*np.ones((N_u))[:,None]

T_boundary = t

# Measurement Spatial/temporal coordinates
X_measurement_aorta1 = np.vstack((X_initial_aorta1, X_boundary_aorta1))
X_measurement_carotid = np.vstack((X_initial_carotid, X_boundary_carotid))
X_measurement_aorta3 = np.vstack((X_initial_aorta3))
X_measurement_aorta4 = np.vstack((X_initial_aorta4, X_boundary_aorta4))

T_measurement = np.vstack((T_initial, T_boundary))

X_residual_aorta1 = lower_bound_vessel_1 + (upper_bound_vessel_1-lower_bound_vessel_1)*np.random.random((N_f))[:,None]
X_residual_carotid = lower_bound_vessel_2 + (upper_bound_vessel_2-lower_bound_vessel_2)*np.random.random((N_f))[:,None]
X_residual_aorta3 = lower_bound_vessel_3 + (upper_bound_vessel_3-lower_bound_vessel_3)*np.random.random((N_f))[:,None]
X_residual_aorta4 = lower_bound_vessel_4 + (upper_bound_vessel_4-lower_bound_vessel_4)*np.random.random((N_f))[:,None]

T_residual = lower_bound_t + (upper_bound_t-lower_bound_t)*np.random.random((N_f))[:,None]

A_initial_aorta1 = get_equilibrium_cross_sectional_area_aorta_1(X_initial_aorta1)
A_initial_carotid = get_equilibrium_cross_sectional_area_carotid(X_initial_carotid)
A_initial_aorta3 = get_equilibrium_cross_sectional_area_aorta_3(X_initial_aorta3)
A_initial_aorta4 = get_equilibrium_cross_sectional_area_aorta_4(X_initial_aorta4)

U_initial_aorta1 = velocity_measurements_aorta1[0]*np.ones((N_u,1))
U_initial_aorta2 = velocity_measurements_carotid[0]*np.ones((N_u,1))
U_initial_aorta3 = velocity_testpoint_aorta3[0]*np.ones((N_u,1))
U_initial_aorta4 = velocity_measurements_aorta4[0]*np.ones((N_u,1))

A_training_aorta1 = np.vstack((A_initial_aorta1,area_measurements_aorta1))
U_training_aorta1 = np.vstack((U_initial_aorta1,velocity_measurements_aorta1))

A_training_carotid = np.vstack((A_initial_carotid,area_measurements_carotid))
U_training_carotid = np.vstack((U_initial_aorta2,velocity_measurements_carotid))

A_training_aorta3 = np.vstack((A_initial_aorta3))
U_training_aorta3 = np.vstack((U_initial_aorta3))

A_training_aorta4 = np.vstack((A_initial_aorta4,area_measurements_aorta4))
U_training_aorta4 = np.vstack((U_initial_aorta4,velocity_measurements_aorta4))

bif_points = [upper_bound_vessel_1, upper_bound_vessel_3]
```

```python
# Build the PINN model
model = OneDBioPINN(X_measurement_aorta1, X_measurement_carotid,
                    X_measurement_aorta3, X_measurement_aorta4,
                    T_measurement, T_initial,
                    A_training_aorta1,  U_training_aorta1,
                    A_training_carotid, U_training_carotid,
                    A_training_aorta3,  U_training_aorta3,
                    A_training_aorta4,  U_training_aorta4,
                    X_residual_aorta1,
                    X_residual_carotid,
                    X_residual_aorta3,
                    X_residual_aorta4,
                    T_residual,
                    layers,
                    bif_points)
```

    WARNING: Logging before flag parsing goes to stderr.
    W0726 13:31:43.689651 4524578240 deprecation.py:323] From /Users/paris/tensorflow/lib/python3.7/site-packages/tensorflow/python/ops/math_grad.py:1205: add_dispatch_support.<locals>.wrapper (from tensorflow.python.ops.array_ops) is deprecated and will be removed in a future version.
    Instructions for updating:
    Use tf.where in 2.0, which has the same broadcast rule as np.where

```python
# Load pre-trained model
model.saver.restore(model.sess, '../SavedModels/CarotidBifurcation.ckpt')

# Train the PINN model using mini-batch stochastic gradient descent
model.train(nIter = 200, batch_size = 1024, learning_rate = 1e-4)
```

    W0726 13:31:48.478670 4524578240 deprecation.py:323] From /Users/paris/tensorflow/lib/python3.7/site-packages/tensorflow/python/training/saver.py:1276: checkpoint_exists (from tensorflow.python.training.checkpoint_management) is deprecated and will be removed in a future version.
    Instructions for updating:
    Use standard file APIs to check for files with this prefix.
    100%|██████████| 200/200 [00:19<00:00, 10.16it/s]

```python
# Evaluate model predictions at the test locations
test_point1 = 0.04964*np.ones((X_residual_aorta1.shape[0],1))
test_point3 = 0.1383*np.ones((t.shape[0],1))

test_aorta1_lboundary = lower_bound_vessel_1*np.ones((t.shape[0],1))
test_carotid_rboundary = upper_bound_vessel_2*np.ones((t.shape[0],1))
test_aorta4_rboundary = upper_bound_vessel_4*np.ones((t.shape[0],1))

A_predict_aorta1, u_predict_aorta1, p_predict_aorta1     = model.predict_aorta1(test_point1, T_residual)
A_predict_carotid, u_predict_carotid, p_predict_carotid    = model.predict_carotid(test_point1, T_residual)
A_predict_aorta3l, u_predict_aorta3l, p_predict_aorta3l  = model.predict_aorta3(test_point1, T_residual)
A_predict_aorta4, u_predict_aorta4, p_predict_aorta4  = model.predict_aorta4(test_point3, t)

A_pred1b, u_pred1b, p_pred1b  = model.predict_aorta1(test_aorta1_lboundary, t)
A_pred2b, u_pred2b, p_pred2b  = model.predict_carotid(test_carotid_rboundary, t)
A_pred3b, u_pred3b, p_pred3b  = model.predict_aorta4(test_aorta4_rboundary, t)
```

```python
# Plot the results
fig1 = plt.figure(1,figsize=(10, 6), facecolor='w')
fig2 = plt.figure(2,figsize=(10, 6), facecolor='w')
fig3 = plt.figure(3,figsize=(10, 6), facecolor='w')

ax1 = fig1.add_subplot(111)
ax2 = fig2.add_subplot(111)
ax3 = fig3.add_subplot(111)

ax1.plot(t, u_predict_aorta4,'r--',linewidth=3.5, markersize=2.5)
ax1.plot(t, velocity_testpoint_aorta3,'b-',linewidth=3.5, markersize=2.5)

ax1.set_xlabel('Time in $s$')
ax1.set_ylabel('Velocity in $m/s$')
ax1.set_title('Compare velocity aorta3')

ax2.plot(t, A_predict_aorta4,'r--',linewidth=3.5, markersize=2.5)
ax2.plot(t, area_testpoint_aorta3,'b-',linewidth=3.5, markersize=2.5)

ax2.set_xlabel('Time in $s$')
ax2.set_ylabel('Area in $mm^2$')
ax2.set_title('Compare area aorta3')

ax3.plot(t, p_predict_aorta4/133.,'r--',linewidth=3.5, markersize=2.5)

ax3.set_xlabel('Time in $s$')
ax3.set_ylabel('Pressure in $mmHg$')
ax3.set_title('Pressure aorta3')
```


```python
%matplotlib inline
```

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

from ipywidgets import interactive
#plt.style.use('ggplot')
```

----
## INHOUD:

* [AD Conversie](#Analog_Digital_Conversion)
    * [Sampling](#Sampling)
    * [Quantization](#Quantization)

----

------------
## Analog Digital Conversion

Two steps are mandatory in order to do digital processing on analog signals.

---
# Sampling, Aliasing en Nyquist:

### Nyquist (Folding) frequency: $$\dfrac{f_s}{2}$$
### Nyquist rate: $$f_s=2 f_{max}$$
### Nyquist interval: $$\left[-\dfrac{f_s}{2}, \dfrac{f_s}{2}\right]$$

### Nyquist condition: $$f_s \geq 2 f_{max}$$
----

```python
def aliasingExample(fs=2.1,f1=1):

    T = 1/fs #sampling period

    tstart = 0
    tend = 100

    dt = 1./100;

    t = np.arange(tstart,tend,dt) #time axis
    n = np.arange(tstart,tend*fs) #discrete time intervals

    f2 = np.mod(f1,fs) #determine aliasing frequency

    if f2>fs/2:
        f2 = f2-fs

    w1 = 2*np.pi*f1
    w2 = 2*np.pi*f2

    phi = -.1*np.pi

    def genSig(arg,type='sin'):
        if type == 'cos':
            return np.cos(arg)
        else:
            return np.sin(arg)

    y1 = genSig(w1*t+phi)
    y2 = genSig(w2*t+phi)
    y2_discr = genSig(w1*n*T+phi)
    t_discr = n*T

    fig,ax = plt.subplots(ncols=1,nrows=2,num=None, figsize=(14,10), facecolor='w', edgecolor='k')
    ax[0].plot(t,y1,'r', label='original: ' + str(f1) + ' Hz', linewidth=3)
    ax[0].plot(t,y2,'b', label='reconstructed: ' + str(f2) + ' Hz')

    ax[0].stem(t_discr,y2_discr,linefmt='k-', markerfmt='ko', basefmt='k.', label='samples')

    ax[0].set_xlabel('Time [s]')
    ax[0].set_ylabel('Amplitude [au]')
    ax[0].legend(fontsize='large',loc='lower right')
    ax[0].grid('on')
    ax[0].set_xlim([0,4])

    # determine frequency axis y1
    n = y2_discr.size
    fvec_2 = np.arange(0,fs,fs/n); # Frequency axis start at 0 ends at Fs, resolution is Fs/N
    fvec_2 = fvec_2[range(int(n/2))]

    Y2_discr = np.fft.fft(y2_discr)/n # fft computing and normalization
    Y2_discr = Y2_discr[range(int(n/2))]

    ax[1].stem(fvec_2,2*abs(Y2_discr))
    ax[1].set_xlabel('Frequency [Hz]')
    ax[1].set_ylabel('Amplitude [au]')
    ax[1].set_xlim([0,2])
    ax[1].grid('on')
```

```python
interactive(aliasingExample, f1 = (0,2,0.5), fs=(0,3,0.05))
```

Failed to display Jupyter Widget of type interactive.

  If you're reading this message in the Jupyter Notebook or JupyterLab Notebook, it may mean
  that the widgets JavaScript is still loading. If this message persists, it
  likely means that the widgets JavaScript library is either not installed or
  not enabled. See the Jupyter
  Widgets Documentation for setup instructions.

  If you're reading this message in another frontend (for example, a static
  rendering on GitHub or NBViewer),
  it may mean that your frontend doesn't currently support widgets.

---

# Quantization

### Supporting functions

```python
def biquant(NoBits, Xmin, Xmax, x):
    # function pq = biquant(NoBits, Xmin, Xmax, value)
    # This routine is created for simulation of the bipolar quantizer.
    #
    # NoBits: number of bits used in quantization.
    # Xmax: overload value.
    # Xmin: minimum value
    # value: input to be quantized.
    # pq: output of the quantized value
    # I: coded integer index

    L = 2**NoBits; #Levels
    R = Xmax-Xmin; #Range
    Q = R/L; #Resolution
    I = np.round((x-Xmin)/Q);

    if ( I == L):
        I = I-1

    if I < 0:
        I = 0

    pq = Xmin + I*Q;
    return pq
```

---

Het samplen zorgt voor een opdeling in de tijd. Kwantiseren is nodig om het signaal op te delen in de amplitude. Het aantal niveaus (levels) wordt bepaald door het aantal bits van de AD converter.

Stel we hebben een 3 bits analoog naar digitaal (AD) converter, het aantal levels wat we dan verkrijgen is:

\begin{equation}
levels = 2^{n} = 2^3 = 8
\end{equation}

De resolutie (vaak in Volt uitgedrukt) wordt bepaald door de verhouding in het bereik van de AD converter en het aantal levels:

\begin{equation}
resolutie (Q) = \frac{bereik (R)}{levels} = \frac{2}{8} = 0.25~V
\end{equation}

```python
N = 1000  # length of signal

# generate signal
A = 1
f = 4
t = np.linspace(0,1,N)
x = A*np.sin(f*2*t)
t_discr = t[::20]
x_discr = x[::20]

nBits = 3
xQ = [biquant(nBits,-A,A,xi) for xi in x_discr]

# plot (quantized) signals
fig, ax1 = plt.subplots(figsize=(10, 6), facecolor='w', edgecolor='k')

ax1.plot(t,x, 'b', label=r'continous signal')
ax1.stem(t_discr,x_discr,label=r'discrete signal',linefmt='r-', markerfmt='ro', basefmt='r.')
ax1.stem(t_discr,xQ,label=r'digital signal',linefmt='g-', markerfmt='go', basefmt='g.')
ax1.set_xlabel('t [s]')
ax1.legend(fontsize='large',loc=0)

plt.show()
```

----

## Impulse function:
\begin{equation}
\delta (t) = \begin{cases}
                  \inf & \text{for $t=0$}.\\
                  0 & \text{otherwise}
                \end{cases}
\end{equation}

### The function is defined by the integral:
$$ \int\limits_{-\infty}^\infty \delta (t) dt = 1$$

```python
fig, ax = plt.subplots(1,2)
ax[0].arrow(x=0, y=0, dx=0, dy=1.5, head_width=0.1, head_length=0.1, fc='k', ec='k')
ax[0].axis((-1,1,0,2))
ax[0].set_title('Continuous')
ax[1].stem((0,),(1,))
ax[1].axis((-1,1,0,2))
ax[1].set_title('Discrete')
plt.subplots_adjust(hspace=9)
plt.show();
```

***

## Step function:
\begin{equation}
u (t) = \begin{cases}
                  1 & \text{for $t \geq 0$}.\\
                  0 & \text{otherwise}
                \end{cases}
\end{equation}

```python
def step_func(t):
    if t >= 0:
        u = 1
    else:
        u = 0
    return u

fig, ax = plt.subplots(1,2)
tc = np.arange(-6,6,1e-2)
td = np.arange(-6,6,1)
uc = np.array([step_func(ti) for ti in tc])
ud = np.array([step_func(ti) for ti in td])
ax[0].plot(tc,uc)
ax[0].axis((-5,5,0,2))
ax[0].set_title('Continuous')
ax[0].set_xlabel('time (t)')
ax[1].stem(td,ud)
ax[1].axis((-5,5,0,2))
ax[1].set_title('Discrete')
ax[1].set_xlabel('sample (n)');
```

***


#
 NISQAI: One-Qubit Quantum Classifier

 Ryan LaRose, Yousif Almulla, Nic Ezzell, Joe Iosue, Arkin Tikku

"Quantum computing needs more quantum software engineers to implement, test, and optimize quantum algorithms."
    -- Matthias Troyer.

"Experimentation on quantum testbeds is needed." -- John Preskill.

# What is NISQAI?

Quantum machine learning is an exciting but conjectural field that lacks testing on quantum computers. NISQAI is a library written to facilitate research in artificial intelligence on current quantum computers and NISQ (noisy intermediate-scale quantum) devices.

In this notebook, we demonstrate the steps toward a quantum neural network working as a simple classifier. We then demonstrate how easily the NISQAI library can facilitate this task.

# Requirements for this Notebook

Since you're reading this, you probably have a working installation of [Python](https://www.python.org/) and/or [Jupyter](http://jupyter.org/). In order to run this notebook, the following external packages are necessary. Installation instructions can be found by following the hyperlinks for each.

* [NumPy](http://www.numpy.org/)
* [SciPy](https://www.scipy.org/)
* [pyQuil](https://pyquil.readthedocs.io/en/stable/) >= v2.4.0.
* [Matplotlib](https://matplotlib.org/)
* [QuTiP](http://qutip.org/docs/4.1/index.html) (only for Bloch sphere visualization features)

Requires: Rigetti `qvm` to be running. This is commonly done by executing `qvm -S` in a terminal.

```python
# builtins
import time

# standard imports
import matplotlib.pyplot as plt
import numpy as np
from scipy.optimize import minimize

# library imports
from pyquil import Program, get_qc
from pyquil import api
import pyquil.gates as ops
from qutip import Bloch, ket

# option for inline plotting
%matplotlib inline
```

# Quantum Neural Networks: A Five Step Process

To implement a neural network for classical data on a quantum computer (i.e., a _quantum neural network_), the following steps need to be performed.

1. Data encoding.
    * Encode the classical data into a form suitable for the quantum circuit.
1. State preparation.
    * Implement a quantum circuit preparing the state of a particular datum.
1. Unitary evolution.
    * Implement a parameterized gate sequence to transform the data.
1. Measurement/non-linearity.
    * Measure the state of the quantum data after unitary evolution to revert back to classical information.       * Non-linearity can be introduced at this point via classical processing.
1. Training/cost function.
    * Define a cost function and optimize the parameters of the unitary to minimize the cost.

In the rest of the notebook, we'll go over how to do each of these steps in turn for a simple classification problem. At the end of the notebook, we'll show how easily this can be done with the NISQAI library and discuss other features of NISQAI.

# Problem Description

The specific problem we look it in this notebook falls under the category of _supervised machine learning_. Specifically, we ask:

_Given $N$ data points $p = (x, y) \in \mathbb{R}^2$ with labels $z(p) = 1$ if $x \le 0.5$ and $z(p) = 0$ otherwise, learn the decision boundary and classify new points._

## 1. Data Encoding

In order to run a quantum neural network on classical data, the classical data needs to be suitably encoded to allow it to be "uploaded" to a quantum computer. We demonstrate a simple encoding scheme below for $(x, y)$ Cartesian coordinates distributed uniformly in a unit square.

### Generating data

First we generate a random set of classical data. For reproducibility, we'll seed our random number generator.

```python
# set random seed for reproducible results
SEED = 1059123109
np.random.seed(seed=SEED)
```

```python
# generate data points distributed uniformly at random in [0, 1) x [0, 1)
npoints = 100
data = np.random.rand(npoints, 2)

def predicate(point):
    """Returns true if the point satisfies the predicate, else false."""
    return True if point[0] <= 0.5 else False

# separate the data with a linear boundary y = x
labels = np.array([1 if predicate(p) else 0 for p in data])

# plot the line y = x
xs = np.linspace(0, 1, 100)
ys = 0.5 * np.ones_like(xs)
plt.plot(ys, xs, '--k')

# plot the data with the color key BLUE = 0 = LEFT, GREEN = 1 = RIGHT
for i in range(npoints):
    if labels[i] == 0:
        ckey = 'g'
    else:
        ckey = 'b'
    plt.scatter(data[i, 0], data[i, 1], color=ckey)

# title and axis lables
plt.title("Random Data Points in Unit Square", fontweight="bold", fontsize=16)

# put on a grid and show the plot
plt.grid()
plt.show()
```

### Qubit Encoding

Here we implement a method of data encoding that we call "qubit encoding." Qubit encoding works by writing two bits of classical information into one bit of quantum information. How is this done? Note that any qubit can be written

\begin{equation}
|\psi\rangle = \alpha |0\rangle + \beta |1\rangle
\end{equation}

where $\alpha, \beta \in \mathbb{C}$ satisfying $|\alpha|^2 + |\beta|^2 = 1$. Because of this normalization condition, we may equivalently write

\begin{equation}
|\psi\rangle = \cos(\theta / 2) |0\rangle + e^{i \phi} \sin(\theta / 2)|1\rangle
\end{equation}

We then encode information into the phases $0 \le \theta \le \pi$ and $ 0 \le \phi \le 2 \pi$.

For the $(x, y)$ coordinates of our points, we will use the encoding

\begin{align}
\theta &= \pi x \\
\phi &= 2 \pi y
\end{align}

This is a simple encoding, but the results of the classification will speak to it's effectivenss. Other encodings, for example

\begin{align}
\theta &= \frac{1}{2} \tan^{-1}\left( \frac{y}{x} \right) \\
\phi &= \pi (x^2 + y^2),
\end{align}

can be used for other data sets.

In code, we may make this transformation as follows:

```python
# encode the classical data via a simple linear "qubit encoding"
qdata = np.zeros_like(data)
for (index, point) in enumerate(data):
    qdata[index][0] = np.pi * point[0]
    qdata[index][1] = 2 * np.pi * point[1]
```

It's now possible to visualize the data as points on the surface of the Bloch sphere.

```python
# empty lists to store values
gxs = []
gys = []
gzs = []
bxs = []
bys = []
bzs = []

# loop over all points, convert to cartesian coords, and append to correct list
for (index, point) in enumerate(qdata):
    theta = point[0]
    phi = point[1]
    sin = np.sin(theta)
    # convert to cartesian coords for plotting
    x = np.sin(theta) * np.cos(phi)
    y = np.sin(theta) * np.sin(phi)
    z = np.cos(theta)
    if labels[index] == 0:
        gxs.append(x)
        gys.append(y)
        gzs.append(z)
    else:
        bxs.append(x)
        bys.append(y)
        bzs.append(z)

# format points correctly for bloch sphere
lpoints = [gxs, gys, gzs]
rpoints = [bxs, bys, bzs]

# get a Bloch sphere
bloch = Bloch()

# options for Bloch sphere
bloch.frame_color = "grey"
bloch.sphere_color = "white"
bloch.point_size = [40]
bloch.sphere_alpha = 0.50
bloch.point_color = ["g", "b"]
bloch.point_marker = ["o"]
bloch.view = [-55, 10]

# add the points on the Bloch sphere
bloch.add_points(lpoints)
bloch.add_points(rpoints)

# show the Bloch sphere
bloch.show()
```

## 2. State Preparation

Now that we have encoded our data, we still need to prepare it in quantum form. Conventionally, all qubits start in the $|0\rangle$ state. The problem we have to solve is:

Given angles $\theta, \phi$, perform the mapping

\begin{equation}
S(\theta, \phi) |0\rangle \rightarrow |\psi\rangle = \cos(\theta / 2) |0\rangle + e^{i \phi} \sin(\theta / 2)|1\rangle
\end{equation}

We call $S$ a _state preparation unitary_ or _state preparation circuit_.

It is clear from the equation above that the matrix representation for $S$ in the computational basis is

\begin{equation}
S(\theta, \phi) = \left[ \begin{matrix}
\cos(\theta / 2) & e^{-i \phi} \sin(\theta / 2)\\
e^{i \phi} \sin(\theta / 2) & - \cos(\theta / 2)  \\
  \end{matrix} \right]
\end{equation}

On current NISQ computers, circuits need to be as short-depth as possible, meaning they contain as few gates as possible. The "qubit encoding" performed above allows for a constant-depth state preparation circuit for any given values of $\theta, \phi$.

Why is this? In general we need to perform some arbitrary unitary transformation $S \in SU(2)$. It is known that such a unitary can be implemented with five standard rotations that can be implemented by current quantum computers. Namely,

\begin{equation}
S = R_z(\gamma_1) R_x(\pi / 2) R_z(\gamma_2) R_x(\pi / 2) R_z(\gamma_3)
\end{equation}

where $0 \le \gamma_i \le 2 \pi$ for $i = 1, 2, 3$. We can define our gate $S$ above in pyquil and compile it to a sequence of five implementable rotations by using the Quil compiler. First we'll write a function to give us our state preparation matrix.

```python
# define the matrix S as a function of theta and phi
def state_prep_mtx(theta, phi):
    """Returns the state preparation matrix according to the qubit encoding above."""
    return np.array([[np.cos(theta / 2), np.exp(-1j * phi) * np.sin(theta / 2)],
                     [np.exp(1j * phi) * np.sin(theta / 2), - np.cos(theta / 2)]])
```

Next we'll define this as a pyquil gate. We do this by first making a program then defining a gate for a particular encoded data point.

```python
# get a pyquil program
qprog = Program()

# pick a particular encoded data point
theta, phi = qdata[0]

# get the state prep matrix for this point
S = state_prep_mtx(theta, phi)

# define a gate in pyquil for the state preparation
qprog.defgate("S", state_prep_mtx(theta, phi))
```

    <pyquil.quil.Program at 0x7fe51b70bcc0>

We can now use this gate in a quantum program to input our classical data into the quantum computer. To do this, we simply write:

```python
# prepare the first encoded data point in quantum form
qprog += ("S", 0)
```

To see our program, we can print it out:

```python
print(qprog)
```

    DEFGATE S:
        0.0767318819324465, 0.7019079174828263-0.7081225131783532i
        0.7019079174828263+0.7081225131783532i, -0.0767318819324465

    S 0

If we measure this qubit now, we get either a zero or a one with probability proportional to it's amplitudes. We'll add this measurement to our program and then run it many times to get an estimate of the probability distribution of it's outcome.

```python
# run the program many times
shots = 1000
qvm = get_qc('1q-qvm')  # connect to QVM with 2 quibts

dist = qvm.run_and_measure(qprog, shots)
```

```python
%matplotlib inline

def histogram(dist):
    """Makes a histogram of the probability of obtaining 0 and 1."""
    # get the zero and one probabilities
    f0 = lambda x: x==0
    f1 = lambda x: x==1
    prob0 = len(list(filter(f0, dist[0]))) / shots
    prob1 = len(list(filter(f1, dist[0]))) / shots

    # make a bar plot
    plt.bar([0, 1], [prob0, prob1])

    # make it look nice
    plt.grid()
    plt.title("Output Probability Distribution", fontsize=16, fontweight="bold")
    plt.xlabel("Measurement Outcomes", fontsize=14)
    plt.ylabel("Probability", fontsize=14)
    plt.ylim(0, 1)
    plt.xticks([0, 1])

    # show it
    plt.show()

# make a histrogram of the output distribution
histogram(dist)
```

We want our output distribution to move towards either zero or one, depending on what label is assigned to that particular data point. To do this, we implement unitary evolution on the circuit, which corresponds to implementing another sequence of gates in our circuit.

## 3. Unitary Evolution

After the classical data has been encoded (step 1) and prepared into the quantum system (step 2), step 3 is to perform unitary evolution on the quantum state representing the data. In the language of classical learning theory, this corresponds to implementing a layer of the neural network.

In the quantum neural network case, we simply need to implement a sequence of parameterized gates.

First we write a function to easily implement such a unitary.

```python
def unitary(angles):
    """Returns a circuit implementing the unitary Rz(theta0) P Rz(theta1) P Rz(theta2)
    where thetea0 = angles[0], theta1 = angles[1], and theta2 = angles[2] and
    P = Rx(pi / 2) is a pi / 2 pulse.
    """
    return Program(
        ops.RZ(angles[0], 0),
        ops.RX(np.pi / 2, 0),
        ops.RZ(angles[1], 0),
        ops.RX(np.pi / 2, 0)
    )
```

Next we use it to add unitary evolution to our quantum neural network for some given angles.

```python
# test angles
angles = 2 * np.pi * np.random.rand(2)

# append the unitary circuit to our quantum neural network program
qprog += unitary(angles)
```

```python
print(qprog)
```

    DEFGATE S:
        0.0767318819324465, 0.7019079174828263-0.7081225131783532i
        0.7019079174828263+0.7081225131783532i, -0.0767318819324465

    S 0
    RZ(0.3142835597303074) 0
    RX(pi/2) 0
    RZ(4.277790366298182) 0
    RX(pi/2) 0

Now we'll run this new circuit with the unitary evolution implemented.

```python
# run the quantum neural network program
histogram(qvm.run_and_measure(qprog, shots))
```

Now we want to use this unitary evolution to move our output probability towards the desired label. We'll suppose for a moment that this point is supposed to be mapped to the 0 output. We'll define an objective function ```obj_simple(angles)``` to capture the amount of 1 outputs, then we'll train over the angles in the unitary to minimize the objective.

```python
def make_program(pangles, uangles):
    """Returns a pyquil program that prepares the state according
    to pangles and applies the unitary according to uangles.
    """
    # instantiate a program
    qprog = Program()

    # define a gate in pyquil for the state preparation
    qprog.defgate("S", state_prep_mtx(pangles[0], pangles[1]))

    # write the program
    qprog += [("S", 0),
             unitary(uangles)]

    return qprog

def obj_simple(angles, shots=1000, verbose=False):
    """Returns the number of zero outputs of a single training example."""
    # make the program
    qprog = make_program([theta, phi], angles)
    if verbose:
        print(qprog)
    dist = qvm.run_and_measure(qprog, shots)

    f1 = lambda x: x == 1
    obj = len(list(filter(f1, dist[0]))) / shots

    print("The current value of the objective function is:", obj, end="\r")
    return obj
```

Now we'll minimize the objective function `obj_simple` by using the modifed `Powell` algorithm builtin to SciPy. The minimization here should take no more than 30 seconds.

```python
out = minimize(obj_simple, x0=2 * np.pi * np.random.rand(2), method="Powell")
```

    The current value of the objective function is: 0.001

Now we can verify that the output distribution skews this point towards the "zero bin."

```python
# do the circuit (neural network) with the optimal parameters
opt_angles = out['x']
qprog = make_program([theta, phi], opt_angles)

# show the output distribution
dist = qvm.run_and_measure(qprog, shots)
histogram(dist)
```

Indeed it does! Here we demonstrated that we can use our neural network to "steer" a point in a particular direction. The idea of training is to "steer" _all_ points in the desired direction. Before we train our quantum neural network, we first discuss the measurement process and it's importance in quantum neural network implementations.

## 4. Measurement

Measurement is used in our circuit above to translate from quantum to classical information. In other quantum neural networks, measurement can be used to introduce non-linearity.

NISQAI treats measurements as both a way to keep circuit-depth low (for implementations on current/near-term quantum computers) as well as introduce non-linearity between layers of a network. Specifically, measuremnets lead to (intermediate) classical data. On a classical computer, we can introduce any non-linear activation function we wish. The new classical data from the output of the activation function can then be prepared into quantum form again via steps 1 and 2 (data encoding and state preparation).

This intermediate classical post-processing is a trademark of NISQAI. It allows for:

1. Short-depth circuits suitable for NISQ computers.
1. Non-linearity between layers of a quantum neural network.
1. Deep learning (many layers) with shallow circuits.

In this simple binary classifier example, we'll restrict to just one layer of the neural network for simplicity.

## 5. Training

We saw above that it's easy to steer one point towards the output we want to get. But training the quantum neural network involves training over all points in the training set. For this, we need to define a cost function that captures the total loss over the entire training set.

To do this, define the _indicator function_ $I(z_i = \hat{z}_i)$ to be 0 if $z_i = \hat{z}_i$ and 1 otherwise. Here, $z_i$ is the exact label of the $i$th training data $(x_i, y_i)$ and $\hat{z}_i$ is the prediction of this label by our neural network. (In code, we use the variables `labels` for $z_i$ and `predictions` for $\hat{z}_i$.)

To define the cost now, we simply sum over all points (say there are $M$ points) in the training data:

\begin{equation}
C = \sum_{i = 1}^{M} I(z_i = \hat{z}_i)
\end{equation}

Note that the cost $C$ depends on the unitary evolution $U$, i.e. $C = C(U)$, because the prediction $\hat{z}_i$ depends on $U$.

We now need to define a cost (objective) function ```obj(...)``` that takes into account all data in the training set. This function will encapsulate everything we've done for a single training point, but now for all of them.

```python
# fraction of total data to use as training data
train_frac = 0.7

def obj(uangles):
    """Returns the objective function C defined above over all training data.

    Args:
        uangles [type: list<float>]
            the angles in the unitary evolution.

    rtype: float
    """
    # grab some training data from the overall data set
    tpoints = int(train_frac * len(qdata))
    tdata = qdata[:tpoints]
    tlabels = labels[:tpoints]

    # initialize a variable to store the output predictions of the neural net
    predictions = np.zeros_like(tlabels, dtype=int)

    # loop over all training data to get the predictions
    for i, pangles in enumerate(tdata):
        # write the program
        qprog = make_program(pangles, uangles)

        # run the program
        out = qvm.run_and_measure(qprog, 1000)

        # get the output probabilities
        f0 = lambda x: x==0
        f1 = lambda x: x==1
        p0 = len(list(filter(f0, out[0]))) / shots
        p1 = len(list(filter(f1, out[0]))) / shots

        # take the prediction to be max(p0, p1)
        if p0 >= p1:
            predictions[i] = 0
        else:
            predictions[i] = 1

    # compute the difference of the labels and return the cost
    cost = sum(abs(predictions - tlabels)) / tpoints
    print("The current value of the cost function is:", cost, end="\r")
    return cost
```

Now let's compute the objective function for some random test angles.

```python
# get some random angles
angs = 2 * np.pi * np.random.rand(2)
cost = obj(angs)
```

    The current value of the cost function is: 0.24285714285714285

We don't want just any random angles for our unitary evolution, however. We want to compute the optimal angles, which we can do by minimizing the cost function. Again we'll use the modified ```Powell``` algorithm for minimization.

Training the neural network here will take more time than simply optimizing the output of a single point. On our computer, it takes around 15 seconds to evaluate the objective function for one set of angles. The `Powell` optimization algorithm generally takes on the order of 20-30 cost function evaluations--for us the optimization takes around 15 minutes on average. You can expect this training to take 15-20 minutes.

If you're in a hurry and don't want to wait for this training, we've saved a set of optimal angles from previous attempts. These angles are for a horizontal boundary $x = 0.5$ trained on 70/100 data points. They will only be optimal for the first set of random points generated by the given seed. We reiterate: _Only use these angles if you haven't changed anything in the notebook and only ran each cell once!_

```python
optimal_angles = [0.0, 0.0]
```

If you chose to skip the training, you should *not* execute the next two cells. Otherwise, continue through the notebook.

```python
# train the quantum neural network and time how long it takes
start = time.time()
out = minimize(fun=obj, x0=angs, method="Powell")
print("\nTotal training runtime took {} minutes.".format((time.time() - start) / 60))
```

    The current value of the cost function is: 0.014285714285714285
    Total training runtime took 8.968616827328999 minutes.

```python
# grab the optimal angles and minimal cost value
optimal_angles = out['x']
fval = out['fun']

# print them out
print(fval)
print(optimal_angles)
```

    0.0
    [0.32186503 0.17768155]

## Results

Now that we've trained on a subset of our data, we can see how the predictions do on the whole dataset.

First, we'll write a function that performs this task.

```python
def get_all_predictions(angles):
    """Returns a numpy array of all predictions."""
    # initialize a variable to store the output predictions of the neural net
    zhats = np.zeros_like(labels, dtype=int)

    # loop over all data to get predictions
    for i, pangles in enumerate(qdata):
        # write the program
        qprog = make_program(pangles, angles)

        # run the program
        out = qvm.run_and_measure(qprog, 1000)

        # get the output probabilities

        f0 = lambda x: x==0
        f1 = lambda x: x==1
        p0 = len(list(filter(f0, out[0]))) / shots
        p1 = len(list(filter(f1, out[0]))) / shots

        # take the prediction to be max(p0, p1)
        if p0 >= p1:
            zhats[i] = 0
        else:
            zhats[i] = 1
    return zhats
```

Then we'll call the function to get the results.

```python
# compute all the predictions of the quantum neural network
predictions = get_all_predictions(optimal_angles)
```

We have now trained our neural network and classifeid all data points. Let's display the statistics below to see how our QNN performed.

```python
# compute statistics of the QNN
ntrain = int(train_frac * npoints)
ncorrect = npoints - sum(abs(predictions - labels))
acc = ncorrect / npoints * 100

# print them out
print(" Results of quantum neural network classification ".center(80, "="))
print("Out of {} total data points:".format(npoints))
print("The QNN was trained on {}% of the total data ({} training points).".format(train_frac * 100, ntrain))
print("The QNN classified {} data points correctly ({}% accuracy).".format(ncorrect, acc))
print("".center(80, "="))

# plot the points, line y = x, and prediction
plt.plot(ys, xs, '--k')
for i in range(npoints):
    if predictions[i] == 0:
        ckey = 'g'
    else:
        ckey = 'b'
    plt.scatter(data[i, 0], data[i, 1], color=ckey)

plt.grid()
plt.title("QNN Predictions", fontsize=16, fontweight="bold")
plt.show()
```

# Discussion of Results

How did your quantum neural network perform? If you followed through this notebook sequentially, you should have seen 100% accuracy in the predictions of the network. A saved plot of our results obtained by running this notebook is included below:

Here, the exact decision boundary (not the learned one) is shown in the plot, and data points are colored according to the quantum neural network predictions. Blue means predicted left of the boundary (0 bin), and green means predicted right of the boundary (1 bin). As can be seen in our example results, all data points are correctly classified.

# Example Done with NISQAI

The above example involved five steps to implementing a neural network on a quantum computer. While the steps aren't hard, programming each one for every particular example is tedious and time consuming. More time could be spent testing quantum neural networks if there were a library to implement all of these steps.

This is where NISQAI comes in. NISQAI is to quantum machine learning what TensorFlow/pyTorch is to classical machine learning. Just like OpenFermion provides a library of code to simplify quantum chemistry on quantum computers, NISQAI provides a library of code to simplify neural networks and other machine learning algorithms on quantum computers.

The above classifier example in NISQAI could be done with the following few lines of code. Here, we assume that ```classical_data``` exists in the program.

## Prototype NISQAI Code

```
# imports
from nisqai import qnn

# get a quantum neural network
network = qnn.qnn(nodes=1, layers=1)

# encode the classical data into quantum form
network.encode(data=classical_data, scheme=qnn.encoding_schemes.SIMPLE_LINEAR)

# add the unitary evolution
network.add_unitary(qnn.ansatze.UNIVERSAL)

# add measurements to the network
network.add_measurements(basis=qnn.bases.Z)

# do the training on 70% of the total data
network.train(optimizer="Powell", fraction=0.7)

# test the neural network on all the data or on new input data
statistics = network.classify()
```

These few lines of code show the power of NISQAI. One of it's greatest strengths is the flexibility to research other quantum neural network implementations, not just the one qubit quantum classifier.

## Features of NISQAI

* Shatters domain barriers.
    * Whether you're a machine learning researcher interested in quantum or a quantum researcher interested in machine learning, NISQAI is built for you.
* Powerful builtins.
    * Numerous prototypes of quantum neural network architectures, including methods for data encoding, state preparation, unitary evolution, measurement, and training.
* Fully modular.
    * For methods like `qnn.add_unitary(...)`, the user can write a function to specify whichever unitary ansatz is desired.
* Loaded with examples.
    * Many instructive code snippets and notebooks to get you started in quantum machine learning.
* Open-source.
    * NISQAI is currently under development with expected release in early 2019. After this, NISQAI will be free and open-source. Always.
* Cross platform.
    * NISQAI works for Windows, Mac, and Linux.

# Conclusions

In this example, we've seen that a quantum neural network is able to successfully classify classical data in $\mathbb{R}^2$. The qubit encoding strategy is able to write two bits of classical information into one bit of quantum information. Additionally, the state preparation circuit for the qubit encoding is short-depth, particularly useful for NISQ computers. We've demonstrated how to train a quantum neural network using the modified Powell algorithm on a simple example. We've also discussed how the NISQAI library greatly simplifies quantum neural network implementations.

# Acknowledgements

The development of NISQAI is supported by the [unitary.fund](http://unitary.fund/). We thank [Will Zeng](https://twitter.com/wjzeng) for creating and running this program, as well as [John Hering](https://twitter.com/johnhering), Jeff Cordova, Nima Alidoust, and [PLOS](https://www.plos.org/) for sponsoring it.