# 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 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, 100, 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]
[ 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.61611044 0.82997297 0.95698234 0.63758879 0.52442317]
[0.26018298 0.94560291 0.87566749 0.77677722 0.07518612]
[0.1291478 0.27554435 0.49308256 0.06694469 0.37334858]
[0.67701685 0.62236129 0.32886104 0.14403291 0.38487002]]
[[0.94066151 0.82999355 0.67013645 0.6089111 0.76797975]
[0.08925162 0.70730738 0.38616466 0.99214843 0.99653095]
[0.42314746 0.50925596 0.85850569 0.26249533 0.4433054 ]
[0.01790036 0.82842809 0.31873158 0.46986309 0.37309847]]
[[0.73009527 0.74129696 0.36985928 0.66016071 0.19166952]
[0.70313963 0.33993884 0.80155675 0.55717499 0.50198088]
[0.94621494 0.29111301 0.91356446 0.40601907 0.27936317]
[0.29871494 0.42579042 0.76071243 0.14393261 0.63504426]]]
[153 77 161 175 192 174 6]
```

## 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)*int16*: 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*float64*: 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)
```

```
[[ 472 1139 637 971 282 194 395 30 370 869]
[ 635 343 218 440 538 651 1146 269 1141 428]
[1163 261 691 366 505 784 354 799 381 1042]
[1058 1078 226 374 1088 83 881 949 1010 1046]
[ 929 944 369 67 806 910 422 841 857 152]
[ 587 513 1039 680 23 500 769 338 627 1075]
[ 418 153 1067 515 1148 553 651 1145 1102 568]
[ 624 321 401 1013 810 1130 588 122 893 638]
[ 2 1149 977 459 115 347 956 786 992 187]
[ 496 997 288 556 83 932 298 721 663 166]]
```

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)
```

```
[[ 167 986 83 247 886 993 237 326 931 127]
[ 902 401 823 297 111 948 330 888 1128 751]
[ 283 966 629 1182 53 126 189 316 810 17]
[ 997 403 438 121 205 613 317 836 18 837]
[ 410 177 651 432 1069 1176 909 396 665 1098]
[ 30 919 446 317 733 83 407 429 430 192]
[ 557 239 100 234 146 620 507 398 942 261]
[ 779 250 712 172 196 685 455 313 198 1130]
[ 235 274 782 438 239 222 288 1173 1066 100]
[1004 1147 639 579 294 44 27 735 646 573]]
[[ True True False True True True True True True False]
[ True True True True False True True True True True]
[ True True True True False False True True True False]
[ True True True False True True True True False True]
[ True True True True True True True True True True]
[False True True True True False True True True True]
[ True True False True False True True True True True]
[ True True True True True True True True True True]
[ True True True True True True True True True False]
[ True True True True True False False True True True]]
```

## 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)
```

```
[[89 12 56 15 87]
[11 34 87 41 75]
[67 66 56 53 70]
[90 80 88 73 16]
[ 9 16 98 61 44]]
[[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]]
[[89 12 56 15 87]
[11 34 87 41 75]
[67 66 56 53 70]
[90 80 88 73 16]
[ 9 16 98 61 44]]
```

```
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)
```

```
[[26 41 48 26 7]
[77 18 73 56 86]
[ 3 19 73 84 73]
[88 42 41 56 92]
[94 10 89 37 73]]
[[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]]
[[26 41 48 26 7]
[77 18 73 56 86]
[ 3 19 73 84 73]
[88 42 41 56 92]
[94 10 89 37 73]]
```

```
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)
```

```
[[64 86 52 37 99]
[ 4 85 54 51 93]
[52 50 89 67 6]
[98 54 77 48 56]
[75 93 76 60 81]]
[[64 86 52 37 99]
[ 4 85 54 51 93]
[52 50 89 67 6]
[98 54 77 48 56]
[75 93 76 60 81]]
[[64 86 52 37 99]
[ 4 85 54 51 93]
[52 50 89 67 6]
[98 54 77 48 56]
[75 93 76 60 81]]
[[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)
```

```
[[ 371 477 792 841 137]
[ 457 1137 986 766 172]
[ 561 652 389 789 850]
[ 886 971 322 979 1148]
[ 274 54 683 13 574]]
[[ 392 498 813 862 158]
[ 478 1158 1007 787 193]
[ 582 673 410 810 871]
[ 907 992 343 1000 1169]
[ 295 75 704 34 595]]
[[ 319 425 740 789 85]
[ 405 1085 934 714 120]
[ 509 600 337 737 798]
[ 834 919 270 927 1096]
[ 222 2 631 -39 522]]
[[ 742 954 1584 1682 274]
[ 914 2274 1972 1532 344]
[1122 1304 778 1578 1700]
[1772 1942 644 1958 2296]
[ 548 108 1366 26 1148]]
[[123.66666667 159. 264. 280.33333333 45.66666667]
[152.33333333 379. 328.66666667 255.33333333 57.33333333]
[187. 217.33333333 129.66666667 263. 283.33333333]
[295.33333333 323.66666667 107.33333333 326.33333333 382.66666667]
[ 91.33333333 18. 227.66666667 4.33333333 191.33333333]]
[[ 137641 227529 627264 707281 18769]
[ 208849 1292769 972196 586756 29584]
[ 314721 425104 151321 622521 722500]
[ 784996 942841 103684 958441 1317904]
[ 75076 2916 466489 169 329476]]
```

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)
```

```
[[ 572 247 970 844 997]
[ 700 996 455 176 399]
[ 750 328 799 665 795]
[ 305 975 397 878 556]
[ 916 982 1106 157 391]]
[[1144 494 1940 1688 1994]
[1400 1992 910 352 798]
[1500 656 1598 1330 1590]
[ 610 1950 794 1756 1112]
[1832 1964 2212 314 782]]
[[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)
```

```
[[ 8 17 66 68 36 65]
[75 46 62 86 8 89]
[81 87 89 88 22 24]
[44 59 88 72 90 69]
[47 70 85 31 30 37]
[ 9 77 48 5 83 68]]
[2. 2. 2. 2. 2. 2.]
[[ 16. 34. 132. 136. 72. 130.]
[150. 92. 124. 172. 16. 178.]
[162. 174. 178. 176. 44. 48.]
[ 88. 118. 176. 144. 180. 138.]
[ 94. 140. 170. 62. 60. 74.]
[ 18. 154. 96. 10. 166. 136.]]
```

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))
```

```
1135
15
[[21.84032967 30.28200786 16.73320053 21.11871208 27.64054992]
[24.8997992 21.04756518 26.98147513 33.12099032 33.36165464]
[22.53885534 32.17141588 24.67792536 31.96873473 33.68976106]
[ 3.87298335 7. 6.244998 17.97220076 24.41311123]
[21.54065923 29.69848481 10.39230485 13.07669683 27.80287755]]
```

## 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**.