Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Answer Key for HW05: linear regressions

⚠️⚠️⚠️ 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 analyses

Load 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_cp
Loading...

Compute 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()
<Figure size 640x480 with 1 Axes>

We can identify it using the idxmin method:

mjjas_temp_avg.idxmin("year")
Loading...

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()
<Figure size 640x480 with 1 Axes>

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_mjjast
LinregressResult(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]")
<Figure size 640x480 with 1 Axes>

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_djf
Loading...
lr_snow_tmax = scipy.stats.linregress(ds_djf["temp_max"], ds_djf["snow_fall"], nan_policy="omit")
lr_snow_tmax
LinregressResult(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]")
<Figure size 640x480 with 1 Axes>

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_tmax
LinregressResult(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]")
<Figure size 640x480 with 1 Axes>

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_lt0
LinregressResult(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_gt0
LinregressResult(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]")
<Figure size 640x480 with 1 Axes>
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]")
<Figure size 640x480 with 1 Axes>

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_mjjast
LinregressResult(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()
<Figure size 800x400 with 2 Axes>

✅ 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_dt
LinregressResult(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]")
<Figure size 640x480 with 1 Axes>

Interpretation: the relationship remains extremely weak, and in fact has changed sign to a very weakly negative relationship.