## Libraries

Python is a high-level open-source language. But the *Python world* is inhabited by many packages or libraries that provide useful things like *array* operations, plotting functions, and much more. We can import libraries of functions to expand the capabilities of Python in our programs.

OK! We’ll start by importing a few libraries to help us out. First: our favorite library is ** NumPy**, providing a bunch of useful array operations (similar to MATLAB).

*We will use it a lot!*The second library we need is

**, a 2D plotting library which we will use to plot our results.**

`Matplotlib`

```
import numpy as np
from matplotlib import pyplot as plt
```

We are importing one library named `numpy`

(that we will call `np`

)and we are importing a module called `pyplot`

(we refer to this library as `p`

lt) of a big library called `matplotlib`

. To use a function belonging to one of these libraries, we have to tell Python where to look for it. For that, each function name is written following the library name, with a dot in between. So if we want to use the NumPy function `linspace(),`

which creates an array with equally spaced numbers between a start and end, we call it by writing:

```
import numpy as np
from matplotlib import pyplot as plt
myarray = np.linspace(0,5,10)
print(myarray)
```

## Variables

Python doesn’t require explicitly declared variable types like Fortran and other languages.

```
a =5 #a is an integer
b= 'Five' #b is a string
c = 5.0 #c is a floating point number
print(type(a))
print(type(b))
print(type(c))
```

we have:

**Note**: if you divide an integer by an integer that yields a remainder, the result will be converted to a float.

## Whitespace in Python

Python uses indents and whitespace to group statements together. To write a short loop in **Fortran**, you might use:

```
b= "Five"
do i = 1, 5
print(b)
end do
```

Python does not use `end do`

blocks like **Fortran**, so the same program as above is written in Python as follows:

```
b= 'Five' #b is a string
for i in range(5):
print(b)
```

If you have nested for-loops, there is a further indent for the inner loop.

```
for i in range(3):
for j in range(3):
print(i, j)
print("This statement is within the i-loop, but not the j-loop")
```

## Slicing Arrays

In `NumPy`

, you can look at portions of arrays in the same way as in **Matlab**, with a few extra tricks thrown in. Let’s take an array of values from 1 to 5.

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

Python uses a **zero-based index**, so let’s look at the first and last element in the array `vec`

```
print(vec)
print(vec[0], vec[4])
```

it has this output:

```
[1 2 3 4 5]
1 5
```

Arrays can also be ‘*sliced*‘, grabbing a range of values. Let’s look at the first three elements:

```
vec = np.array([1, 2, 3, 4, 5])
print(vec)
print(vec[0], vec[4])
print(vec[0:3])
```

**Note** here, the slice is **inclusive on the front** end and **exclusive on the back**, so the above command gives us the values of `vec[0]`

, `vec[1]`

and `vec[2]`

, but not `vec[3]`

.

## Assigning Array Variables

One of the strange little quirks/features in Python that often confuses people comes up when assigning and comparing arrays of values. Here is a quick example. Let’s start by defining a 1-D array called `vecx`

:

```
vecx = np.linspace(1,5,5)
print(vecx)
```

The `linspace`

command return evenly spaced numbers over a specified interval. In this case, for `vecx`

we have:

`[1. 2. 3. 4. 5.]`

OK, so we have an array `vecx`

, with the values 1 through 5. I want to make a copy of that array, called `vecy`

, so I’ll try the following:

```
vecy = vecx
print(vecy)
```

and we have:

`[1. 2. 3. 4. 5.]`

Great. So `vecx`

has the values 1 through 5 and now so does `vecy`

. Now that I have a backup of `vecx`

, I can change its values without worrying about losing data (*or so I may think!*).

```
vecx[2]=10
print(vecx)
print(vecy)
```

we have:

**And that’s how things go wrong!** When you use a statement like `vecx=vecy`

, rather than copying all the values of vecx

into a new array called `vecy`

, Python just creates an alias (or a pointer) called `vecy`

and tells it to route us to `vecx`

. So if we change a value in `vecx`

then `vecy`

will reflect that change (technically, this is called *assignment by reference*).

If you want to make a **true copy** of the array, you have to tell Python to `copy`

every element of `vecx`

into a new array. Let’s call it `vecz`

:

```
vecx = np.linspace(1,5,5)
print(vecx)
vecy = vecx
vecz=vecx.copy()
vecx[2]=10
print(vecx)
print(vecy)
print(vecz)
```

## Matrix Multiplication

A **matrix** is a 2D array, where each element in the array has 2 indices. For example, `[[1, 2], [3, 4]]`

is a matrix, and the index of `1`

is (0,0).

We can prove this using Python and `Numpy`

.

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

The simple form of matrix multiplication is called **scalar multiplication**, multiplying a scalar by a matrix.

Scalar multiplication is generally easy. Each value in the input matrix is multiplied by the scalar, and the output has the same shape as the input matrix.

In Python:

```
import numpy as np
b=7
A = [[1, 2], [3, 4]]
C=np.dot(b,A)
print(np.array(C))
```

**There are a few things to keep in mind.**

- Order matters now. \(A \times B \neq B \times A\);
- Matrices can be multiplied if the number of columns in the 1st equals the number of rows in the 2nd;
- Multiplication is the dot product of rows and columns. Rows of the 1st matrix with columns of the 2nd.

Let’s replicate the result in Python.

```
import numpy as np
A = [[1, 2], [3, 4]]
B = [[5, 6], [7, 8]]
C = np.dot(A,B)
print(np.array(C))
```

## Inner product

The ** inner product **takes two vectors of equal size and returns a single number (scalar). This is calculated by multiplying the corresponding elements in each vector and adding up all of those products. In numpy, vectors are defined as one-dimensional numpy arrays.

To get the inner product, we can use either ** np.inner() **or

**. Both give the same results. The inputs for these functions are two vectors and they should be the same size.**

`np.dot()`

```
import numpy as np
# Vectors as 1D numpy arrays
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
print("a= ", a)
print("b= ", b)
print("\ninner:", np.inner(a, b))
print("dot:", np.dot(a, b))
```

**Transpose**

The ** transpose** of a matrix is found by switching its rows with its columns. We can use

**np.transpose()**function or NumPy

**ndarray.transpose()**method or

**ndarray.T**(a special method which does not require parentheses) to get the transpose. All give the same output.

```
import numpy as np
a = np.array([[1, 2], [3, 4], [5, 6]])
print("a = ")
print(a)
print("\nWith np.transpose(a) function")
print(np.transpose(a))
print("\nWith ndarray.transpose() method")
print(a.transpose())
print("\nWith ndarray.T short form")
print(a.T)
```

**Trace**

The ** trace** is the sum of diagonal elements in a square matrix. There are two methods to calculate the trace. We can simply use the

**method of an ndarray object or get the diagonal elements first and then get the sum.**

`trace()`

```
import numpy as np
a = np.array([[2, 2, 1],
[1, 3, 1],
[1, 2, 2]])
print("a = ")
print(a)
print("\nTrace:", a.trace())
print("Trace:", sum(a.diagonal()))
```

**Rank**

The ** rank** of a matrix is the dimensions of the vector space spanned (generated) by its columns or rows. In other words, it can be defined as the maximum number of linearly independent column vectors or row vectors.

The rank of a matrix can be found using the ** matrix_rank()** function which comes from the

`numpy `*linalg*

package.```
import numpy as np
a = np.arange(1, 10)
a.shape = (3, 3)
print("a = ")
print(a)
rank = np.linalg.matrix_rank(a)
print("\nRank:", rank)
```

**Determinant**

The ** determinant** of a square matrix can be calculated

**function which also comes from the numpy**

`det()`

**package. If the determinant is 0, that matrix is not invertible. It is known as a singular matrix in algebra terms.**

*linalg*```
import numpy as np
a = np.array([[2, 2, 1],
[1, 3, 1],
[1, 2, 2]])
print("a = ")
print(a)
det = np.linalg.det(a)
print("\nDeterminant:", np.round(det))
```

**True inverse**

The ** true inverse** of a square matrix can be found using the

**function of the numpy**

`inv()`

**package. If the determinant of a square matrix is not 0, it has a true inverse.**

*linalg*```
import numpy as np
a = np.array([[2, 2, 1],
[1, 3, 1],
[1, 2, 2]])
print("a = ")
print(a)
det = np.linalg.det(a)
print("\nDeterminant:", np.round(det))
inv = np.linalg.inv(a)
print("\nInverse of a = ")
print(inv)
```

**Flatten**

** Flatten** is a simple method to transform a matrix into a one-dimensional numpy array. For this, we can use the

**flatten()**method of an ndarray object.

```
import numpy as np
a = np.arange(1, 10)
a.shape = (3, 3)
print("a = ")
print(a)
print("\nAfter flattening")
print("------------------")
print(a.flatten())
```

**Eigenvalues and eigenvectors**

Let **A** be an *n x n matrix*. A scalar \(\lambda\) is called an ** eigenvalue** of

**A**if there is a non-zero vector

**x**satisfying the following equation.

\(A x=\lambda x\)

The vector **x** is called the ** eigenvector** of

**A**corresponding to \(\lambda\).

In numpy, both eigenvalues and eigenvectors can be calculated simultaneously using the ** eig()** function.

```
import numpy as np
a = np.array([[2, 2, 1],
[1, 3, 1],
[1, 2, 2]])
print("a = ")
print(a)
w, v = np.linalg.eig(a)
print("\nEigenvalues:")
print(w)
print("\nEigenvectors:")
print(v)
```

The sum of eigenvalues (1+5+1=7) is equal to the trace (2+3+2=7) of the same matrix! The product of the eigenvalues (1x5x1=5) is equal to the determinant (5) of the same matrix!