Guides Guide 6 Reference

ML and Data Processing

The other half of a data or ML interview: NumPy and pandas mental models, the statistics you will be asked to code from scratch, numerical stability, sampling, stream sketches, and the classic algorithms.

An ML or data-infrastructure loop rarely stops at the sixteen patterns. It adds two things: can you think in arrays rather than loops, and can you implement the maths without a library. This page covers both. Every pure-Python function here runs and is tested.

How the code blocks are marked. Most blocks are plain Python and run anywhere. The NumPy and pandas blocks are marked separately, because those libraries are not part of the standard library. All of them still carry complexity comments, same convention as Guide 1.

Contents

  1. Where the work happens
  2. NumPy: thinking in arrays
  3. pandas: thinking in tables
  4. Streaming and chunking
  5. Statistics, by hand
  6. Numerical stability
  7. Distance and similarity
  8. Sampling
  9. Sketches for streams
  10. Classic algorithms to implement
  11. Evaluation metrics
  12. Complexity of the common operations

Where the work happens

The first question in any data problem is which layer you are working at. Choosing wrong costs an order of magnitude, and interviewers watch for it.

LayerGood forRough throughputWatch out for
Pure Python loopLogic, small data, anything with branching~10⁷ simple ops/secFalls over past a few million rows
Built-ins and comprehensionssum, sorted, min, join~10⁸The loop runs in C, but each element is still a Python object
NumPyUniform numeric arrays, linear algebra~10⁹Only pays off if you avoid Python-level loops
pandasLabelled, heterogeneous tables; joins; group-by~10⁸iterrows and apply throw the speedup away
Out of processData larger than RAMSpark, DuckDB, Presto, or chunked streaming
The single rule. A Python for loop over a NumPy array or a pandas column is almost always a bug in disguise. The library’s whole value is that its loop runs in C over a contiguous block of memory. Writing the loop yourself pays the Python overhead and the library overhead.

NumPy: thinking in arrays

What an ndarray is. A fixed-size, single-dtype block of memory plus a shape. A Python list of a million integers is a million separate objects and a million pointers, around 36 MB. A NumPy array of a million int64 values is one contiguous 8 MB block. That layout is why the C loops are fast: the CPU can stream it and vectorise it.

The four things that define an array

AttributeMeaning
shapeA tuple of sizes, for example (1000, 3) for 1000 rows of 3 features.
dtypeThe single element type: int64, float32, bool. Everything is that type.
ndimNumber of dimensions. len(shape).
stridesBytes to step to move one index along each axis. This is how views work with no copy.
import numpy as np

a = np.array([[1, 2, 3], [4, 5, 6]])       # O(n) to build
a.shape                                     # (2, 3)
a.dtype                                     # dtype('int64')

np.zeros((2, 3))                            # O(n)
np.arange(6).reshape(2, 3)                  # O(1) reshape: a VIEW, no copy
np.linspace(0.0, 1.0, 5)                    # 5 evenly spaced values

Vectorisation: the whole point

Python loop: slow
out = np.empty(len(a))
for i in range(len(a)):      # O(n) in PYTHON
    out[i] = a[i] * 2 + 1    # ~100x slower
Vectorised: fast
out = a * 2 + 1              # O(n) in C,
                             # one pass, no
                             # Python objects

Broadcasting

What it is. When two arrays have different shapes, NumPy stretches the smaller one without copying it, provided the shapes are compatible. Compare the shapes right to left: each pair of dimensions must be equal, or one of them must be 1.
import numpy as np

rows = np.zeros((3, 4))          # shape (3, 4)
per_column = np.array([1, 2, 3, 4])          # shape (4,)
rows + per_column                # OK: (3,4) and (4,) -> the row is reused

per_row = np.array([1, 2, 3])                # shape (3,)
# rows + per_row                 # ERROR: (3,4) vs (3,) do not line up
rows + per_row.reshape(3, 1)     # OK: (3,4) and (3,1) -> the column is reused

# The classic use: centre every column of a feature matrix.
features = np.random.rand(100, 5)            # O(n)
centred = features - features.mean(axis=0)   # mean is shape (5,), broadcast down
axis is the dimension that disappears. For a (rows, cols) matrix, axis=0 collapses the rows and gives one value per column. axis=1 collapses the columns and gives one value per row. Say it that way and you will stop guessing.

Views versus copies

import numpy as np

base = np.arange(10)

view = base[2:5]                 # O(1)  a VIEW. No data copied.
view[0] = 99                     # base[2] is now 99 as well

copied = base[2:5].copy()        # O(k)  independent
fancy = base[[2, 3, 4]]          # O(k)  fancy indexing ALWAYS copies
mask = base[base > 5]            # O(n)  boolean masking also copies
Slicing a NumPy array is a view; slicing a Python list is a copy. That is the opposite of the habit you built in Guide 1, and it is the most common source of “why did my original array change”. Fancy indexing and boolean masks copy, plain slices do not.

The operations worth knowing cold

NeedCallCost
Element-wise mathsa + b, np.exp(a), np.log(a)O(n)
Reduce along an axisa.sum(axis=0), a.mean(axis=1), a.max()O(n)
Matrix multiplya @ bO(n³) for n×n
Sortnp.sort(a), np.argsort(a)O(n log n)
Top k without sortingnp.argpartition(a, -k)O(n)
Conditional selectnp.where(cond, x, y)O(n)
Unique values and countsnp.unique(a, return_counts=True)O(n log n)
Cumulativenp.cumsum(a)O(n)
Insertion pointsnp.searchsorted(sorted_a, v)O(log n)
np.argpartition is the NumPy version of quickselect: it puts the k-th element in its final place in O(n) without fully sorting. It is the right answer to “top k of a large array” and most candidates reach for argsort instead.

pandas: thinking in tables

What a DataFrame is. A dict of columns, where each column is a NumPy-backed Series with its own dtype, plus a shared index that labels the rows. It is column-oriented, so operating on a whole column is fast and operating row by row is not.
import pandas as pd

df = pd.DataFrame({"user": ["a", "b", "a"], "spend": [10, 20, 5]})

df.shape                    # (3, 2)
df.dtypes                   # per-column types; object means Python strings
df["spend"].sum()           # O(n) in C
df.head()                   # first 5 rows
df.info()                   # dtypes and MEMORY, the first thing to check

Selecting: loc versus iloc

CallSelects byExample
df.loc[...]Label, and the end is inclusivedf.loc[0:2, "spend"]
df.iloc[...]Integer position, end exclusivedf.iloc[0:2, 1]
df[mask]A boolean Seriesdf[df.spend > 8]
df["col"]One column, as a Seriesdf["spend"]
loc is inclusive of its endpoint. df.loc[0:2] returns three rows, unlike every other slice in Python. It catches everyone once.

The group-by mental model

Split, apply, combine. groupby splits the rows into groups by a key, applies a reduction to each group, then combines the results into a new frame. It is the SQL GROUP BY, and it is the single most used operation in the library.
import pandas as pd

df = pd.DataFrame({"user": ["a", "b", "a"], "spend": [10, 20, 5]})

df.groupby("user")["spend"].sum()               # O(n) split + O(n) reduce
df.groupby("user").agg(total=("spend", "sum"), n=("spend", "size"))

# Add a per-group value back onto every original row, without collapsing.
df["user_total"] = df.groupby("user")["spend"].transform("sum")

Joins

import pandas as pd

left = pd.DataFrame({"id": [1, 2], "x": ["a", "b"]})
right = pd.DataFrame({"id": [2, 3], "y": [9, 8]})

pd.merge(left, right, on="id", how="inner")     # O(n + m) via a hash join
pd.merge(left, right, on="id", how="left")      # keeps every left row
pd.concat([left, left])                         # stacks rows, O(n)
Check the row count after every join. If the key is not unique on the right, an inner join multiplies rows. A join that silently turns 1 million rows into 40 million is the most common data bug there is. Assert the shape, or validate with how="one_to_many" via the validate= argument.

What makes pandas slow

Do notDoWhy
for _, row in df.iterrows()A vectorised column expressioniterrows builds a Series per row. Often 100× slower.
df.apply(f, axis=1)np.where, or column arithmeticapply is a Python loop wearing a nice name.
df = pd.concat([df, row]) in a loopCollect rows in a list, concat onceEach concat copies the whole frame: O(n²).
Leaving strings as object.astype("category")Often 10× less memory and much faster group-by.
Reading every columnpd.read_csv(..., usecols=[...])You cannot be slow with data you never loaded.
Default dtypesfloat32, int32 where the range allowsHalves memory, which often halves runtime.

