# Systems of linear equations
The solution of a system of linear equations $Ax=b$ is obtained as

```python
import numpy as np

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

```python
x = np.linalg.solve(A,b)
print(x)
```

    [ 1. -1.]

To check that this really is a solution, you should always compute the residual, which should vanish (to machine precision):

```python
r = A.dot(x)-b
print(r)
```

    [4.4408921e-16 0.0000000e+00]

The same solution is obtained using the command

```python
x = np.linalg.inv(A).dot(b)
print(x)
```

    [ 1. -1.]

The difference is that this requires computing the inverse of $A$, which is time consuming, whereas `solve(A,b)` applies a fast algorithm for solving linear equations, which does not require the inverse explicitly. This becomes relevant for large systems:

```python
import timeit
print(timeit.timeit(stmt='np.linalg.solve(A,b)',setup='import numpy as np; A = np.random.rand(1000,1000); b = np.random.rand(1000,1)',number=100))
print(timeit.timeit(stmt='np.linalg.inv(A).dot(b)',setup='import numpy as np; A = np.random.rand(1000,1000); b = np.random.rand(1000,1)',number=100))
```

    0.834245866
    2.5875389249999996

You can either use `linalg.inv()` and `linalg.dot()` methods in chain to solve a system of linear equations, or you can simply use the `solve()` method. The `solve()` method is the preferred way.

## Overdetermined systems
A system of linear equations is considered overdetermined if there are more equations than unknowns. For example, we have the overdetermined system

$$x_1 + x_2 =2 \\
x_1 = 1\\
x_2 = 0$$

In practice, we have a system $Ax=b$ where $A$ is a $m$ by $n$ matrix and $b$ is a $m$ dimensional vector, but $m$ is greater than $n$. In this case, the vector $b$ cannot be expressed as a linear combination of the columns of $A$. Hence, we can't find $x$ so that satisfies the problem $Ax=b$ (except in specific cases) but it is possible to determine $x$ so that $Ax$ is as close to $b$ as possible. So we wish to find $x$ which minimizes  $\begin{Vmatrix}Ax-b\end{Vmatrix}$. Considering the [QR decomposition](https://en.wikipedia.org/wiki/QR_decomposition) of $A$, we have that $Ax=b$ becomes $QRx=b$. Multiplying by $Q^T$ we obtain $Q^TQRx=Q^Tb$, and since $Q^T$ is orthogonal (this means that Q^T*Q=I) we have $Rx=Q^Tb$.

Now, this is a well defined system, $R$ is an upper triangular matrix and $Q^T*b$ is a vector. More precisely $b$ is the orthogonal projection of $b$ onto the range of $A$ and $\begin{Vmatrix}Ax-b\end{Vmatrix}=\begin{Vmatrix}Rx-Q^Tb\end{Vmatrix}$.

The function `linalg.lstsq()` provided by numpy returns the least-squares solution to a linear system equation and is able to solve overdetermined systems. Let's compare the solutions of `linalg.lstsq()` with the ones computed using the QR decomposition:

```python
A = np.array([[1, 1], [1, 0],[0, 1]])
b = np.array([2, 1, 0])
x = np.linalg.lstsq(A,b,rcond=None)[0]
print(x)
```

    [1.33333333 0.33333333]

```python
Q,R = np.linalg.qr(A) # qr decomposition of A
Qb = np.dot(Q.T,b) # computing Q^T*b (project b onto the range of A)
x = np.linalg.solve(R,Qb) # solving R*x = Q^T*b
print(x)
```

    [1.33333333 0.33333333]

As we can see, the solutions are the same.

This is the vector for which the norm of the residual $\begin{Vmatrix}r\end{Vmatrix}$ becomes minimal:

```python
r =  A.dot(x)-b
print(r)
np.linalg.norm(r)
```

    [-0.33333333  0.33333333  0.33333333]

    0.5773502691896257

An even simpler example $x=0$, $x=1$. Here the norm of the residual $\begin{Vmatrix}Ax-b\end{Vmatrix}$ is

$$\begin{Vmatrix}r\end{Vmatrix}=\sqrt{x^2+(x-1)^2}$$

and minimising this function (by finding the zero of the derivative) yields $x=1/2$. This is indeed what numpy returns:

```python
A = np.array([[1], [1]]);
b = np.array([0, 1]);
x = np.linalg.lstsq(A,b,rcond=None)[0]
print(x)
```

    [0.5]

## Underdetermined systems
As an example consider
$$x_1 + 2 x_2 + 3 x_3 + 4 x_4  = 1\\
5 x_1 + 6 x_2 + 7 x_3 + 8 x_4 = 2$$
This has an infinite number of solutions. `lstsq` returns one solution:

```python
A = np.array([[1,2,3,4],[5,6,7,8]])
b = np.array([1,2])
x = np.linalg.lstsq(A,b,rcond=None)[0]
print(x)
```

    [-0.05   0.025  0.1    0.175]

Using `scipy.optimize.nnls`, a non-negative least squares solver. Solve $argmin_x \begin{Vmatrix}Ax - b\end{Vmatrix}_2$ for $x\ge0$, returns a solution with as many components as possible equal to zero.

```python
from scipy.optimize import nnls
x, rnorm = nnls(A,b)
print(x)
print(rnorm)
```

    [1.65502277e-16 0.00000000e+00 0.00000000e+00 2.50000000e-01]
    0.0

To find all solutions, we need to determine the kernel of $A$. The nullspace or kernel of a matrix $A$ (denoted $\ker A$) is the set of all vectors $x$, for which $Ax=0$. If $x$ and $y$ are in the nullspace, then $c_1x+c_2y$ is also in the nullspace as

$$A(c_1x+c_2y)=c_1(Ax)+c_2(Ay)=0+0=0$$

The nullspace is a vector space. When $A$ is viewed as a linear transformation, the nullspace is the subspace of $\mathcal{R}^n$ that is sent to 0 under the map $A$, hence the term "fundamental subspace."

An orthonormal basis $N=(n_1,\dots,n_k)$ of the kernel is returned by

```python
from scipy.linalg import null_space, orth
N = null_space(A)
print(N)
```

    [[-0.40008743 -0.37407225]
     [ 0.25463292  0.79697056]
     [ 0.69099646 -0.47172438]
     [-0.54554195  0.04882607]]

The nullspace consists of all vectors $x$ such that $Ax=0$. This defines a system of linear equations that can be solved to give the family of solutions

$$$$

```python
n0=N[:,0]
n1=N[:,1]
assert (abs(A.dot(n0))<=1e-14).all(), "Ax=0 for all x in nullspace {}".format(A.dot(n1))
assert (abs(A.dot(n1))<=1e-14).all(), "Ax=0 for all x in nullspace {}".format(A.dot(n1))
```

which defines a vector space with basis $\{n_0,n_1\}$. As there are two vectors in this basis, the dimension of the nullspace is 2.

```python
# example fundamental subspaces
A = np.array([[1,2,3,3],[2,0,6,2],[3,4,9,7]])
# column/row space (image)
print('column space: {}'.format(orth(A)))
print('row space: {}'.format(orth(A.T)))
# nullspace/ left nullspace (kernel)
print('nullspace: {}'.format(null_space(A)))
print('left nullspace: {}'.format(null_space(A.T)))
assert (abs(A.dot(null_space(A)))<=1e-14).all(), "Ax=0 for all x in nullspace {}".format(A.dot(null_space(A)))
# Fundamental Theorem of Linear Algebra: rank-nullity theorem (relates the dimensions of the four fundamental subspaces)
assert np.linalg.matrix_rank(A)==len(orth(A)[0])==len(orth(A.T)[0]), "The column and row spaces of an m×n matrix A both have dimension r, the rank of the matrix."
assert A.shape[1]-np.linalg.matrix_rank(A)==len(null_space(A)[1]), "The nullspace has dimension n−r."
assert A.shape[0]-np.linalg.matrix_rank(A.T)==len(null_space(A.T)[1]), "The left nullspace has dimension m−r."
# Fundamental Theorem of Linear Algebra: orthogonal spaces (the dot product v⋅w is 0)
assert (abs(null_space(A).T.dot(orth(A.T)))<=1e-14).all(), "The nullspace and row space are orthogonal."
assert (abs(null_space(A.T).T.dot(orth(A)))<=1e-14).all(), "The left nullspace and the column space are also orthogonal."
# Fundamental Theorem of Linear Algebra:  orthonormal basis (singular value decomposition)
[U,s,V]=np.linalg.svd(A)
S = np.zeros(A.shape, dtype=complex)
S[:(A.shape[0]), :(A.shape[0])] = np.diag(s)
assert np.allclose(A, np.dot(U, np.dot(S, V))), "any matrix M can be written as dot product of an m x m unitary martix, an m x n matrix with nonnegative values in diagonal, and an b x n unitary matrix."
```

    column space: [[-0.31994238 -0.36841839]
     [-0.41936816  0.88119879]
     [-0.84956884 -0.29623738]]
    row space: [[-0.25358142  0.17588223]
     [-0.27620609 -0.66896915]
     [-0.76074427  0.52764668]
     [-0.52978752 -0.49308692]]
    nullspace: [[-0.94415867 -0.11543961]
     [ 0.03383545 -0.68923555]
     [ 0.32599804 -0.19126531]
     [-0.03383545  0.68923555]]
    left nullspace: [[ 0.87287156]
     [ 0.21821789]
     [-0.43643578]]

Using sympy to solve the equation set symbolically

```python
from sympy import *

x_1, x_2, x_3, x_4 = symbols('x_1 x_2 x_3 x_4')

res = solve([Eq(1*x_1+2*x_2+3*x_3+4*x_4, 1),
             Eq(5*x_1+6*x_2+7*x_3+8*x_4, 2)],
             [x_1, x_2, x_3, x_4])
print(res)
```

    {x_1: x_3 + 2*x_4 - 1/2, x_2: -2*x_3 - 3*x_4 + 3/4}

```python

```

notebooks/Systems of linear equations.ipynb


# Introduction to Matrices
In general terms, a matrix is an array of numbers that are arranged into rows and columns.

## Matrices and Matrix Notation
A matrix arranges numbers into rows and columns, like this:

\begin{equation}A = \begin{bmatrix}
  1 & 2 & 3 \\
  4 & 5 & 6
 \end{bmatrix}
\end{equation}

Note that matrices are generally named as a capital letter. We refer to the *elements* of the matrix using the lower case equivalent with a subscript row and column indicator, like this:

\begin{equation}A = \begin{bmatrix}
  a_{1,1} & a_{1,2} & a_{1,3} \\
  a_{2,1} & a_{2,2} & a_{2,3}
 \end{bmatrix}
\end{equation}

In Python, you can define a matrix as a 2-dimensional *numpy.**array***, like this:

```python
import numpy as np

A = np.array([[1,2,3],
              [4,5,6]])
print (A)
```

You can also use the *numpy.**matrix*** type, which is a specialist subclass of ***array***:

```python
import numpy as np

M = np.matrix([[1,2,3],
               [4,5,6]])
print (M)
```

There are some differences in behavior between ***array*** and ***matrix*** types - particularly with regards to multiplication (which we'll explore later). You can use either, but most experienced Python programmers who need to work with both vectors and matrices tend to prefer the ***array*** type for consistency.

## Matrix Operations
Matrices support common arithmetic operations.

### Adding Matrices
To add two matrices of the same size together, just add the corresponding elements in each matrix:

\begin{equation}\begin{bmatrix}1 & 2 & 3 \\4 & 5 & 6\end{bmatrix}+ \begin{bmatrix}6 & 5 & 4 \\3 & 2 & 1\end{bmatrix} = \begin{bmatrix}7 & 7 & 7 \\7 & 7 & 7\end{bmatrix}\end{equation}

In this example, we're adding two matrices (let's call them ***A*** and ***B***). Each matrix has two rows of three columns (so we describe them as 2x3 matrices). Adding these will create a new matrix of the same dimensions with the values a1,1 + b1,1, a1,2 + b1,2, a1,3 + b1,3,a2,1 + b2,1, a2,2 + b2,2, and a2,3 + b2,3. In this instance, each pair of corresponding elements(1 and 6, 2, and 5, 3 and 4, etc.) adds up to 7.

Let's try that with Python:

```python
import numpy as np

A = np.array([[1,2,3],
              [4,5,6]])
B = np.array([[6,5,4],
              [3,2,1]])
print(A + B)
```

### Subtracting Matrices
Matrix subtraction works similarly to matrix addition:

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

Here's the Python code to do this:

```python
import numpy as np

A = np.array([[1,2,3],
              [4,5,6]])
B = np.array([[6,5,4],
              [3,2,1]])
print (A - B)
```

#### Conformability
In the previous examples, we were able to add and subtract the matrices, because the *operands* (the matrices we are operating on) are ***conformable*** for the specific operation (in this case, addition or subtraction). To be conformable for addition and subtraction, the operands must have the same number of rows and columns. There are different conformability requirements for other operations, such as multiplication; which we'll explore later.

### Negative Matrices
The nagative of a matrix, is just a matrix with the sign of each element reversed:

\begin{equation}C = \begin{bmatrix}-5 & -3 & -1 \\1 & 3 & 5\end{bmatrix}\end{equation}

\begin{equation}-C = \begin{bmatrix}5 & 3 & 1 \\-1 & -3 & -5\end{bmatrix}\end{equation}

Let's see that with Python:

```python
import numpy as np

C = np.array([[-5,-3,-1],
              [1,3,5]])
print (C)
print (-C)
```

### Matrix Transposition
You can *transpose* a matrix, that is switch the orientation of its rows and columns. You indicate this with a superscript **T**, like this:

\begin{equation}\begin{bmatrix}1 & 2 & 3 \\4 & 5 & 6\end{bmatrix}^{T} = \begin{bmatrix}1 & 4\\2 & 5\\3 & 6 \end{bmatrix}\end{equation}

In Python, both *numpy.**array*** and *numpy.**matrix*** have a **T** function:

```python
import numpy as np

A = np.array([[1,2,3],
              [4,5,6]])
print(A.T)
```

Vector and Matrices by Hiren/03-03-Matrices.ipynb


```python
%matplotlib inline
```

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

## Generalizing the Plotting Function
Let's now use the power of Python to generalize the code we created to plot. In Python, you can pass functions as parameters to other functions. We'll utilize this to pass the math function that we're going to plot.

Note: We can also pass *lambda expressions* (anonymous functions) like this:
```python
lambda x: x + 2```
This is a shorter way to write
```python
def some_anonymous_function(x):
    return x + 2
```

We'll also need a range of x values. We may also provide other optional parameters which will help set up our plot. These may include titles, legends, colors, fonts, etc. Let's stick to the basics now.

Write a Python function which takes another function, x range and number of points, and plots the function graph by evaluating it at every point.

**BIG hint:** If you want to use not only `numpy` functions for `f` but any one function, a very useful (and easy) thing to do, is to vectorize the function `f` (e.g. to allow it to be used with `numpy` broadcasting):
```python
f_vectorized = np.vectorize(f)
y = f_vectorized(x)
```

```python
def plot_math_function(f, min_x, max_x, num_points):
    f_vectorized = np.vectorize(f)
    x = np.linspace(min_x, max_x, num_points)
    y = f_vectorized(x)
    plt.plot(x, y)
    plt.show()
```

```python
plot_math_function(lambda x: 2 * x + 3, -3, 5, 1000)
plot_math_function(lambda x: -x + 8, -1, 10, 1000)
plot_math_function(lambda x: x**2 - x - 2, -3, 4, 1000)
plot_math_function(lambda x: np.sin(x), -np.pi, np.pi, 1000)
plot_math_function(lambda x: np.sin(x) / x, -4 * np.pi, 4 * np.pi, 1000)
```

## Solving Equations Graphically
Now that we have a general plotting function, we can use it for more interesting things. Sometimes we don't need to know what the exact solution is, just to see where it lies. We can do this by plotting the two functions around the "=" sign ans seeing where they intersect. Take, for example, the equation $2x + 3 = 0$. The two functions are $f(x) = 2x + 3$ and $g(x) = 0$. Since they should be equal, the point of their intersection is the solution of the given equation. We don't need to bother marking the point of intersection right now, just showing the functions.

To do this, we'll need to improve our plotting function yet once. This time we'll need to take multiple functions and plot them all on the same graph. Note that we still need to provide the $[x_{min}; x_{max}]$ range and it's going to be the same for all functions.

```python
vectorized_fs = [np.vectorize(f) for f in functions]
ys = [vectorized_f(x) for vectorized_f in vectorized_fs]
```

```python
def plot_math_functions(functions, min_x, max_x, num_points):
    fs_vectorized = [np.vectorize(f) for f in functions]
    x = np.linspace(min_x, max_x, num_points)
    ys = [vectorized_f(x) for vectorized_f in fs_vectorized]

    for y in ys:
        plt.plot(x, y)
    plt.show()
```

```python
plot_math_functions([lambda x: 2 * x + 3, lambda x: 0], -3, 5, 1000)
plot_math_functions([lambda x: 3 * x**2 - 2 * x + 5, lambda x: 3 * x + 7], -2, 3, 1000)
```

This is also a way to plot the solutions of systems of equation, like the one we solved last time. Let's actually try it.

```python
plot_math_functions([lambda x: (-4 * x + 7) / 3, lambda x: (-3 * x + 8) / 5, lambda x: (-x - 1) / -2], -1, 4, 1000)
```


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

__Exponential Growth Equation__

$
\begin{align}
\frac{\partial N}{\partial t} = N(t) * r
\end{align}
$

Let r = 1.2 and N(0) = 10

```python
frames    = 20
timeSerie = np.linspace(1, frames, frames, endpoint=True)
```

```python
pop_exp_120 = [10]
pop_exp_125 = [10]
pop_exp_130 = [10]

for i in range(frames):
    pop_exp_120.append((pop_exp_120[-1] * (1 + 0.20)))
    pop_exp_125.append((pop_exp_125[-1] * (1 + 0.25)))
    pop_exp_130.append((pop_exp_130[-1] * (1 + 0.30)))
```

```python
fig = plt.figure('Exponential Plot', figsize=(14, 6))
ax  = fig.add_subplot(111)
plt.plot(timeSerie ,pop_exp_120[:-1], label='1.20')
plt.plot(timeSerie ,pop_exp_125[:-1], label='1.25')
plt.plot(timeSerie ,pop_exp_130[:-1], label='1.30')
ax.annotate(round(pop_exp_130[-2], 0) , xy=(timeSerie[-1], pop_exp_130[-2]), textcoords='data')
ax.annotate(round(pop_exp_125[-2], 0) , xy=(timeSerie[-1], pop_exp_125[-2]), textcoords='data')
ax.annotate(round(pop_exp_120[-2], 0) , xy=(timeSerie[-1], pop_exp_120[-2]), textcoords='data')
plt.legend(loc='best')
plt.show()
```

__Logistic Equation__

$
\begin{align}
\frac{\partial N}{\partial t} = N(t) * r * [ (K - N(t)) / K ]
\end{align}
$

Let r = 1.2, N(0) = 10 and K = 450

```python
pop_log = []

K = 80

pop_log_120 = [10]
pop_log_125 = [10]
pop_log_130 = [10]

for i in range(frames):
    pop_log_120.append((pop_log_120[-1] * (1 + (0.20 * (K - pop_log_120[-1])/K)) ))
    pop_log_125.append((pop_log_125[-1] * (1 + (0.25 * (K - pop_log_125[-1])/K)) ))
    pop_log_130.append((pop_log_130[-1] * (1 + (0.30 * (K - pop_log_130[-1])/K)) ))
