The USDA Plant Hardiness Zone Map tells you which plants will survive the winter where you live. It is built from one number: the average annual minimum temperature at a location, in degrees Fahrenheit. Locations whose coldest night averages within the same five degrees get the same zone label, from 1a in interior Alaska to 13b in Puerto Rico.
Gardeners have used it for decades. It is printed on the back of seed packets.
In November 2023 the USDA released a new version, built by the PRISM group at Oregon State from thirty years of weather station records. It was the first update since 2012, and it moved about half the country half a zone warmer. The update was reported widely, and a lot of that reporting was not careful about what the two maps actually measure.
Today you have both maps, one row per zip code, and every tool we have learned this week. We will compare them (without a mapping library!) to work out what you can say about the difference between the two files, and where a comparison built this way can mislead you.
Reference
USDA Plant Hardiness Zone Maps for 2012 and 2023, prepared by the PRISM Climate Group at Oregon State University, distributed by zip code. Zip code locations from a public zip code database.
Todayβs patterns
Everything you draw today is built from these patterns.
plt.figure(figsize=(w, h)) # the canvasplt.xlabel(...) ; plt.ylabel(...) ; plt.title(...) # alwaysplt.tight_layout() # lastsns.scatterplot(data=df, x='col', y='col', hue='col') # one measurement against anothersns.histplot(data=df, x='col') # the shape of one columnsns.barplot(x=series.values, y=series.index) # a grouped Series, as bars
We will also use these patterns from earlier in the week:
pd.concat([a, b], ignore_index=True) # the stacking patternpd.merge(left, right, left_on='a', right_on='b') # the join patterndf.pivot_table(index=, columns=, values=) # the pivot patterndf.groupby('key')['col'].agg(['count', 'mean']) # split, apply, combinedf.sort_values('col', ascending=False).head(n) # the top-N patterndf['new'] = df['col'].apply(named_function) # the derived-column pattern
Setup
Create a new notebook named EOD_Day7_Hardiness_Zones.ipynb.
Add a title cell:
# Day 7 EOD: Forty Thousand Zip CodesDate: 09/09/2026
Read the three files:
Code
import pandas as pdimport matplotlib.pyplot as pltimport seaborn as snsbase ='https://eds-217-essential-python.github.io/data/'zones_2012 = pd.read_csv(base +'hardiness_zones_2012.csv')zones_2023 = pd.read_csv(base +'hardiness_zones_2023.csv')zipcodes = pd.read_csv(base +'zip_code_database.csv')
π§ Field Note: getting text into a shape you can use
Two lines of setup here use string operations we have not covered yet. Both do the same kind of job: they take a column of text and turn it into something you can join on or do arithmetic with. Copy them as given, and ask Cella or Kelly if you want the longer version!
Padding a zip code. All three files store zip codes as integers, so 01001 arrives as the number 1001 and the leading zero is gone. Two files that lost their zeros in the same way will still match each other, but the moment you print one or compare it to a real zip code you have a problem. .str.zfill(5)zero-fills a string out to five characters:
Splitting a range. The trange column holds text like '-10 to -5'. To do arithmetic on the cold end of that range you need the first piece of it, as a number:
.str.split() cuts the text at every space, giving ['-10', 'to', '-5']. .str.get(0) takes the first piece from each row. .astype(int) turns '-10' into -10.
On Thursday, in 4C, we said that chaining .str methods together was a Day 7 problem, and here is the chain. Three methods in a row, each one working on whatever the last one returned, which is the same pattern you have been reading all week in .sort_values(...).head(10). A chain is two patterns run one after the other.
Answer each question with code, then write the answer in a markdown cell underneath, in a complete sentence with the numbers in it.
How many rows and columns does each of the three tables have? What are the column names of the two zone tables, and are they the same?
Run .isnull().sum() on both zone tables. Then look at .head() of either one and say, in one sentence, what zone, trange and zonetitle each hold and which of the three columns is redundant.
The two files do not have the same number of rows. Before you do anything else, write down two different explanations for that (not that someone made a mistake).
Part 2: One table out of three
Add a year column to each zone table, then stack the two of them into one table called zones, using the stacking pattern. How many rows?
Add trange_min using the line from the Field Note, then confirm it has no nulls and look at its distinct values with .value_counts().sort_index(). In a markdown cell, say what you notice about the spacing of those values and what that means for the phrase βhow much warmerβ.
Compute the mean trange_min for each year and report the difference. The difference is the headline number, and the rest of the practice is about how much it is worth.
Merge zones with zipcodes to attach a state, a latitude and a longitude to every row. The key columns are called different things in the two tables. Compare the row count before and after, then run the merge again with how='left' and find out how many rows failed to match and how many distinct zip codes that is.
In a markdown cell: 103 rows out of 80,455 did not match, covering 63 zip codes. Is that a number you would fix, a number you would mention, or a number you would ignore? Say which and why. There is more than one defensible answer.
Part 3: The first map
Filter located to the 2023 rows only, then draw a scatter plot with longitude across the bottom, latitude up the side, and hue='trange_min'. Make the figure twelve inches by seven. Label both axes and title it.
Describe what is wrong with that figure in a markdown cell.
Find the culprit. Use the filter pattern on located to pull out every row with a longitude greater than β60, and print its zipcode, state, primary_city, latitude and longitude.
In a markdown cell: that is one zip code, appearing once in each year: two rows out of eighty thousand. Look up roughly where latitude 48.3, longitude β2.1 is. Is the temperature reading wrong, or is something else wrong? What did those two rows do to your figure?
Two rows in eighty thousand
Nothing about those rows is unusual in a table. They are not null, not duplicated, not miscast, and they would survive every cleaning step we learned on Thursday. .describe() on longitude would have shown a maximum of β2.12 and you would have had no reason to look at it.
A figure found them immediately, because a plot gives an outlier as much room on the page as it gives everything else, which is a large part of what visualization is for. Remember the gorilla from EDS 212β¦
Build a table called usa holding only the rows with longitude less than β60, and redraw the 2023 map from it. Then draw the same map for 2012.
You have just drawn a recognizable map of the United States without a mapping library, a projection, or a shapefile. Explain in a markdown cell how that worked here. Then, run usa['state'].nunique() and say which parts of the country are not in these files at all.
Put the two maps side by side on your screen and try to see the difference between them. Can you?
Part 4: What actually changed
Comparing two maps by eye is hard, and for a change this small it mostly does not work. Instead, we will compute the change for every zip code and map that.
Use the pivot pattern to build a table with one row per zip code and one column per year. Index it on ['zipcode', 'state', 'latitude', 'longitude'] so those come along for the ride, put year across the columns and trange_min in the cells, then .reset_index(). What shape is it, and why is that number smaller than 80,000?
Add a temp_diff column: the 2023 value minus the 2012 value. Count its nulls and say what a null means here. Then drop those rows.
Note
π The two year columns are named with the integers2012 and 2023, not with strings, because the year column you pivoted on held integers. So it is change[2023], with no quotes. Everything else about column-to-column arithmetic is the same as it was on Thursday.
Draw a histogram of temp_diff. Label it. Then print change['temp_diff'].value_counts().sort_index() and read the two together.
In a markdown cell: what is the most common value, what is the second most common, and how many distinct values are there in total? Given that, is temp_diff a measurement of how much a place warmed, or is it something else? Be precise.
The tails of that histogram are worth a minute. Use the top-N pattern twice to show the five zip codes with the largest increase and the five with the largest decrease, with their states and coordinates.
In a markdown cell: a zip code that moved by 25 or 30 Β°F has moved five or six whole zones. Do you believe that is a change in the climate? What else could produce it?
temp_diff has too many values to use as a hue= and too few to be interesting as a number. Write a named function called classify_shift that takes one difference and returns 'warmer', 'colder' or 'unchanged', apply it to build a shift column, and count the three categories.
Draw the change map: longitude and latitude again, this time with hue='shift'. Twelve by seven, labelled, titled.
In a markdown cell: Where is the country almost uniformly warmer? Where is it patchy? Where are the 'colder' points, and does their location connect to your answer to question 19?
Then compare this figure with the two maps from question 13, and say in one sentence why the difference had to be computed rather than looked at.
Part 5: States, and the count column
Group change by state and use .agg(['count', 'mean']) on temp_diff. Show the ten states with the largest mean increase.
Read the count column before you read the mean column. Do any entries surprise you from the top ten? Why?
Build a Series of the ten largest mean increases, sorted, and draw it as horizontal bars using the .values and .index idiom. Label both axes with units and title it.
Now, show the five states with the smallest mean increase instead, with their counts. In a markdown cell: one of those states has more zip codes in it than all but one other state in the file. Does that make its small number more trustworthy or less, and does it make the ranking more interesting or less?
Part 6: Write it up
In a single markdown cell of 250 to 350 words, answer this:
A local newspaper is running a story headlined βOur State Is Now a Zone Warmer.β They have your two files and they want one figure and one number from you. What do you send them, and what do you tell them it does not mean?
Your answer must cite at least three specific numbers you computed here, must name at least two distinct reasons the comparison is harder than it sounds, and must end by naming the single figure you would send. Use complete sentences.
The figure minute
Look back through your notebook. You made six figures in this practice, and they did four different jobs:
Question 9 found a data error, in a file that was not otherwise wrong.
Question 13 drew two maps out of two ordinary numeric columns.
Question 18 showed you how few distinct values your βtemperature changeβ really has, which is the most important thing to know about it and is invisible in any mean.
Questions 21 and 25 answered the question.
Four of those six figures were how you found out what you had, and two of them were the figures the practice was about. The ratio is normal and worth expecting, because a lot of the plots you make will never be shown to anybody: their job is to tell you something before you tell anyone else.
Wrap-up
Before you close your notebook, check that:
every figure has an x-label, a y-label, a title, and units where units exist
you compared the row count before and after your merge
you never described temp_diff as a measured temperature change
every mean you reported in Part 5 has a count beside it
your Part 6 answer names something these files cannot tell the newspaper
your notebook reads top to bottom as a document, not as a pile of cells
If one of those checks fails, fix it before you leave today, and grab Cella or Kelly if the fix is not obvious!
Tomorrow we start the two-day final project. You and your team will pick your own dataset and walk it through all ten steps yourselves. Bring the notebook you started this afternoon.