# Double Pendulum with Python package PyDy

```python
from __future__ import print_function, division
from sympy import symbols, simplify
from sympy.physics.mechanics import dynamicsymbols, ReferenceFrame, Point
from sympy.physics.vector import init_vprinting
init_vprinting(use_latex='mathjax', pretty_print=False)
from sympy.physics.mechanics import inertia, RigidBody
from sympy import trigsimp
from sympy.physics.mechanics import KanesMethod
from numpy import deg2rad, rad2deg, array, zeros, linspace
from scipy.integrate import odeint
from pydy.codegen.ode_function_generators import generate_ode_function
import matplotlib.pyplot as plt
import ipywidgets as widgets
from ivisual import *
from IPython.display import display, clear_output
```

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

## Reference frames

```python
inertial_frame = ReferenceFrame('I')

pendulum_frames = [ReferenceFrame('P0'),
                   ReferenceFrame('P1')]

```

    (MainThread) backend module://ipykernel.pylab.backend_inline version unknown

    ERROR! Session/line number was not unique in database. History logging moved to new session 556

```python
psis = [dynamicsymbols('psi0'), #Pendulum1 angle
        dynamicsymbols('psi1')] #Pendulum2 angle

```

```python
pendulum_frames[0].orient(inertial_frame, 'Axis', (psis[0], inertial_frame.z))
pendulum_frames[1].orient(pendulum_frames[0], 'Axis', (psis[1], pendulum_frames[0].z))

```

```python
pendulum_frames[0].dcm(inertial_frame)
```

$$\left[\begin{matrix}\operatorname{cos}\left(\psi_{0}\right) & \operatorname{sin}\left(\psi_{0}\right) & 0\\- \operatorname{sin}\left(\psi_{0}\right) & \operatorname{cos}\left(\psi_{0}\right) & 0\\0 & 0 & 1\end{matrix}\right]$$

```python
simplify(pendulum_frames[1].dcm(inertial_frame))
```

$$\left[\begin{matrix}\operatorname{cos}\left(\psi_{0} + \psi_{1}\right) & \operatorname{sin}\left(\psi_{0} + \psi_{1}\right) & 0\\- \operatorname{sin}\left(\psi_{0} + \psi_{1}\right) & \operatorname{cos}\left(\psi_{0} + \psi_{1}\right) & 0\\0 & 0 & 1\end{matrix}\right]$$

## Define some points

```python
attachment = Point('A')

Lcgs = [symbols('Lcg0'),
       symbols('Lcg1')]

#end_points = [symbols('end0'),
#              symbols('end1')]

pendulum_lengths = [symbols('L0'),
                    symbols('L1')]

pendulum_mass_centers = [Point('cg0'),
                        Point('cg1')]

pendulum_end_points = [Point('end0'),
                       Point('end1')]

pendulum_end_points[0].set_pos(attachment, pendulum_lengths[0]*pendulum_frames[0].y)
pendulum_end_points[1].set_pos(pendulum_end_points[0], pendulum_lengths[1]*pendulum_frames[1].y)

pendulum_mass_centers[0].set_pos(attachment, Lcgs[0]*pendulum_frames[0].y)
pendulum_mass_centers[1].set_pos(pendulum_end_points[0], Lcgs[1]*pendulum_frames[1].y)

```

## Kinematical Differential Equations

```python
rs = [dynamicsymbols('r0'), # Pendulum0 rate
      dynamicsymbols('r1')] # Pendulum1 rate
pendulum_end_point_velocities = [dynamicsymbols('pev0'),
                                dynamicsymbols('pev1')]
```

```python
kinematical_differential_equations = [rs[0] - psis[0].diff(),
                                      rs[1] - psis[1].diff()
                                     ]
kinematical_differential_equations
```

$$\left [ r_{0} - \dot{\psi}_{0}, \quad r_{1} - \dot{\psi}_{1}\right ]$$

## Angular Velocities

```python
pendulum_frames[0].set_ang_vel(inertial_frame,rs[0]*inertial_frame.z)
pendulum_frames[1].set_ang_vel(pendulum_frames[0],rs[1]*inertial_frame.z)

```

```python
pendulum_frames[0].ang_vel_in(inertial_frame)
```

$$r_{0}\mathbf{\hat{i}_z}$$

```python
pendulum_frames[1].ang_vel_in(inertial_frame)
```

$$(r_{0} + r_{1})\mathbf{\hat{i}_z}$$

## Linear Velocities

```python
attachment.set_vel(inertial_frame, 0)

```

```python
pendulum_mass_centers[0].v2pt_theory(attachment, inertial_frame, pendulum_frames[0])
pendulum_mass_centers[0].vel(inertial_frame)
```

$$-  Lcg_{0} r_{0}\mathbf{\hat{p0}_x}$$

```python
pendulum_end_points[0].v2pt_theory(attachment, inertial_frame, pendulum_frames[0])
pendulum_end_points[0].vel(inertial_frame)
```

$$-  L_{0} r_{0}\mathbf{\hat{p0}_x}$$

```python
pendulum_mass_centers[1].v2pt_theory(pendulum_end_points[0], inertial_frame, pendulum_frames[1])
pendulum_mass_centers[1].vel(inertial_frame)
```

$$-  L_{0} r_{0}\mathbf{\hat{p0}_x} -  Lcg_{1} \left(r_{0} + r_{1}\right)\mathbf{\hat{p1}_x}$$

## Mass

```python
pendulum_masses = [symbols('m_P0'),symbols('m_P1')]
```

## Inertia

```python
Izzs = [symbols('I_zz0'),symbols('I_zz1')]
```

```python
pendulum_dyadics = [inertia(pendulum_frames[0], 0, 0, Izzs[0]),
                   inertia(pendulum_frames[1], 0, 0, Izzs[1])]
```

```python
pendulum_dyadics[0]
```

$$I_{zz0}\mathbf{\hat{p0}_z}\otimes \mathbf{\hat{p0}_z}$$

```python
pendulum_dyadics[0].to_matrix(pendulum_frames[0])
```

$$\left[\begin{matrix}0 & 0 & 0\\0 & 0 & 0\\0 & 0 & I_{zz0}\end{matrix}\right]$$

```python
pendulum_central_inertias = [(pendulum_dyadics[0], pendulum_mass_centers[0]),
                             (pendulum_dyadics[1], pendulum_mass_centers[1])]

```

```python
pendulums = [RigidBody('Pendulum0', pendulum_mass_centers[0], pendulum_frames[0],pendulum_masses[0],
                       pendulum_central_inertias[0]),
             RigidBody('Pendulum1', pendulum_mass_centers[1], pendulum_frames[1],pendulum_masses[1],
                       pendulum_central_inertias[1])]
```

## Equations of Motion

```python
coordinates = psis
coordinates
```

$$\left [ \psi_{0}, \quad \psi_{1}\right ]$$

```python
speeds = rs
speeds
```

$$\left [ r_{0}, \quad r_{1}\right ]$$

```python
kane = KanesMethod(inertial_frame, coordinates, speeds, kinematical_differential_equations)
```

```python
g = symbols('g')
```

```python
pendulum_grav_force_vectors = [pendulum_masses[0] * g * inertial_frame.y,
                               pendulum_masses[1] * g * inertial_frame.y]
pendulum_grav_force_vectors
```

$$\left [ g m_{P0}\mathbf{\hat{i}_y}, \quad g m_{P1}\mathbf{\hat{i}_y}\right ]$$

```python
pendulum_grav_forces = [(pendulum_mass_centers[0], pendulum_grav_force_vectors[0]),
                        (pendulum_mass_centers[1], pendulum_grav_force_vectors[1])]
```

```python
attachement_torques = [dynamicsymbols('T_a0'),
                       dynamicsymbols('T_a1')]

pendulum_torque_vectors = [attachement_torques[0] * inertial_frame.z,
                           attachement_torques[1] * inertial_frame.z]

pendulum_torque_vectors
```

$$\left [ T_{a0}\mathbf{\hat{i}_z}, \quad T_{a1}\mathbf{\hat{i}_z}\right ]$$

```python
pendulum_torques = [(pendulum_frames[0], pendulum_torque_vectors[0]),
                    (pendulum_frames[1], pendulum_torque_vectors[1])]
```

```python
#loads = [pendulum_grav_force,
#        pendulum_torque]
loads = pendulum_grav_forces

loads
```

    [(cg0, g*m_P0*I.y), (cg1, g*m_P1*I.y)]

```python
bodies = pendulums
```

```python
fr, frstar = kane.kanes_equations(bodies = bodies,loads = loads)
```

```python
trigsimp(fr + frstar)
```

$$\left[\begin{matrix}L_{0} Lcg_{1} m_{P1} \left(r_{0} + r_{1}\right)^{2} \operatorname{sin}\left(\psi_{1}\right) - L_{0} Lcg_{1} m_{P1} r^{2}_{0} \operatorname{sin}\left(\psi_{1}\right) - L_{0} g m_{P1} \operatorname{sin}\left(\psi_{0}\right) - Lcg_{0} g m_{P0} \operatorname{sin}\left(\psi_{0}\right) - Lcg_{1} g m_{P1} \operatorname{sin}\left(\psi_{0} + \psi_{1}\right) - \left(I_{zz1} + Lcg_{1} m_{P1} \left(L_{0} \operatorname{cos}\left(\psi_{1}\right) + Lcg_{1}\right)\right) \dot{r}_{1} - \left(I_{zz0} + I_{zz1} + Lcg_{0}^{2} m_{P0} + m_{P1} \left(L_{0}^{2} + 2 L_{0} Lcg_{1} \operatorname{cos}\left(\psi_{1}\right) + Lcg_{1}^{2}\right)\right) \dot{r}_{0}\\- L_{0} Lcg_{1} m_{P1} r^{2}_{0} \operatorname{sin}\left(\psi_{1}\right) - Lcg_{1} g m_{P1} \operatorname{sin}\left(\psi_{0} + \psi_{1}\right) - \left(I_{zz1} + Lcg_{1}^{2} m_{P1}\right) \dot{r}_{1} - \left(I_{zz1} + Lcg_{1} m_{P1} \left(L_{0} \operatorname{cos}\left(\psi_{1}\right) + Lcg_{1}\right)\right) \dot{r}_{0}\end{matrix}\right]$$

```python
mass_matrix = trigsimp(kane.mass_matrix_full)
mass_matrix
```

$$\left[\begin{matrix}1 & 0 & 0 & 0\\0 & 1 & 0 & 0\\0 & 0 & I_{zz0} + I_{zz1} + Lcg_{0}^{2} m_{P0} + m_{P1} \left(L_{0}^{2} + 2 L_{0} Lcg_{1} \operatorname{cos}\left(\psi_{1}\right) + Lcg_{1}^{2}\right) & I_{zz1} + Lcg_{1} m_{P1} \left(L_{0} \operatorname{cos}\left(\psi_{1}\right) + Lcg_{1}\right)\\0 & 0 & I_{zz1} + Lcg_{1} m_{P1} \left(L_{0} \operatorname{cos}\left(\psi_{1}\right) + Lcg_{1}\right) & I_{zz1} + Lcg_{1}^{2} m_{P1}\end{matrix}\right]$$

```python
forcing_vector = trigsimp(kane.forcing_full)
forcing_vector
```

$$\left[\begin{matrix}r_{0}\\r_{1}\\2 L_{0} Lcg_{1} m_{P1} r_{0} r_{1} \operatorname{sin}\left(\psi_{1}\right) + L_{0} Lcg_{1} m_{P1} r^{2}_{1} \operatorname{sin}\left(\psi_{1}\right) - L_{0} g m_{P1} \operatorname{sin}\left(\psi_{0}\right) - Lcg_{0} g m_{P0} \operatorname{sin}\left(\psi_{0}\right) - Lcg_{1} g m_{P1} \operatorname{sin}\left(\psi_{0} + \psi_{1}\right)\\- Lcg_{1} m_{P1} \left(L_{0} r^{2}_{0} \operatorname{sin}\left(\psi_{1}\right) + g \operatorname{sin}\left(\psi_{0} + \psi_{1}\right)\right)\end{matrix}\right]$$

## Simulation

```python
constants = [Lcgs[0],
             pendulum_lengths[0],
             pendulum_masses[0],
             Izzs[0],
             Lcgs[1],
             pendulum_masses[1],
             Izzs[1],
             g]
```

```python
constants
```

$$\left [ Lcg_{0}, \quad L_{0}, \quad m_{P0}, \quad I_{zz0}, \quad Lcg_{1}, \quad m_{P1}, \quad I_{zz1}, \quad g\right ]$$

```python
coordinates = psis
coordinates
```

$$\left [ \psi_{0}, \quad \psi_{1}\right ]$$

```python
speeds = rs
speeds
```

$$\left [ r_{0}, \quad r_{1}\right ]$$

```python
#specified = [pendulum_torque]
specified = []

right_hand_side = generate_ode_function(forcing_vector, coordinates,
                                        speeds, constants,
                                        mass_matrix=mass_matrix,
                                        specifieds=specified)
```

```python
help(generate_ode_function)
```

    Help on function generate_ode_function in module pydy.codegen.ode_function_generators:

    generate_ode_function(*args, **kwargs)
        Generates a numerical function which can evaluate the right hand
        side of the first order ordinary differential equations from a
        system described by one of the following three symbolic forms:

            [1] x' = F(x, t, r, p)

            [2] M(x, p) x' = F(x, t, r, p)

            [3] M(q, p) u' = F(q, u, t, r, p)
                q' = G(q, u, t, r, p)

        where

            x : states, i.e. [q, u]
            t : time
            r : specified (exogenous) inputs
            p : constants
            q : generalized coordinates
            u : generalized speeds
            M : mass matrix (full or minimum)
            F : right hand side (full or minimum)
            G : right hand side of the kinematical differential equations

        The generated function is of the form F(x, t, p) or F(x, t, r, p)
        depending on whether the system has specified inputs or not.

        Parameters
        ==========
        right_hand_side : SymPy Matrix, shape(n, 1)
            A column vector containing the symbolic expressions for the
            right hand side of the ordinary differential equations. If the
            right hand side has been solved for symbolically then only F is
            required, see form [1]; if not then the mass matrix must also be
            supplied, see forms [2, 3].
        coordinates : sequence of SymPy Functions
            The generalized coordinates. These must be ordered in the same
            order as the rows in M, F, and/or G and be functions of time.
        speeds : sequence of SymPy Functions
            The generalized speeds. These must be ordered in the same order
            as the rows in M, F, and/or G and be functions of time.
        constants : sequence of SymPy Symbols
            All of the constants present in the equations of motion. The
            order does not matter.
        mass_matrix : sympy.Matrix, shape(n, n), optional
            This can be either the "full" mass matrix as in [2] or the
            "minimal" mass matrix as in [3]. The rows and columns must be
            ordered to match the order of the coordinates and speeds. In the
            case of the full mass matrix, the speeds should always be
            ordered before the speeds, i.e. x = [q, u].
        coordinate_derivatives : sympy.Matrix, shape(m, 1), optional
            If the "minimal" mass matrix, form [3], is supplied, then this
            column vector represents the right hand side of the kinematical
            differential equations.
        specifieds : sequence of SymPy Functions
            The specified exogenous inputs to the system. These should be
            functions of time and the order does not matter.
        linear_sys_solver : string or function
            Specify either `numpy` or `scipy` to use the linear solvers
            provided in each package or supply a function that solves a
            linear system Ax=b with the call signature x = solve(A, b). For
            example, if you need to use custom kwargs for the SciPy solver,
            pass in a lambda function that wraps the solver and sets them.
        constants_arg_type : string
            The generated function accepts two different types of arguments
            for the numerical values of the constants: either a ndarray of
            the constants values in the correct order or a dictionary
            mapping the constants symbols to the numerical values. If None,
            this is determined inside of the generated function and can
            cause a significant slow down for performance critical code. If
            you know apriori what arg types you need to support choose
            either ``array`` or ``dictionary``. Note that ``array`` is
            faster than ``dictionary``.
        specifieds_arg_type : string
            The generated function accepts three different types of
            arguments for the numerical values of the specifieds: either a
            ndarray of the specifieds values in the correct order, a
            function that generates the correctly ordered ndarray, or a
            dictionary mapping the specifieds symbols or tuples of thereof
            to floats, ndarrays, or functions. If None, this is determined
            inside of the generated function and can cause a significant
            slow down for performance critical code. If you know apriori
            what arg types you want to support choose either ``array``,
            ``function``, or ``dictionary``. The speed of each, from fast to
            slow, are ``array``, ``function``, ``dictionary``, None.

                generator : string or and ODEFunctionGenerator, optional
            The method used for generating the numeric right hand side. The
            string options are {'lambdify'|'theano'|'cython'} with
            'lambdify' being the default. You can also pass in a custom
            subclass of ODEFunctionGenerator.

        Returns
        =======
        rhs : function
            A function which evaluates the derivaties of the states. See the
            function's docstring for more details after generation.

```python
help(right_hand_side)
```

    Help on function rhs in module pydy.codegen.ode_function_generators:

    rhs(*args)
        Returns the derivatives of the states, i.e. numerically evaluates the right
        hand side of the first order differential equation.

        x' = f(x, t, r, p)

        Parameters
        ==========
        x : ndarray, shape(4,)
            The state vector is ordered as such:
                - psi0(t)
                - psi1(t)
                - r0(t)
                - r1(t)
        t : float
            The current time.
        r : dictionary; ndarray, shape(0,); function

            There are three options for this argument. (1) is more flexible but
            (2) and (3) are much more efficient.

            (1) A dictionary that maps the specified functions of time to floats,
            ndarrays, or functions that produce ndarrays. The keys can be a single
            specified symbolic function of time or a tuple of symbols. The total
            number of symbols must be equal to 0. If the value is a
            function it must be of the form g(x, t), where x is the current state
            vector ndarray and t is the current time float and it must return an
            ndarray of the correct shape. For example::

              r = {a: 1.0,
                   (d, b) : np.array([1.0, 2.0]),
                   (e, f) : lambda x, t: np.array(x[0], x[1]),
                   c: lambda x, t: np.array(x[2])}

            (2) A ndarray with the specified values in the correct order and of the
            correct shape.

            (3) A function that must be of the form g(x, t), where x is the current
            state vector and t is the current time and it must return an ndarray of
            the correct shape.

            The specified inputs are, in order:
                -
        p : dictionary len(8) or ndarray shape(8,)
            Either a dictionary that maps the constants symbols to their numerical
            values or an array with the constants in the following order:
                - Lcg0
                - L0
                - m_P0
                - I_zz0
                - Lcg1
                - m_P1
                - I_zz1
                - g

        Returns
        =======
        dx : ndarray, shape(4,)
            The derivative of the state vector.

## Set the Initial Conditions, Parameter Values, and Time Array

```python
x0 = array([deg2rad(45),deg2rad(-45),0,0])
x0
```

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

```python
Ls = [20,20]
numerical_constants =  np.array([Ls[0]/2,  # pendulum_length [m]
                                 Ls[0],
                         10,  # pendulum_mass [kg]
                        100,  # pendulum_inertia [kg*m^2]
                        Ls[1]/2,  # pendulum_length [m]
                        10,  # pendulum_mass [kg]
                        100,  # pendulum_inertia [kg*m^2]
                        9.81])  # acceleration due to gravity [m/s^2]

```

```python
# %load exercise_solutions/n07_simulation_sim-setup.py
#numerical_specified = zeros(3)
#numerical_specified = {}
numerical_specified = {}
#numerical_specified['L0'] = 10
#numerical_specified['L'] = 10

args = {'constants': numerical_constants,
        'specified': numerical_specified}

frames_per_sec = 100
final_time = 15.0

t = linspace(0.0, final_time, final_time * frames_per_sec)
```

    /Users/martinalexandersson/Dev/ForceMan/venv/lib/python3.6/site-packages/ipykernel_launcher.py:16: DeprecationWarning: object of type <class 'float'> cannot be safely interpreted as an integer.
      app.launch_new_instance()

```python
right_hand_side(x0, 0.0, numerical_specified, numerical_constants)
```

    array([ 0.        ,  0.        , -0.63410437,  1.44933983])

```python
import inspect
inspect.getsourcelines(right_hand_side)
```

    (['            def rhs(*args):\n',
      '                # args: x, t, p\n',
      '                # or\n',
      '                # args: x, t, r, p\n',
      '\n',
      '                args = self._parse_all_args(*args)\n',
      '\n',
      '                q = args[0][:self.num_coordinates]\n',
      '                u = args[0][self.num_coordinates:]\n',
      '\n',
      '                xdot = self._base_rhs(q, u, *args[2:])\n',
      '\n',
      '                return xdot\n'],
     467)

## Integrate the Equations of Motion

```python
y = odeint(right_hand_side, x0, t, args=(numerical_specified, numerical_constants))
```

```python
fig,ax = plt.subplots()
fig.set_size_inches(15,5)

ax.plot(t,y[:,0])

```

```python

```

```python
fig,ax = plt.subplots()
fig.set_size_inches(15,5)

ax.plot(t,y[:,1])
```

## Visualization

```python
simplify(pendulum_end_points[0].pos_from(attachment).to_matrix(inertial_frame))
```

$$\left[\begin{matrix}- L_{0} \operatorname{sin}\left(\psi_{0}\right)\\L_{0} \operatorname{cos}\left(\psi_{0}\right)\\0\end{matrix}\right]$$

```python
simplify(pendulum_end_points[1].pos_from(attachment).to_matrix(inertial_frame))
```

$$\left[\begin{matrix}- L_{0} \operatorname{sin}\left(\psi_{0}\right) - L_{1} \operatorname{sin}\left(\psi_{0} + \psi_{1}\right)\\L_{0} \operatorname{cos}\left(\psi_{0}\right) + L_{1} \operatorname{cos}\left(\psi_{0} + \psi_{1}\right)\\0\end{matrix}\right]$$

```python
positions = [pendulum_end_points[0].pos_from(attachment),
             pendulum_end_points[1].pos_from(attachment)]

global_position_matrixes = []
for position in positions:
    global_position_matrixes.append(position.to_matrix(inertial_frame))
```

```python

def play(x):

    index = int((len(t)-1)*x/100)

    #for global_position_matrix,pendulum_cylinder,psi_now,L,pendulum_length,psi in zip(
    #    global_position_matrixes,pendulum_cylinders,psi_nows,Ls,pendulum_lengths,psis):
    #
    #    a = global_position_matrix.evalf(subs = {pendulum_length:L,psi:psi_now})
    #    pendulum_cylinder.axis = np.array(a.transpose().tolist()).astype(np.float64)[0]

    psi_nows = y[index,0:len(global_position_matrixes)]

    a = global_position_matrixes[0].evalf(subs = {pendulum_lengths[0]:Ls[0],psis[0]:psi_nows[0]})
    end_point = np.array(a.transpose().tolist()).astype(np.float64)[0]
    pendulum_cylinders[0].axis = end_point

    a = global_position_matrixes[1].evalf(subs = {pendulum_lengths[0]:Ls[0],
                                                  pendulum_lengths[1]:Ls[1],
                                                  psis[0]:psi_nows[0],
                                                  psis[1]:psi_nows[1]})

    pendulum_cylinders[1].axis = np.array(a.transpose().tolist()).astype(np.float64)[0] - pendulum_cylinders[0].axis
    pendulum_cylinders[1].pos = pendulum_cylinders[0].axis

def play2(change):

    #x = interactive_plot.children[0].value
    x = slider.value

    play(x = x)

def plot(x):

    index = int((len(t)-1)*x/100)
    fig,ax = plt.subplots()
    ax.plot(t,y[:,0])
    ax.plot(t[index],y[index,0],'ro')
    display(fig)
    clear_output(wait=True)

```

```python

#from ipywidgets import interactive
#
#
#interactive_plot = interactive(plot, x=(0.0,100.0))
#display(interactive_plot)
#
```

```python
scene2 = canvas(width = 1000,height = 500,up=vector(0,-1,0))
vscale = 3
varr = arrow(pos=vector(0,0,0), axis=vscale*vector(1,0,0), color=color.red,shaftwidth=vscale*0.1)
varr = arrow(pos=vector(0,0,0), axis=vscale*vector(0,1,0), color=color.green,shaftwidth=vscale*0.1)
varr = arrow(pos=vector(0,0,0), axis=vscale*vector(0,0,1), color=color.blue,shaftwidth=vscale*0.1)

pendulum_radius = 0.1

pendulum_cylinders = []
colors = [color.orange,color.magenta]
for L,the_color in zip(Ls,colors):
    pendulum_cylinders.append(cylinder(pos = (0,0,0),axis = (0,1,0),length = L,radius = pendulum_radius,color = the_color))

play_widget = widgets.Play(
    #interval=0.05,
    step = 1,
    value=0,
    min=0,
    max=100,
    description="Press play",
    disabled=False

)
slider = widgets.FloatSlider()
widgets.jslink((play_widget, 'value'), (slider, 'value'))
#widgets.jslink((play_widget, 'value'), (interactive_plot.children[0], 'value'))

play_widget.observe(play2, names='value')

scene2.visible = True
display(scene2)
#display(interactive_plot.children[1])

widgets.HBox([play_widget, slider])
#widgets.HBox([play_widget, interactive_plot.children[0]])

```

    <IPython.core.display.Javascript object>

```python
y
```

```python

```


```python
%pylab inline
```

    Populating the interactive namespace from numpy and matplotlib

Fourier Analysis of a Plucked String
------------------------------------

Let's assume we're studying a plucked string with a shape like this:

\begin{eqnarray}
f(x) & = & 2 A \frac{x}{L}  &  (x<L/2) \\
\\
f(x) & = & 2 A \left(\frac{L-x}{L}\right) & (x >= L/2)
\end{eqnarray}

Let's graph that and see what it looks like:

```python
L=1.0
N=500  # make sure N is even for simpson's rule
A=1.0

def fLeft(x):
    return 2*A*x/L

def fRight(x):
    return 2*A*(L-x)/L

def fa_vec(x):
    """
    vector version
    'where(cond, A, B)', returns A when cond is true and B when cond is false.
    """
    return where(x<L/2, fLeft(x), fRight(x))

x=linspace(0,L,N)  # define the 'x' array
h=x[1]-x[0]        # get x spacing
y=fa_vec(x)
title("Vertical Displacement of a Plucked String at t=0")
xlabel("x")
ylabel("y")
plot(x,y)
```

Let's define the basis functions:

\begin{equation}
|n\rangle = b_n(x) = \sqrt{\frac{2}{L}} \sin(n \pi x/L)
\end{equation}

Let's look at a few of those. In python well use the function `basis(x,n)` for $|n\rangle$:

```python
def basis(x, n):
    return sqrt(2/L)*sin(n*pi*x/L)

for n in range(1,5):
    plot(x,basis(x,n),label="n=%d"%n)

legend(loc=3)
```

If we guess that we can express $f(x)$ as a superposition of $b_n(x)$ then we have:

\begin{equation}
f(x) = c_1 b_1(x) + c_2 b_2(x) + c_3 b_3(x) + \cdots = \sum_{n=1}^\infty c_n b_n(x)
\end{equation}

What happens if we multiply $f(x)$ with $b_m(x)$ and then integrate from $x=0$ to $x=L$?

\begin{equation}
\int_0^L f(x) b_m(x) dx = c_1 \int_0^L b_m(x) b_1(x)dx + c_2 \int_0^L b_m(x) b_2(x)dx + c_3 \int_0^L b_m(x) b_3(x)dx + \cdots
\end{equation}

or more compactly in dirac notation:

\begin{equation}
\langle m|f\rangle = c_1 \langle m|1\rangle + c_2 \langle m|2\rangle + c_3 \langle m|3\rangle + \cdots
\end{equation}

or equivalently:

\begin{equation}
\langle m|f\rangle = \sum_{n=1}^\infty c_n \langle m|n\rangle
\end{equation}

Remember that the $b_n(x)$ are *orthonormal* so that means:

$$\langle n|m \rangle = \int_0^L b_n(x) b_m(x)dx = \delta_{nm}$$

where $\delta_{nm}$ is defined as 0 if $n<>m$ and 1 if $n=m$.

So:

$$\sum_{n=1}^\infty c_n \langle m|n\rangle = c_m$$

or in other words:

$$c_m = \langle m|f\rangle = \int_{0}^{L} b_m(x) f(x)  dx $$

Yeah! Let's do that integral for this case. Note that the function $f(x)$ is symmetric about the midpoint, just like $b_m$ when $m$ is odd. When $m$ is even, the integral is zero. SO, for *odd* m we can write:

$$ c_m = \int_{0}^{L} b_m(x) f(x)  dx = 2 \int_{0}^{L/2} b_m(x) f(x)  dx $$

(when $m$ is odd) or:

$$ c_m = 2 \int_0^{L/2}  \sqrt{\frac{2}{L}} \sin(\frac{m\pi x}{L}) 2A \frac{x}{L} dx $$

$$ c_m = \frac{4A}{L} \sqrt{\frac{2}{L}} \int_0^{L/2} x\sin(\frac{m\pi x}{L}) dx $$

$$ c_m = \frac{4A}{L} \sqrt{\frac{2}{L}} \frac{L^2}{\pi^2 m^2} (-1)^{\frac{m-1}{2}}$$

Or simplifying:

$$ c_m = \frac{4A \sqrt{2L}}{\pi^2 m^2} (-1)^{\frac{m-1}{2}}$$

```python
def simpson_array(f, h):
    """

Use Simpson's Rule to estimate an integral of an array of
    function samples

    f: function samples (already in an array format)
    h: spacing in "x" between sample points

    The array is assumed to have an even number of elements.

    """
    if len(f)%2 != 0:
        raise ValueError("Sorry, f must be an array with an even number of elements.")

    evens =  f[2:-2:2]
    odds = f[1:-1:2]
    return (f[0] + f[-1] + 2*odds.sum() + 4*evens.sum())*h/3.0

def braket(n):
    """
    Evaluate <n|f>
    """
    return simpson_array(basis(x,n)*fa_vec(x),h)

M=20
coefs = [0]
coefs_th = [0]
ys = [[]]
sup = zeros(N)
for n in range(1,M):
    coefs.append(braket(n))   # do numerical integral

    if n%2==0:
        coefs_th.append(0.0)
    else:
        coefs_th.append(4*A*sqrt(2*L)*(-1)**((n-1)/2.0)/(pi**2*n**2))  # compare theory

    ys.append(coefs[n]*basis(x,n))
    sup += ys[n]
    plot(x,sup)

print("%10s\t%10s\t%10s" % ('n', 'coef','coef(theory)'))
print("%10s\t%10s\t%10s" % ('---','-----','------------'))

for n in range(1,M):
    print("%10d\t%10.5f\t%10.5f" % (n, coefs[n],coefs_th[n]))

```

Project 11
============

Pick your own function and compute it's fourier coefficients analytically. Then, check your answer both graphically and numerically using simpson's rule.

```python

```


# Pigou's Example

Consider the following network, also called [Pigou's example](https://www.networkpages.nl/equilibrium-congestion-models-pigous-example/#:~:text=That%20is%2C%20when%20we%20have,100th%20of%20the%20traffic.), where the two vertices ``s`` and ``t`` are connected with two parallel edges.

```{figure} ./img/pigou.png
  :name: pigou
  :width: 70%

  Pigou's example.
```

The upper edge has a constant link travel time $l_1(x_1) = 1$ invariant of the upper link flow $x_1$, whereas the travel time on the lower edge varies by edge flow: $l_2(x_2) = x_2$.
In total, we send 1 unit from s to t, i.e., $x_1$ is the fraction of driver that use the upper edge and $x_2$ that use the lower edge, and $x_1 + x_2 = 1$.

## 1. System optimum, user equilibrium and price of anarchy

### 1.1 System optimum (SO)

A centralized planner -- e.g., the government -- may strive to minimize the average travel time for all participants: $\underset{\mathbf{x}}{\mathrm{argmin} \text{ }} \sum_{e \in E} x_{e} \cdot l_{e}(x_e)$.

The so called system optimum (SO) is easily computed by:

$$
    \begin{equation*}
        \underset{x_1, x_2}{\mathrm{argmin} \text{ }} x_1 \cdot l_1(x_1) + x_2 \cdot l_2(x_2).
    \end{equation*}
$$
By substituting $x_2 = 1 - x_1$ this equals:

$$
    \begin{equation*}
        \underset{x_1}{\mathrm{argmin \text{ }}} x_1 \cdot 1 + (1-x_1) \cdot (1-x_1) = \\
        \underset{x_1}{\mathrm{argmin \text{ }}} x_1 + (1-x_1)^2,
    \end{equation*}
$$
where the system optimal flow is: $x_1 = x_2 = 1/2$. The total travel time in the system optimum is thus $C_\text{SO} = 1/2 \cdot 1 + 1/2 \cdot 1/2 = 1/2 + 1/4 = 3/4$.

### 1.2 User equilibrium (UE)

In the user equilibrium -- with the assumption that all participants have perfect information -- all routes from s to t will take the same amount of time, i.e.,:

$$
    \begin{equation}
        l_1(x_1) = l_2(x_2) \Leftrightarrow 1 = x_2.
    \end{equation}
$$

All users are using the lower arc to go from $s$ to $t$: $x_1=0, x_2=1$. This leads to a total system travel time of $C_\text{UE} = 0 \cdot 1 + 1 \cdot 1 = 0 + 1 = 1$.

### 1.3 Price of Anarchy (PoA)

The [Price of Annarchy](https://en.wikipedia.org/wiki/Price_of_anarchy) (PoA) measures the inefficency of the network utilization due to selfish behaviour of the participants.
It is calculated as the ratio of the total (or equivalently average) travel time for all particpants in the user equilbrium to that of the system optimum:

$$
    \begin{equation*}
        \text{PoA} = \frac{C(\text{UE})}{C(\text{SO})} = \frac{1}{3/4} = 4/3.
    \end{equation*}
$$

## 2. Calculate UE and SO with paminco

Our implementation cannot deal with parallel edges and we thus have to transform the network slightly:

```{figure} ./img/pigou_artificial_vert.png
  :name: pigou_artificial_vert
  :width: 90%

  Adding artificial vertices $V_1$ and $V_2$ to bypass parallel edges.
```

As the network has polynomial edge costs, we can simply pass in the polynomial coefficients to the network constructor:

```python
import paminco
import numpy as np

# We add artifical vertices v and w to bypass parallel edges problem
edge_data = np.array([["s", "v"],
                      ["v", "t"],
                      ["s", "w"],
                      ["w", "t"],
                      ])
# l_e = 1 = 1 + 0*x -> coeff = (1, 0)
# l_e = x = 0 + 1*x -> coeff = (0, 1)
cost = np.array([[1, 0],
                 [1, 0],
                 [0, 1],
                 [0, 1],
                 ])
demand_data = ("s", "t", 1)
lbl2id = {"s": 0, "v": 1, "w": 2, "t": 3}
pigou = paminco.Network(edge_data=edge_data,
                       cost_data=cost,
                       demand_data=demand_data,
                       kw_edge={"map_labels_to_indices": lbl2id}
                       )
```

We find minimum cost flows that coincide with user equilibrium and system optimum if we transform the edge cost by

$$
    \begin{align}
        \text{User equilibrium:} \quad F_e &= \int_0^{x_e} l_e(s) ds \\
        \text{System optimum:} \quad F_e &= x_e \cdot l_e.
    \end{align}
$$

```python
import copy

# Calculate user equilibrium -> F_e = integral_0^(x_e) l_e(s)ds
pigou_ue = copy.deepcopy(pigou)
pigou_ue.cost.integrate(inplace=True)
fw_ue = paminco.NetworkFW(pigou_ue)
fw_ue.run()

# Calculate system optimum -> F_e = x_e * l_e
pigou_so = copy.deepcopy(pigou)
pigou_so.cost.times_x(inplace=True)
fw_so = paminco.NetworkFW(pigou_so)
fw_so.run()
```

### 2.1 Equilibrium flows

We find flows on the (artificial) edges as:

```python
import pandas as pd

flows_ue = fw_ue.flows[["s", "t", "x"]]
flows_so = fw_so.flows[["s", "t", "x"]]
flows = pd.merge(flows_ue, flows_so, on=["s", "t"])
flows[["s", "t"]] = pigou.shared.get_node_label(flows[["s", "t"]].values)
flows.columns = ["from", "to", "flow user eq", "flow sys opt"]
print(flows)
```

      from to  flow user eq  flow sys opt
    0    s  v           0.0           0.5
    1    v  t           0.0           0.5
    2    s  w           1.0           0.5
    3    w  t           1.0           0.5

```{figure} ./img/pigou_equilibria.png
  :name: pigou_equilibria
  :width: 90%

  Flow in user equilibrium (left) and system optimum (right).
```

### 2.2 The Price of Anarchy

The objective function to calculate the system optimum is the total system travel time (TSTT):

$$
    \begin{equation*}
        \text{TSTT}(\mathbf{x}) = \sum_{e \in E} x_{e} \cdot l_{e}(x_e)
    \end{equation*}
$$

```python
def TTST(x):
    return pigou_so.cost.F(x).sum()
```

We find that the total system travel time for Barcelona is increased ``33.33`` percent if users behave selfishly. Note that we have to scale the cost by 1/2 due to the artificial edges:

```python
cost_ue = TTST(fw_ue.flow) / 2
cost_so = TTST(fw_so.flow) / 2
poa = cost_ue / cost_so
print("Cost user eqiulibrium: ", cost_ue)
print("Cost system optimum:   ", cost_so)
print("Price of anarchy:      ", poa.round(2))
```

    Cost user eqiulibrium:  1.0
    Cost system optimum:    0.75
    Price of anarchy:       1.33


# Lecture 9

## Differential Equations I:

### Separation of Variables

```python
import numpy as np
import sympy as sp
import scipy.integrate
sp.init_printing()
##################################################
##### Matplotlib boilerplate for consistency #####
##################################################
from ipywidgets import interact
from ipywidgets import FloatSlider
from matplotlib import pyplot as plt

%matplotlib inline

from IPython.display import set_matplotlib_formats
set_matplotlib_formats('svg')

global_fig_width = 10
global_fig_height = global_fig_width / 1.61803399
font_size = 12

plt.rcParams['axes.axisbelow'] = True
plt.rcParams['axes.edgecolor'] = '0.8'
plt.rcParams['axes.grid'] = True
plt.rcParams['axes.labelpad'] = 8
plt.rcParams['axes.linewidth'] = 2
plt.rcParams['axes.titlepad'] = 16.0
plt.rcParams['axes.titlesize'] = font_size * 1.4
plt.rcParams['figure.figsize'] = (global_fig_width, global_fig_height)
plt.rcParams['font.sans-serif'] = ['Computer Modern Sans Serif', 'DejaVu Sans', 'sans-serif']
plt.rcParams['font.size'] = font_size
plt.rcParams['grid.color'] = '0.8'
plt.rcParams['grid.linestyle'] = 'dashed'
plt.rcParams['grid.linewidth'] = 2
plt.rcParams['lines.dash_capstyle'] = 'round'
plt.rcParams['lines.dashed_pattern'] = [1, 4]
plt.rcParams['xtick.labelsize'] = font_size
plt.rcParams['xtick.major.pad'] = 4
plt.rcParams['xtick.major.size'] = 0
plt.rcParams['ytick.labelsize'] = font_size
plt.rcParams['ytick.major.pad'] = 4
plt.rcParams['ytick.major.size'] = 0
##################################################
```

## What is a Differential Equation?

If $y$ is a variable that depends on the variable $x$, then a **differential equation in $x$ and $y$** is one that relates $\displaystyle \frac{{\rm d}y}{{\rm d}x}$ to $x$, to $y$, or to both.

We write
$$\frac{{\rm d}y}{{\rm d}x}=f(x,y).$$

## How do we Find the Solutions to a Differential Equation?

The simplest differential equations are very similar to integration.

$$\frac{{\rm d}y}{{\rm d}x} = x^3 + 5x^2 + 1$$

Since all the terms in $y$ (just the derivative in this case) on the left, and all the terms involving $x$ on the right.

$${\rm d}y = (x^3 + 5x^2 + 1) ~{\rm d}x$$

Then we integrate both sides
$$\int {\rm d}y = \int (x^3 + 5x^2 + 1) ~{\rm d}x$$

The general solution to this equation is
$$y + A = \int x^3 + 5x^2 + 1\,{\rm d}x = {1\over 4}x^4 + {5\over 3}x^3 + x + B$$
where $A$ and $B$ are a arbitrary constants.

If we are given the **initial conditions**, say, $y=0$ when $x=0$ (often written as $y(0)=0$),
we can determine the value for the constants; in this example $A=B$.

This approach can be generalised into what is known as the **Separation of Variables Method**.
---------------------------------------------------------------------------------------------

## Example 1:
$${{\rm d}y\over {\rm d}x} = 2x + c$$

The equation is already separated, with all terms in $y$ (just the derivative in
this case) on the left, and terms involving $x$ on the right.

We then integrate both sides with respect to $x$:

$$ \int \frac{{\rm d}y}{{\rm d}x}\,{\rm d}x = \int (2x + c)\, {\rm d}x $$

$$y + k_1 = x^2 + cx + k_2$$

$$y = x^2 + cx + A\qquad\mbox{with}\qquad A=k_2-k_1$$

### Initial conditions

$$y = x^2 + cx + A$$

If we are told that $y(1) = c$, can we calculate $A$?

We have that

$$y = x^2 + cx + A\;\qquad\mbox{with}\qquad y(1) = c$$

so:

$$c = 1^2 + c\cdot 1 + A$$

$$A = -1$$

Is this right?

```python
c = 1.0
A = -1.0
x0 = 1.0
x1 = 10.0
x = np.linspace(x0,x1,100)
y_exact = x**2 + c*x + A

def dydx(y, x):
    return 2*x + c
y_numerical = scipy.integrate.odeint(dydx, c, x)

```

```python
plt.plot(x,y_exact,lw=2, c='r', label='exact')
plt.plot(x,y_numerical,lw=2, c='k', ls='--',label='numerical')
plt.xlabel('x')
plt.ylabel('y')
plt.legend()

```

## Example 2:

$${{\rm d}y\over {\rm d}x} = 5 x^{{3\over 2}} y $$

Separate the variables so that all the terms in $x$ are on one side of the equation and all the terms in $y$ are on the other side:

$${1\over y}\frac{{\rm d}y}{{\rm d}x} = 5 x^{{3\over 2}} $$

$${1\over y}\frac{{\rm d}y}{{\rm d}x} = 5 x^{{3\over 2}} $$

Integrating both sides with respect to $x$:

$$\int {1\over y}\frac{{\rm d}y}{{\rm d}x}\, {\rm d}x = \int 5x^{{3\over 2}}\,{\rm d}x \quad
  \Rightarrow\quad \int {1\over y}\,{\rm d}y = \int 5x^{{3\over 2}}\, {\rm d}x$$

Now integrate on each side:
$${\ln y + A} = { 5x^{{5\over 2}}\over 5/2} + B = 2x^{{5\over 2}} + B$$

So:

$$\ln y = 2x^{{5\over 2}} + C{\rm\quad where\quad}C=B-A$$

## Example 3:

$$e^x \frac{{\rm d}y}{{\rm d}x} = {x \over y}$$

Rearrange to get:

$$\int y\,{\rm d}y = \int x e^{-x}\,{\rm d}x$$

We have

$$\int y\,{\rm d}y = \int x e^{-x}\,{\rm d}x$$

Now integrate by parts, $\displaystyle\biggl(\int uv'\,{\rm d}x = [uv]-\int u'v\,{\rm d}x\biggr)$,

with $u=x$ and $v'=e^{-x}$:

\begin{align*}
{y^2\over 2} + c_1 &= -x e ^{-x} + \int e ^{-x}\,{\rm d}x \\\\
      &= -x e ^{-x} - e ^{-x} + c_2 \\\\
  y^2 &= -2 e ^{-x}(1+x) + c_3 \\\\
  y &= \pm\sqrt{-2 e^{-x} (1+x) + c_3}
\end{align*}

Let's check this answer: $y = \pm\sqrt{-2 e^{-x} (1+x) + c_3}$.

Substitute in initial conditions $y(0) = 0$ gives $c_3 = 2$

```python
c3 = 2.0
x0 = 0.0
x1 = 1.0
x = np.linspace(x0,x1,100)

y_exact = np.sqrt(-2*np.exp(-x)*(1 + x) + c3)

def dydx(y, x):
    return np.exp(-x) * x / y

y_numerical = scipy.integrate.odeint(dydx, 1e-8, x) # why 1e-8 instead of zero?

```

```python
plt.plot(x,y_exact,lw=2, c='r', label='exact+')
plt.plot(x,-y_exact,lw=2, c='r', label='exact-')
plt.plot(x,y_numerical,lw=2, c='k', ls='--',label='numerical+')
plt.plot(x,-y_numerical,lw=2, c='k', ls='--',label='numerical-')
plt.xlabel('x')
plt.ylabel('y')
plt.legend()

```

## Example 1: Biochemistry:

The Michaelis-Menten equation relates the rate of an enzyme reaction to its
substrate concentration, and has the form (if there is a large quantity of
enzyme):

$${{\rm d}s\over {\rm d}t}=-{V.s\over K+s}$$

where $s=[S]$ is the substrate concentration, and $V$ and $K$ are constants.
By integration, we will derive expressions relating substrate concentration to time when

1. when $s\gg K$
1. when $s\ll K$

and sketch the graphs of $\;s\;$ against $\;t\;$ in each case.

## Case 1

When $s\gg K$, $K$ in the differential equation's denominator can be neglected:

$$\frac{{\rm d}s}{{\rm d}t} \approx -{V.s\over s} = -V$$

The rate of change in concentration is a constant, so this is a **zeroth order process**.

We can integrate directly to obtain
$$ s = \int -V\,{\rm d}t = -Vt + c$$
where $c$ is a constant of integration.

If $s(0)=s_0$, then $c=s_0$.

The graph thus has slope $-V$ and crosses the vertical axis at $s=s_0$.

## Case 2

When $s\ll K$, $\;s\;$ in the differential equation's denominator can be neglected:
$$\frac{{\rm d}s}{{\rm d}t} = -{V.s\over K+s}\quad{\rm~becomes:\quad}\frac{{\rm d}s}{{\rm d}t} = -{V.s\over K}$$

The rate of change in concentration depends on the concentration, so this is a **first order process**.

$$\int {1\over s}\,{\rm d}s = -\int {V\over K}\,{\rm d}t$$

$$\Rightarrow\quad\ln s = -{V\over K}t + D\qquad{\rm D~is~a~constant}$$

$$\Rightarrow\quad s = Be^{-V t\over K}\qquad{\rm B~is~a~constant}$$

On a graph $\;s\;$ crosses the vertical axis at $\;s=B\;$ and decreases with time exponentially.

Lets look at this numerically using the original equation:

$${{\rm d}s\over {\rm d}t}=-{V.s\over K+s}$$

```python
def solve(K):
    V = 1.0
    t0 = 0.0
    t1 = 10.0
    s0 = 1.0
    t = np.linspace(t0,t1,1000)
    def dsdt(s, t):
        return -V * s / (K + s)
    s = scipy.integrate.odeint(dsdt, s0, t)
    plt.plot(t,s)
    plt.xlabel('t')
    plt.ylabel('s')

```

```python
interact(solve, K = FloatSlider(value=0.01,min=0.01,max=2.0,step=0.01, continuous_update=False))
```

## Example 2: Bacterial growth:

Suppose the growth rate of a bacterial colony is proportional to its size,
and that we observe that the colony triples in size after 10 hours.
How long will it take to reach 11 times its original size?

Let $\;N\;$ represent the number of bacteria, and suppose there are $\;N_0\;$ at time $\;t=0$.

We are given that when $\;t=10\;$ hours, $\;N=3N_0$, and need to find the time at which $\;N=11N_0\;$.
The equation of growth is:

$$\frac{{\rm d}N}{{\rm d}t} = kN$$

We can solve this to obtain $\;\ln N = kt + c.\;$ Using the $\;t=0\;$ condition, we have that $\;\ln N_0 = c,\;$ so

$$\ln{N\over N_0} = kt$$

We can use the data from $\;t=10\;$ to find $\;k:\;$

$$\ln{3N_0\over N_0} = k 10 \qquad\Rightarrow\qquad k={\ln 3\over 10}=0.11{\rm~hr^{-1}}$$

To find $t$ when $N=11N_0$:

$$\ln{11N_0\over N_0} = 0.11 t \qquad\Rightarrow\qquad t={\ln 11\over 0.11}=21.8{\rm~hours.}$$

## Example 3: Radioactive decay:

The rate at which a sample decays is proportional to the amount left, i.e.

$$\frac{{\rm d}N}{{\rm d}t} = -\lambda N$$

where $\;N\;$ is the mass of radioactive atoms at time $\;t\;$ and $\;\lambda\;$ is called the *decay constant*.

The element radium (atomic mass=226) has a decay constant of $\;13.6 \times 10^{-12}\;$s$^{-1}$.

Solving the differential equation via separation of variables, we obtain the general solution

$$N = Be^{-\lambda t}  ~~~~~~~~(1)$$

Consider an arbitrary initial condition: at $\;t=t_0\;$, $\;N=N_0.\;$ Then:

$$N_0 = Be^{-\lambda t_0} \qquad\Rightarrow\qquad B = N_0 e^{\lambda t_0}$$

Substituting in (1) gives the solution

$$ N = N_0 e^{-\lambda(t-t_0)} ~~~~~~~~~(2)$$

The **half-life**, $\;t_{1\over 2},\;$ is the time taken for $\;N\;$ to reduce by half.

Putting $\;N=N_0/2\;$ and $\;t=t_0+t_{1\over 2}\;$ in (2) we get

$$\ln {N_0\over2N_0} = -\lambda t_{1\over 2}$$

$$t_{1\over 2} = {\ln 2\over\lambda} \approx {0.693\over\lambda}$$

Note that this time is **independent** of the initial value $N_0$,

The half-life for radium is thus $\;t_{1\over 2}={\ln2\over13.6\times10^{-12}}=5.10\times10^{10}\,s,\;$ or about 1600 years.

## Example 4: More biochemistry:

The power to which the concentration of a species is raised in a rate law
is the **order** of the process/reaction with respect to that species.
A reaction with the rate law

$$\frac{{\rm d}X}{{\rm d}t} = k_2~[A]~[B]$$

is first order in $A$ and first order in $B$.

The **overall order** of a process/reaction is the sum of the orders of all the
components. This rate law is thus second-order overall.

Note that this is **different** from the order of an ODE, which is given by the
highest derivative.
Both zeroth and first order *processes* are modelled below by *first order
differential equations*.

## (A) Zeroth order processes:

- rate of change is **independent** of concentration, i.e. the rate of change is proportional to concentration raised to power zero

$$\frac{{\rm d}A}{{\rm d}t} = k \quad \text{(growth)}$$

$$\frac{{\rm d}A}{{\rm d}t} = -k\quad \text{(decay)}$$

General solutions:
$$A = A_0 + k(t-t_0)$$

$$A = A_0 - k(t-t_0)$$

## (B) First order processes:

- rate of change depends on the concentration of one species, i.e. the rate of change is proportional to concentration raised to first power.

- Half-life is a constant, i.e. is INDEPENDENT of the amount there at the
  beginning.

$$\frac{{\rm d}A}{{\rm d}t} = kA\quad\text{growth}$$
$$\frac{{\rm d}A}{{\rm d}t} = -kA\quad\text{decay}$$

General solutions:

$$A = A_0 e^{k(t-t_0)}$$
$$A = A_0 e^{-k(t-t_0)}$$

```python
def first_order_process(k):
    t = np.linspace(0,1,1000)

    growth = np.exp(k*t)
    decay = growth[-1]*np.exp(-k*t)

    plt.plot(t,growth,label='first order growth')
    plt.plot(t,decay,label='first order decay')
    plt.xlabel('x')
    plt.ylabel('A')
    plt.legend()

```

```python
interact(first_order_process, k = FloatSlider(value=0.01,min=2,max=10,step=0.01, continuous_update=False))
```


# Tutorial on Automatic Differentiation

(by Lukas Heinrich. See: [pyhep2020-autodiff-tutorial](https://github.com/lukasheinrich/pyhep2020-autodiff-tutorial) )

### Introduction

Welcome to this tutorial on automatic differentiation. Automatic Differentiation is a method to compute exact derivatives of functions implements as **programs**. It's a widely applicable method and famously is used in
many Machine learning optimization problems. E.g. neural networks, which are parametrized by weights $\text{NN}(\text{weights})$ are trained by (stocastic) **gradient** descent to find the minimum of the loss function $L$ where

$$\text{weights}_\text{opt} = \text{argmin}_\text{weights} L(\text{weights}) \hspace{1cm} \nabla L(\text{weights}) = 0$$

This means that efficient algorithms to compute derivatives are crucial.

Aside from ML, many other use-cases require gradients: standard statistical analysis in HEP (fitting, hypothesis testing, ...) requires gradients. Uncertainty propagation (e.g. track parameters) uses gradients, etc..

```python
import pyhf
pyhf.set_backend('jax')
import jax
import jaxlib
import matplotlib.pyplot as plt
import numpy as np
import jax.numpy as jnp
```

### Other approaches to differentiation

Before diving into automatic differentiation, let's review how my might otherwise compute derivatives

#### Finite Differences

A common appraoch to approximate gradients of a black-box function is to evaluate it
at close-by points $x$ and $x+Δx$ and

$\frac{\partial f}{\partial x} \approx \frac{f(x) - f(x+\Delta x}{\Delta x}$ if $\Delta x$ is sufficiently small

```python
def black_box_func(x):
    return x**3+30

def true_gradient_func(x):
    return 3*x**2

def plot_gradients(nsteps,title):
    xi = np.linspace(-5,5,nsteps)
    yi = black_box_func(xi)

    approx_gradient = np.gradient(yi,xi)
    true_gradient   = true_gradient_func(xi)

    plt.plot(xi,yi, label = 'black-box func')
    plt.scatter(xi,yi)

    plt.plot(xi,approx_gradient, label = 'finite diff grad')
    plt.scatter(xi,approx_gradient)

    plt.plot(xi,true_gradient, label = 'true grad')
    plt.scatter(xi,true_gradient)

    plt.legend()
    plt.title(title)
    plt.show()

plot_gradients(11, title = 'it is pretty bad if Δx is too large')
plot_gradients(41, title = 'it gets better at the cost of many evaluations')
```

while only approximate, finite differences is *simple*. I don't need to know
anything about the function beyond having the ability
to *evaluate* it

This way I can compute gradients of functions encoded as a computer
program, and it works in any programming language

For multivariate (possibly vector-valued) functions $\vec{f}(\vec{x}) = f_i(x_1,x_2,\dots,x_n)$ one needs to compute a finite difference
gradient for each partial derivative $\frac{\partial f}{\partial x}$ in order to get the
full jacobian / total derivative $df_i = J_{ik} dx_k\; J_{ik} = \frac{\partial f_i}{\partial x_k}$

In high dimensions, the number of required evaluations explodes!

**Finite Differences**:

* Pro: easy to to, works in any language, no "framework needed"
* Con: inaccurate unless one does a lot of evaluations
* Con does not scale to large dimensions

### Symbolic Differentiation in a CAS

Computer Algebra Systems (CAS), such as Mathematica (or sympy)
can manipulate functional *expressions* and know about differentiation rules (and many other things)

If the function / the prograrm which we want to derive is available as such an expression the
symbolic differentiation can produce **exact gradients**

```python
import sympy

def function(x):
    return x**3

def true_deriv(x):
    return 3*x**2

symbolic_x = sympy.symbols('x')
symbolic_func = function(symbolic_x)
symbolic_func
```

$\displaystyle x^{3}$

Using `lambdify` we can turn it into a normal python function we can evaluate

```python
xi = np.linspace(-5,5,11)
yi = sympy.lambdify(symbolic_x,symbolic_func)(xi)
plt.plot(xi,yi)
plt.scatter(xi,yi)
```

`symbolic_func` is now an experssion which we can differentiate *symbolically*

```python
symbolic_deriv = symbolic_func.diff(symbolic_x)
symbolic_deriv
```

$\displaystyle 3 x^{2}$

```python
def plot_symbolic(nsteps,title):
    xi = np.linspace(-5,5,nsteps)
    yi = sympy.lambdify(symbolic_x,symbolic_func)(xi)
    plt.scatter(xi,yi)
    plt.plot(xi,yi, label = 'function')

    yi = true_deriv(xi)
    plt.plot(xi,yi)
    plt.scatter(xi,yi, label = 'true deriv')

    yi = sympy.lambdify(symbolic_x,symbolic_deriv)(xi)
    plt.plot(xi,yi)
    plt.scatter(xi,yi, label = 'symbolic deriv')

    plt.legend()
    plt.title(title)
    plt.show()

plot_symbolic(11,title = 'the symbolid derivative is always exact')
plot_symbolic(4, title = 'it does not matter where/how often you evaluate it')
```

#### Chain Rule in CAS

We can even handle function compositions

```python
def f1(x):
    #standard operations are overloaded
    return x**2

def f2(x):
    #note here we use a special cos function from sympy
    #instead of e.g. np.cos or math.cos
    return sympy.cos(x)

composition = f2(f1(symbolic_x))

composition
```

$\displaystyle \cos{\left(x^{2} \right)}$

```python
composition.diff(symbolic_x)
```

$\displaystyle - 2 x \sin{\left(x^{2} \right)}$

Since `sympy` knows about the chain rule it can differentiate accordingly

#### Problems with Symbolic Differentiation

This looks great! We get exact derivatives. However,
there are drawbacks

1. Need to implement it in CAS

Most functions we are interested in are not implemented
e.g. Mathematica. Rather we have loads of C, C++, Python
code that we are interested in.

But ok, `sympy` alleviates this to some degree. The functions
`f1` and `f2` are fairly generic since they use operator
overloading. So a symbolic program and a "normal" program
could only differ by a few import statements

```python
from sympy import cos

def f1(x):
    return x**2

def f2(x):
    return cos(x)
```

versus:

```python
from math import cos

def f1(x):
    return x**2

def f2(x):
    return cos(x)
```

Note the code is almost exactly the same

But not all our functions are so simple!

**Expression swell**

Let's look at a quadratic map which is applied a few times

```python
def quadmap(x):
    return x**2 + 3*x + 4

def func(x):
    for i in range(6):
        x = quadmap(x)
    return x

quad_6_times = func(symbolic_x)
quad_6_times
```

$\displaystyle 243 x^{2} + 729 x + 81 \left(x^{2} + 3 x + 4\right)^{2} + 27 \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 9 \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right)^{2} + 3 \left(27 x^{2} + 81 x + 9 \left(x^{2} + 3 x + 4\right)^{2} + 3 \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right)^{2} + 160\right)^{2} + \left(81 x^{2} + 243 x + 27 \left(x^{2} + 3 x + 4\right)^{2} + 9 \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 3 \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right)^{2} + \left(27 x^{2} + 81 x + 9 \left(x^{2} + 3 x + 4\right)^{2} + 3 \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right)^{2} + 160\right)^{2} + 484\right)^{2} + 1456$

This looks pretty intimidating. What happened?
Symbolic programs run through the prgram and
accumulate the full program into a single expression

If we would just blindly differentiate this it would look like this

```python
quad_6_times.diff(symbolic_x)
```

$\displaystyle 486 x + 81 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 27 \left(12 x + 2 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 18\right) \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right) + 9 \left(36 x + 6 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 2 \left(12 x + 2 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 18\right) \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right) + 54\right) \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right) + 3 \left(108 x + 18 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 6 \left(12 x + 2 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 18\right) \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right) + 2 \left(36 x + 6 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 2 \left(12 x + 2 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 18\right) \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right) + 54\right) \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right) + 162\right) \left(27 x^{2} + 81 x + 9 \left(x^{2} + 3 x + 4\right)^{2} + 3 \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right)^{2} + 160\right) + \left(324 x + 54 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 18 \left(12 x + 2 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 18\right) \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right) + 6 \left(36 x + 6 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 2 \left(12 x + 2 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 18\right) \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right) + 54\right) \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right) + 2 \left(108 x + 18 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 6 \left(12 x + 2 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 18\right) \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right) + 2 \left(36 x + 6 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 2 \left(12 x + 2 \left(4 x + 6\right) \left(x^{2} + 3 x + 4\right) + 18\right) \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right) + 54\right) \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right) + 162\right) \left(27 x^{2} + 81 x + 9 \left(x^{2} + 3 x + 4\right)^{2} + 3 \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right)^{2} + 160\right) + 486\right) \left(81 x^{2} + 243 x + 27 \left(x^{2} + 3 x + 4\right)^{2} + 9 \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 3 \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right)^{2} + \left(27 x^{2} + 81 x + 9 \left(x^{2} + 3 x + 4\right)^{2} + 3 \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + \left(9 x^{2} + 27 x + 3 \left(x^{2} + 3 x + 4\right)^{2} + \left(3 x^{2} + 9 x + \left(x^{2} + 3 x + 4\right)^{2} + 16\right)^{2} + 52\right)^{2} + 160\right)^{2} + 484\right) + 729$