```

```python
fig = plt.figure('Exponential Plot', figsize=(14, 6))
ax  = fig.add_subplot(111)
plt.plot(timeSerie ,pop_log_120[:-1], label='1.20')
plt.plot(timeSerie ,pop_log_125[:-1], label='1.25')
plt.plot(timeSerie ,pop_log_130[:-1], label='1.30')
ax.annotate(round(pop_log_130[-2], 0) , xy=(timeSerie[-1], pop_log_130[-2]), textcoords='data')
ax.annotate(round(pop_log_125[-2], 0) , xy=(timeSerie[-1], pop_log_125[-2]), textcoords='data')
ax.annotate(round(pop_log_120[-2], 0) , xy=(timeSerie[-1], pop_log_120[-2]), textcoords='data')
plt.legend(loc='best')
plt.show()
```


# SYMBOLIC PROGRAMMING

**DONE BY:

Thejaswin.S

RA1911026010029

CSE-AIML K1**

**1. Solve the following using symbolic paradigm:

i. Calculate sqrt (2) with 100 decimals.**

```python
from sympy import sqrt
print(sqrt(2).evalf(100))
```

    1.414213562373095048801688724209698078569671875376948073176679737990732478462107038850387534327641573

**ii. Calculate (1/2+1/3) in rational arithmetic.**

```python
import sympy as sym
a0029 = sym.Rational(1, 2)
b0029 = sym.Rational(1, 3)
a0029+b0029
```

$\displaystyle \frac{5}{6}$

**iii. Calculate the expanded form of (x+y) ^ 6.**

```python
import sympy as sym
x0029 = sym.Symbol('x')
y0029 = sym.Symbol('y')
sym.expand((x0029+y0029)**6)
```

$\displaystyle x^{6} + 6 x^{5} y + 15 x^{4} y^{2} + 20 x^{3} y^{3} + 15 x^{2} y^{4} + 6 x y^{5} + y^{6}$

**iv. Simplify the trigonometric expression sin (x) / cos (x)**

```python
import sympy as sym
x0029=sym.Symbol('x')
sym.simplify(sym.sin(x0029)/sym.cos(x0029))
```

$\displaystyle \tan{\left(x \right)}$

**v. Calculate sin x -xx^3n**

```python
import sympy as sym
x0029=sym.Symbol('x')
n0029=sym.Symbol('n')
sym.solveset(sym.sin(x0029)-x0029*x0029**3*n0029,x0029)
```

$\displaystyle \left\{x \mid x \in \mathbb{C} \wedge - n x^{4} + \sin{\left(x \right)} = 0 \right\}$

```python
import sympy
from sympy import *
x0029,n0029=symbols('x n')
expr0029=sin(x0029)-x0029*x0029**3*n0029
smpl0029=trigsimp(expr0029)
smpl0029
```

$\displaystyle - n x^{4} + \sin{\left(x \right)}$

**2. Develop a python code for to carryout the operations on the given algebraic manipulation for the given expression a2−ab+ab−b2=a2−b2  by using the symbolic programming paradigms principles.**

```python
from sympy import *
a,b=symbols('a b')
g10029=simplify(a**2-ab+ab-b**2)
g10029
```

$\displaystyle a^{2} - b^{2}$

**3. Give the Symbolic program for the expression given below:

a. ∬a2 da**

```python
a0029=symbols('a')
g0029=integrate('a**2',a)
j0029=integrate(g0029,a0029)
print("first integration "+str(g0029))
print("second integration "+str(j0029))
```

    first integration a**3/3
    second integration a**4/12

**b. 2x+y2**

```python
x=Symbol('x')
y=Symbol('y')
2*x+y**2
```

$\displaystyle 2 x + y^{2}$

**c. 1/10 + 1/5**

```python
a0029=Rational(1,10)
b0029=Rational(1,5)
a0029+b0029
```

$\displaystyle \frac{3}{10}$

**d. d/dx(sin(x))**

```python
x0029=symbols('x')
g0029=sin(x0029)
j0029=diff(g0029,x0029)
print("Before differentiation "+str(g0029))
print("After differentiation "+str(j0029))
```

    Before differentiation sin(x)
    After differentiation cos(x)

# Functional Programming

**1.	Calculate the following using Lambda calculus:

a.	T AND F**

```python
y0029=lambda s0029: s0029 and False
print("T AND F = ",y0029(True))
```

    T AND F =  False

**b. 3 * 4**

```python
x0029 = lambda a0029,b0029 : a0029*b0029
print("3*4 =",x0029(3,4))
```

    3*4 = 12

**2.	Lambda functions

a.	Write a lambda function to convert measurements from meters to feet.**

```python
x0029=lambda y0029: y0029*3.28084
print("feet : {:.6f}".format(x0029(float(input("enter in metres: ")))))
```

    enter in metres: 12.5
    feet : 41.010500

**b. Write a lambda function in Python to implement the following lambda expression:

         (𝜆𝑓. 𝜆𝑚. (𝑓 + 𝑚)𝑎)(𝜆𝑥. 𝑥2)(𝑏)

Note: You need to write a nested lambda function for implementing f+m where f takes the

square function (which takes argument x) passed as a parameter. The above expression

calculates a^2+b.**

```python
square0029=lambda x: x**2
total0029=lambda f, b: lambda a: f(a)+b
a0029=int(input("Enter value for a: "))
b0029=int(input("Enter value for b: "))
print(total0029(square0029,b0029)(a0029))
```

    Enter value for a: 10
    Enter value for b: 12
    112

**3. Passing and returning a function as an argument

Define a function ‘square’ for squaring a number. Define a function named ‘twice’ that takes a

function f as an argument and returns f(f(x)). Using ‘twice’ and ‘square’ create a function ‘quad’ that

takes n as an argument and returns n 4 . ‘quad’ should not be defined explicitly. It should only be

created as a variable which is then assigned a function.**

```python
def square0029(n0029):
    return n0029**2

def twice0029(f0029):
    return square0029(f0029)

n0029 = int(input("Enter a number: "))
quad0029 = twice0029(square0029(n0029))
print(quad0029)
```

    Enter a number: 10
    10000

**4. Closure

A Closure is a function object that remembers values in enclosing scopes even if they are not present

in memory. We have a closure in Python when a nested function references a value in its enclosing

scope.

a. Study the following program by executing it:**

```python
def multiplier_of(n0029):
    def multiplier(number0029):
        return number0029*n0029
    return multiplier

multiplywith5 = multiplier_of(5)
print(multiplywith5(9))
```

    45

**b. In a lottery system, random number is chosen by retrieving the number from a

random index from a list of random numbers. Write a program to choose a random

number in this way. You must use nested functions – the inner function chooses a

number from a random index and the outer function generates a random list of

numbers. The outer function takes n as a parameter where is the maximum number

that can be put in the random list. (Your code should be similar to the program in

5a)**

```python
import random
def outer_func(n0029):
    list_0029 = random.sample(range(0,n0029+1), n0029)
    def inner_func():
        index = random.randrange(0, n0029)
        return list_0029[index]
    return inner_func

n0029 = int(input("Enter a random number: "))
ans0029 = outer_func(n0029)
print("Random number generated is: {}".format(ans0029()))
```

    Enter a random number: 10
    Random number generated is: 6

**6. Map

A secret message needs to be sent. Use the map function to encrypt the message using Caesar cipher.**

```python
def encrypt(letter_0029, s_0029):
    if (letter_0029.isupper()):
        return chr((ord(letter_0029) + s_0029 - 65) % 26 + 65)
    elif (letter_0029.islower()):
        return chr((ord(letter_0029) + s_0029 - 97) % 26 + 97)
    else:
        return letter_0029

text_0029 = input("Enter the word: ")
s_0029 = int(input("Enter the shift: "))
shift_0029 = [s_0029] * len(text_0029)
result_0029 = list(map(encrypt, text_0029, shift_0029))
#result_0029
print("Encrypted text: ", end="")
for i in result_0029:
    print(i, end="")
```

    Enter the word: hello
    Enter the shift: 2
    Encrypted text: jgnnq

**7. Reduce

Given runs scored by 2 players in a series of matches, write a Python program using reduce function

to find who is the better player of the two in terms of maintaining consistency. (You need to find

SD).**

```python
import functools,operator

pl1_0029 = [51, 50, 58, 55, 53, 60]
pl2_0029 = [98, 42, 88, 74, 88, 12]

mean1_0029 = (functools.reduce(operator.add, pl1_0029)) / len(pl1_0029)
mean2_0029 = (functools.reduce(operator.add, pl2_0029)) / len(pl2_0029)

m1_0029 = [mean1_0029] * len(pl1_0029)
m2_0029 = [mean2_0029] * len(pl2_0029)

def variance_0029(p_0029, m_0029):
    var_0029 = p_0029 - m_0029
    return var_0029**2

sqsum1_0029 = list(map(variance_0029, pl1_0029, m1_0029))
sqsum2_0029 = list(map(variance_0029, pl2_0029, m2_0029))

var1_0029 = (functools.reduce(operator.add, sqsum1_0029)) / len(sqsum1_0029)
var2_0029 = (functools.reduce(operator.add, sqsum2_0029)) / len(sqsum2_0029)

sd1_0029 = var1_0029**0.5
sd2_0029 = var2_0029**0.5

if sd1_0029==sd2_0029:
    print("Both players are consistent.")
elif sd1_0029>sd2_0029:
    print("Player 2 is more consistent.")
else:
    print("Player 1 is more consistent.")
```

    Player 1 is more consistent.

**8. Filter

The marks scored by a class of students in 5 different subjects are stored in a list of lists. Using the

filter function, write a program to find the students who failed in one or more subjects.**

```python
a0029=lambda x0029:x0029<35
marks0029=[[10,90,80,75,32],[23,67,78,87,90],[90,90,90,90,90],[39, 1, 100, 56, 72]]
l=len(marks0029)
for i in range(l):
    red=filter(a0029,marks0029[i])
    if len(list(red))>=1:
        print ("student '"+str(i+1)+"' failed in atleast 1 subject")
```

    student '1' failed in atleast 1 subject
    student '2' failed in atleast 1 subject
    student '4' failed in atleast 1 subject

**9. Map+reduce+filter

Given two trending topics and a bunch of tweets, write a Python program to count the number of

tweets that contain each topic. You need to do this by putting together map(), reduce() and filter()

functions.**

```python
import functools,operator
tweets0029 = ["Is this the final ipl of msd #IPL #chennaisuperkings",
            "Most 10-wicket wins in IPL: 4 - RCB, 2 - MI, 2 - SRH, 2 - CSK #RCBvsRR #CSK #RR #CricTracker",
            "My @chennaisuperkings outfit has arrived! Now to be well enough towear it....Thanks @MSD & the #CSK management! All the best for the rest of the #IPL!",
             "Mental health of #Students deteriorating",
             "#Students suffering inside home and inside themselves",
              "#Students not able to cope up with studies during COVID"]

csk_0029 = ["#csk"] * len(tweets0029)
students_0029 = ["students"] * len(tweets0029)

def segregate_tweets(str0029, topic0029):
    if topic0029 in str0029.lower():
        return str0029
    return ""

result_csk = list(map(segregate_tweets, tweets0029, csk_0029))
result_stud = list(map(segregate_tweets, tweets0029, students_0029))
print(result_csk)
print(result_stud)
print()
def filter_list(str0029):
    return str0029!=""

rescsk_0029 = list(filter(filter_list, result_csk))
resstud_0029 = list(filter(filter_list, result_stud))

print("No. of csk tweets: {}".format(len(rescsk_0029)))
print("No. of students tweets: {}".format(len(resstud_0029)))
```

    ['', 'Most 10-wicket wins in IPL: 4 - RCB, 2 - MI, 2 - SRH, 2 - CSK #RCBvsRR #CSK #RR #CricTracker', 'My @chennaisuperkings outfit has arrived! Now to be well enough towear it....Thanks @MSD & the #CSK management! All the best for the rest of the #IPL!', '', '', '']
    ['', '', '', 'Mental health of #Students deteriorating', '#Students suffering inside home and inside themselves', '#Students not able to cope up with studies during COVID']

    No. of csk tweets: 2
    No. of students tweets: 3

```python

```


# Modeling and Simulation in Python

Chapter 4: Predict

Copyright 2017 Allen Downey

License: [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0)

### Analysis with SymPy

```python
from sympy import *
```

The following line sets up Jupyter notebook to display math.

```python
init_printing()
```

And this function provides the option of showing results in LaTeX format.

```python
from sympy.printing import latex

def show(expr, show_latex=False):
    if show_latex:
        print(latex(expr))
    return expr
