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}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.
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.
# 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
wk.partial_effects(model)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
wk.check(model)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.