1.4.12 · D3Python & Scientific Computing

Worked examples — Reading documentation and debugging

3,436 words16 min readBack to topic

This page is a catalogue of concrete cases for the two skills from Reading documentation and debugging: reading docs to build a mental model, and debugging by systematic narrowing. The parent note gave you the methods. Here we run those methods on every kind of bug you will actually meet, one worked example per case.

Before we touch a single example, we need one honest map of the territory.


The scenario matrix

Every bug you hit falls into a small number of shapes. Think of a bug not as "the code is broken" but as a mismatch between what you expected and what happened. The mismatch has a type. Below is the full list of types we will cover — each cell is one distinct failure mode.

Cell Case class What makes it distinct Example that hits it
C1 Loud crash — exception thrown Program stops, prints a traceback Ex. 1 (KeyError)
C2 Silent wrong answer — no crash Runs fine, output is just wrong Ex. 2 (reset-in-loop)
C3 Degenerate input — empty container Input size = 0, boundary of the loop Ex. 3 (empty list)
C4 Zero / division edge A denominator or count is 0 Ex. 3 & Ex. 8
C5 Type mismatch — wrong "kind" of value str where a number was expected Ex. 4 (str + int)
C6 Off-by-one / boundary index Loop touches one slot too many/few Ex. 5 (index range)
C7 Doc-contract violation You broke a promise the docs stated Ex. 6 (linspace endpoint)
C8 Limiting / large behaviour Correct on small input, fails at scale Ex. 7 (float accumulation)
C9 Real-world word problem Messy CSV, must combine skills Ex. 8 (CSV averages)
C10 Exam-style twist — subtle & adversarial Looks right, one detail sabotages it Ex. 9 (mutable default)

To make this a real map and not just a wall of text, the figure below draws all ten cells as a grid of tiles, sorted along two axes you can feel: left-to-right is how loud the bug is (silent → loud crash), bottom-to-top is how subtle it is to spot (obvious → adversarial). Read the picture like a landscape:

Figure — Reading documentation and debugging
  • Bottom-right corner (loud + obvious): C1, C4, C5, C6 — these crash immediately and tell you where. Easiest to fix.
  • Top-left corner (silent + subtle): C2 and C10 — nothing tells you; you must go hunting. This is the red C2 tile — the corner we spend the most examples on.
  • The tiles in between (C3, C7, C8, C9) mix the two: they can be silent or loud depending on the input.

The one red tile in the picture is the class most beginners fear the most — a silent wrong answer — because nothing tells you it went wrong. Keep your eye on where each example below lands on this map.


Example 1 — The loud crash KeyError [Cell C1]

Forecast: guess now — will every record fail, or just one? Which one?

  1. Read the traceback bottom-up. The parent note's rule: the last line names the exception type. Here it is KeyError: 'price'. Why this step? The exception type already tells you the shape: a KeyError means you asked a dictionary for a key it does not have. Not a math bug, not a type bug — a missing key bug.

  2. Read the value. The message says 'price'. So somewhere record['price'] was requested but 'price' was absent. Why this step? The specific value narrows the search from "some dict" to "the dict missing price".

  3. Scan the data for the offender. Record index 0 has price, index 1 has cost (no price!), index 2 has price. Why this step? Only one record — index 1 — violates the contract. The loop crashes on the second iteration, not the first.

  4. The fix uses the doc-blessed safe accessor dict.get(key, default):

    value = record.get('price', 0) * 1.1

Verify: with the fix, the three outputs are , , . No crash. See 1.7.2-Data-preprocessing-and-cleaning for why missing keys are a data problem, not just a code problem.


Example 2 — Silent wrong answer: reset inside the loop [Cell C2]

Forecast: why exactly 70, and not 0, and not 245?

  1. Recognise the shape. No traceback → this is C2, a silent bug. The parent's tool for C2 is binary search debugging: check a value in the middle of the computation. Why this step? Silent bugs give you no starting line, so you manufacture one with a print.

  2. Check total right before return. print(total)70. Why this step? 70 is the last score. That is a fingerprint: the sum was overwritten, not accumulated.

  3. Check total inside the loop, each pass. It reads 85, then 90, then 70 — never adding up. Why this step? This confirms total = 0 runs every iteration, wiping the previous work. The initialisation is in the wrong scope.

  4. Fix: move total = 0 above the loop.

    total = 0
    for student in students:
        total += student['score']

Verify: . And the fingerprint check: the buggy version must return the last element, because only the final assignment survives — matches the 70 we saw.


