# Let's break down - Numpy for beginners

In this blog, we will start with the basics of the famous python library called Numpy and gradually advance to the complex, and advanced topics. Let us begin this tutorial on NumPy with a brief introduction of what the library is all about.

### **Introduction to NumPy**

Numpy (Numerical Python) is a scientific computation library that helps us work with various derived data types such as arrays, matrices, 3D matrices and much more. You might be wondering that as these provisions are already available in vanilla python, why one needs NumPy. Here are few reasons to work with NumPy.

> ***Note:*** *It’s completely fine if you do not understand the code snippets that are shown below. The point here is to show the advantage of using NumPy over standard python. Dont worry; we will be going through everything one step at a time :)*

**1\. NumPy consumes Less Memory**

NumPy consumes approximately 6 to 7 times less memory for storing the data than normal Python does. The following code cell compares the memory consumed for storing a list of numbers from 0 to 100 by Numpy and normal Python. You can see the difference yourself!

```python
import numpy as np
import sys
python_list = list(range(100))
numpy_array = np.array(list(range(100)))
sizeof_python_list = sys.getsizeof(1) * len(python_list) # Size = 168
sizeof_numpy_array = numpy_array.itemsize * numpy_array.size
print(f'Numpy array consumes {sizeof_python_list/sizeof_numpy_array} times less memory than python lists')
```

**2\. NumPy Computations are Faster!**

Numpy computations are generally faster than normal python computations. The main reason for this is that Numpy leverages the power of the language called C. Most of the NumPy functions are implemented in C in the ba, making it much faster than normal python lists. Let us experiment with the speed on our own below:

```python
import numpy as np
import time
python_list = list(range(100000))
numpy_array = np.array(list(range(100000)))

start_py = time.time()
result_python_list=python_list*75
end_py = time.time()
print(f'Python list takes {round(end_py-start_py,4)} seconds for computation')

start_np = time.time()
result_numpy_array=numpy_array*75
end_np = time.time()
print(f'Numpy arrays take {end_np-start_np} seconds for computation')
```

**3\. Numpy is Powerful**

Numpy has a ton of built-in functions that can come to aid. It also makes it easier to work with higher dimensional data such as multi-dimensional matrices and so on.

And now, with no further ado, let’s begin with the basics of NumPy!

## **Setting up the NumPy Environment**

### **Installing Numpy**

We can install NumPy in our Python environment with the following command

`pip install numpy or conda install numpy`  

## **Creating NumPy Arrays**

### **Creating a 1D Numpy array**

An array in NumPy in various ways.

* Create an empty array and append values to the array later.
    
* Create a NumPy array with values. (You can still append values to it as and when needed).
    
* Create a Python list and convert that Python list to a NumPy array.
    

```python
# Importing the NumPy package
import numpy as np


# Creating an empty array
array1 = np.array([])
print(array1, type(array1))


# Creating an array with values
array2 = np.array([1,2,3,4,5,6])
print(array2, type(array2))


# Convert a python list to numpy array
py_list = list([1,2,3,4,5,6])
array3= np.array(py_list)
print(array3, type(array3))
```

### **Creating 2D and 3D NumPy Arrays**

You can also create numpy arrays with more than one dimension such as 2D and 3D arrays

```python
# creating 2D array
array2d = np.array(
[ [1,2],
[3,4],
[5,6]
])
print(array2d, type(array2d))
# creating 3D array
array3d = np.array(
[[
[1,2],
[3,4],
[5,6]
],
[
[10,20],
[30,40],
[50,60]
]]
)
print(array3d, type(array3d))
```

### **Creating a NumPy array from Pandas Dataframes**

**Pandas Dataframes** can also be converted to a NumPy array easily as shown below

```python
import pandas as pd
df = pd.DataFrame({
'a':[1,2,3],
'b':[10,20,30],
'c':[100,200,300]
})
arr_df = np.array(df)
print(arr_df, type(arr_df))
```

**Converting a NumPy Array to a Python List**

Just like you created a numpy array from a python list, you can also conveniently convert a numpy array to a python list.

