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:
- Read a Python traceback and locate the line that caused the error
- Apply a systematic debugging protocol instead of changing code at random
- Use documentation and error-message searches as part of that protocol
- Recognize silent bugs, which produce wrong results without raising any error
- Keep a debugging log, so that a hard-won fix is not lost by next week
- Write assertions that make bugs announce themselves early
- Write a minimal reproducible example to ask for help effectively
The two-stage protocol
Setup
General directions
About the data
1. Load the data and agree on an expectation
- 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))Print the data frame in full. It is only 18 rows, so you can and should read all of it.
Before writing any more code, agree with your team on the answers to these three questions and write them in a markdown cell.
- How many monitoring sites are in this dataset?
- What is a plausible range for stream water temperature in degrees Celsius?
- Roughly what mean water temperature do you expect across all measurements?
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.0283168Rather 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.
Step 2, look at the data.
stream.dtypesdischarge_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-intYou 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.
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.
- Run the following cell and compare the result against your expectation from 1c-iii.
stream['water_temp_c'].mean()- The cell runs without error. Is the answer right, and how do you know?
- Explain in one sentence why the result is what it is.
- 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.
- 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.
- Which is wrong, the code or your expectation?
- Write code that shows exactly why.
- 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()- Look at the
site_meancolumn. What happened? - Print
site_meanson its own and compare its index tostream’s index. Form a hypothesis about why the assignment produced what it did. - Fix it.
- 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'Write an assertion for each of the following, and confirm each passes on your cleaned data.
streamhas exactly 3 unique sites.
- Every water temperature is between -5 and 40 degrees Celsius, ignoring missing values.
water_temp_c,discharge_cfsandnitrate_mg_lare all numeric.
- 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.
- An assertion can be switched off when Python runs with the
-Oflag, 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 aValueErrorinstead.
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.
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.
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
- 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.
- 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