19  NumPy

TipLearning Objectives
  • Understand what NumPy is and when to use it
  • Create, manipulate, slice, and inspect multi-dimensional arrays (ndarray)
  • Understand array memory allocation, specifically the distinction between views and copies during slicing
  • Perform vectorised arithmetic, broadcasting, axis aggregations, and basic matrix operations
  • Read and write NumPy array data from and to external files

19.1 What Is NumPy?

NumPy (Numerical Python) is a foundational Python library for numerical and scientific computing. It provides efficient data structures and functions for working with large, multi-dimensional numerical datasets. NumPy is widely used in data science, machine learning, engineering, physics, and finance, and it underpins many other libraries in the Python scientific ecosystem, such as pandas, SciPy, and scikit-learn.

You will likely need NumPy in many of your future Python courses, including machine learning and data science, which is why we introduce it here.

19.2 Base Data Structure: The ndarray

The core data structure in NumPy is the ndarray (N-dimensional array).

Key characteristics:

  • Stores elements of a single data type (homogeneous, e.g., all integers or all floats)
  • Supports one-dimensional (vectors), two-dimensional (matrices), and higher-dimensional arrays
  • Optimised for high performance and memory efficiency

19.2.1 Array Creation Examples

There are several ways to create arrays depending on your needs:

import numpy as np

# 1. Direct creation from Python lists
a = np.array([1, 2, 3])          # 1D array (vector)
print(a)
b = np.array([[1, 2], [3, 4]])   # 2D array (matrix)
print(b)

# 2. Sequential arrays
seq = np.arange(0, 10, 2)        # array([0, 2, 4, 6, 8]) — start, stop, step
print(seq)
lin = np.linspace(0, 1, 5)       # 5 evenly spaced numbers from 0 to 1
print(lin)

# 3. Constant arrays
zeros = np.zeros((2, 3))         # 2x3 matrix of 0.0
print("zeros:")
print(zeros)

ones = np.ones((3, 2))           # 3x2 matrix of 1.0
print("ones:")
print(ones)

filled = np.full((2, 2), 7)      # 2x2 matrix filled with 7
print("filled:")
print(filled)
[1 2 3]
[[1 2]
 [3 4]]
[0 2 4 6 8]
[0.   0.25 0.5  0.75 1.  ]
zeros:
[[0. 0. 0.]
 [0. 0. 0.]]
ones:
[[1. 1.]
 [1. 1.]
 [1. 1.]]
filled:
[[7 7]
 [7 7]]

19.2.2 Inspecting Array Attributes

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

print(arr.shape)   # (2, 3) — 2 rows, 3 columns
print(arr.ndim)    # 2      — number of dimensions
print(arr.dtype)   # int64  — data type of elements
print(arr.size)    # 6      — total number of elements
(2, 3)
2
int64
6

19.3 Array Memory: Views vs. Copies

A crucial concept in NumPy is the difference between a view and a copy when slicing arrays.

When you slice a standard Python list, Python creates a brand-new list containing copies of the elements. However, when you slice a NumPy ndarray, NumPy returns a view of the original array. A view shares the exact same underlying memory block as the original array.

19.3.1 Why Views Matter

Because views share memory, modifying a slice modifies the original array.

# Original array
original = np.array([10, 20, 30, 40, 50])
print(original)

# Extract a slice (this is a VIEW)
sub_array = original[1:4]  # array([20, 30, 40])
print("sub_array:")
print(sub_array)

# Modify an element in the slice
sub_array[0] = 999

# The original array is altered!
print("original is altered!")
print(original)  # Output: array([10, 999, 30, 40, 50])
[10 20 30 40 50]
sub_array:
[20 30 40]
original is altered!
[ 10 999  30  40  50]

This design choice makes NumPy extremely fast and memory-efficient when working with gigabytes of data, as it avoids making unnecessary copies in memory.

19.3.2 Creating an Explicit Copy

If you need an independent array where modifications do not affect the original dataset, you must explicitly create a copy using .copy():

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

# Create an explicit copy
independent_slice = original[1:4].copy()

# Modify the copy
independent_slice[0] = 999

# The original array remains untouched
print(original)           # Output: array([10, 20, 30, 40, 50])
print(independent_slice)  # Output: array([999, 30, 40])
[10 20 30 40 50]
[999  30  40]

