How fast is your city warming? (regression on time)
2026-11-10
lm(Y ~ X, data = df) fits a straight line through a cloud of pointssummary(model) gives you:
geom_smooth(method = "lm") draws the lineplot(model) checks the residualsNote
✅ Where today comes in
janitor so the code stays easy to typelm(temp ~ year) and read the warming rate straight off summary()Tip
🖐 Where this is going
Tools today:
GSODR — NOAA station data (Duluth)nasapower — data by lat/long (another source)janitor — clean_names()tidyverse — summarize and plotlm() — the slope is the answerReading:
Naming conventions:
_df_plot_modelWe will cover: where the data comes from, why the slope of a regression on time is a warming rate, and why you have to summarize before you can see it. No code in this chunk.
GSODR package pulls it straight into R
Our station to start:
727450-14913Last lecture:
\[\widehat{\text{area}} = a + b \times \text{mass}\]
Today:
\[\widehat{\text{temp}} = a + b \times \text{year}\]
Nothing about the code changes. Only what X means.
| Summary level | group_by() |
Rows left |
|---|---|---|
| Daily (raw) | none | ~25,000 |
| Yearly | year |
69 |
Important
Summarizing is not throwing data away.
group_by(year) %>% summarize() — the same two lines from Summary StatisticsWe will cover: downloading the Duluth record, cleaning the names, summarizing to yearly means, and fitting lm(temp ~ year).
🛑 After this chunk: Activity Parts 1–5 (download, summarize, plot, fit, read the slope).
get_GSOD() takes years and a station and goes and gets the data
get_GSOD() takes a year range and a station1948:2025 is a vector — “every year in this range”clean_names() is the important habit here
TEMP, YEARMODA, MXSPDtemp would errorclean_names() makes everything lower case and snake case, once, at the topTip
Clean the names as you load the data, and you never think about capitals again.
yearmoda — the dateyear, month, daytemp — daily mean temperature in °CNote
names(df) is how you find out what a downloaded data set actually calls things. Always look before you type.
name yearmoda year month temp
<char> <Date> <int> <int> <num>
1: DULUTH INTERNATIONAL AIRPORT 1948-01-01 1948 1 -8.2
2: DULUTH INTERNATIONAL AIRPORT 1948-01-02 1948 1 -4.1
3: DULUTH INTERNATIONAL AIRPORT 1948-01-03 1948 1 -8.4
select() keeps the five that answer our questionselect() from the Wrangling lecture — just on a much wider real fileTip
Trim early. Every later line is easier to read when the data frame has 5 columns instead of 47.
So you cannot see it. That is not a plotting problem — it is a summarizing problem.
group_by(year) — one group per yearsummarize(temp = mean(temp)) — one number per groupna.rm = TRUE ignores days the station missedNote
Reusing the name temp on the left means every line below keeps working — the same trick from T-Tests II.
geom_smooth(method = "lm") as the Regression lectureBut “it looks like it goes up” is not a result. We need the number.
Call:
lm(formula = temp ~ year, data = dlh_year_df)
Residuals:
Min 1Q Median 3Q Max
-1.99794 -0.51059 0.00688 0.49950 1.90458
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) -49.096842 9.119103 -5.384 9.77e-07 ***
year 0.026872 0.004585 5.860 1.49e-07 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 0.8774 on 68 degrees of freedom
Multiple R-squared: 0.3356, Adjusted R-squared: 0.3258
F-statistic: 34.34 on 1 and 68 DF, p-value: 1.489e-07
lm(temp ~ year) reads “temperature is predicted by year”year → Estimate)year → Pr(>|t|))Multiple R-squared)Look at the year row of the Coefficients table.
Estimate ≈ 0.027 — that is the slope
Pr(>|t|) is tiny (far below 0.001)
Multiple R-squared ≈ 0.34
Important
The intercept looks absurd — about −49 °C.
🛑 Do Activity Parts 1–5 now
clean_names() itlm(temp ~ year) and read the slope, p, and R² off summary()We will cover: labelling each day with a season, fitting a separate model for summer and winter, finding the station ID for a city you choose, and testing its warming rate against Duluth’s.
🛑 After this chunk: Activity Parts 6–10 (seasons, two models, your own city).
The plan — three steps you already know:
mutate() + case_when() to label each daygroup_by(year, season) + summarize()lm() per seasonImportant
⚠️ Seasons are not months — they are hemispheres.
latitude column tells you which hemisphere you are in
# Northern hemisphere seasons --------------------------
# SOUTHERN hemisphere: swap summer and winter
dlh_season_df <- dlh_df %>%
mutate(
season = case_when(
month %in% c(6, 7, 8) ~ "summer",
month %in% c(12, 1, 2) ~ "winter",
TRUE ~ "shoulder"
)
)
# Did it work? -----------------------------------------
dlh_season_df %>% count(season) season n
<char> <int>
1: shoulder 12718
2: summer 6436
3: winter 6287
case_when() reads top to bottom: condition ~ answer
month %in% c(6, 7, 8) = “is month 6, 7 or 8?”TRUE ~ "shoulder" is the catch-all for everything left over — spring and fallcount() is the check: roughly a quarter of the days in each of summer and winter, half in the shoulder📖 R4DS §3.4 — case_when()
# A tibble: 4 × 3
year season temp
<int> <chr> <dbl>
1 1948 summer 17.4
2 1948 winter -12.6
3 1949 summer 18.4
4 1949 winter -11.4
filter(season != "shoulder") — != means “is not”
group_by(year, season) — two grouping columns
.groups = "drop" tidies up afterwardsThis is the same two-column group_by() from T-Tests II.
color = season inside aes() does two jobs at once:
geom_smooth() fit a separate line per season
Call:
lm(formula = temp ~ year, data = summer_df)
Residuals:
Min 1Q Median 3Q Max
-2.6841 -0.7543 0.1585 0.6622 2.1918
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) -24.267149 10.815992 -2.244 0.028116 *
year 0.021059 0.005439 3.872 0.000245 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 1.041 on 68 degrees of freedom
Multiple R-squared: 0.1807, Adjusted R-squared: 0.1686
F-statistic: 14.99 on 1 and 68 DF, p-value: 0.0002448
filter() first, then the same lm() as beforeSummer slope ≈ 0.022 °C/year
Now do exactly the same thing for winter.
Call:
lm(formula = temp ~ year, data = winter_df)
Residuals:
Min 1Q Median 3Q Max
-3.5398 -1.3901 0.0164 1.1662 4.0695
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) -78.24857 20.10231 -3.893 0.000229 ***
year 0.03417 0.01011 3.380 0.001202 **
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 1.934 on 68 degrees of freedom
Multiple R-squared: 0.1439, Adjusted R-squared: 0.1313
F-statistic: 11.43 on 1 and 68 DF, p-value: 0.001202
Winter slope ≈ 0.037 °C/year
Winter is warming about 1.6× as fast as summer.
Both slopes come straight off the year row of each summary():
| Model | Slope (°C/yr) | ×10 (°C/decade) | p |
|---|---|---|---|
| Annual | 0.027 | 0.27 | 0.0000003 |
| Summer | 0.021 | 0.21 | 0.0002 |
| Winter | 0.034 | 0.34 | 0.0012 |
How to write it up:
Mean annual temperature at Duluth rose significantly from 1948 to 2025 (linear regression: slope = 0.027 °C yr⁻¹, equivalent to 0.27 °C per decade; F(1, 68) = 34.3, p < 0.001, R² = 0.34). Winter warmed roughly 1.6 times faster than summer (0.34 vs 0.21 °C per decade).
# Every station, and the years it covers ---------------
load(system.file("extdata", "isd_history.rda",
package = "GSODR"))
stations_df <- isd_history %>%
clean_names() %>%
filter(country_name == "ICELAND") %>%
mutate(begin_year = as.integer(str_sub(begin, 1, 4)),
end_year = as.integer(str_sub(end, 1, 4))) %>%
filter(begin_year <= 1970, end_year >= 2025) %>%
select(stnid, name, begin_year, end_year)
head(stations_df, 3)Key: <stnid>
stnid name begin_year end_year
<char> <char> <int> <int>
1: 040050-99999 BOLUNGAVIK 1952 2025
2: 040180-16201 KEFLAVIK NAVAL AIR STATION 1942 2025
3: 040340-99999 STAFHOLTSEY 1949 2025
GSODR ships a table of every station on Earth with the years each coversstnid goes into get_GSOD(); begin_year tells you where to startNote
This is the route with the longest records — some reach back to the 1930s. The cost is gaps, like Duluth’s.
stations_df
stnid — that is the stationbegin_year — where the range startsyear, month, temp, already lower caseImportant
Southern hemisphere? Swap the season labels in case_when():
month %in% c(12, 1, 2) → summermonth %in% c(6, 7, 8) → winternasapower needs only coordinates
MM and T2M
rename() is needed here# The later of the two start years -------------
start_year <- max(min(dlh_year_df$year),
min(city_year_df$year))
# Label each one BEFORE you stack --------------
duluth_labeled_df <- dlh_year_df %>%
filter(year >= start_year) %>%
mutate(station = "duluth")
city_labeled_df <- city_year_df %>%
filter(year >= start_year) %>%
mutate(station = "my_city")
both_df <- bind_rows(duluth_labeled_df,
city_labeled_df)GSODR, so the columns already matchbind_rows() puts one frame underneath the other
station column is the trick
Important
Compare the years they share — or a difference in place is mixed up with a difference in years.
Your city and Duluth gave different numbers. Different-looking slopes are not always different slopes.
year * station fits a separate slope for each placeyear:stationmy_city row
Estimate — how much your slope differsPr(>|t|) — is that difference real?Note
Your city’s slope = year + year:stationmy_city
🛑 Do Activity Parts 6–10 now
case_when() and summarize by year and seasonThe ideas:
group_by(year) %>% summarize() is the fixThe code:
get_GSOD() — Duluth and your city, by station IDisd_history — find a station in any countrybind_rows() — stack two places into one frameclean_names() — lower-case names, oncegroup_by() + summarize() — the right levelcase_when() — build the seasonlm(temp ~ year), lm(temp ~ year * station)Hand in: your finished activity script, plus the homework — your own city’s warming rate and how it compares with Duluth’s.
References:
ncei.noaa.govpower.larc.nasa.govUp next: