How fast is your city warming? (regression on time)
2026-10-05
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 — download NOAA station datajanitor — 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 | ~28,000 |
| Yearly | year |
78 |
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
1948: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, and finding the station ID for a city you choose.
🛑 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.021 °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.034 °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.001 |
| Summer | 0.021 | 0.21 | <0.001 |
| Winter | 0.034 | 0.34 | 0.001 |
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).
stnid name begin end
<char> <char> <int> <int>
1: 727450-14913 DULUTH INTERNATIONAL AIRPORT 19480101 20250827
2: 999999-14913 DULUTH WILLIAMSN-JOHNSN MUNI 19650101 19721231
3: 997269-99999 DULUTH 20080101 20081231
nearest_stations() takes a latitude, a longitude, and a radius in kmstnid is what you paste into get_GSOD()begin and end tell you the years availableImportant
Pick a station with a long record. A trend needs decades — aim for a begin date of 1970 or earlier.
# The ONLY line that changes ---------------------------
city_df <- get_GSOD(years = 1973:2025,
station = "948660-99999") %>%
clean_names()
# Everything below is identical ------------------------
city_year_df <- city_df %>%
group_by(year) %>%
summarize(temp = mean(temp, na.rm = TRUE))
city_model <- lm(temp ~ year, data = city_year_df)
summary(city_model)years = range your station actually has — don’t ask for years before beginImportant
If your city is in the southern hemisphere, swap the season labels in case_when():
month %in% c(12, 1, 2) → summermonth %in% c(6, 7, 8) → winter🛑 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(years =, station =) and nearest_stations()clean_names() — lower-case names, once, at the topgroup_by() + summarize() — to the right levelcase_when() — build the seasonlm(temp ~ year) + summary() — the rateHand in: your finished activity script, plus the warming rate you found for your own city.
References:
ncei.noaa.govUp next: