Week 06 · Phase 2 · Measuring for real

The Doubling Experiment

Double n, look at the time: flat, twice as slow, or four times as slow?

Big question: What does the running time do when the input doubles?experimentsmatplotlib≈3 hours
By the end of this week you can
  • run a doubling experiment on any function;
  • read the ratio column and name the growth pattern from it alone;
  • draw a labelled plot of time against input size with matplotlib;
  • use a log–log plot when the numbers span several orders of magnitude;
  • read a growth pattern off the slope of a log–log line, and fit that slope with one line of code;
  • recognise the situations where the ratio column quietly misleads you;
  • write a short, honest paragraph interpreting your own measurements.

6.1The question that replaces "is it fast?"

"Is my program fast?" is unanswerable — fast compared to what, on whose machine, with how much data? The useful question is:

The doubling question

If I double the input, what happens to the time?

This one question has a small number of possible answers, and each answer identifies a family of algorithms. You do not need mathematics to use it. You need a stopwatch, four input sizes, and a division.

Time ratio when n doublesWhat it meansTypical code shapeName (week 8)
≈ 1.0 (unchanged)Input size is irrelevantA few lines, no loop over the dataO(1)
a fixed tiny addition, not a multipleEach doubling costs one more stepHalving while loopO(log n)
≈ 2.0Twice the data, twice the workOne loop over the dataO(n)
≈ 2.1 – 2.3A loop, plus a little extra per doublingSortingO(n log n)
≈ 4.0Twice the data, four times the workLoop inside a loopO(n²)
≈ 8.0Three nested loopsTriple loopO(n³)

Memorise two rows if you memorise nothing else: 2.0 is fine, 4.0 is a warning.

6.2Running the experiment

A reusable harness. Give it a function and a list of sizes, get a table with ratios.

doubling_experiment.py
import time

def time_it(func, arg, repeats=5):
    best = None
    for _ in range(repeats):
        start = time.perf_counter()
        func(arg)
        elapsed = time.perf_counter() - start
        if best is None or elapsed < best:
            best = elapsed
    return best

def doubling(func, start_n=10000, doublings=5):
    """Time func at n, 2n, 4n ... and print the ratio each time."""
    sizes, times = [], []
    n = start_n
    previous = None
    print(f"{'n':>9} {'seconds':>12} {'ratio':>7}")
    for _ in range(doublings):
        t = time_it(func, n)
        ratio = t / previous if previous else float('nan')
        print(f"{n:>9} {t:>12.5f} {ratio:>7.2f}")
        sizes.append(n); times.append(t)
        previous = t
        n = n * 2
    return sizes, times

Now try it on three functions with visibly different shapes:

three shapes
def constant(n):                 # ignores n entirely
    return n * (n + 1) // 2

def linear(n):                   # one loop over n
    total = 0
    for i in range(n):
        total += i
    return total

def quadratic(n):                # loop inside a loop
    count = 0
    for i in range(n):
        for j in range(n):
            count += 1
    return count

sizes_l, times_l = doubling(linear, start_n=100000)
sizes_q, times_q = doubling(quadratic, start_n=500, doublings=4)
n seconds ratio 100000 0.00489 nan 200000 0.00981 2.01 400000 0.01955 1.99 800000 0.03920 2.01 1600000 0.07835 2.00 n seconds ratio 500 0.01254 nan 1000 0.05001 3.99 2000 0.20114 4.02 4000 0.80339 3.99

You did not need to read the code to tell them apart. The ratio column did it: 2.0 versus 4.0. This is the core practical skill of the course, and you now have it.

Watch your starting size

Notice that the quadratic experiment starts at n = 500, not 100 000 — a quadratic function at 100 000 would run for hours. Choosing a starting size that finishes but is still big enough to beat the noise is part of the craft. Aim for a first run of roughly 0.01–0.1 seconds.

