Skip to content

NumPy

NumPy is a core Python library for numerical computing. It makes it far easier to work with numbers and data, especially large sets of observations, tables of numbers, vectors, matrices, and multi-dimensional arrays.

For students new to programming or finance, it can help to think of NumPy as a programmable calculator or spreadsheet engine. Rather than processing one value at a time, NumPy lets you apply a single calculation to an entire collection of numbers at once, which is both faster to run and faster to write.

NumPy, short for Numerical Python, provides efficient tools for storing, processing, and analysing numerical data. Its central object is the array, which is similar to a list or a spreadsheet column, but designed specifically for fast numerical computation.

One advantage of working with Python rather than editing a spreadsheet directly is that your original data file can remain completely untouched. Python imports the data into temporary memory, applies calculations and transformations through code, and can then export the results into new files, leaving a clear, reproducible record of exactly what was done.

In finance, this approach is especially useful for working with stock prices, interest rates, returns, portfolio weights, and other time-series data. NumPy helps analysts calculate statistics, run simulations, and prepare data for more advanced modelling.

As an optional practical tool, Visual Studio Code has an extension called Data Wrangler, which provides a variable viewer. This can help you inspect variable descriptions, preview array contents, and keep track of the objects created during a coding session, handy while you're still building intuition for how arrays behave.

Python Code Examples: Creating and Inspecting Arrays

import numpy as np

# Creating a basic array
a = np.array([2, 4, 6, 8, 10])
print(a)
print(a.size)              # number of elements in the array

# np.full() creates an array of a given shape, filled with one repeated value
print(np.full((3, 4), np.pi))    # 3 rows, 4 columns, every value set to pi

# np.empty() creates an array of a given shape with "uninitialised" values
# (whatever happens to already be in memory) - useful when you're about to
# overwrite every value yourself and don't want the extra cost of filling it first
print(np.empty((2, 3)))

# np.arange() works like Python's range(), but returns an array
print(np.arange(1, 5))          # [1 2 3 4]
print(np.arange(1.0, 5.0))      # [1. 2. 3. 4.]
print(np.arange(1, 5, 0.5))     # step size can be a float: [1. 1.5 2. 2.5 3. 3.5 4. 4.5]

# np.linspace() returns a fixed number of evenly spaced values between two endpoints
print(np.linspace(0, 5 / 3, 6))  # 6 evenly spaced values between 0 and 5/3

# np.fromfunction() builds an array by applying a function to each set of coordinates
def my_function(z, y, x):
    return x + 10 * y + 100 * z

print(np.fromfunction(my_function, (3, 2, 10)))

Broadcasting: Operating on Arrays of Different Shapes

NumPy allows arithmetic between arrays of different shapes, following a set of rules known as broadcasting. Rather than memorising the rules abstractly, it's easiest to see them in action:

h = np.arange(5)                    # [0 1 2 3 4]
print(h + [10, 20, 30, 40, 50])       # element-wise addition, same shape: no broadcasting needed

k = np.array([[0, 1, 2], [3, 4, 5]])  # shape (2, 3)

# A single row is "stretched" to match every row of k
print(k + [[100], [200]])             # same as: k + [[100, 100, 100], [200, 200, 200]]

# A single row is stretched across both rows
print(k + [100, 200, 300])            # after broadcasting: [[100, 200, 300], [100, 200, 300]] added to k

# A single number (scalar) is stretched across the entire array
print(k + 1000)                       # same as: k + [[1000, 1000, 1000], [1000, 1000, 1000]]

# Shapes that can't be broadcast together raise an error
try:
    print(k + [33, 44])
except ValueError as e:
    print(e)

The key idea: NumPy tries to "stretch" the smaller array so its shape matches the larger one. If the shapes are incompatible even after stretching, it raises a ValueError rather than guessing what you meant.

Universal Functions and Comparisons

NumPy provides universal functions (often called "ufuncs"), which apply a mathematical operation to every element of an array at once:

