Skip to content

Likelihoods

Non-Gaussian likelihoods (Laplace)

Poisson() (canonical log link) and Bernoulli() (canonical logit link) fit through a dedicated engine="laplace" Newton-Raphson mode-finder; the Gaussian likelihood keeps using engine="exact_gaussian" and LGM.fit rejects the mismatched engine/likelihood combination:

from pylgm import Fixed, IID, LGM, Poisson

model = LGM(
    response="claims",
    likelihood=Poisson(),
    predictor=Fixed("1 + x") + IID("region_effect", index="region", precision=2.0),
    panel=("region",),
    time="time",
    offset="log_exposure",
)
result = model.fit(frame, engine="laplace")

The mode-finder stops on an absolute gradient threshold, and falls back to the Newton decrement ½·∇fᵀH⁻¹∇f when that threshold is out of reach. The gradient's scale follows the data — for a Poisson model its components are of order the counts — so on many well-behaved problems the iteration reaches the mode and then stalls just above the threshold, having already found it. The decrement is scale-invariant and closely estimates the remaining suboptimality, so such a point is accepted instead of raising. result.diagnostics records newton_decrement when that fallback carried a fit.

This changed non-Gaussian results in 0.3.x. Before the fallback, a stalled inner fit raised — which aborted an entire hyperparameters="integrate" run, and, less visibly, made hyperparameters="optimize" treat that hyperparameter as infeasible and search around it. Both now see the converged fit, so previously-recorded Poisson and Bernoulli estimates can move, occasionally a lot: in our test matrix roughly one non-Gaussian fit in ten changed, the largest by 4.9 nats of log marginal likelihood, and in one case an AR1 rho posterior mean corrected from −0.9998 to +0.91 on data simulated with rho = 0.8. The new answers are the trustworthy ones; re-fit rather than comparing against stored output from an earlier version.

LGM.offset names an optional column added directly to the linear predictor eta before the link (e.g. a log-exposure column for a Poisson rate model); it is not itself modeled. The YAML frontend exposes the same vocabulary: likelihood: {family: poisson} / {family: bernoulli} and a top-level offset: <col>. family: gaussian still requires sigma; poisson and bernoulli forbid it, since they have no free dispersion parameter.

The Laplace engine conditions on its effect precisions by default; declaring one as a Hyperparameter instead estimates it by type-II ML (see "Empirical Bayes" below) — INLA-style numerical integration over hyperparameters remains deferred to a later slice. result.fitted_mean is the response-scale prediction, and its meaning depends on the link: for the Poisson log link it is the exact lognormal expectation exp(mean + variance / 2) of the linear predictor; for the Bernoulli logit link there is no closed form, so it is a documented point estimate logit^-1(mean) that ignores the linear-predictor variance. result.predictive_variance is the linear-predictor (eta) posterior variance and excludes response-scale observation noise, for every result type including GaussianResult — see "The predictive_variance convention" below. A runnable example lives at examples/count_glm/README.md, including the Spark data-boundary path (Spark only collects and canonicalizes data; the Laplace fit itself still runs on the driver, same as exact_gaussian).

The GLM families

Beyond Gaussian, Poisson, and Bernoulli, the Laplace engine fits four more generalized-linear families. All reuse the same Newton mode-finder — they differ only in their per-observation link and derivatives.

Family Link Response support Dispersion phi fitted_mean
NegativeBinomial(phi=1.0) log non-negative integers Var = mu + mu^2/phi (NB2); phi -> inf is Poisson exp(mean + var/2)
Gamma(phi=1.0) log positive reals Var = mu^2/phi; shape a = phi exp(mean + var/2)
Beta(phi=1.0) logit open interval (0, 1) Beta(mu*phi, (1-mu)*phi) precision point estimate logit^-1(mean)
Binomial(trials="col") logit integers 0 <= y <= n — (no free dispersion) counts n * p
from pylgm import Beta, Binomial, Fixed, Gamma, LGM, NegativeBinomial

# Overdispersed counts (fixed dispersion phi=2.5)
LGM("y", NegativeBinomial(2.5), Fixed("1 + x"), time="t").fit(frame, engine="laplace")

