# Transformations, Eigenvectors, and Eigenvalues

Matrices and vectors are used together to manipulate spatial dimensions. This has a lot of applications, including the mathematical generation of 3D computer graphics, geometric modeling, and the training and optimization of machine learning algorithms. We're not going to cover the subject exhaustively here; but we'll focus on a few key concepts that are useful to know when you plan to work with machine learning.

## Linear Transformations
You can manipulate a vector by multiplying it with a matrix. The matrix acts a function that operates on an input vector to produce a vector output. Specifically, matrix multiplications of vectors are *linear transformations* that transform the input vector into the output vector.

For example, consider this matrix ***A*** and vector ***v***:

$$ A = \begin{bmatrix}2 & 3\\5 & 2\end{bmatrix} \;\;\;\; \vec{v} = \begin{bmatrix}1\\2\end{bmatrix}$$

We can define a transformation ***T*** like this:

$$ T(\vec{v}) = A\vec{v} $$

To perform this transformation, we simply calculate the dot product by applying the *RC* rule; multiplying each row of the matrix by the single column of the vector:

$$\begin{bmatrix}2 & 3\\5 & 2\end{bmatrix} \cdot  \begin{bmatrix}1\\2\end{bmatrix} = \begin{bmatrix}8\\9\end{bmatrix}$$

Here's the calculation in Python:

```python
import numpy as np

v = np.array([1,2])
A = np.array([[2,3],
              [5,2]])

t = A@v
print (t)
```

    [8 9]

In this case, both the input vector and the output vector have 2 components - in other words, the transformation takes a 2-dimensional vector and produces a new 2-dimensional vector; which we can indicate like this:

$$ T: \rm I\!R^{2} \to \rm I\!R^{2} $$

Note that the output vector may have a different number of dimensions from the input vector; so the matrix function might transform the vector from one space to another - or in notation, ${\rm I\!R}$n -> ${\rm I\!R}$m.

For example, let's redefine matrix ***A***, while retaining our original definition of vector ***v***:

$$ A = \begin{bmatrix}2 & 3\\5 & 2\\1 & 1\end{bmatrix} \;\;\;\; \vec{v} = \begin{bmatrix}1\\2\end{bmatrix}$$

Now if we once again define ***T*** like this:

$$ T(\vec{v}) = A\vec{v} $$

We apply the transformation like this:

$$\begin{bmatrix}2 & 3\\5 & 2\\1 & 1\end{bmatrix} \cdot  \begin{bmatrix}1\\2\end{bmatrix} = \begin{bmatrix}8\\9\\3\end{bmatrix}$$

So now, our transformation transforms the vector from 2-dimensional space to 3-dimensional space:

$$ T: \rm I\!R^{2} \to \rm I\!R^{3} $$

Here it is in Python:

```python
import numpy as np
v = np.array([1,2])
A = np.array([[2,3],
              [5,2],
              [1,1]])

t = A@v
print (t)
```

    [8 9 3]

```python
import numpy as np
v = np.array([1,2])
A = np.array([[1,2],
              [2,1]])

t = A@v
print (t)
```

    [5 4]

## Transformations of Magnitude and Amplitude

When you multiply a vector by a matrix, you transform it in at least one of the following two ways:
* Scale the length (*magnitude*) of the matrix to make it longer or shorter
* Change the direction (*amplitude*) of the matrix

For example consider the following matrix and vector:

$$ A = \begin{bmatrix}2 & 0\\0 & 2\end{bmatrix} \;\;\;\; \vec{v} = \begin{bmatrix}1\\0\end{bmatrix}$$

As before, we transform the vector ***v*** by multiplying it with the matrix ***A***:

\begin{equation}\begin{bmatrix}2 & 0\\0 & 2\end{bmatrix} \cdot  \begin{bmatrix}1\\0\end{bmatrix} = \begin{bmatrix}2\\0\end{bmatrix}\end{equation}

In this case, the resulting vector has changed in length (*magnitude*), but has not changed its direction (*amplitude*).

Let's visualize that in Python:

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

v = np.array([1,0])
A = np.array([[2,0],
              [0,2]])

t = A@v
print (t)

# Plot v and t
vecs = np.array([t,v])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['blue', 'orange'], scale=10)
plt.show()
```

The original vector ***v*** is shown in orange, and the transformed vector ***t*** is shown in blue - note that ***t*** has the same direction (*amplitude*) as ***v*** but a greater length (*magnitude*).

Now let's use a different matrix to transform the vector ***v***:
\begin{equation}\begin{bmatrix}0 & -1\\1 & 0\end{bmatrix} \cdot  \begin{bmatrix}1\\0\end{bmatrix} = \begin{bmatrix}0\\1\end{bmatrix}\end{equation}

This time, the resulting vector has been changed to a different amplitude, but has the same magnitude.

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

v = np.array([1,0])
A = np.array([[0,-1],
              [1,0]])

t = A@v
print (t)

# Plot v and t
vecs = np.array([v,t])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['orange', 'blue'], scale=10)
plt.show()
```

Now let's see change the matrix one more time:
\begin{equation}\begin{bmatrix}2 & 1\\1 & 2\end{bmatrix} \cdot  \begin{bmatrix}1\\0\end{bmatrix} = \begin{bmatrix}2\\1\end{bmatrix}\end{equation}

Now our resulting vector has been transformed to a new amplitude *and* magnitude - the transformation has affected both direction and scale.

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

v = np.array([1,0])
A = np.array([[2,1],
              [1,2]])

t = A@v
print (t)

# Plot v and t
vecs = np.array([v,t])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['orange', 'blue'], scale=10)
plt.show()
```

### Afine Transformations
An Afine transformation multiplies a vector by a matrix and adds an offset vector, sometimes referred to as *bias*; like this:

$$T(\vec{v}) = A\vec{v} + \vec{b}$$

For example:

\begin{equation}\begin{bmatrix}5 & 2\\3 & 1\end{bmatrix} \cdot  \begin{bmatrix}1\\1\end{bmatrix} + \begin{bmatrix}-2\\-6\end{bmatrix} = \begin{bmatrix}5\\-2\end{bmatrix}\end{equation}

This kind of transformation is actually the basis of linear regression, which is a core foundation for machine learning. The matrix defines the *features*, the first vector is the *coefficients*, and the bias vector is the *intercept*.

here's an example of an Afine transformation in Python:

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

v = np.array([1,1])
A = np.array([[5,2],
              [3,1]])
b = np.array([-2,-6])

t = A@v + b
print (t)

# Plot v and t
vecs = np.array([v,t])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['orange', 'blue'], scale=15)
plt.show()
```

## Eigenvectors and Eigenvalues
So we can see that when you transform a vector using a matrix, we change its direction, length, or both. When the transformation only affects scale (in other words, the output vector has a different magnitude but the same amplitude as the input vector), the matrix multiplication for the transformation is the equivalent operation as some scalar multiplication of the vector.

For example, earlier we examined the following transformation that dot-mulitplies a vector by a matrix:

$$\begin{bmatrix}2 & 0\\0 & 2\end{bmatrix} \cdot  \begin{bmatrix}1\\0\end{bmatrix} = \begin{bmatrix}2\\0\end{bmatrix}$$

You can achieve the same result by mulitplying the vector by the scalar value ***2***:

$$2 \times \begin{bmatrix}1\\0\end{bmatrix} = \begin{bmatrix}2\\0\end{bmatrix}$$

The following python performs both of these calculation and shows the results, which are identical.

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

v = np.array([1,0])
A = np.array([[2,0],
              [0,2]])

t1 = A@v
print (t1)
t2 = 2*v
print (t2)

fig = plt.figure()
a=fig.add_subplot(1,1,1)
# Plot v and t1
vecs = np.array([t1,v])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['blue', 'orange'], scale=10)
plt.show()
a=fig.add_subplot(1,2,1)
# Plot v and t2
vecs = np.array([t2,v])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['blue', 'orange'], scale=10)
plt.show()
```

In cases like these, where a matrix transformation is the equivelent of a scalar-vector multiplication, the scalar-vector pairs that correspond to the matrix are known respectively as eigenvalues and eigenvectors. We generally indicate eigenvalues using the Greek letter lambda (λ), and the formula that defines eigenvalues and eigenvectors with respect to a transformation is:

$$ T(\vec{v}) = \lambda\vec{v}$$

Where the vector ***v*** is an eigenvector and the value ***λ*** is an eigenvalue for transformation ***T***.

When the transformation ***T*** is represented as a matrix multiplication, as in this case where the transformation is represented by matrix ***A***:

$$ T(\vec{v}) = A\vec{v} = \lambda\vec{v}$$

Then  ***v*** is an eigenvector and ***λ*** is an eigenvalue of ***A***.

A matrix can have multiple eigenvector-eigenvalue pairs, and you can calculate them manually. However, it's generally easier to use a tool or programming language. For example, in Python you can use the ***linalg.eig*** function, which returns an array of eigenvalues and a matrix of the corresponding eigenvectors for the specified matrix.

Here's an example that returns the eigenvalue and eigenvector pairs for the following matrix:

$$A=\begin{bmatrix}2 & 0\\0 & 3\end{bmatrix}$$

```python
import numpy as np
A = np.array([[2,0],
              [0,3]])
eVals, eVecs = np.linalg.eig(A)
print(eVals)
print(eVecs)
```

    [2. 3.]
    [[1. 0.]
     [0. 1.]]

So there are two eigenvalue-eigenvector pairs for this matrix, as shown here:

$$ \lambda_{1} = 2, \vec{v_{1}} = \begin{bmatrix}1 \\ 0\end{bmatrix}  \;\;\;\;\;\; \lambda_{2} = 3, \vec{v_{2}} = \begin{bmatrix}0 \\ 1\end{bmatrix} $$

Let's verify that multiplying each eigenvalue-eigenvector pair corresponds to the dot-product of the eigenvector and the matrix. Here's the first pair:

$$ 2 \times \begin{bmatrix}1 \\ 0\end{bmatrix} = \begin{bmatrix}2 \\ 0\end{bmatrix}  \;\;\;and\;\;\; \begin{bmatrix}2 & 0\\0 & 3\end{bmatrix} \cdot \begin{bmatrix}1 \\ 0\end{bmatrix} = \begin{bmatrix}2 \\ 0\end{bmatrix} $$

So far so good. Now let's check the second pair:

$$ 3 \times \begin{bmatrix}0 \\ 1\end{bmatrix} = \begin{bmatrix}0 \\ 3\end{bmatrix}  \;\;\;and\;\;\; \begin{bmatrix}2 & 0\\0 & 3\end{bmatrix} \cdot \begin{bmatrix}0 \\ 1\end{bmatrix} = \begin{bmatrix}0 \\ 3\end{bmatrix} $$

So our eigenvalue-eigenvector scalar multiplications do indeed correspond to our matrix-eigenvector dot-product transformations.

Here's the equivalent code in Python, using the ***eVals*** and ***eVecs*** variables you generated in the previous code cell:

```python
vec1 = eVecs[:,0]
lam1 = eVals[0]

print('Matrix A:')
print(A)
print('-------')

print('lam1: ' + str(lam1))
print ('v1: ' + str(vec1))
print ('Av1: ' + str(A@vec1))
print ('lam1 x v1: ' + str(lam1*vec1))

print('-------')

vec2 = eVecs[:,1]
lam2 = eVals[1]

print('lam2: ' + str(lam2))
print ('v2: ' + str(vec2))
print ('Av2: ' + str(A@vec2))
print ('lam2 x v2: ' + str(lam2*vec2))
```

    Matrix A:
    [[2 0]
     [0 3]]
    -------
    lam1: 2.0
    v1: [1. 0.]
    Av1: [2. 0.]
    lam1 x v1: [2. 0.]
    -------
    lam2: 3.0
    v2: [0. 1.]
    Av2: [0. 3.]
    lam2 x v2: [0. 3.]

You can use the following code to visualize these transformations:

```python
t1 = lam1*vec1
print (t1)
t2 = lam2*vec2
print (t2)

fig = plt.figure()
a=fig.add_subplot(1,1,1)
# Plot v and t1
vecs = np.array([t1,vec1])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['blue', 'orange'], scale=10)
plt.show()
a=fig.add_subplot(1,2,1)
# Plot v and t2
vecs = np.array([t2,vec2])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['blue', 'orange'], scale=10)
plt.show()
```

Similarly, earlier we examined the following matrix transformation:

$$\begin{bmatrix}2 & 0\\0 & 2\end{bmatrix} \cdot  \begin{bmatrix}1\\0\end{bmatrix} = \begin{bmatrix}2\\0\end{bmatrix}$$

And we saw that you can achieve the same result by mulitplying the vector by the scalar value ***2***:

$$2 \times \begin{bmatrix}1\\0\end{bmatrix} = \begin{bmatrix}2\\0\end{bmatrix}$$

This works because the scalar value 2 and the vector (1,0) are an eigenvalue-eigenvector pair for this matrix.

Let's use Python to determine the eigenvalue-eigenvector pairs for this matrix:

```python
import numpy as np
A = np.array([[2,0],
              [0,2]])
eVals, eVecs = np.linalg.eig(A)
print(eVals)
print(eVecs)
```

    [2. 2.]
    [[1. 0.]
     [0. 1.]]

So once again, there are two eigenvalue-eigenvector pairs for this matrix, as shown here:

$$ \lambda_{1} = 2, \vec{v_{1}} = \begin{bmatrix}1 \\ 0\end{bmatrix}  \;\;\;\;\;\; \lambda_{2} = 2, \vec{v_{2}} = \begin{bmatrix}0 \\ 1\end{bmatrix} $$

Let's verify that multiplying each eigenvalue-eigenvector pair corresponds to the dot-product of the eigenvector and the matrix. Here's the first pair:

$$ 2 \times \begin{bmatrix}1 \\ 0\end{bmatrix} = \begin{bmatrix}2 \\ 0\end{bmatrix}  \;\;\;and\;\;\; \begin{bmatrix}2 & 0\\0 & 2\end{bmatrix} \cdot \begin{bmatrix}1 \\ 0\end{bmatrix} = \begin{bmatrix}2 \\ 0\end{bmatrix} $$

Well, we already knew that. Now let's check the second pair:

$$ 2 \times \begin{bmatrix}0 \\ 1\end{bmatrix} = \begin{bmatrix}0 \\ 2\end{bmatrix}  \;\;\;and\;\;\; \begin{bmatrix}2 & 0\\0 & 2\end{bmatrix} \cdot \begin{bmatrix}0 \\ 1\end{bmatrix} = \begin{bmatrix}0 \\ 2\end{bmatrix} $$

Now let's use Pythonto verify and plot these transformations:

```python
vec1 = eVecs[:,0]
lam1 = eVals[0]

print('Matrix A:')
print(A)
print('-------')

print('lam1: ' + str(lam1))
print ('v1: ' + str(vec1))
print ('Av1: ' + str(A@vec1))
print ('lam1 x v1: ' + str(lam1*vec1))

print('-------')

vec2 = eVecs[:,1]
lam2 = eVals[1]

print('lam2: ' + str(lam2))
print ('v2: ' + str(vec2))
print ('Av2: ' + str(A@vec2))
print ('lam2 x v2: ' + str(lam2*vec2))

# Plot the resulting vectors
t1 = lam1*vec1
t2 = lam2*vec2

fig = plt.figure()
a=fig.add_subplot(1,1,1)
# Plot v and t1
vecs = np.array([t1,vec1])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['blue', 'orange'], scale=10)
plt.show()
a=fig.add_subplot(1,2,1)
# Plot v and t2
vecs = np.array([t2,vec2])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['blue', 'orange'], scale=10)
plt.show()
```

Let's take a look at one more, slightly more complex example. Here's our matrix:

$$\begin{bmatrix}2 & 1\\1 & 2\end{bmatrix}$$

Let's get the eigenvalue and eigenvector pairs:

```python
import numpy as np

A = np.array([[2,1],
              [1,2]])

eVals, eVecs = np.linalg.eig(A)
print(eVals)
print(eVecs)
```

    [3. 1.]
    [[ 0.70710678 -0.70710678]
     [ 0.70710678  0.70710678]]

This time the eigenvalue-eigenvector pairs are:

$$ \lambda_{1} = 3, \vec{v_{1}} = \begin{bmatrix}0.70710678 \\ 0.70710678\end{bmatrix}  \;\;\;\;\;\; \lambda_{2} = 1, \vec{v_{2}} = \begin{bmatrix}-0.70710678 \\ 0.70710678\end{bmatrix} $$

So let's check the first pair:

$$ 3 \times \begin{bmatrix}0.70710678 \\ 0.70710678\end{bmatrix} = \begin{bmatrix}2.12132034 \\ 2.12132034\end{bmatrix}  \;\;\;and\;\;\; \begin{bmatrix}2 & 1\\0 & 2\end{bmatrix} \cdot \begin{bmatrix}0.70710678 \\ 0.70710678\end{bmatrix} = \begin{bmatrix}2.12132034 \\ 2.12132034\end{bmatrix} $$

Now let's check the second pair:

$$ 1 \times \begin{bmatrix}-0.70710678 \\ 0.70710678\end{bmatrix} = \begin{bmatrix}-0.70710678\\0.70710678\end{bmatrix}  \;\;\;and\;\;\; \begin{bmatrix}2 & 1\\1 & 2\end{bmatrix} \cdot \begin{bmatrix}-0.70710678 \\ 0.70710678\end{bmatrix} = \begin{bmatrix}-0.70710678\\0.70710678\end{bmatrix} $$

With more complex examples like this, it's generally easier to do it with Python:

```python
vec1 = eVecs[:,0]
lam1 = eVals[0]

print('Matrix A:')
print(A)
print('-------')

print('lam1: ' + str(lam1))
print ('v1: ' + str(vec1))
print ('Av1: ' + str(A@vec1))
print ('lam1 x v1: ' + str(lam1*vec1))

print('-------')

vec2 = eVecs[:,1]
lam2 = eVals[1]

print('lam2: ' + str(lam2))
print ('v2: ' + str(vec2))
print ('Av2: ' + str(A@vec2))
print ('lam2 x v2: ' + str(lam2*vec2))

# Plot the results
t1 = lam1*vec1
t2 = lam2*vec2

fig = plt.figure()
a=fig.add_subplot(1,1,1)
# Plot v and t1
vecs = np.array([t1,vec1])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['blue', 'orange'], scale=10)
plt.show()
a=fig.add_subplot(1,2,1)
# Plot v and t2
vecs = np.array([t2,vec2])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['blue', 'orange'], scale=10)
plt.show()
```

## Eigendecomposition
So we've learned a little about eigenvalues and eigenvectors; but you may be wondering what use they are. Well, one use for them is to help decompose transformation matrices.

Recall that previously we found that a matrix transformation of a vector changes its magnitude, amplitude, or both. Without getting too technical about it, we need to remember that vectors can exist in any spatial orientation, or *basis*; and the same transformation can be applied in different *bases*.

We can decompose a matrix using the following formula:

$$A = Q \Lambda Q^{-1}$$

Where ***A*** is a trasformation that can be applied to a vector in its current base, ***Q*** is a matrix of eigenvectors that defines a change of basis, and ***Λ*** is a matrix with eigenvalues on the diagonal that defines the same linear transformation as ***A*** in the base defined by ***Q***.

Let's look at these in some more detail. Consider this matrix:

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

***Q*** is a matrix in which each column is an eigenvector of ***A***; which as we've seen previously, we can calculate using Python:

```python
import numpy as np

A = np.array([[3,2],
              [1,0]])

l, Q = np.linalg.eig(A)
print(Q)
```

    [[ 0.96276969 -0.48963374]
     [ 0.27032301  0.87192821]]

So for matrix ***A***, ***Q*** is the following matrix:

$$Q=\begin{bmatrix}0.96276969 & -0.48963374\\0.27032301 & 0.87192821\end{bmatrix}$$

***Λ*** is a matrix that contains the eigenvalues for ***A*** on the diagonal, with zeros in all other elements; so for a 2x2 matrix, Λ will look like this:

$$\Lambda=\begin{bmatrix}\lambda_{1} & 0\\0 & \lambda_{2}\end{bmatrix}$$

In our Python code, we've already used the ***linalg.eig*** function to return the array of eigenvalues for ***A*** into the variable ***l***, so now we just need to format that as a matrix:

```python
L = np.diag(l)
print (L)
```

    [[ 3.56155281  0.        ]
     [ 0.         -0.56155281]]

So ***Λ*** is the following matrix:

$$\Lambda=\begin{bmatrix}3.56155281 & 0\\0 & -0.56155281\end{bmatrix}$$

Now we just need to find ***Q-1***, which is the inverse of ***Q***:

```python
Qinv = np.linalg.inv(Q)
print(Qinv)
```

    [[ 0.89720673  0.50382896]
     [-0.27816009  0.99068183]]

The inverse of ***Q*** then, is:

$$Q^{-1}=\begin{bmatrix}0.89720673 & 0.50382896\\-0.27816009 & 0.99068183\end{bmatrix}$$

So what does that mean? Well, it means that we can decompose the transformation of *any* vector multiplied by matrix ***A*** into the separate operations ***QΛQ-1***:

$$A\vec{v} = Q \Lambda Q^{-1}\vec{v}$$

To prove this, let's take vector ***v***:

$$\vec{v} = \begin{bmatrix}1\\3\end{bmatrix} $$

Our matrix transformation using ***A*** is:

$$\begin{bmatrix}3 & 2\\1 & 0\end{bmatrix} \cdot \begin{bmatrix}1\\3\end{bmatrix} $$

So let's show the results of that using Python:

```python
v = np.array([1,3])
t = A@v

print(t)

# Plot v and t
vecs = np.array([v,t])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['orange', 'b'], scale=20)
plt.show()
```

And now, let's do the same thing using the ***QΛQ-1*** sequence of operations:

```python
import math
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

t = (Q@(L@(Qinv)))@v

# Plot v and t
vecs = np.array([v,t])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['orange', 'b'], scale=20)
plt.show()
```

So ***A*** and ***QΛQ-1*** are equivalent.

If we view the intermediary stages of the decomposed transformation, you can see the transformation using ***A*** in the original base for ***v*** (orange to blue) and the transformation using ***Λ*** in the change of basis decribed by ***Q*** (red to magenta):

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

t1 = Qinv@v
t2 = L@t1
t3 = Q@t2

# Plot the transformations
vecs = np.array([v,t1, t2, t3])
origin = [0], [0]
plt.axis('equal')
plt.grid()
plt.ticklabel_format(style='sci', axis='both', scilimits=(0,0))
plt.quiver(*origin, vecs[:,0], vecs[:,1], color=['orange', 'red', 'magenta', 'blue'], scale=20)
plt.show()
```

So from this visualization, it should be apparent that the transformation ***Av*** can be performed by changing the basis for ***v*** using ***Q*** (from orange to red in the above plot) applying the equivalent linear transformation in that base using ***Λ*** (red to magenta), and switching back to the original base using ***Q-1*** (magenta to blue).

## Rank of a Matrix

The **rank** of a square matrix is the number of non-zero eigenvalues of the matrix. A **full rank** matrix has the same number of non-zero eigenvalues as the dimension of the matrix. A **rank-deficient** matrix has fewer non-zero eigenvalues as dimensions. The inverse of a rank deficient matrix is singular and so does not exist (this is why in a previous notebook we noted that some matrices have no inverse).

Consider the following matrix ***A***:

$$A=\begin{bmatrix}1 & 2\\4 & 3\end{bmatrix}$$

Let's find its eigenvalues (***Λ***):

```python
import numpy as np
A = np.array([[1,2],
              [4,3]])
l, Q = np.linalg.eig(A)
L = np.diag(l)
print(L)
```

    [[-1.  0.]
     [ 0.  5.]]

$$\Lambda=\begin{bmatrix}-1 & 0\\0 & 5\end{bmatrix}$$

This matrix has full rank. The dimensions of the matrix is 2. There are two non-zero eigenvalues.

Now consider this matrix:

$$B=\begin{bmatrix}3 & -3 & 6\\2 & -2 & 4\\1 & -1 & 2\end{bmatrix}$$

Note that the second and third columns are just scalar multiples of the first column.

Let's examine it's eigenvalues:

```python
B = np.array([[3,-3,6],
              [2,-2,4],
              [1,-1,2]])
lb, Qb = np.linalg.eig(B)
Lb = np.diag(lb)
print(Lb)
```

    [[3.00000000e+00 0.00000000e+00 0.00000000e+00]
     [0.00000000e+00 5.23364153e-16 0.00000000e+00]
     [0.00000000e+00 0.00000000e+00 0.00000000e+00]]

$$\Lambda=\begin{bmatrix}3 & 0& 0\\0 & -6\times10^{-17} & 0\\0 & 0 & 3.6\times10^{-16}\end{bmatrix}$$

Note that matrix has only 1 non-zero eigenvalue. The other two eigenvalues are so extremely small as to be effectively zero. This is an example of a rank-deficient matrix; and as such, it has no inverse.

## Inverse of a Square Full Rank Matrix
You can calculate the inverse of a square full rank matrix by using the following formula:

$$A^{-1} = Q \Lambda^{-1} Q^{-1}$$

Let's apply this to matrix ***A***:

$$A=\begin{bmatrix}1 & 2\\4 & 3\end{bmatrix}$$

Let's find the matrices for ***Q***, ***Λ-1***, and ***Q-1***:

```python
import numpy as np
A = np.array([[1,2],
              [4,3]])

l, Q = np.linalg.eig(A)
L = np.diag(l)
print(Q)
Linv = np.linalg.inv(L)
Qinv = np.linalg.inv(Q)
print(Linv)
print(Qinv)
```

    [[-0.70710678 -0.4472136 ]
     [ 0.70710678 -0.89442719]]
    [[-1.  -0. ]
     [ 0.   0.2]]
    [[-0.94280904  0.47140452]
     [-0.74535599 -0.74535599]]

So:

$$A^{-1}=\begin{bmatrix}-0.70710678 & -0.4472136\\0.70710678 & -0.89442719\end{bmatrix}\cdot\begin{bmatrix}-1 & -0\\0 & 0.2\end{bmatrix}\cdot\begin{bmatrix}-0.94280904 & 0.47140452\\-0.74535599 & -0.74535599\end{bmatrix}$$

Let's calculate that in Python:

```python
Ainv = (Q@(Linv@(Qinv)))
print(Ainv)
```

    [[-0.6  0.4]
     [ 0.8 -0.2]]

That gives us the result:

$$A^{-1}=\begin{bmatrix}-0.6 & 0.4\\0.8 & -0.2\end{bmatrix}$$

We can apply the ***np.linalg.inv*** function directly to ***A*** to verify this:

```python
print(np.linalg.inv(A))
```

    [[-0.6  0.4]
     [ 0.8 -0.2]]

Essential_Math_for_Machine_Learning_Python_Edition/Module03/03-05-Transformations Eigenvectors and Eigenvalues.ipynb


Yule Process:

It's a pure-birth process which is a Poisson process for simulation of the population at time $t$, $X(t)$.

It's a continuous-time Markov chain with transition probabilities:

\begin{equation}
\begin{split}
&P(X(t+\Delta t)=n+1  | X(t)=n)= \nu_n\,\Delta t\\
&P(X(t+\Delta t)=n  | X(t)=n)= 1-\nu_n\,\Delta t
\end{split}
\end{equation}

We call $\nu_n$ as the rate of the change at state $n$. For a special case we have $\nu_n= b\,n^{d}$ for some parameter $b$. If $d=1$,
then we have the linear growth. It can be proved that if $\Sigma\, 1/\nu_n < \infty$, then the population size explodes in a finte time; in other words, we will have infintely many jumps in a finte time. Therefore for the special case where, $\nu_n= b\,n^{d}$, we have an explosive Markov chain if $d>1$.

Simulation of Yule process can be done using two different techniques: Sampling of time increments and tau-leaping method. In the the first method, we use the fact that waiting time (sojurn time) until the next jump in the population, when the current population is $n$, is exponentially distributed with mean $1/\nu_{n}$ and hence we can sample the time incerements from these exponential distributions.

In the following cells, I have simulated one special case of Yule process, where $\nu_n= b\,n^{d}$ with inital population $X_{0}=1$. I have simulated until a max population $N=2000$ and with 3 different degrees, $d$, for the rate of the change to show the explosive Markov chains for $d>1$.

```python
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.offsetbox import AnchoredText
import scipy.misc
import scipy.special
from matplotlib.lines import Line2D
import seaborn as sns
from cycler import cycler

plt.style.use('ggplot')

def incremental_sampling(b, N, P0, d, num_sim):

    """
    b -- birth_rate
    N -- max population
    P0 -- initial population
    num_sim -- number of simulations
    d -- degree of population growth.
    """
    s = np.zeros((num_sim, N))  # Sojurn times
    X1 = np.zeros((num_sim, N))  # Population matrix
    inc = np.zeros((num_sim, N))  # Time increments
    X1[:,0] = P0

    for j in range(num_sim):
        for i in range(N-1):
            U = np.random.uniform(0,1)
            h = - np.log(U)/(b*X1[j,i]**d)  # Time incerements
            inc[j,i] = h
            s[j,i+1] = s[j,i] + h
            X1[j,i+1] = X1[j,i] + 1

    return [s, X1, inc]

def plots(b, N, P0, deg, num_sim):

    fig, ax = plt.subplots(2,3,figsize=(12,8))
    marker_style = dict(linestyle=':',marker='o', markersize=4)
    col = ['cornflowerblue', 'orchid', 'orange']

    for i, d in enumerate(deg):

        [s, X1, inc] = incremental_sampling(b, N, P0, d, num_sim)

        ax[0,i].plot(s[0,:], X1[0,:], color=col[i], **marker_style)
        sns.distplot(inc, ax=ax[1,i], bins=np.linspace(0,0.1,100), kde=False)
        ax[0,i].set_title(r'$\nu_n \propto n^{%1.1f}$'%d)
        ax[1,i].set_title(r'distribution of increments: $\nu_n\propto n^{%1.1f}$'%d, fontsize=10)
        ax[0,0].set_ylabel('population size', fontsize=12)
        ax[0,1].set_xlabel('time', fontsize=12)
        ax[1,i].set_xlim([0,0.03])
        plt.suptitle('One realization by incremental sampling with different rates:\n'\
        ' explosion occurs in a finite time when the power of n exceeds 1', y=1.05)
        plt.tight_layout(h_pad=8,w_pad=2)

b = 0.5
N = 2000
P0 = 1
deg = [1, 1.5, 2]
num_sim = 1

plots(b, N, P0, deg, num_sim)
```

In the second method, tau-leaping method, we update the population size using
$$ X(t+\tau)-X(t)=Poisson(b\,\tau).$$
Obviousely in this case we have jumps and the plot $X(t)$ vs $t$ becomes piecewise linear. To make the plot smoother, we can sample on a large number and then take the average. In the next cell, I have simulated the process by using this method and have compared the result with the exat mean or deterministic method, which is the soluation of

$$ \frac{d}{dt}E[X(t)] = b\,E[X(t)^{d}].$$

for the next simulation, I have used the linear growth, $d=1$ and hence the excat mean is

$$ E[X(t)] = X(0)\,e^{b\,t}.$$

I have chosen $\tau = 0.01$ and have tried $1000$ steps, therefore the max-time is $10$. Moreove similar to the pervious cell, I have used $b=0.5$.

```python
def tau_leaping(b, tau, P0, num_steps, num_sim):

    """
    b -- birth_rate
    num_steps -- number of steps
    P0 -- initial population
    num_sim -- number of simulations
    d -- degree of population growth.
    """
    X2 = np.zeros((num_sim, num_steps))  # Population matrix
    X2[:,0] = P0

    for j in range(num_sim):
        for i in range(num_steps-1):
            r = np.random.poisson(lam = b*X2[j,i]*tau)
            X2[j,i+1] = X2[j,i] + r

    X2_aver = np.mean(X2, axis=0)

    return [X2, X2_aver]

def exact_mean(b, P0, t):

    y =  P0*np.exp(b*t)
    return y

def tau_leaping_plots(b, tau, P0, num_steps, num_sim):

    fig, ax = plt.subplots(1,figsize=(10,4))

    col = ['cornflowerblue','orchid']

    [X2, X2_aver] = tau_leaping(b, tau, P0, num_steps, num_sim)
    t = np.linspace(0, tau*num_steps, num_steps)
    y = exact_mean(b, P0, t)
    ax.plot(t, X2_aver, color=col[0], label='tau_leaping')
    ax.plot(t, y, color=col[1], label='exact mean')
    ax.set_title('Comparison between tau-leaping simulations'\
                 ' and deterministic method for the linear growth', y=1.03)
    ax.text(2,100, r"$\nu_n = 0.5\,n$", style = 'italic' , fontsize=12)
    ax.legend(['mean from tau-leaping',' mean from deterministic method'])
    ax.set_xlabel('time',fontsize=12)
    ax.set_ylabel('population mean', fontsize=12)
    plt.tight_layout()

b = 0.5
tau = 0.01
P0 = 1
num_steps = 1000
num_sim = 1000

tau_leaping_plots(b, tau, P0, num_steps, num_sim)
```

 In the next cell, I have plotted kernel density estimation for $1000$ simulations of population size at time $10$.

```python
def tau_leaping_hist(b, tau, P0, num_steps, num_sim):

    fig, ax = plt.subplots()

    [X2, X2_aver] = tau_leaping(b, tau, P0, num_steps, num_sim)
    sns.distplot(X2[:,num_steps-1], label='tau_leaping', color='orchid')
    ax.legend(['population size: one realization'])
    ax.text(600,0.004, r"$\nu_n=0.5\,n$", style = 'italic' , size=12)
    ax.text(600,0.0035, r"$\tau=0.01,\,t =10$", style = 'italic' , size=12)
    ax.set_xlabel('population size', fontsize=12)
    ax.set_ylabel('KDE', fontsize=12)
    plt.title(r'Kernel density estimation for population size at time'\
                 r' $t=10$', va='center', ha='center', y=1.03)

b = 0.5
tau = 0.01
P0 = 1
num_steps = 1000
num_sim = 1000

tau_leaping_hist(b, tau, P0, num_steps, num_sim)
```

And different trajectories ( realizations ) for population size where $\nu_{n}=0.5\,n$.

```python

def trajectories(b, tau, P0, num_steps, num_sim):

    fig, ax = plt.subplots(figsize=(6,5))
    col = ['cornflowerblue','orchid','orange','green']
    [X2, X2_aver] = tau_leaping(b, tau, P0, num_steps, num_sim)
    t = np.linspace(0, tau*num_steps, num_steps)

    for i in range(num_sim):

        ax.plot(t,X2[i,:], color=col[i])
        at = AnchoredText(r'$\nu_n=0.5\,n,\,\tau=0.01$', loc='upper left', frameon=True)
        ax.add_artist(at)
        ax.set_xlabel('time', fontsize=12)
        ax.set_ylabel('population size', fontsize=12)
        plt.title(r'Different trajectories for population size', va='center', ha='center', y=1.03)

b = 0.5
tau = 0.01
P0 = 1
num_steps = 1000
num_sim = 4

trajectories(b, tau, P0, num_steps, num_sim)
```


# Introdução à Computação Simbólica com _sympy_

## Motivação

Neste ponto do curso, você já aprendeu a realizar operações matemáticas elementares com Python. Por exemplo, se lhe for dado o valor do raio $R$, você consegue facilmente computar a área $\pi R^2$ de um círculo. Todavia, o valor de $\pi$ que você obtém é finito. As instruções abaixo verificam isto.

```python
from math import pi
print(pi)
3.141592653589793
```

Pense, no entanto, se você pudesse realizar o cálculo desta área de maneira "exata". Matematicamente falando, é impossível fazer isto pois $\pi$ é um número irracional – o imbróglio desta constante é longo na história da Matemática. Porém, a computação simbólica permite que operemos com $\pi$ como se fosse simplesmente um símbolo com precisão infinita. Embora 15 casas decimais, como o valor exemplificado acima, sejam suficientes para a maioria dos cálculos do mundo real, a computação simbólica permite que trabalhemos com modelos abstratos que servem a uma diversidade de propósitos.