Streaming and chunking

When the data does not fit in memory, the answer is almost always a generator pipeline. This is Guide 5 applied to real work, and it is pure standard library.

from collections.abc import Iterable, Iterator


def chunks(items: Iterable, size: int) -> Iterator[list]:
    """Yield lists of at most `size` items. O(size) memory, O(n) total.

    >>> list(chunks(range(5), 2))
    [[0, 1], [2, 3], [4]]
    """
    if size < 1:
        raise ValueError("size must be at least 1")

    batch: list = []
    for item in items:
        batch.append(item)          # O(1)
        if len(batch) == size:
            yield batch
            batch = []              # a NEW list: never yield the same object twice

    if batch:                       # the final short batch
        yield batch


def running_unique(items: Iterable) -> Iterator:
    """Drop duplicates while streaming. O(distinct) memory.

    >>> list(running_unique([1, 2, 1, 3, 2]))
    [1, 2, 3]
    """
    seen: set = set()
    for item in items:
        if item not in seen:        # O(1) average
            seen.add(item)
            yield item
Python 3.12 added itertools.batched, which is chunks in C and yields tuples. Write your own in an interview, then name the built-in. For merging many pre-sorted streams, heapq.merge(*streams) does a k-way merge lazily in O(total log k), which is the core of an external sort.

The external-sort shape

StepWhat it doesCost
1. SplitRead chunks that fit in RAM, sort each, write it outO(n log m)
2. Mergeheapq.merge across the sorted runsO(n log k)
MemoryOne buffer per run, not the whole datasetO(k)

The same shape answers “sort 100 GB with 8 GB of RAM”, “merge k sorted files”, and the shuffle stage of a map-reduce job.

Statistics, by hand

Expect to write these without a library, and to be asked about the numerical detail.

import math


def mean(values: list[float]) -> float:
    """Arithmetic mean. O(n) time, O(1) space.

    >>> mean([1.0, 2.0, 6.0])
    3.0
    """
    if not values:
        raise ValueError("mean of an empty sequence")
    return sum(values) / len(values)


def median(values: list[float]) -> float:
    """Middle value; the mean of the middle two when the count is even.

    O(n log n) as written. O(n) expected with quickselect, see Pattern 9.

    >>> median([3.0, 1.0, 2.0])
    2.0
    >>> median([1.0, 2.0, 3.0, 4.0])
    2.5
    """
    if not values:
        raise ValueError("median of an empty sequence")

    ordered = sorted(values)                    # O(n log n)
    mid = len(ordered) // 2
    if len(ordered) % 2 == 1:
        return ordered[mid]
    return (ordered[mid - 1] + ordered[mid]) / 2


def variance(values: list[float], *, sample: bool = True) -> float:
    """Spread around the mean. O(n) time, two passes.

    Args:
        sample: Divide by n-1 (Bessel's correction) for a sample, n for a
            whole population. Getting this wrong is a classic follow-up.

    >>> variance([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], sample=False)
    4.0
    """
    n = len(values)
    if n < 2:
        raise ValueError("variance needs at least two values")

    mu = mean(values)                                   # pass 1
    total = sum((v - mu) ** 2 for v in values)          # pass 2
    return total / (n - 1 if sample else n)


def standard_deviation(values: list[float], *, sample: bool = True) -> float:
    """Square root of the variance, back in the units of the data.

    >>> standard_deviation([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], sample=False)
    2.0
    """
    return math.sqrt(variance(values, sample=sample))

One pass, and numerically stable: Welford

The naive one-pass formula is dangerous. Computing variance as E[x²] - E[x]² subtracts two large, nearly equal numbers. With values around 10⁹ the answer can come out negative. That is catastrophic cancellation, and it is a favourite interview question.
class RunningStats:
    """Mean and variance in ONE pass, numerically stable. Welford's method.

    O(1) time per value, O(1) memory, so it works on an unbounded stream.

    >>> stats = RunningStats()
    >>> for x in [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]:
    ...     stats.add(x)
    >>> round(stats.mean, 10)
    5.0
    >>> round(stats.population_variance, 10)
    4.0
    """

    def __init__(self) -> None:
        self.count = 0
        self.mean = 0.0
        self._sum_squared_deltas = 0.0

    def add(self, value: float) -> None:
        """Fold one more observation in. O(1)."""
        self.count += 1
        delta = value - self.mean
        self.mean += delta / self.count
        # Uses the mean BEFORE and AFTER the update, which is what keeps it stable.
        self._sum_squared_deltas += delta * (value - self.mean)

    @property
    def population_variance(self) -> float:
        return self._sum_squared_deltas / self.count if self.count else 0.0