# Positive-continuous (gamma) and proportions in (0, 1) (beta)
LGM("y", Gamma(3.0), Fixed("1 + x"), time="t").fit(frame, engine="laplace")
LGM("rate", Beta(10.0), Fixed("1 + x"), time="t").fit(frame, engine="laplace")

# Aggregated binomial: `trials` names the per-row count column n; predicts n*p
LGM("successes", Binomial("n"), Fixed("1 + x"), time="t").fit(frame, engine="laplace")

Dispersion phi. For NB/Gamma/Beta, phi is a strictly-positive concentration parameter. Pass a fixed float, or a Hyperparameter to estimate it by type-II ML alongside the effect precisions (hyperparameters="optimize"). The YAML frontend accepts a fixed value only: likelihood: {family: nbinomial, phi: 2.5} (phi is optional and defaults to 1.0); gaussian still requires sigma, and non-φ families reject phi.

Binomial trials. Binomial takes the name of a per-row trials column n (not a value). The response y is the success count, and both the fitted values and out-of-sample predict() return counts n * p rather than the probability p — so predict(new_data) requires new_data to carry its own trials column, and scores each new row against its own n. The n = 1 case reduces exactly to Bernoulli. In YAML: likelihood: {family: binomial, trials: n} (required for binomial, rejected for every other family).

response_prediction for Beta and Binomial follows the Bernoulli convention — a point estimate that ignores the linear-predictor variance — while the two log-link families (NegativeBinomial, Gamma) apply the same exact lognormal correction exp(mean + var/2) as Poisson.

Survival likelihoods

WeibullSurv and ExponentialSurv fit event-time (duration) data under a proportional-hazards (PH) parameterization on the Laplace engine. Unlike the GLM families above, the response is a follow-up time, not a plain outcome, so they carry their own event/censoring data contract:

from pylgm import Fixed, Hyperparameter, IID, LGM, WeibullSurv

model = LGM(
    response="spell_time",                      # follow-up time (must be > 0)
    likelihood=WeibullSurv(
        "event",                                 # 1 = observed, 0 = right-censored
        shape=Hyperparameter("alpha", initial=1.0, transform="log"),
        entry="entry",                           # optional: left-truncation time
    ),
    predictor=Fixed("1 + x") + IID("frailty", index="id", precision=2.0),
)
result = model.fit(frame, engine="laplace", hyperparameters="optimize")

Hazard. With linear predictor eta, the hazard is h(t) = alpha * t**(alpha-1) * exp(eta), cumulative hazard H(t) = t**alpha * exp(eta), survival S(t) = exp(-H(t)). ExponentialSurv is exactly WeibullSurv with alpha = 1 fixed (constant hazard). alpha may be a fixed float or a Hyperparameter (estimated by type-II ML, same mechanism as GLM dispersion phi above); the fitted value is reported on result.hyperparameters["alpha"] (or whatever name the Hyperparameter was given) and — critically — result.predict(new_data) uses the fitted shape, not the Hyperparameter's initial guess.

Data contract. event names a 0/1 column (1 = the event was observed at response, 0 = the subject was right-censored — still at risk — at response). entry, if given, names a left-truncation (delayed-entry) time column: the subject is only observed to be at risk from entry onward, and must satisfy 0 <= entry < response; omit it (entry=None, the default) when there is no delayed entry. Both apply per row — there is no separate panel requirement, though WeibullSurv/ExponentialSurv compose with panel/time like any other likelihood.

Fitted mean & hazard ratios. response_mean (and hence fitted_mean) is the unconditional expected duration E[T] = exp(-eta/alpha) * Gamma(1 + 1/alpha) — a closed-form Weibull moment, not a censoring-adjusted estimator; compare it against the raw (censored) sample mean with that caveat in mind. Exponentiating a fixed-effect coefficient gives the familiar hazard ratio exp(beta): covariate effects multiply the hazard rather than shifting the mean directly.

Frailty (unobserved heterogeneity). There is no dedicated "frailty" construct — declare it as an ordinary IID effect indexed by the individual, exactly like the IID overdispersion idiom in examples/count_regression: one latent value per row/individual, identified through the survival likelihood's nonlinearity rather than through repeated observations per group.