a = np.array([-2.5, 3.1, -1.0, 4.0])
b = np.array([1.0, -3.1, 2.0, 4.0])

print("Original array:")
print(a)

for func in (np.abs, np.sqrt, np.exp, np.log, np.sign, np.ceil, np.modf, np.isnan, np.cos):
    print("\n", func.__name__)
    print(func(a))

# Element-wise comparisons and functions across two arrays
print(np.greater(a, b))      # equivalent to a > b
print(np.copysign(a, b))     # copies the sign of b onto the magnitude of a

Note that np.sqrt applied to a negative number will return nan ("not a number") with a runtime warning, since real square roots of negative numbers don't exist, this is a common early stumbling block worth being aware of.

Indexing and Slicing 1D Arrays

a = np.array([10, 20, 30, 40, 50, 60, 70])

print(a[2:5])       # elements from index 2 up to (not including) index 5
print(a[2:-1])       # from index 2 to the second-to-last element
print(a[:2])          # the first two elements
print(a[2::2])        # from index 2 to the end, taking every second element
print(a[::-1])        # the whole array, reversed

# Assigning to a single index
a[3] = 999
print(a)

# Assigning to a slice
a[2:5] = [997, 998, 999]
print(a)

# A single value assigned to a slice is broadcast across the whole slice
a[2:5] = -1
print(a)

# The replacement slice must be the same length as the target slice
try:
    a[2:5] = [1, 2, 3, 4, 5, 6]   # too long
except ValueError as e:
    print(e)

# Unlike Python lists, NumPy doesn't allow deleting a slice with del
try:
    del a[2:5]
except ValueError as e:
    print(e)

An important gotcha: slices are views, not copies. Modifying a slice modifies the original array too, and vice versa:

a = np.array([10, 20, 30, 40, 50, 60, 70])

a_slice = a[2:6]
a_slice[1] = 1000
print(a)          # the original array was modified!

a[3] = 2000
print(a_slice)    # modifying the original array also updates the slice!

# To avoid this, use .copy() to create an independent array
another_slice = a[2:6].copy()
another_slice[1] = 3000
print(a)                # the original array is untouched

a[3] = 4000
print(another_slice)    # the copy is unaffected by changes to the original

This behaviour exists for performance reasons (copying large arrays every time would be slow), but it's one of the most common sources of confusing bugs for newcomers, so it's worth testing this out yourself in a notebook until it feels intuitive.

Indexing Multi-Dimensional Arrays

b = np.arange(48).reshape(4, 12)   # a 2D array: 4 rows, 12 columns

print(b[1, 2])     # row 1, column 2
print(b[1, :])      # row 1, all columns
print(b[:, 1])      # all rows, column 1
print(b[1:2, :])    # row 1 only, kept as a 2D slice rather than a 1D row

# Selecting specific, non-adjacent rows or columns
print(b[(0, 2), 2:5])          # rows 0 and 2, columns 2 to 4
print(b[:, (-1, 2, -1)])        # all rows; columns -1 (last), 2, and -1 again

# "Fancy indexing": pairing up row and column indices
print(b[(-1, 2, -1, 2), (5, 9, 1, 9)])
# returns a 1D array containing b[-1, 5], b[2, 9], b[-1, 1] and b[2, 9] again

# np.ix_ builds an index grid, useful for selecting a sub-block of rows AND columns together
rows_on = [1, 3]
cols_on = [2, 5, 8]
print(np.ix_(rows_on, cols_on))
print(b[np.ix_(rows_on, cols_on)])

# Boolean indexing: select elements matching a condition
print(b[b % 3 == 1])     # every element in b that leaves a remainder of 1 when divided by 3

For a 3D array, an extra dimension (think of it as "which matrix") is added to the indexing:

c = np.arange(48).reshape(4, 2, 6)   # a 3D array: 4 matrices, each 2 rows x 6 columns

print(c[2, 1, 4])     # matrix 2, row 1, column 4
print(c[2, :, 3])      # matrix 2, all rows, column 3
print(c[2, 1])          # matrix 2, row 1, all columns, equivalent to c[2, 1, :]

# The ellipsis (...) fills in "all remaining dimensions", useful for higher-dimensional arrays
print(c[2, ...])         # matrix 2, all rows, all columns; equivalent to c[2, :, :]
print(c[2, 1, ...])       # matrix 2, row 1, all columns; equivalent to c[2, 1, :]
print(c[2, ..., 3])       # matrix 2, all rows, column 3; equivalent to c[2, :, 3]
print(c[..., 3])          # all matrices, all rows, column 3; equivalent to c[:, :, 3]

Iterating Over Arrays

c = np.arange(48).reshape(4, 2, 6)

# Iterating over a multi-dimensional array iterates over its first axis
for m in c:
    print("Item:")
    print(m)

# .flat lets you iterate over every individual element, regardless of dimensions
for i in c.flat:
    print("Item:", i)

Stacking, Splitting, and Transposing Arrays

# Stacking arrays of the same shape together
q1 = np.full((3, 4), 1.0)
q2 = np.full((4, 4), 2.0)
q3 = np.full((3, 4), 3.0)

q8 = np.stack((q1, q3))   # stacks q1 and q3 along a new first axis
print(q8)
print(q8.shape)            # (2, 3, 4): two 3x4 arrays stacked together

# Splitting an array into equal-sized chunks
r = np.arange(24).reshape(6, 4)

r1, r2, r3 = np.vsplit(r, 3)    # split into 3 chunks, stacked vertically (by rows)
print(r1); print(r2); print(r3)

r4, r5 = np.hsplit(r, 2)        # split into 2 chunks, side by side (by columns)
print(r4); print(r5)

# Transposing rearranges an array's axes
t = np.arange(24).reshape(4, 2, 3)

t1 = t.transpose((1, 2, 0))     # explicitly reorder the axes
t2 = t.transpose()                # reverses all axes by default; equivalent to t.transpose((2, 1, 0))
t3 = t.swapaxes(0, 1)             # swaps just two axes; equivalent to t.transpose((1, 0, 2))

# For a plain 2D matrix, .T is a quick shortcut for the transpose
m1 = np.arange(12).reshape(3, 4)
print(m1.T)

# Transposing a 1D array has no visible effect, since it only has one axis
m2 = np.arange(5)
print(m2.T)

Linear Algebra with NumPy

from numpy import linalg

m3 = np.array([[1, 2, 3], [5, 7, 11], [21, 29, 31]])

n1 = np.array([[10, 20, 30], [40, 50, 60]])
n2 = np.array([[1, 2], [3, 4], [5, 6]])
print(n1.dot(n2))                    # matrix multiplication

print(linalg.inv(m3))                 # the inverse of m3
print(linalg.pinv(m3))                # the pseudo-inverse, works even for non-square matrices
print(m3.dot(linalg.inv(m3)))         # a matrix multiplied by its own inverse gives (approximately) the identity matrix

# QR decomposition
q, r = linalg.qr(m3)
print(q); print(r)
print(q.dot(r))                        # q.dot(r) should reconstruct m3

# Eigenvalues and eigenvectors
eigenvalues, eigenvectors = linalg.eig(m3)
print(eigenvalues)      # λ
print(eigenvectors)     # v
print(m3.dot(eigenvectors) - eigenvalues * eigenvectors)   # should be (approximately) zero: m3.v - λ.v = 0

# Singular value decomposition
m4 = np.array([[1, 0, 0, 0, 2], [0, 0, 3, 0, 0], [0, 0, 0, 0, 0], [0, 4, 0, 0, 0]])
U, S_diag, V = linalg.svd(m4)
print(U); print(S_diag); print(V)

print(np.diag(m3))                     # the diagonal values of m3, top-left to bottom-right

# Solving a system of linear equations, e.g. coeffs · solution = depvars
coeffs = np.array([[2, 6, 1], [1, 5, 3], [1, 2, 4]])
depvars = np.array([13, 15, 16])
solution = linalg.solve(coeffs, depvars)

print(solution)
print(np.allclose(coeffs.dot(solution), depvars))   # confirms the solution is correct (allowing for tiny rounding errors)

A Practical Example: Vectorised Calculations

A common beginner mistake is writing a calculation using nested Python loops. It works, but it's slow, especially on larger arrays. NumPy's vectorised approach applies the calculation to the whole array at once, and is both faster and easier to read:

import math
import numpy as np

# The slow way: looping over every individual cell
data = np.empty((768, 1024))
for y in range(768):
    for x in range(1024):
        data[y, x] = math.sin(x * y / 40.5)   # BAD! Very inefficient.

# The fast, vectorised way: build coordinate grids, then apply the function to the whole grid at once
x_coords = np.arange(0, 1024)
y_coords = np.arange(0, 768)
X, Y = np.meshgrid(x_coords, y_coords)

data = np.sin(X * Y / 40.5)

# Visualise the result
import matplotlib.pyplot as plt

fig = plt.figure(1, figsize=(7, 6))
plt.imshow(data, cmap="hot")
plt.show()

Saving and Loading Arrays

a = np.array([10, 20, 30, 40, 50])

# Saving and loading a single array in NumPy's own binary format (.npy)
np.save("my_array", a)

with open("my_array.npy", "rb") as f:
    content = f.read()
print(content)

a_loaded = np.load("my_array.npy")
print(a_loaded)

# Saving and loading as a plain text CSV file
np.savetxt("my_array.csv", a, delimiter=",")

with open("my_array.csv", "rt") as f:
    print(f.read())

a_loaded = np.loadtxt("my_array.csv", delimiter=",")
print(a_loaded)

# Saving multiple arrays together in a single compressed .npz file
b = np.arange(48).reshape(4, 12)
np.savez("my_arrays", my_a=a, my_b=b)

with open("my_arrays.npz", "rb") as f:
    content = f.read()
print(repr(content)[:180] + "[...]")

my_arrays = np.load("my_arrays.npz")
print(my_arrays.keys())
print(my_arrays["my_a"])

Array Arithmetic vs List Arithmetic

A subtle but important distinction: arithmetic on Python lists behaves very differently from arithmetic on NumPy arrays.

import numpy as np

A = [4, 5, 6]
B = [1, 2, 3]
print(f'A = {A}\nB = {B}')

# With plain Python lists, '+' concatenates rather than adds
C = A + B
print(f'C = {C}')     # [4, 5, 6, 1, 2, 3], NOT element-wise addition!

# Converting to NumPy arrays changes the behaviour of arithmetic operators entirely
Array_A = np.array(A)
Array_B = np.array(B)

Add = Array_A + Array_B
print(f'Add = {Add}')             # element-wise addition: [5 7 9]

Subtract = Array_A - Array_B
print(f'Subtract = {Subtract}')

Multiply = Array_A * Array_B
print(f'Multiply = {Multiply}')    # element-wise multiplication, NOT matrix multiplication

Divide = Array_A / Array_B
print(f'Divide = {Divide}')

Floor_Div = Array_A // Array_B
print(f'Floor_Div = {Floor_Div}')

Modulo = Array_A % Array_B
print(f'Modulo = {Modulo}')

Power = Array_A ** Array_B
print(f'Power = {Power}')
# Creating and slicing a larger array
Array = np.arange(0, 100)
print(f'Array = {Array}')

print(Array[1:10])      # elements at index 1 to 9
print(Array[1:50:10])   # every 10th element between index 1 and 49

Matrix Operations Recap

row_1 = [2, 4, 5, 4, 3, 2]
row_2 = [4, 6, 7, 8, 7, 3]
row_3 = [8, 3, 2, 1, 4, 7]

print(f'row_1 = {row_1}\nrow_2 = {row_2}\nrow_3 = {row_3}')

Matrix_1 = np.array([row_1, row_2, row_3])
Matrix_2 = np.array([[1, 2, 3, 4, 5, 6], [6, 5, 4, 3, 2, 1], [2, 2, 2, 2, 2, 2]])

