⚠️⚠️⚠️ TOTAL POINTS POSSIBLE: 33 ⚠️⚠️⚠️¶
Preliminaries¶
Import needed python packages¶
from matplotlib import pyplot as plt # for plotting
import numpy as np # for working with arrays of numerical values
import pandas as pd # for reading CSV or excel files and subsequent analyses
import scipy # for various scientific calculations
import xarray as xr # for loading data and subsequent analysesLoad the Central Park data¶
Notebook Cell
!pip install pooch
# The above command installs the needed `pooch` 3rd-party package if it's not already installed.
import hashlib # for verifying that the Central Park file is not corrupted
import pathlib # for constructing paths to the dataset's location on disk
import sys # for checking if this is a Google Colab session or not
import pooch # for downloading the dataset from the web, if needed
# Replace "../data" as needed to point to the correct directory for you.
# This can be an *absolute path* or a *relative path*. One dot, `.`, means
# "this directory", while two dots, `..`, means "go up one directory."
LOCAL_DATA_DIR = "../data" # If you're in Colab: just ignore this.
# The URL where the dataset can be downloaded from.
DATA_URL = (
"https://spencerahill.github.io/25f-stat-methods-course/_downloads/"
"91803b82950d49961a65355c075439b3/central-park-station-data_1869-01-01_2023-09-30.nc"
)
# This HASH_HEX stores a "hash" which we use to verify that the data you end up
# with has not been altered or corrupted compared to the one at the above URL.
HASH_HEX = "85237a4bae1202030a36f330764fd5bd0c2c4fa484b3ae34a05db49fe7721eee"
def create_data_path(
colab_dir="/content/data",
local_dir=LOCAL_DATA_DIR,
filename="central-park-station-data_1869-01-01_2023-09-30.nc",
):
"""Set the path for the data, whether on colab or a local Jupyter session."""
is_this_a_colab = "google.colab" in sys.modules
if is_this_a_colab:
data_dir = colab_dir
else:
data_dir = local_dir
DATA_DIR = pathlib.Path(data_dir)
DATA_DIR.mkdir(parents=True, exist_ok=True)
return DATA_DIR / filename
def sha256sum(path: pathlib.Path) -> str:
"""Get the hash of the file at the specified path."""
return hashlib.sha256(path.read_bytes()).hexdigest()
DATA_PATH = create_data_path()
# Determine if we'll need to download the data, which we'll do if either (a)
# the data can't be found, or (b) it appears corrupted/modified from the
# "master" file at the above URL.
need_fetch = (not DATA_PATH.exists()) or (sha256sum(DATA_PATH) != HASH_HEX)
# Download the data if needed.
if need_fetch:
fetched_data = pooch.retrieve(
url=DATA_URL,
known_hash=f"sha256:{HASH_HEX}",
path=DATA_PATH.parents[0],
fname=DATA_PATH.name,
)
print(f"\nDownloaded and verified: {fetched_data}")
else:
print(f"\nVerified existing file at {DATA_PATH}")Looking in links: https://pypi.python.org/pypi, https://testpypi.python.org/pypi
Requirement already satisfied: pooch in /Users/sah2249/miniconda3/envs/25f-stats/lib/python3.13/site-packages (1.8.2)
Requirement already satisfied: platformdirs>=2.5.0 in /Users/sah2249/miniconda3/envs/25f-stats/lib/python3.13/site-packages (from pooch) (4.5.0)
Requirement already satisfied: packaging>=20.0 in /Users/sah2249/miniconda3/envs/25f-stats/lib/python3.13/site-packages (from pooch) (25.0)
Requirement already satisfied: requests>=2.19.0 in /Users/sah2249/miniconda3/envs/25f-stats/lib/python3.13/site-packages (from pooch) (2.32.5)
Requirement already satisfied: charset_normalizer<4,>=2 in /Users/sah2249/miniconda3/envs/25f-stats/lib/python3.13/site-packages (from requests>=2.19.0->pooch) (3.4.4)
Requirement already satisfied: idna<4,>=2.5 in /Users/sah2249/miniconda3/envs/25f-stats/lib/python3.13/site-packages (from requests>=2.19.0->pooch) (3.11)
Requirement already satisfied: urllib3<3,>=1.21.1 in /Users/sah2249/miniconda3/envs/25f-stats/lib/python3.13/site-packages (from requests>=2.19.0->pooch) (2.5.0)
Requirement already satisfied: certifi>=2017.4.17 in /Users/sah2249/miniconda3/envs/25f-stats/lib/python3.13/site-packages (from requests>=2.19.0->pooch) (2025.10.5)
Verified existing file at ../data/central-park-station-data_1869-01-01_2023-09-30.nc
import xarray as xr
# `DATA_PATH` variable was created by the hidden cell just above.
# Un-hide that cell if you want to see the details.
ds_cp = xr.open_dataset(DATA_PATH)
ds_cpCompute and analyze linear regressions¶
Compute each of the following regressions (“Y against X” means you regress Y onto X):
annual block maximum precipitation against daily mean temperature averaged over May through September of each year
snow fall against daily maximum temperature, both restricting to days in December through February
same, but also restricting to days with nonzero snowfall
For each of these, include each of the following:
the Pearson correlation coefficient
the regression slope value, including the units
the regression intercept value, including the units
a scatterplot of the two variables with the regression line overlaid
a ~2 sentence analysis of your interpretation of the results of the regression
⚠️ POINTS: 15¶
One for each of the 5 requested elements per regression) x (3 regressions)
✅ ANSWER: Annual max P vs. MJJAS mean T¶
First compute the annual maximum precip and MJJAS mean T timeseries:
ann_max_p = ds_cp["precip"].groupby("time.year").max()
mjjas_temp = ds_cp["temp_avg"].where(ds_cp["time"].dt.month.isin([5,6,7,8,9]), drop=True)
mjjas_temp_avg = mjjas_temp.groupby("time.year").mean()Plotting the MJJAS T timeseries reveals what’s clearly a bad value sometime in the 1890s:
mjjas_temp_avg.plot()
We can identify it using the idxmin method:
mjjas_temp_avg.idxmin("year")Plotting now the daily timeseries of the MJJAS average temperature for a couple years in this period, we see that the issue is the bad, zero values we’ve seen many times before:
mjjas_temp.sel(time=slice("1892", "1894")).plot()
So mask those out:
mjjas_temp_valid = mjjas_temp.where(mjjas_temp != 0)
mjjas_temp_valid_avg = mjjas_temp_valid.groupby("time.year").mean()Now compute the linear regression:
lr_maxp_mjjast = scipy.stats.linregress(mjjas_temp_valid_avg, ann_max_p)
lr_maxp_mjjastLinregressResult(slope=np.float64(0.02419050995226056), intercept=np.float64(1.454334061352404), rvalue=np.float64(0.029678230117972035), pvalue=np.float64(0.7139316747613376), stderr=np.float64(0.06586731545948786), intercept_stderr=np.float64(4.642193985665543))Make the plot
fig, ax = plt.subplots()
ax.scatter(mjjas_temp_valid_avg, ann_max_p, s=3, c="firebrick")
ax.plot(
[mjjas_temp_valid_avg.min(), mjjas_temp_valid_avg.max()],
[lr_maxp_mjjast.slope * mjjas_temp_valid_avg.min() + lr_maxp_mjjast.intercept,
lr_maxp_mjjast.slope * mjjas_temp_valid_avg.max() + lr_maxp_mjjast.intercept],
color="black")
label_text = f"""
slope: {lr_maxp_mjjast.slope:.02f} inches / deg F
intercept: {lr_maxp_mjjast.intercept:.02f} inches
r={lr_maxp_mjjast.rvalue:.02f}
"""
ax.text(0.99, 0.99, label_text, ha="right", va="top", transform=ax.transAxes)
ax.set_xlabel("MJJAS mean T [deg F]")
ax.set_ylabel("ann max P [inches]")
Interpretation: the relationship between these two variables is very weak...the correlation coefficient is approximately zero, and the regression slope is too. This suggests that the annual maximum rainfall is not strongly determined by the average temperatures over the warmest 5 months of the year.
(See this paper however, which shows that annual max hourly precip at Central Park does have a significant relationship with a different variable, namely the sum of the cooling degree days from May through October.)
✅ ANSWER: Daily snowfall vs. max T, December through February¶
ds_djf = ds_cp.where(ds_cp["time"].dt.month.isin([1,2,12]), drop=True)
ds_djf = ds_djf.where(ds_djf["temp_max"] != 0, drop=True)
ds_djflr_snow_tmax = scipy.stats.linregress(ds_djf["temp_max"], ds_djf["snow_fall"], nan_policy="omit")
lr_snow_tmaxLinregressResult(slope=np.float64(-0.021885558129581126), intercept=np.float64(1.154771006053042), rvalue=np.float64(-0.18752169322826814), pvalue=np.float64(4.050706386449829e-99), stderr=np.float64(0.0010263901996679123), intercept_stderr=np.float64(0.04276606845704229))fig, ax = plt.subplots()
ax.scatter(ds_djf["temp_max"], ds_djf["snow_fall"], s=3, c="firebrick")
ax.plot(
[ds_djf["temp_max"].min(), ds_djf["temp_max"].max()],
[lr_snow_tmax.slope * ds_djf["temp_max"].min() + lr_snow_tmax.intercept,
lr_snow_tmax.slope * ds_djf["temp_max"].max() + lr_snow_tmax.intercept],
color="black")
label_text = f"""
slope: {lr_snow_tmax.slope:.02f} inches / deg F
intercept: {lr_snow_tmax.intercept:.02f} inches
r={lr_snow_tmax.rvalue:.02f}
"""
ax.text(0.99, 0.99, label_text, ha="right", va="top", transform=ax.transAxes)
ax.set_xlabel("daily max T [deg F]")
ax.set_ylabel("daily snow fall [inches]")
Interpretation: This is hard to interpret. There is a weak negative slope, but a lot of the signal seems to be coming from the days with zero snow fall.
✅ ANSWER: Daily snowfall vs. max T where snowfall >0¶
ds_snow = ds_djf.where(ds_djf["snow_fall"] > 0, drop=True)lr_snow_gt0_tmax = scipy.stats.linregress(ds_snow["temp_max"], ds_snow["snow_fall"], nan_policy="omit")
lr_snow_gt0_tmaxLinregressResult(slope=np.float64(-0.0910847819413809), intercept=np.float64(5.12546837362091), rvalue=np.float64(-0.23120645746618565), pvalue=np.float64(1.6011236563687469e-21), stderr=np.float64(0.009427135347300476), intercept_stderr=np.float64(0.32510556511302324))fig, ax = plt.subplots()
ax.scatter(ds_snow["temp_max"], ds_snow["snow_fall"], s=3, c="firebrick")
ax.plot(
[ds_snow["temp_max"].min(), ds_snow["temp_max"].max()],
[lr_snow_gt0_tmax.slope * ds_snow["temp_max"].min() + lr_snow_gt0_tmax.intercept,
lr_snow_gt0_tmax.slope * ds_snow["temp_max"].max() + lr_snow_gt0_tmax.intercept],
color="black")
label_text = f"""
slope: {lr_snow_gt0_tmax.slope:.02f} inches / deg F
intercept: {lr_snow_gt0_tmax.intercept:.02f} inches
r={lr_snow_gt0_tmax.rvalue:.02f}
"""
ax.text(0.99, 0.99, label_text, ha="right", va="top", transform=ax.transAxes)
ax.set_xlabel("daily max T [deg F]")
ax.set_ylabel("daily snow fall [inches]")
Interpretation: Compared to the case including the nonzero snowfall days, the overall negative relationship is stronger.
However, this is a case where the nature of the relationship changes with the values of the variable being regressed onto! Notice that the scatterplot has an overall pyramid shape. That means as the temperature increases from very cold values, you get an overall increase in snowfall, but somewhere around the freezing point of 32F the values start decreasing with further warming.
Both relationships make sense: warmer air can hold more water vapor, and so at very cold temperatures there just aren’t that many water molecules available to turn into snow. But then once the max temperature gets above freezing, that means more of the day is above freezing, which acts to inhibit snowfall.
ABOVE AND BEYOND ANSWER: As a bonus, let’s re-do this but a separate regression for temperature values above and below freezing.
ds_snow_cold = ds_snow.where(ds_snow["temp_max"] < 32)lr_snow_gt0_tmax_lt0 = scipy.stats.linregress(ds_snow_cold["temp_max"], ds_snow_cold["snow_fall"], nan_policy="omit")
lr_snow_gt0_tmax_lt0LinregressResult(slope=np.float64(-0.04151249802289626), intercept=np.float64(3.967241708435825), rvalue=np.float64(-0.05248194272070327), pvalue=np.float64(0.2259841828391944), stderr=np.float64(0.03424634466379669), intercept_stderr=np.float64(0.9116889728745038))ds_snow_warm = ds_snow.where(ds_snow["temp_max"] >= 32)lr_snow_gt0_tmax_gt0 = scipy.stats.linregress(ds_snow_warm["temp_max"], ds_snow_warm["snow_fall"], nan_policy="omit")
lr_snow_gt0_tmax_gt0LinregressResult(slope=np.float64(-0.0782925274092375), intercept=np.float64(4.5795838389847185), rvalue=np.float64(-0.17134641760903052), pvalue=np.float64(7.775512496558637e-09), stderr=np.float64(0.013457346158573871), intercept_stderr=np.float64(0.5067952172851))fig, ax = plt.subplots()
ax.scatter(ds_snow_cold["temp_max"], ds_snow_cold["snow_fall"], s=3, c="firebrick")
ax.plot(
[ds_snow_cold["temp_max"].min(), ds_snow_cold["temp_max"].max()],
[lr_snow_gt0_tmax_lt0.slope * ds_snow_cold["temp_max"].min() + lr_snow_gt0_tmax_lt0.intercept,
lr_snow_gt0_tmax_lt0.slope * ds_snow_cold["temp_max"].max() + lr_snow_gt0_tmax_lt0.intercept],
color="black")
label_text = f"""
slope: {lr_snow_gt0_tmax_lt0.slope:.02f} inches / deg F
intercept: {lr_snow_gt0_tmax_lt0.intercept:.02f} inches
r={lr_snow_gt0_tmax_lt0.rvalue:.02f}
"""
ax.text(0.99, 0.99, label_text, ha="right", va="top", transform=ax.transAxes)
ax.set_xlabel("daily max T [deg F]")
ax.set_ylabel("daily snow fall [inches]")
fig, ax = plt.subplots()
ax.scatter(ds_snow_warm["temp_max"], ds_snow_warm["snow_fall"], s=3, c="firebrick")
ax.plot(
[ds_snow_warm["temp_max"].min(), ds_snow_warm["temp_max"].max()],
[lr_snow_gt0_tmax_gt0.slope * ds_snow_warm["temp_max"].min() + lr_snow_gt0_tmax_gt0.intercept,
lr_snow_gt0_tmax_gt0.slope * ds_snow_warm["temp_max"].max() + lr_snow_gt0_tmax_gt0.intercept],
color="black")
label_text = f"""
slope: {lr_snow_gt0_tmax_gt0.slope:.02f} inches / deg F
intercept: {lr_snow_gt0_tmax_gt0.intercept:.02f} inches
r={lr_snow_gt0_tmax_gt0.rvalue:.02f}
"""
ax.text(0.99, 0.99, label_text, ha="right", va="top", transform=ax.transAxes)
ax.set_xlabel("daily max T [deg F]")
ax.set_ylabel("daily snow fall [inches]")
Implement detrending¶
⚠️ POINTS: 18¶
First, write a python function that uses linear regression to compute and return the linear trend in time of a given variable. ⚠️⚠️⚠️POINTS: 2⚠️⚠️⚠️
Second, write another function that calls the above function to compute the trend, and then subtracts off that trend from the original array. The function should return this resulting detrended array. ⚠️⚠️⚠️POINTS: 2⚠️⚠️⚠️
Third, for the two variables used in the first linear regression above (block-max precip and warm-season mean temp), plot the original timeseries, the detrended timeseries, and the linear trend. These should all go in one 2-panel figure, one panel per variable. Include a brief summary for each variable of your interpretion of the resuls of the detrending. ⚠️⚠️⚠️POINTS: 8. One per plotted element per variable, plus one each for the summaries.⚠️⚠️⚠️
Fourth, repeat the linear regression tasks but using the detrended timeseries. Describe how much the results change or not. ⚠️⚠️⚠️POINTS: 6. Five for the regression tasks, one for the additional discussion.⚠️⚠️⚠️
✅ ANSWER: Trend and detrending functions¶
Notice: the logic I have implemented below is a little more involved than what the question asked for, because I use it for more general cases. Grade yourself only on whether your code’s logic is correct for the precise tasks you were asked to perform, not whether it matches mine exactly.
def _infer_dim_if_1d(arr, dim):
"""Helper function to get dim name of 1D arrays."""
if dim is None:
if arr.ndim == 1:
dim = arr.dims[0]
else:
raise ValueError("Dimension must be specified if array isn't 1D.")
return dim
def trend(arr, dim=None, order=1, return_coeffs=False):
"""Compute linear or higher-order polynomial fit.
If return_coeffs is True, then coeffs.degree(sel=0) is the y-intercept,
coeffs.degree(sel=1) is the slope, etc.
"""
dim = _infer_dim_if_1d(arr, dim)
coeffs = arr.polyfit(dim, order)["polyfit_coefficients"]
if return_coeffs:
return coeffs
return xr.polyval(arr[dim], coeffs)
def detrend(arr, dim=None, order=1):
"""Subtract off the linear or higher order polynomial fit."""
dim = _infer_dim_if_1d(arr, dim)
return (arr - trend(arr, dim, order) + arr.mean(dim)).transpose(*arr.dims)✅ ANSWER: plot of original and detrended fields¶
lr_maxp_mjjast = scipy.stats.linregress(mjjas_temp_valid_avg, ann_max_p)
lr_maxp_mjjastLinregressResult(slope=np.float64(0.02419050995226056), intercept=np.float64(1.454334061352404), rvalue=np.float64(0.029678230117972035), pvalue=np.float64(0.7139316747613376), stderr=np.float64(0.06586731545948786), intercept_stderr=np.float64(4.642193985665543))fig, axarr = plt.subplots(1, 2, figsize=(8, 4))
vars_plot = mjjas_temp_valid_avg, ann_max_p
varnames = "MJJAS temp", "ann. max. P"
units_plot = "deg F", "inches"
for ax, var, varname, units in zip(axarr, vars_plot, varnames, units_plot):
var.plot(ax=ax, label="raw")
trend(var).plot(ax=ax, label="trend")
detrend(var).plot(ax=ax, label="detrended", linestyle="--")
ax.set_title(varname)
ax.set_ylabel(units)
axarr[0].legend()
✅ ANSWER: repeated linear regression on the detrended fields¶
lr_maxp_mjjast_dt = scipy.stats.linregress(detrend(mjjas_temp_valid_avg), detrend(ann_max_p))
lr_maxp_mjjast_dtLinregressResult(slope=np.float64(-0.03118513863131383), intercept=np.float64(5.356196841466913), rvalue=np.float64(-0.03223350890611057), pvalue=np.float64(0.6905129486659488), stderr=np.float64(0.07817513602738196), intercept_stderr=np.float64(5.509245723088633))Make the plot
fig, ax = plt.subplots()
x_plot = detrend(mjjas_temp_valid_avg)
y_plot = detrend(ann_max_p)
lr_obj = lr_maxp_mjjast_dt
ax.scatter(x_plot, y_plot, s=3, c="firebrick")
ax.plot(
[x_plot.min(), x_plot.max()],
[lr_obj.slope * x_plot.min() + lr_obj.intercept,
lr_obj.slope * x_plot.max() + lr_obj.intercept],
color="black")
label_text = f"""
slope: {lr_obj.slope:.02f} inches / deg F
intercept: {lr_obj.intercept:.02f} inches
r={lr_obj.rvalue:.02f}
"""
ax.text(0.99, 0.99, label_text, ha="right", va="top", transform=ax.transAxes)
ax.set_xlabel("detrended MJJAS mean T [deg F]")
ax.set_ylabel("detrended ann max P [inches]")
Interpretation: the relationship remains extremely weak, and in fact has changed sign to a very weakly negative relationship.