```python
# numpy array to python list
np_arr = np.array([1,2,3,4,5,6])
py_list = list(np_arr)
print(py_list, type(py_list))
```

### **Creating Special NumPy Arrays**

Besides everything that we saw about creating NumPy arrays, we can also create specific unique arrays easily using the built-in functions of NumPy. Let us see how to make the following.

1. A NumPy array with random values
    
2. A NumPy array full of zeros
    
3. A NumPy array full of ones
    
4. A NumPy array with values lying within a specified range
    
5. An Identity matrix
    

#### **NumPy Array with Random Values**

Lets say we want to create 2 x 2 a numpy array with random integer values which should lie between the range of 10 to 20. Heres how we can do ot with the **np.random.randint()** function. These values change everytime we run the function

```python
rand_array = np.random.randint(low=10, high=20, size=(2,2))
print(rand_array, type(rand_array))
```

In the above function, we have specified a single low and high value. What if we want each column to have a different range of values? That is also possible, and the code snippet below demonstrates how to do it!

```python
# Gives an array with first column values ranging between 10 - 20 and second column values ranging between 100 - 200
rand_array = np.random.randint(low=[10,100], high=[20,200], size=(2,2))
print(rand_array, type(rand_array))
```

We can also create arrays with values that follow a normal distribution using the  **np.random.rand()**  function

```python
rand_normal_arr = np.random.rand(2,2)
print(rand_normal_arr, type(rand_normal_arr))
```

#### **NumPy Arrays with only Zeros/ones**

Let us now create a numpy array of zeroes and ones using the **np.zeros()** and **np.ones()**  functions.

```python
# creating a 1D array of zeros with length 5
arr_zeros_1d = np.zeros(5)
print("1D array of zeros \n",arr_zeros_1d, type(arr_zeros_1d))
# creating a 2 x 2 matrix of zeros
arr_zeros = np.zeros((2,2))
print("2D matrix of zeros \n",arr_zeros, type(arr_zeros))
# creating a 1D array of ones with length 5
arr_ones_1d = np.ones(5)
print("1D array of ones \n",arr_ones_1d, type(arr_ones_1d))
# creating a 2 x 2 matrix of ones
arr_ones = np.ones((2,2))
print("2D matrix of ones \n",arr_ones, type(arr_ones))
```

#### **NumPy Arrays with values between a specified range**

With the **np.arange()**  function in NumPy, we can create arrays with a range of values. This function takes three arguments namely start, stop and step. The start and stop specify the upper and lower limit respectively. The step parameter refers to the spacing between the values. The default value for the step is 1. If the step parameter is negative, the spacings are calculated in a reverse manner. *When the steps are negative, the start value should be greater than the stop value. Otherwise, an empty array will be returned.*

```python
arr_range_1 = np.arange(start=1, stop=12, step=1)
print('Array with start = 1; stop = 12; step = 1 \n', arr_range_1)

arr_range_2 = np.arange(start=1, stop=12, step=2)
print('Array with start = 1; stop = 12; step = 2 \n', arr_range_2)

arr_range_3 = np.arange(start=12, stop=1, step= -1)
print('Array with start = 12; stop = 1; step = -1 \n', arr_range_3)
```

#### **Creating an Identity matrix in NumPy**

Indentity matrices can also be created using Numpy using the **np.identity()** function. Identity matrices are square matrices with its main diagonal elements as 1 and the remaining elements as 0.

```python
identity_Arr = np.identity(4)
print("4 x 4 identity matrix \n", identity_Arr)
```

## **Manipulating NumPy Arrays**

### **Adding elements to a NumPy array**

So far we saw how to create a NumPy array. Let us now understand how to add elements to a numpy array

```python
# creating a numpy array
org_array = np.array([1,2,3,4,5,6])
print(org_array, type(org_array))

# appending values to that array
appended_array = np.append(org_array, [10,20,30])
print(appended_array, type(appended_array))
```

We can also append values to a two dimensional array and heres how we do it using the  **np.append()**  function

```python
# creating 2D array
org_array2d = np.array(
[
[1,2],
[3,4],
[5,6]
])
print('Before appending \n',org_array2d, type(org_array2d))

# appending values to that array with axis = 0
appended_array2d = np.append(org_array2d, [[10,20]], axis=0)
print('After appending with axis = 0\n',appended_array2d, type(appended_array2d))

# appending values to that array without specifying axis parameter
appended_array2d = np.append(org_array2d, [[10,20]])
print('After appending without axis\n',appended_array2d, type(appended_array2d))
```

In the np.append() function, the axis value is set to None by default. It means that both the original array and the array to be appended will be flattened to its lower dimension, and then the new array will be appended. When we set axis by axis = 0, the values are appended row-wise.

### **Removing elements from a NumPy array**

We can remove any desired element from a numpy array using the  **np.delete()**  function. The  **np.delete()**  function basically takes the list and the index positions to be deleted as the parameters. Indexing in numpy is same as that of indexing python lists.

```python
rand_array = np.random.randint(low = 50, size = 10)
print("Array before deleting \n", rand_array)
new_array = np.delete(rand_array, obj = [3,4,5])
print("Array After deleting \n", new_array)
```

We can notice that the 3rd, 4th and 5th elements are deleted (Indexing in python starts from 0)

### **Reshaping a NumPy array**

We can use the **shape** method to determine the dimension of any NumPy array.

```python
array_np_1 = np.array(
[
[1,2],
[3,4],
[5,6]
])
print("The shape is :",array_np_1.shape)

array_np_2 = np.array([1,3,5,7,9,11])
print("The shape is :",array_np_2.shape)
```

It is also possible to change the shape of an array at any given point using the  **np.reshape()** function in numpy

```python
rand_array = np.random.randint(low=10, high=20, size=(3,2))
print("The original array is \n",rand_array)
print('The shape of the original array is :', rand_array.shape)
reshaped_array = np.reshape(rand_array, newshape =(2, 3))
print("The reshaped array is \n",reshaped_array)
print('The shape of the array after reshaping is :', reshaped_array.shape)
```

In the above example, we changed the dimensions of a 3 x 2 matrix to 2 x 3. But a lot more can be done using the reshape function. We can even change a 1D array to a 2D array or change a 2D array to 1D and much more!

```python
#2D to 1D
rand_array = np.random.randint(low=10, high=20, size=(3,2))
print("The original array is \n",rand_array)
print('The shape of the original array is :', rand_array.shape)
reshaped_array = np.reshape(rand_array, newshape = (6,))
print("The reshaped array is \n",reshaped_array)
print('The shape of the array after reshaping is :', reshaped_array.shape)
```

```python
#1D to 2D
rand_array = np.random.randint(low=10, high=20, size=10)
print("The original array is \n",rand_array)
print('The shape of the original array is :', rand_array.shape)
reshaped_array = np.reshape(rand_array, newshape = (5,2))
print("The reshaped array is \n",reshaped_array)
print('The shape of the array after reshaping is :', reshaped_array.shape)
```

```python
#3D to 2D
rand_array = np.random.randint(low=10, high=20, size=(2,3,4))
print("The original array is \n",rand_array)
print('The shape of the original array is :', rand_array.shape)
reshaped_array = np.reshape(rand_array, newshape = (6,4))
print("The reshaped array is \n",reshaped_array)
print('The shape of the array after reshaping is :', reshaped_array.shape)
```

We have now seen a lot of combinations using reshape. In all the above examples, every time we provide a new shape, we have made sure that it is compatible with the original shape. For example, let us say that the original array is of the dimension 4 x 2. It means that there are 8 elements in the array. Now when we try to reshape this array, you can reshape it into either of these following combinations

* 2 x 4
    
* 1 x 8
    
* 8 x 1
    
* 2 x 2 x 2
    

Any other combinations are not possible. So, when we provide the new dimension to the **np.reshape()** reshape function, we offer the above dimensions in the form of tuples such as (2,4),(1,8),(8,1),(2,2,2). Numpy gives us the provision to provide one of the new shape parameters as -1. It implies that it is an unknown dimension, and NumPy will figure out by itself using the number of elements in the array. Let us understand it with some examples..