```

Create a symbol for time.

```python
t = symbols('t')
show(t)
```

If you combine symbols and numbers, you get symbolic expressions.

```python
expr = t + 1
show(expr)
```

The result is an Add object, which just represents the sum without trying to compute it.

```python
type(expr)
```

    sympy.core.add.Add

`subs` can be used to replace a symbol with a number, which allows the addition to proceed.

```python
expr.subs(t, 2)
```

`f` is a special class of symbol that represents a function.

```python
f = Function('f')
show(f)
```

    f

The type of `f` is `UndefinedFunction`

```python
type(f)
```

    sympy.core.function.UndefinedFunction

Now SymPy understands that `f(t)` means `f` evaluated at `t`, but it doesn't try to evaluate it yet.

```python
f(t)
```

`diff` returns a `Derivative` object that represents the time derivative of `f`

```python
dfdt = diff(f(t), t)
show(dfdt)
```

```python
type(dfdt)
```

    sympy.core.function.Derivative

We need a symbol for `alpha`

```python
alpha = symbols('alpha')
show(alpha)
```

Now we can write the differential equation for proportional growth.

```python
eq1 = Eq(dfdt, alpha*f(t))
show(eq1)
```

And use `dsolve` to solve it.  The result is the general solution.

```python
solution_eq = dsolve(eq1)
show(solution_eq)
```

In this case, finding the particular solution is easy: we just replace `C1` with `p0`

```python
C1, p0 = symbols('C1 p0')
```

```python
particular = solution_eq.subs(C1, p0)
show(particular)
```

In the next example, we'll have to work a little harder to find the particular solution.

### Solving the quadratic growth equation

We'll use the (r, K) parameterization, so we'll need two more symbols:

```python
r, K = symbols('r K')
```

Now we can write the differential equation.

```python
eq2 = Eq(diff(f(t), t), r*f(t) * (1 - f(t)/K))
show(eq2)
```

And solve it.

```python
solution_eq = dsolve(eq2)
show(solution_eq)
```

`rhs` selects the right-hand side of the solution.

```python
general = solution_eq.rhs
show(general)
```

We can evaluate the right-hand side at $t=0$

```python
at0 = general.subs(t, 0)
show(at0)
```

Now we want to find the value of `C1` that makes `f(0) = p0`.

So we'll create the equation `at0 = p0` and solve for `C1`.  Because this is just an algebraic identity, not a differential equation, we use `solve`, not `dsolve`.

The result from `solve` is a sequence of solutions.  In this case, [we have reason to expect only one solution](https://en.wikipedia.org/wiki/Picard%E2%80%93Lindel%C3%B6f_theorem), but we still get a sequence, so we have to use the bracket operator, `[0]`, to select the first one.

```python
solutions = solve(Eq(at0, p0), C1)
type(solutions), len(solutions)
```

    (list, 1)

```python
value_of_C1 = solutions[0]
show(value_of_C1)
```

Now in the general solution, we want to replace `C1` with the value of `C1` we just figured out.

```python
particular = general.subs(C1, value_of_C1)
particular
```

The result is complicated, but SymPy provides a method that tries to simplify it.

```python
particular = simplify(particular)
show(particular)
```

Often simplicity is in the eye of the beholder, but that's about as simple as this expression gets.

Just to double-check, we can evaluate it at `t=0` and confirm that we get `p0`

```python
particular.subs(t, 0)
```

[In some places](https://en.wikipedia.org/wiki/Population_growth#Logistic_equation) you'll see this solution, which is called the "logistic function" written in a different form:

$f(t) = \frac{K}{1 + A e^{-rt}}$

where $A = (K - p_0) / p_0$.

We can use SymPy to confirm that these two forms are equivalent.  First we represent the alternative version of the logistic function:

```python
A = (K - p0) / p0
show(A)
```

```python
logistic = K / (1 + A * exp(-r*t))
show(logistic)
```

To see whether two expressions are equivalent, we can check whether their difference simplifies to 0.

```python
simplify(particular - logistic)
```

This test only works one way: if SymPy says the difference reduces to 0, the expressions are definitely equivalent (and not just numerically close).

But if SymPy can't find a way to simplify the result to 0, that doesn't necessarily mean there isn't one.  Testing whether two expressions is a surprisingly hard problem; in fact, there is no algorithm that can solve it in general.

**Exercise:** Solve the quadratic growth equation using the alternative parameterization

$\frac{df(t)}{dt} = \alpha f(t) + \beta f^2(t) $

```python
# Solution

alpha, beta = symbols('alpha beta')
```

```python
# Solution

eq3 = Eq(diff(f(t), t), alpha*f(t) + beta*f(t)**2)
eq3
```

```python
# Solution

solution_eq = dsolve(eq3)
solution_eq
```

```python
# Solution

general = solution_eq.rhs
general
```

```python
# Solution

at0 = general.subs(t, 0)
at0
```

```python
# Solution

solutions = solve(Eq(at0, p0), C1)
value_of_C1 = solutions[0]
value_of_C1
```

```python
# Solution

particular = general.subs(C1, value_of_C1)
particular.simplify()
```

**Exercise:**  Use [WolframAlpha](https://www.wolframalpha.com/) to solve the quadratic growth model, using either or both forms of parameterization:

    df(t) / dt = alpha f(t) + beta f(t)^2

or

    df(t) / dt = alpha f(t) (1 - f(t)/K)

Find the general solution and also the particular solution where `f(0) = p0`.

```python

```

```python

```


# Pearson Correlation Vs. Z-normalized Euclidean Distance

It is [well understood](https://arxiv.org/pdf/1601.02213.pdf) that the z-normalized Euclidean distance, $ED_{z-norm}$, and the Pearson correlation, $PC$, between any two subsequences with length $m$ share the following relationship:

$ED_{z-norm} = \sqrt {2 * m * (1 - PC)}$

Naturally, when the two subsequences are perfectly correlated (i.e., $PC = 1$), then we get:

\begin{align}
    ED_{z-norm} ={}&
        \sqrt {2 * m * (1 - PC)}
    \\
    ={}&
        \sqrt {2 * m * (1 - 1)}
    \\
    ={}&
        \sqrt {2 * m * 0}
    \\
    ={}&
        \sqrt {0}
    \\
    ={}&
        0
    \\
\end{align}

Similarly, when the two subsequences are completely uncorrelated (i.e., $PC = 0$), then we get:

\begin{align}
    ED_{z-norm} ={}&
        \sqrt {2 * m * (1 - PC)}
    \\
    ={}&
        \sqrt {2 * m * (1 - 0)}
    \\
    ={}&
        \sqrt {2 * m * 1}
    \\
    ={}&
        \sqrt {2 * m}
    \\
\end{align}

In other words, the largest possible z-normalized distance between any pair of subsequences with length $m$ is $\sqrt{2 * m}$. The maximum distance can never be bigger!

Finally, when two subsequences are anti-correlated (i.e., $PC = -1$), then we get:

\begin{align}
    ED_{z-norm} ={}&
        \sqrt {2 * m * (1 - PC)}
    \\
    ={}&
        \sqrt {2 * m * (1 - (-1))}
    \\
    ={}&
        \sqrt {2 * m * 2}
    \\
    ={}&
        \sqrt {4 * m}
    \\
    ={}&
        2 * \sqrt {m}
    \\
\end{align}

Note that while $2 * \sqrt {m}$ (i.e., anti-correlated) is obviously larger than $\sqrt {2m}$ (i.e., uncorrelated), it is basically impossible for a matrix profile distance to be "worse" than uncorrelated (i.e., larger than $\sqrt {2m}$) due to the fact that it is defined as the distance to its one-nearest-neighbor. For example, given a subsequence `T[i : i + m]`, the matrix profile is supposed to return the z-norm distance to its one-nearest-neighbor. So, even if there existed another z-norm subsequence, `T[j : j + m]`, along the time series that was perfectly anti-correlated with `T[i : i + m]`, then any subsequence that is even slightly shifted away from location `j` must have a smaller distance than $2 * \sqrt {m}$. Therefore, a perfectly anti-correlated subsequence would/could (almost?) never be a one-nearest-neighbor to `T[i : i + m]` especially if `T` is long.

```python

```


# The NumPy random package

## Overview
1. Introduction to Numpy.Random
2. Simple Random Data
    - Rand()
    - Randn()
    - Random_sample()
    - Choice()
    - Bytes()
3. Permutations
    - shuffle()
    - permutation()
4. Distributions
    - Uniform()
    - Normal()
    - Binomial()
    - Poisson()
5. Random Generator
6. References

## Introduction to Numpy.random
**Numpy[1]** is a package in the Python Language.  It provides a high-performance multidimensional array object, and tools for working with these arrays. It contains useful linear algebra, Fourier transform, and random number capabilities.
Numpy.random is a sub-package of the Numpy package, and it basically churns out (pseudo) random numbers, using various tools and functions. It takes advantage of the **Mersenne Twister[2]** , which is the most used pseudorandom number generator (PRNG), and is already embedded in Python's library. The functions in the numpy.random sub-package can be divided into 4 categories, which are Simple random data (getting random numbers), permutations, distributions and seeding.

## Simple Random Data

### Rand()

**Numpy.random.rand()[3]** is a function when called (with no argument), will return a random number in the **half-open interval[4]** between 0 (inclusive) and 1 (exclusive). It will also create an array of a given shape and populates it with random samples if parameters are passed through. This function uses the uniform distribution (see numpy.random.uniform).

Parameters:
d0, d1, …, dn : int, optional

The dimensions of the returned array, should all be positive. If no argument is given a single Python float is returned.

Returns:
out : ndarray, shape (d0, d1, ..., dn)

Random values.

```python
import numpy as np
np.random.rand() #produces a random float number between 0 and 1
```

    0.0923385947687978

```python
np.random.rand(2,2) #produces a 2d array with 2 rows and 2 columns
```

    array([[0.18626021, 0.34556073],
           [0.39676747, 0.53881673]])

```python
np.random.rand(3,2,2) #produces a 3d array with 2 rows and 2 columns, and a depth of 3
```

    array([[[0.41919451, 0.6852195 ],
            [0.20445225, 0.87811744]],

           [[0.02738759, 0.67046751],
            [0.4173048 , 0.55868983]],

           [[0.14038694, 0.19810149],
            [0.80074457, 0.96826158]]])

To prove that this function uses the uniform distribution, we will generate an array with 10000 values and plot it on a histogram with **matplotlib.pyplot[5]**, a useful tool for plotting data in graph form. In the graph below, we can see that the values that an almost equal number of values are generated per column.

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

x = np.random.rand(10000) #generate an array with 10000 values
plt.hist(x,color='g') #plot out a histogram of the array
```

### Randn()

**Numpy.random.randn()[6]** is a function that returns a sample (or samples) from the “standard normal” distribution. Returns a random number from using the univariate “normal” (Gaussian) distribution of mean 0 and variance 1.

