# NumPy

The **NumPy** and **Pandas** libraries are central to data science in Python. NumPy allows for the efficient analysis and processing of data arrays with varying sizes, shapes, and number of dimensions while Pandas allows for reading in and working with data tables. In this section, we will focus on NumPy.

After working through this module you will be able to:

- describe and use NumPy data types.
- describe the data type, size, shape, and number of dimensions in an array.
- create, reshape, and slice
**NumPy arrays**. - perform numeric and comparison operations on arrays.

## Creating NumPy Arrays

The **NumPy** library allows for creating and working with **arrays**. It is of specific value when you want to perform mathematical operations on values stored in arrays. Also, it is very fast and memory efficient due to its reliance on the C language. As mentioned in the prior modules, arrays are similar to **lists** in that they store a series of values or elements. However, arrays can be expanded to include many dimensions. For example, an image could be represented as an array with 3 dimensions: height, width, and channels. If you work with **deep learning**, **tensors** are the primary data model used to read and manipulate data and are essentially multidimensional arrays that can be stored in RAM or within GPU memory for faster computation. In short, array-based calculations and manipulations are essential to data science, so NumPy is an important library to learn if you work in the Python environment.

The complete documentation for NumPy can be found here.

Before you can use NumPy, you must make sure that it is installed into your Anaconda environment, as demonstrated in the set-up module. Once NumPy is installed, you will need to import it in order to use it in your code. It is common to assign NumPy an alias name of "np" to simplify your code.

```
import numpy as np
```

**Lists** can be converted to NumPy arrays using the *array()* method. Once the list object is converted to an array the type is defined as *numpy.ndarray*, which indicates that it is a NumPy array specifically. Since this array only has one dimension, it is specifically called a **vector**.

```
lst1 = [3, 6, 7, 8, 9]
arr1 = np.array(lst1)
print(type(lst1))
print(type(arr1))
print(arr1)
```

```
<class 'list'>
<class 'numpy.ndarray'>
[3 6 7 8 9]
```

A two dimensional array is known as a **matrix**. In the example below, I am generating a matrix array from a list of lists.

```
lst2 = [[3, 6, 7, 8, 9], [3, 6, 7, 8, 9], [3, 6, 7, 8, 9]]
arr2 = np.array(lst2)
print(arr2)
```

```
[[3 6 7 8 9]
[3 6 7 8 9]
[3 6 7 8 9]]
```

Again, one of the powerful advantages of NumPy arrays is the ability to store data in arrays with many dimensions. In the example below, I am creating a three dimensional array from a list of lists of lists. This would be similar to an image with dimensions image height, image width, and image channels (for example, red, green, and blue). A four dimensional array could represent a time series (height, width, channels, and time) or a video containing multiple frames (frame height, frame width, channels, and frame number).

```
lst3 = [[[3, 6, 7, 8, 9], [3, 6, 7, 8, 9], [3, 6, 7, 8, 9]],
[[3, 6, 7, 8, 9], [3, 6, 7, 8, 9], [3, 6, 7, 8, 9]],
[[3, 6, 7, 8, 9], [3, 6, 7, 8, 9], [3, 6, 7, 8, 9]]]
arr3 = np.array(lst3)
print(arr3)
```

```
[[[3 6 7 8 9]
[3 6 7 8 9]
[3 6 7 8 9]]
[[3 6 7 8 9]
[3 6 7 8 9]
[3 6 7 8 9]]
[[3 6 7 8 9]
[3 6 7 8 9]
[3 6 7 8 9]]]
```

The cell below provides some examples of NumPy **methods** for generating arrays.

The *arange()* method returns an array of evenly spaced values and accepts start, stop, step, and data type parameters. In the example, I have created an array of evenly spaced values from 0 to 100 with a step size of 5. I used 101 as opposed to 100 since the last value in the provided range is not included. I specifically define the data type as integer, but NumPy can infer a data type if it is not provided.

The *linspace()* method is similar to *arange()*; however, a number of samples is specified as opposed to a step size. In the example, since 5 samples are requested, 5 evenly spaced values between 0 and 100 are returned.

The *ones()* method is used to return an array of 1s. In the example, I have generated a three dimensional array where the first dimension has a length of 3, the second a length of 4, and the third a length of 4. The shape and dimensions of the array are specified using a tuple.