This looks even worse!

Also note that that if we just blindly substitute x for some value
e.g. x=2, we would be computing a lot of the same terms
manyt times. E.g. in the above expression $x^2+3x+4$ appears in a
lot of places due to the "structure' of the original progrm

If you knew the structure of the program you likely could precompute
some of these repeating terms. However once it got all expanded all
this knowledge about the structure is gone!

Modern CAS can recover some of this by finding "common subexpressions" (CSE)

```python
sympy.cse(quad_6_times)
```

    ([(x0, x**2),
      (x1, (3*x + x0 + 4)**2),
      (x2, (9*x + 3*x0 + x1 + 16)**2),
      (x3, (27*x + 9*x0 + 3*x1 + x2 + 52)**2),
      (x4, (81*x + 27*x0 + 9*x1 + 3*x2 + x3 + 160)**2)],
     [729*x + 243*x0 + 81*x1 + 27*x2 + 9*x3 + 3*x4 + (243*x + 81*x0 + 27*x1 + 9*x2 + 3*x3 + x4 + 484)**2 + 1456])

But it's not as automatic and may note find all relevant subexpressions. In any case it's trying hard to recover some
of the structure that is already implicitly present in the prograam we want to differentiate

**Control Flow**

In addition to looping constucts like above, a lot of the functions we are interested in have
control flow structures like if/else statements, while loops, etc..

If we try to create a symbolic expression with conditionals we fail badly

```python
def func(x):
    if x > 2:
        return x**2
    else:
        return x**3

try:
    symbolic_result = func(symbolic_x)
except TypeError as err:
    print(err)
```

    cannot determine truth value of Relational

That's too bad because this is a perfectly respectable function *almost everywhere*

```python
xi = np.linspace(-2,5,1001)
yi = np.asarray([func(xx) for xx in xi])

plt.plot(xi,yi)
plt.scatter(xi,yi)
plt.title("pretty smooth except at x=2")
plt.show()
```

If we could afford finite diffences it would compute gradients *just fine*.

```python
g = np.gradient(yi,xi)
plt.plot(xi,g)
plt.scatter(xi,g)
plt.ylim(-2,10)
plt.title('''\
parabolesque gradient in x^3 region,
linear in x^2 region as expected''');
```

In short: symbolic differentiation is not our saving grace.

* Pro: Gradients are exact, if you can compute them
* Con: Need to implement in CAS. Full-featured Cas not easily available in all languages
* Con: lead to expression swell by losing any structure of the program (needs to be recovered separately0
* Con: Cannot handle common control-flow structures like loops and conditionals easily

## What we need

To recap:

Finite differences is
* easy to implement in any language
* handles arbitrary (halting) programs but
* is inaccurate unless we're ready to pay a large computational overhead

Symbolic differentiation is:
* exact to machine precision
* can lead to exccessive / inefficient computation if not careful
* cannot handle complex programs with control flow structures

 So what we need is a third approach!

One, that is
* exact
* efficient
* can handle arbitrayr programs
* that is easy to implement in many languages

This third approach is 'Automatic' differentiation.

## Short Interlude on Linear Transformations

Before we start, let's first look at *linear transformations** from ℝᵐ → ℝⁿ:

$$
y(x) = Ax
$$

With a given basis, this is representable as a (rectangular0 matrix:

$$
y_i(x) = A_{ij}x_j
$$

For a given linear problem, there are few ways we can run this computation

1. **full matrix computation**

   i.e. we store the full (dense) $nm$ elements of the rectangular matrix and
   compute an explicit matrix multiplication.

   The computation can be fully generic for any matrix

```python
    def result(matrix, vector):
        return np.matmul(matrix,vector)
```

2. **sparse matrix computation**

   If many $A_{ij}=0$, it might be wasteful to expend memory on them. We can just
   create a sparse matrix, by

   * storing only the non-zerro elements
   * storing a look-up table, where those elements are in the matrix

   The computation can be kept general

```python
    def result(sparse_matrix, vector):
        return sparse_matmul(sparse_matrix,vector)
```

3. **matrix-free computation**

    In many cases a linear program is not explicitly given by a Matrix, but it's
    given as *code* / a "black-box" function. As long as the computation in the body of
    keeps to (hard-coded) linear transformation the program is linear. The matrix elements
    are no longer explicitly enumerated and stored in a data structure
    but implicitly defined in the source code.

    This is not anymore a generic computation, but each linear transformation is its own
    program. At the same time this is also the most memory efficient  representation. No
    lookup table is needed since all constants are hard-coded.

```python
    def linear_program(vector):
        z1,z2 = 0,0
        z1 += A_11*x1
        z2 += A_12*x2
        z2 += A_22*x2
        return [z1,z2]
```

### Recovering Matrix Elements from matrix-free computations

#### Matrix-vector products

In the matrix-free setting, the program does not give access to the matrix elements,
but only computes "matrix-vector products" (MVP)

We can use basis vectors to recover the matrix **one column at a time**

```python
def matrix_vector_product(x):
    x1,x2,x3 = x
    z1,z2 = 0,0
    z1 += 2*x1  #MVP statement 1
    z2 += 1*x2  #MVP statement 2
    z2 += 3*x3  #MVP statement 3
    return np.asarray([z1,z2])

M = np.concatenate([
    matrix_vector_product(np.asarray([1,0,0])).reshape(-1,1),
    matrix_vector_product(np.asarray([0,1,0])).reshape(-1,1),
    matrix_vector_product(np.asarray([0,0,1])).reshape(-1,1),
],axis=1)
print(f'M derived from matrix-vector products:\n{M}')
```

    M derived from matrix-vector products:
    [[2 0 0]
     [0 1 3]]

#### Vector Matrix product (VMP)

The same matrix induces a "dual" linear map: ℝⁿ → ℝᵐ
$$ x_k = y_i A_{ik}$$

i.e. instead of a Matrix-vector product it's now a *vector-Matrix* product (VMP)

If one has access to a "vector-Matrix" program corresponding to a matrix $A$ one
can again -- as in the MVP-case -- recover the matrix elements, by feeding in basis vectors.

This time the matrix is built **one row at a time**

```python
def vector_matrix_product(z):
    x1,x2,x3 = 0,0,0
    z1,z2 = z

    x3 += z2*3 #VMP version of statement 3
    x2 += z2*1 #VMP version of statement 2
    x1 += z1*2 #VMP version of statement 1

    return np.asarray([x1,x2,x3])

M = np.concatenate([
    vector_matrix_product(np.asarray([1,0])).reshape(1,-1),
    vector_matrix_product(np.asarray([0,1])).reshape(1,-1),
],axis=0)
print(f'M derived from vector-matix products:\n{M}')
```

    M derived from vector-matix products:
    [[2 0 0]
     [0 1 3]]

#### Short Recap:

For a given linear transformation, characterized by a matrix $A_{ij}$ we have a forward (matrix-vector) and backward (vector-matrix) map $$y_i = A_{ij}x_k$$ $$x_j = y_i A_{ij}$$

and we can use either to recover the full matrix $A_{ij}$

## Wide versus Tall Transformation

If you look at the code above, you'll notice that the number of calls necessary to the MVP or VMP program
is related to the dimensions of matrix itself.

For a $n\times m$ matrix (for a map: ℝᵐ → ℝⁿ), you need as $m$ calls to the "Matrix-vector" program to
built the full matrix one-column-at-a-time. Likewise you need $n$ calls to the "vector-Matrix" program
to build the matrix one-row-at-a-time.

This becomes relevant for very asymmetric maps: e.g. scalar maps from very high-dimensional spaces
$\mathbb{R}^{10000} \to \mathbb{R}$ the "vector-Matrix" appraoch is *vastly* more efficient than the
"Matrix-vector one. There's only one row, so only one call too the VMP program is needed to construct the full matrix!

Similarly, functions mapping few variables into very high dimensional spaces $\mathbb{R} \to \mathbb{R}^{10000}$
it's the opposite: the "Matrix-vector" approach is much better suited than the "vector-Matrix" one (this time it's a single column!).

## Function Compositions

Of course copositions $(f\circ g)(x) = f(g(x))$ of linear maps are also linear, so the above applies.

Depending on whether the "Matrix-vector" or "vector-Matrix" appraoch is used, the data is propagated **forwards** or **backwards**.

|         Forward     |  Backward |
:-------------------------:|:-------------------------:
 |

### From Matrices to Graphs

The "vector-Matrix" or "Matrix-vector" picture can be generalized to arrbitrary directed acyclic graphs.

* In the "Matrix-vector" picture the node value is the edge-weighted sum of the "upstream nodes".
* In the "vector-Matrix" picture the node value is the edge-weighted sum of its "downstream nodes".

(one could in principle always recove a rectangular/matrix-like version of a DAG  by inserting trivial nodes)

|              |    |
:---------- : | : ------ :
 |

```python
def graph_like(x):
    x1,x2,x3 = x
    y1 = 2*x1+x2
    z1,z2 = y1+2*x3,x3-y1 #note that we reach "over" the "ys" and diectly touch x_n
    return np.asarray([z1,z2])

def matrix_like(x):
    x1,x2,x3 = x
    y1 = 2*x1+x2
    y2 = x3 #can just introduce a dummy variable to make it matrix-like
    z1,z2 = y1+2*x3,y2-y1
    return np.asarray([z1,z2])

```

```python
M = np.concatenate([
    matrix_like(np.asarray([1,0,0])).reshape(-1,1),
    matrix_like(np.asarray([0,1,0])).reshape(-1,1),
    matrix_like(np.asarray([0,0,1])).reshape(-1,1),
],axis=1)
print(f'M derived from matrix like computation:\n{M}')

M = np.concatenate([
    graph_like(np.asarray([1,0,0])).reshape(-1,1),
    graph_like(np.asarray([0,1,0])).reshape(-1,1),
    graph_like(np.asarray([0,0,1])).reshape(-1,1),
],axis=1)
print(f'M derived from graph-like products:\n{M}')
```

    M derived from matrix like computation:
    [[ 2  1  2]
     [-2 -1  1]]
    M derived from graph-like products:
    [[ 2  1  2]
     [-2 -1  1]]

## Derivatives

Why are we talking about linear transformations? After all lot of the code we write is non-linear! However, derivatives are always linear.

And derivatives (the jacobian) of a composition $f\circ g$ is the composition of linear derivatives (the jacobians
of each map) i.e. the full jacobian Matrix is the  result of multipying all Jacobians of the composition.
$$J = J_0 J_1 J_2 J_3 \dots J_n $$

(This is just the chain rule)
$$z = f(y) = f(g(x))\hspace{1cm} \frac{\partial f_i}{\partial x_j} = \frac{\partial f_i}{\partial z_j}\frac{\partial z_j}{\partial x_k}$$

I.e. finding derivatives, means characterizing the jacobian matrix. From the above discussion, we can use the "Jacobian-vector product" (JVP, builds Jacobians column-wise) or "vector-Jacobian product" (builds Jacobians row-wise) approach.

In the language of automatic differentiation

* Jacobian-vector products (JVP) = forward mode (forward propagation)

$$ Jv_n =  J_0 J_1 J_3 \dots J_n v_n = J_0 J_1 J_2 J_3 v_3 = J_0 J_1 J_2 v_2 = J_0 J_1 v_1 = J_0 v_0 = \text{col}$$

* vector-Jacobian products (VJP) = reverse mode (reverse propagation)

$$ v_0 J = v_0 J_0 J_1 J_3 \dots J_n = v_1 J_1 J_2 J_3 \dots J_n = v_2 J_2 J_3 \dots J_n = v_3 J_3 \dots J_n = \dots = v_n J_n = \text{row}$$

### Example

Let's work this out on a very simple problem

In the forward pass we use "Matrix-vector" products and need to do two evaluation

In the backward pass we use "vector-Matrix" products and need to do only a single evaluation

Both approaches give the same result. Since this is a map from $\mathbb{R}^2 \to \mathbb{R}^1$ the backward pass is more efficient than the forward pass

Let's look at a real-life example

$$z(x_1,x_2) = y + x_2 = x_1x_2 + x_2$$

This is easy python code

```python
def mul_func(x1,x2):
    return x1*x2

def sum_func(x1,x2):
    return x1+x2

def function(x):
    x1,x2 = x
    y = mul_func(x1,x2)
    z = sum_func(y,x2)
    return z

print(function([2,4]))
```

    12

In the forward pass, an autodiff system needs to create a JVP implementation for each elementary operation

```python
def mul_jvp(x1,dx1,x2,dx2):
    y  = mul_func(x1,x2)
    dy = x1*dx2 + x2*dx1
    return y, dy

def sum_jvp(x1,dx1,x2,dx2):
    return sum_func(x1,x2), dx1 + dx2

def function_jvp(x,dx):
    x1,x2 = x
    dx1,dx2 = dx
    y, dy = mul_jvp(x1,dx1,x2,dx2)
    z, dz = sum_jvp(y,dy, x2, dx2)
    return z,dz
```

Since in the forward pass we build "column-at a time" and our final jacobian is has shape (1x2), i.e. two columns we need two forward passes to get the full Jacobian. Not that for eacch forward pass we also get the fully computed functino value delivered on top!

Also note that the "JVP" version of the functino has the same *structure* as the original function. For each call in the original program there is an equivalent call in the JVP program. However the JVP call does always two things at once

1. compute the nominal result
2. compute the differentials

So it has roughly 2x the run-time as the original program (depending on the complexity of the derivatives). Said another way: computing the one-pass in the derivative has the same computational complexity as the function itself.

```python
print(function_jvp([2,4],[1,0]))
print(function_jvp([2,4],[0,1]))
```

    (12, 4)
    (12, 3)

For the backward pass we build "row-at-a-time'. For each elementary operation we need to build a VJP implementation

```python
def mul_vjp(x1,x2,dx1,dx2,dout):
    dx2 += dout * x1
    dx1 += dout * x2
    return dx1,dx2

def sum_vjp(x1,x2,dx1,dx2,dout):
    dx1 += dout * 1
    dx2 += dout * 1
    return dx1,dx2

def function_vjp(x,dz):

    #run forward
    x1,x2 = x
    y = mul_func(x1,x2)
    z = sum_func(y,x2)

    #zero gradients
    dy  = 0
    dx1 = 0
    dx2 = 0

    #run backward
    dy,dx2  = sum_vjp(y,x1, dy, dx2, dz)
    dx1,dx2 = mul_vjp(x1,x2, dx1, dx2, dy)
    return z,[dx1,dx2]
```

Here, we see the power of backward propagation (or the reverse mode)  we get all gradients of the single row ine oone go. Since this Jacobian only has one row, we're done! And we get the function value delivered on top of the gradients as well!

```python
print(function_vjp([2,4],1.0))
```

    (12, [4.0, 3.0])

Again, let's look at the "VJP" code. The forward pass is *exactly* the same as the original function. This just records the final result and all intermediate values, which we will need for the backward pass.

Moving on to the backward pass, we see again, as in JVP, it has the same *structure* as the forward pass. For each call to a subroutine there is an equivalent call in the backward pass to compute the VJP.

As in the JVP case, the computational complexity of one backward pass is roughly the same as the forward pass. Now unlike the JVP-case we only needed a single pass for **all the gradients** of this scalar function. So obtaining the **full gradient** of a function is only as expensive as the function itself.

## Recap:

Above we have built a *manual* autodiff system. Let's recap what we needed to do

* define a set of operations we want to be differentiable
* define sub-routines for nominal operations, JVP and VJP

Once given a program, we had to do the following

**In the forward mode**:

* just replace the nominal function with the JVP one
* for each variable in the program allocate a "differential" variable and pass it
  into the JVP whereever we also pass the nominal variable

**In the backward mode**:

* Run the program forward, keep track of all values
* keep track of the order of operations on a "record" of sorts
* allocate "differential" variables for all values and initialize to zero
* use the record to replay the order of operations backwards, passing along the
  appropriate differential values, and updating the relevant ones with the result
  of the VJP

All of this is pretty mechanistic and hence "automatable". And given that it's a very narrow
domain of only implementing JVP/JVP operations this is easy to do in any language.

That's why it's **automatic differentiation**

What we gain from this is that we get

* exact derivatives (to machine precision) for arbitrary composed of the operations we define
* complexity of a derivative-pass through the program is of same order of complexity as the original program
* often only a single pass is necessary (e.g. scalar multi-variate functions)
* unlike symbolic differrentiation, the structure of the program is preserved and allows naturally to avoid
  repetitive calculations of the same values
* (we will see that) arbitrary control flows are handles naturally
* it's something that is easy for a comoputer do and for a progarmmer to imlpement

Some notes on pros and cons:

**In the forward mode**:

the signature of each opeartion basically extends
  ```c++
  float f(float x,float y,float z)
  ```
  to
  ```c++
  pair<float> f(float x,float dx,float y,float float dy, float z,float dz)
  ```
  * if you use composite types ("dual numbers") that hold both x,dx you can basically
    keep the signature unchanged
    ```c++
    f(dual x, dual x, dual z)
    ```
  * together with operator overloading on these dual types e.g. `dual * dual` you can
    essentially keep the source code unchanged
    ```c++
    float f(float x, float y): return x*y
    ```
    ->
    ```c++
    dual f(dual x,dual y): return x*y
    ```

* That means it's very easy implement. And memory efficient, no superfluous values are kept when they run out of scope.
* But forward more better for vector-value functions of few parameters

**In the reverse mode**:

* very efficient, but we need to keep track of order (need a "tape" of sorts)
* since we need to access all intermediate varriables, we can run into memory bounds
* the procedurer is a bit more complex than fwd: 1) run fwd, 2) zero grads 3) run bwd