Not (yet) covered. Interval-censoring (event known only to fall between two visits — needs a person-period/discrete-time Bernoulli recoding instead), time-varying covariates (needs episode-splitting into covariate-constant sub-intervals), and competing risks (multiple distinct failure causes) are outside this slice's scope.

A runnable example — a simulated unemployment-duration panel with right-censoring, left-truncation, estimated shape, and an IID frailty term — lives at examples/survival_duration/README.md. In YAML: likelihood: {family: weibullsurv, event: d, shape: 1.5, entry: v} / {family: exponentialsurv, event: d} (shape accepts a fixed value only, same as GLM phi above; entry is optional for both).

The predictive_variance convention

predictive_variance is the linear-predictor posterior variance Var(eta) for every result type (GaussianResult, LaplaceResult, INLAResult) and for result.predict(new_data) — it never includes response-scale observation noise.

This changed for Gaussian models. Before this version, GaussianResult.predictive_variance (and the Gaussian path of predict()) included the observation variance sigma^2 — i.e. it reported Var(eta) + sigma^2 — while the Laplace path already reported Var(eta) alone, so the same attribute name meant two different quantities depending on engine. Reported Gaussian predictive variances (and predictive standard deviations) recorded from before this version are too large by sigma^2; re-fit rather than reuse stored numbers. Poisson and Bernoulli values are unchanged — they never included an observation variance, since those likelihoods have no such parameter.

GaussianResult gains observation_variance (the Gaussian likelihood's sigma^2), and INLAResult carries it too when the conditional engine is Gaussian — there it is E[sigma^2] over the hyperparameter grid, so an integrated fit reconstructs the same way. It is None for a Laplace conditional engine, which has no observation variance. The old, response-scale value is recovered exactly:

response_scale_variance = result.predictive_variance + result.observation_variance

predict()'s returned Prediction does not carry observation_variance itself (it is a property of the fitted GaussianResult, constant across new rows), so the same reconstruction reads prediction.predictive_variance + result.observation_variance.

Why this convention: it is the only one definable for Poisson/Bernoulli likelihoods, which have no observation-variance parameter at all; it matches R-INLA's summary.linear.predictor; and it is what the Laplace path and every non-Gaussian model already did. fitted_mean, mean, log_marginal_likelihood, and the model-assessment criteria (DIC/WAIC/CPO/PIT) are all unaffected by this change.

Structural note. The three result types (GaussianResult, LaplaceResult, INLAResult) now share a private _BaseResult carrying their common read-only properties, delegating methods, and constructor validation; they remain siblings — none inherits another, and no public API was renamed, removed, or moved. The three latent-marginal types (GaussianMarginals, SkewNormalMarginals, TabulatedMarginals) satisfy a documented LatentMarginals protocol, exported from pylgm.inference, formalizing the mean/variance/std/quantile surface they already shared.

_BaseResult is now a dataclass declaring its eleven shared fields once instead of once per result type. This changed repr() output: printed fields now list the shared ones first, then each result type's own fields, rather than interleaved in the previous per-type order. No value, attribute name, or other behaviour changed — this affects only what repr(result) prints.

Exact Gaussian example

pylgm fit examples/synthetic_panel/config.yaml examples/synthetic_panel/data.csv --output synthetic-run

The example combines fixed effects, an IID region effect, and an intrinsic RW1 time effect. Rows with missing responses are prediction targets. The exact Gaussian engine conditions on declared effect precisions and observation standard deviation; it does not integrate their uncertainty.

Exact Gaussian reference limits

The exact Gaussian engine is a small/medium-model reference implementation. Its dense posterior covariance requires O(p^2) memory and its dense factorization requires O(p^3) work for latent dimension p. Conservative dimension and estimated-memory preflights reject larger models before dense conversion. Advanced direct callers may explicitly opt in with fit_gaussian(model, allow_large_dense=True); Pipeline deliberately remains safe by default. Schema-v2 experiments have the equivalent strict configuration switch:

inference:
  engine: exact_gaussian
  allow_large_dense: true
  hyperparameters: ...

The resolved value is recorded in experiment artifacts and is forwarded to every preflight, optimizer evaluation, and prediction fit. It does not change or weaken the 4,096-latent-dimension and 512 MiB default guards; it only provides an explicit opt-in override for schema-v2 experiments.