"""
# NumPy 

NumPy (or Numpy) is a Linear Algebra Library for Python, the reason it is so important for Data Science with Python is that almost all of the libraries in the PyData Ecosystem rely on NumPy as one of their main building blocks.

Numpy is also incredibly fast, as it has bindings to C libraries. 

Helps in working with arrays and matrices in python
"""
"""
## Using NumPy

Once you've installed NumPy you can import it as a library:
"""
import numpy as np
"""
# NumPy Operations
"""
"""
## Arithmetic

Numpy arrays allow us to directly apply a range of operations such as addition, subtraction etc. The operators are overloaded in the library which makes the process possible. The operations occur on an element by element basis.

Use `+`, `-`, `*`, `/` and `**` to perform element wise addition, subtraction, multiplication, division and power.

**Numpy performs element wise operations**
"""
x = np.array([1, 2, 3, 4, 5])
y = np.array([6, 7, 8, 9, 10])
x + y
x - y
x * y
x / y
x ** 2
arr = np.array([0, 1, 2, 3, 4, 5])
# Warning on division by zero, but not an error!
# Just replaced with nan
arr/arr
# Also warning, but not an error instead infinity
1/arr
# Also warning, but not an error instead -infinity
-1/arr
"""


# Numpy Arrays

NumPy arrays are the main way we use Numpy. Numpy arrays essentially come in two flavors: vectors and matrices. Vectors are strictly 1-d arrays and matrices are 2-d (but you should note a matrix can still have only one row or one column).

Let's begin our introduction by exploring how to create NumPy arrays.

## Creating NumPy Arrays

### From a Python List

We can create an array by directly converting a list or list of lists:
"""
my_list = [1,2,3]
my_list
np.array(my_list)
# Find if a value exists in the array
# Returns true if value exists else returns false

integerArray = np.array(my_list)

2 in integerArray # Returns true
my_matrix = [[1,2,3],[4,5,6],[7,8,9]]
my_matrix
np.array(my_matrix)
"""
---
We can also define the output array type
"""
# Define an array arr with integer elements 1,2,3 and 4 of type int
integerArray = np.array([1,2,3,4], int)

print(integerArray)
"""
## Built-in Methods

There are lots of built-in ways to generate Arrays
"""
"""
### arange

Return evenly spaced values within a given interval.

Returns integer having the given step size between them
"""
np.arange(0,10)
np.arange(0,11,2)
"""
### zeros and ones

Generate arrays of zeros or ones
"""
np.zeros(3)
np.zeros((5,5))
np.ones(3)
np.ones((3,3))
"""
### Universal Array Functions

Numpy comes with many [universal array functions](http://docs.scipy.org/doc/numpy/reference/ufuncs.html), which are essentially just mathematical operations we can use to perform the operation across the array.
"""
arr = np.array([0, 1, 2, 3, 4, 5])
#Taking Square Roots
np.sqrt(arr)
#Calcualting exponential (e^)
np.exp(arr)
np.max(arr) #same as arr.max()
np.sin(arr)
np.log(arr)
"""
### linspace
Return evenly spaced numbers over a specified interval.

Returns the given no of points between the given range
"""
# 3 evenly spaced points from 0-10
np.linspace(0,10,3)
# 10 evenly spaced points from 0-5
np.linspace(0, 5, 10)
np.linspace(0,10,50)
"""
## eye

Creates an identity matrix
"""
np.eye(4)
"""
## diag

`diag` extracts a diagonal or constructs a diagonal array.
"""
y = np.ones(3)

np.diag(y)
"""
## Concatenate

Combines 2 arrays and returns a single array
"""
integerArray = np.array([1,2,3,4])
integerArray2 = np.array([5,6])

# Concatenate two arrays
np.concatenate((integerArray, integerArray2))
"""
---
Concatenate function on multidimensional arrays can be performed on any axis. For a two dimensional array (array[row][column]), to concatenate along row, we set axis as 0 (default is also 0). To concatenate along column, we set axis to 1
"""
# Concatenation of multi-dimensional arrays
arr1 = np.array([[1,2], [3,4]])
arr2 = np.array([[5,6], [7,8]])

np.concatenate((arr1, arr2))
# Based on dimension 1 (Column)

np.concatenate((arr1, arr2), axis=0)
# Based on dimension 2 (Row)

np.concatenate((arr1, arr2), axis=1)
"""
## Repeat

Repeat elements of an array using `repeat`.
"""
# Repeating element wise

np.repeat([1, 2, 3], 3)
# Repeating the whole list 

np.array([1, 2, 3] * 3)
"""

## Combining Arrays
"""
"""
---
Use `vstack` to stack arrays in sequence vertically (row wise).
"""
p = np.ones([2, 3])

np.vstack([p, 2*p])
"""
---
Use `hstack` to stack arrays in sequence horizontally (column wise).
"""
np.hstack([p, 2*p])
"""
## Random 

Numpy also has lots of ways to create random number arrays:

### rand
Create an array of the given shape and populate it with
random samples from a uniform distribution
over ``[0, 1)``.
"""
np.random.rand(2)
"""
---
We dont need to pass tuple, we can just pass the # row and columns for the random matrix
"""
np.random.rand(5,5)
# Getting the integer value of random int (0 - 9)

randomArray = np.floor(np.random.rand(10) * 10)
randomArray
"""
### randn

Return a sample (or samples) from the "standard normal" distribution. Unlike rand which is uniform:

Normal distribution, also known as the Gaussian distribution, is a probability distribution that is symmetric about the mean, showing that data near the mean are more frequent in occurrence than data far from the mean
"""
np.random.randn(2)
np.random.randn(5,5)
"""
### randint
Return random integers from `low` (inclusive) to `high` (exclusive).
"""
np.random.randint(1,100)
np.random.randint(1,100,10)
"""
## Array Attributes and Methods

Let's discuss some useful attributes and methods or an array:
"""
arr = np.arange(25)
ranarr = np.random.randint(0,50,10)
arr
ranarr
"""
## Shape

Shape is an attribute that arrays have (not a method):

(row, column)
"""
arr = np.array([1, 2, 3])

# Vector
arr.shape
"""
(3, ) indicates that it is a 1D array
"""
"""
**Getting Transpose**
"""
arr = np.array([[1, 2, 3], [4, 5, 6]])

arr
"""
---
Use `.T` to get the transpose.
"""
arr.T
arr.T.shape
"""
## Reshape
Returns an array containing the same data with a new shape.

**If we cant fill the new array, it will throw an error**
"""
arr = np.arange(25)

arr.reshape(5,5)
arr.shape
# Notice the two sets of brackets
arr.reshape(1,25)
arr.reshape(1,25).shape
arr.reshape(25,1)
"""
## Resize
"""
"""
---
`resize` changes the shape and size of array in-place.
"""
arr = np.arange(9)
arr
arr.resize(3, 3)
arr
"""
## Math Functions

Numpy has many built in math functions that can be performed on arrays.
"""
a = np.array([-4, -2, 1, 3, 5])
# Sum of array elements
a.sum()
# Max of array elements
a.max()
# Min of array elements
a.min()
# Mean of array elements
a.mean()
# Standard Deviation
a.std()
"""
---
`argmax` and `argmin` return the index of the maximum and minimum values in the array.
"""
# Gives the index at which the max maximum value is in the array

a.argmax()
# Gives the index at which the max maximum value is in the array

a.argmin()
x = np.array([[5,2,3], [3,4,5], [1,1,1]])
# Get unqiue values
np.unique(x)
# Get diagonal values
x.diagonal()
# Sort values in the multidimensional array
np.sort(x)
"""
### dtype , astype

You can also grab the data type of the object in the array:
"""
arr = np.array([1, 2, 3, 4, 5])
"""
---
Use `.dtype` to see the data type of the elements in the array.
"""
arr.dtype
"""
---
Use `.astype` to cast to a specific type.
"""
arr = arr.astype('f')
arr.dtype
"""
# NumPy Indexing and Selection
"""
#Creating sample array
arr = np.arange(0,11)
#Show
arr
"""
## Bracket Indexing and Selection
The simplest way to pick one or some elements of an array looks very similar to python lists:
"""
#Get a value at an index
arr[8]
# We can also assign -ve index (counts from the back)
arr[0], arr[4], arr[-1]
#Get values in a range
arr[1:5]
#Get values in a range
arr[0:5]
arr[5:]
# Use negatives to count from the back.
arr[-4:]
"""
---
A second `:` can be used to indicate step-size. `array[start:stop:stepsize]`

Here we are starting 5th element from the end, and counting backwards by 2 until the beginning of the array is reached.
"""
arr[-5::-2]
"""
## Broadcasting


**Broadcasting helps in performing element wise operations on arrays of different dimensions.**

**The smaller array is broadcast to the size of the larger array so that they have compatible shapes.**

Numpy arrays differ from a normal Python list because of their ability to broadcast:
"""
#Setting a value with index range (Broadcasting)
arr[0:5]=100