## I don't want to implement an autodiff system.. Aren't there libraries for this??

Yes there are! And a lot of them in many languages. On the othe rhand, try finding CAS systems in each of those

This is PyHEP, so let's focus on Python. Here, basically what you think of as "Machine Learning frameworks" are at the core autodiff libraries

* Tensorflow
* PyTorch
* JAX

Let's focus on jax

```python
import jax
import jax.numpy as jnp
```

```python
def f(x):
    return x**2
```

`jax.numpy` is almost a drop-in rerplacement for `numpy`. I do `import jax.numpy as jnp` but if you're daring you could do `import jax.numpy as np`

```python
x = jnp.array([1,2,3])
y = jnp.array([2,3,4])
```

    /Users/cranmer/anaconda3/envs/stats-book-2/lib/python3.8/site-packages/jax/lib/xla_bridge.py:130: UserWarning: No GPU/TPU found, falling back to CPU.
      warnings.warn('No GPU/TPU found, falling back to CPU.')

```python
print(x+y)
print(x*y)
print(jnp.log(x))
print(jnp.exp(y))
```

    [3 5 7]
    [ 2  6 12]
    [0.         0.69314718 1.09861229]
    [ 7.3890561  20.08553692 54.59815003]

```python
def f(x):
    return x**3
```

```python
print(f(4.0))
print(jax.grad(f)(4.0)) #boom!
print(jax.grad(jax.grad(f))(4.0)) #boom!
print(jax.grad(jax.grad(jax.grad(f)))(4.0)) #boom!
print(jax.grad(jax.grad(jax.grad(jax.grad(f))))(4.0)) #boom!
```

    64.0
    48.0
    24.0
    6.0
    0.0

```python
xi = jnp.linspace(-5,5)
yi = f(xi)

plt.plot(xi,yi)
```

```python
try:
    jax.grad(f)(xi)
except TypeError as err:
    print(err)
```

    Gradient only defined for scalar-output functions. Output had shape: (50,).

Whoops, jax.grad defaults to reverse mode with a single backward pass, but through broadcasting we get a `vector -> vector` map. We can use some jax magic to "unbroadcast" the function, take the gradient and re-broadcast it

```python
jax.vmap(jax.grad(f))(xi)
```

    DeviceArray([7.50000000e+01, 6.90024990e+01, 6.32548938e+01,
                 5.77571845e+01, 5.25093711e+01, 4.75114536e+01,
                 4.27634319e+01, 3.82653061e+01, 3.40170762e+01,
                 3.00187422e+01, 2.62703040e+01, 2.27717618e+01,
                 1.95231154e+01, 1.65243648e+01, 1.37755102e+01,
                 1.12765514e+01, 9.02748855e+00, 7.02832153e+00,
                 5.27905040e+00, 3.77967514e+00, 2.53019575e+00,
                 1.53061224e+00, 7.80924615e-01, 2.81132861e-01,
                 3.12369846e-02, 3.12369846e-02, 2.81132861e-01,
                 7.80924615e-01, 1.53061224e+00, 2.53019575e+00,
                 3.77967514e+00, 5.27905040e+00, 7.02832153e+00,
                 9.02748855e+00, 1.12765514e+01, 1.37755102e+01,
                 1.65243648e+01, 1.95231154e+01, 2.27717618e+01,
                 2.62703040e+01, 3.00187422e+01, 3.40170762e+01,
                 3.82653061e+01, 4.27634319e+01, 4.75114536e+01,
                 5.25093711e+01, 5.77571845e+01, 6.32548938e+01,
                 6.90024990e+01, 7.50000000e+01], dtype=float64)

that looks better!

`jax.grad(f)` just returns another function. Of course we can just
take the gradient of that as well. And so on...

```python
g1i = jax.vmap(jax.grad(f))(xi)
g2i = jax.vmap(jax.grad(jax.grad(f)))(xi)
g3i = jax.vmap(jax.grad(jax.grad(jax.grad(f))))(xi)
plt.plot(xi,yi,  label = "f")
plt.plot(xi,g1i, label = "f'")
plt.plot(xi,g2i, label = "f''")
plt.plot(xi,g3i, label = "f'''")
plt.legend()
```

## Control Flow

Back when discussing symbolic differentiation we hit a snag when adding
control flow through to our prorgam. In Jax this just passes through
transparently.

Let's compare this to finite differences. So far the only system
we had to compute derivatives of control-flow-ful programs

```python
def control_flow_func(x):
    if x > 2:
        return x**2
    else:
        return x**3

first_gradient_of_cflow = jax.grad(control_flow_func)

xi = jnp.linspace(-2,5,101)
yi = np.asarray([first_gradient_of_cflow(xx) for xx in xi])
plt.plot(xi,yi,c = 'k')

xi = jnp.linspace(-2,5,11)
yi = np.asarray([first_gradient_of_cflow(xx) for xx in xi])
plt.scatter(xi,yi, label = 'jax autodiff')

xi = jnp.linspace(-2,5,11)
yi = np.asarray([control_flow_func(xx) for xx in xi])
plt.scatter(xi,np.gradient(yi,xi), label = 'finite differences')

plt.legend()
```

We can start to see the benefits autodiff. Among other things, finite differnces becomes
quite sensitive to exactly where the evaluation points are (e.g. wrt  to the discontinuity)

As we compute higher derivatives, this error compounds badly for finite differences. But for
autodiff, it's smooth sailing!

```python
second_gradient_of_cflow = jax.grad(first_gradient_of_cflow)
xi = jnp.linspace(-2,5,101)
yi = np.asarray([second_gradient_of_cflow(xx) for xx in xi])
plt.plot(xi,yi,c = 'k')

xi = jnp.linspace(-2,5,11)
yi = np.asarray([second_gradient_of_cflow(xx) for xx in xi])
plt.scatter(xi,yi, label = '2nd deriv jax autodiff')

xi = jnp.linspace(-2,5,11)
yi = np.asarray([control_flow_func(xx) for xx in xi])
plt.scatter(xi,np.gradient(np.gradient(yi),xi), label = '2nd deriv finite differences',)

plt.legend()
```

## Custom Operations

Not all our programs are so simple. Consider this

```
def func(x)
    y_root = solve(x^2 + y^2 == 1,x = x, y_start = 2.0)
    return y_root

```

solving this often goes through some iterative algorithm like Brent bracketing
But, differentiating through the iteration is not the right solution.

We can add our own custom gradients

Recall the implicit function theorem
$$
f(x,y) = x^2 + y^2 -1 = 0
$$

$$
df = 0 \leftrightarrow 2x dx + 2y dy = 0
$$

$$
dy/dx = -x/y
$$

How do we teach this an autodiff system:

Recall:

* we can choose which operations we consider "fundamental"
* we don't need to constrain ourselves to the lowest possible representationo

```python
import jax
from jax import core
import numpy as np
from jax.interpreters import ad

import scipy
import functools
import matplotlib.pyplot as plt

def findroot(f):
    return scipy.optimize.brentq(f,a = 0,b = 10)

def func(x,y):
    return x**2 + y**2 - 1

def y_for_x(x):
    return findroot(functools.partial(func,x))

xi = np.linspace(-1,1)
yi = np.asarray([y_for_x(xx) for xx in xi])

plt.plot(xi,yi)

findrootjax_p = core.Primitive('findrootjax')
findrootjax_p.def_impl(lambda x: y_for_x(x))
ad.defvjp(findrootjax_p, lambda g, x: - x / y_for_x(x))

def findrootjax(x):
    return findrootjax_p.bind(x)
jax.value_and_grad(findrootjax)(0.5)

xi = np.linspace(-1,1,101)
yi = [findrootjax(v) for v in xi]
plt.plot(xi,yi)

xi = np.linspace(-1,1,21)
vg = np.asarray([np.asarray(jax.value_and_grad(findrootjax)(v)) for v in xi])
plt.scatter(xi,vg[:,0])
plt.quiver(xi,vg[:,0],np.ones_like(vg[:,0]),vg[:,1],
    angles = 'uv',
    alpha = 0.5,
)
plt.gcf().set_size_inches(5,2.5)
```

## In HEP

Of course  we can use automatic differentiation
for neural networks. But other things in HEP also
can make use of gradients. A prime example where this is the
case is statistical analysis

For a maximum likelihood fit we want to minimize the log likelihood

$\theta^* = \mathrm{argmin}_\theta(\log L)$

```python
import jax
import jax.numpy as jnp
import numpy as np
import pyhf
import matplotlib.pyplot as plt
```

```python
pyhf.set_backend('jax')
```

```python
m = pyhf.simplemodels.hepdata_like([5.],[10.],[3.5])
pars = jnp.array(m.config.suggested_init())
data = jnp.array([15.] + m.config.auxdata)
m.logpdf(pars,data)
```

    DeviceArray([-4.25748227], dtype=float64)

```python
bestfit = pyhf.infer.mle.fit(data,m)
bestfit
```

    DeviceArray([1., 1.], dtype=float64)

```python
grid = x,y = np.mgrid[.5:1.5:101j,.5:1.5:101j]

points = np.swapaxes(grid,0,-1).reshape(-1,2)
v = jax.vmap(m.logpdf, in_axes = (0,None))(points,data)
v = np.swapaxes(v.reshape(101,101),0,-1)
plt.contourf(x,y,v, levels = 100)
plt.contour(x,y,v, levels = 20, colors = 'w')

grid = x,y = np.mgrid[.5:1.5:11j,.5:1.5:11j]
points = np.swapaxes(grid,0,-1).reshape(-1,2)
values, gradients = jax.vmap(
    jax.value_and_grad(
        lambda p,d: m.logpdf(p,d)[0]
    ), in_axes = (0,None)
)(points,data)

plt.quiver(
    points[:,0],
    points[:,1],
    gradients[:,0],
    gradients[:,1],
    angles = 'xy',
    scale = 75
)
plt.scatter(bestfit[0],bestfit[1], c = 'r')

plt.xlim(0.5,1.5)
plt.ylim(0.5,1.5)
plt.gcf().set_size_inches(5,5)
```

## Thanks for joining the Tutorial!


