# 3D Tic-Tac-Toe

## Objective and Prerequisites

Try this logic programming example to learn how to solve the problem of arranging X’s and O’s on a three-dimensional Tic-Tac-Toe board so as to minimize the number of completed lines or diagonals. This example will show you how a binary programming model can be used to capture simple logical constraints.

This is example 17 from the fifth edition of Model Building in Mathematical Programming by H. Paul Williams on pages 272 and 327 – 328.

This modeling example is at the beginning level. We assume that you have some familiarity with Python and the Gurobi Python API, but you can hopefully pick up any missing concepts from the example.

**Download the Repository**

You can download the repository containing this and other examples by clicking [here](https://github.com/Gurobi/modeling-examples/archive/master.zip).

**Gurobi License**

In order to run this Jupyter Notebook properly, you must have a Gurobi license. If you do not have one, you can request an
[evaluation license](https://www.gurobi.com/downloads/request-an-evaluation-license/?utm_source=3PW&utm_medium=OT&utm_campaign=WW-MU-EDU-OR-O_LEA-PR_NO-Q3_FY20_WW_JPME_3D-Tic-Tac-Toe_COM_EVAL_GITHUB_&utm_term=logic-programing&utm_content=C_JPM)
as a *commercial user*, or download a
[free license](https://www.gurobi.com/academia/academic-program-and-licenses/?utm_source=3PW&utm_medium=OT&utm_campaign=WW-MU-EDU-OR-O_LEA-PR_NO-Q3_FY20_WW_JPME_3D-Tic-Tac-Toe_ACADEMIC_EVAL_GITHUB_&utm_term=logic-programing&utm_content=C_JPM)
as an *academic user*.

---
## Problem Description

Given a 3-D tic-tac-toe board, where players take turns placing $X$'s and $O$'s, the game typically ends when one player completes a line or diagonal; that is, when they manage to place their symbols in three cells that form a line or diagonal in the grid.  The twist that is tackled here is that the game continues until every cell contains a symbol, and the goal is to arrange the symbols to minimize the number of completed lines or diagonals.

---
## Model Formulation

### Decision Variables

$\text{isX}_{ijk} \in [0,1]$: Does cell $(i,j,k)$ contain an $X$ ($isX=1$) or an $O$ ($isX=0$)?

$\text{isLine}_{l} \in [0,1]$: Does line/diagonal $l$ contain 3 of the same symbol?

### Objective Function

- **Lines**: Minimize the number of completed lines or diagonals

\begin{equation}
\text{Minimize} \quad Z = \sum_{l \in \text{Lines}}\text{isLine}_l
\end{equation}

### Constraints

- **Take turns**: The board must contain 14 $X$'s and 13 $O$'s ($X$ goes first).

\begin{equation}
\sum_{ijk} \text{isX}_{ijk} = 14
\end{equation}

- **Lines**: For a line to not be complete, one cell must have a different value.  The simple observation here is that the sum of the corresponding 3 binary variables would be 3 if they are all $X$ and 0 if they were all $O$.   We need to forbid those outcomes whenever $isLine_l == 0$.  Note that $l_0$ is the first cell in line $l$, $l_1$ is the second, and $l_2$ is the third.

\begin{equation}
\text{isLine}_l == 0 \implies isX[l_0] + isX[l_1] + isX[l_2] >= 1 \quad \forall l \in \text{Lines}
\end{equation}

\begin{equation}
\text{isLine}_l == 0 \implies isX[l_0] + isX[l_1] + isX[l_2] <= 2 \quad \forall l \in \text{Lines}
\end{equation}

---
## Python Implementation

We import the Gurobi Python Module.

```python
import gurobipy as gp
from gurobipy import GRB

# tested with Python 3.7.0 & Gurobi 9.0
```

## Model Deployment

We first create a list of all possible lines and diagonals in a 3-D tic-tac-toe board.  Each is represented as a Python tuple with 3 entries, where each entry gives the (i,j,k) position of the corresponding cell.  There are 49 in total.

```python
lines = []
size = 3

for i in range(size):
    for j in range(size):
        for k in range(size):
            if i == 0:
                lines.append(((0,j,k), (1,j,k), (2,j,k)))
            if j == 0:
                lines.append(((i,0,k), (i,1,k), (i,2,k)))
            if k == 0:
                lines.append(((i,j,0), (i,j,1), (i,j,2)))
            if i == 0 and j == 0:
                lines.append(((0,0,k), (1,1,k), (2,2,k)))
            if i == 0 and j == 2:
                lines.append(((0,2,k), (1,1,k), (2,0,k)))
            if i == 0 and k == 0:
                lines.append(((0,j,0), (1,j,1), (2,j,2)))
            if i == 0 and k == 2:
                lines.append(((0,j,2), (1,j,1), (2,j,0)))
            if j == 0 and k == 0:
                lines.append(((i,0,0), (i,1,1), (i,2,2)))
            if j == 0 and k == 2:
                lines.append(((i,0,2), (i,1,1), (i,2,0)))
lines.append(((0,0,0), (1,1,1), (2,2,2)))
lines.append(((2,0,0), (1,1,1), (0,2,2)))
lines.append(((0,2,0), (1,1,1), (2,0,2)))
lines.append(((0,0,2), (1,1,1), (2,2,0)))
```

Next we create our model and our decision variables.

```python
model = gp.Model('Tic_Tac_Toe')
isX = model.addVars(size, size, size, vtype=GRB.BINARY, name="isX")
isLine = model.addVars(lines, vtype=GRB.BINARY, name="isLine")
```

    Using license file c:\gurobi\gurobi.lic

Now we create the constraints.  The first states the board will contain 14 X's (and 13 O's):

```python
x14 = model.addConstr(isX.sum() == 14)
```

The remaining constraints establish the relationship between the $isLine[]$ and $isX[]$ variables.  A line is complete if all three cells contain the same symbol.  In our model, this would correspond to three associated $isX[]$ variables summing to either 3 (all $X$) or 0 (all $O$).  For our purposes, it is enough to enforce the condition that if $isLine[] = 0$, the sum must be strictly between these two values.

```python
for line in lines:
    model.addGenConstrIndicator(isLine[line], False, isX[line[0]] + isX[line[1]] + isX[line[2]] >= 1)
    model.addGenConstrIndicator(isLine[line], False, isX[line[0]] + isX[line[1]] + isX[line[2]] <= 2)
```

Finally, we set the optimization objective, which is to minimize the number of completed lines.

```python
model.setObjective(isLine.sum())
```

Now we perform the optimization.

```python
model.optimize()
```

    Gurobi Optimizer version 9.1.0 build v9.1.0rc0 (win64)
    Thread count: 4 physical cores, 8 logical processors, using up to 8 threads
    Optimize a model with 1 rows, 76 columns and 27 nonzeros
    Model fingerprint: 0xcbf7569f
    Model has 98 general constraints
    Variable types: 0 continuous, 76 integer (76 binary)
    Coefficient statistics:
      Matrix range     [1e+00, 1e+00]
      Objective range  [1e+00, 1e+00]
      Bounds range     [1e+00, 1e+00]
      RHS range        [1e+01, 1e+01]
    Presolve added 98 rows and 0 columns
    Presolve time: 0.00s
    Presolved: 99 rows, 76 columns, 419 nonzeros
    Variable types: 0 continuous, 76 integer (76 binary)
    Found heuristic solution: objective 7.0000000

    Root relaxation: objective 0.000000e+00, 46 iterations, 0.00 seconds

        Nodes    |    Current Node    |     Objective Bounds      |     Work
     Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time

         0     0    0.00000    0    8    7.00000    0.00000   100%     -    0s
    H    0     0                       5.0000000    0.00000   100%     -    0s
         0     0    0.00000    0   18    5.00000    0.00000   100%     -    0s
    H    0     0                       4.0000000    0.00000   100%     -    0s
         0     0    0.00000    0   19    4.00000    0.00000   100%     -    0s
         0     0    0.00000    0    8    4.00000    0.00000   100%     -    0s
         0     0    0.00000    0   21    4.00000    0.00000   100%     -    0s
         0     0    0.50000    0    8    4.00000    0.50000  87.5%     -    0s
         0     0    0.66667    0   18    4.00000    0.66667  83.3%     -    0s
         0     0    1.00000    0    8    4.00000    1.00000  75.0%     -    0s
         0     0    1.00000    0    8    4.00000    1.00000  75.0%     -    0s
         0     2    1.00000    0    8    4.00000    1.00000  75.0%     -    0s

    Cutting planes:
      MIR: 4
      Inf proof: 9
      Zero half: 14

    Explored 2139 nodes (14255 simplex iterations) in 0.26 seconds
    Thread count was 8 (of 8 available processors)

    Solution count 3: 4 5 7

    Optimal solution found (tolerance 1.00e-04)
    Best objective 4.000000000000e+00, best bound 4.000000000000e+00, gap 0.0000%

---
## Result

The optimal solution completes only 4 lines or diagonals.  We can visualize the result using matplotlib (we've peeled off the third dimension of the 3-D tic-tac-toe board).

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

fig, ax = plt.subplots(1, 3, figsize=(10,5))
for i in range(3):
    ax[i].grid()
    ax[i].set_xticks(range(4))
    ax[i].set_yticks(range(4))
    ax[i].tick_params(labelleft=False, labelbottom=False)

for cell in isX.keys():
    if isX[cell].x > 0.5:
        ax[cell[0]].add_patch(plt.Rectangle((cell[1],cell[2]), 1, 1))

plt.show()
```

---
## References

H. Paul Williams, Model Building in Mathematical Programming, fifth edition.

Copyright © 2020 Gurobi Optimization, LLC

```python

```


**
Ejercicios distribuciones de Probabilidad
**

***Miguel Ángel Vélez Guerra***

**
Tabla de contenido
**

Empezaremos importando las librerías que nos ayudarán en el desarrollo del taller.
- numpy para los cálculos complejos necesarios
- scipy.stats para el uso de las funciones estadísticas y distribuciones que necesitamos
- matplotlib.pyplot para graficar las funciones
- **mstats** que es mi librería que cree con algunos métodos y funciones para graficar y hacer cálculos más específicos

```python
#-------Importing from other folder------#

import sys
sys.path.insert(0, "../resources/")

import mstats

#-----------Miguel's statistics----------#
```

```python
import numpy as np
import scipy.stats as ss
import matplotlib.pyplot as plt
```

```javascript
%%javascript
// Script to generate the table of contents
$.getScript('../resources/table_of_contents.js')
```

    <IPython.core.display.Javascript object>

## 1. Distribución binomial
Un fabricante en California le suministra un diseño prototipo para
una pieza de aeronave que requiere su negocio. Este nuevo producto, que es enviado en
lotes de n= 12, sufre una tasa de defectos del 40%.
a. Si usted no desea un riesgo mayor al 10% en la probabilidad de que 5 de los 12 sean
defectuosos, ¿Debería comprarle a ese distribuidor?
b. Si usted no desea enfrentar un riesgo mayor del 20% de probabilidad de que más de 5
salgan defectuosos, ¿Debería comprarle a ese distribuidor?

```python
n_lotes = 12 # Número de lotes de prototipos de aeronave tomados como muestra
pi_lotes = 0.4 # Porcentaje en la tasa de defectos de los lotes
```

Podemos notar con los datos que nos da el ejercicio, que la distribución que siguen los valores es una distribución *binomial*; pues nos dan una muestra (n) y una probabilidad de exito (pi) de que ocurra un evento.

```python
distr_bin_lotes = ss.binom(n_lotes,pi_lotes)
```

```python
mstats.graficar_discreta(distr_bin_lotes, "Distribución Binomial")
```

Aquí estamos declarando toda la distribución binomial que va seguir nuestro ejercicio, según el número de la muestra (15), y nuestra probabilidad de que un prototipo esté defectuoso (0.4). Esto lo haremos usando la función *.binom (distribución binomial)* de la librería *scipy.stats*

**a) Si usted no desea un riesgo mayor al 10% en la probabilidad de que 5 de los 12 sean defectuosos, ¿Debería comprarle a ese distribuidor?**

```python
x_lotes_a = 5 # Número de lotes defectuosos a evaluar
```

Ahora usaremos la función *.pmf (función de masa de probabilidad)* de la librería *scipy.stats.binom* que nos determina la probabilidad en un punto dado.

Nos arroja la probabilidad de que de los 12 lotes con una tasa de defectos del 40%, hayan **exactamente** 5 lotes defectuosos.

```python
prob_lotes_a = distr_bin_lotes.pmf(x_lotes_a)
mstats.graficar_discreta_cdf(distr_bin_lotes, x_lotes_a, x_lotes_a)
prob_lotes_a
```

Como vemos, según el resultado que nos arroja la función, tenemos que hay un **22.7%** de probabilidad de que hayan 5 de 12 lotes defectuosos.

**R/ a)** Como nosotros no queremos tener un riesgo mayor del **10%** en 5 de 12 unidades, entonces rechazaremos a ese distribuidor y no le compraremos sus prototipos, pues éste distribuidor tiene un riesgo del **22.7%**

**b) Si usted no desea enfrentar un riesgo mayor del 20% de probabilidad de que más de 5 salgan defectuosos, ¿Debería comprarle a ese distribuidor?**

```python
x_lotes_b = 5 # Número de lotes defectuosos a evaluar
```

Ahora utilizaremos la función *.cdf (función acumulativa de la distribución)* de la librería *scipy.stats.binom* que nos dará el valor acumulado de la función hasta el punto que le pasamos como parámetro.

Nos arrojará la probabilidad de que de los 12 lotes con una tasa de defectos del 40%, hayan **menos de 5** lotes defectuosos. Entonces, como necesitamos evaluar la probabilidad de que hayan **más de 5** lotes defectuosos, tendremos que restarle a 1 el valor que hemos obtenido (1 - valor)

```python
prob_lotes_b = 1 - distr_bin_lotes.cdf(x_lotes_b)
mstats.graficar_discreta_cdf(distr_bin_lotes, 1, x_lotes_b)
prob_lotes_b
```

Como vemos, según el resultado que nos arroja la función, tenemos que hay un **33.47%** de probabilidad de que hayan más de 5 lotes defectuosos en la muestra de 12 lotes.

**R/ b)** Como nosotros no deseamos enfrentar un riesgo mayor del **20%** de probabilidad de que más de 5 lotes salgan defectuosos. Entonces debemos rechazar la oferta de este distribuidor, que nos ofrece un riesgo del **33.47%**

## 2. Distribución hipergeométrica
Una tienda de productos deportivos tiene en existencia N=
20 pares de botas para esquiar de las cuales r=8 son de su talla. Si usted selecciona n = 3
pares que usted desea, ¿Cuál es la probabilidad de que X = 1 le quede bien?

```python
N_botas = 20 # Población total de pares de botas para esquiar
r_botas = 8 # Número de botas de nuestra talla
n_botas = 3 # Número de pares de botas seleccionado para la muestra
```

Si analizamos la información y los datos que nos brinda el ejercicio, notamos que los valores están distribuidos en una distribución *hipergeométrica*; pues nos dicen el tamaño de la población a analizar (N), una proporción de la población con una condición especial (r), y una muestra para analizar con respecto a la condición especial (n).

```python
distr_hyper_botas = ss.hypergeom(N_botas, r_botas, n_botas)
```

```python
mstats.graficar_discreta(distr_hyper_botas, "Distribución Hipergeométrica")
```

Declaramos la función hipergeométrica que va a seguir nuestro ejercicio, utilizando la función *.hypergeom (distribución hipergeométrica)* de la librería *scipy.stats*.

Esta función recibe como parámetros el tamaño de la población (N), el número de éxitos de la población (r), y el tamaño de la muestra tomada (n)

**¿Cuál es la probabilidad de que X = 1 le quede bien?**

```python
x_botas = 1 # Número de pares de botas seleccionado para la evaluación
```

Vamos a determinar la probabilidad de que **x_botas = 1 botas** seleccionadas sean de nuestra talla **r_botas = 8** de una muestra de **n_botas = 3** tomadas del total de los **N_botas = 20** pares de botas que hay en total

Para eso usaremos la función *.pmf (función de masa de probabilidad)* de la librería *scipy.stats.hypergeom*, que nos dice la probabilidad en un punto dado.

```python
prob_botas = distr_hyper_botas.pmf(x_botas)
mstats.graficar_discreta_cdf(distr_hyper_botas, x_botas, x_botas)
prob_botas
```

**R/** La probabilidad de que 1 de los pares de botas nos sirva es de **46.31%**

## 3. Distribución de Poisson
El cable utilizado para asegurar las estructuras de los puentes
tiene un promedio de 3 defectos por cada 100 yardas. Si usted necesita 50 yardas, ¿Cuál
es la probabilidad de que haya una defectuosa?

```python
mu_yardas = (3/100) # Número promedio de defectos por cada unidad de yardas
n_yardas = 50 # Número de yardas del cable a tener en cuenta
```

Podemos afirmar que los valores del ejercicio siguen una distribución de *Poisson*, pues tenemos una proporción de ocurrencias de un evento por unidad de espacio que siempre es constante.

```python
mu_yardas_cable = (mu_yardas * n_yardas)
```

Nos dicen que según ese promedio de 3 defectos por cada 100 yardas, tenemos que determinar el promedio de defectos que hay en 50 yardas, que son las yardas que tenemos de cable

```python
distr_poi_yardas = ss.poisson(mu_yardas_cable)
```

```python
mstats.graficar_discreta(distr_poi_yardas, "Distribución Poisson")
```

Entonces, según estos datos, ya podemos construir la distribución *poisson* que tienen los datos.

Para esto usaremos la función *.poisson (distribución poisson)* de la librería *scipy.stats*, la cuál la recibe como parámetro el 'mu' del ejercicio que tengamos.

**¿Cuál es la probabilidad de que haya una defectuosa?**

```python
n_yardas = 50 # Número de yardas del cable a tener en cuenta
x_cables = 1 # Número de cables tomados para evaluar si están defetuosos
```

Ahora vamos a determinar la probabilidad de que **x_cables = 1** cables seleccionados estén defectuosos según las condiciones que nos dieron.

Para esto usaremos la función *.pmf (función de masa de probabilidad)* de la librería de *scipy.stats.poisson* que nos da la probabilidad en un punto dado

```python
prob_yardas = distr_poi_yardas.pmf(x_cables)
mstats.graficar_discreta_cdf(distr_poi_yardas, x_cables, x_botas)
prob_yardas
```

**R/** La probabilidad de que uno de los cables seleccionados esté defectuoso es del **33.47%**

## 4. Distribución exponencial
Como gerente de Burguer Heaven, usted observa que los
clientes entran a su establecimiento a razón de 8 por hora. ¿Cuál es la probabilidad de que
pasen más de 15 minutos entre la llegada de dos clientes?

```python
mu_exp = (8/60) # Tasa promedio de entradas de los clientes al establecimientos por minuto
```

Teniendo en cuenta la forma que siguen las distribuciones exponenciales, podemos tomar la distribución de este ejercicio como *exponencial*, pues tenemos una tasa promedio de ocurrencias en un intervalo de tiempo.

```python
##distr_exp = ss.expon(mu_exp)
distr_exp = ss.expon(mu_exp)
```

```python
mstats.distributions.graficar_continua(distr_exp, "Distribución Exponencial")
```

**¿Cuál es la probabilidad de que pasen más de 15 minutos entre la llegada de dos clientes?**

```python
x_exp = 15 # Minutos a evaluar entre la entrada de 2 clientes
```

Entonces, tenemos que con un promedio de 'mu' entradas por minutos al establecimiento. Queremos determinar la probabilidad de que pasen más de 15 minutos entre la llegada de 2 clientes.

Para esto usaremos la función que declaramos al principio del taller *cdf_exponential (función de probabilidad acumulada)* que nos permite calcular la probabilidad acumulada hasta el punto que le pasamos como parámetro junto al 'mu' de la distribución. Luego de calcular esta probabilidad, tendremos que restarle a 1 esa probabilidad, pues nos interesa la probabilidad de que pasen **más** de 15 minutos entre la llegada de 2 clientes.

```python
prob_exp = 1 - mstats.distributions.cdf_exponential(mu_exp, x_exp)
mstats.distributions.graficar_continua_cdf(distr_exp, x_exp, distr_exp.ppf(0.99))
prob_exp
```

**R/** La probabilidad de pasen más de 15 minutos entre la llegada de 2 clientes al establecimiento es de **13.53%**

## 5. Distribución uniforme
Los tiempos de terminación de un trabajo oscilan entre 10.2
minutos a 18.3 minutos y se piensan que están distribuidos uniformemente. ¿Cuál es la
probabilidad de que se requiera entre 12.7 y 14.5 minutos para realizar este trabajo?

```python
a_unif = 10.2 # Tiempo mínimo de terminación de un trabajo
b_unif = 18.3 # Tiempo máximo de terminación de un trabajo
rango_unif = b_unif - a_unif # Rango de oscilación de la terminación de los trabajos general
```

Se puede asegurar que el ejercicio sigue una distribución *uniforme*, pues tenemos un rango de ocurrencia de un evento, y nos están preguntando por la probabilidad de que otro rango esté dentro del rango de la distribución.

```python
distr_unif_trabajos = ss.uniform(a_unif, rango_unif)
```

```python
mstats.graficar_continua(distr_unif_trabajos, "Distribución Uniforme")
```

Con esta información, podemos construir la distribución *uniforme* que sigue el ejercicio, con la ayuda de la función *.uniform* de la librería *scipy.stats* la cual recibe como parámetros para su construcción, el punto mínimo de la distribución, y el rango que tiene *(tiempo máximo - tiempo mínimo)*

**¿Cuál es la probabilidad de que se requiera entre 12.7 y 14.5 minutos para realizar este trabajo?**

```python
a_trabajo = 12.7 # Tiempo mínimo a evaluar de la terminación de un trabajo
b_trabajo = 14.5 # Tiempo máximo a evaluar de la terminación de un trabajo
rango_trabajo = a_trabajo - b_trabajo # Rango de oscilación de la terminación de los trabajos a evaluar
```

Ahora vamos a determinar la probabilidad de que el tiempo necesario para terminar un trabajo esté en el rango de **rango_trabajo = 14.5 - 12.7**.

Entonces necesitamos calculcar la probabilidad acumulado hasta el valor máximo y restarla con la probabilidad acumulada hasta el valor mínimo, para así obtener la probabilidad de que ese intervalo de valores ocurra en la distribución uniforme.

Para esto utilizaremos la función *.cdf (función de probabilidad acumulada)* de la librería *scipy.stats.uniform*.

```python
prob_unif = distr_unif_trabajos.cdf(b_trabajo) - distr_unif_trabajos.cdf(a_trabajo)
mstats.graficar_continua_cdf(distr_unif_trabajos, 12.7, 14.5)
prob_unif
```

**R/** La probabilidad de que se requiera entre 12.7 y 14.5 minutos es de **22.22%**

## 6. Distribución normal
El ministerio de agricultura de estados unidos en un estudio sobre
cultivos ha detectado que las precipitaciones diarias en ciertos lugares de Hawaii parecen
estar distribuidas normalmente con una media de 2.2 pulgadas durante la estación
lluviosa. Se determinó que la desviación estándar era de 0.8 pulgadas.
a. ¿Cuál es la probabilidad de que llueva más de 3.3 pulgadas en un día durante la
estación lluviosa?
b. Halle la probabilidad de que llueva más de 1.3 pulgadas.
c. ¿Cuál es la probabilidad de que las precipitaciones estén entre 2.7 y 3.0 pulgadas?
d. ¿Cuánta precipitación debe presentarse para exceder el 10% de las precipitaciones
diarias?

```python
mu_precip = 2.2 # Valor promedio de pulgadas que se precipitan diariamente
rho_precip = 0.8 # Desviación estándar de pulgadas que se precipitan diariamente
```

Podemos afirmar que la distribución es *normal*, porque tenemos una media de la población y una desviación estándar de la población también. Asumiendo que todos los datos están relacionados con esos valores.

```python
distr_norm_precip = ss.norm(mu_precip, rho_precip)
```

```python
mstats.graficar_continua(distr_norm_precip, "Distribución Normal")
```

Hemos construido la distribución *normal* con la ayuda de la función *.norm (distribución normal)* de la librería *scipy.stats*; la cual recibe como parámetros la media de la población, y la desviación estándar de la población también.

**a) ¿Cuál es la probabilidad de que llueva más de 3.3 pulgadas en un día durante la estación lluviosa?**

```python
x_precip_a = 3.3 # Pulgadas precipitadas a evaluar
```

Vamos a determinar la probabilidad de que llueva más de 3.3 pulgadas en un día **x_precip > 3.3**, que para esto necesitaremos tomar la probabilidad acumulada hasta ese punto y restarle a 1 ese valor de probabilidad.

```python
prob_precip_a = 1 - distr_norm_precip.cdf(3.3)
mstats.graficar_continua_cdf(distr_norm_precip, 3.3, distr_norm_precip.ppf(0.99))
prob_precip_a
```

**R/ a)** La probabilidad de que llueva más de 3.3 pulgadas en un dia durante la estación lluviosa es de **8.45%**

**b) Halle la probabilidad de que llueva más de 1.3 pulgadas.**

```python
x_precip_b = 1.3 # Pulgadas precipitadas a evaluar
```

Vamos a determinar la probabilidad de que llueva más de 1.3 pulgadas en un día **x_precip > 1.3**, que para esto necesitaremos tomar la probabilidad acumulada hasta ese punto y restarle a 1 ese valor de probabilidad.

```python
prob_precip_a = 1 - distr_norm_precip.cdf(1.3)
mstats.graficar_continua_cdf(distr_norm_precip, 1.3, distr_norm_precip.ppf(0.99))
prob_precip_a
```

**R/ b)** Hay un **86.97%** de probabilidades de que llueva más de 1.3 pulgadas

**c) ¿Cuál es la probabilidad de que las precipitaciones estén entre 2.7 y 3.0 pulgadas?**

```python
x1_precip_c = 2.7 # Pulgadas mínimas precipitadas a evaluar
x2_precip_c = 3 # Pulgadas máximas precipitadas a evaluar
```

Entonces, tenemos que determinar la probabilidad de que las precipitaciones estén entre 2.7 y 3 pulgadas, para esto vamos a determinar la probabilidad acumulada hasta el punto máximo (3) y restarla con la probabilidad acumulada hasta el punto mínimo (2.7).

```python
prob_precip_c = distr_norm_precip.cdf(3) - distr_norm_precip.cdf(2.7)
mstats.graficar_continua_cdf(distr_norm_precip, 2.7, 3)
prob_precip_c
```

**R/ c)** La probabilidad de que las precipitaciones estén entre 2.7 y 3 pulgadas es de **10.73%**

**d) ¿Cuánta precipitación debe presentarse para exceder el 10% de las precipitaciones diarias?**

```python
prob_precip_d = 0.1 # Probabilidad de que ocurran 'x' numero de precipitaciones diarias
```

Entonces, para encontrar el valor de precipitaciones correspondiente a la probabilidad del 0.1 en la distribución normal, debemos tratarla como una función matemática, dónde el eje 'x' corresponde a los valores de las precipitaciones que toma el ejercicio, y el eje 'y' corresponde a los valores de las probabilidades respectivas a esos valores 'x', teniendo en cuenta la función que sigue la distribución normal.

\begin{align}
p(x;\mu, \sigma^2) = \frac{1}{\sigma \sqrt{2 \pi}} e^{\frac{-1}{2}\left(\frac{x - \mu}{\sigma} \right)^2}
\end{align}

Teniendo en cuenta esto, podemos encontrar el valor del punto en 'x' si conocemos el valor del punto en 'y'

```python
distr_norm_precip.ppf(0.1)
```

    1.1747587475643197

**R/ d)** Se necesitan más **1.1747** pulgadas precipitadas para exceder el 10% de las precipitaciones diarias.

## 7. Aproximación normal a la distribución binomial
El 45% de todos los empleados del centro
de capacitación gerencial en Condor Magnetics tienen títulos universitarios. ¿Cuál es la
probabilidad de que de los 150 empleados seleccionados aleatoriamente, 72 tengan título
universitario?

```python
pi_empleados = 0.45 # Porcentaje de empleados que tienen títulos universitarios
n_empleados = 150 # Número de empleados de la muestra
```

Según el enunciado y los datos que nos dan, podemos concluir que esta información se refiere a una distribución binomial, pero esta muestra es demasiado grande, y en la construcción de su distribución pueden presentarse problemas.

Entonces lo que haremos será verificar si la podemos aproximar a una distribución normal, teniendo en cuenta que si multiplicamos la muestra por el porcentaje de éxito ***(n * pi)*** y por el porcentaje de fracaso ***(n * (1 - pi))***, nos da un valor mayor o igual a 5, y si pi está próximo a 0.5.

```python
n_empleados * pi_empleados
```

    67.5

```python
n_empleados * (1 - pi_empleados)
```

    82.5

```python
pi_empleados
```

    0.45

Se cumplen las condiciones, entonces podemos aproximar esta distribución binomial a una normal; para esto, la media de la población está dada por la multiplicación de ***(n * pi)*** y la desviación estándar es  ***√(n(π)(1-π)***

```python
mu_empleados = n_empleados * pi_empleados
rho_empleados = ((n_empleados*pi_empleados) * (1 - pi_empleados))**0.5
```

Con estos datos podemos construir la función normal.

```python
distr_norm_empleados = ss.norm(mu_empleados, rho_empleados)
```

```python
mstats.graficar_continua(distr_norm_empleados, "Distribución normal")
```

**¿Cuál es la probabilidad de que de los 150 empleados seleccionados aleatoriamente, 72 tengan título universitario?**

```python
x_empleados = 72 # Número de empleados a evaluar
```

Cómo hemos adaptado una distribución discreta a una normal, no podemos calcular la probabilidad exacta de un punto dado, por lo que tenemos que usar el factor de correción de continuidad, calcularemos la probabilidad que hay entre **71.5 y 72.5**

```python
prob_empleados = distr_norm_empleados.cdf(72.5) - distr_norm_empleados.cdf(71.5)
mstats.graficar_continua_cdf(distr_norm_empleados, 71.5, 72.5)
prob_empleados
```

**R/** La probabilidad de que de los 150 empleados seleccionados aleatoriamente, 72 tengan título universitario es del **4.98%**

## 8. Distribuciones muestrales
Los registros de inversiones muestran que la tasa promedio de rendimiento para las firmas
que están en la industria de bienes de consumo es del 30%, con una desviación estándar
del 12%. Si se selecciona una muestra de 250 de tales firmas, ¿Cuál es la probabilidad de
que la media de estas firmas esté entre el 30 y el 31%?

```python
mu_firmas = 30 # Tasa promedio del rendimiento para las firmas
rho_firmas = 12 # Desviación estándar del rendimiento para las firmas
n_firmas = 250 # Tamaño de la muestra
```

Podemos identificar una distribución normal en una población, pero nos dicen que están cogiendo una muestra de esa población y hacer evaluaciones con respecto a eso, por eso tendremos que aplicar los conceptos de distribuciones muestrales. Entonces tenemos que evaluar la desviación estándar teniendo en cuenta que esta desviación será la desviación estándar de la población dividida entre la raíz cuadrada del tamaño de la muestra.

***desviación estándar / √tamaño de la muestra***

```python
rho_error = rho_firmas / (n_firmas**0.5)
```

Con esto ya definido, podemos construir la distribución normal que tendrá el ejercicio, con el promedio poblacional y ésta desviación estándar que encontramos

```python
distr_norm_firmas = ss.norm(mu_firmas, rho_error)
```

```python
mstats.graficar_continua(distr_norm_firmas, "Distribución normal")
```

**¿Cuál es la probabilidad de que la media de estas firmas esté entre el 30 y el 31%?**

```python
x1_firmas = 30 # Porcentaje promedio 1 de rendimiento para las firmas de la muestra
x2_firmas = 31 # Porcentaja promedio 2 de rendimiento para las firmas de la muestra
```

Ahora lo que tenemos que hacer es calcular la probabilidad acumulada hasta el valor de 31 y restarla con la acumulada que hay hasta 30, de este modo obtendremos la probabilidad de que la media de las firmas esté entre el 30% y el 31%.

```python
prob_firmas = distr_norm_firmas.cdf(x2_firmas) - distr_norm_firmas.cdf(x1_firmas)
mstats.graficar_continua_cdf(distr_norm_firmas, x1_firmas, x2_firmas)
prob_firmas
```

**R/** La probabilidad de que la media de estas firmas esté entre el 30% y el 31% de rendimiento es de **40.61%**

## 9. Proporciones muestrales
Sólo el 22% de todas las firmas en la industria de bienes de consumo comercializa sus
productos directamente con el consumidor final. Si una muestra de 250 firmas revela una
proporción de más del 20% que se compromete en el mercado directo, usted planea hacer
su siguiente compra a las firmas de esta industria. ¿Qué tan probable es que usted gaste
su dinero bien ganado en otra parte?

```python
p_mercado = 0.22 # Proporción del total de firmas en la industria que comercializa directamente con el cliente
n_mercado = 250 # Tamaño de la muestra de las firmas en la industria que comercializa directamente con el cliente
```

Vamos a tener en cuenta que tenemos una población, de la cuál se tiene una proporción que cumple una condición (éxito); y de la cuál se extrae una muestra grande de la cuál también se le conoce la proporción que cumple una condición en esa muestra.

Entonces, tenemos un enunciado de proporciones muestrales.

```python
p_muestra = 0.20 # Proporción de la muestra de firmas en la industria que comercializa directamente con el cliente
```

Vamos a calcular la probabilidad de que la inversión que hagamos la tomen una de esas empresas que comercializa directamente con el cliente, para esto, vamos a aproximar esta proporción muestral que tenemos a los parámetros de una distribución normal.

Antes de esto, tenemos que comprobar que se cumplan las condiciones de que la multiplicación entre el tamaño de la muestra y la proporción de éxitos y fracasos sea mayor o igual a 5 *(np >= 5 y n(1-p) >= 5)*

```python
n_mercado * p_mercado
```

    55.0

```python
n_mercado * (1 - p_mercado)
```

    195.0

Bien, podemos hacer la aproximación a la normal, teniendo en cuenta que el promedio de la distribución muestral de proporciones es igual a la proporción de la población, y la desviación estándar de la distribución muestral de proporciones es

***desviación estándar = √(proporción de éxitos * proporción de fracasos) / (tamaño de la muestra)***

```python
mu_mercado = p_mercado # Promedio de la proporción
rho_mercado = ((p_mercado * (1 - p_mercado)) / (n_mercado))**0.5 # Desviación estándar de la proporción
```

Con esto ya definido podemos construir la distribución normal que tendrá el ejercicio, pasándole el mu y el rho.

```python
distr_norm_mercado = ss.norm(mu_mercado, rho_mercado)
mstats.graficar_continua(distr_norm_mercado, "Distribución Normal")
```

```python
prob_mercado = distr_norm_mercado.cdf(0.20)
mstats.graficar_continua_cdf(distr_norm_mercado, distr_norm_mercado.ppf(0.01), 0.20)
prob_mercado
```

```python

```

exercises/Ejercicios distribuciones de probabilidad.ipynb


# Symbolic Calculations

```python
import sympy as sm
sm.init_printing()
import numpy as np
import scipy as sp
import scipy.integrate
import matplotlib as mpl
mpl.rcParams['mathtext.fontset']='stix'
import matplotlib.pyplot as plt
import sympy.physics.units as u
u.miles.convert_to(u.km).n()
```

Differential equation for the Damped Harmonic Oscillator:
$$
\large
\frac{d^{2}x(t)}{dt^{2}}+2\gamma\omega_{0}\frac{dx(t)}{dt}+\omega_{0}^{2}x(t) = 0
$$

```python
t,omega0 = sm.symbols('t,omega_0', positive=True)
gamma = sm.symbols('gamma',complex=True)
x = sm.Function('x')
ode = 2*gamma*omega0*x(t).diff(t)+omega0**2*x(t)+x(t).diff(t,2)
ode
```

```python
sm.dsolve(ode)
```

```python

```


# Restricted Boltzmann Machine

The restricted Boltzman Machine model is the Joint Probability Distribution which is specified by the Energy Function :

\begin{equation}
P(v,h) = \frac{1}{Z} e^{-E(v,h)}
\end{equation}

The energy function for the RBM is stated as follows:

\begin{equation}
E(v,h) = -b^{T} v - c^{T} h - v^{T} W h
\end{equation}

We also have the Partition Function Z which is the normalizing constant.

\begin{equation}
Z = \Sigma_{v} \Sigma_{h} e^{-E(v,h)}
\end{equation}

In Boltzmann Machines, the partition function Z is intractable and hence implies that the normalized Joint Probability Distribution _P(v)_ is also intractable to evaluate. Even though this is the case, the bipartitie graph structure of the RBM has a special property that the visible and hidden units are conditionally independent, given one another.

\begin{equation}
P(h|v) = \frac{P(h,v)}{P(v)}
       = \frac{1}{P(v)} \frac{1}{Z} exp\{b^{T} v + c^{T} h + v^{T} W h\}
\end{equation}

\begin{equation}
    = \frac{1}{Z'} exp\{\Sigma_j c_j h_j + \Sigma_j v^T W_j h_j \}
\end{equation}

\begin{equation}
    = \frac{1}{Z'} \Pi_j exp \{ c_j h_j + v^T W_j h_j\}
\end{equation}

\begin{equation}
P(h_j = 1,v) = \frac{\hat{P}(h_j = 1,v)}{\hat{P}(h_j = 0,v) + \hat{P}(h_j = 1,v)}
             = \frac{exp\{c_j + v^T W_j \}}{exp\{0\} + exp\{c_j + v^T W_j \}}
\end{equation}

Therefore:
\begin{equation}
P(h_j = 1|v) = \sigma(c_j + v^T W_j )
\end{equation}

Similary from Eq 8 we can say that:
\begin{equation}
P(v|h) = \frac{1}{Z'} \Pi_k exp \{b_k + h^T W_k \}
\end{equation}
Therefore:
\begin{equation}
P(v_k = 1|h) = \sigma(b_k + h^T W_k)
\end{equation}

```python
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import os
from tensorflow.examples.tutorials.mnist import input_data

if not os.path.exists('outRBM/'):
    os.makedirs('outRBM/')

mnist = input_data.read_data_sets('../MNIST_data', one_hot=True)
X_dim = mnist.train.images.shape[1]
y_dim = mnist.train.labels.shape[1]

mb_size = 16
h_dim = 20

W = np.random.randn(X_dim, h_dim) * 0.001
a = np.random.randn(h_dim) * 0.001
b = np.random.randn(X_dim) * 0.001

def sigm(x):
    return 1/(1 + np.exp(-x))

def infer(X):
    # mb_size x x_dim -> mb_size x h_dim
    return sigm(X @ W)

def generate(H):
    # mb_size x h_dim -> mb_size x x_dim
    return sigm(H @ W.T)

# Here we find the Contrastive Divergence
# ----------------------
# Approximate the log partition gradient Gibbs sampling

alpha = 0.1
K = 15  # Num. of Gibbs sampling step

for t in range(1, 1001):
    X_mb = (mnist.train.next_batch(mb_size)[0] > 0.5).astype(np.float)
    g = 0
    g_a = 0
    g_b = 0

    for v in X_mb:
        # E[h|v,W]
        h = infer(v)

        # Gibbs sampling steps
        # --------------------
        v_prime = np.copy(v)

        for k in range(K):
            # h ~ p(h|v,W)
            h_prime = np.random.binomial(n=1, p=infer(v_prime))
            # v ~ p(v|h,W)
            v_prime = np.random.binomial(n=1, p=generate(h_prime))

        # E[h|v',W]
        h_prime = infer(v_prime)

        # Compute data gradient
        grad_w = np.outer(v, h) - np.outer(v_prime, h_prime)
        grad_a = h - h_prime
        grad_b = v - v_prime

        # Accumulate minibatch gradient
        g += grad_w
        g_a += grad_a
        g_b += grad_b

    # Monte carlo gradient
    g *= 1 / mb_size
    g_a *= 1 / mb_size
    g_b *= 1 / mb_size

    # Update to maximize
    W += alpha * g
    a += alpha * g_a
    b += alpha * g_b

# Visualization
# -------------

def plot(samples, size, name):
    size = int(size)
    fig = plt.figure(figsize=(4, 4))
    gs = gridspec.GridSpec(4, 4)
    gs.update(wspace=0.05, hspace=0.05)

    for i, sample in enumerate(samples):
        ax = plt.subplot(gs[i])
        plt.axis('off')
        ax.set_xticklabels([])
        ax.set_yticklabels([])
        ax.set_aspect('equal')
        plt.imshow(sample.reshape(size, size), cmap='Greys_r')

    plt.savefig('outRBM/{}.png'.format(name), bbox_inches='tight')
    plt.close(fig)

X = (mnist.test.next_batch(mb_size)[0] > 0.5).astype(np.float)

H = np.random.binomial(n=1, p=infer(X))
plot(H, np.sqrt(h_dim), 'H')

X_recon = (generate(H) > 0.5).astype(np.float)
plot(X_recon, np.sqrt(X_dim), 'V')
```

# Variational Autoencoder

In Variational AutoEncoder we convert the input into a latent space and calculate the mean and standard deviation. By combining this, we get a new distribution of latent space which provides a better output than a regular autoencoder.

We shall consider the following definitions when deriving the VAE.

    1. X      - The data to be modeled
    2. z      - Latent Variable
    3. P(X)   - Probability distribution of Data
    4. P(z)   - Probability Distribution of Latent Variable
    P(X|z)    - Probability Distribution of the generated Data given the Latent Variable.

In VAE, we try to find the latent space _z_ using the data _X_. Hence we try to infer _P(z)_ using _P(z|X)_. But we do not know _P(z|X)_, hence we use Variational Inference and approach it as an optimization problem. We do this by actual distribution _P(z|X)_ using a simpler distribution such as Gaussian and then find the difference between the two distributions using KL Divergence.

For inferring _P(z|X)_ using _Q(z|X)_, we have the KL Divergence as :
\begin{equation}
D_{KL}[Q(z|X)∥P(z|X)] = \Sigma_z Q(z|X) log \frac{Q(z|X)}{P(z|X)}
                      = E[log Q(z|X) - log P(z|X)]
\end{equation}

Using Bayes' Rule, we can expand _P(z|X)_ as the following:

\begin{equation}
D_{KL}[Q(z|X)∥P(z|X)] = E[log Q(z|X) - log \frac{P(X|z) P(z)}{P(X)}]
= E[log Q(z|X) − log P(X|z) − log P(z) + log P(X)]
\end{equation}

The expection we are finding is over _z_, hence independent of _x_. Therefore, we we move _x_ out of the expectation.

\begin{equation}
D_{KL}[Q(z|X)∥P(z|X)] = E[log Q(z|X) − log P(X|z) − log P(z) ] + log P(X)
\end{equation}
\begin{equation}
D_{KL}[Q(z|X)∥P(z|X)] - log P(X)  = E[log Q(z|X) − log P(X|z) − log P(z)]
\end{equation}

This equation can then be written as another KL Divergence. That is shown below.

\begin{equation}
log P(X) - D_{KL}[Q(z|X)∥P(z|X)] = E[log P(X|z) - (log Q(z|X) − log P(z))]
= E[log P(X|z)] - E[log Q(z|X) - log P(z)]
\end{equation}

This is the final objective functions that we arrive from the derivation:

\begin{equation}
log P(X) - D_{KL}[Q(z|X)∥P(z|X)] = E[log P(X|z)] - D_{KL}[Q(z|X)∥P(z)]
\end{equation}

## Vanilla VAE

```python
import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import os
from tensorflow.examples.tutorials.mnist import input_data

mnist = input_data.read_data_sets('../MNIST_data', one_hot=True)
mb_size = 64
z_dim =
X_dim = mnist.train.images.shape[1]
y_dim = mnist.train.labels.shape[1]
h_dim = 256
c = 0
lr = 1e-3

def plot(samples):
    fig = plt.figure(figsize=(4, 4))
    gs = gridspec.GridSpec(4, 4)
    gs.update(wspace=0.05, hspace=0.05)

    for i, sample in enumerate(samples):
        ax = plt.subplot(gs[i])
        plt.axis('off')
        ax.set_xticklabels([])
        ax.set_yticklabels([])
        ax.set_aspect('equal')
        plt.imshow(sample.reshape(28, 28), cmap='Greys_r')

    return fig

def xavier_init(size):
    in_dim = size[0]
    xavier_stddev = 1. / tf.sqrt(in_dim / 2.)
    return tf.random_normal(shape=size, stddev=xavier_stddev)

# =============================== Q(z|X) ======================================

X = tf.placeholder(tf.float32, shape=[None, X_dim])
z = tf.placeholder(tf.float32, shape=[None, z_dim])

Q_W1 = tf.Variable(xavier_init([X_dim, h_dim]))
Q_b1 = tf.Variable(tf.zeros(shape=[h_dim]))

Q_W2_mu = tf.Variable(xavier_init([h_dim, z_dim]))
Q_b2_mu = tf.Variable(tf.zeros(shape=[z_dim]))

Q_W2_sigma = tf.Variable(xavier_init([h_dim, z_dim]))
Q_b2_sigma = tf.Variable(tf.zeros(shape=[z_dim]))

def Q(X):
    h = tf.nn.relu(tf.matmul(X, Q_W1) + Q_b1)
    z_mu = tf.matmul(h, Q_W2_mu) + Q_b2_mu
    z_logvar = tf.matmul(h, Q_W2_sigma) + Q_b2_sigma
    return z_mu, z_logvar

def sample_z(mu, log_var):
    eps = tf.random_normal(shape=tf.shape(mu))
    return mu + tf.exp(log_var / 2) * eps

# =============================== P(X|z) ======================================

P_W1 = tf.Variable(xavier_init([z_dim, h_dim]))
P_b1 = tf.Variable(tf.zeros(shape=[h_dim]))

P_W2 = tf.Variable(xavier_init([h_dim, X_dim]))
P_b2 = tf.Variable(tf.zeros(shape=[X_dim]))

def P(z):
    h = tf.nn.relu(tf.matmul(z, P_W1) + P_b1)
    logits = tf.matmul(h, P_W2) + P_b2
    prob = tf.nn.sigmoid(logits)
    return prob, logits

# =============================== TRAINING ====================================

z_mu, z_logvar = Q(X)
z_sample = sample_z(z_mu, z_logvar)
_, logits = P(z_sample)

# Sampling from random z
X_samples, _ = P(z)

# E[log P(X|z)]
recon_loss = tf.reduce_sum(tf.nn.sigmoid_cross_entropy_with_logits(logits=logits, labels=X), 1)
# D_KL(Q(z|X) || P(z)); calculate in closed form as both dist. are Gaussian
kl_loss = 0.5 * tf.reduce_sum(tf.exp(z_logvar) + z_mu**2 - 1. - z_logvar, 1)
# VAE loss
vae_loss = tf.reduce_mean(recon_loss + kl_loss)

solver = tf.train.AdamOptimizer().minimize(vae_loss)

sess = tf.Session()
sess.run(tf.global_variables_initializer())
```

    WARNING:tensorflow:From <ipython-input-2-7e44334aa31a>:9: read_data_sets (from tensorflow.contrib.learn.python.learn.datasets.mnist) is deprecated and will be removed in a future version.
    Instructions for updating:
    Please use alternatives such as official/mnist/dataset.py from tensorflow/models.
    WARNING:tensorflow:From /Users/akash/anaconda3/lib/python3.7/site-packages/tensorflow/contrib/learn/python/learn/datasets/mnist.py:260: maybe_download (from tensorflow.contrib.learn.python.learn.datasets.base) is deprecated and will be removed in a future version.
    Instructions for updating:
    Please write your own downloading logic.
    WARNING:tensorflow:From /Users/akash/anaconda3/lib/python3.7/site-packages/tensorflow/contrib/learn/python/learn/datasets/mnist.py:262: extract_images (from tensorflow.contrib.learn.python.learn.datasets.mnist) is deprecated and will be removed in a future version.
    Instructions for updating:
    Please use tf.data to implement this functionality.
    Extracting ../MNIST_data/train-images-idx3-ubyte.gz
    WARNING:tensorflow:From /Users/akash/anaconda3/lib/python3.7/site-packages/tensorflow/contrib/learn/python/learn/datasets/mnist.py:267: extract_labels (from tensorflow.contrib.learn.python.learn.datasets.mnist) is deprecated and will be removed in a future version.
    Instructions for updating:
    Please use tf.data to implement this functionality.
    Extracting ../MNIST_data/train-labels-idx1-ubyte.gz
    WARNING:tensorflow:From /Users/akash/anaconda3/lib/python3.7/site-packages/tensorflow/contrib/learn/python/learn/datasets/mnist.py:110: dense_to_one_hot (from tensorflow.contrib.learn.python.learn.datasets.mnist) is deprecated and will be removed in a future version.
    Instructions for updating:
    Please use tf.one_hot on tensors.
    Extracting ../MNIST_data/t10k-images-idx3-ubyte.gz
    Extracting ../MNIST_data/t10k-labels-idx1-ubyte.gz
    WARNING:tensorflow:From /Users/akash/anaconda3/lib/python3.7/site-packages/tensorflow/contrib/learn/python/learn/datasets/mnist.py:290: DataSet.__init__ (from tensorflow.contrib.learn.python.learn.datasets.mnist) is deprecated and will be removed in a future version.
    Instructions for updating:
    Please use alternatives such as official/mnist/dataset.py from tensorflow/models.
    WARNING:tensorflow:From /Users/akash/anaconda3/lib/python3.7/site-packages/tensorflow/python/framework/op_def_library.py:263: colocate_with (from tensorflow.python.framework.ops) is deprecated and will be removed in a future version.
    Instructions for updating:
    Colocations handled automatically by placer.
    WARNING:tensorflow:From /Users/akash/anaconda3/lib/python3.7/site-packages/tensorflow/python/ops/math_ops.py:3066: to_int32 (from tensorflow.python.ops.math_ops) is deprecated and will be removed in a future version.
    Instructions for updating:
    Use tf.cast instead.

```python
if not os.path.exists('out/'):
    os.makedirs('out/')

i = 0

for it in range(1000000):
    X_mb, _ = mnist.train.next_batch(mb_size)

    _, loss = sess.run([solver, vae_loss], feed_dict={X: X_mb})

    if it % 1000 == 0:
        print('Iter: {}'.format(it))
        print('Loss: {:.4}'. format(loss))
        print()

        samples = sess.run(X_samples, feed_dict={z: np.random.randn(16, z_dim)})
        print (type(samples))
        #fig = plot(samples)
        #samples('out/{}.png'.format(str(i).zfill(3)), bbox_inches='tight')
        i += 1
        #plt.close(fig)
```

# References :
    1.https://wiseodd.github.io/techblog/2016/12/10/variational-autoencoder/
    2.https://github.com/wiseodd/generative-models/blob/master/RBM/rbm_binary_cd.py
    3.https://towardsdatascience.com/intuitively-understanding-variational-autoencoders-1bfe67eb5daf
    4.http://anotherdatum.com/vae2.html

```python

```


# Introduction to Copulas

## Probability Review

Let's start by reviewing some basic probability concepts.

We'll focus specifically on continuous random variables, which is what the Copulas library is primarily intended to support.

### Probability Density Function

A probability density function $f(x)$ captures the likelihood that a random sample from the distribution is equal to $x$. For example, the probability density function for the standard normal distribution is given by

\begin{equation}
f(x) = \frac{1}{2 \pi} e^{-x^2/2}
\end{equation}

Note that the probability density function does **not** return a probability but rather a "relative likelihood" which can take on values in the interval $[0, \infty)$; however, the integral over the probability density function from $-\infty$ to $\infty$ must be equal to one.

### Cumulative Distribution Function

In many cases, the probability density function can be hard to work with directly. Instead, we will use the cumulative distribution function $F(x)$ which is defined as the integral of the probability density function

\begin{equation}
F(x) = \int_{-\infty}^x f(x)
\end{equation}

The below figure shows the probability density function $f(x)$ and the cumulative distribution function $F(x)$ for a normal standard distribution with mean $0.0$ and variance $1$.

```python
import warnings

warnings.filterwarnings('ignore')
```

```python
import numpy as np
from matplotlib import pyplot as plt
from scipy import stats

def plot_cdf_pdf():
    # Generate 10000 evenly distributed values from -4 to 4
    x = np.linspace(-4.0, 4.0, 10000)

    # Compute their Probability Densities and Cumulative Distributions
    pdf = stats.norm.pdf(x)
    cdf = stats.norm.cdf(x)

    figure = plt.figure(figsize=(16, 4))
    figure.add_subplot(1, 2, 1)
    plt.plot(x, pdf)
    plt.title("Probability Density Function")
    plt.xlabel("x")
    plt.ylabel("f(x)")

    figure.add_subplot(1, 2, 2)
    plt.plot(x, cdf)
    plt.title("Cumulative Density Function")
    plt.xlabel("x")
    plt.ylabel("F(x)")
```

```python
plot_cdf_pdf()
```

### Probability Integral Transform

The probability integral transform is a key component in our toolkit for working with probability distributions. Suppose we have a random variable $X$ that comes from a distribution with cumulative density function $F(X)$. Then, we can define a random variable $Y$ as

\begin{equation}
Y = F(X)
\end{equation}

and prove that $Y$ follows a uniform distribution over the interval $[0.0, 1.0]$.

The figure below shows an example of this. We sample some data from a normal distribution and plot it on the left. Then, we use the CDF of the normal distribution to transform the data, plot it on the right, and observe that it resembles an uniform distribution.

```python
from scipy import stats
from matplotlib import pyplot as plt

X = stats.norm.rvs(size=10000)
X_pit = stats.norm.cdf(X)

fig = plt.figure(figsize=(16, 4))
fig.add_subplot(1, 2, 1)
plt.hist(X, density=True, bins=10)
plt.title("Samples")
plt.xlabel("x")

fig.add_subplot(1, 2, 2)
plt.hist(X_pit, density=True, bins=10)
plt.title("Transformed Samples")
plt.xlabel("x")

plt.show()
```

## Copulas

The key intuition underlying copula functions is the idea that marginal distributions can be modeled independently from the joint distribution. For example, consider a dataset with two columns containing age and income. A copula-based modeling approach would:

1. Model age and income independently, transforming them into uniform distributions using the *probability integral transform* explained above.
2. Model the relationship between the transformed variables using the copula function.

In this section, we demonstrate a simplified example of a Gaussian copula.

```python
from copulas.datasets import sample_bivariate_age_income

df = sample_bivariate_age_income()
df.head()
```

      age
      income

      0
      48.935913
      399.161393

      1
      39.234323
      364.225531

      2
      55.659901
      406.475105

      3
      31.810637
      341.276022

      4
      65.342336
      414.347815

```python
from copulas.visualization import scatter_2d

scatter_2d(df)
```

Here's what the age and income variables look like separately.

```python
from copulas.visualization import hist_1d, side_by_side

side_by_side(hist_1d, {'Age': df['age'], 'Income': df['income']})
```

To model this using a Gaussian copula, we can simply run the following:

```python
from copulas.multivariate import GaussianMultivariate

copula = GaussianMultivariate()
copula.fit(df)
```

The GaussianMultivariate class will automatically transform the columns using the best available distribution; let's take a look at what the transformed age and income variables look like.

```python
age_cdf = copula.univariates[0].cdf(df['age'])
inc_cdf = copula.univariates[1].cdf(df['income'])

side_by_side(hist_1d, {'Age': age_cdf, 'Income': inc_cdf})
```

Note that this transformed data looks much more uniform than the original values. Using this transformed data, we can then model the relationship between age and income more easily and generate some synthetic data.

```python
synthetic = copula.sample(len(df))
```

```python
synthetic.head()
```

      age
      income

      0
      31.907286
      304.110961

      1
      48.012266
      371.410661

      2
      35.805468
      360.889437

      3
      53.500743
      350.469573

      4
      39.832329
      350.109369

```python
from copulas.visualization import compare_2d

compare_2d(df, synthetic)
```


# Quaternions as 3D rotation representations
This notebook visualizes the use of unit quaternions as a 3D rotation representation.

**Subjects covered**
1. **Definition and properties of quaternions.**
2. **Unit quaternion as a 3D rotation representation.**
3. **Visualizing quaternion rotation.**
4. **Solving a PnP problem using Levenberg–Marquardt and quaternions to encode camera poses**
5. **Conclusion**
6. **Sources**

Sections 1 and 2 serve as a theoretical underpinning and accessible explanation of methods used.
They can be skipped by readers only interested in the algorithm's implementation.

**Why are we interested in these subjects?**
Quaternions, while less intuitive than rotation matrices, serve as a convenient representation of 3D rotation and pose.
The quaternion avoids several shortcomings of rotation matrices, such as [gimbal lock](https://www.youtube.com/watch?v=zc8b2Jo7mno&ab_channel=GuerrillaCG) and the lack of a straightforward interpolation method.
This has led to the popularity of quaternions in computer graphics, computer vision, robotics, and many other fields.
Similar to 2-dimensional complex numbers, quaternions form a 4-dimensional number system, which makes them mathematically interesting.
It also makes them quite difficult to comprehend in my opinion, but this notebook should alleviate that!

**Further reading**
For a general overview of quaternions see [the Wikipedia page](https://en.wikipedia.org/wiki/Quaternion).
For an accessible derivation of Quaternions, see [this blog](http://www.songho.ca/math/quaternion/quaternion.html) by Song Ho and [this Stanford tutorial](https://graphics.stanford.edu/courses/cs348a-17-winter/Papers/quaternion.pdf) by Yan-Bin Jia.
For those especially interested in the use of quaternions for rotation, see [this Wikipedia page](https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation) and this [interactive visualization](https://eater.net/quaternions) by Ben Eater and Grant Sanderson.

# Quaternions

#### Definition
A quaternion is an element of the quaternion number system, which extends the complex numbers. It has one real and three imaginary components $i$, $j$, and $k$ and is generally represented in the form

$$q =  a+b\ \mathbf {i} +c\ \mathbf {j} +d\ \mathbf {k}$$

Where $a$ is the "scalar part" and $ {b\ \mathbf {i} +c\,\mathbf {j} +d\,\mathbf {k}}$ is the "vector part" of the quaternion.

#### Mutiplication

Quaternions behave according to the following definitions:

$$
 \begin{align}
  & \mathbf {i} ^{2} =\mathbf {j} ^{2}=\mathbf {k} ^{2}=\mathbf {i\,j\,k} =-1 \\
  & \mathbf {i} \,1 =1\,\mathbf {i} =\mathbf {i} ,\qquad \mathbf {j} \,1=1\,\mathbf {j} =\mathbf {j} ,\qquad \mathbf {k} \,1=1\,\mathbf {k} =\mathbf {k} \,. \\
  & \mathbf {i\,j} =\mathbf {k} \\
  & \mathbf {j\,i} =-\mathbf {k} \\
  & \mathbf{jk} = \mathbf{i}
  \end{align}
$$

Which can be summarized into a matrix. (screenshot from [Wikipedia article](https://en.wikipedia.org/wiki/Quaternion), as Jupyter does not allow table styling)

There is a geometrical interpretation of quaternion multiplication. Like that of complex numbers, it relates to rotation. The imaginary numbers $\mathbf {i\,j\,k}$ are interpreted as 3D basis vectors or "Quadrantal vectors" of a coordinate frame. Multiplying two of these vectors, e.g. $\mathbf{jk}$, is interpreted as counter-clockwise rotating $\mathbf{j}$ into $\mathbf{k}$ and taking the basis vector that is perpendicular to the rotation plane as the result.

Mutiplication of two quaternions is known as the Hamilton product

$$q_1q_2 = (a_1 + b_1\mathbf{i} + c_1\mathbf{j} + d_1\mathbf{k})\hspace{0.5em} (a_2 + b_2\mathbf{i} + c_2\mathbf{j} + d_2\mathbf{k})$$

and is determined by the distributive law the defined products of the basis elements. Thus, we distribute as one would do for a polynomial.

$${\displaystyle {\begin{alignedat}{4}&a_{1}a_{2}&&-b_{1}b_{2}&&-c_{1}c_{2}&&-d_{1}d_{2}\\{}+{}(&a_{1}b_{2}&&+b_{1}a_{2}&&+c_{1}d_{2}&&-d_{1}c_{2})\mathbf {i} \\{}+{}(&a_{1}c_{2}&&-b_{1}d_{2}&&+c_{1}a_{2}&&+d_{1}b_{2})\mathbf {j} \\{}+{}(&a_{1}d_{2}&&+b_{1}c_{2}&&-c_{1}b_{2}&&+d_{1}a_{2})\mathbf {k} \end{alignedat}}}$$

Note that quaternion multiplication is not commutative, it is associative, and distributive across addition.

$$
\begin{align}
 pq & \ne qp  \\
 (op)q &= o(pq)\\
 o(p + q) & = op + oq \\
\end{align}
$$

#### Addition
Quaternion addition, e.g. $q_1 + q_2$, entails adding the real parts of $q_1$ to $q_1$ and adding the imaginary parts of $q_1$ to the corresponding imaginary parts of $q_2$.

$$
\begin{align}
q_1 + q_2 & = (a_1 + b_1\mathbf{i} + c_1\mathbf{j} + d_1\mathbf{k}) + (a_2 + b_2\mathbf{i} + c_2\mathbf{j} + d_2\mathbf{k}) \\
 & = (a_1 + a_2) + \mathbf{i} (b_1 + b_2) + \mathbf{j} (c_1 + c_2) + \mathbf{k} (d_1 + d_2)
\end{align}
$$

#### Subtraction
Similar to addition
$$
q_1 - q_2  = (a_1 - a_2) + \mathbf{i} (b_1 - b_2) + \mathbf{j} (c_1 - c_2) + \mathbf{k} (d_1 - d_2)
$$

#### Scalar multiplication
Quaternion scalar multiplication, e.g. $r q$, simply entails multiplying the real part of $q$ by $r$ and each imaginary part by $r$.

$$
\begin{align}
r q & = (r a + r (b \mathbf{i} + c \mathbf{j} +  \mathbf{k}))  \\
    & = (r a + r b \mathbf{i} + r c \mathbf{j} + r \mathbf{k})
\end{align}
$$

#### Conjugate
The conjugation of quaternions is analogous to the conjugation of complex numbers. That is, for a complex number $z = a + b\textbf{i}$ the complex conjugate is $z^{*} = a - b\textbf{i} $.
For a quaternion $q$, the complex conjugate is defined by negating the vector part
$$
\begin{align}
q & =  a+b\ \mathbf {i} +c\ \mathbf {j} +d\ \mathbf {k} \\
q^{*} & =a - b\,\mathbf {i} -c\,\mathbf {j} -d\,\mathbf {k}
\end{align}
$$

Some complex conjugate properties:
$$
\begin{align}
(q^*)^* & = q \\
(p + q)^* & = p^* + q^* \\
(pq)^* &= q^*p^* \\
qq^* &= q^*q
\end{align}
$$

#### Norm
The square root of the product of a quaternion with its conjugate is called its norm and is denoted $\lVert q \rVert$
$$
\lVert q \rVert = \sqrt {qq^{*}}= \sqrt {q^{*}q} = \sqrt {a^{2}+b^{2}+c^{2}+d^{2}}
$$

A real number, $\alpha \in \mathbb{R} $, scales a quaternion's norm by the absolute value of the number.

$$  \lVert \alpha q \rVert =  \lvert \alpha \rvert \hspace{0.5em} \lVert q \rVert$$

This is entailed by the fact that the norm of two quaternions is multiplicative.

$$ \lVert pq \rVert = \lVert p \rVert \hspace{0.5em} \lVert q \rVert $$

The norm of a quaternion is equal to that of its conjugate
$$ \lVert q \rVert = \lVert q^* \rVert$$

This definition of a norm is analogous to the Euclidian norm in a vector space $\mathbb{R}^{n}$ which allows for an analogous definition of the distance between quaternions.

$$ d(p, q) = \lVert p - q \rVert $$

A unit quaternion has a norm of one. We will see later how it is used as a representation of 3D rotation.

$$ \mathbf {U} q=\frac {q}{\lVert q \rVert } $$

#### Inverse
The reciprocal of a quaternion can be defined using the norm and complex conjugate.
$$ q^{-1}={\frac {q^{*}}{\lVert q\rVert ^{2}}}$$

$$ q^{-1}q = q q^{-1} = 1 $$

The inverse of a unit quaterion is its conjugate

$$ \mathbf {U} q^{-1} = \mathbf {U} q^* $$

## Matrix & vector forms of quaternions
Real valued matrices $\mathbb{R}^{m \times n}$ can be used to represent quaternion addition and multiplication. That is, we can convert the four values of quaternions into a matrix representation. Matrix multiplication and addition will correspond to quaternion multiplication and addition. The conjugate of the quaternion will correspond to the transpose of the matrix.

What I find **brilliant** about this correspondence, is that more complex operations on these matrices will have equivalent quaternion semantics.

For example,  we can find a partial derivative of a rotation represented by a unit quaternion with respect to its parameters $\{a,b,c,d\}$ by finding the derivative of the matrix representation. While this is computationally convenient, it is considered peculiar abuse of notation by mathematicians.

For the quaternion

$$q =  a+b\ \mathbf {i} +c\ \mathbf {j} +d\ \mathbf {k}$$

the matrix representation is

$$
\begin{bmatrix}
a & -b & -c & -d  \\
b &  a & -d &  c  \\
c &  d &  a & -b  \\
d & -c & b & a
\end{bmatrix}
$$

Keep in mind that this matrix representation is not unique (There are 48 distinct matrix representations). That is, there exist other matrix representations of quaternions that conserve quaternion multiplication and addition. For details, see this [Wikipedia section](https://en.wikipedia.org/wiki/Quaternion#Matrix_representations).

## Unit quaternion as 3D rotation representations
Unit quaternions provide a convenient mathematical notation for representing pose and rotations in three dimensions. Unlike Euler angles, they avoid gimbal lock, offer a straightforward method to smoothly interpolate between poses, and are efficient to compute. Additionally, they encode rotation as an axis-angle rotation about an arbitrary axis, which is an intuitive mental model of rotation.

A rotation of $\theta$ radians about a unit axis $\mathbf{x} = (x, y, z)$ is represented by the unit quaternion $(c, sx, sy, sz)$ where $C= \cos(\theta /2)$ and $s=\sin(\theta /2)$. In other words:

$$
\begin{align}
& q = \text{Rotatation representation $\theta$ rads. about $\mathbf{x}$} \\
& q = \cos(\theta /2) + \sin(\theta/2)(xi + yj + zk)
\end{align}
$$

#### Performing the rotation with conjugation
The conjugation operation is required to rotate a 3D point by a unit quaternion. Conjugating a 3D point $p$ by unit quaternion $q$ refers to the operation $p \rightarrow qpq^{−1}$. Note that the 3D point $p$ is encoded as a quaternion with a real part equal to zero.

$$
\begin{align}
\text{p rotated } & = \text{Conjugate point p by unit quaternion q} \\
p' & = qpq^{−1}
\end{align}
$$

Proofs are omitted due to their size. For a proof that unit quaternion conjugation is equivalent to Rodrigues' rotation formula, a different axis-angle rotation scheme, see [this post](https://erkaman.github.io/posts/quaternion_rotation.html#mjx-eqn-eqrodri) by Eric Arnebäck. For a more succinct, but weaker proof that unit quaternion conjugation preserves the norm of the rotated point, see [this section](https://www.songho.ca/math/quaternion/quaternion.html#rotation) of Song Ho's blog.

#### Why conjugation?
An intuitive mental model of what the conjugation operation is doing can be found in [this interactive visualization](https://eater.net/quaternions/video/intro). I.e., $\hat{p} = qp$ performs half of the rotation of $p$ while scaling simultaneously scaling $p$'s vector part's norm*. Then, $\hat{p}q^{−1}$ completes the second half of the rotation and reverts $p$'s vector part's norm to its original value.

*- Quaternion norms are multiplicative (see definitions above), but this does not mean the norm of solely the vector parts of $q$ and $p$ is  multiplicative; thus, in general, $qp$ scales $p$'s norm.

#### Matrix form of quaternion rotation
As with the fundamental quaternion operations, conjugation by a unit quaternion has a corresponding matrix form.

$$
\begin{align}
qpq^{-1} & =
\begin{bmatrix}
a \\
b \\
c \\
d
\end{bmatrix}
\begin{bmatrix}
0 \\
x \\
y \\
z
\end{bmatrix}
\begin{bmatrix}
a \\
-b \\
-c \\
-d
\end{bmatrix}
\quad \text{(Quaternion multiplication)}
\\
\\
& =
\begin{bmatrix}
1 - 2 c^2 - 2 d^2  & 2bc - 2ad         & 2 bd + 2 ac     \\
2 bc + 2 ad        & 1 - 2 b^2 - 2d^2  & 2 cd - 2 ab     \\
2 bd - 2 ac        & 2 cd + 2 ab       & 1 - 2b^2 - 2c^2
\end{bmatrix}
\begin{bmatrix}
x \\
y \\
z
\end{bmatrix} \quad \text{(Matrix multiplication)}
\end{align}
$$

For a proof of this equivalence, see [this article](https://www.songho.ca/opengl/gl_quaternion.html).

#### Converting a rotation matrix to a rotation quaternion
The 'rotation matrix $\rightarrow$ quaternion' mapping is simply the inversion of the 'quaternion$\rightarrow$rotation matrix' formula shown above. This inversion is too lengthy to describe here.
For a formal proof of the inversion, see [this article](https://intra.ece.ucr.edu/~farrell/AidedNavigation/D_App_Quaternions/Rot2Quat.pdf) by Jay A. Farrell.
The code for the inversion was taken from [this article](https://d3cw3dd2w32x2b.cloudfront.net/wp-content/uploads/2015/01/matrix-to-quat.pdf) by Mike Day.

## Unit-test matrix representation of quaternions

```python
import ipyvolume as ipv
import numpy as np
import time
np.random.seed(0)

def quaternion_to_matrix(quaternion):
    """ Converts a quaternion to its matrix representation. """
    a, b, c, d = quaternion
    return np.array([[a, -b, -c, -d],
                     [b,  a, -d,  c],
                     [c,  d,  a, -b],
                     [d, -c,  b,  a]])

def matrix_to_quaternion(matrix):
    """ Converts a matrix to its quaternion representation. """
    return matrix[:, 0]

def multiply(q1, q2):
    """ Multiplies two quaternions using the  Hamilton product. """
    a1, b1, c1, d1 = q1
    a2, b2, c2, d2 = q2
    a = a1*a2 - b1*b2 - c1*c2 - d1*d2
    b = a1*b2 + b1*a2 + c1*d2 - d1*c2
    c = a1*c2 - b1*d2 + c1*a2 + d1*b2
    d = a1*d2 + b1*c2 - c1*b2 + d1*a2
    return np.array([a, b, c, d])

def conjugate(quaternion):
    """ Returns the quaternion conjugate. """
    a, b, c, d = quaternion
    return np.array([a, -b, -c, -d])

def norm(quaternion):
    """ Returns the quaternion norm. """
    return np.linalg.norm(quaternion)

def inverse(quaternion):
    """ Returns the quaternion inverse. """
    return conjugate(quaternion) / (norm(quaternion)**2)

def conjugation(quaternion, point):
    """ Conjugates the point by the unit quaternion.
        'point' is a the 3D point represented by a quaternion
        with real part 0 when using conjugation as a rotation.
    """
    q_inv = inverse(quaternion)
    return multiply(multiply(quaternion, point), q_inv)

def axis_angle_to_rot_quaternion(rot_axis, theta):
    """ Returns the quaternion representing rotation for a rotation axis and angle.
        Theta is in radians.
    """
    rot_axis = rot_axis / np.linalg.norm(rot_axis)
    b, c, d = rot_axis # Interpret axis as quaternion with real part = 0.
    cos = np.cos(theta / 2)
    sin = np.sin(theta / 2)
    return np.array([cos, sin*b, sin*c, sin*d]).squeeze()

def quaternion_to_rot_matrix(quaternion):
    """ Converts a unit quaternion to the matrix representing its conjugation. """
    a, b, c, d = quaternion / np.linalg.norm(quaternion)
    rot_matrix = np.array([[1 - 2 * c**2 - 2 * d**2,  2 * b * c - 2 * a * d,    2 * b *d + 2 * a * c   ],
                           [2 * b * c + 2 * a * d,    1 - 2 * b**2 - 2 * d**2,  2 * c * d - 2 * a * b  ],
                           [2 * b * d - 2 * a * c,    2 * c * d + 2 * a * b,    1 - 2 * b**2 - 2 * c**2]])
    return rot_matrix

def rot_matrix_to_quaternion(rot):
    """ Converts a rotation matrix to a quaterion that represents the same rotation.
        Taken from  https://d3cw3dd2w32x2b.cloudfront.net/wp-content/uploads/2015/01/matrix-to-quat.pdf
    """
    r = rot.T
    if r[2,2] < 0:
        if r[0, 0] > r[1,1]:
            t = 1 + r[0, 0] - r[1, 1] - r[2, 2]
            q = [t, r[0, 1] + r[1, 0], r[2, 0] + r[0, 2], r[1, 2] - r[2, 1]]
        else:
            t = 1 - r[0, 0] + r[1, 1] - r[2, 2]
            q = [r[0, 1] + r[1, 0], t, r[1, 2] + r[2, 1], r[2, 0] - r[0, 2]]
    else:
        if r[0, 0] < -r[1, 1]:
            t = 1 - r[0, 0] - r[1, 1] + r[2, 2]
            q = [r[2, 0] + r[0, 2], r[1, 2] + r[2, 1], t, r[0, 1] - r[1, 0]]
        else:
            t = 1 + r[0, 0] + r[1, 1] + r[2, 2]
            q = [r[1, 2] - r[2, 1], r[2, 0] - r[0, 2], r[0, 1] - r[1, 0], t]
    q = np.array(q)
    q *= 0.5 / np.sqrt(t)
    q = -np.roll(q, 1)
    return q
```

```python
# Check multiplication
q1 = np.random.rand(4)
q2 = np.random.rand(4)
q1_m = quaternion_to_matrix(q1)
q2_m = quaternion_to_matrix(q2)
q = multiply(q1, q2)
q_m = matrix_to_quaternion(q1_m @ q2_m)
equal = np.allclose(q, q_m)
print(f'Quaternion product equals matrix multiply: {equal}')

# Check if conjugate equals transpose
q = np.random.rand(4)
matrix = quaternion_to_matrix(q)
q_t = matrix_to_quaternion(matrix.T)
equal = np.allclose(conjugate(q), q_t)
print(f'Quaternion conjugate equals matrix transpose: {equal}')

# Check if inverse equals matrix inverse
q = np.random.rand(4)
q_m = quaternion_to_matrix(q)
q_inv = inverse(q)
q_m_inv = matrix_to_quaternion(np.linalg.inv(q_m))
equal = np.allclose(q_inv, q_m_inv)
print(f'Quaternion inverse equals matrix inverse: {equal}')

# Check if matrix rotation is same as conjugation. Rotate 30 degrees around y axis
point = [1, 0, 0]
point_q = [0, 1, 0, 0]
rad = np.radians(30)
rot_matrix =  np.array([[np.cos(rad),  0, np.sin(rad)],
                        [0,            1,           0],
                        [-np.sin(rad), 0, np.cos(rad)]])
rot_q = axis_angle_to_rot_quaternion([0, 1, 0], rad)
rot_point = rot_matrix @ point
rot_point_q = conjugation(rot_q, point_q)
equal = np.allclose(rot_point, rot_point_q[1:])
print(f'Rotation by quaternion conjugation equals rotation by matrix: {equal}')

q = rot_matrix_to_quaternion(rot_matrix)
rot_matrix_2 = quaternion_to_rot_matrix(q)
equal = np.allclose(rot_matrix, rot_matrix_2)
print(f'Rotation matrix and quaternion conversions work: {equal}')
```

## Visualize rotation with quaternions
In this cell, we will visualize the rotation of points using unit quaternions.
Note how unit quaternions are a straightforward way to encode axis-angle rotations.
Here, the blue line is the rotation axis.
The red line is a random unit vector that will be rotated around the axis.
The random vector is rotated around the axis by 500 degrees.
Then, a different rotation axis is chosen.
This process is repeated 5 times.

```python
def random_line():
    """ Returns a random unit length 3D line segment placed at the origin.

        Note that the line consists of 50 sampled points.
        This is done in order to better visualize the line,
        as ipyvolume's default lines have almost no diameter.
    """
    direction = np.random.rand(3)
    norm_direction = direction / np.linalg.norm(direction)
    line = np.array([norm_direction * length for length in np.linspace(0.0, 1.0, 50)])
    return line

def unit_sphere():
    """ Returns the point cloud of a unit square placed at the origin. """
    sphere = list()
    for x in np.linspace(-1, 1, 10):
        for y in np.linspace(-1, 1, 10):
            for z in np.linspace(-1, 1, 10):
                sphere_point = np.array([x, y, z])
                sphere_point = sphere_point / np.linalg.norm(sphere_point)
                sphere.append(sphere_point)
    sphere = np.array(sphere)
    return sphere
```

```python
# Plot the unit sphere, rotation axis, and line to be rotated.
sphere = unit_sphere()
rot_axis = random_line()
line = random_line()

line_xs, line_ys, line_zs = line.T
sphere_xs, sphere_ys, sphere_zs = sphere.T
axis_xs, axis_ys, axis_zs = rot_axis.T

fig = ipv.pylab.figure(figsize=(15, 15), width=800)
line_plot = ipv.scatter(line_xs, line_ys, line_zs, marker='sphere')
sphere_plot = ipv.scatter(sphere_xs, sphere_ys, sphere_zs, size=0.5, marker='sphere')
rot_axis_plot = ipv.scatter(axis_xs, axis_ys, axis_zs, color='blue', marker='sphere')
ipv.show()
time.sleep(3)
# Visualize rotation in steps of 1 degree
theta = (1 / 360) * (2 * np.pi)

for _ in range(5):
    # Represent an axis-angle rotation with a quaternion
    # Convert to a rot-matrix for efficient broadcasting to all points.
    q_rot = axis_angle_to_rot_quaternion(rot_axis[-1], theta)
    q_rot_matrix = quaternion_to_rot_matrix(q_rot)

    for _ in range(500):
        line = (q_rot_matrix @ line.T).T
        xs, ys, zs = line.T
        line_plot.x = xs
        line_plot.y = ys
        line_plot.z = zs
        time.sleep(0.01)

    # After each rotation, choose a new rotation axis
    rot_axis = random_line()
    axis_xs, axis_ys, axis_zs = rot_axis.T
    rot_axis_plot.x = axis_xs
    rot_axis_plot.y = axis_ys
    rot_axis_plot.z = axis_zs
    time.sleep(1)
```

## Solving a PnP problem using a quaternion for camera pose
Next, we will use quaternions to solve a [Perspective-n-Point](https://en.wikipedia.org/wiki/Perspective-n-Point) (PnP) problem. PnP refers to the process of estimating the pose of a calibrated camera given a set of $n$ 3D points in the world and their corresponding 2D projections in an image.

An intuitive way to think of PnP is as a "camera placement" problem. You have a camera that is back-projecting rays from 2D pixel points. You want to find a camera pose such that these rays intersect with their respective world points. The error between the current and optimal pose is quantified by the reprojection error. The reprojection error measures the in-image distance between the pixel point in the provided image, and the pixel point from the projected 3D world point. The projected 3D world point is calculated using the estimated camera pose and known 3D world point coordinate.

Image from Pix4D article

In this section, we will assume to have perfect knowledge of
* the 3D world point coordinates
* which pixel points correspond to the 3D world points

```python
# Functions defined and used in 1_camera_basics.ipynb notebook.
from notebook_functions import (init_3d_plot,
                                plot_chessboard,
                                plot_camera_wireframe,
                                plot_picture,
                                calibrate_cameras,
                                extrinsics_from_calibration,
                                project_points_to_picture,
                                object_points,
                                images,
                                intrinsics,
                                camera_centers,
                                extrinsics,
                                random_rotation)
import ipyvolume as ipv
import numpy as np
import time
```

## Show a calibrated camera and distort its pose.
To perform PnP, we will need an initial estimate of a camera pose.
Here, we will use a distorted camera extrinsics as an initial estimate.
The distortion is implemented as a random rotation and translation.
This also provides a ground truth pose: the undistorted pose can be used to measure the accuracy of the solution.

```python
def extrinsics_from_quaternion(quaternion, translation):
    """ Return an camera extrinsic matrix for a orientation
        quaternion and displacement translation vector.
    """
    rotation = quaternion_to_rot_matrix(quaternion)
    extrinsic = np.hstack([rotation.squeeze(), translation[None,].T])
    extrinsic = np.vstack([extrinsic, [[0,0,0,1]]])
    return extrinsic

def random_angle(max_angle):
    """ Returns a random angle in radians """
    rad = np.radians(max_angle)
    rand_factor = np.random.uniform(low=-1, high=1)
    return rad * rand_factor

def random_rotation(max_angle=100):
    """ Returns a matrix for random rotation around x, y, and z axis. """
    t_x = random_angle(max_angle)
    t_y = random_angle(max_angle)
    t_z = random_angle(max_angle)

    r_x = np.array([[1,           0,            0],
                    [0, np.cos(t_x), -np.sin(t_x)],
                    [0, np.sin(t_x),  np.cos(t_x)]])

    r_y = np.array([[np.cos(t_y),  0, np.sin(t_y)],
                    [0,            1,         0],
                    [-np.sin(t_y), 0, np.cos(t_y)]])

    r_z = np.array([[np.cos(t_z), -np.sin(t_z), 0],
                    [np.sin(t_z),  np.cos(t_z), 0],
                    [0,            0,           1]])

    return r_x @ r_y @ r_z

def random_translation(max_offset=10):
    """ Returns a random translation vector. """
    return np.random.uniform(low=-max_offset, high=max_offset, size=3)

def distort_extrinsics(extrinsic):
    """ Randomly distorts an extrinsic matrix such that
        the pose it represents is rotated and moved.
    """
    extrinsic = extrinsic.copy()
    rot = extrinsic[:3, :3]
    trans = extrinsic[3, :3]
    rand_rot = random_rotation()
    rand_trans = random_translation()
    extrinsic[:3, :3] = rand_rot @ extrinsic[:3, :3]
    extrinsic[:3, 3] = extrinsic[:3, 3] + rand_trans
    return extrinsic

def distort_quaternion(q):
    """ Distorts a pose quaternion by randomly rotating it, """
    rot_q = random_rotation_quaternion()
    q = conjugation(rot_q, q)
    return q
```

```python
vis_scale = 5
init_3d_plot()
plot_chessboard(object_points)
ipv.show()

inv_intrinsics = np.linalg.inv(intrinsics)
first_cam = extrinsics[0]
# first_cam_est = distort_extrinsics(first_cam) # Uncomment for random distortion. Next line is distortion show in videos.
first_cam_est = np.array([[-.2438,.3825,.8911,6.6443],[.9257,.3655,.0963,-3.8001],
                          [-0.2889,  0.8485, -0.4432, 35.4257],[0,0,0,1]])
quaternion_true = rot_matrix_to_quaternion(first_cam[:3, :3])
quaternion_est = rot_matrix_to_quaternion(first_cam_est[:3, :3])
translation_true = first_cam[:3, 3]
translation_est = first_cam_est[:3, 3]

for idx, extrinsic in enumerate([first_cam, first_cam_est]):
    color = 'red' if idx else 'blue'
    inv_extrinsic = np.linalg.pinv(extrinsic)
    proj_matrix = intrinsics @ extrinsic
    cam_center = -extrinsic[:3, :3].T @ extrinsic[:3, 3]
    plot_camera_wireframe(cam_center, vis_scale, inv_extrinsic, color)
    # back-project pixel points into rays
    if idx == 0:
        image = project_points_to_picture(images[0], object_points, intrinsics, extrinsic)
        chess_corner_proj = proj_matrix @ object_points
        chess_corner_proj /= chess_corner_proj[2]
        chess_corner_proj[3] = 1
    plot_picture(image, inv_extrinsic, vis_scale)
    dir_vecs_in_cam_ref = inv_intrinsics @ chess_corner_proj
    dir_vecs_in_cam_ref[:3] *= 35
    dir_vecs_in_world = inv_extrinsic @ dir_vecs_in_cam_ref
    for vec in dir_vecs_in_world.T:
            ipv.plot([cam_center[0], vec[0]], [cam_center[1], vec[1]], [cam_center[2], vec[2]], color)
```

## Levenberg-Marquardt algorithm to solve PnP and realign camera
The PnP problem is solved by minimizing the reprojection error. The [Levenberg-Marquardt](https://en.wikipedia.org/wiki/Levenberg%E2%80%93Marquardt_algorithm) optimization algorithm is well suited for this. It iteratively minimizes errors by combining a [gradient descent](https://en.wikipedia.org/wiki/Gradient_descent) solution and a [least-squares](https://en.wikipedia.org/wiki/Least_squares) solution. In other words, it finds the derivative of the squared reprojection error w.r.t the camera pose, and combines two solutions:
* Gradient descent - Update the camera in the parameter direction that **reduces** the error.
* Least-squares - Update the camera to the parameter location that **minimizes** the error.

These two solutions model the reprojection error in different ways.

* Gradient descent - Assumes the squared error w.r.t parameters lie on a manifold. A manifold is a mathematical space that is not necessarily Euclidean on a global scale, but can be seen as Euclidean on a local scale. That is, gradients are valid indicators of directions that decrease errors at a local scale.
* Least-squares - Assumes the squared error w.r.t parameters is modeled by a convex quadratic polynomial. Like in high school maths, this function is minimized by setting the derivative to zero.

This makes gradient descent slow but stable and least-squares faster but more erratic. Least-squares makes more assumptions that can be false. The dampening factor $\lambda$ is the parameter that controls the weight of each solution. When $\lambda=0$, only the least-squares solution is used, and it is essentially [Gauss-Newton](https://en.wikipedia.org/wiki/Gauss%E2%80%93Newton_algorithm) optimization. For greater values of $\lambda=0$, the gradient descent determines a larger portion of the update. The code implements the equations in the [Wikipedia article](https://en.wikipedia.org/wiki/Levenberg%E2%80%93Marquardt_algorithm).

```python
def project_points(extrinsics, object_points):
    """ Projects 3D points to an image using a projection matrix. """
    proj_matrix = intrinsics @ extrinsics
    image_points = proj_matrix @ object_points
    image_points = image_points / image_points[2]
    return image_points

def reprojection_error(projection, target):
    """ Calculates the mean squared reprojection error.

    Args:
        projection (np.ndarray): Where the known 3D points are
            currently projected in the image.
        target (np.ndarray): Where the known 3D points should
            be projected to.
    returns:
        error (float): The mean squared error.
    """
    pixel_dist = projection[:2] - target[:2]
    error = np.sqrt(np.sum(pixel_dist ** 2, axis=0))
    return error

def jacobian(quaternion, translation, object_points, target_proj):
    """ Returns the jacobian w.r.t the extrinsic parameters encoded by
        a unit quaternion and translation vector. The derivatives are numerical.

    Args:
        quaternion (np.ndarray): A camera orientation unit quaternion.
        translation (np.ndarray): A camera displacement translation vector.
        object_points (np.ndarray): Known 3D object points being projected the camera.
    Returns:
        jacobian_array: The derivatives of the squared reprojection error
            w.r.t the pose quaternion and the translation vector.
    """
    eps = 0.0000001
    orig_ext = extrinsics_from_quaternion(quaternion, translation)
    orig_error = reprojection_error(project_points(orig_ext, object_points)[:2], target_proj[:2])

    # Get changed extrinsic matrices
    da_ext = extrinsics_from_quaternion(quaternion + [eps, 0, 0, 0], translation)
    db_ext = extrinsics_from_quaternion(quaternion + [0, eps, 0, 0], translation)
    dc_ext = extrinsics_from_quaternion(quaternion + [0, 0, eps, 0], translation)
    dd_ext = extrinsics_from_quaternion(quaternion + [0, 0, 0, eps], translation)
    dx_ext = extrinsics_from_quaternion(quaternion, translation + [eps, 0, 0])
    dy_ext = extrinsics_from_quaternion(quaternion, translation + [0, eps, 0])
    dz_ext = extrinsics_from_quaternion(quaternion, translation + [0, 0, eps])

    # Get numerical derivatives
    da = (reprojection_error(project_points(da_ext, object_points)[:2], target_proj[:2]) - orig_error) / eps
    db = (reprojection_error(project_points(db_ext, object_points)[:2], target_proj[:2]) - orig_error) / eps
    dc = (reprojection_error(project_points(dc_ext, object_points)[:2], target_proj[:2]) - orig_error) / eps
    dd = (reprojection_error(project_points(dd_ext, object_points)[:2], target_proj[:2]) - orig_error) / eps
    dx = (reprojection_error(project_points(dx_ext, object_points)[:2], target_proj[:2]) - orig_error) / eps
    dy = (reprojection_error(project_points(dy_ext, object_points)[:2], target_proj[:2]) - orig_error) / eps
    dz = (reprojection_error(project_points(dz_ext, object_points)[:2], target_proj[:2]) - orig_error) / eps

    jacobian_array = np.array([da, db, dc, dd, dx, dy, dz]).T
    return jacobian_array

def levenberg_marquardt_step(quaternion, translation, object_points, target_proj, dampening_factor):
    """ Takes a step in the gradient descent and least-squares direction.

    Args:
        quaternion (np.ndarray): A camera orientation unit quaternion.
        translation (np.ndarray): A camera displacement translation vector.
        object_points (np.ndarray): Known 3D object points being projected the camera.
        target_proj (np.ndarray): Where the known 3D points should be projected to.
        dampening_factor (float): Paramater to balance gradient descent and least squares solution.
    Return:
        quaternion (np.ndarray): The updated camera orientation unit quaternion.
        translation (np.ndarray): The updated camera displacement translation vector.
        corrections_norm (float): The length of the correction correction vector.
            Can be used to check for convergence.
    """
    extrinsic = extrinsics_from_quaternion(quaternion, translation)
    j = jacobian(quaternion, translation, object_points, target_proj)
    r = reprojection_error(project_points(extrinsic, object_points)[:2], target_proj[:2])
    jtj = j.T @ j
    ident = np.identity(jtj.shape[0])
    inv = np.linalg.pinv(jtj + ident * dampening_factor)
    inv = inv @ j.T
    learning_rate = 0.1
    corrections = -inv @ r * learning_rate
    corrections_norm = np.linalg.norm(corrections)
    quaternion  = quaternion + corrections[:4]
    quaternion = quaternion / np.linalg.norm(quaternion)
    translation = translation + corrections[4:]
    return quaternion, translation, corrections_norm
```

```python
ipv.show()
time.sleep(3)
iterations = 50

for method in ['levenberg', 'gauss']:
    quaternion = quaternion_est.copy()
    translation = translation_est.copy()
    dampening_factor = 0 if method == 'gauss' else 8
    color = 'orange' if method == 'gauss' else 'purple'

    for i in range(iterations):
        quaternion, translation, corrections_norm = levenberg_marquardt_step(quaternion,
                                                                            translation,
                                                                            object_points,
                                                                            chess_corner_proj,
                                                                            dampening_factor)
        extrinsic = extrinsics_from_quaternion(quaternion, translation)
        cam_center = -extrinsic[:3, :3].T @ extrinsic[:3, 3]
        inv_extrinsics = np.linalg.pinv(extrinsic)
        plot_camera_wireframe(cam_center, vis_scale, inv_extrinsics, color)
        time.sleep(0.5)
```

## Conclusion
Pat yourself on the back! In this notebook we just:
* went through the maths behind quaternions
* implemented quaternions
* used quaternions to solve a non-trivial pose optimization problem
* implemented the popular Levenberg Marquardt algorithm

Quaternions will often be abstracted away in software libraries.
Nevertheless, it is good to have an intuition of how they work.
In notebook 4, we will use the g2o library to solve an optimization problem called bundle adjustment.
If one digs into [g2o's source code](https://github.com/RainerKuemmerle/g2o), you will find that the poses and rotations are all represented by quaternions!
Next up: a notebook on epipolar geometry and its many uses.

## Sources:
1. [Wikipedia article](https://en.wikipedia.org/wiki/Quaternion)
2. [Visualization by Ben Eater and Grant Sanderson](https://eater.net/quaternions)
3. [Blog by Song Ho](http://www.songho.ca/math/quaternion/quaternion.html)
4. [Stanford tutorial by Yan-Bin Jia](https://graphics.stanford.edu/courses/cs348a-17-winter/Papers/quaternion.pdf)
5. [Proof by Eric Arnebäck](https://erkaman.github.io/posts/quaternion_rotation.html#mjx-eqn-eqrodri)
6. [Proof by Jay A. Farrell](https://intra.ece.ucr.edu/~farrell/AidedNavigation/D_App_Quaternions/Rot2Quat.pdf)
7. [Code by Mike Day](https://d3cw3dd2w32x2b.cloudfront.net/wp-content/uploads/2015/01/matrix-to-quat.pdf)


# Warmup - Introduction to SymPy
This lab exercise uses the [SymPy](http://www.sympy.org/en/index.html) symbolic math library to model constraints in the problem.  To do that, we will use symbols (`sympy.Symbol`), functions (`sympy.Function`), and expressions (`sympy.Expr`) from sympy, and then we'll combine the function and expression classes to make constraints -- evaluatable symbolic functions.

In this warmup, you will be introduced to the syntax and functionality of SymPy:
- [Example 1](#Example-1:-Symbols): Creating [symbols](http://docs.sympy.org/dev/modules/core.html#module-sympy.core.symbol)
- [Example 2](#Example-2:-Expressions): Creating & manipulating [expressions](http://docs.sympy.org/dev/modules/core.html#id16) with [arithmetic & logical operators](http://docs.sympy.org/dev/modules/core.html#sympy-core)
- [Example 3](#Example-3:-Symbolic-substitution-and-expression-evaluation): Symbolic [substitution & evaluation](http://docs.sympy.org/dev/modules/core.html#subs)
- [Exercises](#SymPy-Exercises): Creating & manipulating constraints & [functions](http://docs.sympy.org/dev/modules/functions/index.html)

(See a list of common "gotchas" for sympy in their documentation: http://docs.sympy.org/dev/gotchas.html)

Start by reading and running the example cells, then complete the steps in the warmup cell.

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

from util import constraint
from IPython.display import display
from sympy import *
init_printing()
```

## Example 1: Symbols
**Sympy provides the `Symbol` class to create symbolic variables.  Create individual symbols by calling the constructor with a symbol name.**  (Tip: Use the `display()` function to pretty-print symbolic terms.)

```python
x = Symbol('x')
display(x)
```

**You can also create symbols from an iterable sequence using the `symbols()` function.**

```python
i, j, k = symbols(['i', 'j', 'k'])  # use implicit unpacking to associate multiple symbols with variables
display((i, j, k))
```

**`symbols()` can also create subscripted sequences of symbolic variables.**

```python
X = symbols("X:3")
display(X)
```

## Example 2: Expressions

**A symbol is the most basic expression.**  (Tip: Jupyter notebooks show information about objects
using the `?` magic function)

```python
x = Symbol('x')
x?
display(x)
```

**You can also define expressions with relations between symbols.** (However, notice that expressions have no _names_...)

```python
x, y = symbols('x y')
or_relation = x | y
or_relation?
display(or_relation)
```

**Also, not all operators can be used in expressions.  The equal sign (=) performs assignment in python, so it cannot be used to make expressions.  Using `=` assigns a new python variable to an existing reference.**

```python
x, y = symbols("x y")
y = x   # now y references the same symbolic object as x
display(y)  # display(y) == x  ??!
```

**Use `sympy.Eq` for symbolic equality expressions:** (Tip: there are lots of expressions in the [sympy docs](http://docs.sympy.org/dev/modules/core.html#sympy-core))

```python
x, z = symbols("x z")
display(Eq(z, x))
```

**Sympy overloads standard python operators so that arithmetic and logical expressions can be constructed directly between symbolic objects.**

```python
x, y, z = symbols("x y z")
display([x**2, x - y, Ne(x, y), (~x & y & z)])
```

## Example 3: Symbolic substitution and expression evaluation

**Given an original expression...**

```python
x, y, z = symbols("x y z")
relation = Eq(x, y)
display(relation)
```

**Symbolic variables can be replaced by other variables, or by concrete values.** (Tip: use positional arguments in the `subs()` method to replace one symbol)

```python
display(relation.subs(x, z))  # Use positional arguments to substitute a single symbol
```

**But keep in mind that substitution returns a _copy_ of the expression -- it doesn't operate in-place.**  (Tip: as a result, you can use substitution on one expression bound to generic variables to generate new instances bound to specific variables.)

Look at what happens when we bind new variables to our equality relation:

```python
a = symbols("a:5")
b = symbols("b:5")
display([relation.subs({x: _a, y: _b}) for _a, _b in zip(a, b)])
```

**Symbol substitution returns an expression.** (Recall that Symbols _are_ expressions).

```python
print(type(relation), type(relation.subs(x, z)))
print(type(relation) == type(relation.subs(x, z)))
```

**But substituting values for all symbols returns a value type.** (Tip: You can substitute multiple symbols in the `subs()` command by providing a mapping (dict) from current symbols to new symbols or values.)

```python
print(type(relation), type(relation.subs({x: 0, y: 1})))
print(type(relation) != type(relation.subs({x: 0, y: 1})))
```

## Example 4: Constraints

**Constraints are a construct of this lab exercise (not part of sympy) that combine symbolic Functions with Expressions for evaluation. The `constraint()` function (defined in the `util` module) takes a name and an expression and returns a "named expression" -- a constraint.**

```python
x, y = symbols(['x', 'y'])
sameAs = constraint("SameAs", Eq(x, y))
display(sameAs)
```

**Constraints are evaluated using the .subs method, just like an expression. If the resulting
expression has unbound (free) symbols, then the result is a new constraint.**

```python
display(sameAs.subs(x, 0), type(sameAs.subs(x, 0)))
```

**If the resulting expression has no free symbols, then the result is only the evaluated expression.**

```python
display(sameAs.subs({x: 0, y: 0}), type(sameAs.subs({x: 0, y: 0})))
```

## SymPy Exercises
Complete the following exercises to check your understanding of sympy symbols, expressions, and constraints:

**Question 1:** Create an array of subscripted symbols A0, A1, A2 stored in a variable named `A`

```python
A = None

# test for completion
assert(len(A) == 3)
assert(all([type(v) == Symbol for v in A]))
print("All tests passed!")
```

**Question 2:** Create an expression E with two generic symbols (e.g., "a" and "b", etc.) that represents logical XOR

```python
E = None

# test for completion
_vars = E.free_symbols
assert(len(_vars) == 2)
xor_table = {(0, 0): False, (0, 1): True, (1, 0): True, (1, 1): False}
assert(all(E.subs(zip(_vars, vals)) == truth for vals, truth in xor_table.items()))
print("All tests passed!")
```

**Question 3:** Create a constraint MaxAbsDiff with three generic arguments to test abs(a - b) < c, and create a copy of the constraint such that it tests abs(A[0] - A[1]) < A[2] from Q1

```python
maxAbsDiff = None
maxAbsDiff_copy = None

# test for completion
assert(maxAbsDiff.free_symbols != maxAbsDiff_copy.free_symbols)
assert(len(maxAbsDiff_copy.free_symbols) == len(maxAbsDiff_copy.args))
inputs = {(0, 6, 7): True, (6, 0, 7): True, (7, 6, 0): False}
assert(all(maxAbsDiff_copy.subs(zip(A[:3], vals)) == truth for vals, truth in inputs.items()))
print("All tests passed!")
```

**(Optional) Question 4:** Create a constraint AllDiff accepting the symbols in A as arguments, returning True if they are all different, and False if any pair is the same

```python
allDiff = None

inputs = (([0, 1, 2], True), ([1, 1, 1], False), ([0, 1, 1], False))
assert(all(allDiff.subs(zip(A, vals)) == truth for vals, truth in inputs))
print("All tests passed!")
```