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}Use an Offset Term for 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.
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.