# Problem: Reflection unitaries

In the previous two problems, the output of a classical Boolean function is computed into an additional qubit. However, quantum computers can also compute Boolean functions into the phase of a quantum state, which we explore in this problem.

Suppose $f:\{0,1\}^n\rightarrow\{0,1\}$ is a Boolean function. For $\beta \in R$, define an $\textit{n}$-qubit unitary

$$
\large
\begin{equation*}
    U_f(\beta) = \sum_{x\in\{0,1\}^n} e^{i\beta f(x)}|x\rangle \langle x|
\tag{1}
\end{equation*}
$$
When $U_f$ is applied to computational basis state $|x\rangle$ it adds a phase $e^{i\beta}$ if $f(x) = 1$ and does  nothing otherwise.

Your goal in this problem is to implement $U_f$ for a variety of functions with $\textit{n}$= 3

\begin{align}
f_0(x_1x_2x_3)&=0,\quad f_1(x_1x_2x_3)=x_1x_2x_3,\quad f_2(x_1x_2x_3)=x_1x_2x_3+(1-x_1)(1-x_2)(1-x_3),\\\nonumber
f_3(x_1x_2x_3)&=x_1+x_2+x_3+x_1x_2x_3,\quad f_4(x_1x_2x_3)=x_1+x_2+x_3.
\tag{2}
\end{align}

Here, $f_m$ has m solutions to f(x) = 1. For instance,$f_2$ evaluates to 1 on the inputs x = 000 and x = 111.You may use $A \geq 0$ ancilla qubits in your implementation initialized in $∣0^A\rangle$ as long as they are returned to $∣0^A\rangle$ by the end of the computation. Two examples are shown in Fig.2 for n= 2 functions.

The "reflection unitaries" $U_f(\beta)$ are useful in some quantum algorithms. In particular, the famous Grover search algorithm uses these reflections to outperform classical search algorithms for unstructured lists.

    $f_m$ for m = 0

```python
# Importing the qiskit module
from qiskit import *

q0 =3    # number of required qubits
a0 =0    # number of ancilla qubit
beta = circuit.Parameter('beta') #Parameter for reflection

# Defining Quantum Circuit with the given circuits
def Circuit_0(q,a,beta):
    if a != 0:
        # initiating required and ancilla qubits
        Q = QuantumRegister(q, 'input')
        A = QuantumRegister(a, 'ancilla')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q,A)
    else:
        # initiating required qubits
        Q = QuantumRegister(q, 'input')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q)

    ##### Create you circuit below #########

    ########################################

    # Uncomment to draw quantum circuit
    display(Circ.draw('mpl'))

    # Transpiling the circuit into u, cnot
    Circ = transpile(Circ, basis_gates=['u3','cx'],optimization_level=3)

    # Uncomment to draw transpiled circuit
#     display(Circ.draw('mpl'))

    return Circ

qc_3a = Circuit_0(q0,a0,beta)
```

    $f_m$ for m = 1

```python
# Importing the qiskit module
from qiskit import *
from math import pi

q1 =3   # number of required qubits
a1 =0   # number of ancilla qubit
beta = circuit.Parameter('beta') #Parameter for reflection

# Defining Quantum Circuit with the given circuits
def Circuit_1(q,a,beta):
    if a != 0:
        # initiating required and ancilla qubits
        Q = QuantumRegister(q, 'input')
        A = QuantumRegister(a, 'ancilla')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q,A)
    else:
        # initiating required qubits
        Q = QuantumRegister(q, 'input')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q)

    ##### Create you circuit below #########
    #Circ.cx(Q[0],Q[1])
    #Circ.cx(Q[1],Q[2])
    #Circ.cx(Q[2],A[0])
    Circ.global_phase = beta / 8
    Circ.rz(beta / 4, Q[:])
    Circ.cx(Q[0], Q[1])
    Circ.rz(-1 * beta / 4, Q[1])
    Circ.cx(Q[0], Q[1])
    Circ.cx(Q[0], Q[2])
    Circ.rz(-1 * beta / 4, Q[2])
    Circ.cx(Q[1], Q[2])
    Circ.rz(beta / 4, Q[2])
    Circ.cx(Q[0], Q[2])
    Circ.rz(-1 * beta / 4, Q[2])
    Circ.cx(Q[1], Q[2])

    ########################################

    # Uncomment to draw quantum circuit
    display(Circ.draw('mpl'))

    # Transpiling the circuit into u, cnot
    Circ = transpile(Circ, basis_gates=['u3','cx'],optimization_level=3)

    # Uncomment to draw transpiled circuit
    #display(Circ.draw('mpl'))

    return Circ

qc_3b = Circuit_1(q1,a1,beta)
```

    $f_m$ for m = 2

```python
# Importing the qiskit module
from qiskit import *

q2 =3   # number of required qubits
a2 =0   # number of ancilla qubit
beta = circuit.Parameter('beta') #Parameter for reflection

# Defining Quantum Circuit with the given circuits
def Circuit_2(q,a,beta):
    if a != 0:
        # initiating required and ancilla qubits
        Q = QuantumRegister(q, 'input')
        A = QuantumRegister(a, 'ancilla')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q,A)
    else:
        # initiating required qubits
        Q = QuantumRegister(q, 'input')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q)

    ##### Create you circuit below #########
    Circ.global_phase = beta / 4
    Circ.cx(Q[0], Q[1])
    Circ.rz(-1 * beta / 2, Q[1])
    Circ.cx(Q[0], Q[1])
    Circ.cx(Q[0], Q[2])
    Circ.rz(-1 * beta / 2, Q[2])
    Circ.cx(Q[0], Q[2])
    Circ.cx(Q[1], Q[2])
    Circ.rz(-1 * beta / 2, Q[2])
    Circ.cx(Q[1], Q[2])
    #########################
    ###############

    # Uncomment to draw quantum circuit
    display(Circ.draw('mpl'))

    # Transpiling the circuit into u, cnot
    Circ = transpile(Circ, basis_gates=['u3','cx'],optimization_level=3)

    # Uncomment to draw transpiled circuit
#     display(Circ.draw('mpl'))

    return Circ

qc_3c = Circuit_2(q2,a2,beta)
```

    $f_m$ for m = 3

```python
# Importing the qiskit module
from qiskit import *

q3 =3   # number of required qubits
a3 =0
# number of ancilla qubit
beta = circuit.Parameter('beta') #Parameter for reflection

# Defining Quantum Circuit with the given circuits
def Circuit_3(q,a,beta):
    if a != 0:
        # initiating required and ancilla qubits
        Q = QuantumRegister(q, 'input')
        A = QuantumRegister(a, 'ancilla')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q,A)
    else:
        # initiating required qubits
        Q = QuantumRegister(q, 'input')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q)

    ##### Create you circuit below #########

    Circ.global_phase = 3 * beta / 8
    Circ.rz(-beta / 4, Q[:])
    Circ.cx(Q[0], Q[1])
    Circ.rz(beta / 4, Q[1])
    Circ.cx(Q[0], Q[1])
    Circ.cx(Q[0], Q[2])
    Circ.rz(beta / 4, Q[2])
    Circ.cx(Q[1], Q[2])
    Circ.rz(3 * beta / 4, Q[2])
    Circ.cx(Q[0], Q[2])
    Circ.rz(beta / 4, Q[2])
    Circ.cx(Q[1], Q[2])

    ########################################

    # Uncomment to draw quantum circuit
    display(Circ.draw('mpl'))

    # Transpiling the circuit into u, cnot
    Circ = transpile(Circ, basis_gates=['u3','cx'],optimization_level=3)

    # Uncomment to draw transpiled circuit
    #display(Circ.draw('mpl'))

    return Circ

qc_3d = Circuit_3(q3,a3,beta)
```

    $f_m$ for m = 4

```python
# Importing the qiskit module
from qiskit import *

q4 =3   # number of required qubits
a4 =0   # number of ancilla qubit
beta = circuit.Parameter('beta') #Parameter for reflection

# Defining Quantum Circuit with the given circuits
def Circuit_4(q, a, beta):
    if a != 0:
        # initiating required and ancilla qubits
        Q = QuantumRegister(q, 'input')
        A = QuantumRegister(a, 'ancilla')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q,A)
    else:
        # initiating required qubits
        Q = QuantumRegister(q, 'input')

        # creating circuit with above qubits
        Circ = QuantumCircuit(Q)

    ##### Create you circuit below #########
    Circ.cx(Q[0],Q[1])
    Circ.cx(Q[1],Q[2])
    Circ.u1(beta,Q[2])
    Circ.cx(Q[1],Q[2])
    Circ.cx(Q[0],Q[1])

    ########################################

    # Uncomment to draw quantum circuit
    #display(Circ.draw('mpl'))

    # Transpiling the circuit into u, cnot
    Circ = transpile(Circ, basis_gates=['u3','cx'],optimization_level=3)

    # Uncomment to draw transpiled circuit
    display(Circ.draw('mpl'))

    return Circ

qc_3e = Circuit_4(q4,a4,beta)
```

### Grader

```python
all_circuits = [qc_3a, qc_3b, qc_3c, qc_3d, qc_3e]
from qc_grader import grade_ex3

grade_ex3(all_circuits)
```

    Checking for circuit 0 ...
    Checking for circuit 1 ...
    Checking for circuit 2 ...
    Checking for circuit 3 ...
    Checking for circuit 4 ...
    Grading your answer for ex3. Please wait...

    Congratulations 🎉! Your answer is correct.
    Your cost is 331.
    Feel free to submit your answer.

# Circuit verification and Cost Metric

$\textbf{Costing metric:}$ For the $m^{th}$ circuit, let $G_m$ be the number of 2-qubit gates, $D_m$ the depth,  and $A_m$ the number of ancillas. Your solution quality will be judged on the following cost metric:
$$\text{Cost} = \sum_{m=0}^4 10G_m + D_m(n+A_m)$$ .
The reasoning behind this metric relates to the error rates in quantum circuits. Notice that $D_m(n+A_m)$ is roughly the spacetime volume of the circuit (the collective number of timesteps qubits participating in thecircuit must be kept alive). Also, two-qubit gates are roughly (optimistically) 10 times as likely to fail as idling qubits. Thus, $Cost_n$ is roughly proportional to error rate of all of your circuits combined.

### Submission for evaluation

```python
## Uncomment and run the following code to submit
from qc_grader import submit_ex3
submit_ex3(all_circuits)
```

    Checking for circuit 0 ...
    Checking for circuit 1 ...
    Checking for circuit 2 ...
    Checking for circuit 3 ...
    Checking for circuit 4 ...
    Submitting your answer for ex3. Please wait...
    Success 🎉! Your answer has been submitted.

```python

```


```python
import ambulance_game as abg

import numpy as np
import sympy as sym
import networkx as nx
import matplotlib.pyplot as plt
import math

from state_probabilities_examples.main import (
    get_symbolic_state_probabilities_1222,
    get_symbolic_state_probabilities_1121,
    get_symbolic_state_probabilities_1122,
    get_symbolic_state_probabilities_1123,
    get_symbolic_state_probabilities_1341,
    get_symbolic_state_probabilities_1131,
    get_symbolic_state_probabilities_1132,
    get_symbolic_state_probabilities_1141,
    get_symbolic_state_probabilities_1142,
    get_symbolic_state_probabilities_1151,
    get_symbolic_state_probabilities_1161,
    get_symbolic_state_probabilities_1171,
    get_symbolic_state_probabilities_1181,
    get_symbolic_state_probabilities_1191,
)
p00, p01, p11, p21, p31, p02, p12, p22, p32, p03, p13, p23, p04, p14, p24, p05, p15, p25, p06, p16, p26, p07, p17, p27, p08, p18, p28 = sym.symbols('p00, p01, p11, p21, p31, p02, p12, p22, p32, p03, p13, p23, p04, p14, p24, p05, p15, p25, p06, p16, p26, p07, p17, p27, p08, p18, p28')
sym_Lambda = sym.symbols("Lambda")
sym_lambda_1 = sym.symbols("lambda_1")
sym_lambda_2 = sym.symbols("lambda_2")
sym_mu = sym.symbols("mu")

```

```python
sym.init_printing(use_latex='mathjax')
```

```python
probs_1222, _ = get_symbolic_state_probabilities_1222()
probs_1121, _, _, _ = get_symbolic_state_probabilities_1121()
probs_1122, _, _, _ = get_symbolic_state_probabilities_1122()
probs_1123, _, _, _ = get_symbolic_state_probabilities_1123()
probs_1341, _, _, _ = get_symbolic_state_probabilities_1341()
probs_1131, _, _, _ = get_symbolic_state_probabilities_1131()
probs_1132, _, _, _ = get_symbolic_state_probabilities_1132()
probs_1141, _, _, _ = get_symbolic_state_probabilities_1141()
probs_1142, _, _, _ = get_symbolic_state_probabilities_1142()
probs_1151, _, _, _ = get_symbolic_state_probabilities_1151()
probs_1161, _, _, _ = get_symbolic_state_probabilities_1161()

```

```python
probs_1121, _, _, _ = get_symbolic_state_probabilities_1121()
probs_1131, _, _, _ = get_symbolic_state_probabilities_1131()
```

```python
probs_1141, _, _, _ = get_symbolic_state_probabilities_1141()
probs_1142, _, _, _ = get_symbolic_state_probabilities_1142()
```

```python
probs_1171, _, _, _ = get_symbolic_state_probabilities_1171()
```

```python
probs_1181, _, _, _ = get_symbolic_state_probabilities_1181()
```

```python
probs_1191, _, _, _ = get_symbolic_state_probabilities_1191()
```

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=2, system_capacity=2, buffer_capacity=2)
```

```python
sym.fraction(probs_1222[0])[0]
```

$\displaystyle \mu^{4}$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=2, buffer_capacity=1)
```

```python
sym.fraction(probs_1121[0])[0]
```

