# Use a By-Variable Smooth

A by-variable smooth fits a distinct smooth for each level of a grouping factor. Unlike a shared smooth, it places no constraint on the shape: one group can have a rising curve while another falls. Use `s(x, by=group_indicator)` with dummy-coded columns (one binary (0/1) column per group level). This is the right approach when you expect group-specific non-linear shapes rather than a common curve shifted up or down.


# Generate data

Simulate 200 observations split between two groups. Group A follows a hump shape that peaks near the center of x. Group B follows an inverted hump that troughs there. Dummy-code the groups into separate binary columns.


``` python
import whittaker as wk
import numpy as np

# Simulate x values for two equal-size groups
rng = np.random.default_rng(23)
n = 100
x = rng.uniform(0, 1, size=2 * n)

# Build opposing group-specific response shapes
y = np.concatenate([
    np.sin(np.pi * x[:n]) + rng.normal(0, 0.15, n),       # group A: hump
    -np.sin(np.pi * x[n:]) + rng.normal(0, 0.15, n),      # group B: inverted hump
])

# Dummy-code groups into binary indicator columns
group_a = np.concatenate([np.ones(n), np.zeros(n)])
group_b = np.concatenate([np.zeros(n), np.ones(n)])

data = {"x": x, "y": y, "group_a": group_a, "group_b": group_b}
```


Group A occupies the first 100 rows; Group B the second. The two shapes are mirror images of each other. A shared smooth would average them into a flat line.


# Fit

Fit a model with two by-variable smooths: one for each group. The `by=group_a` term fits a smooth that is active only where `group_a == 1`, and similarly for `group_b`.


``` python
# Fit separate smooth per group
model = wk.GAM(
    "y ~ s(x, by=group_a) + s(x, by=group_b)"
).fit(data)

model.summary()
```


    /opt/hostedtoolcache/Python/3.12.14/x64/lib/python3.12/site-packages/scipy/optimize/_optimize.py:2358: RuntimeWarning: invalid value encountered in scalar subtract
      p = (xf - fulc) * q - (xf - nfc) * r
    /opt/hostedtoolcache/Python/3.12.14/x64/lib/python3.12/site-packages/scipy/optimize/_optimize.py:2359: RuntimeWarning: invalid value encountered in scalar subtract
      q = 2.0 * (q - r)


    GAM fit summary
    ============================================================
    Formula:    y ~ s(x, by='group_a') + s(x, by='group_b')
    Family:     Gaussian(link='identity')
    Inference:  GCV
    Observations: 200
    Coefficients: 21

    Parametric coefficients:
      Term                       Estimate    Std.Err    t value    p-value
      ------------------------ ---------- ---------- ---------- ----------
      (Intercept)                  0.0723     0.0329      2.199    0.02901

    Approximate significance of smooth terms:
      Term                        EDF Ref.df     Chi.sq    p-value
      ------------------------ ------ ------ ---------- ----------
      s(x, by='group_a')         1.02      2    311.211    < 1e-16
      s(x, by='group_b')         1.09      2    475.682    < 1e-16

    Total EDF:  3.10
    Scale est:  0.103564
    Deviance:   20.3914
    Null dev:   105.4711
    Dev. expl:  80.7%
    GCV score:  0.105754
    AIC:        117.17
    BIC:        127.40


Each smooth gets its own EDF row in the summary. Both should have non-trivial EDF values, confirming that the curves are genuinely non-linear and differ between groups.


# Partial effects


``` python
wk.partial_effects(model)
```


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

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


The partial effects panels show the two smooths on the same x-axis scale. Group A's smooth should peak near the center while Group B's troughs there. The opposite shapes the data generation encoded. A shared smooth would have missed this entirely.


# Diagnostics


``` python
wk.check(model)
```


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

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


Residuals from a correctly specified by-variable model should show no remaining group structure. If you see a pattern tied to the group, consider whether additional terms or a higher `k` are needed.