Similar to *ones()*, *zeros()* generates an array of zeros.

It is also possible to generate random values between 0 and 1 (*random.rand()*) and a specified number of random integer values between two values (*random.randint()*).

```
arr4 = np.arange(0, 101, 5, dtype="int")
print(arr4)
arr5 = np.linspace(0, 100, 5, dtype="int")
print(arr5)
arr6 = np.ones((3, 4, 4))
print(arr6)
arr7 = np.zeros((3, 4, 2))
print(arr7)
arr8 = np.random.rand(3, 4, 5)
print(arr8)
arr9 = np.random.randint(1, 200, 7)
print(arr9)
```

```
[ 0 5 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85
90 95 100]
[ 0 25 50 75 100]
[[[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]]
[[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]]
[[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]]]
[[[0. 0.]
[0. 0.]
[0. 0.]
[0. 0.]]
[[0. 0.]
[0. 0.]
[0. 0.]
[0. 0.]]
[[0. 0.]
[0. 0.]
[0. 0.]
[0. 0.]]]
[[[0.31120543 0.90775916 0.14999824 0.9939034 0.94277863]
[0.53387805 0.4711267 0.84033979 0.86675965 0.31429746]
[0.5859617 0.32628012 0.15007969 0.79497275 0.25216347]
[0.73599019 0.40059352 0.48377569 0.10046393 0.88812258]]
[[0.13383815 0.10238955 0.58979035 0.68648671 0.08067008]
[0.17418803 0.71397615 0.70455953 0.8254091 0.26178698]
[0.30589628 0.59390863 0.72168191 0.04365877 0.52859414]
[0.43429996 0.49355565 0.43217276 0.37844727 0.03878966]]
[[0.79762046 0.22412081 0.72762178 0.30752609 0.53454478]
[0.74531978 0.5899111 0.28801858 0.86724007 0.90602446]
[0.65379145 0.43549822 0.82364164 0.51252386 0.34444615]
[0.42689812 0.87619397 0.32682973 0.90393277 0.94198736]]]
[ 60 19 79 193 58 169 24]
```

## NumPy Data Types

NumPy provides additional and more specific data types in comparison to base Python. Here, I provide a brief explanation of commonly used data types.

*bool_*: Boolean*True*or*False**int8*: 8-bit signed integer (-128 to 127)*in16*: 16-bit signed integer (-32,768 to 32,767)*int32*: 32-bit signed integer (-2,147,483,648 to 2,147,483,647)*int64*: 64-bit signed integer (-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807)*uint8*: 8-bit unsigned integer (0 to 255)*uint16*: 16-bit unsigned integer (0 to 65,535)*uint32*: 32-bit unsigned integer (0 to 4,294,967,295)*uint64*: 64-bit unsigned integer (0 to 18,446,744,073,709,551,615)*float16*: half precision float*float32*: single precision float*float16*: double precision float

**Signed integers** can differentiate positive and negative values while **unsigned integers** cannot. **Float** data can store decimal values while **integer** data cannot. There are also data types for complex numbers, which we will not discuss here.

Below I have demonstrated how to define the data type with the *dtype* parameter. In all cases I am using *.ones()* to create an array with three elements. For both *int8* and *int16*, 1 as an integer value is returned. When the data are defined as *float16*, 1 as a float value is returned (1.). Lastly, when the type is set to *bool_* Boolean *True* is returned since 1 indicates *True* and 0 indicates *False*. Note that the data type will impact the amount of memory needed. For example an *int8* will require less memory than an *int16*. I generally try to use the data type that can represent the data with the least amount of memory unless a specific data type is needed in an analysis.

```
arr1 = np.ones((3), dtype="int8")
print(arr1)
print(arr1.dtype)
arr1 = np.ones((3), dtype="int16")
print(arr1)
print(arr1.dtype)
arr1 = np.ones((3), dtype="float16")
print(arr1)
print(arr1.dtype)
arr1 = np.ones((3), dtype="bool_")
print(arr1)
print(arr1.dtype)
```

```
[1 1 1]
int8
[1 1 1]
int16
[1. 1. 1.]
float16
[ True True True]
bool
```

