This is originally a collaborative project with Isky on an implementation of the Hartree-Fock method, a way of approximating the wave function and thus energy of bonds in a quantum molecular system. However, despite a week of research, the complexity of the problem forces us to cease the plan, and instead resort to a much simpler task of plotting the probability density of hydrogen-like orbitals.

# Plotting the Atomic Orbital - Johnny Tse

This is a project which draws out the hull of each atomic orbital of a single electron in a hydrogen atom. This is done by obtaining the wave function of the electron, finding the (complex) probability amplitude at points around the atom, then to square the amplitude to derive the probability density. A contour of the probability density is then drawn to illustrate the region where the electron is most likely to be found.

It is inevitable that this would be an easy process. It is a difficult task to derive the exhaustive list of the orbitals' wave function which can obey the Schrödinger equation in this situation. However, once derived, one can easily plot out the orbitals, and there is no easy way of finding the wave functions by simulation.

It did, however, allowed for deeper understanding of what the wave function is. Each electron around a nucleus can be characterized by four variables, the principal quantum number (n), the azimuthal quantum number (l), the magnetic quantum number (m) and the spin quantum number (s). The principal quantum number is the average distance of the electron from nucleus/energy level, the azimuthal quantum number shows the orbital's shape/angular momentum, the magnetic quantum number is the orbital's orientation/angular momentum's vector component, and the spin quantum number is the intrinsic angular momentum. Each orbital is described by the variables n,l,m, and due to Pauli exclusion principle, no electron in the system can share the same value for all four variables, hence each orbital can take up to two electrons.

The wave function is usually expressed using polar coordinates, due to the fact that it is possible to separate the wave function into three functions, each taking in only one value of the coordinate. The radial component is a function with exponential decay, and the angular component are formed from spherical harmonics, a set of functions in which combinations of these functions generates arbitrary functions defined on a sphere, like Fourier series. Spherical harmonics plays a role in the construction of the wave functions due to its link to producing standing waves on the surface of a sphere, which is applicable in this situation as well.

Notice that the wavefunction can only be explicitly expressed for single electron atoms (Hydrogen-like). If there are multiple electrons, the repulsion between the electrons would cause the wave functions of the electrons to be entangled, resulting in our inability to write an explicit wave function for it. We can, however, attempt to approximate it, using various methods such as the hartree-fock method.

In terms of programming, it opened me to the world of symbolic computation. In conventional programming, the closest thing resembling symbols are variables, which must hold a value. Symbols, on the other hand, is just a placeholder, allowing different possible methods of calculation (such as evaluating equality). Another feature that this code uses is numpy's capability of vectorization. It is essentially batch processing an array of data with a single function call, which is many times faster than calling the function iteratively for every single data.

## Note #1

Although the 2D graphs below might allude to the idea that the electron is mostly found in the near the nucleus, due to the bright colour, this is in fact not the case. This is because although the outer region of the atom is much dimmer, it cover a greater space. The region near the nucleus has higher probability density, but smaller space, so it is actually quite unlikely to find the electron in that region.

To illustrate my point, here is the probability of find a electron at certain distance from the nucleus for the first three s-orbitals, extracted from "General Chemistry: Principles, Patterns, and Applications - 6.5 Atomic Orbitals and Their Energies":

## Note #2

A hydrogen wave function also comes in two flavours, the complex atomic orbital, and a real atomic orbital. When describing the atomic orbitals of an electron with its four values (n,l,m,s), one would obtain complex atomic orbitals, which is shown below, extracted from the Wikipedia page on magnetic quantum number.

As you can see, many of their shapes are the same, except their phase difference (look at how the colour evolves going around the loop). To make better visualization, complex atomic orbitals are combined to form superpositions, whose wave function lies in the real domain. This is the real orbital, which will be used in the following code.