19.4 Common Operations

Element-wise Operations & Vectorised Functions

Unlike standard Python lists, NumPy arrays allow you to perform mathematical operations on all elements simultaneously without writing explicit for loops.

x = np.array([1, 2, 3, 4])
y = np.array([10, 20, 30, 40])

# Basic Arithmetic
print("A")
print(x + y)         # array([11, 22, 33, 44])
print("B")
print(y - x)         # array([ 9, 18, 27, 36])
print("C")
print(x * y)         # array([10, 40, 90, 160])
print("D")
print(y / x)         # array([10., 10., 10., 10.])
print("E")
print(x ** 2)        # array([ 1,  4,  9, 16])

# Built-in Mathematical Functions
print("sqrt")
print(np.sqrt(x))    # Square root of each element
print("exp")
print(np.exp(x))     # Exponential e^x
print("sine")
print(np.sin(x))     # Trigonometric sine

# Element-wise Comparisons (returns boolean array)
print("boolean ouput")
print(x > 2)         # array([False, False,  True,  True])
A
[11 22 33 44]
B
[ 9 18 27 36]
C
[ 10  40  90 160]
D
[10. 10. 10. 10.]
E
[ 1  4  9 16]
sqrt
[1.         1.41421356 1.73205081 2.        ]
exp
[ 2.71828183  7.3890561  20.08553692 54.59815003]
sine
[ 0.84147098  0.90929743  0.14112001 -0.7568025 ]
boolean ouput
[False False  True  True]

Broadcasting

Broadcasting allows NumPy to perform operations on arrays of different shapes by implicitly expanding the smaller array across the larger one.

# Scalar broadcasting
x = np.array([1, 2, 3])
print("x * 2")
print(x * 2)               # array([2, 4, 6])
print("x + 10")
print(x + 10)              # array([11, 12, 13])

# 2D and 1D Broadcasting

matrix = np.array([[1, 2, 3], 
                    [4, 5, 6]])
row_vec = np.array([10, 20, 30])

# Add row_vec to EVERY row of matrix
print("Add row_vec to EVERY row of matrix")
print(matrix + row_vec)
x * 2
[2 4 6]
x + 10
[11 12 13]
Add row_vec to EVERY row of matrix
[[11 22 33]
 [14 25 36]]

Indexing, Slicing, and Boolean Masking

# 1D Indexing & Slicing
x = np.array([10, 20, 30, 40, 50])
print("x[0]")
print(x[0])          # 10

print("x[1:4]")
print(x[1:4])        # array([20, 30, 40])

print("x[-2:]")
print(x[-2:])        # array([40, 50])

# 2D Slicing [row_slice, column_slice]
A = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])

print("Element at row 0, col 1")
print(A[0, 1])       # 2

print("Entire first row")
print(A[0, :])       # array([1, 2, 3])

print("Entire last column")
print(A[:, 2])       # array([3, 6, 9])

print("2x2 Sub-array: rows 0-1, cols 1-2")
print(A[0:2, 1:3])

print("Boolean Masking (Filtering)")
scores = np.array([45, 88, 92, 60, 71])
passed = scores >= 70
print("passed")
print(passed)        # array([False,  True,  True, False,  True])
print("scores[passed]")
print(scores[passed])# array([88, 92, 71])
x[0]
10
x[1:4]
[20 30 40]
x[-2:]
[40 50]
Element at row 0, col 1
2
Entire first row
[1 2 3]
Entire last column
[3 6 9]
2x2 Sub-array: rows 0-1, cols 1-2
[[2 3]
 [5 6]]
Boolean Masking (Filtering)
passed
[False  True  True False  True]
scores[passed]
[88 92 71]

Reshaping and Combining Arrays

print("Reshaping")
arr = np.arange(6)             # array([0, 1, 2, 3, 4, 5])
print("original", arr)
grid = arr.reshape((2, 3))     # 2x3 matrix
print("grid", grid)

print("Stacking/Combining Arrays")
v1 = np.array([1, 2])
v2 = np.array([3, 4])

print("Vertical stack (2x2 matrix)")
print(np.vstack((v1, v2)))    # Vertical stack (2x2 matrix)
print("Horizontal stack (1D array of length 4)")
print(np.hstack((v1, v2)))    # Horizontal stack (1D array of length 4)
Reshaping
original [0 1 2 3 4 5]
grid [[0 1 2]
 [3 4 5]]
