Unified entry point for occupancy, abundance, distance, removal, and related
models that share the latent-state-plus-imperfect-detection generative
template. The specific model is chosen via the family argument; the engine
(Laplace / nested Laplace / NUTS) via engine.
Arguments
- formula
state-process formula, e.g.
~ elev + forest. For occupancy this is the occupancy probability formula; for N-mixture the abundance formula; for the cover hurdle the latent-presence formula (also given asoccurrence, which reads symmetrically withdetectionandpositive).Single-vector-response families (the cover hurdle,
cover()) also accept the response on the left-hand side,response ~ predictors, in which casey =is omitted (e.g.cover.flat ~ time + habitat). The LHS is evaluated againstdata(then the calling environment), so it may be a bare column or an expression. Matrix / array / list response families (occu(),abun(), thems_*families, ...) keep the one-sided form and supply the response viay =; a two-sided formula for those errors.Structured effects are written as terms inside the formula, the way
lme4,mgcv, andINLAdo: spatial fieldsicar(graph = adj),bym2(graph = adj),gp(lon, lat, prior_range = c(r0, alpha)),spde(lon, lat); random effectsre(group); temporal fieldstemporal(time); spatially varying coefficientsspatial(~ 1 + w || cell, graph = adj)over a graph orspatial(lon, lat, model = "svc", coefficients = ..., prior_range = c(r0, alpha))over coordinates; community latent factorslatent(k). A term enters whichever linear predictor it is written in (occupancyformulaordetection). To share one realization across both predictors, tag the term withid = "u"and writeshare("u")in the other formula.The spatial fields also have a single-verb umbrella
spatial(..., model = ...)that selects the field type by name, mirroringtemporal(time, type = ...)andINLA'sf(i, model = ...):spatial(graph = adj, model = "bym2")isbym2(graph = adj)andspatial(lon, lat, model = "spde")isspde(lon, lat).modelis one of"icar","bym2","car","car_proper","gp","multiscale_gp","spde","svc"; per-model arguments pass through unchanged. The umbrella also reads a varying-coefficient bar (spatial(~ 1 + w || cell, graph = adj)), so both flavours of spatially-varying coefficient – areal over a graph, continuous over coordinates (model = "svc") – are written through the same verb.The continuous fields (
gp(),svc(),spde()) requireprior_range = c(r0, alpha), a penalized-complexity prior on the spatial range encodingP(range < r0) = alpha(Fuglstad et al. 2019). The range is in the units of the coordinates – the kernel isexp(-d / range)– so chooser0as a distance below which the field's correlation would be surprisingly short: on unit-square coordinatesprior_range = c(0.1, 0.05)reads as "a 5% chance the range is under 0.1". There is no default, deliberately. The range is weakly identified by the likelihood alone, so a default would be an invented prior doing real work on the posterior rather than a convenience.Random effects also accept
lme4bar syntax as shorthand forre():(1 | g)isre(g),(x | g)is a correlated random intercept and slopere(g, type = "slope", covariate = x), and(x || g)drops the correlation (correlated = FALSE). Usere()directly for AR1/RW structures or other options.- data
data frame of site-level covariates with
nrow(data) == nrow(y), or atobs_dataframe (fromtobs_data()/tobs_format()) bundling the response, site covariates, and visit covariates. When a frame is passed,yandvisitsare taken from it and must not also be supplied.- family
a
tobs_familyobject (seeobs_family()and the concrete constructorsoccu(),abun(),cover(), ...).- occurrence
state-process (occupancy / latent-presence) formula for the cover hurdle (
occu_cover()/cover()), reading symmetrically withdetectionandpositive. The front-door name forformula; supply one of the two.- detection
detection-process formula, e.g.
~ observer + effort. Family-dependent: required foroccu()andabun(), ignored forjsdm()and (currently)cover().- positive
positive-arm (cover) formula for
occu_cover(), e.g.~ time.sc + habitat. The occurrence spatial field is carried onto this arm with ashare()selector, the INLA-style cross-arm edge written in the formula. The selector is a constructor, so no field name is needed in the common case:share(spatial(), alpha = grid(g))copies the occurrence arm's spatial effect and marginalizes the coupling amplitude over the nodesg;alpha = grid(n = 9)marginalizes it over the engine's own axis read atnslab nodes instead, which sharpens the axis without restating the prior structure it carries;alpha = <scalar>fixes it.share(spatial(), terms = list(intercept = grid(g0), time.sc = grid(g1)))gives a per-component amplitude, keyed by the field's own block names (the intercept block and a||-declared trend column, or its aliastrend);terms =must address every block.share(spatial(), prior = list("pc.prec", c(4, 0.01)))regularizes the copy coefficient itself (prior_alphaintulpa::tulpa_nested_laplace_joint()). One reaches the engine per fit.share(spatial(cell_idx), ...)disambiguates by grouping variable when the occurrence arm carries several spatial effects.decoupling is structural: omit
share()so the field rides occupancy only (a block with noshare()is pinned at zero coupling), or write aspatial()term for the cover arm's own field.
Defaults to
detectionwhen unset (a per-visit cover design matching the detection design).- y
response. Shape depends on family:
occu()– N x J detection-history matrix.abun()– N x J integer count matrix.ms_*– S x N x J array.cover()– length-N vector of cover proportions in [0, 1].
For a single-vector-response family (
cover()) the response may instead be written on theformulaleft-hand side (response ~ predictors), in which casey =is omitted. Supplying the response both on the LHS and viay =errors. Matrix / array / list response families take the response viay =only.- visits
optional visit-level detection covariates. Accepts either:
a named list of
[n_sites, max_visits]matrices (the shape returned bytobs_data()indet.covs) – flattened internally to a long data frame in site-major order;a data frame with
nrow(y) * ncol(y)rows in site-major order.
When
visitsis provided without a"formula"attribute, thedetectionargument is interpreted as the visit-level detection formula and the site-level detection design matrix is an intercept only. To split visit-level and site-level detection covariates (e.g. visit-level effort plus site-level observer category), pass a long data frame withattr(visits, "formula") = ~ effortand usedetection = ~ observerfor the site-level terms.For
occu(), a random-effect bar in the visit-level formula is a random effect over visit rows:detection = ~ effort + (1 | observer)withobservera column ofvisitsgives each observer its own shift of the detection logit on the visits it made. It is fitted withmethod = "laplace"or"nuts", and other families refuse it.- method
inference route, naming a fully-specified path rather than a pair of orthogonal knobs:
"auto"– the family's default route (seedefault_engine)."laplace"– EM + Laplace with Gaussian marginals (fast default)."laplace_sla"– Laplace with skew-corrected (simplified-Laplace) marginals."laplace_gibbs"/"laplace_mi"– Laplace with a post-EM Gibbs / multiple-imputation correction. The fixed-effect prior threads into the correction refits, so these use the same weakly-informative default prior as"laplace"; passpriors = FALSEfor the unpenalised correction."pg_gibbs"– a Polya-Gamma Gibbs sampler over the exact single-season occupancy posterior (the spOccupancyPGOccengine). A real MCMC chain (withRhat/ESSdiagnostics), distinct from"laplace_gibbs"(a stochastic-EM variance correction). Sampler controls (n.iter,n.warmup,n.chains,n.thin,seed,sigma.beta). v1: single-seasonoccu(), site-level detection, no structured terms."nested_laplace"– multi-block nested Laplace (single-season occupancy and cover-hurdle joint)."nested_laplace_sla"– nested Laplace with skew-corrected marginals."nuts"– HMC / NUTS sampler (every structure; reports Rhat / ESS). Not every method is available for every family (e.g. the cover hurdle has no"nuts"path;"nested_laplace"is occupancy- and cover-only). An unsupported method errors with the list of methods that family supports.
- priors
optional prior specification. For occupancy families fit with a Laplace method (
method = "laplace","laplace_sla","nested_laplace"), pass a list oroccu_priors()object to set weakly-informative quadratic priors on the fixed-effect coefficients (defaults pull the detection intercept towardp = 0.5and break the psi-p identifiability ridge at smallJ). Passpriors = FALSEto disable the default prior and recover the unpenalised MAP. The"laplace_gibbs"/"laplace_mi"routes apply the same default prior and thread it through the correction refits. For NUTS, this is forwarded to the underlying tulpa engine.- control
list of low-level engine controls. Names follow the dotted-separator convention. Every default below is resolved from one table (
.TOBS_ENGINE_DEFAULTS), so it is a property of the fitting engine rather than of the family. Where a default differs between the single-species and community entries, both are given.Sampler controls (
method = "nuts"):n.iter– post-warmup sampling iterations kept per chain (default 1000); the total run per chain isn.iter + n.warmup.n.warmup– warmup / adaptation iterations per chain, discarded (default 1000).n.thin– keep everyn.thin-th post-warmup draw (default 1). The kept draws and the per-iteration diagnostics (divergent,accept_prob,treedepth) are thinned by the same stride.n.chains– number of chains, run with offset seeds and pooled (default 1). Split-Rhat / bulk / tail ESS are reported on$convergence.n.threads– chains to run in parallel (default 1, sequential). Values> 1use a PSOCK cluster and require tulpaObs to be installed. This is chain parallelism; it does not change the thread count inside a single gradient evaluation.n.threads.grad– OpenMP threads inside ONE gradient evaluation of a community NUTS target (ms_occu(),ms_count()/jsdm(),ms_abun(),ms_dyn_occu()), whose per-species loop is parallel. Default 0 leaves the count to OpenMP. The per-species reduction is serial and order-fixed, so the gradient is the same at any count.adapt.delta– target acceptance probability (default 0.8 on the single-species families, 0.9 on the community samplers).max.treedepth– NUTS maximum tree depth (default 10).seed– base RNG seed; chaincusesseed + c - 1(default 42 on the single-species families, 1 on the community samplers). The resolved per-chain seeds are stored on$seeds.sigma.beta– prior SD on the coefficients (default 10 on the single-species families and on the log-link community samplersms_count()/jsdm()/ms_abun(), whose unit change is multiplicative; 5 on the logit-link community samplersms_occu(),ms_dyn_occu(),ms_int_occu(),ms_occu_cover()).sigma.logr– prior SD on the community-mean log-dispersionmu_log_r(default 1.5), on the negative-binomial samplers that carry one (ms_abun(),ms_count(),jsdm()). At the default this is an informative prior on the dispersion scale, so raise it to compare the sampler against a maximum-likelihood target. Ignored where the family or mixture has no log-dispersion arm.
Sampler controls (
method = "pg_gibbs"). A conjugate sweep is far cheaper than a NUTS trajectory, so the chain is longer and two chains run by default.n.itercounts differently here: it is the TOTAL number of sweeps and warmup comes out of it, so the chain keepsn.iter - n.warmupdraws, where under"nuts"it is the kept count and the run isn.iter + n.warmuplong.n.iter– total sweeps per chain (default 3000).n.warmup– sweeps discarded from the front (default 1500), leaving 1500 kept draws.n.thin– keep everyn.thin-th post-warmup sweep (default 1).n.chains– number of chains (default 2, so split-Rhat is available without a second call).seed– base RNG seed (default 1).sigma.beta– coefficient prior SD (default 2.5; tighter than the NUTS one because a conjugate update has no step-size adaptation to absorb a wide prior). There is deliberately noadapt.delta/max.treedepth: those are HMC knobs.n.seeds– number of seed-offset refits to fit and LOO-stack into atobs_stackensemble (default 1, a single fit). Memberkuses base seedseed + k - 1. Only meaningful for the stochastic routes ("nuts","laplace_gibbs","laplace_mi"); the deterministic Laplace methods reject it. Seed-variants are statistically identical, so their stacking weights come out roughly uniform (this is a Monte-Carlo robustness device) – pass distinct fits totobs_stack()for a genuine model average.
Laplace controls (
method = "laplace"/"laplace_sla"/"nested_laplace"):max.iter,tol,damping,sigma.beta.logr.sigma.prior– Penalized-Complexity priorc(U, alpha)(P(sigma_log_r > U) = alpha) on the per-species log-dispersion SD underms_abun(mixture = "negbin" / "zinb"). DefaultNULL, pure maximum likelihood.sigma_log_ris one scalar variance over species and at few species can settle near its lower boundary, which shiftsmu_log_rand narrows its interval together (see?ms_abun); the prior adds curvature there.omega.sigma.prioris the same knob for the structural-zero variance and does default toc(1, 0.05); when both are set they must be equal, since one Penalized-Complexity prior is applied across every regularized block.re.aghq– for a formula random effect undermethod = "laplace", run the adaptive Gauss-Hermite debias of the variance components after the EM converges (defaultTRUE). Removes the Laplace small-cluster attenuation ofsigma/ the RE correlation for binary occupancy; setFALSEfor the raw EM (Laplace,nAGQ = 1) fit.n.quad– quadrature points. One name across several routes, each integrating a different marginal over a different latent dimension, so the default is per route rather than one number.n.quad = 1is always the plain Laplace (nAGQ = 1) marginal; higher values refine it toward the exact one. Formula random effect undermethod = "laplace": 9. Adaptive Gauss-Hermite over the exact per-group marginal, debiasing the Laplace small-cluster attenuation of a binary occupancy variance component. Binary data carries little information per group, so the refine wants many nodes. Community N-mixture (ms_abun()): 1, i.e. the EM default. Each species' count marginal is already informative, so the AGHQ refine barely moves the community covariances; it is opt-in viaoptimizer = "joint_fd"for the sparse / rare-species regime.n.quad.scalar(default 3, and floored there) is the trailing per-species log-dispersion / structural-zero-logit coordinate, integrated separately. Community joint occupancy-cover (ms_occu_cover()): 5. Tensor AGHQ over the joint per-species RE vector, so the node count is raised to a power of the RE dimension and stays small. Latent cover-per-unit (cover_aggregate = "latent"): 15 for a beta cover arm, 1 for lognormal, whose per-unit marginal is closed form and needs no quadrature at all. Communitylatent()factors: 5, the Gauss-Hermite nodes the joint site marginal integrates the factor scores on. Both the loading magnitude and the score-matched offset are insensitive to it (argmax stable to < 0.4% against 21 nodes).max.outer– for a community family whoselatent()factors or shared areal field are fit by block coordinate ascent, the cap on the outer alternation between the community EM and the field / factor update. A field block reachestoland stops early, so its default 25 is only a cap; a factor block does not, and each family sets its own budget from a measured bias curve (150 onms_count()/jsdm()/ms_occu(), 25 elsewhere).factor.starts– candidate starting directions the first factor pass selects over, on the joint marginal. Each costs a full loading EM against that family's oracle, so the default is per family: 1 onms_abun()(measured to buy nothing there against a 2.0-2.3x cost), 8 elsewhere. Accepted only by the families fit this way; the resolved value, andmax.outer/n.quadalongside it, is reported asfit$latent_control.re.lkj– LKJ shape (eta) regularizing a correlated random slope's correlation in there.aghqrefine (default 1.5). Pulls a weakly-identified RE correlation off the+-1boundary toward 0 without touching the marginal SDs;re.lkj = 1disables it (uniform). No effect on intercept / uncorrelated terms.sd.load– prior SD on a spatial-factor loading in the community occupancy-cover fit (default 1). The auto-rank ladder selectsKby marginal evidence under this prior, so the selection fit and the final fit necessarily read the same value.inner.solver– for a spatial community N-mixture (ms_abun()with anicar()/bym2()/car_proper()field on the abundance arm), the inner solver integrating the shared field given the community:"em"(default) the closed-form Laplace-EM M-step, or"newton"the exact- Newton shared-field solve alternated with a tulpa AGHQ community debias. Both integrate the field hyperparameter on the outer grid and return the same fit object;"newton"is Poisson- and areal-only, and markedly slower (an FD-gradient profile loop per grid node) – an accuracy / validation alternative, not the production default.integration– how the in-package spatial / community nested-Laplace fitters integrate the outer field hyperparameters (tau,rho,sigma,range):"grid"(default) a fixed tensor grid, or"ccd"a mode-centred central-composite design placed at the marginal-likelihood mode and scaled by the outer posterior covariance, with the outer PSIS Pareto-k reported onfit$spatial_pareto_k. CCD declines to the grid when the outer curvature is ill-conditioned (a weakly-identified axis) or for a single positive hyperparameter;fit$spatial_integrationrecords which ran. Each outer node is a full inner solve, so"ccd"adds a mode-find without a node saving on these coarse grids – it is opt-in, most useful when a multi-axis hyperparameter posterior is well identified.adaptive.grid.cutoff,adaptive.grid.stride,adaptive.grid.max.frac,adaptive.grid.min.cells– tuning forintegration = "grid_adaptive"on the joint-coupled spatial families (occu_cover(),cover(),occu()spatial,occu_multiscale_cover()), which the engine hosts alongside"grid"and"ccd". That integrator evaluates a strict subset of the same tensor lattice – it floods outward from the posterior mode and keeps the cells withinadaptive.grid.cutofflog-density of the peak – and declines back to the dense tensor when the kept region would rival it, so it trades inner solves for nothing but resolution of the hyperparameter tails.adaptive.grid.cutoffis the keep / expand radius (larger keeps more cells, closer to the dense tensor),adaptive.grid.stridethe coarse-seed subsample stride per axis,adaptive.grid.max.fracthe kept-fraction ceiling past which it declines, andadaptive.grid.min.cellsthe smallest dense tensor worth the machinery. Unset leaves each at the engine default. These are a DIFFERENT mechanism fromadaptive.grid/adaptive.grid.edge.thresh/adaptive.grid.max.passes, which refine an already-integrated grid; the two compose and neither implies the other. The saving is largest where a fit carries several outer axes – two spatial arms (a shared field plus an arm-specific one) put three on the grid, whichintegration = "auto"resolves to the dense tensor.diagnose.k– for the joint-coupled spatial families (occu_cover(),occu()spatial,occu_multiscale_cover()), whether to score the outer hyperparameter Gaussian summary with an importance-sampling Pareto-k. DefaultsFALSE(it re-solves the inner Laplace on the full fieldk.samplestimes). WhenTRUE, the fit carriespareto_k(< 0.7= reliable summary),pareto_k_is_ess(the importance-sampling ESS on the PSIS-smoothed weights;/ k.samplesis the relative IS efficiency), andpareto_k_proposal_sourceat the top level (and inglance()).pareto_k_proposal_sourceis"mode_hessian"when the importance proposal came from the Laplace curvature at the hyperparameter mode – curvature-backed, so the k-hat stays trustworthy even when a sharp posterior collapses the integration grid – or"grid_moment"when it came from the grid-weighted node covariance, the regime to watch as the grid concentrates. Stochastic-correction controls ("laplace_gibbs"/"laplace_mi"):n.gibbs/n.imputations(Rubin-pooled draw count) andseed(stored on$seed).
Progress controls (every method):
progresstoggles the console iteration / grid bar (defaultTRUEin an interactive session,FALSEotherwise),progress.everyandprogress.throttleset its emit cadence, andprogress.filenames a heartbeat file rewritten with"<done> <total> <elapsed_s> <eta_s>". The file is the channel that survives a detached run, where a console flush does not, and it is written whenever it is set regardless ofprogress. The environment variableTULPAOBS_PROGRESSsets the console default for the whole session:0(alsofalse/no/off) turns it off, any other value turns it on – for a batch or CI run whose caller cannot passcontrolto each individual fit; an explicitcontrol$progressstill wins.Control names are validated against the chosen
method: passing a sampler control (e.g.n.chains) to a Laplace method, a Laplace control (e.g.max.iter) to"nuts",seedto a deterministic route, or an unrecognized name raises an error rather than being silently ignored.- by
optional name of a species column for a per-species batched fit (
occu_cover()/cover()only). When supplied,datais a long, plot-level frame: one row per site-visit (occu_cover()) or per site (cover()), withbygiving the species.tobs()splitsdataby that column, builds each species' response onto one shared site x visit grid (viatobs_data()), fits the B species independently, and returns atobs_batch. Per-species fits are statistically independent (no pooling) – identical to fitting each species with a separate single-speciestobs()call; for a community model that borrows strength across species usems_occu_cover(). The long -> response pivot needs the column names: passsite =andresponse =(the detection 0/1 column foroccu_cover(), the cover column forcover()), plus, foroccu_cover(),visit =(the replicate column) andy_pos =(the cover column) and any visit-leveldet.covs =. Site-level covariates (those theformula/detectionreference at the cell level) are read as the first row per site, the waytobs_data()(occ.covs = )does.The same long-frame contract drives a SINGLE
occu_cover()fit whenbyis omitted: passsite =,visit =,response =(the 0/1 detection column) andy_pos =(the cover column), plus any visit-leveldet.covs =, with a long, plot-leveldata, andtobs()builds the paired occurrence / cover arms and the site-level design for you – the by= batch reduced to one species, so you no longer hand-build the two responses and align them. The arms default to the compact (ragged) layout on the nested-Laplace route (no per-site visit cap); setcontrol$compact = FALSEfor the dense grid. Seeoccu_cover_inputs()to build and inspect the arms without fitting.The long-frame
response =here names a COLUMN ofdata(the pivot key holding the detection / cover values). It is a separate argument from the positive-part distribution the family constructor takes (cover(response = "beta"),occu_cover(response = "lognormal")), which selects the cover-arm likelihood rather than a data column. Both may appear in one call –tobs(long, family = cover(response = "beta"), site = , response = "cover.flat", ...)– and do not interact.- ...
family-specific named arguments forwarded to the underlying engine builder.
Value
An object of class c("tobs_fit", "<family>_fit", "tulpa_fit").
When control$n.seeds > 1, a tobs_stack ensemble of the seed-offset
refits is returned instead (see tobs_stack()). When by is supplied, a
tobs_batch of per-species fits is returned (see tobs_get()).
Examples
# \donttest{
# Single-season occupancy
sim <- simulate_occu(N = 100, J = 3, n_occ_covs = 1, n_det_covs = 1,
seed = 1)
fit <- tobs(
formula = ~ occ_cov1,
data = sim$data,
family = occu(),
detection = ~ det_cov1,
y = sim$y,
method = "laplace",
control = list(verbose = FALSE, progress = FALSE)
)
summary(fit)
# }