## CCNSS 2018 Module 1: Neurons, synapses and networks
# Tutorial 4: Balanced neuronal excitation and inhibition
[source](https://colab.research.google.com/drive/1I4b01XubbttyGW1nahCEBM-Yl0K98QrV)

Please execute the cell below to initialise the notebook environment.

```python
%autosave 0

import matplotlib.pyplot as plt    # import matplotlib
import numpy as np                 # import numpy
import random                      # import basic random number generator functions

fig_w, fig_h = (6, 4)
plt.rcParams.update({'figure.figsize': (fig_w, fig_h)})
```

    Autosave disabled

## Objectives

In this tutorial, we will build on the conductance-based model in Tutorial 4 to study E/I balance and fluctuation-drive spiking in a conductance based neuron.

**Background paper:**

* Song S, Miller K and Abott L (2000) Competitive Hebbian learning through spike-timing-dependent synaptic plasticity. Nature Neurosci 3.

**Extra reading:**

* Softy W and Koch C (1993) The highly irregular firing of cortical cells is inconsistent with temporal integration of random EPSPs. J Neurosci 13.

* Okun M and Lampl I (2009) Balance of excitation and inhibition. Scholarpedia: http://www.scholarpedia.org/article/Balance_of_excitation_and_inhibition

## Background

```python
t_max = 500e-3   # second
dt = 1e-3        # second
tau = 20e-3      # second
el = -60e-3      # volt
e_ex = 0         # volt
e_in = -70e-3    # volt
vr = -70e-3      # volt
vth = -50e-3     # volt
tau_syn = 5e-3   # second
```

**Exercise 1:** Fill in in the following functions to generate Poisson spikes and simulate a postsynaptic neuron with both excitatory and inhibitoy conductances (no plasticity). Use the code you wrote in Tutorial 3 and build it up to incorporate inhibition, by adding an inhibitory conductance:

\begin{align}
&\tau_m\,\frac{dV}{dt}\ = E_{L} - V(t) + g_{ex}(t)(E_{ex}-V(t))+ g_{in}(t)(E_{in}-V(t)) &\text{if }\quad V(t) \leq V_{th}\\
&V(t) = V_{r} &\text{otherwise}\\
\end{align}

Also modify the code to return the spike times of the postsynpatic neuron. Then, simulate a postsynaptic neuron with 800 excitatory inputs and 200 inhibitory inputs at 10 Hz for 500 ms, with peak conductances of $\bar g_{max,ex} = \bar g_{max,in} = 6*10^{-3}$.

```python
random.seed(0)

def generate_Poisson_spikes(t,rate,n):
  """ Generates poisson spike trains

  Arguments:
  t -- time
  rate -- firing rate (Hz)
  n -- number of spike trains

  Returns:
  pre_spike_train -- spike train matrix, ith row represents whether
                     there is a spike in ith spike train over time
                     (1 if spike, 0 otherwise)
  """
  # insert your code here
  return pre_spike_train

def simulate_postsynaptic_neuron(t,pre_spike_train_ex,pre_spike_train_in):
    """ Simulate nonplastic postsynaptic neuron

    Arguments:
    t -- time
    pre_spike_train_ex -- excitatory presynaptic spike train matrix, same length as t
    pre_spike_train_in -- inhibitory presynaptic spike train matrix, same length as t

    Returns:
    v -- membrane potential
    spike_times -- list of postsynaptic spike times
    """
    # insert your code here
    return v,spike_times

# insert your code here
```

**EXPECTED OUTPUT**
```
24.0
```

**Exercise 2:** Repeat this over 10 trials and make a raster plot showing the spike times for each trial. Calculate the average firing rate over all the trials.

```python
random.seed(0)

# insert your code here
```

**EXPECTED OUTPUT**
```
22.6
```

**Exercise 3:** Now increase the peak inhibitory conductance to  $\bar g_{max,in} = 8*\bar g_{max,ex}$. To maintain a similar firing rate, you should also increase the peak excitatory conductance: $\bar g_{max,ex} = 17*10^{-3}$. How are the raster plots different? Which one is more regular?

```python
random.seed(0)

# insert your code here
```

**EXPECTED OUTPUT**
```
22.2
```

**Exercise 4:** To see what's happening, simulate one example with the excitatory and inhibitory conductances from Exercise 1-2, and one example with the conductances from Exercise 3. Use exactly the same presynaptic spike train for each simulation. Plot the membrane potential for each case. How does this difference in $\bar g_{max,ex}$ and $\bar g_{max,in}$ change the subthreshold dynamics? How does this explain the difference in the postsynaptic spike trains?

```python
random.seed(0)

# insert your code here
```

**EXPECTED OUTPUT**

**Exercise 5:**  By increasing the inhibitory strengths relative to the excitatory strength, the postsynaptic neuron enters an irregular spiking regime in which spikes are drive by fluctuations in the input, rather than the mean input. This is called E/I balance. To quantify the irregularity of spiking, calculate the coefficient of variation (CV) of the inter-spike intervals. The CV is defined as the ratio of the standard deviation to the mean:
$$ CV = \frac{\sigma_{ISI}}{\mu_{ISI}}$$
Fill in the following function to get the CV. Test it by calculating the CV for both the balanced and unbalanced neurons (increase $t_{max}$ to 5 s). Note that for a poisson process, the CV is 1.

```python
random.seed(0)

def get_CV(spike_times):
  """Calculate the coefficient of variation

  Arguments:
  spike_times -- list of spike times

  Returns:
  CV -- coefficient of variation for the spike times
  """
  # insert your code here
  return CV

# insert your code here
```

**EXPECTED OUTPUT**

```0.774793911740589 0.15577586005132124```

**Exercise 6:** Now repeat the previous exercise while ranging the presynaptic range from 10 to 40 Hz. Plot the CV against the firing rate for both the balanced and unbalanced neurons. Why does the CV decrease with firing rate?

```python
random.seed(0)

# insert your text here
```

**EXPECTED OUTPUT**

**Exercise 7:** Next we will see how STDP can help E/I balance in a postsynaptic neuron. First, execute the cell below to set the STDP parameters used in Tutorial 2.

```python
tau_stdp = 20e-3       # second
A_plus = 5e-3
A_minus = A_plus*1.10

gbar_max_ex = 0.015
gbar_max_in = 0.015
```

Then, fill in the functions below to simulate STDP. You can use your code from Tutorial 2, while adding nonplastic inhibitory synaptic inputs. Also fill in the function to simulate a postsynaptic neuron with frozen weights (i.e., with fixed $\bar g_i$). Check that your code works by simulating a plastic postystnaptic neurons receiving input from 300 excitatory cells and 75 inhibitory cells (all firing at 20 Hz) for 5 seconds and plotting the average $\bar g_i(t)$. Then simulate the postsynaptic neuron for 100 ms after freezing the synaptic weights to those at the end of the STDP protocol. Plot the membraine potential.

```python
random.seed(0)

def generate_P(t,pre_spike_train):
  """ Generate P to track presynaptic spikes

  Arguments:
  t -- time
  pre_spike_train -- presynaptic spike train matrix, same length as t

  Returns:
  P -- matrix, ith row is P for the ith presynaptic input
  """
  # insert your code here
  return P

def simulate_postsynaptic_neuron_plastic(t,pre_spike_train_ex,pre_spike_train_in):
    """Simulate a plastic neuron

    Arguments:
    t -- time
    pre_spike_train_ex -- excitatory presynaptic spike train matrix, same length as t
    pre_spike_train_in -- inhibitory presynaptic spike train matrix, same length as t

    Returns:
    gbar -- matrix, ith row is peak excitatory conductance
            over time for ith presynaptic neuron
    v -- membrane potential
    spike_times -- list of spike times
    """
    # insert your code here
    return gbar,v

def simulate_postsynaptic_neuron_frozen(t,pre_spike_train_ex,pre_spike_train_in,gbar):
    """Simulate the postsynaptic neuron after freezing the weights

    Arguments:
    t -- time
    pre_spike_train_ex -- excitatory presynaptic spike train matrix, same length as t
    pre_spike_train_in -- inhibitory presynaptic spike train matrix, same length as t
    gbar -- vector, ith element is peak excitatory conductance
            over time for ith presynaptic neuron

    Returns:
    v -- membrane potential
    spike_times -- list of spike times
    """
    # insert your code here
    return v,spike_times

# insert your code here
```

**EXPECTED OUTPUT**

**Exercise 8:** Now increase $t_{max}$ to 1000 s and simulate STDP by executing the following cell (warning: this is really slow and might kill all your memory).

```python
random.seed(0)

t_max = 1000;
t = np.arange(0,t_max,dt)

n_ex = 300; n_in = 75; rate = 20

pre_spike_train_ex = generate_Poisson_spikes(t,rate,n_ex)
pre_spike_train_in = generate_Poisson_spikes(t,rate,n_in)

gbar, v = simulate_postsynaptic_neuron_plastic(t,pre_spike_train_ex,pre_spike_train_in)

```

When it has completed, plot the histograms at the end of learning and 90% of the way through. Has the system converged?

```python
random.seed(0)

# insert your code here
```

**EXPECTED OUTPUT**

**Exercise 9:** Finally, use the weights at the end of STDP (from Exercise 8) to simulate the postsynaptic neuron for  $t_{max}=15 s$ (use  the function ``simulate_postsynaptic_neuron_frozen``).
Calculate the CV at the beginning and at the end of STDP.

```python
random.seed(0)

# insert your code here
```

**EXPECTED OUTPUT**

```0.10194746220181417 0.7006726102447001```


# 2017-09-13  diode I/V curves

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

#plt.style.use('seaborn-bright')
mpl.rcParams['figure.figsize'] = (15.0, 8.0)
mpl.rcParams['font.size'] = 24
mpl.rcParams['lines.linewidth'] = 5
mpl.rcParams['lines.markersize'] = 5
```

\begin{equation}
i_D = I_S \left[ \exp \left( \dfrac{v_D}{V_T} \right) - 1 \right]
\end{equation}

\begin{equation}
\approx I_S \exp \left( \dfrac{v_D}{V_T} \right)
\end{equation}

Plot both the full equation and the approximation.

```python
kB = 1.381e-23  # J/K
q = 1.602e-19  # C

def id(vd, Is=1e-15, T=300):
    VT = kB * T / q
    return Is * (np.exp(vd / VT) - 1)

def id_approx(vd, Is=1e-15, T=300):
    VT = kB * T / q
    return Is * np.exp(vd / VT)

```

```python
def plotlin_idvd(v, nvt=5):
    plt.close('all')
    ax1 = plt.subplot()
    ax1.plot(v, 1000*id_approx(v), '-', label='approx')
    ax1.plot(v, 1000*id(v), 'o', label='exact')

    r = plt.xlim([0, v.max()])

    plt.ylabel('$I_D$ (mA)')
    plt.xlabel('$V_D$ (V)')
    #plt.suptitle('$i_D$ vs. $v_D$', y=1.05, size='x-large')
    plt.legend(loc='upper left')
    plt.grid(axis='y')

    VT = kB * 300 / q
    ax2 = ax1.twiny()

    vts = np.arange(0, int(v.max() / VT) + 1, nvt)

    ax2.set_xticks(VT * vts)
    ax2.set_xticklabels(vts)
    ax2.set_xlabel(r'$n\times V_T$')
    ax2.grid(True)
    ax2.set_xlim(r)

plotlin_idvd(np.linspace(1e-3, 0.8, 100), nvt=10)
plt.show()
```

A linear-*y* plot isn't so helpful.

Plot with a logarithmic scale to see the vertical scale better:

```python
def plot_idvd(v, nvt=5):
    plt.close('all')
    ax1 = plt.subplot()
    plt.semilogy(v, id_approx(v), '-', label='approx')
    plt.semilogy(v, id(v), 'o', label='exact')
    plt.ylabel('$I_D$ (A)')
    plt.xlabel('$V_D$ (V)')
    #plt.suptitle('$I_D$ vs. $V_D$', y=1.05, size='x-large')
    plt.legend(loc='best')
    r = plt.xlim([0, v.max()])
    plt.grid(axis='y')

    VT = kB*300/q
    ax2 = ax1.twiny()

    vts = np.arange(0, int(v.max()/VT)+1, nvt)

    ax2.set_xticks(VT*vts)
    ax2.set_xticklabels(vts)
    ax2.set_xlabel(r'$n\times V_T$')
    ax2.grid(True)
    ax2.set_xlim(r)

plot_idvd(np.linspace(1e-3, 0.8, 100), nvt=10)
plt.show()

```

Zoom in to near $V_D = 0$

```python
v = np.linspace(0, 0.1, 100)
plot_idvd(v, nvt=1)
plt.show()
```

$\rightarrow$ using the *approximation* is plenty good:

\begin{equation}
i_D \approx \begin{cases}
I_S \exp \left( \dfrac{v_D}{V_T} \right) & v_D > 0 \\
-I_S & v_D < 0
\end{cases}
\end{equation}

... unless $|v_D|$ is small.

**Q:** When is the approximation different from the exact equation by more than 1%?

```python

```

```python

```