def min_max_scale(values: list[float]) -> list[float]:
    """Rescale to [0, 1]. O(n).

    >>> min_max_scale([10.0, 20.0, 30.0])
    [0.0, 0.5, 1.0]
    """
    low, high = min(values), max(values)                # O(n) each
    if high == low:
        return [0.0] * len(values)                      # avoid dividing by zero
    span = high - low
    return [(v - low) / span for v in values]


def z_score(values: list[float]) -> list[float]:
    """Rescale to mean 0, standard deviation 1. O(n).

    >>> [round(z, 4) for z in z_score([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0])]
    [-1.5, -0.5, -0.5, -0.5, 0.0, 0.0, 1.0, 2.0]
    """
    mu = mean(values)
    sigma = standard_deviation(values, sample=False)
    if sigma == 0.0:
        return [0.0] * len(values)
    return [(v - mu) / sigma for v in values]

Numerical stability

The problem. Floats have about 15 significant decimal digits. Three things destroy them: overflow (exp(1000) is infinity), underflow (a product of many small probabilities becomes exactly 0), and cancellation (subtracting two nearly equal large numbers leaves only the noise).
import math


def log_sum_exp(values: list[float]) -> float:
    """log(sum(exp(v))) without overflowing. O(n).

    exp(1000) is infinity, so the direct formula fails. Factoring out the
    largest term makes every exponent at most 0, so every exp is at most 1.

        log(sum(exp(v)))  ==  peak + log(sum(exp(v - peak)))

    >>> round(log_sum_exp([1000.0, 1000.0]), 6)
    1000.693147
    >>> round(log_sum_exp([0.0, 0.0]), 6)
    0.693147
    """
    if not values:
        return -math.inf

    peak = max(values)                                  # O(n)
    if peak == -math.inf:
        return -math.inf

    return peak + math.log(sum(math.exp(v - peak) for v in values))   # O(n)


def softmax(scores: list[float]) -> list[float]:
    """Turn scores into a probability distribution. O(n).

    Subtracting the maximum first changes nothing mathematically, because the
    factor cancels top and bottom, but it prevents overflow.

    >>> [round(p, 4) for p in softmax([1.0, 2.0, 3.0])]
    [0.09, 0.2447, 0.6652]
    >>> round(sum(softmax([1000.0, 1001.0])), 10)
    1.0
    """
    peak = max(scores)
    exponentials = [math.exp(s - peak) for s in scores]
    total = sum(exponentials)
    return [e / total for e in exponentials]


def sigmoid(x: float) -> float:
    """1 / (1 + exp(-x)), branching to keep the exponent negative. O(1).

    >>> round(sigmoid(0.0), 6)
    0.5
    >>> round(sigmoid(-1000.0), 6)
    0.0
    """
    if x >= 0:
        return 1.0 / (1.0 + math.exp(-x))
    # For very negative x, exp(-x) would overflow. Use the equivalent form.
    positive = math.exp(x)
    return positive / (1.0 + positive)
SymptomCauseFix
OverflowError or infexp of a large positive numberSubtract the maximum first. Log-sum-exp.
A probability product becomes 0.0Underflow after many multiplicationsWork in log space and add instead of multiply.
A negative varianceCancellation in E[x²] - E[x]²Welford, or two passes.
0.1 + 0.2 != 0.3Binary floats cannot represent these exactlymath.isclose, or integers, or decimal.Decimal for money.
Division by zero on a constant columnZero variance or zero rangeGuard, and return zeros. Both scalers above do this.

Distance and similarity

import math


def dot(a: list[float], b: list[float]) -> float:
    """Dot product. O(n).

    >>> dot([1.0, 2.0], [3.0, 4.0])
    11.0
    """
    if len(a) != len(b):
        raise ValueError("vectors must have the same length")
    return sum(x * y for x, y in zip(a, b))