## Understanding and Manipulating Array Shape and Dimensions

Let's spend some time discussing the dimensions and shape of an array. The **shape** of an array relates to the length of each dimension. The *len()* function will return the length of the first dimension (in this case 3). To obtain a tuple of the lengths for all dimensions, you must use the *shape* property. So, the array generated has three dimensions with lengths of 3, 4, and 4, respectively. The number of dimensions is returned with the *ndim* property. The *size* property returns the number of features in the array. There are 48 features in the example array: 3 X 4 X 4 = 48. The *dtype* property provides the data type.

```
arr6 = np.ones((3, 4, 4))
print("Length of first dimension: " + str(len(arr6)))
print("Shape of array: " + str(arr6.shape))
print("Number of dimensions: " + str(arr6.ndim))
print("Size of array: " + str(arr6.size))
print("Data type of array: " + str(arr6.dtype))
```

```
Length of first dimension: 3
Shape of array: (3, 4, 4)
Number of dimensions: 3
Size of array: 48
Data type of array: float64
```

NumPy has a built-in methods for changing the shape of an array: *.reshape()*. Note that the number of features or size of the array must perfectly fill the new shape. In the first example, I am maintaining the number of dimensions but changing the shape or length of each dimension. In the second two examples, I am converting the three-dimensional array to two-dimensional arrays. Lastly, I convert the array to a one-dimensional array, or vector, with a length of 48.

```
arr6b = arr6.reshape(4, 4, 3)
arr6c = arr6.reshape(4, 12)
arr6d = arr6.reshape(12, 4)
arr6e = arr6.reshape(48)
print(arr6b)
print(arr6c)
print(arr6d)
print(arr6e)
```

```
[[[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]]
[[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]]
[[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]]
[[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]]]
[[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]]
[[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]
[1. 1. 1. 1.]]
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
```

As a second example, here I am converting a vector array into a multidimensional array or matrix.

```
arr10 = np.random.randint(1, 1200, 100)
arr10b = arr10.reshape(10, 10)
print(arr10b)
```

```
[[ 101 359 27 65 22 306 398 899 487 573]
[ 56 791 270 303 655 186 610 803 1179 1030]
[1005 608 1144 809 88 450 1198 1084 733 552]
[ 939 148 449 504 1082 1032 841 409 273 191]
[ 136 288 393 213 402 818 1183 773 182 850]
[ 571 280 326 869 51 454 360 64 247 176]
[ 925 909 146 558 1009 276 690 821 36 233]
[ 606 307 545 1168 433 963 691 37 156 673]
[ 942 249 130 643 248 740 586 1036 614 197]
[ 809 97 445 426 282 736 765 339 735 1018]]
```

When reshaping, it is possible to have NumPy determine the appropriate size of a single dimension to fill an array with the available elements. This is accomplished using -1 in the array dimension location when applying the *.reshape()* method.

```
arr10 = np.random.randint(1, 1200, 1000)
arr10b = arr10.reshape(-1, 10, 10)
arr10c = arr10.reshape(10, -1, 10)
arr10d = arr10.reshape(10, 10, -1)
print(arr10.shape)
print(arr10b.shape)
print(arr10c.shape)
print(arr10d.shape)
```

```
(1000,)
(10, 10, 10)
(10, 10, 10)
(10, 10, 10)
```

## NumPy Array Indexing

Similar to lists, NumPy arrays are indexed. So, values from the array can be extracted or referenced using their associated index. Since arrays often have multiple dimensions, indexes must also extend into multiple dimensions. See the comments below for general array indexing rules. Remember that indexing starts at 0, the first index provided is included, and the last index provided is not included. Extracting portions of an array is known as **slicing**.