```python
rand_array = np.random.randint(low=10, high=50, size=(4,2))
print("The original array is \n",rand_array)
print('The shape of the original array is :', rand_array.shape)

reshaped_array = np.reshape(rand_array, newshape = (-1,4))
print("The reshaped array is \n",reshaped_array)
print('The shape of the array after reshaping is :', reshaped_array.shape)
```

Notice that we provided (-1,4) as a newshape value to the  **np.reshape()** function but it automatically figured out that it is 2. Let us continue exploring

```python
# reshape using (-1,1)
reshaped_array = np.reshape(rand_array, newshape = (-1,1))
print("The reshaped array is \n",reshaped_array)
print('The shape of the array after reshaping is :', reshaped_array.shape)
```

```python
# reshape using (1,-1)
reshaped_array = np.reshape(rand_array, newshape = (1,-1))
print("The reshaped array is \n",reshaped_array)
print('The shape of the array after reshaping is :', reshaped_array.shape)
```

```python
# reshape using (-1,-1) ---> throws an error
reshaped_array = np.reshape(rand_array, newshape = (-1,-1))
print("The reshaped array is \n",reshaped_array)
print('The shape of the array after reshaping is :', reshaped_array.shape)
```

You must have by now understood why can’t we specify more than one dimension value to be -1. The NumPy package can identify the unknown dimension only if the other dimensions are explicitly mentioned and this is obvious because, if we have an original array of 4 x 3 dimension, and if want the new reshaped array to have 2 columns, then the number of rows can be easily figured out by calculating (4 x 3) / 2 which is equal to 6. But when we dont know the number of columns, it is impossible to calculate the unknown dimension value.  

### **Sorting NumPy arrays**

We can easily sort a numpy array using the  **np.sort()**  function.

```python
arr = np.random.randint(low=20, size = 12)
print("The actual array is \n", arr)
print("The sorted array is \n",np.sort(arr))
```

We can also sort 2D arrays both row wise and column wise sing the axis parameter.

```python
arr = np.random.randint(low=10, high=30, size = (4,3))
print("The actual array is \n", arr)
print("The column wise sorted array is \n",np.sort(arr, axis=0))
print("The row wise sorted array is \n",np.sort(arr, axis=1))
```

### **Flattening NumPy arrays**

Flattening an array is nothing but crushing down higher dimensional arrays to one dimension. There are 2 functions to execute this in Numpy. They are  **np.ndarray.flatten()** and  **np.matrix.flatten()** . The former is meant for all higher dimensional arrays while the latter is meant specifically for matrices

```python
arr_2d = np.random.randint(low=10, high=30, size = (4,3))
print("The actual array is :\n", arr_2d)
print('Array after flattening using method 1 :\n', np.ndarray.flatten(arr_2d))
print('Array after flattening using method 2 :\n', np.matrix.flatten(arr_2d))
```

### **Rotating NumPy arrays**

The **np.rot90()** function can be used to rotate a NumPy array by 90 degrees. This is explained with the example below

The parameter **k** specifies how many times the matrix has to be rotated. The **axes** parameter specifies on what axis the matrix has to be rotated. Considering a 2D array, if the axes value is specified as (0,1), then the array is rotated in the anti-clockwise direction and when the axes value is specified as (1,0) then the array is rotated in the clockwise direction.

```python
arr = np.array([[10,20],[30,40]])
print('The original array is \n', arr)
print('The array after rotation it by 90 degrees once in the anti-clock wise direction is \n', np.rot90(arr,k=1, axes=(0,1)))
print('The array after rotation it by 90 degrees once in the clock wise direction is \n', np.rot90(arr,k=1, axes=(1,0)))
print('The array after rotation it by 90 degrees twice in the clock wise direction is \n', np.rot90(arr,k=2, axes=(1,0)))
print('The array after rotation it by 90 degrees thrice in the anti-clock wise direction is \n', np.rot90(arr,k=3, axes=(0,1)))
```

## **Matrix Operations in Numpy**

If you are familiar with matrices in mathematics, then you would probably know about various matrix operations such as

* Matrix addition
    
* Matrix subtraction
    
* Matrix multiplication
    
* Matrix vector multiplication
    
