No history yet

NumPy Fundamentals

The NumPy Array

At the heart of all numerical computing in Python is the NumPy array. While a standard Python list can hold different data types and is flexible, this flexibility comes at a cost. Each item in a list is a full-fledged Python object, complete with its own type information and metadata, scattered across memory. This makes operations on large lists inefficient.

NumPy's ndarray (n-dimensional array) solves this. It's a grid of values, all of the same type, packed into one continuous block of memory. This compact structure allows NumPy to perform mathematical operations on the entire array at once, using highly optimized, pre-compiled C code.

In the Python world, NumPy arrays are the standard representation for numerical data.

Let's create one. You typically start by importing NumPy and giving it the conventional alias np. Then, you can convert a Python list into an ndarray.

import numpy as np

# Create a 1D array from a list
arr1 = np.array([1, 2, 3, 4, 5])

# Create a 2D array from a list of lists
arr2 = np.array([[1, 2, 3], [4, 5, 6]])

print(arr1)
print(arr2)

Every NumPy array has important properties that describe it. The .shape attribute tells you its dimensions, .dtype tells you the data type of its elements, and .size gives the total number of elements.

# Properties of the 2D array
print(f"Shape: {arr2.shape}")      # Output: Shape: (2, 3)
print(f"Data Type: {arr2.dtype}")  # Output: Data Type: int64
print(f"Size: {arr2.size}")        # Output: Size: 6

Notice the dtype is int64. NumPy automatically inferred the data type from the input. You can also specify it explicitly, which is crucial for managing memory and precision.

The Power of Vectorization

The main reason NumPy is so fast is vectorization. This means expressing operations as occurring on entire arrays rather than looping through individual elements. Let's say you want to add 10 to every number in a large list.

In standard Python, you'd use a loop:

# Using a standard Python loop
python_list = list(range(1000000))
new_list = []
for i in python_list:
    new_list.append(i + 10)

With NumPy, you skip the explicit loop entirely. You can perform the operation directly on the array.

# Using a vectorized NumPy operation
numpy_array = np.arange(1000000)
new_array = numpy_array + 10

The NumPy version isn't just shorter; it's dramatically faster. The Python loop has to interpret each step for every single element. In contrast, the vectorized operation numpy_array + 10 is a single command that executes a highly optimized loop in C, avoiding all of Python's overhead for a million elements.

The key to performant NumPy code is to think in terms of array operations. If you find yourself writing a for loop, ask: 'Can this be vectorized?' The answer is usually yes.

Broadcasting Mechanics

Vectorization gets even more powerful with broadcasting. This is how NumPy handles operations on arrays of different, but compatible, shapes. The smaller array is "broadcast" across the larger array so they have compatible shapes.

The simplest example is adding a scalar to an array, which we just did. The scalar 10 was effectively stretched into an array of the same shape as numpy_array so the element-wise addition could happen.

It also works with arrays of different dimensions. Imagine you have a 2x3 array and you want to add a 1D array to each row.

A = np.array([[1, 2, 3],
              [4, 5, 6]])

b = np.array([10, 20, 30])

C = A + b  # Broadcasting in action!

print(C)
# [[11 22 33]
#  [14 25 36]]

Here, NumPy saw that A has shape (2, 3) and b has shape (3,). It virtually stretched b into a (2, 3) array [[10, 20, 30], [10, 20, 30]] and then performed the addition. This happens without actually creating the larger array in memory, making it highly efficient.

Indexing and Slicing

Accessing elements in NumPy arrays is similar to Python lists but more powerful. For a 1D array, it works just as you'd expect.

arr = np.arange(10, 20)
# [10 11 12 13 14 15 16 17 18 19]

# Get a single element
print(arr[3])  # Output: 13

# Get a slice
print(arr[2:5]) # Output: [12 13 14]

For multi-dimensional arrays, you can use a comma-separated tuple of indices.

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

# Get element at row 1, column 2
print(arr2d[1, 2]) # Output: 6

# Get the first two rows and columns 1 through 2
print(arr2d[:2, 1:3])
# [[2 3]
#  [5 6]]

Universal Functions

NumPy provides hundreds of mathematical functions that operate element-wise on arrays. These are called universal functions, or ufuncs. They are vectorized wrappers for simple functions.

For example, instead of looping through an array to find the square root of each element, you can just use np.sqrt().

arr = np.array([1, 4, 9, 16])

# Apply the sqrt ufunc
squareroots = np.sqrt(arr)

print(squareroots)
# [1. 2. 3. 4.]

Other common ufuncs include np.exp(), np.log(), np.sin(), np.cos(), np.add(), and np.maximum(). These functions are the building blocks for nearly all data analysis and scientific computing tasks.

Quiz Questions 1/6

Why are NumPy arrays generally more memory-efficient and faster for numerical operations than standard Python lists?

Quiz Questions 2/6

What is the term for the NumPy mechanism that allows it to perform operations on arrays of different, but compatible, shapes?

These core concepts—the ndarray object, vectorization, broadcasting, and ufuncs—form the foundation of high-performance data manipulation in Python.