```
arr11 = np.linspace(0, 50, 50, dtype="int")
arr12 = arr11.reshape(2,5,5)
print("Original array")
print(arr12)
print("All values in first index of first dimension")
print(arr12[0]) #This will extract just the values from the first index in the first dimension
print("All values in second index of first dimension")
print(arr12[1]) #This will extract just the values from the second index in the first dimension
print("All values in first index of first dimension and first index of second dimension")
print(arr12[0][0]) #This will extract all values occurring in the first index of both the first and second dimensions
print("A single value specified with three indexes, one for reach dimension")
print(arr12[1, 3, 3]) #This will extract a specific value based on an index in all three dimensions
print("Incorporating ranges")
print(arr12[1, 0:2, 0:2]) #All values in second index of first dimension that are also include in the first to second index of the second and third dimensions
print("Using colons")
print(arr12[:, 0:2, 0:2]) #Only a colon means select all values in a dimension
print(arr12[:,2:,0:2]) #Can also use colons to select all values before or after an index
```

```
Original array
[[[ 0 1 2 3 4]
[ 5 6 7 8 9]
[10 11 12 13 14]
[15 16 17 18 19]
[20 21 22 23 24]]
[[25 26 27 28 29]
[30 31 32 33 34]
[35 36 37 38 39]
[40 41 42 43 44]
[45 46 47 48 50]]]
All values in first index of first dimension
[[ 0 1 2 3 4]
[ 5 6 7 8 9]
[10 11 12 13 14]
[15 16 17 18 19]
[20 21 22 23 24]]
All values in second index of first dimension
[[25 26 27 28 29]
[30 31 32 33 34]
[35 36 37 38 39]
[40 41 42 43 44]
[45 46 47 48 50]]
All values in first index of first dimension and first index of second dimension
[0 1 2 3 4]
A single value specified with three indexes, one for reach dimension
43
Incorporating ranges
[[25 26]
[30 31]]
Using colons
[[[ 0 1]
[ 5 6]]
[[25 26]
[30 31]]]
[[[10 11]
[15 16]
[20 21]]
[[35 36]
[40 41]
[45 46]]]
```

Once values have been selected using index notation they can be changed. In the example below I have converted all values in the first index of the first dimension and the first index of the second dimension to 0.

```
arr12[0][0] = 0
print(arr12)
```

```
[[[ 0 0 0 0 0]
[ 5 6 7 8 9]
[10 11 12 13 14]
[15 16 17 18 19]
[20 21 22 23 24]]
[[25 26 27 28 29]
[30 31 32 33 34]
[35 36 37 38 39]
[40 41 42 43 44]
[45 46 47 48 50]]]
```

## Boolean Arrays

It is also possible to create arrays of Boolean values as demonstrated below.

```
arr13 = np.array([True, False, True, False, True, False, True, False, False])
arr13b = arr13.reshape(3, 3)
print(arr13b)
```

```
[[ True False True]
[False True False]
[ True False False]]
```

**Comparison Operators** can be used to compare each value in an array to a value and return the Boolean result to the associated position in a new array.

```
arr10 = np.random.randint(1, 1200, 100)
arr10b = arr10.reshape(10, 10)
print(arr10b)
arr10bool = arr10b > 150
print(arr10bool)
```

```
[[ 421 1174 908 1126 575 904 557 619 712 164]
[ 295 594 912 601 561 470 87 866 127 1065]
[1042 78 571 1078 265 996 422 557 198 988]
[ 476 1066 510 25 315 1134 657 98 237 593]
[ 837 172 99 437 810 700 83 441 909 1023]
[ 528 421 184 453 729 902 871 1089 238 1181]
[ 942 585 735 770 125 1090 810 567 838 419]
[ 505 1171 758 1148 490 455 192 753 462 4]
[ 567 360 658 820 256 915 926 934 787 822]
[ 211 381 113 978 6 378 772 617 991 110]]
[[ True True True True True True True True True True]
[ True True True True True True False True False True]
[ True False True True True True True True True True]
[ True True True False True True True False True True]
[ True True False True True True False True True True]
[ True True True True True True True True True True]
[ True True True True False True True True True True]
[ True True True True True True True True True False]
[ True True True True True True True True True True]
[ True True False True False True True True True False]]
```

## Copy vs. View

In the first module, I explained how, for mutable data types, setting a variable equal to another variable will result in a reference to the original object or data in memory. So, changes to the original object or the new object will change both since they reference the same data.

The behavior for NumPy arrays is similar. Thus, two methods are available for creating a new variable relative to an existing variable: *copy()* and *view()*.

