Debugging code

Week 4 - Discussion section

Every data scientist writes code that does not work, and a substantial share of any working programmer’s time goes into finding out why. Debugging is a skill distinct from programming and from data science itself, which means that strong programmers are not automatically strong debuggers and that the skill has to be practiced on its own terms. In this discussion section, you will:

The two-stage protocol

Keep this panel visible throughout the whole section

Debugging has two stages, and they ask different questions. Work through them in order, because a program that does not run cannot be checked for correctness.

Stage 1. Does the code run? If not, you have a loud bug, and the traceback is telling you where to look.

Stage 2. Is the answer right? If the code runs but the result is wrong, you have a silent bug. Nothing will notify you about this except checking the result against your own expectations and understanding.

Inside either stage, the loop is the same six steps.

  1. Read the error, or state the mismatch. Note the error type, the message, and the last line of the traceback that points at your code.
  2. Look at the data. Check .shape, .dtypes, .columns, and .head(). Most bugs are a mismatch between the data you have and the data you assumed.
  3. Isolate. Reduce the failing code to the smallest piece that still breaks.
  4. Form a hypothesis and test it. State what you think is wrong in one sentence, then write code that confirms or rejects it. Read the documentation for any function or method involved, because the answer is very often written there already.
  5. Fix, then verify. Re-run from the top and confirm the fix did not break something downstream.
  6. Consider adding a guard, so the bug announces itself if it ever returns.

Debugging is not a purely internal exercise. Searching the documentation for the method you are using, or pasting the exact error message into a search engine, is a legitimate move at any step and often the fastest one, which is why experienced programmers reach for it constantly rather than as a last resort.

If a change does not fix the problem, undo it before trying the next thing. Novice programmers routinely introduce new bugs while hunting an old one, and a pile of half-reverted edits is how a one-bug problem becomes a three-bug problem.

First, what an assert actually does

Step 6 asks you to add a guard, and the tool for that is assert. It takes a condition and a message, does nothing at all when the condition is true, and raises an AssertionError carrying your message when it is false.

assert 2 == 2, 'Two is not two'      # passes silently, nothing happens
assert 2 == 3, 'Two is not three'    # AssertionError: Two is not three

That is the whole idea. An assertion turns an assumption you are carrying in your head into a line of code that fails loudly the moment the assumption stops being true. You will write real ones in exercise 5.

Setup

  1. Open your eds220-discussion-sections directory, in whichever environment you normally work, whether that is the workbench server or your own computer.

  2. Create a new Python notebook inside your eds220-discussion-sections directory and rename it to section-4-debugging.ipynb.

  3. Use the terminal to push this file to your remote repository.

General directions

  • Add comments as appropriate along your code following the course commenting standards.
  • Include markdown cells in between your code cells to add titles and information to each exercise.
  • Several exercises ask you to predict what will happen before you run the code. Do not skip the prediction, because comparing a prediction against an outcome is what builds the skill.
  • The two things you hand in from this section are your debugging log and your completed error-type table, both of which you build as you go.
  • Commit every time you finish a major step. Remember to write your commits in the imperative mood.

About the data

For this section you will use a small constructed dataset of stream monitoring measurements from three sites, with 18 rows and six columns.

Column Meaning
site_id Monitoring station identifier
date Date of the measurement
water_temp_c Water temperature, degrees Celsius
discharge_cfs Stream discharge, cubic feet per second
nitrate_mg_l Nitrate concentration, mg/L
qc_flag Quality control flag assigned by the monitoring program

The example data is synthetic and was generated for the purpose of this lesson. Unlike the other discussion sections, this data is not from an archive. It is constructed, and it is deliberately small enough to read in full, so that you can always check whether your code produced the right answer by looking at the raw values.

Every problem in it, however, is one you will meet in real environmental data:

  • Sentinel values for missing data. Many agencies encode “no measurement” as -999 or -9999 rather than leaving the field empty. NOAA and USGS products do this routinely.
  • Thousands separators. Data exported from a spreadsheet often stores 1,240 as text rather than as the number 1240.
  • Detection limits. Laboratories report concentrations below the instrument’s detection limit as <0.05 rather than as a number, so the whole column arrives as text.
  • Inconsistent identifiers. Station codes entered by hand pick up trailing spaces and inconsistent capitalization.

