Python

Bridge To Data And ML

NumPy Primer

Python's built-in list (Module 1, Chapter 6) is flexible but slow for numeric computation — each element is a full Python object, and operations loop one item a

JrCodex·8 min read

Jr Codex Python Notes

Level: Advanced Prerequisites: Module 5: Advanced Python Time to complete: ~30 minutes


Table of Contents

  1. Why NumPy?
  2. Creating Arrays
  3. Array Shape & Dimensions
  4. Vectorized Operations
  5. Indexing & Slicing
  6. Boolean Masking
  7. Broadcasting
  8. Useful Array Methods
  9. Summary & Next Steps

1. Why NumPy?

Python's built-in list (Module 1, Chapter 6) is flexible but slow for numeric computation — each element is a full Python object, and operations loop one item at a time. NumPy (Numerical Python) provides the ndarray, a fixed-type array stored contiguously in memory, with operations implemented in optimized C — often 10-100x faster than an equivalent Python loop.

import numpy as np
 
# Plain Python list — a loop is required for element-wise math
prices = [10, 20, 30, 40]
doubled = [p * 2 for p in prices]     # Module 2's comprehension pattern
print(doubled)                          # [20, 40, 60, 80]
 
# NumPy array — the operation applies to EVERY element at once, no explicit loop
prices_np = np.array([10, 20, 30, 40])
doubled_np = prices_np * 2
print(doubled_np)                        # [20 40 60 80]

This "apply an operation to every element without writing a loop" style is called vectorization — it's the core idea behind everything in this chapter, and the foundation of virtually every numeric library used later in this curriculum (pandas, scikit-learn, PyTorch).


2. Creating Arrays

import numpy as np
 
a = np.array([1, 2, 3, 4, 5])                  # from a list
print(a)                                          # [1 2 3 4 5]
print(type(a))                                      # <class 'numpy.ndarray'>
 
zeros = np.zeros(5)               # [0. 0. 0. 0. 0.]
ones = np.ones(5)                   # [1. 1. 1. 1. 1.]
range_arr = np.arange(0, 10, 2)       # [0 2 4 6 8] — like Python's range(), but returns an array
linspace = np.linspace(0, 1, 5)         # [0. 0.25 0.5 0.75 1.] — 5 evenly spaced points
 
matrix = np.array([[1, 2, 3], [4, 5, 6]])     # a 2D array, from a nested list (Module 1, Ch.6)
print(matrix)
# [[1 2 3]
#  [4 5 6]]
 
random_arr = np.random.rand(3)          # 3 random floats between 0 and 1

Key difference from Python lists: every element in a NumPy array shares the same data type (dtype) — this uniformity is exactly what makes the C-level optimizations possible.

a = np.array([1, 2, 3])
print(a.dtype)      # int64 (or int32 on some systems)
 
b = np.array([1, 2, 3.5])
print(b.dtype)         # float64 — NumPy "upcasts" ALL elements to float if any one is a float
print(b)                 # [1.  2.  3.5]

3. Array Shape & Dimensions

a = np.array([1, 2, 3, 4, 5])
print(a.shape)      # (5,) — a 1D array with 5 elements
print(a.ndim)          # 1
 
matrix = np.array([[1, 2, 3], [4, 5, 6]])
print(matrix.shape)      # (2, 3) — 2 rows, 3 columns
print(matrix.ndim)          # 2
 
print(matrix.size)            # 6 — total number of elements (2 * 3)
 
reshaped = a.reshape(5, 1)      # reshape into a column: 5 rows, 1 column
print(reshaped)
# [[1]
#  [2]
#  [3]
#  [4]
#  [5]]
 
flattened = matrix.flatten()      # collapse back down to 1D
print(flattened)                    # [1 2 3 4 5 6]
Shape Notation
─────────────────────────────────────────
  (5,)     → 1D array, 5 elements
  (2, 3)    → 2D array (matrix), 2 rows × 3 columns
  (2, 3, 4)  → 3D array, e.g. two 3x4 grids stacked
─────────────────────────────────────────

4. Vectorized Operations

a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])
 
print(a + b)      # [11 22 33 44] — element-wise addition, no loop needed
print(a * b)        # [10 40 90 160] — element-wise multiplication
print(a * 2)          # [2 4 6 8] — every element multiplied by a scalar
print(a ** 2)           # [1 4 9 16] — every element squared
print(a > 2)              # [False False True True] — element-wise comparison, produces a bool array
 
print(np.sqrt(a))      # [1. 1.41 1.73 2.] — apply sqrt to every element
print(np.sum(a))          # 10 — sum of all elements
print(np.mean(a))           # 2.5 — average
print(np.max(a))              # 4
print(np.std(a))                # standard deviation

Compare to what the equivalent would require with a plain Python list:

# Plain Python — every operation needs an explicit loop or comprehension
a_list = [1, 2, 3, 4]
b_list = [10, 20, 30, 40]
added = [x + y for x, y in zip(a_list, b_list)]     # Module 1 Ch.11's zip()
squared = [x ** 2 for x in a_list]
total = sum(a_list)

