The Importance of NumPy
NumPy (Numerical Python) is the fundamental package for scientific computing in Python. It provides a high-performance multidimensional array object and tools for working with these arrays. If you are doing any form of data science or scientific computing in Python, NumPy is the engine under the hood.
Why not use Python Lists?
Python lists are incredibly flexible—they can hold elements of different types, and they grow dynamically. However, this flexibility comes at a significant performance cost.
The Problem with Python Lists:
- Memory Overhead: Each element in a Python list is a full-fledged object. A list of integers doesn’t just store the numbers; it stores pointers to integer objects, which each contain type information and reference counts.
- Lack of Locality: Because list elements are pointers, they can be scattered across memory. This prevents hardware-level optimizations like CPU caching and pre-fetching.
- Looping Speed: Iterating over a Python list in a loop is slow because the interpreter must check the type and perform dispatching for every single operation.
The NumPy Solution: ndarray
The core of NumPy is the ndarray (n-dimensional array) object.
- Contiguous Memory:
ndarrays store data in a single, contiguous block of memory.
- Homogeneous Types: Every element in a NumPy array must be of the same type (e.g., all 64-bit floats).
- Vectorized Operations: Operations on NumPy arrays are performed by compiled C/Fortran code, which can process entire arrays at once without Python loop overhead.
Creating Arrays
NumPy provides multiple ways to initialize arrays.
Interactive Lab
import numpy as np
# From a list
a = np.array([1, 2, 3, 4, 5])
print("From list:", a)
# Filled with zeros or ones
zeros = np.zeros((3, 3))
ones = np.ones((2, 4))
print("Zeros:\n", zeros)
# Ranges
arange = np.arange(0, 10, 2)
linspace = np.linspace(0, 1, 5) # 5 points between 0 and 1
print("Arange:", arange)
print("Linspace:", linspace)
# Random numbers
rand = np.random.rand(2, 2)
print("Random:\n", rand)
Expected output
From list: [1 2 3 4 5]
Zeros:
[[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]]
Arange: [0 2 4 6 8]
Linspace: [0. 0.25 0.5 0.75 1. ]
Random:
[[0.45 0.12]
[0.89 0.34]]
1
2import numpy as np
3
4
5a = np.array([1, 2, 3, 4, 5])
6print("From list:", a)
7
8
9zeros = np.zeros((3, 3))
10ones = np.ones((2, 4))
11print("Zeros:\n", zeros)
12
13
14arange = np.arange(0, 10, 2)
15linspace = np.linspace(0, 1, 5)
16print("Arange:", arange)
17print("Linspace:", linspace)
18
19
20rand = np.random.rand(2, 2)
21print("Random:\n", rand)
22
Array Attributes
Every ndarray has properties that describe its shape, size, and data type.
shape: A tuple indicating the size of each dimension (e.g., (rows, cols)).
ndim: The number of dimensions (axes).
size: The total number of elements.
dtype: The data type of the elements (e.g., int32, float64).
Interactive Lab
import numpy as np
arr = np.array([[1, 2, 3], [4, 5, 6]])
print(f"Shape: {arr.shape}")
print(f"Dimensions: {arr.ndim}")
print(f"Size: {arr.size}")
print(f"Data Type: {arr.dtype}")
Expected output
Shape: (2, 3)
Dimensions: 2
Size: 6
Data Type: int64
1
2import numpy as np
3
4arr = np.array([[1, 2, 3], [4, 5, 6]])
5
6print(f"Shape: {arr.shape}")
7print(f"Dimensions: {arr.ndim}")
8print(f"Size: {arr.size}")
9print(f"Data Type: {arr.dtype}")
10
Vectorization: The Secret Sauce
Vectorization is the process of replacing explicit Python loops with array expressions. This is the primary way to achieve high performance in NumPy.
Consider adding two large vectors:
Interactive Lab
import numpy as np
import time
size = 1000000
a = list(range(size))
b = list(range(size))
# Python Loop approach
start = time.time()
c = [a[i] + b[i] for i in range(size)]
end = time.time()
print(f"Python list addition time: {end - start:.4f}s")
# NumPy approach
na = np.arange(size)
nb = np.arange(size)
start = time.time()
nc = na + nb
end = time.time()
print(f"NumPy vector addition time: {end - start:.4f}s")
Expected output
Python list addition time: 0.0824s
NumPy vector addition time: 0.0012s
1
2import numpy as np
3import time
4
5size = 1000000
6a = list(range(size))
7b = list(range(size))
8
9
10start = time.time()
11c = [a[i] + b[i] for i in range(size)]
12end = time.time()
13print(f"Python list addition time: {end - start:.4f}s")
14
15
16na = np.arange(size)
17nb = np.arange(size)
18start = time.time()
19nc = na + nb
20end = time.time()
21print(f"NumPy vector addition time: {end - start:.4f}s")
22
As you can see, the NumPy version is orders of magnitude faster. It’s not just “shorter code”—it’s fundamentally different execution.
Memory Layout and Slicing
NumPy slicing is unique because it creates views of the data rather than copies. This is extremely efficient for large datasets but requires caution: changing a slice changes the original array.
Interactive Lab
import numpy as np
arr = np.array([0, 1, 2, 3, 4, 5])
s = arr[1:4]
s[0] = 99
print("Original array after modifying slice:", arr)
Expected output
Original array after modifying slice: [ 0 99 2 3 4 5]
1
2import numpy as np
3
4arr = np.array([0, 1, 2, 3, 4, 5])
5s = arr[1:4]
6s[0] = 99
7
8print("Original array after modifying slice:", arr)
9
In the next module, we will explore advanced indexing and multi-dimensional array manipulations.