This is in fact equivalent to the complex type, and the reason it is so is quite complicated. It is alluded by the fact that electrons can be in superposition between orbitals and using the complex atomic orbital or real atomic orbitalis simply the matter of picking which [basis](https://en.wikipedia.org/wiki/Basis_(linear_algebra)) to use.

### Either way, this is very important because it sheds light on what the orbitals we are familiar with actually are. They are useful in chemistry for different electron models, but they are just a possible selection of basis which makes visualization easier. The complex atomic orbital, though looking quite different, will still yield correct answers when used in calculations.

## Please run every block of code below

```python
# The code's dependencies are declared here
%matplotlib notebook
import numpy as np
import matplotlib.pyplot as plt
from sympy.physics.hydrogen import Psi_nlm, R_nl #might be unnecessary
from sympy.functions.special.spherical_harmonics import Znm
from sympy import Symbol
from sympy.utilities.lambdify import lambdify
from mpmath import atan2

import ipyvolume as ipv
```

## Constructing the wave function

In this part, the real wavefunction is constructed. Adjust the atomic orbital being plotted by typing in the principal quantum number (n), the azimuthal quantum number (l) and the magnetic quantum number (m) of the orbital. This would generate the wave function of the orbital in spherical coordinate, using the library sympy.

```python
r = Symbol("r", real=True, positive=True) #radius
phi = Symbol("phi", real=True) #azimuthal angle
theta = Symbol("theta", real=True) #polar angle
Z = Symbol("Z", positive=True, integer=True, nonzero=True) #atomic number

#Variables - n,1,m
n,l,m = 5,2,2

#Construction of complex wave function, hard to visualize
#psi = Psi_nlm(n,l,m,r,phi,theta,Z)

#Construction of real wave function, used in the following code
psi = R_nl(n, l, r, Z)*Znm(l,m,theta,phi).expand(func=True)
print("Psi: ", psi)
```

## Plotting the probability density in 2D

A vertical (parallel to z-axis) 2D slice of the plot of probability density is rendered via the code below, which produces a graph where a light colour means a higher probability density in that region of space.

This is handled using NumPy vectorization, which utilizes a huge tensor (multi-dimensional array) to store the x and y coordinate of every point of the system we wish to record the probability density. The cartesian coordinate is then transformed to the polar coordinate. And the tensor of coordinates is substituted into the wave function to obtain the tensor of probability amplitude, and then the tensor of probability density, which is directly fed into the plotting library to produce the plot.

Utilizing this approach over the native python for-loop, going through each pixel, enables magnitudes of speed up, which allows a detailed plot to be rendered in reasonable time.

The variables that can be adjusted are maxi, the maximum distance to render from nucleus, and resolution, the number of points to measure in a dimension.

```python
#NUMPY VECTORIZATION, FAST
#Variables to adjust
maxi = 40
resolution = 180

#Setting up equally spaced points around the nucleus
base = np.linspace(-maxi, maxi, resolution)[:,np.newaxis]
x2 = np.tile(base, (1,resolution))
y2 = np.swapaxes(x2,0,1)
total = np.concatenate((x2[np.newaxis,:],y2[np.newaxis,:]), axis=0)

#Converting cartesian coordinates into polar coordinates
r2 = np.linalg.norm(total, axis=0)
theta2 = np.arctan2(total[1],total[0])

#Substitution of values
psi2 = psi.subs({Z: 1, phi: 0})
lam_psi = lambdify([r,theta], psi2, 'numpy')
image1 = lam_psi(r2,theta2)
#Convertion of prabability amplitude to probability density
image1 = np.real(image1*np.conj(image1))

#Plotting
plt.imshow(image1, cmap="gist_gray")
plt.colorbar()
```

```python
#NATIVE APPROACH, VERY SLOW
maxi = 40
resolution = 20
image = np.empty([resolution,resolution])

for x in range(resolution):
    for y in range(resolution):
        x1 = 2*maxi*x/resolution-maxi
        y1 = 2*maxi*y/resolution-maxi
        res = psi.subs({Z: 1, r: (x1**2+y1**2)**0.5, phi: 0, theta: atan2(y1,x1)})
        image[x,y] = res * res.conjugate()

plt.imshow(image, cmap="gist_gray")
plt.colorbar()
```

## Plotting the probability density in 3D

Two modes are available to visualize the probability density in 3D. The first method is to plot the regions of space with different colour and opacities based on how high the probability density is. The second method is to draw a 3D contour around the regions of high probability density, creating what is called an isosurface. The volume bounded by the isosurface is where the electron is mainly "located" at (they don't locate at a single point).

This code is similarly written using NumPy vectorization, in order to speed up the execution.

The first part of the code sets up the tensor which stores the probability density at points around the nucleus. Run the second part of the code to plot the entire probability density with colour and opacities. To draw out the isosurface, run the third part of the code. Adjust the slide's position beneath the plot to adjust the threshold for a point to reside within the isosurface.

```python
#Variables to adjust
maxi = 60
resolution = 160

base = np.linspace(-maxi, maxi, resolution)[:,np.newaxis,np.newaxis]
x2 = np.tile(base, (1,resolution,resolution))
y2 = np.swapaxes(x2,0,1)
z2 = np.swapaxes(x2,0,2)

total = np.concatenate((x2[np.newaxis,:],y2[np.newaxis,:],z2[np.newaxis,:]), axis=0)

r2 = np.linalg.norm(total, axis=0)
#Alternative theta calculation
#theta3 = np.abs(np.arctan2(np.linalg.norm(total[:2], axis=0),-total[2]))
np.seterr(all='ignore')
theta2 = np.arctan(np.divide(total[2],np.linalg.norm(total[:2], axis=0))) + np.pi/2
phi2 = np.arctan2(total[1],total[0])

psi2 = psi.subs({Z: 1})
lam_psi = lambdify([r,phi,theta], psi2, 'numpy')
image2 = np.square(lam_psi(r2,phi2,theta2)).real
#Scaling the probability density up
image2 *=1000000000
```

```python
#Volume with opacities
ipv.figure()
ipv.volshow(image2)
ipv.show()
```

```python
#Isosurface
ipv.figure()
ipv.plot_isosurface(image2)
ipv.show()
```


```python
from sympy import *
init_printing()
```

```python
def printcode(vs, es, names):
    for var, val in vs:
        print '    %s = %s' % (var, val)
    for i, val in enumerate(es):
        print '    %s = %s' % (names[i], val)
```

```python
# Landmark state (just its position)
lx, ly = symbols("l_x l_y")
X = Matrix([lx, ly])

# Particle state variables
x, y, theta = symbols("p_x p_y theta", real=True)

# Bearing measurement of landmark
def bearing_measurement():
    s, c = sin(theta), cos(theta)
    # Note: the Y coordinate of the rotation matrix is flipped here
    # because the relative bearing of the cone w.r.t. the center of the camera is flipped
    # or something, I forget
    R = Matrix([[c, s], [s, -c]])
    lo = R * Matrix([lx - x, ly - y])
    return Matrix([atan2(lo[1], lo[0])])

h_x_bearing = bearing_measurement()
l_px = symbols("l_px")
h_z_bearing = Matrix([l_px])
z_bearing = Matrix([l_px])
R_bearing = symbols("lm_R")

def generate_measurement_1d(X, h_x, h_z, z_k):
    H = h_x.jacobian(X)
    M = h_z.jacobian(z_k) + h_x.jacobian(z_k)
    y_k = h_z - h_x
    #vs, es = cse([y_k, H, M], optimizations='basic',
    #                symbols=numbered_symbols("k"))
    #return vs, es
    p11, p12, p22 = symbols("p11 p12 p22")
    r = symbols("r")
    P = Matrix([[p11, p12], [p12, p22]])
    S = H*P*H.T + Matrix([[r]])
    LL = -y_k[0,0]**2/S[0,0] - 0.5*log((2*pi)**2 * S[0,0])
    K = P*H.T / S[0,0]
    Pnew = (eye(2) - K*H)*P
    Pvec = Matrix([Pnew[0,0], Pnew[0,1], Pnew[1,1]])
    #vs, es = cse([y_k, K, LL, Pvec], optimizations='basic',
    #                symbols=numbered_symbols("k"))
    vs, es = cse([y_k[0], S[0,0], H[0,0], H[0,1], LL], # optimizations='basic',
                    symbols=numbered_symbols("k"))
    return vs, es, ["y_k", "S", "H1", "H2", "LL"]

printcode(*generate_measurement_1d(X, h_x_bearing, h_z_bearing, z_bearing))
```

        k0 = sin(theta)
        k1 = l_x - p_x
        k2 = k0*k1
        k3 = cos(theta)
        k4 = l_y - p_y
        k5 = k3*k4
        k6 = k2 - k5
        k7 = k0*k4 + k1*k3
        k8 = l_px - atan2(k6, k7)
        k9 = 1/(k6**2 + k7**2)
        k10 = k7*k9
        k11 = k9*(-k2 + k5)
        k12 = k0*k10 + k11*k3
        k13 = k0*k11 - k10*k3
        k14 = k12*(k12*p11 + k13*p12) + k13*(k12*p12 + k13*p22) + r
        y_k = k8
        S = k14
        H1 = k12
        H2 = k13
        LL = -0.5*log(4*pi**2*k14) - k8**2/k14

```python
def generate_xP():
    p11, p12, p22, H1, H2, S, yk = symbols("p11 p12 p22 H1 H2 S y_k")
    P = Matrix([[p11, p12], [p12, p22]])
    H = Matrix([[H1, H2]])
    K = P*H.T / S
    dx = K*yk
    Pnew = (eye(2) - K*H)*P

    vs, es = cse([dx[0], dx[1], Pnew[0, 0], Pnew[0,1], Pnew[1,1]], optimizations='basic',
                    symbols=numbered_symbols("k"))
    return vs, es, ["dx", "dy", "p11", "p12", "p22"]

printcode(*generate_xP())
```

        k0 = 1/S
        k1 = H2*p12
        k2 = k0*(H1*p11 + k1)
        k3 = H1*p12
        k4 = H2*p22
        k5 = k0*(k3 + k4)
        k6 = H1*k2 - 1
        dx = k2*y_k
        dy = k5*y_k
        p11 = -k1*k2 - k6*p11
        p12 = -k2*k4 - k6*p12
        p22 = -k3*k5 - p22*(H2*k5 - 1)

```python

```

```python

```


```python
from sympy import *
init_printing()
import pandas as pd
import numpy as np
from myhdl import *
from myhdlpeek import *
import random
```

```python
def TruthTabelGenrator(BoolSymFunc):
    """
    Function to generate a truth table from a sympy boolian expression
    BoolSymFunc: sympy boolian expression
    return TT: a Truth table stored in a pandas dataframe
    """
    colsL=sorted([i for i in list(BoolSymFunc.rhs.atoms())], key=lambda x:x.sort_key())
    colsR=sorted([i for i in list(BoolSymFunc.lhs.atoms())], key=lambda x:x.sort_key())
    bitwidth=len(colsL)
    cols=colsL+colsR; cols

    TT=pd.DataFrame(columns=cols, index=range(2**bitwidth))

    for i in range(2**bitwidth):
        inputs=[int(j) for j in list(np.binary_repr(i, bitwidth))]
        outputs=BoolSymFunc.rhs.subs({j:v for j, v in zip(colsL, inputs)})
        inputs.append(int(bool(outputs)))
        TT.iloc[i]=inputs

    return TT

```

```python
def TTMinMaxAppender(TruthTable):
    """
    Function that takes a Truth Table from "TruthTabelGenrator" function
    and appends a columns for the Minterm and Maxterm  exspersions for each

    TruthTable: Truth table from "TruthTabelGenrator"
    return TruthTable: truth table with appened min max term exspersions
    return SOPTerms: list of Sum of Poroduct terms
    return POSTerms: list of Product of Sum Terms
    """

    Mmaster=[]; mmaster=[]; SOPTerms=[]; POSTerms=[]
    for index, row in TruthTable.iterrows():

        if 'm' not in list(row.index):

            rowliterals=list(row[:-1].index)
            Mm=list(row[:-1])

            Mi=[]; mi=[]

            for i in range(len(rowliterals)):
                if Mm[i]==0:
                    Mi.append(rowliterals[i])
                    mi.append(~rowliterals[i])

                elif Mm[i]==0:
                    M.append(rowliterals[i])
                    m.append(~rowliterals[i])

            Mi=Or(*Mi, simplify=False); mi=And(*mi)
            Mmaster.append(Mi); mmaster.append(mi)

            if row[-1]==0:
                POSTerms.append(index)
            elif row[-1]==1:
                SOPTerms.append(index)
        else:

            if row[-3]==0:
                POSTerms.append(index)
            elif row[-3]==1:
                SOPTerms.append(index)

    if 'm' not in list(TruthTable.columns):
        TruthTable['m']=mmaster; TruthTable['M']=Mmaster
    return TruthTable, SOPTerms, POSTerms

```

```python
termsetBuilder=lambda literalsList: set(list(range(2**len(literalsList))))
```

```python
def POS_SOPformCalcater(literls, SOPlist, POSlist, DC=None):
    """
    Wraper function around sympy's SOPform and POSfrom boolian function
    genrator from the SOP, POS, DontCar (DC) list

    """
    minterms=[]; maxterms=[]
    for i in SOPlist:
        minterms.append([int(j) for j in list(np.binary_repr(i, len(literls)))])
    for i in POSlist:
        maxterms.append([int(j) for j in list(np.binary_repr(i, len(literls)))])

    if DC!=None:
        dontcares=[]
        for i in DC:
            dontcares.append([int(j) for j in list(np.binary_repr(i, len(literls)))])
        DC=dontcares

    return simplify(SOPform(literls, minterms, DC)), simplify(POSform(literls, maxterms, DC))

```

```python
def Combo_TB(inputs=[]):
    """
    Basic myHDL test bench for simple compintorial logic testing
    """

    #the # of inputs contorls everything
    Ninputs=len(inputs)
    #genrate sequantil number of inputs for comparsion to known
    SequntialInputs=np.arange(2**Ninputs)

    #run the test for 2^Ninputs Seq and 2^Ninputs randomly =2*2^Ninputs cycles
    for t in range(2*2**Ninputs):
        #run sequantial
        try:
            #genrate binary bit repsersintion of current sequantl input
            NextSeqInput=np.binary_repr(SequntialInputs[t], width=Ninputs)

            #pass each bit into the inputs
            for i in range(Ninputs):
                inputs[i].next=bool(int(NextSeqInput[i]))

        #run the random to cheack for unexsected behavior
        except IndexError:
            NextRanInput=[random.randint(0,1) for i in range(Ninputs)]

            for i in range(Ninputs):
                inputs[i].next=NextRanInput[i]

        #virtural clock for combo only
        yield delay(1)
        now()

```

```python
bool(int('0'))
```

    False

```python
def VerilogTextReader(loc, printresult=True):
    """
    Function that reads in a Verilog file and can print to screen the file
    contant
    """
    with open(f'{loc}.v', 'r') as vText:
        VerilogText=vText.read()
    if printresult:
        print(f'***Verilog modual from {loc}.v***\n\n', VerilogText)
    return VerilogText
```

```python
def VHDLTextReader(loc, printresult=True):
    """
    Function that reads in a vhdl file and can print to screen the file
    contant
    """
    with open(f'{loc}.vhd', 'r') as vhdText:
        VHDLText=vhdText.read()
    if printresult:
        print(f'***VHDL modual from {loc}.vhd***\n\n', VHDLText)
    return VHDLText
```

```python
def MakeDFfromPeeker(data):
    """
    Helper function to read the Peeker JSON information from a myHDL test bench
    simulationn and move the data into a pands dataframe for easer futer parsing
    and snyslsisis

    (note need to update functionality to read in numericl )
    """

    for i in range(len(data['signal'])):
        datainstance=data['signal'][i]['wave']
        while True:
            ith=datainstance.find('.')
            if ith==-1:
                break
            else:
                datainstance=datainstance.replace('.', datainstance[ith-1], 1)

        data['signal'][i]['wave']=datainstance

    DataDF=pd.DataFrame(columns=[i['name'] for i in data['signal']])
    for i in data['signal']:
        DataDF[i['name']]=list(i['wave'])

    return DataDF
```

```python

```

```python
def shannon_exspanson(f, term):
    """
    function to perform shannon's expansion theorm
    f is not a full equation
    """
    cof0=simplify(f.subs(term, 0)); cof1=simplify(f.subs(term, 1))
    return ((~term & cof0 | (term & cof1))), cof0, cof1
```

## Testing cell have been converted to Markdown so as to not clutter .py file

x_1in, x_2in, x_3in, y_out=symbols('x_1in, x_2in, x_3in, y_out')
AND3Def1=Eq(y_out, x_1in & x_2in & x_3in)
AND3Def2=Eq(y_out, And(x_1in , x_2in, x_3in))
AND3Def1, AND3Def2

F=AND3Def1; F

list(F.rhs.atoms())

colsL=sorted([i for i in list(F.rhs.atoms())], key=lambda x:x.sort_key())
colsR=sorted([i for i in list(F.lhs.atoms())], key=lambda x:x.sort_key())
bitwidth=len(colsL)
cols=colsL+colsR; cols

TT=pd.DataFrame(columns=cols, index=range(2**bitwidth)); TT

for i in range(2**bitwidth):
    print([int(i) for i in list(np.binary_repr(i, bitwidth))])

for i in range(2**bitwidth):
    inputs=[int(j) for j in list(np.binary_repr(i, bitwidth))]
    outputs=F.rhs.subs({j:v for j, v in zip(colsL, inputs)})
    inputs.append(int(bool(outputs)))
    TT.iloc[i]=inputs
TT

inputs=[0,0,0]
outputs=F.rhs.subs({j:v for j, v in zip(colsL, inputs)})
outputs

TT=TruthTabelGenrator(AND3Def1)
TT

T0=TT.iloc[0]; T0

POS=[]
T0[-1]
if T0[-1]==0:
    POS.append(0)
POS

T0literal=list(T0[:-1].index); T0literal

Mm0=list(T0[:-1]); Mm0

M=[]; m=[]
for i in range(len(T0literal)):
    if Mm0[i]==0:
        M.append(T0literal[i])
        m.append(~T0literal[i])
    elif Mm0[i]==0:
        M.append(T0literal[i])
        m.append(~T0literal[i])
M=Or(*M); m=And(*m)

TT=TruthTabelGenrator(AND3Def1)
TT
Taple, SOP, POS=TTMinMaxAppender(TT)
SOP, POS

TT

F, w, x, y, z=symbols('F, w, x, y, z')
Feq=Eq(F,(y&z)|(z&~w)); Feq

FTT=TruthTabelGenrator(Feq)
FTT

_, SOP, POS=TTMinMaxAppender(FTT)
SOP, POS

FTT

for i in SOP:
    print([int(j) for j in list(np.binary_repr(i, 4))])

POS_SOPformCalcater([w, y, z], SOP, POS)

SOP


참고문헌 : Pytel 외 저, 이주성 외 역, 재료역학, 2판, 한티미디어, 2013.
Ref: Pytel, Kiusalaas, Sharma, Mechanics of Materials, 2nd Ed., Cengege Learning, 2013.

`python` 기능을 확장해 주는 `module`을 불러 들임 (일부 기능만 사용될 수도 있음)

```python
import numpy as np  # 배열, 행렬 관련 기능
import numpy.linalg as na  # 선형대수 (벡터, 행렬) 관련 기능
import matplotlib.pyplot as plt  # 그래프 관련 기능
import scipy.integrate as si  # 적분 관련 기능
import sympy as sy  # 기호 연산 기능
import sympy.plotting as splot
import IPython.display as disp  # 웹페이지 표시 기능
sy.init_printing()  # 기호 연산 결과 표시 기능 준비
```

## 예제 07.008
ex07.008

부정정보: 중첩법
Statically Indeterminate Beam : Superposition

p. 314

### 문제에서 주어진 변수
Given Parameters

#### 보의 길이
Length of the beam

```python
L_AB_m = sy.symbols('L_AB_m', real=True, nonnegative=True)

s_d = {
    L_AB_m: 10,
}

```

#### 하중
Load

```python
w0_N_m = sy.symbols('w0_N_m', real=True)

s_d.update(
    {
        w0_N_m: -1,
    }
)

```

#### 재료와 단면 특성
Material & section properties

```python
E_Pa, I_m4 = sy.symbols('E_Pa, I_m4', positive=True)

s_d.update(
    {
        E_Pa: 200e9,
        I_m4: 20e6 * (1e-3) ** 4,
    }
)

```

#### 자유물체도
Free body diagram

```python
x_m = sy.symbols('x_m', nonnegative=True)
x_A_m = 0
x_B_m = L_AB_m

```

### 아직 알지 못하는 반력
Reaction forces unknown yet

```python
R_A_N, M_A_Nm, R_B_N = sy.symbols('R_A_N, M_A_Nm, R_B_N', real=True)
```

### 중첩법
Superposition

#### 외팔보 전체에 가해지는 분포하중 $w_0$에 의한 끝점의 처짐
Deflection of a cantilever's end point by distributed load $w_0$ over the whole length

p. 279 Table 6.2

$$
\delta_{Bw_0} = \frac{w_0L^4}{8EI}
$$

```python
delta_Bw0_m = (w0_N_m * L_AB_m ** 4 / (8 * E_Pa * I_m4))
```

```python
delta_Bw0_m
```

#### 외팔보 $L$ 지점에 가해지는 반력 $R$에 의한 끝점의 처짐
Deflection of a cantilever's end point by reaction force $R$ at $L$

p. 279 Table 6.2

$$
\delta_{BR} = \frac{RL^3}{3EI}
$$

```python
delta_BR_m = (R_B_N * L_AB_m ** 3 / (3 * E_Pa * I_m4))
```

```python
delta_BR_m
```

#### 두 처짐을 중첩함
Superpose two $\delta$'s

```python
super_eq = sy.Eq(delta_Bw0_m + delta_BR_m, 0)
```

```python
super_eq
```

#### $R$에 관하여 풂
Solve for $R$

```python
R_B_N_sol = sy.solve(super_eq, R_B_N)[0]
```

```python
R_B_N_sol
```

### 평형방정식
Equilibrium equation

수직방향
Vertical direction

$$
R_A + R_B + w_0 L = 0
$$

```python
fy_eq = sy.Eq(R_A_N + R_B_N + w0_N_m * L_AB_m)
```

```python
fy_eq
```

```python
fy_eq.subs(R_B_N, R_B_N_sol)
```

```python
R_A_N_sol = sy.solve(fy_eq.subs(R_B_N, R_B_N_sol), R_A_N)[0]
```

```python
R_A_N_sol
```

$B$점 중심 회전방향
Rotation direction around $B$

$$
M_A - R_A \cdot L - P_N \cdot (L - a)=0
$$

```python
m_eq = sy.Eq(M_A_Nm - R_A_N * L_AB_m - sy.Rational(1, 2) * w0_N_m * L_AB_m ** 2)
```

```python
m_eq
```

```python
sy.simplify(m_eq.subs(R_A_N, R_A_N_sol))
```

```python
M_A_Nm_sol = sy.solve(m_eq.subs(R_A_N, R_A_N_sol), M_A_Nm)[0]
```

```python
sy.simplify(M_A_Nm_sol)
```


13.

Computer Lab – Chemical Kinetics 1

Goal: The purpose of this lab is to become familiar with modeling a simple ligand binding reaction.

Introduction

Consider a binding reaction where ligand ($L$) binds to a protein receptor ($R$) to form a loosely bound encounter complex ($L\cdot R$), and finally a tightly bound product ($P$) with the forward and backward rate constants $k_{f}$ and $k_{b}$, respectively.1

$$L + R \underset{k_{b1}}{\stackrel{k_{f1}}{⥂}} L\cdot R \stackrel{k_{f2}}{\rightarrow} P \tag{1}$$

First, we need to write out the elementary rate equations:

\begin{align*}
    \\
    \frac{d[L]}{dt} &= \frac{d[R]}{dt} = -k_{f1}[L][R] + k_{b1}[L\cdot R] \tag{1.1}\\
    \frac{d[L\cdot R]}{dt} &= k_{f1}[L][R] -k_{b1}[L\cdot R] - k_{f2}[L\cdot R] \tag{1.2}\\
    \frac{d[P]}{dt} &=  k_{f2}[L\cdot R] \tag{1.3}
    \\
\end{align*}

Next, we apply a steady state approximation on $d[L\cdot R]/dt$:

\begin{equation}
k_{b1}[L\cdot R] = k_{f1}[L][R] - k_{f2}[L\cdot R]
 \implies \boxed{[L\cdot R] = \left( \frac{k_{f1}}{k_{b1}+k_{f2}} [L][R]\right)}
\end{equation}

Now, we take $[L\cdot R]$ and substitute into equations ${1.1}$ and ${1.3}$.

\begin{align}
    \frac{d[L]}{dt} &= \frac{d[R]}{dt} = -k_{f1}[L][R] + k_{b1}\left(\frac{k_{f1}}{k_{b1}+k_{f2}} [L][R] \right) \tag{1.1a}\\
    \frac{d[P]}{dt} &=  k_{f2} \left( \frac{k_{f1}}{k_{b1}+k_{f2}} [L][R]\right) \tag{1.3a}
\end{align}

Note that this last kinetic step is “one-way”, which violates thermodynamics (!). Don’t worry about this yet (we’re getting to kinetics soon in the lecture). For now, suffice it to say that here we invoke the assumption that the back rate is very small compared to forward rate.↩

Let’s walk through the steps of how to build a model of this receptor-ligand reaction using Python.

Procedure

Part I.

Set initial concentration of ligand to 10.0 and receptor to 5.0

Set rate_eqs=ODE_eqs_part_I

set “Forward Rate Parameter” ($k_{f1}$) value to 0.02

set “Reverse Rate Parameter” ($k_{b1}$) value to 0.01

Run the simulation and save the graph.

Part II.

Repeat simulation for following values:

Add "k-prod" ($k_{f2}$) and set value to 0.05.

Set rate_eqs=ODE_eqs_part_II

Run the simulation and save the graph.

Does the reaction reach a steady state?

If not, increase simulation time to 100

Repeat simulation for following values:

“Forward Rate Parameter” ($k_{f1}$): 0.01 , “Reverse Rate Parameter” ($k_{b1}$): 0.02 , “k-prod” ($k_{f2}$): 0.05

“Forward Rate Parameter” ($k_{f1}$): 0.02, “Reverse Rate Parameter” ($k_{b1}$): 0.01 , “k-prod” ($k_{f2}$): 0.5

```python
import numpy as np
import pandas as pd
```

```python
def ODE_eqs_part_I(C, t, k):
    r"""
    :math:`L + R \underset{k_{b1}}{\stackrel{k_{f1}}{⥂}} L\cdot R`

    Args:
        C(list): collection of concentrations for each species [L, R, LR]
        t(float): iterate over some time-course
        k(list): collection of rate constants [kf1, kb1]
    """

    kf1, kb1 = k[0], k[1]
    dC1dt = -kf1*C[0]*C[1]+kb1*C[2]
    dC0dt = dC1dt
    dC2dt = -dC1dt
    return [dC0dt, dC1dt, dC2dt]

def ODE_eqs_part_II(C, t, k):
    r"""See eqs 1.1, 1.2 and 1.3
    :math:`L + R \underset{k_{b1}}{\stackrel{k_{f1}}{⥂}} L\cdot R \stackrel{k_{f2}}{\rightarrow} P`

    Args:
        C(list): collection of concentrations for each species [L, R, LR, P]
        t(float): iterate over some time-course
        k(list): collection of rate constants [kf1, kb1, kf2]
    """

    kf1, kb1, kf2 = k[0], k[1], k[2]
    dC1dt = -kf1*C[0]*C[1]+kb1*C[2]
    dC0dt = dC1dt
    dC2dt = k[0]*C[0]*C[1]-k[1]*C[2]-k[2]*C[2]
    dC3dt = kf2*C[2]
    return [dC0dt, dC1dt, dC2dt, dC3dt]

def ODE_eqs_part_II_SS(C, t, k):
    r"""See eqs 1.2, 1.1a and 1.3a
    Using the steady state approximation on dLR/dt.
    :math:`L + R \underset{k_{b1}}{\stackrel{k_{f1}}{⥂}} L\cdot R \stackrel{k_{f2}}{\rightarrow} P`

    Args:
        C(list): collection of concentrations for each species [L, R, LR, P]
        t(float): iterate over some time-course
        k(list): collection of rate constants [kf1, kb1, kf2]
    """

    kf1, kb1, kf2 = k[0], k[1], k[2]
    dC1dt = -kf1*C[0]*C[1]+kb1*(kf1/(kb1+kf2)*C[0]*C[1])
    dC0dt = dC1dt
    dC2dt = k[0]*C[0]*C[1]-k[1]*C[2]-k[2]*C[2]
    dC3dt = kf2*(kf1/(kb1+kf2)*C[0]*C[1])
    return [dC0dt, dC1dt, dC2dt, dC3dt]

def simulate(steps, simulation_time, parameters, rate_eqs):
    """Method for integrating the ODEs with SciPy odeint
    https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.odeint.html

    Args:
        steps(int): number of iterations
        simulation_time(int): duration of simulation (units: s)
        parameters(dict): {'species': ['L', 'R', 'LR'],\
                          'k': [kf1, kb1, kf2, kb2, ...],\  # rate constants
                          'InitConc': [L, R, P, ...]}       # initial concentrations (units: M)
        rate_eqs(function): method containing the ordinary differential equations
    Returns:
        Pandas DataFrame, plot
    """

    from scipy.integrate import odeint
    print(f"Number of species: {len(parameters['InitConc'])}")
    time = np.linspace(0, simulation_time, steps)
    C = odeint(rate_eqs, parameters["InitConc"], time, (parameters["k"],)).transpose()
    data = {"time": time, **{"%s"%s: C[i] for i,s in enumerate(parameters["species"])}}
    df = pd.DataFrame(data)
    plot = df.plot(x="time", y=parameters["species"], figsize=(10, 4),
                   xlim=(data["time"][0], data["time"][-1]))
    return df,plot

```

```python
parameters = {"species": ["L","R","LR"],
             "InitConc": [10, 5, 0],
              #          [kf1, kb1, kf2]
             "k":        [0.02, 0.01, "NaN"]}
#print(pd.DataFrame(parameters))
df,plot = simulate(steps=1000, simulation_time=10,
                   parameters=parameters, rate_eqs=ODE_eqs_part_I)
plot = plot.set_title(r"$L + R ⥂ L\cdot R$")
fig = plot.get_figure()
#fig.savefig("figure.png")
df
```

```python
parameters = {"species": ["L","R", "LR", "P"],
             "InitConc": [10, 5, 0, 0],
             #            [kf1, kb1, kf2, ...]
             "k":        [0.02, 0.01, 0.05, "NaN"]}
#print(pd.DataFrame(parameters))
df,plot = simulate(steps=1000, simulation_time=100,
                   parameters=parameters, rate_eqs=ODE_eqs_part_II)
plot = plot.set_title(r"$L + R ⥂ L\cdot R \rightarrow P$")
fig = plot.get_figure()
#fig.savefig("figure.png")
df
```

```python
#“Forward Rate Parameter” ( 𝑘𝑓1 ): 0.01 , “Reverse Rate Parameter” ( 𝑘𝑏1 ): 0.02 , “k-prod” ( 𝑘𝑓2 ): 0.05
parameters = {"species": ["L","R", "LR", "P"],
             "InitConc": [10, 5, 0, 0],
             #           [kf1, kb1, kf2, ...]
             "k":        [0.01, 0.02, 0.05, "NaN"]}
df,plot = simulate(steps=1000, simulation_time=100,
                   parameters=parameters, rate_eqs=ODE_eqs_part_II)
plot = plot.set_title(r"$L + R ⥂ L\cdot R \rightarrow P$")
fig = plot.get_figure()
#fig.savefig("figure.png")
df
```

```python
#“Forward Rate Parameter” ( 𝑘𝑓1 ): 0.02, “Reverse Rate Parameter” ( 𝑘𝑏1 ): 0.01 , “k-prod” ( 𝑘𝑓2 ): 0.5
parameters = {"species": ["L","R", "LR", "P"],
             "InitConc": [10, 5, 0, 0],
             "k":        [0.02, 0.01, 0.5, "NaN"]}
df,plot = simulate(steps=1000, simulation_time=100,
                   parameters=parameters, rate_eqs=ODE_eqs_part_II)
plot = plot.set_title(r"$L + R ⥂ L\cdot R \rightarrow P$")
fig = plot.get_figure()
#fig.savefig("figure.png")
df
```

Bonus

This same reaction can be used to describe Michaelis-Menten enzyme kinetics, in which substrate (S) binds a protein enzyme (E) to form an encounter complex (E∙S), and then chemically converts to product (E + P).

$$E + S \underset{k_{b1}}{\stackrel{k_{f1}}{⥂}} E\cdot S \stackrel{k_{cat}}{\to} E + P$$

Suppose an enzyme has kf1 = 107 s-1, kb1 = 106 s-1 and kcat = 104 s-1. By adjusting parameters in the function above, which of these rates constants would you say most sensitively controls the overall rate of catalysis?

```python

```


```python
# Add graph and math features
# 그래프, 수학 기능 추가
import pylab as py

# scipy.optimize.newton()
import scipy.optimize as so

```

```python
# symbolic processor
# 기호처리기
import sympy as sym
import sympy.utilities as su
sym.init_printing()

```

# 복소근과 뉴튼 랩슨법
Newton Rapson Method and Complex Roots

## A polynomial with complex roots
복소근을 갖는 다항식 예

The following video is about applying Newton Raphson method to find complex roots. (26m05s)

아래 비디오는 뉴튼 랩슨법으로 복소근을 찾는 경우에 관한 것이다. (26m05s)

[](https://youtu.be/-RdOwhmqP5s)

```python
z = sym.symbols('z', complex=True)

```

From the video, let's think about the following polynomial.

영상의 여러 다항식 가운데 다음을 생각해 보자.

```python
P_z = z ** 5 + z ** 2 - z + 1
P_z

```

Its derivative would be as follows.

그 미분은 다음과 같을 것이다.

```python
dP_dz = P_z.diff(z)
dP_dz

```

Let's make python functions.
파이썬 함수를 생성해 보자.

```python
p = su.lambdify(z, P_z)

```

```python
assert p(-1) == P_z.subs({z:-1})

```

```python
dp_dz = su.lambdify(z, dP_dz)

```

```python
assert dp_dz(-1) == dP_dz.subs({z:-1})

```

Let's visualize the complex function `p(z)`.

해당 복소 함수 `p(z)` 를 시각화 해 보자.

```python
x = py.linspace(-3, 3, 150+1)
y = py.linspace(-2, 2, 100+1)
X, Y = py.meshgrid(x, y)
Z = X + Y * 1.0j
P = p(Z)

```

```python
cmap = "viridis"
levels = 32

```

Let's take a look at the real and imaginary parts of $P(z)$.
$P(z)$ 의 실수부와 허수부를 살펴보자.

```python
fig, ax = py.subplots(1, 2, figsize=(20, 5))
c0 = ax[0].pcolor(X, Y, P.real, cmap=cmap)
py.colorbar(c0, ax=ax[0])
ax[0].contour(X, Y, P.real, cmap="jet", levels=levels)
ax[0].axis("equal")
py.xlabel("$real(z)$")
py.ylabel("$imag(z)$")
py.title(r"$ real\left( P(z) \right) $");

c1 = ax[1].pcolor(X, Y, P.imag, cmap=cmap)
py.colorbar(c1, ax=ax[1])
ax[1].contour(X, Y, P.imag, cmap="jet", levels=levels)
ax[1].axis("equal");
py.xlabel("$real(z)$")
py.ylabel("$imag(z)$")
py.title(r"$ imag\left( P(z) \right) $");

```

What about the absolute values of $P(z)$?
$P(z)$ 의 절대값은 어떤가?

```python
log_abs_P = py.log(abs(P))

```

```python
fig, ax = py.subplots(figsize=(18, 10))

c_abs = ax.pcolor(X, Y, log_abs_P, cmap=cmap)
py.colorbar(c_abs, ax=ax)
ax.contour(X, Y, log_abs_P, cmap="jet", levels=levels)
ax.axis("equal");
py.xlabel("$real(z)$")
py.ylabel("$imag(z)$")
py.title(r"$ \left| P(z) \right| $");

```

## Finding complex roots using Newton-Raphson method
복소근을 뉴튼랩슨법으로 찾기

```python
class LogAttempts():
    def __init__(self):
        self.z_dict = {}
        self.z_list = []
    def init(self, z0:complex):
        self.z_dict[z0] = []
        self.z_list = self.z_dict[z0] = []
    def f(self, z:complex) -> complex:
        self.z_list.append(z)
        return p(z)

```

```python
logger = LogAttempts()

```

### Various initial conditions
다양한 초기 조건

```python
for z_initial in (-2.0 + 1.0j, -1.0 + 1.0j, 2.0j, 2.0 + 1.0j):

    logger.init(z_initial)
    f_z = logger.f

    root = so.newton(f_z, z_initial, fprime=dp_dz)

```

```python
fig, ax = py.subplots(figsize=(18, 10))

c_abs = ax.pcolor(X, Y, log_abs_P, cmap=cmap, alpha=0.75)
py.colorbar(c_abs, ax=ax)
ax.contour(X, Y, log_abs_P, cmap="jet", levels=levels)

for z_initial in logger.z_dict:
    z_array = py.array(logger.z_dict[z_initial])
    z_real = z_array.real
    z_imag = z_array.imag

    ax.plot(z_real, z_imag, '.-', label=f"{z_initial}")

ax.axis("equal");
ax.legend(loc=0)
py.xlabel("$real(z)$")
py.ylabel("$imag(z)$")
py.title(r"$ \left| P(z) \right| $");

```

## Another Example
다른 예

```python
r = 10.0

```

```python
Q_z = - (r * r - z * z) ** 0.5 + r * 0.5
Q_z

```

Its derivative would be as follows.

그 미분은 다음과 같을 것이다.

```python
dQ_dz = Q_z.diff(z)
dQ_dz

```

```python
q = su.lambdify(z, Q_z)

```

```python
dq_dz = su.lambdify(z, dQ_dz)

```

```python
x = py.linspace(-r*2, r*2, 100+1)
y = py.linspace(-r, r, 100+1)
X, Y = py.meshgrid(x, y)
Z = X + Y * 1.0j
Q = q(Z)

```

```python
log_abs_Q = py.log(abs(Q))

```

```python
fig, ax = py.subplots(figsize=(18, 8))

c_abs = ax.pcolor(X, Y, log_abs_Q, cmap=cmap)
py.colorbar(c_abs, ax=ax)
ax.contour(X, Y, log_abs_Q, cmap="jet", levels=levels)
ax.axis("equal");
py.xlabel("$real(z)$")
py.ylabel("$imag(z)$")
py.title(r"$ \left| Q(z) \right| $");

```

```python
class LogAttemptsQ(LogAttempts):
    def __init__(self):
        # Understanding Python super() with __init__() methods, https://stackoverflow.com/questions/576169
        super(LogAttemptsQ, self).__init__()

    def f(self, z:complex) -> complex:
        self.z_list.append(z)
        result = q(z)
        assert 100 > abs(result), f"abs(result) = {abs(result)}"
        return result

```

```python
logger_q = LogAttemptsQ()

```

```python
for z_initial in (-1.5 * r + 0.01j, -0.5 * r + 1.0j, 0.5 * r + 1.0j):

    logger_q.init(z_initial)
    f_z = logger_q.f

    try:
        root = so.newton(f_z, z_initial, fprime=dq_dz)
    except RuntimeError as e:
        print(e)

```

```python
fig, ax = py.subplots(figsize=(18, 8))

c_abs = ax.pcolor(X, Y, log_abs_Q, cmap=cmap, alpha=0.75)
py.colorbar(c_abs, ax=ax)
ax.contour(X, Y, log_abs_Q, cmap="jet", levels=levels)

for z_initial in logger_q.z_dict:
    z_array = py.array(logger_q.z_dict[z_initial])
    z_real = z_array.real
    z_imag = z_array.imag

    ax.plot(z_real, z_imag, '.-', label=f"{z_initial}")

ax.axis("equal");
ax.legend(loc=0)
py.xlabel("$real(z)$")
py.ylabel("$imag(z)$")
py.title(r"$ \left| Q(z) \right| $");

```

## Final Bell
마지막 종

```python
# stackoverfow.com/a/24634221
import os
os.system("printf '\a'");

```

```python

```


# Tensorflow implementation of temporal parallelisation of Kalman filter and smoother

This notebook implements the algorithms in the paper by S. Särkkä and Á. García-Fernández by leveraging the [Tensorflow](https://www.tensorflow.org/) framework.

## Author: Adrien Corenflos

## Reference:
Simo Särkkä and Ángel F. García-Fernández (2021). Temporal Parallelization of Bayesian Smoothers.
IEEE Transactions on Automatic Control, Volume: 66, Issue: 1, Pages 299-306.
arXiv: https://arxiv.org/abs/1905.13002
DOI: https://doi.org/10.1109/TAC.2020.2976316

- If you are on Colab don't forget to switch the Runtime (in the tab with the same name) type to GPU.
- If you are using a personal machine, to install tensorflow with GPU support please read TensorFlow [user guide](https://www.tensorflow.org/install).

### Imports

```
# Python specific imports that will make our job easier and our code prettier
from collections import namedtuple
from functools import partial
import math
import time
from tqdm.auto import trange, tqdm

# TF specific imports that we will use to code the logic
from tensorflow import function
import tensorflow as tf
import tensorflow_probability as tfp

# Auxiliary libraries that we will use to report results and create the data
import matplotlib.pyplot as plt
import numpy as np
import scipy as sc
```

### Utilities
We define some utilities that will make the code more readable

```
mm = tf.linalg.matmul
mv = tf.linalg.matvec
```

### Model
In the vein of Example 3.6 in the book Bayesian Filtering and Smoothing (S. Särkkä) we consider a simple car tracking model where the car is characterised by its coordinates and directional speeds $\mathbf{z} = [x, y, \dot{x}, \dot{y}]^\top$ for which only the positional components are observed with some uncertainty. Formally this corresponds to the following state-space model:

\begin{equation}
\begin{split}
  x_k &=  F \, x_{k-1} + q_{k-1},\\
  y_k &=  H \, x_k + r_k,
\end{split}
\end{equation}

where $q_k \sim \mathrm{N}(0,Q)$, $r_k \sim \mathrm{N}(0,R)$, and
 \begin{equation}
  F = \begin{pmatrix}
    1 & 0 & \Delta t & 0 \\
    0 & 1 & 0 & \Delta t \\
    0 & 0 & 1 & 0 \\
    0 & 0 & 0 & 1
  \end{pmatrix}
  ,\quad
  Q = q \, \begin{pmatrix}
     \frac{\Delta t^3}{3} & 0 & \frac{\Delta t^2}{2} & 0 \\
     0 & \frac{\Delta t^3}{3} & 0 & \frac{\Delta t^2}{2} \\
     \frac{\Delta t^2}{2} & 0 & \Delta t & 0     \\
     0 & \frac{\Delta t^2}{2} & 0 & \Delta t
  \end{pmatrix},
\end{equation}

along with
\begin{equation}
  H = \begin{pmatrix}
    1 & 0 & 0 & 0 \\
    0 & 1 & 0 & 0
  \end{pmatrix}, \qquad
  R = \begin{pmatrix}
    \sigma^2 & 0  \\
    0 & \sigma^2
  \end{pmatrix}.
\end{equation}

which translates into the following code:

```
StateSpaceModel = namedtuple("StateSpaceModel", ["F", "H", "Q", "R", "m0", "P0", "xdim", "ydim"])
```

```
def make_car_tracking_model(q: float, dt: float, r: float, m0: np.ndarray, P0: np.ndarray):
    F = np.eye(4) + dt * np.eye(4, k=2)
    H = np.eye(2, 4)
    Q = np.kron(np.array([[dt**3/3, dt**2/2],
                          [dt**2/2, dt]]),
                np.eye(2))
    R = r ** 2 * np.eye(2)
    return StateSpaceModel(F, H, q * Q, R, m0, P0, m0.shape[0], H.shape[0])

```

```
car_tracking_model = make_car_tracking_model(q=1., dt=0.1, r=0.5,
                                             m0=np.array([0., 0., 1., -1.]),
                                             P0=np.eye(4))
```

So that we can now write some generic code to sample from a SSM:

```
def get_data(model: StateSpaceModel, T:float, seed:int=0):
    # We first generate the normals we will be using to simulate the SSM:
    rng = np.random.RandomState(seed)
    normals = rng.randn(1 + T, model.xdim + model.ydim)

    # Then we allocate the arrays where the simulated path and observations will
    # be stored:
    xs = np.empty((T, model.xdim))
    ys = np.empty((T, model.ydim))

    # So that we can now run the sampling routine:
    Q_chol = sc.linalg.cholesky(model.Q, lower=True)
    R_chol = sc.linalg.cholesky(model.R, lower=True)
    P0_chol = sc.linalg.cholesky(model.P0, lower=True)
    x = model.m0 + P0_chol @ normals[0, :model.xdim]
    for i, norm in enumerate(normals[1:]):
        x = model.F @ x + Q_chol @ norm[:model.xdim]
        y = model.H @ x + R_chol @ norm[model.xdim:]
        xs[i] = x
        ys[i] = y
    return xs, ys
```

```
log10T = 4
```

```
true_xs, ys = get_data(car_tracking_model, 10 ** log10T, 0)
```

This corresponds to the following car positions in space (truncated at 100 timesteps):

```
fig, ax = plt.subplots(figsize=(7, 7))
ax.plot(true_xs[:100, 0], true_xs[:100, 1], label="True State", color="b")
ax.scatter(*ys[:100].T, label="Observations", color="r")
_ = plt.legend()
```

### Sequential Kalman filter and smoother
We can now write the Kalman filter and smoother sequential routines in a TensorFlow fashion. For more information on these algorithms, we refer the reader to the book:

Särkkä, S. (2013). Bayesian filtering and smoothing. Cambridge University Press.

For which an online version is also available on his [webpage](https://users.aalto.fi/~ssarkka/).

#### Filter:

```
@partial(tf.function, experimental_relax_shapes=True)
def kf(model, observations):
    def body(carry, y):
        m, P = carry
        m = mv(model.F, m)
        P = model.F @ mm(P, model.F, transpose_b=True)+ model.Q

        S = model.H @ mm(P, model.H, transpose_b=True) + model.R

        chol = tf.linalg.cholesky(S)
        Kt = tf.linalg.cholesky_solve(chol, model.H @ P)

        m = m + mv(Kt, y - mv(model.H, m), transpose_a=True)
        P = P - mm(Kt, S, transpose_a=True) @ Kt
        return m, P

    fms, fPs = tf.scan(body, observations, (model.m0, model.P0))
    return fms, fPs

```

```
with tf.device('/CPU:0'):
    fms, fPs = kf(car_tracking_model, ys[:100])
```

#### Smoother:

```
@partial(tf.function, experimental_relax_shapes=True)
def ks(model, ms, Ps):
    def body(carry, inp):
        m, P = inp
        sm, sP = carry

        pm = mv(model.F, m)
        pP = model.F @ mm(P, model.F, transpose_b=True) + model.Q

        chol = tf.linalg.cholesky(pP)
        Ct = tf.linalg.cholesky_solve(chol, model.F @ P)

        sm = m + mv(Ct, (sm - pm), transpose_a=True)
        sP = P + mm(Ct, sP - pP, transpose_a=True) @ Ct
        return sm, sP

    (sms, sPs) = tf.scan(body, (ms[:-1], Ps[:-1]), (ms[-1], Ps[-1]), reverse=True)
    sms = tf.concat([sms, tf.expand_dims(ms[-1], 0)], 0)
    sPs = tf.concat([sPs, tf.expand_dims(Ps[-1], 0)], 0)
    return sms, sPs

```

```
with tf.device('/CPU:0'):
    sms, sPs = ks(car_tracking_model, fms, fPs)
```

So that we can now plot the result agains the true state and the observations:

```
fig, ax = plt.subplots(figsize=(7, 7))
ax.plot(true_xs[:100, 0], true_xs[:100, 1], label="True State", color="b")
ax.plot(fms[:100, 0], fms[:100, 1], label="Filtered", color="g", linestyle="--")
ax.plot(sms[:100, 0], sms[:100, 1], label="Smoothed", color="k", linestyle="--")
ax.scatter(*ys[:100].T, label="Observations", color="r")
_ = plt.legend()
```

#### Filter-Smoother
We can finally combine the filter and the smoother into a single filter-smoother routine:

```
@partial(tf.function, experimental_relax_shapes=True)
def kfs(model, observations):
    return ks(model, *kf(model, observations))
```

### Parallel Kalman filter and smoother
Now that we have a baseline for the sequential model, we can go on and implement the parallel version of the routine, leveraging the associative_scan function already offered by TensorFlow Probability ([see this](https://www.tensorflow.org/probability/api_docs/python/tfp/math/scan_associative)). To do this we will need to do a bit more work, but before this let us offer some intuition around what exactly is happening.

#### Prefix-Sum / Associative Scan
Consider the simple problem of computing the cumulative sum of a given array of floats:

```
array_of_floats = np.arange(2 ** 4, dtype=np.float32)
```

A simple way to do this would be to iterate (scan) through the array from left to right and accumulate the result as we go:

```
def cumulative_sum(arr):
    # obviously numpy implements cumulative sums already, but this is for illustrative purposes.
    res = np.empty_like(arr)
    temp = 0.
    for i, val in enumerate(arr):
        temp += val
        res[i] = temp
    return res
```

```
cumulative_sum(array_of_floats)
```

    array([  0.,   1.,   3.,   6.,  10.,  15.,  21.,  28.,  36.,  45.,  55.,
            66.,  78.,  91., 105., 120.], dtype=float32)

However, the addition operation has the fundamental property to be associative: $(a+b) + c = a + (b + c)$, under which accumulative scans have been proven to be parallelizable (Blelloch, 1990, see the paper for a reference) up to a span complexity of $O(log(N))$ in the context where many processors are available. If we were to write the algorithm in pure python, it would look like the below:

```
def prefix_sum(arr):
    # This is for illustration purposes only, and for instance doesn't take into
    # account the case when the array size is not a pure power of 2
    res = np.copy(arr)
    n = res.shape[0]
    log_n  = np.log2(n).astype(int)

    # Up pass
    for d in range(log_n):
        # this loop can't be done in parallel so it defines the span complexity under
        # parallelization
        for i in range(0, n, 2 ** (d + 1)):
            # this should be done in parallel, therefore would not be taken
            # into account in the span complexity provided we have at least
            # n / 2^{d+1} cores on our GPU
            i1 = i + 2 ** d - 1
            i2 = i + 2 ** (d + 1) - 1
            res[i2] += res[i1]

    res[-1] = 0

    # Down pass
    for d in range(log_n-1, -1, -1):
        # this loop can't be done in parallel so it defines the span complexity under
        # parallelization
        for i in range(0, n, 2 ** (d + 1)):
            # this should be done in parallel, therefore would not be taken
            # into account in the span complexity provided we have at least
            # n / 2^{d+1} cores on our GPU
            i1 = i + 2 ** d - 1
            i2 = i + 2 ** (d + 1) - 1

            res[i1], res[i2] = res[i2], res[i1] + res[i2]

    # Extra pass
    res += arr

    return res
```

```
prefix_sum(array_of_floats)
```

    array([  0.,   1.,   3.,   6.,  10.,  15.,  21.,  28.,  36.,  45.,  55.,
            66.,  78.,  91., 105., 120.], dtype=float32)

#### Filter:

In the case of Kalman filters, one can think of the addition as being the bayesian map operator coming from the fact of "adding" one observation to the model:

\begin{equation}
    p(x_k \mid y_{1:k-1})
    = \int p(x_k \mid x_{k-1})
       \, p(x_{k-1} \mid y_{1:k-1}) \, \mathrm{d}x_{k-1},
\end{equation}

\begin{equation}
    p(x_k \mid y_{1:k})
    = \frac{p(y_k \mid x_k)
       \, p(x_k \mid y_{1:k-1})}
       {\int p(y_k \mid x_k)
          \, p(x_k \mid y_{1:k-1}) \, \mathrm{d}x_k}.
\end{equation}

As it is, this operation on the random variables $x_k$'s is not associative as switching around the observations will result in a completely different result. However S. Särkkä and Á. García-Fernández showed that these could be rewritten in terms of the associative operator given by:

\begin{align*}
    \left(f_{i},g_{i}\right)\otimes\left(f_{j},g_{j}\right) & =\left(f_{ij},g_{ij}\right),
\end{align*}
\begin{align*}
    f_{ij}\left(x\mid z\right) & =\frac{\int g_{j}\left(y\right)f_{j}\left(x\mid y\right)f_{i}\left(y\mid z\right)\mathrm{d}y}{\int g_{j}\left(y\right)f_{i}\left(y\mid z\right)\mathrm{d}y}, \\
    g_{ij}\left(z\right) & =g_{i}\left(z\right)\int g_{j}\left(y\right)f_{i}\left(y\mid z\right)\mathrm{d}y.
\end{align*}

This could in turn be rewritten in terms of the linear equations of the state parameters, which leads to the below algorithm:

##### Initialisation of the associative elements:
We write the function that will initialise them (in parallel) based on the data and the observations:

```
@partial(tf.function, experimental_relax_shapes=True)
def first_filtering_element(model, y):

    m1 = mv(model.F, model.m0)
    P1 = model.F @ mm(model.P0, model.F, transpose_b=True) + model.Q
    S1 = model.H @ mm(P1, model.H, transpose_b=True) + model.R
    S1_chol = tf.linalg.cholesky(S1)
    K1t = tf.linalg.cholesky_solve(S1_chol, model.H @ P1)

    A = tf.zeros_like(model.F)
    b = m1 + mv(K1t, y - mv(model.H, m1), transpose_a=True)
    C = P1 - mm(K1t, S1, transpose_a=True) @ K1t

    S = model.H @ mm(model.Q, model.H, transpose_b=True) + model.R
    chol = tf.linalg.cholesky(S)
    HF = model.H @ model.F
    eta = mv(HF,
             tf.squeeze(tf.linalg.cholesky_solve(chol, tf.expand_dims(y, 1)), 1),
             transpose_a=True)
    J = mm(HF, tf.linalg.cholesky_solve(chol, model.H @ model.F), transpose_a=True)
    return A, b, C, J, eta

@partial(tf.function, experimental_relax_shapes=True)
def generic_filtering_element(model, y):
    S = model.H @ mm(model.Q, model.H, transpose_b=True) + model.R
    chol = tf.linalg.cholesky(S)

    Kt = tf.linalg.cholesky_solve(chol, model.H @ model.Q)
    A = model.F - mm(Kt, model.H, transpose_a=True) @ model.F
    b = mv(Kt, y, transpose_a=True)
    C = model.Q - mm(Kt, model.H, transpose_a=True) @ model.Q

    HF = model.H @ model.F
    eta = mv(HF,
             tf.squeeze(tf.linalg.cholesky_solve(chol, tf.expand_dims(y, 1)), 1),
             transpose_a=True)

    J = mm(HF, tf.linalg.cholesky_solve(chol, HF), transpose_a=True)
    return A, b, C, J, eta
```

And we can combine them properly:

```
@partial(tf.function, experimental_relax_shapes=True)
def make_associative_filtering_elements(model, observations):
    first_elems = first_filtering_element(model, observations[0])
    generic_elems = tf.vectorized_map(lambda o: generic_filtering_element(model, o),
                                      observations[1:], fallback_to_while_loop=False)
    return tuple(tf.concat([tf.expand_dims(first_e, 0), gen_es], 0)
                 for first_e, gen_es in zip(first_elems, generic_elems))

```

##### Associative filtering operator
We now define the associative filtering operator

```
@partial(tf.function, experimental_relax_shapes=True)
def filtering_operator(elems):
    elem1, elem2 = elems

    A1, b1, C1, J1, eta1 = elem1
    A2, b2, C2, J2, eta2 = elem2
    dim = A1.shape[0]
    I = tf.eye(dim, dtype=A1.dtype, )

    temp = tf.linalg.solve(I + C1 @ J2, tf.transpose(A2), adjoint=True)
    A = mm(temp, A1, transpose_a=True)
    b = mv(temp, b1 + mv(C1, eta2), transpose_a=True) + b2
    C = mm(temp, mm(C1, A2, transpose_b=True), transpose_a=True) + C2

    temp = tf.linalg.solve(I + J2 @ C1, A1, adjoint=True)
    eta = mv(temp, eta2 - mv(J2, b1), transpose_a=True) + eta1
    J = mm(temp, J2 @ A1, transpose_a=True) + J1

    return A, b, C, J, eta
```

##### Parallel routine
And we can now combine all these to compute the filtering result in parallel:

```
@partial(tf.function, experimental_relax_shapes=True)
def pkf(model, observations, max_parallel=10000):
    initial_elements = make_associative_filtering_elements(model, observations)
    def vectorized_operator(a, b):
        return tf.vectorized_map(filtering_operator, (a, b), fallback_to_while_loop=False)
    final_elements = tfp.math.scan_associative(vectorized_operator,
                                               initial_elements,
                                               max_num_levels=math.ceil(math.log2(max_parallel)))
    return final_elements[1], final_elements[2]
```

That we can compare with the sequential one:

```
with tf.device('/GPU:0'):
    pfms, pfPs = pkf(car_tracking_model, ys[:100])
```

and we get a maximum absolute difference between the two methods of

```
np.abs(pfms - fms).max()
```

    5.861977570020827e-14

#### Smoother:

The parallel implementation of the smoother follows similar principles and we refer the reader to the paper for more details about the associative formulation.

##### Initialisation of the associative elements:
We now write the function that will initialise them (in parallel) based on the data and the observations:

```
@partial(tf.function, experimental_relax_shapes=True)
def last_smoothing_element(m, P):
    return tf.zeros_like(P), m, P

@partial(tf.function, experimental_relax_shapes=True)
def generic_smoothing_element(model, m, P):
    Pp = model.F @ mm(P, model.F, transpose_b=True) + model.Q

    chol = tf.linalg.cholesky(Pp)
    E  = tf.transpose(tf.linalg.cholesky_solve(chol, model.F @ P))
    g  = m - mv(E @ model.F, m)
    L  = P - E @ mm(Pp, E, transpose_b=True)
    return E, g, L
```

And we can combine them properly:

```
@partial(tf.function, experimental_relax_shapes=True)
def make_associative_smoothing_elements(model, filtering_means, filtering_covariances):
    last_elems = last_smoothing_element(filtering_means[-1], filtering_covariances[-1])
    generic_elems = tf.vectorized_map(lambda o: generic_smoothing_element(model, o[0], o[1]),
                                      (filtering_means[:-1], filtering_covariances[:-1]),
                                      fallback_to_while_loop=False)
    return tuple(tf.concat([gen_es, tf.expand_dims(last_e, 0)], axis=0)
                 for gen_es, last_e in zip(generic_elems, last_elems))
```

##### Associative smoothing operator
We now define the associative smoothing operator

```
@partial(tf.function, experimental_relax_shapes=True)
def smoothing_operator(elems):
    elem1, elem2 = elems
    E1, g1, L1 = elem1
    E2, g2, L2 = elem2

    E = E2 @ E1
    g = mv(E2, g1) + g2
    L = E2 @ mm(L1, E2, transpose_b=True) + L2

    return E, g, L
```

##### Parallel routine
And we can now combine all these to compute the smoothing result in parallel:

```
@partial(tf.function, experimental_relax_shapes=True)
def pks(model, filtered_means, filtered_covariances, max_parallel=10000):
    initial_elements = make_associative_smoothing_elements(model, filtered_means, filtered_covariances)
    reversed_elements = tuple(tf.reverse(elem, axis=[0]) for elem in initial_elements)
    def vectorized_operator(a, b):
        return tf.vectorized_map(smoothing_operator, (a, b), fallback_to_while_loop=False)
    final_elements = tfp.math.scan_associative(vectorized_operator,
                                               reversed_elements,
                                               max_num_levels=math.ceil(math.log2(max_parallel)))
    return tf.reverse(final_elements[1], axis=[0]), tf.reverse(final_elements[2], axis=[0])
```

That we can compare with the sequential one:

```
with tf.device('/GPU:0'):
    psms, psPs = pks(car_tracking_model, pfms, pfPs)
```

```
np.abs(psms - sms).max()
```

    5.861977570020827e-14

#### Filter-Smoother:
Finally we can combine all these functions into one filter-smoother routine.

```
@partial(tf.function, experimental_relax_shapes=True)
def pkfs(model, observations, max_parallel=10000):
    return pks(model, *pkf(model, observations, max_parallel), max_parallel)
```

### Runtime comparison

To compare the runtime of both methods we will compile the routines on GPU and CPU and modify the size of the observation input fed to the routines. It is noteworthy that the parallel version compilation time will depend on the "max_parallel" parameter which defines the depth of the recursion tree compiled (see the [docstring of the method](https://github.com/tensorflow/probability/blob/bf84eb67f342e332b7c09b8f435ba534cd91725e/tensorflow_probability/python/math/scan_associative.py#L210) for more details). A good rule of thumb would be to set it to the number of cores of your GPU (check it with !nvidia-smi in a code cell).

```
input_sizes = np.logspace(2, log10T, num=10, base=10).astype(int)
```

```
n_iter = 5
```

```
def get_average_runtimes(func, n_iter, car_model, observations):
    runtimes = np.empty(input_sizes.shape)
    for i, input_size in tqdm(enumerate(input_sizes), total=runtimes.shape[0]):
        observation_slice = observations[:input_size]
        _ = func(car_model, observation_slice)  # compilation run
        tic = time.time()
        for _ in trange(n_iter, leave=False):
            res = func(car_model, observation_slice)
        runtimes[i] = (time.time() - tic) / n_iter
    return runtimes
```

```
with tf.device('/CPU:0'):
    cpu_ys = tf.constant(ys)
    cpu_car_tracking_model = StateSpaceModel(*(tf.constant(v) for v in car_tracking_model))
    cpu_sequential_runtimes = get_average_runtimes(kfs, n_iter, cpu_car_tracking_model, cpu_ys)
    cpu_parallel_runtimes = get_average_runtimes(lambda *args :pkfs(*args, max_parallel=int(input_sizes[-1])),
                                                 n_iter, cpu_car_tracking_model, cpu_ys)

with tf.device('/GPU:0'):
    gpu_ys = tf.constant(ys)
    gpu_car_tracking_model = StateSpaceModel(*(tf.constant(v) for v in car_tracking_model))
    gpu_sequential_runtimes = get_average_runtimes(kfs, n_iter, gpu_car_tracking_model, gpu_ys)
    gpu_parallel_runtimes = get_average_runtimes(lambda *args :pkfs(*args, max_parallel=int(input_sizes[-1])),
                                                 n_iter, gpu_car_tracking_model, gpu_ys)
```

    HBox(children=(FloatProgress(value=0.0, max=10.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=10.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=10.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=10.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

    HBox(children=(FloatProgress(value=0.0, max=5.0), HTML(value='')))

```
pkfs.pretty_printed_concrete_signatures()
```

```
kfs.pretty_printed_concrete_signatures()
```

And we can plot these:

```
fig, axes = plt.subplots(ncols=2, figsize=(15, 6), sharex=True, sharey=True)
axes[0].loglog(input_sizes, cpu_sequential_runtimes, label="Sequential-CPU", linestyle="-.", linewidth=3)
axes[0].loglog(input_sizes, cpu_parallel_runtimes, label="Parallel-CPU", linewidth=3)
axes[0].legend()

axes[1].loglog(input_sizes, gpu_sequential_runtimes, label="Sequential-GPU", linestyle="-.", linewidth=3)
axes[1].loglog(input_sizes, gpu_parallel_runtimes, label="Parallel-GPU", linewidth=3)
_ = axes[0].set_ylabel("Average run time (seconds)")

for ax in axes:
    _ = ax.set_xlabel("Number of data points")

_ = fig.suptitle("Runtime comparison on CPU and GPU", size=15)
_ = axes[1].legend()
```

It is noteworthy that tensorflow has some multi-threading happening in the background, which is probably why we get a better performance for the parallel algorithm on CPU.

```
tf.__version__
```

    '2.4.0'

```

```


# Week 8 - Discrete Latent Variable Models and Hybrid Models Notebook

In this notebook, we will solve questions discrete latent variable models and hybrid generative models.

 - This notebook is prepared using PyTorch. However, you can use any Python package you want to implement the necessary functions in questions.
 - If the question asks you to implement a specific function, please do not use its readily available version from a package and implement it yourself.

## Question 1

Please answer the questions below:

1. Please give some examples to discrete data modalities.
1. Can we use GANs to generate discrete data points?
1. What is REINFORCE and why do we use it?
1. Please briefly explain Gumbel-Softmax by stating why do we need it and how do we use it in practice?
1. Please conceptually explain how PixelVAE works.
1. What is the novelty of $\beta$-VAE over the classical variational auto-encoder. Please briefly explain.

You can write your answer for each question in the markdown cell below:

**Please write your answer for each question here**

## Question 2

Implement the Gumbel-Softmax function. The function is characterized as below:

\begin{equation}
\hat{z} = \text{soft}\max_i \left(\frac{g_i + \log \pi}{\tau}\right)
\end{equation}

where $\pi$ are the class proabilities, $g_i$ are the i.i.d. samples from the gumbel distribution, and $\tau$ is the temperature parameter $\in (0, 1]$.

You can write additional function or functions to sample from the gumbel distribution.

You can also change the value of the random seed to see different results.

```python
import torch
torch.manual_seed(0)

batch_size = 16

# Let's assume four discrete outputs
num_classes = 4
logits = torch.randn(batch_size, num_classes)
```

```python
def gumbel_softmax(logits, temperature):
  """Applies gumbel softmax operation to the provided logits

    Args:
        logits: (N x num_classes)
        temperature: A scalar constant that determines the bias-variance tradeoff
    Returns:
        the resulting tensor from the operation
  """
  #######################
  # Write code here
  #######################
  pass
```

```python
print(gumbel_softmax(logits, temperature=0.5))
```

**Expected Output:** (Note that these are the outputs that are converted to one-hot vectors. You can choose to give outputs as softmax values as well).

```
tensor([[0., 0., 0., 1.],
        [0., 1., 0., 0.],
        [0., 0., 0., 1.],
        [0., 0., 1., 0.],
        [0., 0., 1., 0.],
        [0., 0., 0., 1.],
        [1., 0., 0., 0.],
        [1., 0., 0., 0.],
        [0., 1., 0., 0.],
        [1., 0., 0., 0.],
        [0., 1., 0., 0.],
        [0., 1., 0., 0.],
        [0., 1., 0., 0.],
        [0., 0., 1., 0.],
        [0., 0., 0., 1.],
        [0., 0., 1., 0.]])
```

**Bonus:** It is recommended for you to tinker with the temperature parameter and see how the results change.

## Question 3

Implement the loss function of VAE-GAN. You can refer to the [paper](https://arxiv.org/pdf/1512.09300.pdf) to see the motivation behind the loss function and the related equations.

The loss function of VAE-GAN consists of three parts, first one being the KL divergence loss:

\begin{equation}
\mathcal{L}_{prior} = D_{KL}(q(z|x)||p(z))
\end{equation}

where $z$ is the latent space vector from the latent distribution $p(z)$ and $x$ is the data point to be reconstructed. Typically, $z$ is sampled from $\mathcal N(0, 1)$. This term is considered as a regularizer and ensures that the distribution of the output of the encoder is similar to $\mathcal N(0, 1)$.

Second term is the reconstruction loss, but with a small twist:

\begin{equation}
\mathcal{L}^{\text{Dis}_l}_{\text{llike}} = -\mathbb{E}_{q(z|x)}[\log(p(\text{Dis}_l(x)|z)]
\end{equation}

Equation above is the log-likelihood based reconstruction loss of the original VAE, except for $x$ is replaced by $\text{Dis}_l(x)$. This is the intermediate represantation of the reconstructed version of $x \sim \text{Dec}(z)$ from the $l^{th}$ layer of the discriminator. This is to ensure that the image is not reconstructed on the pixel-level but more on a feature-level.

Finally, third part of the loss is our good old GAN loss:

\begin{equation}
\mathcal{L}_{\text{GAN}} = \log(\text{Dis}(x)) + \log(1 - \text{Dis}(\text{Gen}(z)))
\end{equation}

The final loss of the VAE-GAN is the sum of all these losses:

\begin{equation}
\mathcal{L} = \mathcal{L}_{prior} + \mathcal{L}^{\text{Dis}_l}_{\text{llike}} + \mathcal{L}_{\text{GAN}}
\end{equation}

Implement all three losses as different functions to the code cells below:

```python
mean = torch.randn(batch_size, 20)
logvar = torch.randn(batch_size, 20)
```

```python
def kl_loss(mean, logvar):
  """Calculates the KL loss based on the mean and logvar outputs of the Encoder network
  w.r.t to the Gaussian with zero mean and unit variance

    Args:
      mean: Tensor of mean values coming from the Encoder (N x D)
      logvar: Tensor of log-variance values coming from the Encoder (N x D)
    Returns:
      The resulting KL loss
  """
  #######################
  # Write code here
  #######################
  pass
```

```python
print(kl_loss(mean, logvar))
```

```python
features_org = torch.randn(batch_size, 100)
features_recon = torch.randn(batch_size, 100)

# Uncomment the line below and run the function again to see a higher reconstruction error
# features_recon = torch.normal(3, 20, batch_size, 100)
```

```python
def reconstruction_loss(features_org, features_recon):
  """Calculates the reconstruction loss with mean squared error

    Args:
      features_org: Features of the original image obtained from an intermediate layer of the discriminator
      features_recon: Features of the reconstructed image obtained from an intermediate layer of the discriminator
    Returns:
      M.S.E based reconstruction error of the features
  """
  #######################
  # Write code here
  #######################
  pass
```

```python
print(reconstruction_loss(faetures_org, features_recon))
```

```python
outputs_real = torch.randn(batch_size, 32).clip(0, 1)
outputs_fake = torch.randn(batch_size, 32).clip(0, 1)
```

```python
def gan_loss(d_real_outputs, d_fake_outputs):
  """Our good old GAN loss, doesn't need much of an explanation :)

    Args:
      d_real_outputs: Discriminator sigmoid outputs for the real data points
      d_fake_outputs: Discriminator sigmoid outputs for the fake data points
    Returns:
      The calculated GAN loss
  """
  #######################
  # Write code here
  #######################
  pass
```

```python
print(gan_loss(outputs_real, outputs_fake))
```

## Bonus

My master's thesis was a hybrid generative model and it was published in Pattern Recognition. I would like to briefly talk about it during the notebook session.

For anyone who is interested, kindly read or skim through the paper before coming to the discussion session. I leave the link to the paper [here](https://faculty.ozyegin.edu.tr/ethemalpaydin/files/2021/01/Uras_bigan_PatRec.pdf).

inzva x METU ImageLab Joint Program/Week 8 - Discrete Latent Variable Models and Hybrid Models/Week_8_Discrete_Latent_Variable_Models_and_Hybrid_Models.ipynb


```python
%matplotlib inline
%config InlineBackend.figure_format = 'retina'

%load_ext autoreload
%autoreload 2

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.pylab as pylab
from simplified_monorotor import Monorotor
import plotting
import testing
import trajectories

pylab.rcParams['figure.figsize'] = 10,10
```

# Feedforward control

The feedforward term captures the acceleration of the target path and adds to the $\bar{u}_1$ calculated by PD controller.

$$
\ddot{z} = \bar{u}_1= k_p(z_t-z)+k_d(z_t-z)+\ddot{z}
$$

#### TODO - Implement PD controller WITH feed forward acceleration

Modify `thrust_control` to incorporate the feedforward term into the PD Controller math.

$$
\begin{align}
e &= z_{\text{target}} - z_{\text{actual}} \\
\dot{e} &= \dot{z}_{\text{target}} - \dot{z}_{\text{actual}} \\
\bar{u}_1 &= k_p e + k_d \dot{e} + \ddot{z}_{\text{ff}} \\
u_1 &= m(g - \bar{u}_1)
\end{align}
$$

```python
class PDController:

    def __init__(self, k_p, k_d, m):
        self.k_p = k_p
        self.k_d = k_d
        self.vehicle_mass = m
        self.g = 9.81

    def thrust_control(self,
                z_target,
                z_actual,
                z_dot_target,
                z_dot_actual,
                z_dot_dot_ff=0.0):
        #
        # TODO
        #   modify the PD control code to incorporate
        #   the feedforward term.

        err = z_target - z_actual
        err_dot = z_dot_target - z_dot_actual
        u_bar = self.k_p * err + self.k_d * err_dot
        u = self.vehicle_mass * (self.g - u_bar)

        return u

testing.pd_controller_test(PDController, feed_forward=True)
```

#### TODO 2 - Compare trajectories with and without a feedforward term

The code below generates plots of $z$ vs $t$ for two drones. One uses FF and the other doesn't.

Run the code and compare the two trajectories. What happens if you increase the oscillation frequency to 10? What happens if you decrease it to 2?

You should notice a **lag** in the system response: the trajectory without the feedforward term should lag behind the desired trajectory in time. This effect diminishes as the oscillation frequency decreases.

```python
# This code simulates TWO drones. One uses the feed forward
# acceleration and the other doesn't. Note the difference in
# trajectories.

MASS_ERROR = 1.0
K_P = 20.0
K_D = 8.0

AMPLITUDE = 0.5
OSCILLATION_FREQUENCY = 5

PERIOD = 2 * np.pi / OSCILLATION_FREQUENCY

# preparation (TWO drones to compare)
drone = Monorotor()
ff_drone = Monorotor()
perceived_mass = drone.m * MASS_ERROR

# instantiate TWO controllers
controller    = PDController(K_P, K_D, perceived_mass)
ff_controller = PDController(K_P, K_D, perceived_mass)

# get trajectories
t, z_path, z_dot_path, z_dot_dot_path = trajectories.cosine(AMPLITUDE,
                                                            PERIOD,
                                                           duration=6.0)
dt = t[1] - t[0]
# run simulation
history = []
ff_history = []
for z_target, z_dot_target, z_dot_dot_ff in zip(z_path,
                                                z_dot_path,
                                                z_dot_dot_path):
    z_actual = drone.z
    z_dot_actual = drone.z_dot

    ff_z_actual = ff_drone.z
    ff_z_dot_actual = ff_drone.z_dot

    u_ff = controller.thrust_control(z_target, ff_z_actual,
                                  z_dot_target, ff_z_dot_actual,
                                  z_dot_dot_ff)
    u = controller.thrust_control(z_target, z_actual,
                                  z_dot_target, z_dot_actual)

    drone.thrust = u
    ff_drone.thrust = u_ff

    drone.advance_state(dt)
    ff_drone.advance_state(dt)

    history.append(drone.X)
    ff_history.append(ff_drone.X)

# generate plots
z_actual = [h[0] for h in history]
z_ff_actual = [h[0] for h in ff_history]
plotting.compare_planned_to_actual(z_actual, z_path, t,
                                   z_ff_actual)
```

[Solution](/notebooks/PD%20with%20FF%20Solution.ipynb)


```javascript
%%javascript
MathJax.Hub.Config({TeX: { equationNumbers: { autoNumber: "AMS" } }});
```

    <IPython.core.display.Javascript object>

# E-BMFLC - Enhanced Band-limited Multiple Fourier Linear Combiner

The Enhanced Band-limited Multiple Fourier Linear Combiner  (E-BMFLC) filter is an improvement on the BMWFLC. The E-BMFLC filter was introduced in this [paper](https://ieeexplore.ieee.org/document/7407298), and a new dynamic estimation strategy for the E-BMFLC in this [paper](https://ieeexplore.ieee.org/document/8308681), but we will not review the latter one at this time.

The following model represents the hand movement of a patient with pathological hand tremor

\begin{equation}\label{eq:motion}
m_k = m_{k-v}+m_{k-i}
\end{equation}

In \eqref{eq:motion}, $m_k$ is the signal of the patients hand motion; $m_{k-v}$ and $m_{k-i}$ are the voluntary and involuntary components of the motion respectively. The main goal of the BMFLC was to estimate the involuntary motion $m_{k-i}$  in real-time. The E-BMFLC have been developed to address two two major drawbacks of the BMFLC filter, inaccurate error calculation and infinite memory.  The implementation of the E-BMFLC filter is divided into two phases of enhancement; $\textit{Phase}$ 1) $\textit{Harmonic Model Enrichment:}$ To deal with the incorrect error calculation, and $\textit{Phase}$ 2) $\textit{Memory manipulation}$: Using a windowed memory instead of the conventional infinite memory.

Instead of using the truncated model that only considers the frequency of the tremor $(m_{k-i}$), $[\omega_1,\omega_n]$ seen in the figure at the bottom, the complete frequency range of the hand motion ($m_k$),

\begin{equation}\label{eq:range}
[\omega_{min}, \omega_{max}]
\end{equation}

is considered. The frequency window is divided into a number of divisions

\begin{equation}\label{eq:divisions}
n=\frac{\omega_{max}-\omega_{min}}{\Delta \omega}
\end{equation}

and $n$ is then the number of harmonics in for the Fourier combiner model of $m_k$. The following Fourier combiner is then used to model the complete hand motion $m_k$, where ${\omega_1} =\omega_{min}$ and ${\omega_n} =\omega_{max}$.

\begin{equation}
\hat{m}_k=\sum_{r=1}^{n}a_{rk}\sin(\omega_r k )+b_{rk}\cos(\omega_rk)
\end{equation}

Then $\textbf{x}_{k}$ and $\textbf{w}_{k}$ are defined

\begin{align}
\textbf{x}_{k}&=
\begin{bmatrix}
[\sin(\omega_1k)&\sin(\omega_2k)&\cdots&\sin(\omega_nk)]^T \label{eq:45}\\
[\cos(\omega_1k)&\cos(\omega_2k)&\cdots&\cos(\omega_nk)]^T
\end{bmatrix} \\
\textbf{w}_{k}&=\begin{bmatrix}
[a_{1k}&a_{2k}&\cdots a_{nk}&]^T\label{eq:46}\\
[b_{1k}&b_{2k}&\cdots b_{nk}&]^T
\end{bmatrix}
\end{align}

Using \eqref{eq:45} and \eqref{eq:46} the linear combiner can be written as

\begin{equation}
\hat{m}_k=\textbf{w}_{k}^T\textbf{x}_{k}
\end{equation}

The error between the signal of the complete motion of the hand $m_k $ and the estimated signal $\hat{m}_k$ is

\begin{equation}\label{key}
\varepsilon_{k}=m_k-\hat{m}_k
\end{equation}

and the recursive LMS algorithm used to update the weights of $\textbf{w}_k$

\begin{align}\label{eq:49}
\textbf{w}_{k+1}&=\rho \textbf{w}_{k}+2\mu\textbf{x}_{k}\varepsilon_k\\
\rho&=\sqrt[\delta]{\alpha}\\
\delta&=\frac{1}{\Delta T}T_p\label{eq:411}
\end{align}

In \eqref{eq:49}, $\rho$  defines the pole of discrete dynamics of the memory windowing for the filter in the Z-domain, considered to enhance the performance of the filter, especially for non-periodic signals. Decreasing $\rho$ increases the rate of forgetting past information. In \eqref{eq:411}, $\Delta T$ is the sampling time (in seconds), $T_p$ is the width of the considered memory window (in seconds), and $\alpha$ is the minimum amplification gain considered within the window.

After the estimated motion $\hat{m}_k$ has been modeled, the estimate of the voluntary motion $\hat{m}_{k-v}$ and  involuntary motion $\hat{m}_{k-i}$ can be extracted. To do this different band-limited windows of frequency can be considered to extract various frequency ranges. Using this technuiqe, the signal modeling and frequency truncation are decoupled, while in the formulation of BMFLC filter these two steps are fused. Two frequency ranges will be considered, $\left[\omega_{a},\omega_{b} \right]$ and $\left[\omega_{c},\omega_{d} \right]$, for voluntary and involuntary motion respectively. In order to extract $\hat{m}_{k-i}$ and $\hat{m}_{k-v}$, the calculated $\textbf{w}_k$ and $\textbf{x}_k$ should be truncated considering the frequency range of the voluntary and involuntary motion. The following definitions are made for this purpose

\begin{equation}
N_a=\dfrac{\omega_{a}-\omega_{min}}{\Delta\omega}, \hspace{8pt} N_b=\dfrac{\omega_{b}-\omega_{min}}{\Delta\omega}
\end{equation}

\begin{equation}
N_c=\dfrac{\omega_{c}-\omega_{min}}{\Delta\omega}, \hspace{8pt} N_d=\dfrac{\omega_{d}-\omega_{min}}{\Delta\omega}
\end{equation}

where $\omega_{min}$ is from \eqref{eq:range}, and $\Delta \omega$ is the same as in \eqref{eq:divisions}. The estimates for $\hat{m}_{k-i}$ and $\hat{m}_{k-v}$ can now be found with the following equations

\begin{equation}
\hat{m}_{k-v}=\textbf{w}_{k-v}^T\textbf{x}_{k-v}
\end{equation}

\begin{align}
\textbf{x}_{k-v}&=
\begin{bmatrix}
[\sin(\omega_{N_{a}}k)&\sin(\omega_{(N_{a}+1)}k)&\cdots&\sin(\omega_{N_{b}}k)]^T \\
[\cos(\omega_{N_{a}}k)&\cos(\omega_{(N_{a}+1)}k)&\cdots&\cos(\omega_{N_{b}}k)]^T
\end{bmatrix} \\
\textbf{w}_{k-v}&=\begin{bmatrix}
[a_{{N_{a}}k}&a_{({N_{a}+1})}k&\cdots & a_{{N_{b}}k}]^T\\
[b_{{N_{a}}k}&b_{(N_{a}+1)}k&\cdots & b_{{N_{b}}k}]^T
\end{bmatrix}
\end{align}

\begin{equation}
\hat{m}_{k-i}=\textbf{w}_{k-i}^T\textbf{x}_{k-i}
\end{equation}

\begin{align}
\textbf{x}_{k-i}&=
\begin{bmatrix}
[\sin(\omega_{N_{c}}k)&\sin(\omega_{(N_{c}+1)}k)&\cdots&\sin(\omega_{N_{d}}k)]^T\\
[\cos(\omega_{N_{c}}k)&\cos(\omega_{(N_{c}+1)}k)&\cdots&\cos(\omega_{N_{d}}k)]^T
\end{bmatrix} \\
\textbf{w}_{k-i}&=\begin{bmatrix}
[a_{{N_{c}}k}&a_{({N_{c}+1})}k&\cdots& a_{{N_{d}}k}]^T\\
[b_{{N_{c}}k}&b_{(N_{c}+1)}k&\cdots& b_{{N_{d}}k}]^T
\end{bmatrix}
\end{align}


```python
# Header starts here.
from sympy.physics.units import *
from sympy import *

# Rounding:
import decimal
from decimal import Decimal as DX
from copy import deepcopy
def iso_round(obj, pv, rounding=decimal.ROUND_HALF_EVEN):
    import sympy
    """
    Rounding acc. to DIN EN ISO 80000-1:2013-08
    place value = Rundestellenwert
    """
    assert pv in set([
        # place value   #  round to:
        1,              #  1
        0.1,            #  1st digit after decimal
        0.01,           #  2nd
        0.001,          #  3rd
        0.0001,         #  4th
        0.00001,        #  5th
        0.000001,       #  6th
        0.0000001,      #  7th
        0.00000001,     #  8th
        0.000000001,    #  9th
        0.0000000001,   # 10th
        ])
    objc = deepcopy(obj)
    try:
        tmp = DX(str(float(objc)))
        objc = tmp.quantize(DX(str(pv)), rounding=rounding)
    except:
        for i in range(len(objc)):
            tmp = DX(str(float(objc[i])))
            objc[i] = tmp.quantize(DX(str(pv)), rounding=rounding)
    return objc

# LateX:
kwargs = {}
kwargs["mat_str"] = "bmatrix"
kwargs["mat_delim"] = ""
# kwargs["symbol_names"] = {FB: "F^{\mathsf B}", }

# Units:
(k, M, G ) = ( 10**3, 10**6, 10**9 )
(mm, cm) = ( m/1000, m/100 )
Newton = kg*m/s**2
Pa     = Newton/m**2
MPa    = M*Pa
GPa    = G*Pa
kN     = k*Newton
deg    = pi/180

half = S(1)/2

# Header ends here.
#
F, E, A, l = var("F, E, A, l")

# Quantities:
sub_list = [
    ( F,        5 *kN    ),
    ( E, 200*1000 *MPa   ),
    ( A,       25 *mm**2 ),
    ( l,     1707 *mm    ),
    ]

c = sqrt(2)/2
EA = E*A
S1, S2, dl1, dl2, u = var("S1, S2, dl1, dl2, u")

eq1 = Eq( F/2 + S2 + S1*c )
eq2 = Eq( S1, EA*dl1 / (sqrt(2)*l) )
eq3 = Eq( S2, EA/2*dl2/l )
eq4 = Eq(dl1, -u*c)
eq5 = Eq(dl2, -u)

eqns = [eq1, eq2, eq3, eq4, eq5]
unks = [S1, S2, dl1, dl2, u]
sol = solve(eqns, unks)
pprint(sol)

u = sol[u]
u = u.subs(sub_list)

pprint("\nu / mm:")
tmp = u
tmp /= mm
tmp = iso_round(tmp,0.001)
pprint(tmp)

# ⎧         √2⋅F           √2⋅F         -√2⋅F⋅l            -2⋅F⋅l            2⋅F⋅l    ⎫
# ⎨S₁: -F + ────, S₂: -F + ────, dl₁: ────────────, dl₂: ────────────, u: ────────────⎬
# ⎩          2              2         A⋅E⋅(√2 + 2)       A⋅E⋅(√2 + 2)     A⋅E⋅(√2 + 2)⎭
#
# u / mm:
# 1.000

```


```python
import numpy as np
import pandas as pd
```

La següent funció retorna el següent càlcul element a element pel vector o matriu d'entrada,

$$\begin{equation} x^2+2x+1 \end{equation}$$

```python
def calcular(x):
    """
    Calcula, per cada element de `x` de forma individual, la funció
    x^2+2x+1

    *NO* es poden fer servir bucles ni list-comprehensions

    :param x: np.array amb els elemnts a calcular
    :return: np.array amb el càlcul fet
    """
    op = lambda x: x**2 + 2*x + 1
    v_op = np.vectorize(op) # Aplica la funció a tots els elements del array.
    return v_op(x)
```

```python
if __name__ == '__main__':
    print(calcular(np.random.random((2, 2))))
```

    [[1.23503182 1.69178066]
     [3.8227119  2.57514637]]

Una altra forma de normalitzar vectors és fer que la seva mitja sigui 0 i la seva desviació estàndard sigui 1. Donat un vector $x$, el vector normalitzat $\bar{x}$ es calcula

$$\bar{x} = \frac{x - \mu_x}{\sigma_x}$$

On $\mu_x$ és la mitja i $\sigma_x$ la desviació estandard

```python
def normalitzar(x):
    """
    Normalitza el vector d'entrada `x` segons la definició
    anterior.

    *NO* es poden fer servir bucles ni list-comprehensions

    :param x: np.array en forma de vector (unidimensional)
    :return: np.array en forma de vector (unidimensional)
    """
    mean = np.mean(x) # Calcula la mitja dels valors del vector.
    sd = np.std(x) # Calcula la desviació estandard.
    norm = lambda x: ((x - mean)/sd)
    v_norm = np.vectorize(norm) # Aplica la funció a tots els valors del vector.
    return v_norm(x)
```

```python
if __name__ == '__main__':
    print(normalitzar(np.random.random(5)))
```

    [ 0.41225851 -0.25640253  1.23268969 -1.76215561  0.37360995]

```python
def normalitzar_matriu_per_columnes(x):
    """
    Normalitza, segons la definició d'abans, les columnes de la
    matriu de forma independent (cada columna es normalitza per la seva
    pròpia mitja i desviació estàndard)

    *NO* es poden fer servir bucles ni list-comprehensions

    :param x: np.array en forma matricial (bidimensional)
    :return: np.array en forma matricial (bidimensional)
    """
    # La mitja i desviació estandard es calculan per columna.
    mean = np.mean(x, axis = 0)
    sd = np.std(x, axis = 0)
    norm = lambda x: ((x - mean)/sd)
    return norm(x)
```

```python
if __name__ == '__main__':
    print(normalitzar_matriu_per_columnes(np.random.random((5, 5))))
```

    [[-0.15803321  1.0226842  -0.06791835 -0.21769128  0.29939414]
     [ 0.03804301  0.71396551  0.58987797  0.89401957  1.27241772]
     [ 1.84708717 -0.11083392 -1.90647556  0.25416854 -1.13643463]
     [-1.05118543  0.21433298  0.85709093  0.88675486 -1.19366401]
     [-0.67591154 -1.84014877  0.52742502 -1.81725169  0.75828679]]

```python
def normalitzar_matriu_per_files(x):
    """
    Normalitza, segons la definició d'abans, les files de la
    matriu de forma independent (cada fila es normalitza per la seva
    pròpia mitja i desviació estàndard)

    *NO* es poden fer servir bucles ni list-comprehensions

    :param x: np.array en forma matricial (bidimensional)
    :return: np.array en forma matricial (bidimensional)
    """
    # El mateix que abans, pero es transposa la matriu per a fer la normalització per files.
    x = np.transpose(x)
    mean = np.mean(x, axis = 0)
    sd = np.std(x, axis = 0)
    norm = lambda x: ((x - mean)/sd)
    # Es torna a transposar al retornar-la.
    return np.transpose(norm(x))
```

```python
if __name__ == '__main__':
    print(normalitzar_matriu_per_files(np.random.random((5, 5))))
```

    [[ 0.12069716 -1.19748768  1.32702337 -1.06297467  0.81274183]
     [-0.87812183 -1.34985192  0.74257157  0.12943953  1.35596265]
     [ 0.75873043  1.55191311 -0.94542198 -0.99095811 -0.37426346]
     [-0.81090938  0.63064112 -0.68620308 -0.81144436  1.67791571]
     [-0.47784076  1.77024443 -1.00759629  0.39708448 -0.68189186]]

```python
def calcular_mitja(x):
    """
    Calcula la mitja del vector d'entrada `x`, però qualsevol element
    NaN ha de ser tractat com si fos un 0. NO es pot modificar el
    vector original que es passa per paràmetre

    *NO* es poden fer servir bucles ni list-comprehensions

    :param x: np.array en forma vectorial (unidimensional)
    :return: float amb la mitja
    """
    # Substitueix els nan per 0, després fa la mitja i la retorna.
    return np.mean(np.where(np.isnan(x), 0, x))
```

```python
if __name__ == '__main__':
    vec = np.random.random(5)
    vec[np.random.randint(0, 5)] = np.nan
    print(calcular_mitja(vec))
```

    0.4633198710224719

```python
def crear_dataframe_1(usuari1, usuari2):
    """
    Donada la informació de dos usuaris, `usuari1` i `usuari2`, crea un
    pd.DataFrame que contingui cada un d'aquests usuaris com a una fila.
    La primera fila ha de tenir per índex "99" i la segona "88", de tipus STR.
    Les columnes han de tenir els següents noms:
        "Nom", "Cognom", "Data Registre", "Bitcoin"

    :param usuari1: Llista (nativa de python) amb les dades del primer usuari
    :param usuari2: Llista (nativa de python) amb les dades del segon usuari
    :return: DataFrame amb les dades dels usuaris
    """
    return pd.DataFrame([
        usuari1,
        usuari2],
    columns=['Nom', 'Cognom', 'Data Registre', 'Bitcoin'], index=['99','88'])
```

```python
if __name__ == '__main__':
    print(crear_dataframe_1(
        ['Mike', 'Strong', '2012-02-03', 99],
        ['Thomas', 'Weak', '2018-01-01', 0.4]
    ))
```

           Nom  Cognom Data Registre  Bitcoin
    99    Mike  Strong    2012-02-03     99.0
    88  Thomas    Weak    2018-01-01      0.4

```python
def crear_dataframe_2(x, exponent):
    """
    Donat un vector (np.arrray) i un exponent màxim, crea un
    DataFrame de pandas on cada columna és la potència
    $x^i$ per cada $i$ entre 0 i `exponent` (incloits). Les columnes han de
    tenir per nom "x<i>", on <i> és la potència

    Per exemple, donat ([1, 2, 3, 4], 2), crearà
    x0 | x1 | x2
    ------------
    1    1    1
    1    2    4
    1    3    9
    1    4    16

    Els indexs de les files són 0, 1, ..., n; on n és el nombre d'elements
    a x

    **Pots fer servir 1 sol bucle per iterar de 0 a exponent, cap més**

    :param x: np.array unidimensional amb les dades per calcular potències
    :param exponent: enter >= 0, màxim exponent a fer servir
    :return: Un DataFrame de pandas, tal i com s'especifica
    """
    matr = []
    col = ["x" + str(i) for i in range(exponent+1)]
    for i in range(exponent + 1):
        matr.append([a**i for a in x])

    return pd.DataFrame(np.asarray(matr).T,
                       columns=col)
```

```python
if __name__ == '__main__':
    print(crear_dataframe_2(np.asarray([1, 2, 3, 4]), 5))
```

       x0  x1  x2  x3   x4    x5
    0   1   1   1   1    1     1
    1   1   2   4   8   16    32
    2   1   3   9  27   81   243
    3   1   4  16  64  256  1024

```python
def consultar_basic(df):
    """
    Donat un DataFrame amb noms i notes, retorna solament
    els noms d'aquells usuaris que tinguin un 5 o més

    :param df: DataFrame amb dos columnes "Nom", "Nota", amb 1 o més
        files. "Nom" és una string i "Nota" un float
    :return: Un pd.Series de Pandas o llista/tupla amb els noms
        (i solament els noms) dels alumnes amb 5 o més
    """
    return pd.Series(df.loc[df['Nota'] >= 5]['Nom'])
```

```python
if __name__ == '__main__':
    print(consultar_basic(pd.DataFrame({
        'Nom': ['Antonio', 'Mireia'],
        'Nota': [5.1, 0.1]
    })))
```

    0    Antonio
    Name: Nom, dtype: object

```python
def consultar_dificil(df):
    """
    De totes les files d'un DataFrame, retorna l'índex d'aquella que tingui
    el menor nombre de NaNs

    *Es pot fer sense bucles, consulta la documentació de Pandas, la cheetsheet
    o stackoverflow*

    :param df: DataFrame sobre el que operar, les files contenen floats o NaN
    :return: L'índex (int, ja ve donat) de la fila amb menys NaNs
    """
    return df.T.isna().sum().idxmin()
```

```python
if __name__ == '__main__':
    print(consultar_dificil(pd.DataFrame([
        [0, np.nan, 3.0, 2, np.nan],
        [np.nan, 1, 2, 3, 4],
        [np.nan, 0, 0, np.nan, np.nan]
    ])))
```

    1