$\displaystyle \mu^{3} \left(\lambda^{A} + \mu\right)$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=3, system_capacity=4, buffer_capacity=1)
```

```python
sym.fraction(sym.factor(probs_1341[0]))[0]
```

$\displaystyle \mu^{5} \left(\lambda^{A} + \mu\right)$

```python

```

# Change in $P_{0,0}$ when adding rows

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=2, buffer_capacity=1)
```

```python
sym.fraction(probs_1121[0])[0]
```

$\displaystyle \mu^{3} \left(\lambda^{A} + \mu\right)$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=2, buffer_capacity=2)
```

```python
sym.fraction(sym.factor(probs_1122[0]))[0]
```

$\displaystyle \mu^{4} \left(\lambda^{A} + \mu\right)^{2}$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=2, buffer_capacity=3)
```

```python
sym.fraction(sym.factor(probs_1123[p00]))[0]
```

$\displaystyle \mu^{5} \left(\lambda^{A} + \mu\right)^{3}$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=3, buffer_capacity=1)
```

```python
sym.fraction(sym.factor(probs_1131[0]))[0]
```

$\displaystyle \mu^{4} \left(\left(\lambda^{A}\right)^{2} + \lambda^{A} \lambda^{o} + 2 \lambda^{A} \mu + \mu^{2}\right)$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=3, buffer_capacity=2)
```

```python
sym.fraction(sym.factor(probs_1132[p00]))[0]
```

$\displaystyle \mu^{5} \left(\left(\lambda^{A}\right)^{2} + \lambda^{A} \lambda^{o} + 2 \lambda^{A} \mu + \mu^{2}\right)^{2}$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=4, buffer_capacity=1)
```

```python
sym.fraction(sym.factor(probs_1141[p00]))[0]
```

$\displaystyle \mu^{5} \left(\left(\lambda^{A}\right)^{3} + 2 \left(\lambda^{A}\right)^{2} \lambda^{o} + 3 \left(\lambda^{A}\right)^{2} \mu + \lambda^{A} \left(\lambda^{o}\right)^{2} + 2 \lambda^{A} \lambda^{o} \mu + 3 \lambda^{A} \mu^{2} + \mu^{3}\right)$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=4, buffer_capacity=2)
```

```python
sym.fraction(sym.factor(probs_1142[p00]))[0]
```

$\displaystyle \mu^{6} \left(\left(\lambda^{A}\right)^{3} + 2 \left(\lambda^{A}\right)^{2} \lambda^{o} + 3 \left(\lambda^{A}\right)^{2} \mu + \lambda^{A} \left(\lambda^{o}\right)^{2} + 2 \lambda^{A} \lambda^{o} \mu + 3 \lambda^{A} \mu^{2} + \mu^{3}\right)^{2}$

```python

```

# Change in $P_{0,0}$ when adding columns

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=2, buffer_capacity=1)
```

```python
sym.fraction(sym.factor(probs_1121[0]))[0]
```

$\displaystyle \mu^{3} \left(\lambda^{A} + \mu\right)$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=3, buffer_capacity=1)
```

```python
sym.fraction(sym.factor(probs_1131[0]))[0]
```

$\displaystyle \mu^{4} \left(\left(\lambda^{A}\right)^{2} + \lambda^{A} \lambda^{o} + 2 \lambda^{A} \mu + \mu^{2}\right)$

```python
sym_mu ** 4 * ((sym_lambda_2 + sym_mu) ** 2 + sym_lambda_2 * sym_lambda_1)
```

$\displaystyle \mu^{4} \left(\lambda^{A} \lambda^{o} + \left(\lambda^{A} + \mu\right)^{2}\right)$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=4, buffer_capacity=1)
```

```python
sym.fraction(sym.factor(probs_1141[p00]))[0]
```

$\displaystyle \mu^{5} \left(\left(\lambda^{A}\right)^{3} + 2 \left(\lambda^{A}\right)^{2} \lambda^{o} + 3 \left(\lambda^{A}\right)^{2} \mu + \lambda^{A} \left(\lambda^{o}\right)^{2} + 2 \lambda^{A} \lambda^{o} \mu + 3 \lambda^{A} \mu^{2} + \mu^{3}\right)$

```python
sym_mu ** 5 * ((sym_lambda_2 + sym_mu) ** 3 + sym_lambda_2 * sym_lambda_1 * (2 * sym_lambda_2 + sym_lambda_1 + 2 * sym_mu))
```

$\displaystyle \mu^{5} \left(\lambda^{A} \lambda^{o} \left(2 \lambda^{A} + \lambda^{o} + 2 \mu\right) + \left(\lambda^{A} + \mu\right)^{3}\right)$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=5, buffer_capacity=1)
```

```python
sym.fraction(sym.factor(probs_1151[p00]))[0]
```

$\displaystyle \mu^{6} \left(\left(\lambda^{A}\right)^{4} + 3 \left(\lambda^{A}\right)^{3} \lambda^{o} + 4 \left(\lambda^{A}\right)^{3} \mu + 3 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} + 6 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu + 6 \left(\lambda^{A}\right)^{2} \mu^{2} + \lambda^{A} \left(\lambda^{o}\right)^{3} + 2 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu + 3 \lambda^{A} \lambda^{o} \mu^{2} + 4 \lambda^{A} \mu^{3} + \mu^{4}\right)$

```python
sym_mu ** 6 * ((sym_lambda_2 + sym_mu) ** 4 + sym_lambda_2 * sym_lambda_1 * ((3*sym_lambda_2 + sym_lambda_1 + 3*sym_mu) * (sym_lambda_2 + sym_lambda_1 + sym_mu) - sym_lambda_1 * (sym_lambda_2 + 2 * sym_mu)))
```

$\displaystyle \mu^{6} \left(\lambda^{A} \lambda^{o} \left(- \lambda^{o} \left(\lambda^{A} + 2 \mu\right) + \left(\lambda^{A} + \lambda^{o} + \mu\right) \left(3 \lambda^{A} + \lambda^{o} + 3 \mu\right)\right) + \left(\lambda^{A} + \mu\right)^{4}\right)$

```python

```

```python
abg.markov.visualise_ambulance_markov_chain(num_of_servers=1, threshold=1, system_capacity=6, buffer_capacity=1)
```

```python
sym.fraction(sym.factor(probs_1161[p00]))[0]
```

$\displaystyle \mu^{7} \left(\left(\lambda^{A}\right)^{5} + 4 \left(\lambda^{A}\right)^{4} \lambda^{o} + 5 \left(\lambda^{A}\right)^{4} \mu + 6 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} + 12 \left(\lambda^{A}\right)^{3} \lambda^{o} \mu + 10 \left(\lambda^{A}\right)^{3} \mu^{2} + 4 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} + 9 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu + 12 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu^{2} + 10 \left(\lambda^{A}\right)^{2} \mu^{3} + \lambda^{A} \left(\lambda^{o}\right)^{4} + 2 \lambda^{A} \left(\lambda^{o}\right)^{3} \mu + 3 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu^{2} + 4 \lambda^{A} \lambda^{o} \mu^{3} + 5 \lambda^{A} \mu^{4} + \mu^{5}\right)$

```python
sym_mu ** 7 * ((sym_lambda_2 + sym_mu) ** 5 + sym_lambda_2 * sym_lambda_1 * ((4*sym_lambda_2 + sym_lambda_1 + 4*sym_mu) * (sym_lambda_2 + sym_lambda_1 + sym_mu) ** 2 - sym_lambda_1 * (sym_lambda_2 + 2 * sym_mu) * (3*sym_lambda_2 + 2*sym_lambda_1 + 3*sym_mu)))
```

$\displaystyle \mu^{7} \left(\lambda^{A} \lambda^{o} \left(- \lambda^{o} \left(\lambda^{A} + 2 \mu\right) \left(3 \lambda^{A} + 2 \lambda^{o} + 3 \mu\right) + \left(\lambda^{A} + \lambda^{o} + \mu\right)^{2} \left(4 \lambda^{A} + \lambda^{o} + 4 \mu\right)\right) + \left(\lambda^{A} + \mu\right)^{5}\right)$

```python

```

```python

```

```python

```

# Summary

```python
sym_mu ** 3 * (sym_lambda_2 + sym_mu)
```

$\displaystyle \mu^{3} \left(\lambda^{A} + \mu\right)$

```python
sym_mu ** 4 * ((sym_lambda_2 + sym_mu) ** 2 + sym_lambda_2 * sym_lambda_1)
```

$\displaystyle \mu^{4} \left(\lambda^{A} \lambda^{o} + \left(\lambda^{A} + \mu\right)^{2}\right)$

```python
sym_mu ** 5 * ((sym_lambda_2 + sym_mu) ** 3 + sym_lambda_2 * sym_lambda_1 * (2 * sym_lambda_2 + sym_lambda_1 + 2 * sym_mu))
```

$\displaystyle \mu^{5} \left(\lambda^{A} \lambda^{o} \left(2 \lambda^{A} + \lambda^{o} + 2 \mu\right) + \left(\lambda^{A} + \mu\right)^{3}\right)$

```python
sym_mu ** 6 * ((sym_lambda_2 + sym_mu) ** 4 + sym_lambda_2 * sym_lambda_1 * ((3*sym_lambda_2 + sym_lambda_1 + 3*sym_mu) * (sym_lambda_2 + sym_lambda_1 + sym_mu) - sym_lambda_1 * (sym_lambda_2 + 2 * sym_mu)))
```

$\displaystyle \mu^{6} \left(\lambda^{A} \lambda^{o} \left(- \lambda^{o} \left(\lambda^{A} + 2 \mu\right) + \left(\lambda^{A} + \lambda^{o} + \mu\right) \left(3 \lambda^{A} + \lambda^{o} + 3 \mu\right)\right) + \left(\lambda^{A} + \mu\right)^{4}\right)$

```python
sym_mu ** 7 * ((sym_lambda_2 + sym_mu) ** 5 + sym_lambda_2 * sym_lambda_1 * ((4*sym_lambda_2 + sym_lambda_1 + 4*sym_mu) * (sym_lambda_2 + sym_lambda_1 + sym_mu) ** 2 - sym_lambda_1 * (sym_lambda_2 + 2 * sym_mu) * (3*sym_lambda_2 + 2*sym_lambda_1 + 3*sym_mu)))
```

$\displaystyle \mu^{7} \left(\lambda^{A} \lambda^{o} \left(- \lambda^{o} \left(\lambda^{A} + 2 \mu\right) \left(3 \lambda^{A} + 2 \lambda^{o} + 3 \mu\right) + \left(\lambda^{A} + \lambda^{o} + \mu\right)^{2} \left(4 \lambda^{A} + \lambda^{o} + 4 \mu\right)\right) + \left(\lambda^{A} + \mu\right)^{5}\right)$

## Does not match actual calculations

```python
sym_mu ** 8 * ((sym_lambda_2 + sym_mu) ** 6 + sym_lambda_2 * sym_lambda_1 * ((5*sym_lambda_2 + sym_lambda_1 + 5*sym_mu) * (sym_lambda_2 + sym_lambda_1 + sym_mu) ** 3 - sym_lambda_1 * (sym_lambda_2 + 2 * sym_mu) * (3*sym_lambda_2 + 2*sym_lambda_1 + 3*sym_mu) ** 2))
```

$\displaystyle \mu^{8} \left(\lambda^{A} \lambda^{o} \left(- \lambda^{o} \left(\lambda^{A} + 2 \mu\right) \left(3 \lambda^{A} + 2 \lambda^{o} + 3 \mu\right)^{2} + \left(\lambda^{A} + \lambda^{o} + \mu\right)^{3} \left(5 \lambda^{A} + \lambda^{o} + 5 \mu\right)\right) + \left(\lambda^{A} + \mu\right)^{6}\right)$

```python

```

```python
print(sym.fraction(probs_1191[p00])[0])
```

    mu**10*(lambda_2**8 + 7*lambda_2**7*lambda_1 + 8*lambda_2**7*mu + 21*lambda_2**6*lambda_1**2 + 42*lambda_2**6*lambda_1*mu + 28*lambda_2**6*mu**2 + 35*lambda_2**5*lambda_1**3 + 90*lambda_2**5*lambda_1**2*mu + 105*lambda_2**5*lambda_1*mu**2 + 56*lambda_2**5*mu**3 + 35*lambda_2**4*lambda_1**4 + 100*lambda_2**4*lambda_1**3*mu + 150*lambda_2**4*lambda_1**2*mu**2 + 140*lambda_2**4*lambda_1*mu**3 + 70*lambda_2**4*mu**4 + 21*lambda_2**3*lambda_1**5 + 60*lambda_2**3*lambda_1**4*mu + 100*lambda_2**3*lambda_1**3*mu**2 + 120*lambda_2**3*lambda_1**2*mu**3 + 105*lambda_2**3*lambda_1*mu**4 + 56*lambda_2**3*mu**5 + 7*lambda_2**2*lambda_1**6 + 18*lambda_2**2*lambda_1**5*mu + 30*lambda_2**2*lambda_1**4*mu**2 + 40*lambda_2**2*lambda_1**3*mu**3 + 45*lambda_2**2*lambda_1**2*mu**4 + 42*lambda_2**2*lambda_1*mu**5 + 28*lambda_2**2*mu**6 + lambda_2*lambda_1**7 + 2*lambda_2*lambda_1**6*mu + 3*lambda_2*lambda_1**5*mu**2 + 4*lambda_2*lambda_1**4*mu**3 + 5*lambda_2*lambda_1**3*mu**4 + 6*lambda_2*lambda_1**2*mu**5 + 7*lambda_2*lambda_1*mu**6 + 8*lambda_2*mu**7 + mu**8)

# Saved outcomes

```python
P00_1111 = sym_mu ** 2

P00_1121 = sym_mu ** 3 * (sym_lambda_2 + sym_mu)

P00_1131 = sym_mu ** 4 * ((sym_lambda_2 + sym_mu) ** 2 + sym_lambda_2 * sym_lambda_1)

P00_1141 = sym_mu ** 5 * ((sym_lambda_2 + sym_mu) ** 3 + sym_lambda_2 * sym_lambda_1 * (2 * sym_lambda_2 + sym_lambda_1 + 2 * sym_mu))

P00_1151 = sym_mu ** 6 * ((sym_lambda_2 + sym_mu) ** 4 + sym_lambda_2 * sym_lambda_1 * ((3*sym_lambda_2 + sym_lambda_1 + 3*sym_mu) * (sym_lambda_2 + sym_lambda_1 + sym_mu) - sym_lambda_1 * (sym_lambda_2 + 2 * sym_mu)))

P00_1161 = sym_mu ** 7 * ((sym_lambda_2 + sym_mu) ** 5 + sym_lambda_2 * sym_lambda_1 * ((4*sym_lambda_2 + sym_lambda_1 + 4*sym_mu) * (sym_lambda_2 + sym_lambda_1 + sym_mu) ** 2 - sym_lambda_1 * (sym_lambda_2 + 2 * sym_mu) * (3*sym_lambda_2 + 2*sym_lambda_1 + 3*sym_mu)))

P00_1171 = sym_mu**8*(sym_lambda_2**6 + 5*sym_lambda_2**5*sym_lambda_1 + 6*sym_lambda_2**5*sym_mu + 10*sym_lambda_2**4*sym_lambda_1**2 + 20*sym_lambda_2**4*sym_lambda_1*sym_mu + 15*sym_lambda_2**4*sym_mu**2 + 10*sym_lambda_2**3*sym_lambda_1**3 + 24*sym_lambda_2**3*sym_lambda_1**2*sym_mu + 30*sym_lambda_2**3*sym_lambda_1*sym_mu**2 + 20*sym_lambda_2**3*sym_mu**3 + 5*sym_lambda_2**2*sym_lambda_1**4 + 12*sym_lambda_2**2*sym_lambda_1**3*sym_mu + 18*sym_lambda_2**2*sym_lambda_1**2*sym_mu**2 + 20*sym_lambda_2**2*sym_lambda_1*sym_mu**3 + 15*sym_lambda_2**2*sym_mu**4 + sym_lambda_2*sym_lambda_1**5 + 2*sym_lambda_2*sym_lambda_1**4*sym_mu + 3*sym_lambda_2*sym_lambda_1**3*sym_mu**2 + 4*sym_lambda_2*sym_lambda_1**2*sym_mu**3 + 5*sym_lambda_2*sym_lambda_1*sym_mu**4 + 6*sym_lambda_2*sym_mu**5 + sym_mu**6)

P00_1181 = sym_mu**9*(sym_lambda_2**7 + 6*sym_lambda_2**6*sym_lambda_1 + 7*sym_lambda_2**6*sym_mu + 15*sym_lambda_2**5*sym_lambda_1**2 + 30*sym_lambda_2**5*sym_lambda_1*sym_mu + 21*sym_lambda_2**5*sym_mu**2 + 20*sym_lambda_2**4*sym_lambda_1**3 + 50*sym_lambda_2**4*sym_lambda_1**2*sym_mu + 60*sym_lambda_2**4*sym_lambda_1*sym_mu**2 + 35*sym_lambda_2**4*sym_mu**3 + 15*sym_lambda_2**3*sym_lambda_1**4 + 40*sym_lambda_2**3*sym_lambda_1**3*sym_mu + 60*sym_lambda_2**3*sym_lambda_1**2*sym_mu**2 + 60*sym_lambda_2**3*sym_lambda_1*sym_mu**3 + 35*sym_lambda_2**3*sym_mu**4 + 6*sym_lambda_2**2*sym_lambda_1**5 + 15*sym_lambda_2**2*sym_lambda_1**4*sym_mu + 24*sym_lambda_2**2*sym_lambda_1**3*sym_mu**2 + 30*sym_lambda_2**2*sym_lambda_1**2*sym_mu**3 + 30*sym_lambda_2**2*sym_lambda_1*sym_mu**4 + 21*sym_lambda_2**2*sym_mu**5 + sym_lambda_2*sym_lambda_1**6 + 2*sym_lambda_2*sym_lambda_1**5*sym_mu + 3*sym_lambda_2*sym_lambda_1**4*sym_mu**2 + 4*sym_lambda_2*sym_lambda_1**3*sym_mu**3 + 5*sym_lambda_2*sym_lambda_1**2*sym_mu**4 + 6*sym_lambda_2*sym_lambda_1*sym_mu**5 + 7*sym_lambda_2*sym_mu**6 + sym_mu**7)

P00_1191 = sym_mu**10*(sym_lambda_2**8 + 7*sym_lambda_2**7*sym_lambda_1 + 8*sym_lambda_2**7*sym_mu + 21*sym_lambda_2**6*sym_lambda_1**2 + 42*sym_lambda_2**6*sym_lambda_1*sym_mu + 28*sym_lambda_2**6*sym_mu**2 + 35*sym_lambda_2**5*sym_lambda_1**3 + 90*sym_lambda_2**5*sym_lambda_1**2*sym_mu + 105*sym_lambda_2**5*sym_lambda_1*sym_mu**2 + 56*sym_lambda_2**5*sym_mu**3 + 35*sym_lambda_2**4*sym_lambda_1**4 + 100*sym_lambda_2**4*sym_lambda_1**3*sym_mu + 150*sym_lambda_2**4*sym_lambda_1**2*sym_mu**2 + 140*sym_lambda_2**4*sym_lambda_1*sym_mu**3 + 70*sym_lambda_2**4*sym_mu**4 + 21*sym_lambda_2**3*sym_lambda_1**5 + 60*sym_lambda_2**3*sym_lambda_1**4*sym_mu + 100*sym_lambda_2**3*sym_lambda_1**3*sym_mu**2 + 120*sym_lambda_2**3*sym_lambda_1**2*sym_mu**3 + 105*sym_lambda_2**3*sym_lambda_1*sym_mu**4 + 56*sym_lambda_2**3*sym_mu**5 + 7*sym_lambda_2**2*sym_lambda_1**6 + 18*sym_lambda_2**2*sym_lambda_1**5*sym_mu + 30*sym_lambda_2**2*sym_lambda_1**4*sym_mu**2 + 40*sym_lambda_2**2*sym_lambda_1**3*sym_mu**3 + 45*sym_lambda_2**2*sym_lambda_1**2*sym_mu**4 + 42*sym_lambda_2**2*sym_lambda_1*sym_mu**5 + 28*sym_lambda_2**2*sym_mu**6 + sym_lambda_2*sym_lambda_1**7 + 2*sym_lambda_2*sym_lambda_1**6*sym_mu + 3*sym_lambda_2*sym_lambda_1**5*sym_mu**2 + 4*sym_lambda_2*sym_lambda_1**4*sym_mu**3 + 5*sym_lambda_2*sym_lambda_1**3*sym_mu**4 + 6*sym_lambda_2*sym_lambda_1**2*sym_mu**5 + 7*sym_lambda_2*sym_lambda_1*sym_mu**6 + 8*sym_lambda_2*sym_mu**7 + sym_mu**8)

```

```python
P00_1111_simplified = P00_1111 / sym_mu ** 2
P00_1121_simplified = P00_1121 / sym_mu ** 3
P00_1131_simplified = P00_1131 / sym_mu ** 4
P00_1141_simplified = P00_1141 / sym_mu ** 5
P00_1151_simplified = P00_1151 / sym_mu ** 6
P00_1161_simplified = P00_1161 / sym_mu ** 7
P00_1171_simplified = P00_1171 / sym_mu ** 8
P00_1181_simplified = P00_1181 / sym_mu ** 9
P00_1191_simplified = P00_1191 / sym_mu ** 10
```

```python
P00_1111_simplified
```

$\displaystyle 1$

```python
sym.expand(P00_1121_simplified)
```

$\displaystyle \lambda^{A} + \mu$

```python
sym.expand(P00_1131_simplified)
```

$\displaystyle \left(\lambda^{A}\right)^{2} + \lambda^{A} \lambda^{o} + 2 \lambda^{A} \mu + \mu^{2}$

```python
sym.expand(P00_1141_simplified)
```

$\displaystyle \left(\lambda^{A}\right)^{3} + 2 \left(\lambda^{A}\right)^{2} \lambda^{o} + 3 \left(\lambda^{A}\right)^{2} \mu + \lambda^{A} \left(\lambda^{o}\right)^{2} + 2 \lambda^{A} \lambda^{o} \mu + 3 \lambda^{A} \mu^{2} + \mu^{3}$

```python
sym.expand(P00_1151_simplified)
```

$\displaystyle \left(\lambda^{A}\right)^{4} + 3 \left(\lambda^{A}\right)^{3} \lambda^{o} + 4 \left(\lambda^{A}\right)^{3} \mu + 3 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} + 6 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu + 6 \left(\lambda^{A}\right)^{2} \mu^{2} + \lambda^{A} \left(\lambda^{o}\right)^{3} + 2 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu + 3 \lambda^{A} \lambda^{o} \mu^{2} + 4 \lambda^{A} \mu^{3} + \mu^{4}$

```python
sym.expand(P00_1161_simplified)
```

$\displaystyle \left(\lambda^{A}\right)^{5} + 4 \left(\lambda^{A}\right)^{4} \lambda^{o} + 5 \left(\lambda^{A}\right)^{4} \mu + 6 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} + 12 \left(\lambda^{A}\right)^{3} \lambda^{o} \mu + 10 \left(\lambda^{A}\right)^{3} \mu^{2} + 4 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} + 9 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu + 12 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu^{2} + 10 \left(\lambda^{A}\right)^{2} \mu^{3} + \lambda^{A} \left(\lambda^{o}\right)^{4} + 2 \lambda^{A} \left(\lambda^{o}\right)^{3} \mu + 3 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu^{2} + 4 \lambda^{A} \lambda^{o} \mu^{3} + 5 \lambda^{A} \mu^{4} + \mu^{5}$

```python
sym.expand(P00_1171_simplified)
```

$\displaystyle \left(\lambda^{A}\right)^{6} + 5 \left(\lambda^{A}\right)^{5} \lambda^{o} + 6 \left(\lambda^{A}\right)^{5} \mu + 10 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{2} + 20 \left(\lambda^{A}\right)^{4} \lambda^{o} \mu + 15 \left(\lambda^{A}\right)^{4} \mu^{2} + 10 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{3} + 24 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} \mu + 30 \left(\lambda^{A}\right)^{3} \lambda^{o} \mu^{2} + 20 \left(\lambda^{A}\right)^{3} \mu^{3} + 5 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{4} + 12 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} \mu + 18 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu^{2} + 20 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu^{3} + 15 \left(\lambda^{A}\right)^{2} \mu^{4} + \lambda^{A} \left(\lambda^{o}\right)^{5} + 2 \lambda^{A} \left(\lambda^{o}\right)^{4} \mu + 3 \lambda^{A} \left(\lambda^{o}\right)^{3} \mu^{2} + 4 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu^{3} + 5 \lambda^{A} \lambda^{o} \mu^{4} + 6 \lambda^{A} \mu^{5} + \mu^{6}$

```python
sym.expand(P00_1181_simplified)
```

$\displaystyle \left(\lambda^{A}\right)^{7} + 6 \left(\lambda^{A}\right)^{6} \lambda^{o} + 7 \left(\lambda^{A}\right)^{6} \mu + 15 \left(\lambda^{A}\right)^{5} \left(\lambda^{o}\right)^{2} + 30 \left(\lambda^{A}\right)^{5} \lambda^{o} \mu + 21 \left(\lambda^{A}\right)^{5} \mu^{2} + 20 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{3} + 50 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{2} \mu + 60 \left(\lambda^{A}\right)^{4} \lambda^{o} \mu^{2} + 35 \left(\lambda^{A}\right)^{4} \mu^{3} + 15 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{4} + 40 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{3} \mu + 60 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} \mu^{2} + 60 \left(\lambda^{A}\right)^{3} \lambda^{o} \mu^{3} + 35 \left(\lambda^{A}\right)^{3} \mu^{4} + 6 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{5} + 15 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{4} \mu + 24 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} \mu^{2} + 30 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu^{3} + 30 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu^{4} + 21 \left(\lambda^{A}\right)^{2} \mu^{5} + \lambda^{A} \left(\lambda^{o}\right)^{6} + 2 \lambda^{A} \left(\lambda^{o}\right)^{5} \mu + 3 \lambda^{A} \left(\lambda^{o}\right)^{4} \mu^{2} + 4 \lambda^{A} \left(\lambda^{o}\right)^{3} \mu^{3} + 5 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu^{4} + 6 \lambda^{A} \lambda^{o} \mu^{5} + 7 \lambda^{A} \mu^{6} + \mu^{7}$

```python
sym.expand(P00_1191_simplified)
```

$\displaystyle \left(\lambda^{A}\right)^{8} + 7 \left(\lambda^{A}\right)^{7} \lambda^{o} + 8 \left(\lambda^{A}\right)^{7} \mu + 21 \left(\lambda^{A}\right)^{6} \left(\lambda^{o}\right)^{2} + 42 \left(\lambda^{A}\right)^{6} \lambda^{o} \mu + 28 \left(\lambda^{A}\right)^{6} \mu^{2} + 35 \left(\lambda^{A}\right)^{5} \left(\lambda^{o}\right)^{3} + 90 \left(\lambda^{A}\right)^{5} \left(\lambda^{o}\right)^{2} \mu + 105 \left(\lambda^{A}\right)^{5} \lambda^{o} \mu^{2} + 56 \left(\lambda^{A}\right)^{5} \mu^{3} + 35 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{4} + 100 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{3} \mu + 150 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{2} \mu^{2} + 140 \left(\lambda^{A}\right)^{4} \lambda^{o} \mu^{3} + 70 \left(\lambda^{A}\right)^{4} \mu^{4} + 21 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{5} + 60 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{4} \mu + 100 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{3} \mu^{2} + 120 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} \mu^{3} + 105 \left(\lambda^{A}\right)^{3} \lambda^{o} \mu^{4} + 56 \left(\lambda^{A}\right)^{3} \mu^{5} + 7 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{6} + 18 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{5} \mu + 30 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{4} \mu^{2} + 40 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} \mu^{3} + 45 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu^{4} + 42 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu^{5} + 28 \left(\lambda^{A}\right)^{2} \mu^{6} + \lambda^{A} \left(\lambda^{o}\right)^{7} + 2 \lambda^{A} \left(\lambda^{o}\right)^{6} \mu + 3 \lambda^{A} \left(\lambda^{o}\right)^{5} \mu^{2} + 4 \lambda^{A} \left(\lambda^{o}\right)^{4} \mu^{3} + 5 \lambda^{A} \left(\lambda^{o}\right)^{3} \mu^{4} + 6 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu^{5} + 7 \lambda^{A} \lambda^{o} \mu^{6} + 8 \lambda^{A} \mu^{7} + \mu^{8}$

```python

```

## Spanning Tree Perspective

```python
def build_graph_theoretic_model(lambda_2, lambda_1, mu, num_of_servers, threshold, system_capacity, buffer_capacity, nodesize=2000, fontsize=12):

    Q = abg.markov.get_transition_matrix(lambda_2=lambda_2, lambda_1=lambda_1, mu=mu, num_of_servers=num_of_servers, threshold=threshold, system_capacity=system_capacity, buffer_capacity=buffer_capacity)
    all_states = abg.markov.build_states(threshold, system_capacity, buffer_capacity)
    G = nx.DiGraph()
    for i, origin_state in enumerate(all_states):
        for j, destination_state in enumerate(all_states):
            if Q[i,j] > 0:
                G.add_edge(origin_state, destination_state, color="blue", weight=Q[i,j])

    plt.figure(figsize=((system_capacity + 1) * 1.5, (buffer_capacity + 1) * 1.5))
    pos = {state: [state[1], -state[0]] for state in all_states}
    nx.draw_networkx_nodes(
        G,
        pos,
        node_size=nodesize,
        nodelist=[state for state in all_states if state[1] < num_of_servers],
    )
    nx.draw_networkx_nodes(
        G,
        pos,
        node_size=nodesize,
        nodelist=[state for state in all_states if state[1] >= num_of_servers],
        node_color="red",
    )

    nx.draw_networkx_edges(G, pos, arrowstyle="fancy", arrowsize=60)
    nx.draw_networkx_labels(G, pos, font_size=fontsize)

    plt.axis("off")
    return G

```

```python
def number_of_spanning_trees_rooted_at_a_state(M, root=0):

    adjacency_matrix = np.zeros([len(M.nodes), len(M.nodes)])
    for row, u in enumerate(M.nodes):
        for col, v in enumerate(M.nodes):
            if (u,v) in M.edges:
                adjacency_matrix[row, col] += 1

    diagonal_out_degree_matrix = np.diag([j for i,j in M.out_degree])
    laplacian = diagonal_out_degree_matrix - adjacency_matrix

    return np.linalg.det(np.delete(np.delete(laplacian, root, 0), root, 1))
```

```python
num_of_servers = 1
threshold = 1
buffer_capacity = 1
number_of_spanning_trees = []

for system_capacity in range(1,10):
    M = build_graph_theoretic_model(lambda_2=0.1, lambda_1=0.1, mu=0.2, num_of_servers=num_of_servers, threshold=threshold, system_capacity=system_capacity, buffer_capacity=buffer_capacity)
    number_of_spanning_trees.append(round(number_of_spanning_trees_rooted_at_a_state(M, root=0)))
    plt.show()
```

```python
number_of_spanning_trees
```

    [1.0, 2.0, 5.0, 13.0, 34.0, 89.0, 233.0, 610.0, 1597.0]

```python

```

# Combinatorial Function for state probabilities

```python
def get_triangle_read_by_row(n, k):
    # T(n,k) = k * Bin(n, k+1) - Bin(n, k) + 1
    return k * math.comb(n, k+1) - math.comb(n, k) + 1

def get_product_matrix(n, k):
    # T(n,k) = Sum i=k...n,Binomial(i-1,k-1)*i
    return sum([math.comb(i-1, k-1) * i for i in range(k, n+1)])

def get_lambda_2_mu_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1, just_coeff=False):
    if system_capacity > 1:
        lambda_2_mu_values = (sym_lambda_2 + sym_mu) ** (system_capacity - 1)
    else:
        lambda_2_mu_values = 1
    return lambda_2_mu_values if not just_coeff else [math.comb(system_capacity - 1, term) for term in range(system_capacity)]

def get_lambda_2_o_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1, just_coeff=False):
    if system_capacity > 2:
        lambda_2_o_values = sym.expand(sym_lambda_2 * ((sym_lambda_2 + sym_lambda_1) ** (system_capacity - 2) - sym_lambda_2 ** (system_capacity - 2) - sym_lambda_1 ** (system_capacity - 2)) + sym_lambda_2 * sym_lambda_1 ** (system_capacity - 2))
    else:
        lambda_2_o_values = 0
    return lambda_2_o_values if not just_coeff else [math.comb(system_capacity - 2, term) for term in range(1, system_capacity - 1)]

def get_lambda_21_o_mu_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1, just_coeff=False):
    coefficients_lambda_21_o_mu = [term for term in range(2,system_capacity - 1)]
    lambda_21_o_mu_values = sum([coefficients_lambda_21_o_mu[i] * sym_lambda_2 * sym_lambda_1 ** (system_capacity - 3 - i) * sym_mu ** (i+1) for i in range(system_capacity - 3)])
    return lambda_21_o_mu_values if not just_coeff else coefficients_lambda_21_o_mu

def get_lambda_2_o1_mu_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1, just_coeff=False):
    coefficients_lambda_2_o1_mu = [
        math.comb(system_capacity-1, system_capacity-term-3) +
        get_triangle_read_by_row(system_capacity-2, term + 1)
        - 1
        for term in range(system_capacity-4)
        ]
    lambda_2_o1_mu_values = sum([coefficients_lambda_2_o1_mu[i-1] * sym_lambda_2 ** (system_capacity - 2 - i) * sym_lambda_1 * sym_mu ** (i) for i in range(1, system_capacity - 3)])
    return lambda_2_o1_mu_values if not just_coeff else coefficients_lambda_2_o1_mu

def get_lambda_2_o_mu1_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1, just_coeff=False):
    coefficients_lambda_2_o_mu1 = [
        math.comb(system_capacity-1, system_capacity-term-4)
        - math.comb(system_capacity-3, term+1)
        + get_product_matrix(system_capacity-4, term+2)
        for term in range(system_capacity-5)]
    lambda_2_o_mu1_values = sum([coefficients_lambda_2_o_mu1[i-2] * sym_lambda_2 ** (system_capacity - 2 - i) * sym_lambda_1  ** i * sym_mu for i in range(2, system_capacity - 3)])
    return lambda_2_o_mu1_values if not just_coeff else coefficients_lambda_2_o_mu1

def get_P00_combinatorically(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1):

    lambda_2_mu_values = get_lambda_2_mu_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1)
    lambda_2_o_values = get_lambda_2_o_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1)

    lambda_21_o_mu_values = get_lambda_21_o_mu_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1)
    lambda_2_o1_mu_values = get_lambda_2_o1_mu_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1)
    lambda_2_o_mu1_values = get_lambda_2_o_mu1_values(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1)

    return lambda_2_mu_values + lambda_2_o_values + lambda_21_o_mu_values + lambda_2_o1_mu_values + lambda_2_o_mu1_values

```

```python

```

```python
sym.expand(P00_1141_simplified)
```

$\displaystyle \left(\lambda^{A}\right)^{3} + 2 \left(\lambda^{A}\right)^{2} \lambda^{o} + 3 \left(\lambda^{A}\right)^{2} \mu + \lambda^{A} \left(\lambda^{o}\right)^{2} + 2 \lambda^{A} \lambda^{o} \mu + 3 \lambda^{A} \mu^{2} + \mu^{3}$

```python
sym.expand(P00_1111_simplified) - sym.expand(get_P00_combinatorically(1))
```

$\displaystyle 0$

```python
sym.expand(P00_1121_simplified) - sym.expand(get_P00_combinatorically(2))
```

$\displaystyle 0$

```python
sym.expand(P00_1131_simplified) - sym.expand(get_P00_combinatorically(3))
```

$\displaystyle 0$

```python
sym.expand(P00_1141_simplified) - sym.expand(get_P00_combinatorically(4))
```

$\displaystyle 0$

```python
sym.expand(P00_1151_simplified) - sym.expand(get_P00_combinatorically(5))
```

$\displaystyle 0$

```python
sym.expand(P00_1161_simplified) - sym.expand(get_P00_combinatorically(6))
```

$\displaystyle 0$

```python
sym.expand(P00_1171_simplified) - sym.expand(get_P00_combinatorically(7))
```

$\displaystyle 18 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu^{2}$

```python
sym.expand(P00_1181_simplified) - sym.expand(get_P00_combinatorically(8))
```

$\displaystyle 60 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} \mu^{2} + 24 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} \mu^{2} + 30 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu^{3}$

```python
sym.expand(P00_1191_simplified) - sym.expand(get_P00_combinatorically(9))
```

$\displaystyle 150 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{2} \mu^{2} + 100 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{3} \mu^{2} + 120 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} \mu^{3} + 30 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{4} \mu^{2} + 40 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} \mu^{3} + 45 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu^{4}$

```python

```

```python

```

## Listing Coefficients based on different terms

## $\lambda_2^i \mu^j$

```python
# Pascal Traingle
for system_capacity in range(10):
    print(get_lambda_2_mu_values(system_capacity, just_coeff=True))
```

    []
    [1]
    [1, 1]
    [1, 2, 1]
    [1, 3, 3, 1]
    [1, 4, 6, 4, 1]
    [1, 5, 10, 10, 5, 1]
    [1, 6, 15, 20, 15, 6, 1]
    [1, 7, 21, 35, 35, 21, 7, 1]
    [1, 8, 28, 56, 70, 56, 28, 8, 1]

## $\lambda_2^i \lambda_1^j$

```python
# Pascal Traingle
for system_capacity in range(10):
    print(get_lambda_2_o_values(system_capacity, just_coeff=True))
```

    []
    []
    []
    [1]
    [2, 1]
    [3, 3, 1]
    [4, 6, 4, 1]
    [5, 10, 10, 5, 1]
    [6, 15, 20, 15, 6, 1]
    [7, 21, 35, 35, 21, 7, 1]

## $\lambda_2 \lambda_1 \mu$

```python
# Coefficients when combining all three terms
print([2])
print([6, 2, 3])
print([12 ,9 ,12, 2, 3, 4])
print([20, 24, 30 ,12, 18, 20, 2, 3, 4, 5])
print([30, 50, 60, 40, 60, 60, 15, 24, 30, 30, 2, 3, 4, 5, 6])
```

    [2]
    [6, 2, 3]
    [12, 9, 12, 2, 3, 4]
    [20, 24, 30, 12, 18, 20, 2, 3, 4, 5]
    [30, 50, 60, 40, 60, 60, 15, 24, 30, 30, 2, 3, 4, 5, 6]

## $\lambda_2^1 \lambda_1^i \mu^j$

```python
# Simple Triangle
for system_capacity in range(10):
    print(get_lambda_21_o_mu_values(system_capacity, just_coeff=True))
```

    []
    []
    []
    []
    [2]
    [2, 3]
    [2, 3, 4]
    [2, 3, 4, 5]
    [2, 3, 4, 5, 6]
    [2, 3, 4, 5, 6, 7]

## $\lambda_2^i \lambda_1^1 \mu^j$

```python
# Combinatorial Expression - 1 + A110952
for system_capacity in range(10):
    print(get_lambda_2_o1_mu_values(system_capacity, just_coeff=True))
```

    []
    []
    []
    []
    []
    [6]
    [12, 12]
    [20, 30, 20]
    [30, 60, 60, 30]
    [42, 105, 140, 105, 42]

```python

```

## $\lambda_2^i \lambda_1^j \mu^1$

```python
# Combinatorial Expression - Pascal Triangle + A127717
for system_capacity in range(10):
    print(get_lambda_2_o_mu1_values(system_capacity, just_coeff=True))
```

    []
    []
    []
    []
    []
    []
    [9]
    [24, 12]
    [50, 40, 15]
    [90, 100, 60, 18]

```python

```

```python

```

## Listing coefficients based on system capacity

```python
system_capacity = 2
print("λ^α μ      :", get_lambda_2_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο    :", get_lambda_2_o_values(system_capacity, just_coeff=True))
print("λ^α^1 λ^ο μ:", get_lambda_21_o_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο^1 μ:", get_lambda_2_o1_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο μ^1:", get_lambda_2_o_mu1_values(system_capacity, just_coeff=True))
P00_1121_simplified
```

    λ^α μ      :[1, 1]
    λ^α λ^ο    :[]
    λ^α^1 λ^ο μ:[]
    λ^α λ^ο^1 μ:[]
    λ^α λ^ο μ^1:[]

$\displaystyle \lambda^{A} + \mu$

```python
system_capacity = 3
print("λ^α μ      :", get_lambda_2_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο    :", get_lambda_2_o_values(system_capacity, just_coeff=True))
print("λ^α^1 λ^ο μ:", get_lambda_21_o_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο^1 μ:", get_lambda_2_o1_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο μ^1:", get_lambda_2_o_mu1_values(system_capacity, just_coeff=True))
sym.expand(P00_1131_simplified)
```

    λ^α μ      :[1, 2, 1]
    λ^α λ^ο    :[1]
    λ^α^1 λ^ο μ:[]
    λ^α λ^ο^1 μ:[]
    λ^α λ^ο μ^1:[]

$\displaystyle \left(\lambda^{A}\right)^{2} + \lambda^{A} \lambda^{o} + 2 \lambda^{A} \mu + \mu^{2}$