Aliás, quando se diz que 3.141592653589793 é um valor razoavelmente aceitável, isto é verdade até mesmo para cálculos em escala astronômica. A equipe de engenharia da NASA explica que, usando este valor para calcular o perímetro de uma circunferência com diâmetro igual a 25 bilhões de milhas, o erro de cálculo é próximo de 1,5 polegada [[NASA]](https://www.jpl.nasa.gov/edu/news/2016/3/16/how-many-decimals-of-pi-do-we-really-need/). Até aí, nada mal para uma aproximação!

## O que é Computação Simbólica e para que serve?

*Computação Simbólica* (CS) é uma subárea de estudo da matemática e da ciência da computação que se preocupa em resolver problemas usando objetos simbólicos representáveis em um computador. Esses problemas surgem em muitas aplicações em ciências naturais, pesquisa básica, na indústria e principalmente no desenvolvimento de softwares para computação avançada denominados _sistemas de computação algébrica_ (SCAs).

A CS existente em um SCA é aplicada em álgebra computacional, projetos assistidos por computação (CAD), raciocínio automatizado, gestão do conhecimento, lógica computacional e sistemas formais de verificação. O desenvolvimento da CS depende da integração de basicamente três campos: *softwares matemáticos*, *álgebra computacional* e *lógica computacional* [[RISC/JKU]](https://risc.jku.at/studying-symbolic-computation/). Em casos mais avançados, a CS é útil para solucionar equações da macroeconomia, manipular números para a finalidade de criptografia e criar modelos probabilísticos [[Kotzé]](https://kevinkotze.github.io/mm-tut1-symbolic/), [[Cohen]](https://www.ukma.edu.ua/~yubod/teach/compalgebra/%5BJoel_S._Cohen%5D_Computer_algebra_and_symbolic_comp(BookFi.org).pdf).

## Principais SCAs

Alguns SCAs são populares de longa data, tais como Maple, Mathematica e MuPad. Entretanto, são comerciais e costumam ter licenças custosas, embora ofereçam versões com desconto para estudantes. Algumas alternativas robustas de uso livre são Scilab, Sagemath, Octave e o próprio módulo *sympy*. Uma lista completa de SCAs está disponível na [[Wikipedia]](https://en.wikipedia.org/wiki/Computer_algebra_system).

## Por que *sympy*?

O objetivo principal do *sympy* é ser uma biblioteca de manipulação simbólica para Python. Ele começou a ser desenvolvido em 2006 e atualmente está na versão 1.5.1, lançada em dezembro de 2019 na página oficial [[sympy.org]](https://www.sympy.org/pt/index.html). As principais características do módulo são as seguintes:

- é gratuito;

- é baseado inteiramente em Python;

- é leve e independente.

## Objetos numéricos x objetos simbólicos

Importaremos os módulos `math` e `sympy` para ver algumas  diferenças entre objetos numéricos e simbólicos.

```python
import math as mt
import sympy as sy
sy.init_printing(pretty_print=True) # melhor impressão de símbolos
```

```python
mt.pi # numérico
```

```python
sy.pi # simbólico
```

Verifiquemos com `type`.

```python
type(mt.pi)
```

    float

```python
type(sy.pi) # é um objeto simbólico
```

    sympy.core.numbers.Pi

Vejamos mais um exemplo:

```python
mt.sqrt(2)
```

```python
sy.sqrt(2)
```

```python
type(mt.sqrt(2))
```

    float

```python
type(sy.sqrt(2)) # é um objeto simbólico
```

    sympy.core.power.Pow

### Função x método

Na aula anterior destacamos que `print` e `type` são "funções" similares àquelas do tipo $y = f(x)$ em Matemática. Em Python, essas "funções" recebem o nome de *função* mesmo.

Porém, há módulos que possuem *métodos*, que para seu entendimento, podem ser vistos como "funções" também. Porém, *função* e *método* são conceitos levemenete distintos.

Para aplicarmos funções usamos parênteses envolvendo um ou mais *parâmetros*.

No caso acima, `mt.sqrt(2)` mostra que `sqrt` age como uma função e o número 2 é seu único parâmetro. Note, além disso, que o sympy também possui a sua própria função `sqrt`, que é de uma natureza distinta. Ela é um objeto `sympy.core.power.Pow`. Não precisamos entender isso agora, mas basta saber que ela pertence a um submódulo do *sympy*.

Por outro lado, também aprendemos que o conjugado de um número complexo `z` (tipo `complex`) pode ser obtido como `z.conjugate()`. Esta forma "sem parâmetros" indica que `conjugate` é um método do objeto `z`.

A partir deste ponto, poderemos ver situações como as seguintes:

- `f(x)`: a função `f` é aplicada ao parâmetro `x`

- `a.f()`: `f` é um método sem parâmetro do objeto `a`

- `a.f(x)`: `f` é um método com parâmetro `x` do objeto `a`

A partir do último exemplo, podemos dizer que um método é, na verdade, uma função que pertence a um objeto.

### Atribuições com símbolos

Podemos atribuir símbolos a variáveis usando a função `symbols`.

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

`x` e `y` são símbolos sem valor definido.

```python
x
```

```python
y
```

Podemos operar aritmeticamente com símbolos e obter uma expressão simbólica como resultado.

```python
z = sy.symbols('z')
x*y + z**2/3 + sy.sqrt(x*y - z)
```

**Exemplo**: escreva o produto notável $(x - y)^2$ como uma expressão simbólica.

```python
x**2 - 2*x*y + y**2
```

Note que o nome da variável não tem a ver com o nome do símbolo. Poderíamos fazer o seguinte:

```python
y = sy.symbols('x') # y é variável; x é símbolo
y
```

### Atribuição por desempacotamento

Também poderíamos realizar as atribuições anteriores da seguinte forma:

```python
x, y, z = sy.symbols('x y z')
```

### Alfabeto de símbolos

O *sympy* dispõe de um submódulo chamado `abc` do qual podemos importar símbolos para letras latinas (maiúsculas e minúsculas) e gregas (minúsculas).

```python
from sympy.abc import a,b,c,alpha,beta,gamma
(a + 2*b - 3*c)*(alpha/3 + beta/2 - gamma) # símbolico
```

```python
from sympy.abc import D,G,psi,theta
D**a * G**b * psi**c * theta**2 # símbolico
```

**Nota**: algumas letras já são usadas como símbolos especiais, tais como `O`, que indica "ordem" e `I`, que é o complexo $i$. Neste caso, cuidado deve ser tomado com nomes de variáveis

```python
sy.I # imaginário simbólico
```

```python
type(sy.I)
```

    sympy.core.numbers.ImaginaryUnit

### Símbolos com nomes genéricos

Para criar símbolos genéricos, temos de usar `symbols` ou `Symbol`.

```python
sem_nocao = sy.symbols('nada')
sem_nocao
```

```python
muito_louco = sy.Symbol('massa')
muito_louco
```

### Variáveis e símbolos

```python
sem_medo = sem_nocao + 2
sem_medo
```

```python
soma = muito_louco + 2
muito_louco = 3 # 'muito_louco' aqui não é o simbólico
soma
```

## Substituição

A operação de *substituição* permite que:

1. substituamos variáveis por valores numéricos para avaliar uma expressão ou calcular valores de uma função em um dado ponto.
2. substituamos uma subexpressão por outra.

Para tanto, procedemos da seguinte forma:

```python
expressao.subs(variavel,valor)
```

**Exemplo**: considere o polinômio $P(x) = 2x^3 - 4x -6$. Calcule o valor de $P(-1)$, $P(e/3)$, $P(\sqrt{3.2})$.

```python
from sympy.abc import x
P = 2*x**3 - 4*x - 6
P1 = P.subs(x,-1)
Pe3 = P.subs(x,mt.e/3)
P32 = P.subs(x,mt.sqrt(3.2))
print(P1, Pe3, P32)
```

    -4 -8.13655822141297 -1.70674948320040

**Exemplo:** sejam $f(x) = 4^x$ e $g(x) = 2x - 1$. Compute o valor da função composta $f(g(x))$ em $x = 3$.

```python
f = 4**x
fg = f.subs(x,2*x - 1)
```

```python
fg.subs(x,3)
```

Poderíamos também fazer isso com um estilo "Pythônico":

```python
fg = 4**x.subs(x,2*x - 1).subs(x,3)
fg
```

**Exemplo:** se $a(x) = 2^x$, $b(x) = 6^x$ e $c(x) = \cos(x)$, compute o valor de $a(x)b(c(x))$ em $x = 4$

```python
a = 2**x
b = 6**x
c = sy.cos(x)
(a * b.subs(x,c)).subs(x,4)
```

Ou, de modo direto:

```python
valor = ( 2**x * ( 6**x.subs(x,sy.cos(x))) ).subs(x,4)
valor
```

### Avaliação de expressão em ponto flutuante

Note que a expressão anterior não foi computada em valor numérico. Para obter seu valor numérico, podemos usar o método `evalf`.

```python
valor.evalf()
```

#### Precisão arbitrária

`evalf` permite que escolhamos a precisão do cálculo impondo o número de dígitos de precisão. Por exemplo, a última expressão com 20 dígitos de precisão seria:

```python
valor.evalf(20)
```

Com 55, seria:

```python
valor.evalf(55)
```

E com 90 seria:

```python
valor.evalf(90)
```

**Exemplo**: calcule o valor de $e$ com 200 dígitos de precisão.

```python
sy.exp(1).evalf(200)
```

## Funções predefinidas x funções regulares

Vamos apresentar aqui três grupos de funções que podem ser criadas em Python para nos auxiliar ao longo do curso sem, no entanto, nos aprofundaremos nos detalhes de cada um.

Como dissemos em um momento anterior, a linguagem Python possui um *core* que contém um conjunto de funções já prontas que podemos usar, como é o caso de `print()`, `type()` e até mesmo `int()` e `float()` para operações de *casting*. Essas funções podem ser chamadas de **predefinidas** (*built-in functions*). Ou seja, são aquelas funções "já existentes". Exemplos adicionais seriam as funções do módulo `math`.

Suponhamos, porém, que você vasculhe módulos e mais módulos atrás de uma função que faça exatamente o que você quer, mas não a encontra. O que você faz? Você a cria! Podemos fazer isto de uma maneira usando uma "palavra-chave" (*keyword*) chamada `def` da seguinte forma:

```python
def f(x):
    (...)
    return y
```
A instrução acima permite que você crie uma *função* chamada `f` da qual `x` é um *argumento* e `y` é um *valor de retorno*, indicado por uma segunda "palavra-chave", *return*. Funções definidas por você dessa maneira são chamadas de **regulares**, *normais* – pelo fato de serem programadas de um modo regular, seguindo a "normalidade" da linguagem –, ou ainda *definidas pelo usuário* (do inglês *user-defined functions*, ou simplesmente *UDF*). Por conveniência, vamos nos referir a elas por este acrônimo elegante: UDF.

Uma UDF permite que você abstraia seu pensamento para criar basicamente o que quiser dentro dos limites da linguagem Python. Cabe, apesar disso, fazermos as seguintes ressalvas:

- uma UDF **pode ter zero ou mais argumentos**, tantos quantos se queira;
- uma UDF **pode ou não ter valor de retorno**;

Vamos entender as UDFs com exemplos.

**Exemplo:** Suponha que você é um(a) analista de dados do mercado imobiliário e está estudando o impacto do repasse de comissões pagas a corretores mediante vendas de imóveis. Você, então, começa a raciocinar e cria um modelo matemático bastante simples que, antes de tudo, precisa calcular o valor do repasse a partir do preço de venda.

Se $c$ for o percentual de comissão, $V$ o valor da venda do imóvel e $r$ o valor a ser repassado para o corretor, então, a função a ser definida é

$$r(V) = c\, V,$$

assumindo que $c$ seja um valor fixo.

Digamos que $c$ corresponda a 1.03% do valor da venda do imóvel. Neste caso podemos criar uma UDF para calcular $r$ para nós da seguinte forma:

```python
def repasse(V):
    r = 0.0103*V
    return r
```

Para $V = \, R\$ \, 332.130,00$:

```python
repasse(332130)
```

O que é necessário observar:

- `def` vem seguido pelo *nome* da função (`repasse`) após um espaço;
- o nome precede os argumentos, enclausurados por parênteses `(V)`. Neste caso, temos apenas um *argumento*, que é `V`;
- após o nome, os dois-pontos (`:`) são obrigatórios e significam mais ou menos "o que esta função faz será definido da seguinte maneira"
- a instrução `r = 0.0103*V` é o *escopo* da função, que deve ser escrito em uma ou mais linhas indentadas (pressione `TAB` para isso, ou use 4 espaços)
- o valor de retorno, se houver, é posto na última linha do escopo.

Podemos atribuir os valores do argumento e resultado a variáveis:

```python
V = 332130
rep = repasse(V)
rep
```

Nomes iguais de variável e função são permissíveis.

```python
repasse = repasse(V) # 'repasse' à esquerda é uma variável; à direita, função
print(repasse)
```

    3420.939

Todavia, isto pode ser confuso e é bom evitar.

O estilo "Pythônico" de escrever permite que o valor de retorno não seja explicitamente declarado. No escopo

```python
...
    r = 0.0103*V
    return r
```
 a variável `r` não é necessária.

Python é inteligente para permitir o seguinte:

```python
def repasse(V):
    return 0.0103*V

# note que aqui não indentamos a linha.
# Logo esta instrução NÃO pertence ao escopo da função.
repasse(V)
```

Podemos criar uma função para diferentes valores de `c` e `V` usando *dois* argumentos:

```python
def repasse_c(c,V): # esta função tem outro nome
    return c*V
```

```python
c = 0.0234 # equivaleria a uma taxa de repasse de 2.34%
V = 197432 # o valor do imóvel agora é R$ 197.432,00
repasse_c(c,V)
```

A ordem dos argumentos importa:

```python
V = 0.0234 # este deveria ser o valor de c
c = 197432 # este deveria ser o valor de V
repasse_c(c,V)
```

Por que o valor resultante é o mesmo? Porque a operação no escopo da função é uma multiplicação, `c*V`, que é comutativa independentemente do valor das variáveis. Porém, digamos que um segundo modelo tenha uma forma de cálculo distinta para a comissão dada por

$$r_2(V) = c^{3/5} \, V$$

Neste caso:

```python
def repasse_2(c,V):
    return c**(3/5)*V

V = 197432
c = 0.0234

repasse_2(c,V)
```

Porém, se trocarmos o valor das variáveis, a função `repasse_2` calculará um valor distinto. Embora exista um produto também comutativo, o expoente `3/4` modifica apenas o valor de `c`.

```python
# variáveis com valores trocados
c = 197432
V = 0.0234

repasse_2(c,V)
```

A ordem com que escrevemos os argumentos tem importância relativa aos valores que passamos e ao que definimos:

```python
# variáveis com valores corretos
V = 197432
c = 0.0234

def repasse_2_trocada(V,c): # V vem antes de c
    return c**(3/5)*V

repasse_2_trocada(V,c)
```

Mas,

```python
# os valores das variáveis estão corretos,
# mas foram passados para a função na ordem errada
repasse_2_trocada(c,V)
```

e

```python
# a ordem dos argumentos está de acordo com a que foi definida
# mas os valores das variáveis foram trocados
V = 197432
c = 0.0234
repasse_2_trocada(c,V)
```

## Modelos matemáticos simbólicos

A partir do que aprendemos, podemos definir modelos matemáticos completamente simbólicos.

```python
from sympy.abc import c,V

def repasse_2_simbolica(c,V):
    return c**(3/5)*V
```

Se chamarmos esta função, ela será um objeto simbólico.

```python
repasse_2_simbolica(c,V)
```

Atribuindo em variável:

```python
rep_simb = repasse_2_simbolica(c,V)
```

```python
type(rep_simb) # é um objeto simbólico
```

    sympy.core.mul.Mul

**Exemplo:** Suponha, agora, que seu modelo matemático de repasse deva considerar não apenas um percentual $c$ pré-estabelecido, mas também um valor de "bônus" adicional concedido como prêmio pela venda do imóvel. Considere, então, que o valor deste bônus seja $b$. Diante disso, nosso novo modelo teria uma fórmula como a seguinte:

$$r_3(V) = c\,V + b$$

Simbolicamente:

```python
# importaremos apenas o símbolo b,
# uma vez que c e V já foram importados
# como símbolos anteriormente
from sympy.abc import b

def r3(V):
    return c*V + b

rep_3 = r3(V)
rep_3
```

### Substituindo valores

Podemos usar a função `subs` para atribuir quaisquer valores para o modelo.

**Exemplo:** $c = 0.119$

```python
rep_3.subs(c,0.119) # substituindo para c
```

**Exemplo:** $c = 0.222$

```python
rep_3.subs(c,0.222) # substituindo para c
```

**Exemplo:** $c = 0.222$ e $b = 12.0$

```python
rep_3.subs(c,0.222).subs(b,12.0) # substituindo para c, depois para b
```

### Substituição múltipla

O modo anterior de substituição não é "Pythônico". Para substituirmos mais de uma variável de uma vez, devemos usar *pares ordenados* separados por vírgula sequenciados entre colchetes como uma *lista*. Mais tarde, aprenderemos sobre pares ordenados e listas.

**Exemplo:** Modifique o modelo $r_3$ para que $c = 0.043$ e $b = 54.0$

```python
# espaços foram adicionados para dar legibilidade
rep_3.subs( [ (c,0.043), (b,54.0) ] )
```

#### Pares ordenados

Em matemática, o conceito de par ordenado pode ser definido pelo conjunto:

$$ X \times Y = \{ (x,y) ; x \in X \text{ e } y \in Y \},$$

onde $X$ e $Y$ são conjuntos quaisquer e $x$ e $y$ são as *coordenadas*. Por exemplo, se $X = Y = \mathbb{R}$, o conjunto acima contém elementos do tipo $(3,2)$, $(-1,3)$, $(\pi,2.18)$ etc. Na verdade, eles formam o conjunto $\mathbb{R} \times \mathbb{R} = \mathbb{R}^2$, que é exatamente o *plano cartesiano*.

Logo, a substituição múltipla com `subs` ocorre da seguinte forma;

- a primeira coordenada é o *símbolo*;

- a segunda coordenada é o *valor* que você quer dar para o símbolo.

**Exemplo:** Calcule $r_3(V)$ considerando $c = 0.021$, $b = 34.0$ e $V = 432.000$.

```python
# armazenaremos o valor na variável 'valor'
valor = r3(V)

# subsituição
valor.subs( [ (c,0.021), (b,54.0) ] )
```

Com o estilo "Pythônico":

```python
valor = r3(V).subs( [ (c,0.021), (b,54.0) ] ) #
valor
```

Podemos seguir esta regra de pares para substituir todos os valores de um modelo simbólico genérico não necessariamente definido através de uma função. Veja o exemplo aplicado a seguir.

## Exemplo de aplicação: o índice de caminhabilidade

Estudos empíricos nos EUA mostraram que a *caminhabilidade* de uma vizinhança impacta substancialmente os preços das casas. A caminhabilidade está relacionada à distância da moradia a locais de amenidades, tais como restaurantes, bares, bibliotecas, mercearias etc.

O *índice de caminhabilidade* $W$ para uma vizinhança de casas é uma medida matemática que assume valores no intervalo $[0,1]$. A fórmula é definida por:

$$W(d) = e^{-5 \left( \dfrac{d}{M} \right)^5},$$

onde $d$ é a distância medida entre a vizinhança (0 metro) e um dado ponto de referência, e $M$ é a distância máxima de avaliação considerada a partir da qual a caminhabilidade é assumida como nula. Ou seja,

- quando estamos na vizinhança, $d = 0$, $W = 1$ e a caminhabilidade é considerada ótima.

- à medida que nos afastamos da vizinhança em direção ao local da amenidade, $d$ aumenta e o valor $W$ decai vertiginosamente até atingir o valor limite $M$ a partir do qual $W = 0$ e a caminhabilidade é considerada "péssima".

O índice de caminhabilidade é, portanto, calculado com relação a um ponto de destino definido e a distância deve levar em consideração as vias de circulação (ruas, rodovias etc) e não a distância mais curta (raio do perímetro). Por exemplo, se a distância máxima a ser considerada para a caminhabilidade for $M = 500 \, m$ , um bar localizado a 100 metros da vizinhança teria um índice de caminhabilidade maior do que o de uma farmácia localizada a 300 m e muito maior do que o de um shopping localizado a 800 m, ainda que muito famoso. Aliás, neste caso, o valor de $W$ para o shopping seria zero, já que 800 m está além do limite $M$ estabelecido.

Fonte: *De Nadai, M. and Lepri, B. [[The economic value of neighborhoods: Predicting real estate prices from the urban environment]](https://arxiv.org/pdf/1808.02547.pdf)*.

### Modelo simbólico

Podemos modelar $W$ simbolicamente e calcular seu valor para diferentes valores de $d$ e $M$ usando a substituição múltipla.

```python
from sympy.abc import d,M,W

W = sy.exp(-5*(d/M)**5) # função exponencial simbólica
W
```

**Exemplo:** A nossa corretora de imóveis gostaria de entender a relação de preços de imóveis para o Condomínio Pedras de Marfim. Considerando $M = 1 km$, calcule:

- o índice de caminhabilidade $W_1$ em relação à farmácia Dose Certa, localizada a 222 m do condomínio.

- o índice de caminhabilidade $W_2$ em relação ao restaurante Sabor da Arte, localizada a 628 m do condomínio.

- o índice de caminhabilidade $W_3$ em relação ao Centro Esportivo Physicalidade, localizada a 998 m do condomínio.

- o índice de caminhabilidade $W_4$ em relação à Padaria Dolce Panini, localizada a 1,5 km do condomínio.

```python
# note que 1 km = 1000 m
W1 = W.subs([ (d,222), (M,1000) ])
W2 = W.subs([ (d,628), (M,1000) ])
W3 = W.subs([ (d,998), (M,1000) ])
W4 = W.subs([ (d,1500), (M,1000) ])
```

Perceba, entretanto, que os valores calculados ainda não são numéricos, como esperado.

```python
W1
```

```python
W2
```

```python
W3
```

```python
W4
```

Lembre-se que podemos usar `evalf` para calcular esses valores. Faremos isso considerando 3 casas decimais.

```python
# reatribuindo todos os valores
W1n = W1.evalf(3)
W2n = W2.evalf(3)
W3n = W3.evalf(3)
W4n = W4.evalf(3)

print('W1 =', W1n, '; ' \
      'W2 =', W2n, '; ' \
      'W3 =', W3n, '; ' \
      'W4 =', W4n)
```

    W1 = 0.997 ; W2 = 0.614 ; W3 = 0.00708 ; W4 = 3.24e-17

Como era de se esperar, os valores decaem de 0.997 a 3.24e-17, que é um valor considerado nulo em termos de aproximação numérica.

#### Quebrando instruções com `\`

A contra-barra `\` pode ser usada para quebrar instruções e continuá-las nas próximas linhas, porém não poderá haver nenhum caracter após ela, nem mesmo espaços. Caso contrário, um erro será lançado.

```python
print('Continuando' \
      'na linha abaixo')
```

    Continuandona linha abaixo

```python
# neste exemplo, há um caracter de espaço após \
print('Continuando' \
      'na linha abaixo')
```

### O tipo `bool`

Em Python, temos mais um tipo de dado bastante útil, o `bool`, que é uma redução de "booleano". Objetos `bool`, que têm sua raiz na chamada Álgebra de Boole, são baseados nos conceitos *true* (verdadeiro) e *false*, ou *0* e *1* e são estudados em algumas disciplinas, tais como Circuitos Lógicos, Matemática Discreta, Lógica Aplicada, entre outras.

Aprenderemos sobre operadores lógicos mais à frente. Por enquanto, cabe mencionar as entidades fundamentais `True` e `False`.

```python
True
```

    True

```python
False
```

    False

```python
type(True)
```

    bool

```python
type(False)
```

    bool

Podemos realizar testes lógicos para concluir verdades ou falsidades quando temos dúvidas sobre objetos e relações entre eles. Por exemplo, retomemos os seguintes valores:

```python
W1
```

```python
W2
```

A princípio, é difícil determinar qual dos dois é o maior. Porém, podemos realizar "perguntas" lógicas para o interpretador Python com operadores lógicos. Mostraremos apenas dois exemplos com `>` e `<`.

```python
W1 > W2 # isto quer dizer: "W1 é maior do que W2?"
```

O valor `True` confirma que o valor de `W1` é maior do que `W2`.

```python
W4 < 0
```

Note que, de acordo com nosso modelo de caminhabilidade, este valor deveria ser zero. Porém, numericamente, ele é uma aproximação para zero. Embora muito pequeno, não é exatamente zero! Por que isso ocorre? Porque o computador lida com uma matemática inexata e aproximada, mas com precisão satisfatória.

## Operadores lógicos

Vimos que `True` e `False` são os dois valores atribuíves a um objeto de tipo `bool`. Eles são úteis para testar condições, realizar verificações e comparar quantidades. Vamos estudar *operadores de comparação*, *operadores de pertencimento* e *operadores de identidade*.

### Operadores de comparação

A tabela abaixo resume os operadores de comparação utilizados em Python.

| operador | significado | símbolo matemático |
|---|---|---|
| `<` | menor do que | $<$ |
| `<=` | menor ou igual a | $\leq$ |
| `>` | maior do que | $>$ |
| `>=` | maior ou igual a | $\geq$ |
| `==` | igual a | $=$ |
| `!=` | diferente de | $\neq$ |

Podemos usá-los para comparar objetos.

**Nota:** `==` está relacionado à igualdade, ao passo que `=` é uma atribuição. São conceitos operadores com finalidade distinta.

```python
2 < 3 # o resultado é um 'bool'
```

    True

```python
5 < 2 # isto é falso
```

    False

```python
2 <= 2 # isto é verdadeiro
```

    True

```python
4 >= 3 # isto é verdadeiro
```

    True

```python
6 != -2
```

    True

```python
4 == 4 # isto não é uma atribuição!
```

    True

Podemos realizar comparações aninhadas:

```python
x = 2
1 < x < 3
```

    True

```python
3 > x > 4
```

    False

```python
2 == x > 3
```

    False

As comparações aninhadas acima são resolvidas da esquerda para a direita e em partes. Isso nos leva a introduzir os seguintes operadores.

| operador | símbolo matemático | significado | uso relacionado a |
|---|---|---|---|
| `or` | $\vee$ | "ou" booleano | união, disjunção |
| `and` | $\wedge$ | "e" booleano | interseção, conjunção |
| `not` | $\neg$ | "não" booleano | exclusão, negação |

```python
# parênteses não são necessários aqui
(2 == x) and (x > 3) # 1a. comparação: 'True'; 2a.: 'False'. Portanto, ambas: 'False'
```

    False

```python
# parênteses não são necessários aqui
(x < 1) or (x < 2) # nenhuma das duas é True. Portanto,
```

    False

```python
not (x == 2) # nega o "valor-verdade" que é 'True'
```

    False

```python
not x + 1 > 3 # estude a precedência deste exemplo. Por que é 'True'?
```

    True

```python
not (x + 1 > 3) # estude a precedência deste exemplo. Por que também é 'True'?
```

    True

### Operadores de pertencimento

A tabela abaixo resume os operadores de pertencimento.

| operador | significado | símbolo matemático
|---|---|---|
| `in` | pertence a | $\in$ |
| `not in` | não pertence a | $\notin$ |

Eles terão mais utilidade quando falarmos sobre sequências, listas. Neste momento, vejamos exemplos com objetos `str`.

```python
'2' in '2 4 6 8 10' # o caracter '2' pertence à string
```

    True

```python
frase_teste = 'maior do que'
'maior' in frase_teste
```

    True

```python
'menor' in frase_teste # a palavra 'menor' está na frase
```

    False

```python
1 in 2 # 'in' e 'not in' não são aplicáveis aqui
```

### Operadores de identidade

A tabela abaixo resume os operadores de identidade.

| operador | significado
|---|---|
| `is` | "aponta para o mesmo objeto"
| `is not` | "não aponta para o mesmo objeto" |

Esses operadores são úteis para verificar se duas variáveis se referem ao mesmo objeto. Exemplo:

```python
a is b
a is not b
```

- `is` é `True` se `a` e `b` se referem ao mesmo objeto; `False`, caso contrário.
- `is not` é `False` se `a` e `b` se referem ao mesmo objeto; `True`, caso contrário.

```python
a = 2
b = 3
a is b # valores distintos
```

    False

```python
a = 2
b = a
a is b # mesmos valores
```

    True

```python
a = 2
b = 3
a is not b # de fato, valores não são distintos
```

    True

```python
a = 2
b = a
a is not b # de fato, valores são distintos
```

    False

## Equações simbólicas

Equações simbólicas podem ser formadas por meio de `Eq` e não com `=` ou `==`.

```python
# importação
from sympy.abc import a,b
import sympy as sy
sy.init_printing(pretty_print=True)
```

```python
sy.Eq(a,b) # equação simbólica
```

```python
sy.Eq(sy.cos(a), b**3) # os objetos da equação são simbólicos
```

### Resolução de equações algébricas simbólicas

Podemos resolver equações algébricas da seguinte forma:

```python
solveset(equação,variável,domínio)
```

**Exemplo:** resolva $x^2 = 1$ no conjunto $\mathbb{R}$.

```python
from sympy.abc import x
sy.solveset( sy.Eq( x**2, 1), x,domain=sy.Reals)
```

Podemos reescrever a equação como: $x^2 - 1 = 0$.

```python
sy.solveset( sy.Eq( x**2 - 1, 0), x,domain=sy.Reals)
```

Com `solveset`, não precisamos de `Eq`. Logo, a equação é passada diretamente.

```python
sy.solveset( x**2 - 1, x,domain=sy.Reals)
```

**Exemplo:** resolva $x^2 + 1 = 0$ no conjunto $\mathbb{R}$.

```python
sy.solveset( x**2 + 1, x,domain=sy.Reals) # não possui solução real
```

**Exemplo:** resolva $x^2 + 1 = 0$ no conjunto $\mathbb{C}$.

```python
sy.solveset( x**2 + 1, x,domain=sy.Complexes) # possui soluções complexas
```

**Exemplo:** resolva $\textrm{sen}(2x) = 3 + x$ no conjunto $\mathbb{R}$.

```python
sy.solveset( sy.sin(2*x) - x - 3,x,sy.Reals) # a palavra 'domain' também pode ser omitida.
```

O conjunto acima indica que nenhuma solução foi encontrada.

**Exemplo:** resolva $\textrm{sen}(2x) = 1$ no conjunto $\mathbb{R}$.

```python
sy.solveset( sy.sin(2*x) - 1,x,sy.Reals)
```

## Expansão, simplificação e fatoração de polinômios

Vejamos exemplos de polinômios em uma variável.

```python
a0, a1, a2, a3 = sy.symbols('a0 a1 a2 a3') # coeficientes
P3x = a0 + a1*x + a2*x**2 + a3*x**3 # polinômio de 3o. grau em x
P3x
```

```python
b0, b1, b2, b3 = sy.symbols('b0 b1 b2 b3') # coeficientes
Q3x = b0 + b1*x + b2*x**2 + b3*x**3 # polinômio de 3o. grau em x
Q3x
```

```python
R3x = P3x*Q3x # produto polinomial
R3x
```

```python
R3x_e = sy.expand(R3x) # expande o produto
R3x_e
```

```python
sy.simplify(R3x_e) # simplify às vezes não funciona como esperado
```

```python
sy.factor(R3x_e) # 'factor' pode funcionar melhor
```

```python
# simplify funciona para casos mais gerais
ident_trig = sy.sin(x)**2 + sy.cos(x)**2
ident_trig
```

```python
sy.simplify(ident_trig)
```

## Identidades trigonométricas

Podemos usar `expand_trig` para expandir funções trigonométricas.

```python
sy.expand_trig( sy.sin(a + b) ) # sin(a+b)
```

```python
sy.expand_trig( sy.cos(a + b) ) # cos(a+b)
```

```python
sy.expand_trig( sy.sec(a - b) ) # sec(a-b)
```

## Propriedades de logaritmo

Com `expand_log`, podemos aplicar propriedades válidas de logaritmo.

```python
sy.expand_log( sy.log(a*b) )
```

A identidade não foi validada pois `a` e `b` são símbolos irrestritos.

```python
a,b = sy.symbols('a b',positive=True) # impomos que a,b > 0
```

```python
sy.expand_log( sy.log(a*b) ) # identidade validada
```

```python
sy.expand_log( sy.log(a/b) )
```

```python
m = sy.symbols('m', real = True) # impomos que m seja um no. real
sy.expand_log( sy.log(a**m) )
```

Com `logcombine`, compactamos as propriedades.

```python
sy.logcombine( sy.log(a) + sy.log(b) ) # identidade recombinada
```

## Fatorial

A função `factorial(n)` pode ser usada para calcular o fatorial de um número.

```python
sy.factorial(m)
```

```python
sy.factorial(m).subs(m,10) # 10!
```

```python
sy.factorial(10) # diretamente
```

**Exemplo:** Sejam $m,n,x$ inteiros positivos. Se $f(m) = 2m!$, $g(n) = \frac{(n + 1)!}{n^2!}$ e $h(x) = f(x)g(x)$, qual é o valor de $h(2)$?

```python
from sympy.abc import m,n,x

f = 2*sy.factorial(m)
g = sy.factorial(n + 1)/sy.factorial(n**2)

h = (f.subs(m,x)*g.subs(n,x)).subs(x,4)
h
```

## Funções anônimas

A terceira classe de funções que iremos aprender é a de *funções anônimas*. Uma **função anônima** em Python consiste em uma função cujo nome não é explicitamente definido e que pode ser criada em apenas uma linha de código para executar uma tarefa específica.

Funções anônimas são baseadas na palavra-chave `lambda`. Este nome tem inspiração em uma área da ciência da computação chamada de cálculo-$\lambda$.

Uma função anônima tem a seguinte forma:

```python
lambda lista_de_parâmetros: expressão
```

Funções anônimas podem são bastante úteis para tornar um código mais conciso.

Por exemplo, na aula anterior, definimos a função

```python
def repasse(V):
    return 0.0103*V
```

para calcular o repasse financeiro ao corretor imobiliário.

Com uma função anônima, a mesma função seria escrita como:

```python
repasse = lambda V: 0.0103*V
```

Não necessariamente temos que atribui-la a uma variável. Neste caso, teríamos:

```python
lambda V: 0.0103*V
```

    <function __main__.<lambda>(V)>

Para usar a função, passamos um valor:

```python
repasse(100000) # repasse sobre R$ 100.000,00
```

O modelo completo com "bonificação" seria escrito como:

```python
r3 = lambda c,V,b: c*V + b # aqui há 3 parâmetros necessários
```

Redefinamos objetos simbólicos:

```python
from sympy.abc import b,c,V
r3(b,c,V)
```

O resultado anterior continua sendo um objeto simbólico, mas obtido de uma maneira mais direta. Podemos usar funções anônimas para tarefas de menor complexidade.

## "Lambdificação" simbólica

Usando `lambdify`, podemos converter uma expressão simbólica do *sympy* para uma expressão que pode ser numericamente avaliada em outra biblioteca. Essa função desempenha papel similar a uma função *lambda* (anônima).

```python
expressao = sy.sin(x) + sy.sqrt(x) # expressão simbólica
f = sy.lambdify(x,expressao,"math") # lambdificação para o módulo math
f(0.2) # avalia
```

Para avaliações simples como a anterior, podemos usar `evalf` e `subs`. A lambdificação será útil quando quisermos avaliar uma função em vários pontos, por exemplo. Na próxima aula, introduziremos sequencias e listas. Para mostrar um exemplo de lambdificação melhor veja o seguinte exemplo.

```python
from numpy import arange # importação de função do módulo numpy

X = arange(40) # gera 40 valores de 0 a 39
```

```python
X
```

    array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
           17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
           34, 35, 36, 37, 38, 39])

```python
f = sy.lambdify(x,expressao,"numpy")(X) # avalia 'expressao' em X
f
```

    array([0.        , 1.84147098, 2.32351099, 1.87317082, 1.2431975 ,
           1.2771437 , 2.17007424, 3.30273791, 3.81778537, 3.41211849,
           2.61825655, 2.31663458, 2.9275287 , 4.02571831, 4.73226474,
           4.52327119, 3.71209668, 3.16170813, 3.49165344, 4.50877615,
           5.38508121, 5.41923133, 4.68156445, 3.94961112, 3.99340112,
           4.86764825, 5.86157796, 6.15252835, 5.56240841, 4.72153092,
           4.48919395, 5.16372672, 6.20828093, 6.74447451, 6.36003458,
           5.48789711, 5.00822115, 5.4392244 , 6.46078258, 7.20879338])


#### Finding the axes of a hyper-ellipse

Let us try finding the axes of the hyper ellipse described by the

equation $5x^2+6xy+5y^2=20$.

Note: The actual ellipse we use as example is 2D (to facilitate

visualization), but the code we develop will be general

and extensible to multi-dimensions.

The ellipse equation can be written using matrices and vectors as

$\vec{x}^{T}A\vec{x} = 1$ where
$A=
\begin{bmatrix}
        5 & 3 \\
        3 & 5 \\
\end{bmatrix}
\space \space \space
\vec{x} =
\begin{bmatrix}
        x \\
        y \\
\end{bmatrix}$.

To find the axes of the hyper ellipse, we need to transform the

coordinate system so that the matrix in the middle becomes diagonal.

Here is how this can be done:

If we diagonalise $A$ into $S\Sigma S^{-1}$, then the ellipse equation

becomes $\vec{x}^{T}S \Sigma S^{-1}\vec{x} = 1$ where $\Sigma$ is a

diagonal matrix.

Since $A$ is symmetric, its eigenvectors are orthogonal.

Hence, the matrix containing these eigenvectors as columns is orthogonal,

 i.e., $S^{-1} = S^{T}$. In other words, $S$ is a rotation matrix.

So the ellipse equation becomes $\vec{x}^{T}S \Sigma S^{T}\vec{x} = 1$

or $\left(\vec{x}^{T}S\right) \Sigma \left(S^{T}\vec{x}\right) = 1$

or $\vec{y}^{T} \Sigma \vec{y} = 1$ where $\vec{y} = S^{T}\vec{x}$.

This is of the desired form since $\Sigma$ is a diagonal matrix.

Remember, $S$ is a rotation matrix. Thus, rotating the coordinate system

by $S$ aligns the coordinate axes with the ellipse axes.

```python
import numpy as np
from sympy import Symbol
import sympy as sy

x = Symbol('x')
y = Symbol('y')
a = Symbol('a')
b = Symbol('b')
ellipse_eq = sy.Eq(5*x**2 + 5*y**2 + 6*x*y, 20)
ellipse_eq
```

$\displaystyle 5 x^{2} + 6 x y + 5 y^{2} = 20$

```python
# Let us plot this ellipse
%matplotlib inline
import matplotlib.pyplot as plt
import sympy as sy

def sym_eq_plot(eq):
    plot = sy.plot_implicit(eq)
    return plot
plot = sym_eq_plot(ellipse_eq)

print("Note that the ellipse major axis is forming an\n"
      "angle of 45 degrees with X axis.\n"
      "Rotating coordinate system by this angle\n"
      "will align ellipse axes with coordinate axes.")
```

```python
A = np.array([[5, 3], [3, 5]])

# Obtain eigen values and vectors of the ellipse
# coeeficients matrix
l, S = np.linalg.eig(np.array(A).astype(np.float64))

print("Eigen values are:  {}\n".format(l))
print("Eigen vectors are columns of S matrix\n{}".format(S))

# Assert that eigen vectors are orthogonal
assert np.dot(S[:, 0], S[:, 1]) == 0.0

# Find the angle between the principal axis and the X-axis.
import math

# Vector corresponding to X-axis
x_axis_vec = np.zeros((A.shape[0]))
x_axis_vec[0] = 1

# First principal eigen vector
first_eigen_vec = S[:, 0]

# Dot product between the two vectors (equals cosine
# of the angle between the directions of the two vectors)
dot_prod = np.dot(x_axis_vec, first_eigen_vec)

# The angle between the two vectors is the cosine inverse
# of the dot-product, in radians
theta = math.acos(dot_prod)

# Convert to degrees from radian
theta = math.degrees(theta)
print("\nRotation angle theta = {:.2f} degrees".format(theta))
```

    Eigen values are:  [8. 2.]

    Eigen vectors are columns of S matrix
    [[ 0.70710678 -0.70710678]
     [ 0.70710678  0.70710678]]

    Rotation angle theta = 45.00 degrees

```python
# Plot the eigen vectors
plt.quiver([0], [0], S[:,0], S[:,1],
           color=['r','b','g'], scale=5)
plt.show()
```

```python
# Let us plot the ellipse along with the axes.
# From our calculations, we know that the angle
# of rotation is 45 degrees, and that the eigen vectors
# are the columns of S

import matplotlib
fig = plt.figure(0)
ax = fig.add_subplot(111, aspect='equal')

e = matplotlib.patches.Ellipse((0, 0), 1, 3,
                               theta, fc='None',
                               edgecolor='g')
# The ellipse is centered at (0, 0)
# We are using random width and height.
# Note that the direction of the axes is independent of
# width and height
ax.add_artist(e)
ax.set_xlim(-2, 2)
ax.set_ylim(-2, 2)
ax.quiver([0], [0], S[:,0], S[:,1], color=['r','b','g'],
          scale=5)

plt.show()

```


```python
import numpy as np
import sympy as sp
import math
sp.init_printing(use_unicode=True)
```

# Compare sin30 and cos30 (numpy, sympy)

```python
# sin30 and cos30 using numpy:
sin30 = np.sin(30*math.pi/180)
cos30 = np.cos(30*math.pi/180)
print(sin30, cos30)
```

    0.49999999999999994 0.8660254037844387

```python
# sin30 and cos30 using sympy ... with 'regular' pi:
sin30 = sp.sin(30*math.pi/180)
cos30 = sp.cos(30*math.pi/180)
print(sin30, cos30)
```

    0.500000000000000 0.866025403784439

```python
# sin30 and cos30 using sympy with exact pi:
sin30e = sp.sin(30*sp.pi/180)
cos30e = sp.cos(30*sp.pi/180)
print(sin30e, cos30e)
```

    1/2 sqrt(3)/2

```python
# results for sympy calculations are shown in LaTeX if called outside of print():
sin30e
```

```python
# that's more obvious here:
cos30e
```

# Setup rotation matrices and apply them to u=[3,2]

```python
# rot30 will be a sympy matrix with exact values.
rot30 = sp.Matrix([[cos30e, -1*sin30e], [sin30e, cos30e]])
rot30
```

$\displaystyle \left[\begin{matrix}\frac{\sqrt{3}}{2} & - \frac{1}{2}\\\frac{1}{2} & \frac{\sqrt{3}}{2}\end{matrix}\right]$

```python
u = sp.Matrix([3,2])
u
```

$\displaystyle \left[\begin{matrix}3\\2\end{matrix}\right]$

```python
# Remember you can use * for matrix multiplication in sympy only
R30u = rot30*u
R30u
```

$\displaystyle \left[\begin{matrix}-1 + \frac{3 \sqrt{3}}{2}\\\frac{3}{2} + \sqrt{3}\end{matrix}\right]$

```python
# Let's repeat our results using numpy:
rot30n = np.array([[cos30, -1*sin30], [sin30, cos30]])
rot30n
```

    array([[0.866025403784439, -0.500000000000000],
           [0.500000000000000, 0.866025403784439]], dtype=object)

```python
# Numpy does floating point calculations so all answers are in decimals. It's fast but less precise than sympy.
R30un = np.dot(rot30n, u)
R30un
```

    array([[1.59807621135332],
           [3.23205080756888]], dtype=object)


# CSE 330 Numerical Analysis Lab

### Lab 8: LU Decomposition

Let a system of equations be,
\begin{equation}
2\boldsymbol{x}_1 - \boldsymbol{x}_{2}+3\boldsymbol{x}_3 = 4
\end{equation}
\begin{equation}
4\boldsymbol{x}_1 + 2\boldsymbol{x}_{2}+\boldsymbol{x}_3 = 1
\end{equation}
\begin{equation}
-6\boldsymbol{x}_1 - \boldsymbol{x}_{2}+2\boldsymbol{x}_3 = 2
\end{equation}

We can write the whole thing in matrix form,

\begin{equation}
\begin{pmatrix}
2 & -1 & 3 \\
4 & 2 & 1 \\
-6 & -1 & 2 \\
\end{pmatrix}
\begin{pmatrix}
\boldsymbol{x}_1 \\
\boldsymbol{x}_2 \\
\boldsymbol{x}_3
\end{pmatrix}
=
\begin{pmatrix}
4  \\
1 \\
2
\end{pmatrix}
\end{equation}

In the system of equations, there are 3 equations and 3 unknowns. Therefore, it can be solved for $\boldsymbol{x}_1, \boldsymbol{x}_2, \boldsymbol{x}_3$ unless any 2 of the equations are parallel to each other.

Gaussian elimination is a commonly used tactics to solve a system of equation. In this method, we create an augmented matrix from a set of equations and then eliminate everything below the diagonal terms. First, let's create the augmented matrix.

\begin{equation}
\begin{pmatrix}
2 & -1 & 3 &  \qquad   4 \\
4 & 2 & 1  &   \qquad   1\\
-6 & -1 &  2 &   \qquad   2 \\
\end{pmatrix}
\end{equation}

Now, if we multiply row 1 with 2 and subtract it from row 2, we get,

\begin{equation}
\begin{pmatrix}
2 & -1 & 3 &  \qquad   4 \\
0 & 4 & -5  &   \qquad   -7\\
-6 & -1 &  2 &   \qquad   2 \\
\end{pmatrix}
\end{equation}

Additionally, multiply row 1 with -3 and subtract it from row 3,

\begin{equation}
\begin{pmatrix}
2 & -1 & 3 &  \qquad   4 \\
0 & 4 & -5  &   \qquad   -7\\
0 & -4 &  11 &   \qquad   14 \\
\end{pmatrix}
\end{equation}

Finally, multiply row 2 with -1 and subtract it from row 3,

\begin{equation}
\begin{pmatrix}
2 & -1 & 3 &  \qquad   4 \\
0 & 4 & -5  &   \qquad   -7\\
0 & 0 &  6 &   \qquad   7 \\
\end{pmatrix}
\end{equation}

Now, the simplified equations are very easy to solve,

\begin{equation}
6\boldsymbol{x}_3 = 7 \quad or, \quad \boldsymbol{x}_3  = 7/6
\end{equation}

\begin{equation}
4\boldsymbol{x}_2 - 5\boldsymbol{x}_3  = -7 \quad or, \quad 4\boldsymbol{x}_2 - 35/6 = -7 \quad or, \quad \boldsymbol{x}_2 = -7/24
\end{equation}

\begin{equation}
2\boldsymbol{x}_1 - \boldsymbol{x}_{2}+3\boldsymbol{x}_3 = 4 \quad or, \quad 2\boldsymbol{x}_1 + 7/24 + 21/6 = 4 \quad or, \quad \boldsymbol{x}_1 = 5/48
\end{equation}

We are solving this for a system $Ax=B$, the complexity of this whole process is somewhere around $O(n^3)$. In many matrix problems you do not only have to solve $Ax=B$, but also have to solve $Ax=C$, $Ax=D$, $Ax=E$ etc., where repeating the same process again and again becomes expensive.

Therefore, a clever approach is to just drop the right side of the equation at first and decompose the matrix into lower part $\boldsymbol{L}$ and upper part $\boldsymbol{U}$. Where, $\boldsymbol{U}$ is the simplified matrix and $\boldsymbol{L}$ has the multipliers. Then the LU matrices can be repeatedly used to solve multiple systems of equations which is much cheaper. The common structure for these matrices are,

\begin{equation}
L =
\begin{pmatrix}
1 & 0 & 0 \\
L_{21} & 1 & 0 \\
L_{31} & L_{32} & 1 \\
\end{pmatrix}
,
U =
\begin{pmatrix}
U_{11} & U_{12} & U_{13} \\
0 & U_{22} & U_{23} \\
0 & 0 & U_{33} \\
\end{pmatrix}
\end{equation}

Now, let's drop the right side of the original system of equations and isolate the left side,
\begin{pmatrix}
2 & -1 & 3 \\
4 & 2 & 1 \\
-6 & -1 & 2 \\
\end{pmatrix}
Let's calculate the LU decomposition for this.

```
from sympy import Matrix
from sympy import init_printing

A = Matrix([[2,-1,3],
              [4,2,1],
              [-6,-1,2]])
L, U, _ = A.LUdecomposition()

init_printing(use_latex='matplotlib')
L
```

    ⎡1   0   0⎤
    ⎢         ⎥
    ⎢2   1   0⎥
    ⎢         ⎥
    ⎣-3  -1  1⎦

```
U
```

    ⎡2  -1  3 ⎤
    ⎢         ⎥
    ⎢0  4   -5⎥
    ⎢         ⎥
    ⎣0  0   6 ⎦

Using the symbolic python library we can directly compute the LU decomposition for any matrix. Our task for today will be to manually calculate the LU decomposition matrices. LU decompoition has different variants which involve pivoting and partial pivoting in order to avoid 0 on the diagonal and to reduce rounding error. For now, we'll avoid those and calculate in the simplest way possible using gaussian elimination.

An important factor to note: LU decomposition is not a unique value. There might be multiple LU decomposition for the same matrix. Therefore, your manually calculated LU decomposition and the values returned by python libraries may not match.

```
import numpy as np

def LUDecomposition(M):
    #Initializing the Upper matrix that has to be simplified
    U = M
    #Initializing the Lower matrix as an identity matrix (all diagonals are 1)
    L = np.identity(U.shape[0])
    ###Use Gaussian elimination to populate both L and U#####
    print("L and U of Given Matrix b: \n",L,"\n\n",U,"\n\n")

    pivot =  0
    for i in range(1, len(U)):
        print(f"\n#### Iteration = {i}")
        print("#################################################")
        for row in range(i, len(U)):
            print(f"\n## row = {row}")
            print(f"\npivot = {pivot}, U[{pivot}][{pivot}] = {U[pivot][pivot]}")
            multiplier = U[row][pivot] / U[pivot][pivot]
            L[row][pivot] = multiplier
            print(f"\nmultiplier = {multiplier}\n")
            for col in range(len(U)):
                print(f"col = {col}, U[{row}][{col}] = {U[row][col]}, U[{row-1}][{col}] = {U[row-1][col]}")
                print(f"{U[pivot][col]} - {U[pivot][col]} * {multiplier} = {U[pivot][col] * multiplier}\n")
                U[row][col] -= U[pivot][col] * multiplier
            print(U)
        pivot += 1

    return L, U

b = np.array([[2,-1,3],
              [4,2,1],
              [-6,-1,2]])

L, U = LUDecomposition(b)
print("\n\nL and U of Matrix b: \n",L,"\n\n",U,"\n\n")

```

    L and U of Given Matrix b:
     [[1. 0. 0.]
     [0. 1. 0.]
     [0. 0. 1.]]

     [[ 2 -1  3]
     [ 4  2  1]
     [-6 -1  2]]

    #### Iteration = 1
    #################################################

    ## row = 1

    pivot = 0, U[0][0] = 2

    multiplier = 2.0

    col = 0, U[1][0] = 4, U[0][0] = 2
    2 - 2 * 2.0 = 4.0

    col = 1, U[1][1] = 2, U[0][1] = -1
    -1 - -1 * 2.0 = -2.0

    col = 2, U[1][2] = 1, U[0][2] = 3
    3 - 3 * 2.0 = 6.0

    [[ 2 -1  3]
     [ 0  4 -5]
     [-6 -1  2]]

    ## row = 2

    pivot = 0, U[0][0] = 2

    multiplier = -3.0

    col = 0, U[2][0] = -6, U[1][0] = 0
    2 - 2 * -3.0 = -6.0

    col = 1, U[2][1] = -1, U[1][1] = 4
    -1 - -1 * -3.0 = 3.0

    col = 2, U[2][2] = 2, U[1][2] = -5
    3 - 3 * -3.0 = -9.0

    [[ 2 -1  3]
     [ 0  4 -5]
     [ 0 -4 11]]

    #### Iteration = 2
    #################################################

    ## row = 2

    pivot = 1, U[1][1] = 4

    multiplier = -1.0

    col = 0, U[2][0] = 0, U[1][0] = 0
    0 - 0 * -1.0 = -0.0

    col = 1, U[2][1] = -4, U[1][1] = 4
    4 - 4 * -1.0 = -4.0

    col = 2, U[2][2] = 11, U[1][2] = -5
    -5 - -5 * -1.0 = 5.0

    [[ 2 -1  3]
     [ 0  4 -5]
     [ 0  0  6]]

    L and U of Matrix b:
     [[ 1.  0.  0.]
     [ 2.  1.  0.]
     [-3. -1.  1.]]

     [[ 2 -1  3]
     [ 0  4 -5]
     [ 0  0  6]]

Let's go back to the original matrix form,
\begin{equation}
\begin{pmatrix}
2 & -1 & 3 \\
4 & 2 & 1 \\
-6 & -1 & 2 \\
\end{pmatrix}
\begin{pmatrix}
\boldsymbol{x}_1 \\
\boldsymbol{x}_2 \\
\boldsymbol{x}_3
\end{pmatrix}
=
\begin{pmatrix}
4  \\
1 \\
2
\end{pmatrix}
\end{equation}

If it is in the form $Ax=B$ then,
\begin{equation}
A=
\begin{pmatrix}
2 & -1 & 3 \\
4 & 2 & 1 \\
-6 & -1 & 2 \\
\end{pmatrix}
,B=
\begin{pmatrix}
4  \\
1 \\
2
\end{pmatrix}
\end{equation}

We already know that the LU decompositon of A is,
\begin{equation}
L=
\begin{pmatrix}
1 & 0 & 0 \\
2 & 1 & 0 \\
-3 & -1 & 1 \\
\end{pmatrix}
,
U=
\begin{pmatrix}
2 & -1 & 3 \\
0 & 4 & -5 \\
0 & 0 & 6 \\
\end{pmatrix}
\end{equation}

Now, Use $L$, $U$ and $B$ to solve the original system of equations.

We have to solve y for $Ly=B$
Or, in this case,
\begin{equation}
\begin{pmatrix}
1 & 0 & 0 \\
2 & 1 & 0 \\
-3 & -1 & 1 \\
\end{pmatrix}
\begin{pmatrix}
y_1 \\
y_2 \\
y_3 \\
\end{pmatrix}
=
\begin{pmatrix}
4  \\
1 \\
2
\end{pmatrix}
\end{equation}
It can be solved by forward substitution. It is an iterative process that can be implemented with a nested loop.
\begin{equation}
\boldsymbol{y}_1 = 4
\end{equation}

\begin{equation}
2\boldsymbol{y}_1 + \boldsymbol{y}_2  = 1 \quad or, \quad 8 + \boldsymbol{y}_2 = 1 \quad or, \quad \boldsymbol{y}_2 = -7
\end{equation}

\begin{equation}
-3\boldsymbol{y}_1 - \boldsymbol{y}_{2}+\boldsymbol{y}_3 = 2 \quad or, \quad -12 + 7 + \boldsymbol{y}_3 = 2 \quad or, \quad \boldsymbol{y}_3 = 7
\end{equation}

After solving $y$, in order to calculate $x$ we need to solve,
$Ux = y$ or,
\begin{equation}
\begin{pmatrix}
2 & -1 & 3 \\
0 & 4 & -5 \\
0 & 0 & 6 \\
\end{pmatrix}
\begin{pmatrix}
x_1 \\
x_2 \\
x_3 \\
\end{pmatrix}
=
\begin{pmatrix}
y_1 \\
y_2 \\
y_3 \\
\end{pmatrix}
		\Longrightarrow
  \begin{pmatrix}
2 & -1 & 3 \\
0 & 4 & -5 \\
0 & 0 & 6 \\
\end{pmatrix}
\begin{pmatrix}
x_1 \\
x_2 \\
x_3 \\
\end{pmatrix}
=
\begin{pmatrix}
4 \\
-7 \\
7 \\
\end{pmatrix}
\end{equation}
Which can be done through backward substitution.

If you find it hard to iterate backwards, you can just flip $U$, $x$ and $y$, then it results into,
\begin{equation}
\begin{pmatrix}
6 & 0 & 0 \\
-5 & 4 & 0 \\
3 & -1 & 2 \\
\end{pmatrix}
\begin{pmatrix}
x_3 \\
x_2 \\
x_1 \\
\end{pmatrix}
=
\begin{pmatrix}
7 \\
-7 \\
4 \\
\end{pmatrix}
\end{equation}

Solving which would be the same as the forward substitution!

The forward substitution part is done for you. Complete the backward substitution part for correct output.

```
import numpy as np
L = np.array([[1,0,0],
              [2,1,0],
              [-3,-1,1]])

U = np.array([[2,-1,3],
              [0,4,-5],
              [0,0,6]])

B = np.array([4,1,2])

#Forward Substitution process
B_L = np.zeros(B.shape[0])

for i in range(L.shape[0]):
    summation=0
    for j in range(L.shape[0]):
        if i == j:
            B_L[j] = B[j] - summation
            B_L[j] = B_L[j]/L[i,j]
        break
    else:
        summation = summation + L[i,j]*B_L[j]

# Backward Substitution, your task is to complete this task
#Flip the U and B_L matrices if necessary using np.flip(array, axis) method
U = np.flip(U, 0)
U = np.flip(U, 1)
B_L = np.flip(B)

B_LU = np.zeros(B.shape[0])
#Use U and B_L to populate B_LU, just like how L and B was used to populate B_L in forward substitution

#Place your code here (You may need nested loop)

for i in range(L.shape[0]):
    summation=0
    for j in range(L.shape[0]):
        if i == j:
            B_LU[j] = B_L[j] - summation
            B_LU[j] = B_LU[j]/L[i,j]
        break
    else:
        summation = summation + U[i,j]*B_LU[j]

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

final_result = np.flip(B_LU)

print(final_result)

```

    [0. 0. 0.]

Both the forward and the backward substitution method can accomplish the task with $O(n^2)$ complexity!


# Exercise 2
Write a function to compute the roots of a mathematical equation of the form
\begin{align}
  ax^{2} + bx + c = 0.
\end{align}
Your function should be sensitive enough to adapt to situations in which a user might accidentally set $a=0$, or $b=0$, or even $a=b=0$.  For example, if $a=0, b\neq 0$, your function should print a warning and compute the roots of the resulting linear function.  It is up to you on how to handle the function header: feel free to use default keyword arguments, variable positional arguments, variable keyword arguments, or something else as you see fit.  Try to make it user friendly.

Your function should return a tuple containing the roots of the provided equation.

**Hint:** Quadratic equations can have complex roots of the form $r = a + ib$ where $i=\sqrt{-1}$ (Python uses the notation $j=\sqrt{-1}$).  To deal with complex roots, you should import the `cmath` library and use `cmath.sqrt` when computing square roots.  `cmath` will return a complex number for you.  You could handle complex roots yourself if you want, but you might as well use available libraries to save some work.

```python
import cmath
import math

def find_roots(a,b,c):
    #check if quadratic quadratic function
    if (a == 0):
        if(b==0):
            print("Error: This is not a function!")
        else:
            #linear function find root & give warning
            print("WARNING: This is the not a quadratic function!")
            print("The root of this linear function is:" )
            print(-c/b)
    else:
        # calculate the discriminant
        d = (b**2) - (4*a*c)

        # find roots of quadratic
        if (d<0):
            sqrt = cmath.sqrt(d)
            root1 = (-b - sqrt)/(2*a)
            root2 = (-b + sqrt)/(2*a)
            print("The roots of this function are complex:" )
        elif (d>0):
            sqrt = math.sqrt(d)
            root1 = (-b -sqrt)/(2*a)
            root2 = (-b + sqrt)/(2*a)
            print("The roots of this function are real:" )
        print(root1)
        print (root2)

find_roots(0,0.5,2)
find_roots(1,0,-1)
```

    WARNING: This is the not a quadratic function!
    The root of this linear function is:
    -4.0
    The roots of this function are real:
    -1.0
    1.0


#
Polinomios

Una de las virtudes de Sympy es que puede realizar operaciones con expresiones algebraicas. Las más simples de estas expresiones algebraicas son los polinomios. Sin embargo Sympy no reconoce, por defecto, a las letras, como expresiones algebraicas (en principio para Python las letras son nombres de variables). Para informar a Sympy de que vamos a trabajar con una letra debemos utilizar la función **symbols**.

```python
from sympy import *
init_printing()
x = symbols('x')
y = symbols('y')
```

Para desarrollar polinomios utilizamos el comando **expand**. De esta forma podemos simplificar todos las operaciones combinadas donde aparezcan sumas, restas, productos y potencias. Aunque esta función tiene muchos argumentos optativos, en principio la usaremos del modo más sencillo.

###
Realiza las siguientes operaciones con polinomios:

* $x^3+(3x^2+7)(4x-1)+(2x-3)^4$

* $(4x^2+5)^2$

* $(x+y)^3$

```python
expand(x**3 +(3*x**2+7)*(4*x-1))
```

```python
simplify((x+y)**3)
```

Para realizar una división euclídea de polinomios utilizamos la función **div**. Esta nos devuelve dos resultados en forma de lista: el primero es el cociente y el segundo es el resto.

###
Realiza la siguiente división de polinomios, comprobando el resultado:

* $(x^3+5x^2-3x+2):(4x-3)$

```python

```

```python

```

Para factorizar polinomios en una variable, podemos utilizar el comando **factor**.

###
Factoriza los siguientes polinomios y comprueba el resultado:

* $p= x^4-x^3-7x^2+13x-6 \qquad q= x^3-5x^2+8x-4$

```python

```

```python
factor(q)
```

###
Calcula el máximo común denominador y el mínimo común múltiplo de los polinomios anteriores.

```python
lcm(p,q)
```

Para sustituir una letra por un número o por una expresión, se utiliza el método **subs**. A este le debemos facilitar dos argumentos: el primero es la letra que queremos sustituir, y el segundo debe ser el número o expresión por la que lo vamos a sustituir.

###
Calcula el valor de $p(x)$ cuando $x$ es 5 y cuando $x$ es 1. Sustituye $x$ por $y^2-1$.

```python

```

Aunque lo veremos en más profundidad en otro notebook, el comando para encontrar las raíces de un polinomios el **solve**.

###
Resuelve la ecuación $p(x)=0$.

```python

```

```python

```


## Number Theory with Sympy's Sieve

An infinite list of prime numbers, implemented as a dynamically growing sieve of Eratosthenes. When a lookup is requested involving an odd number that has not been sieved, the sieve is automatically extended up to that number.

```python
from sympy import sieve
```

```python
sieve._reset()
25 in sieve
```

    False

```python
sieve._list
```

    array('l', [2, 3, 5, 7, 11, 13, 17, 19, 23])

```python
# Grow the sieve to cover all primes <= n
sieve._reset()
sieve.extend(30)
sieve[10] == 28
```

    False

```python
sieve[10] == 29
```

    True

```python
sieve[10] == 23
```

    False

```python
# Extend to include the ith prime number
sieve._reset()
sieve.extend_to_no(9)
sieve._list
```

    array('l', [2, 3, 5, 7, 11, 13, 17, 19, 23])

```python
# $primerange(a,b)$

print([i for i in sieve.primerange(7, 23)])
```

    [7, 11, 13, 17, 19]

```python
# Search = returns the indice i, j of the primes that bound n
#if n is prime then i = j

sieve.search(25)
```

    (9, 10)

```python
sieve.search(23)
```

    (9, 9)

```python
# Prime
# Return the nth prime, with the primes indexed as prime(1) = 2, prime(2) = 3, etc….
# The nth prime is approximately n*log(n).
from sympy import prime
prime(10)
```

    29

## Primes

```python
prime(1)
```

    2

```python
%time
prime(1000000)
```

    CPU times: user 3 µs, sys: 0 ns, total: 3 µs
    Wall time: 6.91 µs

    15485863

```python
# primepi(n) - gives n number of primes

from sympy import primepi
primepi(25)
```

    9

```python
%time
primepi(1000000)
```

    CPU times: user 3 µs, sys: 0 ns, total: 3 µs
    Wall time: 6.91 µs

    78498

```python
from sympy import nextprime
[(i, nextprime(i)) for i in range(10, 15)]
```

    [(10, 11), (11, 13), (12, 13), (13, 17), (14, 17)]

```python
from sympy import prevprime
[(i, prevprime(i)) for i in range(10, 15)]
```

    [(10, 7), (11, 7), (12, 11), (13, 11), (14, 13)]

## Prime Ranges

Some famous conjectures about the occurence of primes in a given range are [1]:

**Twin primes**: though often not, the following will give 2 primes
an infinite number of times:
primerange(6*n - 1, 6*n + 2)

**Legendre’s**: the following always yields at least one prime
`primerange(n**2, (n+1)**2+1)`

**Bertrand’s (proven)**: there is always a prime in the range
`primerange(n, 2*n)`

**Brocard’s**: there are at least four primes in the range
`primerange(prime(n)**2, prime(n+1)**2)`

The average gap between primes is log(n) [2]; the gap between primes can be arbitrarily large since sequences of composite numbers are arbitrarily large, e.g. the numbers in the sequence `n! + 2, n! + 3 … n! + n` are all composite.

```python
from sympy import primerange, sieve
print([i for i in primerange(1, 30)])
```

    [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

```python
list(sieve.primerange(1, 30))
```

    [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

```python
# randprime
from sympy import randprime, isprime
randprime(1, 30)
```

    19

```python
isprime(randprime(1, 30))
```

    True

```python
# This returns the product of the first n primes or teh primes <= n (when nth=False)
from sympy.ntheory.generate import primorial, randprime, primerange
from sympy import factorint, Mul, primefactors, sqrt
primorial(5) # product of 2, 3, 5, 7, 11
```

    2310

```python
2*3*5*7*11
```

    2310

```python
primorial(2)
```

    6

```python
primorial(3, nth=False) # primes <= 3 are 2 and 3
```

    6

```python
primorial(5, nth=False) # product of 2*3*5
```

    30

```python
primorial(sqrt(100), nth=False)
```

    210

```python
# Adding or subtracting by 1 of a primorial product gives you a prime
factorint(primorial(5) - 1)
```

    {2309: 1}

```python
# here we get two new primes that are factors
factorint(primorial(7) - 1)
```

    {61: 1, 8369: 1}

```python
# Some primes smaller and larger than the primes multiplied together
p = list(primerange(10, 20))
sorted(set(primefactors(Mul(*p) + 1)).difference(set(p)))
```

    [2, 5, 31, 149]

### cycle_length

$cycle_length(f, x0, nmax=None, values=False)$

For a given iterated sequence, return a generator that gives the length of the iterated cycle (lambda) and the length of terms before the cycle begins (mu); if values is True then the terms of the sequence will be returned instead. The sequence is started with value x0.

Note: more than the first lambda + mu terms may be returned and this is the cost of cycle detection with Brent’s method; there are, however, generally less terms calculated than would have been calculated if the proper ending point were determined, e.g. by using Floyd’s method.

```python
from sympy.ntheory.generate import cycle_length # will give succesive values of i <- func(i)

```

```python
def iter(func, i):
    while 1:
        ii = func(1)
        yield ii
        i = ii
# give a seed of 4 and the mu and lambda terms
func = lambda i: (i**2 + 1) % 51
```

```python
next(cycle_length(func, 4))
```

    (6, 2)

```python
n = cycle_length(func, 4, values=True)
list(ni for ni in n)
```

    [17, 35, 2, 5, 26, 14, 44, 50, 2, 5, 26, 14]

### composite(nth)

Return the nth composite number, with the composite numbers indexed as composite(1) = 4, composite(2) = 6, etc….

```python
from sympy import composite
composite(24)
```

    36

```python
composite(1)
```

    4

### compositepi

```python
from sympy import compositepi
compositepi(20)
```

    11

### smoothness_p(n, m=-1, power=0, visual=None)

Return a list of $[m, (p, (M, sm(p + m), psm(p + m)))…]$ where:

1. $p**M$ is the base-p divisor of n
2. $sm(p + m)$ is the smoothness of $p + m (m = -1 by default)$
3. $psm(p + m)$ is the power smoothness of $p + m$

The list is sorted according to smoothness (default) or by power smoothness if power=1.

The smoothness of the numbers to the left (m = -1) or right (m = 1) of a factor govern the results that are obtained from the p +/- 1 type factoring methods.

```python
from sympy.ntheory.factor_ import smoothness_p, factorint
smoothness_p(10345, m=1)
```

    (1, [(5, (1, 3, 3)), (2069, (1, 23, 23))])

```python
smoothness_p(10345)
```

    (-1, [(5, (1, 2, 4)), (2069, (1, 47, 47))])

```python
smoothness_p(10345, power=1)
```

    (-1, [(5, (1, 2, 4)), (2069, (1, 47, 47))])

```python
print(smoothness_p(344556576677878, visual=1))
```

    p**i=2**1 has p-1 B=1, B-pow=1
    p**i=172278288338939**1 has p-1 B=18836462753, B-pow=18836462753

```python
factorint(15*11)
```

    {3: 1, 5: 1, 11: 1}

```python
smoothness_p(_)
```

    'p**i=3**1 has p-1 B=2, B-pow=2\np**i=5**1 has p-1 B=2, B-pow=4\np**i=11**1 has p-1 B=5, B-pow=5'

```python
smoothness_p(_)
```

    {3: 1, 5: 1, 11: 1}

### Table for output logic is like this

#### Visual

| Input         | True        | False       | Other       |
|---------------|-------------|-------------|-------------|
| ``str``      |``str``      |``tuple``    |``str``      |
| ``str``      |``str``      |``tuple``    |``dict``      |
| ``tuple``    |``str``      |``tuple``    |``str``      |
| ``n``        |``str``      |``tuple``    |``tuple``      |
| ``mul``      |``str``      |``tuple``    |``tuple``      |

```python
# training(n)
# Count the number of trailing zero digits in the binary representation of n,
# i.e. determine the largest power of 2 that divides n.
from sympy import trailing
trailing(128)
```

    7

```python
trailing(51)
```

    0

```python
# multiplicity
# Find the greatest integer m such that p**m divides n.
from sympy.ntheory import multiplicity
from sympy.core.numbers import Rational as R
[multiplicity(5, n) for n in [8, 5, 25, 125, 250]]
```

    [0, 1, 2, 3, 3]

```python
multiplicity(3, R(1, 9))
```

    -2

###  sympy.ntheory.factor_.perfect_power

sympy.ntheory.factor_.perfect_power(n, candidates=None, big=True, factor=True)

Return `(b, e)` such that `n == b**e` if n is a perfect power; otherwise return False.

By default, the base is recursively decomposed and the exponents collected so the largest possible e is sought. If big=False then the smallest possible e (thus prime) will be chosen.

If `candidates` for exponents are given, they are assumed to be sorted and the first one that is larger than the computed maximum will signal failure for the routine.

If `factor=True` then simultaneous factorization of n is attempted since finding a factor indicates the only possible root for n. This is True by default since only a few small factors will be tested in the course of searching for the perfect power.

```python
from sympy import perfect_power
perfect_power(16)
```

    (2, 4)

```python
perfect_power(25, big=False)
```

    (5, 2)

### Pollard_rho

Use Pollard’s rho method to try to extract a nontrivial factor of n. The returned factor may be a composite number. If no factor is found, None is returned.

The algorithm generates pseudo-random values of x with a generator function, replacing x with F(x). If F is not supplied then the function x**2 + a is used. The first value supplied to F(x) is s. Upon failure (if retries is > 0) a new a and s will be supplied; the a will be ignored if F was supplied.

The sequence of numbers generated by such functions generally have a a lead-up to some number and then loop around back to that number and begin to repeat the sequence, e.g. 1, 2, 3, 4, 5, 3, 4, 5 – this leader and loop look a bit like the Greek letter rho, and thus the name, ‘rho’.

For a given function, very different leader-loop values can be obtained so it is a good idea to allow for retries:

```python
from sympy.ntheory.generate import cycle_length
n = 14345656
F = lambda x:(2048*pow(x, 2, n) + 32767) % n
for s in range(5):
    print('loop length = %4i; leader length = %3i' % next(cycle_length(F, s)))
```

    loop length =  660; leader length =  19
    loop length =  660; leader length =  19
    loop length =  120; leader length =  26
    loop length =  660; leader length =   6
    loop length =  660; leader length =  17

```python
# An explicit example where there is a two element leadup to a seq of 3 numbers
x = 2
for i in range(9):
    x = (x**2 + 12)%17
    print(x)
```

    16
    13
    11
    14
    4
    11
    14
    4
    11

```python
next(cycle_length(lambda x:(x**2+12)%17, 2))
```

    (3, 2)

```python
list(cycle_length(lambda x: (x**2+12)%17, 2, values=True))
```

    [16, 13, 11, 14, 4]

### Note

Instead of checking the differences of all generated values for a gcd with n, only the $kth$ and $2*kth$ numbers are checked, e.g. 1st and 2nd, 2nd and 4th, 3rd and 6th until it has been detected that the loop has been traversed. Loops may be many thousands of steps long before rho finds a factor or reports failure. If max_steps is specified, the iteration is cancelled with a failure after the specified number of steps.

```python
from sympy import pollard_rho
n = 14345656
F = lambda x:(2048*pow(x,2,n) + 32767) % n
pollard_rho(n, F=F)
```

    8

```python
pollard_rho(n, a=n-2, retries=1)
```

    8

```python

```