# 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.


``` python
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.


``` python
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.


``` python
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.


``` python
m_offset.plot()
```


<style>
  #altair-viz-b0a7b878383c40a6be672f13bef91df9.vega-embed {
    width: 100%;
    display: flex;
  }

  #altair-viz-b0a7b878383c40a6be672f13bef91df9.vega-embed details,
  #altair-viz-b0a7b878383c40a6be672f13bef91df9.vega-embed details summary {
    position: relative;
  }
</style>


# 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.


``` python
wk.check(m_offset)
```


<style>
  #altair-viz-87aa934bdef54783aef55166bf3dbed0.vega-embed {
    width: 100%;
    display: flex;
  }

  #altair-viz-87aa934bdef54783aef55166bf3dbed0.vega-embed details,
  #altair-viz-87aa934bdef54783aef55166bf3dbed0.vega-embed details summary {
    position: relative;
  }
</style>


# 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.


``` python
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.
