Skip to content

Effects

pyLGM's predictor is a sum of effects: Fixed("1 + x") + IID(...) + RW1(...). Each structured effect takes a fixed precision or a declared Hyperparameter to be estimated (see Empirical Bayes and priors).

Every effect at a glance

Effect What it models Documented
Fixed(formula) Fixed effects from a formulaic formula core
IID(name, index) Exchangeable random effects per level core
RW1 / RW2 Smooth temporal trends (1st / 2nd difference penalty) core
AR1(name, index, rho=) Stationary first-order autoregression AR1
AR1(..., replicate=) One independent AR1 series per panel unit group-wise AR1
Seasonal(name, index, period=) A slowly-drifting periodic pattern Seasonal
Weighted(effect, by) Modulates an indexed effect by a numeric column — spatially-varying coefficients Weighted
Copy(name, index, scale=) A second occurrence of an existing field at another index, optionally rescaled Copy
Replicated(effect, over=) R independent copies of any indexed effect, sharing its hyperparameters Replicated
Grouped(effect, over=, structure=) R correlated copies of any indexed effect, tied by a between-group structure Grouped
MIDAS(name, columns) Mixed-frequency distributed lag, smoothness-penalised MIDAS
MIDASParametric(...) Restricted lag curve (exp-Almon / Beta kernel) restricted MIDAS
SpaceTime(name, space, time, interaction=) Knorr-Held space-time interaction, types I–IV SpaceTime
Besag(name, index, graph) Intrinsic CAR (ICAR) over a neighbour graph spatial
ProperCAR(..., rho=) Proper CAR with a spatial-dependence parameter spatial
BYM2(..., phi=) Structured + unstructured convolution, interpretable φ spatial
SAR(name, index, graph, rho=) Directed network autoregression (I−ρW)ᵀ(I−ρW) spatial
DynamicSpatialPanel(...) One network per period — SDPD, with ρ, γ, η spatial
LGM(constraints=...) Arbitrary linear constraints A x = e on the latent field constraints

Graphs may be weighted, so the CAR family also models ownership, interbank-exposure and supply-chain networks rather than only geography — see weighted neighbour graphs.

Everything on the spatial and network side lives on the spatial and network effects page; the rest is documented below. Runnable scripts for all of them are in the examples gallery.

Core building blocks

Fixed(formula, prior_precision=1e-6)

An ordinary fixed-effect block, built from a formulaic formula, so "1 + x", "1 + x + I(x**2)" and categorical expansion all behave as they do in R-style formulas. prior_precision is a ridge on the coefficients — the default is deliberately tiny, i.e. an almost-flat prior, so the estimates match what an unpenalised regression would give.

Every model needs at most one Fixed term; its columns are recomputed from the formula when predict scores new rows, which is how a categorical level unseen at fit time is caught rather than silently mis-encoded.

IID(name, index, precision=1.0)

Exchangeable random effects: one latent value per level of index, independent given the precision, Q = τI. This is the workhorse for "a per-group offset I want shrunk toward zero" — regions, firms, individuals.

Unlike a Fixed dummy per group, an IID term pools: the estimated τ decides how much each level is pulled toward the overall mean, so levels with few observations are shrunk more. That is the difference the method comparison measures.

IID is also how you add a frailty to a survival model, and the unstructured half of a BYM2 convolution.

RW1(name, index, precision=1.0) and RW2(...)

Smoothness priors over an ordered index. RW1 penalises first differences (the level wanders, the trend is locally flat), RW2 penalises second differences (the slope wanders, so the fitted trend is smoother and extrapolates linearly rather than flat):

\[ Q_{\text{RW1}} = \tau D_1^\top D_1, \qquad Q_{\text{RW2}} = \tau D_2^\top D_2 . \]

Both are intrinsic: rank-deficient by 1 and 2 respectively, with the null space (level, and level+slope) removed by sum-to-zero constraints. That null space is a nuisance absorbed by the intercept — which is exactly the opposite of Seasonal, whose null space is the signal.

Choose by what you believe about the trend, and note the forecasting consequence: past the last observation RW1 projects a flat mean with variance growing linearly in the horizon, while RW2 continues the local slope with variance growing faster. See how forecasting works.

Regular spacing is assumed. Both relate consecutive levels with no notion of the gap between them, so a missing period should enter as a NaN-response row to keep the grid regular.

AR1 effect

AR1(name, index, precision=1.0, rho=0.5) declares a stationary first-order autoregressive latent effect on the ordered, observed levels of index (the same level-ordering rule as RW1/RW2; at least two levels are required). Its precision is Q = τ/(1−ρ²)·T, with T tridiagonal (unit corners, 1+ρ² on the interior diagonal, −ρ off it), which inverts to Cov[i, j] = (1/τ)·ρ^|i−j|. τ is the marginal precision — INLA's convention — so the marginal variance of every level is exactly 1/τ, directly comparable to an IID effect's precision or BYM2's τ, not an innovation precision. ρ = 0 collapses Q to exactly τ·I (independent levels); as ρ → 1 the effect approaches the intrinsic RW1 limit.

import numpy as np
import pandas as pd
from pylgm import AR1, Fixed, Gaussian, LGM

frame = pd.DataFrame({
    "t": range(10),
    "y": [1.0, 1.4, 1.1, 1.6, 1.3, 1.8, 1.5, 2.0, 1.7, 2.2],
})
model = LGM(
    response="y",
    predictor=Fixed("1") + AR1("trend", index="t", precision=2.0, rho=0.7),
    likelihood=Gaussian(sigma=0.3),
)
result = model.fit(frame)
print(np.round(result.latent_marginals("trend").mean, 3))
# [-0.466 -0.244 -0.358 -0.065 -0.166  0.129  0.027  0.322  0.22   0.506]

rho accepts either a fixed float strictly inside (-1, 1) (plug-in, shown above) or a declared Hyperparameter with transform="logit", in which case ρ is estimated by empirical Bayes and, under hyperparameters="integrate", integrated over jointly with τ by INLA — a non-logit Hyperparameter for rho raises CompilationError. The interval is the fixed (-1, 1) inset by 1e-6 (no graph-derived bound, unlike ProperCAR's ρ); a Hyperparameter's own lower/upper narrow it further if you set them.

One caveat on that interval: conditioning degrades as ρ approaches the bound and as the series grows — cond(Q) runs roughly 2e6·n at the inset edge, so a 1000-level series fitted at ρ ≈ 1 retains only about seven significant digits. Nothing fails, but treat an ρ̂ pinned at the bound on a long series as "the data want a random walk" and consider RW1 instead.

from pylgm import AR1, Fixed, Gaussian, Hyperparameter, LGM
from pylgm.priors import PCPrecision

rho = Hyperparameter("trend.rho", initial=0.0, transform="logit")
tau = Hyperparameter("trend.precision", initial=1.0, prior=PCPrecision(upper_sd=1.0, alpha=0.01))
model = LGM(
    response="y",
    predictor=Fixed("1") + AR1("trend", index="t", precision=tau, rho=rho),
    likelihood=Gaussian(sigma=0.3),
)
eb = model.fit(frame)                                 # empirical Bayes
eb.hyperparameters["trend.rho"]                        # point estimate of rho

post = model.fit(frame, hyperparameters="integrate")   # INLA over (tau, rho)
post.hyperparameter_marginals()["trend.rho"].mean      # rho marginal

Unlike RW1/RW2, AR1 is proper/full-rank, so it carries no sum-to-zero constraint: it works under all three latent strategies, including latent_strategy="laplace" (full Laplace), where RW1/RW2 are rejected because they are intrinsic/constrained. result.predict(new_data) works on AR1 exactly as described in "Predicting new rows" above; an unseen time level raises the same ValueError pointing at the NaN-response workflow.

Regular spacing is assumed. AR1 relates consecutive ordered levels — it has no notion of the gap between them — so a missing period (a skipped month, say) is silently treated as a single step, understating the true elapsed time and its correlation decay. If a period is absent from the data but should count as a step, include it as a row with a NaN response at fit time: it contributes no likelihood but still creates its latent column and restores regular spacing, exactly like the future-period rows in "Predicting new rows" above. Not shipped: irregular-spacing support (ρ^Δt) and AR(p) effects.

From YAML: the standalone load_model frontend declares type: ar1, indexed by a single index, with optional fixed rho (default 0.5), precision (default 1.0), and a group column for the group-wise variant — the config schema keeps the group key, threaded to AR1(replicate=) directly, so loading this YAML raises no deprecation warning. Estimating rho/precision from YAML stays Python-API-only.

predictor:
  effects:
    - {name: trend, type: ar1, index: period, rho: 0.5, precision: 1.0, group: unit}

Group-wise AR1

AR1(name, index, precision, rho, replicate=None) with replicate set declares one independent AR1 series per level of that column — a separate series per firm, country, or region — sharing precision and rho across replicates but not their realizations. This is the panel case: a common persistence parameter estimated from all units at once, with each unit keeping its own trajectory. It is also exactly what Replicated does for any indexed effect; AR1(replicate=) predates Replicated and is kept as the direct, single-effect spelling.

model = LGM(
    response="y",
    predictor=Fixed("1") + AR1(
        "dyn", index="t", replicate="firm",
        rho=Hyperparameter("dyn.rho", initial=0.0, transform="logit"),
        precision=Hyperparameter("dyn.precision", initial=1.0),
    ),
    likelihood=Gaussian(sigma=0.3),
)

The latent field has one cell per (replicate, index) pair, laid out replicate-major and labelled "<replicate>@<level>", so the precision is block diagonal — I_R ⊗ T(ρ), one contiguous AR1 band per replicate. Pooling is in the hyperparameters only: fitting R replicates jointly is exactly R separate AR1 fits at the same ρ and precision (the test suite pins this equality), so no replicate borrows latent strength from another.

The time levels are the union of levels observed anywhere in the frame, so the grid is balanced across replicates: a (replicate, period) cell absent from the data still gets a latent column and a prediction, exactly like the NaN-response future rows above. result.predict(new_data) scores replicated rows; an unseen replicate raises, pointing at the same NaN-response workflow as an unseen level.

Regular spacing is assumed within each replicate, for the same reason it is for the ungrouped effect.

group= is deprecated in favour of replicate=. AR1(group=...) was the original, misleadingly-named spelling — this is R-INLA's replicate, not its group, which means correlated copies with a between-group structure. AR1(..., group="firm") still works and folds into replicate with a DeprecationWarning; passing both raises ValueError. Use replicate= directly in new code.

Seasonal effect

Seasonal(name, index, period, precision=1.0, ridge=1e-6) declares a slowly-drifting seasonal pattern of the declared period (4 for quarterly data, 12 for monthly). With RW1/RW2 for trend it gives the classic trend + seasonal + irregular decomposition:

model = LGM(
    response="y",
    predictor=Fixed("1")
    + RW1("trend", index="t", precision=Hyperparameter("trend.precision", initial=10.0))
    + Seasonal("seas", index="t", period=4,
               precision=Hyperparameter("seas.precision", initial=10.0)),
    likelihood=Gaussian(sigma=0.25),
)
result = model.fit(frame)
result.latent_marginals("seas").mean   # the seasonal component

The penalty sums every period consecutive levels and shrinks that sum toward zero, so precision controls how fast the seasonal pattern may drift, not its size: a pattern that repeats exactly is unpenalized by precision, and a large precision means "the shape is nearly fixed from cycle to cycle".

Why there is a ridge and no constraint. The directions precision leaves unpenalized are exactly the fixed seasonal patterns — the signal, not a nuisance. This is the opposite of RW1/RW2, whose unpenalized level and slope are nuisances absorbed by the intercept, and so are removed by sum-to-zero constraints. Constraining them away here would annihilate a stable seasonal pattern entirely. Instead, as in MIDAS, those directions carry a fixed, precision-independent ridge on their orthogonal projector: the pattern stays estimable, the block stays positive definite, and the effect declares no constraints. Raise ridge to shrink the seasonal amplitude toward zero; lower it to free the pattern further.

This differs from R-INLA's seasonal, which leaves the model rank-deficient and applies a single sum-to-zero constraint for intercept confounding. The ridge reaches the same place — a weakly-identified fixed pattern — while keeping the conditioned precision positive definite, which is what the engines here require.

Regular spacing is assumed, as for RW1/RW2/AR1: the effect relates consecutive ordered levels and has no notion of the gap between them. A missing period should enter as a NaN-response row so the grid stays regular. Future periods included that way also receive an extrapolated seasonal posterior, so the pattern projects forward — see "Predicting new rows".

From YAML: the standalone load_model frontend declares type: seasonal, indexed by a single index, with a required period and optional fixed precision (default 1.0) and ridge (default 1e-6). Estimating precision from YAML stays Python-API-only. Write floats in full YAML form (1.0e-6, not 1e-6, which YAML reads as a string).

predictor:
  effects:
    - {name: seas, type: seasonal, index: month, period: 12, precision: 1.0, ridge: 1.0e-6}

Weighted effects

Weighted(effect, by) modulates an indexed effect by a numeric column: the design becomes diag(by) A instead of the plain incidence A, so the effect's contribution to the predictor at row i is by_i * u_{index(i)} rather than plain u_{index(i)}. This is R-INLA's f(index, weights, model=...).

The model this unlocks is a spatially-varying coefficient: a covariate whose slope is itself a latent field instead of one shared number.

log mu_i = alpha + z_i * u_{s(i)},    u ~ IID(tau)
from pylgm import Fixed, IID, LGM, Poisson, Weighted
from pylgm.parameters import Hyperparameter

# ... a frame whose `region` is the index, `z` the covariate whose slope
# varies, and `y` the Poisson response ...
result = LGM(
    response="y", likelihood=Poisson(),
    predictor=Fixed("1") + Weighted(
        IID("u", index="region", precision=Hyperparameter("tau", initial=1.0)), by="z"
    ),
).fit(frame, engine="laplace")

The effect of z now varies by region: region s's slope is u_s, shrunk toward zero by the estimated tau exactly as an ordinary IID would shrink an intercept. Any indexed effect can be wrapped this way, not only IID — a Weighted(RW1(...), by=...) gives a smoothly-varying-in-time coefficient, a Weighted(Besag(...), by=...) a spatially-smooth one.

Precision, labels and constraints are the inner effect's, untouched. Wrapping changes how the field enters the predictor, not the field itself, so result.latent_marginals("u") and the u:0, u:1, ... labels in result.labels are exactly what the unwrapped IID("u", ...) would produce. The weighting is design-only.

by must be numeric, finite, and not all-zero. A missing or non-numeric column is a data contract error. An all-zero column is rejected too, rather than silently accepted: it would make the effect contribute nothing to the predictor while still consuming latent dimensions, fitting happily and reporting a field the data never actually informed.

Fixed cannot be wrapped. Weighted requires an indexed effect — it scales rows of an incidence matrix A — and Fixed has no index; its design comes from a formula instead. To weight a fixed-effect column, multiply the covariate into the formula directly (Fixed("z:region") or similar), rather than wrapping.

Copy

Copy(name, index, scale=1.0) is a second occurrence of an existing latent field at a different index, R-INLA's f(index, copy="name"). It adds scale * A_index to the target block's design — A_index the incidence matrix of index over the target's own levels — and produces no block of its own.

The model this expresses is a field entering one predictor twice, once unscaled at its declaring index and once rescaled at a second index:

log mu_k = alpha + u_{i(k)} + beta * u_{j(k)},    u ~ IID(tau)
from pylgm import Copy, Fixed, IID, LGM, Poisson
from pylgm.parameters import Hyperparameter

# ... a frame whose `i` indexes u's own declaration, `j` a second index over
# the same levels, and `y` the Poisson response ...
result = LGM(
    response="y", likelihood=Poisson(),
    predictor=Fixed("1")
    + IID("u", index="i", precision=1.0)
    + Copy("u", index="j", scale=Hyperparameter("beta", initial=1.0)),
).fit(frame, engine="laplace")

A copy contributes no labels or block of its own. IID("u", ...) + Copy("u", index="j", ...) produces exactly the blocks and result.labels that IID("u", ...) alone would — the copy only changes what values land in u's existing columns. result.latent_marginals("u") is u as declared; there is nothing named after the copy to query separately.

scale may be a fixed number or a Hyperparameter. A fixed scale bakes into the design once; an estimated scale makes the design a function of the hyperparameter, re-formed on every draw during optimisation, and the fitted value is reported under its own name in result.hyperparameters exactly like any other estimated parameter.

An estimated scale defaults to transform="log", so it cannot be negative or zero unless declared with transform="identity". This is Hyperparameter's own default, not something Copy adds, but it means a fixed scale can be negative while an estimated one cannot without an explicit Hyperparameter(..., transform="identity"). R-INLA's copy beta is unbounded by default; match that behaviour by declaring the transform explicitly.

The copy's index values must already be levels of the target. A copy reuses an existing latent field — it has no mechanism to create a level in it — so an index column containing a value the target was never declared over is a CompilationError at compile time, not a silently-added level.

Weighted(Copy(...)), Shared(Copy(...)), and Copy inside a Joint are all rejected. A copy is a term referencing another effect's block, not an indexed effect with a design of its own, so wrapping it in Weighted or Shared raises TypeError at construction — weight or share the target effect instead. A Joint sub-model containing a bare Copy fails to compile with a CompilationError, because Joint compiles each sub-model's effects independently and has no target block, from an earlier sub-model or the same one, for the copy to fold into.

Replicated

Replicated(effect, over) builds R independent copies of any indexed effect — one per level of over — sharing the wrapped effect's hyperparameters but not its realizations. This is R-INLA's f(index, model=..., replicate=r). AR1(replicate=), in the section above, is the single-effect special case that predates Replicated and is now expressible as Replicated(AR1(...), over=...).

log mu_i = alpha + u_{r(i), s(i)},    u ~ IID(tau)

for replicate r(i) and level s(i) of row i: R independent draws of the inner field, tau shared across all of them.

from pylgm import Fixed, IID, LGM, Poisson, Replicated
from pylgm.parameters import Hyperparameter

# ... a frame whose `region` is IID's own index, `firm` the replicate column,
# and `y` the Poisson response ...
result = LGM(
    response="y", likelihood=Poisson(),
    predictor=Fixed("1") + Replicated(
        IID("u", index="region", precision=Hyperparameter("tau", initial=1.0)), over="firm"
    ),
).fit(frame, engine="laplace")

Structure: I_R ⊗ Q, paired labels. The precision is the Kronecker product of an R×R identity and the inner effect's own precision Q, laid out replicate-major (cell = replicate * n_levels + level, the same convention AR1(replicate=) uses, which is what lets the two match bit for bit below). Labels are "<replicate>@<level>" pairs — two firms over regions ny/sf give result.labels entries "u:f1@ny", "u:f1@sf", "u:f2@ny", "u:f2@sf".

Constraints replicate too: one per replicate, not one shared. A constrained inner effect (RW1, RW2, Besag, ...) gets R copies of its own constraint, I_R ⊗ C, not a single constraint shared across all R copies — a shared constraint would leave R−1 directions unidentified while the fit still converges on plausible numbers.

Replicates share every hyperparameter, never their realizations. Fitting R replicates jointly is exactly R separate fits at the same hyperparameters — precision, rho, whatever the inner effect declares — pooled only in the hyperparameters, never in the latent values themselves.

It commutes with Weighted. Replicated(Weighted(effect, by=...), over=...) and Weighted(Replicated(effect, over=...), by=...) compile to the same block: weighting scales the design, replication reshapes precision and indexing, and the two touch disjoint parts of the block.

A ParametricDesignBlock inner effect is rejected. An effect whose design is itself a function of an estimated hyperparameter — today only MIDASParametric — cannot be replicated. In practice this is caught earlier: MIDASParametric has no index, so Replicated(MIDASParametric(...), ...) already raises TypeError at construction. The compiler carries a second guard for the same case, currently unreachable through the public API, kept so a future design-varying effect that does gain an index fails loudly rather than silently replicating over the wrong row space.

AR1(group=) is deprecated in Replicated's favour. AR1(name, index, precision, rho, group="firm") is exactly Replicated(AR1(name, index, precision, rho), over="firm") — matched bit for bit (labels, design, precision, constraints) across four values of rho, and under a full fit(), in tests/test_replicated_equivalence.py. AR1(group=) still works, folded into AR1(replicate=) with a DeprecationWarning; prefer AR1(replicate=) for a single AR1 panel, or Replicated(AR1(...), over=...) when it reads more consistently alongside other replicated effects in the same model.

Grouped

Grouped(effect, over, structure) builds R correlated copies of any indexed effect, one per level of over, tied together by a between-group precision Q_S. This is R-INLA's f(index, model=..., group=g, control.group=list(model=...)). Where Replicated gives independent copies (I_R ⊗ Q_E), Grouped gives copies correlated across groups (Q_S ⊗ Q_E) — Replicated is exactly the special case structure= IIDStructure().

log mu_i = alpha + u_{g(i), s(i)},    u ~ N(0, (Q_S (x) Q_E)^-1)

for group g(i) and level s(i) of row i: R correlated draws of the inner field, correlated across groups by Q_S and within each group by the inner effect's own precision Q_E.

from pylgm import BesagStructure, Fixed, IID, LGM, Poisson, Grouped
from pylgm.parameters import Hyperparameter

# ... a frame whose `region` is IID's own index, `group` the between-group
# column, `W` a neighbour graph over `group`'s levels, and `y` the Poisson
# response ...
result = LGM(
    response="y", likelihood=Poisson(),
    predictor=Fixed("1") + Grouped(
        IID("u", index="region", precision=Hyperparameter("tau", initial=1.0)),
        over="group", structure=BesagStructure(W),
    ),
).fit(frame, engine="laplace")

Five between-group structures. structure is any of IIDStructure(), RW1Structure() / RW2Structure(), BesagStructure(graph), or AR1Structure(rho) (rho fixed). The first three, paired with the inner effect's own precision, reproduce all four Knorr-Held space-time interaction types — structure plays the role of K_s (the over/space factor) and the inner effect's own precision plays K_t (the time factor), exactly as in SpaceTime:

structure inner effect Knorr-Held reading
IIDStructure() IID type I — unstructured, Grouped reduces exactly to Replicated
IIDStructure() RW1/RW2 type II — each group its own independent temporal trend
BesagStructure(graph) IID type III — each level its own independent spatial pattern
BesagStructure(graph) RW1/RW2 type IV — inseparable: neighbours tied in both factors

AR1Structure(rho) has no direct Knorr-Held type; it is the panel analogue (groups correlated by a stationary AR1, rho fixed), used the same way AR1 is used as a temporal main effect outside the Knorr-Held family. This equivalence to SpaceTime is checked directly, matrix for matrix, in tests/test_grouped_spacetime_oracle.py — except that the RW-based types (II and IV) match only up to one global scalar on the precision: Grouped's inner RW1/RW2 compiles through the library's ordinary, unscaled random-walk builder, while SpaceTime always builds its time factor Sørbye-Rue scaled. See research status for the exact ratio and what it means for a model that mixes the two.

The same divergence also shows up inside one Grouped call, not only between Grouped and SpaceTime. RW1Structure()/RW2Structure() build their between-group precision Sørbye-Rue scaled (rw_structure(n, order, scale=True)), while a plain RW1/RW2 passed as the inner effect is unscaled, the same as everywhere else in the library. So Grouped(RW1("u", index="t"), over="g", structure=RW1Structure()) compiles to exactly kron(rw_structure(G, 1, scale=True), rw_structure(T, 1, scale=False)) — the outer factor scaled, the inner factor not, both spelled RW1 in the same line. Nothing here is a match against R-INLA's default: R-INLA's own rw1/rw2 take an explicit scale.model argument that leaves scaling off unless asked, so check what your own R call passed before assuming either side of a Grouped(RW1(...), structure=RW1Structure()) call matches it.

Structure: Q_S ⊗ Q_E, group-major labels. The precision is the Kronecker product of the structure's precision over over's levels and the inner effect's own precision. Labels are "<group>@<level>" pairs, laid out group-major — the same convention Replicated uses (cell = group_index * n_levels + level_index).

Constraints follow the null space of the product, not one per group. null(Q_S ⊗ Q_E) picks up null(Q_S) ⊗ R^E in addition to any null space the inner effect itself carries, and the two spans can overlap — unlike Replicated, where each replicate's constraint is independent of the others.

It commutes with Weighted. Grouped(Weighted(effect, by=...), over=..., structure=...) and Weighted(Grouped(effect, over=..., structure=...), by=...) compile to the same block.

group and replicate cannot combine on one effect. R-INLA allows both modifiers on a single f() term; pyLGM does not — Grouped(Replicated(...), ...), Replicated(Grouped(...), ...), and wrapping an effect that already declares its own replicate= all raise TypeError at construction. Use one or the other, or fold the two columns into a single grouping column.

The between-group structure's own parameters are fixed, not estimated. AR1Structure(rho) takes a plain float; a declared Hyperparameter there is rejected. Only the inner effect's hyperparameters (precision, rho, phi, ...) are estimated — matching the restriction Shared effects carry today.

Grouped has no YAML block. Unlike SpaceTime, there is no type: grouped in the config schema; declare it from the Python API only.

MIDAS smooth-lag effect

MIDAS(name, columns, precision=1.0, order=2, ridge=1e-6) declares a mixed-frequency distributed-lag effect. Each entry of columns is one column of the high-frequency (HF) covariate at a fixed lag — lag 0, lag 1, … — so the design is just those columns stacked (U-MIDAS: every lag enters as its own regressor). The lag coefficients are then tied together by a random-walk smoothness prior over the lag index, so the estimated lag curve is smooth instead of the noisy, overfit shape unrestricted OLS gives when the lags are many and collinear.

import numpy as np
import pandas as pd
from pylgm import Fixed, Gaussian, Hyperparameter, LGM, MIDAS

columns = tuple(f"x_lag{k}" for k in range(6))
# ... a frame whose `columns` hold the HF covariate at lags 0..5 and `y` the LF target ...
model = LGM(
    response="y",
    predictor=Fixed("1")
    + MIDAS("lag", columns=columns, precision=Hyperparameter("lag.precision", initial=1.0)),
    likelihood=Gaussian(sigma=0.5),
)
result = model.fit(frame)
print(np.round(result.latent_marginals("lag").mean, 3))  # the fitted lag curve

The penalty is a random walk over the lag index, exactly the RW1/RW2 operator (order=1 or order=2, default 2) but applied to the coefficient vector rather than to a time index. order=2 penalises curvature (favouring a smooth, gently bending decay); order=1 penalises steps (favouring a flat or monotone shape). precision (τ) is the smoothing strength: a fixed float plugs it in, a declared Hyperparameter has it estimated by empirical Bayes and, under hyperparameters="integrate", integrated over by INLA — the data choose how much to smooth.

The lag curve's level and slope stay free of τ. A random-walk penalty is rank-deficient: it says nothing about the curve's overall level (order 1) or level and slope (order 2) — the part that carries the covariate's actual effect size. Those null-space directions instead get a fixed, τ-independent precision ridge (δ, default 1e-6, the same diffuse prior every Fixed coefficient gets), so tightening τ smooths the shape without ever shrinking the magnitude toward zero. Concretely the block precision is Q(τ) = τ·DᵀD + δ·P₀, with D the order-order difference operator and P₀ the projector onto its null space; because the two terms act on orthogonal subspaces, τ touches only the curvature directions. The effect is proper and carries no constraint, so it runs under every latent strategy including full Laplace (latent_strategy="laplace").

Aligning the HF data to the LF target is the caller's job. MIDAS takes the lag columns as given; building them (e.g. frame[f"x_lag{k}"] = hf.shift(k) after resampling the HF series onto the LF rows) is upstream data prep. See examples/midas_nowcast for an end-to-end nowcasting run that recovers a known decaying kernel. Parametric lag kernels (exp-Almon / Beta weight functions) are now shipped — see Restricted MIDAS below.

From YAML: the standalone load_model frontend declares type: midas, indexed by the HF lag columns (in place of a single index), with optional fixed precision, order (1 or 2), and ridge. Estimating precision from YAML stays Python-API-only. Write floats in full YAML form (1.0e-6, not 1e-6, which YAML reads as a string).

predictor:
  effects:
    - {name: lag, type: midas, columns: [x0, x1, x2, x3], precision: 1.0, order: 2, ridge: 1.0e-6}

SpaceTime effect (Knorr-Held interaction)

SpaceTime(name, space, time, graph=None, interaction="IV", order=1, precision=1.0, scale=True) builds the interaction term δ(s,t) of a Knorr-Held space-time model — one latent block over the S·T area×period cells with precision τ·(K_s ⊗ K_t). The spatial (Besag) and temporal (RW1/RW2) main effects are composed with +; SpaceTime supplies only the interaction, keeping each term its own variance component.

interaction K_s K_t Reading
"I" I_s I_t unstructured cell-wise interaction (proper)
"II" I_s R_t each area its own independent temporal trend
"III" R_s I_t each period its own independent spatial pattern
"IV" R_s R_t inseparable: neighbours in both space and time tied

R_s is the (weighted) Sørbye–Rue-scaled Besag Laplacian; R_t the scaled RW structure of the given order. graph is required for III/IV and optional for I/II (the area universe is then read from the observed space column). precision is a single τ — a float (plug-in) or a Hyperparameter (estimated by EB, integrated by INLA).

from pylgm import Besag, Fixed, Gaussian, LGM, RW1, SpaceTime

model = LGM(
    response="y",
    predictor=(
        Fixed("1")
        + Besag("area", index="area", graph=W)
        + RW1("period", index="t")
        + SpaceTime("st", space="area", time="t", graph=W, interaction="IV", order=1)
    ),
    likelihood=Gaussian(sigma=0.5),
)
result = model.fit(frame)

Type I is proper and carries no constraint, so it runs under all latent strategies including latent_strategy="laplace". Types II–IV carry the Knorr-Held / Schrödle–Held sum-to-zero constraints (derived from the Kronecker null space), so — like RW2 and Besag — they require hyperparameters="integrate" and are rejected under full Laplace. The compiler emits a one-line warning if a SpaceTime effect is present without its spatial and temporal main effects, since the constraints assume those absorb the marginals. The latent field is dense S·T, so the >4096-dim preflight guard bites at large grids — real economic scale is gated on the sparse backend.

From YAML: the standalone load_model frontend declares type: spacetime, indexed by two columns — space and time (both required) — instead of a single index. graph (inline) or graph_file (an R-INLA .graph or .json neighbour dict) supplies the spatial neighbours, at most one and required for interaction: III/IV. interaction (default IV), order (1/2, default 1), fixed precision (default 1.0), and scale (default true) are optional. Estimating precision from YAML stays Python-API-only.

predictor:
  effects:
    - {name: st, type: spacetime, space: area, time: t, graph: {a: [b], b: [a]}, interaction: IV, order: 1, precision: 1.0}

Restricted MIDAS effect (parametric lag weights)

MIDASParametric(name, columns, kernel="beta", shape1=None, shape2=None, prior_precision=1e-6) collapses the high-frequency lag columns into a single regressor β · Σ_k w(k; θ) · x_{t,k}, where w(·; θ) is a parametric lag-weight kernel. This is the restricted counterpart to the U-MIDAS MIDAS effect, which keeps every lag as its own smoothed coefficient.

kernel shape params weight log w_k (normalized to Σ=1)
"beta" a, b > 0 (a−1)·log x_k + (b−1)·log(1−x_k), x_k=(k+1)/(K+1)
"exp_almon" θ1, θ2 real θ1·k + θ2·k² (θ2<0 ⇒ decay)

Lags are indexed columns[0] (shallowest) … columns[K-1] (deepest); weights are softmax-normalized. Each shape is a float (fixed θ) or a Hyperparameter (estimated by EB, integrated by INLA); omit them for kernel-appropriate defaults (Beta a=b=2; exp-Almon θ1=0, θ2=−0.1). The loading β is a single coefficient with a fixed vague Gaussian prior (prior_precision), so the block is proper and unconstrained — it runs under every latent strategy, including latent_strategy="laplace". predict() rebuilds the aggregate for new rows at the fitted weights.

from pylgm import Fixed, Gaussian, LGM, MIDASParametric

model = LGM(
    response="y",
    predictor=Fixed("1") + MIDASParametric("m", ("x0", "x1", "x2", "x3"), kernel="beta"),
    likelihood=Gaussian(sigma=0.1),
)
result = model.fit(frame)
result.hyperparameters["m.shape1"]  # estimated Beta a

From YAML: the frontend declares type: midas_parametric, indexed by the lag columns with an optional kernel (beta or exp_almon, default beta). The kernel shapes are estimated — the effect's default — and fixed shape overrides stay Python-API-only.

predictor:
  effects:
    - {name: m, type: midas_parametric, columns: [x0, x1, x2, x3], kernel: exp_almon}

Linear constraints (extraconstr)

LGM(..., constraints=...) imposes arbitrary linear constraints A x = e on the assembled latent field — the label-keyed equivalent of R-INLA's extraconstr. Each entry names a linear combination of latent elements by their qualified labels ("effect:level", the same strings that appear in result.labels) and either constrains it to zero (a bare mapping) or to a given value (a (mapping, rhs) pair):

from pylgm import Fixed, Gaussian, IID, LGM

model = LGM(
    response="y",
    likelihood=Gaussian(1.0),
    predictor=Fixed("1") + IID("region", "region", 1.0),
    panel=("region",),
    time="time",
    constraints=[
        {"region:oslo": 1.0, "region:bergen": -1.0},  # force two effects to coincide
        ({"region:tromso": 1.0}, 2.5),                 # pin one effect to 2.5
    ],
)

The first row (a bare mapping) is A x = 0; the second (a (mapping, rhs) pair) is A x = 2.5. Constraints compose with any intrinsic constraints an effect already carries — a Besag sum-to-zero, a random-walk anchor — which are appended automatically. They hold exactly in the posterior mean under both engines (exact_gaussian and laplace) and under hyperparameters= "optimize"/"integrate".

An unknown label raises CompilationError at fit time (labels are only known once the latent field is assembled); a malformed constraint — an empty mapping, all-zero coefficients, a non-finite coefficient or right-hand side — raises at LGM construction.

A nonzero right-hand side is imposed by conditioning the prior on A x = e (conditioning by kriging), matching R-INLA's semantics; see Theory. Contradictory constraints are not rejected — they degrade to the least-squares closest satisfiable field.