```python
system_capacity = 4
print("λ^α μ      :", get_lambda_2_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο    :", get_lambda_2_o_values(system_capacity, just_coeff=True))
print("λ^α^1 λ^ο μ:", get_lambda_21_o_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο^1 μ:", get_lambda_2_o1_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο μ^1:", get_lambda_2_o_mu1_values(system_capacity, just_coeff=True))
sym.expand(P00_1141_simplified)
```

    λ^α μ      :[1, 3, 3, 1]
    λ^α λ^ο    :[2, 1]
    λ^α^1 λ^ο μ:[2]
    λ^α λ^ο^1 μ:[]
    λ^α λ^ο μ^1:[]

$\displaystyle \left(\lambda^{A}\right)^{3} + 2 \left(\lambda^{A}\right)^{2} \lambda^{o} + 3 \left(\lambda^{A}\right)^{2} \mu + \lambda^{A} \left(\lambda^{o}\right)^{2} + 2 \lambda^{A} \lambda^{o} \mu + 3 \lambda^{A} \mu^{2} + \mu^{3}$

```python
system_capacity = 5
print("λ^α μ      :", get_lambda_2_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο    :", get_lambda_2_o_values(system_capacity, just_coeff=True))
print("λ^α^1 λ^ο μ:", get_lambda_21_o_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο^1 μ:", get_lambda_2_o1_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο μ^1:", get_lambda_2_o_mu1_values(system_capacity, just_coeff=True))
sym.expand(P00_1151_simplified)
```

    λ^α μ      :[1, 4, 6, 4, 1]
    λ^α λ^ο    :[3, 3, 1]
    λ^α^1 λ^ο μ:[2, 3]
    λ^α λ^ο^1 μ:[6]
    λ^α λ^ο μ^1:[]

$\displaystyle \left(\lambda^{A}\right)^{4} + 3 \left(\lambda^{A}\right)^{3} \lambda^{o} + 4 \left(\lambda^{A}\right)^{3} \mu + 3 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} + 6 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu + 6 \left(\lambda^{A}\right)^{2} \mu^{2} + \lambda^{A} \left(\lambda^{o}\right)^{3} + 2 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu + 3 \lambda^{A} \lambda^{o} \mu^{2} + 4 \lambda^{A} \mu^{3} + \mu^{4}$

```python
system_capacity = 6
print("λ^α μ      :", get_lambda_2_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο    :", get_lambda_2_o_values(system_capacity, just_coeff=True))
print("λ^α^1 λ^ο μ:", get_lambda_21_o_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο^1 μ:", get_lambda_2_o1_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο μ^1:", get_lambda_2_o_mu1_values(system_capacity, just_coeff=True))
sym.expand(P00_1161_simplified)
```

    λ^α μ      :[1, 5, 10, 10, 5, 1]
    λ^α λ^ο    :[4, 6, 4, 1]
    λ^α^1 λ^ο μ:[2, 3, 4]
    λ^α λ^ο^1 μ:[12, 12]
    λ^α λ^ο μ^1:[9]

$\displaystyle \left(\lambda^{A}\right)^{5} + 4 \left(\lambda^{A}\right)^{4} \lambda^{o} + 5 \left(\lambda^{A}\right)^{4} \mu + 6 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} + 12 \left(\lambda^{A}\right)^{3} \lambda^{o} \mu + 10 \left(\lambda^{A}\right)^{3} \mu^{2} + 4 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} + 9 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu + 12 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu^{2} + 10 \left(\lambda^{A}\right)^{2} \mu^{3} + \lambda^{A} \left(\lambda^{o}\right)^{4} + 2 \lambda^{A} \left(\lambda^{o}\right)^{3} \mu + 3 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu^{2} + 4 \lambda^{A} \lambda^{o} \mu^{3} + 5 \lambda^{A} \mu^{4} + \mu^{5}$

```python
system_capacity = 7
print("λ^α μ      :", get_lambda_2_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο    :", get_lambda_2_o_values(system_capacity, just_coeff=True))
print("λ^α^1 λ^ο μ:", get_lambda_21_o_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο^1 μ:", get_lambda_2_o1_mu_values(system_capacity, just_coeff=True))
print("λ^α λ^ο μ^1:", get_lambda_2_o_mu1_values(system_capacity, just_coeff=True))
sym.expand(P00_1171_simplified)
```

    λ^α μ      :[1, 6, 15, 20, 15, 6, 1]
    λ^α λ^ο    :[5, 10, 10, 5, 1]
    λ^α^1 λ^ο μ:[2, 3, 4, 5]
    λ^α λ^ο^1 μ:[20, 30, 20]
    λ^α λ^ο μ^1:[24, 12]

$\displaystyle \left(\lambda^{A}\right)^{6} + 5 \left(\lambda^{A}\right)^{5} \lambda^{o} + 6 \left(\lambda^{A}\right)^{5} \mu + 10 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{2} + 20 \left(\lambda^{A}\right)^{4} \lambda^{o} \mu + 15 \left(\lambda^{A}\right)^{4} \mu^{2} + 10 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{3} + 24 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} \mu + 30 \left(\lambda^{A}\right)^{3} \lambda^{o} \mu^{2} + 20 \left(\lambda^{A}\right)^{3} \mu^{3} + 5 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{4} + 12 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} \mu + 18 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu^{2} + 20 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu^{3} + 15 \left(\lambda^{A}\right)^{2} \mu^{4} + \lambda^{A} \left(\lambda^{o}\right)^{5} + 2 \lambda^{A} \left(\lambda^{o}\right)^{4} \mu + 3 \lambda^{A} \left(\lambda^{o}\right)^{3} \mu^{2} + 4 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu^{3} + 5 \lambda^{A} \lambda^{o} \mu^{4} + 6 \lambda^{A} \mu^{5} + \mu^{6}$

```python

```

```python

```

# Better Combinatorial Function

```python
def get_triangle_read_by_row(n, k):
    # T(n,k) = k * Bin(n, k+1) - Bin(n, k) + 1
    return k * math.comb(n, k+1) - math.comb(n, k) + 1

def get_product_matrix(n, k):
    # T(n,k) = Sum i=k...n,Binomial(i-1,k-1)*i
    return sum([math.comb(i-1, k-1) * i for i in range(k, n+1)])

def term_coefficient(p1, p2, p3):
    """This function breaks down the coefficients of each term in 6 cases. When the term includes the expression:
            - lambda_2^0: the term has a coefficient of 1
            - lambda_1^0: the term has a binomial coefficient of (C-1 CHOOSE p3)
            - mu^0: the term has a binomial coefficient of (C-2 CHOOSE p2)

        Now given that none of the above expressions occurs then the term may include:
            - lambda_2^1: The term has a coefficient of (C - p2 - 1)
            - lambda_1^1: The term has a coefficient of (C-1 CHOOSE p1) - 1 + Trinagle_sequence(C-2, p3)
            - mu^1: The term has a coefficient of (C-1 CHOOSE p1) - (C-3 CHOOSE (p2-1)) + Product_matrix(C-4, p2)
    """
    if p1 == 0 and p2 > 0:
        return 0
    if p1 == 0 and p2 == 0 and p3 > 0:
        return 1
    if p2 == 0 and p1 > 0:
        return math.comb(p1 + p3, p3)
    if p3 == 0 and p1 > 0 and p2 > 0:
        return math.comb(p1 + p2 - 1, p2)
    if p1 == 1:
        return p3 + 1
    if p2 == 1 and p1 > 1:
        return math.comb(p1 + p3 + 1, p1) - 1 + get_triangle_read_by_row(p1 + p3, p3)
    if p3 == 1:
        return math.comb(p1 + p2 + 1, p1) - math.comb(p1 + p2 -1, p2 - 1) + get_product_matrix(p1 + p2 - 2, p2)

    return sym.symbols("U" + str(p1) + str(p2) + str(p3))
```

```python
def get_00_rate_combinatorically(system_capacity, num_of_servers=1, threshold=1, buffer_capacity=1):

    rate_00 = 0
    for p1 in np.linspace(system_capacity - 1, 0, system_capacity, dtype=int):
        for p2 in np.linspace(system_capacity - p1 - 1, 0, system_capacity - p1, dtype=int):
            p3 = system_capacity - p1 - p2 - 1

            rate_00 += term_coefficient(p1, p2, p3) * sym_lambda_2 ** p1 * sym_lambda_1 ** p2 * sym_mu ** p3

    return rate_00

get_00_rate_combinatorically(7)
```

$\displaystyle U_{222} \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu^{2} + \left(\lambda^{A}\right)^{6} + 5 \left(\lambda^{A}\right)^{5} \lambda^{o} + 6 \left(\lambda^{A}\right)^{5} \mu + 10 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{2} + 20 \left(\lambda^{A}\right)^{4} \lambda^{o} \mu + 15 \left(\lambda^{A}\right)^{4} \mu^{2} + 10 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{3} + 24 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} \mu + 30 \left(\lambda^{A}\right)^{3} \lambda^{o} \mu^{2} + 20 \left(\lambda^{A}\right)^{3} \mu^{3} + 5 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{4} + 12 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} \mu + 20 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu^{3} + 15 \left(\lambda^{A}\right)^{2} \mu^{4} + \lambda^{A} \left(\lambda^{o}\right)^{5} + 2 \lambda^{A} \left(\lambda^{o}\right)^{4} \mu + 3 \lambda^{A} \left(\lambda^{o}\right)^{3} \mu^{2} + 4 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu^{3} + 5 \lambda^{A} \lambda^{o} \mu^{4} + 6 \lambda^{A} \mu^{5} + \mu^{6}$

```python
sym.expand(P00_1171_simplified) #- get_00_rate_combinatorically(5)
```

$\displaystyle \left(\lambda^{A}\right)^{6} + 5 \left(\lambda^{A}\right)^{5} \lambda^{o} + 6 \left(\lambda^{A}\right)^{5} \mu + 10 \left(\lambda^{A}\right)^{4} \left(\lambda^{o}\right)^{2} + 20 \left(\lambda^{A}\right)^{4} \lambda^{o} \mu + 15 \left(\lambda^{A}\right)^{4} \mu^{2} + 10 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{3} + 24 \left(\lambda^{A}\right)^{3} \left(\lambda^{o}\right)^{2} \mu + 30 \left(\lambda^{A}\right)^{3} \lambda^{o} \mu^{2} + 20 \left(\lambda^{A}\right)^{3} \mu^{3} + 5 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{4} + 12 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{3} \mu + 18 \left(\lambda^{A}\right)^{2} \left(\lambda^{o}\right)^{2} \mu^{2} + 20 \left(\lambda^{A}\right)^{2} \lambda^{o} \mu^{3} + 15 \left(\lambda^{A}\right)^{2} \mu^{4} + \lambda^{A} \left(\lambda^{o}\right)^{5} + 2 \lambda^{A} \left(\lambda^{o}\right)^{4} \mu + 3 \lambda^{A} \left(\lambda^{o}\right)^{3} \mu^{2} + 4 \lambda^{A} \left(\lambda^{o}\right)^{2} \mu^{3} + 5 \lambda^{A} \lambda^{o} \mu^{4} + 6 \lambda^{A} \mu^{5} + \mu^{6}$

```python

```


```python
%matplotlib inline
```

`Learn the Basics <intro.html>`_ ||
`Quickstart <quickstart_tutorial.html>`_ ||
`Tensors <tensorqs_tutorial.html>`_ ||
`Datasets & DataLoaders <data_tutorial.html>`_ ||
`Transforms <transforms_tutorial.html>`_ ||
`Build Model <buildmodel_tutorial.html>`_ ||
**Autograd** ||
`Optimization <optimization_tutorial.html>`_ ||
`Save & Load Model <saveloadrun_tutorial.html>`_

Automatic Differentiation with ``torch.autograd``
=======================================

When training neural networks, the most frequently used algorithm is
**back propagation**. In this algorithm, parameters (model weights) are
adjusted according to the **gradient** of the loss function with respect
to the given parameter.

To compute those gradients, PyTorch has a built-in differentiation engine
called ``torch.autograd``. It supports automatic computation of gradient for any
computational graph.

Consider the simplest one-layer neural network, with input ``x``,
parameters ``w`` and ``b``, and some loss function. It can be defined in
PyTorch in the following manner:

```python
import torch

x = torch.ones(5)  # input tensor
y = torch.zeros(3)  # expected output
w = torch.randn(5, 3, requires_grad=True)
b = torch.randn(3, requires_grad=True)
z = torch.matmul(x, w)+b
loss = torch.nn.functional.binary_cross_entropy_with_logits(z, y)
```

```python
loss
```

    tensor(1.6798, grad_fn=<BinaryCrossEntropyWithLogitsBackward>)

Tensors, Functions and Computational graph
------------------------------------------

This code defines the following **computational graph**:

.. figure:: /_static/img/basics/comp-graph.png
   :alt:

In this network, ``w`` and ``b`` are **parameters**, which we need to
optimize. Thus, we need to be able to compute the gradients of loss
function with respect to those variables. In order to do that, we set
the ``requires_grad`` property of those tensors.

Note

You can set the value of ``requires_grad`` when creating a
          tensor, or later by using ``x.requires_grad_(True)`` method.

A function that we apply to tensors to construct computational graph is
in fact an object of class ``Function``. This object knows how to
compute the function in the *forward* direction, and also how to compute
its derivative during the *backward propagation* step. A reference to
the backward propagation function is stored in ``grad_fn`` property of a
tensor. You can find more information of ``Function`` `in the
documentation <https://pytorch.org/docs/stable/autograd.html#function>`__.

```python
print('Gradient function for z =',z.grad_fn)
print('Gradient function for loss =', loss.grad_fn)
```

    Gradient function for z = <AddBackward0 object at 0x7f05d869bc10>
    Gradient function for loss = <BinaryCrossEntropyWithLogitsBackward object at 0x7f05d869bcd0>

Computing Gradients
-------------------