How do you pick that starting size without guessing? Run the function once at a small n, look at the time, and scale up. If your shape is linear and n = 1 000 took 0.00001 s, then 0.05 s needs about 5 000 times more work, so start near n = 5 000 000. If the shape is quadratic, remember that the time grows with the square: a 100× longer run only needs a 10× bigger n. A quick pilot run saves you from either a wall of noise or a coffee break.

6.3Drawing the picture

A table convinces you; a plot convinces everyone else. Matplotlib is already installed in Colab.

a plot with everything it needs
import matplotlib.pyplot as plt

plt.figure(figsize=(6, 4))
plt.plot(sizes_l, times_l, marker="o", label="one loop")
plt.plot(sizes_q, times_q, marker="s", label="loop inside a loop")

plt.xlabel("input size n")
plt.ylabel("time (seconds)")
plt.title("Running time vs input size")
plt.legend()
plt.grid(alpha=0.3)
plt.show()

Every plot you hand in must have: axis labels with units, a title, and a legend if there is more than one line. A plot without labels is not evidence, it is decoration.

When the numbers are wildly different

If one curve is a million times taller than another, the small one flattens into the axis. Switch both axes to logarithmic scale and each straight line's slope tells you the growth: slope 1 is linear, slope 2 is quadratic, flat is constant.

log–log
plt.xscale("log")
plt.yscale("log")

6.4Reading — and measuring — the slope

Why does a log–log plot turn every growth pattern into a straight line? A little algebra makes it click. Almost every function here has the shape "some constant times n to a power":

T = c · nk

Take the logarithm of both sides and the multiplication becomes addition, the power becomes a multiplier:

log T = log c + k · log n

That is the equation of a straight line — log n along the bottom, log T up the side, a slope of exactly k, and the constant c only shifting the line up or down without tilting it. So on log–log axes the power becomes the slope, and the machine-dependent constant becomes an offset you can ignore. Constant → slope 0 (flat). Linear → slope 1. Quadratic → slope 2. Cubic → slope 3.

You do not have to eyeball the slope. numpy.polyfit fits the best straight line through your logged points and hands you the slope as a number:

fit the slope
import numpy as np

def loglog_slope(sizes, times):
    """Best-fit slope through the points on log-log axes."""
    slope, intercept = np.polyfit(np.log(sizes), np.log(times), 1)
    return slope

print(f"linear    slope = {loglog_slope(sizes_l, times_l):.2f}")
print(f"quadratic slope = {loglog_slope(sizes_q, times_q):.2f}")
linear slope = 1.00 quadratic slope = 2.00

This is the ratio column's more grown-up cousin. The ratio reads growth from two points at a time; the fitted slope uses all your points at once, so a single noisy measurement cannot swing the verdict. When a ratio column is bouncing around, fit the slope and see the pattern the individual ratios were hiding.

Slope and ratio say the same thing

A slope of k means doubling n multiplies the time by 2k. Slope 1 → ratio 2. Slope 2 → ratio 4. Slope 3 → ratio 8. Slope 0 → ratio 1. They are two readings of one truth: the ratio is local and quick, the slope is global and steady.

6.5When the ratio column misleads you

The ratio is a wonderful tool, but a tool you trust blindly will eventually cut you. Three situations make honest ratios lie about the pattern.

1. The starting size is too small

Below the noise floor the ratio is meaningless — it wanders 0.6, 1.9, 0.4 with no shape at all, because you are timing interruptions, not your algorithm. This is the number one cause of a "weird" ratio column, and the fix is always the same: grow the starting size until the first run clears roughly 0.01–0.1 seconds, then read again.

2. n log n looks like a drifting 2

The most subtle case. A sorting run does not give a clean 2.0 or 4.0 — it gives a ratio just above 2 that slowly drifts downward towards 2 as n grows:

nratio for n·log₂nreading
1 000 → 2 0002.20above 2, so worse than linear
2 000 → 4 0002.18…
4 000 → 8 0002.17…creeping down…
1 000 000 → 2 000 0002.10…towards 2, but never reaching it

The reason: doubling n doubles the loop (that is the 2), and the log n factor adds only a sliver more each time — a sliver that shrinks as n grows, because the logarithm grows ever more slowly. Read too high a starting size and you might call it linear; read too low and you might call it quadratic. The honest verdict is "just above 2 and falling slowly", the fingerprint of n log n — confirm it by fitting a slope (§6.4), which lands near 1.05–1.1.

3. Memory effects can inflate a linear ratio

A perfectly linear loop can show ratios above 2 once the data grows too big to sit in the CPU's fast cache. Suddenly each item costs a slow trip to main memory, and the time per item rises just as n rises. The algorithm did not change class; the hardware changed gear. If a clean O(n) function starts posting 2.3, 2.6 at very large sizes, suspect the cache before you rewrite the algorithm.

The habit

When a ratio surprises you, do not immediately believe it. Ask the three questions in order: is the run long enough, could this be n log n drifting, and has the data outgrown the cache? Then fit a slope across all your points as a tie-breaker.

6.6Interpreting like a scientist

Every benchmark in this course ends with a short paragraph. Use this skeleton until it becomes automatic:

The five-sentence interpretation
  1. What I measured, and at which sizes.
  2. What the ratio column did (the numbers, not an adjective).
  3. Which growth pattern that matches, and why the code has that shape.
  4. One thing that could have distorted the measurement.
  5. What I would predict for an input ten times larger — a specific number.

Worked example, for the quadratic run above:

"I timed the nested-loop counter at n = 500, 1 000, 2 000 and 4 000, five repeats each, taking the fastest. The time ratios were 3.99, 4.02 and 3.99, and a log–log fit gave a slope of 2.00. Four times the work for twice the data matches a loop inside a loop, which is exactly what the code does: the inner loop runs n times for each of the n outer passes. The 4 000 run took nearly a second, so background load on the shared Colab machine could have inflated it slightly. Extrapolating, n = 40 000 would be 100× the 4 000 time — about 80 seconds — which I would not want to run in a live report."

6.7Try it yourself

Task 1 — identify by ratio alone

Someone hands you these timings. Name each pattern without seeing the code.

nA (s)B (s)C (s)D (s)
1 0000.00210.00000040.01000.0000060
2 0000.00420.00000040.04010.0000065
4 0000.00840.00000040.16020.0000070
8 0000.01670.00000040.64100.0000075
Answers

A doubles each time — one loop over the data (linear). B never changes — constant. C quadruples — nested loops (quadratic). D grows by a small fixed amount each doubling, not a multiple — that is the halving pattern, logarithmic.

Task 2 — your own doubling study

Run doubling() on: (a) summing a list, (b) the worst-case contains search from week 5, (c) the duplicate-finder from week 4, and (d) sorted(data) on a shuffled list. Record all four ratio columns.

What to expect

(a) and (b) ≈ 2.0; (c) ≈ 4.0; (d) ≈ 2.1–2.2. That last one is the fingerprint of n log n — clearly worse than a plain loop, dramatically better than nested loops. Week 13 explains where it comes from.

Task 3 — one figure, two curves

Plot (b) and (c) from Task 2 on the same axes with labels, title and legend. Then make a second version with log–log axes. Which version communicates better, and to whom?

Hint

The linear-axis plot shows the practical horror (one curve shoots off the top). The log–log plot shows the structure (two straight lines with different slopes). Reports for decision-makers usually want the first; technical appendices want the second.

Task 4 — fit the slope, don't just guess it

Write the loglog_slope helper from §6.4 and run it on all four studies from Task 2. Line the fitted slopes up against the last ratio in each column. Do slope and ratio agree (slope 1 ↔ ratio 2, slope 2 ↔ ratio 4)? Where they disagree, which do you trust more, and why?

