Learnixo
Back to blog
AI Systemsintermediate

NumPy Arrays vs Python Lists

Understand why NumPy arrays are fundamental to AI/ML: dtype, shape, memory layout, vectorized operations, and performance comparison with Python lists.

Asma Hafeez KhanMay 16, 20265 min read
PythonNumPyArraysPerformanceVectorizationMachine Learning
Share:š•

Why NumPy?

Python lists are flexible but slow for numerical computation. NumPy arrays are fast because:

  1. Homogeneous dtype — all elements the same type, stored contiguously in memory
  2. C implementation — operations run in C, not Python
  3. Vectorized operations — apply functions to entire arrays without Python loops
Python
import numpy as np

# Python list: heterogeneous, pointer-based, slow loops
py_list = [1.0, 2.0, 3.0, 4.0, 5.0]

# NumPy array: typed, contiguous memory, fast
np_array = np.array([1.0, 2.0, 3.0, 4.0, 5.0])

# Speed comparison: multiply every element by 2
import timeit

data_list = list(range(1_000_000))
data_np   = np.arange(1_000_000, dtype=np.float64)

list_time = timeit.timeit(lambda: [x * 2 for x in data_list], number=100)
np_time   = timeit.timeit(lambda: data_np * 2, number=100)

print(f"List loop: {list_time:.3f}s")
print(f"NumPy:     {np_time:.3f}s")
# NumPy is typically 50-100x faster for large arrays

Creating Arrays

Python
import numpy as np

# From Python list
arr = np.array([1, 2, 3, 4, 5])
print(arr.dtype)   # int64 (platform-dependent)

# Specify dtype
arr_f32 = np.array([1.0, 2.0, 3.0], dtype=np.float32)
arr_f64 = np.array([1.0, 2.0, 3.0], dtype=np.float64)   # Default for floats

# 2D array (matrix)
matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(matrix.shape)   # (3, 3)

# Utility constructors
zeros = np.zeros((3, 4))        # 3Ɨ4 array of 0.0
ones  = np.ones((2, 5))         # 2Ɨ5 array of 1.0
eye   = np.eye(3)               # 3Ɨ3 identity matrix
rng   = np.arange(0, 10, 2)     # [0, 2, 4, 6, 8]
lin   = np.linspace(0, 1, 5)    # [0, 0.25, 0.5, 0.75, 1.0] — 5 evenly spaced points
rand  = np.random.rand(3, 4)    # Random 3Ɨ4 matrix, uniform [0, 1)
randn = np.random.randn(3, 4)   # Random 3Ɨ4 matrix, standard normal

Shape, dtype, and ndim

Python
embeddings = np.random.randn(100, 1536)   # 100 embeddings, 1536 dimensions each

print(embeddings.shape)   # (100, 1536)
print(embeddings.ndim)    # 2
print(embeddings.dtype)   # float64
print(embeddings.size)    # 153600 (total elements)
print(embeddings.nbytes)  # 1228800 (bytes = 153600 * 8 bytes/float64)

# Reshape: same data, different shape
reshaped = embeddings.reshape(1, 100, 1536)   # Add batch dimension
print(reshaped.shape)   # (1, 100, 1536)

flat = embeddings.flatten()               # 1D copy
flat = embeddings.ravel()                 # 1D view (no copy if possible)
print(flat.shape)   # (153600,)

Dtype: Choosing the Right One

Python
# Memory and precision tradeoffs:
# float64 (8 bytes): default, full precision
# float32 (4 bytes): used in most ML frameworks (PyTorch default)
# float16 (2 bytes): half precision for GPU inference
# int32 (4 bytes): token IDs, class labels

embeddings_f64 = np.random.randn(1000, 1536).astype(np.float64)
embeddings_f32 = embeddings_f64.astype(np.float32)

print(embeddings_f64.nbytes)   # 12,288,000 bytes (~12 MB)
print(embeddings_f32.nbytes)   # 6,144,000 bytes (~6 MB)