The normal curve (also known as the bell curve) has its symmetry about the centre. Half of the values generated will be less than the mean, and the other 50% of the values will be greater than the mean.

Parameters:
d0, d1, …, dn : int, optional

The dimensions of the returned array, should be all positive. If no argument is given a single Python float is returned.

Returns:
Z : ndarray or float

A (d0, d1, ..., dn)-shaped array of floating-point samples from the standard normal distribution, or a single such float if no parameters were supplied.

```python
np.random.randn() #returns sample with one number
```

    -0.2959569528837944

```python
np.random.randn(2,2) #returns 2 by 2 array sample
```

    array([[ 0.65228253, -0.16824741],
           [-1.3086617 , -0.82948031]])

For random samples from N(mu, sigma^2), use:

sigma * np.random.randn(...) + mu

Two-by-two array of samples from N(2, 6.25):

```python
2.5 * np.random.randn(2, 2) + 2  #returns 2 by 2 array sample N(2, 6.25)
```

    array([[ 6.33697059,  5.7842766 ],
           [ 1.69177013, -1.772594  ]])

Sample of 10000 values using this function will display a bell curve:

```python
x = np.random.randn(10000) #generate an array with 10000 values
plt.hist(x,color='r')
plt.show()
```

Further explanation of the normal distribution explained under numpy.random.normal()

### Randint()

**numpy.random.randint()[7]** is a function that returns random integers from low (inclusive) to high (exclusive). It uses the discrete uniform distribution, similar to the numpy.random.rand function.

Parameters:
low : int

Lowest (signed) integer to be drawn from the distribution (unless high=None, in which case this parameter is one above the highest such integer).

high : int, optional

If provided, one above the largest (signed) integer to be drawn from the distribution (see above for behavior if high=None).

size : int or tuple of ints, optional

Output shape. If the given shape is, e.g., (m, n, k), then m * n * k samples are drawn. Default is None, in which case a single value is returned.

dtype : dtype, optional

Desired dtype of the result. All dtypes are determined by their name, i.e., ‘int64’, ‘int’, etc, so byteorder is not available and a specific precision may have different C types depending on the platform. The default value is ‘np.int’.

Returns:
out : int or ndarray of ints

size-shaped array of random integers from the appropriate distribution, or a single such random int if size not provided.

note: random.random_integers, which is similar to this function, has been deprecated.

Example: Coin flip example, possible integers are 0 (heads) or 1 (tails)

```python
np.random.randint(2) #random number is either 0 or 1
```

    0

Example: Generate a random byte

```python
np.random.randint(2, size = 8)
```

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

Example: Generate a sample with 10 numbers of which numbers are between 2(inclusive) and 5 (exclusive)

```python
np.random.randint(2,5,size=10)
```

    array([2, 2, 3, 4, 2, 2, 4, 4, 3, 2])

Example: Generate a sample with 10 by 5 numbers that are between 2(inclusive) and 5(exclusive)

```python
np.random.randint(2,5,size=(10,5))
```

    array([[2, 3, 2, 4, 3],
           [2, 3, 3, 2, 4],
           [3, 3, 2, 4, 4],
           [2, 4, 4, 2, 3],
           [3, 2, 3, 4, 4],
           [3, 2, 4, 3, 4],
           [2, 3, 2, 4, 4],
           [4, 2, 2, 3, 4],
           [3, 4, 4, 4, 4],
           [2, 3, 4, 3, 2]])

### Random_sample()

**numpy.random.random_sample()[8]** is a function that returns random floats in the half-open interval. Similar to the randint() function, but instead of returning ints, it'll return floats. Calling a function without any parameters will output a float between 0.0(inlusive) and 1.0(exlusive). Results are based off the continuous uniform distribution.

Parameters:
size : int or tuple of ints, optional

Output shape. If the given shape is, e.g., (m, n, k), then m * n * k samples are drawn. Default is None, in which case a single value is returned.

Returns:
out : float or ndarray of floats

Array of random floats of shape size (unless size=None, in which case a single float is returned).

Example: Generate a random price for a good under one euro

```python
round(np.random.random_sample(),2) #gets a random float and round up to two decimal places
```

    0.1

Example: Generate an array of 3 random floats

```python
np.random.random_sample(3)
```

    array([0.947187  , 0.47447803, 0.63588946])

Example: Generate a random float between -5 and 15
Use formula : (b - a) * random_sample() + a

```python
(15 - -5) * np.random.random_sample() + -5
```

    -4.464749887084832

Example: Return a 3 by 3 array of random floats between -5 and 15

```python
(15 - -5) * np.random.random_sample((3,3)) + -5
```

    array([[ 6.34331272, 13.16863128,  8.17594392],
           [12.99294443, -0.53621199,  3.77536419],
           [11.48817683,  5.83157003,  1.45544615]])

*random(),ranf(), and sample() functions are similar to random_sample and have been deprecated

### Choice()

**numpy.random.choice()[9]** function generates a random sample from a given 1-D array. It uses a uniform distribution when generating the sample, or a non-uniform sample if the user specifies the probability of each element in the array.

Parameters:
a : 1-D array-like or int

If an ndarray, a random sample is generated from its elements. If an int, the random sample is generated as if a were np.arange(a)

size : int or tuple of ints, optional

Output shape. If the given shape is, e.g., (m, n, k), then m * n * k samples are drawn. Default is None, in which case a single value is returned.

replace : boolean, optional

Whether the sample is with or without replacement

p : 1-D array-like, optional

The probabilities associated with each entry in a. If not given the sample assumes a uniform distribution over all entries in a.

Returns:
samples : single item or ndarray

The generated random samples

Raises:
ValueError

If a is an int and less than zero, if a or p are not 1-dimensional, if a is an array-like of size 0, if p is not a vector of probabilities, if a and p have different lengths, or if replace=False and the sample size is greater than the population size

Example: Generate a uniform random sample from an array of number [0,1,2,3,4] of size 4.

```python
np.random.choice(5, 3)
```

    array([2, 1, 4])

Example: Generate a non-uniform random sample from an array of numbers [0,1,2,3,4] of size 4.

```python
np.random.choice(5, 3, p=[0.1, 0.1, 0.1, 0.7, 0])
```

    array([3, 3, 2], dtype=int64)

Example: Generate a uniform random sample from np.arange(5) of size 3 without replacement:

```python
np.random.choice(5, 3, replace=False)
```

    array([1, 3, 2])

Example: Prove that the non-uniform random sample generated uses the probability given to each element. Create a sample size of 1000 with fruit elements, with varying probabilities. The total number of times per fruit is generated should match up to the probabilities given to the respective fruit.

```python
x = np.random.choice(["apple","banana","orange","berry"],(1000),p=[0.1,0.1,0.2,0.6])
plt.hist(x,color='b')
```

As we can see from the graph, berries was given a probability of 0.6, and hence roughly 600 berry objects were created out of a sample size of 1000.

### Bytes()

numpy.random.bytes() is function that generates random bytes.
Parameters:
length : int

Number of random bytes.

Returns:
out : str

String of length length.

Example: Generate a random bte with length 5 and convert to string.

```python
x = np.random.bytes(5)
"".join(map(chr, x))
```

    '[\tv\xa0×'

## Permutations

### Shuffle()

**shuffle[11]** is a function that modifies a sequence by shuffling its contents. For multi dimentionally arrays, shuffle() will only change the order of the array along the first axis.

Parameters:
x : array_like

The array or list to be shuffled.

Returns:

None

Example: Shuffle an array of integers:

```python
arr = np.arange(10)
np.random.shuffle(arr)
arr
```

    array([8, 4, 9, 5, 1, 3, 7, 6, 0, 2])

Example: Shuffle the order of a 2d 3 by 3 array along the first axis.

```python
arr = np.arange(9).reshape((3, 3))
np.random.shuffle(arr)
arr
```

    array([[6, 7, 8],
           [0, 1, 2],
           [3, 4, 5]])

### Permutation()

permutation()[12] is a function that takes in an array and returns a permutated copy of that array. For multi dimentionally arrays, shuffle() will only change the order of the array along the first axis.

Parameters:
x : int or array_like

If x is an integer, randomly permute np.arange(x). If x is an array, make a copy and shuffle the elements randomly.

Returns:
out : ndarray

Permuted sequence or array range.

Example: Permutate an array of 10 integers from 0-9

```python
np.random.permutation(10)
```

    array([2, 7, 1, 9, 0, 5, 8, 6, 4, 3])

```python
arr = np.arange(9).reshape((3, 3))
print("permutated array:")
print(np.random.permutation(arr))
print("original array (not changed):")
print(arr)
```

    permutated array:
    [[0 1 2]
     [3 4 5]
     [6 7 8]]
    original array (not changed):
    [[0 1 2]
     [3 4 5]
     [6 7 8]]

*The **difference[13]** between shuffle() and permutation() is that for permutation(), a copy of the array is shuffled, where as the array itself is shuffled for shuffle()

## Distributions

### Uniform()
**Numpy.random.uniform()[14]** is a function that draws samples from a uniform distribution. Samples are uniformly distributed over the half-open interval [low, high) (includes low, but excludes high). It is the default distribution function used by rand(), randint() and random_sample()

The probability density function of the uniform distribution is
$$p(x) = \frac{1}{b - a}$$
where a = low and b = high

For example: if a = -5 and b = 5
$$p(x) = \frac{1}{5 -(-5)} = \frac{1}{10}$$
therefore, the probability of every object in that array is 0.1

Parameters:
low : float or array_like of floats, optional

Lower boundary of the output interval. All values generated will be greater than or equal to low. The default value is 0.

high : float or array_like of floats

Upper boundary of the output interval. All values generated will be less than high. The default value is 1.0.

size : int or tuple of ints, optional

Output shape. If the given shape is, e.g., (m, n, k), then m * n * k samples are drawn. If size is None (default), a single value is returned if low and high are both scalars. Otherwise, np.broadcast(low, high).size samples are drawn.

Returns:
out : ndarray or scalar

Drawn samples from the parameterized uniform distribution.

Example: Generate a random sample that is uniformly distributed with 10000 values from 0 to 10

```python
import matplotlib.pyplot as plt
s = np.random.uniform(0,10,10000)
count, bins, ignored = plt.hist(s, 10, density=True, color='g')
plt.axhline(y=np.average(count), color='r', linestyle='-')
plt.show()

```

Based on that diagram, we can see that the graph shows a fairly uniform result. The red line shows the probability p(x) of this distribution, which is at 0.1. There is little fluctutation in between the columns.

### Normal()

The **numpy.random.normal()[15]** function draws random samples from a normal distribution. It is also known as the bell curve because of its shape.

Parameters:
loc : float or array_like of floats

Mean (“centre”) of the distribution.