To optimize weights of parameters in the neural network, we need to
compute the derivatives of our loss function with respect to parameters,
namely, we need $\frac{\partial loss}{\partial w}$ and
$\frac{\partial loss}{\partial b}$ under some fixed values of
``x`` and ``y``. To compute those derivatives, we call
``loss.backward()``, and then retrieve the values from ``w.grad`` and
``b.grad``:

```python
loss.backward()
print(w.grad)
print(b.grad)
```

    tensor([[0.0285, 0.2014, 0.3274],
            [0.0285, 0.2014, 0.3274],
            [0.0285, 0.2014, 0.3274],
            [0.0285, 0.2014, 0.3274],
            [0.0285, 0.2014, 0.3274]])
    tensor([0.0285, 0.2014, 0.3274])

```python

```

Note

- We can only obtain the ``grad`` properties for the leaf
    nodes of the computational graph, which have ``requires_grad`` property
    set to ``True``. For all other nodes in our graph, gradients will not be
    available.
  - We can only perform gradient calculations using
    ``backward`` once on a given graph, for performance reasons. If we need
    to do several ``backward`` calls on the same graph, we need to pass
    ``retain_graph=True`` to the ``backward`` call.

Disabling Gradient Tracking
---------------------------

By default, all tensors with ``requires_grad=True`` are tracking their
computational history and support gradient computation. However, there
are some cases when we do not need to do that, for example, when we have
trained the model and just want to apply it to some input data, i.e. we
only want to do *forward* computations through the network. We can stop
tracking computations by surrounding our computation code with
``torch.no_grad()`` block:

```python
z = torch.matmul(x, w)+b
print(z.requires_grad)

with torch.no_grad():
    z = torch.matmul(x, w)+b
print(z.requires_grad)
```

    True
    False

Another way to achieve the same result is to use the ``detach()`` method
on the tensor:

```python
z = torch.matmul(x, w)+b
z_det = z.detach()
print(z_det.requires_grad)
```

    False

There are reasons you might want to disable gradient tracking:
  - To mark some parameters in your neural network at **frozen parameters**. This is
    a very common scenario for
    `finetuning a pretrained network <https://pytorch.org/tutorials/beginner/finetuning_torchvision_models_tutorial.html>`__
  - To **speed up computations** when you are only doing forward pass, because computations on tensors that do
    not track gradients would be more efficient.

More on Computational Graphs
----------------------------
Conceptually, autograd keeps a record of data (tensors) and all executed
operations (along with the resulting new tensors) in a directed acyclic
graph (DAG) consisting of
`Function <https://pytorch.org/docs/stable/autograd.html#torch.autograd.Function>`__
objects. In this DAG, leaves are the input tensors, roots are the output
tensors. By tracing this graph from roots to leaves, you can
automatically compute the gradients using the chain rule.

In a forward pass, autograd does two things simultaneously:

- run the requested operation to compute a resulting tensor
- maintain the operation’s *gradient function* in the DAG.

The backward pass kicks off when ``.backward()`` is called on the DAG
root. ``autograd`` then:

- computes the gradients from each ``.grad_fn``,
- accumulates them in the respective tensor’s ``.grad`` attribute
- using the chain rule, propagates all the way to the leaf tensors.

Note

**DAGs are dynamic in PyTorch**
  An important thing to note is that the graph is recreated from scratch; after each
  ``.backward()`` call, autograd starts populating a new graph. This is
  exactly what allows you to use control flow statements in your model;
  you can change the shape, size and operations at every iteration if
  needed.

Optional Reading: Tensor Gradients and Jacobian Products
--------------------------------------

In many cases, we have a scalar loss function, and we need to compute
the gradient with respect to some parameters. However, there are cases
when the output function is an arbitrary tensor. In this case, PyTorch
allows you to compute so-called **Jacobian product**, and not the actual
gradient.

For a vector function $\vec{y}=f(\vec{x})$, where
$\vec{x}=\langle x_1,\dots,x_n\rangle$ and
$\vec{y}=\langle y_1,\dots,y_m\rangle$, a gradient of
$\vec{y}$ with respect to $\vec{x}$ is given by **Jacobian
matrix**:

\begin{align}J=\left(\begin{array}{ccc}
      \frac{\partial y_{1}}{\partial x_{1}} & \cdots & \frac{\partial y_{1}}{\partial x_{n}}\\
      \vdots & \ddots & \vdots\\
      \frac{\partial y_{m}}{\partial x_{1}} & \cdots & \frac{\partial y_{m}}{\partial x_{n}}
      \end{array}\right)\end{align}

Instead of computing the Jacobian matrix itself, PyTorch allows you to
compute **Jacobian Product** $v^T\cdot J$ for a given input vector
$v=(v_1 \dots v_m)$. This is achieved by calling ``backward`` with
$v$ as an argument. The size of $v$ should be the same as
the size of the original tensor, with respect to which we want to
compute the product:

```python
inp = torch.eye(5, requires_grad=True)
out = (inp+1).pow(2)
out.backward(torch.ones_like(inp), retain_graph=True)
print("First call\n", inp.grad)
out.backward(torch.ones_like(inp), retain_graph=True)
print("\nSecond call\n", inp.grad)
inp.grad.zero_()
out.backward(torch.ones_like(inp), retain_graph=True)
print("\nCall after zeroing gradients\n", inp.grad)
```

    First call
     tensor([[4., 2., 2., 2., 2.],
            [2., 4., 2., 2., 2.],
            [2., 2., 4., 2., 2.],
            [2., 2., 2., 4., 2.],
            [2., 2., 2., 2., 4.]])

    Second call
     tensor([[8., 4., 4., 4., 4.],
            [4., 8., 4., 4., 4.],
            [4., 4., 8., 4., 4.],
            [4., 4., 4., 8., 4.],
            [4., 4., 4., 4., 8.]])

    Call after zeroing gradients
     tensor([[4., 2., 2., 2., 2.],
            [2., 4., 2., 2., 2.],
            [2., 2., 4., 2., 2.],
            [2., 2., 2., 4., 2.],
            [2., 2., 2., 2., 4.]])

Notice that when we call ``backward`` for the second time with the same
argument, the value of the gradient is different. This happens because
when doing ``backward`` propagation, PyTorch **accumulates the
gradients**, i.e. the value of computed gradients is added to the
``grad`` property of all leaf nodes of computational graph. If you want
to compute the proper gradients, you need to zero out the ``grad``
property before. In real-life training an *optimizer* helps us to do
this.

Note

Previously we were calling ``backward()`` function without
          parameters. This is essentially equivalent to calling
          ``backward(torch.tensor(1.0))``, which is a useful way to compute the
          gradients in case of a scalar-valued function, such as loss during
          neural network training.

--------------

Further Reading
~~~~~~~~~~~~~~~~~
- `Autograd Mechanics <https://pytorch.org/docs/stable/notes/autograd.html>`_


# Evaluating classifiers

### Concepts
- Accuracy
- Confusion Matrix
- Precision
- Recall (also called Sensitivity)
- F1-Score
- ROC-Curve
- AUC (Area under Curve)

---

* **Learning objective**:
    * Understand the main tools available to judge your model
    * **sklearn.metrics** is your friend!
* **Content to be covered**:
    * True & False /  Positives & Negatives:
    * Accuracy
    * Precision vs Recall
    * ROC and AUC
    * Confusion Matrix
* **First the theory, then the applied coding**

---

## True & False /  Positives & Negatives:

| Scenario | Predicted: NO | Predicted: YES |
| --- | ----- | --- |
| **Actual: NO** | True Negative | False Positive |
| **Actual: YES** | False Negative | True Positive |

- True means model and data agree

- False means model and data disagree

- Positive means event is triggered - 1 value

- Negative means an event is not triggered - 0 value

**True Positive** - We predicted the major would survive and he did so we have a True positive (1) outcome.

**True Negative** - We predicted the person would die and she did so we got a True negative (0) outcome.

**False Positive** - We predicted someone would survive and little chimney sweep Bertie died so we have a False Positive prediction that he would survive.

**False Negative** - incorrectly predict a negative outcome - We predicted big fat Count von Marx would die but he survived therefore our negative (0) prediction of death was FALSE - False Negative!

## Examples

#### Scenario 1: Gold Diggers!
* We build a scanner which scans earth samples from different regions for gold. If we find it we excavate that area.
* Important to consider: Excavating earth is expensive! So if we don't find gold we lose lots of money!
* Input: 100 diamonds. the model says: 70 real, 30 fake. Actually, 90 real, 10 fake
* How good is our model?
* Want to minimise missed opportunities

#### Scenario 2: Test for Breast Cancer
* We build a tool which tests mammograms for the presence of breast cancer
* Input: 100 mammograms. The model says: 70 clear, 30 cancerous. Actually, 90 clear, 10 cancerous
* How good is our model?
* We want to minimise risk!
* **Lets work through a practical application of scenario 2**

Here, we've included a lot of False Positives!

```python
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
import pandas as pd
from matplotlib import pyplot as plt
%matplotlib inline
```

#### X data is a matrix (more than 1 dimension), y is a vector(only 1 dimension)!

```python
X, y = load_breast_cancer(return_X_y = True)
```

```python
print(X.shape, y.shape)
```

    (569, 30) (569,)

```python
y[:50] # Series of 1s and 0s telling you who does and doesn't
       # have cancer
```

    array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1,
           0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,
           0, 0, 1, 0, 1, 1])

```python
X[0] # these are the a coefficient values
```

    array([1.799e+01, 1.038e+01, 1.228e+02, 1.001e+03, 1.184e-01, 2.776e-01,
           3.001e-01, 1.471e-01, 2.419e-01, 7.871e-02, 1.095e+00, 9.053e-01,
           8.589e+00, 1.534e+02, 6.399e-03, 4.904e-02, 5.373e-02, 1.587e-02,
           3.003e-02, 6.193e-03, 2.538e+01, 1.733e+01, 1.846e+02, 2.019e+03,
           1.622e-01, 6.656e-01, 7.119e-01, 2.654e-01, 4.601e-01, 1.189e-01])

```python
from sklearn.linear_model import LogisticRegression
```

```python
m = LogisticRegression()   #creating a model
m.fit(X, y)                # training the model
```

    /Users/maximcondon/anaconda3/lib/python3.7/site-packages/sklearn/linear_model/logistic.py:433: FutureWarning: Default solver will be changed to 'lbfgs' in 0.22. Specify a solver to silence this warning.
      FutureWarning)

    LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True,
              intercept_scaling=1, max_iter=100, multi_class='warn',
              n_jobs=None, penalty='l2', random_state=None, solver='warn',
              tol=0.0001, verbose=0, warm_start=False)

```python
m.score(X, y)     # calculating the score == accuracy score
```

    0.9595782073813708

---

## Accuracy:
* The percentage of correct guesses
* A nice simple way of assessing the model
* Fails with class imbalance

A common metric in classification, it fails when we have highly imbalanced classes. In those cases, F1 is more appropriate!

How to do an accuracy score without m.score:

```python
from sklearn.metrics import accuracy_score    #import
ypred = m.predict(X)        #generating predictive results
accuracy_score(y, ypred)    # compare the results with our actual results
```

    0.9595782073813708

---

### Precision vs Recall
* A trade off between exactness and totalness
* Precision = **exactness**
    * High Precision = more relevant than irrelevant results returned (at the expense of missing some relevant ones)
    * Also called False Positive Rate
    * High when false positives are low

**Precision is the ability of a classifier to not label a true negative as positive!**

\begin{align}
\frac{True Positive}{True Positive + False Positive}
\end{align}

* Recall = **totalness**.
    * High Recall = most of the relevant results returned (at the expense of including bad results)
    * Also called True Positive Rate
    * High when false negatives are low
* Remember the two scenarios! Which one suits which measure?

Recall is all about real positives!

**Recall is the ability of the classifier to find positive examples. If we wanted to be certain to find all positive examples, we could maximise recall!**

\begin{align}
\ True  Postive  Rate  (TPR) = \frac{True Positives}{True Positives + False Negatives}
\end{align}

i.e. Precision - exact - If it's exact, it means it doesn't make many mistakes eg in gold example it's good to have high precision even if you miss some.