* Matrix division
    
* Matrix transpose
    
* Matrix inverse
    
* Matrix Power
    
* Determining Diagonal Elements of a matrix
    
* Evaluating Upper and Lower triangle elements of a matrix
    

We will be understanding how to implement all the above operations using NumPy one by one.

### **Matrix Addition and Subtraction**

Matrix addition and subtraction can be easily done just by using the **+** and **\-** operators directly unless and until the dimensions of both the matrices are the same.

```python
mat1 = np.random.randint(low = 1, high= 10, size = (3,3))
print("Matrix 1 \n", mat1)
mat2 = np.random.randint(low = 10, high= 20, size = (3,3))
print("Matrix 2 \n", mat2)
print("Matrix 1 + Matrix 2 \n", mat1 + mat2)
print("Matrix 1 - Matrix 2 \n", mat1 - mat2)
```

We can also use the  **np.add()**  function to add two matrices and  **np.subtract()**  too subtract two matrices.

```python
mat1 = np.random.randint(low = 1, high= 10, size = (3,3))
print("Matrix 1 \n", mat1)
mat2 = np.random.randint(low = 10, high= 20, size = (3,3))
print("Matrix 2 \n", mat2)
print("Matrix 1 + Matrix 2 \n", np.add(mat1, mat2))
print("Matrix 1 - Matrix 2 \n", np.subtract(mat1,mat2))
```

### **Matrix Multiplication**

The  **np.matmul()**  function multiplies two matrices in the conventional manner.

```python
mat1 = np.random.randint(low = 1, high= 10, size = (3,2))
print("Matrix 1 \n", mat1)
mat2 = np.random.randint(low = 10, high= 20, size = (2,3))
print("Matrix 2 \n", mat2)
print("Matrix 1 x Matrix 2 (Matrix multiplication) \n", np.matmul(mat1, mat2))
```

If the two matrices are of the same dimension, then the **\*** operator does element wise multiplication

```python
mat1 = np.random.randint(low = 1, high= 10, size = (2,2))
print("Matrix 1 \n", mat1)
mat2 = np.random.randint(low = 10, high= 20, size = (2,2))
print("Matrix 2 \n", mat2)
print("Matrix 1 x Matrix 2 (Element wise multiplication) \n", mat1*mat2)
```

### **Matrix Division**

The  **np.divide()**  function helps us to divide two matrices. Division of two matrices can also be pulled off using the **/** operator.

```python
mat1 = np.random.randint(low = 1, high= 10, size = (2,2))
print("Matrix 1 \n", mat1)
mat2 = np.random.randint(low = 10, high= 20, size = (2,2))
print("Matrix 2 \n", mat2)
print("Matrix 1 / Matrix 2 (using / operator) \n", mat1/mat2)
print("Matrix 1 / Matrix 2 (using np.divide function) \n", np.divide(mat1,mat2))
```

Numpy is smart enough to broadcast the elements of the smaller matrix over the larger matrix if that is possible. Let us understand that with the example below

```python
mat = np.random.randint(low = 1, high= 10, size = (2,2))
print("Matrix\n", mat)
vec = np.random.randint(low = 10, high= 20, size = (2,1))
print("Vector\n", vec)
print("Matrix / Vector (using / operator) \n", mat/vec)
print("Matrix / vector (using np.divide function) \n", np.divide(mat,vec))
```

In the above example, mat is a 2 x 2 matrix while vec is a 2 x 1 vector. It can be seen from the results that the vector has been devided from each column of the matrix. Or in other terms, the vector has been broadcasted over the matrix during division.

### **Matrix Transpose**

Transpose is nothing but reversing the axes of a matrix. When a matrix is transposed, the row elements become the column elements and the column elements become the row elements. Transpose of a matrix can be obtained using the **np.transpose()** function.

```python
mat = np.random.randint(low = 1, high= 10, size = (2,2))
print("Original Matrix\n", mat)
print("Transposed Matrix\n", np.transpose(mat))
```

### **Matrix inverse**

The **np.linalg.inv()** function provides the inverse of a matrix.

*Calculating the inverse of a matrix is a little critical job and requires a stronghold in the basics of matrices. This tutorial mainly focuses on how we can leverage the NumPy package to perform these kinds of scientific computations rather than getting into its mathematical aspects, which is why we won’t be going through its mathematical background.*

```python
mat = np.array([
[1,2,3],
[0,1,4],
[5,6,0]
])
print("Original matrix \n", mat)
print("The inverse of the matrix is\n", np.linalg.inv(mat))
```

### **Matrix Power**

Matrix powers are not the same as we do for normal numbers. If A is a 2 x 2 matrix, then A^2 is A times A. Here A times A means matrix multiplication and not element-wise multiplication. Such kind of matrix powers can be derived using the **np.linalg.matrix\_power()** function in NumPy. In some cases, we might just need the individual squares of all the elements in the matrix. In such cases, we can use the **\*\*** operator.  

```python
mat = np.random.randint(low=1, high=10, size=(2,2))
print('The original matrix is \n', mat)
mat_square = np.linalg.matrix_power(mat,2)
print('The square of the matrix is \n', mat_square)
print('Square of all the elements in the matrix is \n', mat**2)
```

### **Extracting Diagonal of a matrix in NumPy**

The Main Diagonal of a square matrix has the elements that are present main diagonal (top left to bottom right). These diagonal elements can be extracted from the matrix using the **np.diag()** function. The parameter k denotes the diagonal that is required. When k=0, it returns the main diagonal elements. When k=1, it returns the diagonal elements above the main diagonal and when k=-1, it returns the diagonal elements one step below the main diagonal. The value of k is 0 by default. Below is an example.

```python
mat = np.array([
[1,2,3],
[0,1,4],
[5,6,0]
])
print("Original matrix \n", mat)
print("Main Diagonal elements of the matrix are: \n", np.diag(mat))
print("Elements of the matrix one step above the main diagonal are: \n", np.diag(mat, k=1))
print("Elements of the matrix one step below the main diagonal are: \n", np.diag(mat, k=-1))
```

### **Evaluating Upper and lower triangular matrix in NumPy**

Generally in a square matrix, the elements present above the main diagonal form the Upper triangle and the elements below the main diagonal from the lower triangle. These upper and lower triangular elements can be easily extracted using **np.triu()** and  **np.tril()** functions respectively. Just like we specified the k parameter for the **np.diag()** function, we can also specify the k parameter here to return a matrix with elements above/below the specified diagonal as 0.

```python
mat = np.array([
[1,2,3],
[0,1,4],
[5,6,0]
])
print("Original matrix \n", mat)
print('The upper triangular matrix \n', np.triu(mat))
print('The lower triangular matrix \n', np.tril(mat))
print('The lower triangular matrix with k=1 \n', np.tril(mat, k=1))
print('The upper triangular matrix with k=-1 \n', np.tril(mat, k=-1))
```

## **Indexing Numpy arrays**

Indexing is the most crucial part when it comes to array manipulations. Just like list indexing in python, indexing in numpy also begins with 0. The Numpy package has really powerful indexing methods. There are various kinds of indexing in Numpy. But in this tutorial, we will be categorising the indexing methods in the following manner.

* Basic indexing
    
* Indexing using slicing operator
    
* Indexing 2D arrays
    
* Indexing 3D arrays
    
* Advanced indexing using integer arrays
    
* Advanced indexing using Boolean conditions
    

### **Basic Indexing**

Let us start with the basics of indexing. Let us create an array using the  **np.arange()**  function.

```python
arr = np.arange(0,150,10)
print('The original array is \n', arr)
Output-
The original array is 
 [  0  10  20  30  40  50  60  70  80  90 100 110 120 130 140]
```

Let us try to grab the 5th element of the array. We can easily do that in the following manner

```python
print('The 5th element of the array is :', arr[5])
```

### **Indexing using slicing operator**

We can also obtain values within a range of index using the slicing technique. Indexing using slicing works in the **array\[start:stop:step\]** manner. The start and stop specify the index range’s upper and lower limits, and the step specifies the spacing between each index. Let us understand it with some examples

```python
print('The element of the array from 4th index to 10th index: \n', arr[4:10])
print('The element of the array from 2nd index to 12th index in steps of 2: \n', arr[2:12:2])
print('The element of the array from 14th index to 6th index in steps of -2:\n', arr[14:6:-2])
print('The element of the array from 3rd index to the end of the array in steps of 2:\n', arr[3::2])
# returns the array in a reversed manner
print('All the element of the array steps of -1: \n', arr[::-1])
```

### **Indexing 2D arrays**

Indexing a two-dimensional array is always done in an **array\[row, col\]** manner. All sorts of indexing techniques that we used previously while indexing 1D arrays like slicing, indexing using index lists can also be used here. Let us see a few examples to understand it better.

```python
arr = np.arange(0,250,10).reshape(5,5)
print('The original array is \n', arr)
```

```python
print('The element in 2nd row and 3rd column is:', arr[2, 3])
print('The element in 3rd row and 1st column is:', arr[3, 1])
print('All the elements in 3rd row are:', arr[3, :])
print('All the elements in 2nd column are:', arr[:, 2])
print('Elements in 2nd column with row indices ranging between 1 and 3 are:', arr[1:3, 2])
print('Elements in 4th row with column indices ranging between 0 and 3 are:', arr[4, 0:3])
print('Elements with row indices ranging between 1 and 3 and column indices ranging between 2 and 4 are: \n', arr[1:3, 2:4])
```

### **Indexing 3D arrays**

Imagine 3D arrays as different matrices stacked one on top of the other. So while indexing 3D arrays, we dont just mention the row and column index, but also mention on which matrix we should be looking for the specified row and column indices. 3D arrays are indexed in **array\[matrix, row, col\]** manner Let us see a few examples

```python
arr = np.arange(45).reshape(3,3,5)
print('The original array is \n', arr)
```

```python
print('The 0th 2D matrix: \n', arr[0])
print('The 1st 2D matrix: \n', arr[1])
print('The 2nd row of the 1st 2D matrix: \n', arr[1,2,:])
print('The 1st row of the 2nd 2D matrix: \n', arr[2,1,:])
print('The 0th column of the 1st 2D matrix: \n', arr[1,:,0])
print('The 3rd column of the 0th 2D matrix: \n', arr[0,:,3])
print('The element present in the 2nd row and 4th column of the 1st 2D matrix:', arr[1,2,4])
print('The element present in the 0th row and 3rd column of the 0th 2D matrix:', arr[0,0,3])
```

### **Advanced indexing using integer arrays**

As we discussed earlier, numpy has really powerful and sophisticated indexing methods and indexing using integer arrays is one among them. Let us consider the following array

```python
arr = np.arange(10)
print('The original array is \n', arr)
```

If we want only the 3rd, 5th and 9th elements we can easily extract those using integer array indexing. To do that, we will first have to create an integer array of indices

```python
index_arr_1 = np.array([3,5,9])
print('The index array is \n', index_arr_1)
The index array is 
[3 5 9]
```

Now we can easily pass this index array to our original array as follows

```python
print('The 3rd, 5th and 9th elements of the array are \n', arr[index_arr_1])
```

We can also repeat an index more than once using index arrays!

```python
index_arr_2 = np.array([2,3,2,3,2,3])
print('The array returned after indexing is \n', arr[index_arr_2])
```

### **Advanced indexing using boolean conditions**

We can also specify a logical condition to extract elements from the array. It returns the elements of the array for which the specified condition is true. The following example explains how to extract elements that are greater than 5 from an array.

```python
arr = np.arange(12) 
 print('The original array is \n', arr)
 print('The elements that are greater than 5 are \n', arr[arr>5])
 print('The elements that are lesser than 5 are \n', arr[arr<5])
 print('The elements that equal to 5 \n', arr[arr==5])
 print('The elements are even \n', arr[arr%2==0])
```

## **Saving NumPy arrays**

We can easily save any numpy array as a .npy file using the [**np.save**](http://np.save)**()** function and here is how to do!

```python
arr_to_save = np.arange(1,10).reshape(3,3)
np.save(file='array.npy', arr=arr_to_save).
```