scale : float or array_like of floats

Standard deviation (spread or “width”) of the distribution.

size : int or tuple of ints, optional

Output shape.

Returns:
out : ndarray or scalar

Drawn samples from the parameterized normal distribution.

Data can be "distributed" in different ways, but in many cases, data tends to be around a central value with no bias left or right, and it gets close to a normal distribution like this[16]:

50% of values less than the mean and 50% greater than the mean, and the symmetry is about the center.

To understand normal distributions, we need to understand what **standard deviations** are. Standard deviations is basically a measure of how spread out numbers are. When we calculate the standard deviation we find that generally:
1. 68% of values are within 1 standard deviation of the mean
2. 95% of values are within 2 standard deviations of the mean
3. 99.7% of values are within 3 standard deviations of the mean

Example of drawing a bellcurve of the height of students in a school, with mean 1.4, standard deviation of 0.15 and a sample size of 1000 students. [17]

```python
s = np.random.normal(1.4, 0.15, 1000)
count, bins, ignored = plt.hist(s, 30, density=True,alpha=0.5)

import matplotlib.mlab as mlab
y = mlab.normpdf(bins, 1.4, 0.15) # add the best fit line
plt.plot(bins, y, 'r--')
plt.subplots_adjust(left=0.15)
plt.axvline(x=1.4, color='y')
plt.axvline(x=1.25, color='y')
plt.axvline(x=1.1, color='y')
plt.axvline(x=1.55, color='y')
plt.axvline(x=1.7, color='y')
```

### Binomial()

The **numpy.random.binomial()[18]** function draws samples from a binomial distribution.

Parameters:
n : int or array_like of ints

Parameter of the distribution, >= 0. Floats are also accepted, but they will be truncated to integers.

p : float or array_like of floats

Parameter of the distribution, >= 0 and <=1.

size : int or tuple of ints, optional

Output shape.

Returns:
out : ndarray or scalar

Drawn samples from the parameterized binomial distribution, where each sample is equal to the number of successes over the n trials.

Example: Tossing a coin. The probability of the coin landing on heads is 0.5, and the probability of the coin landing on tails is also 0.5. If the coin is tossed 3 times, what are the probabilities of every outcome?

There are 8 possibilities:
1. HHH
2. HTT
3. HHT
4. HTH
5. THT
6. TTH
7. THH
8. TTT

Therefore, we can see that the probability of getting 3 heads is 1/8 (HHH), the probability of getting 2 heads is 3/8 (HHT,HTH,THH), the probability of getting 1 head is 3/8 (THT,TTH,HTT), and the probability of getting no heads is 1/8 (TTT).

Let's run this test 100 times using numpy:

```python
n, p = 3, 0.5
x = np.random.binomial(n, p,100)
plt.hist(x)
```

From the graph, we can see roughly 3/8 or 38 times there will be two heads or two tails, and 1/8 of the time, there will be no tails or no heads.

Example: Dangerous Road. Unlike a coin flip with has an equal probability, the probability of an accident occuring on a dangerous road might not be equal as not having an accident. Say if the chances of having an accident on this road is 0.3 (0.7 probability of a safe journey), what is the probability that the next 5 out of 10 cars will be involved in an accident?

Let's run this test 10000 times:

```python
n, p = 10, 0.3

x = np.random.binomial(n, p,10000)
plt.hist(x)
```

We can see that roughly 1000 times out of 10000 times the next 5 cars will be in accidents, that means the probability of the next 5 cars being in an accident is 10%.

Examples referenced from **mathisfun.com[19]**

### poisson()

The **numpy.random.poisson()[20]** function draws samples from a poisson distribution.

Parameters:
lam : float or array_like of floats

Expectation of interval, should be >= 0. A sequence of expectation intervals must be broadcastable over the requested size.

size : int or tuple of ints, optional

Output shape.

Returns:
out : ndarray or scalar

Drawn samples from the parameterized Poisson distribution.

Above shows an example of Poisson curves with carying lamdas, extracted from the **umass maths department website[20]**. The Poisson distribution shows that the smaller the lambda, the range of likely possibilities will lie closer to the zero line.  For example, if the lambda is 1, the graph will be skewed to the left, compared to say lambda = 5 where the graph will be less skewed.

The values of a poisson curve can be calculated using the Poisson distribution formula, which is

$$P(k; \lambda)=\frac{\lambda^k e^{-\lambda}}{k!}$$

Example: In the World Cup, an average of 2.5 goals are scored each game. Modeling this situation with a Poisson distribution, what is the probability that  goals are scored in a game? (example from brilliant.org[21]

\begin{align}
P(X=0)= \frac{2.5^0e^{-2.5}}{0!} \approx 0.082\\\\
P(X=1) = \frac{2.5^1e^{-2.5}}{1!} \approx 0.205\\\\
P(X=2) = \frac{2.5^2e^{-2.5}}{2!} \approx 0.257\\\\
P(X=3) = \frac{2.5^3e^{-2.5}}{3!} \approx 0.213\\\\
P(X=4) = \frac{2.5^4e^{-2.5}}{4!} \approx 0.133\\\\
\vdots
\end{align}

```python
s = np.random.poisson(2.5, 10000)
count, bins, ignored = plt.hist(s, 10, density=True, color="purple")
plt.show()

```

## Random generator

As mentioned above, numpy uses the Mersenne Twister for its PseudoRandom Number Generator (PRNG).  The PRNG-generated sequence is not truly random, because it is completely determined by an initial value, called the PRNG's seed (which may include truly random values).

The functions included in numpys random generator are:
1. **numpy.random.RandomState()[23]**, a container for the Mersenne Twister pseudo-random number generator.
2. **numpy.random.seed()[24]**, which seeds the generator
3. **numpy.random.get_state()[25]**, which returns a tuple representing the internal state of the generator.
4. **numpy.random.set_state(state)[26]**, which sets the internal state of the generator from a tuple.

An example of seeding and containing random states, from this **stackoverflow explanation[27]**:

When a user calls the function np.random.seed(1), the seed for the generator is set to the first number.

```python
np.random.seed(1)
print(np.random.rand())
print(np.random.rand(2,2))
```

    0.417022004702574
    [[7.20324493e-01 1.14374817e-04]
     [3.02332573e-01 1.46755891e-01]]

When the user runs the same two functions with the same seed, the results should be the exact same:

```python
np.random.seed(1)
print(np.random.rand())
print(np.random.rand(2,2))
```

    0.417022004702574
    [[7.20324493e-01 1.14374817e-04]
     [3.02332573e-01 1.46755891e-01]]

We can use np.random.RandomState as a container for the PRNG:

```python
r = np.random.RandomState(1)
print(r.rand())
print(r.rand(2,2))
```

    0.417022004702574
    [[7.20324493e-01 1.14374817e-04]
     [3.02332573e-01 1.46755891e-01]]

The user can run get_state() which will return the entire PRNG:

```python
print(r.get_state())
```

    ('MT19937', array([2629073562, 2983301384,  681580311, 4033622241,  792772838,
           3306961981,   92883131, 1785085746, 3364128315, 2402025379,
           3224868746, 1145213362, 3784365245, 1948434636, 2667646161,
           2598854474,  921967201, 1345782310, 4019597455, 2906395199,
           1349669984, 2676817993, 4201769589, 2002781766, 3540177092,
           4224925813, 3661313599, 1709930435, 1812273278, 2973452884,
           1592291796, 3452239013, 3588672187, 3228651068, 3191454495,
           3286135343, 2640545275, 3096953148, 3746505897, 2292163827,
           2164382601, 1581410039, 2413832827, 2536571847,  179684232,
           1638923698, 3155158821, 1454330362, 4050607484,  607322300,
           2216566078,  597866774, 1036426282,  732815996, 3131865469,
           2440339870, 2814550949, 1479383443, 2449469876, 3810238677,
           2923086221,  437801529, 2891199990, 1886893516, 3898673786,
            376204646, 1392372379, 4123661669, 1140754642, 3539167101,
           2386309702, 3740957436, 4033654965, 1720449988, 3434980330,
           4213508374, 3576835843, 2818865106, 1653162115, 2935588114,
           3870616539,  102614847, 1476834675, 1220770796, 1233652508,
           2385138085, 1300608482, 2753953039,  262993567, 4009374062,
           2143978386, 1613109469, 3072671496, 1223816410, 4088822114,
           3382188205, 2250056281, 1926821318, 3806317775, 2882166470,
             94227745, 2877123406, 1030225246, 1555072155,   95009460,
           1855512191, 2840453856, 2478087736,  836340051, 1566383306,
           2625414289, 3519123538,  840509954, 2373484829,  676528503,
           2783662465, 3557034492, 2566048980, 2347785709, 3566819907,
           1311855742,  198269976, 2693520819, 2127070362,  961491174,
           3932714317,  837664826, 4277891831, 3535515583, 2831416447,
           3505045078, 3763313683,  367436315, 3614057572, 3780746374,
           2693039652, 2297021184, 2224934154,  698822522, 2718629137,
           1175446314, 2603507610, 2067589016, 2280810156, 2037033584,
           3956938481, 1112874779, 3264939860, 2054107185, 2354026721,
           1958640221, 2844284824, 3775753525, 2462549847, 3562644229,
           3683686884, 3714884555, 2266356233, 2808583945,  980888698,
           1137581788, 2771236582, 1975939317, 1605707990,  614167064,
            767063856, 4227905160, 3590303986, 2932373212, 2230415839,
            127157074, 2328724316, 3356372094, 3215726425,  282321962,
           4226412442,  106823192, 3925701436,  610765913,  281952627,
           1832011890, 2670621135, 1012800992, 1489632964, 1371755819,
           2529629289,  607643288, 3941535311, 3202770816,  284461833,
           3696778854,  325625733, 2671656400, 1391137252, 1240723705,
           3132941411,  314202987, 1301784708, 2575857120,  313287791,
           3569720512,  981744121, 2986286440, 1051168756, 3881027887,
           1088809168, 3421075971, 2655923113, 2577977181, 3968201444,
           1406585576, 4001025594, 1854233928, 3825832114,  954907921,
           3109212504, 1706316388, 1550715292, 2934259476, 1892992132,
           4050317728, 4110743408,  359920673, 3542060425,  597068400,
            843058885, 3799566677, 2064802063,  860032883, 2732673041,
           3457529378, 3660165513, 1125914118, 4212233956, 3757086488,
           2632116865,  462233764,  161423763,  141910303, 2265109737,
           1983126147, 2231395445, 2147383559,  611750005, 2194081082,
           1699618892, 3841025952, 1294478167,  936120505, 4102008957,
           4268194620, 1994019922, 1187332833, 3953675561, 1917283619,
            896867387, 1634068959, 3950876764,  485558549,   45383166,
           2795959904, 1717914918, 3449856475, 3796449494, 3318166191,
           1007512487,  480498390, 1730605673, 3972660041, 2287008439,
            539639323, 1569683418, 3795864463,  453065119, 2449404120,
            265049099, 3514892628, 2665563904, 2534523080, 1969344934,
           4294466702, 1478959417, 1858551310, 3029422620, 4121720519,
           2933843154, 3209557296, 3111170404, 4264711934, 2448282623,
           1388503581, 1999436060,  818418468, 3283991819, 2356924521,
           2684567658, 1424193429, 1187340812, 3847120877, 1987859863,
           1879502046, 3422594099, 1419478013, 4148487199, 2538837125,
           3694851522,  636350498, 1097832595, 3779331880,  351970715,
           2534774459, 3389311029, 3762283879, 3742425828, 3882821767,
              2683353, 1981273229, 4068324016,  539226467, 2411256222,
           1780609115, 2059099269, 2889497980, 1123848930,  540086248,
           3467353606, 3362203896, 4058078927,   93044840,  426751932,
            824266620, 3444590461, 2122918776, 3339845861, 4233923286,
           3733051177, 2957657929, 3110908772, 2551930098, 1294126893,
           2213336821, 2124571119,   49780221, 2901327722, 2493306969,
           2545470627,  102527300, 3876142393, 4097726412, 3510695954,
            660912408, 2033930425, 1601509561,  969180562,  635252598,
            177954239, 3054207519, 4122051269, 2787463443, 1664731394,
           2907371963, 1484884283, 1560546623, 2902374922, 2395942225,
            451352804, 3346805556, 3459298550, 3482428591,   10753957,
           4101820340, 3306178891, 1122941824,  355877597, 1683964498,
            238724805, 3926649337, 3197046734, 4277634633, 2288745211,
           4202067531,  526022968, 4017453944, 1499184106, 2677441952,
           2227353703, 1995296581, 1690255681, 2920887680,  605849491,
           3072795503,  746910746, 2709796449, 1225135658,  657841564,
           4070363626,  144842260, 3718575695, 3159187032,   88291794,
           3129049475,  410962657, 1728726693, 2397606939, 4126386549,
            751549633, 4226219908, 1549973222, 3060733996, 3741110422,
           2530947598, 2627897488, 2317706652, 1170828427, 2671701715,
           1153351468, 3762293788, 4093330405, 1641962571,  699324101,
           3173743570, 1798831929, 3467616712, 4198420524, 2448981354,
            499920867,  968642107, 2140815539, 4193124145, 1639223168,
            284638153,  396985542, 3543438633,  237854258, 3938010494,
             24441053, 2947436871, 1273496002,  719279415, 3574242559,
           1040109604, 3849196601, 3250223302, 3411729501, 3031943234,
           2932285520, 2932420675, 2011314805, 2480850074, 3207806491,
            462404995, 3279042455, 4270524229, 1064389665, 1894847490,
            721365878,  357178131, 2827490451, 2604438657,  242514037,
            678802395, 1322770750, 2747624534, 4246466163, 4188936761,
           1207204018,  980275996,  841637218, 1468131552, 4102349079,
            586888764, 3105466755, 1628818384, 2991889790, 2801191520,
           2114916962, 1124291831, 3242113092, 1082871720, 3625937786,
           2796251125, 1651820702, 3427511545, 2035120316, 1024058911,
           4209506140, 2527167744,  213886228, 2514956543, 2450260579,
            327684603, 3444379103, 3884997363, 2844468873, 2261078634,
           3926825101,  439487268, 3789435080, 1212963762, 4259079565,
           2772611204, 2534236055, 2430244594,  916922266,  903950702,
           3381351589, 2268543712, 3616954837, 1273083041, 1682465785,
           1342921678, 2593265787, 3033724173, 2988544460, 1824668777,
           1214999983,  257453352, 4187931679, 3523379959, 1481153225,
           4290295859,  220376185, 4136013972,  511679284, 3510589272,
           1404047266, 3712771231, 2125374725,   32037606, 3601685135,
           3433623522,  841138647, 1610171318, 3920699442, 1084892922,
           3146108732, 3672652561, 1148331655, 2473777375, 3039860130,
           1170979324,  575756423, 1389255297,  339011744, 2351938991,
           4050094885, 2773634239, 3715040333, 3920910597,  562407139,
             29887881, 1623822350,  358193390, 1822261341, 1243290919,
            755541153,  153529770,  994467513, 1339524978, 3174283928,
           2782204324,  182251010, 1833692038, 3477775846, 2820237500,
           2165639585, 4089211432, 4010345846, 1238643345, 3710224584,
           2251039304, 1196168985, 3165387311, 3920626153, 1796963839,
           2112227260, 3358845388,  593715887, 3046897033, 2968478428,
           3846781604, 3565923316, 2452128692,   68338106, 1427007580,
           2192917968,   75235680, 2134869635, 1857807303, 3745016485,
           2601327385,   35512535,  559792668, 2093376088,  939608650,
           2259549051, 2605376692, 2058599240, 2980581379, 3160415220,
           2739135905,  254886981, 1652380747,  371107437, 1123937393,
           4185309254,  864314942, 2739416220, 2185572068, 1163546293,
           3491702910,  996401156, 1198755052, 2898003956,  745796080,
           4127642404, 4237523457, 1274635091, 3144139009, 1600421663,
           4226154574,  538248802,  373236455,  116925273], dtype=uint32), 10, 0, 0.0)

## References
1. https://www.numpy.org/ - Welcome to NumPy - Introduction of NumPy
2. https://www.maths.tcd.ie/~fionn/misc/mt.php - Details of the Mersenne Twister formula
3. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.randint.html#numpy.random.randint - numpy.random.rand() explanation
4. http://mathworld.wolfram.com/Half-ClosedInterval.html - explanation of the half-open interval
5. https://matplotlib.org/api/pyplot_api.html - introduction to matplotlib.pyplot package
6. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.randn.html#numpy.random.randn - numpy.random.randn() explanation
7. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.random_integers.html#numpy.random.random_integers - numpy.random.randint() explanation
8. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.random_sample.html#numpy.random.random_sample - numpy.random.random_sample explanation
9. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.choice.html#numpy.random.choice - numpy.random.choice() explanation
10. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.bytes.html#numpy.random.bytes - numpy.random.bytes explanation
11. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.shuffle.html#numpy.random.shuffle - shuffle() explanation
12. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.permutation.html - permutation() explanation
13. https://stackoverflow.com/questions/15474159/shuffle-vs-permute-numpy - difference between shuffle() and permutation()
14. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.uniform.html#numpy.random.uniform - official numpy reference for numpy.random.uniform()
15. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.normal.html#numpy.random.normal - official numpy reference for numpy.random.normal()
16. https://www.mathsisfun.com/data/standard-normal-distribution.html - good explanation of bell curves by math is fun.
17. https://plot.ly/matplotlib/histograms/#basic-histogram-with-hist-function - code to draw bell curve
18. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.binomial.html#numpy.random.binomial - official numpy reference for numpy.random.binomial()
19. https://www.mathsisfun.com/data/binomial-distribution.html - good examples of binomial distribution
20. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.poisson.html#numpy.random.poisson - official numpy reference for numpy.random.possion()
21. https://www.umass.edu/wsp/resources/poisson/ - good explanation and graphs for poisson curve
22. https://brilliant.org/wiki/poisson-distribution/ - good examples of poisson curve
23. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.RandomState.html#numpy.random.RandomState - official numpy reference for numpy.random.RandomState()
24. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.seed.html#numpy.random.seed
25. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.get_state.html#numpy.random.get_state
26. https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.random.set_state.html#numpy.random.set_state
27. https://stackoverflow.com/questions/22994423/difference-between-np-random-seed-and-np-random-randomstate?rq=1

```python

```


```python
# This cell just imports the relevant modules

import numpy
import pylab
from math import pi, exp
from sympy import sin, cos, Function, Symbol, diff, integrate, dsolve, checkodesol, solve, ode_order, classify_ode, pprint
import mpmath
```

```python
###### ORDER OF AN ODE ######
###### Lecture 7, slide 9 ######
t = Symbol('t') # Independent variable
eta = Symbol('eta') # Constant
v = Function('v') # Dependent variable v(t)
ode = diff(v(t),t) + eta*v(t) # The ODE we wish to solve. Make sure the RHS is equal to zero.
print("ODE #1:")
pprint(ode)
print("The order of ODE #1 is %d" % ode_order(ode, v(t)))

x = Function('x') # Dependent variable x(t)
m = Symbol('m') # Constant
k = Symbol('k') # Constant
ode = m*diff(x(t),t,2) + k*x(t)
print("ODE #2:")
pprint(ode)
print("The order of ODE #2 is %d" % ode_order(ode, x(t)))

y = Function('y') # Dependent variable y(t)
ode = diff(y(t),t,4) - diff(y(t),t,2)
print("ODE #3:")
pprint(ode)
print("The order of ODE #3 is %d" % ode_order(ode, y(t)))
```

```python
###### ANALYTICAL SOLUTIONS ######
###### Lecture 7, slide 14 ######
x = Symbol('x') # Independent variable
y = Function('y') # Dependent variable y(x)

# The ODE we wish to solve. Make sure the RHS is equal to zero.
ode = diff(y(x),x) - 2*x*(1-y(x))
solution = dsolve(ode, y(x)) # Solve the ode for function y(x).
print("ODE #4:")
pprint(ode)
print("The solution to ODE #4 is: ", solution)

# This function checks that the result of dsolve is indeed a solution
# to the ode. Basically it substitutes in 'solution' into 'ode' and
# checks that the RHS is zero. If it is, the function returns 'True'.
print("Checking solution using checkodesol...")
check = checkodesol(ode, solution)
if(check[0] == True):
    print("y(x) is indeed a solution to ODE #4")
else:
    print("y(x) is NOT a solution to ODE #4")

# The mpmath module can handle initial conditions (x0, y0) when solving an
# initial value problem, using the odefun function. However, this will
# not give you an analytical solution to the ODE, only a numerical
# solution. The print statement below compares the numerical solution
# with the values of the (already known) analytical solution between x=0 and x=10.

f = mpmath.odefun(lambda x, y: 2*x*(1-y), x0=0, y0=2)
for x in numpy.linspace(0, 10, 100):
    print(f(x), 1.0 + exp(-x**2))
```

```python
###### SEPARATION OF VARIABLES ######
###### Lecture 7, slide 20 ######
x = Symbol('x') # Independent variable
y = Function('y') # Dependent variable y(x)
# The ODE we wish to solve.
ode = (1.0/y(x))*diff(y(x),x) - cos(x)
print("ODE #5:")
pprint(ode)
# Solve the ode for function y(x).using separation of variables.
# Note that the optional 'hint' argument here has been used
# to tell SymPy how to solve the ODE. However, it is usually
# smart enough to work it out for itself.
solution = dsolve(ode, y(x), hint='separable')
print("The solution to ODE #5 is: ", solution)
```

```python
###### INTEGRATION FACTOR ######
###### Lecture 7, slide 23 ######
x = Symbol('x') # Independent variable
y = Function('y') # Dependent variable y(x)
# The ODE we wish to solve.
ode = diff(y(x),x) - 2*x + 2*x*y(x)
print("ODE #6:")
pprint(ode)
# Solve the ode for function y(x).using separation of variables
solution = dsolve(ode, y(x))
print("The solution to ODE #6 is: ", solution)
```

```python
###### APPLICATION: RADIOACTIVE DECAY ######
###### Lecture 7, slide 26 ######
t = Symbol('t') # Independent variable
N = Function('N') # Dependent variable N(t)
l = Symbol('l') # Constant
# The ODE we wish to solve.
ode = diff(N(t),t) + l*N(t)
print("ODE #7:")
pprint(ode)
solution = dsolve(ode, N(t))
print("The solution to ODE #7 is: ", solution)
```

```python
###### APPLICATION: PARTICLE SETTLING ######
###### Lecture 7, slide 31 ######
t = Symbol('t') # Independent variable - time
v = Function('v') # Dependent variable v(t) - the particle velocity
# Physical constants
rho_f = Symbol('rho_f') # Fluid density
rho_p = Symbol('rho_p') # Particle density
eta = Symbol('eta') # Viscosity
g = Symbol('g') # Gravitational acceleration
a = Symbol('a') # Particle radius
# The ODE we wish to solve.
ode = diff(v(t),t) - ((rho_p - rho_f)/rho_p)*g + (9*eta/(2*(a**2)*rho_p))*v(t)
print("ODE #8:")
pprint(ode)
solution = dsolve(ode, v(t))
print("The solution to ODE #8 is: ", solution)
```


# Linear programming with scipy

See https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.linprog.html

```python
import scipy.optimize
```

Problem examples:
- http://people.brunel.ac.uk/~mastjjb/jeb/or/morelp.html

## Scipy's syntax

Example for a problem of 2 dimensions:

$$
\begin{align}
    \min_{x_1,x_2} & \quad \color{\red}{c_1} x_1 + \color{\red}{c_2} x_2 \\
    \text{s.t.}    & \quad \color{\orange}{A_{1,1}} x_1 + \color{\orange}{A_{1,2}} x_2 \leq \color{\green}{b_1} \\
                   & \quad \color{\orange}{A_{2,1}} x_1 + \color{\orange}{A_{2,2}} x_2 \leq \color{\green}{b_2} \\
                   & \quad \color{\purple}{B_{1,1}} \geq x_1 \geq \color{\purple}{B_{1,2}} \\
                   & \quad \color{\purple}{B_{2,1}} \geq x_2 \geq \color{\purple}{B_{2,2}} \\
\end{align}
$$

$$
\color{\red}{
\boldsymbol{c} = \begin{pmatrix}
    c_1 \\
    c_2
\end{pmatrix}
}
\quad
\color{\orange}{
\boldsymbol{A} = \begin{pmatrix}
    A_{1,1} & A_{1,2} \\
    A_{2,1} & A_{2,2}
\end{pmatrix}
}
\quad
\color{\green}{
\boldsymbol{b} = \begin{pmatrix}
    b_1 \\
    b_2
\end{pmatrix}
}
\quad
\color{\purple}{
\boldsymbol{B} = \begin{pmatrix}
    B_{1,1} & B_{1,2} \\
    B_{2,1} & B_{2,2}
\end{pmatrix}
}
$$

$$
\text{scipy.optimize.linprog}(\color{\red}{\boldsymbol{c}}, ~ \color{\orange}{\boldsymbol{A}}, ~ \color{\green}{\boldsymbol{b}}, ~ \color{\purple}{\boldsymbol{B}})
$$

## Example 1

$$
\begin{align}
    \min_{x_0,x_1} & \quad -x_0 + 4 x_1 \\
    \text{s.t.}    & \quad -3 x_0 + x_1 \leq 6 \\
                   & \quad -x_0 - 2 x_1 \geq -4 \\
                   & \quad  x_1 \geq -3
\end{align}
$$

```python
# Coefficients of the linear objective function to be minimized
c = [-1, 4]

# 2-D array which, when matrix-multiplied by x, gives the values of the upper-bound inequality constraints at x.
A = [[-3, 1],
     [ 1, 2]]

# 1-D array of values representing the upper-bound of each inequality constraint (row) in A.
b = [6, 4]

# Sequence of (min, max) pairs for each element in x, defining the bounds on that parameter.
# Use None for one of min or max when there is no bound in that direction.
# By default bounds are (0, None) (non-negative).
# If a sequence containing a single tuple is provided, then min and max will be applied to all variables in the problem.
x0_bounds = (None, None)
x1_bounds = (-3, None)
bounds = (x0_bounds,x1_bounds)

scipy.optimize.linprog(c, A_ub=A, b_ub=b, bounds=bounds)
```

## The carpenter problem

```python
# Coefficients of the linear objective function to be minimized
c = np.array([3, 5])

# 2-D array which, when matrix-multiplied by x, gives the values of the upper-bound inequality constraints at x.
A_ub = np.array([[3, 2],
                 [1, 2],
                 [5, 4]])

# 1-D array of values representing the upper-bound of each inequality constraint (row) in A_ub.
b_ub = np.array([700, 500, 1500])

# Sequence of (min, max) pairs for each element in x, defining the bounds on that parameter.
# Use None for one of min or max when there is no bound in that direction.
# By default bounds are (0, None) (non-negative).
# If a sequence containing a single tuple is provided, then min and max will be applied to all variables in the problem.
bounds = ((0, None), (0, None))

scipy.optimize.linprog(-c, A_ub=A_ub, b_ub=b_ub, bounds=bounds)
```

The optimal solution is obtain for $x_1=100$ and $x_2=200$ with a gain of 1300.


# Symbolically Understanding Christoffel Symbol and Riemann Curvature Tensor using EinsteinPy

```python
import sympy
from einsteinpy.symbolic import MetricTensor, ChristoffelSymbols, RiemannCurvatureTensor

sympy.init_printing()  # enables the best printing available in an environment
```

### Defining the metric tensor for 3d spherical coordinates

```python
syms = sympy.symbols('r theta phi')
# define the metric for 3d spherical coordinates
metric = [[0 for i in range(3)] for i in range(3)]
metric[0][0] = 1
metric[1][1] = syms[0]**2
metric[2][2] = (syms[0]**2)*(sympy.sin(syms[1])**2)
# creating metric object
m_obj = MetricTensor(metric, syms)
m_obj.tensor()
```

### Calculating the christoffel symbols

```python
ch = ChristoffelSymbols.from_metric(m_obj)
ch.tensor()
```

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

### Calculating the Riemann Curvature tensor

```python
# Calculating Riemann Tensor from Christoffel Symbols
rm1 = RiemannCurvatureTensor.from_christoffels(ch)
rm1.tensor()
```

```python
# Calculating Riemann Tensor from Metric Tensor
rm2 = RiemannCurvatureTensor.from_metric(m_obj)
rm2.tensor()
```

### Calculating the christoffel symbols for Schwarzschild Spacetime Metric
  - The expressions are unsimplified

```python
syms = sympy.symbols("t r theta phi")
G, M, c, a = sympy.symbols("G M c a")
# using metric values of schwarschild space-time
# a is schwarzschild radius
list2d = [[0 for i in range(4)] for i in range(4)]
list2d[0][0] = 1 - (a / syms[1])
list2d[1][1] = -1 / ((1 - (a / syms[1])) * (c ** 2))
list2d[2][2] = -1 * (syms[1] ** 2) / (c ** 2)
list2d[3][3] = -1 * (syms[1] ** 2) * (sympy.sin(syms[2]) ** 2) / (c ** 2)
sch = MetricTensor(list2d, syms)
sch.tensor()
```

```python
# single substitution
subs1 = sch.subs(a,0)
subs1.tensor()
```

```python
# multiple substitution
subs2 = sch.subs([(a,0), (c,1)])
subs2.tensor()
```

```python
sch_ch = ChristoffelSymbols.from_metric(sch)
sch_ch.tensor()
```

### Calculating the simplified expressions

```python
simplified = sch_ch.simplify()
simplified
```

docs/source/examples/Symbolically Understanding Christoffel Symbol and Riemann Curvature Tensor using EinsteinPy.ipynb


# Solve equation systems with SymPy
Once an a while you need to solve simple equation systems, I have found that using SymPy for this is a much better option than using pen and paper, where I usually make mistakes. Here is some short examples...

```python
# This Python 3 environment comes with many helpful analytics libraries installed
# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python
# For example, here's several helpful packages to load

import numpy as np # linear algebra
import pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)
import sympy as sp

# Input data files are available in the read-only "../input/" directory
# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory

import os
for dirname, _, filenames in os.walk('/kaggle/input'):
    for filename in filenames:
        print(os.path.join(dirname, filename))

# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using "Save & Run All"
# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session
```

## Linear system

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

```python
eq_1 = sp.Eq(lhs=y,
             rhs=2*x)
eq_1
```

```python
eq_2 = sp.Eq(lhs=z,
             rhs=3*y)
eq_2
```

```python
eq_2.subs(y,sp.solve(eq_1,y)[0])
```

We try to use *solve* to get the expression for **z**:

```python
eqs = (
    eq_1,
    eq_2,
)

solution = sp.solve(eqs, [z])
solution
```

...so this is giving us **eq_2** which we kind of know already.
So we must add a list [y,z] to solve both equations.

```python
eqs = (
    eq_1,
    eq_2,
)

solution = sp.solve(eqs, [y,z])
solution
```

```python
solution[z]
```

## Quadratic

```python
eq_3 = sp.Eq(lhs=z**2,
             rhs=2*y)
eq_3
```

```python
eq_3.subs(y,sp.solve(eq_1,y)[0])
```

```python
eqs = (
    eq_1,
    eq_3,
)

solution = sp.solve(eqs, [y,z])
solution
```

Since there is now two solutions these are given in a list where each item contain the solution for **y** and **z**.

```python
solution[0][-1]
```

```python
solution[1][-1]
```

But in order to make this less confusing the *dict* flag can be set to True:

```python
eqs = (
    eq_1,
    eq_3,
)

solution = sp.solve(eqs, [y,z], dict=True)
solution
```

## Changing the order of symbols:

```python
eqs = (
    eq_1,
    eq_3,
)

solution = sp.solve(eqs, [z,y])
solution
```

... this is swapping the order of solutions

## Changing the order of equations:

```python
eqs = (
    eq_3,
    eq_1,
)

solution = sp.solve(eqs, [y,z])
solution
```

...this has no effect.