# Element-wise operations
Add_Matrix = Matrix_1 + Matrix_2
print(f'Add_Matrix = {Add_Matrix}')

Subtract_Matrix = Matrix_1 - Matrix_2
print(f'Subtract_Matrix = {Subtract_Matrix}')

Multiplication_Matrix = Matrix_1 * Matrix_2    # element-wise, not true matrix multiplication
print(f'Multiplication_Matrix = {Multiplication_Matrix}')

Division_Matrix = Matrix_1 / Matrix_2
print(f'Division_Matrix = {Division_Matrix}')

# True matrix multiplication requires compatible shapes, so we transpose Matrix_2 first
Matrix_2T = np.transpose(Matrix_2)
Mat_Mul = np.matmul(Matrix_1, Matrix_2T)
print(f'Matrix_2_Transpose = {Matrix_2T}\n\nMat_Mul = {Mat_Mul}')

# Indexing a matrix
print(Matrix_1[0])          # first row
print(Matrix_1[:, 0])        # first column
print(Matrix_1[1, 2])         # row 1, column 2
print(Matrix_1[1][2])         # same result, different indexing style
print(Matrix_1[0:2, 4:6])      # a sub-block: rows 0-1, columns 4-5

# Boolean (conditional) indexing
print(Matrix_1[Matrix_1 > 4])
print(Matrix_1[(Matrix_1 > 3) & (Matrix_1 <= 6)])

# Transpose
Matrix_1T = np.transpose(Matrix_1)
print(f'Matrix_1T = {Matrix_1T}')

# Only square matrices support eigenvalues, rank, trace, determinant, and inverse in the usual sense
Square_Matrix = np.array([[4, 2, 1], [3, 5, 2], [1, 1, 6]])

Eigen_Value, Eigen_Vector = np.linalg.eig(Square_Matrix)
print(f'Eigen_Value = {Eigen_Value}, Eigen_Vector = {Eigen_Vector}')

Rank = np.linalg.matrix_rank(Square_Matrix)
print(f'Rank = {Rank}')

Trace = np.trace(Square_Matrix)     # the sum of the diagonal elements
print(f'Trace = {Trace}')

Det = np.linalg.det(Square_Matrix)
print(f'Det = {Det}')

Inv = np.linalg.inv(Square_Matrix)
print(f'Inv = {Inv}')

Iden_Matrix = np.matmul(Square_Matrix, Inv)   # a matrix times its inverse gives (approximately) the identity matrix
print(f'Iden_Matrix = {Iden_Matrix}')

# Solving a system of linear equations: Square_Matrix . Solve = Matrix_2 (using a matching square Matrix_2)
Matrix_2_sq = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 10]])
Solve = np.linalg.solve(Square_Matrix, Matrix_2_sq)
print(f'Solve = {Solve}')

Matrix_2_Check = np.matmul(Square_Matrix, Solve)   # should reconstruct Matrix_2_sq
print(f'Matrix_2_Check = {Matrix_2_Check}')

# Inner and outer products
Inner_Product = np.inner(Matrix_1, Matrix_1)
print(f'Inner_Product = {Inner_Product}')

Outer_Product = np.outer(Matrix_1, Matrix_1)
print(f'Outer_Product = {Outer_Product}')

Splitting Stacked Arrays

# Splitting a "stacked" array back into its original pieces
V_Stack = np.vstack([np.arange(6) for _ in range(150)])   # 150 rows, stacked vertically
V_Split = np.vsplit(V_Stack, 50)                             # split back into 50 equal chunks
print(V_Split)

H_Stack = np.hstack([np.arange(30).reshape(1, 30) for _ in range(3)])
H_Split = np.hsplit(H_Stack, 3)
print(H_Split)

A closing note: you don't need to memorise every one of these functions right away. What matters most at this stage is recognising the categories of things NumPy can do, creating arrays, indexing and slicing them, applying maths across them all at once, and reshaping them, so that later, when you need a specific operation, you know roughly where to look.