def euclidean(a: list[float], b: list[float]) -> float:
    """Straight-line distance, the L2 norm of the difference. O(n).

    >>> euclidean([0.0, 0.0], [3.0, 4.0])
    5.0
    """
    return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))


def manhattan(a: list[float], b: list[float]) -> float:
    """L1 distance: sum of absolute differences. O(n).

    >>> manhattan([0.0, 0.0], [3.0, 4.0])
    7.0
    """
    return sum(abs(x - y) for x, y in zip(a, b))


def cosine_similarity(a: list[float], b: list[float]) -> float:
    """Cosine of the angle between two vectors, in [-1, 1]. O(n).

    Ignores magnitude entirely, which is why it is the default for text
    embeddings: a long document should not be "far" from a short one.

    >>> round(cosine_similarity([1.0, 0.0], [0.0, 1.0]), 6)
    0.0
    >>> round(cosine_similarity([1.0, 1.0], [2.0, 2.0]), 6)
    1.0
    """
    norm_a = math.sqrt(dot(a, a))
    norm_b = math.sqrt(dot(b, b))
    if norm_a == 0.0 or norm_b == 0.0:
        raise ValueError("cosine similarity is undefined for a zero vector")
    return dot(a, b) / (norm_a * norm_b)


def jaccard(a: set, b: set) -> float:
    """Overlap of two sets: intersection over union. O(min(|a|, |b|)).

    >>> jaccard({1, 2, 3}, {2, 3, 4})
    0.5
    """
    if not a and not b:
        return 1.0
    return len(a & b) / len(a | b)
Skip the square root when you only need to rank. sqrt is monotone, so comparing squared distances gives the same ordering and keeps integer inputs exact. This is exactly the trick in K Closest Points to Origin, and it applies to every nearest-neighbour search.
On normalised vectors, cosine similarity and Euclidean distance rank identically, because |a - b|² = 2 - 2·cos when both have unit length. That is why vector databases normalise on write and then use a plain dot product.

Sampling

import bisect
import itertools
import random


def reservoir_sample(stream, k: int) -> list:
    """Take k items uniformly at random from a stream of UNKNOWN length.

    O(n) time, O(k) memory, and one pass. Item i survives with probability
    k/i at every later step, which works out to k/n overall.

    >>> reservoir_sample(range(3), 5)
    [0, 1, 2]
    >>> len(reservoir_sample(range(1000), 4))
    4
    """
    if k < 0:
        raise ValueError("k must be non-negative")

    reservoir: list = []
    for index, item in enumerate(stream):
        if index < k:
            reservoir.append(item)              # fill it first
        else:
            position = random.randint(0, index)  # inclusive on both ends
            if position < k:
                reservoir[position] = item       # evict a uniform survivor

    return reservoir


class WeightedSampler:
    """Draw an index with probability proportional to its weight.

    Prefix sums plus binary search: O(n) to build, O(log n) per draw.

    >>> sampler = WeightedSampler([0.0, 1.0, 0.0])
    >>> {sampler.draw() for _ in range(20)}     # only index 1 has any weight
    {1}
    """

    def __init__(self, weights: list[float]) -> None:
        if not weights or any(w < 0 for w in weights):
            raise ValueError("weights must be non-empty and non-negative")

        self._cumulative = list(itertools.accumulate(weights))   # O(n)
        self._total = self._cumulative[-1]
        if self._total <= 0:
            raise ValueError("weights must sum to a positive number")

    def draw(self) -> int:
        """One weighted draw. O(log n)."""
        target = random.random() * self._total
        return bisect.bisect_right(self._cumulative, target)


def train_test_split(rows: list, *, test_fraction: float = 0.2, seed: int = 0):
    """Shuffle then cut. O(n).

    A fixed seed makes the split reproducible, which matters more than it
    sounds: without it, two runs are not comparable.

    >>> train, test = train_test_split(list(range(10)), test_fraction=0.3)
    >>> len(train), len(test)
    (7, 3)
    >>> sorted(train + test) == list(range(10))
    True
    """
    if not 0.0 <= test_fraction < 1.0:
        raise ValueError("test_fraction must be in [0, 1)")

    shuffled = list(rows)
    random.Random(seed).shuffle(shuffled)        # O(n), Fisher-Yates
    cut = int(len(shuffled) * (1.0 - test_fraction))
    return shuffled[:cut], shuffled[cut:]