#Show
arr
# Add a row to a matrix
mat = np.array([[1,2,3], [9,8,7]])
arr = np.array([1,1,1])

# On adding the two, arr will get broadcasted and will get added as a matrix with both rows same as [1,1,1]
mat + arr
arr = np.arange(0,11)

#Show
arr
#Important notes on Slices
slice_of_arr = arr[0:6]

#Show slice
slice_of_arr
#Change Slice
slice_of_arr[:]=99

#Show Slice again
slice_of_arr
"""
**Now note the changes also occur in our original array!**
"""
arr
"""
**Data is not copied, it's a view of the original array! This avoids memory problems!**
"""
"""
## Copying Array

Be careful with copying and modifying arrays in NumPy!
"""
"""

`arr_copy` is a slice of `arr`

**Here r and r2 point to the same array**
"""
arr = np.arange(10)
print(arr) 

arr_copy = arr[: 5]      # Here arr_copy and arr points to the same array
print(arr_copy)
# Showing that arr and arr_copy points to the same array

arr_copy[:] = 0

print(arr)
"""
On changing arr_copy elements, we can see it those elements also got changed in the original array
"""
"""
------
To avoid this, use `arr.copy` to create a copy that will not affect the original array

**To make arr_copy and arr point to different arrays use copy() method**
"""
arr = np.arange(10)

#To get a copy, need to be explicit
arr_copy = arr.copy()

arr_copy
# Showing that arr and arr_copy points to the same array

arr_copy[:] = 0

print(arr)      # now we can see that changing arr_copy elements have no effect on arr elements
"""
## Indexing a 2D array (matrices)

The general format is **arr_2d[row][col]** or **arr_2d[row,col]**. I recommend usually using the comma notation for clarity.
"""
arr_2d = np.array(([5,10,15],[20,25,30],[35,40,45]))

#Show
arr_2d
#Indexing row
arr_2d[1]
# Format is arr_2d[row][col] or arr_2d[row,col]

# Getting individual element value
arr_2d[1][0]
# Getting individual element value
arr_2d[1,0]
# 2D array slicing

#Shape (2,2) from top right corner
arr_2d[:2,1:]
#Shape bottom row
arr_2d[-1]
#Shape bottom row
arr_2d[2,:]
arr_2d[:2, :-1]
"""
---
This is a slice of the last row, and only every other element.
"""
arr_2d[-1, ::2]
"""
### Fancy Indexing

Fancy indexing allows you to select entire rows or columns out of order,to show this, let's quickly build out a numpy array:
"""
#Set up matrix
arr2d = np.zeros((10,10))
#Length of array
arr_length = arr2d.shape[1]
#Set up array

for i in range(arr_length):
    arr2d[i] = i
    
arr2d
"""
Fancy indexing allows the following
"""
arr2d[[2,4,6,8]]
#Allows in any order
arr2d[[6,4,2,7]]
"""
## More Indexing Help
Indexing a 2d matrix can be a bit confusing at first, especially when you start to add in step size. Try google image searching NumPy indexing to fins useful images, like this one:
"""
"""
## Selection

Let's briefly go over how to use brackets for selection based off of comparison operators.
"""
arr = np.arange(1,11)
arr
arr > 4
bool_arr = arr > 4
bool_arr
arr[bool_arr]
arr[arr > 2]
x = 2
arr[arr > x]
arr[arr > 5] = 10

arr
"""
## Compare Arrays

We can also use comparison operators to compare complete arrays with one another
"""
arr1 = np.array([[1,2], [3,4]])
arr2 = np.array([[5,6], [7,8]])
# We can compare complete arrays of equal size element wise
arr1 > arr2
# We can compare elements of an array with a given value
arr1 == 2
# Also warning, but not an error instead -infinity
-1/arr
"""
## Vector and Matrix Operations
"""
matrix1 = np.array([[1,2,3], [4,5,6], [7,8,9]])
matrix2 = np.array([[1,1,1], [0,0,0], [1,1,1]])
"""
---
**Dot Product:**  

$ \begin{bmatrix}x_1 \ x_2 \ x_3\end{bmatrix}
\cdot
\begin{bmatrix}y_1 \\ y_2 \\ y_3\end{bmatrix}
= x_1 y_1 + x_2 y_2 + x_3 y_3$
"""
# Dot operator
np.dot(matrix1, matrix2)
# Cross operator
np.cross(matrix1, matrix2)
# Outer operator
np.outer(matrix1, matrix2)
# Inner operator
np.inner(matrix1, matrix2)
"""
Thanks, and dont forget to upvote.
"""