1. Load the data and agree on an expectation

  1. Copy the following cell into your notebook and run it. It builds the data frame from text, exactly as if you had read it from a CSV file.
import io
import pandas as pd

CSV = """site_id,date,water_temp_c,discharge_cfs,nitrate_mg_l,qc_flag
SB-01,2024-01-15,12.4,"1,240",0.82,good
SB-01,2024-02-15,13.1,"1,180",0.79,good
SB-01,2024-03-15,15.8,"2,050",1.21,good
SB-01,2024-04-15,18.2,"1,620",0.94,good
sb-01,2024-05-15,21.5,"980",0.66,good
SB-01 ,2024-06-15,-999,"540",0.51,suspect
SB-02,2024-01-15,11.8,"3,410",1.88,good
SB-02,2024-02-15,12.3,"3,290",1.92,good
SB-02,2024-03-15,14.9,"4,870",2.41,good
SB-02,2024-04-15,17.6,"3,950",2.09,estimated
SB-02,2024-05-15,20.9,"2,240",1.44,good
sb-02,2024-06-15,-999,"1,130",1.02,suspect
SB-03,2024-01-15,10.2,"620",<0.05,good
SB-03,2024-02-15,10.9,"590",<0.05,good
SB-03,2024-03-15,13.4,"1,040",0.33,good
SB-03,2024-04-15,16.1,"870",0.28,good
SB-03 ,2024-05-15,19.3,"410",<0.05,good
SB-03,2024-06-15,-999,"180",<0.05,suspect
"""

stream = pd.read_csv(io.StringIO(CSV))
  1. Print the data frame in full. It is only 18 rows, so you can and should read all of it.

  2. Before writing any more code, agree with your team on the answers to these three questions and write them in a markdown cell.

    1. How many monitoring sites are in this dataset?
    2. What is a plausible range for stream water temperature in degrees Celsius?
    3. Roughly what mean water temperature do you expect across all measurements?

Do not skip (c). Stage 2 of the protocol compares the answer you got against the answer you expected, so without a recorded expectation there is nothing to compare against, and a silent bug has nothing to run into. This is the single habit that separates people who catch their own mistakes from people who publish them.

CHECK IN WITH YOUR TEAM

MAKE SURE YOU’VE ALL SUCCESSFULLY LOADED THE DATA BEFORE CONTINUING

2. A worked example

Your first task is to convert stream discharge from cubic feet per second to cubic metres per second, which means multiplying by 0.0283168. The following cell attempts it and fails.

stream['discharge_cfs'] * 0.0283168

Rather than asking you to fix this, this exercise walks through all six steps so you can see what the protocol looks like in practice. Read it, run each cell yourself, and confirm you get the same output. The next exercise hands the work back to you entirely.

Step 1, read the error. Running the cell gives:

TypeError: can't multiply sequence by non-int of type 'float'

The type is TypeError, so an operation met a kind of data it cannot work with. The word sequence is the clue, because Python calls strings and lists sequences, and multiplying a sequence by an integer repeats it. Python is not refusing to do arithmetic, it is trying to repeat something and finding 0.0283168 is not a whole number of repetitions.

Reading a traceback

A traceback reads bottom to top. The last line gives the error type and message, which is the single most informative thing on the screen. Above it, Python lists the chain of calls that led there, starting from your cell and descending into library code. The useful line is usually the lowest one that names a file you wrote, because everything below it is pandas internals and is rarely where your bug lives.

When several errors appear at once, read the first one. Later errors are often consequences of the first.

Step 2, look at the data.

stream.dtypes

discharge_cfs is object, which is how pandas labels a column of text. A column of measurements has arrived as text.

Step 3, isolate. Reduce to the smallest failing piece, which here is a single value.

stream['discharge_cfs'].iloc[0] * 0.0283168   # same TypeError, one value
stream['discharge_cfs'].iloc[0]              # '1,240'

Step 4, form a hypothesis and test it. The hypothesis is that thousands separators have forced the column to text. Test it by looking at the distinct values.

stream['discharge_cfs'].unique()

Eleven of the eighteen values carry a comma. Note that the remaining seven, the ones only reaching the hundreds, have no comma at all, and yet every value still appears wrapped in quotes as a string. That is the point worth seeing: a single non-numeric value is enough to force the entire column to text, so the handful of entries with separators has made even the clean ones unusable for arithmetic. The hypothesis holds.

Step 5, fix and verify. Remove the separator, convert, then check the result against something you know.

discharge_cms = (stream['discharge_cfs']
                    .str.replace(',', '', regex=False)
                    .astype(float)) * 0.0283168

discharge_cms.head(3)

This gives 35.11, 33.41 and 58.05 m³/s. Verify rather than assume, because 1,240 cfs is a little over a thousand and a cubic metre is about 35 cubic feet, so an answer near 35 m³/s is the right order of magnitude. Had the answer come back near 35,000 the conversion would have gone the wrong way.

Step 6, consider adding a guard.

assert pd.api.types.is_numeric_dtype(discharge_cms), 'Discharge failed to convert to numeric'

And if none of this had worked? Suppose the protocol had run out of road and you still did not know why the multiplication failed. At that point you would ask someone, and you would ask by building a minimal reproducible example, which is a two-row version of the problem that another person can run in isolation.

import pandas as pd
df = pd.DataFrame({'discharge_cfs': ['1,240', '980']})
df['discharge_cfs'] * 0.0283168     # TypeError: can't multiply sequence by non-int

You will build one of these properly in exercise 6. Notice already that shrinking the problem to two rows often reveals the cause before you have finished writing the question.

The log. Finally, record what happened, because in ten weeks you will meet this bug again and will not remember the fix. This log is an artifact of this discussion section, and it is one of the two things you hand in. Copy this table into a markdown cell and keep adding rows to it as you work through the rest of the section.

What I expected What happened My hypothesis What I changed Result
Discharge in m³/s, around 35 TypeError: can't multiply sequence by non-int discharge_cfs is text because of thousands separators .str.replace(',', '') then .astype(float) Works, 35.11 m³/s, guard added

commit, pull, and push changes

3. On your own

Four cells below are broken. No steps are given for any of them. For each one, predict what will happen before you run it, then work the full protocol, fix it, and add a row to your log.

a.

stream['site_id'].strip()

For the guard on this one, the useful check is not about dtype but about how many distinct sites you end up with. Be careful here, because removing the stray whitespace is not on its own enough to get that number down to what you decided in exercise 1c.

b.

stream['nitrate_mg_l'].astype(float)

This error names the exact value it choked on, which is the fastest route to a fix that any error message gives you.

Step 5 is not purely a coding decision here. The values reported as <0.05 are measurements that fell below the laboratory’s detection limit, so the true concentration lies somewhere between zero and 0.05 mg/L. Discuss with your team what those values should become. There is no single correct answer, and the common conventions include substituting the detection limit itself, substituting half of it, or treating the values as missing. Choose one, implement it, and write a comment stating what you did and why.

A pipeline earns trust when its judgment calls are visible, not merely when it runs cleanly.

c. 

stream['temperature'].mean()

d. 

stream.iloc[18]

e. Complete the error-type table

Using what the five errors you have now met taught you, complete this table in a markdown cell. This table is the second of the two things you hand in from this section.

Error type What it usually means in pandas
KeyError
AttributeError
TypeError
ValueError
IndexError

commit, pull, and push changes

4. Bugs that do not raise errors

Everything so far has been stage 1, where Python told you something was wrong. Stage 2 is harder, because the code runs, returns a plausible-looking number, and nothing warns you. Catching these depends entirely on the expectations you recorded in exercise 1c.

  1. Run the following cell and compare the result against your expectation from 1c-iii.
stream['water_temp_c'].mean()
  1. The cell runs without error. Is the answer right, and how do you know?
  2. Explain in one sentence why the result is what it is.
  3. Fix it. Several approaches work, and the best one handles the problem at the moment the data is read, so that no later calculation can ever touch the sentinel.
  1. Run the following cell and compare the result against your expectation from 1c-i.
stream['site_id'].nunique()

If you already fixed this in exercise 3a, re-load the raw data into a new variable and run it against that, so you can see what the bug looked like.

  1. Which is wrong, the code or your expectation?
  2. Write code that shows exactly why.
  1. Now a bug that produces no number at all, and still says nothing. You want to attach each site’s mean temperature back onto every row.
site_means = stream.groupby('site_id')['water_temp_c'].mean()
stream['site_mean'] = site_means
stream[['site_id', 'water_temp_c', 'site_mean']].head()
  1. Look at the site_mean column. What happened?
  2. Print site_means on its own and compare its index to stream’s index. Form a hypothesis about why the assignment produced what it did.
  3. Fix it.
The rule this bug teaches

When you assign a Series into a column, pandas aligns the two by index label, not by position. Here site_means is indexed by site_id while stream is indexed 0 to 17, so no label matches anything and every row receives NaN. Index alignment is usually a convenience, and it is the most common source of pandas results that look right at a glance while being wrong.

  1. The exercise that matters most. Run the per-site summary on your cleaned data.
stream.groupby('site_id')['water_temp_c'].mean()

Discuss with your team and answer in a markdown cell. If you had not fixed the bugs in (a) and (b), what would this output have looked like, and would you have noticed?

commit, pull, and push changes

5. Assertions and guards

Step 6 of the protocol is to consider adding a guard, and you met the mechanics of assert at the top of this section. An assert statement checks a condition and raises an AssertionError with your message when the condition is false, which converts a silent bug into a loud one.

assert condition, 'message explaining what went wrong'
  1. Write an assertion for each of the following, and confirm each passes on your cleaned data.

  2. stream has exactly 3 unique sites.

  1. Every water temperature is between -5 and 40 degrees Celsius, ignoring missing values.
  2. water_temp_c, discharge_cfs and nitrate_mg_l are all numeric.
  1. Confirm your guards actually work. Re-load the raw data into a new variable without any cleaning, run your three assertions against it, and check that each one fails.

A check you have never seen fail is not a check. An assertion that passes on broken data is testing the wrong thing, and it will hand you false confidence at the exact moment you need real confidence. Every guard you write deserves one run against data you know is bad.

  1. An assertion can be switched off when Python runs with the -O flag, so assertions suit checks on things that should never happen inside your own pipeline rather than validation of input a stranger might supply. Discuss with your team which of your three guards you would keep in a script you hand to a colleague, and which you would rewrite to raise a ValueError instead.

commit, pull, and push changes

6. Constructing minimal reproducible examples

When the protocol does not get you there, you ask someone, and how you ask determines whether they can help. A minimal reproducible example is the smallest piece of self-contained code that another person can run to see your problem, and it is built from the smallest data that still shows the problem, the shortest code that triggers it, the full error message, and a sentence saying what you expected instead. You saw a two-row version of one at the end of exercise 2.

  1. Choose one bug from this section. In a markdown cell, write a minimal reproducible example of it as though you were posting it for help.

  2. Discuss with your team why a three-row example gets a better answer than attaching the full dataset and a sixty-line notebook.

7. Collect your code

  1. In a new code cell, collect the relevant code into a single streamlined workflow that loads the raw data, cleans it, asserts that it is valid, and reports the mean water temperature and mean discharge by site. Your code cell should:
  • Only print the final results.
  • Not include output from intermediate variables or checks.
  • Not include methods or functions that do not directly contribute to the analysis.
  • If appropriate, combine methods using code chaining instead of creating intermediate variables.
  • Comment your code following our class comments guidelines, and state your detection-limit decision in a comment.
  • Use appropriate line breaks and indentation to make code readable.
  1. Write two or three full sentences reporting the mean water temperature and mean discharge at each site, with units. Note how many measurements were excluded as missing and how many nitrate values fell below the detection limit.

commit, pull, and push changes

Takeaways

  • A traceback is information, not a punishment. Read the bottom line first, and the first error first.
  • Most bugs are a mismatch between the data you have and the data you assumed, so inspect .dtypes and .shape before changing any code.
  • Reading the documentation is debugging. The accessor, the argument or the default you are missing is usually written down somewhere, and looking it up is faster than guessing.
  • An error that stops your code is a good day, because the bug that returns a plausible wrong number is the one that reaches a published figure.
  • You can only catch a silent bug if you know roughly what answer to expect, which makes domain knowledge part of your debugging toolkit.
  • Guard every assumption you rely on, and confirm the guard fails on data you know is bad.
  • Keep the log. The bug you solve today is one you will meet again, and the fix is cheap to record and expensive to rediscover.