Example 3 — Degenerate & zero-division: the empty list [Cells C3 + C4]

Forecast: does this crash, return 0, or return None?

  1. Trace the values for the empty input. sum(...) over nothing is 0 (Python's sum starts at 0). Then len([]) is 0. Why this step? We must follow every symbol to its value at the boundary — this is the "cover the degenerate case" rule from the contract.

  2. Spot the collision. We compute 0 / 0. Python raises ZeroDivisionError: division by zero. Why this step? A denominator that can be zero (C4) is a landmine. Empty input (C3) is exactly what makes len zero — the two cells overlap here.

  3. Decide the correct behaviour. "Average of no students" has no numeric answer. The honest fix returns a signal, not a fake number:

    def average(students):
        if not students:
            return None
        return sum(s['score'] for s in students) / len(students)

Verify:

  • average([])None (no crash).
  • average([{'score': 80}, {'score': 100}]).

The number line below shows the danger point. The black dots are the possible values of len(students); the red dot at 0 is the single value where the divisor becomes zero and the code explodes. Everything to the right (the black arrow, len >= 1) is the safe zone:

Figure — Reading documentation and debugging

Example 4 — Type mismatch: number meets string [Cell C5]

Forecast: does it crash, or silently concatenate?

  1. Read the traceback. TypeError: unsupported operand type(s) for +=: 'int' and 'str'. Why this step? TypeError (C5) means an operation was asked to combine two kinds it cannot. The message names both kinds: int and str.

  2. Find where the kinds collide. total starts as int 0. First pass: 0 + '85' — int + str is undefined, crash on iteration 1. Why this step? In Python + is overloaded: number+number adds, string+string joins. Mixing them is deliberately forbidden so bugs surface loudly.

  3. Fix by converting at the boundary — coerce each value to the type you meant:

    total = sum(int(s) for s in scores)

Verify: int('85') + 90 + int('70') . This is the same cleaning step you meet in 1.4.5-File-IOand-CSV-handling, where everything read from a file arrives as text.


Example 5 — Off-by-one boundary [Cell C6]

Forecast: which index is illegal here?

  1. Trace the indices. The list [3, 7, 10] has valid indices 0, 1, 2. len(nums) is 3. So nums[3] reaches past the end. Why this step? Python indexing is zero-based: a list of length has indices to . Index is always one too far — the classic off-by-one.

  2. Read the crash. IndexError: list index out of range. Why this step? IndexError is the signature of C6 — you walked off the edge of the container.

  3. Fix: the last element is at len(nums) - 1, the one before it at len(nums) - 2. Cleaner still, use negative indices:

    return nums[-1] - nums[-2]

Verify: nums[-1] - nums[-2] . The picture makes the fence-post clear. The three black boxes are the real list slots (indices 0, 1, 2 holding 3, 7, 10); the red dashed box on the right is the phantom slot nums[3] that the buggy code reaches for — there is no value there, so Python raises IndexError:

Figure — Reading documentation and debugging

Example 6 — Doc-contract violation: linspace endpoint [Cell C7]

Forecast: why does dropping endpoint change the spacing?

  1. Name the symbol first. Let (read "delta x") stand for the gap between two neighbouring points — the constant step size. We use the Greek letter because in maths it traditionally means "a change in" or "difference between", and the gap is the difference between one point and the next. So literally reads as "the change in x from one point to the next". Why this step? The contract from the docs is stated in terms of that gap, so we need a name for it before we can compare the two cases.

  2. Re-read the documented contract (from the parent note). linspace(start, stop, num, endpoint=True). With endpoint=True the num points include stop; the spacing is Why this step? The parent's "Mental Model = Purpose + Contracts + Edge Cases" — endpoint is an edge-case knob, and we broke its default silently.

  3. Compute the two contracts side by side.

    • endpoint=True: . Points: .
    • endpoint=False: . Points: stop excluded. Why this step? The denominator changes from num - 1 to num when the endpoint is dropped. That single term is the whole bug.
  4. Fix: to keep spacing 2.5, use the default endpoint=True (or ask for num=5 over [0, 8] if you truly want to exclude 10). Match the doc to your intent.

Verify: and — both computed in the checks below. See 1.4.10-Introduction-to-matplotlib for where these evenly-spaced arrays feed plotting.


Example 7 — Limiting behaviour: float accumulation at scale [Cell C8]

Forecast: True or False? And by how much is it off?

  1. Small mental check. added ten times should be mathematically. Why this step? This establishes the expected value so we can measure the drift.

  2. Understand the machine's limit. Computers store numbers in binary. has no exact binary form (like has no exact decimal). Each += 0.1 carries a tiny rounding error, and errors accumulate — this only becomes visible over many additions (C8, the limiting case). Why this step? A correct-looking loop can drift once repeated enough; the bug scales with iteration count.

  3. Measure the drift. total comes out as 0.9999999999999999, so total == 1.0 is False. Why this step? Confirms floats need a tolerance, never ==.

  4. Fix: compare with a tolerance.

    abs(total - 1.0) < 1e-9   # True

Verify: the checks below confirm abs(sum of ten 0.1s - 1.0) < 1e-9 is True, while exact equality is False.


Example 8 — Real-world word problem: averaging a messy CSV [Cell C9]

Forecast: what is the correct denominator — 5 students, or fewer?

  1. List every failure shape hiding here. Blank string '' (a C3-style degenerate/empty value), the non-numeric text 'absent' (C5 type mismatch), and — if every score were filtered out — a zero denominator (C4 division edge). One word problem touches three cells at once. Why this step? Real data is a mixture — naming the shapes first tells you which guards to write.

  2. Filter to valid numeric scores.

    valid = []
    for name, s in rows:
        s = s.strip()
        if s.isdigit():
            valid.append(int(s))

    Why this step? str.isdigit() (a documented string method) answers "is this text a string of digits 0–9?" — it rejects '' and 'absent' in one move.

  1. Guard the zero case, then average.

    average = sum(valid) / len(valid) if valid else None

    Why this step? If every score were invalid, valid is empty and we must not divide by zero (C4 again).

  2. Compute. Valid scores are . Denominator is 3, not 5.

Verify: ; the two rejected rows ('', 'absent') correctly do not count. This is exactly the pipeline in 1.7.2-Data-preprocessing-and-cleaning.


Example 9 — Exam-style twist: the mutable default argument [Cell C10]

Forecast: does the second call print [20] or something else? Guess before reading on.

  1. Recognise the shape. No crash, and the first call looks perfect — this is C10, the adversarial twist that lives in the silent-and-subtle corner of the matrix (top-left of the map figure, next to C2). Why this step? When code passes the obvious test but you sense a trap, you must check the docs for a quiet rule you might have violated.

  2. Read the documented rule most people skip. Python evaluates a default argument once, at the moment the function is definednot freshly on each call. Why this step? This single sentence in the docs is the entire bug. The [] is created one time and then reused on every call that omits bucket.

  3. Trace the shared object across calls.

    • Definition time: one empty list L = [] is made and stored as the default.
    • Call 1 add_score(10): appends 10 to L → returns [10]. Looks right.
    • Call 2 add_score(20): appends 20 to the same L → returns [10, 20], not [20]. Why this step? The bug is invisible on the first call and only surfaces on the second — exactly why it is an exam favourite. The output [10, 20] is the fingerprint of a shared mutable default.
  4. Fix with the standard sentinel idiom. Use None as the default and build a fresh list inside the function on every call:

    def add_score(score, bucket=None):
        if bucket is None:
            bucket = []
        bucket.append(score)
        return bucket

    Why this step? None is immutable and cannot accumulate state, so each call that omits bucket gets a brand-new list.

Verify: the buggy version's calls return [10] then [10, 20] (shared list); the fixed version returns [10] then [20] (independent lists) — both confirmed in the checks below. Compare how default hyperparameters behave in 2.3.4-Hyperparameter-tuning-grid-search-random-search — defaults are contracts you inherit.


Recall

Recall Which debugging tool matches which bug shape?

Crash (C1) → read the traceback bottom-up. Silent wrong answer (C2) → binary-search the values. Which shape gives no traceback? ::: C2 (silent wrong answer) — nothing tells you, so you insert prints.

Recall Why is

average([]) a zero-division bug and not an "empty list" bug? Empty input (C3) is the cause; len([]) == 0 makes the denominator zero, and dividing by zero (C4) is the crash. ::: The empty list produces the zero denominator — C3 triggers C4.

Recall Why does

np.linspace(0,10,5,endpoint=False) give spacing 2.0 not 2.5? With endpoint=False the divisor is num = 5, not num - 1 = 4. ::: instead of .

Recall Why can

str.isdigit() silently lose valid scores? It is True only for digit runs 09, so -5 and 3.5 return False. ::: Negatives and decimals are rejected as if they were junk — a silent data-loss bug.