Stacking/Combining Arrays
Vertical stack (2x2 matrix)
[[1 2]
 [3 4]]
Horizontal stack (1D array of length 4)
[1 2 3 4]

Aggregations

Aggregations summarise data across the entire array or along specific axes (axis=0 for columns, axis=1 for rows).

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

# Entire Array Aggregations
print("Sum")
print(A.sum())       # 21
print("Mean")
print(A.mean())      # 3.5
print("Max")
print(A.max())       # 6
print("std")
print(A.std())       # 1.7078...

# Axis-Specific Aggregations
print("Sum over axis 0")
print(A.sum(axis=0)) # Column sums -> array([5, 7, 9])

print("Mean over axis 1")
print(A.mean(axis=1))# Row means   -> array([3., 4.])

print("Argmax over axis 1")
print(A.argmax(axis=1)) # Index of maximum value per row -> array([1, 2])

Linear Algebra Operations

NumPy supports basic matrix manipulations such as transposition and matrix multiplication:

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

print("Matrix Transposition")
print(A.T)

print("Matrix Multiplication") #(@ operator or np.dot)
print(A @ B)
Matrix Transposition
[[1 3]
 [2 4]]
Matrix Multiplication
[[19 22]
 [43 50]]

19.5 Reading and Saving Data

Reading Data

Common methods include reading numerical values from text or CSV files:

# Reading from CSV (skipping header row if present)
data = np.loadtxt("data.csv", delimiter=",", skiprows=1)

or reading from NumPy’s native binary format (.npy or .npz):

data = np.load("data.npy")

Saving Data

# Saving to a text/CSV file with formatted output
np.savetxt("output.csv", data, delimiter=",", fmt="%.2f")

# Saving to NumPy's efficient binary format
np.save("output.npy", data)

NumPy is often combined with pandas, another package designed for more complex data ingestion tasks (such as handling mixed data types, missing values, and column names).

19.6 Advantages and Disadvantages

Advantages Disadvantages
Extremely fast for numerical operations due to C-based implementation Limited support for non-numeric or mixed data types
Memory-efficient compared to native Python lists Less intuitive for labelled or relational data
Extensive mathematical and array processing capabilities Steeper initial learning curve than basic Python lists
Seamlessly integrates with the scientific Python ecosystem

19.7 Common Alternatives and Their Use Cases

pandas
Used for labelled, tabular data (DataFrames). Builds on top of NumPy and adds indexing, grouping, and data-cleaning tools.

SciPy
Extends NumPy with advanced scientific algorithms (optimisation, signal processing, numerical integration, and statistics).

TensorFlow / PyTorch
Used for large-scale numerical computation and machine learning, especially with GPU acceleration and automatic differentiation.

Python Lists
Suitable for small datasets or heterogeneous (mixed-type) data, but inefficient for large numerical computations.

19.8 Documentation and Resources

The NumPy documentation is very thorough and readable. Practice using the official documentation to look up functions, parameters, and methods.

Official Documentation: https://numpy.org/doc/

19.9 Exercises

ExerciseExercise 1 - Array Creation & Inspection
  1. Create a 1D array of integers from 0 to 9.
  2. Create a 2D array of shape (3, 3) filled with zeros.
  3. Create a 2D array of shape (2, 4) filled with ones.
  4. Print the shape, data type, and number of dimensions of each created array.

Consider using np.arange(), np.zeros(), np.ones(), .shape, .dtype, and .ndim.

import numpy as np

# 1. 1D array of integers from 0 to 9
arr1 = np.arange(10)
print("1D Array:", arr1)

# 2. 2D array of zeros (3x3)
arr2 = np.zeros((3, 3))
print("\n2D Zeros Array:\n", arr2)

# 3. 2D array of ones (2x4)
arr3 = np.ones((2, 4))
print("\n2D Ones Array:\n", arr3)

# 4. Print attributes
print("\nArray 1 -> Shape:", arr1.shape, "| dtype:", arr1.dtype, "| ndim:", arr1.ndim)
print("Array 2 -> Shape:", arr2.shape, "| dtype:", arr2.dtype, "| ndim:", arr2.ndim)
print("Array 3 -> Shape:", arr3.shape, "| dtype:", arr3.dtype, "| ndim:", arr3.ndim)
1D Array: [0 1 2 3 4 5 6 7 8 9]

