import numpy as np

"""
Explanation: NumPy
NumPy is a Python library that provides ndarray (a multi-dimensional array structure) and a wide range of functions to perform operations on ndarray and mathematical operations. <a href="https://docs.scipy.org/doc/numpy/reference/">NumPy Documentation</a>.
End of explanation
"""


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

my_vector.shape

my_vector.dtype

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

my_matrix.shape

# Find the length of each element in bytes
my_matrix.itemsize

my_matrix2 = np.array([[1, 2], [3, 4]], dtype=np.int8)
my_matrix2.itemsize

"""
Explanation: The ndarray structure
The ndarray data structure is essential in NumPy. It is an n-dimensional array that can contain dtype objects.
End of explanation
"""


# Create an uninitialised array of specified shape and dtype
np.empty(shape=(4,4),dtype=np.int8)

np.zeros(4)

np.zeros((4,4))

np.zeros((4,4)) + 42

# Create a new zero matrix of the same shape as another matrix.
np.zeros_like(my_matrix)

np.ones(4)

np.ones((4,4))

# Similar to Python's built-in range() function
np.arange(start=0, stop=10, step=2) 

# Like arange() but instead of a step size, we specify the 
# number of values that we need. It generates lineary-spaced
# numbers in the given interval
np.linspace(start=10, stop=20, num=5)

# Generate numbers that are evenly spaced on a logarithmic scale
np.logspace(start=1, stop=2, num=10)

"""
Explanation: Array creation methods
End of explanation
"""


arr1 = np.array([10, 87, 86,  5,  4, 38, 94, 76, 12, 17])
arr1

arr1.max(), arr1.argmax()

arr1.min(), arr1.argmin()

arr1_copy = arr1.copy()
arr1_copy

"""
Explanation: Aggregate methods (min and max)
End of explanation
"""


matrix1 = np.arange(1,26).reshape(5,5)
matrix1

# Sum values in the matrix
matrix1.sum()

# Sum values by column
matrix1.sum(0) 

"""
Explanation: Summations
End of explanation
"""


prime_numbers = np.array([2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97])
prime_numbers

prime_numbers.reshape(5, 5)

"""
Explanation: Transform a 1D array into a 2D array
End of explanation
"""


np.eye(4)

"""
Explanation: Identity Matrix
End of explanation
"""


# Use NumPy to generate four random numbers between 0 and 1
np.random.rand(4)

np.random.rand(4, 4)

np.random.randint(1, 101)

# Generates 10 random integers between 1 and 100
np.random.randint(1, 101, 10) 

"""
Explanation: Random Numbers
End of explanation
"""


# Generate four numbers from the normal distribution centred around zero
np.random.randn(4)

np.random.randn(4, 4)

"""
Explanation: Sample from the normal distribution
End of explanation
"""


arr2 = np.arange(0, 101, 10)
arr2

arr2[2]

# Use Python's slice notation to fetch elements from the array
arr2[3:6] 

arr2[3:]

arr2[:4]

# Boolean indexing 
arr2[arr2 > 5]

arr2 > 5

arr_with_nans = np.array([np.nan, 1,2,np.nan,3,4,5])
arr_with_nans

# Get an array where NaN elements are omitted
arr_with_nans[~np.isnan(arr_with_nans)]

matrix2 = np.arange(1, 26).reshape(5,5)
matrix2

matrix2[1]

matrix2[1,2] # same as matrix[1][2]

matrix2[1:4,1:4]

# Use ellipsis to get elements from the third column
matrix2[...,2]

# Fetch elements placed at corners of the 5x5 array
rows = np.array([[0,0],[4,4]])
cols = np.array([[0,4],[0,4]])
matrix2[rows, cols]

"""
Explanation: Indexing and slicing
End of explanation
"""


arr3 = np.array([9, 4, 4])
arr3

np.tile(arr3, (4, 1))

np.tile(arr3, (5, 2))

"""
Explanation: Tiling
Tiling is a common and useful operation when we need to extend an array so that its shape matches another NumPy array. By doing so, we can use the 'tiled' array in applying some element-wise operation between them.
End of explanation
"""


macro_nutrients = np.array([[0.3, 2.5, 3.5],
                            [2.9, 27.5, 0],
                            [0.4, 1.3, 23.9],
                            [14.4, 6, 2.3]])
calories_per_macro = np.array([9, 4, 4])
macro_nutrients * calories_per_macro

arr4 = np.arange(0, 10)
arr4

arr4[0:4] = 10
arr4

"""
Explanation: Broadcasting
Broadcasting is Numpy's terminology for performing mathematical operations between arrays of different shapes. If certain assumptions hold, the smaller of the two arrays is said to be broadcast to the size of the larger array in order to make the two arrays compatible so  element-to-element operations can be performed. <a href="http://eli.thegreenplace.net/2015/broadcasting-arrays-in-numpy/">Read more</a>.
End of explanation
"""


arr5 = np.arange(0, 10)
arr6 = np.arange(10, 20)

arr5

arr6

arr5 + arr6

arr6 - arr5

arr5 * arr6

arr5 + 10 # broadcasting

arr6 - 10

arr5 ** 2

# NumPy generates a warning if we attempt to divide by zero.
arr5 / arr5

1 / arr5

"""
Explanation: Arithmetic
End of explanation
"""


arr7 = np.array([2, 6, 7, 10, 45, 200])
arr7

# Computes the square root of each element of the array
np.sqrt(arr7)

np.exp(arr7)

np.log(arr7)

"""
Explanation: Universal functions
A universal function (ufunc) is a mathematical function that operates on arrays in an element-by-element fashion supporting array broadcasting, type casting, and several other standard features. For more information read the <a href="https://docs.scipy.org/doc/numpy/reference/ufuncs.html">ufunc documentation</a>.
End of explanation
"""


