---------------------------------------------------------------------- This is the API documentation for the whittaker library. ---------------------------------------------------------------------- ## Core model The primary GAM interface for fitting, predicting, and inspecting models. GAM(formula: 'str | Formula', family: 'Family | None' = None) -> 'None' Generalized Additive Model with automatic smoothness selection. A GAM extends the generalized linear model by replacing some or all linear predictor terms with smooth, data-driven functions of the covariates: $$ g(\mathbb{E}[y_i]) = \eta_i = \beta_0 + \sum_j \beta_j x_{ij} + \sum_k f_k(z_{ik}) $$ where $g$ is a link function, $\beta_j x_{ij}$ are ordinary parametric (linear) terms, and each $f_k$ is an unspecified smooth function represented by a spline basis (`s()`) or a tensor product of bases for multivariate smooths (`te()`, `ti()`, `t2()`). This lets the model capture nonlinear relationships without having to guess a parametric form ahead of time, while still supporting the full range of exponential-family response distributions (Gaussian, Binomial, Poisson, Gamma, Tweedie, and more) via a `Family` object. Use `GAM` when you suspect a covariate's effect on the response is nonlinear, when you want interaction surfaces between two or more continuous covariates, or when you want automatic, data-driven control of model complexity rather than manually choosing a polynomial degree or a fixed set of basis functions. A `GAM` is specified with a formula string in an R/mgcv-like syntax, e.g. `"y ~ s(x1) + s(x2, bs='cr', k=15) + te(x3, x4) + group"`, where `s()` denotes a univariate (or `by=`-varying) smooth, `te()`/`ti()`/`t2()` denote tensor-product smooths of two or more variables, and bare names denote ordinary parametric terms. See `Formula`, `SmoothTerm`, `LinearTerm`, `InteractionTerm`, and `OffsetTerm` for the term types this formula parses into. Fitting (`fit()`) proceeds by Penalized Iteratively Reweighted Least Squares (P-IRLS): each smooth's wiggliness is controlled by a quadratic penalty $\lambda_k \boldsymbol{\beta}_k^T \mathbf{S}_k \boldsymbol{\beta}_k$ on its coefficients, and the smoothing parameters $\lambda_k$ are themselves estimated from the data. By default this is via Generalized Cross-Validation (GCV), or via Restricted Maximum Likelihood (REML) or Marginal Likelihood (ML) when smooths are treated as correlated random effects. Larger $\lambda_k$ shrinks a smooth toward a simpler (e.g. linear or constant) shape; smaller $\lambda_k$ allows more flexibility. This automatic selection is what distinguishes a GAM from simply choosing a fixed spline basis: the *effective* complexity of each term (its effective degrees of freedom, or EDF) is learned rather than fixed in advance. Once fitted, a `GAM` supports prediction with standard errors and intervals (`predict()`), partial-effect plotting (`plot()`), residual and basis-dimension diagnostics (`check()`, `gam_check()`, `k_check()`), hypothesis tests for parametric and smooth terms (`parametric_tests()`, `smooth_tests()`), and a text summary (`summary()`) analogous to `summary.gam()` in R's mgcv. Parameters ---------- formula : str or Formula Model formula, either as a string (e.g. `"y ~ s(x1) + s(x2) + x3"`) or an already-parsed `Formula` object. The left-hand side names the response column; the right-hand side lists smooth terms (`s()`, `te()`, `ti()`, `t2()`), parametric terms (bare column names), interactions (`x1 * x2`), and optionally an `offset(...)` term. Use `0 +` or `- 1` on the right-hand side to suppress the intercept. family : Family or None Response distribution and link function. Defaults to `Gaussian()` (identity link) if not given. Other options include `Binomial`, `Poisson`, `Gamma`, and `Tweedie`-family classes, each defining the variance function, deviance, and link used during P-IRLS. Examples -------- ```{python} import numpy as np from whittaker import GAM rng = np.random.default_rng(0) x = np.sort(rng.uniform(0, 10, 200)) y = np.sin(x) + rng.normal(scale=0.2, size=200) gam = GAM("y ~ s(x)").fit({"x": x, "y": y}) print(gam.summary()) ``` PredictionResult(values: 'NDArray', se: 'NDArray | None', linear_predictor: 'NDArray', lower: 'NDArray | None' = None, upper: 'NDArray | None' = None) -> None Container returned by `GAM.predict()` for `type="response"` or `type="link"`. Bundles the point predictions together with their optional standard errors and interval bounds so that all quantities produced by a single `predict()` call travel together. Use `values` for the predictions themselves; the other attributes are populated only when the corresponding arguments (`se=True`, `interval=...`) were requested. Attributes ---------- values : numpy.ndarray Predicted values, shape `(n,)`. On the response scale (`mu`) when `type="response"`, or on the linear predictor scale (`eta`) when `type="link"`. se : numpy.ndarray or None Standard errors of the linear predictor, shape `(n,)`. `None` unless `se=True` was passed to `predict()`. linear_predictor : numpy.ndarray Predictions on the linear predictor scale, shape `(n,)`. Always populated, regardless of `type`, so that the response-scale mean can be recovered via the link function. lower : numpy.ndarray or None Lower bound of the requested interval, on the same scale as `values`. `None` unless `interval` was set to `"confidence"`, `"prediction"`, or `"simultaneous"`. upper : numpy.ndarray or None Upper bound of the requested interval, on the same scale as `values`. `None` unless `interval` was set. TermsPredictionResult(terms: 'dict[str, NDArray]', se: 'dict[str, NDArray] | None', labels: 'list[str]' = ) -> None Container returned by `GAM.predict(type="terms")`. Instead of collapsing every smooth's effect into a single linear predictor, each smooth term's contribution is kept separate. This is useful for decomposing a fitted additive model into its constituent partial effects (e.g., to inspect how much of the prediction at a point comes from `s(x1)` versus `s(x2)`) without needing to build partial-effect plots. Attributes ---------- terms : dict[str, numpy.ndarray] Maps each term label (e.g. `"s(x1)"`, `"te(x1, x2)"`, or `"s(x1):group_a"` for factor-`by` smooths) to that term's contribution to the linear predictor, each of shape `(n,)`. Contributions sum (plus the intercept and any parametric terms) to the full linear predictor. se : dict[str, numpy.ndarray] or None Maps each term label to its per-term standard error, each of shape `(n,)`. `None` unless `se=True` was passed to `predict()`. labels : list[str] Term labels in formula order, matching the keys of `terms` and `se`. PosteriorPredictResult(samples: 'NDArray') -> None Container returned by `GAM.posterior_predict()`. Holds the full `(n, n_draws)` posterior predictive sample matrix and provides convenience methods for computing quantile summaries, means, and intervals. Attributes ---------- samples : numpy.ndarray Posterior predictive draws, shape `(n, n_draws)`. Each column is one draw from the posterior predictive distribution (coefficient uncertainty *plus* observation noise). GoodnessOfFit(deviance: 'float', null_deviance: 'float', deviance_explained: 'float', r_squared_adj: 'float', aic: 'float', bic: 'float', gcv_score: 'float | None', scale: 'float', edf_total: 'float', n_obs: 'int') -> None Goodness-of-fit statistics returned by `GAM.goodness_of_fit()`. Bundles all fit-quality metrics into a single object so they can be inspected, compared, or logged without calling each property individually. Attributes ---------- deviance : float Model deviance at convergence. null_deviance : float Deviance of the intercept-only model. deviance_explained : float Proportion of null deviance explained, in `[0, 1]`. r_squared_adj : float Adjusted R-squared, accounting for model complexity via the effective degrees of freedom. aic : float Akaike Information Criterion. bic : float Bayesian Information Criterion. gcv_score : float or None GCV score. `None` for Bayesian fits (VI, MCMC). scale : float Estimated scale (dispersion) parameter. edf_total : float Total effective degrees of freedom. n_obs : int Number of observations. GamCheckResult(deviance_residuals: 'NDArray', fitted_values: 'NDArray', response: 'NDArray', k_check: 'list', deviance_explained: 'float', scale: 'float', edf_total: 'float', n_obs: 'int') -> None Container returned by `GAM.gam_check()`, bundling residual diagnostics with fit summary statistics and basis-dimension adequacy checks. This mirrors the console output of R mgcv's `gam.check()`: it lets you inspect whether the residuals look well-behaved and whether any smooth's basis dimension `k` was set too small (in which case the smooth may be under-fitting), all from a single object. Printing the result (or relying on its `__repr__`) gives a compact textual report; the individual attributes are also available for building custom diagnostic plots (see `GAM.check()`). Attributes ---------- deviance_residuals : numpy.ndarray Deviance residuals, shape `(n,)`. Should look approximately normal and homoscedastic for a well-specified model. fitted_values : numpy.ndarray Fitted values `mu` on the response scale, shape `(n,)`. response : numpy.ndarray Observed response values `y` used for fitting, shape `(n,)`. k_check : list[KCheckResult] One basis-dimension check per smooth term. Each entry reports a k-index and a simulation-based p-value; low p-values (typically flagged with `*`) suggest the smooth's basis dimension `k` may be too small to capture the true function. deviance_explained : float Proportion of null deviance explained by the model, in `[0, 1]` (analogous to R-squared for non-Gaussian families). scale : float The estimated scale (dispersion) parameter `phi`. edf_total : float The total effective degrees of freedom across all model terms. n_obs : int The number of observations used in the fit. CheckDataResult(deviance_residuals: 'NDArray', pearson_residuals: 'NDArray', fitted_values: 'NDArray', response: 'NDArray', qq_theoretical: 'NDArray', qq_observed: 'NDArray') -> None Structured diagnostic data underlying `check()` plots. Provides the same four diagnostic datasets that `check()` renders as Altair charts, but as raw arrays for custom plotting with matplotlib or other libraries. Attributes ---------- deviance_residuals : NDArray Deviance residuals, shape `(n,)`. pearson_residuals : NDArray Pearson residuals, shape `(n,)`. fitted_values : NDArray Fitted values on the response scale, shape `(n,)`. response : NDArray Observed response values, shape `(n,)`. qq_theoretical : NDArray Theoretical normal quantiles for the QQ plot, shape `(n,)`. qq_observed : NDArray Sorted deviance residuals for the QQ plot, shape `(n,)`. SensitivityResult(multipliers: 'NDArray', predictions: 'NDArray', edf_total: 'NDArray', deviance_explained: 'NDArray', gcv_scores: 'NDArray', aic_values: 'NDArray', smoothing_params: 'NDArray', baseline_idx: 'int') -> None Result of a smoothing-parameter sensitivity analysis. Shows how predictions and fit statistics change as the smoothing parameters are scaled by a set of multipliers around their estimated (or fixed) values. Each row corresponds to one multiplier value applied uniformly to all smoothing parameters. Attributes ---------- multipliers : NDArray Multiplier values used, shape `(n_steps,)`. predictions : NDArray Fitted values at each multiplier, shape `(n_steps, n_obs)`. edf_total : NDArray Total effective degrees of freedom at each step, shape `(n_steps,)`. deviance_explained : NDArray Deviance explained at each step, shape `(n_steps,)`. gcv_scores : NDArray GCV score at each step, shape `(n_steps,)`. aic_values : NDArray AIC at each step, shape `(n_steps,)`. smoothing_params : NDArray Actual smoothing parameters used, shape `(n_steps, n_penalties)`. baseline_idx : int Index into `multipliers` corresponding to the original fit (multiplier closest to 1). PartialDependenceResult(term: 'str', x: 'dict[str, NDArray]', effect: 'NDArray', se: 'NDArray', lower: 'NDArray', upper: 'NDArray', edf: 'float', level: 'float') -> None Partial dependence data for one smooth term. Contains the evaluation grid, estimated effect, standard errors, and confidence bounds for a single smooth term. This is the data underlying `partial_effects()` plots, exposed as arrays for custom plotting or downstream analysis. Attributes ---------- term : str Term label (e.g. `"s(x)"`). x : dict[str, NDArray] Evaluation grid. For 1-D smooths, a single key mapping to a 1-D array. For 2-D smooths, two keys mapping to 1-D marginal grids (use `np.meshgrid` to expand). effect : NDArray Estimated partial effect at each grid point, shape `(n_grid,)`. se : NDArray Standard errors, shape `(n_grid,)`. lower : NDArray Lower confidence bound, shape `(n_grid,)`. upper : NDArray Upper confidence bound, shape `(n_grid,)`. edf : float Effective degrees of freedom for this term. level : float Confidence level used for the bounds. SimultaneousCIResult(estimate: 'NDArray', se: 'NDArray', lower: 'NDArray', upper: 'NDArray', term_label: 'str', crit_value: 'float') -> None Simultaneous confidence band for a smooth term. Attributes ---------- estimate : NDArray Estimated smooth effect at each evaluation point. se : NDArray Pointwise standard errors. lower : NDArray Lower simultaneous band. upper : NDArray Upper simultaneous band. term_label : str Label of the smooth term. crit_value : float Critical value from posterior simulation. ## Inference Result types from parametric and smooth tests, concurvity, influence diagnostics, derivatives, marginal effects, and contrasts. ParametricTestResult(term_label: 'str', estimate: 'float', se: 'float', stat: 'float', p_value: 'float') -> None Result of a Wald test for a parametric coefficient. Attributes ---------- term_label: Human-readable label for the term. estimate: Coefficient estimate β̂. se: Standard error of β̂. stat: Test statistic (t for Gaussian, z for known-scale families). p_value: Two-sided p-value. SmoothTestResult(term_label: 'str', stat: 'float', edf: 'float', ref_df: 'float', p_value: 'float') -> None Result of an approximate test for H_0: f_j = 0. Attributes ---------- term_label: Human-readable label for the smooth term. stat: Chi-squared test statistic. edf: Effective degrees of freedom for the smooth. ref_df: Reference degrees of freedom for the chi-squared test. p_value: Approximate p-value. ConcurvityResult(worst: 'NDArray', observed: 'NDArray', estimate: 'NDArray', labels: 'list[str]' = , full: 'bool' = True) -> None Concurvity diagnostics for smooth terms. Values range from 0 (no concurvity) to 1 (complete confounding). When `full=True`, each array has shape `(n_smooths,)` measuring each smooth against all other model terms combined. When `full=False`, each array has shape `(n_smooths, n_smooths)` with pairwise measures. Attributes ---------- worst: Upper-bound concurvity: the maximum proportion of each smooth's basis space that lies in the space of the comparator. observed: Concurvity of the actual fitted smooth function. estimate: Concurvity based on the estimated smooth's squared norm relative to the null model. labels: Smooth term labels in the same order as the array axes. full: Whether this is a full (overall) or pairwise result. KCheckResult(term_label: 'str', k_prime: 'int', edf: 'float', k_index: 'float', p_value: 'float') -> None Result of basis dimension adequacy check for a single smooth. Attributes ---------- term_label: Human-readable label for the smooth term. k_prime: Basis dimension after identifiability constraints (upper bound on EDF). edf: Effective degrees of freedom for the smooth. k_index: Ratio of neighbor-differencing variance estimate to overall residual variance. Values well below 1 suggest `k` may be too small. p_value: Simulation-based p-value. Low values indicate the basis dimension may be inadequate. InfluenceResult(hat_values: 'NDArray', cooks_distance: 'NDArray') -> None Observation-level influence diagnostics. Attributes ---------- hat_values: Leverage (diagonal of the hat matrix), shape `(n,)`. cooks_distance: Cook's distance for each observation, shape `(n,)`. DispersionTestResult(dispersion: 'float', chi2_stat: 'float', p_value: 'float') -> None Result of a dispersion test. Attributes ---------- dispersion: Estimated dispersion ratio (should be ~1 for correctly specified Poisson/Binomial). chi2_stat: Chi-squared test statistic. p_value: Two-sided p-value. VIFResult(term: 'str', vif: 'float') -> None Variance inflation factor for a parametric term. DerivativeResult(term: 'str', x: 'NDArray', derivative: 'NDArray', se: 'NDArray', lower: 'NDArray', upper: 'NDArray', level: 'float', order: 'int') -> None Result of smooth derivative estimation. Attributes ---------- term: Label for the smooth term. x: Covariate values at which derivatives are evaluated. derivative: Estimated derivative values, shape `(n,)`. se: Standard errors of the derivative estimates. lower: Lower confidence band. upper: Upper confidence band. level: Confidence level used. order: Derivative order (1 or 2). MarginalEffectResult(term: 'str', variable: 'str', x: 'NDArray', effect: 'NDArray', se: 'NDArray', lower: 'NDArray', upper: 'NDArray', level: 'float', by_values: 'dict[str, float] | None' = None) -> None Result of marginal effect estimation for one smooth term. Attributes ---------- term: Label for the smooth term. variable: The focal variable. x: Covariate values for the focal variable. effect: Estimated marginal effect (partial effect on the linear predictor). se: Standard errors. lower: Lower confidence band. upper: Upper confidence band. level: Confidence level used. by_values: Dict of conditioning variable values, if any. ContrastResult(term: 'str', x: 'NDArray', difference: 'NDArray', se: 'NDArray', lower: 'NDArray', upper: 'NDArray', level: 'float', label: 'str') -> None Result of a pairwise comparison between two conditions. Attributes ---------- term: Smooth term label. x: Covariate grid. difference: Estimated difference (condition1 - condition2). se: Standard error of the difference. lower: Lower confidence bound. upper: Upper confidence bound. level: Confidence level. label: Description of the comparison. ## Formula Formula parsing and term specifications for model construction. Formula(response: 'str', terms: 'list[Term]', intercept: 'bool' = True) -> None A parsed model formula: the structured representation of a `GAM`'s right-hand side. A `Formula` is what `whittaker.formula.parser.parse` produces from a formula string such as `"y ~ s(x1) + s(x2, bs='cr', k=15) + te(x3, x4) + group"`, and is what `GAM.__init__` accepts either as that raw string or as an already-parsed `Formula` object. It separates the response column name from an ordered list of `Term` objects (`LinearTerm`, `SmoothTerm`, `InteractionTerm`, `OffsetTerm`) describing the right-hand side, plus whether an intercept is included. Downstream code (`whittaker.model_matrix.build_model_matrix`) consumes a `Formula` to construct the actual numeric design matrix and penalty structure used for fitting. Parameters ---------- response : str Name of the response variable (the left-hand side, before `~`). terms : list[Term] Ordered list of model terms (the right-hand side, after `~`), where `Term` is a union of `LinearTerm`, `SmoothTerm`, `InteractionTerm`, and `OffsetTerm`. intercept : bool Whether the model includes an intercept column. `True` by default; suppress it by including `0 +` or `- 1` on the right-hand side of the formula string (e.g. `"y ~ 0 + s(x)"`). SmoothTerm(variables: 'tuple[str, ...]', smooth_type: 'str' = 's', bs: 'str' = 'tp', k: 'int' = -1, by: 'str | None' = None, extra: 'dict[str, Any]' = ) -> None A smooth term, e.g. `s(x1, bs='cr', k=10)` or `te(x1, x2)`. Represents an unspecified smooth function $f(\cdot)$ of one or more covariates, entered into the model matrix as a spline basis expansion with an associated wiggliness penalty. The penalty's strength (the smoothing parameter $\lambda$) is estimated automatically when the `GAM` is fit, rather than being a free choice like the degree of a polynomial term — this is what makes the term "smooth" in the GAM sense rather than a fixed parametric basis expansion. `smooth_type` controls how multiple `variables` are combined: - `"s"`: a single (marginal) smooth. Most common for one variable; for two or more it fits a single isotropic basis (e.g. a thin-plate spline over `(x1, x2)` jointly), appropriate when the covariates share the same scale/units. - `"te"`: a full tensor-product smooth. Builds a separate marginal basis for each variable and forms their tensor (outer) product, with one smoothing parameter per marginal direction. Appropriate for interactions between covariates on different scales, and implicitly includes the main effects of each variable. - `"ti"`: a tensor-product *interaction* smooth. Like `"te"`, but each marginal's penalty null space (e.g. the linear component) is projected out first, so the term captures only the pure interaction with no main-effect content. Used for an ANOVA-style decomposition, e.g. `s(x1) + s(x2) + ti(x1, x2)` separates main effects from their interaction. - `"t2"`: an alternative tensor-product parameterization to `"te"`, decomposing the penalty over every non-empty subset of the marginal directions ($2^d - 1$ penalties for $d$ variables) so each interaction order gets its own smoothing parameter. Parameters ---------- variables : tuple[str, ...] Column names that are arguments to the smooth function. A single name for `"s"` (or two or more for a multivariate `"s"`); two or more names are required for `"te"`, `"ti"`, and `"t2"`. smooth_type : str One of `"s"`, `"te"`, `"ti"`, `"t2"` (see above). Defaults to `"s"`. bs : str Basis type. Common values include: - `"tp"` (default): thin plate regression spline — a good general-purpose default with no need to place knots. - `"cr"`: cubic regression spline. - `"cc"`: cyclic (periodic) cubic regression spline, for covariates such as day-of-year or angle where the ends of the range should meet smoothly. - `"ps"`: P-spline (B-spline basis with a discrete difference penalty). - `"cp"`: cyclic P-spline. - `"ts"` / `"cs"`: shrinkage versions of `"tp"` / `"cr"` with an extra penalty on the null space, useful for automatic term selection without `select=True`. - `"re"`: random-effect basis (one ridge-penalized column per factor level), for smooth-random-intercept terms. - `"fs"`: factor-smooth interaction (a separate smooth per factor level, sharing one smoothing parameter). - `"ad"`, `"gp"`, `"ds"`, `"so"`, `"mrf"`, `"mpi"`/`"mpd"`, `"cx"`/`"cv"`: adaptive, Gaussian-process, Duchon spline, soap-film, Markov-random-field, and monotone/convex-constrained bases respectively, for more specialized use cases. k : int Number of basis functions (an upper bound on the effective degrees of freedom the smooth can use). `-1` (default) means auto-select a sensible default for the basis type. by : str or None Name of a factor or numeric column for a factor-by smooth (a separate curve estimated per factor level) or a varying-coefficient smooth (the smooth's value multiplies a numeric `by` column) — mirroring the `by=` argument in R's mgcv. extra : dict[str, Any] Any additional keyword arguments passed through to the underlying basis constructor (e.g. `xt=`, `m=`), for basis types with extra configuration options. LinearTerm(variable: 'str') -> None A plain linear (parametric) term, e.g. `x1` or `group`. Represents a bare covariate name on the right-hand side of a formula — anything that is not wrapped in `s()`, `te()`, `ti()`, `t2()`, or `offset()`, and does not use `*`/`:` interaction syntax. The column is entered into the model matrix unmodified (numeric columns as a single linear column; string/categorical columns are expanded to dummy indicator columns) and contributes a single unpenalized coefficient (or one per non-reference level, for a factor) to the linear predictor. Use a `LinearTerm` for effects you want to assume are linear, or for categorical covariates; use a `SmoothTerm` when you want the data to determine the shape of the relationship. Parameters ---------- variable : str Name of the data column this term refers to. InteractionTerm(left: 'str', right: 'str', full: 'bool' = True) -> None A two-way parametric interaction between two bare covariates, e.g. `x1 * x2`. Represents a crossing of two columns in the model matrix (numeric-by-numeric products, numeric-by-factor varying slopes, or factor-by-factor cell means, depending on the column types). Use this when you want an interaction *without* smoothing — for a smooth interaction surface between two continuous covariates, use a tensor-product `SmoothTerm` (`te()`, `ti()`, or `t2()`) instead, or a factor-`by` `SmoothTerm` for "one smooth per group". `full=True` corresponds to `*`-style crossing (R's convention): both main effects (`x1` and `x2` individually) plus the interaction column(s) are included. `full=False` corresponds to `:`-style crossing: the interaction only, with no accompanying main effects. Note that the parser (`whittaker.formula.parser.parse`) currently only produces terms with `full=True` (`x1 * x2` syntax); `x1:x2` colon syntax is not valid Python and is rejected at parse time, so `full=False` terms must be constructed directly if needed. Parameters ---------- left : str Name of the first covariate. right : str Name of the second covariate. full : bool If `True` (default), include both main effects and the interaction (`*`-style). If `False`, include only the interaction, dropping the main effects (`:`-style). OffsetTerm(expression: 'str') -> None An offset term, e.g. `offset(log_exposure)`. An offset is a covariate whose coefficient is fixed at exactly `1` rather than estimated — it is added directly onto the linear predictor unpenalized and unscaled. The canonical use case is modeling rates with a count-based family: for a Poisson model of event counts with varying exposure times, `offset(log(exposure))` lets the model fit the rate per unit exposure while accounting for the fact that longer exposures mechanically produce more events (since `log(mu) = eta + log(exposure)` implies `mu / exposure = exp(eta)`, the rate). The `expression` attribute stores the raw, unparsed text found inside `offset(...)` in the formula string (e.g. `"log(exposure)"` or `"log_exposure"`) — expression evaluation, if any, happens later when the model matrix is built. Parameters ---------- expression : str Raw expression text inside `offset(...)`. parse(formula: 'str') -> 'Formula' Parse *formula* into a `~whittaker.formula.terms.Formula`. Turns an `mgcv`-style R formula string such as `"y ~ s(x1) + s(x2, bs='cr') + x3"` into a structured `Formula` object: a response column name, an ordered list of `Term` objects (one per right-hand-side entry), and a flag indicating whether an intercept is included. `whittaker.gam.GAM` calls `parse()` internally when constructed from a formula string, and the resulting `Formula` is later consumed by `~whittaker.model_matrix.build_model_matrix` to build the numeric design matrix and penalty structure. This lets users specify a GAM the same way they would in R's `mgcv`, but written in Python. Rather than a hand-written grammar or regular expression, `parse()` uses Python's standard-library `ast` module to parse the right-hand side as a Python expression and walks the resulting syntax tree; consequently, only formula constructs that are expressible as plain Python expressions are supported — bare names, function calls, `+`/`-`/`*` binary operators, and the integer literals `0`, `1`, and `-1` for intercept control. Anything else raises a `ValueError` naming the unsupported construct. The following right-hand-side syntax is recognised: - A bare column name, e.g. `x3`, becomes a `~whittaker.formula.terms.LinearTerm`. - A call to `s()`, `te()`, `ti()`, or `t2()` becomes a `~whittaker.formula.terms.SmoothTerm`. Positional arguments name the smooth's variable(s) (more than one for `te()`/`ti()`/`t2()` tensor products). Recognised keyword arguments are `bs=` (the basis type, a string such as `"cr"`, `"mpi"`, or `"cx"`), `k=` (the basis dimension, an `int`, or a `list[int]` giving one dimension per marginal for tensor terms), and `by=` (a bare column name for a by-variable interaction). Any other keyword, e.g. `xt=`, `m=`, or `degree=`, is collected into the term's `extra` dict and passed through unevaluated (as a literal or bare-name string). - A call to `offset()`, e.g. `offset(log(n))`, becomes an `~whittaker.formula.terms.OffsetTerm` whose `expression` is the unparsed argument text. - `x1 * x2` becomes a full interaction (`~whittaker.formula.terms.InteractionTerm` with `full=True`): both main effects plus the interaction column. Both operands of `*` must be bare column names. Note that `x1:x2`-style colon syntax for a *reduced* (interaction-only) term is **not** recognised by this parser — `:` is not a valid Python binary operator between identifiers, so `ast.parse` rejects it, and a formula string using `x1:x2` raises a `ValueError` rather than producing a reduced interaction term. - `0`, `-1`, or `+0` on their own suppresses the intercept (`Formula.intercept` becomes `False`). `+1` is accepted as a no-op, since the intercept is already included by default. Parameters ---------- formula: A model formula string such as `"y ~ s(x1) + s(x2, bs='cr') + x3"`. Returns ------- Formula Structured representation of the formula. Raises ------ ValueError If the formula string is malformed or contains unsupported syntax. Notes ----- This is not a full formula-parsing DSL like R's `formula()` — there is no `.` shorthand for "all other columns", no `poly()` or other in-formula transformations, and no arbitrary nesting beyond what is listed above. The supported grammar is exactly what can be expressed as a restricted `ast.parse(rhs, mode="eval")` walk over names, calls, and `+`/`-`/`*` binary operators; any construct outside that grammar raises a `ValueError` with a message describing what was found and what is supported. Examples -------- ```{python} from whittaker.formula.parser import parse formula = parse("y ~ s(x1) + s(x2, bs='cr', k=15) + x3") formula.response ``` ```{python} [(term.__class__.__name__, term) for term in formula.terms] ``` ## Response families Distributions and link functions for the response variable. Each family specifies a conditional distribution and a link function relating the linear predictor to the conditional mean. Family() Abstract family defining the response distribution and link function. A `Family` encapsulates everything the P-IRLS fitting loop needs to know about the conditional distribution of the response `y` given the linear predictor `eta`. Every GLM and GAM family in Whittaker belongs to the exponential dispersion family, and is fully characterized by three ingredients: 1. The **link function** `g`, relating the mean `mu` to the linear predictor `eta`: `eta = g(mu)`, along with its inverse and derivative. 2. The **variance function** `V(mu)`, relating the variance of the response to its mean: `Var(Y) = phi * V(mu)`, where `phi` is the dispersion (scale) parameter. 3. The **deviance** and **log-likelihood**, which quantify goodness of fit and are used by `GAM.fit()` for smoothing parameter selection (GCV/REML) and by `GAM.summary()` for reporting. Subclasses must implement all abstract methods (`link`, `link_inverse`, `link_derivative`, `variance`, `deviance`, `log_likelihood`, `simulate`). The link function and its inverse/derivative are used by the P-IRLS algorithm to form pseudo-data (the working response `z`) and working weights `W` at each iteration. Families whose loss does not fit the standard GLM deviance framework (e.g. `QuantileFamily`, `CoxPH`, `OrderedCategorical`, `Multinomial`) may instead override `irls_update` to supply `z` and `W` directly. Whittaker ships with the following concrete families: - `Gaussian` — identity link, constant variance; the default family for continuous, unbounded responses. - `Poisson` — log link, `V(mu) = mu`; for count data. - `Binomial` — logit link, `V(mu) = mu(1-mu)`; for binary or proportion responses. - `Gamma` — log link, `V(mu) = mu^2`; for positive, right-skewed continuous data. - `NegativeBinomial` — log link, `V(mu) = mu + mu^2/theta`; for overdispersed counts. - `Beta` — logit link; for proportions strictly between 0 and 1. - `Tweedie` / `TweedieEstimated` (via `tw()`) — log link, `V(mu) = mu^p`; for compound Poisson-Gamma data with a point mass at zero (e.g. insurance claims). - `InverseGaussian` — log link, `V(mu) = mu^3`; for positive, heavy-tailed continuous data. - `CoxPH` — proportional hazards partial likelihood; for survival/time-to-event data. - `OrderedCategorical` — cumulative logit (proportional odds); for ordinal responses. - `Multinomial` — baseline-category logit; for unordered categorical responses. - `QuantileFamily` — Extended Log-F (ELF) smooth pinball loss; for quantile regression. For distributional regression, where more than one parameter of the response distribution (e.g. both the mean and the scale) is modeled by its own smooth predictor, see `GAMLSSFamily` and its concrete subclasses (`GaussianLS`, `GammaLS`, `BetaLS`, `ZeroInflatedPoisson`, `ZeroInflatedNegativeBinomial`). Examples -------- Families are passed to `GAM` via the `family` argument; they are rarely instantiated by users beyond that. ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 200 x = np.linspace(0, 2 * np.pi, n) y = rng.poisson(np.exp(0.5 * np.sin(x))) data = {"x": x, "y": y} # Any concrete Family subclass can be passed to GAM model = wk.GAM("y ~ s(x)", family=wk.Poisson()) model.fit(data, method="REML") print(model.summary()) ``` Gaussian() Gaussian (Normal) family with identity link. The Gaussian family models a continuous, unbounded response with constant variance. It is the default family in Whittaker and corresponds to classical (penalized) least-squares regression: with the identity link, P-IRLS converges in a single step since the working response and weights do not depend on the current fit. Use it whenever the response is real-valued, approximately symmetric, and its spread does not depend systematically on its mean — for example, physical measurements, log-transformed sizes, or residual-like quantities. If the variance grows with the mean, or the response is a count, proportion, or strictly positive quantity, consider `Poisson`, `Binomial`, `Gamma`, or another family instead. Notes ----- The canonical (and only supported) link is the identity function: $$ g(\mu) = \mu $$ so the linear predictor `eta` is directly on the response scale and no back-transformation is needed for predictions. The variance function is constant in the mean, $$ V(\mu) = 1, \qquad \operatorname{Var}(Y) = \phi \, V(\mu) = \sigma^2, $$ which is what makes the Gaussian family the special case in which ordinary least squares and maximum-likelihood estimation coincide. The deviance is the residual sum of squares: $$ D(y, \hat\mu) = \sum_i (y_i - \hat\mu_i)^2 . $$ Examples -------- Fit a GAM with a smooth term to noisy sine-wave data using the (default) Gaussian family: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 200 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.Gaussian()) model.fit(data, method="REML") print(model.summary()) ``` Poisson() Poisson family with log (canonical) link. The Poisson family models the response as non-negative integer counts, such as the number of events observed in a fixed interval of time, space, or exposure. It is the standard choice for count data when the variance of the counts is approximately equal to their mean. The canonical log link guarantees positive fitted values on the response scale and gives the linear predictor a multiplicative interpretation: a one-unit increase in a covariate multiplies the expected count by `exp(coefficient)`. Notes ----- The canonical link is the natural logarithm: $$ g(\mu) = \log(\mu) $$ The variance function is $V(\mu) = \mu$, so the variance equals the mean. If the observed variance substantially exceeds the mean (overdispersion), consider `NegativeBinomial` or `Tweedie` instead. The deviance is $$ D(y, \hat\mu) = 2 \sum_i \left[ y_i \log\!\left(\frac{y_i}{\hat\mu_i}\right) - (y_i - \hat\mu_i) \right] . $$ Examples -------- Fit a GAM to simulated count data with a smooth, log-linear trend: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 200 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(0.5 * np.sin(x)) y = rng.poisson(mu) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.Poisson()) model.fit(data, method="REML") print(model.summary()) ``` Binomial() Binomial family with logit (canonical) link. The Binomial family models a response that is either binary (`0`/`1`) or a proportion in `[0, 1]` (e.g. the fraction of successes out of a known number of trials). The logit link maps the unit interval to the whole real line, so the linear predictor is unconstrained while the fitted mean is always a valid probability. Use it for classification-style GAMs, for aggregated success/trial proportions, or wherever the outcome represents the probability of an event. The logit link additionally gives coefficients a log-odds interpretation: a one-unit increase in a covariate changes the log-odds of success by `coefficient`, and multiplies the odds by `exp(coefficient)`. Notes ----- The canonical link is the logit function: $$ g(\mu) = \log\!\left(\frac{\mu}{1-\mu}\right) $$ with inverse the logistic sigmoid $\mu = g^{-1}(\eta) = 1 / (1 + e^{-\eta})$. The variance function is $$ V(\mu) = \mu(1-\mu), $$ which is largest near $\mu = 0.5$ and shrinks toward zero as $\mu$ approaches either boundary. The deviance is $$ D(y, \hat\mu) = 2 \sum_i \left[ y_i \log\!\left(\frac{y_i}{\hat\mu_i}\right) + (1 - y_i) \log\!\left(\frac{1 - y_i}{1 - \hat\mu_i}\right) \right] . $$ Examples -------- Fit a GAM to a binary outcome with a smooth, nonlinear log-odds relationship: ```{python} import numpy as np import whittaker as wk from scipy.special import expit rng = np.random.default_rng(0) n = 300 x = np.linspace(-3, 3, n) p = expit(np.sin(x)) y = rng.binomial(1, p) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.Binomial()) model.fit(data, method="REML") print(model.summary()) ``` Gamma() Gamma family with log link. The Gamma family models strictly positive, continuous, right-skewed responses — for example, insurance claim sizes, waiting times, rainfall amounts, or other quantities that are bounded below by zero and become more variable as their mean grows. Although the canonical link for the Gamma distribution is the inverse, Whittaker uses the log link by default, since it guarantees positive fitted values and is generally easier to interpret (coefficients act multiplicatively on the response, as with `Poisson`). Use `Gamma` when the response is positive and continuous and its coefficient of variation is roughly constant across the range of fitted values; if instead the variance grows linearly with the mean, `Poisson` or `Tweedie` with `1 < p < 2` may fit better. Notes ----- The (non-canonical, but default) link is the natural logarithm: $$ g(\mu) = \log(\mu) $$ The variance function grows with the square of the mean: $$ V(\mu) = \mu^2, \qquad \operatorname{Var}(Y) = \phi \, \mu^2, $$ so the coefficient of variation $\sqrt{\operatorname{Var}(Y)} / \mu = \sqrt{\phi}$ is constant. The deviance is $$ D(y, \hat\mu) = 2 \sum_i \left[ -\log\!\left(\frac{y_i}{\hat\mu_i}\right) + \frac{y_i - \hat\mu_i}{\hat\mu_i} \right] . $$ Examples -------- Fit a GAM to positive, right-skewed data with a smooth trend: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 200 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(0.5 + 0.4 * np.sin(x)) shape = 4.0 y = rng.gamma(shape, mu / shape) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.Gamma()) model.fit(data, method="REML") print(model.summary()) ``` NegativeBinomial(theta: 'float' = 1.0) -> 'None' Negative Binomial family with log link (NB2 parameterization). The Negative Binomial family models count data that is overdispersed relative to the Poisson distribution, i.e. where the observed variance exceeds the mean. This commonly arises when counts are driven by unobserved heterogeneity across observations (e.g. some individuals or locations are systematically more prone to events than others). Whittaker uses the NB2 parameterization, where the variance function is `V(mu) = mu + mu^2/theta` and `theta` controls the degree of overdispersion: as `theta -> infinity` the distribution converges to `Poisson`, while smaller `theta` implies heavier overdispersion. The canonical log link is used, giving the same multiplicative interpretation of coefficients as `Poisson`. Parameters ---------- theta : float, default=1.0 Overdispersion (size) parameter, must be positive. Smaller values of `theta` imply greater overdispersion; larger values make the distribution approach `Poisson`. The value supplied at construction is used as the starting point and is refined during fitting via an outer iteration around P-IRLS unless explicitly held fixed by the caller. Notes ----- The canonical link is the natural logarithm: $$ g(\mu) = \log(\mu) $$ The variance function is $$ V(\mu) = \mu + \frac{\mu^2}{\theta}, $$ so the variance always exceeds the mean by the extra term $\mu^2/\theta$. The deviance is $$ D(y, \hat\mu) = 2 \sum_i \left[ y_i \log\!\left(\frac{y_i}{\hat\mu_i}\right) - (y_i + \theta) \log\!\left(\frac{y_i + \theta}{\hat\mu_i + \theta}\right) \right] . $$ Examples -------- Fit a GAM to overdispersed count data: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 200 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(0.5 * np.sin(x)) theta = 3.0 y = rng.negative_binomial(theta, theta / (theta + mu)) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.NegativeBinomial(theta=theta)) model.fit(data, method="REML") print(model.summary()) ``` Beta(phi: 'float | None' = None) -> 'None' Beta regression family with logit link. The Beta family models a continuous response strictly between 0 and 1 — for example rates, fractions, or proportions that are not simply the ratio of successes to a known number of trials (in which case `Binomial` is usually more appropriate). It parameterizes the Beta distribution by its mean `mu` and a precision parameter `phi`, so that larger `phi` yields a tighter distribution around `mu` for fixed mean, analogous to the role `theta` plays in `NegativeBinomial`. The logit link keeps the fitted mean within `(0, 1)` and gives coefficients the same log-odds interpretation as in `Binomial` regression. Parameters ---------- phi : float or None, default=None Fixed precision parameter, must be positive if provided. If `None` (the default), precision is treated as unknown and estimated from the data via the scale parameter (`phi = 1 / scale`); in that case `scale_known` is `False`. Passing a fixed `phi` is useful when the precision is known a priori or should not be re-estimated. Notes ----- The link is the logit function: $$ g(\mu) = \log\!\left(\frac{\mu}{1-\mu}\right) $$ The variance function is $$ V(\mu) = \frac{\mu(1-\mu)}{1+\phi}, $$ so larger `phi` (higher precision) shrinks the variance for a given mean. The deviance is twice the difference between the saturated and fitted log-likelihoods, $$ D(y, \hat\mu) = 2 \sum_i \left[ \ell(y_i; y_i) - \ell(y_i; \hat\mu_i) \right], $$ where $\ell$ is the Beta log-density parameterized by $(a, b) = (\mu \phi, (1-\mu)\phi)$. Examples -------- Fit a GAM to a proportion response with a smooth mean trend: ```{python} import numpy as np import whittaker as wk from scipy.special import expit rng = np.random.default_rng(0) n = 200 x = np.linspace(0, 2 * np.pi, n) mu = expit(np.sin(x)) phi = 20.0 y = rng.beta(mu * phi, (1 - mu) * phi) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.Beta()) model.fit(data, method="REML") print(model.summary()) ``` Tweedie(p: 'float' = 1.5) -> 'None' Tweedie family with log link. The Tweedie family is an exponential dispersion model whose variance function is a power of the mean, `V(mu) = mu^p`. It is most widely used for `1 < p < 2`, the compound Poisson-Gamma case: a distribution with a point mass at zero and a continuous, right-skewed density on the positive reals. This shape is a natural fit for aggregate insurance claims (many policyholders have zero claims, and the rest have positive, Gamma-like claim amounts), precipitation totals, and biomass or catch data with structural zeros. Whittaker also supports `p > 2` for purely positive, heavy-tailed continuous data (including the inverse Gaussian case at `p = 3`; see `InverseGaussian`). The Poisson (`p = 1`) and Gamma (`p = 2`) boundary cases are excluded here — use `Poisson` or `Gamma` directly for exact likelihood computations at those values. Parameters ---------- p : float, default=1.5 Variance power. Must satisfy `1 < p < 2` (compound Poisson-Gamma, the typical choice for insurance-type data with zeros) or `p > 2` (positive continuous, heavy-tailed data). Values of exactly `1` or `2` are rejected because they correspond to `Poisson` and `Gamma`, which have simpler, exact deviance and likelihood formulas. If `p` is unknown, use `tw()` to estimate it from the data instead of fixing it here. Notes ----- The link is the natural logarithm: $$ g(\mu) = \log(\mu) $$ The variance function is a power of the mean: $$ V(\mu) = \mu^{p}, $$ which interpolates between `Poisson`-like behavior (`p` near 1) and `Gamma`-like behavior (`p` near 2), or heavier-tailed behavior for `p > 2`. The unit deviance is $$ d(y, \hat\mu) = 2 \left[ \frac{y^{2-p}}{(1-p)(2-p)} - \frac{y\,\hat\mu^{1-p}}{1-p} + \frac{\hat\mu^{2-p}}{2-p} \right], $$ summed over observations to give the total deviance. Because the Tweedie density has no closed form for `1 < p < 2`, the log-likelihood is evaluated using the Dunn & Smyth (2005) saddlepoint approximation. Examples -------- Fit a GAM to compound Poisson-Gamma data with a point mass at zero: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(1.0 + 0.5 * np.sin(x)) p = 1.5 scale = 1.0 lam = mu ** (2 - p) / (scale * (2 - p)) alpha = (2 - p) / (p - 1) gamma_scale = scale * (p - 1) * mu ** (p - 1) n_claims = rng.poisson(lam) y = np.array( [rng.gamma(alpha, gamma_scale[i], size=n_claims[i]).sum() for i in range(n)] ) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.Tweedie(p=1.5)) model.fit(data, method="REML") print(model.summary()) ``` TweedieEstimated(p_range: 'tuple[float, float]' = (1.01, 1.99), n_grid: 'int' = 20) -> 'None' Tweedie family with variance power estimated by profile likelihood. `TweedieEstimated` behaves exactly like `Tweedie` (log link, `V(mu) = mu^p`, compound Poisson-Gamma density for `1 < p < 2`) except that the variance power `p` is not fixed at construction time. Instead, `GAM.fit()` performs a profile-likelihood grid search: the model is refit at each candidate `p` in `p_range`, and the value minimizing AIC is retained. This is useful when the appropriate degree of "Poisson-ness" versus "Gamma-ness" in claims, rainfall, or other zero-inflated positive data is not known in advance. Users typically construct this family via the `tw()` convenience function rather than instantiating `TweedieEstimated` directly. Parameters ---------- p_range : tuple of float, default=(1.01, 1.99) `(p_min, p_max)` range to search over. Both endpoints should lie strictly within `(1, 2)` for the compound Poisson-Gamma case (the typical use case for insurance-style data with structural zeros). n_grid : int, default=20 Number of candidate `p` values in the initial grid search over `p_range`. A finer grid gives a more precise estimate of `p` at the cost of additional model fits. Notes ----- Once fitted, the estimated value of `p` is available via the inherited `p` property, and `p_estimated` reports whether estimation has completed. The link, variance function, and deviance are identical to those of `Tweedie`: $$ g(\mu) = \log(\mu), \qquad V(\mu) = \mu^{p}. $$ See `Tweedie` for the full deviance and log-likelihood formulas. Examples -------- Fit a GAM letting Whittaker choose the Tweedie variance power automatically: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(1.0 + 0.5 * np.sin(x)) y = np.array([rng.gamma(2.0, m / 2.0) if rng.random() > 0.3 else 0.0 for m in mu]) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.tw()) model.fit(data, method="REML") print(model.family) print(model.summary()) ``` tw(p_range: 'tuple[float, float]' = (1.01, 1.99), n_grid: 'int' = 20) -> 'TweedieEstimated' Create a Tweedie family with estimated variance power. Convenience constructor mirroring the `tw()` function familiar from `mgcv`. The variance power `p` of the Tweedie distribution (see `Tweedie`) is selected automatically by profile likelihood during model fitting rather than fixed by the user. The model is fitted at `n_grid` candidate values of `p` spaced across `p_range`, and the value minimizing AIC is chosen as the final family. Parameters ---------- p_range : tuple of float, default=(1.01, 1.99) `(p_min, p_max)` range to search. Must satisfy `1 < p_min` and `p_max < 2` (or both `> 2` for the positive-continuous case). Defaults to `(1.01, 1.99)`, which covers the compound Poisson-Gamma case used for most zero-inflated positive data. n_grid : int, default=20 Number of candidate `p` values in the grid search. Defaults to `20`. Returns ------- TweedieEstimated A Tweedie family, with variance function $V(\mu) = \mu^{p}$ and log link $g(\mu) = \log(\mu)$, whose power `p` will be estimated by profile likelihood the next time the returned family is passed to `GAM.fit()`. Examples -------- ```{python} import whittaker as wk model = wk.GAM("y ~ s(x)", family=wk.tw(p_range=(1.05, 1.95), n_grid=15)) ``` InverseGaussian() Inverse Gaussian family with log link. The Inverse Gaussian family models strictly positive, continuous responses whose variance grows even faster than in the Gamma case, producing heavier right tails. It arises naturally as a first-passage-time distribution (e.g. the time for a diffusion process to reach a threshold) and is a common choice for lifetime, duration, and other highly skewed positive data. `InverseGaussian` is the Tweedie special case with variance power `p = 3` (compare `Tweedie` and `tw()`), implemented directly here for exact deviance and log-likelihood computations rather than the saddlepoint approximation used by the general `Tweedie` family. The log link is used for the same reasons as in `Gamma`: it keeps fitted values positive and gives coefficients a multiplicative interpretation. Notes ----- The link is the natural logarithm: $$ g(\mu) = \log(\mu) $$ The variance function grows with the cube of the mean: $$ V(\mu) = \mu^{3}, $$ making this family appropriate when large means are associated with disproportionately large variability. The deviance is $$ D(y, \hat\mu) = \sum_i \frac{(y_i - \hat\mu_i)^2}{\hat\mu_i^2\, y_i} . $$ Examples -------- Fit a GAM to heavy-tailed positive data with a smooth trend: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 200 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(1.0 + 0.4 * np.sin(x)) scale = 0.5 lam = mu / scale y = rng.wald(mu, lam) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.InverseGaussian()) model.fit(data, method="REML") print(model.summary()) ``` CoxPH(status: 'str' = 'event', ties: 'str' = 'breslow') -> 'None' Cox proportional hazards family for survival analysis. `CoxPH` fits a semiparametric proportional hazards model, allowing smooth (via `s()`) and linear covariate effects on the log hazard while leaving the baseline hazard `h0(t)` unspecified. Use this family whenever the response is a time-to-event outcome that may be right-censored — for example, time to failure, time to churn, or time to death/relapse in survival data — and the goal is to model how covariates shift the instantaneous risk of the event over time. Rather than a per-observation deviance and log-likelihood in the usual GLM sense, `CoxPH` maximizes the Cox partial likelihood via a custom `irls_update`, so the "response" `y` passed to `GAM.fit()` is the observed survival/censoring time, and the event indicator is supplied separately through `set_data()` (populated automatically by `GAM.fit()` from the column named by `status`). Parameters ---------- status : str, default="event" Name of the column in the data dict containing the event indicator (`1` = event observed, `0` = right-censored). This column is looked up automatically from the data passed to `GAM.fit()`. ties : str, default="breslow" Tie-handling method for the partial likelihood when multiple observations share the same event time: `"breslow"` (default, simpler and faster) or `"efron"` (more accurate when ties are frequent). Notes ----- The hazard is modeled multiplicatively as $$ h(t \mid x) = h_0(t)\, e^{\eta(x)}, \qquad \eta(x) = X\beta, $$ where `h0(t)` is an unspecified baseline hazard and `eta` is the (possibly smooth) linear predictor, so `link` and `link_inverse` are both the identity. There is no closed-form variance function or unit deviance in the usual GLM sense; instead, model fitting maximizes the Cox partial log-likelihood, $$ \ell(\beta) = \sum_{i:\, \delta_i = 1} \left[ \eta_i - \log\!\left( \sum_{j \in R(t_i)} e^{\eta_j} \right) \right], $$ where $\delta_i$ is the event indicator and $R(t_i)$ is the risk set at time $t_i$ (those still under observation just before $t_i$). The overall "deviance" reported by `GAM.summary()` is $-2\ell(\beta)$. After fitting, `baseline_hazard()` and `survival_function()` expose the Breslow estimate of the cumulative baseline hazard and the implied survival curve. Examples -------- Fit a Cox proportional hazards GAM with a smooth effect of age on the hazard: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 300 age = rng.uniform(40, 80, n) risk = np.exp(0.03 * (age - 60)) time = rng.exponential(1.0 / risk) censor_time = rng.exponential(2.0, n) observed_time = np.minimum(time, censor_time) event = (time <= censor_time).astype(float) data = {"time": observed_time, "age": age, "event": event} model = wk.GAM("time ~ s(age)", family=wk.CoxPH(status="event")) model.fit(data, method="REML") print(model.summary()) ``` OrderedCategorical(n_categories: 'int') -> 'None' Ordered categorical (proportional odds / cumulative logit) family. `OrderedCategorical` models an ordinal response — one with a small number of categories that have a natural order but no meaningful numeric spacing, such as a Likert scale ("disagree" / "neutral" / "agree") or a severity grade — using the proportional-odds cumulative logit model. A single set of `K - 1` ordered cutpoints (thresholds) `alpha_1 < alpha_2 < ... < alpha_{K-1}` is estimated jointly with the smooth/linear predictor `eta`, and each cutpoint defines a binary split between "category `k` or below" versus "above category `k`". Because the same `eta` (and hence the same covariate effects) is shared across all thresholds, covariate effects are assumed to shift the log-odds of being in a higher category by the same amount at every threshold — the proportional-odds assumption. Use this family for ordinal outcomes with three or more ordered levels; for a two-level (binary) response, use `Binomial` instead, and for unordered categorical responses, use `Multinomial`. Parameters ---------- n_categories : int Number of ordered response categories `K` (must be `>= 2`). Responses passed to `GAM.fit()` should be integer-coded `1, 2, ..., K`. Notes ----- The response, without an intercept in the design matrix (since the cutpoints absorb it), is modeled through cumulative probabilities: $$ P(Y \le k \mid \eta) = \operatorname{expit}(\alpha_k - \eta), \qquad k = 1, \dots, K-1, $$ which is equivalent to a logit link on each cumulative probability, $g(P(Y \le k)) = \alpha_k - \eta$. Category probabilities follow by differencing: $$ P(Y = 1) = \operatorname{expit}(\alpha_1 - \eta), \qquad P(Y = K) = 1 - \operatorname{expit}(\alpha_{K-1} - \eta), $$ and for interior categories $1 < k < K$, $$ P(Y = k) = \operatorname{expit}(\alpha_k - \eta) - \operatorname{expit}(\alpha_{k-1} - \eta). $$ Because this loss does not fit the standard GLM deviance framework, `link` and `link_inverse` are the identity on `eta`, and fitting instead uses a custom `irls_update` together with an inner maximum-likelihood step (`_update_cutpoints`) that re-estimates the cutpoints `alpha` at each P-IRLS iteration. The deviance reported is $-2$ times the multinomial log-likelihood of the observed categories under the fitted probabilities. Examples -------- Fit a GAM to a four-level ordinal response with a smooth covariate effect: ```{python} import numpy as np import whittaker as wk from scipy.special import expit rng = np.random.default_rng(0) n = 300 x = np.linspace(-3, 3, n) eta = np.sin(x) cutpoints = np.array([-1.5, 0.0, 1.5]) y = np.empty(n) for i in range(n): probs = np.diff( np.concatenate([[0.0], expit(cutpoints - eta[i]), [1.0]]) ) y[i] = rng.choice([1, 2, 3, 4], p=probs) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.OrderedCategorical(n_categories=4)) model.fit(data, method="REML") print(model.summary()) ``` Multinomial(n_categories: 'int') -> 'None' Multinomial logistic family for unordered categorical responses. `Multinomial` models a categorical response with `K` unordered levels — for example, a choice among several unranked options — using a baseline-category logit model. Unlike `OrderedCategorical`, no assumption is made about the ordering of categories or a shared direction of covariate effects: each non-reference category `k` gets its own intercept `alpha_k` and its own loading coefficient `beta_k` that rescales the shared linear predictor `eta`, so different categories can respond differently (even in sign) to the same covariate effect. The final category `K` is fixed as the reference, with `alpha_K = 0` and `beta_K = 0`. Use this family when the response is nominal (has no natural order) with more than two levels; for binary outcomes use `Binomial`, and for ordinal outcomes use `OrderedCategorical`. Parameters ---------- n_categories : int Number of response categories `K` (must be `>= 2`). Responses passed to `GAM.fit()` should be integer-coded `1, 2, ..., K`, with category `K` as the reference. Notes ----- Category probabilities are obtained from a softmax over per-category logits built from the shared linear predictor `eta`: $$ P(Y = k \mid \eta) = \frac{\exp(\alpha_k + \beta_k \eta)} {\sum_{j=1}^{K} \exp(\alpha_j + \beta_j \eta)}, \qquad \alpha_K = \beta_K = 0. $$ As with `OrderedCategorical`, this loss does not fit the standard GLM deviance framework: `link` and `link_inverse` are the identity on `eta`, and a custom `irls_update` drives P-IRLS while an inner maximum-likelihood step (`_update_params`) re-estimates the per-category intercepts `alpha` and loadings `beta` at each iteration. The reported deviance is $-2$ times the multinomial log-likelihood of the observed categories under the fitted probabilities, $$ D(y, \hat P) = -2 \sum_{i} \log \hat P(Y_i = y_i \mid \eta_i). $$ Examples -------- Fit a GAM to a three-level unordered categorical response: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 300 x = np.linspace(-3, 3, n) eta = np.sin(x) alphas = np.array([0.0, 0.5]) betas = np.array([1.0, -1.5]) logits = np.column_stack( [alphas[0] + betas[0] * eta, alphas[1] + betas[1] * eta, np.zeros(n)] ) probs = np.exp(logits) / np.exp(logits).sum(axis=1, keepdims=True) y = np.array([rng.choice([1, 2, 3], p=probs[i]) for i in range(n)], dtype=float) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=wk.Multinomial(n_categories=3)) model.fit(data, method="REML") print(model.summary()) ``` ## Distributional families (GAMLSS) Location-scale-shape families for distributional regression, where multiple distribution parameters are modeled as smooth functions. GAMLSSFamily() Abstract base class for GAMLSS distributional families. Ordinary `Family` subclasses (used by `GAM`) model only the mean `mu` of the response as a function of covariates, treating any other distributional parameters (e.g. the variance or dispersion) as constant across observations. A `GAMLSSFamily`, used by `GAMLSS` instead of `GAM`, generalizes the single-parameter `Family` abstraction to the location-scale-shape setting: instead of one response parameter with a mean link, it defines a full response distribution with `K` named parameters $\theta_1, \ldots, \theta_K$ (e.g. location `mu` and scale `sigma`), each with its own link function $g_k$ and its own additive predictor $\eta_k = g_k(\theta_k)$. This is useful whenever more than the mean of the response changes systematically with covariates — for example, when the spread (heteroscedasticity), skew, or zero-inflation probability also varies across the range of the predictors. `GAMLSS` fits every parameter's additive predictor jointly by alternating penalized IRLS updates across parameters (the RS algorithm), which relies on each family supplying the per-parameter score `dl_dtheta`, (expected) Fisher information `d2l_dtheta2`, link/inverse-link/link-derivative, log-likelihood, initial values, and a `simulate` method. Subclasses must implement all of the abstract methods below. Whittaker ships with the following concrete GAMLSS families: - `GaussianLS` — location-scale Gaussian: identity link for the mean, log link for the standard deviation. - `GammaLS` — location-scale Gamma: log link for both the mean and the coefficient of variation. - `BetaLS` — mean-precision Beta: logit link for the mean, log link for the precision. - `ZeroInflatedPoisson` — Poisson mean plus a zero-inflation probability, for count data with excess zeros. - `ZeroInflatedNegativeBinomial` — overdispersed counts with excess zeros, combining `NegativeBinomial`-style overdispersion with zero-inflation. Examples -------- GAMLSS families are passed to `GAMLSS`, and each distributional parameter gets its own formula: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = np.sin(x) sigma = 0.2 + 0.3 * np.abs(np.cos(x)) y = rng.normal(mu, sigma) data = {"x": x, "y": y} # Model both the mean and the standard deviation as smooth functions of x model = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "sigma": "y ~ s(x)"}, family=wk.GaussianLS(), ) model.fit(data) print(model.summary()) ``` GaussianLS() Gaussian location-scale family for GAMLSS. `GaussianLS` extends the plain `Gaussian` family to allow both the mean `mu` and the standard deviation `sigma` to vary smoothly with covariates, rather than assuming constant variance. Use it when a continuous, approximately symmetric response shows heteroscedasticity — for example, when the spread of measurements grows or shrinks over the range of a predictor — and you want the model to capture that varying spread rather than average it away. Each parameter has its own additive predictor and its own link function: `mu` uses the identity link (as in `Gaussian`), and `sigma` uses the log link, which keeps the fitted standard deviation positive. Notes ----- The two parameters use distinct link functions: $$ g_{\mu}(\mu) = \mu \qquad \text{(identity)}, \qquad g_{\sigma}(\sigma) = \log(\sigma). $$ The response density is the ordinary Gaussian density evaluated at the fitted, observation- specific `mu` and `sigma`: $$ f(y \mid \mu, \sigma) = \frac{1}{\sigma\sqrt{2\pi}} \exp\!\left(-\frac{(y-\mu)^2}{2\sigma^2}\right), $$ so the log-likelihood contribution for a single observation is $\ell_i = -\log\sigma_i - \tfrac{1}{2}\log(2\pi) - \tfrac{1}{2}\left(\frac{y_i - \mu_i}{\sigma_i}\right)^2$. Unlike `Gaussian`, there is no separate scale parameter to estimate: `sigma` itself is the quantity being modeled by its own smooth predictor. Examples -------- Fit a GAMLSS with a smoothly varying mean and standard deviation: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = np.sin(x) sigma = 0.2 + 0.3 * np.abs(np.cos(x)) y = rng.normal(mu, sigma) data = {"x": x, "y": y} model = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "sigma": "y ~ s(x)"}, family=wk.GaussianLS(), ) model.fit(data) print(model.summary()) ``` GammaLS() Gamma location-scale family for GAMLSS. `GammaLS` extends the plain `Gamma` family by letting both the mean `mu` and the coefficient of variation `sigma` vary smoothly with covariates, instead of assuming a fixed shape parameter. Use it for strictly positive, right-skewed responses where not only the typical magnitude but also the relative spread (coefficient of variation) changes systematically across the range of the predictors — for example, cost or duration data whose relative volatility grows with the covariates rather than staying proportional to `mu` alone. Both parameters use the log link, keeping `mu > 0` and `sigma > 0`. Notes ----- `GammaLS` parameterizes the Gamma distribution by its mean `mu > 0` and its coefficient of variation `sigma > 0`, where `sigma = 1 / sqrt(shape)` and `shape = alpha = 1 / sigma^2`. Both parameters use the log link: $$ g_{\mu}(\mu) = \log(\mu), \qquad g_{\sigma}(\sigma) = \log(\sigma). $$ The response density is the Gamma density with shape `alpha = 1/sigma^2` and rate `alpha/mu`: $$ f(y \mid \mu, \sigma) = \frac{(\alpha/\mu)^{\alpha}}{\Gamma(\alpha)}\, y^{\alpha - 1} \exp\!\left(-\frac{\alpha y}{\mu}\right), \qquad \alpha = \frac{1}{\sigma^{2}}. $$ Because `sigma` is the coefficient of variation, $\operatorname{Var}(Y) = \sigma^2 \mu^2$, so this is a direct generalization of `Gamma`'s variance function `V(mu) = mu^2` in which the proportionality constant `sigma^2` is itself allowed to depend on covariates. Examples -------- Fit a GAMLSS where both the mean and relative spread of a positive response vary smoothly: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(1.0 + 0.4 * np.sin(x)) sigma = 0.2 + 0.15 * np.abs(np.cos(x)) shape = 1.0 / sigma**2 y = rng.gamma(shape, mu / shape) data = {"x": x, "y": y} model = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "sigma": "y ~ s(x)"}, family=wk.GammaLS(), ) model.fit(data) print(model.summary()) ``` BetaLS() Beta family for GAMLSS with mean-precision parameterisation. `BetaLS` extends the plain `Beta` family by letting both the mean `mu` and the precision `phi` vary smoothly with covariates, rather than treating precision as a single estimated constant. Use it for responses strictly between 0 and 1 (rates, proportions, fractions) where not only the typical level but also how tightly the response clusters around that level changes across the range of the predictors — for example, a proportion that becomes more variable in some regions of the covariate space and more tightly concentrated in others. The mean uses the logit link (as in `Beta`) and the precision uses the log link, keeping `mu` in `(0, 1)` and `phi > 0`. Notes ----- The two parameters use distinct link functions: $$ g_{\mu}(\mu) = \log\!\left(\frac{\mu}{1-\mu}\right), \qquad g_{\phi}(\phi) = \log(\phi). $$ If `a = mu * phi` and `b = (1 - mu) * phi`, the response follows `y ~ Beta(a, b)` with density $$ f(y \mid \mu, \phi) = \frac{y^{a-1}(1-y)^{b-1}}{B(a, b)}, \qquad a = \mu\phi,\ \ b = (1-\mu)\phi. $$ As in `Beta`, larger `phi` concentrates the distribution more tightly around `mu` (`Var(Y) = mu(1-mu) / (1+phi)`), but here `phi` is itself modeled as a smooth function of covariates rather than a single scalar. Examples -------- Fit a GAMLSS where both the mean and precision of a proportion response vary smoothly: ```{python} import numpy as np import whittaker as wk from scipy.special import expit rng = np.random.default_rng(0) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = expit(np.sin(x)) phi = 10.0 + 15.0 * np.abs(np.cos(x)) y = rng.beta(mu * phi, (1 - mu) * phi) data = {"x": x, "y": y} model = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "phi": "y ~ s(x)"}, family=wk.BetaLS(), ) model.fit(data) print(model.summary()) ``` ZeroInflatedPoisson() Zero-inflated Poisson (ZIP) family for GAMLSS. Count data often has more zeros than a plain `Poisson` model can explain — for example, when some observations are structurally incapable of the event occurring at all (a "never-taker" always reports zero), in addition to the zeros that arise simply because the Poisson mean is low. `ZeroInflatedPoisson` models this as a mixture: with probability `pi` an observation is a structural zero, and with probability `1 - pi` it is drawn from an ordinary `Poisson(mu)` distribution (which can itself still produce a zero). Both `mu` and `pi` are modeled as smooth functions of covariates through `GAMLSS`, so the excess-zero probability and the count intensity can each vary independently across the covariate space. If overdispersion remains even among the non-structural-zero counts, use `ZeroInflatedNegativeBinomial` instead. Notes ----- Two distributional parameters are modeled, each with its own link: $$ g_{\mu}(\mu) = \log(\mu), \qquad g_{\pi}(\pi) = \log\!\left(\frac{\pi}{1-\pi}\right). $$ The probability mass function is a mixture of a point mass at zero and a Poisson distribution: $$ P(Y = 0) = \pi + (1-\pi) e^{-\mu}, \qquad P(Y = k) = (1-\pi) \frac{\mu^{k} e^{-\mu}}{k!} \quad \text{for } k > 0. $$ Examples -------- Fit a GAMLSS for count data with excess zeros: ```{python} import numpy as np import whittaker as wk from scipy.special import expit rng = np.random.default_rng(0) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(0.5 + 0.4 * np.sin(x)) pi = expit(-1.0 + 0.8 * np.cos(x)) is_structural_zero = rng.uniform(size=n) < pi counts = rng.poisson(mu) y = np.where(is_structural_zero, 0, counts).astype(float) data = {"x": x, "y": y} model = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "pi": "y ~ s(x)"}, family=wk.ZeroInflatedPoisson(), ) model.fit(data) print(model.summary()) ``` ZeroInflatedNegativeBinomial(theta: 'float' = 1.0) -> 'None' Zero-inflated negative binomial (ZINB) family for GAMLSS. `ZeroInflatedNegativeBinomial` combines the two departures from plain `Poisson` counts that are most common in practice: overdispersion (variance exceeding the mean, as in `NegativeBinomial`) and structural excess zeros (as in `ZeroInflatedPoisson`). It is appropriate for count data where, even after allowing for a mixture of structural and sampling zeros, the remaining positive counts are still more variable than a Poisson model would predict — for example, healthcare utilization counts, insurance claim frequencies, or ecological abundance data with many true absences plus overdispersed non-zero counts. `mu` (the NB mean) and `pi` (the zero-inflation probability) are modeled as smooth functions of covariates through `GAMLSS`, while the overdispersion parameter `theta` is fixed at construction rather than estimated per observation. Parameters ---------- theta : float, default=1.0 Negative binomial size (overdispersion) parameter, must be positive. Larger values mean less overdispersion (`theta -> infinity` recovers `ZeroInflatedPoisson`); smaller values mean more overdispersion among the non-structural-zero counts. Unlike `mu` and `pi`, `theta` is a single fixed value shared across all observations rather than modeled by a smooth predictor. Notes ----- Two distributional parameters are modeled, each with its own link: $$ g_{\mu}(\mu) = \log(\mu), \qquad g_{\pi}(\pi) = \log\!\left(\frac{\pi}{1-\pi}\right). $$ The probability mass function is a mixture of a point mass at zero and a Negative Binomial distribution using the mean-size parameterization (`Var(NB) = mu + mu^2/theta`): $$ P(Y = 0) = \pi + (1-\pi)\, \mathrm{NB}(0 \mid \mu, \theta), \qquad P(Y = k) = (1-\pi)\, \mathrm{NB}(k \mid \mu, \theta) \quad \text{for } k > 0, $$ where $$ \mathrm{NB}(k \mid \mu, \theta) = \binom{k+\theta-1}{k} \left(\frac{\theta}{\theta+\mu}\right)^{\theta} \left(\frac{\mu}{\theta+\mu}\right)^{k}. $$ Examples -------- Fit a GAMLSS for overdispersed count data with excess zeros: ```{python} import numpy as np import whittaker as wk from scipy.special import expit rng = np.random.default_rng(0) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(0.5 + 0.4 * np.sin(x)) pi = expit(-1.0 + 0.8 * np.cos(x)) theta = 2.0 is_structural_zero = rng.uniform(size=n) < pi counts = rng.negative_binomial(theta, theta / (theta + mu)) y = np.where(is_structural_zero, 0, counts).astype(float) data = {"x": x, "y": y} model = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "pi": "y ~ s(x)"}, family=wk.ZeroInflatedNegativeBinomial(theta=theta), ) model.fit(data) print(model.summary()) ``` ## Distributional regression The GAMLSS fitting interface for distributional regression models. GAMLSS(formulas: 'dict[str, str]', family: 'GAMLSSFamily | None' = None) -> 'None' Generalized Additive Model for Location, Scale, and Shape. A GAMLSS extends the ordinary GAM by allowing every parameter of the response distribution, not just its mean, to depend on covariates through its own smooth additive predictor. For a distribution with parameters `theta_1, ..., theta_K` (e.g. location `mu`, scale `sigma`, and possibly shape parameters `nu`, `tau`), each parameter has its own link function `g_k` and its own formula: $$g_k(\theta_k) = \eta_k = X_k \beta_k, \quad k = 1, \dots, K$$ This makes it possible to model, for example, both the mean and the variance of `y` as smooth functions of `x`, which ordinary (mean-only) GAMs cannot do. Fitting uses the RS ("Rigby and Stasinopoulos") algorithm, which cycles through the parameters, holding all but one fixed, updating it via penalized IRLS, and repeating until the penalized log-likelihood converges. Use `GAMLSS` when the assumption of a fixed dispersion (constant variance, constant shape) is implausible, such as heteroscedastic regression, or regression with distributions like the negative binomial or beta that have separate location and shape parameters. Parameters ---------- formulas: Dict mapping parameter names to formula strings. All formulas must share the same response variable. Example: `{"mu": "y ~ s(x1)", "sigma": "y ~ s(x2)"}`. The set of keys must match `family.parameter_names` exactly. family: A `GAMLSSFamily` specifying the distributional model, including the number and names of parameters, their link functions, and log-likelihood derivatives used by the RS algorithm. Defaults to `GaussianLS()` (Gaussian location-scale, i.e. `mu` and `sigma` both modeled). Notes ----- Rigby & Stasinopoulos (2005) formulate GAMLSS fitting as penalized maximum likelihood. Within each outer RS iteration, and for each parameter `theta_k` in turn, a working response and weight are formed from the score and Fisher information of the log-likelihood with respect to `theta_k`: $$z_k = \eta_k + \frac{\partial \ell / \partial \theta_k}{\partial^2 \ell / \partial \theta_k^2} \cdot g_k'(\theta_k), \qquad w_k = -\frac{\partial^2 \ell}{\partial \theta_k^2} \Big/ g_k'(\theta_k)^2$$ and a penalized weighted least squares problem is solved for `beta_k`, with all other parameters held at their current fitted values. Smoothing parameters for each parameter's smooth terms can be selected by GCV, REML, or ML at every inner iteration. The algorithm alternates over parameters until the global deviance `-2 * log_likelihood` stops improving. Examples -------- ```{python} import numpy as np from whittaker.gamlss import GAMLSS from whittaker.families.gaussian_ls import GaussianLS rng = np.random.default_rng(0) n = 500 x = rng.uniform(0, 1, n) mu = np.sin(2 * np.pi * x) sigma = np.exp(-1 + 2 * x) y = rng.normal(mu, sigma) model = GAMLSS( formulas={"mu": "y ~ s(x)", "sigma": "y ~ s(x)"}, family=GaussianLS(), ) model.fit({"x": x, "y": y}, method="REML") pred = model.predict({"x": x[:5]}) print(pred.values) ``` GAMLSSPrediction(values: 'dict[str, NDArray]', linear_predictors: 'dict[str, NDArray]', se: 'dict[str, NDArray] | None' = None) -> None Result of `GAMLSS.predict()`. Holds, for every distributional parameter in the fitted `GAMLSSFamily`, the predicted value on the response scale (`values`), the corresponding linear predictor (`linear_predictors`), and optionally the standard error of that linear predictor (`se`). Because a GAMLSS estimates several parameters at once (for example `mu` and `sigma` of a location-scale family), the results are keyed by parameter name rather than returned as a single array. Attributes ---------- values: Dict mapping parameter names to predicted values on the response scale, i.e. `theta = g^{-1}(eta)` for each parameter's link function `g`. linear_predictors: Dict mapping parameter names to predicted linear predictors `eta = X @ beta` (plus any offset). se: Dict mapping parameter names to standard errors on the linear predictor scale, or `None` if `se=False` was passed to `predict()`. ## Smooth basis types Basis constructors for smooth terms. Each basis type can be specified in a formula via `bs=` or constructed directly for advanced use. SmoothBasis() Abstract base class for all smooth basis types. Every smooth term in a GAM — a thin plate spline, a cubic regression spline, a P-spline, a Gaussian process smooth, and so on — is represented internally as a linear basis expansion `f(x) = B(x) @ beta` together with a quadratic roughness penalty `beta.T @ S @ beta`. This class fixes the contract that every basis type must satisfy so that the fitting machinery (penalized least squares, smoothing-parameter selection, prediction) can treat all basis types uniformly. Concrete subclasses differ only in how `B` and `S` are constructed, not in how they are consumed. Subclasses must implement `fit()`, `basis_matrix()`, `penalty_matrix()`, `null_space_dimension()`, and the `n_basis` property. The typical workflow is:: basis = MyBasis(k=10) basis.fit(x_train) B_train = basis.basis_matrix(x_train) # (n_train, k) B_new = basis.basis_matrix(x_new) # (n_new, k) S = basis.penalty_matrix() # (k, k) Notes ----- A penalized basis decomposes its `k` basis functions into an unpenalized *null space* of dimension `M` (typically low-order polynomials, for which the penalty contributes nothing — e.g. a straight line has zero roughness under a second-derivative penalty) and a penalized *range space* of dimension `k - M`. The total penalty for smoothing parameter `lambda` is $$ \lambda \, \boldsymbol{\beta}^\top \mathbf{S} \boldsymbol{\beta}, $$ where `S` is symmetric positive semi-definite with `null_space_dimension()` zero eigenvalues. Basis types built on this contract may additionally supply identifiability constraints (via `identifiability_constraints()`) when the raw basis is not full rank on its own — for example when several smooth terms share an intercept and must each be constrained to have mean zero over the training data. Examples -------- Any concrete subclass follows this pattern: ```{python} import numpy as np from whittaker.smooths import TPRS rng = np.random.default_rng(0) x = rng.uniform(0, 1, 100) basis = TPRS(k=10).fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` TPRS(k: 'int' = 10, m: 'int' = 2) -> 'None' Thin Plate Regression Splines (TPRS). A thin plate spline is the function that minimizes squared error subject to a penalty on total curvature, with no need to choose knot locations — it is the natural multivariate generalization of the cubic smoothing spline. The full thin plate spline has one basis function per unique data point, which is computationally impractical for anything beyond a few hundred observations, so TPRS instead constructs a low-rank approximation using the leading eigenvectors of the (nullspace-projected) thin plate spline kernel matrix (Wood 2003). Because it works for any number of covariate dimensions `d` and requires no knot placement, TPRS is a good default basis for smooth terms of one or more continuous, non-cyclic covariates, especially in more than two dimensions where tensor-product alternatives become unwieldy. Parameters ---------- k: Total number of basis functions (including the `M` null-space columns). Must satisfy `k > M`. Larger `k` allows more wiggly fits at the cost of more computation; the penalty (not `k`) ultimately controls smoothness once `lambda` is chosen. The default is `10`. m: Spline order. Controls the order of derivative penalized: `m=2` penalizes (squared) second derivatives, the classic "thin plate" bending energy. Must satisfy `2m > d` where `d` is the covariate dimension. Common choices: `m=2` for `d <= 3` (the default), `m=3` for `d` in `{4, 5}`. The default is `2`. Notes ----- The full thin plate spline basis uses the radial kernel $$ \eta_m(r) = \begin{cases} r^{2m - d} & 2m - d \text{ odd} \\ r^{2m-d} \log(r) & 2m - d \text{ even} \end{cases}, $$ evaluated at pairwise distances `r = ||x_i - x_j||` between data points, plus a polynomial null space of all monomials of total degree at most `m - 1`, which has dimension `M = C(m - 1 + d, d)`. TPRS builds the basis in two stages: 1. **Polynomial null space** (first `M` columns): the unpenalized low-degree polynomials, for which the roughness penalty is identically zero (e.g. any straight line has zero bending energy under `m=2`). 2. **Truncated spline part** (remaining `k - M` columns): the full kernel matrix `E` is projected onto the orthogonal complement of the polynomial null space and eigen-decomposed; the `k - M` eigenvectors with the largest eigenvalues give the best rank-`(k - M)` approximation to the full thin plate spline in the sense of minimizing the change in the penalty for a given basis dimension. The resulting penalty matrix is block-diagonal, $$ \mathbf{S} = \operatorname{diag}(0, \ldots, 0, \lambda_1, \ldots, \lambda_{k-M}), $$ with the `M` null-space rows/columns exactly zero and the remaining diagonal entries equal to the retained eigenvalues of the projected kernel matrix. Because the basis is derived from an eigendecomposition of a matrix that mixes all covariate scales, columns of `x` with very different scales can cause numerical issues; centering and/or standardizing each column of `x` before fitting is advisable. Examples -------- ```{python} import numpy as np from whittaker.smooths import TPRS rng = np.random.default_rng(0) x = rng.uniform(0, 1, 100) basis = TPRS(k=10).fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` CRS(k: 'int' = 10) -> 'None' Cubic Regression Splines (natural cubic splines with quantile knots). A cubic regression spline is a piecewise-cubic function that is twice continuously differentiable, with a roughness penalty based on the integrated squared second derivative (classical smoothing-spline theory). Equivalent to mgcv's `bs="cr"` basis. Unlike `TPRS`, which avoids explicit knot placement, CRS parameterizes the spline directly by its values at `k` knots placed at evenly-spaced quantiles of the training data, so it is cheaper to fit for a single covariate and gives a basis whose coefficients have a direct interpretation as function values at the knots. Choose CRS over `TPRS` when you have exactly one covariate, want knot-based interpretability, or need a classical natural-cubic-spline basis for compatibility with other software; choose `TPRS` for multivariate smooths or when knot placement is undesirable. Only univariate covariates are supported. The first two columns of the basis matrix correspond to the linear null space of the penalty (`{1, x}`); the remaining `k - 2` columns are penalized. Note that unlike TPRS, the null-space columns are **not** stored as the leading columns. The full k-column basis is used directly, with the penalty having a 2-dimensional null space. Parameters ---------- k: Number of basis functions (equivalently, the number of knots). Must be at least `3` (fewer knots cannot support a cubic spline with a nontrivial penalized part). Larger `k` places more knots and allows more local flexibility at the cost of more parameters to estimate; as with all penalized bases, the penalty (not `k`) is what ultimately controls the smoothness of the fitted curve once `lambda` is chosen. The default is `10`. Notes ----- Knots `t_0 < t_1 < \cdots < t_{k-1}` are placed at evenly-spaced quantiles of the training data `x` (falling back to an evenly-spaced grid over `[min(x), max(x)]` if quantile ties would otherwise produce duplicate knots). Basis construction follows the classical natural cubic spline parameterization by function values at the knots (de Boor; Wood, *Generalized Additive Models*, 2nd ed., §5.3.1): 1. The interior second derivatives `\mathbf{d} = (f''(t_1), \ldots, f''(t_{k-2}))^\top` are related to the coefficient vector `\boldsymbol{\beta}` (the spline values at the knots) by a tridiagonal linear system `\mathbf{R} \mathbf{d} = \mathbf{Q}^\top \boldsymbol{\beta}`, where `Q` (the `k \times (k-2)` matrix built by `_build_Q()`) and `R` (the `(k-2) \times (k-2)` symmetric positive-definite tridiagonal matrix built by `_build_R()`) are determined entirely by the knot spacings `h_l = t_{l+1} - t_l`. 2. Natural boundary conditions (`f''(t_0) = f''(t_{k-1}) = 0`) pad `\mathbf{d}` with zeros at both ends to give the full `k \times k` second-derivative operator `\mathbf{A}` such that `\mathbf{d}_{\text{full}} = \mathbf{A} \boldsymbol{\beta}`. 3. Within each knot interval the spline is the unique cubic determined by the function values and second derivatives at the two endpoints; outside `[t_0, t_{k-1}]` the natural boundary conditions force the spline to continue linearly, so CRS extrapolates linearly rather than polynomially beyond the training range. The roughness penalty is the integral of squared second derivative, `\int f''(x)^2 \, dx = \boldsymbol{\beta}^\top \mathbf{S} \boldsymbol{\beta}`, with $$ \mathbf{S} = \mathbf{Q} \mathbf{R}^{-1} \mathbf{Q}^\top, $$ a `k \times k` positive semi-definite matrix of rank `k - 2`. Its two-dimensional null space is spanned by the constant and linear functions evaluated at the knots (any straight line has zero second derivative everywhere, hence zero penalty). Because `S` is built from tridiagonal `Q` and `R`, it is well-conditioned and cheap to compute even for large `k`; the main numerical caveat is that near-duplicate knots (from heavily tied data) can make `R` ill-conditioned, which is why `fit()` falls back to an evenly-spaced knot grid when quantile knots would collide. Examples -------- ```{python} import numpy as np from whittaker.smooths import CRS x = np.linspace(0, 1, 100) basis = CRS(k=10).fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` PSpline(k: 'int' = 10, degree: 'int' = 3, m: 'int' = 2) -> 'None' P-Spline: B-spline basis with m-th order difference penalty. A P-spline (Eilers & Marx 1996) combines a rich B-spline basis on equally-spaced knots with a discrete difference penalty directly on the coefficients, rather than an integrated-derivative penalty on the fitted curve. This decouples basis richness from smoothness — you can use many more basis functions than you would dare with an unpenalized spline, because the difference penalty (together with an appropriately chosen `lambda`) does the work of controlling wiggliness. Equivalent to mgcv's `bs="ps"` basis. P-splines are a good default choice for a single continuous covariate: they are cheap to construct (no eigendecomposition or dense linear solve is needed to build the penalty), the penalty matrix is banded/sparse which keeps large-`k` fits fast, and B-splines extrapolate smoothly beyond the training range via their boundary basis functions. Unlike `CRS`, knots are equidistant rather than placed at data quantiles, so P-splines are somewhat less efficient when the data are very unevenly distributed but are simpler and faster to set up. Parameters ---------- k: Number of B-spline basis functions. Must satisfy `k >= degree + 1`. Larger `k` gives a richer basis (more, narrower B-splines) and lets the fit follow finer local structure; as with other penalized bases, wiggliness is ultimately governed by the penalty and `lambda`, not by `k` alone, so `k` can usually be set generously (e.g. `k=20-40`) without much downside. The default is `10`. degree: Polynomial degree of each B-spline piece. `degree=3` (cubic) is the conventional choice and matches most GAM software; `degree=1` gives a piecewise-linear basis useful for less smooth phenomena, `degree=0` a step-function basis. The default is `3` (cubic). m: Order of the difference penalty applied to adjacent B-spline coefficients. `m=2` (the default) penalizes second differences, which is the discrete analogue of penalizing curvature and is by far the most common choice; `m=1` penalizes changes in level (shrinks toward a constant), `m=3` penalizes changes in slope-of-slope for extra-smooth fits. Must satisfy `0 < m < k`. The default is `2` (second differences: penalizes curvature). Notes ----- The knot vector is built by `_bspline_knots()`: `degree + 1` repeated (clamped) knots at each of `x_min` and `x_max`, plus `k - degree - 1` interior knots equally spaced between them, giving `k` B-spline basis functions of the requested `degree` via `scipy`'s `BSpline` machinery (de Boor's algorithm). Because the knots are equally spaced rather than data-adaptive, the design matrix `basis_matrix(x)` can be evaluated in closed form for any `x`, including points beyond `[x_min, x_max]`, which the boundary B-splines extend smoothly. The penalty acts directly on the coefficient vector `\boldsymbol{\beta}` through the `m`-th order finite-difference matrix `\mathbf{D}_m` (shape `(k - m, k)`, built by applying `numpy.diff` `m` times to the identity): $$ \mathbf{S} = \mathbf{D}_m^\top \mathbf{D}_m, $$ a `k \times k` positive semi-definite matrix of rank `k - m`. Its `m`-dimensional null space is spanned by the discrete polynomial sequences `[1, 1, \ldots, 1]`, `[0, 1, \ldots, k-1]`, ..., up to degree `m - 1` in the coefficient index — i.e. coefficient vectors that are themselves polynomial in index have zero penalty, mirroring how polynomials of degree `< m` have zero `m`-th derivative in the continuous case. Because `S` is banded (bandwidth `m`), it is sparse and cheap to factorize even for large `k`; the main numerical caveat is that `basis_matrix()` clips evaluation points to the B-spline's knot support before calling `scipy`'s `BSpline.design_matrix`, since points exactly at or beyond the padded boundary can otherwise trigger an out-of-support error. Examples -------- ```{python} import numpy as np from whittaker.smooths import PSpline x = np.linspace(0, 1, 100) basis = PSpline(k=10).fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` CyclicCRS(k: 'int' = 10) -> 'None' Cyclic Cubic Regression Spline (periodic natural cubic spline). A cyclic (periodic) cubic regression spline is the natural extension of `CRS` to covariates that wrap around, such as time of day, day of year, or wind direction, where the value and slope at the end of the range must match the value and slope at the start. Equivalent to mgcv's `bs="cc"` basis. The spline is periodic over the range of the training data: `f(x_min) = f(x_max)`, `f'(x_min) = f'(x_max)`, and `f''(x_min) = f''(x_max)`. Values outside the training range are mapped into the periodic domain via modular arithmetic, so predictions remain well-defined for any `x`. Choose `CyclicCRS` (rather than plain `CRS`) whenever the covariate is inherently circular; using a non-cyclic basis on such data would otherwise produce an artificial discontinuity at the wrap-around point. The cyclic constraint absorbs one degree of freedom, so k knots produce k-1 basis functions. The penalty null space is 1-dimensional (constant functions only as linear functions are no longer unpenalized under periodicity). Parameters ---------- k: Number of knots placed around the periodic domain. Must be at least `4`, which yields at least `3` basis functions (`n_basis = k - 1`) after the cyclic constraint removes one degree of freedom. As with `CRS`, larger `k` gives more local flexibility, with overall smoothness controlled by the penalty and `lambda` rather than by `k` alone. The default is `10`. Notes ----- Knots are placed at evenly-spaced quantiles of the training data (falling back to an evenly-spaced grid when quantile ties would produce duplicates), exactly as in `CRS`, but the second-derivative relationship between the coefficient vector and the knot second derivatives is built on a *circular* tridiagonal system: `_build_cyclic_Q()` and `_build_cyclic_R()` construct the periodic analogues of `CRS`'s `Q` and `R` matrices, wrapping the sub/super-diagonal entries around modulo `m = k - 1` (the number of free coefficients once `f(x_min) = f(x_max)` is imposed). Concretely, the periodic constraint identifies knot `t_{k-1}` with `t_0`, so `\boldsymbol{\beta}` has only `m = k - 1` free entries, and second derivatives satisfy `\mathbf{R}_c \mathbf{d} = \mathbf{Q}_c^\top \boldsymbol{\beta}` with `Q_c`, `R_c` both `m \times m` and *circulant-tridiagonal* (each row's neighbors wrap around index `m`). Unlike `CRS`, there are no natural boundary conditions — periodicity itself replaces them — so the penalty null space drops from dimension 2 (constant and linear) to dimension 1 (constant only): a periodic function cannot be a nonzero linear function of `x`, since a nonzero slope is incompatible with `f(x_min) = f(x_max)`. The penalty is the same quadratic form as in `CRS`, computed from the circular matrices, $$ \mathbf{S} = \mathbf{Q}_c \mathbf{R}_c^{-1} \mathbf{Q}_c^\top, $$ an `m \times m` (`m = k - 1`) positive semi-definite matrix of rank `m - 1`, whose one-dimensional null space is the constant sequence. At evaluation time, `basis_matrix()` maps any `x` into the periodic domain `[x_min, x_max)` via `knots[0] + (x - knots[0]) % period` before evaluating the same piecewise-cubic construction as `CRS`, so extrapolation is not linear (as in `CRS`) but exactly periodic. Examples -------- ```{python} import numpy as np from whittaker.smooths import CyclicCRS x = np.linspace(0, 2 * np.pi, 100) basis = CyclicCRS(k=10).fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` CyclicPSpline(k: 'int' = 10, degree: 'int' = 3, m: 'int' = 2) -> 'None' Cyclic P-Spline (periodic B-spline basis with circular difference penalty). A cyclic P-spline is the periodic counterpart of `PSpline`: a B-spline basis whose knots and coefficients wrap around a circle, combined with a *circular* finite-difference penalty that also wraps around, so smoothness is enforced across the boundary rather than just within it. Equivalent to mgcv's `bs="cp"` basis. The B-spline basis is constructed to be periodic over the training data range, so `f(x_min) = f(x_max)` and, because the penalty ties coefficients across the wrap point, the fit avoids any artificial kink at the seam. Values outside the training range are mapped into the periodic domain via modular arithmetic. Prefer `CyclicPSpline` over `CyclicCRS` for the same reasons `PSpline` is often preferred over `CRS`: cheaper construction, equally-spaced (rather than quantile) knots, and a sparse banded penalty that scales well to larger `k`; prefer `CyclicCRS` when knot-based interpretability or an integrated-derivative penalty is wanted. The circular difference penalty penalizes the m-th differences of adjacent coefficients with wrap-around at the boundaries. The penalty null space is 1-dimensional (constant functions only). Parameters ---------- k: Number of periodic B-spline basis functions. Must satisfy `k >= degree + 1`. Larger `k` gives more local flexibility around the cycle; overall smoothness is governed by the penalty and `lambda`. The default is `10`. degree: Polynomial degree of each B-spline piece. `degree=3` (cubic) is conventional; lower degrees give coarser, more locally-supported bases. The default is `3` (cubic). m: Order of the circular difference penalty applied to adjacent coefficients, with wrap-around so that the coefficients at the end of the cycle are treated as adjacent to those at the start. `m=2` (the default) penalizes circular second differences, the periodic analogue of curvature. The default is `2`. Notes ----- `_periodic_bspline_knots()` builds a periodic knot vector by taking `k` equally-spaced knots spanning one period `[x_min, x_max)` and extending them periodically by `degree` knots on each side, so that the B-spline basis functions near the boundary have support that wraps smoothly around the cycle. `basis_matrix()` evaluates the (non-periodic) B-spline design on this extended knot vector and then folds the trailing `degree` "wrapped" columns back onto the leading `degree` columns (`B[:, :degree] += B_full[:, k:]`), so the returned matrix has exactly `k` periodic basis functions. The penalty replaces the ordinary finite-difference matrix used by `PSpline` with a *circular* difference matrix `\mathbf{D}_m` (built by `_cyclic_diff_matrix()` via repeated left-multiplication by the circular first-difference operator `D_1[i, i] = -1`, `D_1[i, (i+1) \bmod k] = 1`), giving a square `k \times k` penalty $$ \mathbf{S} = \mathbf{D}_m^\top \mathbf{D}_m, $$ positive semi-definite with rank `k - 1`. Its one-dimensional null space is the constant coefficient sequence: because the difference operator wraps around, no nonconstant polynomial sequence in the coefficient index can have zero circular difference (unlike the non-cyclic `PSpline`, where degree-`< m` polynomial sequences are all unpenalized). As with `PSpline`, the penalty is banded/sparse (with corner entries from the wrap-around) and cheap to factorize even for large `k`. Examples -------- ```{python} import numpy as np from whittaker.smooths import CyclicPSpline x = np.linspace(0, 2 * np.pi, 100) basis = CyclicPSpline(k=10).fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` ShrinkageTPRS(k: 'int' = 10, m: 'int' = 2) -> 'None' Shrinkage Thin Plate Regression Spline. Equivalent to mgcv's `bs="ts"` basis. Ordinary penalized smooths (such as `TPRS`) always leave a low-dimensional null space — typically the constant and linear terms — completely unpenalized, so even a very large smoothing parameter `lambda` cannot remove the term from the model entirely: the fit can be flattened to a straight line, but not to exactly zero. `ShrinkageTPRS` fixes this by adding a second penalty that acts specifically on the null space, using the double-penalty construction of Marra & Wood (2011). With two independently-chosen smoothing parameters, the fitting machinery can drive both the wiggly part and the null-space part of the smooth to zero simultaneously, so the whole term can be shrunk out of the model. This makes `ShrinkageTPRS` a good choice over plain `TPRS` whenever a term's inclusion is itself uncertain and you want automatic variable selection via GCV or REML rather than a separate hypothesis test. The basis functions are identical to `TPRS` (`bs="tp"`); only the penalty structure differs. Parameters ---------- k: Total number of basis functions, including the `M` null-space columns. The default is `10`. See `TPRS` for guidance on choosing `k`. m: Spline order. Must satisfy `2m > d` where `d` is the covariate dimension. The default is `2`. See `TPRS` for guidance on choosing `m`. Notes ----- `ShrinkageTPRS` reuses the exact basis construction of `TPRS`: the first `M` columns span the polynomial null space (degree `<= m - 1` monomials) and the remaining `k - M` columns are the truncated eigenbasis of the projected thin-plate kernel. What changes is the penalty. Instead of a single penalty matrix, `penalty_matrices()` returns **two** matrices: $$ \mathbf{S}_{\text{wiggle}} = \operatorname{diag}(0, \ldots, 0, \lambda_1, \ldots, \lambda_{k-M}), \qquad \mathbf{S}_{\text{null}} = \begin{pmatrix} \mathbf{I}_M & \mathbf{0} \\ \mathbf{0} & \mathbf{0} \end{pmatrix}, $$ where `S_wiggle` is exactly the ordinary `TPRS` penalty (zero on the null-space block) and `S_null` is a projection matrix that is the identity on the null-space block and zero elsewhere. During fitting, each matrix is scaled by its own smoothing parameter, `lambda_wiggle` and `lambda_null`, and the two contributions are added together: $$ \lambda_{\text{wiggle}} \, \boldsymbol{\beta}^\top \mathbf{S}_{\text{wiggle}} \boldsymbol{\beta} + \lambda_{\text{null}} \, \boldsymbol{\beta}^\top \mathbf{S}_{\text{null}} \boldsymbol{\beta}. $$ Because `S_wiggle + S_null` is strictly positive definite (it has no zero eigenvalues once both penalties act together), the combined penalty null space is empty, which is why `null_space_dimension()` returns `0` for this basis — none of the `k` basis functions is exempt from penalization once both smoothing parameters are positive. If `lambda_null` is estimated to be very large during fitting, the null-space coefficients are effectively zeroed and the term is excluded from the model, which is the mechanism behind automatic term selection. Examples -------- ```{python} import numpy as np from whittaker.smooths import ShrinkageTPRS rng = np.random.default_rng(0) x = rng.uniform(0, 1, 100) basis = ShrinkageTPRS(k=10).fit(x) B = basis.basis_matrix(x) S_wiggle, S_null = basis.penalty_matrices() B.shape, S_wiggle.shape, S_null.shape ``` ShrinkageCRS(k: 'int' = 10) -> 'None' Shrinkage Cubic Regression Spline. Equivalent to mgcv's `bs="cs"` basis. `CRS` penalizes only the curvature of a natural cubic spline, leaving its 2-dimensional null space of constant and linear functions completely free — a large smoothing parameter flattens the fit to a line, but never removes it from the model. `ShrinkageCRS` adds a second penalty, built directly from the eigenstructure of the ordinary CRS penalty, that specifically targets this null space. Following the double-penalty approach of Marra & Wood (2011), the two penalties are given independent smoothing parameters during fitting, so that both the wiggly and the linear/constant parts of the term can be shrunk simultaneously, letting the term drop out of the model entirely. Prefer `ShrinkageCRS` over plain `CRS` for univariate terms whose presence in the model is uncertain and where automatic selection via GCV or REML is preferred over an explicit inclusion/exclusion test. The basis functions are identical to `CRS` (`bs="cr"`); only the penalty structure differs. Parameters ---------- k: Number of basis functions (equal to the number of knots). Must be at least `3`. The default is `10`. See `CRS` for guidance on choosing `k`. Notes ----- `ShrinkageCRS` reuses the exact basis construction of `CRS`: `k` knots at evenly-spaced quantiles of the training data, with the design matrix built from natural-cubic-spline basis functions (see `CRS` for the full construction). What changes is the penalty. The ordinary CRS penalty is $$ \mathbf{S}_{\text{wiggle}} = \mathbf{Q} \mathbf{R}^{-1} \mathbf{Q}^\top, $$ which is positive semi-definite with rank `k - 2` and a 2-dimensional null space spanned by the constant and linear functions evaluated at the knots. `ShrinkageCRS` eigendecomposes `S_wiggle`, identifies the eigenvectors `U_null` whose eigenvalues are numerically zero (below `1e-10` times the largest eigenvalue), and builds a second penalty matrix from their outer product: $$ \mathbf{S}_{\text{null}} = \mathbf{U}_{\text{null}} \mathbf{U}_{\text{null}}^\top. $$ `S_null` is positive semi-definite with rank `2`, non-zero exactly on the subspace that `S_wiggle` leaves unpenalized. During fitting each matrix is scaled by its own smoothing parameter and the two contributions are summed: $$ \lambda_{\text{wiggle}} \, \boldsymbol{\beta}^\top \mathbf{S}_{\text{wiggle}} \boldsymbol{\beta} + \lambda_{\text{null}} \, \boldsymbol{\beta}^\top \mathbf{S}_{\text{null}} \boldsymbol{\beta}. $$ Because `S_wiggle + S_null` is strictly positive definite, the combined penalty has no null space, so `null_space_dimension()` returns `0`: none of the `k` basis functions escapes penalization once both smoothing parameters are positive. Examples -------- ```{python} import numpy as np from whittaker.smooths import ShrinkageCRS rng = np.random.default_rng(0) x = rng.uniform(0, 1, 100) basis = ShrinkageCRS(k=10).fit(x) B = basis.basis_matrix(x) S_wiggle, S_null = basis.penalty_matrices() B.shape, S_wiggle.shape, S_null.shape ``` DuchonSpline(k: 'int' = 10, m: 'int | list | tuple' = 2) -> 'None' Duchon spline basis. Duchon splines (Duchon, 1977) generalize thin plate splines by decoupling the radial basis exponent from the covariate dimension. Ordinary TPRS ties the exponent of its radial kernel to both the derivative order `m` being penalized and the covariate dimension `d` (exponent `2m - d`), which means that for high-dimensional covariates the derivative order actually penalized can end up being uncomfortably high just to keep the kernel well-defined. `DuchonSpline` introduces an independent exponent parameter `s`, so the radial kernel and the polynomial null-space order can be chosen separately. Like TPRS, it is built as a low-rank eigen-approximation to the full spline (Wood 2003's construction, generalized to the Duchon kernel), so it requires no knot placement and works for any covariate dimension. Choose `DuchonSpline` over `TPRS` when you want explicit control over the smoothness/exponent trade-off independent of dimension — for example to match a specific derivative-penalty order in higher dimensions without also inflating the null-space order. Parameters ---------- k: Total number of basis functions, including the `M` polynomial null-space columns. Must satisfy `k > M` where `M = C(m_order - 1 + d, d)`. Larger `k` allows more wiggly fits at the cost of more computation; the roughness penalty (not `k`) ultimately controls smoothness once `lambda` is chosen. The default is `10`. m: Order specification, either: * a single integer, interpreted as the polynomial null-space order `m_order`, with the radial exponent parameter `s` defaulting to `1.0`; or * a two-element list/tuple `[s, m_order]`, where `s >= 0` is the real-valued radial basis exponent (the kernel behaves like `r^(2s)`, optionally with a log factor) and `m_order >= 1` is the polynomial null-space order (the null space consists of all monomials of total degree `<= m_order - 1`). Setting `s = m_order - d / 2` for integer `m_order` recovers the ordinary TPRS basis for that order. The default is `2` (i.e. `s=1.0`, `m_order=2`). Notes ----- The Duchon radial kernel is $$ \eta_s(r) = \begin{cases} r^{2s} & 2s \text{ is not an even integer} \\ r^{2s} \log(r) & 2s \text{ is an even integer} \end{cases}, $$ evaluated at pairwise distances `r = ||x_i - x_j||`, with the convention `η(0) = 0`. Together with the polynomial null space of all monomials of total degree at most `m_order - 1` (dimension `M = C(m_order - 1 + d, d)`), the basis is constructed in the same two stages as `TPRS`: 1. **Polynomial null space** (first `M` columns): unpenalized low-degree polynomials. 2. **Truncated spline part** (remaining `k - M` columns): the full `n x n` kernel matrix is projected onto the orthogonal complement of the null space (via a QR decomposition of the null-space design matrix) and eigendecomposed; the `k - M` leading eigenvectors give the best rank-`(k - M)` approximation to the full Duchon spline for that basis dimension. The resulting penalty matrix is block-diagonal, $$ \mathbf{S} = \operatorname{diag}(0, \ldots, 0, \lambda_1, \ldots, \lambda_{k-M}), $$ with the first `M` rows/columns exactly zero (unpenalized null space) and the remainder equal to the retained eigenvalues of the projected kernel matrix. As with `TPRS`, columns of `x` with very different scales can cause numerical issues in the eigendecomposition, so centering and/or standardizing covariates before fitting is advisable. Non-integer or large `s` values can also make the kernel matrix increasingly ill-conditioned; if fitting becomes numerically unstable, try a smaller `s` or standardized covariates. Examples -------- ```{python} import numpy as np from whittaker.smooths import DuchonSpline rng = np.random.default_rng(0) x = rng.uniform(0, 1, 100) basis = DuchonSpline(k=10, m=[1.0, 2]).fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` GaussianProcess(k: 'int' = 10, cov: 'str' = 'matern32') -> 'None' Gaussian process (kriging) smooth basis. Equivalent to mgcv's `bs="gp"` basis. This basis treats the unknown smooth function as a realization of a zero-mean Gaussian process with a chosen covariance (kernel) function, in the spirit of kriging/spatial statistics. Rather than working with the full `n x n` covariance matrix (which does not scale well and has no natural low-rank truncation-by-penalty like TPRS), this implementation builds a rank-`k` basis from the leading eigenfunctions of the covariance matrix evaluated at the training points, with the inverse eigenvalues serving directly as the penalty. Because the covariance function is stationary and isotropic (depends only on distance between points), `GaussianProcess` is naturally suited to spatial covariates or any setting where you want smoothness governed by a physically or statistically motivated correlation structure — e.g. exponential decay of spatial correlation — rather than a derivative-based bending-energy penalty like TPRS. Parameters ---------- k: Number of basis functions, i.e. the number of leading eigenfunctions of the covariance matrix retained. Larger `k` captures more of the covariance structure at the cost of more computation; if `k` exceeds the number of training points `n`, it is silently reduced to `n`. The default is `10`. cov: Name of the covariance (kernel) function used to build the Gram matrix. One of: * `"exp"` — exponential covariance (Matern with `nu=1/2`): `sigma^2 exp(-r / rho)`. Produces rough, non-differentiable sample paths; use when the underlying process is expected to be continuous but not smooth. * `"matern32"` — Matern with `nu=3/2`: once-differentiable sample paths. A reasonable general-purpose default, balancing smoothness and local flexibility. This is the default. * `"matern52"` — Matern with `nu=5/2`: twice-differentiable sample paths, smoother than `"matern32"`. * `"sqexp"` — squared exponential (RBF): `sigma^2 exp(-r^2 / (2 rho^2))`. Produces infinitely differentiable, very smooth sample paths; can over-smooth sharp local features. Notes ----- Given training covariates `x` with pairwise distances `r = ||x_i - x_j||`, the covariance (Gram) matrix `C` has entries `C_{ij} = k(r_{ij}; rho)` for the chosen kernel `k`. The range parameter `rho` is not user-specified; it is set automatically during `fit()` to one quarter of the mean range of the covariates, a simple heuristic that keeps the effective correlation length commensurate with the spread of the data. `C` is eigendecomposed and the `k` eigenvectors `U` with the largest eigenvalues `d_1, ..., d_k` are retained: $$ \mathbf{C} \approx \mathbf{U} \operatorname{diag}(d_1, \ldots, d_k) \mathbf{U}^\top . $$ The basis functions evaluated at new points `x*` are $$ \mathbf{B}(x^*) = \mathbf{C}(x^*, x_{\text{train}}) \, \mathbf{U} \, \operatorname{diag}(d_1, \ldots, d_k)^{-1}, $$ i.e. the covariance between `x*` and the training points, projected onto the retained eigenvectors and rescaled by the inverse eigenvalues (a Nystrom-style low-rank Karhunen-Loeve approximation to the process). The penalty matrix is diagonal in the inverse eigenvalues, $$ \mathbf{S} = \operatorname{diag}(d_1^{-1}, \ldots, d_k^{-1}), $$ which corresponds to the negative log-density of the Gaussian process prior on the coefficients: directions with small eigenvalue (little prior variance) are penalized heavily, and directions with large eigenvalue are penalized lightly. Because every eigenvalue is penalized, `null_space_dimension()` is `0` — there is no unpenalized null space, unlike thin plate or cubic regression splines, so even the "constant" and "linear" trends across the domain are (lightly) shrunk under this basis. Eigenvalues are clamped away from zero (to machine epsilon) for numerical stability when inverting; using a very small `k` or a badly-scaled covariate range can still lead to an ill-conditioned Gram matrix. Examples -------- ```{python} import numpy as np from whittaker.smooths import GaussianProcess rng = np.random.default_rng(0) x = rng.uniform(0, 1, 100) basis = GaussianProcess(k=10, cov="matern32").fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` SoapFilm(*, boundary: 'list[NDArray] | None' = None, knots: 'NDArray | None' = None, k: 'int' = 30) -> 'None' Soap film smooth for 2-D domains with complex boundaries. Implements the soap-film smoother of Wood, Bravington & Hedley (2008). Ordinary 2-D smooths such as `TPRS` or tensor-product splines treat the covariate domain as if it were convex and unobstructed; when the true domain has holes, concave coastlines, peninsulas, or other complicated shapes, such smooths will "leak" information across boundaries that are close in Euclidean distance but far apart when you have to travel around the domain (e.g. two points on opposite banks of a narrow bay). `SoapFilm` avoids this by finite-element-discretizing the domain itself: the smooth is represented as a piecewise-linear function on a triangulation of the actual (possibly non-convex, possibly multiply-connected) region, so its value at any point only depends on interior knots reachable through the domain. Use `SoapFilm` instead of a standard 2-D smooth whenever the covariates are genuinely spatial coordinates and the region they live in is not simply a rectangle — for example coastal, riverine, or other geographically constrained data. Parameters ---------- boundary: List of boundary loops, each an `(m, 2)` array of ordered vertices tracing a closed polygon. The first loop is the outer boundary of the domain; any additional loops are holes cut out of it (e.g. islands or excluded regions). If not supplied, a padded rectangular bounding box around the training data is used, which reduces the smooth to an ordinary (simply-connected, convex) domain — supply an explicit boundary whenever the domain has a non-trivial shape. knots: Interior knot locations as an `(nk, 2)` array; these become the nodes of the finite-element triangulation and directly determine the basis dimension. If not supplied, a roughly square grid of candidate points is generated over the bounding box, filtered to those lying inside the domain (outer boundary minus holes), and then subsampled down to at most `k` points. Supplying knots explicitly gives more control over their placement, which matters near sharp domain features. k: Target number of basis functions. If `knots` is not supplied, this determines the density of the automatically generated knot grid, and the actual number of basis functions equals the number of interior knots retained (which may be less than `k`). If `knots` is supplied directly, `k` is not used to size the basis; the number of basis functions equals `len(knots)`. The default is `30`. Notes ----- Fitting proceeds in three stages: 1. **Triangulation.** Interior knots are combined with points sampled along the boundary loops (each boundary segment contributes its endpoint and midpoint) and a Delaunay triangulation is built over all of these points. 2. **Finite-element assembly.** On each triangle, standard piecewise-linear (barycentric) basis functions `phi_i` are used to assemble a stiffness matrix `K` (with entries `K_{ij} = integral of grad(phi_i) . grad(phi_j)` over the domain) and a mass matrix `M` (with entries `M_{ij} = integral of phi_i * phi_j`), then both are restricted to the rows and columns corresponding to interior knots (boundary points are not free parameters). 3. **Basis evaluation.** At an arbitrary evaluation point, the containing triangle is located (via `Delaunay.find_simplex`) and the point's barycentric coordinates within that triangle give its basis-function weights; points outside the triangulation fall back to a nearest-knot indicator. The penalty matrix is exactly the interior-restricted stiffness matrix, $$ \mathbf{S} = \mathbf{K}_{\text{interior}}, \qquad \boldsymbol{\beta}^\top \mathbf{S} \boldsymbol{\beta} = \int_\Omega \lVert \nabla f \rVert^2 \, dA, $$ the discretized Dirichlet energy (membrane/thin-film bending energy) of the fitted surface over the domain `Omega`, consistent with the "soap film" interpretation: the fitted surface behaves like a soap film stretched across the (possibly perforated) domain boundary. `S` is positive semi-definite; its null space corresponds to the constant function, so `null_space_dimension()` is `0` here by convention (the constant is handled via `identifiability_constraints()` rather than being excluded from the penalty). Because the basis is piecewise-linear on a triangulation rather than smooth in the classical sense, the fitted surface is continuous but only once-differentiable, and the quality of the fit is sensitive to the density and placement of interior knots relative to the sharpness of the domain's boundary features — very thin or highly concave regions may need denser knots near the constriction to avoid leakage. Examples -------- ```{python} import numpy as np from whittaker.smooths import SoapFilm rng = np.random.default_rng(0) x = rng.uniform(0, 1, (100, 2)) basis = SoapFilm(k=20).fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` MRFBasis(k: 'int' = -1, neighborhood: 'dict | NDArray | None' = None) -> 'None' Markov random field basis for areal spatial data. A Markov random field (MRF) smooth represents spatial structure over discrete areal units — counties, districts, postcodes, grid cells on a lattice — where the covariate is a categorical label rather than a continuous coordinate, and the only spatial information available is which units are adjacent to which. It is equivalent to mgcv's `bs="mrf"` basis. Each unique region gets its own basis function (an indicator column), and smoothness across the map is enforced directly through the neighborhood graph rather than through any distance metric: the penalty discourages the fitted values of neighboring regions from differing, so choose this basis over `TPRS`-style continuous smooths whenever the domain is a set of discrete areas linked by an adjacency structure (e.g. shared borders) instead of by Euclidean coordinates. Parameters ---------- k: Maximum number of regions to retain. If `-1` (the default), all observed levels of the grouping variable are kept as basis functions. If a positive integer smaller than the number of observed levels, only the first `k` (in sorted level order) are used; this is rarely what a user wants for MRF smooths (unlike continuous bases, reducing `k` does not give a lower-rank approximation of the same structure — it silently drops regions), so in most workflows the default of `-1` should be left alone. neighborhood: The neighborhood structure that defines which regions are considered adjacent. Either a `dict` mapping region labels to lists of neighbor labels (only pairs need to be listed once; the adjacency is symmetrized automatically), or a square, symmetric adjacency matrix (`ndarray`) whose row/column order matches the sorted unique levels of the fitted grouping variable. This argument is required — there is no sensible default neighborhood structure. Notes ----- Let there be `k` unique regions after `fit()`. The basis matrix `B` is the `n x k` matrix of region indicators, `B[i, j] = 1` if observation `i` belongs to region `j` and `0` otherwise — identical in structure to `RandomEffectBasis`. What distinguishes an MRF smooth is its penalty. Writing `A` for the symmetric adjacency matrix (`A[i, j] = 1` if regions `i` and `j` are neighbors) and `D = \operatorname{diag}(A \mathbf{1})` for the diagonal matrix of neighbor counts, the penalty matrix is the graph Laplacian $$ \mathbf{L} = \mathbf{D} - \mathbf{A}, $$ so that the roughness penalty takes the form $$ \boldsymbol{\beta}^\top \mathbf{L} \boldsymbol{\beta} = \sum_{(i,j) \, \in \, \text{neighbors}} (\beta_i - \beta_j)^2 . $$ This penalizes exactly the pairwise differences between the fitted level for each region and the fitted levels of its geographic neighbors, pulling adjacent regions toward a common value as `lambda` grows, while leaving regions that are far apart on the map free to differ. The graph Laplacian of a connected neighborhood graph has exactly one zero eigenvalue, with eigenvector proportional to the all-ones vector; `null_space_dimension()` therefore returns `1` for a fully connected graph (the penalty cannot shrink a common overall level, only differences between neighbors), but can be larger if the neighborhood graph has multiple disconnected components, since each component then has its own unpenalized constant. Because the raw indicator basis shares an unpenalized constant with the model intercept, a sum-to-zero constraint (returned by `identifiability_constraints()`) is needed for identifiability when fitting alongside an intercept term. `fit()` raises if fewer than two regions are present, since a spatial smooth is meaningless with only one area, and raises if no `neighborhood` is supplied. Examples -------- ```{python} import numpy as np from whittaker.smooths.mrf import MRFBasis rng = np.random.default_rng(0) regions = np.array(["A", "B", "C", "D"]) x = rng.choice(regions, size=40) neighborhood = { "A": ["B"], "B": ["A", "C"], "C": ["B", "D"], "D": ["C"], } basis = MRFBasis(neighborhood=neighborhood).fit(x) B = basis.basis_matrix(x) S = basis.penalty_matrix() B.shape, S.shape ``` AdaptiveTPRS(k: 'int' = 10, m: 'int' = 2, n_penalties: 'int' = -1) -> 'None' Adaptive Thin Plate Regression Spline. An ordinary `TPRS` uses a single smoothing parameter `lambda` to control wiggliness uniformly across the whole covariate domain, which is a poor fit when the true function is smooth in some regions and rapidly varying in others (e.g. a signal with a sharp local feature embedded in an otherwise flat trend). `AdaptiveTPRS` addresses this by decomposing the ordinary TPRS penalty matrix into its eigenvectors and turning each eigenvector (or a contiguous block of them) into its own separate penalty term with its own smoothing parameter, following the "adaptive smoothing" construction of Wood (2000, 2017, sec. 5.4.2). Because each eigenvector of the original penalty corresponds to a distinct spatial pattern of wiggliness, giving each its own `lambda` lets the fitted smoothing-parameter-selection procedure impose more smoothing where the data support a flat fit and less where they support local structure. Choose `AdaptiveTPRS` over plain `TPRS` when there is a-priori reason to expect the required smoothness to vary spatially and the extra smoothing parameters (and their computational cost during fitting) can be afforded; otherwise, use `TPRS`. The number of adaptive penalty components is controlled by `n_penalties`. With `n_penalties=k-M` (the default), every eigenvector gets its own λ. Smaller values group eigenvectors into blocks for computational efficiency. Parameters ---------- k: Total number of basis functions, exactly as in `TPRS` (the default is `10`). Since `AdaptiveTPRS` uses the same underlying basis functions as `TPRS` and only changes how the penalty is structured, the same guidance for choosing `k` applies. m: Spline order, exactly as in `TPRS` (the default is `2`). Controls the order of derivative that the *unweighted* thin plate penalty targets before it is decomposed into adaptive components. n_penalties: Number of adaptive penalty components to construct from the eigendecomposition of the base TPRS penalty. If `-1` (the default), one penalty is created per retained eigenvector (`k - M` penalties total, the maximum granularity and maximum flexibility for spatially varying smoothness). If a smaller positive integer is given, the eigenvectors (already sorted by decreasing eigenvalue during `TPRS.fit()`) are grouped into that many contiguous blocks of roughly equal size, each sharing one smoothing parameter; this reduces the number of smoothing parameters that must be estimated, trading some spatial adaptivity for faster, more stable fitting. Notes ----- After `fit()` (inherited unchanged from `TPRS`), the basis matrix and its first `M` null-space columns are identical to plain `TPRS`; only the penalty differs. Let `D_r = \operatorname{diag}(d_1, \ldots, d_r)` be the diagonal matrix of the `r = k - M` eigenvalues retained by `TPRS.fit()` (in descending order), so that the ordinary TPRS penalty restricted to the range space is `D_r` itself. `AdaptiveTPRS` partitions the index set `\{1, \ldots, r\}` into `n_penalties` contiguous blocks `B_1, \ldots, B_{n_penalties}` of (approximately) equal size and returns one `k x k` penalty matrix per block, $$ \mathbf{S}_b = \operatorname{diag}\bigl(0, \ldots, 0,\; \max(d_i, \epsilon) \cdot [i \in B_b], \ldots\bigr), \qquad b = 1, \ldots, n_\text{penalties}, $$ where each `S_b` is zero everywhere except in the diagonal entries corresponding to eigenvectors in its block, and `\epsilon = 10^{-10}` guards against the (numerically possible) tiny negative eigenvalues that arise from the projection step in `TPRS.fit()`. The total penalty used during model fitting is the weighted sum `\sum_b \lambda_b \, \boldsymbol{\beta}^\top \mathbf{S}_b \boldsymbol{\beta}`, with one smoothing parameter `\lambda_b` per block selected by the outer GCV/REML procedure — this is what allows the fitted smoothness to vary spatially: blocks corresponding to slowly varying eigenvectors can receive small `\lambda_b` (little shrinkage, high local flexibility) while blocks corresponding to rapidly varying eigenvectors receive large `\lambda_b` (heavy shrinkage), or vice versa, according to what the data support in different parts of the covariate space. Since the block matrices `S_b` are mutually orthogonal (each touches disjoint diagonal entries) and together cover exactly the `k - M` range-space coefficients, `null_space_dimension()` is unchanged from `TPRS` and equals `M`. Because more penalties mean more smoothing parameters to estimate, `n_penalties` close to `r` can make outer optimization slower and, on small or noisy datasets, less numerically stable; the default of `n_penalties=-1` should be reduced if fitting becomes unreliable. Examples -------- ```{python} import numpy as np from whittaker.smooths.adaptive import AdaptiveTPRS rng = np.random.default_rng(0) x = rng.uniform(0, 1, 100) basis = AdaptiveTPRS(k=10, n_penalties=3).fit(x) B = basis.basis_matrix(x) penalties = basis.penalty_matrices() B.shape, len(penalties), penalties[0].shape ``` RandomEffectBasis(k: 'int' = -1) -> 'None' Random effect basis (one-hot encoding with identity penalty). A random effect basis represents a grouping factor — subject ID, site, batch, cluster — as a penalized random intercept rather than as a fixed factor with one unconstrained parameter per level. It is equivalent to mgcv's `bs="re"` basis and to the random-intercept term of a linear mixed model: each unique level of the grouping variable gets its own one-hot column, and an identity penalty shrinks the estimated level effects toward their common mean, with the amount of shrinkage controlled by a single smoothing parameter selected by GCV or REML (equivalent to the group-level variance in a mixed model). Choose this basis over an ordinary fixed factor whenever the number of levels is large, levels have unbalanced sample sizes, or the goal is to borrow strength across levels rather than to estimate every level's effect independently; choose it over `MRFBasis` or `FactorSmoothBasis` when the grouping levels have no spatial/ordering structure to exploit and only an exchangeable random intercept is wanted. The covariate should be a 1-D array of group labels (strings, integers, or any hashable type). Parameters ---------- k: Maximum number of levels to retain. If `-1` (the default), all observed levels are kept as basis functions. If a positive integer smaller than the number of observed levels, only the first `k` (in sorted level order) are used and any other levels are silently dropped from the basis (their rows become all-zero); this is rarely desirable for a random effect, so the default of `-1` should normally be left as-is unless there is a specific reason to cap the number of levels. Notes ----- Let there be `k` unique levels of the grouping factor after `fit()`. The basis matrix `B` is the `n x k` matrix of level indicators, `B[i, j] = 1` if observation `i` belongs to level `j` and `0` otherwise. The penalty matrix is the `k x k` identity, $$ \mathbf{S} = \mathbf{I}_k, $$ so the roughness penalty is simply the sum of squared level effects, $$ \boldsymbol{\beta}^\top \mathbf{S} \boldsymbol{\beta} = \sum_{j=1}^{k} \beta_j^2 , $$ exactly the ridge-type penalty that shrinks every group deviation toward zero as `lambda` grows, with no level treated differently from any other. Because the identity matrix is full rank, `null_space_dimension()` is always `0` — there is no unpenalized subspace, and in principle the entire random effect can be shrunk away as `lambda \to \infty`, recovering a model with no group-level variation at all. Because the raw indicator columns are collinear with an overall intercept (every row sums to `1`), `identifiability_constraints()` returns a sum-to-zero constraint that should be enforced when the random effect is fit alongside a fixed intercept term. `fit()` raises `ValueError` if fewer than two unique levels are present, since a random effect with a single level carries no information about between-group variation. Examples -------- ```{python} import numpy as np from whittaker.smooths.random import RandomEffectBasis rng = np.random.default_rng(0) groups = rng.choice(["a", "b", "c", "d"], size=50) basis = RandomEffectBasis().fit(groups) B = basis.basis_matrix(groups) S = basis.penalty_matrix() B.shape, S.shape ``` FactorSmoothBasis(k: 'int' = 10, xt: 'str' = 'tp', **marginal_kwargs: 'Any') -> 'None' Factor-smooth interaction basis (per-level smooth with shared penalties). A factor-smooth interaction fits a separate curve of a numeric covariate for every level of a grouping factor — one trajectory per subject in a longitudinal study, one seasonal curve per site, one dose-response curve per batch — while pooling information across levels by sharing the same wiggliness penalty (and, optionally, coefficient-level shrinkage) across all of them. It is equivalent to mgcv's `bs="fs"` basis and is the natural GAM analogue of a random-slope mixed model. Choose this basis over fitting `k` independent smooths (one per level) when the levels are numerous, some levels have little data, and it is desirable to borrow strength across levels via a shared smoothing parameter; choose it over a single shared smooth (with an additional `RandomEffectBasis` for level differences) when each level's mean *shape*, not merely its overall level, is expected to differ appreciably. Parameters ---------- k: Number of basis functions for the marginal smooth per level. Applies to every level identically — all per-level smooths share the same basis dimension. Larger `k` allows more wiggly per-level curves at the cost of more coefficients (`n_levels * k` total); since the smoothing parameter (not `k`) ultimately controls how wiggly the fitted curves are, `k` mainly needs to be large enough not to unduly constrain the shape. The default is `10`. xt: Marginal basis type used for each level's smooth. One of `"tp"` (thin plate regression spline, a good general-purpose default), `"cr"` (cubic regression spline, cheaper for a single covariate with many knots), or `"ps"` (P-spline, useful when a difference penalty on B-spline coefficients is preferred). The default is `"tp"`. **marginal_kwargs: Additional keyword arguments forwarded to the marginal basis constructor for the chosen `xt` (e.g. `m` for the spline order used by `"tp"` and `"ps"` bases). Notes ----- Let there be `L` factor levels and let the fitted marginal basis (shared in form, but evaluated per level) have `k_m` basis functions with null-space dimension `M`. The full basis matrix is block-diagonal by level: $$ \mathbf{B} = \begin{bmatrix} \mathbf{1}_{[\text{level}=1]} \odot \mathbf{B}_m & \mathbf{1}_{[\text{level}=2]} \odot \mathbf{B}_m & \cdots & \mathbf{1}_{[\text{level}=L]} \odot \mathbf{B}_m \end{bmatrix}, $$ where `B_m` is the marginal basis matrix evaluated at the numeric covariate and `\mathbf{1}_{[\text{level}=\ell]}` is the indicator for observations belonging to level `\ell` (each row of `B` is nonzero only in the block for its own level); the total number of columns is `L * k_m`. The penalty structure has `1 + M` components: 1. A shared **wiggliness** penalty, replicated identically across all levels via the Kronecker structure $$ \mathbf{S}_{\text{wiggle}} = \mathbf{I}_L \otimes \mathbf{S}_m , $$ where `S_m` is the marginal basis's own penalty matrix. A single smoothing parameter controls how wiggly *every* level's curve is allowed to be. 2. **One penalty per marginal null-space component** (there are `M` of them, e.g. `M=2` for a thin plate spline with `m=2` — the constant and linear components). For each null-space eigenvector `v` of `S_m`, the corresponding penalty places `\operatorname{outer}(v, v)` in every level's diagonal block, so that this penalty shrinks that low-order component of the curve (e.g. each level's intercept, or each level's linear trend) toward a common value across levels — a random-intercept/random-slope penalty for exactly the marginal basis's unpenalized directions. Because every basis coefficient is touched by at least one of these `1 + M` penalties (the wiggliness penalty covers the range space and the null-space penalties cover what the wiggliness penalty leaves unpenalized), `null_space_dimension()` is always `0` and no `identifiability_constraints()` are required — the basis is fully penalized and does not collide with a fixed intercept. `fit()` raises `ValueError` if fewer than 2 factor levels are present, since factor-smooth interactions require differentiating between levels. Examples -------- ```{python} import numpy as np from whittaker.smooths.factor_smooth import FactorSmoothBasis rng = np.random.default_rng(0) n = 100 subject = rng.choice(["s1", "s2", "s3"], size=n) x_numeric = rng.uniform(0, 1, n) basis = FactorSmoothBasis(k=8).fit(x_numeric, subject) B = basis.basis_matrix(x_numeric, subject) penalties = basis.penalty_matrices() B.shape, len(penalties) ``` TensorProductBasis(marginals: 'list[SmoothBasis]') -> 'None' Tensor product of marginal smooth bases (`te()`-style interaction smooth). A tensor product smooth builds a multivariate smooth function of two or more covariates out of one-dimensional (or otherwise lower-dimensional) marginal smooths, one per covariate, by taking the row-wise outer product of their basis matrices. This is the standard way to represent an interaction between covariates that are measured on very different scales or units (e.g. a spatial coordinate combined with time, or a covariate in meters combined with one in years) — unlike an isotropic basis such as `TPRS`, which assumes all covariates share a common notion of distance, a tensor product smooth applies a separate marginal penalty (and, in principle, a separate smoothing parameter) to each covariate direction, so that the anisotropic scaling of the covariates does not distort the fitted surface. Choose `TensorProductBasis` over `TPRS` whenever the covariates involved are not naturally on comparable scales, and use `TensorInteractionBasis` instead when a decomposition into separate main-effect and pure-interaction terms (an ANOVA-style model) is wanted. Parameters ---------- marginals: List of (typically unfitted) marginal basis objects, one per covariate dimension to be combined, e.g. `[TPRS(k=10), TPRS(k=8)]` for a bivariate smooth. Each marginal is fit independently to its own column of `x` inside `fit()`. At least 2 marginals are required; for a single covariate, use the marginal basis directly instead of wrapping it in a tensor product. Notes ----- Given `d` marginal bases with basis matrices `B_1, \ldots, B_d` (each `B_j` of shape `(n, k_j)`), the tensor product basis matrix is the row-wise Kronecker product $$ \mathbf{B}[i, :] = \mathbf{B}_1[i, :] \otimes \mathbf{B}_2[i, :] \otimes \cdots \otimes \mathbf{B}_d[i, :], \qquad i = 1, \ldots, n, $$ which has `k = k_1 k_2 \cdots k_d` columns in total — every combination of one marginal basis function from each dimension. This basis represents *all* smooth functions expressible as sums of products of the marginal bases, including both pure main effects and their interaction, so unlike `TensorInteractionBasis`, no separate main-effect terms need to be added to the model for identifiability of low-order structure (though it is common practice in mgcv-style formulas to do so anyway for a cleaner ANOVA decomposition). For each marginal direction `j` with own penalty `S_j`, the tensor product carries one whole-basis penalty per marginal direction, $$ \mathbf{S}_j^{\text{tensor}} = \mathbf{I}_{k_1} \otimes \cdots \otimes \mathbf{I}_{k_{j-1}} \otimes \mathbf{S}_j \otimes \mathbf{I}_{k_{j+1}} \otimes \cdots \otimes \mathbf{I}_{k_d}, $$ returned in order by `penalty_matrices()`; `penalty_matrix()` sums these into a single matrix only for compatibility with the base `SmoothBasis` interface — for a proper anisotropic fit (one smoothing parameter per marginal direction), use `penalty_matrices()` directly rather than `penalty_matrix()`. The null-space dimension of the combined penalty is the product of the marginal null-space dimensions, `M = M_1 \cdot M_2 \cdots M_d` (the multivariate polynomials that are simultaneously in the null space of every marginal penalty). Because `k` grows multiplicatively with the number of marginals and their individual sizes, tensor products become expensive quickly in more than two or three dimensions; for higher-dimensional smooths of covariates on comparable scales, an isotropic basis such as `TPRS` is usually preferable. Examples -------- ```{python} import numpy as np from whittaker.smooths.tensor import TensorProductBasis from whittaker.smooths.tprs import TPRS rng = np.random.default_rng(0) x = rng.uniform(0, 1, (100, 2)) basis = TensorProductBasis([TPRS(k=6), TPRS(k=5)]).fit(x) B = basis.basis_matrix(x) penalties = basis.penalty_matrices() B.shape, len(penalties) ``` TensorInteractionBasis(marginals: 'list[SmoothBasis]') -> 'None' Tensor product interaction basis (`ti()`-style pure interaction smooth). `TensorInteractionBasis` builds a tensor product smooth like `TensorProductBasis`, but first projects each marginal basis onto its own penalty *range space* (removing the marginal's null-space components, such as the constant and linear terms) before taking the tensor product. The result spans only the pure interaction between the covariates — none of the lower-order main-effect structure that a plain tensor product basis would otherwise reintroduce. This makes it the right building block for ANOVA-style decompositions of a smooth surface into orthogonal pieces, e.g. `s(x1) + s(x2) + ti(x1, x2)`, where `s(x1)` and `s(x2)` already carry the main effects and `ti(x1, x2)` is meant to add only what a sum of the two one-dimensional smooths cannot represent. Use `TensorInteractionBasis` instead of `TensorProductBasis` whenever main effects are (or will be) modeled by separate marginal smooths and double-counting of the main-effect structure inside the interaction term must be avoided; use `TensorProductBasis` or `TensorProductBasisT2` when the interaction term is meant to stand alone and include the main effects itself. Parameters ---------- marginals: List of (unfitted) marginal basis objects, one per covariate dimension, e.g. `[TPRS(k=10), TPRS(k=8)]`. At least 2 marginals are required. Notes ----- For each marginal basis with penalty `S_j`, `fit()` eigendecomposes `S_j` and keeps only the eigenvectors `U_j` with (numerically) positive eigenvalues — the penalized *range space* — while discarding the null-space eigenvectors (the unpenalized polynomials, dimension `M_j`). Each marginal's basis matrix is then reprojected onto this reduced space, `B_j' = B_j U_j`, of dimension `r_j = k_j - M_j` rather than the original `k_j`, before the marginals are combined with the same row-wise Kronecker product used by `TensorProductBasis`: $$ \mathbf{B}[i, :] = \mathbf{B}_1'[i, :] \otimes \mathbf{B}_2'[i, :] \otimes \cdots \otimes \mathbf{B}_d'[i, :]. $$ Because every marginal contributes only its range space, none of the columns of `B` correspond to a main-effect direction, and the total basis dimension is the product of the range-space dimensions, `k = r_1 \cdot r_2 \cdots r_d`, smaller than the `k_1 \cdots k_d` used by `TensorProductBasis` on the same marginals. In this reduced basis, each marginal's penalty is already diagonal (it is expressed in its own eigenbasis), `\operatorname{diag}(d_{j,1}, \ldots, d_{j,r_j})` for the retained eigenvalues `d_{j,i}`, and the per-direction penalty matrices are the Kronecker products $$ \mathbf{S}_j^{\text{ti}} = \mathbf{I}_{r_1} \otimes \cdots \otimes \operatorname{diag}(d_{j, 1}, \ldots, d_{j, r_j}) \otimes \cdots \otimes \mathbf{I}_{r_d}, $$ exactly analogous to `TensorProductBasis.penalty_matrices()` but operating in the range-space coordinates. Since every retained coefficient direction is, by construction, in some marginal's range space and therefore penalized by at least one of these matrices, `null_space_dimension()` is always `0` for the interaction basis itself. Note that the eigendecomposition used to find each marginal's range space assumes the marginal penalty has a well-separated null space (a numerical tolerance of `1e-10` relative to the largest eigenvalue is used to distinguish "zero"); for marginal bases with unusual or nearly-singular penalties this tolerance may need revisiting. Examples -------- ```{python} import numpy as np from whittaker.smooths.tensor import TensorInteractionBasis from whittaker.smooths.tprs import TPRS rng = np.random.default_rng(0) x = rng.uniform(0, 1, (100, 2)) basis = TensorInteractionBasis([TPRS(k=6), TPRS(k=5)]).fit(x) B = basis.basis_matrix(x) penalties = basis.penalty_matrices() B.shape, len(penalties) ``` TensorProductBasisT2(marginals: 'list[SmoothBasis]') -> 'None' Tensor product basis with full penalty decomposition (`t2()`-style interaction smooth). `TensorProductBasisT2` builds exactly the same basis matrix as `TensorProductBasis` (`te()`) — the row-wise Kronecker product of the marginal bases — but replaces `te()`'s `d` per-direction penalties with a richer decomposition that has one penalty for *every* non-empty subset of the marginal directions, following the "type 2" tensor product construction of Wood, Scheipl and Faraway (2013). Because it uses ordinary quadratic penalties throughout (rather than the null-space/range-space projections used internally by `te()`), the resulting penalties are positive semi-definite by construction and combine cleanly with random-effects representations of the smooth, and it never has negative smoothing-parameter degeneracies that can occasionally affect `te()`. Prefer `TensorProductBasisT2` over plain `TensorProductBasis` when a strictly additive quadratic-penalty structure is needed (e.g. for mixed-model / REML-based fitting), or when the interaction component of the surface is believed to need noticeably different smoothing from any single marginal direction on its own and a dedicated smoothing parameter for that pure-interaction subset is wanted. Notes ----- For `d` marginals with penalties `S_1, \ldots, S_d` and basis dimensions `k_1, \ldots, k_d`, every non-empty subset `\sigma \subseteq \{1, \ldots, d\}` of marginal directions gets its own penalty $$ \mathbf{S}_\sigma = \mathbf{M}_1 \otimes \mathbf{M}_2 \otimes \cdots \otimes \mathbf{M}_d, \qquad \mathbf{M}_j = \begin{cases} \mathbf{S}_j & j \in \sigma \\ \mathbf{I}_{k_j} & j \notin \sigma \end{cases}, $$ giving `2^d - 1` penalties in total (compared to the `d` penalties of `TensorProductBasis`). For two marginals with penalties `S_1, S_2`, this is $$ \{\, \mathbf{S}_1 \otimes \mathbf{I},\ \ \mathbf{I} \otimes \mathbf{S}_2,\ \ \mathbf{S}_1 \otimes \mathbf{S}_2 \,\}, $$ the usual two `te()`-style main-effect-direction penalties plus one additional penalty, `S_1 ⊗ S_2`, that penalizes roughness jointly in *both* directions at once — this extra term is what lets the pure two-way-interaction component of the surface have its own smoothing parameter, separate from either marginal direction's smoothness. As the number of marginals `d` grows, the number of penalties grows exponentially (`2^d - 1`), so this construction is practical mainly for `d = 2` or `d = 3`; `null_space_dimension()` and `identifiability_constraints()` are inherited unchanged from `TensorProductBasis`, since the basis matrix itself does not change — only the penalty is decomposed further. Examples -------- ```{python} import numpy as np from whittaker.smooths.tensor import TensorProductBasisT2 from whittaker.smooths.tprs import TPRS rng = np.random.default_rng(0) x = rng.uniform(0, 1, (100, 2)) basis = TensorProductBasisT2([TPRS(k=6), TPRS(k=5)]).fit(x) B = basis.basis_matrix(x) penalties = basis.penalty_matrices() B.shape, len(penalties) # 3 penalties for 2 marginals (2^2 - 1) ``` ## Shape-constrained smooths Monotone, convex, and concave smooth basis types with built-in shape enforcement. MonotonePSpline(k: 'int' = 20, degree: 'int' = 3, m: 'int' = 2, decreasing: 'bool' = False) -> 'None' Shape-constrained P-spline: monotone increasing or decreasing. Uses the same B-spline basis and difference penalty as `~whittaker.smooths.pspline.PSpline`, but additionally requires the fitted curve `f(x) = \sum_j \beta_j B_j(x)` to be non-decreasing (or, with `decreasing=True`, non-increasing) over the whole domain. This is enforced as a linear inequality constraint on the coefficients rather than on `f` directly: because each B-spline basis function `B_j` is non-negative and has local support (a "bump" that overlaps only its neighbours), a non-decreasing sequence of coefficients `\beta_1 \le \beta_2 \le \dots \le \beta_k` guarantees a non-decreasing curve. Intuitively, moving `x` to the right shifts the basis functions' weight away from earlier, smaller-or-equal coefficients and onto later, larger-or-equal ones, so the weighted sum can only stay flat or increase. Use `MonotonePSpline` (via `s(x, bs="mpi")` for increasing or `s(x, bs="mpd")` for decreasing in a formula) whenever domain knowledge says the relationship must be monotone — e.g. a dose-response curve, a cumulative distribution, or a growth curve — and an unconstrained smooth would otherwise wiggle non-monotonically due to noise. Parameters ---------- k: Number of B-spline basis functions. The default is `20`. degree: B-spline polynomial degree. The default is `3` (cubic). m: Difference penalty order. The default is `2`. decreasing: If `True`, enforce monotone *decreasing*. The default is `False` (monotone increasing). Notes ----- The monotonicity constraint is enforced during fitting, not by direct constrained optimization. Instead, at each penalized iteratively reweighted least squares (P-IRLS) iteration the ordinary (unconstrained) coefficient update is projected onto the monotone cone: `whittaker.fitting.pirls` detects any smooth term whose basis is a `MonotonePSpline` and passes its coefficient block through `project_monotone` before the next iteration's linear predictor is formed. This projection uses the Pool Adjacent Violators Algorithm (PAVA), the standard algorithm for isotonic regression (Barlow, Bartholomew, Bremner & Brunk, 1972; Best & Chakravarti, 1990). Given an arbitrary vector, PAVA finds the closest non-decreasing vector to it in the least-squares sense by scanning for adjacent "violations" (a value followed by a smaller one) and replacing each violating block with its mean, merging blocks until no violations remain. Because this is an orthogonal projection onto the convex cone of non-decreasing sequences, iterating it alongside the P-IRLS coefficient update drives the fit toward the coefficient vector, within that cone, that best balances the penalized deviance and the constraint. Examples -------- ```{python} import numpy as np import whittaker as wt rng = np.random.default_rng(0) x = np.sort(rng.uniform(0, 1, 200)) y = 3 * x + rng.normal(scale=0.15, size=200) model = wt.GAM("y ~ s(x, bs='mpi')").fit({"x": x, "y": y}) new_x = np.linspace(0, 1, 50) fitted = model.predict({"x": new_x}).values np.all(np.diff(fitted) >= -1e-8) ``` ConvexPSpline(k: 'int' = 20, degree: 'int' = 3, m: 'int' = 2, concave: 'bool' = False) -> 'None' Shape-constrained P-spline: convex or concave. Uses the same B-spline basis and difference penalty as `~whittaker.smooths.pspline.PSpline`, but additionally requires the fitted curve `f(x) = \sum_j \beta_j B_j(x)` to be convex (or, with `concave=True`, concave) over the whole domain. As with `MonotonePSpline`, this is enforced as a linear inequality on the coefficients: for an equally-spaced B-spline basis, the curve is convex whenever the second differences of the coefficients, `\Delta^2 \beta_j = \beta_j - 2\beta_{j-1} + \beta_{j-2}`, are all non-negative. Use `ConvexPSpline` (via `s(x, bs="cx")` for convex or `s(x, bs="cv")` for concave in a formula) when the relationship is known to have a single bend of consistent curvature — e.g. a cost curve, a learning curve, or a concave production function — and an unconstrained smooth would otherwise produce spurious inflection points from noise. Parameters ---------- k: Number of B-spline basis functions. The default is `20`. degree: B-spline polynomial degree. The default is `3` (cubic). m: Difference penalty order. The default is `2`. concave: If `True`, enforce concavity. The default is `False` (convex). Notes ----- As with `MonotonePSpline`, the constraint is enforced during P-IRLS fitting by projecting the ordinary coefficient update onto the convex (or concave) cone after each iteration: smooth terms whose basis is a `ConvexPSpline` have their coefficient block passed through `project_convex` before the next iteration's linear predictor is formed (see `whittaker.fitting.pirls`). The projection extends the monotone PAVA projection by one order of differencing: convexity requires the *first* differences of the coefficients, `d_j = \beta_j - \beta_{j-1}`, to form a non-decreasing sequence (equivalently, that the second differences of `\beta` are non-negative), so `project_convex` computes the first differences, projects *them* onto the monotone cone with PAVA (see the `Notes` on `MonotonePSpline` for the algorithm), and then reconstructs `\beta` by cumulatively summing the projected differences back up from `\beta_0`. Concavity is handled by negating the differences before and after the PAVA projection, mirroring `decreasing=True` for `MonotonePSpline`. Examples -------- ```{python} import numpy as np import whittaker as wt rng = np.random.default_rng(0) x = np.sort(rng.uniform(-1, 1, 200)) y = x**2 + rng.normal(scale=0.1, size=200) model = wt.GAM("y ~ s(x, bs='cx')").fit({"x": x, "y": y}) new_x = np.linspace(-1, 1, 50) fitted = model.predict({"x": new_x}).values second_diff = np.diff(fitted, n=2) # Second differences are non-negative up to fitting/projection tolerance. np.all(second_diff >= -1e-2) ``` ## Quantile regression Quantile GAMs with optional non-crossing constraints. QuantileGAM(formula: 'str | Formula', quantiles: 'list[float] | None' = None, *, sigma: 'float' = 0.1, non_crossing: 'bool' = True) -> 'None' Non-crossing quantile GAM. Fits a separate additive quantile regression model for each requested quantile level `tau`, and enforces the natural ordering constraint that quantile curves must not cross: for `tau_1 < tau_2`, the fitted curve `q_{tau_1}(x)` must lie at or below `q_{tau_2}(x)` at every observed covariate combination. Ordinary quantile GAMs, fit independently for each `tau`, provide no such guarantee and can produce curves that cross, especially in regions with sparse data or heavy smoothing. Use `QuantileGAM` whenever you need multiple quantiles of a conditional distribution (e.g. to build a prediction interval or characterize skewness/heteroscedasticity) and want the estimated quantiles to respect the required monotone ordering in `tau`. Parameters ---------- formula: Model formula (e.g. `"y ~ s(x)"`), shared across all quantile levels; only the loss function differs between them. quantiles: Quantile levels to fit. Must be in `(0, 1)` and will be sorted. Defaults to `[0.1, 0.25, 0.5, 0.75, 0.9]`. sigma: Bandwidth of the smoothed pinball ("extended log-F", ELF) loss used to approximate the non-differentiable quantile check loss. Smaller `sigma` more closely approximates the true quantile loss but can slow IRLS convergence; use `calibrate_sigma()` to select it via cross-validation. non_crossing: If `True` (default), enforce the non-crossing constraint via iterative isotonic projection. If `False`, quantiles are fit completely independently and may cross. Notes ----- Each quantile is fit by minimizing a smoothed pinball loss (the ELF loss of Fasiolo et al. 2021), which approximates the quantile check function $$\rho_\tau(u) = u \, (\tau - \mathbb{1}[u < 0])$$ with a twice-differentiable surrogate suitable for IRLS. After each round of fitting, the vector of fitted quantiles at every observation, `[q_{\tau_1}(x_i), \dots, q_{\tau_k}(x_i)]`, is checked for monotonicity; if it is violated anywhere, the vector is projected onto the monotone non-decreasing cone via the pool-adjacent-violators algorithm (PAVA), following the "stepwise projection" strategy of Bondell, Reich, & Wang (2010). The projected fitted values are then used to re-derive coefficients (via a least-squares refit against the corrected working response), and the cycle repeats for up to `max_iter` rounds or until no crossings remain. Examples -------- ```{python} import numpy as np from whittaker.quantile_gam import QuantileGAM rng = np.random.default_rng(0) n = 500 x = rng.uniform(0, 1, n) y = np.sin(2 * np.pi * x) + rng.normal(scale=0.2 + 0.3 * x, size=n) model = QuantileGAM("y ~ s(x)", quantiles=[0.1, 0.25, 0.5, 0.75, 0.9]) model.fit({"x": x, "y": y}) preds = model.predict({"x": x[:5]}) # dict of tau -> PredictionResult print(model.crossing_fraction()) ``` QuantileGAMResult(quantiles: 'list[float]', models: 'dict[float, GAM]', coefficients: 'dict[float, NDArray]') -> None Result container for a fitted QuantileGAM. Bundles the per-quantile fitted `GAM` objects and coefficient vectors produced by `QuantileGAM.fit()`, keyed by the quantile level `tau` they estimate. Attributes ---------- quantiles: Sorted quantile levels, each in `(0, 1)`. models: Dict mapping `tau -> fitted GAM`, one GAM per quantile level, each fit with the expectile/quantile loss family for that `tau`. coefficients: Dict mapping `tau -> coefficient vector`, the fitted basis coefficients for each quantile's model. QuantileFamily(tau: 'float' = 0.5, sigma: 'float' = 1.0) -> 'None' Quantile regression via the Extended Log-F (ELF) pseudo-family. `QuantileFamily` fits a single conditional quantile `tau` of the response — for example the median (`tau=0.5`) or the 90th percentile (`tau=0.9`) — rather than the conditional mean targeted by families such as `Gaussian` or `Gamma`. This is useful whenever the object of interest is not the average behavior of the response but a specific point in its distribution, e.g. modeling the upper tail of a skewed cost distribution, or building prediction bands by fitting several quantiles (`tau` values) side by side. Rather than the non-smooth pinball ("check") loss used by classical quantile regression, Whittaker uses the smooth Extended Log-F (ELF) approximation of Fasiolo et al. (2021), which is differentiable and therefore fits within the standard P-IRLS loop via a custom `irls_update`. To fit several quantiles jointly with a shared smoothness structure, fit one `QuantileFamily` per `tau` and compare/combine the resulting models, or see `ConformalPredictor` for distribution-free coverage guarantees around a fitted mean model. Parameters ---------- tau : float, default=0.5 Quantile level to estimate, in `(0, 1)`. `tau=0.5` corresponds to median regression; smaller values target lower quantiles and larger values target upper quantiles. sigma : float, default=1.0 Bandwidth (smoothing) parameter controlling how closely the ELF loss approximates the non-smooth pinball loss. Smaller values give a sharper, more faithful approximation to the pinball loss (and to the check-function optimum) but a less smooth optimization surface; larger values give a smoother but more biased approximation. `sigma` may be adjusted after construction via the `sigma` property, e.g. to anneal it across fitting iterations. Notes ----- `QuantileFamily` has no meaningful link or variance function in the usual GLM sense — `link` and `link_inverse` are the identity on `eta` — because fitting instead minimizes the ELF loss directly. For a residual `r = y - mu`, the ELF loss is $$ \rho_{\tau,\sigma}(r) = \tau r + \sigma \log\!\left(1 + e^{-r/\sigma}\right), $$ which converges to the pinball loss $\rho_\tau(r) = \tau r \, \mathbb{1}[r \ge 0] - (1-\tau) r \, \mathbb{1}[r < 0]$ as $\sigma \to 0$. Its first and second derivatives with respect to `mu`, $$ \frac{\partial \rho}{\partial \mu} = -\left[\tau - 1 + \operatorname{expit}(r/\sigma)\right], \qquad \frac{\partial^2 \rho}{\partial \mu^2} = \frac{1}{\sigma}\, s (1 - s), \quad s = \operatorname{expit}(r/\sigma), $$ supply the working response `z` and working weight `W` used by the custom `irls_update`. The reported "deviance" is `2 * sum(ELF loss)`, so that it reduces to twice the usual pinball loss in the limit `sigma -> 0`. Examples -------- Fit the 10th, 50th, and 90th percentile curves of a heteroscedastic response: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = np.sin(x) noise_scale = 0.2 + 0.3 * np.abs(np.cos(x)) y = mu + rng.normal(0, noise_scale, n) data = {"x": x, "y": y} for tau in (0.1, 0.5, 0.9): model = wk.GAM("y ~ s(x)", family=wk.QuantileFamily(tau=tau)) model.fit(data, method="REML") print(f"tau={tau}:") print(model.summary()) ``` calibrate_sigma(formula: 'str', data: 'InputData', tau: 'float' = 0.5, *, n_folds: 'int' = 5, sigma_values: 'NDArray | list[float] | None' = None, method: 'str' = 'GCV', seed: 'int | None' = None) -> 'float' Find the ELF bandwidth sigma that minimises out-of-sample ELF loss. The quantile family used by `QuantileGAM` and `GAM` with quantile loss replaces the non-differentiable check function with a smooth "extended log-F" (ELF) surrogate controlled by a bandwidth `sigma`. Too large a `sigma` over-smooths the check loss and biases the fitted quantile; too small a `sigma` makes the surrogate nearly non-differentiable again and can destabilize IRLS. `calibrate_sigma` selects `sigma` empirically by K-fold cross-validation: for each candidate value, the model is fit on `K - 1` folds, predictions are made on the held-out fold, and the true (non-smoothed) pinball loss $$\rho_\tau(y - \hat q_\tau) = (y - \hat q_\tau)\,(\tau - \mathbb{1}[y < \hat q_\tau])$$ is accumulated across folds. The sigma minimizing total out-of-sample pinball loss is refined with a second, finer grid search around the best value from the coarse grid. Parameters ---------- formula: GAM formula string, e.g. `"y ~ s(x)"`. data: Column-oriented data dict. tau: Target quantile level in `(0, 1)`. n_folds: Number of CV folds. sigma_values: Candidate sigma values to evaluate. If `None`, a log-spaced grid of 10 values from `0.01 * sd(y)` to `2 * sd(y)` is used, followed by a refinement grid around the best value. If an explicit grid is passed, no refinement step is performed. method: Smoothing parameter selection method used when fitting each candidate model (`"GCV"`, `"REML"`, `"ML"`). seed: Random seed for fold assignment. Returns ------- float Calibrated sigma value (the one minimizing CV pinball loss). Examples -------- ```{python} import numpy as np from whittaker.calibration import calibrate_sigma rng = np.random.default_rng(0) n = 400 x = rng.uniform(0, 1, n) y = np.sin(2 * np.pi * x) + rng.normal(scale=0.2 + 0.3 * x, size=n) best_sigma = calibrate_sigma("y ~ s(x)", {"x": x, "y": y}, tau=0.9, n_folds=5, seed=0) print(round(best_sigma, 4)) ``` ## Conformal prediction Distribution-free prediction intervals via split, CV+, and jackknife+ conformal methods. conformal_fit(formula: 'str', data: 'InputData', *, method: 'str' = 'split', family: 'Family | None' = None, level: 'float' = 0.95, cal_fraction: 'float' = 0.25, n_folds: 'int' = 10, fit_method: 'str' = 'REML', select: 'bool' = False, seed: 'int | None' = None) -> 'ConformalPredictor' Fit a GAM with conformal calibration. Fits a `GAM` and calibrates it so that `ConformalPredictor.predict()` returns prediction intervals with a distribution-free, finite-sample marginal coverage guarantee, using one of three conformal methods: - **Split conformal**: the data is randomly split into a training set (fit the GAM) and a calibration set (compute absolute residuals). The interval half-width is the `ceil((n_cal+1) * level) / n_cal` empirical quantile of the calibration residuals, giving intervals of constant width `values +/- quantile`. Simple and fast, but "wastes" data on the calibration split and can be less efficient than the alternatives. - **CV+**: the data is split into `n_folds` folds; each fold is used to compute out-of-fold residuals from a model trained on the rest. At prediction time, all fold models' predictions are combined with all fold residuals via a min/max construction (Barber et al. 2021), giving tighter, per-observation intervals without a dedicated calibration split. - **Jackknife+**: the leave-one-out analogue of CV+, using `n` individual leave-one-out refits. Provides the tightest intervals of the three but is the most computationally expensive since it requires `n` refits. Parameters ---------- formula: GAM formula string. data: Column-oriented data dict. method: Conformal method: `"split"` (default), `"cv+"`, or `"jackknife+"`. family: Response distribution family. Defaults to `Gaussian()`. level: Nominal coverage probability (default `0.95`). cal_fraction: Fraction of data held out for calibration in the split method (default `0.25`). Ignored for `"cv+"` and `"jackknife+"`. n_folds: Number of folds for the `"cv+"` method (default `10`). Ignored for `"split"` and `"jackknife+"`. fit_method: Smoothing parameter selection method for the GAM (default `"REML"`). select: If `True`, enable double-penalty variable selection. seed: Random seed for data splitting. Notes ----- Split conformal computes the calibration quantile as $$\hat q = \left\lceil (n_{\text{cal}} + 1) \cdot \text{level} \right\rceil \big/ n_{\text{cal}} \quad \text{quantile of} \quad \{|y_i - \hat\mu(x_i)| : i \in \text{calibration set}\},$$ which, under exchangeability of calibration and test points, guarantees `P(y \in [\hat\mu(x) - \hat q, \hat\mu(x) + \hat q]) \ge \text{level}` marginally over new draws. CV+ and jackknife+ replace this single quantile with, for each test point, the appropriate quantile of the `n` (or `n_folds`) values `{fold/LOO prediction +/- that fold's residual}`, trading extra computation for tighter, locally-adapted intervals while retaining the same finite-sample coverage guarantee. Returns ------- ConformalPredictor A calibrated predictor that can produce intervals on new data. Examples -------- ```{python} import numpy as np from whittaker.conformal import conformal_fit, conformal_coverage rng = np.random.default_rng(0) n = 500 x = rng.uniform(0, 1, n) y = np.sin(2 * np.pi * x) + rng.normal(scale=0.3, size=n) predictor = conformal_fit("y ~ s(x)", {"x": x, "y": y}, method="split", level=0.9, seed=0) result = predictor.predict({"x": x[:5]}) print(result.lower, result.upper) print(conformal_coverage(predictor, {"x": x, "y": y}, response="y")) ``` conformal_coverage(predictor: 'ConformalPredictor', data: 'InputData', response: 'str') -> 'float' Compute empirical coverage of conformal intervals on held-out data. A useful sanity check that the realized coverage on a given dataset is close to (at least) the nominal `predictor.level`; systematic under-coverage may indicate a violation of the exchangeability assumption underlying conformal prediction (e.g. distribution shift between calibration and test data). Parameters ---------- predictor: A fitted `ConformalPredictor`. data: Data containing both covariates and the response. response: Name of the response variable. Returns ------- float Fraction of observations falling within the conformal interval. ConformalPredictor(model: 'GAM', calibration_scores: 'NDArray', quantile: 'float', level: 'float', method: 'str', _models: 'list[GAM] | None' = None, _fold_preds: 'NDArray | None' = None, _fold_ids: 'NDArray | None' = None) -> None A calibrated conformal predictor ready to produce intervals. Created by `conformal_fit()`. Wraps a fitted `GAM` (or, for `"cv+"`/`"jackknife+"`, an ensemble of fold/leave-one-out GAMs) together with the conformity scores from calibration, so that `predict()` on new data returns intervals with a finite-sample marginal coverage guarantee that does not rely on the GAM's error distribution being correctly specified — only on the calibration and test data being exchangeable. Attributes ---------- model: The GAM used for point predictions: fit on the training split for `"split"`, or on the full data for `"cv+"`/`"jackknife+"` (in which case predictions are instead ensembled from the per-fold/per-observation models). calibration_scores: Absolute residuals from the calibration step (calibration split, K-fold, or leave-one-out, depending on `method`). quantile: The calibration quantile of `calibration_scores` used to set interval half-width (`"split"` only). level: Nominal coverage level. method: Conformal method: `"split"`, `"cv+"`, or `"jackknife+"`. ConformalResult(values: 'NDArray', lower: 'NDArray', upper: 'NDArray', level: 'float', method: 'str', calibration_scores: 'NDArray', quantile: 'float') -> None Result of conformal prediction. Returned by `ConformalPredictor.predict()`. Holds point predictions together with distribution-free prediction intervals whose coverage is guaranteed (under exchangeability) to be at least the nominal `level`, regardless of whether the underlying `GAM` is correctly specified. Attributes ---------- values: Point predictions (response scale); for `"cv+"` and `"jackknife+"` this is the average of the fold/leave-one-out models' predictions. lower: Lower prediction bounds. upper: Upper prediction bounds. level: Nominal coverage level (e.g. `0.95`). method: Conformal method used (`"split"`, `"cv+"`, or `"jackknife+"`). calibration_scores: Conformity scores (absolute residuals) from the calibration step. quantile: The calibration quantile used for interval width (only meaningful for the `"split"` method, where the interval is `values +/- quantile`; for `"cv+"`/`"jackknife+"` interval bounds vary per observation and are not simply `values +/- quantile`). ## Causal inference Causal GAMs using double/debiased machine learning (DML), heterogeneous treatment effect estimation (CATE), and mediation analysis. CausalGAM(outcome: 'str', treatment: 'str', confounders: 'list[str]', *, method: 'str' = 'partially_linear', family: 'Family | None' = None, n_folds: 'int' = 5) -> 'None' Causal GAM for treatment effect estimation. Estimates the causal effect of a treatment `D` on an outcome `Y`, controlling for a set of confounders `X`, using double/debiased machine learning (DML; Chernozhukov et al. 2018) with GAM nuisance models. Two structural forms are supported: - **Partially linear** (`method="partially_linear"`): `Y = theta * D + f(X) + eps`, giving a single constant average treatment effect (ATE) `theta`. - **Interactive** (`method="interactive"`): `Y = g(D, X) + eps`, allowing the treatment effect to vary smoothly with `X` (conditional average treatment effect, CATE). DML addresses the regularization bias that arises when flexible, penalized nuisance models (here, GAMs for `E[Y | X]` and `E[D | X]`) are plugged directly into a naive treatment-effect estimator: the smoothing bias in the nuisance fits would otherwise leak into the treatment effect estimate. Cross-fitting (fitting nuisance models on one subset of folds and evaluating residuals on the held-out fold) together with a Neyman-orthogonal moment condition makes the resulting ATE estimate root-n consistent and asymptotically normal even though the nuisance GAMs converge at slower nonparametric rates. Use `CausalGAM` for observational-data effect estimation where confounding is plausibly captured by smooth functions of observed covariates, and you want valid inference (standard errors, confidence intervals) on the treatment effect rather than just a point prediction. Parameters ---------- outcome: Name of the outcome variable. treatment: Name of the treatment variable. confounders: List of confounder variable names. Both the outcome and treatment nuisance GAMs use `s(c)` smooth terms for each confounder `c`. method: `"partially_linear"` (default) for constant ATE, or `"interactive"` for heterogeneous treatment effects (enables `.cate()`). family: Response distribution for the outcome nuisance model. Defaults to `Gaussian()`. The treatment nuisance model always uses `Gaussian()` regardless of this setting, since DML residualizes the treatment via its conditional mean. n_folds: Number of cross-fitting folds for DML (default `5`). Each fold's nuisance models are fit on the other `n_folds - 1` folds and evaluated on the held-out fold to avoid overfitting bias. Notes ----- Fitting proceeds in three steps. First, cross-fitted residuals are formed for both outcome and treatment: $$\hat\varepsilon_{Y,i} = Y_i - \hat m_Y(X_i), \qquad \hat\varepsilon_{D,i} = D_i - \hat m_D(X_i)$$ where `\hat m_Y` and `\hat m_D` are GAM estimates of `E[Y \mid X]` and `E[D \mid X]`, each fit on folds excluding observation `i`. Second, the ATE is estimated by the residual-on-residual regression (the partialling-out estimator): $$\hat\theta = \frac{\sum_i \hat\varepsilon_{D,i} \, \hat\varepsilon_{Y,i}} {\sum_i \hat\varepsilon_{D,i}^2}$$ Third, its standard error is derived from the empirical variance of the Neyman-orthogonal score $\psi_i = \hat\varepsilon_{D,i}(\hat\varepsilon_{Y,i} - \hat\theta \hat\varepsilon_{D,i})$: $$\widehat{\mathrm{se}}(\hat\theta) = \sqrt{\frac{\overline{\psi^2}} {\left(\sum_i \hat\varepsilon_{D,i}^2\right)^{2} / n}}$$ When `method="interactive"`, a further GAM is fit on the pseudo-outcome `\hat\varepsilon_{Y,i} / \hat\varepsilon_{D,i}`, weighted by `\hat\varepsilon_{D,i}^2`, to recover the CATE as a smooth function of the confounders. Examples -------- ```{python} import numpy as np from whittaker.causal import CausalGAM rng = np.random.default_rng(0) n = 1000 x = rng.uniform(0, 1, n) d = rng.binomial(1, 1 / (1 + np.exp(-(2 * x - 1))), n).astype(float) y = 1.5 * d + np.sin(2 * np.pi * x) + rng.normal(scale=0.3, size=n) model = CausalGAM(outcome="y", treatment="d", confounders=["x"], n_folds=5) model.fit({"x": x, "d": d, "y": y}, seed=0) print(model.treatment_effect()) ``` TreatmentEffect(ate: 'float', se: 'float', ci_lower: 'float', ci_upper: 'float', level: 'float', p_value: 'float', method: 'str', n_obs: 'int') -> None Average treatment effect estimate with inference. Returned by `CausalGAM.treatment_effect()`, this holds the debiased/double-machine-learning estimate of the average treatment effect (ATE) together with its standard error, confidence interval, and a Wald test against the null of no effect. Attributes ---------- ate: Estimated average treatment effect: the coefficient `theta` in the partially linear model `Y = theta * D + f(X) + eps`, or its interactive-model analogue. se: Standard error of the ATE estimate, computed from the influence function of the DML moment condition. ci_lower: Lower bound of the `level`-confidence interval, `ate - z * se`. ci_upper: Upper bound of the `level`-confidence interval, `ate + z * se`. level: Confidence level used to construct the interval (e.g. `0.95`). p_value: Two-sided p-value for `H0: ATE = 0`, from a normal (Wald) approximation. method: Estimation method used (`"partially_linear"` or `"interactive"`). n_obs: Number of observations used in estimation. CATEResult(x: 'NDArray', cate: 'NDArray', se: 'NDArray', lower: 'NDArray', upper: 'NDArray', variable: 'str', level: 'float') -> None Conditional average treatment effect estimates. Returned by `CausalGAM.cate()` when the model was fit with `method="interactive"`. Represents the treatment effect as a smooth function of one confounder variable, evaluated on a grid (or on user-supplied covariate data), together with pointwise confidence bands. Attributes ---------- x: Covariate values of `variable` at which CATE is evaluated. cate: CATE estimates, `tau(x) = E[Y(1) - Y(0) | X = x]`, at each value of `x`. se: Standard errors of the CATE estimates. lower: Lower pointwise confidence bounds, `cate - z * se`. upper: Upper pointwise confidence bounds, `cate + z * se`. variable: Name of the conditioning (confounder) variable that CATE is plotted against. level: Confidence level used for the bands. mediation_analysis(outcome: 'str', treatment: 'str', mediator: 'str', confounders: 'list[str]', data: 'InputData', *, family: 'Family | None' = None, fit_method: 'str' = 'REML', select: 'bool' = False, n_simulations: 'int' = 1000, seed: 'int | None' = None) -> 'MediationResult' Causal mediation analysis with GAM nuisance models. Estimates how much of the total effect of `treatment` on `outcome` operates through an intermediate `mediator` variable, versus acting directly, controlling for `confounders`. Uses the simulation-based approach of Imai, Keele, & Tingley (2010): a mediator GAM `E[M | D, X]` and an outcome GAM `E[Y | D, M, X]` are fit on the observed data, and then used to predict the outcome under counterfactual combinations of treatment and mediator status that isolate the direct and indirect pathways. Use this when you have a hypothesized causal chain `treatment -> mediator -> outcome` (plus a possible direct `treatment -> outcome` path) and want to decompose the total causal effect into how much passes through the mediator versus how much does not. Parameters ---------- outcome: Name of the outcome variable. treatment: Name of the treatment variable (binary 0/1). mediator: Name of the mediator variable. confounders: List of confounder variable names, entered as smooth terms in both the mediator and outcome models. data: Column-oriented data containing outcome, treatment, mediator, and confounder columns. family: Response distribution for the outcome model. Defaults to `Gaussian()`. The mediator model always uses `Gaussian()`. fit_method: Smoothing parameter selection method for both nuisance models. select: Enable double-penalty variable selection in the nuisance models. n_simulations: Number of bootstrap resamples used to estimate standard errors for the total, direct, and indirect effects. seed: Random seed for the bootstrap. Notes ----- Natural direct and indirect effects are computed by contrasting predicted outcomes under three counterfactual scenarios, holding treatment fixed at `d \in \{0, 1\}` and setting the mediator to its predicted value under either treatment level: $$\text{indirect} = \frac{1}{n}\sum_i \left[\hat Y_i(1, \hat M_i(1)) - \hat Y_i(1, \hat M_i(0))\right], \qquad \text{direct} = \frac{1}{n}\sum_i \left[\hat Y_i(1, \hat M_i(0)) - \hat Y_i(0, \hat M_i(0))\right]$$ where `\hat Y_i(d, m)` is the outcome GAM's prediction with treatment set to `d` and mediator set to `m`, and `\hat M_i(d)` is the mediator GAM's prediction with treatment set to `d`. The total effect is `indirect + direct`, and `proportion_mediated = indirect / total`. Standard errors for all three quantities come from re-running the full procedure (refitting both GAMs) on `n_simulations` bootstrap resamples of the data. Returns ------- MediationResult The total, direct, and indirect effects with bootstrap standard errors, and the proportion of the total effect that is mediated. Examples -------- ```{python} import numpy as np from whittaker.causal import mediation_analysis rng = np.random.default_rng(0) n = 500 x = rng.uniform(0, 1, n) d = rng.binomial(1, 0.5, n).astype(float) m = 0.5 * d + 0.3 * x + rng.normal(scale=0.2, size=n) y = 0.4 * d + 0.8 * m + np.sin(2 * np.pi * x) + rng.normal(scale=0.3, size=n) result = mediation_analysis( outcome="y", treatment="d", mediator="m", confounders=["x"], data={"x": x, "d": d, "m": m, "y": y}, n_simulations=200, seed=0, ) print(result) ``` MediationResult(total_effect: 'float', direct_effect: 'float', indirect_effect: 'float', proportion_mediated: 'float', total_se: 'float', direct_se: 'float', indirect_se: 'float', n_obs: 'int') -> None Mediation analysis results. Returned by `mediation_analysis()`. Decomposes the total effect of a treatment on an outcome into a direct component (not passing through the mediator) and an indirect component (passing through the mediator), following the potential-outcomes framework of Imai, Keele, & Tingley (2010). Standard errors are obtained by a nonparametric bootstrap over the whole estimation procedure (refitting both the mediator and outcome GAMs on each resample). Attributes ---------- total_effect: Total effect of treatment on outcome, `direct_effect + indirect_effect`. direct_effect: Direct effect of treatment on outcome, holding the mediator fixed at its value under treatment (natural direct effect). indirect_effect: Indirect effect of treatment on outcome operating through the mediator (natural indirect effect). proportion_mediated: Fraction of the total effect attributable to the mediator, `indirect_effect / total_effect`. total_se: Bootstrap standard error of the total effect. direct_se: Bootstrap standard error of the direct effect. indirect_se: Bootstrap standard error of the indirect effect. n_obs: Number of observations. ## Streaming and online GAMs Incremental fitting via sufficient statistics for data that arrives in batches. StreamingGAM(formula: 'str | Formula', *, family: 'Family | None' = None, decay: 'float' = 1.0, smoothing_params: 'list[float] | None' = None) -> 'None' Streaming / online GAM. Fits a GAM incrementally by accumulating weighted sufficient statistics (`X'WX` and `X'Wz`) across data batches, rather than storing and re-fitting on the full dataset each time. This makes it suitable for data arriving continuously or in a stream too large to hold in memory at once: the memory footprint depends only on the number of basis coefficients `p` (via a `p x p` matrix), not on the number of observations seen. The model structure (formula, basis dimensions, penalties) is fixed at initialisation from a small pilot batch, and subsequent `partial_fit()` calls add data without storing the raw observations. Two update modes are supported: **accumulate mode** (`decay=1.0`, the default), where every batch contributes equally regardless of when it arrived, and **sliding-window mode** (`decay < 1.0`), where older batches' contributions to the accumulated statistics are exponentially downweighted, allowing the model to adapt to a slowly drifting data-generating process. Use `StreamingGAM` for large or continuously-arriving datasets where holding the full data in memory (as ordinary `GAM` does) is impractical, or where the underlying relationship may drift over time and old data should be forgotten. Parameters ---------- formula: Model formula (e.g. `"y ~ s(x1) + s(x2)"`). family: Response distribution family. Defaults to `Gaussian()`. decay: Exponential decay factor for sliding window. `1.0` (default) means no decay (accumulate all data equally). Values less than `1.0` downweight older batches' sufficient statistics by a factor of `decay` every time a new batch arrives. smoothing_params: Fixed smoothing parameters. If `None`, estimated from the pilot batch (via a one-off ordinary `GAM` fit with REML) and optionally re-estimated later via `solve(reestimate_smoothing=True)`. Notes ----- Each `partial_fit()` call treats the incoming batch as one step of iteratively reweighted least squares: given the current coefficients, it computes working responses `z` and IRLS weights `W` for the batch, forms the batch's contribution to the weighted normal equations, $$X_{\text{batch}}' W X_{\text{batch}}, \qquad X_{\text{batch}}' W z_{\text{batch}},$$ and adds these to the running totals (after applying the decay factor, if any, to the existing totals). Calling `solve()` then solves the penalized normal equations $$\left(\sum_{\text{batches}} X'WX + S_\lambda\right) \beta = \sum_{\text{batches}} X'Wz$$ via a Cholesky factorization, where `S_\lambda = \sum_j \lambda_j S_j` is the weighted sum of penalty matrices. Because only the accumulated `p x p` matrix `X'WX` and length-`p` vector `X'Wz` are retained, this scales to arbitrarily many observations at fixed memory cost in `p`. Examples -------- ```{python} import numpy as np from whittaker.streaming import StreamingGAM rng = np.random.default_rng(0) model = StreamingGAM("y ~ s(x)") for _ in range(5): x = rng.uniform(0, 1, 200) y = np.sin(2 * np.pi * x) + rng.normal(scale=0.2, size=200) model.partial_fit({"x": x, "y": y}) model.solve() print(model.summary()) ``` StreamingSnapshot(n_obs: 'int', n_batches: 'int', coefficients: 'NDArray', smoothing_params: 'list[float]', edf_total: 'float', scale: 'float', deviance: 'float') -> None A snapshot of streaming GAM state at a point in time. Appended to `StreamingGAM`'s history every time `solve()` is called, letting you track how coefficients, smoothing parameters, and fit diagnostics evolve as more batches of data arrive. Retrieved via `StreamingGAM.smoothing_history()`. Attributes ---------- n_obs: Total (possibly decayed, under sliding-window mode) observation count at the time of this `solve()` call. n_batches: Number of batches processed so far. coefficients: Coefficient estimates at this solve. smoothing_params: Smoothing parameters used for this solve. edf_total: Total effective degrees of freedom at this solve. scale: Estimated scale (dispersion) parameter at this solve. deviance: Accumulated (possibly decayed) deviance at this solve. ## Multi-response GAMs Joint fitting of multiple response variables with optional residual correlation modeling. MultiResponseGAM(responses: 'list[str]', formula: 'str', *, response_formulas: 'dict[str, str] | None' = None, family: 'Family | None' = None, correlation: 'str' = 'independent') -> 'None' Multi-response GAM. Fits multiple response variables jointly against a common set of covariates, optionally sharing smooth terms (same basis and formula structure, though each response still gets its own coefficients) and estimating the residual correlation between responses. Internally, each response is fit as its own `GAM` using the shared formula plus any response-specific additional terms; what makes this a genuinely multivariate model rather than just several independent fits is the optional joint residual covariance structure, which is useful for understanding how responses co-vary after accounting for the shared covariates, and for joint (GLS-style) prediction via `joint_predict()`. Use `MultiResponseGAM` when you have several related outcomes measured on the same units (e.g. multiple biomarkers, or several pollutant concentrations) that likely share similar covariate relationships and whose residuals may be correlated. Parameters ---------- responses: List of response variable names (at least two). formula: Shared formula applied to all responses (e.g. `"s(x1) + s(x2)"`). The response side (before `~`, if present) is ignored; use `responses` to specify the response variables. response_formulas: Dict mapping response name to a response-specific formula string (covariates only, e.g., `{"y1": "s(x3)"}`) added on top of the shared formula for that response only. family: Response distribution family (applied to all responses). Defaults to `Gaussian()`. correlation: Residual correlation structure: `"independent"` (default), which fits each response's `GAM` independently with no cross-response covariance modeling, or `"unstructured"`, which additionally estimates a full `k x k` residual covariance matrix from the fitted residuals. Notes ----- Under `correlation="unstructured"`, after each response's GAM is fit, the residual matrix `R \in \mathbb{R}^{n \times k}` (columns are `y_j - \hat y_j` for each response `j`) is used to estimate the residual covariance, $$\hat\Sigma = \frac{R^\top R}{n - 1},$$ and the corresponding correlation matrix by rescaling to unit diagonal. This does not feed back into how the individual response GAMs are fit (each is still fit marginally), but it is used by `joint_predict()` to report a joint covariance alongside the stacked mean predictions, and by `residual_correlation()` for diagnosing cross-response dependence. Examples -------- ```{python} import numpy as np from whittaker.multi_response import MultiResponseGAM rng = np.random.default_rng(0) n = 400 x = rng.uniform(0, 1, n) shared = np.sin(2 * np.pi * x) y1 = shared + rng.normal(scale=0.2, size=n) y2 = 0.5 * shared + rng.normal(scale=0.2, size=n) model = MultiResponseGAM(["y1", "y2"], "s(x)", correlation="unstructured") model.fit({"x": x, "y1": y1, "y2": y2}) print(model.residual_correlation()) ``` MultiResponseResult(predictions: 'dict[str, PredictionResult]', responses: 'list[str]') -> None Prediction result for multiple responses. Returned by `MultiResponseGAM.predict()`. Behaves like a dict keyed by response name (via `__getitem__`) and iterates over response names (via `__iter__`), while retaining the ordered list of responses for convenience. Attributes ---------- predictions: Dict mapping response name to its `PredictionResult` (values, optional standard errors, and linear predictor) from that response's individual `GAM`. responses: List of response names (ordered), matching the order passed to `MultiResponseGAM`. ResidualCorrelation(covariance: 'NDArray', correlation: 'NDArray', responses: 'list[str]') -> None Estimated residual correlation structure. Returned by `MultiResponseGAM.residual_correlation()` when the model was fit with `correlation="unstructured"`. Holds the empirical residual covariance and correlation matrices across the `k` jointly modeled responses, estimated from each response's fitted-model residuals. Attributes ---------- covariance: Residual covariance matrix (`k x k`) where `k` = number of responses, computed as `R'R / (n - 1)` where `R` is the `(n, k)` matrix of residuals (observed minus fitted, one column per response). correlation: Residual correlation matrix (`k x k`), the covariance matrix rescaled to unit diagonal. responses: Response names (ordering matches matrix rows/columns). ## Functional regression Scalar-on-function regression where predictors include functional covariates (curves). FunctionalGAM(response: 'str', functional_terms: 'list[FunctionalTerm | dict]', *, scalar_terms: 'str | None' = None, family: 'Family | None' = None) -> 'None' Scalar-on-function GAM. Fits a model where the response is scalar but one or more predictors are functional, i.e. each observation carries an entire curve `X_i(t)` measured over a domain (such as a temperature profile over time, or a spectral curve over wavelength), rather than a single number. Each functional covariate contributes a linear functional term to the predictor, $$\int X_i(t)\,\beta(t)\,dt,$$ where `beta(t)` is an unknown smooth coefficient function that must itself be estimated. This integral is approximated numerically (trapezoidal quadrature over the observed grid) and `beta(t)` is expanded in a B-spline or Fourier basis with a roughness penalty, turning the infinite-dimensional problem of estimating a function into a finite penalized regression that can be fit with the same machinery as any other GAM smooth term. Use `FunctionalGAM` when your predictors are naturally curves or profiles rather than scalars, and you want to recover how different regions of the domain contribute to the response (e.g. "does temperature early in the season matter more than temperature late in the season?"). Parameters ---------- response: Name of the scalar response variable. functional_terms: List of `FunctionalTerm` specifications (or dicts with the same keys), one per functional covariate. scalar_terms: Optional formula string for additional scalar smooth/linear terms (e.g. `"s(temperature) + humidity"`) fit alongside the functional terms. family: Response distribution family. Defaults to `Gaussian()`. Notes ----- For each functional term, the coefficient function is expanded as `\beta(t) = \sum_{k=1}^{K} c_k \phi_k(t)` in a basis `\{\phi_k\}` (B-spline or Fourier), so the functional effect for observation `i` becomes a finite inner product with a numerically integrated design column: $$\int X_i(t)\,\beta(t)\,dt \;\approx\; \sum_{k=1}^{K} c_k \underbrace{\sum_t X_i(t)\,\phi_k(t)\,w_t}_{J_{i,k}}$$ where `w_t` are trapezoidal quadrature weights. The coefficients `c_k` are penalized by a difference penalty (B-spline) or a frequency-based penalty (Fourier) of order `penalty_order`, controlling the smoothness of the recovered `beta(t)`. All functional and scalar design columns are combined into one design matrix and fit jointly via penalized IRLS (`pirls_fit`), so the smoothing parameters for each functional term's coefficient function, and for any scalar smooth terms, are selected simultaneously. Examples -------- ```{python} import numpy as np from whittaker.functional import FunctionalGAM, FunctionalTerm rng = np.random.default_rng(0) n, T = 300, 50 t_grid = np.linspace(0, 1, T) beta_true = np.sin(2 * np.pi * t_grid) X_curves = rng.normal(size=(n, T)) + np.sin(3 * t_grid) y = X_curves @ beta_true / T + rng.normal(scale=0.3, size=n) model = FunctionalGAM("y", [FunctionalTerm(name="X_curves", n_basis=12)]) model.fit({"y": y, "X_curves": X_curves}) cf = model.coefficient_function("X_curves") print(cf.values[:5]) ``` FunctionalTerm(name: 'str', basis: 'str' = 'bspline', domain: 'tuple[float, float]' = (0.0, 1.0), n_basis: 'int' = 15, penalty_order: 'int' = 2) -> None Specification for a functional covariate. Describes how one functional (curve-valued) predictor should enter a `FunctionalGAM`: which basis to expand its coefficient function `beta(t)` in, over what domain, at what resolution, and with what roughness penalty. Attributes ---------- name: Name of the functional covariate in the data dict. The corresponding data entry should be a 2-D array of shape `(n, T)` where `T` is the number of grid points. basis: Basis type for expanding beta(t): `"bspline"` (default), a B-spline basis with a difference penalty, or `"fourier"`, a sine/cosine basis with a penalty on higher frequencies. domain: Tuple `(t_min, t_max)` specifying the domain of the functional argument. Grid points are assumed equally spaced over this domain. n_basis: Number of basis functions used to represent `beta(t)`. Defaults to 15. Must be `>= 3`. penalty_order: Order of the difference penalty (for B-spline) or derivative penalty (for Fourier), controlling how strongly higher-order wiggliness in `beta(t)` is penalized. Defaults to 2 (penalizes curvature). CoefficientFunction(grid: 'NDArray', values: 'NDArray', se: 'NDArray | None' = None, lower: 'NDArray | None' = None, upper: 'NDArray | None' = None, term_name: 'str' = '') -> None Estimated coefficient function beta(t) for a functional term. Returned by `FunctionalGAM.coefficient_function()`. Represents the fitted weight that each point `t` along a functional covariate's domain contributes to the scalar response, together with pointwise confidence bands derived from the model's coefficient covariance. Attributes ---------- grid: Evaluation grid on the functional domain, shape `(T,)`. values: Estimated `beta(t)` values at grid points, shape `(T,)`. se: Standard errors of `beta(t)`, shape `(T,)`, or `None`. lower: Lower confidence bound, `values - z * se`, shape `(T,)`, or `None`. upper: Upper confidence bound, `values + z * se`, shape `(T,)`, or `None`. term_name: Name of the functional term this coefficient function belongs to. ## Large datasets Scalable GAM fitting for datasets that exceed memory or benefit from parallel computation. BigGAM(formula: 'str | Formula', family: 'Family | None' = None, *, n_discrete: 'int' = 200) -> 'None' GAM for large datasets using discretized fitting. `BigGAM` is a drop-in subclass of `~whittaker.gam.GAM` for datasets too large to fit comfortably with the standard dense design matrix (roughly `n > 1_000_000`). It uses the `bam` approach of Wood, Li, & Shaddick (2017): each covariate is rounded onto a grid of at most `n_discrete` representative values, and the smooth basis is evaluated only once per unique (combination of) discretized value(s) rather than once per observation. An index array records, for every observation, which unique bin it fell into (see `DiscretizedBlock.indices` in `build_discretized_model_matrix`). Fitting still runs penalized iteratively reweighted least squares (P-IRLS), exactly as in `~whittaker.gam.GAM.fit`, alternating an inner coefficient update with an outer smoothing parameter selection. The difference is purely computational: instead of forming the full `n x p` design matrix `X` and computing `X'WX` and `X'Wz` directly, `bam_fit` (in `whittaker.fitting.bam`) accumulates these quantities per bin — for each smooth's discretized block, observation weights are aggregated into per-bin totals via `numpy.bincount` over the bin indices, and the resulting `d x d` cross-products of unique basis rows (`d` = number of unique bins) are scattered into the correct `p x p` block of `X'WX` (see `_compute_XtWX`). Because `d` can be orders of magnitude smaller than `n`, this reduces the memory needed for the cross-product step from `O(n p)` to `O(d p)`, and the resulting fit closely approximates the exact (non-discretized) GAM fit on the same data. `BigGAM` is a drop-in subclass of `GAM`: `predict()`, `summary()`, `plot()`, and `check()` all work the same way as for `GAM`. The one internal difference is that `self._model_matrix.X` is an empty array (the dense design matrix is never materialized) so operations that would otherwise reconstruct per-term columns (e.g. `smooth_tests()`) instead re-expand each smooth's columns on demand from its `DiscretizedBlock` via `_expand_block_columns`. Parameters ---------- formula Model formula as a string (e.g. `"y ~ s(x1) + s(x2) + x3"`), or an already-parsed `Formula` object. Same syntax as `~whittaker.gam.GAM`. family Response distribution family, e.g. `Gaussian()`, `Binomial()`, `Poisson()`, `Gamma()`, or `Tweedie()`. Defaults to `Gaussian()`. n_discrete Maximum number of unique representative values per covariate (or per combination of covariates, for multi-dimensional smooths). Defaults to `200`. Notes ----- `n_discrete` controls the accuracy/memory tradeoff directly. Larger values give a discretized grid that more finely resolves each covariate's range, so the fit approaches the exact (non-discretized) GAM fit at the cost of more unique bins `d` and therefore more memory and computation in the `X'WX` accumulation step. Smaller values reduce memory and speed up fitting, but coarsen the covariate resolution: because all observations within a bin share the same basis row, this can slightly bias smooths with high curvature or steep local features, since fine-scale variation within a bin is averaged away. The default of `200` is usually more than enough resolution for typical smooth terms; it rarely needs to be increased unless a covariate has an unusually large number of important local features. The benefit of discretization (versus plain `GAM`) is only realized once `n` is much larger than `n_discrete`, i.e. for large datasets — for small or moderate `n`, `GAM` is simpler and just as fast. Examples -------- ```{python} import numpy as np import whittaker as wt from whittaker.bam import BigGAM rng = np.random.default_rng(0) n = 5_000 x1 = rng.uniform(0, 1, n) x2 = rng.uniform(0, 1, n) y = np.sin(2 * np.pi * x1) + x2**2 + rng.normal(scale=0.2, size=n) model = BigGAM("y ~ s(x1) + s(x2)", n_discrete=100).fit({"x1": x1, "x2": x2, "y": y}) print(model.summary()) ``` This example uses a modest `n` for speed, but `BigGAM`'s memory and speed advantage over plain `GAM` really shows up once `n` reaches into the millions, where materializing the full design matrix would be impractical. PolarsGAM(formula: 'str | Formula', family: 'Family | None' = None, *, n_discrete: 'int' = 200, chunk_size: 'int' = 100000) -> 'None' GAM that reads data from Polars LazyFrames, DataFrames, or files. `PolarsGAM` extends `~whittaker.bam.BigGAM` with a data-loading layer built on Polars, so it can source data from an in-memory Polars `DataFrame`/`LazyFrame` or directly from a file on disk (CSV, Parquet, IPC/Arrow, or NDJSON), without requiring the caller to materialize the whole dataset into a `dict` of NumPy arrays first. File paths are opened lazily (`pl.scan_*`), and the resulting `LazyFrame` is collected using Polars' streaming query engine (`collect(streaming=True)`), which evaluates the query plan incrementally rather than loading the entire source at once. The collected frame is then walked in `chunk_size`-row slices (`iter_slices`) and each column is converted to a NumPy array and concatenated, producing the same `dict[str, numpy.ndarray]` that `~whittaker.gam.GAM.fit` and `~whittaker.bam.BigGAM.fit` expect. Fitting itself then proceeds exactly as in `BigGAM`: covariates are discretized to at most `n_discrete` unique values per smooth, and the design matrix is never materialized. Use `PolarsGAM` when the data already lives in Polars, or on disk in a Polars-readable format, and you want to avoid a manual load-then-convert step — particularly for datasets in the 1M-100M row range where Polars' streaming engine keeps peak memory bounded during the read. For SQL-native sources or datasets that are more naturally expressed as a database query (joins, filters, aggregations), see `~whittaker.duckdb.DuckDBGAM` instead. Requires the `polars` package (install via `pip install whittaker[polars]`). Parameters ---------- formula : str or Formula Model formula as a string (e.g. `"y ~ s(x1) + s(x2) + x3"`), or an already-parsed `Formula` object. Same syntax as `~whittaker.gam.GAM`. family : Family, optional Response distribution family, e.g. `Gaussian()`, `Binomial()`, `Poisson()`, `Gamma()`, or `Tweedie()`. Defaults to `Gaussian()`. n_discrete : int Maximum number of unique representative values per covariate used when discretizing smooth terms (see `~whittaker.bam.BigGAM`). Defaults to `200`. chunk_size : int Number of rows collected per slice when converting the source into NumPy arrays. Smaller values reduce peak memory during the Polars-to-NumPy conversion step at the cost of more Python-level overhead; larger values reduce overhead but require more memory per slice. Defaults to `100_000`. Examples -------- ```{python} import numpy as np import polars as pl from whittaker.polars_streaming import PolarsGAM rng = np.random.default_rng(0) n = 5_000 x1 = rng.uniform(0, 1, n) x2 = rng.uniform(0, 1, n) y = np.sin(2 * np.pi * x1) + x2**2 + rng.normal(scale=0.2, size=n) df = pl.DataFrame({"x1": x1, "x2": x2, "y": y}) model = PolarsGAM("y ~ s(x1) + s(x2)", n_discrete=100, chunk_size=1_000) model.fit(df.lazy()) print(model.summary()) ``` A file path can be passed directly instead of a `DataFrame`/`LazyFrame` — `PolarsGAM` infers the format from the extension and scans it lazily: ```{python} #| eval: false model = PolarsGAM("y ~ s(x)") model.fit("large_dataset.parquet") ``` Fitting from an actual multi-gigabyte file requires `pip install whittaker[polars]` and enough disk I/O bandwidth to stream the file; the in-memory example above is kept small so it runs quickly, but the same code path scales to files with tens of millions of rows. DuckDBGAM(formula: 'str | Formula', family: 'Family | None' = None, *, n_discrete: 'int' = 200, chunk_size: 'int' = 100000) -> 'None' GAM that reads data directly from DuckDB via SQL. `DuckDBGAM` is a SQL-native variant of `BigGAM` for fitting a GAM without first loading the source data into pandas or polars. `fit()` accepts either a bare table/view name or an arbitrary `SELECT` query — anything expressible in SQL, including joins, filters, aggregations, and window functions — and DuckDB does the work of producing the resulting rows. Internally, `_stream_as_dict` reads those rows through DuckDB's Arrow batch interface (`conn.sql(query).to_arrow_reader(batch_size=chunk_size)`), concatenating `chunk_size`-row Arrow batches into the column-oriented dict that `build_discretized_model_matrix` and `bam_fit` (the same discretized-basis machinery used by `BigGAM`) consume to fit the model. Use `DuckDBGAM` whenever the training data already lives in DuckDB, in Parquet/CSV files DuckDB can scan directly, or in a view/query that would be expensive to materialize by hand before fitting. Parameters ---------- formula : str or Formula Model formula (same syntax as `~whittaker.gam.GAM`), e.g. `"y ~ s(x1) + s(x2)"`. The left-hand side names the response column that must be selectable from `source`; the right-hand side lists smooth terms, linear terms, and interactions in `mgcv`-style syntax. family : Family, optional Response distribution family, e.g. `Gaussian()`, `Binomial()`, `Poisson()`, or `Gamma()`. Defaults to `Gaussian()` (identity link). n_discrete : int Number of discretization grid points per covariate used when building the discretized model matrix (see `build_discretized_model_matrix`). Larger values give a more accurate approximation to the exact basis evaluation at the cost of a larger `grid_size * p` term in memory and compute. Defaults to `200`, which is adequate for most smooths. chunk_size : int Number of rows per Arrow batch fetched from DuckDB while streaming (see `Notes` below). Larger values reduce Python-level batch-processing overhead but increase peak memory per batch; smaller values do the opposite. Defaults to `100_000`. Notes ----- Memory usage during fitting is bounded by *O(n d + grid_size * p)* rather than *O(n p)*, where *n* is the row count, *d* the number of raw covariates, *p* the number of basis functions, and *grid_size* the discretization resolution (`n_discrete`). The `n d` term comes from streaming the raw columns rather than an *n x p* design matrix; the `grid_size * p` term comes from evaluating the basis only at the discretization grid. `_stream_as_dict` is what keeps the raw data at `O(n d)` rather than materializing a full *n x p* matrix twice as `conn.sql(...).df()` would: it pulls one `chunk_size`-row Arrow batch at a time from `to_arrow_reader` and appends each batch's columns to a list, so DuckDB never has to build (and Python never has to hold) more than one batch's worth of Arrow data plus the columns accumulated so far. Examples -------- ```{python} import duckdb import numpy as np import whittaker as wt from whittaker.duckdb import DuckDBGAM conn = duckdb.connect() rng = np.random.default_rng(0) conn.execute( "CREATE TABLE data AS " "SELECT i AS id, (i / 100.0) AS x, " "sin(2 * pi() * i / 100.0) + ? * random() AS y " "FROM range(200) AS t(i)", [0.2], ) model = DuckDBGAM("y ~ s(x)").fit("data", conn) print(model.summary()) ``` A query can be used directly in place of a table name, e.g. to filter or join before fitting: ```{python} model2 = DuckDBGAM("y ~ s(x)").fit_query( "SELECT x, y FROM data WHERE x < 0.8", conn ) print(model2.n_rows) ``` ## Cross-validation K-fold cross-validation for GAMs with deviance, MSE, and MAE scoring. cross_validate(formula: 'str', data: 'InputData', *, family: 'Family | None' = None, n_folds: 'int' = 10, method: 'str' = 'GCV', metric: 'str' = 'deviance', select: 'bool' = False, seed: 'int | None' = None) -> 'CVResult' K-fold cross-validation for a GAM specification. Estimates the out-of-sample predictive performance of a GAM by repeatedly refitting it on `n_folds - 1` folds of the data and scoring the fit on the remaining held-out fold, then aggregating the resulting losses across folds. Because each fold is scored on data that was not used to fit that particular model, `cross_validate()` gives a more honest estimate of generalization error than simply scoring a single fit on the data it was trained on, which is optimistic (the fit has already adapted to that data's noise). Use it to compare candidate formulas, families, fitting methods, or basis choices on equal footing, or as a sanity check that a model selected by GCV/REML/ML also performs well out of sample. Two loss metrics are available via `metric`: - `"deviance"` (default): for each fold, the family's `deviance()` between the held-out responses and the predictions from a model fit on the training folds, divided by the number of test observations in that fold. This matches the deviance-based loss the family itself uses during fitting, so it is comparable across `method` choices for the same `family`. - `"mse"`: the mean squared error, `mean((y_test - pred) ** 2)`, on the response scale. This is family-agnostic and directly interpretable in the response's original units, but does not account for family-specific variance structure the way deviance does. Folds are constructed by randomly permuting the row indices with `rng.permutation(n)` (where `rng` is seeded from `seed`) and then assigning fold id `i * n_folds // n` to the row that ends up in position `i` of the permutation. This produces a non-stratified partition into `n_folds` contiguous-in-permutation-order, roughly (but not exactly, when `n` is not a multiple of `n_folds`) equal-size groups; no attempt is made to balance the distribution of the response or any covariate across folds. Parameters ---------- formula : str GAM formula string, e.g. `"y ~ s(x1) + s(x2) + x3"`. The response named on the left-hand side is looked up in `data` to build the fold assignment and to compute the loss; the right-hand side is passed unchanged to `~whittaker.gam.GAM` for every fold. data : dict[str, numpy.ndarray] or InputData Column-oriented data as `{name: 1-D array}` (or any `InputData`-compatible object, such as a `pandas.DataFrame` or `polars.DataFrame`). Must contain every column referenced by `formula`, all of equal length. family : Family, optional Response distribution family, e.g. `Gaussian()`, `Binomial()`, `Poisson()`, `Gamma()`, or `Tweedie()`. Used both to fit each fold's `~whittaker.gam.GAM` and, when `metric="deviance"`, to compute each fold's loss via `family.deviance()`. Defaults to `Gaussian()`. n_folds : int Number of folds to split the data into. Must be at least 2 and, for every fold to receive at least one test observation, should not exceed the number of rows in `data`. Defaults to `10`. See the Notes section below for guidance on choosing this value. method : str Smoothing-parameter selection method passed through to `GAM.fit()` for every fold. One of `"GCV"` (default), `"REML"`, or `"ML"`; see `GAM.fit()` for what each criterion optimizes. metric : str Loss metric to compute on each held-out fold: `"deviance"` (default) or `"mse"`. See the discussion above for exactly how each is computed. select : bool Whether to add shrinkage penalties for automatic smooth-term selection, forwarded to `GAM.fit(select=...)` for every fold. Defaults to `False`. seed : int, optional Seed for the `numpy.random.default_rng()` random number generator used to build the fold assignment. Pass a fixed integer to make the fold split (and hence the resulting `CVResult`) reproducible across calls; `None` (the default) uses a fresh, non-reproducible seed. Returns ------- CVResult Cross-validation result holding the mean out-of-sample loss (`cv_score`), the per-fold losses (`cv_scores`), their standard error (`cv_se`), and the number of folds used (`n_folds`). Notes ----- The number of folds controls a bias-variance tradeoff in the CV estimate itself. With a small `n_folds` (e.g. `3`-`5`), each training fold omits a large fraction of the data, so the fitted model is somewhat different from (typically smoother/less flexible than) a model fit on the full dataset; the resulting `cv_score` tends to be pessimistically biased, but because there are only a few, relatively large folds, `cv_scores` tends to have lower variance across repeated runs. With a large `n_folds` (up to the leave-one-out limit, `n_folds = n`), each training fold is nearly the full dataset, so bias shrinks toward the true generalization error of the full-data fit — but the individual test folds are tiny (a single point at `n_folds = n`), so `cv_scores` becomes noisier (higher variance), and fitting cost grows linearly with `n_folds` since a full `GAM.fit()` is performed once per fold. In practice, `n_folds = 5` or `n_folds = 10` are common compromises between these effects. Leave-one-out cross-validation is rarely used directly for GAMs because of its cost; `method="GCV"` in `GAM.fit()` already computes an efficient analytical approximation to the leave-one-out error from a single fit, without refitting the model `n` times. Examples -------- ```{python} import numpy as np import whittaker as wt rng = np.random.default_rng(0) x = np.sort(rng.uniform(0, 1, 200)) y = np.sin(2 * np.pi * x) + rng.normal(scale=0.2, size=200) result = wt.cross_validate("y ~ s(x)", {"x": x, "y": y}, n_folds=5, seed=0) print(result.cv_score, result.cv_se) ``` ```{python} # Compare two candidate formulas on the same folds using the seed. linear_result = wt.cross_validate("y ~ x", {"x": x, "y": y}, n_folds=5, seed=0) smooth_result = wt.cross_validate("y ~ s(x)", {"x": x, "y": y}, n_folds=5, seed=0) linear_result.cv_score, smooth_result.cv_score ``` CVResult(cv_score: 'float', cv_scores: 'NDArray', cv_se: 'float', n_folds: 'int') -> None Result of `cross_validate()`. Holds the per-fold and aggregate out-of-sample loss values produced by k-fold cross-validation of a `~whittaker.gam.GAM` specification, along with the number of folds used to obtain them. Use `cv_score` as a single summary number for model comparison, and `cv_scores` together with `cv_se` to gauge how much that summary varies across folds. Parameters ---------- cv_score : float Mean out-of-sample loss across all folds, i.e. `numpy.mean(cv_scores)`. This is on the scale of whichever `metric` was requested from `cross_validate()` — mean deviance per test observation for `metric="deviance"`, or mean squared error on the response scale for `metric="mse"`. Lower values indicate better out-of-sample predictive performance; use this value to compare competing formulas, families, or fitting methods evaluated on the same data and folds. cv_scores : numpy.ndarray Per-fold out-of-sample loss values, shape `(n_folds,)`. Element `i` is the loss computed by fitting the GAM on every fold except `i` and scoring it on fold `i`. Inspect this array directly to check whether the CV estimate is driven by a small number of unusual folds. cv_se : float Standard error of the mean CV score across folds, computed as the sample standard deviation of `cv_scores` (with Bessel's correction, `ddof=1`) divided by `sqrt(n_folds)`. Provides a rough measure of the uncertainty in `cv_score` due to the particular random fold assignment; useful for judging whether a difference in `cv_score` between two models is likely to be meaningful. n_folds : int Number of folds actually requested when this result was produced. Matches the `n_folds` argument passed to `cross_validate()`. ## scikit-learn integration GAM estimators compatible with the scikit-learn API for use in pipelines and grid search. GAMRegressor(formula: 'str | None' = None, *, family: 'Family | None' = None, method: 'str' = 'GCV', select: 'bool' = False) -> 'None' Scikit-learn compatible GAM regressor. `GAMRegressor` wraps `whittaker.gam.GAM` behind the scikit-learn `BaseEstimator` / `RegressorMixin` interface, exposing the familiar `fit(X, y)`, `predict(X)`, `get_params()`/`set_params()` methods instead of `GAM`'s formula-and-data-dictionary API. This makes it a drop-in estimator anywhere scikit-learn expects one: inside a `Pipeline` (e.g. chained after a `StandardScaler` or a `ColumnTransformer`), as the estimator tuned by `GridSearchCV` or `RandomizedSearchCV` (searching over `formula`, `method`, or `select`), scored with `cross_val_score` or `cross_validate`, or combined with other regressors inside a `VotingRegressor` or a stacking ensemble. Because raw numpy feature columns carry no names, `GAMRegressor` assigns synthetic names `x0`, `x1`, ..., `x{n_features - 1}` to the columns of `X` in order (see `_make_feature_names`). Unless an explicit `formula` is supplied, a default additive formula with one smooth `s(xi)` per feature is built automatically (see `_build_formula`), giving $$ \eta = \beta_0 + \sum_{i=0}^{p-1} f_i(x_i), $$ connected to the mean response through the link implied by `family`. Supplying `formula` overrides this default; it accepts either a bare right-hand side such as `"s(x0) + s(x1)"` — in which case the response name `"y"` is prepended automatically to form `"y ~ s(x0) + s(x1)"` — or a complete formula already containing `"~"`, which is used as-is. This makes it possible to mix smooth and linear terms, use interactions (`x0:x1`), or omit features, exactly as with `GAM` directly. Parameters ---------- formula : str, optional GAM formula for the right-hand side (e.g. `"s(x0) + s(x1)"`), or a complete formula containing `"~"` (e.g. `"y ~ s(x0) + x1"`). If it contains `"~"` it is used verbatim; otherwise the response `"y"` is prepended. If `None` (the default), a formula with one `s(xi)` smooth per input feature is generated automatically. family : Family, optional Response distribution family passed through to the underlying `GAM`. Defaults to `Gaussian()` (identity link), i.e. ordinary least-squares-style additive regression. method : str Smoothing parameter selection criterion forwarded to `GAM.fit`: `"GCV"` (default), `"REML"`, or `"ML"`. See `whittaker.gam.GAM.fit` for the meaning of each option. select : bool If `True`, enable double-penalty smooth selection (an extra penalty that can shrink an entire smooth to zero), forwarded to `GAM.fit`. Defaults to `False`. Notes ----- The hyperparameters exposed to `GridSearchCV`/`RandomizedSearchCV` via `get_params()` are exactly the constructor arguments — `formula`, `family`, `method`, and `select` — because scikit-learn's `get_params` introspects the `__init__` signature. The smoothing parameters `\lambda_j` themselves are never tunable hyperparameters of `GAMRegressor`: they are always chosen internally, for the given `method`, during `fit()`. To tune smoothing behavior via cross-validation, search over `method` and `select` (which change how `\lambda_j` are selected) rather than trying to pass `\lambda_j` values directly. Examples -------- ```{python} import numpy as np from whittaker.sklearn import GAMRegressor rng = np.random.default_rng(0) X = rng.uniform(-2, 2, size=(200, 2)) y = np.sin(X[:, 0]) + 0.5 * X[:, 1] ** 2 + rng.normal(scale=0.2, size=200) reg = GAMRegressor(formula="s(x0) + s(x1)") reg.fit(X, y) reg.predict(X[:5]) ``` `GAMRegressor` works inside scikit-learn model-selection tooling, such as `sklearn.model_selection.cross_val_score` (requires `pip install scikit-learn`): ```{python} from sklearn.model_selection import cross_val_score scores = cross_val_score(GAMRegressor(), X, y, cv=5) scores ``` GAMClassifier(formula: 'str | None' = None, *, method: 'str' = 'GCV', select: 'bool' = False) -> 'None' Scikit-learn compatible GAM classifier (binary). `GAMClassifier` wraps `whittaker.gam.GAM` behind the scikit-learn `BaseEstimator` / `ClassifierMixin` interface for binary classification. It always fits a `whittaker.families.binomial.Binomial(link="logit")` family internally — i.e. a logistic GAM — so the linear predictor is related to the class-1 probability `\mu` by the logit link, `\eta = \log(\mu / (1 - \mu))`. Only two-class problems are supported: `fit()` raises a `ValueError` if `y` does not contain exactly two distinct labels. The formula-building behavior (synthetic feature names, default `s(xi)`-per-feature formula, or an explicit `formula`) is identical to `GAMRegressor`; see that class for details. Fitted attributes follow the scikit-learn classifier convention: `self.classes_` holds the two observed labels sorted ascending (as returned by `numpy.unique`), and `predict_proba` returns probability columns ordered to match `self.classes_` (column 0 is `P(y = classes_[0])`, column 1 is `P(y = classes_[1])`). This makes `GAMClassifier` compatible with `Pipeline`, `GridSearchCV`/`RandomizedSearchCV` (scored via `"accuracy"`, `"roc_auc"`, or a custom scorer), and any tooling that consumes `predict_proba`, such as `sklearn.calibration.CalibratedClassifierCV` or manual decision-threshold tuning on the predicted probabilities. Parameters ---------- formula : str, optional GAM formula for the right-hand side (e.g. `"s(x0) + s(x1)"`), or a complete formula containing `"~"`. If it contains `"~"` it is used verbatim; otherwise the response `"y"` is prepended. If `None` (the default), a formula with one `s(xi)` smooth per input feature is generated automatically. method : str Smoothing parameter selection criterion forwarded to `GAM.fit`: `"GCV"` (default), `"REML"`, or `"ML"`. See `whittaker.gam.GAM.fit` for the meaning of each option. select : bool If `True`, enable double-penalty smooth selection, forwarded to `GAM.fit`. Defaults to `False`. Notes ----- As with `GAMRegressor`, the hyperparameters exposed to `GridSearchCV`/`RandomizedSearchCV` via `get_params()` are the constructor arguments — `formula`, `method`, and `select` — since scikit-learn's `get_params` introspects `__init__`. There is no `family` parameter here because the family is fixed to `Binomial(link="logit")`. The smoothing parameters `\lambda_j` are always selected internally by `method` during `fit()` rather than being directly tunable. Examples -------- ```{python} import numpy as np from whittaker.sklearn import GAMClassifier rng = np.random.default_rng(0) X = rng.uniform(-2, 2, size=(300, 2)) logit = 1.5 * np.sin(X[:, 0]) - X[:, 1] p = 1 / (1 + np.exp(-logit)) y = rng.binomial(1, p) clf = GAMClassifier(formula="s(x0) + s(x1)") clf.fit(X, y) clf.predict_proba(X[:5]) ``` ```{python} clf.predict(X[:5]) ``` ## Serialization Save and load fitted GAMs, and convert to/from mgcv-compatible dictionaries. save_gam(model: 'Any', path: 'str | Path') -> 'None' Save a fitted GAM to a `.npz` archive. Serializes everything needed to reconstruct a fitted `~whittaker.gam.GAM` for prediction and inference without recomputing basis fits or re-running P-IRLS. Use this to persist a model between sessions, ship a fitted model to another machine, or cache an expensive fit. The archive is a standard `numpy` `.npz` file (produced with `numpy.savez_compressed`) and can, in principle, be inspected with `numpy.load` alone, though `load_gam` is the supported way to read it back. Internally the archive stores two kinds of data under one file: - A single JSON-encoded metadata blob under the key `"__metadata__"`, containing the formula (response, intercept flag, and each term), the family (class name and any extra parameters such as a Tweedie power or negative-binomial dispersion), fit statistics (smoothing parameters, scale, GCV score, EDF, deviance, iteration count, convergence flag, AIC/BIC, etc.), model-matrix metadata (column names, intercept/parametric counts, offset expressions), and, per smooth term, its formula term, coefficient column range, null-space dimension, penalty indices, and basis state (attribute values of the fitted `~whittaker.smooths.base.SmoothBasis`). - Raw `numpy` arrays stored alongside the metadata: `coefficients`, `linear_predictor`, `fitted_values`, `residuals`, the training design matrix `X`, the `response` vector, and, when present, `weights`, `prior_weights`, `pseudo_data`, and `offset`. Each smooth's penalty matrix is stored as `penalty_{i}` (one array per penalty block, in the order the smooths contribute penalties). Any array-valued attribute of a smooth's fitted basis (e.g. knot locations, training covariate values) is stored under a key of the form `smooth_{idx}_basis_{attr}` (or `smooth_{idx}_basis_{attr}_{subkey}` for nested dict attributes), with a `{"__ndarray__": key}` pointer left in the metadata blob so `load_gam` can find it. Note that the archive has no explicit format-version field: there is currently no mechanism to detect or migrate across schema changes, so a saved archive is only guaranteed to load correctly with a `whittaker` version compatible with the one that wrote it. Parameters ---------- model : GAM A fitted `~whittaker.gam.GAM` instance, i.e. one on which `fit()` has already been called. path : str or pathlib.Path Output file path. `numpy.savez_compressed` appends a `.npz` extension automatically if the given path does not already end in one. Raises ------ TypeError If `model` is not a `GAM` instance. RuntimeError If `model` has not been fitted (`model.is_fitted` is `False`). Examples -------- ```{python} import numpy as np import whittaker as wt from whittaker.io import save_gam, load_gam rng = np.random.default_rng(0) x = np.sort(rng.uniform(0, 1, 200)) y = np.sin(2 * np.pi * x) + rng.normal(scale=0.2, size=200) model = wt.GAM("y ~ s(x)").fit({"x": x, "y": y}) save_gam(model, "gam_model.npz") reloaded = load_gam("gam_model.npz") new_x = np.linspace(0, 1, 5) np.allclose( model.predict({"x": new_x}).values, reloaded.predict({"x": new_x}).values, ) ``` load_gam(path: 'str | Path') -> 'Any' Load a fitted GAM from a `.npz` archive created by `save_gam`. Reads back every piece of state that `save_gam` wrote — the formula, family, fitted coefficients and fit statistics, training design matrix and penalties, and each smooth's basis state (restored via an internal `_basis_from_state` helper that reconstructs the original `~whittaker.smooths.base.SmoothBasis` subclass without calling its constructor) — and assembles them into a fully fitted `~whittaker.gam.GAM`. The returned model behaves exactly as it did before saving: `predict()`, `summary()`, `plot()`, and `check()` all work immediately, with no re-fitting or basis refitting performed. Parameters ---------- path : str or pathlib.Path Path to the `.npz` file written by `save_gam`. Returns ------- GAM A fitted `~whittaker.gam.GAM` ready for prediction and inference. Examples -------- ```{python} import numpy as np import whittaker as wt from whittaker.io import save_gam, load_gam rng = np.random.default_rng(1) x = np.sort(rng.uniform(0, 1, 150)) y = np.cos(3 * x) + rng.normal(scale=0.15, size=150) model = wt.GAM("y ~ s(x)").fit({"x": x, "y": y}) save_gam(model, "gam_model.npz") reloaded = load_gam("gam_model.npz") reloaded.is_fitted ``` from_mgcv_dict(d: 'dict[str, Any]', data: 'dict[str, NDArray] | None' = None) -> 'Any' Import an mgcv `gam` object exported as a dictionary. The inverse of `to_mgcv_dict`: reconstructs a `~whittaker.gam.GAM` from a dictionary shaped like an R `mgcv::gam` object, typically produced in R with `jsonlite::toJSON(gam_model)` (or an equivalent hand-built dict) and passed into Python after parsing the JSON. Use this to bring a model fitted in R into `whittaker` for further prediction, plotting, or comparison against a Python fit. There are two modes, selected by whether `data` is supplied: - Without `data` (the default): only the formula, family, fitted coefficients, and smoothing parameters are restored onto the returned `GAM`. No model matrix or smooth basis is built, so the result is a lightweight container for inspecting the imported coefficients — it is *not* usable for `predict()`, since the smooth bases (knots, constraints, etc.) that the coefficients were fit against are not reconstructed. - With `data` (the original training data, as `{name: 1-D array}`): `~whittaker.model_matrix .build_model_matrix` is called on `data` to refit each smooth's basis and assemble the design matrix, the linear predictor and fitted values are recomputed from the imported coefficients, and a full `FitResult` (deviance, residuals, etc.) is attached. In this mode the returned model is fully usable for `predict()` on new data, since its smooth bases were rebuilt from the same training data mgcv used. The R family name in `d["family"]["family"]` is translated to the corresponding `whittaker` family class via an internal mapping (`_mgcv_family_map`), e.g. `"gaussian"` -> `Gaussian`, `"poisson"` -> `Poisson`, `"binomial"` -> `Binomial`, `"Gamma"` -> `Gamma`, `"inverse.gaussian"` -> `InverseGaussian`, `"Tweedie"` -> `Tweedie`, `"nb"` -> `NegativeBinomial`, `"cox.ph"` -> `CoxPH`, and `"betar"` -> `Beta`. A family name not in this table is passed through unchanged and will raise `ValueError` if it does not match a known `whittaker` family class. Parameters ---------- d : dict An mgcv-compatible dictionary, e.g. parsed from `jsonlite::toJSON(gam_model)` in R, or produced by `to_mgcv_dict`. Must contain at least `"coefficients"`; `"formula"`, `"family"`, `"sp"`, and `"smooth"` are used when present to reconstruct the formula, family, and smoothing parameters as accurately as possible. data : dict[str, numpy.ndarray], optional The original training data used to fit the model in R, as `{name: 1-D array}`. When given, smooth bases are rebuilt from this data and the returned model supports `predict()`. When omitted (the default), only coefficients and smoothing parameters are restored and the model cannot be used for prediction. Returns ------- GAM A `~whittaker.gam.GAM` instance. Fully fitted and prediction-ready when `data` is provided; otherwise a formula/family/coefficient container only. Notes ----- This function is intended to interoperate with the R `mgcv` package's `gam` object structure. Full fidelity is not guaranteed: only the family names listed in `_mgcv_family_map` are recognized, and mgcv fields with no `whittaker` counterpart (e.g. certain smooth-specific `xt` options) are ignored rather than reconstructed. Examples -------- ```{python} import numpy as np import whittaker as wt from whittaker.io import to_mgcv_dict, from_mgcv_dict rng = np.random.default_rng(3) x = np.sort(rng.uniform(0, 1, 120)) y = np.sin(2 * x) + rng.normal(scale=0.1, size=120) data = {"x": x, "y": y} model = wt.GAM("y ~ s(x)").fit(data) mgcv_dict = to_mgcv_dict(model) # Round-trip through the mgcv-style dict, refitting bases from the training data. reimported = from_mgcv_dict(mgcv_dict, data=data) reimported.predict({"x": np.linspace(0, 1, 3)}).values ``` to_mgcv_dict(model: 'Any') -> 'dict[str, Any]' Export a fitted GAM as an mgcv-compatible dictionary. Builds a plain `dict` whose keys and nested structure mirror the fields of a fitted `gam` object from R's `mgcv` package, rather than `whittaker`'s own internal representation. Use this when you need to hand a Python-fitted model to R code — typically by serializing the result with `json.dumps` and reading it in R with `jsonlite::fromJSON`, or comparing a `whittaker` fit against an equivalent `mgcv::gam()` fit term by term. Top-level keys include `coefficients` (the fitted coefficient vector), `sp` (smoothing parameters), `scale` and `scale.estimated`, `gcv.ubre`, `edf` and `edf.total`, `deviance` and `null.deviance`, `aic`, `n` (observation count) and `p` (coefficient count), `converged`, `iter`, `method`, `formula` (as a string), `family` (a nested dict with the family name and any extra parameter such as a Tweedie power or negative-binomial `theta`), `smooth` (a list, one entry per smooth term), `nsdf`, and `intercept`. Each entry in `smooth` describes one smooth term with mgcv-style field names: `term` (covariate names), `bs` (the two-letter mgcv basis-type code), `label`, `first.para`/ `last.para` (1-based coefficient column range), `null.space.dim`, `df`, optionally `by`/`by.level` for `by`-variable smooths, and `S` (a list of penalty matrix blocks, sliced from the model's full penalty matrices down to just this term's coefficient columns). The `bs` code is produced by mapping the `whittaker` basis class name to mgcv's naming convention, e.g. `TPRS` maps to `"tp"`, `ShrinkageTPRS` to `"ts"`, `CRS` to `"cr"`, `PSpline` to `"ps"`, `CyclicPSpline` to `"cp"`, `RandomEffectBasis` to `"re"`, and so on; a basis with no known mgcv counterpart falls back to its `whittaker` class name unchanged. Parameters ---------- model : GAM A fitted `~whittaker.gam.GAM` instance. Returns ------- dict An mgcv-compatible dictionary with keys such as `coefficients`, `sp`, `family`, `smooth`, `edf`, and `deviance`, suitable for JSON serialization and import into R. Raises ------ TypeError If `model` is not a `GAM` instance. RuntimeError If `model` has not been fitted (`model.is_fitted` is `False`). Notes ----- This function is intended to interoperate with the R `mgcv` package's `gam` object structure so that a `whittaker` fit can be inspected or compared from R. Full fidelity is not guaranteed: not every `mgcv` field is populated (for example, no `Vp`/`Vc` covariance matrices are exported), and not every `whittaker` family or basis has a direct `mgcv` equivalent, in which case the original class name is used as-is rather than an invented mgcv code. Examples -------- ```{python} import json import numpy as np import whittaker as wt from whittaker.io import to_mgcv_dict rng = np.random.default_rng(2) x = np.sort(rng.uniform(0, 1, 100)) y = x**2 + rng.normal(scale=0.1, size=100) model = wt.GAM("y ~ s(x)").fit({"x": x, "y": y}) mgcv_dict = to_mgcv_dict(model) sorted(mgcv_dict.keys()) ``` ```{python} # The dict is JSON-serializable and can be written out for R to read. payload = json.dumps(mgcv_dict) mgcv_dict["smooth"][0]["bs"] ``` ## Datasets Built-in synthetic datasets for testing and examples. load_dataset(name: 'str', as_frame: 'bool' = False) -> 'dict[str, NDArray] | Any' Load a built-in example dataset. Parameters ---------- name: Dataset name. Call `list_datasets` to see all available names. as_frame: If `True`, return a `pandas.DataFrame` instead of a plain `dict`. Requires `pandas` to be installed. Returns ------- dict[str, NDArray] or pandas.DataFrame Column-oriented data. The `dict` form is accepted directly by `~whittaker.gam.GAM.fit` and all other Whittaker model classes. Examples -------- ```{python} import whittaker as wk data = wk.load_dataset("mcycle") model = wk.GAM("accel ~ s(times)").fit(data) model.edf ``` ```{python} df = wk.load_dataset("wages", as_frame=True) df.head() ``` list_datasets() -> 'list[dict[str, str]]' Return a list of all built-in datasets with their metadata. Returns ------- list[dict[str, str]] Each entry has keys `name`, `description`, `variables`, `family`, and `note`. Examples -------- ```{python} import whittaker as wk rows = wk.list_datasets() [r["name"] for r in rows] ``` ## Model comparison Compare fitted GAMs by information criteria, LOO-CV, WAIC, and stacking weights. compare(*models: 'GAM') -> 'ComparisonResult' Compare multiple fitted GAMs in a summary table. Collects AIC, BIC, deviance explained, adjusted R-squared, EDF, GCV (when available), and scale from each model, sorts by AIC, and computes delta-AIC from the best model. Parameters ---------- *models : GAM Two or more fitted GAM objects. Returns ------- ComparisonResult Comparison table sorted by AIC (best first). Raises ------ ValueError If fewer than 2 models are provided. Examples -------- ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) x = np.linspace(0, 2 * np.pi, 200) y = np.sin(x) + rng.normal(0, 0.3, 200) data = {"x": x, "y": y} m1 = wk.GAM("y ~ x").fit(data) m2 = wk.GAM("y ~ s(x, k=5)").fit(data) m3 = wk.GAM("y ~ s(x, k=15)").fit(data) print(wk.compare(m1, m2, m3)) ``` ComparisonResult(rows: 'list[ComparisonRow]') -> None Result of comparing multiple fitted GAMs. Rows are sorted by AIC (best first). The `delta_aic` field on each row gives the AIC difference from the best model, making it easy to see which models are competitive. Attributes ---------- rows : list[ComparisonRow] One row per model, sorted by AIC (ascending). ComparisonRow(label: 'str', aic: 'float', delta_aic: 'float', bic: 'float', deviance_explained: 'float', r_squared_adj: 'float', edf_total: 'float', gcv_score: 'float | None', scale: 'float', n_obs: 'int') -> None One row of a model comparison table. Attributes ---------- label : str Formula string identifying the model. aic : float Akaike Information Criterion. delta_aic : float AIC difference from the best (lowest-AIC) model. bic : float Bayesian Information Criterion. deviance_explained : float Proportion of null deviance explained. r_squared_adj : float Adjusted R-squared. edf_total : float Total effective degrees of freedom. gcv_score : float or None GCV score, or `None` for Bayesian fits. scale : float Estimated scale (dispersion) parameter. n_obs : int Number of observations. loo_compare(result1: 'LOOResult', result2: 'LOOResult') -> 'LOOComparison' Compare two PSIS-LOO results computed on the same observations. The standard error of the difference uses the pointwise LOO values from both models, which gives a paired comparison that accounts for correlation across observations (Vehtari et al., 2017). Parameters ---------- result1, result2 : LOOResult LOO results from two models fitted to the same data. Must have the same number of observations. Returns ------- LOOComparison Contains `elpd_diff = result1.elpd_loo - result2.elpd_loo` and its standard error. LOOResult(elpd_loo: 'float', se_elpd_loo: 'float', p_loo: 'float', pointwise: 'NDArray', pareto_k: 'NDArray', n_bad_k: 'int') -> None Result of PSIS-LOO cross-validation on a fitted GAM. Attributes ---------- elpd_loo : float Expected log predictive density (ELPD_LOO), summed over observations. Higher is better. se_elpd_loo : float Approximate standard error of `elpd_loo`, computed as `sqrt(n * var(pointwise))`. p_loo : float Effective number of parameters (LOO penalty). Computed as `lpd_full - elpd_loo` where `lpd_full` is the log-likelihood at the posterior mean. Large `p_loo` relative to the actual parameter count suggests model misspecification. pointwise : NDArray Per-observation LOO log predictive density values, shape `(n,)`. pareto_k : NDArray Per-observation Pareto `k` diagnostic, shape `(n,)`. Values above 0.7 indicate that the PSIS approximation is unreliable for that observation; values above 1.0 indicate the importance weights have infinite variance and LOO is invalid. n_bad_k : int Number of observations with `pareto_k > 0.7`. LOOComparison(elpd_diff: 'float', se_diff: 'float') -> None Comparison of two PSIS-LOO results on the same data. Attributes ---------- elpd_diff : float Difference in ELPD_LOO: `result1.elpd_loo - result2.elpd_loo`. Positive means `result1` is preferred; negative means `result2`. se_diff : float Standard error of `elpd_diff`, computed from the pointwise differences using `sqrt(n * var(pointwise1 - pointwise2))`. waic_compare(result1: 'WAICResult', result2: 'WAICResult') -> 'WAICComparison' Compare two WAIC results computed on the same observations. The standard error of the difference uses the pointwise ELPD values from both models, giving a paired comparison that accounts for correlation across observations. Parameters ---------- result1, result2 : WAICResult WAIC results from two models fitted to the same data. Must have the same number of observations. Returns ------- WAICComparison WAICResult(elpd_waic: 'float', se_elpd_waic: 'float', p_waic: 'float', waic: 'float', pointwise: 'NDArray') -> None Result of WAIC computation on a fitted GAM. Attributes ---------- elpd_waic : float Expected log pointwise predictive density, summed over observations. Higher is better. se_elpd_waic : float Approximate standard error of `elpd_waic`, computed as `sqrt(n * var(pointwise))`. p_waic : float Effective number of parameters (WAIC penalty), computed as the sum of the per-observation variance of the log-likelihood across posterior draws. waic : float The WAIC value on the deviance scale: `-2 * elpd_waic`. Lower is better. pointwise : NDArray Per-observation ELPD contributions, shape `(n,)`. WAICComparison(elpd_diff: 'float', se_diff: 'float') -> None Comparison of two WAIC results on the same data. Attributes ---------- elpd_diff : float Difference in ELPD_WAIC: `result1.elpd_waic - result2.elpd_waic`. Positive means `result1` is preferred while negative means `result2`. se_diff : float Standard error of `elpd_diff`, computed from the pointwise differences using `sqrt(n * var(pointwise1 - pointwise2))`. stacking(*results: 'LOOResult | WAICResult') -> 'StackingResult' Compute stacking weights for model averaging. Given LOO or WAIC results from multiple models fitted to the same data, finds the optimal combination weights that maximize the combined leave-one-out predictive density of the weighted mixture. Unlike pairwise `loo_compare()` or `waic_compare()`, stacking handles any number of models simultaneously and produces a single set of weights suitable for prediction averaging. Parameters ---------- *results : LOOResult or WAICResult Two or more LOO or WAIC results. All must be the same type and computed on the same data (same number of observations). Returns ------- StackingResult Contains the optimal weights, combined ELPD, and a display-friendly summary. Examples -------- ```python loo1 = model1.loo() loo2 = model2.loo() loo3 = model3.loo() result = stacking(loo1, loo2, loo3) print(result.weights) # e.g., array([0.62, 0.35, 0.03]) ``` StackingResult(weights: 'NDArray', elpd_stacking: 'float', se_elpd_stacking: 'float', n_models: 'int', n_obs: 'int', method: 'str') -> None Result of stacking weight optimization. Attributes ---------- weights : NDArray Optimal stacking weights, shape `(K,)`, summing to 1. Each weight is the contribution of the corresponding model to the predictive mixture. elpd_stacking : float Combined ELPD of the stacking mixture, summed over observations. se_elpd_stacking : float Approximate standard error of `elpd_stacking`. n_models : int Number of models in the comparison. n_obs : int Number of observations. method : str Whether the pointwise ELPD values came from `"loo"` or `"waic"`. ## Posterior predictive checks Simulate data from the posterior predictive distribution to assess model fit. PPCResult(y_rep: 'NDArray', observed: 'NDArray', _stats: 'dict[str, tuple[float, NDArray]]' = ) -> None Result of a posterior predictive check on a fitted GAM. Attributes ---------- y_rep : NDArray Posterior predictive draws on the response scale, shape `(n, n_sim)`. Each column is one draw from the posterior predictive distribution (a plausible dataset the model could have generated). observed : NDArray Observed response values used to fit the model, shape `(n,)`. ## Plotting Diagnostic and partial-effect plotting functions. check(model: 'GAM', plots: 'tuple[str, ...] | list[str] | None' = None) -> 'alt.VConcatChart' Produce GAM diagnostic plots. Provides the standard suite of residual diagnostics used to assess GAM fit quality, analogous to `mgcv::gam.check()` in R. All requested diagnostics are returned as a single vertically concatenated Altair chart so calling `wk.check(model)` as the last expression in a cell displays inline. Available plots (selected via `plots=`): - `"qq"`: QQ plot of deviance residuals against theoretical normal quantiles. Systematic curvature away from the reference line suggests the response distribution (family) may be misspecified. - `"residuals"`: Pearson residuals vs fitted values. A even, patternless scatter around zero is the target; funnel shapes suggest heteroscedasticity (consider a location-scale family), and curvature suggests a missing or under-smoothed term. - `"histogram"`: Histogram of deviance residuals, for checking overall symmetry and shape. - `"response"`: Observed response vs fitted values, with a 1:1 reference line, for an overall sense of fit quality and to spot outliers. Parameters ---------- model: A fitted GAM. plots: Which diagnostic plots to include. Pass a list of names (e.g., `["qq", "residuals"]`) or `None` (default) for all four, in the order `"qq"`, `"residuals"`, `"histogram"`, `"response"`. Returns ------- altair.VConcatChart All requested diagnostic plots stacked vertically into a single chart. Examples -------- ```{python} import numpy as np from whittaker.gam import GAM from whittaker.plotting import check rng = np.random.default_rng(0) n = 300 x = rng.uniform(0, 1, n) y = np.sin(2 * np.pi * x) + rng.normal(scale=0.2, size=n) model = GAM("y ~ s(x)").fit({"x": x, "y": y}) chart = check(model, plots=["qq", "residuals"]) print(type(chart).__name__) ``` partial_effects(model: 'GAM', *, n_points: 'int' = 200, level: 'float' = 0.95) -> 'alt.VConcatChart | alt.Chart | alt.LayerChart | alt.FacetChart | alt.HConcatChart' Plot partial effects with confidence bands for each smooth term. For univariate `s()` terms, produces a line plot with a shaded confidence band. For bivariate `te()` and `ti()` terms, produces a heatmap of the partial effect with a companion SE panel. Parameters ---------- model: A fitted GAM. n_points: Number of evenly spaced points at which to evaluate each smooth. For 2-D smooths, the grid has approximately `sqrt(n_points)` points per side. level: Confidence level for the bands (the default is `0.95` -> ±1.96 SE). Returns ------- altair.VConcatChart or altair.Chart One panel per smooth term (two for 2-D smooths), vertically concatenated. ## Model matrix Low-level model matrix construction from formulas and data. build_model_matrix(formula: 'Formula', data: 'dict[str, NDArray]', *, apply_constraints: 'bool' = True, select: 'bool' = False) -> 'ModelMatrix' Assemble the full design matrix and penalty structure from a formula. A generalized additive model is fit by turning the covariates into a purely numeric *model matrix* (also called a *design matrix*) `X` — an `(n, p)` array of `n` observations by `p` coefficients — such that the linear predictor is simply a matrix-vector product: $$ \eta = X \beta + \text{offset} $$ Every term in the formula contributes one or more columns to `X`. An intercept contributes a single column of ones; a linear term `x3` contributes the column `x3` itself; an interaction `x1:x2` contributes the elementwise product `x1 * x2`. A smooth term such as `s(x)`, by contrast, expands into *several* columns: the basis functions of the underlying spline (or other smooth) basis, evaluated at each observation's value of `x`. Fitting a GAM is then no different from fitting a (penalized) linear model in these expanded columns — the non-linearity of `f(x)` lives entirely in how the basis functions are constructed, not in how the coefficients enter the model. `build_model_matrix` is the bridge between a parsed `~whittaker.formula.terms.Formula` and the numeric matrices the P-IRLS fitting engine (`~whittaker.pirls.pirls_fit`) actually needs: the design matrix `X` plus, for each smooth term, a quadratic penalty matrix `S_j` that penalizes wiggliness via `beta.T @ S_j @ beta`. Each `S_j` starts out sized to just that term's own basis functions but is expanded ("embedded") to the full `(p, p)` model dimension, with zeros everywhere outside that term's column block, so that summing `lambda_j * S_j` over all terms gives a single penalty matrix that can be added directly to the unpenalized normal equations. Parameters ---------- formula : Formula A parsed `~whittaker.formula.terms.Formula`, typically produced by `~whittaker.formula.parser.parse`. data : dict[str, numpy.ndarray] Column-oriented data as `{name: 1-D array}`. Every column referenced by the formula must be present. All arrays must have the same length. apply_constraints : bool If `True` (the default), apply sum-to-zero identifiability constraints to each smooth term so the intercept is identifiable. select : bool If `True`, add an extra penalty on each smooth's null space so that terms can be penalized to zero entirely (double penalty approach, Marra & Wood 2011). This enables automatic smooth selection via GCV or REML. Smooths that already have `null_space_dim == 0` (e.g. `bs="ts"`, `bs="cs"`, `bs="re"`, `bs="fs"`) are unaffected. Returns ------- ModelMatrix Bundled design matrix, penalties, and metadata. Raises ------ KeyError If a required column is missing from *data*. ValueError If an unsupported basis type is requested. Notes ----- An ordinary smoothing penalty `S` for a term like `s(x)` typically has a non-trivial null space — directions in coefficient space that the penalty does not shrink at all (e.g. the linear component of a cubic spline). This means increasing that term's smoothing parameter `lambda_j` can flatten the smooth toward a straight line, but never all the way to zero, so ordinary GCV/REML smoothing-parameter selection cannot remove an irrelevant term from the model entirely. When `select=True`, `build_model_matrix` adds a second penalty matrix per term (see `_null_space_penalty`) built from the eigendecomposition of the term's own penalty `S`: the eigenvectors with (numerically) zero eigenvalue span exactly the null space of `S`, and projecting onto that eigenspace gives a penalty `S_null` that penalizes only those previously-unpenalized directions. With both `S` and `S_null` present (each with its own smoothing parameter), driving both `lambda_j` and the null-space smoothing parameter to large values shrinks the *entire* term, including its linear component, to zero — allowing REML- or GCV-based fitting to perform automatic term selection much like a lasso penalty does for linear models. See Marra, G. and Wood, S.N. (2011), "Practical variable selection for generalized additive models", *Computational Statistics & Data Analysis*, 55(7), 2372-2387. Examples -------- ```{python} import numpy as np from whittaker.formula.parser import parse from whittaker.model_matrix import build_model_matrix rng = np.random.default_rng(0) x = np.sort(rng.uniform(0, 1, 50)) y = np.sin(2 * np.pi * x) + rng.normal(scale=0.2, size=50) formula = parse("y ~ s(x)") model_matrix = build_model_matrix(formula, {"x": x, "y": y}) model_matrix.X.shape ``` ```{python} model_matrix.column_names[:5] ``` predict_matrix(model: 'ModelMatrix', new_data: 'dict[str, NDArray]') -> 'NDArray' Build the prediction design matrix for new data. Predicting from a fitted GAM requires evaluating each term at new covariate values in *exactly* the same numeric representation used during fitting. For a linear term this is trivial (just read off the new column), but for a smooth term it is not: simply re-fitting a fresh basis on the new data would choose different knots, degrees of freedom, and identifiability constraints, producing a matrix whose columns don't line up with the fitted coefficients `beta` at all. `predict_matrix` avoids this by reusing the exact `SmoothBasis` objects stored on `model.smooths[i].basis` — the same knots and constraints the model was fitted with — and simply evaluating `basis.basis_matrix()` at the new covariate values, so the resulting columns are directly compatible with the training-time coefficients. Because a `ModelMatrix` does not retain the original `~whittaker.formula.terms.Formula` object (only its numeric consequences), `predict_matrix` first calls `_reconstruct_formula` to rebuild an equivalent `Formula` from `model.column_names` and `model.smooths`, then walks that formula's terms in order — extracting linear/interaction columns directly from `new_data` and re-evaluating each smooth's stored basis — to assemble a new design matrix with the same column layout as `model.X`. Parameters ---------- model : ModelMatrix A `ModelMatrix` previously returned by `build_model_matrix()`. new_data : dict[str, numpy.ndarray] Column-oriented new data. Must contain every covariate column referenced by the original formula (the response column is not needed). Returns ------- numpy.ndarray Design matrix of shape `(n_new, p)` with the same column layout as `model.X`. Notes ----- `_reconstruct_formula` infers term boundaries purely from `column_names` and the stored `SmoothInfo` objects, not from any record of the literal original formula string. In practice this reconstruction always agrees with the training matrix's column layout, since it is derived from the very structures that layout was built from — but the reconstructed `Formula`'s term order and string labels are *inferred*, and should not be treated as a faithful reproduction of the original formula object passed to `build_model_matrix` (for example, `full=True` interactions are not reconstructed exactly as originally specified). Examples -------- Continuing from the `build_model_matrix` example above, predict at new values of `x` using the same fitted basis: ```{python} import numpy as np from whittaker.formula.parser import parse from whittaker.model_matrix import build_model_matrix, predict_matrix rng = np.random.default_rng(0) x = np.sort(rng.uniform(0, 1, 50)) y = np.sin(2 * np.pi * x) + rng.normal(scale=0.2, size=50) model_matrix = build_model_matrix(parse("y ~ s(x)"), {"x": x, "y": y}) new_x = np.linspace(0, 1, 5) X_new = predict_matrix(model_matrix, {"x": new_x}) X_new.shape ``` ModelMatrix(X: 'NDArray', penalties: 'list[NDArray]', smooths: 'list[SmoothInfo]' = , column_names: 'list[str]' = , has_intercept: 'bool' = True, n_parametric: 'int' = 0, offset: 'NDArray | None' = None, offset_expressions: 'list[str]' = , response: 'NDArray' = ) -> None Numeric design matrix, penalties, and metadata produced by :func:`build_model_matrix`. A `ModelMatrix` is the numeric form of a fitted `~whittaker.gam.GAM`'s formula: the design matrix `X` such that the linear predictor is `eta = X @ beta` (plus an optional offset), together with one quadratic penalty matrix per smooth term and enough metadata (`smooths`, `column_names`) to later build a matching prediction matrix or attribute coefficients back to individual terms. `GAM.fit()` calls `build_model_matrix` once and stores the result so that `predict()`, `summary()`, and `plot()` can all refer back to the same column layout. Attributes ---------- X : numpy.ndarray Full design matrix, shape `(n, p)` where *n* is the number of observations and *p* is the total number of columns (intercept + parametric + all constrained smooth bases). penalties : list of numpy.ndarray List of `(p, p)` penalty matrices, one per smooth term (or per marginal/null-space penalty within a term), each containing that term's penalty embedded in the appropriate block of the full model dimension so `beta.T @ S_j @ beta` depends only on that term's coefficients. smooths : list of SmoothInfo Per-smooth metadata (`SmoothInfo`) in formula order. column_names : list of str Human-readable label for each column of `X`, in order, e.g. `"(Intercept)"`, a covariate name for a linear term, or `"s(x)[0]"` for the first basis function of a smooth term. has_intercept : bool Whether column 0 is the intercept. n_parametric : int Number of parametric (linear + interaction) columns, not counting the intercept. offset : numpy.ndarray or None Offset vector of shape `(n,)`, or `None` if no offset term. offset_expressions : list of str Column expressions summed to form `offset`, retained so `predict_offset` can reconstruct the offset for new data. response : numpy.ndarray The response column as a 1-D float array. SmoothInfo(term: 'SmoothTerm', basis: 'SmoothBasis', col_start: 'int', col_end: 'int', null_space_dim: 'int', penalty_indices: 'list[int]' = , by_var: 'str | None' = None, by_level: 'str | None' = None) -> None Metadata describing where one smooth term lives inside a `ModelMatrix`. Each `~whittaker.formula.terms.SmoothTerm` in a formula (an `s()`, `te()`, `ti()`, or `t2()` call) expands into several columns of the full design matrix `X` — one per basis function of the fitted `SmoothBasis`. A `SmoothInfo` records the bookkeeping needed to make sense of that expansion after the fact: which contiguous slice `X[:, col_start:col_end]` belongs to this term, the fitted `SmoothBasis` instance itself (so the exact same knots and constraints can be reused later), and which entries of `ModelMatrix.penalties` hold this term's own penalty block(s). `build_model_matrix` creates one `SmoothInfo` per smooth term (or per `by=` level, for factor-by smooths) and stores the list on `ModelMatrix.smooths`; `predict_matrix` later reads these back to evaluate each smooth's basis on new data without needing the original `Formula` object. Attributes ---------- term : SmoothTerm The parsed `SmoothTerm` this info belongs to. basis : SmoothBasis The fitted `SmoothBasis` instance for this term. Retained so that `predict_matrix` can call `basis.basis_matrix()` on new covariate values using the exact knots, degrees of freedom, and identifiability constraints learned during fitting, rather than re-fitting a new basis. col_start : int Start column index (inclusive) in the full model matrix `X`. col_end : int End column index (exclusive) in the full model matrix `X`. null_space_dim : int Dimension of the penalty null space for this smooth, i.e. the number of basis directions (after identifiability constraints) that the penalty does not shrink at all. For an ordinary cubic or thin-plate spline this is typically the "linear" part of the smooth; for shrinkage bases (`bs="ts"`, `bs="cs"`) and random-effect/factor-smooth bases (`bs="re"`, `bs="fs"`) it is `0` because every direction is already penalized. penalty_indices : list of int Indices into `ModelMatrix.penalties` that belong to this smooth. For `s()` terms this is usually a single index (or two, if `select=True` added a null-space penalty); for `te()` terms it is one index per marginal direction. by_var : str or None Name of the `by=` variable for this term, or `None` if the term has no `by=` modifier. by_level : str or None For factor `by=` variables, the specific level this `SmoothInfo` corresponds to (one `SmoothInfo` is created per level); `None` for continuous `by=` variables or terms without a `by=` modifier. ---------------------------------------------------------------------- This is the User Guide documentation for the package. ---------------------------------------------------------------------- ## Getting started ### Get started ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Whittaker brings the full power of Generalized Additive Models (GAMs) to Python. It is built on NumPy and SciPy, follows the mathematical framework of Wood's `mgcv`, and provides a formula-based interface that makes specifying even complex models a one-liner. ## Installation Whittaker targets Python 3.10+ and is not yet on PyPI. Once released: ```bash pip install whittaker ``` ### Dependencies The core package depends on: | Package | Purpose | |---|---| | NumPy | Array computation and linear algebra | | SciPy | Cholesky factorization, optimization, B-spline evaluation | These are installed automatically. ### From source To install the development version from GitHub: ```bash git clone https://github.com/rich-iannone/whittaker.git cd whittaker pip install -e ".[dev]" ``` The `[dev]` extra installs testing and linting tools (pytest, ruff, pyright). ### Verifying the installation After installing, check that Whittaker loads correctly and prints its version. ```{python} import whittaker as wk # Print the installed version print(wk.__version__) ``` If no error is raised and a version string appears, the installation is working. ## What Whittaker provides Whittaker includes a broad set of tools for modern statistical modeling: - **14 response families** including Gaussian, Poisson, Binomial, Gamma, Negative Binomial, Beta, Tweedie, Inverse Gaussian, Cox PH, and more - **20+ smooth basis types** including thin plate regression splines (TPRS), P-splines, cubic regression splines, tensor products, cyclic splines, random effects, soap film smooths, Gaussian processes, and Markov random fields - **Shape constraints**: monotone increasing/decreasing, convex, and concave smooths - **Distributional regression** (GAMLSS): model location, scale, and shape parameters simultaneously - **Quantile regression** with non-crossing constraints - **Conformal prediction** for distribution-free prediction intervals - **Causal inference** via double/debiased machine learning - **Streaming/online fitting** for data that arrives in batches - **Multi-response GAMs** for jointly modeling multiple outcomes - **Functional regression** for scalar-on-function models - **Large dataset support** via BigGAM, PolarsGAM, and DuckDBGAM - **scikit-learn integration** for use in ML pipelines ## How this guide is organized The user guide is grouped into sections that follow the modeling workflow. **Getting started** - **[Understanding GAMs](01-understanding-gams.qmd)**: what GAMs are, when to use them, and how they relate to linear models. - **[Quick start](02-quick-start.qmd)**: a complete example from raw data to a fitted model. - **[Built-in datasets](03-datasets.qmd)**: ready-made datasets for learning and testing. **Fitting models** - **[Smooth terms](04-smooths.qmd)**: the full catalog of basis types and how to choose among them. - **[Response families](05-families.qmd)**: Gaussian, Poisson, Binomial, and more. - **[Model fitting](06-fitting.qmd)**: the P-IRLS algorithm, smoothness selection, and convergence. - **[Data input](07-data-input.qmd)**: how Whittaker accepts dict-based data. **Prediction and inference** - **[Prediction](08-prediction.qmd)**: point estimates, standard errors, and confidence bands. - **[Simultaneous confidence bands](09-simultaneous-ci.qmd)**: curve-wide intervals that cover the entire smooth at once. - **[Partial dependence as data](10-partial-dependence.qmd)**: structured arrays for custom plotting or downstream analysis. **Model diagnostics** - **[Diagnostics](11-diagnostics.qmd)**: basis adequacy, residuals, and QQ plots. - **[Diagnostic data for custom plots](12-check-data.qmd)**: raw diagnostic arrays for matplotlib or other plotting libraries. - **[Advanced diagnostics](13-advanced-diagnostics.qmd)**: influence, concurvity, dispersion tests, and quantile residuals. **Model selection** - **[Cross-validation](14-cross-validation.qmd)**: K-fold cross-validation for GAMs. - **[Model comparison](15-compare.qmd)**: comparing models with AIC, BIC, and deviance tests. - **[ANOVA for GAMs](16-anova.qmd)**: analysis of deviance for nested model comparisons. **Extended features** - **[Shape constraints](17-shape-constraints.qmd)**: monotone, convex, and concave smooths. - **[Distributional regression](18-gamlss.qmd)**: GAMLSS for location-scale-shape models. - **[Quantile regression](19-quantile.qmd)**: quantile GAMs with non-crossing constraints. - **[Conformal prediction](20-conformal.qmd)**: distribution-free prediction intervals. - **[Causal inference](21-causal.qmd)**: causal GAMs via double machine learning. - **[Streaming and online fitting](22-streaming.qmd)**: incremental GAM fitting. - **[Multi-response models](23-multi-response.qmd)**: jointly modeling multiple outcomes. - **[Functional regression](24-functional.qmd)**: scalar-on-function regression. **Large datasets** - **[Large datasets](25-large-datasets.qmd)**: BigGAM, PolarsGAM, and DuckDBGAM. **Bayesian inference** - **[Variational inference](26-variational-inference.qmd)**: fast approximate Bayesian fitting. - **[MCMC sampling](27-mcmc.qmd)**: full posterior sampling with NUTS and HMC. - **[LOO comparison](28-loo.qmd)**: leave-one-out cross-validation for Bayesian models. - **[WAIC comparison](29-waic.qmd)**: information-criterion-based model comparison. - **[Posterior predictive checks](30-ppc.qmd)**: testing whether the model generates realistic data. - **[Posterior predictive distributions](31-posterior-predict.qmd)**: drawing from the predictive distribution. - **[Model averaging with stacking](32-stacking.qmd)**: combining Bayesian models by stacking weights. **Advanced inference** - **[Smoothing parameter sensitivity](33-sensitivity.qmd)**: how robust are conclusions to smoothing choices. - **[Derivatives and marginal effects](34-derivatives.qmd)**: rates of change and their uncertainty. **Tooling** - **[Programmatic formulas](35-programmatic-formulas.qmd)**: building formulas in code. - **[scikit-learn integration](36-sklearn.qmd)**: using Whittaker in ML pipelines. - **[Model matrix utilities](37-model-matrix.qmd)**: inspecting and manipulating design matrices. **Deployment** - **[Serialization](38-serialization.qmd)**: saving and loading fitted models. ### Understanding GAMs If you have used linear regression before, you already understand the core idea behind a Generalized Additive Model (GAM). This page explains what GAMs are, when they are useful, and how they connect to the linear models you may already know. ## Starting from linear regression A linear regression model says that the expected value of a response $y$ depends on predictors through a straight-line relationship: $$y = \beta_0 + \beta_1 x_1 + \beta_2 x_2 + \varepsilon$$ Each coefficient $\beta_j$ tells you how much $y$ changes for a one-unit increase in $x_j$. This is simple and interpretable, but it forces every relationship to be a straight line. Real data often curve. ## What a GAM changes A GAM replaces each linear term $\beta_j x_j$ with a smooth function $f_j(x_j)$ whose shape is learned from the data: $$y = \beta_0 + f_1(x_1) + f_2(x_2) + \varepsilon$$ The function $f_j$ can be wiggly, flat, or anything in between (whatever the data support really). You do not need to guess the right polynomial degree or manually specify breakpoints. The model figures out the shape on its own. The word **additive** means the effect of each predictor is modeled separately and the contributions are summed. This keeps the model interpretable: you can plot $f_1(x_1)$ and $f_2(x_2)$ individually to see how each predictor influences the response. ## When might you use a GAM? GAMs are a good fit when: - **Relationships are nonlinear** and you do not know the functional form in advance. Rather than trying polynomials, log transforms, or binning, a GAM learns the shape directly. - **You want interpretability**. Unlike black-box models, a GAM gives you a smooth curve for each predictor that you can inspect, plot, and reason about. - **You need uncertainty estimates**. GAMs provide standard errors and confidence bands for every smooth, so you know where the model is confident and where it is uncertain. - **You have moderate to large sample sizes**. GAMs need enough data to estimate smooth shapes reliably (as a rough guide, at least 50--100 observations per smooth term). GAMs may not be the best choice when: - **Relationships are truly linear**. A GAM will recover a straight line when the data are linear, but a plain linear model is simpler and faster. - **You need high-dimensional feature interactions**. GAMs model each predictor separately by default. Interactions between two or three predictors are possible (via tensor products), but GAMs are not designed for the kind of high-dimensional interaction learning that tree ensembles or neural networks handle. - **Prediction speed is critical**. For latency-sensitive applications with very large feature sets, simpler models or pre-compiled predictions may be faster. ## How smoothness is controlled The key question is: how does a GAM decide how wiggly each smooth should be? Each smooth function is built from a set of **basis functions**, which are simple building blocks (like splines) that are combined to approximate any smooth shape. The number of basis functions (called $k$) sets the maximum possible complexity. A **smoothing parameter** $\lambda$ penalizes wiggliness. A large $\lambda$ produces a nearly straight line whereas a small $\lambda$ allows the curve to follow the data closely. The right $\lambda$ is selected automatically by a statistical criterion. The most common is **REML** (Restricted Maximum Likelihood), which balances fit against complexity. This means you do not have to hand-tune the smoothness. You set $k$ large enough (the default of 10 is usually fine), and the model finds the right amount of flexibility. ## Generalized: beyond Gaussian data The "Generalized" in GAM means the framework extends beyond continuous, normally distributed responses. Just as a generalized linear model (GLM) handles counts, binary outcomes, and proportions through a **link function** and an appropriate distribution, a GAM does the same with smooth terms. For example: - **Count data** (e.g., number of events): use a Poisson family with a log link. The model becomes $\log(\mu) = \beta_0 + f(x)$, and predictions are on the count scale. - **Binary outcomes** (e.g., yes/no): use a Binomial family with a logit link. The model estimates the probability of success as a smooth function of the predictors. - **Strictly positive data** (e.g., costs, durations): use a Gamma family. The choice of family tells the model how the variance relates to the mean and what scale the relationship operates on. This is the same idea as in GLMs but GAMs simply add the ability to make each predictor's effect nonlinear. ## The GAM workflow A typical analysis with a GAM follows these steps: 1. **Specify** the model with a formula: which variables are smooth, which are linear, what family to use. 2. **Fit** the model. Smoothing parameters are estimated automatically. 3. **Check** the fit: are the basis dimensions large enough? Do the residuals look reasonable? 4. **Interpret** the results: look at the summary, plot the smooth terms, compute predictions with confidence intervals. 5. **Refine** if needed: increase basis dimensions, change the family, add or remove terms. This iterative workflow is central to GAM modeling. Unlike machine learning pipelines where you optimize a single metric, GAM analysis involves inspecting diagnostics and understanding what the model has learned. ## GAMs compared to other approaches | Approach | Strengths | Limitations | |---|---|---| | Linear regression | Simple, fast, highly interpretable | Cannot capture nonlinear effects | | Polynomial regression | Can model curves | Must choose degree and unstable at boundaries | | GAMs | Flexible curves, automatic smoothness, interpretable | Additive structure limits interactions | | Random forests / boosting | Handle interactions and high dimensions | Less interpretable, no smooth uncertainty | | Neural networks | Arbitrary function approximation | Require large data, opaque | GAMs occupy a sweet spot: they're more flexible than linear models, and they're more interpretable than black-box methods. They are especially strong when you care about *understanding* the relationship between each predictor and the response, not just making predictions. ## Where to go next - **[Quick start](02-quick-start.qmd)**: see a complete GAM analysis in code, from raw data to predictions and plots. - **[Smooth terms](04-smooths.qmd)**: the full catalog of basis types available in Whittaker. - **[Response families](05-families.qmd)**: all supported distributions and link functions. ### Quick start ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` This is a short tour from raw data to a fitted GAM, a model summary, and predictions. Each step is only a few lines. The rest of the guide covers every piece in depth. ## Create some data We start with a simple nonlinear relationship: $y = \sin(x) + \varepsilon$, where $\varepsilon$ is Gaussian noise. This is a classic test case for smooth function estimation. ```{python} import numpy as np import whittaker as wk # Generate 200 observations from a noisy sine curve rng = np.random.default_rng(23) n = 200 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) # Whittaker accepts data as a dictionary of arrays data = {"x": x, "y": y} ``` The data is a dictionary mapping column names to 1-D NumPy arrays. Every column referenced in the formula must be present. ## Specify and fit the model A GAM formula looks like an R formula. Wrapping a predictor in `s()` tells Whittaker to model it as a smooth function. The default basis is a thin plate regression spline (TPRS) with 10 basis functions. ```{python} # Create a GAM with a smooth term for x model = wk.GAM("y ~ s(x)") # Fit the model (smoothing parameters are selected automatically via REML) model.fit(data, method="REML") ``` Whittaker automatically selects the smoothing parameter $\lambda$ by REML (restricted maximum likelihood). No manual tuning is required. The `fit()` method returns `self`, so you can chain calls: `wk.GAM("y ~ s(x)").fit(data)`. ## Inspect the summary ```{python} # Print a summary of the fitted model print(model.summary()) ``` The summary reports: - **Smooth terms**: the effective degrees of freedom (EDF) for each smooth. An EDF near 1 means the smooth is approximately linear (higher values indicate more complex curvature). - **Model fit statistics**: deviance explained, GCV score, and scale estimate $\hat\phi$. ## Predict on new data ```{python} # Create a fine grid for smooth predictions x_new = np.linspace(0, 2 * np.pi, 100) new_data = {"x": x_new} # Predict on the response scale preds = model.predict(new_data) # The result contains fitted values print(f"Prediction shape: {preds.values.shape}") print(f"First 5 predictions: {preds.values[:5].round(3)}") ``` The `predict()` method returns a `PredictionResult` with a `.values` attribute containing predictions on the response scale ($\hat\mu = g^{-1}(\hat\eta)$. For Gaussian with identity link, this is simply $X\hat\beta$). ## Predictions with standard errors ```{python} # Predict with standard errors preds_se = model.predict(new_data, se=True) # Standard errors are on the linear predictor scale print(f"SE shape: {preds_se.se.shape}") print(f"First 5 SEs: {preds_se.se[:5].round(4)}") ``` Setting `se=True` additionally computes standard errors from the Bayesian posterior covariance matrix $V_\beta = \hat\phi (X^\top W X + \sum_j \lambda_j S_j)^{-1}$, where $S_j$ are the penalty matrices and $\lambda_j$ the estimated smoothing parameters. ## Visualize the fit ```{python} import altair as alt # Build a DataFrame for plotting x_plot = np.linspace(0, 2 * np.pi, 200) preds_plot = model.predict({"x": x_plot}, se=True) # Compute 95% confidence band on the response scale z = 1.96 lower = preds_plot.values - z * preds_plot.se upper = preds_plot.values + z * preds_plot.se # Observed data points points = alt.Chart({"values": [{"x": float(x[i]), "y": float(y[i])} for i in range(n)]}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) # Fitted curve fit_data = [ {"x": float(x_plot[i]), "fit": float(preds_plot.values[i]), "lower": float(lower[i]), "upper": float(upper[i])} for i in range(len(x_plot)) ] line = alt.Chart({"values": fit_data}).mark_line(color="firebrick", strokeWidth=2).encode( x="x:Q", y="fit:Q" ) # Confidence band band = alt.Chart({"values": fit_data}).mark_area(opacity=0.2, color="firebrick").encode( x="x:Q", y="lower:Q", y2="upper:Q" ) # True function true_data = [{"x": float(x_plot[i]), "true": float(np.sin(x_plot[i]))} for i in range(len(x_plot))] true_line = alt.Chart({"values": true_data}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="true:Q") # Combine all layers (band + points + line + true_line).properties( width="container", height=300, title="GAM fit: y ~ s(x)" ) ``` The red curve is the estimated smooth $\hat{f}(x)$, the shaded band is the 95% pointwise confidence interval, the gray dashed line is the true $\sin(x)$, and the blue points are the observed data. The GAM recovers the true shape closely, with the confidence band covering the truth everywhere. ## A model with multiple smooths GAMs shine when you have multiple predictors, each with a potentially nonlinear effect. Here we generate data with two smooth effects and a linear term: ```{python} # Generate data with two smooth effects rng = np.random.default_rng(23) n = 300 x1 = np.linspace(0, 2 * np.pi, n) x2 = rng.uniform(0, 1, n) x3 = rng.normal(0, 1, n) # True relationship: sin(x1) + x2^2 + 0.5*x3 + noise y = np.sin(x1) + x2**2 + 0.5 * x3 + rng.normal(0, 0.3, n) data_multi = {"x1": x1, "x2": x2, "x3": x3, "y": y} ``` ```{python} # Fit a GAM with two smooth terms and one linear term model_multi = wk.GAM("y ~ s(x1) + s(x2) + x3") model_multi.fit(data_multi, method="REML") # Print the summary print(model_multi.summary()) ``` ```{python} # Predicted vs. observed for the multi-predictor model preds_multi = model_multi.predict(data_multi) obs_vs_pred = [ {"observed": float(y[i]), "predicted": float(preds_multi.values[i])} for i in range(len(y)) ] scatter_multi = alt.Chart({"values": obs_vs_pred}).mark_circle( size=20, opacity=0.4, color="steelblue" ).encode( x=alt.X("observed:Q", title="Observed y"), y=alt.Y("predicted:Q", title="Predicted y"), ) # 1:1 reference line y_range = [float(min(y.min(), preds_multi.values.min())), float(max(y.max(), preds_multi.values.max()))] ref_line = alt.Chart( {"values": [{"v": y_range[0]}, {"v": y_range[1]}]} ).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode( x=alt.X("v:Q", title="Observed y"), y=alt.Y("v:Q", title="Predicted y"), ) (ref_line + scatter_multi).properties( width="container", height=350, title="Multi-predictor GAM: predicted vs. observed" ) ``` The formula `"y ~ s(x1) + s(x2) + x3"` specifies: - `s(x1)`: a smooth function of `x1` (captures the sine wave) - `s(x2)`: a smooth function of `x2` (captures the quadratic) - `x3`: a plain linear term (enters the model as $\beta \cdot x_3$) The summary shows that `s(x1)` uses more effective degrees of freedom (capturing the sine wave's curvature) while `s(x2)` uses fewer (a quadratic is a simpler shape). ## Non-Gaussian responses For count data, binary outcomes, or other non-Gaussian responses, specify a family: ```{python} # Poisson count data rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) mu = np.exp(0.5 + 0.8 * np.sin(x)) # true log-linear rate y_counts = rng.poisson(mu) # Fit a Poisson GAM model_pois = wk.GAM("y ~ s(x)", family=wk.Poisson()) model_pois.fit({"x": x, "y": y_counts.astype(float)}, method="REML") # Predictions are on the response scale (counts) preds_pois = model_pois.predict({"x": x}) print(f"Mean predicted count: {preds_pois.values.mean():.2f}") print(f"Mean observed count: {y_counts.mean():.2f}") ``` ```{python} # Scatter of observed counts + fitted rate curve + true rate x_fine = np.linspace(0, 2 * np.pi, 200) preds_fine = model_pois.predict({"x": x_fine}) true_rate = np.exp(0.5 + 0.8 * np.sin(x_fine)) obs_counts = [ {"x": float(x[i]), "y": float(y_counts[i])} for i in range(len(x)) ] points_pois = alt.Chart({"values": obs_counts}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="Count"), ) fit_rate = [ {"x": float(x_fine[i]), "rate": float(preds_fine.values[i])} for i in range(len(x_fine)) ] fitted_line = alt.Chart({"values": fit_rate}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="rate:Q") true_rate_data = [ {"x": float(x_fine[i]), "rate": float(true_rate[i])} for i in range(len(x_fine)) ] truth_line = alt.Chart({"values": true_rate_data}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="rate:Q") (points_pois + fitted_line + truth_line).properties( width="container", height=300, title="Poisson GAM: fitted rate vs. true rate" ) ``` The Poisson family uses a log link, so the model is $\log(\mu) = \beta_0 + f(x)$ and predictions are on the count scale after applying $\exp$. ## Where to go next - **[Smooth terms](04-smooths.qmd)**: TPRS, cubic splines, P-splines, tensor products, and more. - **[Response families](05-families.qmd)**: all supported distributions and link functions. - **[Model fitting](06-fitting.qmd)**: the P-IRLS algorithm, GCV vs. REML, and convergence. - **[Prediction and inference](08-prediction.qmd)**: standard errors, confidence intervals, and term-level predictions. ### Built-in datasets ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Whittaker ships with 10 synthetic datasets covering a range of response families and modeling scenarios. They are generated with fixed random seeds, so they are fully reproducible without internet access or optional dependencies. Use them for quick demonstrations, testing, and learning. ## Listing available datasets `list_datasets()` returns metadata for every built-in dataset: ```{python} import whittaker as wk for ds in wk.list_datasets(): print(f"{ds['name']:>14} {ds['family']:<12} {ds['description']}") ``` Each entry includes the dataset `name`, a short `description`, the intended response `family`, the `variables` it contains, and a `note` about what modeling scenario it illustrates. ## Loading a dataset `load_dataset()` returns a column-oriented dictionary that can be passed directly to `GAM.fit()`: ```{python} data = wk.load_dataset("mcycle") print(f"Type: {type(data)}") print(f"Keys: {list(data.keys())}") print(f"Observations: {len(data['times'])}") ``` ```{python} model = wk.GAM("accel ~ s(times)").fit(data) model.summary() ``` ### Loading as a DataFrame Pass `as_frame=True` to get a pandas DataFrame instead (requires pandas): ```{python} df = wk.load_dataset("wages", as_frame=True) df.head() ``` ## Dataset catalog ### mcycle — Gaussian, heteroscedastic Simulated motorcycle-crash accelerometer data (n=133). Head acceleration measured at various times after impact. Strongly non-linear and heteroscedastic — a standard stress test for smoothing. ```{python} data = wk.load_dataset("mcycle") model = wk.GAM("accel ~ s(times)").fit(data) gof = model.goodness_of_fit() print(f"EDF: {gof.edf_total:.1f}, Dev. explained: {gof.deviance_explained:.1%}") ``` ### co2 — Gaussian, trend + seasonal Synthetic monthly CO2 concentrations (n=504, 1958–1999) with a rising trend and annual cycle. Good for cyclic smooths and additive decomposition. ```{python} data = wk.load_dataset("co2") model = wk.GAM("co2 ~ s(t) + s(month, bs='cc', k=12)").fit(data, method="REML") gof = model.goodness_of_fit() print(f"EDF: {gof.edf_total:.1f}, Dev. explained: {gof.deviance_explained:.1%}") ``` ### fish — Poisson counts Fish-abundance survey (n=300) with a hump-shaped temperature effect and a linear depth effect. ```{python} from whittaker.families.poisson import Poisson data = wk.load_dataset("fish") model = wk.GAM("count ~ s(temperature) + s(depth)", family=Poisson()).fit(data) gof = model.goodness_of_fit() print(f"EDF: {gof.edf_total:.1f}, Dev. explained: {gof.deviance_explained:.1%}") ``` ### credit — Binomial (binary) Credit-default dataset (n=1000) with smooth effects of income and debt ratio on default probability. ```{python} from whittaker.families.binomial import Binomial data = wk.load_dataset("credit") model = wk.GAM("default ~ s(income) + s(debt_ratio) + s(age)", family=Binomial()).fit(data) gof = model.goodness_of_fit() print(f"EDF: {gof.edf_total:.1f}, Dev. explained: {gof.deviance_explained:.1%}") ``` ### wages — Gamma Worker-earnings dataset (n=800) with log-wages shaped by smooth age and experience effects. ```{python} from whittaker.families.gamma import Gamma data = wk.load_dataset("wages") model = wk.GAM("wage ~ s(age) + s(experience)", family=Gamma()).fit(data) gof = model.goodness_of_fit() print(f"EDF: {gof.edf_total:.1f}, Dev. explained: {gof.deviance_explained:.1%}") ``` ### proportions — Beta Seed-germination dataset (n=400) with a bounded `[0, 1]` response and a non-linear temperature optimum. ```{python} from whittaker.families.beta import Beta data = wk.load_dataset("proportions") model = wk.GAM("germination_rate ~ s(temperature) + s(water)", family=Beta()).fit(data) gof = model.goodness_of_fit() print(f"EDF: {gof.edf_total:.1f}, Dev. explained: {gof.deviance_explained:.1%}") ``` ### meuse — Gaussian, spatial River-bank heavy-metals dataset (n=155) with map coordinates. Log(zinc) decreases with distance from the river. Good for 2D spatial smooths. ```{python} import numpy as np data = wk.load_dataset("meuse") data["log_zinc"] = np.log(data["zinc"]) model = wk.GAM("log_zinc ~ s(x, y) + s(dist)").fit(data, method="REML") gof = model.goodness_of_fit() print(f"EDF: {gof.edf_total:.1f}, Dev. explained: {gof.deviance_explained:.1%}") ``` ### survival — Cox PH Clinical-trial survival dataset (n=250) with a smooth age effect and a binary treatment arm. Approximately 30% censored. ```{python} from whittaker.families.cox_ph import CoxPH data = wk.load_dataset("survival") model = wk.GAM("time ~ s(age) + treatment", family=CoxPH(status="event")).fit(data) print(f"EDF: {model.goodness_of_fit().edf_total:.1f}") ``` ### abalone — Gaussian, multi-predictor Abalone morphology dataset (n=500) with four predictors and a ring count response. Good for tensor products and multi-term additive models. ```{python} data = wk.load_dataset("abalone") model = wk.GAM("rings ~ s(length) + s(shucked_weight)").fit(data) gof = model.goodness_of_fit() print(f"EDF: {gof.edf_total:.1f}, Dev. explained: {gof.deviance_explained:.1%}") ``` ### climate — GaussianLS (location-scale) Climate station dataset (n=600) where both the mean and variance of temperature depend on altitude and latitude. Designed for GAMLSS location-scale modeling. ```{python} data = wk.load_dataset("climate") model = wk.GAM("temperature ~ s(altitude) + s(latitude) + s(month, bs='cc', k=12)").fit( data, method="REML" ) gof = model.goodness_of_fit() print(f"EDF: {gof.edf_total:.1f}, Dev. explained: {gof.deviance_explained:.1%}") ``` ::: {.callout-tip} ## GAMLSS with the climate dataset For the full location-scale analysis, see [Distributional regression (GAMLSS)](18-gamlss.qmd), which models both the mean and variance as smooth functions of the predictors. ::: ## Where to go next - **[Quick start](02-quick-start.qmd)**: fitting your first model. - **[Response families](05-families.qmd)**: choosing the right family for your data. - **[Data input](07-data-input.qmd)**: using your own data with pandas, polars, or pyarrow. ## Fitting models ### Smooth terms ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` A GAM replaces the linear term $\beta x$ with a smooth function $f(x)$, allowing the data to determine the shape of each predictor's effect. This page covers every smooth type available in Whittaker, with runnable examples showing when and how to use each one. ## What smooth functions are In a standard linear model, each predictor enters as a straight line: $y = \beta_0 + \beta_1 x$. A GAM relaxes this to $$y = \beta_0 + f(x)$$ where $f$ is an unknown smooth function estimated from the data. The key idea is to represent $f$ as a weighted sum of known **basis functions** $b_k$: $$f(x) = \sum_{k=1}^{K} \beta_k \, b_k(x)$$ The coefficients $\beta_k$ are estimated by penalized likelihood. The **penalty** controls smoothness and without it, the model would interpolate the noise. The standard roughness penalty is $$\lambda \int \left[ f''(x) \right]^2 dx$$ where $\lambda$ is the **smoothing parameter**. Large $\lambda$ produces a smoother (less wiggly) curve, $\lambda \to 0$ reproduces an unpenalized fit, and $\lambda \to \infty$ shrinks $f$ toward a straight line. Whittaker selects $\lambda$ automatically by REML (or GCV), so you rarely need to set it by hand. ::: {.callout-tip} ## The basis dimension is an upper bound, not the fit complexity The number of basis functions `k` sets the **maximum** possible complexity of the smooth. The actual complexity (reported as the effective degrees of freedom, EDF) is determined by $\lambda$. Setting `k` too low truncates the function space and can cause underfitting, but setting it somewhat too high is harmless because the penalty takes care of the rest. ::: ## Thin plate regression splines (TPRS): `bs="tp"` {#tprs} TPRS is the default basis in Whittaker, as it is in `mgcv`. It is an **optimal** smoother: for a given number of basis functions, TPRS minimizes a global measure of roughness without requiring you to choose knot locations. This makes it an excellent default for exploratory work. **Penalty**: integrated squared second derivative (1D) or the thin plate spline penalty (2D+). **When to use**: the safe default for any univariate or low-dimensional smooth. ```{python} import numpy as np import whittaker as wk import altair as alt # Generate data from a noisy sine curve rng = np.random.default_rng(23) n = 200 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) # Fit a GAM with the default TPRS basis model_tp = wk.GAM("y ~ s(x)") model_tp.fit({"x": x, "y": y}, method="REML") # Print summary to see the EDF print(model_tp.summary()) ``` The summary shows the effective degrees of freedom (EDF) for the smooth. An EDF near 1 means the smooth is approximately linear (higher values indicate more curvature). For a sine wave, expect an EDF around 5--7. ```{python} # Predict on a fine grid with standard errors x_grid = np.linspace(0, 2 * np.pi, 300) preds = model_tp.predict({"x": x_grid}, se=True) # Compute 95% confidence band z = 1.96 lower = preds.values - z * preds.se upper = preds.values + z * preds.se # Build the plot data obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] fit_data = [ {"x": float(x_grid[i]), "fit": float(preds.values[i]), "lower": float(lower[i]), "upper": float(upper[i])} for i in range(len(x_grid)) ] true_data = [ {"x": float(x_grid[i]), "true": float(np.sin(x_grid[i]))} for i in range(len(x_grid)) ] # Observed points points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) # Fitted curve line = alt.Chart({"values": fit_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") # Confidence band band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") # True function true_line = alt.Chart({"values": true_data}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="true:Q") # Combine layers (band + points + line + true_line).properties( width="container", height=320, title="TPRS smooth (default): y ~ s(x)" ) ``` The red curve is the estimated smooth $\hat{f}(x)$, the shaded band is the 95% pointwise confidence interval, and the gray dashed line is the true $\sin(x)$. The TPRS basis recovers the shape closely with only 10 basis functions. ### Increasing the basis dimension If the true function is more complex, increase `k`: ```{python} # A more complex function needs more basis functions rng = np.random.default_rng(23) n = 400 x = np.linspace(0, 4 * np.pi, n) y = np.sin(x) + 0.5 * np.sin(3 * x) + rng.normal(0, 0.3, n) # k=10 (default) vs k=25 model_k10 = wk.GAM("y ~ s(x)").fit({"x": x, "y": y}, method="REML") model_k25 = wk.GAM("y ~ s(x, k=25)").fit({"x": x, "y": y}, method="REML") # Compare predictions x_grid = np.linspace(0, 4 * np.pi, 400) preds_k10 = model_k10.predict({"x": x_grid}) preds_k25 = model_k25.predict({"x": x_grid}) # Build comparison data comp_data = [ {"x": float(x_grid[i]), "fit": float(preds_k10.values[i]), "model": "k=10"} for i in range(len(x_grid)) ] + [ {"x": float(x_grid[i]), "fit": float(preds_k25.values[i]), "model": "k=25"} for i in range(len(x_grid)) ] alt.Chart({"values": comp_data}).mark_line(strokeWidth=2).encode( x=alt.X("x:Q", title="x"), y=alt.Y("fit:Q", title="f(x)"), color=alt.Color("model:N", title="Basis dimension"), ).properties(width="container", height=300, title="Effect of basis dimension k") ``` With `k=10`, the smooth cannot fully capture the higher-frequency component, but `k=25` captures both harmonics. Use `model.check()` (see [Diagnostics](11-diagnostics.qmd)) to decide if `k` is large enough. ## Cubic regression splines: `bs="cr"` {#crs} Cubic regression splines are piecewise cubic polynomials joined at **knots** with continuous first and second derivatives. They are slightly cheaper to compute than TPRS and provide an interpretable, knot-based representation. **Penalty**: integrated squared second derivative. **When to use**: when computational speed matters, or when you want explicit control over knot placement. ```{python} # Generate data rng = np.random.default_rng(23) n = 200 x = np.linspace(0, 1, n) y = np.exp(2 * x) * np.sin(6 * x) + rng.normal(0, 0.5, n) # Fit with cubic regression spline basis model_cr = wk.GAM("y ~ s(x, bs='cr', k=15)") model_cr.fit({"x": x, "y": y}, method="REML") # Print summary print(model_cr.summary()) ``` ```{python} # Predict and plot x_grid = np.linspace(0, 1, 300) preds_cr = model_cr.predict({"x": x_grid}, se=True) # Build plot data obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] fit_data = [ {"x": float(x_grid[i]), "fit": float(preds_cr.values[i]), "lower": float(preds_cr.values[i] - 1.96 * preds_cr.se[i]), "upper": float(preds_cr.values[i] + 1.96 * preds_cr.se[i])} for i in range(len(x_grid)) ] points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode(x=alt.X("x:Q"), y=alt.Y("y:Q")) line = alt.Chart({"values": fit_data}).mark_line( color="darkgreen", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="darkgreen" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") (band + points + line).properties( width="container", height=320, title="Cubic regression spline: s(x, bs='cr', k=15)" ) ``` ## P-splines: `bs="ps"` {#psplines} P-splines combine a **B-spline basis** with a **difference penalty** on adjacent coefficients. Instead of penalizing the integrated squared second derivative, the penalty acts on finite differences of the coefficients $\beta_k$: $$\lambda \sum_k (\Delta^m \beta_k)^2$$ where $\Delta^m$ is the $m$-th order difference operator. The default is $m = 2$ (second-order differences). **When to use**: large datasets, time series, or evenly-spaced data. P-splines are very efficient because the B-spline basis is banded. ```{python} # Generate evenly-spaced data (typical for time series) rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 10, n) y = 2 * np.sin(x) + 0.3 * x + rng.normal(0, 0.5, n) # Fit with P-spline basis model_ps = wk.GAM("y ~ s(x, bs='ps', k=20)") model_ps.fit({"x": x, "y": y}, method="REML") # Print summary print(model_ps.summary()) ``` ```{python} # Predict on a fine grid with standard errors x_grid = np.linspace(0, 10, 300) preds_ps = model_ps.predict({"x": x_grid}, se=True) # Compute 95% confidence band lower = preds_ps.values - 1.96 * preds_ps.se upper = preds_ps.values + 1.96 * preds_ps.se # Build plot data obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] fit_data = [ {"x": float(x_grid[i]), "fit": float(preds_ps.values[i]), "lower": float(lower[i]), "upper": float(upper[i])} for i in range(len(x_grid)) ] # Observed points points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) # Fitted curve line = alt.Chart({"values": fit_data}).mark_line( color="darkorchid", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") # Confidence band band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="darkorchid" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") (band + points + line).properties( width="container", height=320, title="P-spline smooth: s(x, bs='ps', k=20)" ) ``` ::: {.callout-note} ## Changing the penalty order Pass `m` to control the difference penalty order. `m=1` penalizes first differences (piecewise linear tendency), `m=2` penalizes second differences (piecewise quadratic tendency, the default), and `m=3` penalizes third differences. For example: `s(x, bs='ps', k=20, m=3)`. ::: ## Cyclic splines: `bs="cc"` and `bs="cp"` {#cyclic} For **periodic** predictors---time of day, day of year, angle---use a cyclic spline. The basis is constrained so that $f$ and its first derivative match at the endpoints of the covariate range. - `bs="cc"`: cyclic cubic regression spline - `bs="cp"`: cyclic P-spline **When to use**: any predictor that wraps around (hours, months, compass bearing, etc.). ```{python} # Simulate periodic data: temperature over a year rng = np.random.default_rng(23) n = 365 day = np.linspace(0, 365, n, endpoint=False) # True seasonal pattern: warm in summer, cold in winter temp = 15 + 10 * np.sin(2 * np.pi * (day - 80) / 365) + rng.normal(0, 2, n) # Fit a cyclic cubic spline model_cc = wk.GAM("y ~ s(x, bs='cc', k=12)") model_cc.fit({"x": day, "y": temp}, method="REML") # Predict on a full cycle day_grid = np.linspace(0, 365, 365) preds_cc = model_cc.predict({"x": day_grid}, se=True) # Build plot data obs_data = [{"day": float(day[i]), "temp": float(temp[i])} for i in range(n)] fit_data = [ {"day": float(day_grid[i]), "fit": float(preds_cc.values[i]), "lower": float(preds_cc.values[i] - 1.96 * preds_cc.se[i]), "upper": float(preds_cc.values[i] + 1.96 * preds_cc.se[i])} for i in range(len(day_grid)) ] points = alt.Chart({"values": obs_data}).mark_circle( size=10, opacity=0.2, color="steelblue" ).encode( x=alt.X("day:Q", title="Day of year"), y=alt.Y("temp:Q", title="Temperature (C)"), ) line = alt.Chart({"values": fit_data}).mark_line( color="darkorange", strokeWidth=2 ).encode(x="day:Q", y="fit:Q") band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="darkorange" ).encode(x="day:Q", y="lower:Q", y2="upper:Q") (band + points + line).properties( width="container", height=320, title="Cyclic cubic spline: temperature by day of year" ) ``` The cyclic basis forces the smooth to wrap seamlessly from day 365 back to day 0. Without `bs="cc"`, the endpoints would be unconstrained, producing a discontinuity in the fitted seasonal pattern. ## Tensor product smooths: `te()` and `ti()` {#tensor} When you need a smooth function of **two or more** predictors measured on **different scales** (e.g., latitude and time, or temperature and pressure), use tensor product smooths. Unlike `s(x1, x2)` (which applies a single isotropic penalty treating all dimensions equally), `te()` applies a **separate marginal penalty** to each variable. $$f(x_1, x_2) = \sum_j \sum_k \beta_{jk} \, b_j^{(1)}(x_1) \, b_k^{(2)}(x_2)$$ The key distinction between `te()` and `ti()`: - **`te(x1, x2)`**: the full tensor product smooth, including main effects and interaction. - **`ti(x1, x2)`**: the tensor product **interaction only**. Use `ti()` to decompose the surface into interpretable pieces: `ti(x1) + ti(x2) + ti(x1, x2)`. ```{python} # Generate 2D data: f(x1, x2) = sin(x1) * cos(x2) rng = np.random.default_rng(23) n = 500 x1 = rng.uniform(0, 2 * np.pi, n) x2 = rng.uniform(0, 2 * np.pi, n) y = np.sin(x1) * np.cos(x2) + rng.normal(0, 0.3, n) # Fit a tensor product smooth model_te = wk.GAM("y ~ te(x1, x2)") model_te.fit({"x1": x1, "x2": x2, "y": y}, method="REML") # Print summary print(model_te.summary()) ``` ```{python} # Predict on a grid for visualization n_grid = 40 x1_grid = np.linspace(0, 2 * np.pi, n_grid) x2_grid = np.linspace(0, 2 * np.pi, n_grid) x1_mesh, x2_mesh = np.meshgrid(x1_grid, x2_grid) x1_flat = x1_mesh.ravel() x2_flat = x2_mesh.ravel() preds_te = model_te.predict({"x1": x1_flat, "x2": x2_flat}) # Heatmap of the fitted surface grid_data = [ {"x1": float(x1_flat[i]), "x2": float(x2_flat[i]), "f_hat": float(preds_te.values[i])} for i in range(len(x1_flat)) ] alt.Chart({"values": grid_data}).mark_rect().encode( x=alt.X("x1:Q", bin=alt.Bin(maxbins=n_grid), title="x1"), y=alt.Y("x2:Q", bin=alt.Bin(maxbins=n_grid), title="x2"), color=alt.Color("mean(f_hat):Q", scale=alt.Scale(scheme="viridis"), title="f(x1, x2)"), ).properties(width="container", height=400, title="Tensor product surface: te(x1, x2)") ``` ::: {.callout-tip} ## Decomposing with `ti()` To test whether the interaction is significant, decompose the surface: ```python model_ti = wk.GAM("y ~ ti(x1) + ti(x2) + ti(x1, x2)") model_ti.fit(data, method="REML") print(model_ti.summary()) ``` The p-value on the `ti(x1, x2)` term tells you whether the interaction is needed beyond the additive main effects. ::: ## Shrinkage splines: `bs="ts"` and `bs="cs"` {#shrinkage} Standard smooth penalties have a **null space**---a set of functions (typically linear) that are not penalized at all. This means that even with $\lambda \to \infty$, a standard smooth can never shrink to zero (it can only shrink to a straight line). **Shrinkage splines** add an extra penalty component that penalizes the null space, allowing the smooth to be penalized all the way to zero. This turns smoothing parameter selection into an automatic form of **variable selection**: if a predictor is uninformative, its smooth is driven to zero rather than to a residual linear trend. - `bs="ts"`: shrinkage version of TPRS (`bs="tp"`) - `bs="cs"`: shrinkage version of CRS (`bs="cr"`) ```{python} # Generate data where x2 is uninformative rng = np.random.default_rng(23) n = 300 x1 = np.linspace(0, 2 * np.pi, n) x2 = rng.uniform(0, 1, n) # pure noise predictor y = np.sin(x1) + rng.normal(0, 0.3, n) # Fit with shrinkage splines model_shrink = wk.GAM("y ~ s(x1, bs='ts') + s(x2, bs='ts')") model_shrink.fit({"x1": x1, "x2": x2, "y": y}, method="REML") # Summary shows x2 shrunk toward zero print(model_shrink.summary()) ``` In the summary, the EDF for `s(x2)` should be very close to zero, indicating that the shrinkage penalty has effectively removed this uninformative predictor from the model. ::: {.callout-important} ## When to prefer shrinkage splines Use `bs="ts"` or `bs="cs"` when you have many candidate predictors and want the model to automatically drop uninformative ones. For models where every predictor is known to be relevant, the standard bases (`bs="tp"`, `bs="cr"`) are preferred because the extra penalty adds slight computational cost without benefit. ::: ## Random effects: `bs="re"` {#random-effects} The random effect basis `bs="re"` represents a simple i.i.d. random effect: $\beta_j \sim N(0, \sigma^2)$. Each level of the grouping variable gets its own coefficient, and the penalty controls the variance $\sigma^2$. This lets you mix smooth terms with random intercepts (or slopes) in a single GAM, essentially fitting a generalized additive mixed model (GAMM) without switching to a different function. ```{python} # Generate grouped data: 5 groups with different intercepts rng = np.random.default_rng(23) n_per_group = 50 n_groups = 5 n = n_per_group * n_groups # Group labels (repeated) group = np.repeat(np.arange(n_groups), n_per_group).astype(float) # Random intercepts for each group group_effects = rng.normal(0, 1.5, n_groups) x = rng.uniform(0, 2 * np.pi, n) y = np.sin(x) + group_effects[group.astype(int)] + rng.normal(0, 0.3, n) # Fit with a smooth for x and a random intercept for group model_re = wk.GAM("y ~ s(x) + s(group, bs='re')") model_re.fit({"x": x, "group": group, "y": y}, method="REML") # Summary shows estimated variance of the random intercepts print(model_re.summary()) ``` The EDF for `s(group, bs='re')` reflects how much the group intercepts vary. If all groups have similar means, the EDF is shrunk toward zero. If groups differ substantially, the EDF approaches the number of groups minus one. ## Shape-constrained smooths {#shape} Sometimes theory or domain knowledge tells you that a relationship should be monotone, convex, or concave. Shape-constrained smooths enforce these restrictions in the basis construction. - **`bs="mpi"`**: monotone increasing - **`bs="mpd"`**: monotone decreasing - **`bs="cx"`**: convex - **`bs="cv"`**: concave ```{python} # Generate data from a monotone increasing function rng = np.random.default_rng(23) n = 200 x = np.linspace(0, 5, n) y = np.log1p(x) + rng.normal(0, 0.2, n) # Fit monotone increasing smooth model_mono = wk.GAM("y ~ s(x, bs='mpi', k=10)") model_mono.fit({"x": x, "y": y}, method="REML") # Compare with unconstrained TPRS model_free = wk.GAM("y ~ s(x, k=10)") model_free.fit({"x": x, "y": y}, method="REML") # Predict from both models x_grid = np.linspace(0, 5, 200) preds_mono = model_mono.predict({"x": x_grid}) preds_free = model_free.predict({"x": x_grid}) # Build comparison data comp_data = [ {"x": float(x_grid[i]), "fit": float(preds_mono.values[i]), "model": "Monotone (mpi)"} for i in range(len(x_grid)) ] + [ {"x": float(x_grid[i]), "fit": float(preds_free.values[i]), "model": "Unconstrained (tp)"} for i in range(len(x_grid)) ] alt.Chart({"values": comp_data}).mark_line(strokeWidth=2).encode( x=alt.X("x:Q", title="x"), y=alt.Y("fit:Q", title="f(x)"), color=alt.Color("model:N", title="Smooth type"), ).properties(width="container", height=300, title="Shape-constrained vs. unconstrained smooth") ``` For this data, both models give similar results because the true function is monotone. The constrained smooth guarantees monotonicity even in regions with sparse data or noise, which can be important for dose-response curves, growth models, and calibration functions. ## By-variable smooths {#by-variable} A **by-variable** smooth allows the shape of $f(x)$ to vary across levels of a factor, or to scale with a continuous modifier. This is specified with the `by=` argument inside `s()`. ### Factor by-variable When `by=` names a categorical variable, Whittaker fits a **separate smooth** for each level: ```{python} # Generate data where the smooth shape differs by group rng = np.random.default_rng(23) n_per = 150 x_a = np.linspace(0, 2 * np.pi, n_per) x_b = np.linspace(0, 2 * np.pi, n_per) y_a = np.sin(x_a) + rng.normal(0, 0.3, n_per) y_b = 0.5 * np.cos(x_b) + rng.normal(0, 0.3, n_per) # Combine into a single dataset x = np.concatenate([x_a, x_b]) y = np.concatenate([y_a, y_b]) group = np.array(["A"] * n_per + ["B"] * n_per) # Fit with a by-variable smooth model_by = wk.GAM("y ~ s(x, by=group)") model_by.fit({"x": x, "y": y, "group": group}, method="REML") # Summary shows separate EDFs for each group print(model_by.summary()) ``` ```{python} # Predict each group on a fine grid x_grid = np.linspace(0, 2 * np.pi, 200) obs_data = [ {"x": float(x[i]), "y": float(y[i]), "group": str(group[i])} for i in range(len(x)) ] fit_data = [] for g in ["A", "B"]: g_arr = np.array([g] * len(x_grid)) preds_g = model_by.predict({"x": x_grid, "group": g_arr}) fit_data.extend( {"x": float(x_grid[i]), "fit": float(preds_g.values[i]), "group": g} for i in range(len(x_grid)) ) points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3 ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), color=alt.Color("group:N", title="Group"), ) lines = alt.Chart({"values": fit_data}).mark_line( strokeWidth=2 ).encode( x="x:Q", y=alt.Y("fit:Q", title="y"), color="group:N", ) (points + lines).properties( width="container", height=300, title="Factor by-variable smooth: s(x, by=group)" ) ``` The model estimates a different smooth $f_A(x)$ and $f_B(x)$, each with its own EDF and smoothing parameter. This is the GAM analogue of an interaction between a smooth and a factor. ### Continuous by-variable When `by=` names a continuous variable, the smooth is **scaled** by that variable: $z \cdot f(x)$. This is useful for varying-coefficient models. ```{python} # Varying-coefficient model: effect of x depends on z rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) z = rng.uniform(0.5, 2.0, n) y = z * np.sin(x) + rng.normal(0, 0.3, n) # Fit: the effect of x is scaled by z model_vc = wk.GAM("y ~ s(x, by=z)") model_vc.fit({"x": x, "z": z, "y": y}, method="REML") print(model_vc.summary()) ``` ```{python} # Show how the smooth varies at different z values x_grid = np.linspace(0, 2 * np.pi, 200) z_quantiles = np.percentile(z, [25, 50, 75]) fit_data = [] for zq in z_quantiles: z_arr = np.full_like(x_grid, zq) preds_vc = model_vc.predict({"x": x_grid, "z": z_arr}) fit_data.extend( {"x": float(x_grid[i]), "fit": float(preds_vc.values[i]), "z": f"z = {zq:.2f}"} for i in range(len(x_grid)) ) alt.Chart({"values": fit_data}).mark_line(strokeWidth=2).encode( x=alt.X("x:Q", title="x"), y=alt.Y("fit:Q", title="f(x) scaled by z"), color=alt.Color("z:N", title="z value"), ).properties( width="container", height=300, title="Varying-coefficient smooth: effect of x at different z levels" ) ``` ## Choosing the basis dimension `k` {#choosing-k} The basis dimension `k` determines the maximum complexity of the smooth. It is **not** the number of effective degrees of freedom (EDF). The EDF is always less than or equal to `k - 1` (one degree of freedom is consumed by the identifiability constraint). ### Rules of thumb 1. **Start with the default** (`k=10`). This is enough for most smooth relationships. 2. **Run `model.check()`** after fitting. If the residual pattern for a smooth shows structure, or the k-index is below 1 with a significant p-value, increase `k`. 3. **`k` cannot exceed the number of unique covariate values**. For a predictor with only 8 unique values, `k` is capped at 8. 4. **Doubling `k` is a safe strategy**: if `k=10` seems too low, try `k=20`. If `k=20` still reports a low k-index, try `k=40`. 5. **Computational cost scales with `k`**: for TPRS, the cost is $O(nk^2)$. For P-splines and cubic splines, it is $O(nk)$ due to banded structure. ```{python} # Checking basis dimension adequacy rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 4 * np.pi, n) y = np.sin(x) + 0.5 * np.sin(3 * x) + rng.normal(0, 0.3, n) # Fit with default k model_check = wk.GAM("y ~ s(x)") model_check.fit({"x": x, "y": y}, method="REML") # Check for basis dimension adequacy wk.check(model_check) ``` ::: {.callout-warning} ## Do not set `k` too low If `model.check()` reports a k-index below 1 with a significant p-value, the basis is too restrictive. The smooth cannot capture the true complexity of the relationship, and the fit will be biased. Increase `k` until the k-index is no longer significant. ::: ## Comparison of basis types The table below summarizes all available basis types in Whittaker. | Basis | `bs=` | Penalty | Null space | Best for | |-------|-------|---------|------------|----------| | Thin plate regression spline | `"tp"` | Integrated $f''(x)^2$ | Linear | General default | | Cubic regression spline | `"cr"` | Integrated $f''(x)^2$ | Linear | Speed, explicit knots | | P-spline | `"ps"` | Differenced coefficients | Polynomial | Large/regular data | | Cyclic cubic | `"cc"` | Integrated $f''(x)^2$ | Constant | Periodic (time of day, etc.) | | Cyclic P-spline | `"cp"` | Differenced coefficients | Constant | Periodic, large data | | Shrinkage TPRS | `"ts"` | $f''(x)^2$ + null space | None | Variable selection | | Shrinkage CRS | `"cs"` | $f''(x)^2$ + null space | None | Variable selection | | Random effect | `"re"` | Ridge ($\sum \beta_j^2$) | None | Grouping factors | | Adaptive TPRS | `"ad"` | Spatially varying | Linear | Varying smoothness | | Soap film | `"so"` | Boundary-aware | Linear | Complex 2D domains | | Gaussian process | `"gp"` | GP covariance | Depends on kernel | Spatial correlation | | Duchon spline | `"ds"` | Generalized TPS | Polynomial | Generalized smoothness | | Markov random field | `"mrf"` | Neighborhood | None | Areal/graph data | | Factor smooth | `"fs"` | Group-level curves | None | Random smooth effects | | Monotone increasing | `"mpi"` | Shape-constrained | None | Dose-response | | Monotone decreasing | `"mpd"` | Shape-constrained | None | Decay curves | | Convex | `"cx"` | Shape-constrained | None | Convex relationships | | Concave | `"cv"` | Shape-constrained | None | Diminishing returns | ## Other smooth types ### Adaptive smooths: `bs="ad"` {#adaptive} Adaptive smooths allow the amount of smoothing to vary over the range of the predictor. This is useful when the function is smooth in some regions but wiggly in others. ```python model = wk.GAM("y ~ s(x, bs='ad', k=20)") ``` ### Soap film smooths: `bs="so"` {#soap} Soap film smooths are designed for 2D smoothing over complex domains with boundaries (e.g., estuaries, lakes, or irregular geographic regions). The penalty respects the boundary, preventing smoothing across physical barriers. ```python model = wk.GAM("y ~ s(x, z, bs='so', xt=boundary)") ``` ### Gaussian process smooths: `bs="gp"` {#gp} A smooth specified as a Gaussian process with a chosen covariance function. Useful when you want to encode prior beliefs about correlation structure. ```python model = wk.GAM("y ~ s(x, bs='gp')") ``` ### Markov random field: `bs="mrf"` {#mrf} For areal data (counts by region, district-level outcomes), the MRF basis defines smoothing over a neighborhood graph. Adjacent regions are penalized toward similar values. ```python model = wk.GAM("y ~ s(region, bs='mrf', xt=adjacency_matrix)") ``` ### Factor smooth interaction: `bs="fs"` {#fs} Factor smooth interactions fit a separate smooth for each level of a factor, sharing a single smoothing parameter. This is the random-effects analogue of a by-variable smooth and is useful when you expect each group to have a similar (but not identical) functional form. ```python model = wk.GAM("y ~ s(x, group, bs='fs')") ``` ::: {.callout-tip} ## `bs="fs"` vs. `s(x, by=group)` Use `bs="fs"` when the group-level curves should be shrunk toward a common shape (a random-effects perspective). Use `by=group` when each group's curve is treated as a fixed effect with its own smoothing parameter. In practice, `bs="fs"` is more parsimonious and better suited when you have many groups. ::: ## Where to go next - **[Response families](05-families.qmd)**: choosing the right distribution and link function for your response variable. - **[Model fitting](06-fitting.qmd)**: details on REML vs. GCV, the P-IRLS algorithm, and convergence diagnostics. - **[Prediction and inference](08-prediction.qmd)**: standard errors, confidence intervals, and partial effect plots. - **[Diagnostics](11-diagnostics.qmd)**: residual checks, `model.check()`, and concurvity. ### Response families ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` A GAM's **family** specifies two things: the conditional distribution of the response $y$ given the predictors, and the **link function** $g$ that relates the conditional mean $\mu$ to the linear predictor $\eta$: $$g(\mu) = \eta = \beta_0 + f_1(x_1) + f_2(x_2) + \cdots$$ The choice of family determines how predictions are mapped back to the response scale, how variance changes with the mean, and which loss function (deviance) is optimized during fitting. Getting the family right is essential: a misspecified family can bias estimates, distort confidence intervals, and produce nonsensical predictions. ## The exponential family framework All families in Whittaker belong to the exponential dispersion family, characterized by a density of the form: $$f(y \mid \theta, \phi) = a(y, \phi)\,\exp\!\left[\frac{y\theta - b(\theta)}{\phi}\right]$$ where $\theta$ is the **canonical parameter**, $\phi$ is the **dispersion parameter**, and $b(\cdot)$ is the **cumulant function**. From this formulation, four key ingredients follow: - **Mean**: $\mu = b'(\theta)$. - **Variance function**: $\text{Var}(Y) = \phi\, V(\mu)$, where $V(\mu) = b''(\theta)$. The shape of $V(\mu)$ is what distinguishes one family from another. - **Link function**: $g(\mu) = \eta$. The **canonical link** sets $g(\mu) = \theta$ (alternative links are also supported). - **Deviance**: $D(y, \hat\mu) = 2\phi \sum_i \bigl[\ell(y_i; y_i) - \ell(y_i; \hat\mu_i)\bigr]$, where $\ell$ is the log-likelihood. Deviance is the quantity minimized during P-IRLS fitting (see [Model fitting](06-fitting.qmd)). ::: {.callout-tip} ## Canonical vs. non-canonical links Using the canonical link simplifies the score equations and guarantees concavity of the log-likelihood. Non-canonical links (e.g., `log` for Gamma instead of `inverse`) are often preferred for interpretability but may require more P-IRLS iterations to converge. ::: ## Quick reference table The table below summarizes every family available in Whittaker. GAMLSS families (for distributional regression) are covered in a [separate page](18-gamlss.qmd). | Family | String | Default link | $V(\mu)$ | Typical use | |---|---|---|---|---| | `Gaussian()` | `"gaussian"` | identity | $1$ | Continuous, unbounded | | `Poisson()` | `"poisson"` | log | $\mu$ | Event counts | | `Binomial()` | `"binomial"` | logit | $\mu(1-\mu)$ | Binary / proportions | | `Gamma()` | `"gamma"` | inverse | $\mu^2$ | Positive continuous (right-skewed) | | `NegativeBinomial()` | `"nb"` | log | $\mu + \mu^2/\theta$ | Overdispersed counts | | `Beta()` | `"beta"` | logit | $\mu(1-\mu)/(1+\phi)$ | Proportions on $(0,1)$ | | `Tweedie()` | `"tweedie"` | log | $\mu^p$ | Mixed zero/continuous | | `InverseGaussian()` | `"inverse.gaussian"` | $1/\mu^2$ | $\mu^3$ | Positive, heavy-tailed | | `CoxPH()` | `"coxph"` | log | | Survival / hazard | ## Specifying a family There are two ways to set the family: a string shorthand or a family object. Use the string when the default link is what you want. Use the object when you need a non-default link or want to set initial parameter values. ```python # String shorthand model = wk.GAM("y ~ s(x)", family="gaussian") # Family object model = wk.GAM("y ~ s(x)", family=wk.Gaussian()) ``` ## Gaussian: continuous responses {#sec-gaussian} The Gaussian family is the default. It assumes the response is continuous and unbounded, with constant variance: - **Link**: $g(\mu) = \mu$ (identity). - **Variance function**: $V(\mu) = 1$, so $\text{Var}(Y) = \phi$. - **Deviance**: $D = \sum_i (y_i - \hat\mu_i)^2$ (residual sum of squares). This is the classical regression setting: the model minimizes penalized least squares and converges in a single step of P-IRLS. **Supported links**: `identity` (default), `log`, `inverse`. ### Example: noisy sine curve ```{python} import numpy as np import whittaker as wk import altair as alt rng = np.random.default_rng(0) n = 200 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} model_gauss = wk.GAM("y ~ s(x)", family=wk.Gaussian()) model_gauss.fit(data, method="REML") print(model_gauss.summary()) ``` Because the identity link maps the linear predictor directly to the mean, predictions are on the original scale without any back-transformation. ### Visualizing the Gaussian fit ```{python} x_grid = np.linspace(0, 2 * np.pi, 300) preds = model_gauss.predict({"x": x_grid}, se=True) z = 1.96 lower = preds.values - z * preds.se upper = preds.values + z * preds.se points = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y[i])} for i in range(n)]} ).mark_circle(size=15, opacity=0.3, color="steelblue").encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) fit_data = [ {"x": float(x_grid[i]), "fit": float(preds.values[i]), "lower": float(lower[i]), "upper": float(upper[i])} for i in range(len(x_grid)) ] line = alt.Chart({"values": fit_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") (band + points + line).properties( width="container", height=300, title="Gaussian GAM: y ~ s(x)" ) ``` ## Poisson: count responses {#sec-poisson} The Poisson family is the standard choice for non-negative integer counts. Its key assumption is that the variance equals the mean (equidispersion). - **Link**: $g(\mu) = \log(\mu)$ (log link). - **Variance function**: $V(\mu) = \mu$. - **Deviance**: $D = 2\sum_i \bigl[y_i \log(y_i / \hat\mu_i) - (y_i - \hat\mu_i)\bigr]$. The log link ensures that predicted counts are always non-negative. **Supported links**: `log` (default), `identity`, `sqrt`. ::: {.callout-tip} ## Exposure and offsets When counts are observed over varying durations or areas, include an offset term to model the **rate** rather than the raw count: ```python model = wk.GAM("count ~ s(x) + offset(log_exposure)", family="poisson") ``` Here `log_exposure` is the natural log of the exposure variable. ::: ### Example: species counts along a transect ```{python} rng = np.random.default_rng(1) n = 250 x = np.linspace(0, 6, n) eta = 0.8 + 1.2 * np.sin(x) # log-scale linear predictor mu = np.exp(eta) # true rate y_counts = rng.poisson(mu).astype(float) data_pois = {"x": x, "y": y_counts} model_pois = wk.GAM("y ~ s(x, k=15)", family=wk.Poisson()) model_pois.fit(data_pois, method="REML") print(model_pois.summary()) ``` ### Visualizing the Poisson fit ```{python} x_grid = np.linspace(0, 6, 300) preds_pois = model_pois.predict({"x": x_grid}, se=True) # Confidence intervals on the response (count) scale z = 1.96 lower_pois = np.exp(np.log(preds_pois.values) - z * preds_pois.se) upper_pois = np.exp(np.log(preds_pois.values) + z * preds_pois.se) points_pois = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y_counts[i])} for i in range(n)]} ).mark_circle(size=15, opacity=0.3, color="teal").encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="Count"), ) fit_data_pois = [ {"x": float(x_grid[i]), "fit": float(preds_pois.values[i]), "lower": float(lower_pois[i]), "upper": float(upper_pois[i])} for i in range(len(x_grid)) ] line_pois = alt.Chart({"values": fit_data_pois}).mark_line( color="darkorange", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band_pois = alt.Chart({"values": fit_data_pois}).mark_area( opacity=0.15, color="darkorange" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") true_pois = [{"x": float(x_grid[i]), "mu": float(np.exp(0.8 + 1.2 * np.sin(x_grid[i])))} for i in range(len(x_grid))] true_line_pois = alt.Chart({"values": true_pois}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="mu:Q") (band_pois + points_pois + line_pois + true_line_pois).properties( width="container", height=300, title="Poisson GAM: count ~ s(x)" ) ``` The orange curve is the estimated rate $\hat\mu(x)$, the gray dashed line is the true generating rate, and each blue-green point is an observed count. ## Binomial: binary and proportion responses {#sec-binomial} The Binomial family handles two types of response: binary outcomes (0/1) and proportions with a known denominator. The logit link maps probabilities from $(0,1)$ to the real line. - **Link**: $g(\mu) = \log\!\bigl(\mu / (1 - \mu)\bigr)$ (logit). - **Variance function**: $V(\mu) = \mu(1 - \mu)$. - **Deviance**: $D = -2\sum_i \bigl[y_i \log(\hat\mu_i) + (1 - y_i)\log(1 - \hat\mu_i)\bigr]$ (for binary data). **Supported links**: `logit` (default), `probit`, `cloglog`, `cauchit`. ### Example: probability of occurrence ```{python} rng = np.random.default_rng(2) n = 300 x = np.linspace(-3, 3, n) eta = -1.0 + 1.5 * x - 0.3 * x**2 # logit-scale predictor prob = 1 / (1 + np.exp(-eta)) # true probability y_bin = rng.binomial(1, prob).astype(float) data_bin = {"x": x, "y": y_bin} model_bin = wk.GAM("y ~ s(x)", family=wk.Binomial()) model_bin.fit(data_bin, method="REML") print(model_bin.summary()) ``` ### Visualizing the Binomial fit ```{python} x_grid = np.linspace(-3, 3, 300) preds_bin = model_bin.predict({"x": x_grid}, se=True) # Confidence intervals via the logit transform logit_hat = np.log(preds_bin.values / (1 - preds_bin.values)) lower_bin = 1 / (1 + np.exp(-(logit_hat - 1.96 * preds_bin.se))) upper_bin = 1 / (1 + np.exp(-(logit_hat + 1.96 * preds_bin.se))) points_bin = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y_bin[i])} for i in range(n)]} ).mark_circle(size=12, opacity=0.15, color="purple").encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="P(y = 1)"), ) fit_data_bin = [ {"x": float(x_grid[i]), "fit": float(preds_bin.values[i]), "lower": float(lower_bin[i]), "upper": float(upper_bin[i])} for i in range(len(x_grid)) ] line_bin = alt.Chart({"values": fit_data_bin}).mark_line( color="darkviolet", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band_bin = alt.Chart({"values": fit_data_bin}).mark_area( opacity=0.15, color="darkviolet" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") (band_bin + points_bin + line_bin).properties( width="container", height=300, title="Binomial GAM: binary outcome ~ s(x)" ) ``` ::: {.callout-note} ## Proportion data For grouped binomial data (successes out of $n$ trials), use the `cbind` syntax: ```python model = wk.GAM("cbind(successes, failures) ~ s(x)", family="binomial") ``` This is more efficient than expanding to individual binary rows and correctly accounts for the denominator. ::: ## Gamma: positive continuous responses {#sec-gamma} The Gamma family is appropriate for strictly positive, right-skewed continuous data where the variance increases with the mean. Insurance claims, precipitation amounts, and response times are classic use cases. - **Link**: $g(\mu) = 1/\mu$ (inverse, canonical) or $g(\mu) = \log(\mu)$ (log). - **Variance function**: $V(\mu) = \mu^2$. - **Deviance**: $D = 2\sum_i \bigl[-\log(y_i / \hat\mu_i) + (y_i - \hat\mu_i)/\hat\mu_i\bigr]$. The Gamma family uses a **log link** by default, which guarantees positive fitted values. ### Example: reaction time data ```{python} rng = np.random.default_rng(3) n = 250 x = np.linspace(0.5, 5, n) mu = np.exp(1.0 + 0.4 * np.sin(2 * x)) # true mean (always positive) shape = 10.0 y_gamma = rng.gamma(shape, scale=mu / shape, size=n) data_gamma = {"x": x, "y": y_gamma} model_gamma = wk.GAM("y ~ s(x)", family=wk.Gamma()) model_gamma.fit(data_gamma, method="REML") print(model_gamma.summary()) ``` With the log link the model is $\log(\mu) = \beta_0 + f(x)$, so coefficients are interpretable as multiplicative effects on the response. ### Visualizing the Gamma fit ```{python} x_grid = np.linspace(0.5, 5, 300) preds_gamma = model_gamma.predict({"x": x_grid}, type="response") points_gamma = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y_gamma[i])} for i in range(n)]} ).mark_circle(size=15, opacity=0.3, color="goldenrod").encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) line_gamma = alt.Chart( {"values": [{"x": float(x_grid[i]), "fit": float(preds_gamma.values[i])} for i in range(len(x_grid))]} ).mark_line(color="firebrick", strokeWidth=2).encode(x="x:Q", y="fit:Q") (points_gamma + line_gamma).properties( width="container", height=300, title="Gamma GAM: fitted mean" ) ``` ## Negative Binomial: overdispersed counts {#sec-nb} When count data exhibit more variability than the Poisson model allows (i.e., the variance exceeds the mean), the Negative Binomial family provides extra flexibility via an overdispersion parameter $\theta$: - **Link**: $g(\mu) = \log(\mu)$. - **Variance function**: $V(\mu) = \mu + \mu^2 / \theta$. As $\theta \to \infty$, this reduces to the Poisson. - **Deviance**: $D = 2\sum_i \bigl[\theta\,\log\!\bigl(\theta / (\theta + \hat\mu_i)\bigr) + y_i \log\!\bigl(y_i(\theta + \hat\mu_i) / (\hat\mu_i(\theta + y_i))\bigr)\bigr]$. The overdispersion parameter $\theta$ is estimated alongside the smoothing parameters during REML optimization. **Supported links**: `log` (default), `identity`, `sqrt`. ### Example: overdispersed species counts ```{python} rng = np.random.default_rng(4) n = 300 x = np.linspace(0, 5, n) mu = np.exp(1.5 + 0.8 * np.sin(1.5 * x)) theta = 3.0 # overdispersion parameter # Generate NB data: Poisson-Gamma mixture lam = rng.gamma(theta, scale=mu / theta, size=n) y_nb = rng.poisson(lam).astype(float) data_nb = {"x": x, "y": y_nb} model_nb = wk.GAM("y ~ s(x, k=15)", family=wk.NegativeBinomial()) model_nb.fit(data_nb, method="REML") print(model_nb.summary()) ``` ```{python} # Compare observed variance to mean (overdispersion check) print(f"Observed mean: {y_nb.mean():.2f}") print(f"Observed variance: {y_nb.var():.2f}") print(f"Variance / mean: {y_nb.var() / y_nb.mean():.2f}") ``` A ratio of variance to mean substantially greater than 1 is the hallmark of overdispersion. The Poisson assumption ($\text{Var} = \mu$) would underestimate standard errors here, leading to overconfident inference. The Negative Binomial family handles this correctly. ### Visualizing the Negative Binomial fit ```{python} x_grid = np.linspace(0, 5, 300) preds_nb = model_nb.predict({"x": x_grid}, type="response") points_nb = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y_nb[i])} for i in range(n)]} ).mark_circle(size=15, opacity=0.3, color="teal").encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="Count"), ) line_nb = alt.Chart( {"values": [{"x": float(x_grid[i]), "fit": float(preds_nb.values[i])} for i in range(len(x_grid))]} ).mark_line(color="darkorange", strokeWidth=2).encode(x="x:Q", y="fit:Q") (points_nb + line_nb).properties( width="container", height=300, title="Negative Binomial GAM: fitted rate" ) ``` ## Beta: proportions on (0, 1) {#sec-beta} When the response is a continuous proportion (e.g., vegetation cover, exam scores as fractions), the Beta family is the appropriate choice. Unlike the Binomial family (which models counts out of a known total), the Beta family handles continuous values strictly between 0 and 1. - **Link**: $g(\mu) = \log\!\bigl(\mu / (1 - \mu)\bigr)$ (logit). - **Variance function**: $V(\mu) = \mu(1 - \mu) / (1 + \phi)$, where $\phi$ is a precision parameter. - **Deviance**: based on the Beta log-likelihood with parameters $\alpha = \mu\phi$ and $\beta = (1-\mu)\phi$. **Supported links**: `logit` (default), `probit`, `cloglog`, `cauchit`. ### Example: proportion of canopy cover ```{python} rng = np.random.default_rng(5) n = 200 x = np.linspace(0, 4, n) mu = 1 / (1 + np.exp(-(0.5 + 0.8 * np.sin(1.5 * x)))) # true proportion phi = 20.0 # precision alpha = mu * phi beta_param = (1 - mu) * phi y_beta = rng.beta(alpha, beta_param) data_beta = {"x": x, "y": y_beta} model_beta = wk.GAM("y ~ s(x)", family=wk.Beta()) model_beta.fit(data_beta, method="REML") print(model_beta.summary()) ``` ### Visualizing the Beta fit ```{python} x_grid = np.linspace(0, 4, 300) preds_beta = model_beta.predict({"x": x_grid}, type="response") points_beta = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y_beta[i])} for i in range(n)]} ).mark_circle(size=15, opacity=0.3, color="seagreen").encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="Proportion"), ) line_beta = alt.Chart( {"values": [{"x": float(x_grid[i]), "fit": float(preds_beta.values[i])} for i in range(len(x_grid))]} ).mark_line(color="darkviolet", strokeWidth=2).encode(x="x:Q", y="fit:Q") (points_beta + line_beta).properties( width="container", height=300, title="Beta GAM: fitted mean proportion" ) ``` ::: {.callout-warning} ## Boundary values The Beta distribution is defined on the open interval $(0, 1)$. If your data contain exact 0s or 1s, you must transform them slightly (e.g., $(y(n-1) + 0.5)/n$) or consider a zero/one-inflated model. ::: ## Tweedie: mixed continuous-discrete responses {#sec-tweedie} The Tweedie family generalizes several distributions through a **power parameter** $p$: - $p = 0$: Gaussian - $p = 1$: Poisson - $1 < p < 2$: compound Poisson-Gamma (a point mass at zero plus a continuous distribution on the positives) - $p = 2$: Gamma - $p = 3$: inverse Gaussian The most common use is $1 < p < 2$, which naturally handles data with exact zeros and a continuous positive tail (e.g., insurance claims, daily rainfall). - **Link**: $g(\mu) = \log(\mu)$. - **Variance function**: $V(\mu) = \mu^p$. **Supported links**: `log` (default), `identity`. ### Example: insurance claims ```{python} rng = np.random.default_rng(6) n = 300 x = np.linspace(0, 5, n) mu = np.exp(0.5 + 0.6 * np.sin(x)) # Simulate Tweedie-like data (compound Poisson-Gamma) p_tw = 1.5 phi_tw = 1.0 poisson_rate = mu**(2 - p_tw) / ((2 - p_tw) * phi_tw) gamma_shape = (2 - p_tw) / (p_tw - 1) gamma_scale = phi_tw * (p_tw - 1) * mu**(p_tw - 1) n_events = rng.poisson(poisson_rate) y_tw = np.array([ rng.gamma(gamma_shape, scale=gamma_scale[i], size=n_events[i]).sum() if n_events[i] > 0 else 0.0 for i in range(n) ]) data_tw = {"x": x, "y": y_tw} model_tw = wk.GAM("y ~ s(x)", family=wk.Tweedie()) model_tw.fit(data_tw, method="REML") print(model_tw.summary()) ``` ```{python} # Proportion of exact zeros print(f"Proportion of zeros: {(y_tw == 0).mean():.2%}") ``` ### Visualizing the Tweedie fit ```{python} x_grid = np.linspace(0, 5, 300) preds_tw = model_tw.predict({"x": x_grid}, type="response") points_tw = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y_tw[i]), "zero": "zero" if y_tw[i] == 0.0 else "positive"} for i in range(n)]} ).mark_circle(size=15, opacity=0.4).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), color=alt.Color("zero:N", scale=alt.Scale( domain=["zero", "positive"], range=["crimson", "steelblue"] ), title="Value"), ) line_tw = alt.Chart( {"values": [{"x": float(x_grid[i]), "fit": float(preds_tw.values[i])} for i in range(len(x_grid))]} ).mark_line(color="firebrick", strokeWidth=2).encode(x="x:Q", y="fit:Q") (points_tw + line_tw).properties( width="container", height=300, title="Tweedie GAM: fitted mean with zero mass" ) ``` ::: {.callout-tip} ## Estimating the power parameter If you are unsure what value of $p$ to use, the `tw()` shorthand lets Whittaker estimate the power parameter from the data: ```python model = wk.GAM("y ~ s(x)", family=wk.tw()) ``` This adds $p$ to the set of parameters estimated during REML optimization. ::: ## Inverse Gaussian: positive heavy-tailed responses {#sec-invgauss} The Inverse Gaussian family is appropriate for positive continuous data with a heavier right tail than the Gamma. It arises naturally as the distribution of first passage times in Brownian motion. - **Link**: $g(\mu) = 1/\mu^2$ (canonical). - **Variance function**: $V(\mu) = \mu^3$. - **Deviance**: $D = \sum_i (y_i - \hat\mu_i)^2 / (\hat\mu_i^2 \, y_i)$. **Supported links**: `1/mu^2` (default), `log`, `inverse`, `identity`. ### Example ```{python} from scipy.stats import invgauss as invgauss_dist rng = np.random.default_rng(7) n = 200 x = np.linspace(0.5, 4, n) mu = np.exp(0.5 + 0.3 * x) y_ig = np.array([invgauss_dist.rvs(mu=m, scale=1.0, random_state=rng) for m in mu]) data_ig = {"x": x, "y": y_ig} model_ig = wk.GAM("y ~ s(x)", family=wk.InverseGaussian()) model_ig.fit(data_ig, method="REML") print(model_ig.summary()) ``` The Inverse Gaussian is less commonly used than the Gamma but should be considered when the coefficient of variation increases more steeply with the mean (since $V(\mu) = \mu^3$ vs. $\mu^2$ for Gamma). ## Cox proportional hazards: survival data {#sec-coxph} The `CoxPH()` family supports semiparametric survival analysis via the Cox proportional hazards model. Unlike the other families above, this is not a member of the exponential dispersion family in the traditional sense. Instead, it uses a partial likelihood formulation. ```python model = wk.GAM("survival_object ~ s(age) + s(biomarker)", family=wk.CoxPH()) model.fit(data, method="REML") ``` See the full CoxPH documentation in the API reference for details on constructing the survival response object and interpreting hazard ratios. ## Choosing the right family Selecting a family involves matching the distribution to three characteristics of your response variable: 1. **Support**: what values can $y$ take? - Unbounded real numbers → Gaussian - Non-negative integers → Poisson or Negative Binomial - Binary (0/1) → Binomial - Strictly positive reals → Gamma, Inverse Gaussian - Proportions in $(0, 1)$ → Beta - Non-negative with point mass at zero → Tweedie ($1 < p < 2$) 2. **Mean-variance relationship**: how does the spread change with the level? - Constant variance → Gaussian - Variance $\propto$ mean → Poisson - Variance $\propto$ mean$^2$ → Gamma - Variance $>$ mean (overdispersion) → Negative Binomial 3. **Domain knowledge**: what does the scientific context suggest? - Rates and counts → Poisson or Negative Binomial - Times, costs, concentrations → Gamma or Inverse Gaussian - Survival analysis → CoxPH ::: {.callout-tip} ## Diagnostic checks After fitting, use [model diagnostics](11-diagnostics.qmd) to verify your family choice. The key checks are: - **Residual plots**: residuals vs. fitted values should show no systematic pattern. - **QQ plot**: quantile-quantile plot of deviance residuals should be approximately linear. - **Overdispersion**: for Poisson models, check that the estimated scale parameter is close to 1. If it is much larger, switch to Negative Binomial. ::: ## Where to go next - **[Smooth terms](04-smooths.qmd)**: basis types, tensor products, and choosing `k`. - **[Model fitting](06-fitting.qmd)**: P-IRLS, REML vs. GCV, and convergence settings. - **[Prediction and inference](08-prediction.qmd)**: standard errors, confidence intervals, and term-level predictions. - **[Diagnostics](11-diagnostics.qmd)**: residual plots, QQ plots, and model checking. ### Model fitting ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Whittaker fits GAMs by **penalized iteratively reweighted least squares (P-IRLS)**, a generalization of weighted least squares that handles non-Gaussian responses by iterating over a sequence of penalized weighted regression problems. This page describes the fitting algorithm, smoothness selection criteria, and the practical controls you have over the fitting process. ## The P-IRLS algorithm For a given set of smoothing parameters $\boldsymbol{\lambda} = (\lambda_1, \ldots, \lambda_J)$, fitting proceeds in five steps: 1. **Initialize**: set $\mu = $ `family.initialize(y)`, $\eta = g(\mu)$. The initialization is family-specific: for Gaussian data $\mu = y$, for Poisson $\mu = y + 0.1$, for binomial $\mu = (y + 0.5)/2$. 2. **Pseudo-data**: compute the working response $$z = \eta + (y - \mu)\, g'(\mu)$$ This is a first-order Taylor expansion of the link function around the current $\mu$, converting the non-Gaussian problem into a weighted least-squares problem. 3. **Working weights**: compute $$W = \frac{1}{\bigl[g'(\mu)\bigr]^2 \, V(\mu)}$$ where $V(\mu)$ is the variance function of the family. These weights account for both the link function and the heteroscedasticity of the response. 4. **Penalized WLS**: solve the penalized weighted least-squares system $$\bigl(X^\top W X + \textstyle\sum_{j=1}^{J} \lambda_j S_j\bigr)\,\hat\beta = X^\top W z$$ where $S_j$ is the penalty matrix for the $j$-th smooth term. This is the core computational step. The penalty matrices $S_j$ are positive semi-definite and penalize the roughness of each smooth, pulling $f_j$ toward the penalty null space (typically low-order polynomials). 5. **Update**: compute $\eta = X\hat\beta$ and $\mu = g^{-1}(\eta)$. Repeat steps 2--5 until the relative change in penalized deviance falls below $10^{-7}$. ### The Gaussian identity-link special case For the Gaussian family with an identity link, P-IRLS reduces to a **single** penalized least-squares solve. The link function is the identity ($g(\mu) = \mu$), so $g'(\mu) = 1$, and the variance function is constant ($V(\mu) = 1$). The working weights become $W = I$ and the pseudo-data become $z = y$, so the system collapses to: $$\bigl(X^\top X + \textstyle\sum_j \lambda_j S_j\bigr)\,\hat\beta = X^\top y$$ No iteration is required. This makes Gaussian GAMs substantially faster to fit than their non-Gaussian counterparts. ## Smoothness selection The roughness of each smooth is controlled by a non-negative smoothing parameter $\lambda_j$. Larger $\lambda_j$ produces a smoother $f_j$ while $\lambda_j = 0$ removes the penalty entirely (interpolating spline). Rather than setting $\lambda_j$ by hand, Whittaker selects it automatically by optimizing one of three criteria. ### GCV (generalized cross-validation) GCV selects $\boldsymbol\lambda$ by minimizing: $$\text{GCV}(\boldsymbol\lambda) = \frac{n\, D(y,\hat\mu)}{[n - \text{tr}(\mathbf{A})]^2}$$ where $D$ is the deviance and $\mathbf{A} = X(X^\top W X + \sum_j \lambda_j S_j)^{-1}X^\top W$ is the influence (hat) matrix. The trace $\text{tr}(\mathbf{A})$ is the effective degrees of freedom of the model. GCV is an approximation to leave-one-out cross-validation that avoids refitting the model $n$ times. It is minimized over $\log\boldsymbol\lambda$ using a Newton method with analytical gradient and Hessian. **Properties**: - GCV is well-understood theoretically and widely used. - It tends to undersmooth in finite samples --- occasional fits with too many effective degrees of freedom. - It can be sensitive to influential observations because it weights all residuals equally. ```python model = wk.GAM("y ~ s(x)") model.fit(data, method="GCV") ``` ### REML (restricted maximum likelihood) REML treats the smooth coefficients as random effects and maximizes the restricted log-likelihood: $$\ell_{\text{REML}}(\boldsymbol\lambda, \phi) = -\frac{1}{2}\Bigl[ n \log(2\pi\phi) + \frac{D}{\phi} + \log\bigl|X^\top W X + \textstyle\sum_j \lambda_j S_j\bigr| - \log\bigl|\textstyle\sum_j \lambda_j S_j^{+}\bigr| \Bigr]$$ where $S_j^{+}$ denotes the pseudoinverse restricted to the range space of $S_j$, and $\phi$ is the scale parameter. **Advantages over GCV**: - REML has better finite-sample properties: it is less prone to undersmoothing. - It is more stable when the true function is close to the penalty null space. - REML accounts for the uncertainty in $\hat\beta$ when estimating $\phi$, producing better variance estimates. - It is the recommended default in both Whittaker and `mgcv`. ```python model = wk.GAM("y ~ s(x)") model.fit(data, method="REML") # recommended default ``` ### ML (maximum likelihood) ML maximizes the full marginal log-likelihood rather than the restricted version: $$\ell_{\text{ML}}(\boldsymbol\lambda, \phi) = -\frac{1}{2}\Bigl[ n \log(2\pi\phi) + \frac{D}{\phi} + \log\bigl|X^\top W X + \textstyle\sum_j \lambda_j S_j\bigr| \Bigr]$$ The key difference from REML is the absence of the $\log|\sum_j \lambda_j S_j^{+}|$ term. This means ML does not adjust for the degrees of freedom consumed by the fixed effects, which can lead to slight undersmoothing relative to REML --- the same bias that makes ML variance estimates biased downward in classical linear models. **When to use ML**: ML is appropriate when you need to compare models with different fixed-effect structures using likelihood ratio tests, because REML likelihoods are not comparable across models with different fixed effects. ```python model = wk.GAM("y ~ s(x)") model.fit(data, method="ML") ``` ### Comparing GCV, REML, and ML ```{python} import numpy as np import whittaker as wk # Generate noisy data with a smooth underlying function rng = np.random.default_rng(23) n = 150 x = np.linspace(0, 2 * np.pi, n) y_true = np.sin(x) + 0.3 * np.cos(3 * x) y = y_true + rng.normal(0, 0.4, n) data = {"x": x, "y": y} # Fit with each method model_gcv = wk.GAM("y ~ s(x, k=20)") model_gcv.fit(data, method="GCV") model_reml = wk.GAM("y ~ s(x, k=20)") model_reml.fit(data, method="REML") model_ml = wk.GAM("y ~ s(x, k=20)") model_ml.fit(data, method="ML") # Compare EDF and smoothing parameters print("Method | EDF total | Smoothing param") print("--------|-----------|----------------") print(f"GCV | {model_gcv.edf_total:9.3f} | {model_gcv.smoothing_params[0]:.4f}") print(f"REML | {model_reml.edf_total:9.3f} | {model_reml.smoothing_params[0]:.4f}") print(f"ML | {model_ml.edf_total:9.3f} | {model_ml.smoothing_params[0]:.4f}") ``` GCV typically selects the smallest smoothing parameter (largest EDF), REML the largest (fewest EDF), and ML falls between the two. ```{python} import altair as alt # Build predictions for each method x_plot = np.linspace(0, 2 * np.pi, 200) preds_gcv = model_gcv.predict({"x": x_plot}) preds_reml = model_reml.predict({"x": x_plot}) preds_ml = model_ml.predict({"x": x_plot}) # Assemble plot data plot_records = [] for i in range(len(x_plot)): plot_records.append({"x": float(x_plot[i]), "fit": float(preds_gcv.values[i]), "Method": "GCV"}) plot_records.append({"x": float(x_plot[i]), "fit": float(preds_reml.values[i]), "Method": "REML"}) plot_records.append({"x": float(x_plot[i]), "fit": float(preds_ml.values[i]), "Method": "ML"}) # Observed data obs_records = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] # True function (recomputed on the plot grid) y_true_plot = np.sin(x_plot) + 0.3 * np.cos(3 * x_plot) true_records = [{"x": float(x_plot[i]), "y": float(y_true_plot[i])} for i in range(len(x_plot))] points = alt.Chart({"values": obs_records}).mark_circle( size=12, opacity=0.25, color="gray" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) lines = alt.Chart({"values": plot_records}).mark_line(strokeWidth=2).encode( x="x:Q", y="fit:Q", color=alt.Color("Method:N", scale=alt.Scale( domain=["GCV", "REML", "ML"], range=["#e45756", "#4c78a8", "#72b7b2"] )), strokeDash=alt.StrokeDash("Method:N", scale=alt.Scale( domain=["GCV", "REML", "ML"], range=[[0], [0], [4, 4]] )), ) true_line = alt.Chart({"values": true_records}).mark_line( color="black", strokeDash=[2, 2], strokeWidth=1, opacity=0.5 ).encode(x="x:Q", y="y:Q") (points + true_line + lines).properties( width="container", height=300, title="Smoothness selection: GCV vs REML vs ML" ) ``` The black dashed line is the true function. REML (blue) tends to produce the smoothest fit, GCV (red) the most flexible, and ML (teal, dashed) sits in between. ::: {.callout-tip} ## When in doubt, use REML REML is the recommended default for nearly all applications. It is less prone to overfitting than GCV and produces more reliable confidence intervals. Switch to GCV only if you have a specific reason (e.g., reproducing a legacy analysis) or to ML when you need comparable likelihoods across models with different fixed effects. ::: ## Fixed smoothing parameters Sometimes you want to bypass automatic smoothness selection and fix $\lambda_j$ at known values. This is useful for: - **Reproducing results** from another analysis where $\lambda$ was determined externally. - **Sensitivity analysis**: checking how the fit changes as you vary $\lambda$. - **Simulation studies**: fitting at a known truth. Pass a list of smoothing parameters to `fit()` in the same order as the smooth terms appear in the formula: ```{python} # Fix the smoothing parameter for s(x) model_fixed = wk.GAM("y ~ s(x, k=20)") model_fixed.fit(data, smoothing_params=[1.0]) print(f"Fixed lambda: {model_fixed.smoothing_params}") print(f"EDF: {model_fixed.edf_total:.3f}") ``` When `smoothing_params` is provided, the `method` argument is ignored --- no optimization over $\lambda$ takes place. ```{python} # Multiple smooths: one lambda per smooth rng = np.random.default_rng(0) n = 200 x1 = np.linspace(0, 1, n) x2 = rng.uniform(0, 1, n) y2 = np.sin(4 * x1) + 0.5 * x2**2 + rng.normal(0, 0.3, n) data2 = {"x1": x1, "x2": x2, "y": y2} model_two = wk.GAM("y ~ s(x1) + s(x2)") model_two.fit(data2, smoothing_params=[1.0, 0.5]) print(f"Lambda for s(x1): {model_two.smoothing_params[0]:.2f}") print(f"Lambda for s(x2): {model_two.smoothing_params[1]:.2f}") ``` ::: {.callout-warning} Fixing smoothing parameters at inappropriate values can produce severely under- or overfit models. Use automatic selection (REML) unless you have a compelling reason to fix $\lambda$. ::: ## Variable selection with `select=True` Standard penalized regression cannot shrink a smooth term to zero --- the penalty null space (the space of functions not penalized, usually polynomials up to a given order) always survives. The **double penalty** approach adds an extra penalty on the null space, allowing the entire smooth to be penalized toward zero: $$\text{penalty}_j = \lambda_j \boldsymbol\beta^\top S_j \boldsymbol\beta + \lambda_j^{*} \boldsymbol\beta^\top S_j^{*} \boldsymbol\beta$$ where $S_j^{*}$ penalizes the null space of $S_j$ and $\lambda_j^{*}$ is an additional smoothing parameter estimated alongside $\lambda_j$. When `select=True`, Whittaker adds this extra penalty for every smooth term. If a predictor has no effect, both $\lambda_j$ and $\lambda_j^{*}$ can grow large enough to drive the smooth's EDF to approximately zero, effectively removing it from the model. ```{python} # Variable selection example: x3 is pure noise rng = np.random.default_rng(23) n = 300 x1 = np.linspace(0, 2 * np.pi, n) x2 = rng.uniform(0, 1, n) x3 = rng.normal(0, 1, n) # noise predictor y_sel = np.sin(x1) + x2**2 + rng.normal(0, 0.3, n) data_sel = {"x1": x1, "x2": x2, "x3": x3, "y": y_sel} # Without variable selection model_nosel = wk.GAM("y ~ s(x1) + s(x2) + s(x3)") model_nosel.fit(data_sel, method="REML") # With variable selection model_sel = wk.GAM("y ~ s(x1) + s(x2) + s(x3)") model_sel.fit(data_sel, method="REML", select=True) print("Without select=True:") print(model_nosel.summary()) print("\nWith select=True:") print(model_sel.summary()) ``` ```{python} # Grouped bar chart comparing per-term EDF with and without select=True term_labels = ["s(x1)", "s(x2)", "s(x3)"] edf_nosel = model_nosel.edf edf_sel = model_sel.edf bar_records = [] for label, edf_std, edf_shrink in zip(term_labels, edf_nosel, edf_sel): bar_records.append({"Term": label, "Model": "Standard", "EDF": float(edf_std)}) bar_records.append({"Term": label, "Model": "select=True", "EDF": float(edf_shrink)}) alt.Chart({"values": bar_records}).mark_bar().encode( x=alt.X("Term:N", title="Smooth term", axis=alt.Axis(labelAngle=0)), y=alt.Y("EDF:Q", title="Effective degrees of freedom"), color=alt.Color("Model:N", scale=alt.Scale( domain=["Standard", "select=True"], range=["#4c78a8", "#e45756"] )), xOffset="Model:N", ).properties(width="container", height=300, title="Variable selection: per-term EDF") ``` With `select=True`, the EDF for `s(x3)` should be driven close to zero, confirming that Whittaker identified the noise predictor. The EDF values for `s(x1)` and `s(x2)` remain largely unchanged. ::: {.callout-tip} ## When to use variable selection Use `select=True` when you have many candidate predictors and want the model to automatically determine which ones have a nonlinear effect. It is especially useful in exploratory analysis where you are unsure which predictors are relevant. For confirmatory analysis where the model structure is known, standard fitting (without `select=True`) is appropriate. ::: ## Observation weights Observation weights enter the penalized WLS system by modifying the weight matrix. If you supply a weight vector $w_i$, the working weight matrix becomes $\tilde{W} = \text{diag}(w_i) \cdot W$, and the penalized WLS system is: $$\bigl(X^\top \tilde{W} X + \textstyle\sum_j \lambda_j S_j\bigr)\,\hat\beta = X^\top \tilde{W} z$$ This allows you to: - **Downweight outliers**: set $w_i < 1$ for observations you suspect are contaminated. - **Account for known precision**: when observations have different known variances, set $w_i = 1 / \sigma_i^2$. - **Handle aggregated data**: when each row represents $n_i$ observations, set $w_i = n_i$. ```{python} # Weights example: downweight outlier observations rng = np.random.default_rng(23) n = 100 x = np.linspace(0, 2 * np.pi, n) y_w = np.sin(x) + rng.normal(0, 0.3, n) # Add some outliers outlier_idx = [10, 30, 50, 70, 90] y_w[outlier_idx] += rng.choice([-3, 3], size=len(outlier_idx)) # Create weights: 1.0 for normal observations, 0.1 for outliers w = np.ones(n) w[outlier_idx] = 0.1 data_w = {"x": x, "y": y_w} # Fit without weights model_unweighted = wk.GAM("y ~ s(x)") model_unweighted.fit(data_w, method="REML") # Fit with weights model_weighted = wk.GAM("y ~ s(x)") model_weighted.fit(data_w, method="REML", weights=w) print(f"Unweighted scale: {model_unweighted.scale:.4f}") print(f"Weighted scale: {model_weighted.scale:.4f}") print(f"Unweighted EDF: {model_unweighted.edf_total:.3f}") print(f"Weighted EDF: {model_weighted.edf_total:.3f}") ``` ```{python} # Scatter with outliers highlighted plus weighted vs unweighted fitted curves x_fit = np.linspace(0, 2 * np.pi, 200) preds_unw = model_unweighted.predict({"x": x_fit}) preds_w = model_weighted.predict({"x": x_fit}) is_outlier = np.zeros(n, dtype=bool) is_outlier[outlier_idx] = True scatter_records = [ {"x": float(x[i]), "y": float(y_w[i]), "Type": "Outlier" if is_outlier[i] else "Normal"} for i in range(n) ] fit_records = [] for i in range(len(x_fit)): fit_records.append({"x": float(x_fit[i]), "fit": float(preds_unw.values[i]), "Model": "Unweighted"}) fit_records.append({"x": float(x_fit[i]), "fit": float(preds_w.values[i]), "Model": "Weighted"}) points = alt.Chart({"values": scatter_records}).mark_circle().encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), color=alt.Color("Type:N", scale=alt.Scale( domain=["Normal", "Outlier"], range=["gray", "#e45756"] )), size=alt.Size("Type:N", scale=alt.Scale( domain=["Normal", "Outlier"], range=[30, 80] ), legend=None), opacity=alt.condition( alt.datum.Type == "Outlier", alt.value(0.8), alt.value(0.3), ), ) lines = alt.Chart({"values": fit_records}).mark_line(strokeWidth=2).encode( x="x:Q", y="fit:Q", color=alt.Color("Model:N", scale=alt.Scale( domain=["Unweighted", "Weighted"], range=["#e45756", "#4c78a8"] )), strokeDash=alt.StrokeDash("Model:N", scale=alt.Scale( domain=["Unweighted", "Weighted"], range=[[4, 4], [0]] )), ) (points + lines).properties( width="container", height=300, title="Effect of observation weights on outlier robustness" ) ``` The weighted fit should produce a lower scale estimate and fewer effective degrees of freedom because it is not distorted by the outliers. ## Convergence ### What convergence means The P-IRLS algorithm converges when the relative change in penalized deviance between successive iterations falls below the tolerance threshold ($10^{-7}$ by default). Specifically, the algorithm stops when: $$\frac{|D^{(k)} - D^{(k-1)}|}{|D^{(k-1)}| + 0.1} < \text{tol}$$ The $0.1$ in the denominator prevents numerical issues when the deviance is near zero. ### Checking the fit After fitting, inspect the model to verify the fit is reasonable: ```{python} model_check = wk.GAM("y ~ s(x)") model_check.fit({"x": x, "y": np.sin(x) + rng.normal(0, 0.3, n)}, method="REML") print(f"EDF total: {model_check.edf_total:.1f}") print(f"Scale: {model_check.scale:.4f}") print(f"Deviance: {model_check.deviance:.2f}") ``` For Gaussian models with an identity link, the P-IRLS algorithm converges in a single step. For non-Gaussian models, convergence typically occurs within 5--15 iterations. ::: {.callout-warning} ## What to do if the fit looks wrong 1. **Check the data**: fitting problems are often caused by complete separation (in logistic regression), extreme outliers, or a poor choice of family. 2. **Reduce `k`**: an overly flexible model can be unstable. Reducing the basis dimension can help. 3. **Try a different method**: GCV and REML can behave differently on ill-conditioned problems. 4. **Run `model.check()`**: the basis dimension adequacy test can reveal underfitting (see [Diagnostics](11-diagnostics.qmd)). ::: ## Effective degrees of freedom (EDF) The effective degrees of freedom measures the complexity of each smooth term. It is defined as: $$\text{EDF}_j = \text{tr}(\mathbf{A}_j)$$ where $\mathbf{A}_j$ is the block of the hat matrix corresponding to the $j$-th smooth. The total model EDF is $\text{EDF}_{\text{total}} = \sum_j \text{EDF}_j + p$, where $p$ is the number of parametric (unpenalized) coefficients including the intercept. **Interpreting EDF**: | EDF value | Interpretation | |-----------|----------------| | $\approx 1$ | The smooth is approximately linear | | $\approx 2$ | The smooth is approximately quadratic | | $3$--$5$ | Moderate curvature | | $> 5$ | High complexity: consider checking the data for artifacts | | $\approx k - 1$ | The smooth is using nearly all available basis functions: increase `k` | ```{python} # EDF interpretation model_edf = wk.GAM("y ~ s(x, k=20)") model_edf.fit({"x": x, "y": np.sin(x) + rng.normal(0, 0.3, n)}, method="REML") print(f"Total EDF: {model_edf.edf_total:.3f}") print(f"Scale estimate: {model_edf.scale:.4f}") ``` ::: {.callout-note} EDF is not the same as the basis dimension `k`. The parameter `k` sets an upper bound on complexity, while the smoothing parameter $\lambda$ (selected by REML or GCV) determines how much of that capacity is actually used. Setting `k=20` does not mean the smooth uses 20 degrees of freedom --- it may use only 4 or 5 if the data support a simpler shape. See the [smooth terms](04-smooths.qmd) page for guidance on choosing `k`. ::: ## Scale estimation The scale parameter $\hat\phi$ measures the residual variability after accounting for the smooth effects. For the Gaussian family, $\hat\phi = \hat\sigma^2$ is the residual variance. It is estimated by: $$\hat\phi = \frac{D(y, \hat\mu)}{n - \text{EDF}_{\text{total}}}$$ where $D$ is the deviance and $\text{EDF}_{\text{total}}$ is the total effective degrees of freedom. This is the analogue of $s^2 = \text{RSS} / (n - p)$ in ordinary least squares, replacing $p$ with the (fractional) EDF. For families with a known scale (Poisson, binomial), $\phi$ is fixed at 1 and is not estimated. ```{python} # Scale estimation print(f"Scale (phi-hat): {model_edf.scale:.4f}") print(f"Deviance: {model_edf.deviance:.4f}") print(f"GCV score: {model_edf.gcv_score:.4f}") ``` The scale estimate directly affects confidence intervals and p-values: standard errors are proportional to $\sqrt{\hat\phi}$, so an overestimated scale produces wider confidence bands. ## Practical guidance ### When to use REML vs GCV | Situation | Recommendation | |-----------|----------------| | Default, general-purpose fitting | **REML** | | Reproducing a legacy analysis that used GCV | GCV | | Comparing models with different fixed effects via LRT | ML | | Very large $n$ (> 50,000) | GCV may be faster (both give similar results) | | Sparse data or small $n$ | **REML** (GCV tends to undersmooth) | ### When to increase `k` The basis dimension `k` should be large enough that the smooth can capture the true function shape. After fitting, run `model.check()` (see [Model diagnostics](11-diagnostics.qmd)) to test whether `k` is adequate: ```python model.check() ``` If the k-index is below 1 with a significant p-value, double `k` and re-fit: ```python model = wk.GAM("y ~ s(x, k=20)") model.fit(data, method="REML") model.check() ``` ::: {.callout-tip} ## A safe workflow for production models 1. Start with the defaults: `method="REML"`, `k=10`. 2. Fit the model and run `model.check()`. 3. If any smooth fails the k-index test, increase `k` for that term and re-fit. 4. Review the summary: are the EDF values plausible for the data? 6. If you suspect irrelevant predictors, re-fit with `select=True`. 7. Generate [predictions](08-prediction.qmd) and [diagnostic plots](11-diagnostics.qmd). ::: ## Summary of fit attributes After calling `model.fit()`, the following attributes are available: | Attribute | Type | Description | |-----------|------|-------------| | `model.smoothing_params` | `list[float]` | Estimated (or fixed) $\lambda_j$ values | | `model.edf_total` | `float` | Total effective degrees of freedom | | `model.scale` | `float` | Estimated scale parameter $\hat\phi$ | | `model.deviance` | `float` | Model deviance | | `model.gcv_score` | `float` | GCV score (computed regardless of method) | ## Where to go next - **[Prediction and inference](08-prediction.qmd)**: standard errors, confidence intervals, and term-level predictions from a fitted model. - **[Model diagnostics](11-diagnostics.qmd)**: residual plots, k-index tests, and `model.check()`. - **[Smoothing parameter sensitivity](33-sensitivity.qmd)**: verify that predictions are robust to the specific smoothing parameters chosen by REML, GCV, or ML. - **[Variational inference](26-variational-inference.qmd)**: a principled Bayesian posterior for non-Gaussian families, using `method="VI"`. - **[Smooth terms](04-smooths.qmd)**: choosing the right basis and basis dimension. - **[Response families](05-families.qmd)**: how the family and link function affect the P-IRLS algorithm. ### Data input ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Whittaker accepts data as a Python dictionary mapping column names to 1-D NumPy arrays. This is the simplest and most portable data format. It works with any data source and avoids coupling the library to a specific DataFrame backend. ## The data dictionary Every `fit()` and `predict()` call expects a dictionary where keys are column names (strings) and values are 1-D NumPy arrays of the same length. ```{python} import numpy as np import whittaker as wk # The data dictionary: one key per column, one array per key data = { "x1": np.linspace(0, 2 * np.pi, 100), "x2": np.random.default_rng(23).uniform(0, 1, 100), "y": np.sin(np.linspace(0, 2 * np.pi, 100)) + np.random.default_rng(23).normal(0, 0.3, 100), } # Fit a model (the formula references keys from the dictionary) model = wk.GAM("y ~ s(x1) + s(x2)") model.fit(data, method="REML") print(f"Fitted with {len(data['y'])} observations") ``` Every column referenced in the formula must be present in the dictionary. Extra columns are ignored. ## Converting from DataFrames If your data is in a Polars or Pandas DataFrame, convert it to a dictionary before passing it to Whittaker. ### From Polars ```{python} import polars as pl # Create a Polars DataFrame df_pl = pl.DataFrame( { "x": np.linspace(0, 2 * np.pi, 100), "y": np.sin(np.linspace(0, 2 * np.pi, 100)) + np.random.default_rng(23).normal(0, 0.3, 100), } ) # Convert to a dict of NumPy arrays data_from_polars = {col: df_pl[col].to_numpy() for col in df_pl.columns} # Fit the model model = wk.GAM("y ~ s(x)") model.fit(data_from_polars, method="REML") print(f"Fitted from Polars: EDF = {model.edf_total:.1f}") ``` ### From Pandas ```{python} import pandas as pd # Create a Pandas DataFrame df_pd = pd.DataFrame( { "x": np.linspace(0, 2 * np.pi, 100), "y": np.sin(np.linspace(0, 2 * np.pi, 100)) + np.random.default_rng(23).normal(0, 0.3, 100), } ) # Convert to a dict of NumPy arrays data_from_pandas = {col: df_pd[col].to_numpy() for col in df_pd.columns} # Fit the model model = wk.GAM("y ~ s(x)") model.fit(data_from_pandas, method="REML") print(f"Fitted from Pandas: EDF = {model.edf_total:.1f}") ``` ::: {.callout-tip} ## Zero-copy conversion For Polars DataFrames backed by Arrow arrays, `to_numpy()` is often zero-copy, so no data is duplicated. For Pandas with NumPy-backed columns, `to_numpy()` returns a view of the underlying array. This means conversion is essentially free for typical numerical data. ::: ## Prediction data The `predict()` method accepts the same dictionary format. You only need to include the covariate columns (the response column is not required for prediction). ```{python} # Predict on new data (only covariate columns are needed) new_data = {"x": np.linspace(0, 2 * np.pi, 50)} preds = model.predict(new_data) print(f"Predictions shape: {preds.values.shape}") ``` ```{python} import altair as alt x_plot = np.linspace(0, 2 * np.pi, 100) pred_plot = model.predict({"x": x_plot}, se=True) plot_data = [ {"x": float(x_plot[i]), "fit": float(pred_plot.values[i]), "lower": float(pred_plot.values[i] - 1.96 * pred_plot.se[i]), "upper": float(pred_plot.values[i] + 1.96 * pred_plot.se[i])} for i in range(len(x_plot)) ] pts = alt.Chart( {"values": [{"x": float(data_from_pandas["x"][i]), "y": float(data_from_pandas["y"][i])} for i in range(len(data_from_pandas["y"]))]} ).mark_circle(size=15, opacity=0.3, color="steelblue").encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) band = alt.Chart({"values": plot_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") line = alt.Chart({"values": plot_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") (band + pts + line).properties( width="container", height=300, title="Predictions from the fitted GAM" ) ``` ## Missing values GAMs do not support missing values in model covariates. If your data contains `NaN` values, Whittaker will raise an error at fit time. Remove or impute missing values before fitting. ```{python} # Example: cleaning missing values before fitting rng = np.random.default_rng(23) x_with_nans = np.array([1.0, 2.0, np.nan, 4.0, 5.0, np.nan, 7.0, 8.0, 9.0, 10.0]) y_with_nans = np.sin(x_with_nans) # Drop rows with NaN in any column mask = np.isfinite(x_with_nans) & np.isfinite(y_with_nans) clean_data = { "x": x_with_nans[mask], "y": y_with_nans[mask], } print(f"Original: {len(x_with_nans)} rows") print(f"After cleaning: {len(clean_data['x'])} rows") ``` ## Data types All values are converted to `float64` internally. Integer arrays, boolean arrays, and other numeric types are converted automatically. Non-numeric data (strings, objects) cannot be used directly. Encode categorical variables as numeric indicators before passing them to Whittaker. ```{python} # Integer data is converted to float automatically data_int = { "x": np.arange(50), "y": np.random.default_rng(23).poisson(3, 50).astype(float), } model = wk.GAM("y ~ s(x)", family=wk.Poisson()) model.fit(data_int, method="REML") print(f"Fitted with integer covariates: EDF = {model.edf_total:.1f}") ``` The integer array is converted internally and the model fits as expected. ## Functional covariates For [functional regression](24-functional.qmd), functional covariates are passed as 2-D arrays in the data dictionary. Each row is one observation's curve, and each column is one grid point along the functional domain. ```{python} # Functional covariate: each row is a curve observed at 50 grid points rng = np.random.default_rng(23) n, T = 100, 50 X_func = rng.normal(0, 1, (n, T)).cumsum(axis=1) / np.sqrt(T) # The data dict can mix 1-D (scalar) and 2-D (functional) arrays data_func = { "curves": X_func, # shape (100, 50), functional covariate "y": rng.normal(0, 1, n), # shape (100,), scalar response } print(f"Functional covariate shape: {data_func['curves'].shape}") print(f"Response shape: {data_func['y'].shape}") ``` The 2-D array stores one curve per row. See the [functional regression page](24-functional.qmd) for the full workflow of fitting and interpreting functional GAMs. ## Where to go next - **[Quick start](02-quick-start.qmd)**: fit a complete GAM from a data dictionary in a few lines. - **[Smooth terms](04-smooths.qmd)**: the smooth types available and how to configure them. - **[Functional regression](24-functional.qmd)**: fitting models with functional covariates. - **[Large datasets](25-large-datasets.qmd)**: scalable backends when the data does not fit in memory. ## Prediction and inference ### Prediction and inference ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` After fitting a GAM, the next step is to generate predictions and quantify uncertainty. This page covers point predictions, standard errors, confidence intervals, term-level decompositions, residuals, and how link functions affect interpretation for non-Gaussian models. ## Point predictions on the response scale The `predict()` method generates predictions for new covariate values. By default, predictions are on the **response scale** (meaning the inverse link function has already been applied): $$\hat\mu = g^{-1}(\hat\eta) = g^{-1}(X_{\text{new}} \hat\beta)$$ For Gaussian models with the identity link, this reduces to $\hat\mu = X_{\text{new}} \hat\beta$. For Poisson models with the log link, predictions are exponentiated counts. For binomial models with the logit link, predictions are probabilities. ```{python} import numpy as np import whittaker as wk # Generate data from a noisy sine curve rng = np.random.default_rng(23) n = 200 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} # Fit a Gaussian GAM model = wk.GAM("y ~ s(x)") model.fit(data, method="REML") # Predict on a fine grid x_new = np.linspace(0, 2 * np.pi, 100) new_data = {"x": x_new} preds = model.predict(new_data) print(f"Prediction shape: {preds.values.shape}") print(f"First 5 values: {preds.values[:5].round(4)}") ``` The result is a `PredictionResult` object. The `.values` attribute contains the response-scale predictions. ## Predictions on the linear predictor scale Sometimes you need predictions on the **linear predictor scale** (before the inverse link is applied). This is the raw $\hat\eta = X_{\text{new}} \hat\beta$. Pass `type="link"` to get these: ```{python} preds_link = model.predict(new_data, type="link") # For Gaussian + identity link, the two scales are identical print(f"Link-scale values: {preds_link.values[:5].round(4)}") print(f"Response-scale values: {preds.values[:5].round(4)}") ``` The `PredictionResult` always carries both scales. Regardless of the `type=` argument, you can access the linear predictor via `.linear_predictor`: ```{python} preds = model.predict(new_data) # .values is on the response scale while .linear_predictor is on the link scale print(f"Response: {preds.values[:3].round(4)}") print(f"Linear predictor: {preds.linear_predictor[:3].round(4)}") ``` ::: {.callout-tip} For Gaussian models with the identity link, `.values` and `.linear_predictor` are numerically identical. The distinction matters for non-Gaussian families where the link function is nonlinear (see [Prediction for non-Gaussian models](#prediction-for-non-gaussian-models) below). ::: ## Standard errors Setting `se=True` computes a standard error for each prediction. Standard errors are always reported on the **linear predictor scale**, regardless of whether you requested response-scale predictions: ```{python} preds_se = model.predict(new_data, se=True) print(f"SE shape: {preds_se.se.shape}") print(f"First 5 SEs: {preds_se.se[:5].round(4)}") ``` ### The Bayesian covariance matrix The standard errors come from the **Bayesian posterior covariance** of the coefficient vector $\hat\beta$: $$V_\beta = \hat\phi \bigl(X^\top W X + \textstyle\sum_j \lambda_j S_j\bigr)^{-1}$$ where: - $\hat\phi$ is the estimated scale parameter, - $W$ is the diagonal matrix of working weights from the final IRLS iteration, - $S_j$ are the penalty matrices for each smooth term, and - $\lambda_j$ are the estimated smoothing parameters. The prediction variance at a new point $\mathbf{x}_*$ is: $$\text{Var}(\hat\eta_*) = \mathbf{x}_*^\top V_\beta\, \mathbf{x}_*$$ and the standard error is $\text{SE}(\hat\eta_*) = \sqrt{\mathbf{x}_*^\top V_\beta\, \mathbf{x}_*}$. ::: {.callout-note} These are **Bayesian** standard errors, not frequentist. They have good frequentist coverage properties (Nychka 1988, Wood 2006), but they include a component from the penalty that a purely frequentist SE would not. This is why the SEs are well-calibrated even at the boundaries of the data, where the smooth is partially identified by the penalty. ::: ### Unconditional standard errors By default, the covariance matrix conditions on the estimated smoothing parameters $\hat\lambda_j$ as if they were known. Setting `unconditional=True` uses the corrected covariance $V_c$ (Marra & Wood, 2012), which accounts for the additional uncertainty in $\hat\lambda$: ```{python} # Unconditional SEs (wider, more honest) preds_unc = model.predict(new_data, se=True, unconditional=True) print(f"Conditional SE (first 3): {preds_se.se[:3].round(4)}") print(f"Unconditional SE (first 3): {preds_unc.se[:3].round(4)}") ``` ::: {.callout-important} Unconditional standard errors require the model to be fitted with `method="REML"` or `method="ML"`. If the model was fitted with `method="GCV"`, requesting `unconditional=True` raises a `ValueError`. ::: ## Constructing confidence intervals ### Using `interval="confidence"` The most convenient way to obtain confidence intervals is the `interval=` argument to `predict()`: ```{python} preds_ci = model.predict(new_data, interval="confidence", level=0.95) print(f"Lower bound (first 3): {preds_ci.lower[:3].round(4)}") print(f"Fitted value (first 3): {preds_ci.values[:3].round(4)}") print(f"Upper bound (first 3): {preds_ci.upper[:3].round(4)}") ``` Intervals are computed on the linear predictor scale and transformed to the response scale by the inverse link. For the Gaussian identity-link case, the pointwise 95% interval is: $$\hat\mu \pm t_{n - \text{edf},\; 0.975}\;\text{SE}(\hat\eta)$$ For families with known scale (Poisson, binomial), the normal quantile $z_{0.975}$ is used instead of the $t$-quantile. ### Manual construction from SEs You can also build intervals yourself from the standard errors. This gives full control over the quantile used: ```{python} from scipy.stats import norm preds_se = model.predict(new_data, se=True) # 95% pointwise CI on the linear predictor scale z = norm.ppf(0.975) eta_lower = preds_se.linear_predictor - z * preds_se.se eta_upper = preds_se.linear_predictor + z * preds_se.se # For Gaussian identity link, the response scale is the same print(f"Manual lower (first 3): {eta_lower[:3].round(4)}") print(f"Built-in lower (first 3): {preds_ci.lower[:3].round(4)}") ``` ::: {.callout-tip} For non-Gaussian models, construct the interval on the linear predictor scale and then apply the inverse link to both bounds. This ensures the interval respects the natural constraints of the response (e.g., positivity for Poisson counts, $[0, 1]$ for binomial probabilities). ::: ### Prediction intervals Confidence intervals quantify uncertainty in the **mean** response $\mu$. **Prediction intervals** additionally include the response-distribution variance, so they cover where a new observation might fall: ```{python} preds_pi = model.predict(new_data, interval="prediction", level=0.95) print(f"CI width (mean): {(preds_ci.upper - preds_ci.lower).mean():.4f}") print(f"PI width (mean): {(preds_pi.upper - preds_pi.lower).mean():.4f}") ``` Prediction intervals are always wider than confidence intervals because they account for both estimation uncertainty and observation-level noise. ### Simultaneous confidence bands Pointwise intervals cover the true function at each individual point with the stated probability, but they do not guarantee coverage of the **entire** curve simultaneously. For a band that holds uniformly: ```{python} preds_sim = model.predict( new_data, interval="simultaneous", level=0.95, ) print(f"Pointwise CI width (mean): {(preds_ci.upper - preds_ci.lower).mean():.4f}") print(f"Simultaneous band width (mean): {(preds_sim.upper - preds_sim.lower).mean():.4f}") ``` Simultaneous bands are wider than pointwise intervals because they must cover the function everywhere at once. ## Visualizing predictions with a confidence band Here is a complete example that fits a GAM, predicts on a grid, and plots the result with observed data, the fitted curve, and a 95% confidence band: ```{python} import altair as alt # Predict with confidence interval x_plot = np.linspace(0, 2 * np.pi, 200) preds_plot = model.predict({"x": x_plot}, interval="confidence", level=0.95) # Observed data layer obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) # Fitted curve + confidence band fit_data = [ { "x": float(x_plot[i]), "fit": float(preds_plot.values[i]), "lower": float(preds_plot.lower[i]), "upper": float(preds_plot.upper[i]), } for i in range(len(x_plot)) ] line = alt.Chart({"values": fit_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") # True function true_data = [ {"x": float(x_plot[i]), "true": float(np.sin(x_plot[i]))} for i in range(len(x_plot)) ] true_line = alt.Chart({"values": true_data}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="true:Q") (band + points + line + true_line).properties( width="container", height=320, title="Gaussian GAM with 95% confidence band" ) ``` The red curve is the estimated smooth $\hat{f}(x)$, the shaded band is the 95% pointwise confidence interval, the gray dashed line is the true $\sin(x)$, and the blue points are the observed data. ## Term-level predictions For models with multiple smooth terms, `type="terms"` decomposes the linear predictor into the individual contribution of each smooth: $$\hat\eta = \hat\beta_0 + \hat{f}_1(x_1) + \hat{f}_2(x_2) + \cdots$$ Each $\hat{f}_j$ is returned separately, allowing you to visualize how each predictor affects the response. ```{python} # Fit a model with two smooth terms rng = np.random.default_rng(23) n = 300 x1 = np.linspace(0, 2 * np.pi, n) x2 = rng.uniform(0, 1, n) y_multi = np.sin(x1) + 2 * x2**2 + rng.normal(0, 0.3, n) data_multi = {"x1": x1, "x2": x2, "y": y_multi} model_multi = wk.GAM("y ~ s(x1) + s(x2)") model_multi.fit(data_multi, method="REML") # Predict term-level contributions x1_grid = np.linspace(0, 2 * np.pi, 100) x2_grid = np.linspace(0, 1, 100) new_data_terms = {"x1": x1_grid, "x2": x2_grid} term_preds = model_multi.predict(new_data_terms, type="terms", se=True) print(f"Term labels: {term_preds.labels}") for label in term_preds.labels: vals = term_preds.terms[label] print(f" {label}: range [{vals.min():.3f}, {vals.max():.3f}]") ``` The result is a `TermsPredictionResult` with: - `.terms`: a dict mapping each term label to its contribution array (shape `(n,)`) - `.se`: a dict mapping each term label to its standard error array (or `None` if `se=False`) - `.labels`: term labels in formula order ### Visualizing term contributions ```{python} import altair as alt # Build data for both terms charts = [] for label in term_preds.labels: vals = term_preds.terms[label] ses = term_preds.se[label] z = 1.96 # Determine the x-axis values based on the term label if "x1" in label: x_vals = x1_grid x_label = "x1" else: x_vals = x2_grid x_label = "x2" term_data = [ { "x": float(x_vals[i]), "effect": float(vals[i]), "lower": float(vals[i] - z * ses[i]), "upper": float(vals[i] + z * ses[i]), } for i in range(len(x_vals)) ] line = alt.Chart({"values": term_data}).mark_line( color="firebrick", strokeWidth=2 ).encode( x=alt.X("x:Q", title=x_label), y=alt.Y("effect:Q", title=f"f({x_label})"), ) band = alt.Chart({"values": term_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") zero = alt.Chart({"values": [{}]}).mark_rule( color="gray", strokeDash=[3, 3] ).encode(y=alt.datum(0)) chart = (band + line + zero).properties( width="container", height=220, title=label ) charts.append(chart) charts[0] | charts[1] ``` Each panel shows one smooth term's estimated effect $\hat{f}_j(x_j)$ with a 95% confidence band. The dashed horizontal line at zero is the reference: values above zero indicate a positive contribution to the linear predictor at that covariate value. ## In-sample fitted values and residuals After fitting, the model stores in-sample quantities as properties: ```{python} # Fitted values on the response scale fitted = model.fitted_values print(f"Fitted values shape: {fitted.shape}") print(f"First 5 fitted values: {fitted[:5].round(4)}") ``` ```{python} # Response residuals (y - mu) resid = model.residuals print(f"Residuals shape: {resid.shape}") print(f"First 5 residuals: {resid[:5].round(4)}") print(f"Mean residual: {resid.mean():.6f}") ``` The `.residuals` property returns **response residuals** ($y - \hat\mu$). For other residual types, use the `get_residuals()` method: ```{python} # Deviance residuals (default) dev_resid = model.get_residuals(type="deviance") # Pearson residuals: (y - mu) / sqrt(V(mu)) pear_resid = model.get_residuals(type="pearson") # Working residuals: used internally by P-IRLS work_resid = model.get_residuals(type="working") print(f"Deviance residual range: [{dev_resid.min():.3f}, {dev_resid.max():.3f}]") print(f"Pearson residual range: [{pear_resid.min():.3f}, {pear_resid.max():.3f}]") print(f"Working residual range: [{work_resid.min():.3f}, {work_resid.max():.3f}]") ``` ::: {.callout-note} For Gaussian models with the identity link, response, deviance, Pearson, and working residuals are all proportional to each other. The differences become meaningful for non-Gaussian families where the variance function $V(\mu)$ is not constant. ::: ## Prediction for non-Gaussian models For non-Gaussian families, the **link function** creates a distinction between the linear predictor scale and the response scale. Understanding this distinction is essential for correct interpretation. ### Poisson example With a log link, the model is $\log(\mu) = \eta = X\beta$. Predictions on the response scale are exponentiated: $\hat\mu = \exp(\hat\eta)$. ```{python} # Generate Poisson count data rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) true_rate = np.exp(0.5 + 0.8 * np.sin(x)) y_counts = rng.poisson(true_rate).astype(float) # Fit a Poisson GAM model_pois = wk.GAM("y ~ s(x)", family=wk.Poisson()) model_pois.fit({"x": x, "y": y_counts}, method="REML") # Predict on a grid x_grid = np.linspace(0, 2 * np.pi, 100) preds_pois = model_pois.predict({"x": x_grid}, se=True) # Compare scales print(f"Response scale (counts): {preds_pois.values[:5].round(3)}") print(f"Link scale (log counts): {preds_pois.linear_predictor[:5].round(3)}") print(f"exp(link) = response: {np.exp(preds_pois.linear_predictor[:5]).round(3)}") ``` ```{python} # Visualize the Poisson fit pts_pois = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y_counts[i])} for i in range(n)]} ).mark_circle(size=12, opacity=0.2, color="teal").encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="Count"), ) true_grid = np.exp(0.5 + 0.8 * np.sin(x_grid)) fit_pois = [ {"x": float(x_grid[i]), "fit": float(preds_pois.values[i]), "true": float(true_grid[i])} for i in range(len(x_grid)) ] line_fit = alt.Chart({"values": fit_pois}).mark_line( color="darkorange", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") line_true = alt.Chart({"values": fit_pois}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="true:Q") (pts_pois + line_fit + line_true).properties( width="container", height=300, title="Poisson GAM: fitted rate (orange) vs. true rate (dashed)" ) ``` ### Confidence intervals for non-Gaussian models Intervals are constructed on the linear predictor scale and then transformed via the inverse link. This ensures the bounds respect the natural constraints of the response distribution: ```{python} # Poisson predictions with confidence intervals preds_pois_ci = model_pois.predict( {"x": x_grid}, interval="confidence", level=0.95 ) # All bounds are positive (as expected for counts) print(f"Lower bound range: [{preds_pois_ci.lower.min():.3f}, {preds_pois_ci.lower.max():.3f}]") print(f"Upper bound range: [{preds_pois_ci.upper.min():.3f}, {preds_pois_ci.upper.max():.3f}]") ``` ::: {.callout-tip} Because the interval is constructed as $\exp(\hat\eta \pm z \cdot \text{SE})$, the resulting band on the response scale is **asymmetric** around $\hat\mu$. This asymmetry is a feature: it prevents impossible negative predictions for Poisson and gamma models, and keeps binomial probabilities within $[0, 1]$. ::: ### Binomial example For binary outcomes with a logit link, response-scale predictions are probabilities: ```{python} # Generate binary data rng = np.random.default_rng(23) n = 400 x = np.sort(rng.uniform(-3, 3, n)) prob = 1 / (1 + np.exp(-(0.5 + 1.5 * np.sin(x)))) y_bin = rng.binomial(1, prob).astype(float) # Fit a binomial GAM model_bin = wk.GAM("y ~ s(x)", family=wk.Binomial()) model_bin.fit({"x": x, "y": y_bin}, method="REML") # Predictions are probabilities preds_bin = model_bin.predict({"x": x_grid}, se=True) print( f"Probability range: [{preds_bin.values.min():.3f}, {preds_bin.values.max():.3f}]" ) print( f"Log-odds range: [{preds_bin.linear_predictor.min():.3f}, {preds_bin.linear_predictor.max():.3f}]" ) ``` ```{python} # Visualize the binomial fit prob_true = 1 / (1 + np.exp(-(0.5 + 1.5 * np.sin(x_grid)))) fit_bin = [ {"x": float(x_grid[i]), "fit": float(preds_bin.values[i]), "true": float(prob_true[i])} for i in range(len(x_grid)) ] pts_bin = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y_bin[i])} for i in range(n)]} ).mark_circle(size=12, opacity=0.15, color="steelblue").encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="P(y = 1)"), ) line_bin = alt.Chart({"values": fit_bin}).mark_line( color="darkorange", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") line_true_bin = alt.Chart({"values": fit_bin}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="true:Q") (pts_bin + line_bin + line_true_bin).properties( width="container", height=300, title="Binomial GAM: fitted probability (orange) vs. true (dashed)" ) ``` ## Prediction type summary The `type=` argument to `predict()` controls what is returned: | `type=` | Result class | `.values` contains | Use case | |---|---|---|---| | `"response"` (default) | `PredictionResult` | $\hat\mu = g^{-1}(\hat\eta)$ | Interpretable predictions | | `"link"` | `PredictionResult` | $\hat\eta = X\hat\beta$ | Building custom intervals | | `"terms"` | `TermsPredictionResult` | Per-term $\hat{f}_j(x_j)$ | Decomposing the fit | All prediction types support `se=True` for standard errors. The `interval=` argument is available for `"response"` and `"link"` types but not for `"terms"`. ## Complete practical example This example brings together fitting, prediction, term-level decomposition, and visualization for a two-predictor Gaussian model: ```{python} import altair as alt # Simulate data with two smooth effects rng = np.random.default_rng(99) n = 400 x1 = np.linspace(0, 4 * np.pi, n) x2 = rng.uniform(0, 5, n) y = 2 * np.cos(x1) + 0.3 * x2**1.5 + rng.normal(0, 0.5, n) data = {"x1": x1, "x2": x2, "y": y} # Fit with REML model = wk.GAM("y ~ s(x1, k=15) + s(x2)") model.fit(data, method="REML") print(model.summary()) ``` ```{python} # Predict on a grid for x1, holding x2 at its mean x1_grid = np.linspace(0, 4 * np.pi, 200) x2_mean = np.full(200, x2.mean()) preds = model.predict( {"x1": x1_grid, "x2": x2_mean}, interval="confidence", level=0.95, ) # Plot predictions with confidence band fit_data = [ { "x1": float(x1_grid[i]), "fit": float(preds.values[i]), "lower": float(preds.lower[i]), "upper": float(preds.upper[i]), } for i in range(len(x1_grid)) ] obs_data = [{"x1": float(x1[i]), "y": float(y[i])} for i in range(n)] points = alt.Chart({"values": obs_data}).mark_circle( size=10, opacity=0.15, color="steelblue" ).encode( x=alt.X("x1:Q", title="x1"), y=alt.Y("y:Q", title="y"), ) line = alt.Chart({"values": fit_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x1:Q", y="fit:Q") band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x1:Q", y="lower:Q", y2="upper:Q") (band + points + line).properties( width="container", height=320, title="Predictions at mean(x2) with 95% CI" ) ``` ```{python} # Term-level decomposition for the same model term_preds = model.predict( {"x1": x1_grid, "x2": np.linspace(0, 5, 200)}, type="terms", se=True, ) # Show the s(x1) term label_x1 = term_preds.labels[0] f1 = term_preds.terms[label_x1] se1 = term_preds.se[label_x1] term_data = [ { "x1": float(x1_grid[i]), "effect": float(f1[i]), "lower": float(f1[i] - 1.96 * se1[i]), "upper": float(f1[i] + 1.96 * se1[i]), } for i in range(len(x1_grid)) ] line_t = alt.Chart({"values": term_data}).mark_line( color="firebrick", strokeWidth=2 ).encode( x=alt.X("x1:Q", title="x1"), y=alt.Y("effect:Q", title=f"f(x1)"), ) band_t = alt.Chart({"values": term_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x1:Q", y="lower:Q", y2="upper:Q") zero_t = alt.Chart({"values": [{}]}).mark_rule( color="gray", strokeDash=[3, 3] ).encode(y=alt.datum(0)) (band_t + line_t + zero_t).properties( width="container", height=320, title=f"Term contribution: {label_x1}" ) ``` ## Where to go next - **[Derivatives and marginal effects](34-derivatives.qmd)**: rates of change, partial effects, and pairwise comparisons for smooth terms. - **[Diagnostics](11-diagnostics.qmd)**: residual plots, `gam_check()`, and basis dimension checks. - **[Model fitting](06-fitting.qmd)**: how P-IRLS works, GCV vs. REML, and convergence settings. - **[Response families](05-families.qmd)**: all supported distributions and their link functions. ### Simultaneous confidence bands ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Pointwise confidence intervals cover the true function at each *individual* point with the stated probability, but they say nothing about whether the *entire* curve lies within the band. A 95% pointwise band means each point has 95% coverage independently (across many points, you would expect some to miss). Simultaneous confidence bands guarantee (approximately) that the whole function lies within the band with the stated probability. Whittaker provides two ways to get simultaneous bands: - **`predict(..., interval="simultaneous")`** — overall simultaneous bands for the full linear predictor or response, covered in the [prediction guide](08-prediction.qmd). - **`simultaneous_ci()`** — term-level simultaneous bands for individual smooth terms, useful for inference about where a specific smooth is significantly different from zero. This article focuses on `simultaneous_ci()`. ## Why simultaneous bands matter When you look at a pointwise confidence band for a smooth and ask *"where does this band exclude zero?"*, you are performing multiple implicit tests (one at every evaluation point). Pointwise bands do not adjust for this multiplicity, so some apparent "significant" regions may be false positives. Simultaneous bands solve this by computing a wider critical value that accounts for the supremum of the standardized deviation across the entire curve. If the simultaneous band excludes zero at a region, you can be confident the smooth is truly non-zero there at the stated level. ## Setup ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x, k=10)").fit(data, method="REML") model.summary() ``` ## Computing simultaneous bands for a smooth `simultaneous_ci()` returns a `SimultaneousCIResult` dataclass with the smooth's estimate, standard errors, lower and upper bounds, the term label, and the critical value used. All fields are available as attributes (e.g., `sci.estimate`), and dict-style access (e.g., `sci["estimate"]`) still works for backward compatibility: ```{python} grid = {"x": np.linspace(0, 2 * np.pi, 200)} sci = model.simultaneous_ci(grid, term=0) print(f"Term: {sci['term_label']}") print(f"Critical value: {sci['crit_value']:.3f}") print(f"Compare to z_{0.975} = 1.96 for pointwise") ``` The critical value is always larger than the pointwise z-value (1.96 for 95%), which is why the simultaneous band is wider. ## Pointwise vs. simultaneous comparison Let's visualize both bands side by side to see the difference: ```{python} import altair as alt preds = model.predict(grid, type="terms", se=True) term_label = sci["term_label"] pw_estimate = preds.terms[term_label] pw_se = preds.se[term_label] pw_lower = pw_estimate - 1.96 * pw_se pw_upper = pw_estimate + 1.96 * pw_se x_vals = grid["x"] plot_data = [] for i in range(len(x_vals)): plot_data.append({ "x": float(x_vals[i]), "estimate": float(sci["estimate"][i]), "pw_lower": float(pw_lower[i]), "pw_upper": float(pw_upper[i]), "sim_lower": float(sci["lower"][i]), "sim_upper": float(sci["upper"][i]), }) pw_band = alt.Chart({"values": plot_data}).mark_area( opacity=0.3, color="steelblue" ).encode(x=alt.X("x:Q"), y="pw_lower:Q", y2="pw_upper:Q") sim_band = alt.Chart({"values": plot_data}).mark_area( opacity=0.15, color="darkorange" ).encode(x=alt.X("x:Q"), y="sim_lower:Q", y2="sim_upper:Q") line = alt.Chart({"values": plot_data}).mark_line( color="black" ).encode(x="x:Q", y=alt.Y("estimate:Q", title="s(x)")) zero = alt.Chart({"values": [{"y": 0}]}).mark_rule( color="firebrick", strokeDash=[4, 4] ).encode(y="y:Q") (sim_band + pw_band + line + zero).properties( width=500, height=300, title="Pointwise (blue) vs. simultaneous (orange) 95% bands" ) ``` The simultaneous band (orange) is wider everywhere. Where the simultaneous band excludes zero, you have strong evidence that the smooth is non-zero, adjusted for the fact that you are making this claim about the entire curve. ## Selecting a specific term For models with multiple smooths, specify the term by index or name: ```{python} x2 = rng.uniform(0, 5, n) y2 = np.sin(x) + 0.5 * x2 + rng.normal(0, 0.3, n) data2 = {"x": x, "x2": x2, "y": y2} model2 = wk.GAM("y ~ s(x) + s(x2)").fit(data2, method="REML") grid2 = {"x": np.linspace(0, 2 * np.pi, 100), "x2": np.linspace(0, 5, 100)} sci_x = model2.simultaneous_ci(grid2, term="x") sci_x2 = model2.simultaneous_ci(grid2, term="x2") print(f"s(x) critical value: {sci_x['crit_value']:.3f}") print(f"s(x2) critical value: {sci_x2['crit_value']:.3f}") ``` Each term gets its own critical value because the multiplicity correction depends on the term's basis dimension and the correlation structure of its basis functions. ## Unconditional bands By default, the bands are conditional on the estimated smoothing parameters. Setting `unconditional=True` additionally accounts for the uncertainty in the smoothing parameters themselves, producing even wider bands: ```{python} sci_cond = model.simultaneous_ci(grid, term=0) sci_uncond = model.simultaneous_ci(grid, term=0, unconditional=True) mean_width_cond = (sci_cond["upper"] - sci_cond["lower"]).mean() mean_width_uncond = (sci_uncond["upper"] - sci_uncond["lower"]).mean() print(f"Mean band width (conditional): {mean_width_cond:.4f}") print(f"Mean band width (unconditional): {mean_width_uncond:.4f}") print(f"Ratio: {mean_width_uncond / mean_width_cond:.2f}x") ``` Use unconditional bands when the smoothing parameter is uncertain (e.g., when the REML criterion surface is flat as shown by [smoothing parameter sensitivity](33-sensitivity.qmd)). ## Controlling the simulation The critical value is computed via posterior simulation (drawing from the Bayesian posterior of the coefficients and computing the maximum standardized deviation). You can control the number of simulations and the random seed: ```{python} sci_1k = model.simultaneous_ci(grid, term=0, n_sim=1_000, seed=0) sci_50k = model.simultaneous_ci(grid, term=0, n_sim=50_000, seed=0) print(f"Critical value (1,000 sims): {sci_1k['crit_value']:.4f}") print(f"Critical value (50,000 sims): {sci_50k['crit_value']:.4f}") ``` The default of 10,000 simulations is usually sufficient. Increase it if you need very precise critical values (e.g., for publication). ## When to use simultaneous vs. pointwise | Scenario | Use | |---|---| | *"Is the smooth significantly non-zero at this specific x?"* | Pointwise CI | | *"Over what range of x is the smooth significantly non-zero?"* | Simultaneous band | | *"Can I claim the entire fitted curve lies within this band?"* | Simultaneous band | | Exploratory analysis, quick checks | Pointwise CI | | Publication-quality inference | Simultaneous band | ## Where to go next - **[Prediction and inference](08-prediction.qmd)**: overall simultaneous bands via `predict(..., interval="simultaneous")`. - **[Derivatives and marginal effects](34-derivatives.qmd)**: detecting where a smooth is significantly increasing or decreasing. - **[Smoothing parameter sensitivity](33-sensitivity.qmd)**: check whether the smoothing parameter is well-determined before relying on conditional bands. ### Partial dependence as data ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` The `partial_effects()` method produces Altair charts that are ready to display, but sometimes you need the underlying numbers. You might want to plot with matplotlib, export the curves to a CSV, run a custom analysis on the estimated effects, or combine smooth estimates across models. The `partial_dependence()` method returns the same data that `partial_effects()` plots, but as structured arrays rather than chart objects. ## Why partial dependence as data? `partial_effects()` is the fast path: one call gives you a polished visualization of every smooth term. But Altair charts are not always what you need: - **Matplotlib or seaborn workflows**: your project already uses matplotlib and you want a consistent style. - **Export**: you want to write the estimated curves to a file for a collaborator or a table in a paper. - **Custom analysis**: you want to compute the area under a smooth, find the x-value where the effect crosses zero, or compare effects across models numerically. - **Fine-grained control**: you need to overlay observed data, add reference lines, or combine panels in ways that go beyond what the built-in plots offer. `partial_dependence()` gives you structured `PartialDependenceResult` objects with arrays you can manipulate freely. ## Basic usage Fit a model on the motorcycle crash dataset and call `partial_dependence()`: ```{python} import numpy as np import whittaker as wk data = wk.load_dataset("mcycle") model = wk.GAM("accel ~ s(times, k=15)") model.fit(data, method="REML") results = model.partial_dependence() ``` The return value is a list of `PartialDependenceResult` objects, one per smooth term in formula order: ```{python} print(f"Number of results: {len(results)}") print(results[0]) ``` Each result is a dataclass with the following fields: ```{python} r = results[0] print(f"term: {r.term}") print(f"x keys: {list(r.x.keys())}") print(f"effect: shape {r.effect.shape}, dtype {r.effect.dtype}") print(f"se: shape {r.se.shape}") print(f"lower: shape {r.lower.shape}") print(f"upper: shape {r.upper.shape}") print(f"edf: {r.edf:.2f}") print(f"level: {r.level}") print(f"n_grid: {r.n_grid}") ``` The `x` dictionary maps covariate names to their evaluation grids. For a 1-D smooth like `s(times)`, there is a single key. The `effect`, `se`, `lower`, and `upper` arrays all have the same length as the grid. ## Plotting with the raw arrays With the arrays in hand, building a custom plot is straightforward. Here we plot the estimated effect as a line with a shaded confidence band using Altair: ```{python} import altair as alt r = results[0] x_vals = r.x["times"] plot_data = [ {"times": float(x_vals[i]), "effect": float(r.effect[i]), "lower": float(r.lower[i]), "upper": float(r.upper[i])} for i in range(len(x_vals)) ] band = alt.Chart({"values": plot_data}).mark_area( opacity=0.25, color="steelblue" ).encode(x="times:Q", y="lower:Q", y2="upper:Q") line = alt.Chart({"values": plot_data}).mark_line( color="steelblue", strokeWidth=2 ).encode(x=alt.X("times:Q", title="times"), y=alt.Y("effect:Q", title=f"Effect ({r.term})")) ref = alt.Chart({"values": [{"y": 0}]}).mark_rule( color="gray", strokeDash=[4, 4] ).encode(y="y:Q") (band + ref + line).properties( width="container", height=300, title=f"Partial dependence: {r.term} (EDF = {r.edf:.1f})" ) ``` You can overlay the raw data by adding a scatter layer before the effect line (this is something that is easy with arrays but would require more work if you only had a chart object). ## Multi-term models When a model has multiple smooth terms, `partial_dependence()` returns one result per term. You can iterate over them to build a panel of plots: ```{python} data_wages = wk.load_dataset("wages") model_wages = wk.GAM("wage ~ s(age) + s(experience)") model_wages.fit(data_wages, method="REML") results_wages = model_wages.partial_dependence() panels = [] for r in results_wages: var_name = list(r.x.keys())[0] x_vals = r.x[var_name] pd_data = [ {"x": float(x_vals[i]), "effect": float(r.effect[i]), "lower": float(r.lower[i]), "upper": float(r.upper[i])} for i in range(len(x_vals)) ] band = alt.Chart({"values": pd_data}).mark_area( opacity=0.25, color="steelblue" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") line = alt.Chart({"values": pd_data}).mark_line( color="steelblue", strokeWidth=2 ).encode( x=alt.X("x:Q", title=var_name), y=alt.Y("effect:Q", title="Partial effect"), ) ref = alt.Chart({"values": [{"y": 0}]}).mark_rule( color="gray", strokeDash=[4, 4] ).encode(y="y:Q") panels.append( (band + ref + line).properties( width=300, height=250, title=f"{r.term} (EDF = {r.edf:.1f})" ) ) alt.hconcat(*panels) ``` ## Controlling the grid and confidence level Two keyword-only parameters let you adjust the evaluation: - **`n_points`** (default `200`): the number of evenly spaced points along each covariate's range. More points give smoother curves whereas fewer points are faster. - **`level`** (default `0.95`): the confidence level for the `lower` and `upper` bounds. ```{python} # Coarse grid with 90% confidence bands results_coarse = model.partial_dependence(n_points=50, level=0.90) r_coarse = results_coarse[0] print(f"Grid points: {r_coarse.n_grid}") print(f"Confidence level: {r_coarse.level}") ``` Compare two confidence levels side by side: ```{python} r_95 = model.partial_dependence(n_points=200, level=0.95)[0] r_80 = model.partial_dependence(n_points=200, level=0.80)[0] x_vals = r_95.x["times"] ci_data = [] for i in range(len(x_vals)): ci_data.append({ "times": float(x_vals[i]), "effect": float(r_95.effect[i]), "lower_95": float(r_95.lower[i]), "upper_95": float(r_95.upper[i]), "lower_80": float(r_80.lower[i]), "upper_80": float(r_80.upper[i]), }) band_95 = alt.Chart({"values": ci_data}).mark_area( opacity=0.15, color="steelblue" ).encode(x="times:Q", y="lower_95:Q", y2="upper_95:Q") band_80 = alt.Chart({"values": ci_data}).mark_area( opacity=0.30, color="steelblue" ).encode(x="times:Q", y="lower_80:Q", y2="upper_80:Q") line = alt.Chart({"values": ci_data}).mark_line( color="steelblue", strokeWidth=2 ).encode( x=alt.X("times:Q", title="times"), y=alt.Y("effect:Q", title="Partial effect"), ) ref = alt.Chart({"values": [{"y": 0}]}).mark_rule( color="gray", strokeDash=[4, 4] ).encode(y="y:Q") (band_95 + band_80 + ref + line).properties( width="container", height=300, title="Effect of confidence level on band width" ) ``` ## 2-D smooths For a 2-D smooth like `s(x, y)`, the `x` dictionary has two keys corresponding to the two covariates. Each value is a 1-D marginal grid of length approximately `sqrt(n_points)`. The `effect`, `se`, `lower`, and `upper` arrays are flattened over the full grid (length = `n_side^2`). To reconstruct the 2-D surface, use `np.meshgrid` on the marginal grids and reshape the effect: ```{python} data_meuse = wk.load_dataset("meuse") model_2d = wk.GAM("zinc ~ s(x, y, k=25)") model_2d.fit(data_meuse, method="REML") results_2d = model_2d.partial_dependence(n_points=225) r2 = results_2d[0] print(f"Term: {r2.term}") print(f"x keys: {list(r2.x.keys())}") print(f"Marginal grid lengths: {[len(v) for v in r2.x.values()]}") print(f"Effect length: {len(r2.effect)}") ``` Plot the 2-D partial effect as a heatmap: ```{python} x_grid = r2.x["x"] y_grid = r2.x["y"] # Build a flat list of grid cells with their effect values heat_data = [] idx = 0 for yi in range(len(y_grid)): for xi in range(len(x_grid)): heat_data.append({ "x": float(x_grid[xi]), "y": float(y_grid[yi]), "effect": float(r2.effect[idx]), }) idx += 1 alt.Chart({"values": heat_data}).mark_rect().encode( x=alt.X("x:O", title="x", axis=alt.Axis(labelAngle=0, values=x_grid[::3].tolist())), y=alt.Y("y:O", title="y", sort="descending", axis=alt.Axis(values=y_grid[::3].tolist())), color=alt.Color("effect:Q", scale=alt.Scale(scheme="redblue", domainMid=0), title="Partial effect"), ).properties( width=400, height=350, title=f"2-D partial dependence: {r2.term}" ) ``` ## When to use `partial_dependence()` vs `partial_effects()` | Scenario | Method | |---|---| | Quick visualization during exploration | `partial_effects()` | | Publication-quality matplotlib figure | `partial_dependence()` | | Export smooth curves to CSV or DataFrame | `partial_dependence()` | | Overlay observed data on effect plots | `partial_dependence()` | | Numerical analysis (zero crossings, AUC) | `partial_dependence()` | | Interactive notebook with Altair tooltips | `partial_effects()` | | Compare effects across multiple models | `partial_dependence()` | Both methods compute the same underlying quantities. The difference is purely in what they return: chart objects vs. arrays. ## Where to go next - [Prediction and inference](08-prediction.qmd) covers the `predict()` method for response-scale predictions and confidence intervals. - [Derivatives and marginal effects](34-derivatives.qmd) shows how to estimate the rate of change of smooth effects using `derivatives()` and `marginal_effects()`. - [Simultaneous confidence bands](09-simultaneous-ci.qmd) explains how to construct bands that cover the entire smooth simultaneously rather than pointwise. - [Model diagnostics](11-diagnostics.qmd) covers `gam_check()` for residual diagnostics and basis dimension adequacy checks. ## Model diagnostics ### Model diagnostics ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Always check your model after fitting. GAMs can fail silently when a basis dimension is too small, when the data contain structure the smoother cannot capture, or when the distributional assumptions are wrong. This page covers the diagnostic tools Whittaker provides. ## A model to diagnose We start by fitting a model to data with a known structure, so we can verify that the diagnostics behave sensibly. ```{python} import numpy as np import whittaker as wk # Generate data: sin(x) + noise rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) # Fit with default settings model = wk.GAM("y ~ s(x)") model.fit({"x": x, "y": y}, method="REML") print(model.summary()) ``` ## Model summary The `summary()` method is the first thing to inspect. It reports: - **Effective degrees of freedom (EDF)** for each smooth term. An EDF near 1 means the smooth is approximately linear. An EDF close to $k - 1$ (the maximum) suggests the basis may be too small. - **Deviance explained**: the proportion of null deviance accounted for by the model, analogous to $R^2$ in linear regression. - **Scale estimate** $\hat\phi$: for Gaussian models, this is the estimated residual variance $\hat\sigma^2$. ```{python} # Access individual fit statistics print(f"EDF total: {model.edf_total:.1f}") print(f"Scale (sigma^2): {model.scale:.4f}") print(f"Deviance: {model.deviance:.2f}") ``` ## Goodness of fit Rather than accessing each fit statistic individually, the `goodness_of_fit()` method returns a single `GoodnessOfFit` object that bundles together the most commonly used measures of model quality. This is especially convenient when you want a quick snapshot of how well the model fits the data, or when comparing several models side by side. ```{python} gof = model.goodness_of_fit() print(gof) ``` The printed summary includes deviance explained, adjusted $R^2$, AIC, BIC, GCV (when available), the scale estimate, and the total effective degrees of freedom. Adjusted $R^2$ penalizes for model complexity using the effective degrees of freedom rather than the raw parameter count, so it gives a fairer comparison between models of different flexibility: $$R^2_{\text{adj}} = 1 - (1 - R^2) \cdot \frac{n - 1}{n - \text{EDF} - 1}$$ Each field is accessible as a plain attribute, which makes it straightforward to build comparison tables or apply decision rules programmatically: ```{python} print(f"Deviance explained: {gof.deviance_explained:.1%}") print(f"Adjusted R-squared: {gof.r_squared_adj:.4f}") print(f"AIC: {gof.aic:.2f}") print(f"BIC: {gof.bic:.2f}") print(f"GCV: {gof.gcv_score:.6f}") print(f"Observations: {gof.n_obs}") ``` For Bayesian fits (VI or MCMC), the GCV score is not applicable and is reported as `None`. All other fields remain available. ::: {.callout-tip} ## Comparing models with goodness of fit When comparing two or more models, collect each one's `GoodnessOfFit` and compare the metrics that matter for your goal. Lower AIC or BIC favors predictive accuracy with a complexity penalty. Higher deviance explained and adjusted $R^2$ indicate better in-sample fit. ::: ## Basis dimension adequacy The basis dimension $k$ sets the maximum complexity of each smooth. If $k$ is too small, the model cannot capture the true function shape. The `check()` method runs a basis dimension adequacy test (the k-index test) for each smooth term. ```{python} # Run the check wk.check(model) ``` The k-index is based on the ratio of the residual variance estimated from neighboring residuals to the overall residual variance. A k-index below 1 with a significant p-value is a warning that the basis dimension may be too small. ::: {.callout-note} ## When to increase k If `check()` reports a k-index below 1 with a significant p-value, re-fit with a larger `k`: ```python model2 = wk.GAM("y ~ s(x, k=20)") model2.fit(data, method="REML") wk.check(model2) ``` Keep increasing `k` until the k-index test is no longer significant. The smoothing parameter selection (REML) will prevent overfitting even with a large `k`. The penalty shrinks away unnecessary complexity. ::: ## Residual analysis Residuals are the primary tool for checking distributional assumptions. For Gaussian models, well- behaved residuals should be approximately normal with constant variance. ```{python} # Deviance residuals (default) resids = model.residuals print(f"Residual shape: {resids.shape}") print(f"Mean: {resids.mean():.4f}") print(f"Std: {resids.std():.4f}") ``` ### Residuals vs. fitted values Plotting residuals against fitted values checks the constant-variance assumption. The plot should show no systematic pattern, just a random scatter around zero. ```{python} import altair as alt # Get fitted values and residuals fitted = model.fitted_values resids = model.residuals # Build the plot data resid_data = [ {"fitted": float(fitted[i]), "residual": float(resids[i])} for i in range(len(fitted)) ] alt.Chart({"values": resid_data}).mark_circle( size=15, opacity=0.4, color="steelblue" ).encode( x=alt.X("fitted:Q", title="Fitted values"), y=alt.Y("residual:Q", title="Deviance residuals"), ).properties( width="container", height=300, title="Residuals vs. fitted values" ) + alt.Chart({"values": [{"y": 0}]}).mark_rule( color="firebrick", strokeDash=[4, 4] ).encode(y="y:Q") ``` A funnel shape (variance increasing with fitted values) suggests a non-constant variance and may indicate that a different family or link function is needed. ### QQ plot of residuals A quantile-quantile plot compares the distribution of residuals against a theoretical normal distribution. Points should fall close to the diagonal line. ```{python} # Compute theoretical and sample quantiles for QQ plot sorted_resids = np.sort(resids) n_resids = len(sorted_resids) theoretical = np.array([ float(x) for x in np.quantile( rng.normal(0, 1, 10000), np.linspace(0.5 / n_resids, 1 - 0.5 / n_resids, n_resids) ) ]) qq_data = [ {"theoretical": float(theoretical[i]), "sample": float(sorted_resids[i])} for i in range(n_resids) ] # QQ points qq_points = alt.Chart({"values": qq_data}).mark_circle( size=15, opacity=0.4, color="steelblue" ).encode( x=alt.X("theoretical:Q", title="Theoretical quantiles"), y=alt.Y("sample:Q", title="Sample quantiles"), ) # Reference line ref_min = min(theoretical.min(), sorted_resids.min()) ref_max = max(theoretical.max(), sorted_resids.max()) ref_data = [{"x": float(ref_min), "y": float(ref_min)}, {"x": float(ref_max), "y": float(ref_max)}] ref_line = alt.Chart({"values": ref_data}).mark_line( color="firebrick", strokeDash=[4, 4] ).encode(x="x:Q", y="y:Q") (qq_points + ref_line).properties( width="container", height=400, title="Normal QQ plot of residuals" ) ``` Systematic departures from the line indicate non-normality. Heavy tails (S-shaped departures) suggest a heavier-tailed family might be more appropriate. ### Histogram of residuals ```{python} # Histogram of deviance residuals hist_data = [{"residual": float(r)} for r in resids] alt.Chart({"values": hist_data}).mark_bar( opacity=0.7, color="steelblue" ).encode( x=alt.X("residual:Q", bin=alt.Bin(maxbins=30), title="Deviance residuals"), y=alt.Y("count():Q", title="Frequency"), ).properties( width="container", height=250, title="Distribution of residuals" ) ``` ## Diagnosing an inadequate model To illustrate what diagnostics look like when something is wrong, let's deliberately under-fit by using too few basis functions for a complex signal. ```{python} # Generate data with high-frequency oscillation rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 4 * np.pi, n) y = np.sin(x) + 0.5 * np.sin(3 * x) + rng.normal(0, 0.3, n) # Fit with k=5 (too few basis functions for this signal) model_bad = wk.GAM("y ~ s(x, k=5)") model_bad.fit({"x": x, "y": y}, method="REML") # Check: the k-index should flag the problem wk.check(model_bad) ``` The k-index test flags the model as inadequate. The fix is to increase the basis dimension. ```{python} # Re-fit with more basis functions model_good = wk.GAM("y ~ s(x, k=20)") model_good.fit({"x": x, "y": y}, method="REML") # The check should now pass wk.check(model_good) ``` With $k = 20$, the k-index test passes. Now compare the two fits visually to see the difference. ```{python} # Compare fits visually x_plot = np.linspace(0, 4 * np.pi, 300) pred_bad = model_bad.predict({"x": x_plot}) pred_good = model_good.predict({"x": x_plot}) true_vals = np.sin(x_plot) + 0.5 * np.sin(3 * x_plot) # Build plot data plot_data = [] for i in range(len(x_plot)): plot_data.append({"x": float(x_plot[i]), "y": float(pred_bad.values[i]), "model": "k=5 (underfit)"}) plot_data.append({"x": float(x_plot[i]), "y": float(pred_good.values[i]), "model": "k=20 (adequate)"}) plot_data.append({"x": float(x_plot[i]), "y": float(true_vals[i]), "model": "Truth"}) alt.Chart({"values": plot_data}).mark_line().encode( x=alt.X("x:Q"), y=alt.Y("y:Q"), color=alt.Color("model:N", title="Model"), strokeDash=alt.condition( alt.datum.model == "Truth", alt.value([4, 4]), alt.value([0]) ), ).properties(width="container", height=300, title="Effect of basis dimension on fit quality") ``` The underfit model (k=5) misses the high-frequency component entirely, while k=20 captures both sine components. The `check()` method correctly flagged the k=5 model. ## Practical diagnostic workflow A good diagnostic workflow after fitting any GAM: 1. **`model.summary()`**: check that EDF values make sense, deviance explained is reasonable 2. **`model.goodness_of_fit()`**: get a compact snapshot of AIC, BIC, adjusted $R^2$, and other quality metrics in one call 3. **`wk.check(model)`**: verify basis dimensions are adequate (k-index test) 4. **Residuals vs. fitted**: check for patterns indicating wrong family or missing terms 5. **QQ plot**: check the distributional assumption 6. **If any diagnostic fails**: consider increasing `k`, changing the family, adding terms, or restructuring the model ::: {.callout-tip} ## The most common fix The most common diagnostic issue is an inadequate basis dimension. REML will never overfit even with a generous `k`, so it is always safe to increase `k`. Start with the default (10), check, and double if the k-index test is significant. Repeat until the test passes. ::: You can now inspect a fitted GAM for basis adequacy, residual patterns, and distributional assumptions using the diagnostic tools on this page. ## Where to go next - **[Model fitting](06-fitting.qmd)**: smoothness selection methods (REML, GCV, ML) that control how flexible the smooth terms are. - **[Response families](05-families.qmd)**: choosing a family that matches the data-generating process, which is the most common fix when diagnostics reveal distributional problems. - **[Posterior predictive checks](30-ppc.qmd)**: a complementary diagnostic that tests whether the model generates realistic data. - **[Advanced diagnostics](13-advanced-diagnostics.qmd)**: influence, concurvity, dispersion tests, and quantile residuals for deeper model checking. - **[Smoothing parameter sensitivity](33-sensitivity.qmd)**: check how much predictions change as smoothing parameters vary, to assess robustness of conclusions. - **[Prediction and inference](08-prediction.qmd)**: confidence intervals and term-level predictions from a model that passes diagnostics. ### Diagnostic data for custom plots ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` ## Why diagnostic data? The `check()` function from `whittaker.plotting` produces a set of four Altair diagnostic charts (QQ plot, residuals vs. fitted, histogram of deviance residuals, and response vs. fitted) with a single call. This is the fastest way to visually assess a model. But sometimes you need the raw arrays behind those plots: - **Matplotlib or seaborn workflows**: your project already uses matplotlib and you want a consistent style across all figures. - **Automated checks**: you want to run programmatic tests on the residuals (e.g., normality tests, heteroscedasticity detection) rather than eyeballing charts. - **Export**: you need to write the diagnostic values to a CSV or pass them to another tool. - **Fine-grained control**: you want to customize axis limits, colors, annotations, or panel layout beyond what the built-in plots offer. The `check_data()` method on a fitted `GAM` returns a `CheckDataResult` dataclass containing all the arrays that `check()` would plot, without producing any charts. ## Basic usage Fit a model on the motorcycle crash dataset and call `check_data()`: ```{python} import numpy as np import whittaker as wk data = wk.load_dataset("mcycle") model = wk.GAM("accel ~ s(times, k=15)") model.fit(data, method="REML") cd = model.check_data() print(cd) ``` The return value is a `CheckDataResult` dataclass with the following fields: ```{python} print(f"n_obs: {cd.n_obs}") print(f"deviance_residuals: shape {cd.deviance_residuals.shape}") print(f"pearson_residuals: shape {cd.pearson_residuals.shape}") print(f"fitted_values: shape {cd.fitted_values.shape}") print(f"response: shape {cd.response.shape}") print(f"qq_theoretical: shape {cd.qq_theoretical.shape}") print(f"qq_observed: shape {cd.qq_observed.shape}") ``` Every field is a NumPy array of length `n_obs` (except `qq_theoretical` and `qq_observed`, which are sorted for direct plotting). The arrays correspond exactly to the data rendered by the four panels in `check()`. ## Recreating the four diagnostic plots With the arrays in hand, you can reproduce all four diagnostic plots. Here they are arranged in a 2x2 grid using Altair, mirroring the layout of `check()`: ```{python} import altair as alt # 1. QQ plot of deviance residuals qq_data = [ {"theoretical": float(cd.qq_theoretical[i]), "observed": float(cd.qq_observed[i])} for i in range(len(cd.qq_theoretical)) ] qq_min = min(cd.qq_theoretical.min(), cd.qq_observed.min()) qq_max = max(cd.qq_theoretical.max(), cd.qq_observed.max()) qq_ref = [{"x": float(qq_min), "y": float(qq_min)}, {"x": float(qq_max), "y": float(qq_max)}] qq_plot = ( alt.Chart({"values": qq_data}).mark_circle(size=15, opacity=0.6, color="steelblue").encode( x=alt.X("theoretical:Q", title="Theoretical quantiles"), y=alt.Y("observed:Q", title="Observed quantiles"), ) + alt.Chart({"values": qq_ref}).mark_line(color="firebrick", strokeDash=[4, 4]).encode( x="x:Q", y="y:Q" ) ).properties(width=300, height=250, title="QQ plot") # 2. Residuals vs. fitted values resid_data = [ {"fitted": float(cd.fitted_values[i]), "residual": float(cd.deviance_residuals[i])} for i in range(cd.n_obs) ] resid_plot = ( alt.Chart({"values": resid_data}).mark_circle(size=15, opacity=0.6, color="steelblue").encode( x=alt.X("fitted:Q", title="Fitted values"), y=alt.Y("residual:Q", title="Deviance residuals"), ) + alt.Chart({"values": [{"y": 0}]}).mark_rule(color="firebrick", strokeDash=[4, 4]).encode( y="y:Q" ) ).properties(width=300, height=250, title="Residuals vs. fitted") # 3. Histogram of deviance residuals hist_data = [{"residual": float(r)} for r in cd.deviance_residuals] hist_plot = alt.Chart({"values": hist_data}).mark_bar( opacity=0.8, color="steelblue" ).encode( x=alt.X("residual:Q", bin=alt.Bin(maxbins=30), title="Deviance residuals"), y=alt.Y("count():Q", title="Frequency"), ).properties(width=300, height=250, title="Histogram of residuals") # 4. Response vs. fitted values resp_data = [ {"fitted": float(cd.fitted_values[i]), "response": float(cd.response[i])} for i in range(cd.n_obs) ] mn, mx = float(cd.fitted_values.min()), float(cd.fitted_values.max()) resp_ref = [{"x": mn, "y": mn}, {"x": mx, "y": mx}] resp_plot = ( alt.Chart({"values": resp_data}).mark_circle(size=15, opacity=0.6, color="steelblue").encode( x=alt.X("fitted:Q", title="Fitted values"), y=alt.Y("response:Q", title="Response"), ) + alt.Chart({"values": resp_ref}).mark_line(color="firebrick", strokeDash=[4, 4]).encode( x="x:Q", y="y:Q" ) ).properties(width=300, height=250, title="Response vs. fitted") # 2x2 grid (qq_plot | resid_plot) & (hist_plot | resp_plot) ``` Because you have full control over the chart specifications, you can adjust colors, add annotations, change bin widths, or rearrange the panel layout to suit your needs. ## Automated residual checks Having the residuals as arrays makes it straightforward to run programmatic diagnostics. For example, you can test whether the deviance residuals are approximately normally distributed using the Shapiro-Wilk test, and look for heteroscedasticity by checking whether the variance of residuals changes across the range of fitted values: ```{python} from scipy import stats # Shapiro-Wilk test for normality of deviance residuals stat, p_value = stats.shapiro(cd.deviance_residuals) print(f"Shapiro-Wilk statistic: {stat:.4f}") print(f"p-value: {p_value:.4f}") if p_value < 0.05: print("Evidence against normality of residuals (p < 0.05)") else: print("No strong evidence against normality (p >= 0.05)") ``` To check for heteroscedasticity, split the residuals into groups by fitted value and compare their variances: ```{python} # Split residuals into lower and upper halves by fitted value median_fitted = np.median(cd.fitted_values) lower = cd.deviance_residuals[cd.fitted_values <= median_fitted] upper = cd.deviance_residuals[cd.fitted_values > median_fitted] # Levene's test for equal variances stat_lev, p_lev = stats.levene(lower, upper) print(f"Levene's test statistic: {stat_lev:.4f}") print(f"p-value: {p_lev:.4f}") if p_lev < 0.05: print("Evidence of heteroscedasticity (p < 0.05)") else: print("No strong evidence of heteroscedasticity (p >= 0.05)") ``` You can combine these tests into a reusable function that flags potential problems automatically, rather than relying on visual inspection every time you fit a model. ## `check_data()` vs `check()` | Scenario | Method | |---|---| | Quick visual diagnostic in a notebook | `check()` | | Publication-quality matplotlib figure | `check_data()` | | Automated test suite or CI pipeline | `check_data()` | | Export residuals to CSV or DataFrame | `check_data()` | | Statistical tests on residuals | `check_data()` | | Interactive exploration with Altair tooltips | `check()` | | Custom panel layout or overlays | `check_data()` | Both methods compute the same underlying quantities. `check()` returns Altair chart objects; `check_data()` returns arrays in a `CheckDataResult` dataclass. ## Where to go next - [Model diagnostics](11-diagnostics.qmd) covers the full diagnostic workflow, including `gam_check()` for basis dimension adequacy checks. - [Advanced diagnostics](13-advanced-diagnostics.qmd) dives deeper into residual types and influence measures. - [Partial dependence as data](10-partial-dependence.qmd) follows the same pattern: raw arrays for the data behind `partial_effects()` plots. ### Advanced diagnostics ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` The [Model diagnostics](11-diagnostics.qmd) page covers the essentials: `summary()`, `check()`, residual plots, and `goodness_of_fit()`. This page goes deeper with tools for detecting influential observations, diagnosing collinearity among smooth terms, testing for overdispersion, and computing improved residuals for non-Gaussian families. ## Setup We use a Poisson dataset with a nonlinear effect and an outlier, so the diagnostics have something to find. ```{python} import numpy as np import whittaker as wk from whittaker.families.poisson import Poisson rng = np.random.default_rng(23) n = 200 x1 = np.sort(rng.uniform(0, 2 * np.pi, n)) x2 = rng.normal(0, 1, n) lam = np.exp(1.0 * np.sin(x1) + 0.3 * x2) y = rng.poisson(lam).astype(float) # Plant an outlier y[100] = 150.0 data = {"x1": x1, "x2": x2, "y": y} model = wk.GAM("y ~ s(x1) + s(x2)", family=Poisson()).fit(data) ``` ## Influence diagnostics `model.influence()` computes two observation-level measures: - **Hat values** (leverage): the diagonal of the smoothing matrix $\mathbf{H}$. Observations with high leverage have outsized influence on the fitted curve because they sit in sparse regions of the covariate space or near the boundaries. - **Cook's distance**: a combined measure of leverage and residual size. A large Cook's distance means that removing the observation would substantially change the fitted model. ```{python} infl = model.influence() print(f"Hat values shape: {infl.hat_values.shape}") print(f"Cook's distance shape: {infl.cooks_distance.shape}") ``` ### Identifying influential observations A common rule of thumb is to flag observations with Cook's distance greater than $4/n$. Let's see which observations stand out: ```{python} threshold = 4.0 / n flagged = np.where(infl.cooks_distance > threshold)[0] print(f"Flagged {len(flagged)} observations (Cook's D > {threshold:.4f})") print(f"Top 5 by Cook's distance:") top5 = np.argsort(infl.cooks_distance)[-5:][::-1] for idx in top5: print(f" obs {idx}: Cook's D = {infl.cooks_distance[idx]:.4f}, " f"hat = {infl.hat_values[idx]:.4f}, y = {y[idx]:.0f}") ``` The planted outlier at observation 100 should appear prominently. In practice, you would investigate flagged observations to decide whether they are genuine data points or errors. ### Visualizing influence ```{python} import altair as alt infl_data = [ {"index": int(i), "cooks_d": float(infl.cooks_distance[i]), "hat": float(infl.hat_values[i]), "flagged": bool(infl.cooks_distance[i] > threshold)} for i in range(n) ] alt.Chart({"values": infl_data}).mark_circle(size=30).encode( x=alt.X("hat:Q", title="Hat value (leverage)"), y=alt.Y("cooks_d:Q", title="Cook's distance"), color=alt.condition( alt.datum.flagged, alt.value("firebrick"), alt.value("steelblue"), ), opacity=alt.condition(alt.datum.flagged, alt.value(1.0), alt.value(0.4)), ).properties( width=500, height=300, title="Influence diagnostics: leverage vs. Cook's distance" ) ``` Points in the upper-right corner have both high leverage and a large residual (these are the most influential observations). Red points exceed the $4/n$ threshold. ## Concurvity Concurvity is the GAM analogue of collinearity. It measures how well each smooth term can be approximated by the other terms in the model. If two smooths are near-confounded (concurvity close to 1), their individual estimates are unreliable, even though the overall model fit may be fine. ```{python} conc = model.concurvity() print(f"Smooth terms: {conc.labels}") print(f"Worst-case concurvity: {conc.worst}") print(f"Observed concurvity: {conc.observed}") print(f"Estimated concurvity: {conc.estimate}") ``` Three measures are reported: - **`worst`**: the upper bound on concurvity, based on the basis function spaces. This asks: *"in the worst case, how much of this smooth's flexibility could be absorbed by the rest of the model?"* - **`observed`**: concurvity of the actual fitted smooth. This is usually lower than the worst case because the data do not fully exploit the overlapping basis functions. - **`estimate`**: an $R^2$-style measure of how well the fitted smooth can be predicted from the other terms. Values above 0.8 are a concern. Values above 0.9 are a strong warning that the smooth estimates may be unstable. ### Pairwise concurvity Pass `full=False` to see which specific pairs of smooths are confounded: ```{python} conc_pair = model.concurvity(full=False) print(f"Pairwise worst-case concurvity:") for i, label_i in enumerate(conc_pair.labels): for j, label_j in enumerate(conc_pair.labels): if i < j: print(f" {label_i} vs {label_j}: {conc_pair.worst[i, j]:.3f}") ``` When pairwise concurvity is high between two specific smooths, consider whether one of them is redundant, or whether a shared tensor product `te(x1, x2)` would be a better model structure. ## Dispersion test For Poisson and Binomial models, the scale parameter is fixed at 1. If the data exhibit more variability than the model assumes (overdispersion), standard errors and p-values will be too small. The dispersion test checks this by comparing the Pearson chi-squared statistic to its expected value under the null of no overdispersion. ```{python} disp = model.dispersion_test() print(f"Estimated dispersion: {disp.dispersion:.2f}") print(f"Chi-squared stat: {disp.chi2_stat:.1f}") print(f"p-value: {disp.p_value:.4g}") ``` A dispersion ratio substantially above 1 indicates overdispersion. If the p-value is significant, consider switching to a `NegativeBinomial` family (for count data) or using quasi-likelihood adjustments. ::: {.callout-tip} ## What to do about overdispersion Our test data includes a planted outlier, which inflates the dispersion estimate. In practice, you should first investigate influential observations (see above). If overdispersion persists after removing genuine outliers, switch to a family that handles it: `NegativeBinomial()` for counts, or `Gamma()` for positive continuous data with increasing variance. ::: ## Quantile residuals For non-Gaussian families, deviance residuals may not be approximately normal even when the model is correct. Randomized quantile residuals transform the residuals so that, under the correct model, they *are* standard normal (regardless of the family). ```{python} qr = model.quantile_residuals(seed=23) print(f"Mean: {qr.mean():.4f}") print(f"Std: {qr.std():.4f}") print(f"Shape: {qr.shape}") ``` For a correctly specified Poisson model, the quantile residuals should look like a sample from $N(0, 1)$. Large departures (especially in the tails) point to misspecification. Quantile residuals are more reliable than deviance residuals for discrete families, where the discrete probability mass creates artifacts in the QQ plot. ```{python} sorted_qr = np.sort(qr) n_qr = len(sorted_qr) theoretical = np.quantile( rng.normal(0, 1, 10000), np.linspace(0.5 / n_qr, 1 - 0.5 / n_qr, n_qr), ) qq_data = [ {"theoretical": float(theoretical[i]), "sample": float(sorted_qr[i])} for i in range(n_qr) ] ref_min = min(theoretical.min(), sorted_qr.min()) ref_max = max(theoretical.max(), sorted_qr.max()) ref_data = [{"x": float(ref_min), "y": float(ref_min)}, {"x": float(ref_max), "y": float(ref_max)}] qq_points = alt.Chart({"values": qq_data}).mark_circle( size=15, opacity=0.4, color="steelblue" ).encode(x=alt.X("theoretical:Q", title="Theoretical quantiles"), y=alt.Y("sample:Q", title="Quantile residuals")) ref_line = alt.Chart({"values": ref_data}).mark_line( color="firebrick", strokeDash=[4, 4] ).encode(x="x:Q", y="y:Q") (qq_points + ref_line).properties( width=400, height=400, title="QQ plot of quantile residuals" ) ``` ## Variance inflation factors For models with multiple parametric (linear) terms, `model.vif()` computes variance inflation factors to check for collinearity among the parametric predictors. VIF values above 5--10 indicate problematic collinearity. ```{python} model_vif = wk.GAM("y ~ x1 + x2 + s(x1)", family=Poisson()).fit(data) vif_results = model_vif.vif() for v in vif_results: print(f" {v.term}: VIF = {v.vif:.2f}") ``` VIF only applies to the parametric (linear) terms, not to the smooths. For smooth-smooth confounding, use `concurvity()` instead. ## Diagnostic summary | Tool | What it checks | When to use | |------|---------------|-------------| | `influence()` | Observations driving the fit | After fitting, before interpreting | | `concurvity()` | Smooth-smooth confounding | Models with 2+ smooth terms | | `dispersion_test()` | Overdispersion | Poisson / Binomial families | | `quantile_residuals()` | Distributional assumptions | Non-Gaussian families | | `vif()` | Parametric collinearity | Models with 2+ linear terms | ## Where to go next - **[Model diagnostics](11-diagnostics.qmd)**: the essential diagnostic workflow (summary, check, residual plots). - **[Derivatives and marginal effects](34-derivatives.qmd)**: interpret smooth shapes after diagnosing the model. - **[ANOVA for GAMs](16-anova.qmd)**: formal deviance-difference tests for nested models. - **[Response families](05-families.qmd)**: choosing a family that matches the data-generating process when diagnostics reveal distributional problems. ## Model selection ### Cross-validation ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Smoothing parameter selection (GCV, REML) optimises within a single fit, but cross-validation answers a different question: **how well does the model generalise to unseen data?** Use it to compare formulas, families, or basis configurations on the same dataset. Whittaker provides `cross_validate()`, which performs $K$-fold cross-validation on a GAM specification and returns a `CVResult` with the mean score, per-fold scores, and a standard error. ## Basic usage ```{python} import numpy as np import whittaker as wk # Generate data with a known signal rng = np.random.default_rng(23) n = 400 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + 0.5 * np.sin(3 * x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} # 10-fold cross-validation (the default) result = wk.cross_validate("y ~ s(x)", data, method="REML") print(f"CV score (deviance): {result.cv_score:.4f}") print(f"SE of CV score: {result.cv_se:.4f}") print(f"Number of folds: {result.n_folds}") ``` The `cv_score` is the mean out-of-sample loss across folds. Lower is better. The `cv_se` gives the standard error of this mean, which is useful for comparing models: two models whose scores differ by less than one standard error are essentially equivalent. ## Comparing models Cross-validation is most useful for comparing competing specifications. For example, how many basis functions does the smooth need? ```{python} # Compare different basis dimensions results = {} for k in [5, 10, 15, 20, 30]: cv = wk.cross_validate(f"y ~ s(x, k={k})", data, method="REML", seed=23) results[k] = cv for k, cv in results.items(): print(f"k={k:2d}: CV = {cv.cv_score:.4f} (SE = {cv.cv_se:.4f})") ``` ```{python} import altair as alt # Plot CV scores with error bars plot_data = [ {"k": k, "cv_score": float(cv.cv_score), "lower": float(cv.cv_score - cv.cv_se), "upper": float(cv.cv_score + cv.cv_se)} for k, cv in results.items() ] points = alt.Chart({"values": plot_data}).mark_point(size=60, color="steelblue").encode( x=alt.X("k:Q", title="Basis dimension k", scale=alt.Scale(domain=[3, 32])), y=alt.Y("cv_score:Q", title="CV score (deviance)"), ) errorbars = alt.Chart({"values": plot_data}).mark_rule(color="steelblue").encode( x="k:Q", y="lower:Q", y2="upper:Q", ) (points + errorbars).properties( width="container", height=280, title="Cross-validation score by basis dimension" ) ``` The CV score drops as `k` increases from 5 to around 15, then levels off. Since REML penalises away unnecessary complexity, larger `k` values do not overfit, but the CV score confirms that 15 basis functions are sufficient for this signal. ## Comparing families Cross-validation also helps choose between response distributions. Here we simulate count data and compare Poisson and Gaussian fits: ```{python} # Count data rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2, n) mu = np.exp(0.5 + 0.8 * np.sin(2 * np.pi * x)) y_counts = rng.poisson(mu).astype(float) count_data = {"x": x, "y": y_counts} # Compare families cv_gauss = wk.cross_validate("y ~ s(x)", count_data, method="REML", seed=23) cv_pois = wk.cross_validate("y ~ s(x)", count_data, family=wk.Poisson(), method="REML", seed=23) print(f"Gaussian CV: {cv_gauss.cv_score:.4f} (SE = {cv_gauss.cv_se:.4f})") print(f"Poisson CV: {cv_pois.cv_score:.4f} (SE = {cv_pois.cv_se:.4f})") ``` ::: {.callout-note} ## Comparing scores across families The `"deviance"` metric uses each family's own deviance, so scores from different families are not directly comparable on the same scale. Switch to `metric="mse"` for an apples-to-apples comparison on the response scale: ```{python} cv_gauss_mse = wk.cross_validate( "y ~ s(x)", count_data, method="REML", metric="mse", seed=23 ) cv_pois_mse = wk.cross_validate( "y ~ s(x)", count_data, family=wk.Poisson(), method="REML", metric="mse", seed=23 ) print(f"Gaussian MSE: {cv_gauss_mse.cv_score:.4f}") print(f"Poisson MSE: {cv_pois_mse.cv_score:.4f}") ``` ::: ## Per-fold scores The `cv_scores` array gives the loss for each fold, which is useful for checking whether a single fold is driving the overall score: ```{python} result = wk.cross_validate("y ~ s(x)", data, method="REML", seed=23) print(f"Per-fold scores: {result.cv_scores.round(4)}") print(f"Mean: {result.cv_scores.mean():.4f}") print(f"Std: {result.cv_scores.std():.4f}") ``` ```{python} # Plot per-fold scores fold_data = [ {"fold": i + 1, "score": float(s)} for i, s in enumerate(result.cv_scores) ] bars = alt.Chart({"values": fold_data}).mark_bar(color="steelblue", opacity=0.7).encode( x=alt.X("fold:O", title="Fold"), y=alt.Y("score:Q", title="Fold score (deviance)"), ) mean_line = alt.Chart({"values": [{"y": float(result.cv_score)}]}).mark_rule( color="firebrick", strokeDash=[4, 4], strokeWidth=1.5 ).encode(y="y:Q") (bars + mean_line).properties( width="container", height=250, title="Per-fold CV scores (red line = mean)" ) ``` ## Choosing the number of folds ```{python} # Compare 5-fold vs 10-fold vs leave-one-out-ish (n_folds=n) for k in [5, 10, 20]: cv = wk.cross_validate("y ~ s(x)", data, n_folds=k, method="REML", seed=23) print(f"{k:2d}-fold: CV = {cv.cv_score:.4f} (SE = {cv.cv_se:.4f})") ``` ::: {.callout-tip} ## How many folds? - **5-fold**: faster, slightly higher bias, lower variance. Good for large datasets. - **10-fold** (default): a good balance for most datasets. - **20-fold or more**: lower bias but higher variance and slower. Useful for small datasets where you want to use as much training data as possible per fold. ::: ## Smooth selection via cross-validation The `select=True` option enables double-penalty smooth selection, which can shrink entire smooth terms to zero. Cross-validation can verify whether this helps: ```{python} # Restore the original data from the first example rng = np.random.default_rng(23) n = 400 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + 0.5 * np.sin(3 * x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} # Add a noise variable that should be selected out noise = rng.uniform(0, 1, n) data_noise = {"x": x, "noise": noise, "y": y} cv_no_select = wk.cross_validate( "y ~ s(x) + s(noise)", data_noise, method="REML", seed=23 ) cv_select = wk.cross_validate( "y ~ s(x) + s(noise)", data_noise, method="REML", select=True, seed=23 ) print(f"Without selection: {cv_no_select.cv_score:.4f}") print(f"With selection: {cv_select.cv_score:.4f}") ``` ## Reproducibility Pass `seed` to get reproducible fold assignments: ```{python} cv1 = wk.cross_validate("y ~ s(x)", data, method="REML", seed=123) cv2 = wk.cross_validate("y ~ s(x)", data, method="REML", seed=123) print(f"Same seed, same result: {cv1.cv_score == cv2.cv_score}") ``` Without a seed, the fold assignment is randomised on each call. ## The CVResult object `CVResult` is a simple dataclass with four fields: | Field | Type | Description | |-------|------|-------------| | `cv_score` | `float` | Mean out-of-sample loss across folds | | `cv_scores` | `NDArray` | Per-fold loss values | | `cv_se` | `float` | Standard error of the mean score | | `n_folds` | `int` | Number of folds | ## Practical workflow A typical model-selection workflow combines cross-validation with REML fitting: 1. **Candidate models**: vary the formula, basis dimension, or family 2. **Cross-validate each**: use the same `seed` so folds are identical 3. **Compare scores**: pick the model with the lowest CV score, or the simplest model within one SE of the best 4. **Final fit**: refit the chosen model on all the data ```{python} # Step 1-3: compare candidates candidates = { "s(x, k=5)": "y ~ s(x, k=5)", "s(x, k=10)": "y ~ s(x, k=10)", "s(x, k=20)": "y ~ s(x, k=20)", "s(x) + s(noise)": "y ~ s(x) + s(noise)", } best_name, best_score = None, float("inf") for name, formula in candidates.items(): cv = wk.cross_validate(formula, data_noise, method="REML", seed=23) flag = "" if cv.cv_score < best_score: best_score = cv.cv_score best_name = name flag = " <-- best" print(f" {name:20s}: {cv.cv_score:.4f} (SE {cv.cv_se:.4f}){flag}") # Step 4: refit the winner on all data print(f"\nBest model: {best_name}") final = wk.GAM(candidates[best_name]) final.fit(data_noise, method="REML") print(f"Final EDF: {final.edf_total:.1f}") ``` ::: {.callout-tip} ## The one-SE rule A common heuristic: instead of picking the model with the lowest CV score, pick the simplest model whose score is within one standard error of the best. This guards against overfitting to the validation folds and tends to produce more parsimonious models. ::: You can now use K-fold cross-validation to compare models, select basis dimensions, and apply the one-SE rule for parsimonious model selection. ## Where to go next - **[Model comparison with LOO](28-loo.qmd)**: PSIS-LOO cross-validation for Bayesian model comparison without refitting. - **[Model diagnostics](11-diagnostics.qmd)**: residual checks and basis adequacy tests as a complement to cross-validation. - **[Model fitting](06-fitting.qmd)**: how smoothness selection (REML, GCV) relates to cross-validation. ### Model comparison with compare() ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` When you have several candidate models for the same data, you need a quick way to line them up and see which one fits best. The `compare()` function collects AIC, BIC, deviance explained, adjusted R-squared, EDF, and GCV from each model and presents them in a single sorted table. ## Basic usage Fit two or more models on the same data, then pass them all to `compare()`: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 200 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} m_linear = wk.GAM("y ~ x").fit(data) m_smooth = wk.GAM("y ~ s(x, k=5)").fit(data) m_flex = wk.GAM("y ~ s(x, k=15)").fit(data) result = wk.compare(m_linear, m_smooth, m_flex) print(result) ``` Models are sorted by AIC (lowest first). The **ΔAIC** column shows how far each model is from the best: a ΔAIC of 0 marks the winner, and values above ~10 indicate models with essentially no empirical support relative to the best. ## Accessing individual rows The result is a `ComparisonResult` containing a list of `ComparisonRow` objects. You can index into it or use the `.best` property: ```{python} best = result.best print(f"Best model: {best.label}") print(f" AIC: {best.aic:.2f}") print(f" BIC: {best.bic:.2f}") print(f" Dev. explained: {best.deviance_explained:.1%}") print(f" Adj. R²: {best.r_squared_adj:.4f}") print(f" EDF: {best.edf_total:.1f}") ``` Each row also carries `gcv_score` (or `None` for Bayesian fits), `scale`, and `n_obs`. ## Comparing Bayesian fits `compare()` works with VI-fitted models too. Since GCV is not available for Bayesian fits, that column is omitted from the table: ```{python} m_vi1 = wk.GAM("y ~ s(x, k=5)").fit(data, method="VI") m_vi2 = wk.GAM("y ~ s(x, k=15)").fit(data, method="VI") print(wk.compare(m_vi1, m_vi2)) ``` ## Non-Gaussian models The same interface works for any response family. Here we compare Poisson models: ```{python} from whittaker.families.poisson import Poisson x_p = np.linspace(0, 3, 150) y_p = rng.poisson(np.exp(0.5 * np.sin(x_p))).astype(float) pois_data = {"x": x_p, "y": y_p} p1 = wk.GAM("y ~ x", family=Poisson()).fit(pois_data) p2 = wk.GAM("y ~ s(x)", family=Poisson()).fit(pois_data) print(wk.compare(p1, p2)) ``` ## When to use compare() `compare()` is most useful when you have a small set of candidate models and want a quick side-by-side summary. It is *not* a formal hypothesis test. For that, see [ANOVA for GAMs](16-anova.qmd), which performs sequential deviance-difference tests between nested models. Use `compare()` for: - **Model selection**: choosing among several candidate formulas or basis dimensions. - **Quick screening**: narrowing down a large set of models before deeper analysis. - **Reporting**: producing a tidy summary table for a paper or presentation. ::: {.callout-tip} ## Combining with other tools - Use [smoothing parameter sensitivity](33-sensitivity.qmd) to check whether the winning model's conclusions are robust to the choice of smoothing parameters. - Use [cross-validation](14-cross-validation.qmd) for out-of-sample prediction accuracy, which `compare()` does not assess. - For Bayesian model comparison, see [LOO](28-loo.qmd), [WAIC](29-waic.qmd), and [stacking](32-stacking.qmd). ::: ## Where to go next - **[Model diagnostics](11-diagnostics.qmd)**: residual analysis and goodness-of-fit metrics for individual models. - **[ANOVA for GAMs](16-anova.qmd)**: formal deviance-difference tests for nested model comparison. - **[Cross-validation](14-cross-validation.qmd)**: out-of-sample evaluation with K-fold CV. - **[Model comparison with LOO](28-loo.qmd)**: leave-one-out cross-validation for Bayesian models. ### ANOVA for GAMs ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` When you have two or more nested models (a simpler model that is a special case of a more complex one) you can test whether the additional complexity is justified by comparing their deviances. The `anova()` method performs sequential deviance-difference tests. This is different from information-criterion comparisons (AIC, BIC) or predictive criteria (LOO, WAIC). Those methods do not require the models to be nested and estimate out-of-sample predictive accuracy. ANOVA tests a null hypothesis ("does the more complex model explain significantly more deviance?") using the sampling distribution of the deviance difference. ## When to use ANOVA Use `model.anova()` when: - you have a clear nesting hierarchy (e.g., a linear model vs. a smooth model, or a model with fewer terms vs. one with more) - you want a formal p-value for whether the extra terms are needed - the models use the same family and are fitted to the same data For non-nested models, use `loo_compare()`, `waic_compare()`, or `stacking()` instead. ## Basic usage Fit two or more models and call `anova()` on any one of them, passing the others as arguments. The models are automatically sorted from simplest to most complex. ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + 0.3 * np.cos(3 * x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} # Model 1: intercept only (null model) m_null = wk.GAM("y ~ 1").fit(data) # Model 2: linear effect m_linear = wk.GAM("y ~ x").fit(data) # Model 3: smooth effect with moderate flexibility m_smooth = wk.GAM("y ~ s(x, k=10)").fit(data) # Model 4: smooth effect with high flexibility m_flex = wk.GAM("y ~ s(x, k=20)").fit(data) ``` ```{python} result = m_null.anova(m_linear, m_smooth, m_flex) print(result) ``` ## Reading the ANOVA table The table has one row per model, sorted from simplest (fewest EDF) to most complex. For each successive pair of models: - **Df**: the difference in effective degrees of freedom between the two models. This measures the additional complexity. - **Deviance**: the reduction in deviance from the simpler to the more complex model. - **Statistic**: an F-statistic (for unknown-scale families like Gaussian and Gamma) or chi-squared statistic (for known-scale families like Poisson and Binomial). - **p-value**: the probability of seeing this large a deviance reduction by chance, under the null that the simpler model is adequate. A small p-value means the more complex model is significantly better. ```{python} # Access individual rows for i, row in enumerate(result.rows): p_str = f"{row.p_value:.4g}" if row.p_value is not None else "---" print(f"Model {i+1}: Resid.Df={row.resid_df:.1f}, " f"Resid.Dev={row.resid_dev:.2f}, p={p_str}") ``` ## Interpreting the results In the example above, each step adds complexity: 1. **Null → Linear**: tests whether `x` has any linear effect on `y`. A significant p-value means a linear trend is present. 2. **Linear → Smooth (k=10)**: tests whether the relationship is nonlinear. A significant p-value means the smooth captures structure that a straight line misses. 3. **Smooth (k=10) → Smooth (k=20)**: tests whether extra flexibility is needed. If the p-value is not significant, the simpler smooth is adequate (the additional basis functions are being penalized away). This sequential testing workflow is the standard approach for building up a GAM: start simple, add complexity, and stop when additional terms no longer yield significant deviance reductions. ## Poisson example with chi-squared tests For known-scale families, the test uses a chi-squared distribution instead of an F distribution. ```{python} from whittaker.families.poisson import Poisson rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) lam = np.exp(0.8 * np.sin(x)) y_pois = rng.poisson(lam).astype(float) pois_data = {"x": x, "y": y_pois} m1 = wk.GAM("y ~ 1", family=Poisson()).fit(pois_data) m2 = wk.GAM("y ~ x", family=Poisson()).fit(pois_data) m3 = wk.GAM("y ~ s(x)", family=Poisson()).fit(pois_data) result_pois = m1.anova(m2, m3) print(result_pois) ``` The test type is shown in the result: ```{python} print(f"Test type: {result_pois.test}") print(f"Scale used: {result_pois.scale}") ``` ## Two-model comparison `anova()` works with just two models for a simple A vs. B test: ```{python} result_ab = m_linear.anova(m_smooth) print(result_ab) ``` This is the most common use case: you have a model and want to know whether adding a smooth term (or extra covariates) is justified. ## Requirements and limitations - **Same family**: all models must use the same response family. Comparing a Gaussian model against a Poisson model is not meaningful. - **Same data**: all models must be fitted to the same observations. The method checks this and raises an error if the observation counts differ. - **Nesting**: the test is valid when models are nested (each simpler model is a special case of the more complex one). For non-nested models, the p-values are approximate at best. - **Frequentist fits only**: `anova()` requires frequentist fits (GCV, REML, or ML). For Bayesian model comparison, use `loo_compare()`, `waic_compare()`, or `stacking()`. ## Where to go next - **[Model comparison with LOO](28-loo.qmd)**: PSIS-LOO for non-nested model comparison. - **[Model comparison with WAIC](29-waic.qmd)**: WAIC as an alternative to LOO. - **[Model averaging with stacking](32-stacking.qmd)**: combine multiple models instead of choosing one. - **[Model fitting](06-fitting.qmd)**: how smoothness selection criteria (REML, GCV, ML) affect the models being compared. - **[Derivatives and marginal effects](34-derivatives.qmd)**: interpret the smooth effects once you have settled on a model. ## Advanced features ### Shape constraints ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Shape-constrained smooths let you incorporate domain knowledge directly into the model. When theory or experience tells you that a relationship must be monotone, convex, or concave, enforcing that constraint prevents the model from fitting spurious wiggles that violate known behavior. This page covers every shape-constrained basis in Whittaker, with worked examples and guidance on when each one is appropriate. ## Why shape constraints matter An unconstrained smooth estimates any shape the data supports. That flexibility is a strength when you have no prior knowledge, but it becomes a liability when it produces fits that contradict established theory. Common situations where constraints help: - **Dose-response curves**: higher doses should not produce lower responses (monotone increasing). - **Decay processes**: concentration or activity decreases over time (monotone decreasing). - **Economies of scale**: average cost decreases then levels off, forming a convex curve. - **Diminishing returns**: each additional unit of input produces less additional output (concave). - **Age effects in growth**: height increases with age in children (monotone increasing). - **Calibration functions**: instrument readings should increase with the true value. Without a constraint, an unconstrained smooth may oscillate in regions where data are sparse or noisy, producing a fit that violates domain knowledge and reduces interpretability. A shape constraint eliminates these artifacts without forcing a specific parametric form---the smooth is still flexible within the constraint. ::: {.callout-tip} ## Constraints vs. parametric models Shape constraints offer a middle ground between fully parametric models (e.g., fitting a logistic curve) and unconstrained smooths. You get the flexibility of a nonparametric fit with the guarantee that the estimated function respects the qualitative behavior you expect. ::: ## Monotone increasing smooths: `bs="mpi"` {#monotone-increasing} A monotone increasing P-spline constrains $f(x)$ so that $f(x_1) \le f(x_2)$ whenever $x_1 < x_2$. The smooth can still curve, accelerate, or decelerate---it just cannot decrease. Mathematically, the monotone increasing smooth is constructed from a standard P-spline basis with coefficients $\beta_k$ constrained so that $\beta_1 \le \beta_2 \le \cdots \le \beta_K$. This ordering is enforced after each PIRLS (penalized iteratively re-weighted least squares) iteration using the **pool adjacent violators algorithm** (PAVA), which projects the coefficient vector onto the monotone cone. ### Example: dose-response data A pharmacological experiment measures the response to increasing doses of a drug. Theory dictates that the response should not decrease as the dose rises. ```{python} import numpy as np import whittaker as wk import altair as alt # Simulate a dose-response curve (Emax-like shape with noise) rng = np.random.default_rng(23) n = 150 dose = np.sort(rng.uniform(0, 10, n)) # True response: saturating Emax curve true_response = 100 * dose / (2 + dose) y = true_response + rng.normal(0, 8, n) data = {"dose": dose, "y": y} # Fit a monotone increasing smooth model_mpi = wk.GAM("y ~ s(dose, bs='mpi', k=15)") model_mpi.fit(data, method="REML") # Fit an unconstrained smooth for comparison model_free = wk.GAM("y ~ s(dose, k=15)") model_free.fit(data, method="REML") print("=== Monotone increasing model ===") print(model_mpi.summary()) ``` ```{python} # Predict from both models on a fine grid dose_grid = np.linspace(0, 10, 300) preds_mpi = model_mpi.predict({"dose": dose_grid}) preds_free = model_free.predict({"dose": dose_grid}) # Build data for the comparison plot obs_data = [ {"dose": float(dose[i]), "response": float(y[i])} for i in range(n) ] fit_data = [ {"dose": float(dose_grid[i]), "fit": float(preds_mpi.values[i]), "model": "Monotone (mpi)"} for i in range(len(dose_grid)) ] + [ {"dose": float(dose_grid[i]), "fit": float(preds_free.values[i]), "model": "Unconstrained (tp)"} for i in range(len(dose_grid)) ] # Observed points points = alt.Chart({"values": obs_data}).mark_circle( size=20, opacity=0.3, color="steelblue" ).encode( x=alt.X("dose:Q", title="Dose"), y=alt.Y("response:Q", title="Response"), ) # Fitted curves from both models lines = alt.Chart({"values": fit_data}).mark_line(strokeWidth=2).encode( x=alt.X("dose:Q", title="Dose"), y=alt.Y("fit:Q", title="Response"), color=alt.Color("model:N", title="Smooth type"), ) (points + lines).properties( width="container", height=320, title="Dose-response: monotone increasing vs. unconstrained" ) ``` Both fits track the saturating response, but the unconstrained smooth may dip slightly in sparse regions. The monotone constraint guarantees a non-decreasing fit, which is the scientifically credible result for a dose-response relationship. ## Monotone decreasing smooths: `bs="mpd"` {#monotone-decreasing} The monotone decreasing basis is the mirror image of `bs="mpi"`: it constrains $f(x_1) \ge f(x_2)$ whenever $x_1 < x_2$. This is enforced by requiring $\beta_1 \ge \beta_2 \ge \cdots \ge \beta_K$. ### Example: radioactive decay The activity of a radioactive sample decreases over time. An unconstrained smooth might show small increases due to measurement noise, but the true process is strictly decreasing. ```{python} # Simulate exponential decay with noise rng = np.random.default_rng(23) n = 120 time = np.sort(rng.uniform(0, 10, n)) # True decay: A(t) = 100 * exp(-0.3 * t) true_activity = 100 * np.exp(-0.3 * time) y_decay = true_activity + rng.normal(0, 5, n) data_decay = {"time": time, "y": y_decay} # Fit monotone decreasing smooth model_mpd = wk.GAM("y ~ s(time, bs='mpd', k=12)") model_mpd.fit(data_decay, method="REML") # Fit unconstrained smooth for comparison model_free_decay = wk.GAM("y ~ s(time, k=12)") model_free_decay.fit(data_decay, method="REML") print("=== Monotone decreasing model ===") print(model_mpd.summary()) ``` ```{python} # Predict and plot time_grid = np.linspace(0, 10, 300) preds_mpd = model_mpd.predict({"time": time_grid}) preds_free_decay = model_free_decay.predict({"time": time_grid}) obs_data = [ {"time": float(time[i]), "activity": float(y_decay[i])} for i in range(n) ] fit_data = [ {"time": float(time_grid[i]), "fit": float(preds_mpd.values[i]), "model": "Monotone decreasing (mpd)"} for i in range(len(time_grid)) ] + [ {"time": float(time_grid[i]), "fit": float(preds_free_decay.values[i]), "model": "Unconstrained (tp)"} for i in range(len(time_grid)) ] points = alt.Chart({"values": obs_data}).mark_circle( size=20, opacity=0.3, color="steelblue" ).encode( x=alt.X("time:Q", title="Time"), y=alt.Y("activity:Q", title="Activity"), ) lines = alt.Chart({"values": fit_data}).mark_line(strokeWidth=2).encode( x=alt.X("time:Q", title="Time"), y=alt.Y("fit:Q", title="Activity"), color=alt.Color("model:N", title="Smooth type"), ) (points + lines).properties( width="container", height=320, title="Radioactive decay: monotone decreasing vs. unconstrained" ) ``` The constrained smooth enforces the physically required monotone decrease. Any apparent upticks in the unconstrained fit are noise artifacts that the constraint eliminates. ## Convex smooths: `bs="cx"` {#convex} A convex smooth constrains $f$ so that the second derivative is non-negative everywhere: $f''(x) \ge 0$. Geometrically, the curve always bends upward---it can be flat or U-shaped, but it cannot have a local maximum. The constraint is enforced by requiring the second-order differences of the coefficients to be non-negative: $\Delta^2 \beta_k = \beta_{k+2} - 2\beta_{k+1} + \beta_k \ge 0$. This is implemented by projecting the cumulative sum of the second differences onto the non-negative orthant after each PIRLS iteration. ### Example: U-shaped cost curve Average cost per unit typically decreases at low production levels (spreading fixed costs) and increases at high levels (diminishing returns to scale), producing a convex function. ```{python} # Simulate a U-shaped average cost curve rng = np.random.default_rng(23) n = 180 quantity = np.sort(rng.uniform(1, 20, n)) # True cost: quadratic with minimum around q=10 true_cost = 0.5 * (quantity - 10) ** 2 + 20 y_cost = true_cost + rng.normal(0, 3, n) data_cost = {"quantity": quantity, "y": y_cost} # Fit convex smooth model_cx = wk.GAM("y ~ s(quantity, bs='cx', k=12)") model_cx.fit(data_cost, method="REML") # Fit unconstrained smooth model_free_cost = wk.GAM("y ~ s(quantity, k=12)") model_free_cost.fit(data_cost, method="REML") print("=== Convex model ===") print(model_cx.summary()) ``` ```{python} # Predict and plot q_grid = np.linspace(1, 20, 300) preds_cx = model_cx.predict({"quantity": q_grid}) preds_free_cost = model_free_cost.predict({"quantity": q_grid}) obs_data = [ {"quantity": float(quantity[i]), "cost": float(y_cost[i])} for i in range(n) ] fit_data = [ {"quantity": float(q_grid[i]), "fit": float(preds_cx.values[i]), "model": "Convex (cx)"} for i in range(len(q_grid)) ] + [ {"quantity": float(q_grid[i]), "fit": float(preds_free_cost.values[i]), "model": "Unconstrained (tp)"} for i in range(len(q_grid)) ] points = alt.Chart({"values": obs_data}).mark_circle( size=20, opacity=0.3, color="steelblue" ).encode( x=alt.X("quantity:Q", title="Production quantity"), y=alt.Y("cost:Q", title="Average cost"), ) lines = alt.Chart({"values": fit_data}).mark_line(strokeWidth=2).encode( x=alt.X("quantity:Q", title="Production quantity"), y=alt.Y("fit:Q", title="Average cost"), color=alt.Color("model:N", title="Smooth type"), ) (points + lines).properties( width="container", height=320, title="Average cost curve: convex vs. unconstrained" ) ``` The convex constraint ensures the fitted curve has no local maxima, producing the U-shape expected from economic theory. ## Concave smooths: `bs="cv"` {#concave} A concave smooth constrains $f''(x) \le 0$: the curve always bends downward. It can be flat, rise with decreasing slope, or decline with increasing slope, but it cannot have a local minimum. ### Example: diminishing returns to fertilizer Crop yield increases with fertilizer application, but each additional unit produces less additional yield. The relationship is concave. ```{python} # Simulate diminishing returns rng = np.random.default_rng(23) n = 160 fertilizer = np.sort(rng.uniform(0, 100, n)) # True yield: square root relationship (concave) true_yield = 10 * np.sqrt(fertilizer) + 5 y_yield = true_yield + rng.normal(0, 4, n) data_yield = {"fertilizer": fertilizer, "y": y_yield} # Fit concave smooth model_cv = wk.GAM("y ~ s(fertilizer, bs='cv', k=12)") model_cv.fit(data_yield, method="REML") # Fit unconstrained smooth model_free_yield = wk.GAM("y ~ s(fertilizer, k=12)") model_free_yield.fit(data_yield, method="REML") print("=== Concave model ===") print(model_cv.summary()) ``` ```{python} # Predict and plot fert_grid = np.linspace(0, 100, 300) preds_cv = model_cv.predict({"fertilizer": fert_grid}) preds_free_yield = model_free_yield.predict({"fertilizer": fert_grid}) obs_data = [ {"fertilizer": float(fertilizer[i]), "yield": float(y_yield[i])} for i in range(n) ] fit_data = [ {"fertilizer": float(fert_grid[i]), "fit": float(preds_cv.values[i]), "model": "Concave (cv)"} for i in range(len(fert_grid)) ] + [ {"fertilizer": float(fert_grid[i]), "fit": float(preds_free_yield.values[i]), "model": "Unconstrained (tp)"} for i in range(len(fert_grid)) ] points = alt.Chart({"values": obs_data}).mark_circle( size=20, opacity=0.3, color="steelblue" ).encode( x=alt.X("fertilizer:Q", title="Fertilizer (kg/ha)"), y=alt.Y("yield:Q", title="Yield (tonnes/ha)"), ) lines = alt.Chart({"values": fit_data}).mark_line(strokeWidth=2).encode( x=alt.X("fertilizer:Q", title="Fertilizer (kg/ha)"), y=alt.Y("fit:Q", title="Yield (tonnes/ha)"), color=alt.Color("model:N", title="Smooth type"), ) (points + lines).properties( width="container", height=320, title="Diminishing returns: concave vs. unconstrained" ) ``` The concave constraint ensures the fitted curve never accelerates upward, matching the agronomic expectation that marginal returns diminish. ## Combining constraints in one model {#combining} Different predictors in the same model can have different shape constraints. Whittaker handles this naturally: each smooth term's constraint is applied independently during the PIRLS iterations. ### Example: drug efficacy depends on dose (monotone) and temperature (convex) Consider an experiment where drug efficacy increases monotonically with dose, while the degradation rate follows a convex function of storage temperature (faster degradation at both very low and very high temperatures). ```{python} # Simulate two-predictor model with different constraints rng = np.random.default_rng(23) n = 250 dose = np.sort(rng.uniform(0, 10, n)) temperature = rng.uniform(5, 45, n) # True relationship: # efficacy increases monotonically with dose (log-like) # degradation is convex in temperature (U-shaped around 25C) true_efficacy = 20 * np.log1p(dose) - 0.02 * (temperature - 25) ** 2 y_eff = true_efficacy + rng.normal(0, 3, n) data_combined = {"dose": dose, "temperature": temperature, "y": y_eff} # Fit model with monotone increasing dose and convex temperature model_combined = wk.GAM("y ~ s(dose, bs='mpi', k=10) + s(temperature, bs='cx', k=10)") model_combined.fit(data_combined, method="REML") print("=== Combined constraints model ===") print(model_combined.summary()) ``` ```{python} # Visualize the dose effect (holding temperature at its mean) dose_grid = np.linspace(0, 10, 200) temp_mean = np.full_like(dose_grid, np.mean(temperature)) preds_dose = model_combined.predict({"dose": dose_grid, "temperature": temp_mean}) # Visualize the temperature effect (holding dose at its mean) temp_grid = np.linspace(5, 45, 200) dose_mean = np.full_like(temp_grid, np.mean(dose)) preds_temp = model_combined.predict({"dose": dose_mean, "temperature": temp_grid}) # Build data for a two-panel comparison dose_plot_data = [ {"x": float(dose_grid[i]), "fit": float(preds_dose.values[i]), "term": "Dose effect (monotone increasing)"} for i in range(len(dose_grid)) ] temp_plot_data = [ {"x": float(temp_grid[i]), "fit": float(preds_temp.values[i]), "term": "Temperature effect (convex)"} for i in range(len(temp_grid)) ] chart_dose = alt.Chart({"values": dose_plot_data}).mark_line( strokeWidth=2, color="firebrick" ).encode( x=alt.X("x:Q", title="Dose"), y=alt.Y("fit:Q", title="Predicted efficacy"), ).properties(width="container", height=250, title="Dose effect (mpi)") chart_temp = alt.Chart({"values": temp_plot_data}).mark_line( strokeWidth=2, color="darkgreen" ).encode( x=alt.X("x:Q", title="Temperature (C)"), y=alt.Y("fit:Q", title="Predicted efficacy"), ).properties(width="container", height=250, title="Temperature effect (cx)") chart_dose | chart_temp ``` Each term respects its own constraint: the dose curve is guaranteed non-decreasing while the temperature curve is guaranteed convex. The constraints are applied independently, so they do not interfere with each other. ## How the projection works {#projection} Shape constraints in Whittaker are enforced by a **projection step** inserted into the standard PIRLS algorithm. At each iteration, after solving the penalized least squares problem for the unconstrained coefficients, the coefficients are projected onto the constraint set. ### Monotonicity: the PAVA algorithm For monotone increasing constraints, the projection uses the **pool adjacent violators algorithm** (PAVA). Given a coefficient vector $(\beta_1, \ldots, \beta_K)$ that may violate the ordering constraint, PAVA produces the closest vector (in the least-squares sense) that satisfies $\beta_1 \le \beta_2 \le \cdots \le \beta_K$. The algorithm works by scanning the coefficients from left to right. When it encounters a violation ($\beta_{k+1} < \beta_k$), it **pools** the two values by replacing both with their (weighted) average. This pooling cascades backward as needed until the ordering is restored. The result is the $L^2$-nearest point in the monotone cone. For monotone decreasing constraints, the same algorithm is applied to the negated coefficients. ### Convexity and concavity: cumulative sums For convex constraints, the second-order differences of the coefficients $\Delta^2 \beta_k = \beta_{k+2} - 2\beta_{k+1} + \beta_k$ must be non-negative. The projection works in two steps: 1. Compute the second differences of the current coefficient vector. 2. Project the second differences onto the non-negative orthant (clamp negatives to zero). 3. Reconstruct the coefficient vector via cumulative summation. For concave constraints, the second differences must be non-positive, so the projection clamps positive second differences to zero. ::: {.callout-note} ## Convergence behavior The projection step can slow convergence compared to unconstrained fitting because the constraint may be active at different knots across iterations. In practice, convergence is still fast---usually within 10--20 PIRLS iterations. If you encounter convergence warnings, try increasing `k` or reducing the complexity of the model. ::: ## Practical guidance {#guidance} ### When to use shape constraints Use a shape constraint when: 1. **Domain knowledge is strong.** You have clear theoretical or empirical reasons to believe the relationship is monotone, convex, or concave. Dose-response curves, growth trajectories, and thermodynamic relationships are classic examples. 2. **Data are sparse in some regions.** An unconstrained smooth may oscillate where data are thin. A constraint stabilizes the fit in these regions without requiring more data. 3. **Interpretability matters.** Stakeholders expect a monotone or convex fit. A smooth that dips or wiggles in unexpected ways can undermine trust in the model. 4. **Extrapolation is needed.** Shape constraints improve the behavior of the smooth near the boundaries of the data range, where unconstrained smooths are most prone to edge effects. ### When not to use shape constraints Avoid constraints when: - The true relationship is not monotone or convex. A misspecified constraint will bias the fit. - You are in an exploratory phase and want the data to speak freely. - The sample size is large enough that the unconstrained smooth already captures the correct shape without artifacts. ### Checking whether a constraint helps Compare the constrained and unconstrained models by examining the predictions and, where applicable, an information criterion like AIC: ```{python} # Reuse the dose-response data from above print("Monotone increasing model:") print(f" AIC: {model_mpi.summary()}") print() print("Unconstrained model:") print(f" AIC: {model_free.summary()}") ``` ::: {.callout-warning} ## Do not blindly compare AIC AIC comparisons between constrained and unconstrained models should be interpreted with caution. The effective degrees of freedom in a constrained model do not have the same meaning as in an unconstrained model, because the constraint reduces the effective parameter space. Use AIC as a rough guide, but rely primarily on domain knowledge and visual inspection of the fitted curves. ::: ### Summary of basis types | Basis | `bs=` | Constraint | Use case | |-------|-------|------------|----------| | Monotone increasing | `"mpi"` | $f'(x) \ge 0$ | Dose-response, growth, calibration | | Monotone decreasing | `"mpd"` | $f'(x) \le 0$ | Decay, depreciation, cooling | | Convex | `"cx"` | $f''(x) \ge 0$ | U-shaped costs, accelerating growth | | Concave | `"cv"` | $f''(x) \le 0$ | Diminishing returns, saturation | All four types are P-spline variants and accept the same arguments as `bs="ps"` (including `k` and `m`). They can be combined freely with each other and with unconstrained smooth types in the same model formula. ## Where to go next - **[Smooth terms](04-smooths.qmd)**: the full catalog of basis types, including the unconstrained variants that shape-constrained smooths build upon. - **[Model fitting](06-fitting.qmd)**: details on the PIRLS algorithm and how the projection step integrates with it. - **[Diagnostics](11-diagnostics.qmd)**: residual checks and `model.check()` to verify that the constraint is appropriate for your data. ### Distributional regression (GAMLSS) ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` A standard GAM models the conditional **mean** of the response as a smooth function of the predictors. Everything else about the distribution (the variance, skewness, zero-inflation) is treated as fixed. That assumption is often wrong. Reaction-time variability increases under cognitive load. Insurance claim amounts become more dispersed in certain regions. Survey proportions cluster near 0 or 1 depending on the question. Generalized Additive Models for Location, Scale, and Shape (GAMLSS) remove these restrictions. Every parameter of the response distribution (not just the mean) gets its own additive predictor with its own smooth terms, its own link function, and its own penalty. The result is a model that captures how the **entire conditional distribution** changes with the covariates. ## The GAMLSS framework In a standard GAM for the Gaussian family you model one parameter: $$g(\mu) = \mathbf{X}\boldsymbol{\beta} + \sum_j f_j(x_j)$$ GAMLSS generalizes this to $K$ distribution parameters $\theta_1, \ldots, \theta_K$, each with its own link function $g_k$ and its own additive predictor: $$g_k(\theta_k) = \mathbf{X}_k \boldsymbol{\beta}_k + \sum_j f_{kj}(x_j) \qquad k = 1, \ldots, K$$ For a Gaussian location-scale model, $K = 2$: $\theta_1 = \mu$ (mean) with an identity link, and $\theta_2 = \sigma$ (standard deviation) with a log link (to keep it positive). For a zero-inflated Poisson, $K = 2$: $\theta_1 = \mu$ (rate) with a log link, and $\theta_2 = \pi$ (zero-inflation probability) with a logit link. ::: {.callout-note} ## Formula conventions In Whittaker's GAMLSS interface, formulas are passed as a dictionary keyed by parameter name. The response variable must appear on the left-hand side of every formula, and all formulas must share the same response variable: ```python formulas = { "mu": "y ~ s(x)", # mean parameter "sigma": "y ~ s(x)", # scale parameter } ``` ::: ## Available GAMLSS families Whittaker provides five GAMLSS families. Each defines a response distribution and the set of parameters that can be modeled as functions of covariates. | Family | Parameters | Default links | Typical use | |---|---|---|---| | `GaussianLS()` | $\mu$ (mean), $\sigma$ (std. dev.) | identity, log | Heteroscedastic continuous data | | `GammaLS()` | $\mu$ (mean), $\sigma$ (CV) | log, log | Positive data with varying dispersion | | `BetaLS()` | $\mu$ (mean), $\phi$ (precision) | logit, log | Proportions on $(0,1)$ | | `ZeroInflatedPoisson()` | $\mu$ (rate), $\pi$ (zero prob.) | log, logit | Counts with excess zeros | | `ZeroInflatedNegativeBinomial()` | $\mu$ (rate), $\sigma$ (dispersion), $\pi$ (zero prob.) | log, log, logit | Overdispersed counts with excess zeros | ## Gaussian location-scale: heteroscedastic data The simplest GAMLSS application is data where the **spread** changes with a predictor. A standard Gaussian GAM assumes constant variance, so its confidence intervals are too narrow where the data are noisy and too wide where they are tight. Modeling $\sigma$ as a smooth function of $x$ fixes this. ### Simulating heteroscedastic data ```{python} import numpy as np import whittaker as wk import altair as alt rng = np.random.default_rng(23) n = 400 x = np.linspace(0, 6, n) # Mean: a smooth curve mu_true = 2 * np.sin(x) # Standard deviation: increases with x sigma_true = 0.3 + 0.4 * x y = mu_true + rng.normal(0, sigma_true) data = {"x": x, "y": y} ``` The noise level is small near $x = 0$ and large near $x = 6$. A standard GAM would estimate the mean correctly but would give uniform-width confidence bands (too cautious on the left, not cautious enough on the right). ### Fitting the model ```{python} model_gls = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "sigma": "y ~ s(x)"}, family=wk.GaussianLS(), ) model_gls.fit(data) print(model_gls.summary()) ``` Both $\mu$ and $\sigma$ have their own smooth terms with separate EDFs and smoothing parameters. The summary reports each parameter's additive predictor independently. ### Prediction and visualization ```{python} x_grid = np.linspace(0, 6, 300) preds_gls = model_gls.predict({"x": x_grid}) # Extract predicted mu and sigma mu_hat = preds_gls.values["mu"] sigma_hat = preds_gls.values["sigma"] # Build 95% prediction intervals using the predicted sigma z = 1.96 lower_pi = mu_hat - z * sigma_hat upper_pi = mu_hat + z * sigma_hat # Observed data obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] # Fitted curve and prediction interval fit_data = [ {"x": float(x_grid[i]), "mu": float(mu_hat[i]), "lower": float(lower_pi[i]), "upper": float(upper_pi[i])} for i in range(len(x_grid)) ] # True mu for comparison true_data = [ {"x": float(x_grid[i]), "mu_true": float(2 * np.sin(x_grid[i]))} for i in range(len(x_grid)) ] points = alt.Chart({"values": obs_data}).mark_circle( size=12, opacity=0.25, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") line = alt.Chart({"values": fit_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="mu:Q") true_line = alt.Chart({"values": true_data}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="mu_true:Q") (band + points + line + true_line).properties( width="container", height=350, title="GaussianLS: prediction intervals widen as variance increases" ) ``` The prediction band fans out to the right, reflecting the increasing noise level. A standard GAM would produce a band of constant width: too narrow on the right where the data truly are noisy, and too wide on the left where the data are precise. ::: {.callout-tip} ## When is a location-scale model worth the extra complexity? Run a standard GAM first and inspect the residuals. If a plot of squared residuals against $x$ shows a clear trend, the variance is not constant, and a `GaussianLS` model will give you better prediction intervals and more honest uncertainty estimates. ::: ## Gamma location-scale: positive data with varying shape For strictly positive, right-skewed data the `GammaLS()` family models both the mean and the coefficient of variation as smooth functions of covariates. This is useful for financial data, waiting times, and environmental measurements where both the level and the relative spread change. ```{python} rng = np.random.default_rng(7) n = 350 x = np.linspace(0.5, 5, n) # True mean (always positive) mu_true = np.exp(1.0 + 0.5 * np.sin(2 * x)) # True CV increases with x cv_true = 0.15 + 0.1 * x shape_true = 1.0 / cv_true**2 scale_true = mu_true / shape_true y_gamma = rng.gamma(shape_true, scale=scale_true) data_gamma = {"x": x, "y": y_gamma} model_gamma_ls = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "sigma": "y ~ s(x)"}, family=wk.GammaLS(), ) model_gamma_ls.fit(data_gamma) print(model_gamma_ls.summary()) ``` ```{python} x_grid = np.linspace(0.5, 5, 300) preds_gamma = model_gamma_ls.predict({"x": x_grid}) mu_hat_gamma = preds_gamma.values["mu"] obs_gamma = [{"x": float(x[i]), "y": float(y_gamma[i])} for i in range(n)] fit_gamma = [{"x": float(x_grid[i]), "mu": float(mu_hat_gamma[i])} for i in range(len(x_grid))] points_gamma = alt.Chart({"values": obs_gamma}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) line_gamma = alt.Chart({"values": fit_gamma}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y=alt.Y("mu:Q", title="y")) (points_gamma + line_gamma).properties( width="container", height=300, title="Gamma location-scale: fitted mean" ) ``` The log link on both $\mu$ and $\sigma$ ensures positivity. The fitted `sigma` smooth captures how the coefficient of variation increases along the transect. ## Beta location-scale: proportion data When modeling proportions on the open interval $(0, 1)$, the `BetaLS()` family lets you model both the mean proportion and the **precision** (or equivalently, the variance around that mean) as functions of covariates. This is common in ecological data (percent cover), educational data (test score fractions), and manufacturing (yield proportions). ```{python} rng = np.random.default_rng(12) n = 300 x = np.linspace(0, 4, n) # True mean proportion (logit scale -> (0, 1)) mu_true = 1 / (1 + np.exp(-(0.5 + 0.8 * np.sin(1.5 * x)))) # Precision varies: high precision in the middle, low at extremes phi_true = np.exp(2.5 + 1.0 * np.cos(x)) alpha = mu_true * phi_true beta_param = (1 - mu_true) * phi_true y_beta = rng.beta(alpha, beta_param) data_beta = {"x": x, "y": y_beta} model_beta_ls = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "phi": "y ~ s(x)"}, family=wk.BetaLS(), ) model_beta_ls.fit(data_beta) print(model_beta_ls.summary()) ``` ```{python} x_grid = np.linspace(0, 4, 300) preds_beta = model_beta_ls.predict({"x": x_grid}) mu_hat_beta = preds_beta.values["mu"] obs_beta = [{"x": float(x[i]), "y": float(y_beta[i])} for i in range(n)] fit_beta = [{"x": float(x_grid[i]), "mu": float(mu_hat_beta[i])} for i in range(len(x_grid))] points_beta = alt.Chart({"values": obs_beta}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) line_beta = alt.Chart({"values": fit_beta}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y=alt.Y("mu:Q", title="y")) (points_beta + line_beta).properties( width="container", height=300, title="Beta location-scale: fitted mean proportion" ) ``` The logit link on $\mu$ constrains predictions to $(0, 1)$. The log link on $\sigma$ (the precision parameter) keeps it positive. Modeling precision as a function of $x$ avoids the common problem of over- or under-dispersed residuals at different covariate values. ::: {.callout-note} ## Beta boundary values Like the standard `Beta()` family, `BetaLS()` requires $y \in (0, 1)$---not 0 or 1 exactly. If your data contain boundary values, apply a small nudge (e.g., $y' = (y(n-1) + 0.5) / n$) before fitting. ::: ## Zero-inflated Poisson: counts with excess zeros Many count datasets contain more zeros than a Poisson distribution can explain. Species that are absent from most survey sites, customers who never purchase, medical events that do not occur for most patients: all produce **zero-inflated** data. The `ZeroInflatedPoisson()` family models two processes simultaneously: 1. A **structural zero process**: with probability $\pi(x)$, the observation is always zero (the species is absent, the customer is inactive). 2. A **Poisson count process**: with probability $1 - \pi(x)$, the observation follows a Poisson distribution with rate $\mu(x)$. Both $\mu$ and $\pi$ can be smooth functions of different (or the same) covariates. ### Simulating zero-inflated count data ```{python} rng = np.random.default_rng(99) n = 500 x = np.linspace(0, 5, n) # True Poisson rate: varies smoothly mu_true = np.exp(0.5 + 0.8 * np.sin(1.5 * x)) # True zero-inflation probability: higher at both ends logit_pi_true = 1.0 - 1.5 * np.sin(np.pi * x / 5) pi_true = 1 / (1 + np.exp(-logit_pi_true)) # Generate data is_structural_zero = rng.binomial(1, pi_true).astype(bool) y_counts = np.where( is_structural_zero, 0.0, rng.poisson(mu_true).astype(float), ) data_zip = {"x": x, "y": y_counts} print(f"Proportion of zeros: {(y_counts == 0).mean():.1%}") print(f"Proportion of structural zeros: {is_structural_zero.mean():.1%}") ``` ### Fitting the ZIP model ```{python} model_zip = wk.GAMLSS( formulas={"mu": "y ~ s(x)", "pi": "y ~ s(x)"}, family=wk.ZeroInflatedPoisson(), ) model_zip.fit(data_zip) print(model_zip.summary()) ``` The model estimates separate smooth functions for the Poisson rate ($\mu$) and the zero-inflation probability ($\pi$). Each has its own EDF and smoothing parameter. ### Visualizing the zero-inflated fit ```{python} x_grid = np.linspace(0, 5, 300) preds_zip = model_zip.predict({"x": x_grid}) mu_hat_zip = preds_zip.values["mu"] pi_hat_zip = preds_zip.values["pi"] # Build data for the two-panel chart rate_data = [ {"x": float(x_grid[i]), "value": float(mu_hat_zip[i]), "parameter": "Rate (mu)"} for i in range(len(x_grid)) ] + [ {"x": float(x_grid[i]), "value": float(np.exp(0.5 + 0.8 * np.sin(1.5 * x_grid[i]))), "parameter": "Rate (mu), true"} for i in range(len(x_grid)) ] pi_data = [ {"x": float(x_grid[i]), "value": float(pi_hat_zip[i]), "parameter": "Zero-inflation (pi)"} for i in range(len(x_grid)) ] + [ {"x": float(x_grid[i]), "value": float(1 / (1 + np.exp(-(1.0 - 1.5 * np.sin(np.pi * x_grid[i] / 5))))), "parameter": "Zero-inflation (pi), true"} for i in range(len(x_grid)) ] # Observed data (show counts as a strip) obs_data = [{"x": float(x[i]), "y": float(y_counts[i])} for i in range(n)] # Top panel: observed data with fitted rate points_zip = alt.Chart({"values": obs_data}).mark_circle( size=10, opacity=0.2, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="Count"), ) rate_fitted = alt.Chart( {"values": [d for d in rate_data if "true" not in d["parameter"]]} ).mark_line(color="darkorange", strokeWidth=2).encode( x="x:Q", y=alt.Y("value:Q", title="Count"), ) rate_true = alt.Chart( {"values": [d for d in rate_data if "true" in d["parameter"]]} ).mark_line(color="gray", strokeDash=[4, 4], strokeWidth=1.5).encode( x="x:Q", y="value:Q", ) top_panel = (points_zip + rate_fitted + rate_true).properties( width="container", height=220, title="Fitted rate (mu) with observed counts" ) # Bottom panel: zero-inflation probability pi_fitted = alt.Chart( {"values": [d for d in pi_data if "true" not in d["parameter"]]} ).mark_line(color="crimson", strokeWidth=2).encode( x=alt.X("x:Q", title="x"), y=alt.Y("value:Q", title="P(structural zero)"), ) pi_true_line = alt.Chart( {"values": [d for d in pi_data if "true" in d["parameter"]]} ).mark_line(color="gray", strokeDash=[4, 4], strokeWidth=1.5).encode( x="x:Q", y="value:Q", ) bottom_panel = (pi_fitted + pi_true_line).properties( width="container", height=200, title="Fitted zero-inflation probability (pi)" ) alt.vconcat(top_panel, bottom_panel).resolve_scale(x="shared") ``` The top panel shows the fitted Poisson rate overlaid on the observed counts. The bottom panel shows the estimated zero-inflation probability $\hat\pi(x)$ (crimson) against the true generating function (gray dashed). The model successfully separates the two sources of zeros: those from the Poisson process (rate-driven) and those from structural absence (zero-inflation-driven). ## Zero-inflated Negative Binomial When zero-inflated count data are also **overdispersed** (variance exceeds the mean within the count component), the `ZeroInflatedNegativeBinomial()` family adds a dispersion parameter $\sigma$ alongside the rate $\mu$ and the zero-inflation probability $\pi$. This is the most flexible count model in Whittaker. ```{python} model_zinb = wk.GAMLSS( formulas={ "mu": "y ~ s(x)", "sigma": "y ~ s(x)", "pi": "y ~ s(x)", }, family=wk.ZeroInflatedNegativeBinomial(), ) model_zinb.fit(data_zip) print(model_zinb.summary()) ``` ```{python} x_zip = data_zip["x"] x_grid = np.linspace(float(x_zip.min()), float(x_zip.max()), 300) new_data = {"x": x_grid} mu_zip = model_zip.predict(new_data).values["mu"] mu_zinb = model_zinb.predict(new_data).values["mu"] fit_compare = [ {"x": float(x_grid[i]), "rate": float(mu_zip[i]), "model": "ZIP"} for i in range(len(x_grid)) ] + [ {"x": float(x_grid[i]), "rate": float(mu_zinb[i]), "model": "ZINB"} for i in range(len(x_grid)) ] alt.Chart({"values": fit_compare}).mark_line(strokeWidth=2).encode( x=alt.X("x:Q", title="x"), y=alt.Y("rate:Q", title="Fitted rate (mu)"), color=alt.Color("model:N", title="Model"), ).properties( width="container", height=300, title="ZIP vs. ZINB: fitted rate comparison" ) ``` ::: {.callout-warning} ## Model complexity and identifiability The ZINB model has three smooth functions to estimate, which requires substantially more data than a ZIP or standard Poisson. With small samples, the dispersion and zero-inflation parameters can be poorly identified. Start with a simpler model (`ZeroInflatedPoisson` or `NegativeBinomial`) and add complexity only if residual diagnostics indicate it is needed. ::: ## How fitting works GAMLSS models are fitted by an **alternating (outer) iteration** that cycles through the distribution parameters: 1. **Initialize** all parameters to reasonable starting values (e.g., the response mean for $\mu$, the residual standard deviation for $\sigma$). 2. **Cycle**: for each parameter $\theta_k$ in turn, hold the other parameters fixed and update $\theta_k$ by running one step of penalized iteratively reweighted least squares (P-IRLS) on its working model. The working response and weights depend on the current values of all other parameters. 3. **Check convergence**: if the overall penalized deviance has changed by less than a tolerance (default $10^{-7}$), stop. Otherwise, return to step 2. This is a **backfitting** algorithm over the distribution parameters. Within each P-IRLS step, the smooth terms for that parameter are estimated exactly as in a standard GAM: the REML (or GCV) criterion selects the smoothing parameters, and the basis/penalty machinery is identical. ::: {.callout-note} ## Convergence considerations Because the outer iteration is a coordinate-descent scheme, convergence is guaranteed under mild regularity conditions, but the number of outer cycles grows with the complexity of the model. Two practical tips: - **Start simple.** Fit the `mu` model first as a standard GAM, inspect the residuals, and add a `sigma` (or `pi`) model only if the diagnostics suggest it. - **Watch the iteration count.** If `.fit()` reports that it has not converged, consider simplifying the formulas (e.g., reducing `k` or removing terms) before increasing the maximum number of outer iterations. ::: ## When to use GAMLSS vs. a standard GAM A standard GAM (with the appropriate family) is sufficient when the **shape** of the response distribution does not change with the covariates (only the mean shifts). GAMLSS adds value in three situations: 1. **Heteroscedasticity.** The variance (or coefficient of variation) changes systematically with a predictor. Classic example: measurement precision that degrades with distance, concentration, or time. Use `GaussianLS()` or `GammaLS()`. 2. **Changing shape.** The skewness or kurtosis of the response varies. For proportions near boundaries, the Beta distribution's shape parameters change the degree of asymmetry. Use `BetaLS()`. 3. **Structural zeros.** A fraction of the population can never produce a positive count, and that fraction varies with covariates. Use `ZeroInflatedPoisson()` or `ZeroInflatedNegativeBinomial()`. If none of these apply (the data have roughly constant spread and no excess zeros) a standard GAM is simpler, faster, and easier to interpret. The table below summarizes the decision: | Situation | Recommended model | |---|---| | Constant variance, no excess zeros | Standard GAM with `Gaussian()`, `Poisson()`, etc. | | Variance changes with covariates | `GAMLSS` with `GaussianLS()` or `GammaLS()` | | Proportion data with varying precision | `GAMLSS` with `BetaLS()` | | Count data with excess zeros | `GAMLSS` with `ZeroInflatedPoisson()` | | Overdispersed counts with excess zeros | `GAMLSS` with `ZeroInflatedNegativeBinomial()` | ::: {.callout-important} ## More parameters means more data Each additional distributional parameter requires its own smooth to be estimated. A two-parameter GAMLSS needs roughly twice the effective sample size of a one-parameter GAM to achieve comparable precision. A three-parameter model (ZINB) needs even more. Always check that your sample size is adequate before adding distributional complexity. ::: ## Where to go next - **[Response families](05-families.qmd)**: the standard (single-parameter) families that underlie the GAMLSS extensions. - **[Smooth terms](04-smooths.qmd)**: basis types, tensor products, and choosing `k` for the smooth terms inside each distributional parameter. - **[Model fitting](06-fitting.qmd)**: details on REML, P-IRLS, and convergence diagnostics that apply to the inner loop of GAMLSS fitting. - **[Diagnostics](11-diagnostics.qmd)**: residual checks and model validation, including randomized quantile residuals for GAMLSS models. ### Quantile regression ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Standard GAMs model the **conditional mean** $E(y \mid x)$. This is the right target when the distribution of $y$ is roughly symmetric and homoscedastic. But in many real problems the variance, skewness, or tail behavior of the response changes with the predictors. In those settings a single mean curve is an incomplete summary. **Quantile regression** fills the gap by estimating any conditional quantile $Q_\tau(y \mid x)$ --- the value below which a fraction $\tau$ of the response distribution falls, given the covariates. Whittaker's `QuantileGAM` fits one or more conditional quantiles simultaneously using smooth additive functions, producing a full picture of how the response distribution shifts and stretches across the predictor space. ## Why quantiles matter ### Heteroscedastic data When variance grows (or shrinks) with a predictor, a mean-only model hides the most interesting story. A fan of quantile curves reveals exactly how the spread changes. ### Risk quantification In finance, environmental science, and engineering, the tails of the distribution carry the most consequence. Estimating the 5th or 95th percentile directly answers questions like "what is the worst-case flood level?" or "what return should an investor expect in the bottom decile?" ### Adaptive prediction intervals Classical confidence intervals assume a fixed error distribution (typically Gaussian). Quantile-based intervals adapt automatically to local variance and skewness, without distributional assumptions. ::: {.callout-note} ## Quantile regression vs. distributional regression (GAMLSS) GAMLSS (covered in the [distributional regression page](18-gamlss.qmd)) models the full conditional distribution by parameterizing location, scale, and shape. Quantile regression is **distribution-free**: it targets individual quantiles without assuming any parametric form. This makes it more robust when the true distribution is unknown, but it does not yield a full density estimate. ::: ## Fitting multiple quantiles The core workflow is straightforward: specify the quantiles you want, write a formula, and call `.fit()`. ```{python} import whittaker as wk import numpy as np import altair as alt # --- Simulate heteroscedastic data --- rng = np.random.default_rng(23) n = 400 x = rng.uniform(0, 6, n) # Variance increases linearly with x sigma_x = 0.3 + 0.4 * x y = np.sin(x) + sigma_x * rng.normal(size=n) data = {"x": x, "y": y} # --- Fit quantile GAM at five levels --- quantiles = [0.1, 0.25, 0.5, 0.75, 0.9] qgam = wk.QuantileGAM( formula="y ~ s(x)", quantiles=quantiles, ).fit(data) ``` The fitted model stores a separate smooth for each requested quantile. Use `.predict()` to evaluate them on a prediction grid: ```{python} x_grid = np.linspace(0, 6, 200) new_data = {"x": x_grid} preds = qgam.predict(new_data) # dict: quantile -> PredictionResult ``` `preds` is a dictionary whose keys are the quantile levels and whose values are the predicted arrays. Let's visualize all five curves together. ```{python} # Build a long-form data list for Altair fan_records = [] for tau, vals in preds.items(): for i in range(len(x_grid)): fan_records.append({"x": float(x_grid[i]), "y_hat": float(vals.values[i]), "quantile": str(tau)}) # Scatter of raw data scatter = alt.Chart({"values": [{"x": float(x[i]), "y": float(y[i])} for i in range(n)]}).mark_circle( size=12, opacity=0.20, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) # Quantile curves q_lines = alt.Chart({"values": fan_records}).mark_line(strokeWidth=2).encode( x="x:Q", y=alt.Y("y_hat:Q", title="y"), color=alt.Color( "quantile:N", scale=alt.Scale( domain=["0.1", "0.25", "0.5", "0.75", "0.9"], range=["#4575b4", "#91bfdb", "#d73027", "#91bfdb", "#4575b4"], ), legend=alt.Legend(title="Quantile"), ), strokeDash=alt.condition( alt.datum.quantile == "0.5", alt.value([1, 0]), # solid for median alt.value([5, 3]), # dashed for others ), ) (scatter + q_lines).properties( width="container", height=320, title="Quantile GAM fan chart: heteroscedastic data", ) ``` The fan of curves widens as $x$ increases, faithfully tracking the growing variance. The solid red line is the median ($\tau = 0.5$). The dashed curves are the 10th/90th and 25th/75th percentiles. ## The ELF loss function Classical quantile regression minimizes the **check function** (also called the pinball loss), which has a kink at zero. This creates difficulties for penalized likelihood methods because the gradient is discontinuous. Whittaker uses the **expectile-like family (ELF)** loss instead. The ELF loss smooths the kink with a bandwidth parameter $\sigma$, producing a twice-differentiable objective that integrates cleanly into P-IRLS fitting. As $\sigma \to 0$, ELF converges to the true check function, so the quantile interpretation is preserved. ::: {.callout-tip} ## You rarely need to set sigma by hand The default $\sigma$ is chosen to balance bias and smoothness. Use `calibrate_sigma()` (described below) when you need to verify the default or tighten the approximation for very sharp quantile estimates. ::: ## The non-crossing problem When quantiles are estimated independently, nothing prevents the fitted curves from crossing. For instance, the predicted 25th percentile might exceed the 75th percentile at some covariate values. Crossings are an artifact of separate estimation---they violate the monotonicity property that $Q_{\tau_1}(y \mid x) \le Q_{\tau_2}(y \mid x)$ whenever $\tau_1 < \tau_2$. In practice, crossings are most common when: - The sample size is small relative to the number of quantiles. - The quantiles are close together (e.g., 0.48 and 0.52). - The underlying relationship is highly nonlinear. ### Detecting crossings The `.crossing_fraction()` method reports the fraction of the prediction grid where at least one pair of quantile curves crosses: ```{python} frac = qgam.crossing_fraction() print(f"Crossing fraction: {frac:.4f}") ``` A value of zero means the curves are properly ordered everywhere. Any positive value indicates violations. ### Enforcing non-crossing with isotonic projection Set `non_crossing=True` to enforce monotonicity across quantile levels. Whittaker uses an isotonic regression projection after each P-IRLS update: at every evaluation point, the predicted quantiles are sorted so that lower quantile levels always produce lower fitted values. ```{python} # Fit with the non-crossing constraint qgam_nc = wk.QuantileGAM( formula="y ~ s(x)", quantiles=quantiles, non_crossing=True, ).fit(data) frac_nc = qgam_nc.crossing_fraction() print(f"Crossing fraction (constrained): {frac_nc:.4f}") ``` Let's compare the unconstrained and constrained fits side by side to see the effect. ```{python} preds_nc = qgam_nc.predict(new_data) # Build records for both models compare_records = [] for tau, vals in preds.items(): for i in range(len(x_grid)): compare_records.append({ "x": float(x_grid[i]), "y_hat": float(vals.values[i]), "quantile": str(tau), "model": "unconstrained", }) for tau, vals in preds_nc.items(): for i in range(len(x_grid)): compare_records.append({ "x": float(x_grid[i]), "y_hat": float(vals.values[i]), "quantile": str(tau), "model": "constrained", }) compare_chart = alt.Chart({"values": compare_records}).mark_line(strokeWidth=1.5).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y_hat:Q", title="y"), color=alt.Color("quantile:N", legend=alt.Legend(title="Quantile")), strokeDash=alt.StrokeDash("model:N", legend=alt.Legend(title="Model")), ).facet( column=alt.Column("model:N", title=None), ).properties( title="Unconstrained vs. non-crossing quantile GAM", ) compare_chart ``` In the constrained panel, every quantile curve sits strictly below the one above it across the entire range of $x$. ::: {.callout-warning} ## Non-crossing adds a small bias The isotonic projection guarantees monotonicity but introduces a small amount of bias because it shifts fitted values. In most applications the bias is negligible compared to the variance of the estimates, but for very closely spaced quantiles (e.g., 0.49 and 0.51) it can matter. Check the model summary to verify that the bias is acceptable. ::: ## Prediction intervals from quantile regression A natural use of quantile regression is to build prediction intervals. The `.predict_interval()` method returns the lower and upper bounds defined by the outermost pair of fitted quantiles: ```{python} lower, upper = qgam_nc.predict_interval(new_data) ``` For a model fitted at quantiles `[0.1, 0.25, 0.5, 0.75, 0.9]`, this returns the 10th and 90th percentile curves as the bounds of an 80% prediction interval. ### Comparison with Gaussian confidence intervals A standard GAM produces symmetric intervals that assume Gaussian errors. When the data are heteroscedastic, these intervals are too narrow in high-variance regions and too wide in low-variance regions. Quantile-based intervals adapt automatically. ```{python} # Fit a standard GAM for comparison gam = wk.GAM(formula="y ~ s(x)").fit(data, method="REML") pred_gam = gam.predict(new_data, se=True) # 80% Gaussian CI: mean +/- 1.28 * SE z80 = 1.2816 g_lower = pred_gam.values - z80 * pred_gam.se g_upper = pred_gam.values + z80 * pred_gam.se # Build Altair data interval_records = [] for i in range(len(x_grid)): interval_records.append({ "x": float(x_grid[i]), "q_lower": float(lower[i]), "q_upper": float(upper[i]), "g_lower": float(g_lower[i]), "g_upper": float(g_upper[i]), }) base = alt.Chart({"values": interval_records}) # Quantile interval band q_band = base.mark_area(opacity=0.25, color="#d73027").encode( x=alt.X("x:Q", title="x"), y=alt.Y("q_lower:Q", title="y"), y2="q_upper:Q", ) # Gaussian interval band g_band = base.mark_area(opacity=0.20, color="#4575b4").encode( x="x:Q", y=alt.Y("g_lower:Q", title="y"), y2="g_upper:Q", ) # Scatter pts = alt.Chart( {"values": [{"x": float(x[i]), "y": float(y[i])} for i in range(n)]} ).mark_circle(size=10, opacity=0.15, color="gray").encode( x="x:Q", y="y:Q", ) (g_band + q_band + pts).properties( width="container", height=320, title="80% prediction intervals: Gaussian (blue) vs. quantile (red)", ) ``` The red (quantile) band fans out to the right, matching the increasing noise, while the blue (Gaussian) band maintains roughly constant width and under-covers in the high-variance region. ## Sigma calibration The ELF loss bandwidth $\sigma$ controls how closely the smooth surrogate approximates the true check function. A smaller $\sigma$ gives a tighter approximation (less bias toward expectiles) but can make optimization harder. The `calibrate_sigma` utility evaluates several candidate $\sigma$ values and reports the bias--variance trade-off for each. ```{python} best_sigma = wk.calibrate_sigma( formula="y ~ s(x)", data=data, tau=0.5, seed=23, ) print(f"Best sigma for tau=0.5: {best_sigma:.4f}") ``` The function returns the $\sigma$ value that minimises the out-of-sample pinball loss via cross-validation. In most cases, the default $\sigma$ is adequate. ::: {.callout-note} ## When to calibrate sigma Calibration is most useful when: - You are estimating extreme quantiles ($\tau < 0.05$ or $\tau > 0.95$) where the ELF approximation matters most. - You observe that the fitted quantile curves do not align well with the empirical quantiles of the residuals. - You need to report formal quantile coverage and want to minimize the ELF-induced bias. For exploratory work at moderate quantiles (0.1--0.9), the default is usually fine. ::: ## Model summary Like other Whittaker models, `QuantileGAM` provides a `.summary()` method: ```{python} print(qgam_nc.summary()) ``` The summary reports, for each quantile level, the effective degrees of freedom, the smoothing parameter $\lambda$, and the ELF loss at convergence. It also notes whether the non-crossing constraint was active. ## Practical guidance ### Choosing quantiles - **Five-number summary** `[0.1, 0.25, 0.5, 0.75, 0.9]` is a good default for exploratory work. It captures the center, the interquartile range, and the tails. - **Prediction intervals** only need two quantiles. Use `[0.05, 0.95]` for a 90% interval or `[0.025, 0.975]` for a 95% interval. Add the median `0.5` if you also need a point prediction. - **Extreme quantiles** ($\tau < 0.05$ or $\tau > 0.95$) require larger sample sizes. As a rough rule, you need at least $10 / \min(\tau, 1 - \tau)$ observations to estimate a quantile reliably. ### When to prefer quantile regression over Gaussian intervals | Scenario | Better approach | |---|---| | Symmetric, constant variance | Gaussian GAM (simpler, efficient) | | Heteroscedastic but symmetric | Quantile GAM or GAMLSS with log-link on $\sigma$ | | Skewed or heavy-tailed | Quantile GAM (no distributional assumption) | | Need full density estimate | GAMLSS | | Need specific tail quantiles for risk | Quantile GAM | | Outlier-robust central estimate | Quantile GAM at $\tau = 0.5$ (median regression) | ::: {.callout-tip} ## Median regression as a robust alternative When outliers are a concern but you only need a central estimate, fitting a single quantile at $\tau = 0.5$ (the median) gives a smooth that is much less sensitive to extreme values than the least-squares mean. This is because the check function for the median penalizes absolute deviations rather than squared deviations. ::: ### Combining with other Whittaker features - **Multiple smooths**: `"y ~ s(x1) + s(x2)"` works exactly as with `GAM`. Each quantile gets its own smooth surface. - **Linear terms**: `"y ~ x1 + s(x2)"` combines parametric and smooth terms. - **Factor-by smooths**: `"y ~ s(x, by=group)"` fits separate quantile curves per group level, useful for comparing distributional differences across categories. You can now fit quantile GAMs, enforce non-crossing constraints, build adaptive prediction intervals, and calibrate the ELF loss bandwidth. ## Where to go next - **[Distributional regression (GAMLSS)](18-gamlss.qmd)**: model the full conditional distribution parametrically when you need a density estimate (not just individual quantiles). - **[Conformal prediction](20-conformal.qmd)**: distribution-free prediction intervals with finite-sample coverage guarantees. - **[Model diagnostics](11-diagnostics.qmd)**: residual plots and basis adequacy checks. - **[Smooth terms](04-smooths.qmd)**: the smooth types available in quantile GAM formulas. ### Conformal prediction ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Standard confidence intervals from a GAM rely on distributional assumptions -- Gaussian errors, correctly specified variance functions, and a well-calibrated Bayesian posterior covariance. When those assumptions are suspect, or when you need a hard coverage guarantee regardless of the true data-generating process, **conformal prediction** offers an alternative: distribution-free prediction intervals with finite-sample coverage guarantees. This page covers the three conformal methods available in Whittaker, shows how to fit and visualize them, and explains when to use conformal intervals instead of (or alongside) the classical Bayesian intervals from `predict()`. ## What conformal prediction is Conformal prediction constructs prediction intervals that satisfy a **marginal coverage guarantee**: $$P\bigl(Y_{\text{new}} \in \hat{C}(X_{\text{new}})\bigr) \geq 1 - \alpha$$ for any distribution $P_{X,Y}$, any sample size $n$, and any base model. The only assumption is **exchangeability** of the data -- a weaker condition than independence that allows mild temporal structure but excludes adversarial distribution shift. The core idea is simple: instead of relying on a parametric model for the error distribution, you calibrate the interval width using the empirical distribution of **conformity scores** (typically absolute residuals) on held-out data. This lets the data itself tell you how wide the interval needs to be. ## Why conformal prediction matters Classical GAM confidence intervals have excellent properties when the model is well specified, but they can undercover in several common situations: - **Model misspecification**: the true variance function does not match the assumed family. - **Heavy-tailed errors**: Gaussian-based intervals are too narrow for heavy-tailed data. - **Small samples**: the asymptotic approximation underlying Bayesian CIs may be loose. - **Non-standard smooths**: for complex tensor-product or adaptive smooths, the posterior covariance approximation may be inaccurate. Conformal prediction sidesteps all of these concerns. The coverage guarantee holds for any base model -- even a badly misspecified one. A better model produces tighter intervals, but coverage is guaranteed regardless. ::: {.callout-note} Conformal prediction provides **marginal** coverage, meaning the guarantee is averaged over the randomness in both $X$ and $Y$. It does not guarantee **conditional** coverage at every individual $x$ value. In practice, the intervals tend to be wider where the model is less accurate, which provides reasonable conditional behavior. ::: ## Split conformal prediction Split conformal is the simplest and fastest method. It works in three steps: 1. **Split** the data into a training set and a calibration set. 2. **Fit** the GAM on the training set. 3. **Calibrate**: compute absolute residuals on the calibration set, then take the $\lceil(1-\alpha)(1 + n_{\text{cal}})\rceil / n_{\text{cal}}$ quantile as the interval half-width. The resulting interval is $\hat{y}(x) \pm q$, where $q$ is the calibrated quantile. This is fast (only one model fit) but uses less data for fitting than the full dataset. ```{python} import numpy as np import whittaker as wk # Generate data with a nonlinear trend and heteroscedastic noise rng = np.random.default_rng(23) n = 400 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + 0.3 * (1 + 0.5 * np.abs(np.sin(x))) * rng.normal(0, 1, n) data = {"x": x, "y": y} # Fit a split conformal predictor predictor_split = wk.conformal_fit( "y ~ s(x)", data, method="split", level=0.95, cal_fraction=0.25, seed=23, ) # Predict on a fine grid x_new = np.linspace(0, 2 * np.pi, 200) result_split = predictor_split.predict({"x": x_new}) print(f"Prediction level: {result_split.level}") print( f"Interval width (constant): {(result_split.upper[0] - result_split.lower[0]):.4f}" ) print(f"Values shape: {result_split.values.shape}") ``` The `ConformalResult` has four attributes: - `.values` -- point predictions (the GAM fitted values). - `.lower` -- lower bound of the prediction interval. - `.upper` -- upper bound of the prediction interval. - `.level` -- the nominal coverage level (e.g., 0.95). Notice that split conformal produces **constant-width** intervals: the half-width $q$ is the same everywhere. This is a known limitation -- the intervals do not adapt to regions of higher or lower noise. ### Visualizing split conformal intervals ```{python} import altair as alt # Observed data obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] points = alt.Chart({"values": obs_data}).mark_circle( size=12, opacity=0.2, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) # Conformal band fit_data = [ { "x": float(x_new[i]), "fit": float(result_split.values[i]), "lower": float(result_split.lower[i]), "upper": float(result_split.upper[i]), } for i in range(len(x_new)) ] line = alt.Chart({"values": fit_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band = alt.Chart({"values": fit_data}).mark_area( opacity=0.15, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") # True function true_data = [ {"x": float(x_new[i]), "true": float(np.sin(x_new[i]))} for i in range(len(x_new)) ] true_line = alt.Chart({"values": true_data}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="true:Q") (band + points + line + true_line).properties( width="container", height=320, title="Split conformal prediction (95% level)" ) ``` The constant-width band is clearly visible. It covers the true function (gray dashed line) and the vast majority of observations, but is wider than necessary in low-noise regions and potentially too narrow in high-noise regions. ::: {.callout-tip} The `cal_fraction` parameter controls the train/calibration split. A larger calibration set produces a more precise quantile estimate (less variability in interval width across random seeds) but leaves less data for fitting the GAM. The default of `0.25` is a reasonable starting point. ::: ## CV+ conformal prediction **Cross-validation+** (CV+), introduced by Barber et al. (2021), improves on split conformal by using all the data for both fitting and calibration. It works as follows: 1. **Fold** the data into $K$ folds (default $K = 5$). 2. For each fold $k$, fit the GAM on the remaining $K - 1$ folds and compute leave-fold-out residuals for the held-out observations. 3. For a new test point $x$, aggregate the $K$ models' predictions and the cross-validated residuals to construct an interval. CV+ produces **tighter** intervals than split conformal because it uses the full dataset for fitting. The coverage guarantee is slightly weaker in theory (coverage $\geq 1 - 2\alpha$ in the worst case) but is typically close to the nominal level in practice. ```{python} # Fit a CV+ conformal predictor predictor_cv = wk.conformal_fit( "y ~ s(x)", data, method="cv+", level=0.95, n_folds=5, seed=23, ) # Predict on the same grid result_cv = predictor_cv.predict({"x": x_new}) print(f"CV+ interval width (mean): {(result_cv.upper - result_cv.lower).mean():.4f}") print(f"Split interval width: {(result_split.upper - result_split.lower).mean():.4f}") ``` CV+ intervals are **not** constant-width. Because the residuals from different folds vary in magnitude, the interval adapts somewhat to the local difficulty of prediction. ```{python} # Compare interval widths across the domain cv_data = [ { "x": float(x_new[i]), "fit": float(result_cv.values[i]), "lower": float(result_cv.lower[i]), "upper": float(result_cv.upper[i]), } for i in range(len(x_new)) ] line_cv = alt.Chart({"values": cv_data}).mark_line( color="darkorange", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band_cv = alt.Chart({"values": cv_data}).mark_area( opacity=0.15, color="darkorange" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") (band_cv + points + line_cv + true_line).properties( width="container", height=320, title="CV+ conformal prediction (95% level)" ) ``` ## Jackknife+ conformal prediction **Jackknife+** is the most expensive method. It refits the GAM $n$ times, each time leaving out one observation (leave-one-out). This produces the most precise residual distribution and typically yields the tightest intervals with the best empirical coverage. The computational cost scales linearly in $n$ (one model refit per observation), which makes jackknife+ impractical for very large datasets. For moderate-sized data ($n < 2000$), it is often the best choice. ```{python} # Use a smaller dataset for jackknife+ (LOO refits are expensive) n_small = 150 x_small = np.linspace(0, 2 * np.pi, n_small) y_small = np.sin(x_small) + 0.3 * rng.normal(0, 1, n_small) predictor_jk = wk.conformal_fit( "y ~ s(x)", {"x": x_small, "y": y_small}, method="jackknife+", level=0.95, seed=23, ) result_jk = predictor_jk.predict({"x": x_new}) print(f"Jackknife+ interval width (mean): {(result_jk.upper - result_jk.lower).mean():.4f}") ``` ::: {.callout-warning} Jackknife+ requires $n$ model refits, where $n$ is the number of observations. For large datasets, consider CV+ with a moderate number of folds as a practical compromise between interval quality and computation time. ::: ## Comparing methods The three conformal methods trade off computation, interval width, and theoretical coverage guarantees: | Method | Model refits | Coverage guarantee | Interval width | Adaptivity | |---|---|---|---|---| | Split | 1 | $\geq 1 - \alpha$ | Widest | Constant width | | CV+ | $K$ (default 5) | $\geq 1 - 2\alpha$ | Moderate | Partially adaptive | | Jackknife+ | $n$ | $\geq 1 - 2\alpha$ | Tightest | Most adaptive | In practice, all three methods typically achieve coverage close to the nominal $1 - \alpha$ level. The theoretical worst-case bounds for CV+ and jackknife+ ($1 - 2\alpha$) are conservative and rarely observed. ```{python} # Side-by-side comparison of interval widths comparison_data = [] for i in range(len(x_new)): comparison_data.append({ "x": float(x_new[i]), "method": "Split", "width": float(result_split.upper[i] - result_split.lower[i]), }) comparison_data.append({ "x": float(x_new[i]), "method": "CV+", "width": float(result_cv.upper[i] - result_cv.lower[i]), }) width_chart = alt.Chart({"values": comparison_data}).mark_line(strokeWidth=2).encode( x=alt.X("x:Q", title="x"), y=alt.Y("width:Q", title="Interval width"), color=alt.Color("method:N", title="Method"), ).properties( width="container", height=280, title="Conformal interval width by method" ) width_chart ``` ## Coverage verification After constructing conformal intervals, you can verify the empirical coverage on held-out data using `wk.conformal_coverage()`. This function computes the fraction of test observations that fall within the predicted intervals: ```{python} # Generate a fresh test set from the same process x_test = rng.uniform(0, 2 * np.pi, 500) y_test = np.sin(x_test) + 0.3 * (1 + 0.5 * np.abs(np.sin(x_test))) * rng.normal(0, 1, 500) test_data = {"x": x_test, "y": y_test} # Check coverage for each method cov_split = wk.conformal_coverage(predictor_split, test_data, response="y") cov_cv = wk.conformal_coverage(predictor_cv, test_data, response="y") print(f"Nominal level: 0.95") print(f"Split coverage: {cov_split:.4f}") print(f"CV+ coverage: {cov_cv:.4f}") ``` ::: {.callout-note} Empirical coverage on any single test set will fluctuate around the nominal level due to sampling variability. The conformal guarantee is that coverage is **at least** $1 - \alpha$ in expectation over the randomness in the calibration data. A single test set may show coverage slightly below the nominal level (averaging over many random splits would confirm the guarantee). ::: ## Using with non-Gaussian families Conformal prediction works with any response family supported by Whittaker. For non-Gaussian models, conformal intervals are particularly valuable because the parametric assumptions underlying standard confidence intervals are harder to verify. Here is an example with Poisson count data: ```{python} # Generate Poisson count data with a smooth rate function rng = np.random.default_rng(99) n = 400 x_pois = np.linspace(0, 2 * np.pi, n) true_rate = np.exp(1.0 + 0.8 * np.sin(x_pois)) y_pois = rng.poisson(true_rate).astype(float) pois_data = {"x": x_pois, "y": y_pois} # Fit conformal predictor with Poisson family predictor_pois = wk.conformal_fit( "y ~ s(x)", pois_data, method="cv+", level=0.95, family=wk.Poisson(), n_folds=5, seed=23, ) # Predict on a grid x_pois_new = np.linspace(0, 2 * np.pi, 200) result_pois = predictor_pois.predict({"x": x_pois_new}) print(f"Predicted rate range: [{result_pois.values.min():.2f}, {result_pois.values.max():.2f}]") print(f"Lower bound range: [{result_pois.lower.min():.2f}, {result_pois.lower.max():.2f}]") print(f"Upper bound range: [{result_pois.upper.min():.2f}, {result_pois.upper.max():.2f}]") ``` ```{python} # Visualize Poisson conformal intervals obs_pois = [{"x": float(x_pois[i]), "y": float(y_pois[i])} for i in range(n)] points_pois = alt.Chart({"values": obs_pois}).mark_circle( size=12, opacity=0.2, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="Count"), ) fit_pois = [ { "x": float(x_pois_new[i]), "fit": float(result_pois.values[i]), "lower": float(result_pois.lower[i]), "upper": float(result_pois.upper[i]), } for i in range(len(x_pois_new)) ] line_pois = alt.Chart({"values": fit_pois}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band_pois = alt.Chart({"values": fit_pois}).mark_area( opacity=0.15, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") true_pois = [ {"x": float(x_pois_new[i]), "true": float(np.exp(1.0 + 0.8 * np.sin(x_pois_new[i])))} for i in range(len(x_pois_new)) ] true_pois_line = alt.Chart({"values": true_pois}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q", y="true:Q") (band_pois + points_pois + line_pois + true_pois_line).properties( width="container", height=320, title="CV+ conformal prediction for Poisson counts" ) ``` ::: {.callout-tip} For Poisson and binomial models, conformal intervals on the response scale can extend below zero. This is a consequence of the additive residual-based construction. If you need intervals that respect the natural constraints of the response, clip the lower bound: `np.maximum(result.lower, 0)`. ::: ## Coverage verification for Poisson ```{python} # Test set for Poisson data x_pois_test = rng.uniform(0, 2 * np.pi, 500) y_pois_test = rng.poisson(np.exp(1.0 + 0.8 * np.sin(x_pois_test))).astype(float) cov_pois = wk.conformal_coverage( predictor_pois, {"x": x_pois_test, "y": y_pois_test}, response="y", ) print(f"Poisson CV+ coverage: {cov_pois:.4f} (nominal: 0.95)") ``` ## Practical guidance: conformal vs. Bayesian intervals Whittaker provides two fundamentally different kinds of prediction intervals. Choosing between them depends on your goals and the reliability of your model assumptions. **Use Bayesian confidence intervals** (`predict(interval="confidence")`) when: - You trust the assumed response family and link function. - You want intervals for the **mean** response $\mu(x)$, not for individual observations. - You need **conditional** intervals that are valid at each specific $x$. - You are interested in term-level uncertainty decomposition. - Computational cost matters and you want intervals from a single model fit. **Use conformal prediction intervals** (`conformal_fit()`) when: - You want intervals for **individual future observations**, not the mean. - You need a finite-sample coverage guarantee without distributional assumptions. - The response distribution may be misspecified, heavy-tailed, or heteroscedastic. - You want a sanity check on your parametric intervals. - You are comfortable with marginal (not conditional) coverage. ::: {.callout-important} Conformal and Bayesian intervals answer different questions. A Bayesian confidence interval targets the **mean** $\mu(x)$ and shrinks toward zero width as $n \to \infty$. A conformal prediction interval targets an **individual observation** $Y$ and converges to the width of the noise distribution, which does not shrink with sample size. Comparing the two directly is not meaningful unless you are clear about which quantity you are trying to cover. ::: The two approaches are complementary. In practice, a good workflow is: 1. Fit a GAM and examine the Bayesian confidence intervals for the mean response. 2. Run `conformal_fit()` with the same formula and family to get prediction intervals for individual observations. 3. Use `conformal_coverage()` on a held-out test set to verify that the intervals have the expected coverage. If the conformal intervals are much wider than expected, it may indicate model misspecification or unexplained heterogeneity that the parametric model is not capturing. ## Where to go next - **[Prediction and inference](08-prediction.qmd)**: Bayesian confidence intervals, standard errors, and term-level decomposition. - **[Diagnostics](11-diagnostics.qmd)**: residual plots and model checking to assess whether parametric intervals are trustworthy. - **[Response families](05-families.qmd)**: all supported distributions and their link functions. ### Causal inference ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Prediction and causal inference are fundamentally different tasks. A prediction model asks "what value of $Y$ do we expect given $X$?", while a causal model asks "what would happen to $Y$ if we intervened to change $D$?" A model that predicts well can give badly biased causal estimates when confounders --- variables that affect both the treatment and the outcome --- are present. Whittaker's `CausalGAM` combines the flexible nonlinear modeling of GAMs with the **double/debiased machine learning** (DML) framework of Chernozhukov et al. (2018) to produce valid causal estimates even when the relationship between confounders and outcome is highly nonlinear. ## Why prediction is not causation Consider estimating the effect of a job training program ($D$) on wages ($Y$). Participants self-select into training, and the factors driving that selection (education, prior experience) also affect wages. A naive regression of $Y$ on $D$ confounds the treatment effect with these selection effects. The core problem is **confounding**: when $X$ causes both $D$ and $Y$, the observed association between $D$ and $Y$ mixes the causal effect of $D$ with the indirect path through $X$. ::: {.callout-important} ## No statistical method can eliminate unmeasured confounding The methods on this page require that all confounders are observed and included in the model. This is the **unconfoundedness** (or selection-on-observables) assumption. If important confounders are omitted, the estimated treatment effect will be biased regardless of how flexible the model is. ::: ## The partially linear model `CausalGAM` estimates the **partially linear model**: $$Y = \theta D + g(X) + \varepsilon$$ where: - $Y$ is the outcome, - $D$ is the treatment (binary or continuous), - $X$ is a vector of confounders, - $g(X)$ is an unknown smooth function of the confounders (estimated with a GAM), - $\theta$ is the **average treatment effect** (ATE) --- the causal parameter of interest, - $\varepsilon$ is the error term, assumed to satisfy $E[\varepsilon \mid D, X] = 0$. The key insight is that $g(X)$ is a **nuisance function**: we need to estimate it to remove confounding, but we do not care about its shape. The DML framework ensures that small errors in estimating $g$ do not contaminate the estimate of $\theta$. ## The DML framework Double/debiased machine learning (Chernozhukov et al. 2018) solves two problems that arise when using flexible models for causal inference: **regularization bias** and **overfitting bias**. ### Cross-fitted residualization The DML procedure has three steps: 1. **Residualize the outcome.** Regress $Y$ on $X$ using a GAM and compute residuals: $\tilde{Y} = Y - \hat{g}(X)$. 2. **Residualize the treatment.** Regress $D$ on $X$ using a GAM and compute residuals: $\tilde{D} = D - \hat{m}(X)$. 3. **Estimate $\theta$.** Regress $\tilde{Y}$ on $\tilde{D}$ (OLS on the residuals): $\hat{\theta} = \frac{\sum \tilde{D}_i \tilde{Y}_i}{\sum \tilde{D}_i^2}$. To avoid overfitting bias, steps 1--2 use **cross-fitting**: the data is split into $K$ folds, and the residuals for each fold are computed using a model trained on the remaining $K - 1$ folds. This ensures that the residuals are not evaluated on the same data used to fit the nuisance models. ### The orthogonal moment condition The estimate $\hat{\theta}$ satisfies the **orthogonal moment condition**: $$\frac{1}{n} \sum_{i=1}^{n} \tilde{D}_i \left( \tilde{Y}_i - \hat{\theta} \tilde{D}_i \right) = 0$$ This moment is "orthogonal" in the sense that it is locally insensitive to small perturbations in the nuisance functions $\hat{g}$ and $\hat{m}$. This property --- called **Neyman orthogonality** --- is what allows the use of regularized, data-adaptive estimators (like GAMs) for the nuisance functions without introducing first-order bias into $\hat{\theta}$. ::: {.callout-note} ## Why "double" in double machine learning? The name refers to the two residualization steps: residualizing both the outcome and the treatment against the confounders. The "debiased" part comes from the orthogonal moment condition, which removes the bias that would arise from using a single residualization. ::: ## Average treatment effect (ATE) The simplest causal question is: "what is the average effect of treatment on the outcome?" This is the **average treatment effect** (ATE). Let's estimate it with simulated data where we know the true effect. ### Simulating an RCT-like dataset We generate data from a partially linear model with a known treatment effect of $\theta = 2.0$, nonlinear confounding, and a binary treatment whose probability depends on the confounders: ```{python} import numpy as np import whittaker as wk # Simulate RCT-like data with known treatment effect rng = np.random.default_rng(23) n = 1000 # Two confounders x1 = rng.normal(0, 1, n) x2 = rng.normal(0, 1, n) # Treatment depends on confounders (selection bias) propensity = 1 / (1 + np.exp(-(0.5 * x1 + 0.3 * x2))) d = rng.binomial(1, propensity, n).astype(float) # Outcome: nonlinear confounding + treatment effect of 2.0 true_theta = 2.0 g_x = np.sin(2 * x1) + x2**2 - 1 # nonlinear confounder effect y = true_theta * d + g_x + rng.normal(0, 0.5, n) data = {"y": y, "d": d, "x1": x1, "x2": x2} ``` ### Fitting the causal model ```{python} # Create and fit a CausalGAM causal = wk.CausalGAM( outcome="y", treatment="d", confounders=["x1", "x2"], method="partially_linear", n_folds=5, ) causal.fit(data, seed=23) ``` The `n_folds=5` argument controls the number of cross-fitting folds. More folds reduce overfitting bias at the cost of fitting more nuisance models. ### Extracting the treatment effect ```{python} # Get the ATE with 95% confidence interval te = causal.treatment_effect(level=0.95) print(f"Estimated ATE: {te.ate:.3f}") print(f"Standard error: {te.se:.3f}") print(f"95% CI: [{te.ci_lower:.3f}, {te.ci_upper:.3f}]") print(f"p-value: {te.p_value:.4f}") print(f"True effect: {true_theta}") ``` The estimated ATE should be close to the true value of 2.0, with a confidence interval that covers it. The p-value tests the null hypothesis $H_0: \theta = 0$ (no treatment effect). ### Model summary The `summary()` method provides a complete overview of the causal analysis: ```{python} print(causal.summary()) ``` ### Inspecting residuals The orthogonalized residuals from the cross-fitting procedure can be inspected directly. These are the $\tilde{Y}$ and $\tilde{D}$ values used to estimate $\theta$: ```{python} import altair as alt resid_y, resid_d = causal.residuals() # Plot residualized outcome vs residualized treatment resid_data = [ {"d_resid": float(resid_d[i]), "y_resid": float(resid_y[i])} for i in range(n) ] points = alt.Chart({"values": resid_data}).mark_circle( size=12, opacity=0.3, color="steelblue" ).encode( x=alt.X("d_resid:Q", title="Residualized treatment (D~)"), y=alt.Y("y_resid:Q", title="Residualized outcome (Y~)"), ) # Add the regression line (slope = ATE) x_range = np.linspace(float(resid_d.min()), float(resid_d.max()), 100) line_data = [ {"d_resid": float(x_range[i]), "y_resid": float(te.ate * x_range[i])} for i in range(len(x_range)) ] line = alt.Chart({"values": line_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="d_resid:Q", y="y_resid:Q") (points + line).properties( width="container", height=350, title="Orthogonalized residuals: slope = estimated ATE" ) ``` The slope of the red line through the residualized data is exactly the estimated ATE. After removing the confounders' effects from both $Y$ and $D$, the remaining linear relationship captures the causal effect of treatment on outcome. ::: {.callout-tip} ## Interpreting the residual plot A tight linear pattern in the residual plot indicates that the treatment effect is well-identified after conditioning on confounders. Nonlinearity or heteroscedasticity in this plot may suggest model misspecification or the presence of treatment effect heterogeneity. ::: ## Conditional average treatment effect (CATE) The ATE summarizes the treatment effect into a single number, but the effect may vary across individuals. The **conditional average treatment effect** (CATE) captures this heterogeneity: $$\tau(x) = E[Y(1) - Y(0) \mid X = x]$$ where $Y(1)$ and $Y(0)$ are potential outcomes under treatment and control. When $\tau(x)$ varies with $x$, we say there is **treatment effect heterogeneity**. `CausalGAM` estimates the CATE by fitting a smooth function of the covariates to the treatment effect surface. ### Simulating heterogeneous effects We now simulate data where the treatment effect varies with $x_1$: ```{python} # Simulate data with heterogeneous treatment effects rng = np.random.default_rng(123) n = 1200 x1 = rng.uniform(-3, 3, n) x2 = rng.normal(0, 1, n) # Treatment assignment depends on confounders propensity = 1 / (1 + np.exp(-(0.4 * x1 - 0.2 * x2))) d = rng.binomial(1, propensity, n).astype(float) # The treatment effect varies with x1: larger effect for positive x1 true_cate = 1.0 + 1.5 * np.sin(x1) g_x = 0.5 * x1**2 + x2 y = true_cate * d + g_x + rng.normal(0, 0.5, n) cate_data = {"y": y, "d": d, "x1": x1, "x2": x2} ``` ### Estimating the CATE curve ```{python} # Fit the causal model causal_het = wk.CausalGAM( outcome="y", treatment="d", confounders=["x1", "x2"], method="interactive", n_folds=5, ) causal_het.fit(cate_data, seed=123) # Estimate the CATE as a function of x1 cate_result = causal_het.cate(cate_data, variable="x1", n_points=50, level=0.95) ``` ### Plotting the CATE curve ```{python} # Build plot data for estimated CATE cate_plot_data = [ {"x1": float(cate_result.x[i]), "cate": float(cate_result.cate[i]), "lower": float(cate_result.lower[i]), "upper": float(cate_result.upper[i])} for i in range(len(cate_result.x)) ] # Build true CATE curve x1_grid = np.linspace(-3, 3, 200) true_cate_curve = 1.0 + 1.5 * np.sin(x1_grid) true_data = [ {"x1": float(x1_grid[i]), "true_cate": float(true_cate_curve[i])} for i in range(len(x1_grid)) ] # Estimated CATE with confidence band band = alt.Chart({"values": cate_plot_data}).mark_area( opacity=0.2, color="firebrick" ).encode( x=alt.X("x1:Q", title="x1"), y=alt.Y("lower:Q", title="Treatment effect"), y2="upper:Q", ) cate_line = alt.Chart({"values": cate_plot_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x1:Q", y="cate:Q") # True CATE true_line = alt.Chart({"values": true_data}).mark_line( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x1:Q", y="true_cate:Q") (band + cate_line + true_line).properties( width="container", height=350, title="CATE as a function of x1 (estimated vs. true)" ) ``` The red curve is the estimated CATE $\hat{\tau}(x_1)$ with its 95% confidence band, and the gray dashed line is the true effect $1 + 1.5 \sin(x_1)$. The GAM-based CATE estimator captures the nonlinear treatment effect heterogeneity. Regions where the confidence band is wider indicate less certainty about the effect --- typically at the edges of the data distribution. ::: {.callout-note} ## CATE interpretation A positive CATE at $x_1 = a$ means the treatment is beneficial (increases $Y$) for individuals with $x_1 = a$. A negative CATE means the treatment is harmful. If the confidence band covers zero, the effect is not statistically distinguishable from zero at that point. ::: ## Mediation analysis Sometimes we want to understand not just whether a treatment affects an outcome, but **how** it operates. Mediation analysis decomposes the total effect of $D$ on $Y$ into: - **Direct effect**: the effect of $D$ on $Y$ that does not pass through the mediator $M$. - **Indirect effect**: the effect of $D$ on $Y$ that operates through $M$. The relationship is: total effect = direct effect + indirect effect. The **proportion mediated** measures how much of the total effect is explained by the mediator. ### Example: training, skills, and wages Suppose a training program ($D$) improves wages ($Y$), but part of the effect operates through skill acquisition ($M$). We want to know how much of the wage gain is due to improved skills versus other channels (signaling, network effects, etc.). ```{python} # Simulate mediation data rng = np.random.default_rng(99) n = 800 x1 = rng.normal(0, 1, n) # Treatment (binary) d = rng.binomial(1, 0.5, n).astype(float) # Mediator: skills are affected by treatment and confounders m = 0.8 * d + 0.5 * x1 + rng.normal(0, 0.3, n) # Outcome: wages depend on treatment (directly) and mediator y = 1.0 * d + 1.5 * m + np.sin(x1) + rng.normal(0, 0.5, n) med_data = {"y": y, "d": d, "m": m, "x1": x1} ``` In this simulation: - The direct effect of $D$ on $Y$ is 1.0. - The indirect effect through $M$ is $0.8 \times 1.5 = 1.2$ (treatment increases $M$ by 0.8, and each unit of $M$ increases $Y$ by 1.5). - The total effect is $1.0 + 1.2 = 2.2$. ```{python} # Run the mediation analysis med_result = wk.mediation_analysis( outcome="y", treatment="d", mediator="m", confounders=["x1"], data=med_data, n_simulations=500, seed=23, ) print(f"Total effect: {med_result.total_effect:.3f} (SE: {med_result.total_se:.3f})") print(f"Direct effect: {med_result.direct_effect:.3f} (SE: {med_result.direct_se:.3f})") print(f"Indirect effect: {med_result.indirect_effect:.3f} (SE: {med_result.indirect_se:.3f})") print(f"Proportion mediated: {med_result.proportion_mediated:.3f}") ``` ```{python} # Horizontal bar chart decomposing the mediation effects med_bar_data = [ {"Effect": "Direct effect", "Magnitude": med_result.direct_effect}, {"Effect": "Indirect effect", "Magnitude": med_result.indirect_effect}, {"Effect": "Total effect", "Magnitude": med_result.total_effect}, ] alt.Chart({"values": med_bar_data}).mark_bar().encode( x=alt.X("Magnitude:Q", title="Effect magnitude"), y=alt.Y("Effect:N", sort=["Total effect", "Direct effect", "Indirect effect"]), color=alt.Color( "Effect:N", scale=alt.Scale( domain=["Direct effect", "Indirect effect", "Total effect"], range=["steelblue", "coral", "gray"], ), legend=None, ), ).properties(width="container", height=200, title="Mediation decomposition") ``` The results should recover the true effects: a total effect near 2.2, a direct effect near 1.0, and an indirect effect near 1.2. The proportion mediated should be approximately $1.2 / 2.2 \approx 0.55$, indicating that more than half the treatment effect operates through the mediator. ::: {.callout-warning} ## Mediation requires stronger assumptions Beyond unconfoundedness of the treatment, mediation analysis assumes that there are no unmeasured confounders of the mediator-outcome relationship. This is a stronger and often harder-to-justify assumption. Interpret mediation results with appropriate caution. ::: ## Practical guidance ### When are causal GAMs appropriate? `CausalGAM` is well-suited when: - **Confounders affect the outcome nonlinearly.** Linear adjustment may leave residual confounding if the true $g(X)$ is nonlinear. GAMs handle this automatically. - **You have observational data with a clear treatment variable.** The partially linear model is designed for settings where one variable is the "treatment" and the rest are confounders. - **The treatment effect is approximately constant or varies smoothly.** The DML framework estimates a single ATE. For heterogeneous effects, the CATE method provides smooth variation, but not arbitrary discontinuities. - **Sample sizes are moderate to large.** Cross-fitting requires enough data in each fold to fit good nuisance models. With $K = 5$ folds, each nuisance model is trained on 80% of the data. ### Key assumptions 1. **Unconfoundedness (no unmeasured confounders).** All variables that affect both $D$ and $Y$ must be included in the confounders. This is not testable from the data alone and requires domain knowledge to justify. 2. **Overlap (positivity).** For every value of the confounders, there must be a positive probability of receiving both treatment and control. If some confounder values perfectly predict treatment assignment, the effect is not identifiable in that region. 3. **Correct model structure.** The partially linear model assumes that the treatment enters linearly ($\theta D$) while confounders enter nonparametrically ($g(X)$). If the treatment effect is truly heterogeneous, the ATE is still interpretable as an average, but the CATE method is more informative. ::: {.callout-tip} ## Sensitivity analysis Since unconfoundedness cannot be tested, it is good practice to conduct sensitivity analyses: how large would an unmeasured confounder need to be to change your conclusions? While Whittaker does not yet include built-in sensitivity tools, the treatment effect estimates and standard errors provide the raw materials for manual sensitivity bounds (e.g., Rosenbaum bounds or the E-value approach). ::: ### Choosing the number of folds The `n_folds` parameter controls the bias-variance trade-off in cross-fitting: | Folds | Training fraction | Behavior | |-------|-------------------|----------| | 2 | 50% | More bias from weaker nuisance models | | 5 | 80% | Good default: balances bias and variance | | 10 | 90% | Less bias, but more computation | Five folds is the default and works well in most settings. Increase to 10 if you have a large dataset and want to minimize finite-sample bias. ### Comparison with naive regression To illustrate the value of the DML approach, consider what happens when we ignore confounding: ```{python} # Naive estimate: regress y on d without adjusting for confounders # Using the ATE simulation data from above rng = np.random.default_rng(23) n = 1000 x1 = rng.normal(0, 1, n) x2 = rng.normal(0, 1, n) propensity = 1 / (1 + np.exp(-(0.5 * x1 + 0.3 * x2))) d = rng.binomial(1, propensity, n).astype(float) true_theta = 2.0 g_x = np.sin(2 * x1) + x2**2 - 1 y = true_theta * d + g_x + rng.normal(0, 0.5, n) # Naive difference in means naive_ate = y[d == 1].mean() - y[d == 0].mean() # DML estimate causal_check = wk.CausalGAM( outcome="y", treatment="d", confounders=["x1", "x2"], method="partially_linear", n_folds=5, ) causal_check.fit({"y": y, "d": d, "x1": x1, "x2": x2}, seed=23) dml_ate = causal_check.treatment_effect().ate print(f"True ATE: {true_theta:.3f}") print(f"Naive estimate: {naive_ate:.3f}") print(f"DML estimate: {dml_ate:.3f}") ``` ```{python} # Forest-plot-style dot chart comparing estimates to the true ATE dot_data = [ {"Estimate": "True ATE", "Value": true_theta}, {"Estimate": "Naive", "Value": naive_ate}, {"Estimate": "DML", "Value": dml_ate}, ] # Vertical reference line at the true ATE rule = alt.Chart({"values": [{"x": true_theta}]}).mark_rule( color="gray", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="x:Q") points = alt.Chart({"values": dot_data}).mark_point( size=100, filled=True ).encode( x=alt.X("Value:Q", title="Treatment effect estimate"), y=alt.Y( "Estimate:N", sort=["True ATE", "Naive", "DML"], title=None, ), color=alt.Color( "Estimate:N", scale=alt.Scale( domain=["True ATE", "Naive", "DML"], range=["gray", "coral", "steelblue"], ), legend=None, ), ) (rule + points).properties( width="container", height=200, title="Naive vs. DML: bias comparison" ) ``` The naive difference-in-means estimate is biased because treated individuals tend to have confounder values that are associated with higher outcomes. The DML estimator removes this confounding and recovers the true effect. ## Where to go next - **[Conformal prediction](20-conformal.qmd)**: distribution-free prediction intervals that complement causal effect estimation. - **[Model diagnostics](11-diagnostics.qmd)**: residual checks and basis adequacy tests. - **[Prediction and inference](08-prediction.qmd)**: confidence intervals and term-level predictions from the nuisance GAM. ### Streaming and online fitting ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` When data arrives in batches (sensor readings, web logs, financial ticks), refitting a full GAM from scratch after each batch is wasteful. Whittaker's `StreamingGAM` accumulates sufficient statistics incrementally and solves a penalized regression on the running totals, giving an approximate GAM fit that updates in constant time per batch. ## The idea A standard GAM fit solves: $$(X^\top W X + \sum_j \lambda_j S_j)\,\hat\beta = X^\top W z$$ The key insight is that $X^\top W X$ and $X^\top W z$ are additive over observations. If data arrives in batches $B_1, B_2, \ldots$, we can accumulate: $$X^\top W X = \sum_t (X_t^\top W_t X_t), \qquad X^\top W z = \sum_t (X_t^\top W_t z_t)$$ and solve once on the accumulated statistics. This is the sufficient statistics approach to online learning. ## Basic streaming fit ```{python} import numpy as np import whittaker as wk # Generate data rng = np.random.default_rng(23) n = 600 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) # Create a streaming GAM sgam = wk.StreamingGAM("y ~ s(x)") # Feed data in batches of 200 batch_size = 200 for i in range(0, n, batch_size): batch = { "x": x[i : i + batch_size], "y": y[i : i + batch_size], } sgam.partial_fit(batch) # Solve the accumulated system sgam.solve() print(f"N obs: {sgam.n_obs}") print(f"N batches: {sgam.n_batches}") print(f"EDF: {sgam.edf_total:.1f}") print(f"Scale: {sgam.scale:.4f}") ``` The first call to `partial_fit()` does a pilot GAM fit to establish the model structure (basis matrices, knot locations, initial smoothing parameters). Subsequent calls accumulate the sufficient statistics without re-fitting. ## How it works step by step 1. **First batch**: a full GAM is fitted (the pilot fit) to determine the basis matrices and initial smoothing parameters. The sufficient statistics $X^\top W X$ and $X^\top W z$ are initialized. 2. **Subsequent batches**: the basis matrix $X_t$ is computed for the new data, working weights and pseudo-data are formed, and $X_t^\top W_t X_t$ and $X_t^\top W_t z_t$ are added to the running totals. 3. **Solve**: at any point, calling `solve()` performs a Cholesky factorization on the accumulated system and returns updated coefficients, EDF, and scale. ```{python} # Predictions work the same as a regular GAM x_test = np.linspace(0.5, 2 * np.pi - 0.5, 50) pred = sgam.predict({"x": x_test}) print(f"Prediction shape: {pred.values.shape}") print(f"First 5 predictions: {pred.values[:5].round(3)}") ``` ## Comparing with a full GAM A streaming GAM on all data at once should closely match a full GAM fit. ```{python} # Full GAM for comparison gam = wk.GAM("y ~ s(x)") gam.fit({"x": x, "y": y}, method="REML") # Compare predictions x_test = np.linspace(0.5, 2 * np.pi - 0.5, 50) pred_stream = sgam.predict({"x": x_test}).values pred_full = gam.predict({"x": x_test}).values # Correlation between streaming and full GAM predictions corr = np.corrcoef(pred_stream, pred_full)[0, 1] print(f"Correlation with full GAM: {corr:.4f}") ``` ```{python} import altair as alt # Plot both fits plot_data = [] for i in range(len(x_test)): plot_data.append({"x": float(x_test[i]), "y": float(pred_stream[i]), "model": "StreamingGAM"}) plot_data.append({"x": float(x_test[i]), "y": float(pred_full[i]), "model": "Full GAM"}) true_data = [{"x": float(x_test[i]), "y": float(np.sin(x_test[i])), "model": "Truth"} for i in range(len(x_test))] alt.Chart({"values": plot_data + true_data}).mark_line().encode( x=alt.X("x:Q"), y=alt.Y("y:Q"), color=alt.Color("model:N"), strokeDash=alt.condition( alt.datum.model == "Truth", alt.value([4, 4]), alt.value([0]) ), ).properties(width="container", height=300, title="Streaming GAM vs. full GAM") ``` ## Exponential decay for sliding windows In many streaming applications, recent data is more relevant than old data. The `decay` parameter applies exponential downweighting to older batches: each time a new batch arrives, the existing sufficient statistics are multiplied by `decay` before the new batch is added. ```{python} # Simulate a distribution shift: first half is sin(x), second half is 2*sin(x) rng = np.random.default_rng(23) n = 400 x = np.linspace(0, 2 * np.pi, n // 2) y1 = np.sin(x) + rng.normal(0, 0.2, n // 2) y2 = 2 * np.sin(x) + rng.normal(0, 0.2, n // 2) # Without decay: old and new data are weighted equally sgam_nodecay = wk.StreamingGAM("y ~ s(x)") sgam_nodecay.partial_fit({"x": x, "y": y1}) sgam_nodecay.partial_fit({"x": x, "y": y2}) sgam_nodecay.solve() # With decay=0.3: recent data dominates sgam_decay = wk.StreamingGAM("y ~ s(x)", decay=0.3) sgam_decay.partial_fit({"x": x, "y": y1}) sgam_decay.partial_fit({"x": x, "y": y2}) sgam_decay.solve() # Compare predictions at x = pi/2 (sin = 1) x_check = np.array([np.pi / 2]) pred_nodecay = sgam_nodecay.predict({"x": x_check}).values[0] pred_decay = sgam_decay.predict({"x": x_check}).values[0] print(f"No decay (equal weighting): {pred_nodecay:.2f}") print(f"Decay=0.3 (recent dominates): {pred_decay:.2f}") print(f"Expected (recent data): {2 * np.sin(np.pi / 2):.2f}") ``` ```{python} # Plot the predicted curves against the recent truth x_grid = np.linspace(0, 2 * np.pi, 100) pred_nd = sgam_nodecay.predict({"x": x_grid}).values pred_dc = sgam_decay.predict({"x": x_grid}).values recent_truth = 2 * np.sin(x_grid) plot_data = [] for i in range(len(x_grid)): plot_data.append({"x": float(x_grid[i]), "y": float(pred_nd[i]), "model": "No decay"}) plot_data.append({"x": float(x_grid[i]), "y": float(pred_dc[i]), "model": "Decay=0.3"}) plot_data.append({"x": float(x_grid[i]), "y": float(recent_truth[i]), "model": "Recent truth"}) alt.Chart({"values": plot_data}).mark_line().encode( x=alt.X("x:Q"), y=alt.Y("y:Q"), color=alt.Color("model:N"), strokeDash=alt.condition( alt.datum.model == "Recent truth", alt.value([4, 4]), alt.value([0]) ), ).properties(width="container", height=300, title="Exponential decay: tracking distribution shift") ``` ::: {.callout-note} ## Choosing the decay rate A decay of 1.0 (the default) gives equal weight to all batches. Values closer to 0 give more weight to recent data. A decay of 0.5 means each batch halves the weight of all previous batches. Choose based on how quickly you expect the underlying relationship to change. ::: ## Monitoring and re-estimation ### Smoothing history Each time you call `solve()`, the streaming GAM records a snapshot of the model state. You can inspect this history to monitor how the model evolves over time. ```{python} # Build a longer history rng = np.random.default_rng(23) sgam = wk.StreamingGAM("y ~ s(x)") for batch_idx in range(10): n_batch = 50 x_batch = rng.uniform(0, 2 * np.pi, n_batch) y_batch = np.sin(x_batch) + rng.normal(0, 0.3, n_batch) sgam.partial_fit({"x": x_batch, "y": y_batch}) sgam.solve() # Inspect the history history = sgam.smoothing_history() print(f"Number of snapshots: {len(history)}") for snap in history[:3]: print(f" n_obs={snap.n_obs}, batches={snap.n_batches}, edf={snap.edf_total:.1f}") ``` ```{python} # Plot EDF convergence over batches edf_data = [ {"batch": i + 1, "edf": float(snap.edf_total)} for i, snap in enumerate(history) ] alt.Chart({"values": edf_data}).mark_line(point=True).encode( x=alt.X("batch:Q", title="Batch number"), y=alt.Y("edf:Q", title="EDF total"), ).properties(width="container", height=250, title="EDF convergence over batches") ``` ### Should I refit? The `should_refit()` method checks whether enough new batches have arrived since the last solve to warrant re-estimating smoothing parameters. ```{python} # After many batches, smoothing params may need updating print(f"Should refit? {sgam.should_refit(min_batches=10)}") ``` ### Re-estimating smoothing parameters By default, `solve()` uses the smoothing parameters from the pilot fit. To re-estimate them via GCV on the accumulated statistics, pass `reestimate_smoothing=True`: ```{python} sgam.solve(reestimate_smoothing=True) print(f"Updated smoothing params: {sgam.smoothing_params}") ``` ## Predictions with standard errors ```{python} # Standard errors from the accumulated covariance x_test = np.linspace(0.5, 2 * np.pi - 0.5, 20) pred_se = sgam.predict({"x": x_test}, se=True) print(f"Prediction shape: {pred_se.values.shape}") print(f"SE shape: {pred_se.se.shape}") print(f"Mean SE: {pred_se.se.mean():.4f}") ``` ## Resetting the accumulator To start fresh while keeping the model structure (basis matrices, smoothing parameters from the pilot fit), call `reset()`: ```{python} sgam.reset() print(f"After reset: n_obs={sgam.n_obs}, n_batches={sgam.n_batches}") print(f"Model structure preserved: {sgam.is_initialised}") ``` ## Multiple smooth terms Streaming GAMs support the same formula syntax as regular GAMs: ```{python} # Two smooth terms rng = np.random.default_rng(23) n = 300 x1 = np.linspace(0, 2 * np.pi, n) x2 = rng.uniform(0, 1, n) y = np.sin(x1) + 2 * x2 + rng.normal(0, 0.3, n) sgam2 = wk.StreamingGAM("y ~ s(x1) + s(x2)") batch_size = 100 for i in range(0, n, batch_size): batch = { "x1": x1[i : i + batch_size], "x2": x2[i : i + batch_size], "y": y[i : i + batch_size], } sgam2.partial_fit(batch) sgam2.solve() print(sgam2.summary()) ``` ## Fixed smoothing parameters If you know the appropriate smoothing parameters (e.g., from a previous full fit), you can fix them to avoid the pilot fit's automatic selection: ```{python} sgam_fixed = wk.StreamingGAM("y ~ s(x)", smoothing_params=[1.0]) sgam_fixed.partial_fit({"x": x[:100], "y": y[:100]}) sgam_fixed.solve() print(f"Fixed SP: {sgam_fixed.smoothing_params}") ``` Fixing the smoothing parameters avoids the overhead of automatic selection when you already know good values from a prior full fit. ::: {.callout-tip} ## When to use StreamingGAM Use `StreamingGAM` when: - Data arrives in batches and you want to update the model without re-fitting from scratch - The full dataset is too large to hold in memory - You need to monitor model evolution over time - You want a sliding-window model with exponential decay For one-shot large-dataset fitting, consider [BigGAM or PolarsGAM](25-large-datasets.qmd) instead. ::: You can now fit GAMs incrementally on streaming data, control the decay rate for non-stationary processes, and fix smoothing parameters when they are known in advance. ## Where to go next - **[Large datasets](25-large-datasets.qmd)**: BigGAM, PolarsGAM, and DuckDBGAM for one-shot fitting on large data. - **[Model fitting](06-fitting.qmd)**: how P-IRLS and smoothness selection work under the hood. - **[Saving and loading models](38-serialization.qmd)**: persist a fitted streaming model for later use. ### Multi-response models ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` When multiple outcomes are measured on the same observations (for example, blood pressure and cholesterol, or multiple pollutant concentrations at the same monitoring stations), fitting them jointly can capture shared structure and estimate residual correlations. Whittaker's `MultiResponseGAM` fits a separate GAM per response but provides a unified interface for prediction, diagnostics, and correlation estimation. ## Basic multi-response model ```{python} import numpy as np import whittaker as wk # Generate two correlated responses driven by the same smooth rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) e1 = rng.normal(0, 0.3, n) e2 = 0.7 * e1 + rng.normal(0, 0.2, n) # correlated errors y1 = np.sin(x) + e1 y2 = 0.5 * np.sin(x) + 1.5 + e2 data = {"x": x, "y1": y1, "y2": y2} ``` The two responses share the same smooth predictor `x` but have different signal strengths and correlated residuals. We fit them jointly: ```{python} # Fit a multi-response GAM with a shared smooth formula model = wk.MultiResponseGAM(["y1", "y2"], "s(x)") model.fit(data, method="REML") print(f"Responses: {model.responses}") print(f"N responses: {model.n_responses}") print(f"Is fitted: {model.is_fitted}") ``` The first argument lists the response column names. The second is the shared formula (covariates only, no response side). Each response gets its own GAM with the same smooth structure. ## Predictions ```{python} # Predict both responses on new data x_new = np.linspace(0, 2 * np.pi, 100) result = model.predict({"x": x_new}) # Access predictions by response name print(f"y1 predictions shape: {result['y1'].values.shape}") print(f"y2 predictions shape: {result['y2'].values.shape}") ``` ```{python} import altair as alt # Plot both response fits plot_data = [] for i in range(len(x_new)): plot_data.append({"x": float(x_new[i]), "y": float(result["y1"].values[i]), "response": "y1"}) plot_data.append({"x": float(x_new[i]), "y": float(result["y2"].values[i]), "response": "y2"}) alt.Chart({"values": plot_data}).mark_line(strokeWidth=2).encode( x=alt.X("x:Q"), y=alt.Y("y:Q"), color=alt.Color("response:N"), ).properties(width="container", height=300, title="Multi-response GAM predictions") ``` ## Predictions with standard errors ```{python} # Standard errors for each response result_se = model.predict({"x": x_new}, se=True) print(f"y1 SE mean: {result_se['y1'].se.mean():.4f}") print(f"y2 SE mean: {result_se['y2'].se.mean():.4f}") ``` ## Residual correlation When responses are modeled jointly, the residuals often carry shared information that the smooth terms do not capture. Estimating this residual correlation is useful for understanding the unexplained association between outcomes. ```{python} # Fit with unstructured residual correlation model_corr = wk.MultiResponseGAM( ["y1", "y2"], "s(x)", correlation="unstructured", ) model_corr.fit(data, method="REML") # Estimate residual correlation rc = model_corr.residual_correlation() print(rc) ``` The residual correlation matrix shows how much the residuals of each response pair co-vary after accounting for the smooth terms. A strong positive correlation means the unexplained variation in one response tends to go in the same direction as the other. ```{python} # Access the raw matrices print(f"Covariance matrix:\n{rc.covariance.round(4)}") print(f"\nCorrelation matrix:\n{rc.correlation.round(4)}") ``` ```{python} # Heatmap of the residual correlation matrix responses = model_corr.responses records = [ {"row": r, "col": c, "corr": float(rc.correlation[i, j])} for i, r in enumerate(responses) for j, c in enumerate(responses) ] base = alt.Chart({"values": records}) heatmap = base.mark_rect().encode( x=alt.X("col:N", title=None), y=alt.Y("row:N", title=None), color=alt.Color( "corr:Q", scale=alt.Scale(scheme="redblue", domain=[-1, 1]), title="Correlation", ), ) labels = base.mark_text(fontSize=12).encode( x="col:N", y="row:N", text=alt.Text("corr:Q", format=".3f"), color=alt.condition( alt.datum.corr > 0.5, alt.value("white"), alt.value("black") ), ) (heatmap + labels).properties(width="container", height=250, title="Residual correlation matrix") ``` ## Joint prediction For applications that need all responses in a single matrix (e.g., multivariate downstream analysis), use `joint_predict()`: ```{python} # Joint prediction returns (n x k) matrix and optional covariance preds_matrix, cov_matrix = model_corr.joint_predict({"x": x_new}) print(f"Joint predictions shape: {preds_matrix.shape}") print(f"Covariance matrix shape: {cov_matrix.shape}") ``` ## Per-response model access You can extract the fitted GAM for any individual response for further inspection: ```{python} # Get the individual GAM for y1 gam_y1 = model.response_model("y1") print(f"y1 GAM fitted: {gam_y1.is_fitted}") print(f"y1 EDF: {gam_y1.edf_total:.1f}") ``` ## EDF and deviance per response ```{python} # Effective degrees of freedom per response edfs = model.edf() print(f"EDF: {edfs}") # Deviance per response devs = model.deviance() print(f"Deviance: {devs}") ``` ## Response-specific formulas Sometimes different responses need different covariates. Use `response_formulas` to add response-specific terms on top of the shared formula: ```{python} # Add an extra covariate z that only affects y1 rng = np.random.default_rng(23) z = rng.uniform(0, 1, n) data_extra = {"x": x, "z": z, "y1": y1 + 2 * z, "y2": y2} model_specific = wk.MultiResponseGAM( ["y1", "y2"], "s(x)", response_formulas={"y1": "s(z)"}, # y1 gets an extra smooth for z ) model_specific.fit(data_extra, method="REML") # y1 should have higher EDF because it has an extra smooth print(f"y1 EDF: {model_specific.edf()['y1']:.1f}") print(f"y2 EDF: {model_specific.edf()['y2']:.1f}") ``` ## Three or more responses `MultiResponseGAM` works with any number of responses (minimum 2): ```{python} # Three responses y3 = np.cos(x) + rng.normal(0, 0.3, n) data_three = {"x": x, "y1": y1, "y2": y2, "y3": y3} model_three = wk.MultiResponseGAM( ["y1", "y2", "y3"], "s(x)", correlation="unstructured", ) model_three.fit(data_three, method="REML") # 3x3 correlation matrix rc3 = model_three.residual_correlation() print(rc3) ``` ## Summary The `summary()` method reports per-response fit statistics, including EDF, deviance, and smoothing parameters for each response. ```{python} print(model_corr.summary()) ``` ::: {.callout-tip} ## When to use MultiResponseGAM Use `MultiResponseGAM` when: - You want to fit multiple outcomes with shared covariates in a single call - You need to estimate residual correlations between outcomes - You want a unified prediction interface for multiple responses - Different responses may need different additional terms via `response_formulas` If the responses are truly independent and you do not need correlation estimates, fitting separate `GAM` objects is equivalent and may be simpler. ::: You can now fit multi-response GAMs with shared or response-specific formulas, estimate residual correlations, and access per-response models for further inspection. ## Where to go next - **[Prediction and inference](08-prediction.qmd)**: confidence intervals and term-level predictions for individual response models. - **[Model diagnostics](11-diagnostics.qmd)**: check each response model's residuals and basis adequacy. - **[Functional regression](24-functional.qmd)**: another multi-predictor setting where the covariates are entire curves. ### Functional regression ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` In many applications, the predictor is not a single number but a whole curve observed over a domain: a spectrum over wavelengths, a temperature profile over time, an fMRI signal over brain regions. **Functional regression** models how a scalar response depends on these functional covariates. Whittaker's `FunctionalGAM` implements scalar-on-function regression, where the response $y$ is scalar and one or more predictors $X_i(t)$ are functions. The model is: $$y_i = \beta_0 + \int X_i(t)\,\beta(t)\,dt + \varepsilon_i$$ where $\beta(t)$ is a smooth coefficient function that describes how each point along the functional domain contributes to the response. The integral is approximated by numerical quadrature, and $\beta(t)$ is expanded in a B-spline or Fourier basis with a roughness penalty to ensure smoothness. ## Generating functional data To illustrate, we simulate data where the coefficient function is a sine wave: points along the curve where the true $\beta(t) > 0$ increase the response, and points where $\beta(t) < 0$ decrease it. ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(23) n = 200 # number of observations T = 50 # number of grid points per curve t_grid = np.linspace(0, 1, T) # True coefficient function: a sine wave beta_true = np.sin(2 * np.pi * t_grid) # Each observation is a random walk (smooth curve) X_func = np.zeros((n, T)) for i in range(n): X_func[i, :] = rng.normal(0, 1, T).cumsum() / np.sqrt(T) # Response: integral of X(t)*beta(t) + noise dt = 1.0 / (T - 1) w = np.full(T, dt) w[0] = dt / 2 w[-1] = dt / 2 y = X_func @ (beta_true * w) + rng.normal(0, 0.3, n) print(f"Functional covariate shape: {X_func.shape}") print(f"Response shape: {y.shape}") ``` Each row of `X_func` is one observation's curve, sampled at `T` equally-spaced grid points over the domain $[0, 1]$. The data dictionary stores the functional covariate as a 2-D array and the response as a 1-D array. ## Fitting the model ```{python} # Specify the functional term model = wk.FunctionalGAM( response="y", functional_terms=[ wk.FunctionalTerm(name="curves", domain=(0, 1), n_basis=15), ], ) # Fit the model model.fit({"curves": X_func, "y": y}, method="REML") print(f"EDF total: {model.edf_total:.1f}") print(f"Scale: {model.scale:.4f}") print(f"Deviance: {model.deviance:.2f}") ``` The `FunctionalTerm` specifies: - `name`: the key in the data dictionary (must be a 2-D array) - `domain`: the endpoints of the functional argument (here $[0, 1]$) - `n_basis`: number of basis functions for expanding $\beta(t)$ (default 15) - `basis`: `"bspline"` (default) or `"fourier"` ## Extracting the coefficient function The estimated $\hat\beta(t)$ tells you how each point along the functional domain influences the response. Positive values at time $t$ mean that higher values of the curve at $t$ increase the response while negative values mean they decrease it. ```{python} # Extract the estimated coefficient function with confidence intervals cf = model.coefficient_function("curves", n_grid=200, level=0.95) print(f"Grid shape: {cf.grid.shape}") print(f"Values shape: {cf.values.shape}") print(f"SE shape: {cf.se.shape}") ``` ```{python} import altair as alt # Plot the estimated coefficient function against the truth cf_data = [ {"t": float(cf.grid[i]), "beta": float(cf.values[i]), "lower": float(cf.lower[i]), "upper": float(cf.upper[i])} for i in range(len(cf.grid)) ] true_data = [ {"t": float(cf.grid[i]), "beta": float(np.sin(2 * np.pi * cf.grid[i]))} for i in range(len(cf.grid)) ] # Confidence band band = alt.Chart({"values": cf_data}).mark_area(opacity=0.2, color="steelblue").encode( x=alt.X("t:Q", title="t (functional domain)"), y="lower:Q", y2="upper:Q", ) # Estimated beta(t) estimate = alt.Chart({"values": cf_data}).mark_line(color="steelblue", strokeWidth=2).encode( x="t:Q", y=alt.Y("beta:Q", title="beta(t)"), ) # True beta(t) truth = alt.Chart({"values": true_data}).mark_line( color="firebrick", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="t:Q", y="beta:Q") (band + estimate + truth).properties( width="container", height=300, title="Estimated coefficient function (blue) vs. truth (red dashed)" ) ``` The blue curve is $\hat\beta(t)$, the shaded band is the 95% pointwise confidence interval, and the red dashed line is the true $\beta(t) = \sin(2\pi t)$. The GAM recovers the shape of the coefficient function, showing that the functional predictor's contribution to the response varies sinusoidally across the domain. ## Prediction ```{python} # Predict on the first 10 observations pred = model.predict({"curves": X_func[:10], "y": y[:10]}) print(f"Predictions: {pred[:5].round(3)}") # With standard errors mu, se = model.predict({"curves": X_func[:10], "y": y[:10]}, se=True) print(f"SEs: {se[:5].round(4)}") ``` ```{python} # Predicted vs. observed for the first 10 observations scatter_data = [ {"observed": float(y[i]), "predicted": float(pred[i])} for i in range(10) ] obs_range = [min(y[:10]), max(y[:10])] points = alt.Chart({"values": scatter_data}).mark_circle(size=50, color="steelblue").encode( x=alt.X("observed:Q", title="Observed"), y=alt.Y("predicted:Q", title="Predicted"), ) line_data = [{"v": float(obs_range[0])}, {"v": float(obs_range[1])}] ref_line = alt.Chart({"values": line_data}).mark_line( strokeDash=[4, 4], color="firebrick" ).encode(x="v:Q", y="v:Q") (points + ref_line).properties(width="container", height=300, title="Predicted vs. observed") ``` ## Using a Fourier basis For functional covariates with periodic structure (e.g., spectral data, seasonal patterns), a Fourier basis may be more natural than B-splines: ```{python} model_fourier = wk.FunctionalGAM( response="y", functional_terms=[ wk.FunctionalTerm(name="curves", basis="fourier", domain=(0, 1), n_basis=15), ], ) model_fourier.fit({"curves": X_func, "y": y}, method="REML") cf_fourier = model_fourier.coefficient_function("curves") print(f"Fourier basis EDF: {model_fourier.edf_total:.1f}") ``` ::: {.callout-tip} ## Choosing the basis - **B-splines** (`basis="bspline"`, default): flexible, no periodicity assumption, good for most applications. - **Fourier** (`basis="fourier"`): natural for periodic signals, where $\beta(t)$ is expected to be a sum of sines and cosines. The penalty shrinks higher frequencies, giving a smooth estimate. ::: ## Multiple functional terms You can include more than one functional covariate: ```{python} # Two functional predictors rng = np.random.default_rng(23) T2 = 30 t2 = np.linspace(0, 2, T2) beta2 = t2**2 - t2 # quadratic coefficient function on [0, 2] X2 = rng.normal(0, 1, (n, T2)).cumsum(axis=1) / np.sqrt(T2) dt2 = 2.0 / (T2 - 1) w2 = np.full(T2, dt2) w2[0] = w2[-1] = dt2 / 2 y2 = X_func @ (beta_true * w) + X2 @ (beta2 * w2) + rng.normal(0, 0.3, n) model_two = wk.FunctionalGAM( response="y", functional_terms=[ wk.FunctionalTerm(name="f1", domain=(0, 1), n_basis=15), wk.FunctionalTerm(name="f2", domain=(0, 2), n_basis=12), ], ) model_two.fit({"f1": X_func, "f2": X2, "y": y2}, method="REML") print(model_two.summary()) ``` ```{python} # Extract coefficient functions for both functional terms cf1 = model_two.coefficient_function("f1", n_grid=200) cf2 = model_two.coefficient_function("f2", n_grid=200) # Panel for f1: estimated vs. true beta = sin(2*pi*t) cf1_data = [{"t": float(cf1.grid[i]), "beta": float(cf1.values[i]), "type": "Estimated"} for i in range(len(cf1.grid))] true1_data = [{"t": float(cf1.grid[i]), "beta": float(np.sin(2 * np.pi * cf1.grid[i])), "type": "True"} for i in range(len(cf1.grid))] est1 = alt.Chart({"values": cf1_data}).mark_line(color="steelblue", strokeWidth=2).encode( x=alt.X("t:Q", title="t"), y=alt.Y("beta:Q", title="beta(t)"), ) truth1 = alt.Chart({"values": true1_data}).mark_line( color="firebrick", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="t:Q", y="beta:Q") panel1 = (est1 + truth1).properties(width="container", height=250, title="f1: sin(2*pi*t)") # Panel for f2: estimated vs. true beta = t^2 - t cf2_data = [{"t": float(cf2.grid[i]), "beta": float(cf2.values[i]), "type": "Estimated"} for i in range(len(cf2.grid))] true2_data = [{"t": float(cf2.grid[i]), "beta": float(cf2.grid[i]**2 - cf2.grid[i]), "type": "True"} for i in range(len(cf2.grid))] est2 = alt.Chart({"values": cf2_data}).mark_line(color="steelblue", strokeWidth=2).encode( x=alt.X("t:Q", title="t"), y=alt.Y("beta:Q", title="beta(t)"), ) truth2 = alt.Chart({"values": true2_data}).mark_line( color="firebrick", strokeDash=[4, 4], strokeWidth=1.5 ).encode(x="t:Q", y="beta:Q") panel2 = (est2 + truth2).properties(width="container", height=250, title="f2: t^2 - t") panel1 | panel2 ``` ## Mixed models: functional + scalar terms Often you have both functional and scalar predictors. The `scalar_terms` parameter adds standard GAM smooth terms alongside the functional terms: ```{python} # Add a scalar covariate x_scalar = np.linspace(0, 2 * np.pi, n) y_mixed = X_func @ (beta_true * w) + np.sin(x_scalar) + rng.normal(0, 0.3, n) model_mixed = wk.FunctionalGAM( response="y", functional_terms=[wk.FunctionalTerm(name="curves", domain=(0, 1))], scalar_terms="s(temp)", ) model_mixed.fit({"curves": X_func, "temp": x_scalar, "y": y_mixed}, method="REML") print(model_mixed.summary()) ``` The scalar terms use the standard GAM smooth machinery (TPRS, P-splines, etc.) while the functional terms use basis expansion and numerical integration. ## Specifying terms as dictionaries For convenience, functional terms can also be specified as plain dictionaries: ```{python} model_dict = wk.FunctionalGAM( response="y", functional_terms=[ {"name": "curves", "basis": "bspline", "domain": (0, 1), "n_basis": 20}, ], ) model_dict.fit({"curves": X_func, "y": y}, method="REML") print(f"EDF: {model_dict.edf_total:.1f}") ``` Dictionary specification is convenient when building terms programmatically or reading configurations from files. ::: {.callout-note} ## Data format for functional covariates Functional covariates must be 2-D NumPy arrays of shape `(n, T)` where `n` is the number of observations and `T` is the number of grid points. The grid points are assumed equally spaced over the `domain`. Scalar covariates and the response are 1-D arrays as usual. ::: ## Summary The `summary()` method reports the functional terms with their basis type, number of basis functions, domain, and effective degrees of freedom. ```{python} print(model.summary()) ``` You can now fit scalar-on-function regression models with one or more functional covariates, extract and visualize the estimated coefficient functions, and combine functional terms with standard smooth terms. ## Where to go next - **[Data input](07-data-input.qmd)**: how to prepare data dictionaries, including 2-D arrays for functional covariates. - **[Multi-response models](23-multi-response.qmd)**: jointly model multiple scalar responses. - **[Smooth terms](04-smooths.qmd)**: the smooth types available for scalar covariates in mixed models. - **[Model diagnostics](11-diagnostics.qmd)**: residual checks for functional GAM fits. ## Scalability ### Large datasets ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Standard GAM fitting builds the full $n \times p$ design matrix, which becomes memory-prohibitive when $n$ is in the millions. Whittaker provides three scalable backends that use discretised covariates and streaming aggregation to fit GAMs on large datasets without materializing the full matrix: | Class | Data source | Key feature | |-------|------------|-------------| | `BigGAM` | In-memory dict | Discretised P-IRLS on NumPy arrays | | `PolarsGAM` | Polars DataFrames, LazyFrames, or files | Lazy streaming via Polars | | `DuckDBGAM` | DuckDB tables or SQL queries | SQL-native streaming via Arrow | All three extend `GAM`, so prediction, summary, diagnostics, and serialization work the same way as a regular GAM once fitted. ## BigGAM: discretised fitting `BigGAM` implements the discretised P-IRLS algorithm of Wood, Li & Shaddick (2017). Instead of storing one row per observation, each covariate is discretised into a grid of `n_discrete` unique values (default 200), and the sufficient statistics are accumulated over the grid. This reduces memory from $O(n \cdot p)$ to $O(d \cdot p)$ where $d \ll n$. ```{python} import numpy as np import whittaker as wk # Simulate a dataset large enough to demonstrate discretised fitting rng = np.random.default_rng(23) n = 2_000 x1 = rng.uniform(0, 2 * np.pi, n) x2 = rng.uniform(0, 1, n) y = np.sin(x1) + 2 * x2 + rng.normal(0, 0.3, n) data = {"x1": x1, "x2": x2, "y": y} # BigGAM uses the same formula syntax model = wk.BigGAM("y ~ s(x1) + s(x2)", n_discrete=200) model.fit(data, method="fREML") print(model.summary()) ``` The `method="fREML"` (fast REML) is the default and recommended smoothing parameter selection method for `BigGAM`. It exploits the discretised structure for efficient computation of the REML criterion. ### Controlling the discretisation grid The `n_discrete` parameter controls the resolution of the covariate grid. Larger values give a more faithful approximation but use more memory and time. ```{python} # Compare discretisation resolutions for nd in [50, 200]: m = wk.BigGAM("y ~ s(x1) + s(x2)", n_discrete=nd) m.fit(data, method="fREML") print(f"n_discrete={nd:3d}: EDF = {m.edf_total:.1f}, scale = {m.scale:.4f}") ``` ::: {.callout-tip} ## Choosing n_discrete For most datasets, the default of 200 is a good balance. You rarely need more than 500. If the true function is very smooth, even 50 can work well. Increase `n_discrete` if the model diagnostics suggest the discretisation is too coarse, but check `model.check()` first since basis dimension inadequacy is more common than discretisation error. ::: ### Predictions Predictions work exactly like a standard GAM: ```{python} x_new = np.linspace(0, 2 * np.pi, 100) pred = model.predict({"x1": x_new, "x2": np.full(100, 0.5)}) print(f"Prediction shape: {pred.values.shape}") ``` ```{python} import altair as alt # Compare BigGAM fit with the true function true_vals = np.sin(x_new) + 2 * 0.5 plot_data = [ {"x1": float(x_new[i]), "y": float(pred.values[i]), "source": "BigGAM"} for i in range(len(x_new)) ] + [ {"x1": float(x_new[i]), "y": float(true_vals[i]), "source": "Truth"} for i in range(len(x_new)) ] alt.Chart({"values": plot_data}).mark_line(strokeWidth=2).encode( x=alt.X("x1:Q", title="x1"), y=alt.Y("y:Q", title="f(x1) at x2=0.5"), color=alt.Color("source:N"), strokeDash=alt.condition( alt.datum.source == "Truth", alt.value([4, 4]), alt.value([0]) ), ).properties(width="container", height=300, title="BigGAM fit vs. truth (2,000 observations)") ``` ## PolarsGAM: fitting from Polars and files `PolarsGAM` extends `BigGAM` to accept Polars DataFrames, LazyFrames, or file paths. Data is streamed in chunks, so the full dataset never needs to be in memory at once. ### From a Polars DataFrame ```{python} import polars as pl # Create a Polars DataFrame df = pl.DataFrame({"x1": x1, "x2": x2, "y": y}) model_pl = wk.PolarsGAM("y ~ s(x1) + s(x2)", chunk_size=2_000) model_pl.fit(df, method="fREML") print(f"Rows processed: {model_pl.n_rows}") print(f"Chunk size: {model_pl.chunk_size}") print(f"EDF: {model_pl.edf_total:.1f}") ``` ### From a LazyFrame or file `PolarsGAM` can scan Parquet, CSV, IPC (Arrow), and NDJSON files directly. The file is read lazily via Polars' streaming engine, so only one chunk is in memory at a time: ```{python} # Save to Parquet for demonstration import tempfile, pathlib tmpdir = pathlib.Path(tempfile.mkdtemp()) parquet_path = tmpdir / "data.parquet" df.write_parquet(parquet_path) # Fit directly from the file model_file = wk.PolarsGAM("y ~ s(x1) + s(x2)", chunk_size=2_000) model_file.fit(str(parquet_path), method="fREML") print(f"EDF from Parquet: {model_file.edf_total:.1f}") ``` ::: {.callout-note} ## Supported file formats | Extension | Reader | |-----------|--------| | `.parquet` | `pl.scan_parquet()` | | `.csv` | `pl.scan_csv()` | | `.ipc`, `.arrow` | `pl.scan_ipc()` | | `.ndjson` | `pl.scan_ndjson()` | The format is detected from the file extension. ::: ## DuckDBGAM: fitting from SQL `DuckDBGAM` streams data from DuckDB tables or SQL queries via DuckDB's Arrow interface. This is ideal when the data lives in a database or when you want to filter and transform data with SQL before fitting. ```{python} import duckdb # Create a DuckDB connection and load data conn = duckdb.connect() conn.execute("CREATE TABLE obs AS SELECT * FROM read_parquet(?)", [str(parquet_path)]) # Fit from a table name model_duck = wk.DuckDBGAM("y ~ s(x1) + s(x2)", chunk_size=2_000) model_duck.fit("obs", conn, method="fREML") print(f"EDF from DuckDB: {model_duck.edf_total:.1f}") del model_pl, model_file, df ``` ### SQL queries Use `fit_query()` to fit from an arbitrary SQL query: ```{python} # Fit on a filtered subset via SQL model_filtered = wk.DuckDBGAM("y ~ s(x1)", chunk_size=2_000) model_filtered.fit_query( "SELECT x1, y FROM obs WHERE x2 > 0.5", conn, method="fREML", ) print(f"EDF (filtered): {model_filtered.edf_total:.1f}") ``` ::: {.callout-tip} ## When to use DuckDBGAM Use `DuckDBGAM` when: - Your data is already in DuckDB (a common analytics stack) - You want to pre-filter or transform data with SQL before fitting - The dataset is too large for a Polars scan (DuckDB's out-of-core engine handles very large files) ::: ## Smoothing parameter selection All three scalable backends support the same selection methods: | Method | Description | |--------|-------------| | `"fREML"` | Fast REML (default, recommended for large data) | | `"REML"` | Restricted maximum likelihood | | `"ML"` | Maximum likelihood | | `"GCV"` | Generalised cross-validation | `fREML` is specifically optimised for discretised fitting and is significantly faster than standard REML on large datasets. ## Smooth selection The `select=True` option enables double-penalty smooth selection, which can shrink entire terms to zero: ```{python} # Add a noise variable noise = rng.uniform(0, 1, n) data_noise = {"x1": x1, "x2": x2, "noise": noise, "y": y} model_select = wk.BigGAM("y ~ s(x1) + s(x2) + s(noise)", n_discrete=200) model_select.fit(data_noise, method="fREML", select=True) print(model_select.summary()) ``` The noise term should receive a very low EDF, effectively being selected out. ## Non-Gaussian families Scalable backends work with all response families: ```{python} # Poisson counts rng = np.random.default_rng(23) n_pois = 2_000 x = rng.uniform(0, 2 * np.pi, n_pois) mu = np.exp(0.5 + 0.8 * np.sin(x)) y_pois = rng.poisson(mu).astype(float) model_pois = wk.BigGAM("y ~ s(x)", family=wk.Poisson(), n_discrete=200) model_pois.fit({"x": x, "y": y_pois}, method="fREML") print(f"Poisson BigGAM EDF: {model_pois.edf_total:.1f}") ``` ## Which backend to choose ```{python} #| echo: false #| output: true ``` | Scenario | Backend | Why | |----------|---------|-----| | Data fits in memory | `BigGAM` | Simplest API, dict input | | Data in Polars or files | `PolarsGAM` | Lazy streaming, zero-copy | | Data in DuckDB / SQL | `DuckDBGAM` | SQL filtering, out-of-core | | Incremental batches | `StreamingGAM` | Online updates, decay | For datasets under ~100K rows, the standard `GAM` is usually fast enough. Above that, `BigGAM` or its streaming variants keep fitting times manageable without sacrificing accuracy. ```{python} # Clean up conn.close() import shutil shutil.rmtree(tmpdir) ``` You can now choose the right scalable backend for your data source and fit GAMs on datasets that would not fit in memory with the standard `GAM` class. ## Where to go next - **[Streaming and online fitting](22-streaming.qmd)**: incremental fitting when data arrives in batches over time. - **[Model fitting](06-fitting.qmd)**: how P-IRLS and smoothness selection work in the standard pipeline. - **[Saving and loading models](38-serialization.qmd)**: persist a fitted large-data model for deployment. ## Advanced features ### Variational inference ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Whittaker's default fitting pipeline uses P-IRLS to find the posterior mode $\hat\beta$ and then treats the **Laplace approximation** $q(\beta) = \mathcal{N}(\hat\beta,\, V_\beta)$ as the posterior, where $V_\beta = \phi\,(X^\top W X + \sum_j \lambda_j S_j)^{-1}$. For Gaussian response this is exact. For non-Gaussian families (Poisson, Binomial, Gamma) it is an approximation that can underestimate posterior spread when the likelihood surface is skewed. **Variational inference (VI)** replaces the Laplace approximation with a better-calibrated posterior, chosen by maximizing the Evidence Lower BOund (ELBO): $$\text{ELBO}(\phi) = \mathbb{E}_q[\log p(y \mid \beta)] - \text{KL}\bigl(q(\beta) \|\, p(\beta \mid \boldsymbol\lambda)\bigr)$$ The key practical benefit is more accurate uncertainty quantification for non-Gaussian families, especially at small-to-moderate sample sizes, without the cost or complexity of MCMC. ## When to use VI Use `method="VI"` when: - your response is non-Gaussian (e.g., Poisson, Binomial, Gamma, etc.) **and** you want well-calibrated and credible intervals rather than Wald-style confidence intervals - sample sizes are moderate ($n \lesssim 5000$) and the posterior may be skewed - you want a principled probabilistic posterior (not just a point estimate with a heuristic covariance) but MCMC is too slow for your use case For large $n$, the Laplace approximation is already very accurate and `method="REML"` (the default) is both faster and nearly as good. For Gaussian response, VI and Laplace give identical results. Whittaker detects this and skips the optimization entirely. ## Basic usage Pass `method="VI"` to `fit()` (everything else stays the same). ```{python} import numpy as np import whittaker as wk from whittaker.families.poisson import Poisson rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) lam = np.exp(1.5 * np.sin(x)) y = rng.poisson(lam).astype(float) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=Poisson()) model.fit(data, method="VI") model.summary() ``` The summary shows `Inference: Variational Bayes` and the final ELBO instead of GCV/AIC/BIC, which are not defined for VI fits. ## The variational family Whittaker uses a **full-rank Gaussian** variational family: $$q(\beta) = \mathcal{N}(m,\, C), \qquad C = LL^\top$$ where $m \in \mathbb{R}^p$ is the variational mean and $L$ is a lower-triangular Cholesky factor with positive diagonal. This parameterization guarantees positive-definiteness throughout optimization and it yields numerically stable gradients. Full-rank covariance is a good default for GAMs: smooth coefficients within a term are heavily correlated by construction, and a diagonal (mean-field) approximation would produce incorrect uncertainty estimates for the smooth curves. The parameters $(m, L)$ are initialized from the P-IRLS solution (VI is a refinement of the Laplace approximation and not a replacement) and then optimized with the Adam optimizer. ## Predictions and uncertainty `predict(se=True)` and confidence intervals work exactly as with any other fitting method. Standard errors are derived from the variational posterior covariance $C$: $$\text{SE}(\hat\eta_i) = \sqrt{x_i^\top C\, x_i}$$ ```{python} import altair as alt x_new = np.linspace(0, 2 * np.pi, 200) preds = model.predict({"x": x_new}, se=True, interval="confidence") obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="count"), ) fit_data = [ { "x": float(x_new[i]), "fit": float(preds.values[i]), "lower": float(preds.lower[i]), "upper": float(preds.upper[i]), } for i in range(len(x_new)) ] line = alt.Chart({"values": fit_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") (band + line + points).properties( title="Poisson GAM with VI: fitted curve and 95% credible interval", width=500, height=300, ) ``` ## Inspecting the variational result The `vi_result` property returns the underlying `VIResult` object with detailed information about the optimization: ```{python} vr = model.vi_result print(f"ELBO at convergence: {vr.elbo:.4f}") print(f"Iterations: {vr.n_iter}") print(f"Converged: {vr.converged}") print(f"Posterior covariance shape: {vr.posterior_cov.shape}") ``` Note that `vi_result` will be `None` if the model was fitted with any other method. ### ELBO trace The ELBO should increase monotonically (or nearly so) as optimization proceeds. A non-monotone trace suggests that the learning rate is too high. Plot `elbo_history` to diagnose: ```{python} elbo_data = [ {"iteration": i, "ELBO": float(vr.elbo_history[i])} for i in range(len(vr.elbo_history)) ] alt.Chart({"values": elbo_data}).mark_line(color="steelblue").encode( x=alt.X("iteration:Q", title="Iteration"), y=alt.Y("ELBO:Q", title="ELBO"), ).properties( title="ELBO convergence trace", width=500, height=250, ) ``` A trace that rises quickly and then flattens is ideal. Oscillations indicate `lr` should be reduced. A trace that rises very slowly may benefit from a larger `lr` or more iterations. ## Posterior samples `posterior_samples(n)` draws coefficient vectors directly from $q(\beta) = \mathcal{N}(m, C)$: ```{python} beta_samples = model.posterior_samples(n=500, seed=23) print(f"Shape: {beta_samples.shape}") # (p, 500) ``` This also works after `method="REML"` or `"GCV"` fits (in that case samples are drawn from the Laplace posterior), so code that uses `posterior_samples()` is inference-method agnostic. ## Simulating from the posterior `simulate()` uses the posterior coefficient samples to propagate uncertainty through the response distribution: ```{python} sims = model.simulate(n_sim=200, seed=0) print(f"Simulations shape: {sims.shape}") # (n, 200): integer count draws ``` ## Controlling VI Pass a `vi_options=` dict to `fit()` to override any of the optimizer settings: ```python model = wk.GAM("y ~ s(x)", family=Poisson()).fit( data, method="VI", vi_options={ "lr": 0.005, # Adam learning rate (default 0.01) "max_iter": 2000, # maximum optimizer steps (default 1000) "tol": 1e-5, # relative ELBO change threshold (default of 1e-4) "patience": 10, # consecutive steps below tol before stopping (the default is 5) "n_quad": 30, # Gauss-Hermite quadrature points (default: 20) "seed": 23, # seed for reproducibility }, ) ``` ### Block-diagonal covariance For large models with many smooth terms, the full $p \times p$ Cholesky factor can be expensive to run. `cov_structure="block"` assigns one Cholesky block per smooth term, dropping cross-term correlations: ```python model = wk.GAM("y ~ s(x1) + s(x2) + s(x3)", family=Poisson()).fit( data, method="VI", vi_options={"cov_structure": "block"}, ) ``` The cost drops from $O(p^2)$ to $O(\sum_j k_j^2)$, where $k_j$ is the basis dimension of the $j$-th smooth. For a model with 5 terms each with $k = 10$, this is a 5x reduction in parameters. ### Variational scale parameter For families with a separate scale parameter $\phi$ (Gamma, InverseGaussian), the default is to fix $\phi$ at its P-IRLS estimate. Setting `phi_inference="variational"` includes $\log\phi$ in the variational family as a log-normal marginal $q(\phi) = \text{LogNormal}(\mu_\phi, \sigma_\phi^2)$, which gives better-calibrated intervals when $n$ is small: ```python from whittaker.families.gamma import Gamma model = wk.GAM("y ~ s(x)", family=Gamma()).fit( data, method="VI", vi_options={"phi_inference": "variational"}, ) vr = model.vi_result print(f"log φ mean: {vr.log_phi_mean:.4f}") print(f"log φ variance: {vr.log_phi_var:.4f}") ``` ## Gaussian fast path For Gaussian response with the identity link, the Laplace approximation is the exact posterior (no optimization is needed). Whittaker detects this and returns immediately with $n\_\text{iter} = 0$: ```{python} from whittaker.families.gaussian import Gaussian gauss_model = wk.GAM("y ~ s(x)", family=Gaussian()).fit( {"x": np.linspace(0, 1, 200), "y": rng.normal(size=200)}, method="VI", ) print(f"Iterations: {gauss_model.vi_result.n_iter}") # 0 print(f"Converged: {gauss_model.vi_result.converged}") # True ``` The `VIResult` from a Gaussian fit is identical to what the Laplace approximation would give, so switching between `method="REML"` and `method="VI"` is seamless for Gaussian models. ## Properties not available for VI fits The `deviance`, `null_deviance`, `deviance_explained`, `aic`, `bic`, and `gcv_score` properties are not defined for VI fits and raise `NotImplementedError`. Use the ELBO as the convergence diagnostic and use posterior predictive checks (via `simulate()`) for model comparison. ## Where to go next - **[Model comparison with LOO](28-loo.qmd)**: use PSIS-LOO to compare the predictive accuracy of competing Bayesian GAM fits. - **[Posterior predictive distributions](31-posterior-predict.qmd)**: the full predictive distribution at new data points, including observation noise. - **[Posterior predictive checks](30-ppc.qmd)**: assess whether a model generates data consistent with the observations. - **[MCMC sampling](27-mcmc.qmd)**: exact posterior inference via the No-U-Turn Sampler, for when VI's Gaussian approximation is insufficient. - **[Prediction and inference](08-prediction.qmd)**: confidence intervals, simultaneous bands, and term-level predictions from any fitted model. - **[Model diagnostics](11-diagnostics.qmd)**: residual plots and `model.check()`. - **[Response families](05-families.qmd)**: how the family affects VI convergence and calibration. ### MCMC sampling ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Whittaker's default inference pipeline is the **Laplace approximation**: after P-IRLS finds the posterior mode $\hat\beta$, the posterior is approximated as $q(\beta) = \mathcal{N}(\hat\beta,\, V_\beta)$, where $V_\beta = \phi\,(X^\top W X + \sum_j \lambda_j S_j)^{-1}$. For Gaussian response this is exact; for non-Gaussian families it can underestimate spread when the likelihood surface is asymmetric. **MCMC** (via the No-U-Turn Sampler, NUTS) draws directly from the exact posterior, so no Gaussian approximation is made. NUTS uses the gradient of the log-posterior to grow a binary trajectory tree in both directions, stopping automatically when the path would double back on itself. This gives it the efficiency of Hamiltonian Monte Carlo without requiring you to tune a trajectory length. The result is a collection of coefficient vectors that represent the full posterior distribution of the smooth functions. ## When to use MCMC Use `method="MCMC"` when: - you need the most accurate posterior uncertainty for non-Gaussian families (Poisson, Binomial, Gamma) and neither the Laplace approximation nor VI is sufficient - the posterior may be multimodal or strongly skewed - you want posterior predictive distributions for individual observations, not just marginal standard errors - you are doing formal Bayesian inference and need to verify convergence via R-hat and ESS For large $n$ or when a fast answer is needed, the Laplace approximation (`method="REML"`, the default) is usually adequate. For non-Gaussian families with moderate $n$, variational inference (`method="VI"`) offers a nice middle ground: better calibrated than Laplace, cheaper than MCMC. ## Basic usage Pass `method="MCMC"` to `fit()`. Everything else stays the same. ```{python} import numpy as np import whittaker as wk from whittaker.families.poisson import Poisson rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) lam = np.exp(1.5 * np.sin(x)) y = rng.poisson(lam).astype(float) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=Poisson()) model.fit(data, method="MCMC") model.summary() ``` The summary shows `Inference: MCMC (NUTS, …)` along with acceptance rate, R-hat, ESS, mean tree depth, and — if any occur — a divergent-transition warning. GCV, AIC, and BIC are not defined for MCMC fits. ## Inspecting the MCMC result The `mcmc_result` property returns an `MCMCResult` object with the full posterior sample and convergence diagnostics: ```{python} mr = model.mcmc_result print(f"Samples shape: {mr.samples.shape}") # (p, n_chains * n_samples) print(f"R-hat (max): {mr.r_hat.max():.4f}") print(f"ESS bulk (min): {mr.ess.min():.1f}") print(f"ESS tail (min): {mr.ess_tail.min():.1f}") print(f"Acceptance rate: {mr.acceptance_rate:.3f}") # mean per-leaf α for NUTS print(f"Mean tree depth: {mr.mean_tree_depth:.2f}") # NUTS only print(f"Divergences: {mr.n_divergent}") ``` `mcmc_result` is `None` if the model was fitted with any other method. ### R-hat and ESS **R-hat** is the rank-normalized split R-hat (Vehtari et al. 2021). Each chain is first split in half, giving twice as many half-chains. Classic Gelman-Rubin R-hat is then applied to the rank-normalized draws. Splitting detects non-stationarity within a single chain. Rank normalization makes the statistic robust to heavy-tailed posteriors. Values below 1.01 are ideal. Values below 1.1 are generally acceptable; values above 1.1 suggest insufficient warmup, too few chains, or poor geometry. **ESS bulk** (`ess`) measures mixing in the bulk of the posterior. It applies the standard autocorrelation ESS estimator to the rank-normalized draws. An ESS ratio (ESS / total draws) above 0.05 is generally adequate. The value below 0.05 suggests the chains are heavily autocorrelated. **ESS tail** (`ess_tail`) measures how reliably the sampler visits the tails. It is the minimum of the ESS of the binary indicator $I(x \le Q_{0.05})$ and $I(x \ge Q_{0.95})$ across all draws. Low tail ESS (even when bulk ESS looks fine) signals that the sampler is stuck in the bulk and rarely reaches the extremes of the posterior. ```{python} import altair as alt diag_data = [ {"coefficient": i, "r_hat": float(mr.r_hat[i]), "ess": float(mr.ess[i])} for i in range(len(mr.r_hat)) ] threshold = alt.Chart({"values": [{}]}).mark_rule( strokeDash=[4, 4], color="firebrick" ).encode(y=alt.datum(1.1)) points = alt.Chart({"values": diag_data}).mark_point(size=60, filled=True).encode( x=alt.X("coefficient:O", title="Coefficient index"), y=alt.Y("r_hat:Q", title="R-hat", scale=alt.Scale(zero=False)), color=alt.condition( alt.datum.r_hat > 1.1, alt.value("firebrick"), alt.value("steelblue"), ), ) (points + threshold).properties( title="R-hat by coefficient (red = above 1.1 threshold)", width=500, height=250, ) ``` ### Divergences A **divergent transition** occurs when the leapfrog integrator encounters a region of very high curvature and the Hamiltonian energy error exceeds 1000. This signals that the sampler has strayed into a part of the posterior that the step size cannot handle correctly. The resulting draw is biased (and not just noisy). `n_divergent` counts the total number of divergent transitions across all chains and all post-warmup samples. For a well-specified model with a reasonable step size, this should be zero. Any non-zero value is a warning that posterior geometry is causing problems: ```python if mr.n_divergent > 0: print(f"Warning: {mr.n_divergent} divergent transitions detected.") print("Try a higher target_accept (e.g. 0.90) or reparameterize the model.") ``` Increasing `target_accept` causes the dual-averaging algorithm to adapt to a smaller step size, which reduces energy errors at the cost of shorter trajectories. If divergences persist despite a high target acceptance rate, the posterior may have a funnel-shaped geometry that requires reparameterization. ## Predictions and uncertainty `predict(se=True)` and confidence intervals work exactly as with any other fitting method. Standard errors are derived from the posterior samples: ```{python} x_new = np.linspace(0, 2 * np.pi, 200) preds = model.predict({"x": x_new}, se=True, interval="confidence") obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="count"), ) fit_data = [ { "x": float(x_new[i]), "fit": float(preds.values[i]), "lower": float(preds.lower[i]), "upper": float(preds.upper[i]), } for i in range(len(x_new)) ] line = alt.Chart({"values": fit_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="fit:Q") band = alt.Chart({"values": fit_data}).mark_area( opacity=0.2, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") (band + line + points).properties( title="Poisson GAM with MCMC: fitted curve and 95% credible interval", width=500, height=300, ) ``` ## Posterior samples `posterior_samples(n)` draws coefficient vectors from the empirical posterior: ```{python} beta_samples = model.posterior_samples(n=500, seed=23) print(f"Shape: {beta_samples.shape}") # (p, 500) ``` This also works after `method="REML"`, `"GCV"`, or `"VI"` fits (in those cases samples are drawn from the Laplace or variational posterior), so code that calls `posterior_samples()` is inference-method agnostic. ## Simulating from the posterior `simulate()` propagates posterior coefficient uncertainty through the response distribution, giving a posterior predictive sample: ```{python} sims = model.simulate(n_sim=200, seed=0) print(f"Simulations shape: {sims.shape}") # (n, 200): integer count draws ``` ## Controlling MCMC Pass an `mcmc_options=` dict to `fit()` to override any sampler setting: ```python model = wk.GAM("y ~ s(x)", family=Poisson()).fit( data, method="MCMC", mcmc_options={ "n_chains": 4, # number of independent chains (default: 4) "n_samples": 1000, # post-warmup draws per chain (default: 1000) "n_warmup": 500, # warmup (adaptation) draws per chain (default: 500) "max_tree_depth": 10, # NUTS: max binary-tree doublings per step (default: 10) "seed": 23, # seed for reproducibility }, ) ``` ### Choosing a sampler Two samplers are available via the `sampler` key: - **`"NUTS"`** (default): No-U-Turn Sampler. Automatically selects trajectory length by growing a binary tree until a U-turn is detected. Requires no manual tuning of leapfrog steps and generally mixes better than fixed-length HMC. - **`"HMC"`**: Static-trajectory HMC with a fixed number of leapfrog steps per proposal, controlled by `leapfrog_steps` (the default is `10`). ```python # Static HMC with 20 leapfrog steps per proposal model = wk.GAM("y ~ s(x)", family=Poisson()).fit( data, method="MCMC", mcmc_options={"sampler": "HMC", "leapfrog_steps": 20}, ) ``` `mean_tree_depth` in `MCMCResult` reports the mean number of binary-tree doublings per post-warmup NUTS step (0.0 for HMC). Each doubling doubles the number of leapfrog evaluations: depth $j$ corresponds to $2^j$ steps. A depth of 5–7 is typical; if it consistently hits `max_tree_depth` consider raising that limit. ### Step-size adaptation Whittaker uses dual-averaging step-size adaptation (Nesterov 2009, as implemented in Stan) during the warmup phase. The adapted step size is fixed for the sampling phase. The default target acceptance rate is `0.65`. NUTS often benefits from a higher target: ```python model = wk.GAM("y ~ s(x)", family=Poisson()).fit( data, method="MCMC", mcmc_options={"target_accept": 0.80}, # default: 0.65 ) ``` For NUTS, `acceptance_rate` in `MCMCResult` reports the mean per-leaf acceptance statistic (mean $\min(1, \exp(H_0 - H_i))$ over all leapfrog steps), which tracks the dual-averaging target and is directly comparable to HMC's Metropolis acceptance rate. Higher target acceptance rates lead to smaller step sizes and more correlated draws. Lower rates lead to larger steps but more rejections. ### Mass matrix Whittaker uses a **two-phase warmup** to adapt the diagonal mass matrix. **Phase 1** (first half of warmup): the sampler runs with a mass matrix initialized from the Laplace posterior covariance, $M = \text{diag}(1 / V_\beta)$, collecting draws while the dual-averaging algorithm adapts the step size. **Midpoint**: the empirical variance of the phase-1 draws is used to update the mass matrix, $M \leftarrow \text{diag}(1 / \widehat{\sigma}^2_\beta)$. The step-size dual-averaging is reset so that phase 2 can re-adapt the step size to the new geometry. This update is skipped when fewer than 10 warmup steps precede it, so very short warmup schedules are handled gracefully. **Phase 2** (second half of warmup): dual-averaging continues with the updated mass matrix, and the averaged step size from the end of this phase is used for all post-warmup sampling. This two-phase scheme substantially improves mixing when posterior coefficient scales differ by orders of magnitude. And this is common with B-spline bases where an intercept at scale $e^5$ coexists with smooth components at scale $0.01$. ## Properties not available for MCMC fits The `deviance`, `null_deviance`, `deviance_explained`, `aic`, `bic`, and `gcv_score` properties are not defined for MCMC fits and raise `NotImplementedError`. Use R-hat and ESS for convergence assessment, and posterior predictive checks (via `simulate()`) for model comparison. ## Where to go next - **[Model comparison with LOO](28-loo.qmd)**: use PSIS-LOO to compare the predictive accuracy of competing Bayesian GAM fits. - **[Posterior predictive distributions](31-posterior-predict.qmd)**: the full predictive distribution at new data points, including observation noise. - **[Posterior predictive checks](30-ppc.qmd)**: assess whether a model generates data consistent with the observations. - **[Variational inference](26-variational-inference.qmd)**: a faster alternative to MCMC for non-Gaussian families that gives better-calibrated intervals than the Laplace approximation. - **[Prediction and inference](08-prediction.qmd)**: confidence intervals, simultaneous bands, and term-level predictions from any fitted model. - **[Model diagnostics](11-diagnostics.qmd)**: residual plots and `model.check()`. - **[Response families](05-families.qmd)**: how the family affects posterior shape and MCMC efficiency. ### Model comparison with LOO ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` When you have two or more candidate models for the same data, you need a principled way to decide which one predicts better. **Leave-one-out cross-validation (LOO-CV)** estimates each model's out-of-sample predictive accuracy by asking: how well does the model predict each observation when that observation is left out of the fit? Refitting the model $n$ times is expensive. **Pareto-Smoothed Importance Sampling (PSIS-LOO)** (Vehtari, Gelman & Gabry, 2017) approximates the leave-one-out predictive densities from a single set of posterior draws, making LOO-CV practical for Bayesian GAMs. The result is an estimate of the **expected log predictive density (ELPD)**, a measure of predictive accuracy where higher values indicate better out-of-sample predictions. ## When to use PSIS-LOO Use `model.loo()` when: - you have two or more Bayesian GAM fits (`method="VI"` or `method="MCMC"`) on the same data and want to know which predicts better - you want a model-selection criterion that accounts for the full posterior, not just the point estimate (unlike AIC or BIC) - you need per-observation diagnostics that flag influential points where the LOO approximation may be unreliable For frequentist fits, AIC and BIC are available through `model.aic` and `model.bic`. PSIS-LOO requires posterior draws and is only available after Bayesian fitting. ## Basic usage Fit two competing models with the same Bayesian method and call `.loo()` on each. ```{python} import numpy as np import whittaker as wk from whittaker.families.poisson import Poisson rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) lam = np.exp(1.5 * np.sin(x)) y = rng.poisson(lam).astype(float) data = {"x": x, "y": y} # Model 1: smooth effect of x m1 = wk.GAM("y ~ s(x)", family=Poisson()).fit(data, method="VI") # Model 2: linear effect of x only m2 = wk.GAM("y ~ x", family=Poisson()).fit(data, method="VI") loo1 = m1.loo(seed=0) loo2 = m2.loo(seed=0) print(loo1) ``` The output shows the ELPD estimate, its standard error, the effective number of parameters ($p_\text{LOO}$), and how many observations have a Pareto $k$ diagnostic above the reliability threshold. ## Understanding the LOO result The `LOOResult` object contains everything you need to evaluate and compare models. **`elpd_loo`** is the summed expected log predictive density. Higher values mean better predictive accuracy. The value itself is on a log scale, so differences between models are more informative than the absolute number. **`se_elpd_loo`** is the standard error of the ELPD estimate, computed as $\sqrt{n \cdot \text{Var}(\text{pointwise})}$. It quantifies sampling uncertainty in the LOO approximation. **`p_loo`** is the effective number of parameters, computed as the difference between the full-data log predictive density and the LOO estimate. When `p_loo` is much larger than the actual number of model coefficients, it suggests model misspecification or highly influential observations. ```{python} print(f"ELPD: {loo1.elpd_loo:.2f} (SE {loo1.se_elpd_loo:.2f})") print(f"p_LOO: {loo1.p_loo:.2f}") print(f"Observations: {len(loo1.pointwise)}") ``` These diagnostics give a first indication of how well the model generalizes. ## Pareto $k$ diagnostics PSIS works by fitting a Generalized Pareto Distribution to the tail of the importance weights for each observation. The estimated shape parameter $k$ tells you how reliable the approximation is for that specific data point. | Pareto $k$ range | Interpretation | |---|---| | $k \le 0.5$ | Good. The estimate is reliable. | | $0.5 < k \le 0.7$ | Acceptable. Some noise but generally trustworthy. | | $0.7 < k \le 1.0$ | Problematic. The PSIS estimate may be biased. | | $k > 1.0$ | Invalid. The importance weights have infinite variance. | ```{python} import altair as alt k_data = [ {"observation": i, "pareto_k": float(loo1.pareto_k[i])} for i in range(len(loo1.pareto_k)) ] threshold = alt.Chart({"values": [{}]}).mark_rule( strokeDash=[4, 4], color="firebrick" ).encode(y=alt.datum(0.7)) points = alt.Chart({"values": k_data}).mark_circle(size=20, opacity=0.5).encode( x=alt.X("observation:Q", title="Observation index"), y=alt.Y("pareto_k:Q", title="Pareto k"), color=alt.condition( alt.datum.pareto_k > 0.7, alt.value("firebrick"), alt.value("steelblue"), ), ) (points + threshold).properties( title="Pareto k diagnostics (red = above 0.7 threshold)", width=500, height=250, ) ``` Observations with $k > 0.7$ are flagged in `n_bad_k`. If you see many flagged observations, the LOO approximation may not be trustworthy and you should investigate those data points for high leverage or model misspecification. ## Comparing models `loo_compare()` computes the paired difference in ELPD between two models. Because the comparison uses pointwise LOO values from both models, it accounts for the correlation across observations and gives a tighter standard error than comparing the two ELPD estimates independently. ```{python} from whittaker import loo_compare cmp = loo_compare(loo1, loo2) print(cmp) ``` A positive `elpd_diff` means the first model is preferred; negative means the second. The standard error of the difference tells you how confident you can be. As a rough guideline, a difference larger than two standard errors is strong evidence in favor of one model. ```{python} ratio = abs(cmp.elpd_diff) / cmp.se_diff print(f"|ELPD diff| / SE = {ratio:.1f}") if ratio > 2: preferred = "model 1 (smooth)" if cmp.elpd_diff > 0 else "model 2 (linear)" print(f"Strong evidence for {preferred}") else: print("Models are not clearly distinguishable") ``` In this example, the smooth model captures the sinusoidal pattern that the linear model cannot, which is reflected in the ELPD comparison. ## LOO with MCMC fits The workflow is identical for MCMC fits. The only difference is that `.loo()` uses all stored posterior draws directly instead of sampling from a variational approximation, so the `n_draws=` parameter is ignored. ```python model = wk.GAM("y ~ s(x)", family=Poisson()).fit(data, method="MCMC") loo_result = model.loo() ``` MCMC fits with more draws produce more stable LOO estimates and better Pareto $k$ diagnostics. ## Where to go next - **[Model averaging with stacking](32-stacking.qmd)**: combine multiple models using optimal stacking weights computed from LOO or WAIC results. - **[Model comparison with WAIC](29-waic.qmd)**: a cheaper alternative to LOO that uses the variance of the log-likelihood instead of importance sampling. - **[Posterior predictive checks](30-ppc.qmd)**: assess whether a model generates data that looks like the observed data, complementing LOO's predictive-accuracy perspective. - **[MCMC sampling](27-mcmc.qmd)**: fitting models with the No-U-Turn Sampler for exact posterior inference. - **[Variational inference](26-variational-inference.qmd)**: a faster Bayesian alternative that also supports LOO. - **[Cross-validation](14-cross-validation.qmd)**: K-fold cross-validation for frequentist fits. - **[Model diagnostics](11-diagnostics.qmd)**: residual plots and `model.check()`. ### Model comparison with WAIC ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` The **Widely Applicable Information Criterion (WAIC)** (Watanabe, 2010; Gelman, Hwang & Vehtari, 2014) is a Bayesian model-comparison criterion that estimates out-of-sample predictive accuracy from the posterior log-likelihood matrix. Like [PSIS-LOO](28-loo.qmd), it returns an estimate of the **expected log predictive density (ELPD)**, but it uses the variance of the log-likelihood across posterior draws instead of importance-sampling corrections. This makes WAIC cheaper to compute and free of the Pareto $k$ diagnostic concerns that can arise with LOO. ## When to use WAIC Use `model.waic()` when: - you want a quick Bayesian model-comparison metric without the importance-sampling overhead of LOO - you are comparing several Bayesian fits (`method="VI"` or `method="MCMC"`) on the same data - none of your observations are highly influential (if some are, LOO's per-observation diagnostics are more informative) WAIC and LOO are asymptotically equivalent and typically give very similar results. LOO is generally preferred when Pareto $k$ diagnostics are clean, because it is more robust to influential observations. WAIC is a good first-pass alternative, especially when fitting many candidate models. For frequentist fits, use `model.aic` and `model.bic` instead. ## Basic usage Fit two competing models and call `.waic()` on each. ```{python} import numpy as np import whittaker as wk from whittaker.families.poisson import Poisson rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) lam = np.exp(1.5 * np.sin(x)) y = rng.poisson(lam).astype(float) data = {"x": x, "y": y} # Model 1: smooth effect of x m1 = wk.GAM("y ~ s(x)", family=Poisson()).fit(data, method="VI") # Model 2: linear effect of x only m2 = wk.GAM("y ~ x", family=Poisson()).fit(data, method="VI") w1 = m1.waic(seed=0) w2 = m2.waic(seed=0) print(w1) ``` ## Understanding the WAIC result The `WAICResult` object contains: **`elpd_waic`** is the summed expected log pointwise predictive density. Higher values mean better predictive accuracy. **`se_elpd_waic`** is the standard error, computed as $\sqrt{n \cdot \text{Var}(\text{pointwise})}$. **`p_waic`** is the effective number of parameters, computed as the sum of the per-observation variance of the log-likelihood across posterior draws. It measures how much the posterior predictions vary from observation to observation. When `p_waic` is much larger than the actual parameter count, it suggests the model may be overfit or misspecified. **`waic`** is the WAIC on the deviance scale: $-2 \cdot \text{ELPD}_\text{WAIC}$. Lower is better. This scale matches the familiar AIC/BIC convention. ```{python} print(f"ELPD_WAIC: {w1.elpd_waic:.2f} (SE {w1.se_elpd_waic:.2f})") print(f"p_WAIC: {w1.p_waic:.2f}") print(f"WAIC: {w1.waic:.2f}") print(f"Observations: {len(w1.pointwise)}") ``` ## Comparing models `waic_compare()` computes the paired difference in ELPD between two models, using the pointwise values to account for correlation across observations. ```{python} from whittaker import waic_compare cmp = waic_compare(w1, w2) print(cmp) ``` A positive `elpd_diff` means the first model is preferred. As with LOO, a difference larger than two standard errors is strong evidence. ```{python} ratio = abs(cmp.elpd_diff) / cmp.se_diff print(f"|ELPD diff| / SE = {ratio:.1f}") if ratio > 2: preferred = "model 1 (smooth)" if cmp.elpd_diff > 0 else "model 2 (linear)" print(f"Strong evidence for {preferred}") else: print("Models are not clearly distinguishable") ``` ## WAIC vs LOO WAIC and LOO estimate the same quantity (ELPD) and are asymptotically equivalent. For well-behaved models they typically agree closely: ```{python} import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore") loo1 = m1.loo(seed=0) print(f"ELPD_WAIC: {w1.elpd_waic:.2f} (SE {w1.se_elpd_waic:.2f})") print(f"ELPD_LOO: {loo1.elpd_loo:.2f} (SE {loo1.se_elpd_loo:.2f})") print(f"p_WAIC: {w1.p_waic:.2f}") print(f"p_LOO: {loo1.p_loo:.2f}") ``` The key differences: | | WAIC | PSIS-LOO | |---|---|---| | **Speed** | Fast (just means and variances) | Requires PSIS smoothing per observation | | **Diagnostics** | No per-observation diagnostics | Pareto $k$ flags unreliable observations | | **Robustness** | Sensitive to influential observations | PSIS stabilizes extreme weights | | **Recommendation** | Good first pass | Preferred when Pareto $k$ diagnostics are clean | ## WAIC with MCMC fits The workflow is the same for MCMC fits. All stored posterior draws are used automatically. ```python model = wk.GAM("y ~ s(x)", family=Poisson()).fit(data, method="MCMC") w = model.waic() ``` ## Where to go next - **[Model averaging with stacking](32-stacking.qmd)**: combine multiple models using optimal stacking weights computed from LOO or WAIC results. - **[Model comparison with LOO](28-loo.qmd)**: PSIS-LOO with per-observation Pareto $k$ diagnostics. - **[Posterior predictive checks](30-ppc.qmd)**: assess whether the model generates realistic data. - **[MCMC sampling](27-mcmc.qmd)**: fitting models with the No-U-Turn Sampler. - **[Variational inference](26-variational-inference.qmd)**: a faster Bayesian alternative. - **[Cross-validation](14-cross-validation.qmd)**: K-fold cross-validation for frequentist fits. ### Posterior predictive checks ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` A model that fits the data well by some criterion (ELPD, AIC, deviance explained) might still generate data that looks nothing like the observations. **Posterior predictive checks (PPC)** test this directly: draw many replicated datasets from the fitted model and compare them to the real data. If the model is adequate, the replicated data should be statistically indistinguishable from what was actually observed. The idea is simple. For each replicated dataset $y^\text{rep}$, compute a test statistic $T(y^\text{rep})$ (the mean, the standard deviation, the proportion of zeros, or any other summary). Then compare the distribution of $T(y^\text{rep})$ across all replicates to the observed value $T(y^\text{obs})$. If the observed value sits in the bulk of the replicated distribution, the model captures that aspect of the data well. If it sits in the extreme tail, the model has a systematic discrepancy. ## When to use PPC Use `model.ppc()` when: - you want to check whether a fitted model generates realistic data, not just whether it predicts well - you suspect the model may miss important features of the response distribution (overdispersion, excess zeros, skewness) - you want a visual, intuitive diagnostic that complements formal metrics like ELPD or deviance PPC works with every fitting method. For Bayesian fits (`method="VI"` or `method="MCMC"`), the replicated datasets are drawn from the full posterior predictive distribution. For frequentist fits, the Laplace approximation to the posterior is used. ## Basic usage Call `.ppc()` on any fitted model. The result is a `PPCResult` that prints a table of test statistics with Bayesian p-values. ```{python} import numpy as np import whittaker as wk from whittaker.families.poisson import Poisson rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) lam = np.exp(1.5 * np.sin(x)) y = rng.poisson(lam).astype(float) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)", family=Poisson()).fit(data, method="VI") result = model.ppc(n_sim=1000, seed=0) print(result) ``` Each row shows an observed statistic, the mean of that statistic across all replicated datasets, and a Bayesian p-value. A p-value near 0.5 means the model reproduces that feature of the data well whereas values near 0 or 1 indicate a systematic discrepancy. ## Bayesian p-values The **Bayesian p-value** for a statistic $T$ is the proportion of replicated datasets where the statistic equals or exceeds the observed value: $$p_B = P\bigl(T(y^\text{rep}) \ge T(y^\text{obs})\bigr)$$ Unlike classical p-values, the Bayesian p-value is a calibration diagnostic, not a hypothesis test. It can answer the question: "does the model generate data whose summary statistics are consistent with what we observed?" A well-calibrated model should produce p-values scattered around 0.5 for all statistics. You can retrieve the p-value for any individual statistic by name. ```{python} print(f"p-value for 'mean': {result.p_value('mean'):.3f}") print(f"p-value for 'sd': {result.p_value('sd'):.3f}") print(f"p-value for 'min': {result.p_value('min'):.3f}") print(f"p-value for 'max': {result.p_value('max'):.3f}") ``` The available statistic names are listed in `result.stat_names`. ## Visualizing test statistics For a deeper look, retrieve the full distribution of a statistic across all replicates using `result.stat()`. This returns the observed value and the array of replicated values, which you can plot as a histogram. ```{python} import altair as alt obs_sd, rep_sd = result.stat("sd") hist_data = [{"sd": float(v)} for v in rep_sd] hist = alt.Chart({"values": hist_data}).mark_bar(opacity=0.6, color="steelblue").encode( x=alt.X("sd:Q", bin=alt.Bin(maxbins=40), title="Standard deviation"), y=alt.Y("count()", title="Count"), ) obs_line = alt.Chart({"values": [{"sd": float(obs_sd)}]}).mark_rule( color="firebrick", strokeWidth=2 ).encode(x="sd:Q") obs_label = alt.Chart({"values": [{"sd": float(obs_sd), "label": "observed"}]}).mark_text( color="firebrick", dy=-10, fontSize=12 ).encode(x="sd:Q", text="label:N") (hist + obs_line + obs_label).properties( title="PPC: standard deviation (observed vs. replicated)", width=500, height=250, ) ``` The observed value (red line) should fall within the bulk of the histogram. If it sits in the tail, the model is systematically over- or under-estimating the variability of the response. ## Detecting model problems PPC is especially useful for catching distributional misspecification. Consider fitting a Poisson model to data that is actually overdispersed. The Poisson distribution has variance equal to its mean, so the standard deviation of the replicated data will be too low. ```{python} # Simulate overdispersed count data (Negative Binomial) y_od = rng.negative_binomial(n=3, p=3 / (3 + lam)).astype(float) data_od = {"x": x, "y": y_od} model_pois = wk.GAM("y ~ s(x)", family=Poisson()).fit(data_od, method="VI") result_pois = model_pois.ppc(n_sim=1000, seed=0) print(result_pois) ``` A p-value near 0 for `sd` is a clear signal that the Poisson model cannot reproduce the observed variability. The `max` statistic may also show an extreme p-value, since overdispersed data produces larger outliers than Poisson draws. ## PPC with different fitting methods PPC works with any fitting method, including frequentist fits. The underlying mechanism differs (the Laplace approximation is used for frequentist fits), but the interface is identical. ```{python} # Frequentist fit model_reml = wk.GAM("y ~ s(x)", family=Poisson()).fit(data, method="REML") result_reml = model_reml.ppc(n_sim=500, seed=0) print(result_reml) ``` Frequentist PPC results are generally similar to Bayesian ones for well-identified models with moderate to large sample sizes. The main difference appears in small-sample or weakly identified settings, where the Laplace approximation underestimates posterior spread. ## Available test statistics The following statistics are computed automatically for every PPC. | Statistic | What it checks | |---|---| | `mean` | Location: does the model get the overall level right? | | `sd` | Spread: does the model reproduce the observed variability? | | `min` | Lower tail: does the model generate plausible extreme low values? | | `max` | Upper tail: does the model generate plausible extreme high values? | | `prop_zero` | Zero-inflation: does the model produce the right proportion of zeros? | The `prop_zero` statistic is particularly useful for count data. A p-value near 0 suggests the model underproduces zeros, which is a hallmark of zero-inflation that a standard Poisson or Negative Binomial model may not capture. ## Where to go next - **[Posterior predictive distributions](31-posterior-predict.qmd)**: access the full predictive sample for custom checks beyond the built-in statistics. - **[Model comparison with LOO](28-loo.qmd)**: use PSIS-LOO to compare the predictive accuracy of competing models, complementing PPC's adequacy perspective. - **[MCMC sampling](27-mcmc.qmd)**: fitting models with the No-U-Turn Sampler for exact posterior draws used in PPC. - **[Variational inference](26-variational-inference.qmd)**: a faster Bayesian alternative whose posterior draws feed into PPC. - **[Model diagnostics](11-diagnostics.qmd)**: residual plots and `model.check()` for complementary diagnostic perspectives. - **[Response families](05-families.qmd)**: choosing the right family avoids the distributional misspecification that PPC is designed to detect. ### Posterior predictive distributions ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` When you need the full distribution of new observations (and not just a point estimate or a confidence interval) `posterior_predict()` returns the complete posterior predictive sample. Each draw accounts for both uncertainty in the model coefficients and observation-level noise from the response distribution, so the result reflects what new data from the same process might actually look like. This is distinct from a confidence or credible interval for the mean, which only tells you where the average response is likely to be. A posterior predictive interval tells you where an individual new observation is likely to fall, which is almost always wider because it adds the inherent randomness of the response on top of the parameter uncertainty. ## When to use posterior_predict Use `posterior_predict()` when: - you need prediction intervals that include observation noise, not just uncertainty in the mean - you want to propagate predictive uncertainty through a downstream calculation (e.g., computing the probability that a new observation exceeds a threshold) - you are building custom posterior predictive checks beyond the built-in `ppc()` statistics - you want to visualize the full predictive distribution at specific covariate values For uncertainty in the *mean response* only (no observation noise), use `simulate(unconditional=False)` or `predict(interval="credible")` instead. ## Basic usage We start by fitting a Gaussian GAM with variational inference and then drawing 2000 posterior predictive samples at 200 evenly spaced new points. The result is a `PosteriorPredictResult` object that stores the full `(n, n_draws)` sample matrix and provides convenience methods for common summaries. ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} model = wk.GAM("y ~ s(x)").fit(data, method="VI") x_new = np.linspace(0, 2 * np.pi, 200) pp = model.posterior_predict({"x": x_new}, n_draws=2000, seed=0) pp ``` The printed representation shows the number of prediction points and the number of draws. The raw sample matrix is available as `pp.samples`, a NumPy array of shape `(200, 2000)` where each column is one complete draw from the posterior predictive distribution. ## Summarizing the predictive distribution Working with 2000 draws per point is useful for downstream calculations, but most of the time you want a compact summary. The result object provides convenience methods for the most common ones. ### Mean and standard deviation The posterior predictive mean is a natural point estimate that averages over both coefficient uncertainty and observation noise. The standard deviation quantifies the total predictive spread at each point. ```{python} pp_mean = pp.mean() pp_std = pp.std() print(f"Predictive mean shape: {pp_mean.shape}") print(f"Predictive std shape: {pp_std.shape}") print(f"Mean std across points: {pp_std.mean():.4f}") ``` ### Posterior predictive intervals `interval()` returns an equal-tailed interval at the requested coverage level. The default is 95%, meaning 2.5% of the draws fall below the lower bound and 2.5% fall above the upper bound at each prediction point. ```{python} lower, upper = pp.interval() print(f"95% interval width (mean): {np.mean(upper - lower):.4f}") ``` You can request any coverage level. An 80% interval is narrower because it discards more of the tails: ```{python} lower_80, upper_80 = pp.interval(0.80) print(f"80% interval width (mean): {np.mean(upper_80 - lower_80):.4f}") ``` ### Arbitrary quantiles For more fine-grained summaries, `quantile()` accepts a scalar or a list of quantile values. A scalar returns one value per prediction point; a list returns a matrix with one row per quantile. ```{python} median = pp.quantile(0.5) print(f"Median shape: {median.shape}") deciles = pp.quantile([0.1, 0.5, 0.9]) print(f"Deciles shape: {deciles.shape}") ``` ## Visualizing predictive uncertainty Plotting the 95% posterior predictive interval alongside the observed data gives an intuitive picture of where the model expects new observations to fall. The band should contain roughly 95% of the data points if the model is well calibrated. ```{python} import altair as alt obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"), ) fit_data = [ { "x": float(x_new[i]), "mean": float(pp_mean[i]), "lower": float(lower[i]), "upper": float(upper[i]), } for i in range(len(x_new)) ] line = alt.Chart({"values": fit_data}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="mean:Q") band = alt.Chart({"values": fit_data}).mark_area( opacity=0.15, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") (band + line + points).properties( title="Posterior predictive: fitted mean and 95% interval", width=500, height=300, ) ``` The band covers approximately 95% of new observations. Points falling outside the band are expected (about 5% of the time for a well-calibrated model). If significantly more or fewer points fall outside, the model may be mis-specified so consider running a formal [posterior predictive check](30-ppc.qmd) to investigate. ## Posterior predict vs. other uncertainty tools Whittaker provides several ways to quantify uncertainty. The key distinction is whether the result includes observation noise (the randomness of individual data points around the mean) or only reflects uncertainty in the estimated mean itself. The table below summarizes all the options: | Method | Includes observation noise? | Returns | |---|---|---| | `predict(interval="confidence")` | No | Interval for the mean | | `predict(interval="prediction")` | Yes (normal approx.) | Interval for a new observation | | `predict(interval="credible")` | No | Bayesian interval for the mean | | `simulate(unconditional=False)` | No | `(n, n_sim)` draws of the mean | | `simulate(unconditional=True)` | Yes | `(n, n_sim)` draws of new observations | | **`posterior_predict()`** | **Yes** | **`PosteriorPredictResult` with convenience methods** | `posterior_predict()` is equivalent to `simulate(unconditional=True)` but returns a richer result object with `mean()`, `std()`, `quantile()`, and `interval()` methods. Use `posterior_predict()` when you want both the full sample and convenient summaries; use `simulate()` when you only need the raw matrix. ## Threshold exceedance probabilities One of the most powerful uses of the full posterior predictive sample is estimating the probability that a new observation exceeds (or falls below) a given threshold. This is a calculation that point estimates and intervals cannot provide (you need the entire distribution). For example, you might ask: "at each value of $x$, what is the probability that a new observation will be above 0.5?" With the sample matrix in hand, this can be determined as such: ```{python} threshold = 0.5 prob_above = np.mean(pp.samples > threshold, axis=1) thresh_data = [ {"x": float(x_new[i]), "prob": float(prob_above[i])} for i in range(len(x_new)) ] alt.Chart({"values": thresh_data}).mark_line( color="steelblue", strokeWidth=2 ).encode( x=alt.X("x:Q", title="x"), y=alt.Y("prob:Q", title=f"P(y_new > {threshold})", scale=alt.Scale(domain=[0, 1])), ).properties( title=f"Probability that a new observation exceeds {threshold}", width=500, height=250, ) ``` The curve tracks the sine wave: the probability is highest where the true function peaks above the threshold and drops to near zero at the troughs. You can replace `0.5` with any threshold relevant to your application (e.g., a regulatory limit, a clinical cutoff, a business target, etc.). ## Poisson example `posterior_predict()` respects the family's observation model. For a Poisson GAM the response distribution is discrete, so every draw is a non-negative integer. This is in contrast to `predict(interval="prediction")`, which uses a normal approximation and can produce non-integer or negative bounds for count data. ```{python} from whittaker.families.poisson import Poisson rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) lam = np.exp(1.5 * np.sin(x)) y = rng.poisson(lam).astype(float) pois_model = wk.GAM("y ~ s(x)", family=Poisson()).fit({"x": x, "y": y}, method="VI") x_pois = np.linspace(0, 2 * np.pi, 200) pp_pois = pois_model.posterior_predict({"x": x_pois}, n_draws=2000, seed=0) print(f"Min draw: {pp_pois.samples.min()}") print(f"All integer-valued: {np.all(pp_pois.samples == np.round(pp_pois.samples))}") ``` The posterior predictive interval for the Poisson model is asymmetric. It's wider where the predicted rate is high (right-skewed count distribution) and tighter near zero where the distribution is compressed against the lower bound: ```{python} lower_pois, upper_pois = pp_pois.interval() mean_pois = pp_pois.mean() obs_pois = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] points_pois = alt.Chart({"values": obs_pois}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode(x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="count")) fit_pois = [ { "x": float(x_pois[i]), "mean": float(mean_pois[i]), "lower": float(lower_pois[i]), "upper": float(upper_pois[i]), } for i in range(len(x_pois)) ] line_pois = alt.Chart({"values": fit_pois}).mark_line( color="firebrick", strokeWidth=2 ).encode(x="x:Q", y="mean:Q") band_pois = alt.Chart({"values": fit_pois}).mark_area( opacity=0.15, color="firebrick" ).encode(x="x:Q", y="lower:Q", y2="upper:Q") (band_pois + line_pois + points_pois).properties( title="Poisson GAM: posterior predictive mean and 95% interval", width=500, height=300, ) ``` This asymmetry is a natural consequence of the Poisson distribution and is captured automatically because `posterior_predict()` draws from the actual response distribution rather than relying on a symmetric normal approximation. ## Works with all inference methods `posterior_predict()` works with every fitting method. For Bayesian fits (`method="VI"` or `method="MCMC"`), draws come from the full posterior. For frequentist fits (`method="REML"`, `"GCV"`, `"ML"`), the Laplace approximation to the posterior is used instead. This means you can switch inference methods without changing any downstream code that consumes the posterior predictive sample. ```{python} model_reml = wk.GAM("y ~ s(x)").fit({"x": x, "y": np.sin(x) + rng.normal(0, 0.3, n)}) pp_reml = model_reml.posterior_predict(n_draws=500, seed=0) print(f"REML posterior predict: {pp_reml}") ``` For Gaussian models with moderate-to-large sample sizes, the Laplace approximation is very accurate, so the posterior predictive samples from a frequentist fit will be nearly indistinguishable from those of a VI or MCMC fit. For non-Gaussian families at small sample sizes, VI or MCMC will generally give better-calibrated predictive distributions. ## Training data predictions When `new_data` is omitted, predictions are made at the training data points. This is useful for in-sample posterior predictive checks or for comparing the observed response against the model's predictive distribution at each training observation. ```{python} pp_train = model.posterior_predict(n_draws=500, seed=0) print(f"Training data shape: {pp_train.samples.shape}") ``` You can use this to compute, for example, the proportion of training observations that fall within the model's 95% posterior predictive interval. Here's a quick calibration check: ```{python} lower_train, upper_train = pp_train.interval() y_train = data["y"] coverage = np.mean((y_train >= lower_train) & (y_train <= upper_train)) print(f"Empirical coverage: {coverage:.1%}") ``` A well-calibrated model should show coverage close to 95%. Substantially lower coverage suggests the model is overconfident (intervals too narrow), while higher coverage suggests it is overly conservative. For a more thorough assessment, use the built-in [posterior predictive checks](30-ppc.qmd). ## Where to go next - **[Posterior predictive checks](30-ppc.qmd)**: automated PPC with built-in test statistics that compare replicated datasets against the observations. - **[Prediction and inference](08-prediction.qmd)**: confidence intervals, simultaneous bands, and term-level predictions for when you need uncertainty in the mean rather than in new observations. - **[Variational inference](26-variational-inference.qmd)**: fitting Bayesian GAMs whose posterior feeds into `posterior_predict()`. - **[MCMC sampling](27-mcmc.qmd)**: exact posterior inference for the most accurate predictive distributions. - **[Model comparison with LOO](28-loo.qmd)**: comparing models by out-of-sample predictive accuracy using PSIS-LOO. ### Model averaging with stacking ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` When you have several candidate models and are unsure which one is best, you can combine them rather than choosing a single winner. Stacking finds optimal combination weights that maximize the combined leave-one-out predictive density of the weighted mixture. The result is a principled way to hedge across models: better-predicting models receive higher weights, and models that add nothing to the mixture receive weights near zero. This goes beyond pairwise comparison tools like `loo_compare()` and `waic_compare()`, which only tell you which of two models is preferred. Stacking handles any number of models simultaneously and produces a single set of weights you can use for prediction averaging or reporting. ## When to use stacking Use `stacking()` when: - you have three or more candidate models and want to know how much each one contributes to the best predictive mixture - two models perform similarly by ELPD and you want to average their predictions rather than pick one arbitrarily - you want to combine structurally different models (e.g., a smooth GAM, a linear model, and a model with interactions) into a single predictive distribution If you only have two models and want to know which one is better, `loo_compare()` or `waic_compare()` may be sufficient. Stacking is most valuable when the number of candidate models is larger and the goal is a combined prediction. ## Basic usage Fit several models, compute LOO or WAIC for each, then pass the results to `stacking()`. The function returns a `StackingResult` with the optimal weights and the combined ELPD. ```{python} import numpy as np import whittaker as wk import warnings rng = np.random.default_rng(23) n = 200 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + 0.3 * np.cos(3 * x) + rng.normal(0, 0.3, n) data = {"x": x, "y": y} # Three candidate models with increasing flexibility model_linear = wk.GAM("y ~ x").fit(data, method="VI") model_k5 = wk.GAM("y ~ s(x, k=5)").fit(data, method="VI") model_k15 = wk.GAM("y ~ s(x, k=15)").fit(data, method="VI") ``` Each model captures the data differently: the linear model misses the curvature entirely, the $k = 5$ smooth captures the main sine wave but may miss the higher-frequency cosine component, and the $k = 15$ smooth has enough flexibility for both. Stacking lets the data decide how to weight each contribution. Compute LOO for each model (WAIC would also work) and pass the results to `stacking()`: ```{python} with warnings.catch_warnings(): warnings.simplefilter("ignore") loo_linear = model_linear.loo(n_draws=500, seed=0) loo_k5 = model_k5.loo(n_draws=500, seed=0) loo_k15 = model_k15.loo(n_draws=500, seed=0) result = wk.stacking(loo_linear, loo_k5, loo_k15) print(result) ``` The printed summary shows each model's stacking weight alongside a bar chart for quick visual comparison. Weights near zero indicate that the model adds little predictive value beyond what the other models already provide. ## Interpreting the weights Stacking weights are not posterior model probabilities. They are the optimal mixture proportions for combining the *predictive distributions* of the candidate models. A weight of 0.6 on Model 3 does not mean there is a 60% chance that Model 3 is the true data-generating process. It means that 60% of the predictive mixture should come from Model 3 in order to maximize out-of-sample predictive performance. This distinction matters in practice: stacking weights can be non-zero for a model that is clearly "wrong" if that model contributes complementary predictive information. Conversely, a model that is very similar to the best model may receive a weight near zero because it adds nothing new to the mixture. ```{python} # Access individual weights for i, w in enumerate(result.weights): print(f"Model {i + 1}: weight = {w:.3f}") ``` ## Using stacking weights for prediction Once you have the weights, you can form a weighted average of predictions from each model. This gives you a single predictive distribution that combines the strengths of all models. ```{python} import altair as alt x_new = np.linspace(0, 2 * np.pi, 200) new_data = {"x": x_new} # Compute predictions from each model pred_linear = model_linear.predict(new_data).values pred_k5 = model_k5.predict(new_data).values pred_k15 = model_k15.predict(new_data).values # Weighted average pred_stacked = ( result.weights[0] * pred_linear + result.weights[1] * pred_k5 + result.weights[2] * pred_k15 ) # True function for comparison true_vals = np.sin(x_new) + 0.3 * np.cos(3 * x_new) plot_data = [] for i in range(len(x_new)): plot_data.append({"x": float(x_new[i]), "y": float(pred_stacked[i]), "model": "Stacked"}) plot_data.append({"x": float(x_new[i]), "y": float(true_vals[i]), "model": "Truth"}) obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3, color="steelblue" ).encode(x=alt.X("x:Q"), y=alt.Y("y:Q")) lines = alt.Chart({"values": plot_data}).mark_line().encode( x="x:Q", y="y:Q", color=alt.Color("model:N"), strokeDash=alt.condition( alt.datum.model == "Truth", alt.value([4, 4]), alt.value([0]), ), ) (lines + points).properties( title="Stacked prediction vs. truth", width=500, height=300, ) ``` The stacked prediction follows the truth closely by combining the flexibility of the $k = 15$ smooth with contributions from the other models where they help. ## Stacking with WAIC Stacking works with either LOO or WAIC results. WAIC is computationally cheaper than LOO, so it can be a good choice when the number of candidate models is large. ```{python} waic_linear = model_linear.waic(n_draws=500, seed=0) waic_k5 = model_k5.waic(n_draws=500, seed=0) waic_k15 = model_k15.waic(n_draws=500, seed=0) result_waic = wk.stacking(waic_linear, waic_k5, waic_k15) print(result_waic) ``` The weights from LOO and WAIC stacking are generally similar for well-behaved models. LOO stacking is preferred when some Pareto $k$ diagnostics are marginal (0.5--0.7), since the PSIS smoothing provides a more reliable estimate of the pointwise predictive density in those cases. ## Stacking vs. pairwise comparison The pairwise `loo_compare()` and `waic_compare()` functions compare two models at a time and report the ELPD difference with a standard error. This is useful for a simple A/B comparison, but has limitations when the model set is larger: - pairwise comparisons do not account for redundancy among models. Two models that are nearly identical will both compare favorably against a third, but adding both to a mixture is wasteful. Stacking detects this and assigns low weight to the redundant model. - with $K$ models, there are $\binom{K}{2}$ pairwise comparisons, and the results can be contradictory or hard to synthesize. Stacking gives a single, coherent answer. Stacking subsumes pairwise comparison: if you stack two models and one gets all the weight, the conclusion is the same as `loo_compare()` would give. But stacking also handles the case where both models contribute, which pairwise comparison cannot express. ## Combined ELPD The `elpd_stacking` field reports the expected log predictive density of the stacking mixture, summed over observations. This is always at least as large as the best individual model's ELPD, because the optimization is free to put all the weight on a single model if that is optimal. ```{python} print(f"ELPD (stacking): {result.elpd_stacking:.2f}") print(f"ELPD (linear): {loo_linear.elpd_loo:.2f}") print(f"ELPD (k=5): {loo_k5.elpd_loo:.2f}") print(f"ELPD (k=15): {loo_k15.elpd_loo:.2f}") ``` The stacking ELPD is at least as good as the best individual model, and often better when the models contribute complementary predictive information. The standard error (`result.se_elpd_stacking`) quantifies the uncertainty in this estimate. ## Where to go next - **[Model comparison with LOO](28-loo.qmd)**: PSIS-LOO cross-validation, whose pointwise ELPD values feed into stacking. - **[Model comparison with WAIC](29-waic.qmd)**: an alternative to LOO that can also be used as input to stacking. - **[Posterior predictive distributions](31-posterior-predict.qmd)**: the full predictive distribution from each model, which stacking weights can combine. - **[Posterior predictive checks](30-ppc.qmd)**: verify that candidate models generate realistic data before averaging them. - **[Variational inference](26-variational-inference.qmd)**: fitting the Bayesian models whose LOO or WAIC results are stacked. ### Smoothing parameter sensitivity ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` When you fit a GAM, the smoothing parameters $\lambda$ control the trade-off between fidelity to the data and smoothness of the fitted curves. REML, GCV, and ML each choose a particular set of $\lambda$ values, but the choice is never exact (it is itself an estimate, subject to uncertainty). A natural question is: how much would the predictions change if the smoothing parameters were somewhat different? The `smoothing_sensitivity()` method answers this by re-fitting the model across a grid of multiplier values applied to the estimated smoothing parameters. If predictions are stable across a wide range of multipliers, you can be confident that the conclusions do not hinge on the exact $\lambda$ values chosen. If they change substantially, that signals the data do not strongly constrain the smoothness of the fit, and results should be interpreted more cautiously. ## Basic usage Fit a model, then call `smoothing_sensitivity()`. The method scales all smoothing parameters by each multiplier in the grid, re-fits with those fixed values, and collects the predictions and fit statistics at each step. ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(23) n = 200 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) model = wk.GAM("y ~ s(x)").fit({"x": x, "y": y}) sens = model.smoothing_sensitivity() print(sens) ``` The printed summary reports the multiplier range, the span of EDF values across the grid, and the largest absolute prediction change at any observation. A small maximum change relative to the response scale is a sign of robustness. ## Understanding the result The `SensitivityResult` contains arrays indexed by the multiplier step. Each row corresponds to one multiplier value, applied uniformly to *all* smoothing parameters in the model. **`multipliers`**: the grid of scaling factors. By default, 11 values log-spaced from 0.01 to 100 (i.e., the estimated $\lambda$ divided by 100 up to multiplied by 100). The baseline (the original fit) sits at multiplier 1.0 in the middle of the grid. **`predictions`**: the fitted values at each step, shape `(n_steps, n_obs)`. Use `baseline_predictions` to get the row corresponding to the original fit. **`edf_total`**: total effective degrees of freedom at each step. Small multipliers (less smoothing) yield higher EDF; large multipliers (more smoothing) shrink the EDF toward 1. **`deviance_explained`**, **`gcv_scores`**, **`aic_values`**: fit-quality metrics at each step. These help you see whether the chosen $\lambda$ sits near the optimum. ```{python} # The baseline index identifies which row matches the original fit print(f"Baseline multiplier: {sens.multipliers[sens.baseline_idx]:.2f}") print(f"Baseline EDF: {sens.edf_total[sens.baseline_idx]:.1f}") print(f"EDF range: [{sens.edf_total.min():.1f}, {sens.edf_total.max():.1f}]") ``` ## Visualizing prediction sensitivity A prediction envelope shows how much the fitted curve varies across the multiplier grid. A narrow envelope means the predictions are robust; a wide one reveals regions where the data do not strongly constrain the fit. ```{python} import altair as alt x_plot = np.linspace(0, 2 * np.pi, 200) sens_plot = model.smoothing_sensitivity(new_data={"x": x_plot}, n_steps=21) # Build envelope: min and max prediction at each x across all multipliers pred_min = sens_plot.predictions.min(axis=0) pred_max = sens_plot.predictions.max(axis=0) baseline = sens_plot.baseline_predictions envelope_data = [ { "x": float(x_plot[i]), "lower": float(pred_min[i]), "upper": float(pred_max[i]), "baseline": float(baseline[i]), } for i in range(len(x_plot)) ] band = alt.Chart({"values": envelope_data}).mark_area( opacity=0.2, color="steelblue" ).encode( x=alt.X("x:Q"), y=alt.Y("lower:Q", title="Prediction"), y2="upper:Q", ) line = alt.Chart({"values": envelope_data}).mark_line( color="steelblue" ).encode(x="x:Q", y="baseline:Q") obs_data = [{"x": float(x[i]), "y": float(y[i])} for i in range(n)] points = alt.Chart({"values": obs_data}).mark_circle( size=15, opacity=0.3, color="gray" ).encode(x="x:Q", y="y:Q") (band + line + points).properties( width=500, height=300, title="Prediction envelope across smoothing parameter multipliers (0.01x–100x)" ) ``` The shaded region covers all predictions from the most wiggly (0.01x) to the most smooth (100x) setting. The solid line is the baseline fit at the estimated $\lambda$. ## Tracking fit statistics across the grid Plotting EDF, GCV, or AIC against the multiplier reveals where the optimum sits and how flat or peaked the criterion surface is. A flat minimum means the data tolerate a range of smoothing levels. A sharp minimum means the criterion strongly prefers one setting. ```{python} metric_data = [ {"multiplier": float(m), "GCV": float(g), "AIC": float(a), "EDF": float(e)} for m, g, a, e in zip( sens.multipliers, sens.gcv_scores, sens.aic_values, sens.edf_total ) ] gcv_chart = alt.Chart({"values": metric_data}).mark_line( point=True, color="steelblue" ).encode( x=alt.X("multiplier:Q", scale=alt.Scale(type="log"), title="Smoothing multiplier"), y=alt.Y("GCV:Q", title="GCV score"), ).properties(width=350, height=200, title="GCV across multipliers") aic_chart = alt.Chart({"values": metric_data}).mark_line( point=True, color="firebrick" ).encode( x=alt.X("multiplier:Q", scale=alt.Scale(type="log"), title="Smoothing multiplier"), y=alt.Y("AIC:Q", title="AIC"), ).properties(width=350, height=200, title="AIC across multipliers") gcv_chart | aic_chart ``` Both GCV and AIC reach their minimum near multiplier 1.0, confirming that the automatic selection found a good setting. The curves are relatively flat near the minimum, which means moderate changes to $\lambda$ would not substantially affect the fit. ## Customizing the grid By default, `smoothing_sensitivity()` uses 11 log-spaced multipliers from 0.01 to 100. You can customize this in several ways: ```{python} # Fewer steps, narrower range sens_narrow = model.smoothing_sensitivity(n_steps=5, log_range=(-1.0, 1.0)) print(f"Multipliers: {sens_narrow.multipliers.round(2)}") ``` ```{python} # Explicit multiplier values sens_custom = model.smoothing_sensitivity(multipliers=[0.1, 0.5, 1.0, 2.0, 10.0]) print(f"Multipliers: {sens_custom.multipliers}") ``` A narrower range is useful when you only care about local sensitivity (e.g., "what if $\lambda$ were half or double its current value?"). A wider range shows the full spectrum from severely underfitting (very large $\lambda$) to overfitting (very small $\lambda$). ## When sensitivity is high If the prediction envelope is wide, the fit is sensitive to the choice of $\lambda$. This can happen when: - the sample size is small: fewer observations mean less information to pin down the smoothing level. Consider whether the data support the complexity of the model. - the signal is weak: when the signal-to-noise ratio is low, a range of smoothness levels are roughly equally plausible. Reporting the prediction envelope alongside the point estimate is more honest than reporting only the best-fit curve. - the basis dimension is too large: an unnecessarily large `k` gives the optimizer more freedom, and the criterion surface can become flatter. Reducing `k` to a value supported by `wk.check(model)` can sharpen the optimum. In these cases, `unconditional=True` in `predict()` already inflates confidence intervals to account for smoothing-parameter uncertainty. The sensitivity analysis complements this by showing the full range of plausible fitted curves, not just the interval at the estimated $\lambda$. ## Where to go next - **[Model diagnostics](11-diagnostics.qmd)**: basis dimension checks, residual analysis, and the `goodness_of_fit()` summary that captures fit quality at any single $\lambda$ setting. - **[Model fitting](06-fitting.qmd)**: how REML, GCV, and ML select smoothing parameters, and when to use each criterion. - **[Prediction and inference](08-prediction.qmd)**: confidence intervals with `unconditional=True` for smoothing-parameter uncertainty. - **[Cross-validation](14-cross-validation.qmd)**: K-fold cross-validation as an alternative assessment of predictive performance. ### Derivatives and marginal effects ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` The partial effects plot from `model.partial_effects()` shows the estimated shape of each smooth, but it does not directly answer questions like *"where is the effect increasing?"* or *"at what rate?"*. For that you need the derivative of the smooth with respect to its covariate. Whittaker provides three tools for this kind of inference: - **`derivatives()`** estimates the first or second derivative of a smooth, with confidence bands that let you identify regions where the rate of change is significantly different from zero. - **`marginal_effects()`** evaluates a smooth over a grid while holding other covariates fixed, giving the partial effect on the linear predictor. - **`pairwise_comparisons()`** computes the difference between two conditions along a smooth, with pointwise confidence bands (useful for asking *"does the effect of x differ between group A and group B?"*). All three methods use the Bayesian posterior covariance of the coefficients to compute standard errors, so the confidence bands account for smoothing uncertainty. Setting `unconditional=True` additionally inflates the bands to account for uncertainty in the smoothing parameters themselves. ## Setup We will work with a simulated dataset that has a nonlinear effect of `x`, a linear effect of a grouping variable `z`, and a smooth interaction between `x` and `z` via a `by=` variable. ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(23) n = 400 x = np.sort(rng.uniform(0, 2 * np.pi, n)) z = rng.choice([0.0, 1.0], size=n) # True effect: sin(x) for z=0, sin(x) + 0.5*cos(2x) for z=1 mu = np.sin(x) + z * 0.5 * np.cos(2 * x) y = mu + rng.normal(0, 0.3, n) data = {"x": x, "y": y, "z": z} model = wk.GAM("y ~ s(x, by=z)").fit(data, method="REML") model.summary() ``` ## First derivatives The first derivative $\partial f / \partial x$ tells you the instantaneous rate of change of the smooth at each point. Where the derivative is positive, the effect is increasing. Where it is negative, the effect is decreasing. Where the confidence band excludes zero, that change is statistically significant. `derivatives()` uses central finite differences on the basis matrix with delta-method standard errors to estimate the derivative at a fine grid of points along the covariate's range. ```{python} deriv = model.derivatives("x") ``` The result is a list of `DerivativeResult` objects, one per smooth term involving the variable. Each contains: - **`x`**: the evaluation grid - **`derivative`**: the estimated derivative values - **`se`**: standard errors - **`lower`** and **`upper`**: confidence bands at the specified level (default 95%) ```{python} d = deriv[0] # first smooth term print(f"Term: {d.term}") print(f"Order: {d.order}") print(f"Grid points: {len(d.x)}") print(f"Confidence level: {d.level}") ``` ### Plotting derivatives A derivative plot with the confidence band clearly shows where the smooth is significantly increasing or decreasing (regions where the band excludes zero). ```{python} import altair as alt d = deriv[0] deriv_data = [ {"x": float(d.x[i]), "derivative": float(d.derivative[i]), "lower": float(d.lower[i]), "upper": float(d.upper[i])} for i in range(len(d.x)) ] band = alt.Chart({"values": deriv_data}).mark_area( opacity=0.2, color="steelblue" ).encode(x=alt.X("x:Q"), y="lower:Q", y2="upper:Q") line = alt.Chart({"values": deriv_data}).mark_line( color="steelblue" ).encode(x="x:Q", y=alt.Y("derivative:Q", title="∂f/∂x")) zero = alt.Chart({"values": [{"y": 0}]}).mark_rule( color="firebrick", strokeDash=[4, 4] ).encode(y="y:Q") (band + line + zero).properties( width=500, height=250, title=f"First derivative of {d.term}" ) ``` Where the shaded band lies entirely above (or below) the red dashed line at zero, the smooth is significantly increasing (or decreasing) at the 95% level. ## Second derivatives The second derivative $\partial^2 f / \partial x^2$ measures the curvature of the smooth. Where it is significantly different from zero, the smooth is concave (negative) or convex (positive). This is useful for identifying inflection points and regions of rapid change. ```{python} deriv2 = model.derivatives("x", order=2) d2 = deriv2[0] d2_data = [ {"x": float(d2.x[i]), "derivative": float(d2.derivative[i]), "lower": float(d2.lower[i]), "upper": float(d2.upper[i])} for i in range(len(d2.x)) ] band2 = alt.Chart({"values": d2_data}).mark_area( opacity=0.2, color="darkorange" ).encode(x=alt.X("x:Q"), y="lower:Q", y2="upper:Q") line2 = alt.Chart({"values": d2_data}).mark_line( color="darkorange" ).encode(x="x:Q", y=alt.Y("derivative:Q", title="∂²f/∂x²")) (band2 + line2 + zero).properties( width=500, height=250, title=f"Second derivative of {d2.term}" ) ``` ## Detecting significant change A common applied question is: *"over what range of x is the effect significantly changing?"*. The answer is wherever the derivative's confidence band excludes zero. You can extract these regions programmatically: ```{python} d = deriv[0] sig_increase = (d.lower > 0) sig_decrease = (d.upper < 0) print(f"Significantly increasing over x in: " f"[{d.x[sig_increase].min():.2f}, {d.x[sig_increase].max():.2f}]") print(f"Significantly decreasing over x in: " f"[{d.x[sig_decrease].min():.2f}, {d.x[sig_decrease].max():.2f}]") print(f"Not significantly changing: {(~sig_increase & ~sig_decrease).sum()} " f"of {len(d.x)} grid points") ``` ## Marginal effects While `derivatives()` tells you the *rate of change*, `marginal_effects()` tells you the *level* of the smooth at each point, holding other covariates fixed. This is the GAM equivalent of the `marginaleffects` package in R or `gratia::smooth_estimates()`. ```{python} me = model.marginal_effects("x") ``` Each `MarginalEffectResult` contains the smooth's contribution to the linear predictor (not the response scale), evaluated over a grid of the focal variable while other covariates are held at their means. ```{python} m = me[0] print(f"Term: {m.term}") print(f"Variable: {m.variable}") print(f"Grid points: {len(m.x)}") print(f"Conditioning values: {m.by_values}") ``` ### Conditioning on specific values The `at` parameter lets you fix other covariates at specific values instead of their means. This is especially useful for `by=` smooths or models with interactions: ```{python} me_z0 = model.marginal_effects("x", at={"z": 0.0}) me_z1 = model.marginal_effects("x", at={"z": 1.0}) ``` ```{python} plot_data = [] for label, results in [("z = 0", me_z0), ("z = 1", me_z1)]: m = results[0] for i in range(len(m.x)): plot_data.append({ "x": float(m.x[i]), "effect": float(m.effect[i]), "lower": float(m.lower[i]), "upper": float(m.upper[i]), "group": label, }) band_me = alt.Chart({"values": plot_data}).mark_area(opacity=0.15).encode( x=alt.X("x:Q"), y=alt.Y("lower:Q", title="Partial effect on η"), y2="upper:Q", color=alt.Color("group:N", title="Condition"), ) line_me = alt.Chart({"values": plot_data}).mark_line().encode( x="x:Q", y="effect:Q", color="group:N", ) (band_me + line_me).properties( width=500, height=300, title="Marginal effects of x, conditioned on z" ) ``` The two curves show how the smooth effect of `x` differs between the two groups. The confidence bands overlap in some regions (suggesting no significant difference there) and separate in others (suggesting the group effect is real). ## Pairwise comparisons `pairwise_comparisons()` directly estimates the difference between two conditions with pointwise confidence bands. This is more formal than eyeballing the overlap of marginal-effect bands, because it accounts for the covariance between the two estimates. ```{python} contrasts = model.pairwise_comparisons( "x", pairs=[({"z": 1.0}, {"z": 0.0})], ) c = contrasts[0] print(f"Term: {c.term}") print(f"Comparison: {c.label}") ``` The `ContrastResult` contains the estimated difference `f(x | z=1) - f(x | z=0)` with standard errors and confidence bands. Where the band excludes zero, the two conditions are significantly different at that value of `x`. ```{python} contrast_data = [ {"x": float(c.x[i]), "difference": float(c.difference[i]), "lower": float(c.lower[i]), "upper": float(c.upper[i])} for i in range(len(c.x)) ] band_c = alt.Chart({"values": contrast_data}).mark_area( opacity=0.2, color="steelblue" ).encode(x=alt.X("x:Q"), y="lower:Q", y2="upper:Q") line_c = alt.Chart({"values": contrast_data}).mark_line( color="steelblue" ).encode(x="x:Q", y=alt.Y("difference:Q", title="f(z=1) − f(z=0)")) zero_c = alt.Chart({"values": [{"y": 0}]}).mark_rule( color="firebrick", strokeDash=[4, 4] ).encode(y="y:Q") (band_c + line_c + zero_c).properties( width=500, height=250, title="Pairwise comparison: z=1 vs. z=0" ) ``` Where the band is entirely above zero, the `z=1` group has a significantly higher effect. Where it crosses zero, the difference is not significant. This is the GAM analogue of `emmeans` or `marginaleffects::comparisons()` from R. ## Unconditional intervals By default, all three methods use the Bayesian posterior covariance *conditional on* the estimated smoothing parameters. Setting `unconditional=True` adds the extra uncertainty from estimating $\lambda$ itself (Marra & Wood, 2012), producing wider and more conservative bands: ```{python} deriv_cond = model.derivatives("x") deriv_uncond = model.derivatives("x", unconditional=True) d_c, d_u = deriv_cond[0], deriv_uncond[0] print(f"Mean SE (conditional): {d_c.se.mean():.4f}") print(f"Mean SE (unconditional): {d_u.se.mean():.4f}") print(f"Ratio: {d_u.se.mean() / d_c.se.mean():.2f}x") ``` The unconditional intervals are always at least as wide. Use them when the smoothing-parameter uncertainty is an important part of the inference. For example, when the GCV or REML criterion surface is flat (as shown by `smoothing_sensitivity()`). ## Where to go next - **[Model diagnostics](11-diagnostics.qmd)**: residual analysis and basis dimension checks before interpreting smooth effects. - **[Advanced diagnostics](13-advanced-diagnostics.qmd)**: influence, concurvity, dispersion tests, and other model-checking tools. - **[Prediction and inference](08-prediction.qmd)**: confidence and prediction intervals for the overall response. - **[Smoothing parameter sensitivity](33-sensitivity.qmd)**: check whether derivative-based conclusions are robust to the choice of smoothing parameters. - **[ANOVA for GAMs](16-anova.qmd)**: formal deviance-difference tests for nested model comparison. ### Programmatic formula construction ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Throughout the user guide, formulas are written as strings like `"y ~ s(x1) + s(x2)"`. This is convenient for interactive work, but sometimes you need to build formulas programmatically (for example, when the set of covariates is determined at runtime, or when you want to manipulate individual terms in a loop). Whittaker's formula system has two layers: 1. **String formulas**: parsed by `parse_formula()` into structured objects. 2. **Term objects**: `LinearTerm`, `SmoothTerm`, `InteractionTerm`, `OffsetTerm`, and `Formula` (which can be constructed directly and passed to `GAM()`). ## Parsing a string formula `parse_formula()` turns a formula string into a `Formula` object: ```{python} from whittaker import parse_formula f = parse_formula("y ~ s(x1, k=10) + x2 + x1 * x3") print(f"Response: {f.response}") print(f"Intercept: {f.intercept}") print(f"Terms ({len(f.terms)}):") for t in f.terms: print(f" {type(t).__name__}: {t}") ``` The `Formula` object records the response name, an ordered list of terms, and whether an intercept is included. Each term is one of the four term types. ## Building formulas from term objects Instead of parsing a string, you can construct a `Formula` directly from term objects. This is useful when the terms are determined by data or configuration: ```{python} from whittaker import Formula, LinearTerm, SmoothTerm f = Formula( response="y", terms=[ SmoothTerm(variables=("x1",), k=10), SmoothTerm(variables=("x2",), bs="cr"), LinearTerm(variable="x3"), ], ) print(f) ``` The resulting `Formula` is identical to what `parse_formula("y ~ s(x1, k=10) + s(x2, bs='cr') + x3")` would produce, and can be passed directly to `GAM()`: ```{python} import numpy as np import whittaker as wk rng = np.random.default_rng(0) n = 200 data = { "x1": np.linspace(0, 2 * np.pi, n), "x2": rng.uniform(0, 5, n), "x3": rng.normal(size=n), "y": np.sin(np.linspace(0, 2 * np.pi, n)) + rng.normal(0, 0.3, n), } model = wk.GAM(f).fit(data) model.summary() ``` ## Term types ### SmoothTerm `SmoothTerm` represents `s()`, `te()`, `ti()`, and `t2()` terms: ```{python} from whittaker import SmoothTerm # Equivalent to s(x, k=15, bs='cr') s1 = SmoothTerm(variables=("x",), k=15, bs="cr") print(s1) # Equivalent to te(x1, x2) te = SmoothTerm(variables=("x1", "x2"), smooth_type="te") print(te) # Equivalent to s(x, by=group) s_by = SmoothTerm(variables=("x",), by="group") print(s_by) # Equivalent to ti(x1, x2, k=5) ti = SmoothTerm(variables=("x1", "x2"), smooth_type="ti", k=5) print(ti) ``` The `extra` dictionary passes additional keyword arguments to the basis constructor: ```{python} # Equivalent to s(x, bs='ps', m=2): P-spline with second-order penalty s_ps = SmoothTerm(variables=("x",), bs="ps", extra={"m": 2}) print(s_ps) ``` ### LinearTerm `LinearTerm` represents a bare covariate entered linearly (unpenalized): ```{python} from whittaker import LinearTerm lt = LinearTerm(variable="age") print(lt) ``` For categorical columns, `LinearTerm` automatically expands to dummy indicators (one per non-reference level) when the model matrix is built. ### InteractionTerm `InteractionTerm` represents a parametric interaction between two covariates: ```{python} from whittaker import InteractionTerm # Full interaction (x1 * x2): includes both main effects + interaction full = InteractionTerm(left="x1", right="x2", full=True) print(full) # Interaction only (x1 : x2): no main effects interaction_only = InteractionTerm(left="x1", right="x2", full=False) print(interaction_only) ``` For smooth interactions between continuous variables, use a tensor-product `SmoothTerm` instead. ### OffsetTerm `OffsetTerm` represents a covariate with a fixed coefficient of 1, commonly used for exposure terms in rate models: ```{python} from whittaker import OffsetTerm offset = OffsetTerm(expression="log_exposure") print(offset) ``` ## Dynamic formula construction The main advantage of the object API is building formulas dynamically. Here's an example that creates a smooth term for every numeric column in a dataset: ```{python} feature_cols = ["x1", "x2", "x3", "x4", "x5"] terms = [SmoothTerm(variables=(col,)) for col in feature_cols] formula = Formula(response="y", terms=terms) print(formula) ``` You can also conditionally add terms: ```{python} terms = [] for col in feature_cols: if col in ("x1", "x2"): terms.append(SmoothTerm(variables=(col,), k=20)) else: terms.append(LinearTerm(variable=col)) formula = Formula(response="y", terms=terms) print(formula) ``` ## Suppressing the intercept Set `intercept=False` to drop the intercept (equivalent to `y ~ 0 + ...`): ```{python} f_no_intercept = Formula( response="y", terms=[SmoothTerm(variables=("x",))], intercept=False, ) print(f_no_intercept) ``` ## Inspecting required columns `Formula.required_columns()` returns every data column the formula needs, in first-seen order: ```{python} f = parse_formula("y ~ s(x1, by=group) + x2 + te(x3, x4)") print(f.required_columns()) ``` This is useful for validating that a dataset has all the columns a formula expects before fitting. ## Mixing string and object APIs You can use strings for interactive exploration and switch to the object API when you need programmatic control. Both produce the same `Formula` objects and both are accepted by `GAM()`: ```{python} # These are equivalent: m1 = wk.GAM("y ~ s(x1, k=10) + x2") m2 = wk.GAM(Formula( response="y", terms=[SmoothTerm(variables=("x1",), k=10), LinearTerm(variable="x2")], )) print(m1.formula) print(m2.formula) ``` ## Where to go next - **[Smooth terms](04-smooths.qmd)**: details on basis types, knot placement, and the `k` parameter. - **[Model fitting](06-fitting.qmd)**: fitting methods and smoothing parameter selection. - **[Data input](07-data-input.qmd)**: supported data formats and column types. ### scikit-learn integration ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Whittaker's core `GAM` class uses a formula-and-dictionary interface: you write `"y ~ s(x1) + x2"` and pass a column-oriented dict. This is expressive for GAM-specific work, but it does not plug directly into scikit-learn's `fit(X, y)` / `predict(X)` ecosystem. The `GAMRegressor` and `GAMClassifier` wrappers bridge that gap. They accept plain numpy arrays, auto-name the columns (`x0`, `x1`, ...), and delegate to a `GAM` internally (so a GAM can participate in `Pipeline`, `GridSearchCV`, `cross_val_score`, and any other scikit-learn tooling). ## GAMRegressor `GAMRegressor` is a `RegressorMixin` for continuous responses. By default it fits a Gaussian family with one `s(xi)` smooth per feature column: ```{python} import numpy as np from whittaker.sklearn import GAMRegressor rng = np.random.default_rng(0) X = rng.uniform(-2, 2, size=(200, 2)) y = np.sin(X[:, 0]) + 0.5 * X[:, 1] ** 2 + rng.normal(scale=0.2, size=200) reg = GAMRegressor() reg.fit(X, y) print(f"Features: {reg.feature_names_}") print(f"R² score: {reg.score(X, y):.4f}") ``` ### Custom formulas Pass `formula` to override the default additive formula. Use `x0`, `x1`, ... to refer to columns by position. A bare right-hand side (no `~`) gets `"y ~ "` prepended automatically: ```{python} reg_custom = GAMRegressor(formula="s(x0, k=20) + x1") reg_custom.fit(X, y) print(f"R²: {reg_custom.score(X, y):.4f}") ``` You can also write a full formula with `~`: ```{python} reg_full = GAMRegressor(formula="y ~ s(x0) + s(x1, bs='cr', k=8)") reg_full.fit(X, y) print(f"R²: {reg_full.score(X, y):.4f}") ``` ### Choosing the fitting method The `method` parameter controls smoothing parameter selection, and `select` enables double-penalty term selection: ```{python} reg_reml = GAMRegressor(method="REML", select=True) reg_reml.fit(X, y) print(f"R² (REML + select): {reg_reml.score(X, y):.4f}") ``` ### Non-Gaussian families Pass a `family` to fit non-Gaussian responses: ```{python} from whittaker.families.poisson import Poisson X_p = rng.uniform(0, 3, size=(200, 1)) y_p = rng.poisson(np.exp(0.5 * np.sin(X_p[:, 0]))).astype(float) reg_pois = GAMRegressor(family=Poisson()) reg_pois.fit(X_p, y_p) print(f"Predictions (first 5): {reg_pois.predict(X_p[:5]).round(3)}") ``` ## GAMClassifier `GAMClassifier` is a `ClassifierMixin` for binary classification. It always uses a Binomial family with a logit link internally: ```{python} from whittaker.sklearn import GAMClassifier X_c = rng.uniform(-2, 2, size=(300, 2)) logit = 1.5 * np.sin(X_c[:, 0]) - X_c[:, 1] p = 1 / (1 + np.exp(-logit)) y_c = rng.binomial(1, p).astype(float) clf = GAMClassifier() clf.fit(X_c, y_c) print(f"Classes: {clf.classes_}") print(f"Accuracy: {clf.score(X_c, y_c):.4f}") ``` ### Predicted probabilities `predict_proba()` returns a `(n_samples, 2)` array where column 0 is `P(y = 0)` and column 1 is `P(y = 1)`: ```{python} proba = clf.predict_proba(X_c[:5]) print(proba.round(3)) ``` ## Cross-validation Both wrappers work with `cross_val_score` and `cross_validate`: ```{python} from sklearn.model_selection import cross_val_score scores = cross_val_score(GAMRegressor(), X, y, cv=5, scoring="r2") print(f"5-fold R²: {scores.round(3)}") print(f"Mean: {scores.mean():.3f}") ``` ```{python} scores_clf = cross_val_score( GAMClassifier(), X_c, y_c, cv=5, scoring="accuracy" ) print(f"5-fold accuracy: {scores_clf.round(3)}") print(f"Mean: {scores_clf.mean():.3f}") ``` ## Pipelines GAM wrappers work as any estimator in a scikit-learn `Pipeline`: ```{python} from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler pipe = Pipeline([ ("scaler", StandardScaler()), ("gam", GAMRegressor(formula="s(x0) + s(x1)")), ]) pipe.fit(X, y) print(f"Pipeline R²: {pipe.score(X, y):.4f}") ``` The scaler standardizes features before they reach the GAM. Since the GAM learns its own smooth functions, scaling is rarely necessary but it can help when features have very different ranges and you are combining the GAM with other estimators. ## Hyperparameter search `GridSearchCV` can search over `formula`, `method`, and `select`: ```{python} from sklearn.model_selection import GridSearchCV param_grid = { "method": ["GCV", "REML"], "select": [False, True], } search = GridSearchCV(GAMRegressor(), param_grid, cv=3, scoring="r2") search.fit(X, y) print(f"Best params: {search.best_params_}") print(f"Best R²: {search.best_score_:.4f}") ``` ## Accessing the underlying GAM After fitting, the underlying `GAM` object is available as `reg.gam_`. This gives you access to the full Whittaker API (summaries, partial effects, diagnostics): ```{python} reg = GAMRegressor(formula="s(x0) + s(x1)", method="REML") reg.fit(X, y) reg.gam_.summary() ``` ```{python} gof = reg.gam_.goodness_of_fit() print(f"AIC: {gof.aic:.2f}") print(f"Dev. explained: {gof.deviance_explained:.1%}") ``` ## When to use GAM vs. GAMRegressor | | `GAM` | `GAMRegressor` / `GAMClassifier` | |---|---|---| | **Input format** | Named dict `{"x": array}` | Numpy array `X` | | **Formula** | Required | Auto-generated or optional | | **scikit-learn compatible** | No | Yes | | **Full inference API** | Direct | Via `.gam_` | | **Best for** | GAM-specific work, inference, diagnostics | ML pipelines, cross-validation, grid search | Use `GAM` directly when you are doing GAM-specific work: interpreting smooth effects, computing derivatives, running diagnostics, or comparing models. Use the sklearn wrappers when you need a GAM to participate in a scikit-learn workflow (pipelines, cross-validation, or hyperparameter search). ## Limitations - **Feature names are positional**: columns are named `x0`, `x1`, ... by position. If you reorder features between `fit` and `predict`, the formula terms will apply to the wrong columns. - **Binary classification only**: `GAMClassifier` supports exactly two classes. For multi-class problems, use `GAM` with the `Multinomial` family directly. - **No `transform` method**: GAM wrappers are estimators, not transformers. They cannot be used as intermediate steps in a pipeline (only as the final estimator). - **Smoothing parameters are not hyperparameters**: `GridSearchCV` can tune `method` and `select`, but the smoothing parameters themselves are always selected internally during `fit()`. ## Where to go next - **[Model fitting](06-fitting.qmd)**: details on `method` and `select` options. - **[Model diagnostics](11-diagnostics.qmd)**: checking the fit via `reg.gam_`. - **[Cross-validation](14-cross-validation.qmd)**: Whittaker's own cross-validation for GAM-specific metrics (deviance, term-level EDF). ### Model matrix utilities ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Most users never need to touch the model matrix directly as `GAM.fit()` and `GAM.predict()` handle it internally. But if you want to inspect the basis functions, understand the penalty structure, extract term-level columns for custom inference, or build a prediction matrix manually, Whittaker exposes the low-level `build_model_matrix()` and `predict_matrix()` functions along with the `ModelMatrix` and `SmoothInfo` dataclasses. ## Overview When a GAM is fitted, the formula is converted into a numeric **design matrix** `X` of shape `(n_observations, n_coefficients)` such that the linear predictor is: $$\eta = X \beta + \text{offset}$$ Each formula term contributes columns to `X`: - **Intercept**: a single column of ones. - **Linear terms** (`x1`): the raw covariate column. - **Interactions** (`x1 * x2`): the elementwise product (plus main effects). - **Smooth terms** (`s(x)`): several columns, with one per basis function of the fitted spline. The `build_model_matrix()` function performs this expansion and also constructs the penalty matrices that control smoothing. ## Building a model matrix ```{python} import numpy as np import whittaker as wk from whittaker.model_matrix import build_model_matrix, predict_matrix rng = np.random.default_rng(0) n = 100 data = { "x1": np.linspace(0, 2 * np.pi, n), "x2": rng.uniform(0, 5, n), "y": np.sin(np.linspace(0, 2 * np.pi, n)) + rng.normal(0, 0.3, n), } formula = wk.parse_formula("y ~ s(x1, k=8) + x2") mm = build_model_matrix(formula, data) print(f"Design matrix shape: {mm.X.shape}") print(f"Number of coefficients: {mm.n_coefs}") print(f"Number of observations: {mm.n_obs}") print(f"Has intercept: {mm.has_intercept}") print(f"Parametric columns: {mm.n_parametric}") print(f"Number of penalties: {len(mm.penalties)}") ``` ## Column names Every column in `X` has a human-readable label: ```{python} for i, name in enumerate(mm.column_names): print(f" [{i:2d}] {name}") ``` Column 0 is the intercept, then the parametric terms, then the smooth's basis functions. ## Inspecting smooth terms with SmoothInfo Each smooth term in the formula produces a `SmoothInfo` object that records where that term's columns live in the full design matrix: ```{python} for info in mm.smooths: print(f"Term: {info.term}") print(f"Columns: {info.col_start}:{info.col_end} " f"({info.col_end - info.col_start} basis functions)") print(f"Null space dim: {info.null_space_dim}") print(f"Penalty indices: {info.penalty_indices}") print(f"Basis type: {type(info.basis).__name__}") print() ``` `SmoothInfo` gives you: - **`col_start` / `col_end`**: the slice `X[:, col_start:col_end]` for this term's basis columns. - **`basis`**: the fitted `SmoothBasis` instance (retaining knots and constraints for prediction). - **`null_space_dim`**: how many basis directions are unpenalized (the "linear" part). - **`penalty_indices`**: which entries in `mm.penalties` belong to this term. ## Extracting a term's basis columns Use `col_start` and `col_end` to extract the basis matrix for a specific smooth: ```{python} info = mm.smooths[0] B = mm.X[:, info.col_start:info.col_end] print(f"Basis matrix for {info.term}: shape {B.shape}") print(f"First 3 rows:\n{B[:3].round(4)}") ``` ## Penalty matrices Each smooth term contributes one or more penalty matrices. These are `(n_coefs, n_coefs)` matrices embedded in the full model dimension, with nonzero entries only in the block corresponding to that term's columns: ```{python} S = mm.penalties[0] print(f"Penalty shape: {S.shape}") nonzero_rows = np.any(S != 0, axis=1) print(f"Nonzero rows: {np.where(nonzero_rows)[0].tolist()}") print(f"Matches smooth columns: {info.col_start}:{info.col_end}") ``` The combined (unweighted) penalty is available as a convenience property: ```{python} S_total = mm.penalty_matrix print(f"Combined penalty shape: {S_total.shape}") print(f"Nonzero entries: {np.count_nonzero(S_total)}") ``` ## Building a prediction matrix After fitting, `predict_matrix()` builds a new design matrix for unseen data using the *same* knots, constraints, and column layout as the training matrix: ```{python} new_data = { "x1": np.linspace(0, 2 * np.pi, 50), "x2": rng.uniform(0, 5, 50), } X_pred = predict_matrix(mm, new_data) print(f"Prediction matrix shape: {X_pred.shape}") print(f"Same columns as training: {X_pred.shape[1] == mm.n_coefs}") ``` This is what `GAM.predict()` uses internally. You can use it directly for custom predictions: ```{python} model = wk.GAM("y ~ s(x1, k=8) + x2").fit(data) beta = model._fit_result.coefficients eta = X_pred @ beta print(f"Manual linear predictor (first 5): {eta[:5].round(4)}") auto_preds = model.predict(new_data) print(f"GAM.predict() values (first 5): {auto_preds.values[:5].round(4)}") ``` ## The response column `ModelMatrix` also stores the response variable as extracted from the data: ```{python} print(f"Response shape: {mm.response.shape}") print(f"Response (first 5): {mm.response[:5].round(4)}") ``` ## Working with offsets If the formula includes an `offset()` term, the offset vector is stored on the `ModelMatrix` and can be reconstructed for new data with `predict_offset()`: ```{python} from whittaker.model_matrix import predict_offset print(f"Offset: {mm.offset}") print(f"Offset expressions: {mm.offset_expressions}") ``` For a model with an offset (e.g., a Poisson rate model with `offset(log_exposure)`), `predict_offset()` evaluates the offset expression on new data. ## Use cases The model matrix utilities are useful for: - custom inference: extracting term-level basis matrices for manual Bayesian or frequentist calculations beyond what the built-in inference methods provide. - debugging: verifying that the basis expansion matches expectations (correct number of basis functions, expected penalty structure). - teaching: understanding how a GAM converts a formula into a penalized linear regression. - extensions: building custom estimators that use Whittaker's basis machinery but a different fitting algorithm. ## Where to go next - **[Smooth terms](04-smooths.qmd)**: basis types and the `k` parameter. - **[Programmatic formula construction](35-programmatic-formulas.qmd)**: building `Formula` objects from term objects. - **[Model fitting](06-fitting.qmd)**: how the design matrix feeds into P-IRLS fitting. ## Deployment ### Saving and loading models ```{python} #| echo: false #| output: false import great_docs as gd gd.enable_tbl_preview(n_head=8, n_tail=3) ``` Once a GAM is fitted, you often want to save it: for deployment, for sharing with collaborators, or for reproducing results later without re-fitting. Whittaker provides two serialization pathways: - **Native format** (`save_gam` / `load_gam`): round-trips a fitted GAM to a compact `.npz` archive. Fast, lossless, Python-only. Supports frequentist, VI, and MCMC fits. - **`mgcv` interchange** (`to_mgcv_dict` / `from_mgcv_dict`): converts between Whittaker GAMs and R's `mgcv` `gam` object structure. Useful for cross-language workflows (frequentist fits only). ## Saving and loading a fitted GAM ```{python} import numpy as np import whittaker as wk import tempfile, pathlib # Fit a model rng = np.random.default_rng(23) n = 300 x = np.linspace(0, 2 * np.pi, n) y = np.sin(x) + rng.normal(0, 0.3, n) model = wk.GAM("y ~ s(x)") model.fit({"x": x, "y": y}, method="REML") print(f"Original EDF: {model.edf_total:.1f}") print(f"Original scale: {model.scale:.4f}") ``` ### Saving ```{python} # Save to a .npz file tmpdir = pathlib.Path(tempfile.mkdtemp()) model_path = tmpdir / "my_model.npz" wk.save_gam(model, model_path) print(f"Saved to: {model_path}") print(f"File size: {model_path.stat().st_size:,} bytes") ``` The `.npz` archive contains the formula, family, coefficients, smoothing parameters, penalty matrices, and all information needed to reconstruct the model for prediction and inference. ### Loading ```{python} # Load the model back loaded = wk.load_gam(model_path) print(f"Loaded EDF: {loaded.edf_total:.1f}") print(f"Loaded scale: {loaded.scale:.4f}") ``` The loaded model is a fully functional `GAM`. You can call `predict()`, `summary()`, and all other methods: ```{python} # Predictions match the original x_test = np.linspace(0, 2 * np.pi, 50) pred_original = model.predict({"x": x_test}).values pred_loaded = loaded.predict({"x": x_test}).values max_diff = np.abs(pred_original - pred_loaded).max() print(f"Max prediction difference: {max_diff:.1e}") ``` ```{python} import altair as alt # Verify visually plot_data = [ {"x": float(x_test[i]), "y": float(pred_loaded[i]), "source": "Loaded"} for i in range(len(x_test)) ] + [ {"x": float(x_test[i]), "y": float(np.sin(x_test[i])), "source": "Truth"} for i in range(len(x_test)) ] alt.Chart({"values": plot_data}).mark_line(strokeWidth=2).encode( x=alt.X("x:Q"), y=alt.Y("y:Q", title="f(x)"), color=alt.Color("source:N"), strokeDash=alt.condition( alt.datum.source == "Truth", alt.value([4, 4]), alt.value([0]) ), ).properties(width="container", height=300, title="Predictions from loaded model") ``` ### Standard errors from loaded models The loaded model retains the full covariance structure, so predictions with standard errors work: ```{python} pred_se = loaded.predict({"x": x_test}, se=True) print(f"SE shape: {pred_se.se.shape}") print(f"Mean SE: {pred_se.se.mean():.4f}") ``` ## Saving models with different families The `save_gam()` and `load_gam()` functions handle all response families: ```{python} # Poisson model rng = np.random.default_rng(23) mu = np.exp(0.5 + 0.8 * np.sin(x)) y_pois = rng.poisson(mu).astype(float) model_pois = wk.GAM("y ~ s(x)", family=wk.Poisson()) model_pois.fit({"x": x, "y": y_pois}, method="REML") pois_path = tmpdir / "poisson_model.npz" wk.save_gam(model_pois, pois_path) loaded_pois = wk.load_gam(pois_path) print(f"Original family: {type(model_pois.family).__name__}") print(f"Loaded family: {type(loaded_pois.family).__name__}") print(f"Predictions match: {np.allclose( model_pois.predict({'x': x_test}).values, loaded_pois.predict({'x': x_test}).values )}") ``` ## Saving and loading Bayesian fits Models fitted with `method="VI"` or `method="MCMC"` can be saved and loaded with the same `save_gam()` / `load_gam()` functions. The archive stores the full posterior: the posterior mean, the posterior covariance (or Cholesky factor for VI), and the MCMC samples and diagnostics for MCMC fits. ### Variational inference ```{python} tmpdir_bayes = pathlib.Path(tempfile.mkdtemp()) model_vi = wk.GAM("y ~ s(x)") model_vi.fit({"x": x, "y": y}, method="VI") vi_path = tmpdir_bayes / "vi_model.npz" wk.save_gam(model_vi, vi_path) loaded_vi = wk.load_gam(vi_path) print(f"VI result preserved: {loaded_vi.vi_result is not None}") print(f"ELBO: {loaded_vi.vi_result.elbo:.2f}") print(f"Predictions match: {np.allclose( model_vi.predict({'x': x_test}).values, loaded_vi.predict({'x': x_test}).values )}") ``` The loaded VI model retains the full variational posterior, so you can draw posterior samples and compute predictions with standard errors exactly as with the original: ```{python} # Draw from the posterior draws = loaded_vi.vi_result.draw(500, seed=0) print(f"Posterior draws shape: {draws.shape}") # Standard errors pred_vi = loaded_vi.predict({"x": x_test}, se=True) print(f"Mean SE: {pred_vi.se.mean():.4f}") ``` Fit metrics such as AIC, BIC, and deviance are computed on the fly from the stored posterior mean and are available immediately after loading: ```{python} print(f"AIC: {loaded_vi.aic:.2f}") print(f"BIC: {loaded_vi.bic:.2f}") print(f"Deviance: {loaded_vi.deviance:.2f}") ``` ### MCMC ```{python} model_mcmc = wk.GAM("y ~ s(x)") model_mcmc.fit( {"x": x, "y": y}, method="MCMC", mcmc_options={"n_chains": 2, "n_samples": 500, "n_warmup": 250, "seed": 42}, ) mcmc_path = tmpdir_bayes / "mcmc_model.npz" wk.save_gam(model_mcmc, mcmc_path) loaded_mcmc = wk.load_gam(mcmc_path) print(f"MCMC result preserved: {loaded_mcmc.mcmc_result is not None}") print(f"Chains: {loaded_mcmc.mcmc_result.n_chains}") print(f"Samples per chain: {loaded_mcmc.mcmc_result.n_samples}") ``` MCMC diagnostics (R-hat, ESS, acceptance rate) are preserved in the archive: ```{python} mr = loaded_mcmc.mcmc_result print(f"Max R-hat: {mr.r_hat.max():.4f}") print(f"Min bulk ESS: {mr.ess.min():.0f}") print(f"Min tail ESS: {mr.ess_tail.min():.0f}") print(f"Acceptance rate: {mr.acceptance_rate:.3f}") ``` The full sample array is stored, so posterior predictive checks and LOO-CV work on the loaded model: ```{python} print(f"Samples shape: {loaded_mcmc.mcmc_result.samples.shape}") print(f"Predictions match: {np.allclose( model_mcmc.predict({'x': x_test}).values, loaded_mcmc.predict({'x': x_test}).values )}") ``` ```{python} # Clean up Bayesian temp files import shutil shutil.rmtree(tmpdir_bayes) ``` ::: {.callout-note} ## mgcv interchange and Bayesian fits The `to_mgcv_dict()` export is only available for frequentist fits. R's `mgcv` does not have a Bayesian fitting mode that corresponds to Whittaker's VI or MCMC, so there is no meaningful mgcv representation for these models. ::: ## mgcv interchange For cross-language workflows, Whittaker can export fitted GAMs as dictionaries that mirror the structure of R's `mgcv::gam` objects, and import them back. ### Exporting to mgcv format ```{python} # Export to an mgcv-compatible dictionary mgcv_dict = wk.to_mgcv_dict(model) # Inspect the keys print(f"Keys: {sorted(mgcv_dict.keys())}") print(f"Coefficients shape: {np.array(mgcv_dict['coefficients']).shape}") print(f"Family: {mgcv_dict['family']}") ``` The dictionary contains the same fields as an R `gam` object: `coefficients`, `sp` (smoothing parameters), `family`, `smooth` (smooth term metadata), and more. You can serialise it to JSON for transfer to R: ```{python} import json # Convert NumPy arrays to lists for JSON serialization def to_json_safe(obj): if isinstance(obj, np.ndarray): return obj.tolist() if isinstance(obj, np.floating): return float(obj) if isinstance(obj, np.integer): return int(obj) raise TypeError(f"Cannot serialize {type(obj)}") json_str = json.dumps(mgcv_dict, default=to_json_safe) print(f"JSON size: {len(json_str):,} characters") ``` ::: {.callout-note} ## Using in R In R, load the JSON and use it with `mgcv`: ```r library(jsonlite) library(mgcv) # Load the exported model gam_data <- fromJSON("model.json") # Reconstruct the gam object (details depend on your workflow) # The dictionary mirrors mgcv's internal structure ``` ::: ### Importing from mgcv The `data=` parameter provides the original training data, which is needed to rebuild the design matrix for prediction: ```{python} # Round-trip: export, then re-import with training data reimported = wk.from_mgcv_dict(mgcv_dict, data={"x": x, "y": y}) pred_reimp = reimported.predict({"x": x_test}).values max_diff = np.abs(pred_original - pred_reimp).max() print(f"Round-trip max difference: {max_diff:.1e}") # Standard errors are also available after full reconstruction pred_se_reimp = reimported.predict({"x": x_test}, se=True) print(f"SE available: {pred_se_reimp.se is not None}") ``` ## Validation `save_gam()` requires a fitted model: ```{python} # Trying to save an unfitted model raises an error unfitted = wk.GAM("y ~ s(x)") try: wk.save_gam(unfitted, tmpdir / "unfitted.npz") except RuntimeError as e: print(f"Error: {e}") ``` ```{python} # Trying to save a non-GAM object raises a TypeError try: wk.save_gam("not a model", tmpdir / "bad.npz") except TypeError as e: print(f"Error: {e}") ``` ## File format details The `.npz` format is a standard NumPy compressed archive. It is: - compact: typically 10-100 KB for a standard GAM - fast: loading is nearly instantaneous - portable: works across Python versions and platforms (any system with NumPy) - not human-readable: use `to_mgcv_dict()` + JSON if you need a human-readable format ::: {.callout-warning} ## Security note `load_gam()` uses `np.load()` with `allow_pickle=False` by default. The `.npz` files contain only arrays and metadata, not pickled Python objects. This means loaded models are safe from arbitrary code execution, unlike pickle-based serialization. ::: ## Practical deployment workflow A typical workflow for deploying a GAM as a prediction service: 1. train: fit the model on your training data 2. validate: check diagnostics, cross-validate 3. save: `wk.save_gam(model, "model_v1.npz")` 4. deploy: ship the `.npz` file with your application 5. load: `model = wk.load_gam("model_v1.npz")` 6. predict: `model.predict(new_data)` ```{python} # Clean up import shutil shutil.rmtree(tmpdir) ``` You can now save and load fitted GAMs in the native `.npz` format (including Bayesian fits with their full posterior, MCMC samples, and diagnostics) export frequentist models to `mgcv`-compatible dictionaries for cross-language workflows, and deploy models with full prediction and standard error support. ## Where to go next - **[Prediction and inference](08-prediction.qmd)**: confidence intervals and term-level predictions from a loaded model. - **[Model diagnostics](11-diagnostics.qmd)**: verify that a loaded model passes diagnostic checks. - **[Large datasets](25-large-datasets.qmd)**: scalable fitting backends whose results can be serialized with the same tools. - **[Variational inference](26-variational-inference.qmd)**: the VI fitting method whose posterior is preserved by `save_gam`. - **[MCMC sampling](27-mcmc.qmd)**: the MCMC fitting method whose samples and diagnostics are preserved by `save_gam()`. ## Reference ### Glossary Terms used throughout the Whittaker documentation, listed alphabetically. Where a term has a direct counterpart in Whittaker's API, the relevant class or function is noted. Additive model : A regression model in which the predictor is the sum of individual functions of the covariates: $\eta = \beta_0 + f_1(x_1) + f_2(x_2) + \cdots$. Each function $f_j$ captures the effect of one covariate independently. Because the model is additive, the contribution of each covariate can be estimated and visualized separately. See also **generalized additive model**. Adaptive smooth (`bs="ad"`) : A **thin plate regression spline** whose smoothness is allowed to vary spatially along the covariate axis. Instead of a single smoothing parameter $\lambda$, `AdaptiveTPRS` decomposes the ordinary TPRS penalty matrix into its eigenvectors and assigns each (or a contiguous block of eigenvectors) its own $\lambda$. Regions where the true function varies rapidly receive less smoothing, while flatter regions are smoothed more heavily. The number of separate penalty components is controlled by `n_penalties`. Prefer over plain TPRS when there is a priori reason to expect the required smoothness to vary across the covariate range. Specified with `bs="ad"` in the formula. Available via `AdaptiveTPRS`. See also **TPRS**. Basis dimension (`k`) : The number of basis functions used to represent a smooth term before penalization. It sets an upper bound on the flexibility of the smooth: higher `k` allows more complex shapes, but the **smoothing parameter** $\lambda$ prevents overfitting by penalizing excess curvature. In practice, `k` should be set large enough that the data do not need more flexibility than it allows. The **k-index test** (`model.check()`) flags terms where `k` may be too small. The default in Whittaker is `k=10` for most basis types. Basis function : One of a set of known functions whose weighted sum approximates an unknown smooth function. For example, a cubic regression spline with `k=10` represents a smooth as $f(x) = \sum_{j=1}^{10} \beta_j B_j(x)$, where each $B_j$ is a basis function and the $\beta_j$ are estimated coefficients. Different basis types (TPRS, CRS, P-splines) use different sets of basis functions with different mathematical properties. Beta family : A response family for proportions on the open interval $(0, 1)$, using a logit link by default: $g(\mu) = \log(\mu / (1 - \mu))$. The variance function is $V(\mu) = \mu(1-\mu)/(1+\phi)$, where $\phi$ is a precision parameter. Available via `Beta()`. See also **BetaLS** for the distributional variant. BetaLS (Beta location-scale) : A **GAMLSS** family that models both the mean $\mu$ and the precision $\phi$ of a Beta distribution as smooth functions of covariates. The logit link constrains $\mu$ to $(0, 1)$ and the log link keeps $\phi$ positive. Available via `BetaLS()`. See also **GAMLSS**. BigGAM : A scalable variant of `GAM` that uses discretized P-IRLS to fit models on large datasets. Each covariate is binned into `n_discrete` unique values and sufficient statistics are accumulated over the grid, reducing memory from $O(n \cdot p)$ to $O(d \cdot p)$ where $d \ll n$. Uses **fREML** for smoothing parameter estimation. See also **PolarsGAM**, **DuckDBGAM**. Binomial family : A response family for binary outcomes or proportions. The default logit link maps the linear predictor to a probability: $P(y = 1 \mid x) = 1 / (1 + e^{-\eta})$. The variance function is $V(\mu) = \mu(1 - \mu)$. Available via `Binomial()`. Canonical link : The link function that sets $g(\mu) = \theta$, where $\theta$ is the canonical parameter of the exponential family. Using the canonical link simplifies the score equations and guarantees concavity of the log-likelihood. Examples: identity for Gaussian, log for Poisson, logit for Binomial. Non-canonical links are sometimes preferred for interpretability. CATE (Conditional Average Treatment Effect) : The expected treatment effect conditional on covariates: $\tau(x) = E[Y(1) - Y(0) \mid X = x]$. Unlike the **ATE**, CATE allows the effect to vary across the covariate space. Estimated by `CausalGAM` with `method="interactive"`. See also **treatment effect**. CausalGAM : A GAM-based implementation of the **double machine learning** (DML) framework for estimating causal treatment effects from observational data. It uses cross-fitted nuisance models to partial out confounders, producing debiased estimates of the **ATE** or **CATE**. Available via `CausalGAM`. Coefficient function ($\beta(t)$) : In **functional GAM**, the smooth function that weights each location $t$ along the domain of a functional covariate in its contribution to the scalar response: $\text{effect}_i = \int X_i(t)\,\beta(t)\,\mathrm{d}t$. The coefficient function is estimated as a penalized expansion in a B-spline or Fourier basis and is returned by `FunctionalGAM.coefficient_function()` as a `CoefficientFunction` object with pointwise confidence bands. See also **functional GAM**, **FunctionalTerm**. Concavity constraint : A shape constraint that forces a smooth to curve downward (i.e., $f''(x) \le 0$). Useful for modeling diminishing returns. Specified with `bs="cv"` in the formula. See also **shape constraint**, **convexity constraint**. Concordance : See **concurvity**. Concurvity : The nonlinear analogue of collinearity. When two smooth terms in a GAM can approximate each other, the model has high concurvity, which inflates standard errors and makes individual term estimates unreliable. Diagnosed with `model.concurvity()`, which returns pairwise and overall concurvity measures. Values above 0.8 are typically concerning. Confidence band (interval) : A band around the fitted curve that reflects uncertainty in the estimated smooth. **Pointwise** intervals cover the true value at each individual $x$ with the stated probability. **Simultaneous** bands cover the entire curve at once and are wider, accounting for the multiple-testing aspect of evaluating many points. Simultaneous bands are produced by `predict(..., interval="simultaneous")`. Conformal prediction : A distribution-free method for constructing prediction intervals with finite-sample coverage guarantees. Whittaker implements three conformal methods: **split conformal** (`"split"`), **CV+** (`"cv+"`), and **jackknife+** (`"jackknife+"`). The intervals adapt to local variance without distributional assumptions. Available via `ConformalPredictor`. Conformity score : A measure of how unusual a calibration observation is relative to the fitted model, used in **conformal prediction**. The simplest conformity score is the absolute residual $s_i = |y_i - \hat\mu_i|$. The empirical quantile of the calibration scores determines the half-width of prediction intervals for new observations: a larger score means the model predicted that point poorly and the resulting interval will be wider. See also **conformal prediction**, **marginal coverage**. Contrast : The difference between a smooth term's predicted values at two specific covariate configurations. Contrasts answer questions like "how does the effect at $x = 5$ differ from the effect at $x = 2$?" with a point estimate, standard error, and confidence interval. Computed by `model.contrasts()`, which returns a `ContrastResult`. See also **marginal effect**. Convexity constraint : A shape constraint that forces a smooth to curve upward (i.e., $f''(x) \ge 0$). Useful for modeling U-shaped cost curves or economies of scale. Specified with `bs="cx"` in the formula. See also **shape constraint**, **concavity constraint**. Cook's distance : A leave-one-out influence diagnostic that measures how much the fitted values change when observation $i$ is removed: $D_i = \frac{(\hat{\boldsymbol\mu} - \hat{\boldsymbol\mu}_{(i)})^\top (\hat{\boldsymbol\mu} - \hat{\boldsymbol\mu}_{(i)})}{p\,\hat\phi}$. In practice it is approximated from the **hat matrix** diagonals and residuals without refitting. Large values flag observations that disproportionately drive the model fit. Returned by `model.influence()` in `InfluenceResult.cooks_distance`. See also **hat matrix**. Cox proportional hazards (`CoxPH`) : A semiparametric model for time-to-event (survival) data that models the hazard as $h(t \mid x) = h_0(t)\,e^{\eta(x)}$, where $h_0(t)$ is an unspecified baseline hazard and $\eta$ is the (possibly smooth) linear predictor. Fitting maximizes the Cox partial log-likelihood, which conditions on the **risk set** at each event time and does not require estimating $h_0(t)$. Right-**censored** observations (event time unknown but bounded below) are handled naturally. The `ties=` argument selects the Breslow or Efron approximation for tied event times. Available via `CoxPH(status="event")` as a family in `GAM`. See also **family**. Cross-fitting : A procedure used in **double machine learning** to avoid overfitting bias when estimating causal effects. The data are partitioned into $K$ folds. For each fold, **nuisance functions** (outcome and treatment models) are trained on the remaining $K-1$ folds, and residuals are computed on the held-out fold. This ensures the residuals used to estimate the treatment effect are independent of the nuisance fits. Combined with **Neyman orthogonality**, cross-fitting makes the treatment effect estimator root-$n$ consistent despite using regularized nuisance GAMs. Used internally by `CausalGAM`. See also **double machine learning**. Cross-validation (CV) : A resampling procedure for estimating out-of-sample model performance. The data are split into $k$ folds. The model is trained on $k - 1$ folds and evaluated on the held-out fold, rotating so every observation is used for evaluation exactly once. The $k$ scores are averaged to produce a single performance estimate. In Whittaker, `cross_validate()` returns a `CVResult` with the mean score, its standard error, and per-fold breakdowns. Cubic regression spline (CRS) : A piecewise cubic polynomial with knots placed at quantiles of the covariate. The pieces join with continuous first and second derivatives, ensuring overall smoothness. CRS uses a wiggliness penalty on the integrated squared second derivative. Specified with `bs="cr"` in the formula. See also **cyclic CRS**. Cumulant function : The function $b(\theta)$ in the exponential family density whose derivatives give the mean ($\mu = b'(\theta)$) and the **variance function** ($V(\mu) = b''(\theta)$). It characterizes the distribution and determines the **deviance**. Cyclic CRS : A variant of the **cubic regression spline** that wraps around, matching function values and derivatives at the boundary knots. Useful for periodic covariates such as time of day or day of year. Specified with `bs="cc"` in the formula. Cyclic P-spline : A variant of the **P-spline** basis with periodic boundary conditions. Like cyclic CRS but using B-spline basis functions with a difference penalty. Specified with `bs="cps"` in the formula. Deviance : A measure of the discrepancy between the fitted model and a saturated (perfect-fit) model: $D = 2\phi[\ell(\text{saturated}) - \ell(\text{fitted})]$. It generalizes the residual sum of squares to non-Gaussian families and is the quantity minimized during **P-IRLS** fitting. The **deviance explained** (analogous to $R^2$) is $1 - D / D_\text{null}$. Deviance explained : The proportion of the **null deviance** accounted for by the fitted model: $1 - D_\text{model}/D_\text{null}$. Analogous to $R^2$ in linear regression, ranging from 0 (no improvement over an intercept-only model) to 1 (perfect fit on the deviance scale). Accessed via `model.deviance_explained` and reported in `model.summary()`. See also **null deviance**, **deviance**. Deviance residuals : Residuals based on the contribution of each observation to the total deviance: $r_i^D = \text{sign}(y_i - \hat\mu_i) \sqrt{d_i}$, where $d_i$ is the unit deviance. Deviance residuals are more nearly normally distributed than raw residuals for non-Gaussian families, making them the default choice for diagnostic plots. Divergent transition : An **MCMC** step in which the Hamiltonian energy error exceeds a threshold (1000 by default), indicating that the **leapfrog integrator** has entered a region of very high posterior curvature and can no longer accurately simulate the dynamics. The resulting draw is biased rather than just being noisy, so any divergences invalidate local inferences. Divergences are reported in `MCMCResult.n_divergent`. Remedies include increasing `target_accept` (which adapts to a smaller step size) or reparameterizing the model to remove funnels or other high-curvature geometry. See also **NUTS**, **Hamiltonian Monte Carlo**, **warmup**. Dispersion parameter ($\phi$) : A scale parameter that controls the spread of the response distribution beyond what is captured by the variance function. For Gaussian models, $\phi = \sigma^2$. For Poisson and Binomial, $\phi = 1$ (known). For Gamma, $\phi$ is estimated. Accessed via `model.scale`. Double machine learning (DML) : A framework for estimating causal effects in the presence of high-dimensional confounders. DML uses cross-fitting to estimate nuisance functions (outcome and treatment models) separately from the target causal parameter, avoiding regularization bias. Implemented in Whittaker via **CausalGAM**. Double penalty (smooth selection) : A technique for automatic smooth term selection that adds a second penalty to each smooth, allowing the entire term to be shrunk to zero (effectively removed from the model). Activated with `select=True` in `model.fit()`. Terms that do not contribute receive near-zero **effective degrees of freedom**. Duchon spline (`bs="ds"`) : A generalization of the **thin plate regression spline** that provides explicit control over both the polynomial null-space order $m$ and the radial exponent $s$ of the kernel (the standard TPRS is the special case $s = 1$). Smaller values of $s$ give a less smooth, more locally adaptive kernel at the cost of a larger polynomial null space. Useful when the default TPRS penalty becomes numerically awkward in higher dimensions or when the analyst wants direct control over the smoothness-exponent trade-off. Specified with `bs="ds"` and optional `m=[s, m_order]` in the formula. Available via `DuchonSpline`. DuckDBGAM : A scalable GAM backend that streams data from DuckDB tables or SQL queries via Arrow. Ideal when the data lives in a database or when SQL-based filtering and transformation are needed before fitting. See also **BigGAM**, **PolarsGAM**. Effective degrees of freedom (EDF) : The effective number of parameters used by a smooth term, accounting for penalization. An EDF of 1 means the term is a straight line. Higher values indicate more complex shapes. The total EDF across all terms determines model complexity. Accessed via `model.edf_total` and reported per term in `model.summary()`. Effective sample size (ESS) : An estimate of how many independent draws the correlated output of an MCMC chain is worth. Because successive MCMC samples are correlated, the information content of $N$ draws is less than that of $N$ independent draws. Whittaker reports two ESS variants (Vehtari et al. 2021): - **ESS bulk** (`mcmc_result.ess`): Geyer's initial positive sequence estimator applied to *rank-normalized* draws. Measures mixing in the bulk of the posterior. An ESS / total-draws ratio above 0.05 is generally adequate. - **ESS tail** (`mcmc_result.ess_tail`): the minimum of the ESS of the binary indicators $I(x \le Q_{0.05})$ and $I(x \ge Q_{0.95})$. Measures how reliably the sampler explores the extreme tails. Low tail ESS can persist even when bulk ESS looks healthy. Both are reported in `model.summary()`. See also **MCMC**, **R-hat**. ELF (Expectile-Like Family) loss : The smoothed loss function used by **QuantileGAM** to approximate the quantile check function. It replaces the non-differentiable kink at zero with a smooth surrogate controlled by a bandwidth parameter $\sigma$. As $\sigma \to 0$, ELF converges to the true check function. The smoothness enables P-IRLS fitting. See also **sigma calibration**. Exponential family : A class of probability distributions whose density can be written as $f(y \mid \theta, \phi) = a(y, \phi) \exp[(y\theta - b(\theta))/\phi]$. All response families in Whittaker (Gaussian, Poisson, Binomial, Gamma, etc.) belong to this class. The exponential family structure enables a unified fitting algorithm (**P-IRLS**). Factor smooth (`bs="fs"`) : A smooth interaction that creates a separate smooth of a numeric covariate for each level of a grouping factor, with all levels sharing the same **smoothing parameter**. Useful for random-slope models. Specified with `s(x, group, bs="fs")` in the formula. Available via `FactorSmoothBasis`. Family : An object that specifies the conditional distribution of the response and the **link function** connecting the mean to the linear predictor. Every GAM requires a family. Whittaker provides: `Gaussian`, `Poisson`, `Binomial`, `Gamma`, `NegativeBinomial`, `Beta`, `Tweedie`, `InverseGaussian`, and `CoxPH`. Set via the `family=` argument to `GAM`. Fitted values : The model's estimate of the conditional mean $\hat\mu_i$ for each training observation. On the response scale, fitted values are obtained by applying the inverse link to the linear predictor: $\hat\mu = g^{-1}(\hat\eta)$. Accessed via `model.predict(training_data)`. Formula : A string specifying the model structure: the response variable, smooth terms, linear terms, and interactions. Whittaker uses R-style formula syntax: `"y ~ s(x1) + s(x2) + x3"` specifies a model with two smooth terms and one linear term. The formula is parsed into a `Formula` object that builds the **model matrix**. fREML (fast REML) : A variant of **REML** optimized for discretized fitting in **BigGAM** and its streaming variants. It exploits the reduced-rank structure of the discretized design matrix for efficient smoothing parameter estimation. The recommended method for large datasets. Functional GAM : A GAM for scalar-on-function regression, where one or more predictors are entire curves (functions) observed on a grid. The model estimates a **coefficient function** $\beta(t)$ that weights the functional covariate along its domain. Available via `FunctionalGAM` with `FunctionalTerm` specifications. Gamma family : A response family for strictly positive, right-skewed continuous data. The default link is the inverse: $g(\mu) = 1/\mu$. The log link is often preferred for interpretability. The variance function is $V(\mu) = \mu^2$, meaning variance grows with the squared mean. Available via `Gamma()`. GammaLS (Gamma location-scale) : A **GAMLSS** family that models both the mean $\mu$ and the coefficient of variation of a Gamma distribution as smooth functions of covariates. Available via `GammaLS()`. GAMLSS (Generalized Additive Models for Location, Scale, and Shape) : An extension of GAMs that models multiple parameters of the response distribution (not just the mean) as smooth functions of covariates. For example, `GaussianLS` models both the mean $\mu$ and the standard deviation $\sigma$, capturing heteroscedasticity. Fitted via alternating outer iteration. Available via `GAMLSS` with distributional families such as `GaussianLS`, `GammaLS`, `BetaLS`, `ZeroInflatedPoisson`, and `ZeroInflatedNegativeBinomial`. Gaussian family : The default response family, assuming continuous unbounded responses with constant variance. The identity link maps the linear predictor directly to the mean: $\hat\mu = \hat\eta$. Fitting reduces to penalized least squares and converges in a single P-IRLS step. Available via `Gaussian()`. GaussianLS (Gaussian location-scale) : A **GAMLSS** family that models both the mean $\mu$ and the standard deviation $\sigma$ as smooth functions of covariates. Useful when variance changes with the predictors (heteroscedasticity). Available via `GaussianLS()`. Gaussian process smooth (`bs="gp"`) : A smooth basis built from the leading eigenfunctions of a covariance (kernel) matrix evaluated at the training points. The inverse eigenvalues serve directly as the penalty, encoding prior beliefs about the correlation structure of the unknown function rather than imposing a derivative-based bending-energy penalty. Available covariance functions: `"matern32"` (default, once-differentiable), `"matern52"` (twice-differentiable), `"exp"` (exponential/rough), and `"sqexp"` (squared-exponential, infinitely smooth). Well suited to spatial covariates where smoothness reflects physical correlation. Specified with `bs="gp"` in the formula. Available via `GaussianProcess`. GCV (Generalized Cross-Validation) : A smoothing parameter selection criterion that estimates leave-one-out cross-validation error without refitting: $\text{GCV} = n \sum (y_i - \hat\mu_i)^2 / (n - \text{EDF})^2$. GCV tends to undersmooth more than **REML** and is less robust to concurvity. It is an alternative to `method="REML"` in `model.fit()`. Generalized additive model (GAM) : A regression model that extends the generalized linear model by replacing linear covariate effects with smooth functions: $g(\mu) = \beta_0 + f_1(x_1) + f_2(x_2) + \cdots$. The smooth functions are estimated from the data using penalized regression splines, with **smoothing parameters** controlling the trade-off between fit and smoothness. The central class in Whittaker is `GAM`. Generalized linear model (GLM) : A regression model that extends linear regression to non-Gaussian responses via a **link function** and an exponential family distribution. A GLM is a GAM in which every smooth function is replaced by a linear term. GLMs are a special case of GAMs. Hamiltonian Monte Carlo (HMC) : A Markov chain Monte Carlo algorithm that uses gradient information to make distant proposals with high acceptance probability. The algorithm simulates Hamiltonian dynamics in an augmented space $(β, p)$, where $p$ is an auxiliary momentum vector. Each proposal is generated by the **leapfrog integrator** and accepted or rejected via a Metropolis step. HMC explores the posterior far more efficiently than random-walk Metropolis by following the geometry of the log-posterior surface. Whittaker provides two HMC-based samplers: static-trajectory HMC (`sampler="HMC"`), which runs a fixed number of leapfrog steps per proposal, and **NUTS** (`sampler="NUTS"`, the default), which selects the trajectory length automatically. Both use a diagonal mass matrix pre-conditioned by the Laplace posterior covariance and dual-averaging step-size adaptation during warmup. Activated with `method="MCMC"` in `model.fit()`. See also **NUTS**, **MCMC**, **leapfrog integrator**. Hat matrix (influence matrix) : The matrix $\mathbf{A}$ such that $\hat{\mathbf{y}} = \mathbf{A} \mathbf{y}$. Its diagonal elements $A_{ii}$ measure each observation's leverage (influence on its own fitted value). The trace of $\mathbf{A}$ equals the **effective degrees of freedom**. High-leverage observations warrant closer inspection in diagnostics. Heteroscedasticity : The condition in which the variance of the response depends on the predictors. Standard GAMs with a Gaussian family assume constant variance. When heteroscedasticity is present, **GAMLSS** (which models variance as a smooth function) or **quantile regression** (which makes no variance assumption) are more appropriate. Identifiability constraint : A constraint applied to each smooth term to make it uniquely estimable. By default, smooth terms in a GAM are centered to have mean zero across the data, so the intercept $\beta_0$ captures the overall level. Without this constraint, the intercept and the smooth would be confounded. Inverse link : The function $g^{-1}$ that maps from the linear predictor scale back to the response (mean) scale. For example, with a log link, the inverse link is $g^{-1}(\eta) = e^\eta$. Predictions on the response scale are obtained by applying the inverse link to the linear predictor. k-index test : A diagnostic test for whether the **basis dimension** `k` is large enough for a smooth term. It compares the estimated residual variance near neighbouring covariate values to the overall residual variance. A significant result (p-value below 0.05) suggests that `k` should be increased. Produced by `model.check()` and reported as a `GamCheckResult`. Knot : A fixed point along the covariate axis where basis function pieces join. In **CRS**, knots are placed at quantiles of the covariate. In **P-splines**, knots are equally spaced. The number and placement of knots, combined with the **smoothing parameter**, determine the shape of the fitted smooth. Laplace approximation : An approximation to the marginal likelihood used in **REML** and **ML** estimation of smoothing parameters. It replaces the integral over the coefficient space with a Gaussian approximation centered at the mode, enabling efficient optimization of the smoothing parameter objective. Leapfrog integrator : A numerical integrator for Hamiltonian dynamics used inside **Hamiltonian Monte Carlo**. It alternates half-steps in the momentum $p$ and full steps in the position $\beta$, which preserves volume in phase space (symplecticity) and is exactly time-reversible. Both properties are required for the Metropolis acceptance step to maintain detailed balance and sample the correct posterior distribution. Whittaker's leapfrog takes $L$ steps of size $\varepsilon$ per proposal, with $L$ fixed by `leapfrog_steps` in `mcmc_options`. See also **Hamiltonian Monte Carlo**. Linear predictor ($\eta$) : The additive combination of the intercept, smooth terms, and linear terms before applying the link function: $\eta_i = \beta_0 + \sum_j f_j(x_{ij})$. For Gaussian models with an identity link, $\eta = \mu$. For other families, $\eta$ is on a transformed scale (log, logit, etc.) and must be back-transformed via the **inverse link** to obtain predictions on the response scale. Link function : A monotonic, differentiable function $g$ that maps the conditional mean $\mu$ to the **linear predictor** $\eta$: $g(\mu) = \eta$. The link ensures that predictions respect the constraints of the response distribution (e.g., the log link keeps Poisson means positive). Each **family** has a default (canonical) link, but alternatives are available. Marginal coverage : The probability that the true response falls within the prediction interval, averaged over the distribution of new observations: $P(Y_{n+1} \in [L(X_{n+1}), U(X_{n+1})]) \ge 1 - \alpha$. **Conformal prediction** methods provide a finite-sample marginal coverage guarantee without distributional assumptions. Marginal coverage does not guarantee coverage at every specific covariate value (that stronger notion is called conditional coverage). See also **conformal prediction**, **conformity score**. Marginal effect : The change in the predicted response for a unit change in one covariate, holding all others fixed. For a smooth term $f(x)$, the marginal effect at a point is the derivative $f'(x)$. Computed by `model.derivatives()`. Markov random field (MRF) smooth (`bs="mrf"`) : A smooth for discrete areal data (counties, districts, grid cells) that uses one indicator basis function per region and penalizes differences between neighboring regions via the graph Laplacian: $\boldsymbol\beta^\top \mathbf{L}\boldsymbol\beta = \sum_{(i,j)\in\text{neighbors}} (\beta_i - \beta_j)^2$. The neighborhood structure is supplied by the user as a dict mapping region labels to neighbor lists, or as a symmetric adjacency matrix. Use instead of a continuous 2-D smooth whenever the covariate domain is a set of discrete areas linked by shared borders rather than Euclidean coordinates. Specified with `bs="mrf"` in the formula. Available via `MRFBasis`. See also **graph Laplacian**. Mediation analysis : A causal analysis that decomposes the total effect of a treatment on an outcome into a **direct effect** (not through the mediator) and an **indirect effect** (through the mediator). Requires the assumption of no unmeasured confounders of the mediator-outcome relationship. Available via `mediation_analysis()`. MCMC (Markov chain Monte Carlo) : A family of algorithms for drawing samples from a probability distribution by constructing a Markov chain whose stationary distribution is the target posterior. Rather than approximating the posterior analytically (as the **Laplace approximation** does), MCMC produces a set of coefficient vectors that characterize the full posterior distribution, including any non-Gaussian shape, skewness, or multimodality. Whittaker's default MCMC sampler is **NUTS** (No-U-Turn Sampler); static-trajectory **Hamiltonian Monte Carlo** (HMC) is also available via `sampler="HMC"`. Multiple independent chains are run in parallel; convergence is assessed via **R-hat** and **ESS**. Activated with `method="MCMC"` in `model.fit()`. See also **NUTS**, **Hamiltonian Monte Carlo**, **R-hat**, **effective sample size**, **warmup**. ML (Maximum Likelihood) : A smoothing parameter selection criterion that maximizes the full (profiled) log-likelihood over the smoothing parameters. ML tends to undersmooth relative to **REML** because it does not account for the loss of degrees of freedom from estimating the fixed effects. Selected with `method="ML"` in `model.fit()`. Model matrix (design matrix) : The matrix $\mathbf{X}$ whose columns are the evaluated basis functions (and any parametric terms) for all observations. Each row corresponds to an observation and each column to a **basis function** or parametric coefficient. The model matrix is constructed from the **formula** and the data by the `ModelMatrix` class. Monotonicity constraint : A shape constraint that forces a smooth to be non-decreasing (`bs="mpi"`) or non-increasing (`bs="mpd"`). Enforced by projecting P-IRLS updates onto the monotone cone using the pool adjacent violators algorithm (**PAVA**). See also **shape constraint**. Multinomial family : A response family for unordered categorical data with $K \ge 2$ levels. Uses a baseline-category logit model: $P(Y = k \mid \eta) \propto \exp(\alpha_k + \beta_k\eta)$, with the final category as the reference ($\alpha_K = \beta_K = 0$). Unlike **ordered categorical**, no assumption is made about category ordering — each non-reference category has its own loading $\beta_k$ that can differ in magnitude and sign, so categories can respond differently to the same covariate effect. Available via `Multinomial(n_categories=K)`. For binary responses use `Binomial`; for ordinal responses use `OrderedCategorical`. See also **ordered categorical**. Multi-response GAM : A GAM that fits multiple response variables simultaneously against a shared formula, optionally estimating the residual correlation between responses. Each response has its own coefficients, but corresponding smooth terms share the same basis structure. Useful when several related outcomes are measured on the same units (e.g. multiple environmental metrics at the same sites) and joint modeling improves efficiency or coherence. The estimated residual correlation is returned by `model.residual_correlation()` as a `ResidualCorrelation` object. Available via `MultiResponseGAM`. Negative Binomial family : A response family for overdispersed count data (variance exceeds the mean). Uses the NB2 parameterization with variance function $V(\mu) = \mu + \mu^2/\theta$, where $\theta$ controls overdispersion. As $\theta \to \infty$, the distribution converges to Poisson. Available via `NegativeBinomial()`. Neyman orthogonality : A property of the moment condition used in **double machine learning** that makes the treatment effect estimate insensitive to first-order errors in the **nuisance functions**. Specifically, the derivative of the identifying equation with respect to the nuisance parameters is zero at the truth, so that estimation error in the nuisance GAMs contributes only second-order bias to the treatment effect estimate. Combined with **cross-fitting**, Neyman orthogonality ensures `CausalGAM` produces a root-$n$ consistent, asymptotically normal treatment effect estimate despite using regularized GAMs for the nuisance fits. Non-crossing constraint : A constraint in **quantile regression** that ensures fitted quantile curves do not cross: $\hat{Q}_{\tau_1}(x) \le \hat{Q}_{\tau_2}(x)$ whenever $\tau_1 < \tau_2$. Enforced by isotonic projection after each P-IRLS update. Activated with `non_crossing=True` in `QuantileGAM`. Null deviance : The deviance of an intercept-only model, computed as though no predictors were included: $D_\text{null} = D(\mathbf{y},\, \bar\mu\,\mathbf{1})$, where $\bar\mu$ is the maximum-likelihood estimate of the mean under the null (intercept-only) model. Serves as the baseline for computing **deviance explained**: a larger reduction from $D_\text{null}$ to $D_\text{model}$ indicates a better fit. Accessed via `model.null_deviance`. See also **deviance**, **deviance explained**. Nuisance function : A function that must be estimated as part of a statistical procedure but is not the primary object of inference. In **double machine learning** (DML), the outcome regression $E[Y \mid X]$ and the treatment regression $E[T \mid X]$ are both nuisance functions: they are needed to form debiased residuals for the treatment effect estimate but are not themselves the causal parameters of interest. `CausalGAM` fits nuisance functions as penalized GAMs using **cross-fitting** to avoid regularization bias. See also **cross-fitting**, **Neyman orthogonality**. NUTS (No-U-Turn Sampler) : An extension of **Hamiltonian Monte Carlo** (Hoffman & Gelman 2014) that eliminates the need to specify a trajectory length. Instead of running a fixed number of **leapfrog** steps, NUTS grows a binary tree of states by doubling the trajectory in alternating forward and backward directions. Expansion stops automatically when a U-turn condition is detected: the leading endpoint has begun to double back toward the root, indicated by $(β^+ - β^-)\cdot p^- < 0$ or $(β^+ - β^-)\cdot p^+ < 0$. The final proposed state is drawn from the tree by **slice sampling** and **biased progressive sampling** within each subtree, ensuring detailed balance. NUTS adapts the step size $\varepsilon$ during **warmup** using dual-averaging. The tree depth $j$ determines the number of leapfrog evaluations: $2^j$ per NUTS step. Mean tree depth is reported in `MCMCResult.mean_tree_depth`; a cap is set by `max_tree_depth` (default 10). NUTS is the default sampler in Whittaker (`sampler="NUTS"`). See also **Hamiltonian Monte Carlo**, **MCMC**, **leapfrog integrator**, **warmup**. Offset : A term in the linear predictor with a fixed coefficient of 1, not estimated from the data. Offsets are used to account for known exposure or population size in rate models. Specified as `offset(log_exposure)` in the formula. Ordered categorical (`OrderedCategorical`) : A response family for ordinal data — outcomes with a natural order but no meaningful numeric spacing, such as Likert scales or severity grades. Uses the proportional-odds (cumulative logit) model: $\log[P(Y \le k \mid \eta)/P(Y > k \mid \eta)] = \alpha_k - \eta$, where $\alpha_1 < \cdots < \alpha_{K-1}$ are estimated cutpoints and $\eta$ is the shared linear predictor. The proportional-odds assumption is that a unit increase in $\eta$ shifts the log-odds of being in a higher category by the same amount at every threshold. Available via `OrderedCategorical(n_categories=K)`. For binary data use `Binomial`; for nominal (unordered) data use `Multinomial`. P-IRLS (Penalized Iteratively Reweighted Least Squares) : The core fitting algorithm for GAMs. It iterates between constructing a penalized working linear model (using weights and a working response derived from the current fit) and solving the resulting penalized least-squares problem. For Gaussian models with an identity link, P-IRLS converges in a single step. For other families, it iterates until convergence. P-spline : A smooth basis that combines B-spline basis functions with a difference penalty on adjacent coefficients. The penalty order controls the type of smoothness: first-order penalizes jumps, second-order (default) penalizes curvature. P-splines are computationally efficient because the B-spline basis and the penalty matrix are both banded. Specified with `bs="ps"` in the formula. Partially linear model : The structural form used by `CausalGAM` with `method="partial"` for estimating the average treatment effect. The outcome is decomposed as $Y = \theta T + g(X) + \varepsilon$, where $\theta$ is the constant treatment effect, $g(X)$ is a nonparametric smooth of the confounders, and $T$ is the treatment indicator. After partialling out $g(X)$ from both $Y$ and $T$ via **cross-fitting**, $\theta$ is estimated by regressing the outcome residuals on the treatment residuals. See also **CausalGAM**, **double machine learning**. Partial dependence : The isolated effect of one smooth term on the predicted response, computed by varying that term's covariates over a grid while holding all other terms at their model-matrix values and applying the identifiability constraint. Unlike `partial_effects()` (which returns Altair charts), `model.partial_dependence()` returns structured `PartialDependenceResult` objects with arrays for the grid values, effects, standard errors, and confidence bounds — suitable for custom plotting or downstream analysis. See also **smooth term**, **confidence band**. PAVA (Pool Adjacent Violators Algorithm) : An algorithm for isotonic regression that enforces monotonicity by iteratively merging adjacent blocks of values that violate the ordering constraint, replacing them with their weighted average. Used internally by **monotonicity constraints** and the **non-crossing constraint** in quantile regression. Pearson residuals : Residuals standardized by the variance function: $r_i^P = (y_i - \hat\mu_i) / \sqrt{V(\hat\mu_i)}$. For Poisson models, this divides by $\sqrt{\hat\mu_i}$. For Binomial, by $\sqrt{\hat\mu_i(1 - \hat\mu_i)}$. Useful for checking the variance assumption. Penalty matrix : The matrix $\mathbf{S}$ that encodes the smoothness penalty for a basis. The penalized objective is $\|\mathbf{y} - \mathbf{X}\boldsymbol\beta\|^2 + \lambda \boldsymbol\beta^\top \mathbf{S} \boldsymbol\beta$. For a second-derivative penalty, $\mathbf{S}$ is the integrated squared second derivative of the basis functions. Larger $\lambda$ values penalize wiggliness more strongly. Pinball loss (check function) : The standard loss function for quantile regression: $\rho_\tau(u) = u(\tau - \mathbf{1}[u < 0])$, where $\tau \in (0,1)$ is the target quantile. It penalizes over- and under-prediction asymmetrically: residuals above zero cost $(1 - \tau)|u|$ and residuals below zero cost $\tau|u|$. The conditional $\tau$-th quantile is the minimizer of the expected pinball loss. Because the pinball loss has a non-differentiable kink at zero, Whittaker approximates it with the smooth **ELF** loss during fitting. See also **ELF loss**, **sigma calibration**, **quantile regression**. Poisson family : A response family for non-negative integer counts, assuming equidispersion (variance equals the mean). The log link ensures predicted counts are positive: $\hat\mu = \exp(\hat\eta)$. When the data show overdispersion, consider **Negative Binomial** or **quasi-Poisson** approaches. Available via `Poisson()`. PolarsGAM : A scalable GAM backend that extends **BigGAM** to accept Polars DataFrames, LazyFrames, or file paths (Parquet, CSV, IPC, NDJSON). Data is streamed in chunks via Polars' lazy engine, so the full dataset never needs to be in memory. See also **BigGAM**, **DuckDBGAM**. Prediction interval : An interval intended to cover a future observation (not just the mean) with a stated probability. Prediction intervals are wider than **confidence bands** because they account for both estimation uncertainty and residual noise. Produced by `predict(..., interval="prediction")`. PredictionResult : The object returned by `GAM.predict()`. Contains `.values` (predicted means on the response scale), `.linear_predictor` (on the link scale), and optionally `.se`, `.lower`, `.upper` when standard errors or intervals are requested. Quantile regression : A regression method that estimates conditional quantiles $Q_\tau(y \mid x)$ rather than the conditional mean. It is distribution-free and robust to outliers. Whittaker's `QuantileGAM` fits one or more quantiles simultaneously using smooth additive functions and the **ELF** loss. Useful for heteroscedastic data, risk quantification, and adaptive prediction intervals. R-hat (rank-normalized split R-hat) : A convergence diagnostic for **MCMC** (Vehtari et al., 2021). Each chain is first split in half, giving twice as many half-chains. This detects non-stationarity within a single chain. Classic Gelman-Rubin R-hat is then applied to the *rank-normalized* draws, making the statistic robust to heavy-tailed posteriors where the classic version can give a false sense of convergence. $\hat{R} \approx 1$ when all half-chains have converged to the same distribution. Values below 1.01 are ideal, values below 1.1 are generally acceptable, and values above 1.1 suggest insufficient warmup, too few chains, or poor posterior geometry. Computed per coefficient and reported in `model.summary()` and `model.mcmc_result.r_hat`. See also **MCMC**, **effective sample size**. Random effect smooth : A smooth term that acts as a penalized random intercept or slope. It is a ridge-penalized set of indicator variables for a grouping factor, equivalent to a normal random effect in mixed models. Specified with `bs="re"` in the formula. REML (Restricted Maximum Likelihood) : The recommended smoothing parameter selection criterion. REML maximizes a modified likelihood that accounts for the loss of degrees of freedom from estimating the fixed effects, producing more stable and slightly smoother fits than **GCV** or **ML**. Selected with `method="REML"` in `model.fit()`. Residuals : The difference between observed and fitted values, computed in various forms depending on the family: **deviance residuals** (default), **Pearson residuals**, working residuals, and response residuals. Residual plots are the primary tool for checking model assumptions. Accessed via `model.predict(training_data)` subtracted from the response, or via the `type=` argument. Scale parameter : See **dispersion parameter**. Shape constraint : A restriction on the shape of a smooth term, such as monotonicity, convexity, or concavity. Shape constraints are enforced by projecting the P-IRLS solution onto the constrained space at each iteration. Available basis types: `"mpi"` (monotone increasing), `"mpd"` (monotone decreasing), `"cx"` (convex), `"cv"` (concave). See also **PAVA**. Shrinkage smooth : A variant of TPRS or CRS that includes an extra penalty component capable of shrinking the entire smooth to zero, enabling automatic smooth selection without using `select=True`. Specified with `bs="ts"` (shrinkage TPRS) or `bs="cs"` (shrinkage CRS) in the formula. Sigma calibration : The process of selecting the **ELF** bandwidth parameter $\sigma$ for **quantile regression**. Smaller $\sigma$ gives a tighter approximation to the true quantile but makes optimization harder. `calibrate_sigma()` evaluates candidate values via cross-validation and returns the one that minimizes the out-of-sample pinball loss. Simultaneous confidence band : A confidence region that covers the entire true curve $f(x)$ with the stated probability, not just each individual point. Constructed by simulating from the posterior covariance of the coefficients to estimate a critical value $c_\alpha$ such that $P(\sup_x |f(x) - \hat f(x)| / \text{se}(x) \le c_\alpha) = 1 - \alpha$. The resulting band $\hat f(x) \pm c_\alpha \cdot \text{se}(x)$ is wider than the **pointwise** interval because it corrects for the multiplicity of evaluating the curve at many points. Produced by `model.simultaneous_ci()`, which returns a `SimultaneousCIResult`. See also **confidence band**. Smooth selection : The process of deciding which smooth terms to include in the model. Whittaker supports two approaches: **double penalty** selection (`select=True`), which can shrink entire terms to zero during fitting, and **cross-validation** comparison of models with different term structures. See also **shrinkage smooth**. Smooth term : A nonlinear function of a covariate estimated from the data, expressed as a weighted sum of **basis functions** with a **smoothness penalty**. Written as `s(x)` in the formula. Multiple covariates can form tensor product smooths via `te(x1, x2)` or tensor interactions via `ti(x1, x2)`. Smoothing parameter ($\lambda$) : A non-negative scalar that controls the trade-off between data fidelity and smoothness. When $\lambda = 0$, the smooth interpolates the data (no penalty). As $\lambda \to \infty$, the smooth shrinks to a straight line (maximum penalty). Estimated automatically by **REML**, **GCV**, **ML**, or **fREML**, or set manually via `sp=` in the formula. Soap film smooth (`bs="so"`) : A smooth for two-dimensional spatial data over irregular domains that may contain boundaries, concavities, or interior holes. Standard 2-D smooths such as **TPRS** or **tensor product smooth** treat the domain as convex and can leak information across geographic barriers (e.g. two points on opposite banks of a narrow bay appear close in Euclidean distance but are far apart within the domain). The soap film smooth avoids this by finite-element-discretizing the actual domain — expressed as a list of boundary polygons — and penalizing thin-plate bending energy only over paths reachable within the domain. Suitable for coastal, riverine, or any geographically constrained data. Specified with `bs="so"` and boundary polygons plus interior knots supplied via `xt=` in the formula. Available via `SoapFilm`. StreamingGAM : A GAM variant for online learning that accumulates sufficient statistics incrementally as new data batches arrive. Each `partial_fit()` call updates the running totals. `solve()` produces a GAM fit from the accumulated statistics. An optional `decay` parameter downweights older batches for tracking distribution shift. Available via `StreamingGAM`. Tensor product smooth (`te`) : A smooth of two or more covariates constructed by taking the row-wise Kronecker product of marginal basis matrices. Tensor products can model interactions between covariates on different scales. Each marginal has its own **smoothing parameter**, so a two-dimensional `te(x1, x2)` has two or more penalties. Written as `te(x1, x2)` in the formula. Tensor interaction (`ti`) : A smooth that captures only the interaction effect between covariates, excluding the main effects. Used in models like `"y ~ s(x1) + s(x2) + ti(x1, x2)"` to decompose the bivariate surface into interpretable main effects plus a pure interaction. Written as `ti(x1, x2)` in the formula. Thin plate regression spline (TPRS) : The default smooth basis in Whittaker. TPRS approximates the thin plate spline (which minimizes integrated squared second derivatives over any number of dimensions) using a truncated eigen-decomposition. It is isotropic (invariant to rotation of the covariates) and does not require knot placement. Specified with `bs="tp"` in the formula. Treatment effect (ATE) : The average treatment effect: $\text{ATE} = E[Y(1) - Y(0)]$, the expected difference in outcomes between the treated and control conditions, averaged over the population. Estimated by **CausalGAM** and returned as a `TreatmentEffect` object with `.ate`, `.se`, `.ci_lower`, `.ci_upper`, and `.p_value`. Tweedie family : A response family for data with exact zeros mixed with positive continuous values (e.g., insurance claims, rainfall). The variance function is $V(\mu) = \mu^p$, where the power parameter $p$ is between 1 and 2. At $p = 1$, it reduces to Poisson; at $p = 2$, to Gamma. Available via `Tweedie()`. Variance function ($V(\mu)$) : The function that describes how the variance of the response depends on the mean: $\text{Var}(Y) = \phi \cdot V(\mu)$. Each **family** has a characteristic variance function: $V(\mu) = 1$ for Gaussian, $V(\mu) = \mu$ for Poisson, $V(\mu) = \mu^2$ for Gamma, etc. The variance function determines the weights in **P-IRLS** and is central to residual diagnostics. VIF (Variance Inflation Factor) : A diagnostic for collinearity among the parametric terms (or among smooths, via **concurvity**). A VIF of 1 means no collinearity (values above 5-10 are concerning). Computed by `model.vif()`. Warmup : The initial phase of **MCMC** sampling during which the sampler adapts its step size (and, in some algorithms, its mass matrix) to the geometry of the posterior. Warmup draws are discarded after adaptation because the chain has not yet reached its stationary distribution. Whittaker uses dual-averaging step-size adaptation during warmup, targeting an acceptance rate of 0.65. The number of warmup draws per chain is controlled by `n_warmup` in `mcmc_options` (default: 500). See also **MCMC**, **Hamiltonian Monte Carlo**. Working response : An adjusted dependent variable used internally by **P-IRLS**. At each iteration, the working response is $z_i = \hat\eta_i + (y_i - \hat\mu_i) / g'(\hat\mu_i)$, where $g'$ is the derivative of the link function. P-IRLS then solves a weighted least-squares problem with the working response as the target. Zero-inflated model : A **GAMLSS** model for count data with more zeros than a standard Poisson or Negative Binomial distribution can accommodate. The model has a point-mass component at zero (with probability $\pi$) and a count component (with mean $\mu$). Both $\pi$ and $\mu$ can be modeled as smooth functions of covariates. Available via `ZeroInflatedPoisson()` and `ZeroInflatedNegativeBinomial()`.