Recall is making sure False negatives are low (ie predicts it's false but actually True) - better to incorrectly tell people they have breast cancer and then correct it then tell someone they don't have it and they do!

```python
from sklearn.metrics import precision_score, recall_score
```

```python
precision_score(y, ypred)
```

    0.9613259668508287

So here we have few false positives (i.e. when your model tells someone they have breast cancer but they don't) so precision is high: 4% chance we tell someone they have it but they don't.

```python
recall_score(y, ypred)
```

    0.9747899159663865

This is very high! Here we have few false negatives - ie your model tells someone they DON'T have breast cancer but your model is wrong and they do - 2.5% chance we tell someone they don't have breast cancer but they do! This is what we want to avoid!

---

## Confusion matrix

* Lets plot the confusion matrix of scenario two, and then prettify it with seaborn
* Refer above to understand the structure

```python
from sklearn.metrics import confusion_matrix
confusion = confusion_matrix(y, ypred)
confusion
```

    array([[198,  14],
           [  9, 348]])

**Remember:**

| Scenario | Predicted: NO | Predicted: YES |
| --- | ----- | --- |
| **Actual: NO** |True Negative | False Positive |
| **Actual: YES** | False Negative | True Positive |

```python
import numpy as np
import seaborn as sns
from matplotlib import pyplot as plt
%matplotlib inline
```

```python
plt.figure(figsize = (5,5))

sns.heatmap(confusion,           #confusion matrix from above
            xticklabels = np.unique(y), # just labels x-axis
            yticklabels = np.unique(y), # just labels y-axis (unique - removes duplicates)
            cmap = 'Oranges',
            annot = True,      #includes the annotations of the numbers on coloured squares
            fmt = 'g')         # gives us integers on those coloured squares instead of
                               # scientific notation!
plt.xlabel("Predicted")
plt.ylabel("Actual")
```

- Top left corner says NO breast cancer in actuality, and no breast cancer in the model therefore a TRUE NEGATIVE.

- Top right corner says in actuality NO breast cancer, but our model predicted they do therefore a FALSE POSITIVE.

- Bottom left says they DO have breast cancer in actuality, but our model predicted they DON'T so we have a FALSE NEGATIVE.

- Bottom right says in actuality they DO have breast cancer, and our model agrees therefore we have a TRUE POSITIVE.

---

## ROC & AUC

### ROC - Receiver Operating Characteristic

* A bit like the confusion matrix, but it still works when there is class imbalance
* Receiver Operating Characteristics Curve
    * ROC shows the false positive rate (x-axis) versus the true positive rate (y-axis) for a number of different candidate threshold values between 0.0 and 1.0.
    * Put another way, plot recall on the x-axis, and precision on the y-axis

### AUC - Area Under Curve

* The AUC function takes both the true outcomes (0, 1) from the test set and the predicted probabilities for the 1 class. It returns the AUC score between 0.0 and 1.0
* We basically want as much under the curve as possible! So the higher the AUC the better!

```python
X.shape, y.shape
```

    ((569, 30), (569,))

```python
df = pd.DataFrame(X)
df.plot.scatter(0,1, c=y, cmap='jet')

plt.xlabel('x1')
plt.ylabel('x2')
```

```python
df.plot.scatter(0, 1, c=ypred, cmap='jet')
```

```python
from sklearn.metrics import roc_curve, auc, roc_auc_score
```

```python
roc_auc_score(y, ypred)
```

    0.9543760900586651

```python
roc_curve(y, ypred)
```

    (array([0.        , 0.06603774, 1.        ]),
     array([0.        , 0.97478992, 1.        ]),
     array([2, 1, 0]))

**fpr** : Increasing false positive rates such that element i is the false positive rate of predictions with score >= thresholds[i].

**tpr** : Increasing true positive rates such that element i is the true positive rate of predictions with score >= thresholds[i].

**thresholds** : array, shape = [n_thresholds]. Decreasing thresholds on the decision function used to compute fpr and tpr. thresholds[0] represents no instances being predicted and is arbitrarily set to max(y_score) + 1.

```python
fpr, tpr, thresholds = roc_curve(y, ypred)
```

```python
fpr
```

    array([0.        , 0.06603774, 1.        ])

```python
plt.plot(fpr, tpr)
plt.xlabel('False positive rate')
plt.ylabel('True positive rate')
plt.title('ROC curve')
plt.show()
```

```python
print(f'AUC: {round(auc(fpr, tpr)*100, 2)}%')
```

    AUC: 95.44%

### Exercises

1. When is the accuracy not a good metric?

2. Evaluate your model

**easy**: calculate an accuracy. **medium**: calculate precision and recall. **hard**: draw an ROC curve or precision-recall plot

3. Decision Boundary

Plot the decision boundary for two scalar features from the Titanic dataset. Use this code example:

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

from sklearn import datasets
from sklearn.tree import DecisionTreeClassifier

# Loading some example data to play with
iris = datasets.load_iris()
X = iris.data[:, [0, 2]]
y = iris.target
```

```python
X.shape, y.shape
```

    ((150, 2), (150,))

```python
# Train a classification model
m = DecisionTreeClassifier(max_depth=4)
m.fit(X, y)
# What about other models? Try them here as well.
```

    DecisionTreeClassifier(class_weight=None, criterion='gini', max_depth=4,
                max_features=None, max_leaf_nodes=None,
                min_impurity_decrease=0.0, min_impurity_split=None,
                min_samples_leaf=1, min_samples_split=2,
                min_weight_fraction_leaf=0.0, presort=False, random_state=None,
                splitter='best')

```python
m.score(X, y)
```

    0.98

```python
#step size in the mesh
h = 0.01

#Plotting decision regions
#For that, we will assign a color to each point in the mesh [x_min, x_max] * [y_min, y_max]
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
                     np.arange(y_min, y_max, h))

f, ax = plt.subplots(1, 1, sharex='col', sharey='row', figsize=(10, 8))

Xgrid = np.c_[xx.ravel(), yy.ravel()]
Z = m.predict(Xgrid)

# Put the result into a color plot
Z = Z.reshape(xx.shape)
ax.contourf(xx, yy, Z, alpha=0.4)

#plot the training points
ax.scatter(X[:, 0], X[:, 1], c=y, s=50, edgecolor='k')
ax.set_title(f'Decision Tree (depth=4), Training Score: {round(m.score(X, y), 2)}')
plt.xlabel('Sepal Length (cm)')
plt.ylabel('Petal Length (cm)')

plt.show()
```

---


# Physics 256
## Numerical Quadrature

## Last Time

- Simultaneous over relaxation (SOR)
- Application to the Poisson equation (finite charge density)

## Today

- Solving for the magnetic field
- Approximating integrals

## Setting up the Notebook

```python
import matplotlib.pyplot as plt
import numpy as np
%matplotlib inline
plt.style.use('notebook');
%config InlineBackend.figure_format = 'retina'
colors = ["#2078B5", "#FF7F0F", "#2CA12C", "#D72827", "#9467BE", "#8C574B",
            "#E478C2", "#808080", "#BCBE20", "#17BED0", "#AEC8E9", "#FFBC79",
            "#98E08B", "#FF9896", "#C6B1D6", "#C59D94", "#F8B7D3", "#C8C8C8",
           "#DCDC8E", "#9EDAE6"]
```

## The Biot-Savart Law

The integral form of one of Maxwell's equations allows us to solve for the magnetic field $\vec{B}$ for situations with constant current:

\begin{equation}
\vec{B}(\vec{r}) = \frac{\mu_0}{4\pi} \int_C \frac{I \vec{d\ell} \times \vec{r'}}{r'^3}
\end{equation}

where $C$ is a curve parameterizing the wire carying current and $\vec{r'}$ is the distance between the current element $\vec{d\ell}$ and the point of measurement $\vec{r}$.

### Example: Field outside a helix.

Find the magnetic field around a current carrying helical wire that wraps around the $y$-axis parameterized by $w(t) = \left(\cos(\omega t),t,\sin(\omega t)\right)$ for $t \in (-4\pi,4\pi)$.

```python
from mpl_toolkits.mplot3d import Axes3D
from scipy.constants import pi as π

ω = 1

fig = plt.figure(figsize=plt.figaspect(1))
ax = fig.gca(projection='3d')

q = np.linspace(-16,16,1000)
t = np.linspace(-4*π, 4*π, 1000)
x = np.cos(ω*t)
y = t
z = np.sin(ω*t)
ax.plot(np.zeros_like(q),np.zeros_like(q),q, color='k', linewidth=1)
ax.plot(q,np.zeros_like(q),np.zeros_like(q), color='k', linewidth=1)
ax.plot(np.zeros_like(q),q,np.zeros_like(q), color='k', linewidth=1)
ax.plot(x, y, z)
ax.text(0,0,18, 'z', fontsize='18')
ax.text(0,18,0, 'y', fontsize='18')
ax.text(18,0,0, 'x', fontsize='18')
ax.view_init(25, 30)
plt.axis('off');
```

We find:

\begin{equation}
\vec{B} = \frac{\mu_0 I}{4\pi} \int_{-4\pi}^{4\pi} dt \frac{\left(z-\sin \omega t - \omega\cos \omega t(y-t), \omega\sin \omega t(z-\omega\sin \omega t) + \omega\cos \omega t (x-\omega\cos \omega t), - \omega \sin \omega t(y-t) - x + \omega \cos \omega t \right)}{\left[(x-\cos \omega t)^2 + (y-t)^2 + (z-\sin \omega t)^2\right]^{3/2}}
\end{equation}

This integral cannot be solved analytically *except* at some high-symmetry points.  Can we construct a general numerical method to do this?

```python
def dBx(t,x,y,z,ω):
    '''x-component of the B field (in units of μ0I)'''
    r = (x-np.cos(ω*t))**2+(y-t)**2+(z-np.sin(ω*t))**2
    return (1/(4.0*π))*(z - np.sin(ω*t) - ω*np.cos(ω*t)*(y-t))/r**3/2

def dBy(t,x,y,z,ω):
    '''y-component of the B field (in units of μ0I)'''
    r = (x-np.cos(ω*t))**2+(y-t)**2+(z-np.sin(ω*t))**2
    return (1/(4.0*π))*(ω*np.sin(ω*t)*(z-np.sin(ω*t))+ω*np.cos(ω*t)*(x-np.cos(ω*t)))/r**3/2

def dBz(t,x,y,z,ω):
    '''z-component of the B field (in units of μ0I)'''
    r = (x-np.cos(ω*t))**2+(y-t)**2+(z-np.sin(ω*t))**2
    return (1/(4.0*π))*(-ω*np.sin(ω*t)*(y-t)-x+np.cos(ω*t))/r**3/2
```

## Newton-Cotes Quadrature

Consider the general definite integral:

\begin{equation}
I = \int_a^b f(x) dx
\end{equation}

and consider breaking up the region of integration into $N$ equally sized regions of size:

\begin{equation}
\Delta x = \frac{b-a}{N}
\end{equation}

where $N \in \mathbb{Z} \gg 1$.  The first step is to break up the integral into $N$ pieces:

\begin{align}
I &= \int_a^b f(x) dx \newline
&= \int_{x_0}^{x_1} f(x) dx + \int_{x_1}^{x_2} f(x) dx + \cdots + \int_{x_{N-1}}^{x_N} f(x) dx \newline
&= \sum_{i=0}^{N-1} \int_{x_i}^{x_{i+1}} f(x) dx
\end{align}

where we have defined $x_i = a + i\Delta x$.  Different numerical integration algorthims correspond to different approximations for the *top* of the panels.

### Rectangular Rule

We replace the function with a constant value:

\begin{equation}
I_R = \int_{a}^{b} f(x) dx \approx \sum_{i=0}^{N-1} f(x_i) \Delta x .
\end{equation}

### Trapezoidal Rule
We can do better using a Taylor expansion at lowest order which provides a linear approximation to the function.

\begin{align}
I_i &= \int_{x_i}^{x_{i+1}} f(x) dx \approx \Delta x \left[f(x_i) + \frac{1}{2}\left(f(x_{i+1})-f(x_i)\right) \right] \newline
&\approx  \frac{\Delta x}{2} \left[f(x_{i+1}) + f(x_i) \right]
\end{align}

Summing over all panels:

\begin{equation}
I_T   \approx \frac{\Delta x}{2} \left[f(a) + f(b) \right] +  \sum_{i=1}^{N-1} f(x_i) \Delta x .
\end{equation}

<div class="span alert alert-success">
<h2>  Programming challenge </h2>

Complete the code below to implement the trapezoidal rule.

</div>

```python
def trapezoidal_rule(f,x,*params):
    '''The trapezoidal rule for numerical integration of f(x) over x.'''

    a,b = x[0],x[-1]
    Δx = x[1] - x[0]
    I = 0

    I += 0.5*Δx*(f(a,*params)+f(b,*params))
    for n in range(1,x.size-1):
        I += Δx*f(x[n],*params)

    #return Δx*(0.5*(f(a,*params)+f(b,*params)) + np.sum([f(cx,*params) for cx in x[1:-1]]))

    return I
```

### Let's look at the magnetic field components along the $y$-axis

```python
N = 500
t = np.linspace(-4*π,4*π,N)

# along the axis
y = np.linspace(-20,20,N)
x,z = 0,0

Bx = np.zeros_like(y)
By = np.zeros_like(y)
Bz = np.zeros_like(y)

for i in range(N):
    Bx[i] = trapezoidal_rule(dBx,t,x,y[i],z,ω)
    By[i] = trapezoidal_rule(dBy,t,x,y[i],z,ω)
    Bz[i] = trapezoidal_rule(dBz,t,x,y[i],z,ω)
```

```python
plt.plot(y,Bx,label=r'$B_x(0,y,0)$')
plt.plot(y,By, label=r'$B_y(0,y,0)$')
plt.plot(y,Bz, label=r'$B_z(0,y,0)$')

plt.legend(frameon=True, loc='lower right')
plt.xlabel('y')
plt.ylabel(r'$B/\mu_0 I$')
```

## How do we determine the error in these expressions?

Consider the [Euler-Maclaurin series](https://en.wikipedia.org/wiki/Euler%E2%80%93Maclaurin_formula) for the discrete function $f(i)$:

\begin{equation}
\sum_{i=0}^{N-1} f(i) = \int_0^N f(u) du - \frac{1}{2} \left[f(0) + f(N) \right] + \sum_{k=1}^\infty \frac{B_{2k}}{(2k\!)} \left[f^{(2k-1)}(N) - f^{(2k-1)}(0) \right] (\Delta x)^{2k-1}
\end{equation}

where $B_{2k}$ are the [Bernoulii numbers](https://en.wikipedia.org/wiki/Bernoulli_number) and $f^{(k)} = d^k f(u)/du^k$.

If we make the variable transformation: $x = u \Delta x + a$ then $u =0 \Rightarrow x=a$ and $u=N \Rightarrow x=b$.  Also on the LHS, when $u=i$ we have the discrete position $x_i$.  We can write:

\begin{equation}
\sum_{i=0}^{N-1} f(x_i) = \frac{1}{\Delta x} \int_{a}^{b} f(x) dx - \frac{1}{2}\left[ f(a) + f(b)\right] +  \sum_{k=1}^\infty \frac{B_{2k}}{(2k\!)} \left[f^{(2k-1)}(b) - f^{(2k-1)}(a) \right] (\Delta x)^{2k-1}
\end{equation}

and multiplying by $\Delta x$ and rearranging such that our integral $I$ is on the LHS we find:

\begin{equation}
\int_{a}^{b} f(x) dx = \sum_{i=0}^{N-1} \Delta x f(x_i) + \frac{\Delta x}{2}\left[ f(a) + f(b)\right]  -  \frac{(\Delta x)^2}{12} \left[f'(b) - f'(a) \right] + \mathrm{O}\left(\Delta x^4\right) .
\end{equation}

Therefore we see that the errors are:

\begin{eqnarray}
\text{Rectangular:} \quad & \quad \mathrm{O}{\left(\Delta x\right)} \newline
\text{Trapezoidal:} \quad & \quad \mathrm{O}{\left(\Delta x^2\right)}
\end{eqnarray}

where we note that things can go seriously wrong when we have a divergent derivative at the endpoints.

```python

```


```python
from sympy import *
init_printing(use_latex='mathjax')
x, y, z = symbols('x, y, z')
```

Para muchos cálculos que se realizan con matrices, es más útil tener la matriz expresada como un **producto de matrices**. Naturalmente para que esto sea útil, estas nuevas matrices tienen que ser de tipos más sencillos que la original.

Dada una matriz cuadrada, se puede interpretar como una **aplicación lineal de un espacio vectorial en si mismo**. La matriz está escrita con respecto a una base, pero podemos utilizar otra base y entonces la forma de la matriz cambia, a pesar de que la aplicación lineal es la misma. Lo más sencillo sería que que todo endomorfismo se pudiera escribir en una base como una matriz diagonal. Desgraciadamente no siempre es posible, incluso aunque el cuerpo sobre el que trabajemos sea algebraicamente cerrado. En principio trabajamos sobre cuerpo real o el cuerpo complejo.

Para saber si una matriz es diagonalizable tenemos el método **is_diagonalizable**. Una vez que sabemos que es diagonalizable utilizamos **charpoly** para calcular el polinomio característico. Las raíces de este polinomio son los valores propios, que podemos conocer con **eigenvals**. Los vectores propios asociados se obtienen con **eigenvects**. El método más general es **diagonalize**, que nos devuelve la matriz diagonal y además la matriz de cambio de base, esto es, la matriz $P$ tal que:
$$
P^{-1}\cdot A \cdot P = D
$$
siendo $A$ la matriz de partida, $P$ la matriz de cambio de base y $D$ la matriz diagonal.

###
Diagonaliza la matriz:

$$A=
\begin{pmatrix}
1 & 0 &2\\
3& 3&-3\\
1&0&2
\end{pmatrix}
$$

```python

```

```python

```

Dada una matriz simétrica $A$ se puede interpretar como un producto escalar en un espacio vectorial, multiplicando los vectores por ambos lados. La matriz dada es la matriz de la métrica (o forma cuadrática) en  una base. Sin embargo siempre existe una base donde la forma cuadrática se reduce a suma de cuadrados (forma diagonal). Si $A$ es la matriz de partida, la matriz de cambio de base es $L$ y la matriz diagonal $D$, entonces:
$$
A= L\cdot D\cdot L^t
$$
Tanto la matriz $L$ como la $D$ se obtienen con el método **LDLdecomposition**.

###
Diagonaliza la forma cuadrática asociada a la matriz:

$$B=
\begin{pmatrix}
25 & 15 &-5\\
15& 18&0\\
-5&0&11
\end{pmatrix}
$$

```python

```

```python

```

La descomposición LU de una matriz consiste es escribir la matriz $A$ como un producto $LU$ donde $L$ es una matriz triangular inferior (con la diagonal llena de unos) y $U$ una superior. No siempre es posible hallar esta factorización, pero permutando adecuadamente las filas, si es posible. El método **LUdecomposition** devuelve tanto $L$ como $U$, como un vector que indica la permutación de filas que es necesario realizar para poder realizar la factorización. Si la lista es vacía, no se necesitan cambios de filas.

###
Realiza la descomposición LU de la matriz $B$.

```python
L, U, P=A.LUdecomposition()
```

```python
L*U, A
```

$$\begin{pmatrix}\left[\begin{matrix}25 & 15 & -5\\15 & 18 & 0\\-5 & 0 & 11\end{matrix}\right], & \left[\begin{matrix}25 & 15 & -5\\15 & 18 & 0\\-5 & 0 & 11\end{matrix}\right]\end{pmatrix}$$

La descomposición QR de una matriz $A$ consiste en encontrar dos matrices $Q$ y $R$, tales que:

* $A=Q\cdot R$

* $Q$ es una matriz ortogonal ($Q\cdot Q^t=\mathrm{Id})$

* $R$ es triangular superior.

###
Realiza la descomposición QR de la matriz $B$.

```python
A
```

$$\left[\begin{matrix}25 & 15 & -5\\15 & 18 & 0\\-5 & 0 & 11\end{matrix}\right]$$

```python
Q, R =A.QRdecomposition()
```

```python
Q*Q.T
```

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

```python
Q*R, A
```

$$\begin{pmatrix}\left[\begin{matrix}25 & 15 & -5\\15 & 18 & 0\\-5 & 0 & 11\end{matrix}\right], & \left[\begin{matrix}25 & 15 & -5\\15 & 18 & 0\\-5 & 0 & 11\end{matrix}\right]\end{pmatrix}$$

Dada una matriz simétrica y definida positiva, es conocido que se puede encontrar una base ortonormal, donde la matriz se reduce a la identidad. Si $L$ es la matriz de cambio de base, la descomposición LDL se  reduce a la expresión:
$$
L\cdot \mathrm{Id}\cdot  L^t= L\cdot L^t=A
$$
Esta es la conocida como descomposión de Cholesky, que se puede obtener con el método **cholesky**.

###
Realiza la descomposicoón de Cholesky de la matriz $B$.

```python
A.LDLdecomposition()
```

$$\begin{pmatrix}\left[\begin{matrix}1 & 0 & 0\\\frac{3}{5} & 1 & 0\\- \frac{1}{5} & \frac{1}{3} & 1\end{matrix}\right], & \left[\begin{matrix}25 & 0 & 0\\0 & 9 & 0\\0 & 0 & 9\end{matrix}\right]\end{pmatrix}$$

```python
L*L.T, A
```

$$\begin{pmatrix}\left[\begin{matrix}25 & 15 & -5\\15 & 18 & 0\\-5 & 0 & 11\end{matrix}\right], & \left[\begin{matrix}25 & 15 & -5\\15 & 18 & 0\\-5 & 0 & 11\end{matrix}\right]\end{pmatrix}$$

```python

```

notebooks/15.- Factorizacion de matrices.ipynb


#Subclassing and Superclassing: Multimodal SoftMax

Our SoftMax class's `poly` attribute allows us to input a polygon, from which it automatically generates all the normal vectors and turns them into weights (following the process in [Chapter 2](02_from_templates.ipynb)). But, can we specify *any* polygon?

As we've seen in the T-shape example, we need to ensure the sum of all the normals is zero. We fixed that by subdividing a class into multiple classes that ensure the sum of all normals is zero, but we manually subdivided the South class to do so. Is there an algorithm we can develop to subdivide our classes into subclasses that sum to zero, then recombine those subclasses into the original classes we wanted? Would we be asking that question if we didn't have an answer?

## Superclasses and Multimodal SoftMax

Two or more classes can be combined by associating them with the same label and summing their probabilities. This allows for non-convex classes (such as, say, a ring of probability density surrounding a center class). In the pentagon example from chapter 3, we could also describe the target's location as either inside or outside the pentagon, as such:

```python
import numpy as np
from cops_and_robots.robo_tools.fusion.softmax import SoftMax, make_regular_2D_poly
%matplotlib inline

labels = ['Interior','Exterior','Exterior', 'Exterior', 'Exterior', 'Exterior']
poly = make_regular_2D_poly(5, max_r=2, theta=-np.pi/4, origin=(2,-1))
sm = SoftMax(poly=poly, class_labels=labels, resolution=0.1, steepness=3)
sm.plot(plot_poly=True, plot_normals=False)
```

But, we have a problem: our class boundaries have shifted. What's going on here? Let's examine a 1D case. The first plot below shows a SoftMax distribution with three separate classes, whereas the second plot shows a Multimodal SoftMax distribution with one class surrounded by another.

```python
labels = ['Moving Backward', 'Stopped', 'Moving Forward']
sm = SoftMax(weights=np.array([[-2], [0], [2],]),
             biases=np.array([-0.5, 0, -0.5]),
             state_spec='x', class_labels=labels,
             bounds=[-1, 0, 1, 1])
sm.plot()
```

```python
labels = ['Moving', 'Stopped', 'Moving']
sm = SoftMax(weights=np.array([[-2], [0], [2],]),
             biases=np.array([-0.5, 0, -0.5]),
             state_spec='x', class_labels=labels,
             bounds=[-1, 0, 1, 1])
sm.plot()
```

```python
labels = ['Moving', 'Stopped', 'Moving']
sm = SoftMax(weights=np.array([[-3], [0], [3],]),
             biases=np.array([-1, 0, -1]),
             state_spec='x', class_labels=labels,
             bounds=[-1, 0, 1, 1])
sm.plot()
```

The summing the probabilities of `Moving Backward` and `Moving Forward` into a new `Moving` class has completely changed our class boundaries! `Stopped` still has the exact same probability distribution, but it is dominated by the new `Moving` superclass.

This is an example of the *dependence between class gradient and inter-class boundaries*. That is, when we combine classes to make an MMS distribution, the superclass will either have a new boundary with the other classes, or the gradient of either the superclass or the other classes must change to perserve boundaries.

In the above example, the meanings of the probability distributions remain the same: if we do not discriminate the direction of movement, then the chance the target is moving, given any state $x$, is the same as the sum of both directions. In this sense, there is nothing wrong with our MMS formulation.

However, in the case where we have clearly defined class boundaries, it is important to maintain those boundaries, thus the slope of the distributions must change: we must amend probabilistic meaning to preserve spatial meaning. Let's look at the math to derive this relationship between class gradient and inter-class boundaries.

Take three arbitrary classes `A`, `B`, and `C`. We will sum `A` and `C` together to make superclass `D`:

\begin{align}
P(L=D \vert \mathbf{x}) &= P(L=A \vert \mathbf{x}) + P(L=C \vert \mathbf{x}) \\
&= \frac{e^{\mathbf{w}_A ^T \mathbf{x} + b_A}}{\sum_{k\in {A, B, C}} e^{\mathbf{w}_k^T\mathbf{x} + b_k}}
+ \frac{e^{\mathbf{w}_C^T \mathbf{x} + b_C}}{\sum_{k\in {A, B, C}} e^{\mathbf{w}_k^T\mathbf{x} + b_k}}\\
&= \frac{e^{\mathbf{w}_A ^T \mathbf{x} + b_A} + e^{\mathbf{w}_C^T \mathbf{x} + b_C}}{\sum_{k\in {A, B, C}} e^{\mathbf{w}_k^T\mathbf{x} + b_k}}
\end{align}

Recall that when the classes have equal probability (i.e. at a boundary shared by both classes), the log of the ratio of the class probabilities is zero:

\begin{align}
L_{ln}(B,D) &= 0 =
\ln{\frac{P(L=B \vert \mathbf{x})}{P(L=D \vert \mathbf{x})}}
= \ln{\frac{e^{\mathbf{w}_B^T \mathbf{x} + b_B}}{e^{\mathbf{w}_A ^T \mathbf{x} + b_A} + e^{\mathbf{w}_C^T \mathbf{x} + b_C}}} \\
&= \mathbf{w}_B^T \mathbf{x} + b_B - \ln{\left( e^{\mathbf{w}_A ^T \mathbf{x} + b_A} + e^{\mathbf{w}_C^T \mathbf{x} + b_C}\right)}
\end{align}

If we want the class boundaries to remain the same between regular and multimodal SoftMax, then we must also have:

$$
L_{ln}(B,A) = 0 = (\mathbf{w}_B - \mathbf{w}_A)^T \mathbf{x}_{AB} + (b_B - b_A)
$$

and

$$
L_{ln}(B,C) = 0 = (\mathbf{w}_B - \mathbf{w}_C)^T \mathbf{x}_{BC} + (b_B - b_C)
$$

Where $x_{AB}$ and $x_{BC}$ are the solutions to the above equations for fixed weights (i.e. the locations of the boundary in the state space).

Also, recall the ODE for the slope of a SoftMax distribution:

$$
\frac{\partial P(L = i \vert \mathbf{x})} {\partial \mathbf{x}} =
P(L = i \vert \mathbf{x}) \left(\mathbf{w}_{i} - \sum_{k=1}^M \mathbf{w}_{k}P(L = k \vert \mathbf{x}) \right) \\
$$

### Example: T-shape revisited

## Subclassing through convex decomposition

### Example: Camera model

```python
from IPython.core.display import HTML

# Borrowed style from Probabilistic Programming and Bayesian Methods for Hackers
def css_styling():
    styles = open("../styles/custom.css", "r").read()
    return HTML(styles)
css_styling()
```

<link href='http://fonts.googleapis.com/css?family=Roboto:100,100italic,500,300,300italic,400' rel='stylesheet' type='text/css'>

<style>
    div.cell{
        width:800px;
        margin-left:16% !important;
        margin-right:auto;
    }
    h1, h2, h3, h4 {
        font-family: "Roboto", "wingdings", sans-serif;
    }
    h1{
        font-weight: 500;
    }
    h2{
        font-weight: 400;
    }
    h3{
        font-weight: 300 !important;
/*         font-style: italic; */
    }
    h4{
        font-weight: 300 !important;
        font-style: italic;
        margin-top:12px;
        margin-bottom: 3px;
       }
    div.text_cell_render{
        font-family: "HelveticaNeue-light", "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif;
        line-height: 145%;
        font-size: 120%;
        width:800px;
        margin-left:auto;
        margin-right:auto;
    }
    .CodeMirror{
            font-family: "Source Code Pro", source-code-pro,Consolas, monospace;
    }
    .prompt{
        display: None;
    }
    .text_cell_render h5 {
        font-weight: 300;
        font-size: 22pt;
        color: #4057A1;
        font-style: italic;
        margin-bottom: .5em;
        margin-top: 0.5em;
        display: block;
    }

    .warning{
        color: rgb( 240, 20, 20 )
        }
</style>