2D Zeros Array:
 [[0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]]

2D Ones Array:
 [[1. 1. 1. 1.]
 [1. 1. 1. 1.]]

Array 1 -> Shape: (10,) | dtype: int64 | ndim: 1
Array 2 -> Shape: (3, 3) | dtype: float64 | ndim: 2
Array 3 -> Shape: (2, 4) | dtype: float64 | ndim: 2
ExerciseExercise 2 - Reshaping and Spacing
  1. Create a 1D array containing 12 evenly spaced numbers between 0 and 5 (inclusive).
  2. Reshape this 1D array into a 2D matrix with 3 rows and 4 columns.
  3. Reshape the original array into another matrix with 2 rows and 6 columns.

Consider using np.linspace() and .reshape().

import numpy as np

# 1. 12 evenly spaced numbers from 0 to 5
arr_1d = np.linspace(0, 5, 12)
print("1D Array:\n", arr_1d)

# 2. Reshape into (3, 4)
matrix_3x4 = arr_1d.reshape((3, 4))
print("\n3x4 Matrix:\n", matrix_3x4)

# 3. Reshape into (2, 6)
matrix_2x6 = arr_1d.reshape((2, 6))
print("\n2x6 Matrix:\n", matrix_2x6)
1D Array:
 [0.         0.45454545 0.90909091 1.36363636 1.81818182 2.27272727
 2.72727273 3.18181818 3.63636364 4.09090909 4.54545455 5.        ]

3x4 Matrix:
 [[0.         0.45454545 0.90909091 1.36363636]
 [1.81818182 2.27272727 2.72727273 3.18181818]
 [3.63636364 4.09090909 4.54545455 5.        ]]

2x6 Matrix:
 [[0.         0.45454545 0.90909091 1.36363636 1.81818182 2.27272727]
 [2.72727273 3.18181818 3.63636364 4.09090909 4.54545455 5.        ]]
ExerciseExercise 3 - Slicing: Views vs. Copies
  1. Create a 1D array containing integers from 10 to 50 (inclusive), stepping by 10.
  2. Create a slice containing the middle three elements without using .copy().
  3. Modify the first element of your slice to 999.
  4. Print the original array to verify that it has changed.
  5. Repeat the process, but this time explicitly create a copy of the slice before modifying it. Verify that the original array remains unchanged.

Consider using np.arange() and .copy().

import numpy as np

# 1. Create array
original = np.arange(10, 60, 10)
print("Original Array:", original)

# 2. Slice without copy (creates a VIEW)
view_slice = original[1:4]

# 3. Modify element in view
view_slice[0] = 999

# 4. Verify original has changed
print("Original after modifying view:", original)

# 5. Repeat using explicit copy
original_2 = np.arange(10, 60, 10)
copy_slice = original_2[1:4].copy()
copy_slice[0] = 999

print("Original after modifying copy:", original_2)
print("Modified copy:", copy_slice)
Original Array: [10 20 30 40 50]
Original after modifying view: [ 10 999  30  40  50]
Original after modifying copy: [10 20 30 40 50]
Modified copy: [999  30  40]
ExerciseExercise 4 - Element-wise Operations
  1. Convert the sequences [1, 2, 3, 4] and [10, 20, 30, 40] into NumPy arrays.
  2. Compute their element-wise sum.
  3. Multiply each element of the first array by 3.
  4. Compute the square of each element in the second array.
  5. Calculate the square root of each element in the second array.

Consider using np.array() and np.sqrt().

import numpy as np

# 1. Convert sequences to arrays
a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])

# 2. Element-wise sum
print("Element-wise sum:", a + b)

# 3. Multiply array 'a' by 3
print("Array 'a' multiplied by 3:", a * 3)

# 4. Square of array 'b'
print("Square of array 'b':", b ** 2)

# 5. Square root of array 'b'
print("Square root of array 'b':", np.sqrt(b))
Element-wise sum: [11 22 33 44]
Array 'a' multiplied by 3: [ 3  6  9 12]
Square of array 'b': [ 100  400  900 1600]
Square root of array 'b': [3.16227766 4.47213595 5.47722558 6.32455532]
ExerciseExercise 5 - Slicing and Sub-arrays
  1. Create a 3x3 matrix containing numbers from 1 to 9.
  2. Extract the first row.
  3. Extract the last column.
  4. Extract the 2x2 sub-array located at the bottom-right corner.