Split before you do anything else. Computing a mean, a vocabulary or a scaler on the full dataset and then splitting leaks test information into training. That is data leakage, it inflates your offline metrics, and it is the failure interviewers probe hardest on an ML question.

Sketches for streams

What a sketch is. A fixed-memory approximate answer to a question that would otherwise need memory proportional to the data. You trade exactness for a bounded footprint, with a known error bound. This is the standard toolkit for high-volume event pipelines.
QuestionStructureMemoryError
Have I seen this before?Bloom filterO(bits)False positives only, never false negatives
How many times have I seen this?Count-Min sketchO(width × depth)Never underestimates
How many distinct values?HyperLogLog~12 KBAbout 2% for billions of items
What are the top k?Count-Min plus a heapO(k + sketch)Misses only rare items
A uniform sampleReservoirO(k)Exact and uniform
Approximate quantilest-digest, or KLLO(compression)Tight at the tails
import hashlib


class BloomFilter:
    """Probabilistic set membership in a fixed bitmap.

    No false negatives: if it says "absent", the item was never added.
    False positives are possible, and their rate rises as you add items.

    Uses hashlib rather than the built-in hash(), because hash() on strings
    is randomised per process, so results would differ between runs.

    >>> bloom = BloomFilter()
    >>> bloom.add("apple")
    >>> "apple" in bloom
    True
    >>> "pear" in bloom
    False
    """

    def __init__(self, bits: int = 4096, hashes: int = 4) -> None:
        self._bits = bits
        self._hashes = hashes
        self._bitmap = bytearray((bits + 7) // 8)      # O(bits) memory, fixed

    def _positions(self, item: str):
        """Derive several independent positions from one digest. O(hashes)."""
        digest = hashlib.sha256(item.encode()).digest()
        for i in range(self._hashes):
            chunk = int.from_bytes(digest[i * 4 : i * 4 + 4], "big")
            yield chunk % self._bits

    def add(self, item: str) -> None:
        """O(hashes), independent of how many items are already in it."""
        for position in self._positions(item):
            self._bitmap[position // 8] |= 1 << (position % 8)

    def __contains__(self, item: str) -> bool:
        """O(hashes). True means "probably present", False means "definitely not"."""
        return all(
            self._bitmap[position // 8] & (1 << (position % 8))
            for position in self._positions(item)
        )


class CountMinSketch:
    """Approximate frequency counts in fixed memory.

    Every cell is shared by many keys, so collisions only ever ADD. Taking
    the minimum across rows discards the most-collided estimates, which is
    why the answer never underestimates.

    >>> sketch = CountMinSketch()
    >>> for _ in range(5):
    ...     sketch.add("clicks")
    >>> sketch.estimate("clicks")
    5
    >>> sketch.estimate("never-seen")
    0
    """

    def __init__(self, width: int = 512, depth: int = 4) -> None:
        self._width = width
        self._depth = depth
        self._table = [[0] * width for _ in range(depth)]     # O(width * depth)

    def _positions(self, key: str):
        for row in range(self._depth):
            digest = hashlib.sha256(f"{row}:{key}".encode()).digest()
            yield row, int.from_bytes(digest[:4], "big") % self._width

    def add(self, key: str, count: int = 1) -> None:
        """O(depth), regardless of stream length."""
        for row, column in self._positions(key):
            self._table[row][column] += count

    def estimate(self, key: str) -> int:
        """O(depth). Never below the true count, sometimes above."""
        return min(self._table[row][column] for row, column in self._positions(key))

Classic algorithms to implement

import random
from collections import Counter


def knn_classify(
    train: list[list[float]], labels: list, query: list[float], k: int
):
    """k-nearest-neighbours vote. O(n * d) to score, O(n log n) to rank.

    There is no training step: all the work happens at query time, which is
    the trade-off to name when asked.

    >>> points = [[0.0], [1.0], [10.0], [11.0]]
    >>> knn_classify(points, ["low", "low", "high", "high"], [9.5], k=3)
    'high'
    """
    if not 1 <= k <= len(train):
        raise ValueError(f"k must be in 1..{len(train)}")

    # For large n, a size-k heap is O(n log k) and beats the full sort.
    ranked = sorted(range(len(train)), key=lambda i: euclidean(train[i], query))
    votes = Counter(labels[i] for i in ranked[:k])
    return votes.most_common(1)[0][0]


def kmeans(
    points: list[list[float]], k: int, *, iterations: int = 20, seed: int = 0
) -> tuple[list[list[float]], list[int]]:
    """Lloyd's algorithm. O(iterations * n * k * d).

    Returns the centroids and the cluster index of each point. It converges
    to a LOCAL optimum, so the initial choice matters; real implementations
    use k-means++ seeding and several restarts.

    >>> data = [[0.0], [1.0], [10.0], [11.0]]
    >>> centroids, assignment = kmeans(data, 2)
    >>> sorted(sorted(c) for c in centroids)
    [[0.5], [10.5]]
    """
    if not 1 <= k <= len(points):
        raise ValueError(f"k must be in 1..{len(points)}")

    rng = random.Random(seed)
    centroids = [list(p) for p in rng.sample(points, k)]
    assignment = [0] * len(points)
    previous: list[int] | None = None

    for _ in range(iterations):
        # Assign: each point joins its nearest centroid. O(n * k * d)
        assignment = [
            min(range(k), key=lambda c: euclidean(point, centroids[c]))
            for point in points
        ]

        if assignment == previous:
            break                       # converged, nothing will change again
        previous = assignment

        # Update: each centroid moves to the mean of its members. O(n * d)
        for cluster in range(k):
            members = [p for p, a in zip(points, assignment) if a == cluster]
            if members:
                centroids[cluster] = [sum(axis) / len(members) for axis in zip(*members)]

    return centroids, assignment


def linear_regression_gd(
    xs: list[float],
    ys: list[float],
    *,
    learning_rate: float = 0.05,
    epochs: int = 2000,
) -> tuple[float, float]:
    """Fit y = w*x + b by gradient descent. O(epochs * n).

    The loss is mean squared error. Its gradients are
        d/dw = 2/n * sum((pred - y) * x)
        d/db = 2/n * sum(pred - y)

    >>> w, b = linear_regression_gd([0.0, 1.0, 2.0, 3.0], [1.0, 3.0, 5.0, 7.0])
    >>> round(w, 3), round(b, 3)
    (2.0, 1.0)
    """
    if len(xs) != len(ys) or not xs:
        raise ValueError("xs and ys must be non-empty and the same length")

    n = len(xs)
    w = b = 0.0

    for _ in range(epochs):
        errors = [w * x + b - y for x, y in zip(xs, ys)]        # O(n)
        gradient_w = 2.0 * sum(e * x for e, x in zip(errors, xs)) / n
        gradient_b = 2.0 * sum(errors) / n
        w -= learning_rate * gradient_w
        b -= learning_rate * gradient_b

    return w, b


def linear_regression_closed_form(
    xs: list[float], ys: list[float]
) -> tuple[float, float]:
    """The exact answer in one pass, no iteration. O(n).

    w = covariance(x, y) / variance(x), and b follows from the means.

    >>> w, b = linear_regression_closed_form([0.0, 1.0, 2.0, 3.0], [1.0, 3.0, 5.0, 7.0])
    >>> round(w, 10), round(b, 10)
    (2.0, 1.0)
    """
    n = len(xs)
    mean_x, mean_y = sum(xs) / n, sum(ys) / n

    covariance = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
    spread = sum((x - mean_x) ** 2 for x in xs)
    if spread == 0.0:
        raise ValueError("all x values are identical, the slope is undefined")

    w = covariance / spread
    return w, mean_y - w * mean_x
Know when the closed form beats gradient descent. For ordinary least squares on a handful of features, the exact solution is O(n·d² + d³) and needs no tuning. Gradient descent wins when d is large, when the data does not fit in memory, or when the model is not linear. Volunteering that comparison is the difference between reciting an algorithm and understanding it.

Evaluation metrics

def confusion(y_true: list[int], y_pred: list[int]) -> tuple[int, int, int, int]:
    """Return (true positives, false positives, false negatives, true negatives).

    Labels are 0 or 1. O(n).

    >>> confusion([1, 1, 0, 0], [1, 0, 1, 0])
    (1, 1, 1, 1)
    """
    if len(y_true) != len(y_pred):
        raise ValueError("label lists must be the same length")

    tp = fp = fn = tn = 0
    for actual, predicted in zip(y_true, y_pred):
        if predicted == 1 and actual == 1:
            tp += 1
        elif predicted == 1:
            fp += 1
        elif actual == 1:
            fn += 1
        else:
            tn += 1

    return tp, fp, fn, tn


def precision_recall_f1(
    y_true: list[int], y_pred: list[int]
) -> tuple[float, float, float]:
    """Precision, recall and their harmonic mean. O(n).

    Precision: of what I flagged, how much was right. Punishes false alarms.
    Recall:    of what was there, how much did I find. Punishes misses.

    >>> precision_recall_f1([1, 1, 0, 0], [1, 0, 1, 0])
    (0.5, 0.5, 0.5)
    """
    tp, fp, fn, _ = confusion(y_true, y_pred)

    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    if precision + recall == 0.0:
        return precision, recall, 0.0

    f1 = 2 * precision * recall / (precision + recall)
    return precision, recall, f1


def roc_auc(y_true: list[int], scores: list[float]) -> float:
    """Area under the ROC curve, via the rank formula. O(n log n).

    AUC equals the probability that a random positive outranks a random
    negative, so it can be read straight off the ranks with no thresholds.

    >>> roc_auc([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9])
    1.0
    >>> roc_auc([0, 1], [0.9, 0.1])
    0.0
    """
    positives = sum(y_true)
    negatives = len(y_true) - positives
    if positives == 0 or negatives == 0:
        raise ValueError("AUC needs at least one positive and one negative")

    order = sorted(range(len(scores)), key=lambda i: scores[i])   # O(n log n)
    rank_sum = 0.0
    index = 0

    while index < len(order):
        # Tied scores must SHARE the average rank, or AUC is wrong.
        end = index
        while end + 1 < len(order) and scores[order[end + 1]] == scores[order[index]]:
            end += 1

        average_rank = (index + end) / 2 + 1        # ranks are 1-based
        for position in range(index, end + 1):
            if y_true[order[position]] == 1:
                rank_sum += average_rank

        index = end + 1

    return (rank_sum - positives * (positives + 1) / 2) / (positives * negatives)

Which metric to argue for

SituationMetricWhy
Balanced classesAccuracyFine, and only fine, when the classes are balanced.
Rare positives, such as fraudPrecision, recall, PR-AUCAt 0.1% positives, always predicting “no” scores 99.9% accuracy.
False alarms are expensivePrecisionPaging a human, or blocking a good user.
Misses are expensiveRecallMissed fraud, missed disease, missed outage.
Ranking, not classifyingROC-AUC, or NDCGThreshold-free. Ads and search live here.
RegressionRMSE, or MAERMSE punishes big errors. MAE is robust to outliers.
Accuracy on an imbalanced dataset is the classic trap. If 999 of 1000 events are negative, a model that predicts “negative” every time is 99.9% accurate and completely useless. Say this before you are asked; it is the fastest way to show you have shipped a model.

Complexity of the common operations

OperationTimeNote
Mean, sum, min, max over nO(n)One pass, O(1) memory.
Median by sortingO(n log n)O(n) expected with quickselect.
Variance, two-passO(n)Welford does it in one pass and is stable.
Pairwise distances, n pointsO(n² · d)The reason brute-force kNN does not scale.
kNN query, brute forceO(n · d)A KD-tree or HNSW index gets this to roughly O(log n).
k-means, one iterationO(n · k · d)Multiply by the iteration count.
Matrix multiply, n×nO(n³)The dominant cost in a dense neural network layer.
Linear regression, closed formO(n·d² + d³)Fine while d is small.
Gradient descentO(epochs · n · d)Use mini-batches to cut the n.
Sorting for AUCO(n log n)The sort dominates the metric.
Hash join of n and m rowsO(n + m)Builds a hash table on the smaller side.
Group-by over n rowsO(n)Hash based, not sort based, in both pandas and Spark.
External sort of n rowsO(n log n)With O(k) memory for k sorted runs.
Bloom or Count-Min operationO(hashes)Independent of how much data has passed through.

The eight things to carry forward


Guide 5 — Advanced Python Back to all 16 patterns