NumPy replaces all of that looping with a single, optimized operation per line.


5. Indexing & Slicing

Familiar syntax from Module 1's strings/lists, extended to multiple dimensions:

a = np.array([10, 20, 30, 40, 50])
print(a[0])           # 10
print(a[-1])            # 50
print(a[1:4])             # [20 30 40]
 
matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
 
print(matrix[0])           # [1 2 3] — entire first row
print(matrix[0, 1])           # 2 — row 0, column 1 (NumPy's own comma syntax)
print(matrix[:, 1])              # [2 5 8] — entire SECOND column (all rows, column index 1)
print(matrix[0:2, 1:3])            # [[2 3] [5 6]] — a sub-matrix (rows 0-1, columns 1-2)
matrix[:, 1]  — Reading the Syntax
─────────────────────────────────────────
  matrix[ ROWS , COLUMNS ]
           :       1
           ↑        ↑
      "all rows"  "column index 1"
─────────────────────────────────────────

6. Boolean Masking

A distinctly NumPy idiom, used constantly in real data work — filter an array using a condition, without writing an explicit loop:

a = np.array([10, 15, 20, 25, 30, 35])
 
mask = a > 20
print(mask)         # [False False False True True True]
 
filtered = a[mask]     # only keeps elements where mask is True
print(filtered)          # [25 30 35]
 
# Usually written in one line:
print(a[a > 20])      # [25 30 35]
 
# Combine conditions with & (and) / | (or) — NOT Python's `and`/`or` (Module 1, Ch.3)!
print(a[(a > 15) & (a < 30)])      # [20 25]
 
# Modify only the matching elements
a[a > 20] = 0
print(a)      # [10 15 20 0 0 0]

Important: use & and |, never and/or, when combining conditions on arrays — Python's and/or don't work element-wise and will raise an error on arrays with more than one element.


7. Broadcasting

Broadcasting is NumPy's rule for applying operations between arrays of different shapes, by conceptually "stretching" the smaller one to match — this is what made a * 2 work earlier (a single scalar "broadcast" across the whole array).

matrix = np.array([[1, 2, 3], [4, 5, 6]])       # shape (2, 3)
row = np.array([10, 20, 30])                       # shape (3,)
 
result = matrix + row
print(result)
# [[11 22 33]
#  [14 25 36]]
Broadcasting Visualized
─────────────────────────────────────────
  matrix (2,3)        row (3,)  "stretched" to match
  [1  2  3]      +    [10 20 30]     [1  2  3]     [10 20 30]
  [4  5  6]            (conceptually)  [4  5  6]  +  [10 20 30]
                                          = [[11 22 33], [14 25 36]]
─────────────────────────────────────────

Broadcasting only works when shapes are compatible (matching, or one of them is size 1 along a dimension) — mismatched shapes raise a ValueError. This exact mechanism is why you can write data - data.mean() in one line rather than looping — a pattern you'll see constantly once you reach the Machine Learning notes' data preprocessing chapter.


8. Useful Array Methods

a = np.array([5, 2, 8, 1, 9, 3])
 
print(np.sort(a))        # [1 2 3 5 8 9] — like Module 1's sorted(), but for arrays
print(np.argsort(a))       # [3 1 5 0 2 4] — INDICES that would sort the array
print(np.unique(a))           # [1 2 3 5 8 9] — like Module 1's set(), but sorted and array-typed
 
print(a.argmax())      # 4 — index of the maximum value
print(a.argmin())        # 3 — index of the minimum value
 
matrix = np.array([[1, 2], [3, 4], [5, 6]])
print(matrix.sum(axis=0))     # [9 12] — sum DOWN each column
print(matrix.sum(axis=1))       # [3 7 11] — sum ACROSS each row
axis Parameter — the #1 Source of Confusion for Beginners
─────────────────────────────────────────
  axis=0  → operate DOWN the rows (collapse rows, one result per COLUMN)
  axis=1  → operate ACROSS the columns (collapse columns, one result per ROW)
─────────────────────────────────────────

9. Summary & Next Steps

Key Takeaways

  • NumPy's ndarray stores same-typed data contiguously and applies operations in optimized C — vastly faster than looping over a Python list for numeric work.
  • Vectorized operations (a + b, a * 2, np.sqrt(a)) apply element-wise without an explicit loop.
  • Boolean masking (a[a > 20]) is the idiomatic way to filter arrays — combine conditions with &/|, never and/or.
  • Broadcasting lets operations work between differently-shaped arrays by conceptually stretching the smaller one — the mechanism behind writing data - data.mean() in a single line.

Concept Check

  1. Why is np.array([1, 2, 3]) * 2 faster than [x * 2 for x in [1, 2, 3]] at scale?
  2. Why must you use & instead of and when combining two boolean array conditions?
  3. What does axis=0 mean when calling .sum(axis=0) on a 2D array?

Next Chapter

Chapter 2: Pandas Primer


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index