# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# ### Numpy is the numerical computing package in Python

import numpy as np

# The import statement is used to import external libraries. All functions within the imported library are now accesible through that library's namespace. Since we imported numpy as "np", we can now access the functions within the Numpy library through: np.\<function name\>

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

np.arange(10)

np.arange(0, 10, 1)

# The arange function takes in the "start" value, "stop" value, and the "step" size as inputs and returns an evenly spaced array. For more information, read the documentation: 

# + jupyter={"outputs_hidden": true}
# np.linspace?
# -

np.linspace(0, 9,10)

np.zeros(10)

np.array([ [1,1], [1,1] ])

#arr = np.ones( (2,2) )
arr = np.ones ( (3,4) )
arr

# Above, we created a matrix with 3 rows and 4 columns. It has a shape of 3 x 4

arr.shape

np.full_like(np.zeros(10), 10)

np.ones(10) * 10

x = 2 #integer
y = "this is a string"
x * y

# ## Why use numpy?
#
# Firstly, numpy provides access to efficient and optimized implementation of array handling operations. For instance, let's repeat the exercise from the previous notebook, where we created a list of 1000 elements and performed certain operations on each element.

# %%timeit
x = []
for i in range(0, 1000, 1):
    x.append(i**2 + 0.5 * i + 2.5)

# %%timeit 
xn = np.arange(0, 1000, 1)
xn = xn**2 + 0.5 * xn + 2.5

# <br/><br/>
# Numpy is ~2 orders of magnitude faster!
#
# The pure Python way of doing things is slower because Python is a dynamically typed language and the compiler may not implement memory optimizations such as loading up the array elements into memory before the operation is carried out.
#
# But the numpy way of doing things is faster because the underlying code is written in C, and we get all the optimization that comes with having defined types and compiler optimizations with memory management. We also avoid the overheads that come with storing the data type and checking it before every operation is carried out, leading to much faster running code!

# ### Indexing in numpy

x = np.arange(5, 10, 1)

x

x.shape

print(x[0], x[1], x[2], x[3], x[4])

print(x[5])

# #### Index 5 threw an error because our array has elements in the 0th, 1st, 2nd, 3rd, and 4th positions only. Remember, indexing in Python begins from 0

x[ [0,1,2,3] ]

# You can call elements of the array by passing an array of indices!

x[ np.arange(4) ]

# The above two cells are equivalent to each other.

# ### Boolean indexing:
# Pass an array of boolean (True/False) values and corresponding elements of the original array are selected

x[[True, False, True, False, True]]

x[((x % 2) == 1)]

# What happened above?

x % 2

# The percentage sign is known as the modulo operator, which returns the remainder after division. So 5 divided by 2 is equal to 2 with a remainder of 1, 6 divided by 2 is equal to 3 with a remainder of 0 and so on...

(x % 2) == 1

# The above is a logical test for each element of the array produced after the modulo operation. And finally, we pass the boolean array produced from this logical test as boolean indexers of the original array!

# ### Slicing

print(x[0: 5])

print(x[-1], x[-2], x[-3], x[-4], x[-5])

# Numpy slicing operator is ":" the colon. Use the operator with the lower index and the higher index to access slices of the array

x[0:3]

x[0:-1]

x[0:-2]

# You can step over elements of the array using double colons ::<step_size> followed by the step size

x[::2]

x[::-1]

# It also works in reverse!

x[::-2]

# The indexing and slicing operations work on any N dimensional array. Just seperate the indices by commas:

twoDarray = np.array([ [1,2,3], 
              [4,5,6],
              [7,8,9]])

twoDarray

# The same can be created using the reshape function in numpy

np.arange(1, 10, 1).reshape(3,3)

twoDarray[0,0]

twoDarray[0,1]

twoDarray[1,0]

twoDarray[ [0, 1, 2], [0, 0, 0] ]

twoDarray[:, 0]

# The above two operations are equivalent, can you work out how? In the 1st case, an array of row indices and an array of column indices were passed. The corresponding elements located at (0,0), (1,0), and (2, 0) were retrieved. In the 2nd cell, the slicing operator selects all the rows and the 0th column.
#
# To see the slicing operation more explicitly, see below:

twoDarray[0:3, 0]

# ### Array operations
#
# All operations on the numpy object are by default applied to every element in the array

x

x + 10

x

x = (x + 10) / 10

x

x[3] = x[0] * 5

x

# ### Exercise 03: Using both arange and linspace
#
# 1. Create an array of length 5, 10, 100, 1000 of equally spaced numbers between 0 and 1 
# 1. Create a 2 dimensional array, "arr", of shape 3x3, with the first row having all 1's, second row having all 2's, and the third row having all 3's
# 1. Access the diagonal elements of "arr" created above and multiply it by 10
# 1. Access the first column of "arr" and add it with the third column of "arr"

# ## Exercise 04: Fancy indexing
#
# 1. Create an array ranging from 0 to 100: arange(0, 100, 1).
# 1. Reshape this array into 10 rows and 10 columns.
# 1. Using arrays of indices, select the diagonal elements alone.
# 1. Using slicing operators, select the 1st 3 elements of the 1st row.
# 1. Select all elements that are greater than 49.
# 1. use np.triu_indices to extract the upper triangular elements of the matrix (all elements that lie on or above the diagonal)

# +
# # %load ./solutions/sol_np_reshape.py

StarcoderdataJupyterScriptsDedupFiltered