# Exploring a generic Markov model of chromatin accessibility

Last updated by: Jonathan Liu, 4/23/2021

Here, we will explore a generic Markov chain model of chromatin accessibility, where we model chromatin with a series of states and Markov transitions between them. Of interest is the onset time, the time it takes for the system to reach the final, transcriptionally competent state. We will indicate that the limit of equal, irreversible reactions is the limit of noise performance and that allowing for some reversibility weakens performance. We will then show that with a transient input, the model can achieve much better performance.

```python
#Import necessary packages
%matplotlib inline
import numpy as np
from scipy.spatial import ConvexHull
import matplotlib.pyplot as plt
import scipy.special as sps
from IPython.core.debugger import set_trace
from numba import njit, prange
import numba as numba
from datetime import date
import time as Time
import seaborn as sns

#Set number of threads
numba.set_num_threads(4)
```

```python
# PBoC plotting style (borrowed from Manuel's github)
def set_plotting_style():
    """
    Formats plotting enviroment to that used in Physical Biology of the Cell,
    2nd edition. To format all plots within a script, simply execute
    `mwc_induction_utils.set_plotting_style() in the preamble.
    """
    rc = {'lines.linewidth': 1.25,
          'axes.labelsize': 12,
          'axes.titlesize': 12,
          'axes.facecolor': '#E3DCD0',
          'xtick.labelsize': 12,
          'ytick.labelsize': 12,
          'xtick.color': 'white',
          'xtick.direction': 'in',
          'xtick.top': True,
          'xtick.bottom': True,
          'xtick.labelcolor': 'black',
          'ytick.color': 'white',
          'ytick.direction': 'in',
          'ytick.left': True,
          'ytick.right': True,
          'ytick.labelcolor': 'black',
          'font.family': 'Arial',
          #'grid.linestyle': '-', Don't use a grid
          #'grid.linewidth': 0.5,
          #'grid.color': '#ffffff',
          'axes.grid': False,
          'legend.fontsize': 8}
    plt.rc('text.latex', preamble=r'\usepackage{sfmath}')
    #plt.rc('xtick.major', pad=5)
    #plt.rc('ytick.major', pad=5)
    plt.rc('mathtext', fontset='stixsans', sf='sansserif')
    plt.rc('figure', figsize=[3.5, 2.5])
    plt.rc('svg', fonttype='none')
    plt.rc('legend', title_fontsize='12', frameon=True,
           facecolor='#E3DCD0', framealpha=1)
    sns.set_style('darkgrid', rc=rc)
    sns.set_palette("colorblind", color_codes=True)
    sns.set_context('notebook', rc=rc)

# Some post-modification fixes that I can't seem to set in the rcParams
def StandardFigure(ax):
    ax.tick_params(labelcolor='black')
    ax.xaxis.label.set_color('black')
    ax.yaxis.label.set_color('black')

set_plotting_style()
```

```python
#Function to generate a random transition matrix for a generic Markov chain with n states, and an irreversible
#transition into the final state.

#Inputs:
#   n: number of states
#   k_min: minimum transition rate
#   k_max: maximum transition rate

#pseudocode
#generate 2D matrix based on n
#loop over each index, if indices are connected by 1 then generate a value (except final state)
#Calculate diagonal elements from summing columns to zero
#

def MakeRandomTransitionMatrix(n, k_min, k_max):
    #Initialize the transition matrix
    Q = np.zeros((n,n))

    #Loop through transition indices (note that the final column is all zeros since it's an absorbing state)
    for i in range(n):
        for j in range(n-1):
            #If the indices are exactly one apart (i.e. adjacent states), then make a transition rate
            if np.abs(i-j) == 1:
                Q[i,j] = np.random.uniform(k_min,k_max)

    #Calculate the diagonal elements by taking the negative of the sum of the column
    for i in range(n-1):
        Q[i,i] = -np.sum(Q[:,i])

    return Q

```

```python
#Function to generate a transition matrix for equal, irreversible transitions (i.e. Gamma distribution results)
#We assume the final state is absorbing

#Inputs:
#   n: number of states
#   k: transition rate

def MakeGammaDistMatrix(n, k):
    #Initialize the transition matrix
    Q = np.zeros((n,n))

    #Loop through transition indices (note that the final column is all zeros since it's an absorbing state)
    for i in range(n):
        for j in range(n-1):
            #All forward transitions are equal to k
            if i == j + 1:
                Q[i,j] = k

    #Calculate the diagonal elements by taking the negative of the sum of the column
    for i in range(n-1):
        Q[i,i] = -np.sum(Q[:,i])

    return Q

#Similar function for making a transition matrix with equal forward transitions of magnitude k and
#equal backward transitions of magnitude k * f
def MakeEqualBidirectionalMatrix(n, k, f):
    #Initialize the transition matrix
    Q = np.zeros((n,n))

    #Loop through transition indices (note that the final column is all zeros since it's an absorbing state)
    for i in range(n):
        for j in range(n-1):
            #All forward transitions are equal to k
            if i == j + 1:
                Q[i,j] = k
            elif i == j - 1:
                Q[i,j] = k * f

    #Calculate the diagonal elements by taking the negative of the sum of the column
    for i in range(n-1):
        Q[i,i] = -np.sum(Q[:,i])

    return Q

```

```python
#Simulation for calculating onset times for a generic Markov chain using Gillespie algorithm
#Using vectorized formulation for faster speed

def CalculatetOn_GenericMarkovChainGillespie(Q,n,N_cells):
#Calculates the onset time for a linear Markov chain with forward and backward rates.
#The transition rate can be time-varying, but is the same
#global rate for each transition. The model assumes n states, beginning
#in the 1st state. Using the Gillespie algorithm and a Markov chain formalism, it
#simulates N_cells realizations of the overall time it takes to reach the
#nth state.

#For now, this only works with steady transition rates. Later we will modify this to account
#for time-varying rates.

# Inputs:
#   Q: transition rate matrix, where q_ji is the transition rate from state i to j for i =/= j and
#      q_ii is the sum of transition rates out of state i
#   n: number of states
#   N_cells: number of cells to simulate

# Outputs:
#   t_on: time to reach the final state for each cell (length = N_cells)

## Setup variables
    t_on = np.zeros(N_cells) #Time to transition to final ON state for each cell
    state = np.zeros(N_cells, dtype=int) #State vector describing current state of each cell

    ## Run simulation
    # We will simulate waiting times for each transition for each cell and stop once each cell has
    # reached the final state

    #Set diagonal entries in transition matrix to nan since self transitions don't count
    for i in range(n):
        Q[i,i] = 0

    #Construct the transition vector out of each cell's current state
    Q_states = np.zeros((N_cells,n))
    while np.sum(state) < (n-1)*N_cells:
        Q_states = np.transpose(Q[:,state])

        #Generate random numbers in [0,1] for each cell
        randNums = np.random.random(Q_states.shape)

        #Calculate waiting times for each entry in the transition matrix
        #Make sure to suppress divide by zero warning
        with np.errstate(divide='ignore'):
            tau = (1/Q_states) * np.log(1/randNums)

        #Find the shortest waiting time to figure out which state we transitioned to for each cell
        tau_min = np.amin(tau, axis=1)
        newState = np.argmin(tau, axis=1)

        #Replace infinities with zero, corresponding to having reached the final state
        newState[tau_min==np.inf] = n-1
        tau_min[tau_min==np.inf] = 0

        #Update the state and add the waiting time to the overall waiting time
        state = newState
        t_on += tau_min
    return t_on
```

```python
#Simulation for calculating onset times for a generic Markov chain using Gillespie algorithm
#Using vectorized formulation for faster speed

def CalculatetOn_GenericMarkovChainGillespieTime(Q,n,t_d,N_cells):
#Calculates the onset time for a linear Markov chain with forward and backward rates.
#The transition rate can be time-varying, but is the same
#global rate for each transition. The model assumes n states, beginning
#in the 1st state. Using the Gillespie algorithm and a Markov chain formalism, it
#simulates N_cells realizations of the overall time it takes to reach the
#nth state.

#This considers time-dependent transition rates parameterized by a diffusion timescale t_d.
#The time-dependent rate has the form r ~ (1 - exp(-t/t_d)). For now, we assume only the forwards
#rates have the time-dependent profile, and that backwards rates are time-independent.

# Inputs:
#   Q: 3D transition rate matrix, where q_kji is the transition rate at time k from state i to j for i =/= j and
#      q_kii is the sum of transition rates out of state i
#   n: number of states
#   t_d: diffusion timescale of time-dependent transition rate
#   N_cells: number of cells to simulate

# Outputs:
#   t_on: time to reach the final state for each cell (length = N_cells)

## Setup variables
    t_on = np.zeros(N_cells) #Time to transition to final ON state for each cell
    time = np.zeros(N_cells) #Vector of current time for each cell
    state = np.zeros(N_cells, dtype=int) #State vector describing current state of each cell

    ## Run simulation
    # We will simulate waiting times for each transition for each cell and stop once each cell has
    # reached the final state

    #Set diagonal entries in transition matrix to nan since self transitions don't count
    for i in range(n):
        Q[i,i] = 0

    #Define the diffusion timescale matrix t_d (finite for forwards rates, effectively 0 for backwards rates)
    t_d_mat = np.zeros((n,n))
    t_d_mat[:,:] = 0.00000001 #Non forwards transitions are essentially 0 diffusive timescale
    for i in range(n):
        for j in range(n-1):
            #Forwards rates
            if i == j + 1:
                t_d_mat[i,j] = t_d

    #Construct the transition vector out of each cell's current state
    Q_states = np.zeros((N_cells,n))
    #Construct the diffusion timescale vector for each cell
    t_d_states = np.zeros((N_cells,n))
    while np.sum(state) < (n-1)*N_cells:
        Q_states = np.transpose(Q[:,state])
        t_d_states = np.transpose(t_d_mat[:,state])

        #Construct the current time vector for each cell
        time_states = np.transpose(np.tile(time,(n,1)))

        #Generate random numbers in [0,1] for each cell
        randNums = np.random.random(Q_states.shape)

        #Calculate waiting times for each entry in the transition matrix
        #Make sure to suppress divide by zero warning

        #For the exponential profile, this uses the lambertw/productlog function. The steady-state
        #case corresponds to t_d -> 0.
        with np.errstate(divide='ignore', invalid='ignore'):
            #Temp variables for readability
            a = 1/Q_states * np.log(1/randNums)
            b = -np.exp(-(a + t_d_states * np.exp(-time_states/t_d_states) + time_states)/t_d_states)
            tau = np.real(t_d_states * sps.lambertw(b) + a + t_d_states *\
                          np.exp(-time_states / t_d_states))
        #Find the shortest waiting time to figure out which state we transitioned to for each cell
        tau_min = np.amin(tau, axis=1)
        newState = np.argmin(tau, axis=1)

        #Replace infinities with zero, corresponding to having reached the final state
        newState[tau_min==np.inf] = n-1
        tau_min[tau_min==np.inf] = 0

        #Update the state and add the waiting time to the overall waiting time
        state = newState
        t_on += tau_min
        time += tau_min
    return t_on
```

# The steady-state regime
First, let's get a feel for the model in the steady-state case.

We consider a Markov chain with $k+1$ states labeled with indices $i$, with the first state labeled with index $0$. The system will begin in state $0$ at time $t=0$ and we will assume the final state $k$ is absorbing. For example, this could correspond to the transcriptionally competent state. We will allow for forwards and backwards transition rates between all states, except for the final absorbing state, which will have no backwards transition out of it. Denote the transition from state $i$ to state $j$ with the transition rate $\beta_{i,j}$. So,, we have the reaction network:

\begin{equation}
0 \underset{\beta_{1,0}}{\overset{\beta_{0,1}}{\rightleftharpoons}} 1 \underset{\beta_{2,1}}{\overset{\beta_{1,2}}{\rightleftharpoons}} ... \overset{\beta_{k-1,k}}{\rightarrow} k
\end{equation}

We will be interested in the mean and variance of the distribution of times $P_k(t)$ to start at state $0$ and reach the final state $k$.

We will first consider the simple case where the transition rates $\beta$ are constant in time, and that we have only forward transitions that are all equal in magnitude. In this case, the distribution $P_k(t)$ is simply given by a Gamma distribution with shape parameter $k$ and rate parameter $\beta$. $P_k(t)$ then has the form

\begin{equation}
P_k(t) = \frac{\beta^k}{\Gamma(k)}t^{k-1}e^{-\beta t}
\end{equation}

where $\Gamma$ is the Gamma function. Below we show analytical and simulated results for the distribution of onset times.

```python
#Let's visualize the distribution of onset times for the Gamma distribution case

#Function for analytical Gamma distribution
def GamPDF(x,shape,rate):
    return x**(shape-1)*(np.exp(-x*rate) / sps.gamma(shape)*(1/rate)**shape)

#Pick some parameters
beta = 1 #transition rate
n = np.array([2,3,4]) #number of states
k = n-1 #Number of steps

#Simulate the distributions
N_cells = 10000

t_on = np.zeros((len(n),N_cells))

for i in range(len(n)):
    Q = MakeGammaDistMatrix(n[i], beta) #Transition matrix
    t_on[i,:] = CalculatetOn_GenericMarkovChainGillespie(Q,n[i],N_cells)

#Plot results
colors = ['tab:blue','tab:red','tab:green']
bins = np.arange(0,10,0.5)
t = np.arange(0,10,0.1)
ToyModelDist = plt.figure()
#plt.title('Onset distributions for equal, irreversible transitions')

for i in range(len(k)):
    plt.hist(t_on[i,:],bins=bins,density=True,alpha=0.5,label='simulation k=' + str(k[i]),
            color=colors[i],linewidth=0)
    plt.plot(t,GamPDF(t,n[i]-1,beta),'--',label='theory k=' + str(k[i]),color=colors[i])

plt.xlabel('onset time')
plt.ylabel('frequency')
plt.legend()
StandardFigure(plt.gca())
plt.show()
```

The mean $\mu_k$ and variance $\sigma^2_k$ have simple analytical expressions and are given by

\begin{equation}
\mu_k = \frac{k}{\beta} \\
\sigma^2_k = \frac{k}{\beta^2}
\end{equation}

For this analysis, we will consider a two-dimensional feature space consisting of the mean onset time on the x-axis and the squared CV (variance divided by square mean) in the onset time on the y-axis. The squared CV is a measure of the "noise" of the system at a given mean. For this simple example then:

\begin{equation}
\mu_k = \frac{k}{\beta} \\
CV^2_k = \frac{1}{k}
\end{equation}

Thus, for this scenario with equal, irreversible reactions, the squared CV is independent of the transition rates $\beta$ and depends only on the number of steps $k$. Plotting in our feature space results in a series of horizontal lines, with each line corresponding to the particular number of steps in the model.

```python
#Setting up our feature space
beta_min = 0.5 #Minimum transition rate
beta_max = 5 #Maximum transition rate
beta_step = 0.1 #Resolution in transition rates
beta_range = np.arange(beta_min,beta_max,beta_step)
n = np.array([2,3,4,5]) #Number of states

means = np.zeros((len(n),len(beta_range)))
CV2s = np.zeros((len(n),len(beta_range)))

#Simulate results
for i in range(len(n)):
    for j in range(len(beta_range)):
        Q = MakeGammaDistMatrix(n[i], beta_range[j])
        t_on = CalculatetOn_GenericMarkovChainGillespie(Q,n[i],N_cells)
        means[i,j] = np.mean(t_on)
        CV2s[i,j] = np.var(t_on)/np.mean(t_on)**2

#Plot results
meanVals = np.arange(0,10,0.1)
CV2Pred = np.zeros((len(n),len(meanVals)))
colors = ['tab:blue','tab:red','tab:green','tab:purple']
for i in range(len(n)):
    CV2Pred[i,:] = (1/(n[i]-1)) * np.ones(len(meanVals))
ToyModelFeatureSpace = plt.figure()
#plt.title('Feature space for equal, irreversible reactions')

for i in range(len(n)):
    plt.plot(means[i,:],CV2s[i,:],'.',label='simulation k=' + str(n[i]-1),color=colors[i])
    plt.plot(meanVals,CV2Pred[i,:],'--',label='theory k=' + str(n[i]-1),color=colors[i])

plt.xlabel('mean')
plt.ylabel('CV^2')
plt.legend()
StandardFigure(plt.gca())
plt.show()
```

What happens if we now allow for backwards transitions as an extension to this ideal case? We'll retain the idea of equal forward transition rates $\beta$, but now allow for equal backwards transitions of magnitude $\beta f$ (except from the final absorbing state $k$).

\begin{equation}
0 \underset{\beta f}{\overset{\beta}{\rightleftharpoons}} 1 \underset{\beta f}{\overset{\beta}{\rightleftharpoons}} ... \overset{\beta}{\rightarrow} k
\end{equation}

We will investigate what happens when we vary $f$. Let's see what happens for $k=3$ steps.

```python
#Setting up parameters
n = 3
beta_min = 0.1
beta_max = 5.1
beta_step = 0.1
beta_range = np.arange(beta_min,beta_max,beta_step)
N_cells = 10000

#Backwards transitions
f = np.arange(0,4,1) #fractional magnitude of backwards transition relative to forwards
means = np.zeros((len(beta_range),len(f)))
CV2s = np.zeros((len(beta_range),len(f)))

for i in range(len(beta_range)):
    for j in range(len(f)):
        Q = MakeEqualBidirectionalMatrix(n,beta_range[i],f[j])
        t_on = CalculatetOn_GenericMarkovChainGillespie(Q,n,N_cells)
        means[i,j] = np.mean(t_on)
        CV2s[i,j] = np.var(t_on) / np.mean(t_on)**2

#Plot results
#Distribution for fixed beta and varying f
beta = 1
bins = np.arange(0,20,0.5)
t = np.arange(0,10,0.1)
colors = ['tab:blue','tab:red','tab:green','tab:purple']

BackwardsDist = plt.figure()

for i in range(len(f)):
    Q = MakeEqualBidirectionalMatrix(n,beta,f[i])
    t_on = CalculatetOn_GenericMarkovChainGillespie(Q,n,N_cells)
    plt.hist(t_on,bins=bins,density=True,alpha=0.3,label='f = ' + str(f[i]),
             linewidth=0,color=colors[i])

plt.xlabel('onset time')
plt.ylabel('frequency')
plt.legend()
StandardFigure(plt.gca())
plt.show()

BackwardsFeatureSpace = plt.figure()
#plt.title('Investigation of impact of backwards rates on feature space (k=2)')
plt.plot((n-1)/beta_range,(1/(n-1))*np.ones(beta_range.shape),'k--',label='Gamma dist. limit')

for i in range(len(f)):
    plt.plot(means[:,i],CV2s[:,i],'.',label='f = ' + str(f[i]),color=colors[i])

plt.xlabel('mean')
plt.ylabel('CV^2')
plt.legend()
StandardFigure(plt.gca())
plt.show()
```

We see that as the backwards transition rate increases, the overall noise increases! This makes intuitive sense, since with a backwards transition rate, the system is more likely to spend extra time hopping between states before reaching the final absorbing state, increasing the overall time to finish as well as the variability in finishing times.

Because actual irreversible reactions are effectively impossible to achieve in reality, the performance of the Gamma distribution model (i.e. equal, irreversible forward transitions) represents a bound to the noise performance of a real system. With this more realistic scenario of backwards transitions, the overall noise is higher.

# Transients help improve noise performance

In the steady-state regime, the only way to decrease the noise (i.e. squared CV) in onset times was to increase the number of steps. What about in the transient regime?

Here, we will investigate the changes to this parameter space by using a transient rate $\beta(t)$. This is of biological interest because many developmental processes occur out of steady state. For example, several models of chromatin accessibility hypothesize that the rate of chromatin state transitioning is coupled to the activity of pioneer factors like Zelda. During each rapid cell cycle division event in the early fly embryo, the nuclear membrane breaks down and reforms again, and transcription factors are expelled and re-introduced back into the nucleus. Thus, after each division event, there is a transient period during which the concentration of pioneer factors at a given gene locus is out of steady state.

For now, we will assume a reasonable form for the transition rate. Let's assume that forward transition rates are mediated by the concentration of a pioneer factor like Zelda, e.g. in some on-rate fashion. Considering $\beta$ to be a proxy for Zelda concentration, for example, we will write down this transient $\beta(t)$ as the result of a simple diffusive process with form

\begin{equation}
\beta(t) = \beta (1 - e^{-t / \tau} )
\end{equation}

Here, $\beta$ is the asymptotic, saturating value of $\beta(t)$, and $\tau$ is the time constant governing the time-varying nature of the transition rate. For a diffusive process, $\tau$ would be highly dependent on the diffusion constant, for example.

For comparison, the time plots of the constant and transient input are shown below, for $\tau = 3$ and $\beta = 1$.

```python
#Looking at steady-state vs input transient profiles
time = np.arange(0,10,0.1)
dt = 0.1
w_base = 1
w_const = w_base * np.ones(time.shape)
N_trans = 2
N_cells = 1000

#Now with transient exponential rate
tau = 3
w_trans = w_base * (1 - np.exp(-time / tau))

#Plot the inputs
TransientInputs = plt.figure()
#plt.title('Input transition rate profiles')
plt.plot(time,w_const,label='constant',color='tab:blue')
plt.plot(time,w_trans,label='transient',color='tab:red')
plt.xlabel('time')
plt.ylabel('rate')
plt.legend()
StandardFigure(plt.gca())
TransientInputs.set_figheight(1) #Make this figure short for formatting purposes
plt.show()
```

Because of the time-varying nature of $\beta(t)$, the resulting distribution $P_k(t)$ for the case of equal, irreversible forward transition rates no longer obeys a simple Gamma distribution, and an analytical solution is difficult (or even impossible). Nevertheless, we can easily simulate the distributions numerically, shown below.

```python
#Let's visualize the distribution of onset times for the case of equal, irreversible forward transition rates,
#comparing steady-state and transient input profiles, varying the "diffusion" constant tau

#Pick some parameters
beta = 1 #transition rate
n = 3 #Number of states
tau = np.array([1,3])

#Simulate the distributions
N_cells = 10000

#Steady state
Q_steady = MakeGammaDistMatrix(n, beta)
t_on_steady = CalculatetOn_GenericMarkovChainGillespie(Q_steady,n,N_cells)

#Transient
t_on_trans = np.zeros((len(tau),N_cells))

for i in range(len(tau)):
    Q = MakeGammaDistMatrix(n, beta) #Transition matrix
    t_on_trans[i,:] = CalculatetOn_GenericMarkovChainGillespieTime(Q,n,tau[i],N_cells)

#Plot results
bins = np.arange(0,10,0.25)
colors = ['tab:red','tab:green']
TransientDist = plt.figure()
#plt.title('Onset distributions for k=2 equal, irreversible transitions, steady-state vs. transient input')

plt.hist(t_on_steady,bins=bins,density=True,alpha=0.5,label='steady state',
         linewidth=0,color='tab:blue')
for i in range(len(tau)):
    plt.hist(t_on_trans[i,:],bins=bins,density=True,alpha=0.5,linewidth=0,
             label='transient tau=' + str(tau[i]),color=colors[i])

plt.xlabel('onset time')
plt.ylabel('frequency')
plt.legend()
StandardFigure(plt.gca())
plt.show()
```

We see that increasing the time constant $\tau$ results in a rightward shift of the onset time distribution, as expected since the time-varying transition rate profile will results in slower initial transition rates. What impact does this have on the noise? Below we show the feature space holding $k=2$ fixed while varying $\tau$, and then holding $\tau=3$ fixed and varying $k$.

```python
#Exploring the impact of transient inputs
#First, fix k and vary tau
n = 3 #number of states
beta_min = 0.1
beta_max = 5.1
beta_step = 0.1
beta_range = np.arange(beta_min,beta_max,beta_step)
tau = np.arange(1,10,3)

#Simulate the distributions
N_cells = 5000

#Steady state
means_steady = np.zeros(len(beta_range))
CV2s_steady = np.zeros(len(beta_range))
for i in range(len(beta_range)):
    Q = MakeGammaDistMatrix(n, beta_range[i])
    t_on = CalculatetOn_GenericMarkovChainGillespie(Q,n,N_cells)
    means_steady[i] = np.mean(t_on)
    CV2s_steady[i] = np.var(t_on) / np.mean(t_on)**2

#Transient
means_trans = np.zeros((len(tau),len(beta_range)))
CV2s_trans = np.zeros((len(tau),len(beta_range)))

for i in range(len(tau)):
    for j in range(len(beta_range)):
        Q = MakeGammaDistMatrix(n, beta_range[j]) #Transition matrix
        t_on = CalculatetOn_GenericMarkovChainGillespieTime(Q,n,tau[i],N_cells)
        means_trans[i,j] = np.mean(t_on)
        CV2s_trans[i,j] = np.var(t_on) / np.mean(t_on)**2

#Plot results
colors = ['tab:blue','tab:red','tab:green']
TransientFeatureSpaceFixedK = plt.figure()
#plt.title('Investigation of transient inputs on feature space (k=2, tau varying)')
plt.plot((n-1)/beta_range,(1/(n-1))*np.ones(beta_range.shape),
         'k--',label='Gamma dist. limit in steady-state',color='black')
plt.plot(means_steady,CV2s_steady,'k.',label='steady state simulation',color='black')

for i in range(len(tau)):
    plt.plot(means_trans[i,:],CV2s_trans[i,:],'.',
             label='transient simulation tau=' + str(tau[i]),color=colors[i])

plt.xlabel('mean')
plt.ylabel('CV^2')
plt.legend()
StandardFigure(plt.gca())
plt.show()

#Now fix tau and vary k
n =  np.array([2,3,4,5])
tau = 3

#Simulate the distributions
N_cells = 5000

#Transient
means_trans = np.zeros((len(n),len(beta_range)))
CV2s_trans = np.zeros((len(n),len(beta_range)))

for i in range(len(n)):
    for j in range(len(beta_range)):
        Q = MakeGammaDistMatrix(n[i], beta_range[j]) #Transition matrix
        t_on = CalculatetOn_GenericMarkovChainGillespieTime(Q,n[i],tau,N_cells)
        means_trans[i,j] = np.mean(t_on)
        CV2s_trans[i,j] = np.var(t_on) / np.mean(t_on)**2

#Plot results
colors = ['black','tab:red','tab:green','tab:blue']
TransientFeatureSpaceFixedTau = plt.figure()
#plt.title('Investigation of transient inputs on feature space (k varying, tau=3)')

for i in range(len(n)):
    plt.plot((n[i]-1)/beta_range,(1/(n[i]-1))*np.ones(beta_range.shape),'--',\
             color=colors[i],label='steady state k=' + str(n[i]-1))
    plt.plot(means_trans[i,:],CV2s_trans[i,:],'.',color=colors[i],\
             label='transient simulation k=' + str(n[i]-1))

plt.xlabel('mean')
plt.ylabel('CV^2')
plt.legend()
StandardFigure(plt.gca())
plt.show()
```

In each case, the transient input reduces noise! It seems like for increasing $\tau$, the performance improves. This makes intuitive sense because having a time-dependent input profile will make earlier transitions "weaker," so transitions that happen before the expected time are less likely, tightening the overall distribution of onset times. The relevant timescale is the dimensionless ratio $\frac{\beta}{\tau}$ - the faster the intrinsic transition rate $\beta$ is to the transient input timescale $\tau$, the larger the effects of the transient input. This manifests in the feature space for low values of the mean onset time, where the discrepancy between steady-state and transient is more apparent.

# Transient inputs can improve performance in non-ideal models
Earlier, we saw that in the steady-state case, the presence of finite backwards transition rates decreased the overall performance of the model in terms of noise. The greater the backwards transition rates, the worse the performance got. Here, we'll show that having transient inputs can counteract this performance loss.

As before, we'll assume a model with equal forward transition rates $\beta$ and equal backward transition rates $\beta f$. We'll compare the steady state case with the transient input case, parameterized with timescale $\tau$. Note that we'll only consider the forward transition rates to be transient, and assume the backward transition rates are still time-independent. Biologically, this would correspond to the forward transitions corresponding to on-rates of some pioneer factor like Zelda that is coupled to a time-dependent concentration profile, while backward transitions are time-independent off-rates.

Below, we explore the feature space in the steady-state vs. transient cases, with the steady-state ideal case of equal, irreversible transitions as a reference. We'll first consider the case fixing $k=2$ and $f=0.2$ and varying $\tau$.

```python
#Setting up parameters
n = 3
beta_min = 0.1
beta_max = 5.1
beta_step = 0.1
beta_range = np.arange(beta_min,beta_max,beta_step)
f = 0.2
tau = np.array([0.25,0.5,1,3])

#Simulate results
N_cells = 10000

#Steady state
means_steady = np.zeros(len(beta_range))
CV2s_steady = np.zeros(len(beta_range))

for i in range(len(beta_range)):
    Q = MakeEqualBidirectionalMatrix(n,beta_range[i],f)
    t_on = CalculatetOn_GenericMarkovChainGillespie(Q,n,N_cells)
    means_steady[i] = np.mean(t_on)
    CV2s_steady[i] = np.var(t_on) / np.mean(t_on)**2

#Transient
means_trans = np.zeros((len(beta_range),len(tau)))
CV2s_trans = np.zeros((len(beta_range),len(tau)))

for i in range(len(beta_range)):
    for j in range(len(tau)):
        Q = MakeEqualBidirectionalMatrix(n,beta_range[i],f)
        t_on =  CalculatetOn_GenericMarkovChainGillespieTime(Q,n,tau[j],N_cells)
        means_trans[i,j] = np.mean(t_on)
        CV2s_trans[i,j] = np.var(t_on) / np.mean(t_on)**2

#Plot results
colors = ['tab:blue','tab:red','tab:green','tab:purple']
TransientFeatureSpaceBackwards = plt.figure()
#plt.title('Impact of transient inputs on feature space with backward rates (k=2, f=' + str(f) + ')')
plt.plot((n-1)/beta_range,(1/(n-1))*np.ones(beta_range.shape),'k--',
         label='Steady-state ideal limit')
plt.plot(means_steady,CV2s_steady,'k.',label='Steady-state, f=' + str(f))

for i in range(len(tau)):
    plt.plot(means_trans[:,i],CV2s_trans[:,i],'.',
             label='Transient, f=' + str(f) + ', tau=' + str(tau[i]),color=colors[i])

plt.xlabel('mean')
plt.ylabel('CV^2')
plt.legend()
StandardFigure(plt.gca())
plt.show()
```

Interesting! As shown earlier, the steady-state case with a backwards transition rate is worse than the ideal limit with equal, irreversible forward rates. However, using a transient rate can counterbalance this and still achieve performance better than the ideal limit in the steady-state case.

This suggests that given a backwards transition rate that is some fraction $f$ in magnitude of the forwards transition rate, there exists some "diffusion" timescale $\tau$ of the input transition rate that can bring the squared CV back to the ideal steady-state limit with no backwards rates.

```python
# Export figures

ToyModelDist.savefig('figures/ToyModelDist.pdf')
ToyModelFeatureSpace.savefig('figures/ToyModelFeatureSpace.pdf')
BackwardsDist.savefig('figures/BackwardsDist.pdf')
BackwardsFeatureSpace.savefig('figures/BackwardsFigureSpace.pdf')
TransientInputs.savefig('figures/TransientInputs.pdf')
TransientDist.savefig('figures/TransientDist.pdf')
TransientFeatureSpaceFixedK.savefig('figures/TransientFeatureSpaceFixedK.pdf')
TransientFeatureSpaceFixedTau.savefig('figures/TransientFeatureSpaceFixedTau.pdf')
TransientFeatureSpaceBackwards.savefig('figures/TransientFeatureSpaceBackwards.pdf')
```

```python

```


# Variational AutoEncoder (VAE)

$\def\abs#1{\left\lvert #1 \right\rvert} \def\Set#1{\left\{ #1 \right\}} \def\mc#1{\mathcal{#1}} \def\M#1{\boldsymbol{#1}} \def\R#1{\mathsf{#1}} \def\RM#1{\boldsymbol{\mathsf{#1}}} \def\op#1{\operatorname{#1}} \def\E{\op{E}} \def\d{\mathrm{\mathstrut d}}$

To do:
- ~~explanation for the trainning and ploting~~
- the analysis of the two term in the ELBO and relation with Information theory
- ~~infoVAE~~
- Examples showing why InfoVAE
- add references in relevant part

Notations:
*When substitue, remember skip the code*
- ~~substitue Z by \R{Z} and similar for X, J . but need skip \mc{X}, \mc{Z}. *DO NOT DIRECTLY REPLACE IN VS CODE, BETTER DO THIS WITH JSON VIEW*
Several place involve \R{z} to indicate an element of Z as random variable, which has already used \R{z}, same for \R{\hat{z}}~~
- ~~substitue Encoder by *Encoder*, and similar for decoder~~

To improve:
- why directly sampling posterior is intractable
- reparameterization trick, seems have relationship with importance sampling
- whether also use sampling for x_hat in the experiment
- where to put the code for importing packages
- should we use capital variable name for batch_size, epochs, etc..
- ~~The train dateset and test dataset maybe combined. Currently, use the train dataset for training, and test dataset for evaluating is a bit improper to see the reconstruction performance.~~  Only use train_dataset for visuallization

- the Modeling bias problem in VAE, may use     $L_{\text{VAE}} = D(P_{X|\hat{Z}} || P_{\hat{X}|Z}) + D(P_{\hat{Z}} || P_{Z}) +H(X)$ , but may not metion this problem.

Remark
- The following follows the idea from https://arxiv.org/abs/1606.05908 , Tutorial on Variational Autoencoders

- I skipped the illustration from $p_{\hat{X}|Z}(x|z) = \mc{N}(\mu_{\phi}, \sigma^2 * \M{I})$ to $p_{\hat{X}|Z}(x|z) = \mc{N}(\mu_{\phi}, \Sigma_{\phi})$

- About the notation, since the standard Gaussian distribution in latent space is introduced first, ***$Z$ is the know standard Gaussian distribution, and then $\hat{Z}$ is the representation given by the encoder***. the pairs are: $X - (Encoder) - \hat{Z}$, $Z - (Decoder) - \hat{X}$, $\hat{Z} - (Decoder) - \bar{X}$.
When we consider VIB, we may need the markov chain $Y - X - Z$, where $Z$ is used as the representation of $X$, and if VIB is involved, then introduce $\bar{Z}$ as the know stardard Gaussian distribution is better, and the pairs are: $X - (Encoder) - Z$, $\bar{Z} - (Decoder) - \hat{X}$, $Z - (Decoder) - \bar{X}$.
If switch $Z$ and $\hat{Z}$ is needed, remember do this for little z and \hat{z}.

# Problem formulation

Given a dataset containing $N$ samples from random variable $\R{X}$.
How to design a model that with latent random variariable $\R{\R{Z}}$ as input, it generate different samples that are like those already in a trainning datase, but not exactly the same?

Suppose a deterministic function $g: \mc{\R{Z}} \rightarrow \mc{\R{X}}$ parametrized by $\phi$ which models $p_{\hat{\R{X}}|\R{Z}}$, i.e., outputs the generated samples with input from latent space $\mc{\R{Z}}$.

When $\R{Z}$ is random, $g(\R{Z})$ will be a random variable in sample space $\mc{\R{X}}$.
e want to choose the $\phi$ such that by sampling a $z \sim p_{\R{Z}}$, $g(z)$ will be like the $x's$ in the dataset with high probability.

We may try to maximize the probability for generating sample $\hat{\R{X}}$ by training the the parameter $\phi$ of function $g$ according to the law of total probability:
$$p_{\hat{\R{X}}}(\R{X})=\int p_{\hat{\R{X}}|\R{Z}}(\R{X}|z) p_{\R{Z}}(z) dz.$$

We assume $p_{\R{Z}}$ is a standard Gaussian distribution.
For $p_{\hat{\R{X}}|\R{Z}}(\R{X}|z)$, usually we can assume it to be a multivariate Gaussian or Bernoulli distribution. Here we assume it to be a Gaussian distribution with mean and variance given by $g(\R{Z})$. This is to say,
$$\R{Z} \sim \mc{N}(\M{0},\M{I}),$$
and $$p_{\hat{\R{X}}|\R{Z}}(x|z) = \mc{N}(x | \mu_{\phi}, \Sigma_{\phi}),$$
where
$$[\mu_{\phi}, \Sigma_{\phi}] = g(z).$$

The empiracal way to approximate $p_{\hat{\R{X}}}$ is to sammple a large number of $z$ values $\{z_1, z_2, \dots, z_n\}$ from $\R{Z}$ followed by computing:
$$p_{\hat{\R{X}}}(\R{X}) \approx \frac{1}{n} \sum_{i} p_{\hat{\R{X}}|\R{Z}}(\R{X}|z_i)$$

However, this needs too many samples and is intractable.

Then we want to sample the $z$ values that are likely to generate $x$. This means we need inferring $\hat{\R{Z}}$ from $\R{X}$.

We assume $p_{\hat{\R{Z}}|\R{X}}(\R{Z}|x)$ is a Gaussian distribution, and use a trainable function $f:\mc{\R{X}} \rightarrow \mc{\R{Z}}$ parameterized by $\theta$ to output the mean and variance of $p_{\hat{\R{Z}}|\R{X}}(\R{Z}|x)$. This is to say
$$ p_{\hat{\R{Z}}|\R{X}}(z|x) =\mc{N}(z | \mu_{\theta}, \Sigma_{\theta}), $$
where
$$[\mu_{\theta}, \Sigma_{\theta}] = f(x).$$

We have:
$$ p_{\hat{\R{X}}}(x) = \underbrace{E[p_{\hat{\R{X}}|\R{Z}}(x|\hat{\R{Z}})| x=\R{X}] -D(P_{\hat{\R{Z}}|\R{X}}(\cdot | x) || P_{\R{Z}})}_{\text{ELBO}(x)} + D(P_{\hat{\R{Z}}|\R{X}}(\cdot | x) | P_{\R{Z}|\R{X}}(\cdot | x)).$$

Hence,
$$ E[p_{\hat{\R{X}}}(\R{X})] \geq \underbrace{E[p_{\hat{\R{X}}|\R{Z}}(\R{X}|\hat{\R{Z}})] - D(P_{\hat{\R{Z}}|\R{X}} || P_{\R{Z}}|P_{\R{X}})}_{\text{ELBO}}. $$

Then to maximize $E[p_{\hat{\R{X}}}(\R{X})]$, we can optimize the parameters of the whole model by maximizing the ELBO.

We usually minimizing an objective function, and it is
$$\min_\limits{\phi, \theta} L_{\text{VAE}} := E[ - p_{\hat{\R{X}}|\R{Z}}(\R{X}|\hat{\R{Z}})] + D(P_{\hat{\R{Z}}|\R{X}} || P_{\R{Z}}|P_{\R{X}})$$

---
**Exercise**

Prove that
$$ p_{\hat{\R{X}}}(x) \geq E[p_{\hat{\R{X}}|\R{Z}}(x|\hat{\R{Z}})|x=\R{X}] -D(P_{\hat{\R{Z}}|\R{X}}(\cdot | x) || P_{\R{Z}}). $$

We use neural networks for $f$ and $g$, and we call $f$ the $Decoder$ and $g$ the *Encoder*, since the structure of the whole model resembles an autoencoder.

Reference
https://github.com/Jackson-Kang/Pytorch-VAE-tutorial/blob/master/01_Variational_AutoEncoder.ipynb
https://github.com/AntixK/PyTorch-VAE
https://github.com/GuHongyang/VaDE-pytorch

Tutorial on Variational Autoencoders  https://arxiv.org/abs/1606.05908

VAE https://arxiv.org/abs/1312.6114
Fixing a broken ELBO http://proceedings.mlr.press/v80/alemi18a.html
beta-vae: Learning basic visual concepts with a constrained variational framework https://openreview.net/forum?id=Sy2fzU9gl
Understanding disentangling in β-VAE https://arxiv.org/abs/1804.03599
Isolating Sources of Disentanglement in VAEs https://arxiv.org/abs/1802.04942
InfoVAE https://ojs.aaai.org/index.php/AAAI/article/view/4538

```python
import torch
import torch.nn as nn

import numpy as np

from tqdm import tqdm
from torchvision.utils import save_image, make_grid

import matplotlib.pyplot as plt
```

```python
# https://pytorch.org/docs/stable/notes/randomness.html
SEED = 0
torch.manual_seed(SEED)
np.random.seed(SEED)

import random
random.seed(SEED)
import datetime
```

```python
# Model Hyperparameters

dataset_path = './datasets'

cuda = torch.cuda.is_available()
DEVICE = torch.device("cuda" if cuda else "cpu")

batch_size = 128

x_dim  = 784 # dimension of input data for Decoder
latent_dim = 200 # dimension for latent representation z
hidden_dims = [400]
decoder_hidden_dims = None # if not None, it means specifying different hidden nodes of internal layers for Decoder with Encoder

beta = 1 # the coefficient for the divergence term in L_{VAE}, beta = 1 is the vanilla VAE

lr = 1e-3

epochs = 15

```

# Load Dataset

Use MNIST dataset as an example.

```python
from torchvision.datasets import MNIST
import torchvision.transforms as transforms
from torch.utils.data import DataLoader

mnist_transform = transforms.Compose([
        transforms.ToTensor(),
])

kwargs = {'num_workers': 1, 'pin_memory': True}

train_dataset = MNIST(dataset_path, transform=mnist_transform, train=True, download=True)
test_dataset  = MNIST(dataset_path, transform=mnist_transform, train=False, download=True)

train_loader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True, **kwargs)
test_loader  = DataLoader(dataset=test_dataset,  batch_size=batch_size, shuffle=False, **kwargs)
```

# Model

Build the *Encoder*, i.e., the function $f$, for inferring $\hat{\R{Z}}$ from $\R{X}$.
$$ p_{\hat{\R{Z}}|\R{X}}(z|x) =\mc{N}(z | \mu_{\theta}, \Sigma_{\theta}), $$
where
$$[\mu_{\theta}, \Sigma_{\theta}] = f(x).$$

We assume $\Sigma_{\theta}$ is diagonal, i.e.,
$$\Sigma_{\theta} := \text{diag}\left(\left\{\sigma_{\theta, 1}^2, \sigma_{\theta, 2}^2, \dots,  \sigma_{\theta, d_{\R{Z}}}^2 \right\}\right) =  \text{diag} \left( \left\{\sigma_{\theta, j}^2 \right\}_{j=1}^{d_{\R{Z}}} \right),$$
where $d_{\R{Z}}$ is the dimension of $\R{Z}$.

The output of the *Encoder* is $\mu_{\theta}$ and $\left[\log \sigma_{\theta, 1}^2, \log \sigma_{\theta, 2}^2, \dots,  \log \sigma_{\theta, d_{\R{Z}}}^2 \right]$

```python
class Encoder(nn.Module):
    def __init__(self, x_dim=x_dim, latent_dim=latent_dim, hidden_dims = hidden_dims):
        super(Encoder,self).__init__()

        self.x_dim = x_dim
        self.latent_dim = latent_dim
        self.hidden_dims = hidden_dims

        modules = []

        self.input_dim = x_dim

        for h_dim in self.hidden_dims:
            modules.append(
                nn.Sequential(
                    nn.Linear(self.input_dim, h_dim),
                    nn.ReLU(inplace=True)
                )
            )
            self.input_dim = h_dim

        self.encoder=nn.Sequential(*modules)

        self.mu_z=nn.Linear(hidden_dims[-1], latent_dim)
        self.log_var_z=nn.Linear(hidden_dims[-1], latent_dim)

    def forward(self, x):
        e=self.encoder(x)

        mu=self.mu_z(e)
        log_var=self.log_var_z(e)

        return mu, log_var
```

Build the *Decoder*, i.e., the function $g$, for generating $\hat{\R{X}}$ from $\R{Z}$.

$$p_{\hat{\R{X}}|\R{Z}}(x|z) = \mc{N}(x | \mu_{\phi}, \Sigma_{\phi}),$$
where
$$[\mu_{\phi}, \Sigma_{\phi}] = g(z).$$

The default hidden dimensions of the layers in *Decoder* are the reverse order of those in *Encoder*.

We can also define a different one for *Decoder* by setting the argument *decoder_hidden_dims*.

In the Decoder here, for an input $z$ we use $\mu_{\phi}$ as $\hat{x}$.

```python
class Decoder(nn.Module):
    def __init__(self, x_dim=x_dim, latent_dim=latent_dim, hidden_dims = hidden_dims, decoder_hidden_dims = decoder_hidden_dims):
        super(Decoder,self).__init__()

        self.x_dim = x_dim
        self.latent_dim = latent_dim
        self.hidden_dims = hidden_dims
        self.decoder_hidden_dims = decoder_hidden_dims

        modules = []

        if self.decoder_hidden_dims is None:
            self.decoder_hidden_dims = list(reversed(self.hidden_dims))

        self.input_dim = self.latent_dim

        for h_dim in self.decoder_hidden_dims:
            modules.append(
                nn.Sequential(
                    nn.Linear(self.input_dim, h_dim),
                    nn.ReLU(inplace=True)
                )
            )
            self.input_dim = h_dim

        self.decoder=nn.Sequential(*modules)

        self.mu_x=nn.Linear(self.decoder_hidden_dims[-1], self.x_dim)
        # self.log_var_x=nn.Linear(hidden_dims[-1], self.x_dim)

    def forward(self, z):
        d=self.decoder(z)

        # use Sigmoid activation to ensure elements of mu, or the x_hat below are in (0,1)
        mu=torch.sigmoid(self.mu_x(d))
        # log_var=self.log_var_x(d)

        # for decoder, we care about the mean
        x_hat = mu

        return x_hat
```

# Reparameterization

To train the *Encoder* and *Decoder*, we need the gradients with respect to the trainable parameters, and backpropagate through the neural networks.

However, the input for the *Decoder*, $\hat{z}$ values is randomly sampled from $\mc{N}(\mu_{\theta}, \Sigma_{\theta})$, and the backpropagation cannot flow through such a random node.

Instead of directly sampling $\hat{z}$ values the reparameterization trick introduces a random variable $$\epsilon \sim \mc{N}(\M{0},\M{I}),$$ and sample
$$\epsilon_i \sim \mc{N}(\M{0},\M{I}),$$ then $z_i$ is obtained by
$$z_i = \mu_{\theta} + \text{diag} \left( \left\{\sigma_{\theta, j} \right\}_{j=1}^{d_{\R{Z}}} \right) \cdot \epsilon_i,$$
where we assume $\Sigma_{\theta}$ is a diagonal matrix with
$$\Sigma_{\theta} := \text{diag} \left( \left\{\sigma_{\theta, j}^2 \right\}_{j=1}^{d_{\R{Z}}} \right).$$

```python
def reparameterization(mean, std):
    epsilon = torch.randn_like(std).to(DEVICE)        # sampling epsilon
    z = mean + std*epsilon                          # reparameterization trick
    return z
```

Build the model composed of *Encoder* and *Decoder*.

```python
class Model(nn.Module):
    def __init__(self, encoder, decoder):
        super(Model, self).__init__()
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, x):
        mu, log_var = self.encoder(x)
        z = reparameterization(mu, torch.exp(0.5 * log_var))
        x_hat = self.decoder(z)

        return x_hat, mu, log_var
```

```python
encoder=Encoder()
decoder=Decoder()

model = Model(encoder=encoder, decoder=decoder).to(DEVICE)
```

# Train the model

```python
from torch.optim import Adam

def loss_function(x, x_hat, mu, log_var):
    reconstruction_loss = nn.functional.binary_cross_entropy(x_hat, x, reduction='mean')
    KLD = - 0.5 * torch.mean(1+ log_var - mu ** 2 - log_var.exp())

    return reconstruction_loss + beta * KLD, reconstruction_loss, KLD

optimizer = Adam(model.parameters(), lr=lr)
```

```python
print("Start training VAE...")
model.train()

loss_list = []
reconstruction_loss_list = []
KLD_list = []
for epoch in range(epochs):
    overall_loss = 0
    overall_reconstruction_loss = 0
    overall_KLD = 0
    overall_sample_number = 0
    for batch_idx, (x, _) in enumerate(train_loader):

        x = x.view(-1, x_dim)
        x = x.to(DEVICE)

        optimizer.zero_grad()

        x_hat, mu, log_var = model(x)
        loss, reconstruction_loss, KLD = loss_function(x, x_hat, mu, log_var)

        overall_loss += loss.item() * x.shape[0]
        overall_reconstruction_loss += reconstruction_loss.item() * x.shape[0]
        overall_KLD += KLD.item() * x.shape[0]
        overall_sample_number += x.shape[0]

        train_loss = overall_loss / overall_sample_number

        train_reconstruction_loss = overall_reconstruction_loss / overall_sample_number
        train_KLD = overall_KLD / overall_sample_number

        loss.backward()
        optimizer.step()

    loss_list.append(train_loss)
    reconstruction_loss_list.append(train_reconstruction_loss)
    KLD_list.append(train_KLD)

    # print(datetime.datetime.now().strftime('%Y-%m-%d  %H:%M:%S'), "\tEpoch", epoch + 1, ":", "\tKLD Loss: ", overall_KLD / overall_sample_number, "\treconstruction Loss: ", overall_reconstruction_loss / overall_sample_number, "\tLoss: ", overall_loss / overall_sample_number)

    print(datetime.datetime.now().strftime('%Y-%m-%d  %H:%M:%S'), " \tEpoch {}: \tKLD Loss: {:.6f} \treconstruction Loss: {:.6f} \tLoss: {:.6f}".format(epoch + 1, train_KLD, train_reconstruction_loss, train_loss))

print("Finish!!")
```

```python
plt.figure()
plt.plot(range(1,epochs+1), loss_list, label = 'loss')
plt.plot(range(1,epochs+1), reconstruction_loss_list, label = 'reconstruction_loss')
plt.plot(range(1,epochs+1), KLD_list, label = 'KLD')
plt.xlabel('epoch')
plt.ylabel('value')
plt.legend()
plt.show()
```

# Visuallization

You can input a integer in the *text box*, or drag the *slider bar*, and the value will be the index for the sample which will be shown with title "orignal image", and the corresponding reconstructed image will also be shwon.

You might see some images are not reconstructed well.

```python
selected_dataset = train_dataset # select the dataset to visualize, values: train_dataset, test_dataset

from ipywidgets import interact, interactive, fixed, interact_manual
import ipywidgets as widgets

import matplotlib.pyplot as plt
%matplotlib inline

import numpy as np

def plot_x(i):
    model.eval()
    with torch.no_grad():
        x=selected_dataset[i][0]
        x = x.view(-1, x_dim)
        x = x.to(DEVICE)

        x_hat, _, _ = model(x)

        fig = plt.figure(figsize=(8,8))
        plt.subplot(1,2,1)
        plt.title('Original image')
        plt.imshow(x.view(28,28).cpu().numpy())
        plt.subplot(1,2,2)
        plt.title('reconstructed image')
        plt.imshow(x_hat[0].view(28,28).cpu().numpy())
        # plt.imshow(x_hat[0].view(28,28).cpu().numpy(), cmap="gray")
        plt.show()

widget_a = widgets.BoundedIntText(value=1, min=0, max=len(selected_dataset)-1, step=1)
widget_b = widgets.IntSlider(value=1, min=0, max=len(selected_dataset)-1, step=1)
mylink = widgets.jslink((widget_a, 'value'), (widget_b, 'value'))

display(widget_a)
interact(plot_x, i = widget_b)
```

Generate samples by randomly sampling $z$ values from $p_{\R{Z}}$

```python
model.eval()

with torch.no_grad():
    noise = torch.randn(batch_size, latent_dim).to(DEVICE)
    generated_images = decoder(noise)

save_image(generated_images.view(-1, 1, 28, 28), 'generated_samples.png')
```

```python
from IPython.display import Image
from IPython.core.display import HTML
Image(filename =r"./generated_samples.png")
```

You can view the single generated image by changing the value of the argument *idx*.

```python
def show_image(x, idx, title=None):
    x = x.view(batch_size, 28, 28)

    fig = plt.figure()
    if title is not None:
        plt.suptitle(title + ', idx=' + str(idx))
    plt.imshow(x[idx].cpu().numpy())

show_image(generated_images, idx=0, title='generated image')
```

---
**Exercise**

What is the relationship between the divergence term in $L_{\text{VAE}}$ and $I(\hat{\R{Z}} \wedge \R{X})$?

What will happen if the model is sufficiently flexible and the $L_{\text{VAE}}$ achieves optimal value?

Try use stronger *Encoder* and *Decoder*.

```python

```

```python

```

# Balancing the two terms in $L_{\text{VAE}}$

Define the loss $L_{\beta}$
$$L_{\beta} := E[ - p_{\hat{\R{X}}|\R{Z}}(\R{X}|\hat{\R{Z}})] + \beta D(P_{\hat{\R{Z}}|\R{X}} || P_{\R{Z}}|P_{\R{X}}).$$

# Distanglement of latent representation in $\beta$-VAE

```python

```

Define a uniform random variable $\R{J}$ on $\{1,2,\dots, N\}$ which relates to the samples in the dataset. Then $x_{\R{J}}$ is a random variable.
Define
$$ p_{\hat{\R{Z}}|x_{\R{J}}} (\cdot|x_j) := p_{\hat{\R{Z}}|\R{J}}(\cdot|j). $$
Then we have
\begin{align*}
    p_{\R{J}}(j) &= \frac{1}{N}, \\
    p_{\hat{\R{Z}}, \R{J}}(\cdot|j) &= p_{\hat{\R{Z}} | \R{J}}(\cdot|j) p_{\R{J}}(j) = \frac{1}{N}p_{\hat{\R{Z}} | \R{J}}(\cdot|j)
\end{align*}

Then
\begin{align*}
D(P_{\hat{\R{Z}}|\R{X}} || P_{\R{Z}}|P_{\R{X}}) &= E \left[ D(P_{\hat{\R{Z}}|\R{X}})(\cdot|\R{X}) || P_{\R{Z}} \right] \\
&= E \left[ D(P_{\hat{\R{Z}}|\R{J}})(\cdot|\R{J}) || P_{\R{Z}} \right] \\
&= \underbrace{D \left( P_{\hat{\R{Z}} \R{J}} || P_{\hat{\R{Z}}} P_{\R{J}} \right)}_{\text{Index-Code MI}}
+ \underbrace{D \left( P_{\hat{\R{Z}}}|| \prod_{k=1}^{d_{\R{Z}}} P_{\R{z}_k} \right)}_{\text{Total Correlation}}
+ \underbrace{ \sum_{k=1}^{d_{\R{Z}}} D \left( P_{\hat{\R{z}}_k} || P_{\R{z}_k} \right)}_{\text{Dimension-wise divergence}}.
\end{align*}
For details, read [Isolating Sources of Disentanglement in VAEs](https://arxiv.org/abs/1802.04942).

# InfoVAE

Two problems of $L_{\text{VAE}}$:
- When model capacity is limited, there is a tradoff between the two terms in $L_{\text{VAE}}$, and the variational posterior $p_{\hat{\R{Z}}|\R{X}}$ may be quit inaccurate.
    - The reconstruction term can encorage choosing $p_{\hat{\R{Z}}|\R{X}}(\cdot|x_i)$ and $p_{\hat{\R{Z}}|\R{X}}(\cdot|x_j)$ to choose disjoint support when $x_i \not= x_j$. The divergence term in $L_{\text{VAE}}$ may not always sufficient to prevent this.

- When model capacity is sufficiently large, $I(\hat{\R{Z}} \wedge \R{X})$ tends to be vanishingly small, and $\hat{\R{X}}$ tend to be independent of $\R{Z}$.

$$L_{\text{VAE}} = D(P_{X|\hat{Z}} || P_{\hat{X}|Z}) + D(P_{\hat{Z}} || P_{Z}) + \underbrace{H(\R{X})}_{\text{constant}}$$

Solution: add a term in the loss to encourage the MI between $\hat{\R{Z}}$ and $\R{X}$.

The infoVAE loss:
$$
\begin{align}
L_{InfoVAE} &= D(P_{\R{X}|\hat{\R{Z}}} || P_{\hat{\R{X}}|\R{Z}}|P_{\hat{\R{Z}}}) + \gamma D(P_{\hat{\R{Z}}}||P_{\R{Z}}) - \alpha I(\hat{\R{Z}} \wedge \R{X}) \\
&= E[\log p_{\R{X}|\hat{\R{Z}}}(\R{X}|\hat{\R{Z}})] + E[- \log p_{\hat{\R{X}}|\R{Z}}(\R{X}|\hat{\R{Z}})] + \gamma E[\log p_{\hat{\R{Z}}}(\hat{\R{Z}})]- \gamma E [\log p_{\R{Z}}(\hat{\R{Z}})]  - \alpha E[\log p_{\hat{\R{Z}}|\R{X}}(\hat{\R{Z}}|\R{X})] + \alpha E[\log_{\hat{\R{Z}}}(\hat{\R{Z}})]  \\
&= E[\log p_{\hat{\R{Z}}|\R{X}}(\hat{\R{Z}}|\R{X})] + E[\log p_{\R{X}}(\R{X})] - E[p_{\hat{\R{Z}}}(\hat{\R{Z}})] + E[- \log p_{\hat{\R{X}}|\R{Z}}(\R{X}|\hat{\R{Z}})] + \gamma E[\log p_{\hat{\R{Z}}}(\hat{\R{Z}})]- \gamma E [\log p_{\R{Z}}(\hat{\R{Z}})]  - \alpha E[\log p_{\hat{\R{Z}}|\R{X}}(\hat{\R{Z}}|\R{X})] + \alpha E[\log_{\hat{\R{Z}}}(\hat{\R{Z}})]  \\
&= E[- \log p_{\hat{\R{X}}|\R{Z}}(\R{X}|\hat{\R{Z}})] + (1-\alpha)E[\log p_{\hat{\R{Z}}|\R{X}}(\hat{\R{Z}}|\R{X})] +  (\alpha + \gamma -1)E[p_{\hat{\R{Z}}}(\hat{\R{Z}})] - \gamma E [\log p_{\R{Z}}(\hat{\R{Z}})] + E[\log p_{\R{X}}(\R{X})] \\
&= E[- \log p_{\hat{\R{X}}|\R{Z}}(\R{X}|\hat{\R{Z}})] +  (1-\alpha)D(P_{\hat{\R{Z}}|\R{X}}||P_{\R{Z}}| P_{\R{X}}) + (\alpha + \gamma -1)D(P_{\hat{\R{Z}}}||P_{\R{Z}}) - \underbrace{H(\R{X})}_{\text{constant}}.
\end{align}
$$

refer to the implementation by https://github.com/pratikm141/MMD-Variational-Autoencoder-Pytorch-InfoVAE

```python

```


```python
import numpy as np
import sympy as sy
sy.init_printing()
```

```python
row,col=2,2
kwds=['x{}(0:{})'.format(i,col) for i in range(row) ]
symbols_row_col=np.array([sy.symbols(kwd) for kwd in kwds]).reshape(row,col)
symbols_row_col=sy.Matrix(symbols_row_col)

```

```python

```

```python
x=sy.symbols('x')
x
```

```python

```

```python
size=4
kwd_ts='t(0:{})'.format(size)
t=sy.symbols(kwd_ts)

list_size=[i for i in range(size)]
i=2
lix=sy.prod([(x-t[k])/(t[i]-t[k]) for k in filter(lambda x:x is not i, list_size)])
lix
```

```python
xs=[0.0,0.2,0.4,0.6,0.8,1.2,1.4,1.6,1.8,2.0]
ys=[2.0,2.1,1.6,2.6,1.5,2.7,0.67,3.5,0.94,2.0]
len(xs)
```

```python
xs=[0.0,0.2,0.4,0.6,0.8,1.2,1.4,1.6,1.8,2.0]
ys=[2.0,2.1,1.6,2.6,1.5,2.7,0.67,3.5,0.94,2.0]
size=len(xs)
kwd_ts='t(0:{})'.format(size)
t=sy.symbols(kwd_ts)
x=sy.symbols('x')
x
list_size=[i for i in range(size)]
lixs=[]
for i in range(size):
    lixs.append(sy.prod([(x-t[k])/(t[i]-t[k]) for k in filter(lambda x:x is not i, list_size)]))
Px=0
for i in range(size):
    Px+=ys[i]*lixs[i]
Px
```

```python
subs={t[k]:xs[k] for k in range(size)}
P_subts=Px.subs(subs)
```

```python

legendre=sy.Lambda(x,P_subts.subs({x:x}))
```

```python
pltx=np.linspace(0,2,100)
myfunc=np.vectorize(legendre)
plty=myfunc(pltx)
print(plty)
```

    [2.00000000000000 5.21057907440319 6.88577000639673 7.46310114806561
     7.29210213552447 6.64729253631776 5.73977316436325 4.72752493962344
     3.72451521817907 2.80870667831678 2.02905911963008 1.41160991496680
     0.964714349340236 0.683522685652140 0.553766514256015 0.554922772017295
     0.662819756603701 0.851745513264062 1.09611513432727 1.37174978607481
     1.65681666351031 1.93247557086811 2.18327443446972 2.39733277475188
     2.56634899595412 2.68546429506479 2.75301304618568 2.77018668248311
     2.74063537535115 2.67002919931731 2.56559797157528 2.43566656683092
     2.28920023139853 2.13537225518292 1.98316430633011 1.84100779092493
     1.71647276915780 1.61600923987487 1.54474399736686 1.50633476764026
     1.50288194625233 1.53489698607685 1.60132532110159 1.69962066154098
     1.82586655617796 1.97494028992859 2.14071346814999 2.31628303418819
     2.49422597308700 2.66687057225096 2.82657683917695 2.96601851713789
     3.07845909192000 3.15801424638069 3.19989339470890 3.20061321483212
     3.15817649542579 3.07221012343975 2.94405665896474 2.77681467661768
     2.57532389642940 2.34609208247100 2.09716175415663 1.83791593330982
     1.57882343967868 1.33112564863160 1.10646813725951 0.916482269054421
     0.772323502724621 0.684175057547449 0.660727525948130 0.708647093730029
     0.832047209566363 1.03198083799674 1.30597283425343 1.64761449477225
     2.04624496422129 2.48674691830735 2.94948679149491 3.41043278009609
     3.84148692396159 4.21107075422274 4.48500729020352 4.62774557573780
     4.60397746369280 4.38069998751282 3.92978040005960 3.23108481293573
     2.27623533383628 1.07306467528081 -0.349158604666724 -1.93365677037898
     -3.58913961588365 -5.18178482025562 -6.52611092707035 -7.37464717930829
     -7.40629958515566 -6.21330762275160 -3.28668091309096 2.00000000000000]

```python
import matplotlib.pyplot as plt
%matplotlib inline
```

```python
fig,ax=plt.subplots()
ax.plot(pltx,plty)
ax.plot(xs,ys,'gx')
```

```python
# final code is....
```

```python
ts=[0.0,0.2,0.4,0.6,0.8,1.2,1.4,1.6,1.8,2.0]
ys=[2.0,2.1,1.6,2.6,1.5,2.7,0.67,3.5,0.94,2.0]
size=len(xs)
kwd_ts='t(0:{})'.format(size)
t=sy.symbols(kwd_ts)
x=sy.symbols('x')

itr=[i for i in range(size)]
lixs=[]
for i in range(size):
    lixs.append(sy.prod([(x-t[k])/(t[i]-t[k]) for k in filter(lambda x:x is not i, itr)]))
P_abstract=0
for i in range(size):
    P_abstract+=ys[i]*lixs[i]

vals={t[k]:ts[k] for k in range(size)}
Pts=P_abstract.subs(vals)

lagrange=sy.Lambda(x,Pts.subs({x:x}))
pltx=np.linspace(0,2,100)
lagrange=np.vectorize(lagrange)
plty=lagrange(pltx)

fig,ax=plt.subplots()
ax.plot(pltx,plty)
ax.plot(xs,ys,'gx')
```

# vandermonde

```python
xs=[0.0,0.2,0.4,0.6,0.8,1.2,1.4,1.6,1.8,2.0]
ys=[2.0,2.1,1.6,2.6,1.5,2.7,0.67,3.5,0.94,2.0]
vander=np.vander(xs)
vander
solve=np.linalg.inv(vander) @ ys
solve
```

    array([  5.47970834e+02,  -4.82500672e+03,   1.77785657e+04,
            -3.55800678e+04,   4.19740211e+04,  -2.95990240e+04,
             1.20294681e+04,  -2.53055213e+03,   2.05373611e+02,
             2.00000000e+00])

```python
legendre(0)
```

```python
power=[x**k for k in range(size)[::-1]]
testfunc=sy.Lambda(x,np.dot(solve,power).subs({x:x}))
```

```python
legendre(38)
```

```python
testfunc(38)
```

```python
testfunc
```

```python
import numpy as np
import sympy as sy
from matplotlib import pyplot as plt
%matplotlib inline
sy.init_printing()
xs=[0.0,0.2,0.4,0.6,0.8,1.2,1.4,1.6,1.8,2.0]
ys=[2.0,2.1,1.6,2.6,1.5,2.7,0.67,3.5,0.94,2.0]
size=len(xs)
vander=np.vander(xs)
coeff=np.linalg.inv(vander) @ ys
x=sy.symbols('x')
power=[x**k for k in range(size)[::-1]]
lagrange_poly=sy.Lambda(x,np.dot(coeff,power).subs({x:x}))
print(lagrange_poly)
lagrange_poly=np.vectorize(lagrange_poly)
pltx=np.linspace(0,2,100)
plty=lagrange_poly(pltx)
fig,ax=plt.subplots()
ax.plot(pltx,plty)
ax.plot(xs,ys,'gx')
```

```python
lagrange_poly

```

    <numpy.lib.function_base.vectorize at 0x2589c7a76a0>

```python

```


```python
!pip install tbcontrol
import control
import numpy
import matplotlib.pyplot as plt
import tbcontrol
tbcontrol.expectversion("0.1.10")
%matplotlib inline
# import pandas as pd
import scipy.optimize
from tbcontrol.responses import fopdt, sopdt
from ipywidgets import interact

def resultplot1(K, tau, theta, y0):
    plt.figure(figsize=(21, 7))
    plt.scatter(ts, ym)
    plt.plot(ts, fopdt(ts, K, tau, theta, y0), color='red')
    plt.grid(True)

def resultplot2(K, tau, zeta, theta, y0):
    plt.figure(figsize=(21, 7))
    plt.scatter(ts, ym)
    plt.plot(ts, sopdt(ts, K, tau, zeta,theta, y0), color='red')
    plt.grid(True)
```

    Requirement already satisfied: tbcontrol in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (0.2.1)
    Requirement already satisfied: packaging in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tbcontrol) (21.0)
    Requirement already satisfied: scipy in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tbcontrol) (1.3.0)
    Requirement already satisfied: tqdm in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tbcontrol) (4.62.3)
    Requirement already satisfied: numpy in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tbcontrol) (1.16.4)
    Requirement already satisfied: pyparsing>=2.0.2 in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from packaging->tbcontrol) (2.4.7)
    Requirement already satisfied: colorama in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tqdm->tbcontrol) (0.4.4)

# Identificación: Ajuste de la respuesta al escalón

## Respuesta al escalón

La respuesta de un sistema **LTI** $\mathcal{H}$ ante una señal escalón $\epsilon(t)$ en la entrada se conoce como [*respuesta al escalón*](https://en.wikipedia.org/wiki/Step_response) $h_\epsilon(t)$.

Se define como:

\begin{equation}
h_\epsilon(t) = \mathcal{H} \{ \epsilon(t) \}
\end{equation}

Puede entenderse la señal escalón en la entrada como el encendido/activación de un sistema en el instante $t=0$. Se relaciona con la respuesta al impulso $h(t)$ mediante:

\begin{equation}
h_\epsilon(t) = \epsilon(t) * h(t) = \int_{-\infty}^{t} h(\tau) \; d\tau
\end{equation}

Pues debe tenerse en cuenta que:

\begin{equation}
\epsilon(t) = \int_{-\infty}^{t} \delta(\tau) d\tau
\end{equation}

Esta expresión muestra que la respuesta al impulso puede encontrarse a partir de la respuesta al escalón mediante:

\begin{equation}
h(t) = \frac{d h_\epsilon(t)}{dt}
\end{equation}

Así, la respuesta de un sistema LTI $y(t) = \mathcal{H} \{ x(t) \}$  ante cualquier señal de entrada $x(t)$ puede encontrarse a partir de la respuesta al escalón como:

\begin{equation}
y(t) = x(t) * \frac{d h_\epsilon(t)}{dt} = \frac{d x(t)}{dt} * h_\epsilon(t)
\end{equation}

Observe que la señal de salida del sistema LTI está definida por :
- La covolución de la **derivada de la señal de entrada** con la **respuesta al escalón**.
- La covolución de la **señal de entrada** con la **derivada de la respuesta al escalón**.

Así, la respuesta al escalón es una alternativa apropiada a la caracterización de sistemas LTI pues el impulso de Dirac no puede obtenerse en la el mundo real.

## Función de transferencia

En el dominio del tiempo, la salida $y(t)$ de un [sistema LTI](https://es.wikipedia.org/wiki/Sistema_LTI) está determinada por la convolución entre la entrada $x(t)$ y su [respuesta al impulso](../systems_time_domain/impulse_response.ipynb) $h(t)$.

\begin{equation}
y(t) = x(t) * h(t)
\end{equation}

En el dominio transformado se tiene

\begin{equation}
Y(s) = X(s) \cdot H(s)
\end{equation}

\begin{equation}
H(s) = \frac{Y(s)}{X(s)}
\end{equation}

donde:
- $X(s) = \mathcal{L} \{ x(t) \}$ es la transformada de Laplace de la señal de entrada.
- $Y(s) = \mathcal{L} \{ y(t) \}$ es la transformada de Laplace de la señal salida.
- $H(s) = \mathcal{L} \{ h(t) \}$ es la transformada de Laplace de la respuesta impulsional, también denominada [Función de transferencia](https://es.wikipedia.org/wiki/Funci%C3%B3n_de_transferencia).

Así, un sistema LTI queda modelado mediante la respuesta impulsional bien sea en el dominio temporal o en el dominio frecuencial.

### Estrategia tentativa para obtener un modelo

Pasos para obtener experimentalmente un modelo $H(S)$ apropiado para un sistema:

- Se excita el sistema con una entrada conocida evitando comportamientos no lineales.
- Se encuentra una expresión matemática en el dominio de las frecuencias que se ajuste a la señal de salida obtenida.
- Se encuentra el cociente entre las representaciones frecuenciales de la salida y la entrada.

En sistemas de orden elevado es recomendable encontrar modelos de orden más bajo.

## Ejemplo

Suponga un sistema de orden elevado cuyo modelo conoce

\begin{equation}
G_{real}(s) = \frac{s+2}{2s^3 + 3s^2 + 4s + 1}
\end{equation}

La respuesta ante un escalón unitario es:
\begin{equation}
\frac{1}{s} \cdot \frac{s+2}{2s^3 + 3s^2 + 4s + 1}
\end{equation}

```python
# Se define la función de transferencia
Greal = control.tf([1, 2], [2, 3, 4, 1])
Greal
```

$$\frac{s + 2}{2 s^3 + 3 s^2 + 4 s + 1}$$

```python
# Simulación del sistema ante un escalón unitario
ts, ys = control.step_response(Greal)
```

En datos de sistemas reales se tiene el efecto de ruido y sesgo, lo que modifica el valor inicial de la señal de salida e introduce un comportamieto aleatorio alrededor de la señal de salida "ideal".

```python
yinitial = 10
measurement_noise = numpy.random.randn(len(ys))*0.1
```

```python
ym = ys + yinitial + measurement_noise
```

```python
plt.figure(figsize=(21, 7))
plt.scatter(ts, ym, label='Datos')
plt.plot(ts, ys + yinitial, color='red', label='Modelo ideal')
plt.legend(loc='best')
plt.grid(True)
```

## Ajuste de un modelo de primer orden

Se encuentra un modelo de primer orden de la forma:

\begin{equation}
G(s) = \frac{K}{\tau s + 1} \cdot e^{-s\theta}
\end{equation}

Introduciendo además el sesgo.

Librería `tbcontrol.responses`

```python
fopdt
```

    <function tbcontrol.responses.fopdt(t, K, tau, theta=0, y0=0)>

```python
interact(resultplot1,
         K=(0., 10.),
         tau=(0., 10.),
         theta=(0., 10.),
         y0=(0., 20.));

```

    interactive(children=(FloatSlider(value=5.0, description='K', max=10.0), FloatSlider(value=5.0, description='t…

We can use the `scipy.optimize.curve_fit` tool to do this fit just like when we did regression without time.

```python
[K, tau, theta, y0], _ = scipy.optimize.curve_fit(fopdt, ts, ym, [3.0, 1.3, 1.3, 9.2])
[K, tau, theta, y0]
```

    [2.7303859097940366, 2.6186612666853124, -0.201507962862213, 9.2982703097088]

Estos parámetros definen la función de transferencia de primer orden que mejor se ajusta al comportamiento medido.

```python
Y_1orden = fopdt(ts, K, tau, theta, y0)
```

\begin{equation}
G(s) = \frac{K}{\tau s + 1} \cdot e^{-s\theta}
\end{equation}
más sesgo.

## Ajuste de un modelo de segundo orden

También puede ajustarse un modelo de segundo orden.

\begin{align}
G(s) &= \frac{K}{(\tau s)^2 + 2\tau \zeta s + 1} \cdot e^{-s\theta} \\
&= \frac{K \omega_0^2}{s^2 + 2\zeta \omega_0 s + \omega_0^2} \cdot e^{-s\theta} \\
\end{align}

Introduciendo además el sesgo.

```python
interact(resultplot2,
         K=(0., 10.),
         tau=(0., 10.),
         theta=(0., 10.),
         zeta = (-2.0, 2.0),
         y0=(0., 20.));

```

    interactive(children=(FloatSlider(value=5.0, description='K', max=10.0), FloatSlider(value=5.0, description='t…

```python
p2_inicial = [2, 0.6, 2, 0.5, 10]
[K_2, tau_2, zeta_2, theta_2, y0_2], pcov = scipy.optimize.curve_fit(sopdt, ts, ym, p2_inicial)
[K_2, tau_2, zeta_2, theta_2, y0_2]
```

    [2.004298085242325,
     0.920299093604281,
     1.6716923395069392,
     0.3147820494724495,
     9.990416180744504]

```python
perr = numpy.sqrt(numpy.diag(pcov))
perr
```

    array([0.03354921, 0.26092934, 0.40652605, 0.17464457, 0.03306521])

```python
Y_2orden = sopdt(ts, K_2, tau_2, zeta_2, theta_2, y0_2)
```

```python
plt.figure(figsize=(21, 7))
plt.scatter(ts, ym, label='Data')
plt.plot(ts, Y_1orden, color='red', label='ajuste orden 1')
plt.plot(ts, Y_2orden, color='blue', label='ajuste orden 2')
plt.plot(ts, ys + 10, color='gray', label='Original')
plt.legend(loc='best')
plt.grid(True)
```

```python

```


## Testing look-elsewhere effect from combining two searches for the same particle using Gaussian Processes

by Kyle Cranmer, Dec 17, 2015

The correction for 1d look-elsewhere effect (LEE) presented in
*Trial factors or the look elsewhere effect in high energy physics* by  Ofer Vitells and Eilam Gross http://arxiv.org/abs/arXiv:1005.1891

It is often claimed that when one has two statistically independent searches for the same particle, then a peak in one tells you where to look for the other, thus eliminating the look-elsewhere effect. These searches might be from two different experiments (eg. ATLAS and CMS), two different decay modes, or two different time perieods (eg. run1 and run2).  There are various flaws in this logic, as stressed by Bob Cousins in
[these slides](https://indico.cern.ch/event/233551/contribution/1/attachments/389867/542286/cousins_look_elsewhere_14feb2013.pdf).
This issue quickly becomes subtle as the intuitive procedure of using the location of the excess in one search to inform where to look in the other is not fully specified. A few things can be said about the intuitive procedure
   * there is a symmetry of switching search1 and search2, so any approach that breaks this symmetry is going to have some weird properties. Our inference should not depend on the order!
   * you can decide to break that symmetry by picking one of the searches (without seeing the results) in order to have the correct Type-I error rate (coverage), but that will lead to sub-optimal inference. (And Cousins has pointed out that there may be something insightful to say by connecting to "Buehler's betting game")
   * if you use search 1 to *approximately* specify the location of the bump for search 2, then there is still a residual LEE for search 2 (though it will be considerably smaller)
   * The combined result doesn't depend on the order, but clearly has a look-elsewhere effect that needs to be corrected

In what follows I will explore the behavior of the LEE correction for the combination of two searches (which can be trivially extended to more than two searches).

### Formalism

The starting point is to consider a search for a new particle with signal strength $\mu$ and unknown mass $\nu$ on top of a background distribution described by some nuisance parameters $\theta$. We perform the search by scanning over the mass $\nu$ and calculating the test statistic

\begin{equation}
q(\nu) = -2 \log \frac{ \max_{\theta} L(\mu=0, \nu, \theta)}{ \max_{\mu, \theta} L(\mu, \nu, \theta)}
\end{equation}

Assuming the background-only is true, $q(\nu)$ is a chi-square random field (with 1 degree of freedom). That means that, for any point $\nu$, the quantity $q(\nu)$ would have a chi-square distribution if you repeated the experiment many times.

The maximum local significance is based on $Z_{local} = \sqrt{q_{max}}$, where $q_{max} = \max_\nu q(\nu)$.
The correction from local to global significance is given by:

\begin{equation}
p_{global} = p_{local} + N \exp(-(q_{max}-u_0)/2)
\end{equation}

where $N$ is the average number of upcrossings above level $u_0$ (i.e. times that $q(\nu) > u_0$).
This $N$ characterizes the search -- searches with good mass resolution over a large mass range will have large values of $N$, while searches with poor mass resolution will have small values of $N$.

### Shortcut: Gaussian Processes

Creating many likelihood scans from pseudo-experiments (toy Monte Carlo) is somewhat time consuming, so here we make realizations of a chi-square random field by using a Gaussian Process.
The main trick we will use is that a chi-square distribution for one degree of freedom is the same as the distribution of $x^2$ if $x$ is normally distributed. As you might have guessed, a Gaussian Process (GP) is like a chi-square random field, but it is Gaussian-distributed at each point.

Note, the distributions are not independent at each point, there is some covariance. So if the $q(\nu)$ is high at one point, you can expect it to be high near by. We can control this behavior via the GP's kernel. In particular,
$K(\nu, \nu') = Cov[q(\nu)^2, q(\nu')^2]$. We can essentially specify what the mass resolution of our virtual search is via the length scale used in ther kernel.

For more on the theory of Gaussian Processes, the best resource is available for free online: [Rasmussen & Williams (2006)](http://www.gaussianprocess.org/gpml/). We will [`george`](http://dan.iel.fm/george/current/) -- a nice python package for Gaussian Processes (GP).

### Connection to the asymptotic approximation

The next major conceptual pilar for this work is the asymptotic approximations for the likelihood ratio. Wilks's theorem states that assuming background-only ($\mu=0$) the distribution of the best fit signal strength $\hat{\mu}$ follows a Gaussian distribution $G(\hat{\mu} | \mu=0, \sigma)$, where $\sigma^2 = \textrm{Var}[\mu]$. With that assumption $q(\mu) = (\hat{\mu}/\sigma)^2$, hence $q(\mu)$ is chi-square distributed. In this way of thinking, the GP is generating results for $(\hat{\mu}/\sigma)$ as a function of the mass parameter $\nu$.

This allows us to quickly do combinations on these toy results. Fundamentally, we wish to perform a likelihood combination at every mass point. This is additive in the log-likelihood ratio:
\begin{equation}
q_{12}(\nu) = q_1(\nu) + q_2(\nu) + const
\end{equation}

This is also a chi-square random field. In the asymptotic limit, the likelihood combination is equivalent to:

\begin{equation}
\hat{\mu}_{12}(\nu) = \frac{\hat{\mu}_1(\nu) \sigma_1^{-2}(\nu) +\hat{\mu}_2(\nu)\sigma_2^{-2}(\nu)}{\sigma_1^{-2}(\nu)+\sigma_2^{-2}(\nu)}
\end{equation}

together with variance

\begin{equation}
Var[\hat{\mu}_{12}(\nu)] \equiv\sigma_{12}^2(\nu) = \frac{1}{\sigma_1^{-2}(\nu)+\sigma_2^{-2}(\nu)}
\end{equation}

The important part here is that we can also work out the kernel for the Gaussian process that describes $(\hat{\mu}_{12}/\sigma_{12})^2(\nu)$. In particular, the covariance between $\nu_1$ and $\nu_2$ of the GP for combination can be derived from the covariance of the GP for searches 1 and 2.

**Consider the simple case** where the two searches have the same constant sensitivity: $\sigma_1(\nu) = \sigma_2(\nu) = \sigma$. Then $\hat{\mu}_{12}(\nu) = [\hat{\mu}_1(\nu)+\hat{\mu}_2(\nu)]/2$ and $\sigma_{12}^2 = \sigma^2/2$. So the kernel for the GP that describes the combination is given by:

\begin{equation}
K_{12}(\nu, \nu') = Cov[(\hat{\mu}_{12}(\nu)/\sigma_{12})^2, (\hat{\mu}_{12}(\nu')/\sigma_{12})^2
= \frac{1}{2} K_{1}(\nu, \nu') + \frac{1}{2} K_{2}(\nu, \nu')
\end{equation}

**Correlarry** If two searches have the same mass resolution and statistical power, the effective $N$ needed to calculate the LEE for the combination is the same as the individual searches. (This can be demonstraited with the code below by setting `ratio_of_correlations=1.`

**Note** In what follows, I'll demonstrate this for the simple case, but this can be extended to have separate $\sigma(\nu)$ curves for searches 1 and 2.

### Basic outline of what will be shown below

We will create three different Gaussian Processes:
   * `gp1` will generate results from experiment 1
   * `gp2` will generate results from experiment 2
   * we will explicitly combine the results from experiments 1 and 2
   * `gp12` will be shown to have the same behavior as the combination of `gp1` and `gp2`

```python
%pylab inline --no-import-all
#plt.rc('text', usetex=True)
plt.rcParams['figure.figsize'] = (6.0, 6.0)
#plt.rcParams['savefig.dpi'] = 60
```

    Populating the interactive namespace from numpy and matplotlib

```python
import george
from george.kernels import ExpSquaredKernel, My2ExpLEEKernel, MySignificanceKernel
from scipy.stats import chi2, norm
```

```python
length_scale_of_correaltion=3.
ratio_of_length_scales=10.
kernel1 = ExpSquaredKernel(length_scale_of_correaltion, ndim=1)
kernel2 = ExpSquaredKernel(ratio_of_length_scales**2*length_scale_of_correaltion, ndim=1)
kernel12 = 0.5*kernel1+0.5*kernel2
```

```python
# Create the Gaussian process
# gp = george.GP(kernel)
gp1 = george.GP(kernel1, solver=george.HODLRSolver) #faster
gp2 = george.GP(kernel2, solver=george.HODLRSolver) #faster
gp12 = george.GP(kernel12, solver=george.HODLRSolver) #faster
```

```python
a1,b1,a2,b2, dummy = 1.e-2,0.1,-1.e-2,1.1, 1e3
newkernel1 = My2ExpLEEKernel(a1=a1,b1=b1,a2=0.,b2=dummy,\
                             l1=length_scale_of_correaltion,\
                             l2=ratio_of_length_scales*length_scale_of_correaltion)

newkernel2 = My2ExpLEEKernel(a1=0.,b1=dummy,a2=a2,b2=b2,\
                             l1=length_scale_of_correaltion,\
                             l2=ratio_of_length_scales*length_scale_of_correaltion)

newkernel12 = My2ExpLEEKernel(a1=a1,b1=b1,a2=a2,b2=b2,\
                             l1=length_scale_of_correaltion,\
                             l2=ratio_of_length_scales*length_scale_of_correaltion)
```

```python
#build equivalent combined kernel from basic parts and operations
# this is almost it, but still need to divide by ((1./sig11/sig11 + 1./sig21/sig21))
# and current implementation of MySignificanceKernel should be 1/sig not sig
#sig1kernel = MySignificanceKernel(a=a1,b=b1)
#sig2kernel = MySignificanceKernel(a=a2,b=b2)
#newkernel12 = sig1kernel*kernel1+sig2kernel*kernel2
```

```python
# made up sensitivity curves
def sigma1(x):
    return a1*x+b1
def sigma2(x):
    return a2*x+b2
```

```python
gp1 = george.GP(newkernel1) #,solver=george.HODLRSolver)
gp2 = george.GP(newkernel2) #,solver=george.HODLRSolver)
gp12 = george.GP(newkernel12) #,solver=george.HODLRSolver)
```

```python
n_scan_points=250
x = np.linspace(0,100,n_scan_points)
```

```python
# slow part: pre-compute internal stuff for the GP
gp1.compute(x)
gp2.compute(x)
gp12.compute(x)
```

```python
# evaluate one realization of the GP
z1 = gp1.sample(x)
z2 = gp2.sample(x)
z12 = gp12.sample(x)
# plot the chi-square random field
plt.plot(x,z1, color='red')
plt.plot(x,z2, color='red')
plt.plot(x,z12)
plt.ylabel(r'$z(\nu)$')
plt.xlabel(r'$\nu$')

```

Now lets histogram the values of the random field.
Don't get confused here... if you pick a single point and histogram the value of over many instances, you expect a Gaussian. However, for a single instance, you don't expect the histogram for the value of the field to be Gaussian (because of the correlations). Thought experiments: if you make `length_scale_of_correaltion` very small, then each point is essentially independent and you do expect to see a Gaussian; however, if `length_scale_of_correaltion` is very large then you expect the field to be nearly constant and the histogram below would be a delta function.

## Define some quick helper functions

```python
def q_to_pvalue(q):
    return (1.-chi2.cdf(q, 1))/2 #divide by 2 for 1-sided test

def pvalue_to_significance(p):
    return -norm.ppf(p)

def significance_to_pvalue(Z):
    return 1.-norm.cdf(Z)
```

```python
def num_upcrossings(z):
    """count number of times adjacent bins change between 0,1"""
    return np.sum((z-np.roll(z,1))**2)/2
```

```python
def global_pvalue(u,u0, n):
    #return (1.-chi2.cdf(u, 1))/2. + np.exp(-(u-u0)/2)*n #1-sided p-value
    return (1.-chi2.cdf(u, 1)) + np.exp(-(u-u0)/2)*n # 2-sided p-value
```

### Define the threshold for counting upcrossings

```python
u1 = 0.1
```

Check the code to count upcrossings and the LEE correction is working

```python
n_samples = 1000
n_plots = 3
plt.figure(figsize=(9,n_plots*3))

z_array = gp1.sample(x,n_samples)
n_up = np.zeros(n_samples)
sig1 = sigma1(x)
sig2 = sigma2(x)

for scan_no, z in enumerate(z_array):
    scan = (z/sig1)**2
    exc1 = (scan>u1) + 0. #add 0. to convert from bool to double
    n_up[scan_no] = num_upcrossings(exc1)
    if scan_no < n_plots:
        plt.subplot(n_plots,2,2*scan_no+1)
        plt.plot(x,scan)
        plt.plot([0,100],[u1,u1], c='r')
        plt.subplot(n_plots,2,2*scan_no+2)
        plt.plot(x,exc1)
        plt.ylim(-.1,1.1)
        print('experiment %d has %d upcrossings' %(scan_no, n_up[scan_no]))
n_av = np.mean(n_up)
print("average number of upcrossings in %d experiments is %f" %(n_samples, n_av))
```

### Make prediction for global p-value for q_max distribution

```python
u = np.linspace(5,25,100)
global_p = global_pvalue(u,u1,n_av)
```

### Generate many toy experiments (via the Gaussian Process), find maximum local significance for each, and check the prediction for the LEE-corrected global p-value

```python
n_samples = 10000
z_array = gp1.sample(x,n_samples)

q_max = np.zeros(n_samples)

for scan_no, z in enumerate(z_array):
    scan = (z/sig1)**2
    q_max[scan_no] = np.max(scan)
```

```python
bins, edges, patches = plt.hist(q_max, bins=30)
icdf = 1.-np.cumsum(bins/n_samples)
icdf = np.hstack((1.,icdf))
icdf_error = np.sqrt(np.cumsum(bins))/n_samples
icdf_error = np.hstack((0.,icdf_error))
plt.xlabel('$q_{max}$')
plt.ylabel('counts / bin')
```

```python
# plot the p-value
plt.plot(edges,icdf, c='r', label='toys')
plt.errorbar(edges,icdf,yerr=icdf_error)
plt.plot(u, global_p, label='prediction')
plt.xlabel('$u$')
plt.ylabel('$P(q_{max} >u)$')
plt.legend(('prediction','toys'))
#plt.ylabel('P(q>u)')
plt.ylim(1E-3,10)
plt.xlim(0,25)
plt.semilogy()
```

Wow! that was awesome! Go math!

# Part 2

```python
plt.plot(x,sig1)
plt.plot(x,sig2)
plt.ylim(0,4)
```

## Now let's do some experiments combining two searches

```python
n_samples = 10000
z_array1 = gp1.sample(x,n_samples)
z_array2 = gp2.sample(x,n_samples)

n_av1, n_av2, n_av12 = 0., 0., 0.
q_max = np.zeros((n_samples,3))

q_10 = np.zeros((n_samples,3))

n_plots = 3
plt.figure(figsize=(9,n_plots*3))
scan_no=0
for z1, z2 in zip(z_array1,z_array2):

    scan1  = (z1/sig1)**2
    scan2  = (z2/sig2)**2
    scan12 = ((z1/sig1**2 + z2/sig2**2 )/(sig1**-2+sig2**-2))**2  # This is where the combination happens
    scan12 = scan12*(sig1**-2 + sig2**-2)

    if scan_no==0:
        print sig1[5], sig2[5], scan1[5], scan2[5], scan12[5]

    exc1 = (scan1>u1) + 0. #add 0. to convert from bool to double
    exc2 = (scan2>u1) + 0. #add 0. to convert from bool to double
    exc12 = (scan12>u1) + 0. #add 0. to convert from bool to double

    if scan_no < n_plots:
        aspect = 1.
        #plt.subplot(n_plots,3,3*scan_no+1)
        plt.subplot(n_plots,1,1*scan_no+1)
        plt.plot(x,scan1, c='r', label='search 1')
        #plt.subplot(n_plots,3,3*scan_no+2)
        plt.subplot(n_plots,1,1*scan_no+1)
        plt.plot(x,scan2, c='g', label='search 2')
        #plt.subplot(n_plots,3,3*scan_no+3)
        plt.subplot(n_plots,1,1*scan_no+1)
        plt.plot(x,scan12, c='b', label='combined')
        plt.legend(('search 1', 'search 2', 'combined'))

    q_max[scan_no,:] = [np.max(scan1), np.max(scan2), np.max(scan12)]
    q_10[scan_no,:] = [scan1[10],scan2[10], scan12[10]]

    #print num_upcrossings(exc1)
    n_av1 += 1.*num_upcrossings(exc1)/n_samples
    n_av2 += 1.*num_upcrossings(exc2)/n_samples
    n_av12 += 1.*num_upcrossings(exc12)/n_samples
    scan_no +=1

print "n_av search 1, search 2, combined = ", n_av1, n_av2, n_av12
```

```python
#Simple scaling:
print "check simple scailing rule: prediction=%f, observed=%f" %(np.sqrt((n_av1**2+n_av2**2)/2), n_av12)
```

    check simple scailing rule: prediction=10.244003, observed=9.442700

## Now let's test the prediction that `gp12` has the same behavior as the explicit combination of search 1 and search 2.

```python
z_array12 = gp12.sample(x,n_samples)

q12_max = np.zeros((n_samples))
n_up = np.zeros(n_samples)

for scan_no, z12 in enumerate(z_array12):
    scan12 = (z12)**2 * (sig1**-2 + sig2**-2)  #divide out the variance of the combined
    q12_max[scan_no] = np.max(scan12)
    n_up[scan_no] = num_upcrossings((scan12 > u1)+0.)
print("average number of upcrossings for combined GP = %f" %(np.mean(n_up)))

```

    average number of upcrossings for combined GP = 9.568000

Compare $q_{max}$ distribution from direct combination with the prediction from gp12

```python
bins, edges, patches = plt.hist(q_max[:,2], bins=50, alpha=0.1, color='r', label='explicit combination')
bins, edges, patches = plt.hist(q12_max, bins=edges, alpha=0.1, color='b', label='predicted')
plt.ylabel('counts/bin')
plt.xlabel('$q_{max}$')
plt.legend(('explicit combination', 'predicted'))
```

```python
u = np.linspace(5,25,100)
global_p = global_pvalue(u,u1,np.mean(n_up))

icdf = 1.-np.cumsum(bins/n_samples)
icdf = np.hstack((1.,icdf))
icdf_error = np.sqrt(np.cumsum(bins))/n_samples
icdf_error = np.hstack((0.,icdf_error))

plt.plot(edges,icdf, c='r', label='toys')
plt.errorbar(edges,icdf,yerr=icdf_error)
plt.plot(u, global_p, label='prediction')
plt.xlabel('$u$')
plt.ylabel('$P(q_{max} >u)$')
plt.legend(('prediction','toys'))
#plt.ylabel('P(q>u)')
plt.ylim(1E-3,10)
plt.xlim(0,25)
plt.semilogy()
```

Bingo!

```python

```


# 鍛鍊你的資料分析力 | Python 程式設計

> 函式

郭耀仁 <yaojenkuo@datainpoint.com> from [DATAINPOINT](https://www.datainpoint.com/)

> One way to organize our Python code and make it more readable and reusable is functions.

## 程式封裝的層級

- 套件（Libraries）
    - 模組（Modules）
        - 類別（Classes）
            - **函式（Functions）**

## 為什麼需要封裝程式

- 結構化程式碼
- 重複使用程式碼
- 規模化程式碼

## 程式封裝的層級

- **將數行程式碼組合為一個函式（Function）**
- 將數個函式組合為一個類別（Class）
- 將數個類別組合為一個模組（Module）
- 將數個模組組合為一個套件（Library）

## 大綱

- 預設參數
- 多個輸出
- 不定個數輸入：彈性參數
- `lambda`
- 錯誤與例外
- 全域（Global）與區域（Local）
- 遞迴（Recursion）

## 預設參數

## 給函式一個最常使用的參數設定，但又賦予讓使用者更動的彈性

## 寫作一個函式 `get_circle_metrics()` 預設計算面積、但亦可以計算周長

\begin{equation}
\text{Circle Area} = \pi r^2 \\
\text{Circle Perimeter} = 2 \pi r
\end{equation}

```python
def get_circle_metrics(r, is_area=True):
    """
    依據半徑計算圓形面積或周長
    """
    pi = 3.14156
    if is_area:
        return pi*r**2
    else:
        return 2*pi*r
help(get_circle_metrics)
print(get_circle_metrics(3))
print(get_circle_metrics(3, False))
```

## 多個輸出

## 函式若需要多個輸出可以仰賴預設的 `tuple`

```python
def function_name():
    # 生成多個輸出
    return output_0, output_1, ...
```

## 寫作一個函式 `get_circle_metrics()` 計算面積以及周長

```python
def get_circle_metrics(r):
    """
    依據半徑計算圓形面積以及周長
    """
    pi = 3.14156
    area = pi*r**2
    perimeter = 2*pi*r
    return area, perimeter
print(get_circle_metrics(3))
print(type(get_circle_metrics(3)))
```

```python
circle_area, circle_perimeter = get_circle_metrics(3)
print(circle_area)
print(circle_perimeter)
```

## 亦可以利用其他資料結構回傳

- `list`
- `dict`
- `set`

## 不定個數輸入：彈性參數

## 有時我們的函式不確定使用者會想輸入幾個參數

## `*args` 讓使用者可以傳入可迭代的 tuple 物件

```python
def get_args(*args):
    return args

get_args(5, 5, 6, 6)
```

    (5, 5, 6, 6)

## `**kwargs` 讓使用者可以傳入可迭代的 dict-like 物件

```python
def get_kwargs(**kwargs):
    return kwargs

get_kwargs(中正區=100, 大同區=103, 中山區=104)
```

    {'中正區': 100, '大同區': 103, '中山區': 104}

## `lambda`

## 不使用 `def` 保留字與 `return` 自訂函式

```python
# Define
modulo_of_2 = lambda x: x%2
# Use
modulo_of_2(0)
```

    0

## 常與 `lambda` 一起使用的函式型函式（functional functions）

- `map()`
- `filter()`

```python
help(map)
```

    Help on class map in module builtins:

    class map(object)
     |  map(func, *iterables) --> map object
     |
     |  Make an iterator that computes the function using arguments from
     |  each of the iterables.  Stops when the shortest iterable is exhausted.
     |
     |  Methods defined here:
     |
     |  __getattribute__(self, name, /)
     |      Return getattr(self, name).
     |
     |  __iter__(self, /)
     |      Implement iter(self).
     |
     |  __new__(*args, **kwargs) from builtins.type
     |      Create and return a new object.  See help(type) for accurate signature.
     |
     |  __next__(self, /)
     |      Implement next(self).
     |
     |  __reduce__(...)
     |      Return state information for pickling.

```python
help(filter)
```

    Help on class filter in module builtins:

    class filter(object)
     |  filter(function or None, iterable) --> filter object
     |
     |  Return an iterator yielding those items of iterable for which function(item)
     |  is true. If function is None, return the items that are true.
     |
     |  Methods defined here:
     |
     |  __getattribute__(self, name, /)
     |      Return getattr(self, name).
     |
     |  __iter__(self, /)
     |      Implement iter(self).
     |
     |  __new__(*args, **kwargs) from builtins.type
     |      Create and return a new object.  See help(type) for accurate signature.
     |
     |  __next__(self, /)
     |      Implement next(self).
     |
     |  __reduce__(...)
     |      Return state information for pickling.

```python
map(lambda x: x%2, range(10))
```

    <map at 0x7fe16263e3c8>

```python
filter(lambda x: x%2, range(10))
```

    <filter at 0x7fe16263e5c0>

## `map()` 與 `filter()` 的輸出是一種特殊類別，稱為產生器（Generator）

產生器類別的輸出必須搭配使用資料結構函式，常用的是 `list()`。

```python
list(map(lambda x: x%2, range(10)))
```

    [0, 1, 0, 1, 0, 1, 0, 1, 0, 1]

```python
list(filter(lambda x: x%2, range(10)))
```

    [1, 3, 5, 7, 9]

## 許多的內建函式輸出都是產生器（Generator）類別

- `map()`
- `filter()`
- `enumerate()`
- `zip()`

```python
first_five_primes = [2, 3, 5, 7, 11]
enumerate(first_five_primes)
```

    <enumerate at 0x7fe1626473f0>

```python
list(enumerate(first_five_primes))
```

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

```python
first_five_odds = [1, 3, 5, 7, 9]
zip(first_five_primes, first_five_odds)
```

    <zip at 0x7fe1625be5c8>

```python
list(zip(first_five_primes, first_five_odds))
```

    [(2, 1), (3, 3), (5, 5), (7, 7), (11, 9)]

## 使用產生器（Generator）類別時要注意兩件事

- 需要將輸出實體化
- 實體化僅能進行一次

```python
map_object = map(lambda x: x%2, range(10))
print(list(map_object))
print(list(map_object))
```

    [0, 1, 0, 1, 0, 1, 0, 1, 0, 1]
    []

## 如何自訂一個產生器？

使用類似 List comprehension 的語法，將中括號 `[]` 改為小括號 `()`。

```python
my_generator = (i%2 for i in range(10))
print(my_generator)
print(list(my_generator))
```

    <generator object <genexpr> at 0x7fe162639c50>
    [0, 1, 0, 1, 0, 1, 0, 1, 0, 1]

## 如何自訂一個生成產生器的函式

使用 `yield` 保留字。

```python
def my_generator(x):
    for i in range(x):
        yield i%2
print(my_generator(10))
print(list(my_generator(10)))
```

    <generator object my_generator at 0x7fe162639c50>
    [0, 1, 0, 1, 0, 1, 0, 1, 0, 1]

## 錯誤與例外

## 三種基本的錯誤

- Syntax errors：語法錯誤
- Runtime errors：直譯錯誤
- Semantic errors：邏輯錯誤

```python
# Syntax errors
print("Hello World!"
```

## 常見的 Runtime errors

- `NameError`
- `TypeError`
- `IndexError`
- `ZeroDivisionError`
- ...etc.

```python
# Runtime error: NameError
msg = "Hello World"
print(Msg)
```

```python
# Runtime error: TypeError
"55" + 66
```

```python
# Runtime error: IndexError
avengers_movies = ["The Avengers", "Avengers: Age of Ultron", "Avengers: Infinity War", "Avengers: Endgame"]
avengers_movies[4]
```

```python
def divide(x, y):
    """
    將輸入的兩個數字相除
    """
    return x / y
```

```python
print(divide(55, 66))
print(divide(5566, 0))
```

## 使用 `try...except...` 處理錯誤與例外

```python
def safe_divide(x, y):
    """
    將輸入的兩個數字相除
    """
    try:
        return x / y
    except:
        return "Something went wrong..."
```

```python
print(safe_divide(55, 66))
print(safe_divide(5566, 0))
```

    0.8333333333333334
    Something went wrong...

## 在 `except` 後面連接錯誤的種類捕捉特定錯誤

```python
def safe_divide(x, y):
    """
    將輸入的兩個數字相除
    """
    try:
        return x / y
    except ZeroDivisionError:
        return "Demoninator should not be zero!"
    except TypeError:
        return "Numerator and demoninator should not be strings!"
```

```python
print(safe_divide(5566, 0))
print(safe_divide(55, '66'))
```

    Demoninator should not be zero!
    Numerator and demoninator should not be strings!

## 以 `raise` 告知使用者函式不支援的功能

```python
def is_prime(x):
    """
    判斷輸入的正整數 x 是否為質數
    """
    if x < 0:
        raise ValueError("x must be positive!")
    elif isinstance(x, float):
        raise ValueError("x must be an integer!")
    divisor_cnt, i = 0, 1
    while i <= x:
        if x % i == 0:
            divisor_cnt += 1
        if divisor_cnt > 2:
            break
        i += 1
    return divisor_cnt == 2
```

```python
is_prime(89.0)
```

```python
is_prime(-5566)
```

## 全域（Global）與區域（Local）

## 什麼是全域與區域？

- 在函式的程式碼結構以外所建立的物件屬於全域
- 在函數的程式碼結構中建立的物件屬於區域

## 全域物件與區域物件的差別？

- 區域物件**僅可**在區域中使用
- 全域物件可以在全域以及區域中使用

```python
# 區域物件僅可在區域中使用
def get_squared(x):
    squared_x = x**2 # Local
    return squared_x

print(get_squared(2))
print(squared_x) # Local object cannot be accessed in global
```

```python
# 全域物件可以在全域以及區域中使用
squared_x = 4
print(squared_x) # Global object can be accessed in global, of course
def get_squared():
    return squared_x

print(get_squared()) # Global object can be accessed in local
```

    4
    4

## 遞迴（Recursion）

## 什麼是遞迴

## 在定義函式的同時就呼叫「正在」定義的函式

## 常見的遞迴示範：階乘（factorial）函式

$$n! = 1 \times 2 \times ... \times (n-1) \times n$$

```python
def factorial(n):
    if n == 0:
        return 1
    elif n == 1:
        return 1
    else:
        return n*factorial(n-1) # recursive happens here
```

```python
print(factorial(0))
print(factorial(1))
print(factorial(2))
print(factorial(3))
```

    1
    1
    2
    6

## 另一個常見的遞迴示範：Fibonacci 函式

$$F_0 = 0 \\
F_1 = 1 \\
F_n = F_{n-1} + F_{n-2}
$$

```python
def fibonacci(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fibonacci(n-1) + fibonacci(n-2) # recursive happens here
```

```python
print(fibonacci(0))
print(fibonacci(1))
print(fibonacci(2))
print(fibonacci(3))
```

    0
    1
    1
    2

## 隨堂練習

## 隨堂練習：定義一個函式 `product(*args)` 能回傳 `*args` 所組成之數列的乘積

- 預期輸入：彈性參數 `*args`
- 預期輸出：一個數值

```python
def product(*args):
    """
    >>> product(0, 1, 2)
    0
    >>> product(1, 2, 3, 4, 5)
    120
    >>> product(1, 3, 5, 7, 9)
    945
    """
```

## 隨堂練習：定義一個函式 `iso_country(**kwargs)` 能讓使用者創建國家的 Alpha-3 code 與國家名稱的對應 dict

<https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes。

- 預期輸入：彈性參數 `**kwargs`
- 預期輸出：一個 dict

```python
def iso_country(**kwargs):
    """
    >>> iso_country(TWN='Taiwan')
    {'TWN': 'Taiwan'}
    >>> iso_country(TWN='Taiwan', USA='United States of America')
    {'TWN': 'Taiwan', 'USA': 'United States of America'}
    >>> iso_country(TWN='Taiwan', USA='United States of America', JPN='Japan')
    {'TWN': 'Taiwan', 'USA': 'United States of America', 'JPN': 'Japan'}
    """
```

## 隨堂練習：定義一個函式 `mean(*args)` 能回傳 `*args` 所組成之數列的平均數

\begin{equation}
\mu = \frac{\sum_{i=1}^n x_i}{n}
\end{equation}

- 預期輸入：彈性參數 `*args`
- 預期輸出：一個數值

```python
def mean(*args):
    """
    >>> mean(1, 3, 5, 7, 9)
    5.0
    >>> mean(3, 4, 5, 6, 7)
    5.0
    >>> mean(3)
    3.0
    """
```

## 隨堂練習：定義一個函式 `std(*args)` 回傳 `*args` 所組成之數列的樣本標準差

\begin{equation}
\sigma = \sqrt{\frac{1}{n-1}\sum_{i=1}^{n}(x_i - \bar{x})^2}
\end{equation}

<https://en.wikipedia.org/wiki/Standard_deviation>

- 預期輸入：彈性參數 `*args`
- 預期輸出：一個數值或文字

```python
def std(*args):
    """
    >>> std(1, 3, 5, 7, 9)
    3.1622776601683795
    >>> std(3, 4, 5, 6, 7)
    1.5811388300841898
    >>> std(3)
    'Please input at least 2 numbers.'
    """
```

## 隨堂練習：定義一個函式 `fibonacci_list(N, f0=0, f1=1)` 回傳長度為 `N`、前兩個數字分別為 `f0` 與 `f1` 的費氏數列

\begin{equation}
F_0 = 0, F_1 = 1 \\
F_n = F_{n-1} + F_{n-2} \text{ , For } n > 1
\end{equation}

<https://en.wikipedia.org/wiki/Fibonacci_number>

- 預期輸入：三個整數
- 預期輸出：一個長度為 N 的 list

```python
def fibonacci_list(N, f0=0, f1=1):
    """
    >>> fibonacci_list(5)
    [0, 1, 1, 2, 3]
    >>> fibonacci_list(5, 1, 2)
    [1, 2, 3, 5, 8]
    >>> fibonacci_list(10, 1, 2)
    [1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
    """
```

## 執行測試

```python
%load ../test_cases/test_cases_07.py
```