Use an Offset Term for Exposure

Model event rates with a Poisson GAM by adding an offset for varying exposure.

A Poisson GAM models expected counts, but raw counts conflate the underlying rate with how long (or how much) each unit was observed. Fish traps left in the water for seven days will catch more fish than traps left for one day, even if the true catch rate at that location is identical. The offset term corrects for this: by pre-computing log(exposure) and adding offset(log_exposure) to the formula, the model estimates the log-rate rather than the log-count, with the coefficient on the log-exposure fixed to exactly one.

Generate synthetic data

Create a dataset of fish trap deployments where each trap is monitored for a variable number of days. The true count-generating process depends on exposure, so a model that ignores it will be misspecified.

import numpy as np
import whittaker as wk

# Simulate trap deployments with varying exposure
rng = np.random.default_rng(23)
n = 300
temperature = rng.uniform(5, 25, n)
exposure = rng.choice([1, 2, 3, 7], n)

# Compute true log-rate and draw Poisson counts
log_rate = -0.5 + 0.1 * temperature - 0.003 * temperature**2
count = rng.poisson(np.exp(log_rate) * exposure)

# Assemble data with pre-computed log-exposure
data = {"temperature": temperature, "exposure": exposure,
        "log_exposure": np.log(exposure), "count": count}

Fit without an offset (incorrect)

The naive model treats all traps as if they were observed for the same duration. The smooth for temperature will absorb some of the exposure variation, biasing the estimated effect.

m_naive = wk.GAM("count ~ s(temperature)", family=wk.Poisson()).fit(data)
m_naive.summary()
GAM fit summary
============================================================
Formula:    count ~ s(temperature)
Family:     Poisson(link='log')
Inference:  GCV
Observations: 300
Coefficients: 10

Parametric coefficients:
  Term                       Estimate    Std.Err    z value    p-value
  ------------------------ ---------- ---------- ---------- ----------
  (Intercept)                  1.4235     0.0283     50.239    < 1e-16

Approximate significance of smooth terms:
  Term                        EDF Ref.df     Chi.sq    p-value
  ------------------------ ------ ------ ---------- ----------
  s(temperature)             1.55      2      2.408     0.2999

Total EDF:  2.55
Scale est:  1.000000
Deviance:   778.2976
Null dev:   782.4304
Dev. expl:  0.5%
GCV score:  2.638915
AIC:        1655.64
BIC:        1665.07

Fit with an offset (correct)

Adding offset(log_exposure) to the formula fixes the log-exposure coefficient to one. The offset column must be pre-computed and present in the data (the formula parser treats the argument as a column name, not an expression). The smooth for temperature now estimates the log-rate, which is the quantity of genuine scientific interest.

m_offset = wk.GAM(
    "count ~ s(temperature) + offset(log_exposure)",
    family=wk.Poisson(),
).fit(data)
m_offset.summary()
GAM fit summary
============================================================
Formula:    count ~ s(temperature) + offset(log_exposure)
Family:     Poisson(link='log')
Inference:  GCV
Observations: 300
Coefficients: 10

Parametric coefficients:
  Term                       Estimate    Std.Err    z value    p-value
  ------------------------ ---------- ---------- ---------- ----------
  (Intercept)                  0.2317     0.0284      8.172  3.027e-16

Approximate significance of smooth terms:
  Term                        EDF Ref.df     Chi.sq    p-value
  ------------------------ ------ ------ ---------- ----------
  s(temperature)             2.05      3     10.619    0.01397

Total EDF:  3.05
Scale est:  1.000000
Deviance:   306.1603
Null dev:   782.4304
Dev. expl:  60.9%
GCV score:  1.041635
AIC:        1184.52
BIC:        1195.84

Visualize the rate smooth

m_offset.plot() shows the partial effect of temperature on the log-rate scale. The inverted-U shape reflects the quadratic log-rate used when generating the data.

m_offset.plot()

Check diagnostics

Inspect the residuals for the offset model. If the naive model were used instead, the residuals vs fitted plot would show a systematic trend driven by the unmodeled exposure variation.

wk.check(m_offset)

Compare deviance explained

The offset model should explain more deviance than the naive model, because it correctly accounts for the exposure structure in the data.

m_naive.deviance_explained, m_offset.deviance_explained
(0.0052819744670160595, 0.6087059921818201)

When to use an offset

Scenario Offset
Fish counts; traps monitored for varying days offset(log_days)
Insurance claims; policies with varying coverage periods offset(log_years_exposed)
Disease incidence; areas with different population sizes offset(log_population)

Use an offset whenever the count response reflects a rate multiplied by a known, varying exposure. Without the offset, the smooth must work harder to explain variance that is purely a measurement artifact.