When using *view()*, the variable will reference the same data or object in memory. So, changes to the original variable or the new variable created using *view()* will result in changes to both. Also using *view()*, you can reference portions of an array. This allows you to work with a subset of the data values without copying or replicating the data in memory.

In contrast to *view()*, *copy()* will copy the data or object in memory, so changes made to the original or copied object will not impact the original object.

The three examples below demonstrate this behavior. In the first example, *arr2* is created as a view of *arr1* while *arr3* is created as a copy of *arr1*. A subsequent change to *arr1* changes *arr1* and *arr2* but not *arr3*. In the second example, a change to *arr2*, a view of *arr1*, impacts both *arr1* and *arr2* but not *arr3*. Lastly, changes to *arr3* impacts only *arr3* and not *arr1* or *arr2* since it is a copy of *arr1* as opposed to a view.

In summary, if you want to make a copy of an array as opposed to referencing the original data, you should use the *.copy()* method.

```
import copy
arr1 = np.array(25)
arr2 = np.array(25)
arr3 = np.array(25)
arr1 = np.random.randint(1, 100, 25)
arr1 = arr1.reshape(5, 5)
print(arr1)
arr2 = arr1.view()
arr3 = arr1.copy()
arr1[:, :] = 0
print(arr1)
print(arr2)
print(arr3)
```

```
[[28 2 20 49 66]
[56 11 49 29 33]
[ 8 37 91 70 53]
[18 97 60 25 96]
[30 21 74 10 32]]
[[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]]
[[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]]
[[28 2 20 49 66]
[56 11 49 29 33]
[ 8 37 91 70 53]
[18 97 60 25 96]
[30 21 74 10 32]]
```

```
import copy
arr1 = np.array(25)
arr2 = np.array(25)
arr3 = np.array(25)
arr1 = np.random.randint(1, 100, 25)
arr1 = arr1.reshape(5, 5)
print(arr1)
arr2 = arr1.view()
arr3 = arr1.copy()
arr2[:, :] = 0
print(arr1)
print(arr2)
print(arr3)
```

```
[[65 23 12 29 68]
[70 70 22 55 98]
[83 29 95 91 21]
[28 1 89 60 18]
[62 87 74 69 16]]
[[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]]
[[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]]
[[65 23 12 29 68]
[70 70 22 55 98]
[83 29 95 91 21]
[28 1 89 60 18]
[62 87 74 69 16]]
```

```
import copy
arr1 = np.array(25)
arr2 = np.array(25)
arr3 = np.array(25)
arr1 = np.random.randint(1, 100, 25)
arr1 = arr1.reshape(5, 5)
print(arr1)
arr2 = arr1.view()
arr3 = arr1.copy()
arr3[:, :] = 0
print(arr1)
print(arr2)
print(arr3)
```

```
[[82 52 25 32 94]
[33 77 85 11 6]
[59 78 53 15 36]
[72 4 24 75 87]
[56 18 35 70 32]]
[[82 52 25 32 94]
[33 77 85 11 6]
[59 78 53 15 36]
[72 4 24 75 87]
[56 18 35 70 32]]
[[82 52 25 32 94]
[33 77 85 11 6]
[59 78 53 15 36]
[72 4 24 75 87]
[56 18 35 70 32]]
[[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]]
```

### Array Arithmetic and Operations

It is generally easy to perform mathematical operations on arrays as demonstrated below. In all cases, the same operation is applied to all elements in the array.

```
arr14 = np.random.randint(1, 1200, 25)
arr14b = arr14.reshape(5, 5)
print(arr14b)
print(arr14b+21)
print(arr14b-52)
print(arr14b*2)
print(arr14b/3)
print(arr14b**2)
```

```
[[ 381 96 992 1069 423]
[ 213 476 1155 233 624]
[1035 947 147 490 302]
[ 537 346 578 173 92]
[ 606 1157 95 537 236]]
[[ 402 117 1013 1090 444]
[ 234 497 1176 254 645]
[1056 968 168 511 323]
[ 558 367 599 194 113]
[ 627 1178 116 558 257]]
[[ 329 44 940 1017 371]
[ 161 424 1103 181 572]
[ 983 895 95 438 250]
[ 485 294 526 121 40]
[ 554 1105 43 485 184]]
[[ 762 192 1984 2138 846]
[ 426 952 2310 466 1248]
[2070 1894 294 980 604]
[1074 692 1156 346 184]
[1212 2314 190 1074 472]]
[[127. 32. 330.66666667 356.33333333 141. ]
[ 71. 158.66666667 385. 77.66666667 208. ]
[345. 315.66666667 49. 163.33333333 100.66666667]
[179. 115.33333333 192.66666667 57.66666667 30.66666667]
[202. 385.66666667 31.66666667 179. 78.66666667]]
[[ 145161 9216 984064 1142761 178929]
[ 45369 226576 1334025 54289 389376]
[1071225 896809 21609 240100 91204]
[ 288369 119716 334084 29929 8464]
[ 367236 1338649 9025 288369 55696]]
```

It is also possible to perform mathematical operations on sets of arrays as long as they have the same shape. In such cases, elements are matched based on having the same position within the array.

```
arr14 = np.random.randint(1, 1200, 25)
arr14b = arr14.reshape(5, 5)
print(arr14b)
print(arr14b+arr14b)
print(arr14b-arr14b)
```

```
[[ 365 332 812 427 402]
[ 76 1 108 295 461]
[ 824 255 917 328 1157]
[1114 900 216 487 169]
[ 353 405 290 191 1100]]
[[ 730 664 1624 854 804]
[ 152 2 216 590 922]
[1648 510 1834 656 2314]
[2228 1800 432 974 338]
[ 706 810 580 382 2200]]
[[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]
[0 0 0 0 0]]
```

To summarize the results from above it is possible to:

- perform mathematical operations between an array with any shape and a scalar (i.e., single value)
- perform mathematical operations between arrays that have the same shape

There are some other cases in which it is possible to perform mathematical operations using a technique known as **broadcasting**. The following rules summarize when broadcasting can be used and how.

- If two arrays have a different number of dimensions, the shape of the array with fewer dimensions is padded with ones on its leading side (for example, to multiply an array of shape (3) by an array of shape (3,3), the first array must be converted to shape (1,3)).
- If the shape of the arrays does not match in a dimension, the array with shape equal to 1 in that dimension is stretched to match the other shape.
- If in any dimension the sizes disagree and neither is equal to 1, an error is raised.

In the example below, an array of shape (6, 6) is multiplied by an array of shape (6). This requires that the second array be broadcasted to a shape of (1, 6).

```
arr1 = np.random.randint(1, 100, 36)
arr1b = arr1.reshape(6, 6)
arr2 = np.ones((6))
arr2[:] = 2
print(arr1b)
print(arr2)
print(arr1b*arr2)
```

```
[[69 4 32 71 72 2]
[45 66 96 54 88 7]
[78 7 55 53 97 46]
[22 91 29 34 85 37]
[ 8 24 87 88 24 6]
[33 6 64 38 29 44]]
[2. 2. 2. 2. 2. 2.]
[[138. 8. 64. 142. 144. 4.]
[ 90. 132. 192. 108. 176. 14.]
[156. 14. 110. 106. 194. 92.]
[ 44. 182. 58. 68. 170. 74.]
[ 16. 48. 174. 176. 48. 12.]
[ 66. 12. 128. 76. 58. 88.]]
```

NumPy provides mathematical functions and methods for performing common tasks. The last block of code below provides some examples.

```
arr14 = np.random.randint(1, 1200, 25)
arr14b = arr14.reshape(5, 5)
print(np.max(arr14b))
print(np.min(arr14b))
print(np.sqrt(arr14b))
```

```
1031
15
[[27.85677655 21.70253441 15.16575089 31.8747549 26. ]
[17.57839583 13.19090596 9.43398113 29.25747768 12.04159458]
[ 3.87298335 25.29822128 10.39230485 25.43619468 17.43559577]
[20.46948949 32.10918872 20.54263858 25.90366769 31.51190251]
[ 6.08276253 22.7815715 31.33687923 18.41195264 30.18277655]]
```

## Concluding Remarks

As mentioned above, NumPy is central to using Python for analyzing data. So, an understanding of NumPy is important for data and geospatial data scientists. Additional libraries and modules make use of NumPy to expand Python's data science functionalities. In the next section, we will explore one of these libraries: **Pandas**.