Consider using np.arange(), .reshape(), and 2D indexing [row_slice, col_slice].

import numpy as np

# 1. Create 3x3 matrix
grid = np.arange(1, 10).reshape((3, 3))
print("3x3 Matrix:\n", grid)

# 2. Extract first row
print("\nFirst Row:", grid[0, :])

# 3. Extract last column
print("Last Column:", grid[:, -1])

# 4. Extract 2x2 bottom-right sub-array
print("Bottom-Right 2x2 Sub-array:\n", grid[1:, 1:])
3x3 Matrix:
 [[1 2 3]
 [4 5 6]
 [7 8 9]]

First Row: [1 2 3]
Last Column: [3 6 9]
Bottom-Right 2x2 Sub-array:
 [[5 6]
 [8 9]]
ExerciseExercise 6 - Boolean Masking and Filtering
  1. Create a 1D array with values: [12, -5, 8, 0, -3, 15, 22, -1].
  2. Create a boolean mask identifying which values are strictly greater than zero.
  3. Use this mask to extract only the positive numbers.
  4. Set all negative numbers in the original array to 0.

Consider using comparison operators (>) and boolean indexing (arr[mask]).

import numpy as np

# 1. Create array
data = np.array([12, -5, 8, 0, -3, 15, 22, -1])

# 2. Boolean mask for positive values
positive_mask = data > 0
print("Boolean Mask:", positive_mask)

# 3. Filter array using mask
positive_numbers = data[positive_mask]
print("Positive Numbers:", positive_numbers)

# 4. Replace negative numbers with 0
data[data < 0] = 0
print("Modified Data Array:", data)
Boolean Mask: [ True False  True False False  True  True False]
Positive Numbers: [12  8 15 22]
Modified Data Array: [12  0  8  0  0 15 22  0]
ExerciseExercise 7 - Combining Arrays
  1. Create two 1D arrays: [1, 2, 3] and [4, 5, 6].
  2. Stack them vertically to form a 2x3 matrix.
  3. Stack them horizontally to form a 1D array of length 6.

Consider using np.vstack() and np.hstack().

import numpy as np

# 1. Create arrays
v1 = np.array([1, 2, 3])
v2 = np.array([4, 5, 6])

# 2. Stack vertically
v_stacked = np.vstack((v1, v2))
print("Vertically Stacked:\n", v_stacked)

# 3. Stack horizontally
h_stacked = np.hstack((v1, v2))
print("\nHorizontally Stacked:", h_stacked)
Vertically Stacked:
 [[1 2 3]
 [4 5 6]]

Horizontally Stacked: [1 2 3 4 5 6]
ExerciseExercise 8 - Random Numbers and Aggregations
  1. Generate a 3x4 array of random integers between 0 and 10 (inclusive).
  2. Calculate the total sum of all elements in the array.
  3. Compute the mean score for each column (axis=0).
  4. Find the maximum element in each row (axis=1).

Consider using np.random.randint(), .sum(), .mean(), and .max().

import numpy as np

# Set random seed for reproducibility
np.random.seed(42)

# 1. Generate 3x4 random integer array (low, high, size)
random_matrix = np.random.randint(0, 11, size=(3, 4))
print("Random Matrix:\n", random_matrix)

# 2. Total sum
print("\nTotal Sum:", random_matrix.sum())

# 3. Column means (axis=0)
print("Column Means:", random_matrix.mean(axis=0))

# 4. Row maximums (axis=1)
print("Row Maximums:", random_matrix.max(axis=1))
Random Matrix:
 [[ 6  3 10  7]
 [ 4  6  9  2]
 [ 6 10 10  7]]

Total Sum: 80
Column Means: [5.33333333 6.33333333 9.66666667 5.33333333]
Row Maximums: [10  9 10]
ExerciseExercise 9 - Normalisation
  1. Create a 5x5 matrix filled with random floating-point values between 0 and 1.
  2. Normalise the matrix so that all values range between 0 and 1 using Min-Max normalisation:
    \[\text{Normalised} = \frac{X - X_{\text{min}}}{X_{\text{max}} - X_{\text{min}}}\]

Consider using np.random.rand(), .min(), and .max().

import numpy as np

# Set random seed for reproducibility
np.random.seed(10)

# 1. Create 5x5 random float matrix
rand_matrix = np.random.rand(5, 5)
print("Original Matrix:\n", rand_matrix)

# 2. Min-Max Normalisation
min_val = rand_matrix.min()
max_val = rand_matrix.max()

normalised_matrix = (rand_matrix - min_val) / (max_val - min_val)
print("\nNormalised Matrix:\n", normalised_matrix)
Original Matrix:
 [[0.77132064 0.02075195 0.63364823 0.74880388 0.49850701]
 [0.22479665 0.19806286 0.76053071 0.16911084 0.08833981]
 [0.68535982 0.95339335 0.00394827 0.51219226 0.81262096]
 [0.61252607 0.72175532 0.29187607 0.91777412 0.71457578]
 [0.54254437 0.14217005 0.37334076 0.67413362 0.44183317]]

Normalised Matrix:
 [[0.80823251 0.01769843 0.66322948 0.7845168  0.52089242]
 [0.23260785 0.20445058 0.79686805 0.17395695 0.08888513]
 [0.71769454 1.         0.         0.53530637 0.85173194]
 [0.64098263 0.75602799 0.30325904 0.96248417 0.74846616]
 [0.56727463 0.14558165 0.38906147 0.70587058 0.46120088]]
ExerciseExercise 10 - Data Processing and Analysis Workflow
  1. Create a 2D array of student scores (4 students, 3 subjects per student). Save it to student_scores.csv.
  2. Load the data back from student_scores.csv and display it.
  3. Calculate the average score for each subject (column) and store it in a 1D array named avg.
  4. Identify the student index (row number) with the highest average overall score.
  5. Normalise each subject column independently so that scores in each column range between 0 and 1.
  6. Bonus: Create a boolean mask indicating which individual student scores are strictly above the average score for their respective subject.

Consider using np.savetxt(), np.loadtxt(), .mean(axis=0), .mean(axis=1), np.argmax(), and broadcasting.

import numpy as np

# 1. Create student scores array and save to CSV
scores = np.array([[78, 85, 90],
                   [60, 72, 68],
                   [95, 92, 98],
                   [82, 80, 84]])

np.savetxt("student_scores.csv", scores, delimiter=",", fmt="%d")

# 2. Load from CSV
loaded_scores = np.loadtxt("student_scores.csv", delimiter=",")
print("Loaded Scores:\n", loaded_scores)

# 3. Average score for each subject (column)
avg = loaded_scores.mean(axis=0)
print("\nSubject Averages (avg):", avg)

# 4. Student with highest average score
student_means = loaded_scores.mean(axis=1)
top_student_index = np.argmax(student_means)
print(f"Top Student Index: {top_student_index} (Average Score: {student_means[top_student_index]:.2f})")

# 5. Normalise each subject column independently
col_mins = loaded_scores.min(axis=0)
col_maxs = loaded_scores.max(axis=0)
normalised_scores = (loaded_scores - col_mins) / (col_maxs - col_mins)
print("\nColumn-Normalised Scores:\n", normalised_scores)

# 6. Bonus: Scores above subject average (using broadcasting)
above_avg_mask = loaded_scores > avg
print("\nScores Above Subject Average:\n", above_avg_mask)
Loaded Scores:
 [[78. 85. 90.]
 [60. 72. 68.]
 [95. 92. 98.]
 [82. 80. 84.]]

Subject Averages (avg): [78.75 82.25 85.  ]
Top Student Index: 2 (Average Score: 95.00)

Column-Normalised Scores:
 [[0.51428571 0.65       0.73333333]
 [0.         0.         0.        ]
 [1.         1.         1.        ]
 [0.62857143 0.4        0.53333333]]

Scores Above Subject Average:
 [[False  True  True]
 [False False False]
 [ True  True  True]
 [ True False False]]
ExerciseExercise 11 - Extension: Using NumPy in Expense Calculator
  1. Modify the expense calculator to load numeric expense amounts from a CSV file using NumPy.
  2. Compute total expenses, average expense, median expense, and standard deviation.
  3. Filter out all individual expenses below a user-defined threshold using boolean masking.

Consider using np.loadtxt(), np.sum(), np.mean(), np.median(), np.std(), and boolean masks.