# PyTorch models expect float32 by default
# OpenAI embeddings return float64 by default — convert when needed

Vectorized Operations

Operations on NumPy arrays apply element-wise automatically:

Python
a = np.array([1.0, 2.0, 3.0])
b = np.array([4.0, 5.0, 6.0])

# Element-wise arithmetic (no loop needed)
print(a + b)    # [5. 7. 9.]
print(a * b)    # [4. 10. 18.]
print(a ** 2)   # [1. 4. 9.]
print(a / b)    # [0.25 0.4  0.5]

# Universal functions (ufuncs) — apply to every element
print(np.sqrt(a))      # [1.    1.414 1.732]
print(np.exp(a))       # [2.718 7.389 20.086]
print(np.log(a))       # [0.     0.693 1.099]


# Key AI computation: cosine similarity
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

query_embedding = np.random.randn(1536)
doc_embedding   = np.random.randn(1536)
sim = cosine_similarity(query_embedding, doc_embedding)
print(f"Cosine similarity: {sim:.4f}")


# Batch cosine similarity: query vs all documents at once
def batch_cosine_similarity(query: np.ndarray, docs: np.ndarray) -> np.ndarray:
    """Compute similarity between one query and many documents."""
    query_norm = query / np.linalg.norm(query)              # Normalize query
    doc_norms = docs / np.linalg.norm(docs, axis=1, keepdims=True)  # Normalize each doc
    return doc_norms @ query_norm                            # Matrix multiply

query = np.random.randn(1536)
doc_embeddings = np.random.randn(100, 1536)   # 100 documents
similarities = batch_cosine_similarity(query, doc_embeddings)
print(similarities.shape)   # (100,) — one score per document
top_indices = np.argsort(similarities)[::-1][:5]   # Top 5 document indices

Aggregation

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

# Global aggregations
print(arr.sum())     # 45
print(arr.mean())    # 5.0
print(arr.max())     # 9
print(arr.min())     # 1
print(arr.std())     # Standard deviation

# Axis-wise (axis=0: collapse rows, result per column; axis=1: collapse columns, result per row)
print(arr.sum(axis=0))   # [12 15 18] — sum each column
print(arr.sum(axis=1))   # [6  15 24] — sum each row
print(arr.max(axis=1))   # [3  6  9]  — max of each row

# Useful in ML
retrieval_scores = np.array([[0.92, 0.45, 0.78], [0.61, 0.89, 0.33]])
print(np.mean(retrieval_scores, axis=1))   # Mean score per query
print(np.argmax(retrieval_scores, axis=1)) # Index of highest-scoring doc per query

Views vs Copies

Python
arr = np.arange(10)

# Slicing returns a VIEW — same data in memory
view = arr[2:5]
view[0] = 99
print(arr)   # [ 0  1 99  3  4  5  6  7  8  9] — original changed!

# .copy() returns an independent copy
copy = arr[2:5].copy()
copy[0] = 0
print(arr)   # Unchanged

# Check if array is a view
print(view.base is arr)   # True — view shares arr's data
print(copy.base is None)  # True — copy owns its data


# Why it matters in AI: avoid unintended mutation
embeddings = load_embeddings()
batch = embeddings[:32]    # View — modifications affect original
batch = embeddings[:32].copy()  # Safe independent copy

NumPy vs Python List: Summary

| Feature | Python List | NumPy Array | |---|---|---| | Type | Heterogeneous (any type) | Homogeneous (one dtype) | | Memory | Pointers to objects | Contiguous typed buffer | | Element-wise ops | Manual loop | Vectorized (C speed) | | Indexing | Supports negative | Supports negative + fancy | | Speed (numeric ops) | ~1x | ~50-100x | | Memory (floats) | ~56 bytes/element | 4-8 bytes/element | | Multidimensional | Nested lists | Native ndarray | | Math functions | math module (scalar) | np. (vectorized) | | When to use | Mixed types, small data | Numeric computation, ML |

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.