Expected

(a) and (b) fit near 1.0, (c) near 2.0, (d) near 1.05–1.1. The slope is steadier because it uses every point at once; a lone noisy ratio cannot tilt it. When a single ratio looks odd but the slope is clean, trust the slope.

Task 5 — watch n log n drift

Run doubling(sorted_wrapper, start_n=10000, doublings=7) where sorted_wrapper(n) shuffles a list of n items and sorts it. Read the ratio column top to bottom. Does it settle on a single number, or drift? Explain what you see using §6.5, and give the fitted slope.

Expected

The ratio hovers a little above 2 and drifts slowly downward as n grows — never landing on a clean value — because the log factor adds an ever-smaller sliver each doubling. The fitted slope comes out near 1.05–1.1, which is the honest label: n log n, distinctly worse than linear but far better than quadratic.

Task 6 — a misleading start size

Run your linear study twice: once with start_n=10 and once with start_n=1000000, four doublings each. Compare the two ratio columns. Which one gives clean 2.0s, and what does the other one teach you about trusting a ratio?

Answer

The tiny start gives a jumpy, meaningless column (0.7, 1.8, 1.1…) because every run is buried in noise; the large start gives steady 2.0s. Same algorithm, same code — only the size changed. A ratio is only worth reading once the run is well clear of the noise floor (§6.5, case 1).

6.8Self-check

Doubling n makes the time go from 0.10 s to 0.41 s. The most likely shape is:

A ratio near 4 is the nested-loop signature. Twice the data, four times the work.

Times at n = 1 000 / 2 000 / 4 000 are 0.0000060 / 0.0000065 / 0.0000070 s. This is:

Adding a constant per doubling is exactly what log n does. Multiplying per doubling would be linear or worse.

Your first timing point is 0.00003 s and the ratios are 0.7, 1.9, 0.4. The best next move is:

Ratios only mean something once the signal is well above the timer noise. Grow n first, interpret second.

On a log–log plot your measurements form a straight line of slope 2. The algorithm is:

On log–log axes the power becomes the slope: slope 1 is linear, 2 is quadratic, 3 is cubic, and a flat line is constant.

A sorting benchmark posts ratios 2.20, 2.18, 2.16, 2.15 as n grows. The best reading is:

A slowly drifting ratio a little above 2 is the fingerprint of n log n. A fitted slope near 1.05–1.1 confirms it.

6.9Homework

Due before week 7 — about 3 hours
  1. Create AA_Week06.ipynb with the time_it and doubling harness, plus the loglog_slope helper.
  2. Complete Task 2 (four studies), Task 3 (two figures) and Task 4 (fitted slopes for all four).
  3. Complete Task 5 (watch n log n drift) and Task 6 (the misleading start size), and write one sentence on what each taught you about reading a ratio.
  4. For each of the four studies, write the five-sentence interpretation from §6.6, quoting both the last ratio and the fitted slope.
  5. Add one prediction and test it: pick any study, predict the time at the next doubling before running it, then run it and report the error as a percentage.

6.10Words from this week

TermMeaning in plain words
doubling experimentMeasuring at n, 2n, 4n… and watching the ratio of times.
ratio columnEach time divided by the previous one — the machine-independent part of the result.
log–log plotBoth axes logarithmic; growth patterns become straight lines with distinct slopes.
slope (log–log)The power in T = c·nᵏ; slope 1 is linear, 2 is quadratic, 0 is constant.
line fittingUsing every point at once (e.g. numpy.polyfit) to read a steady slope.
cache effectData outgrowing fast memory, which can push a linear ratio above 2.
extrapolationUsing a measured pattern to predict a size you have not run.
harnessReusable code that runs and times an experiment for you.
Where this leads

The ratio column revealed the shape, but the seconds drift with your laptop. Week 7 switches to counting steps — a measure that does not change when the hardware does.