Comparing freqTLS to bayesTLS

freqTLS is the maximum-likelihood / profile-likelihood complement to bayesTLS. This vignette lays out the relationship, the credit, and a reproducible three-way comparison on the shared benchmark datasets.

This vignette builds without Stan. The live bayesTLS (Stan) calls are shown as display-only R code for reproducibility; the Bayesian and classical two-stage numbers are read from the version-stamped maintainer-built cache shipped in inst/extdata/bayesTLS_benchmark_cache.rds. The freqTLS fits run live. A source checkout and installed package therefore use the same cached comparator results without requiring Stan; the recipe below explains how a maintainer rebuilds the cache from its pinned bayesTLS source commit.

library(freqTLS)

Credit and origins

The thermal-load-sensitivity (TLS) modelling framework implemented here was introduced by Daniel W. A. Noble, Pieter A. Arnold, and Patrice Pottier in the bayesTLS package (manuscript in preparation). The 4PL thermal death-time model and the mapping from the midpoint slope to z and CTmax are theirs. freqTLS is an independent likelihood implementation of that framework; please cite bayesTLS when you use it. The bundled shrimp_lethal and zebrafish_lethal datasets are vendored from bayesTLS under CC BY 4.0 (see ?shrimp_lethal, ?zebrafish_lethal, and citation("freqTLS")).

freqTLS contributes a TMB maximum-likelihood likelihood, the direct CTmax/z reparameterisation that makes both quantities profile-able, and profile-likelihood confidence intervals.

The three-way design

The benchmark compares three estimators of the same constant-shape 4PL with a matched time unit and reference time. The two model fits use the relative midpoint threshold. The classical two-stage estimator uses absolute LT50; it is shown beside them only for lethal datasets whose fitted asymptotes are near zero and one, where the two thresholds are close. This is the fairness boundary used throughout this article:

Estimator Path Uncertainty
Classical two-stage bayesTLS::ts_stage1 -> ts_stage2 -> ts_ci delta-method CI
Bayesian bayesTLS::fit_4pl(temp_effects = "mid") -> extract_tdt(target_surv = "relative") posterior credible interval
freqTLS fit_4pl() -> tls(method = "profile") profile-likelihood confidence interval

Under this matched configuration the Bayesian and profile-likelihood fits target the same likelihood and the same fitted curve (see vignette("model-math")); they differ in how they summarise uncertainty — a posterior (with priors) versus a prior-free likelihood interval.

The reproducible recipe (not run here)

This is the exact bayesTLS recipe used to build the cache on a machine with Stan installed. It is a plain fenced code block, not an executable vignette chunk.

library(bayesTLS)
data(shrimp_lethal, package = "freqTLS")

# 1. Standardise: name the temperature / duration / count columns and the time
#    unit. fit_4pl() and the two-stage path both consume the standardised frame.
std <- bayesTLS::standardize_data(
  data          = shrimp_lethal,
  temp          = "Temperature_assay",
  duration      = "Duration_exposure_hours",
  n_total       = "N_individuals_after_trial",
  mortality     = "Mortality_after_trial",
  duration_unit = "hours"
)

# 2. Bayesian fit, matched: constant shape via temp_effects = "mid", beta-binomial.
bfit <- bayesTLS::fit_4pl(
  data         = std,
  temp_effects = "mid",
  family       = brms::brmsfamily("beta_binomial", link = "identity"),
  chains       = 4, iter = 4000, seed = 123, backend = "cmdstanr"
)

# 3. Relative-threshold CTmax + z at tref = 1 hour. NOTE: t_ref / time_multiplier
#    live on extract_tdt() (and ts_stage2 / ts_ci), NOT on fit_4pl().
btdt  <- bayesTLS::extract_tdt(
  bfit, target_surv = "relative",
  t_ref = 1, time_multiplier = 1, output_time_unit = "hours"
)
ctmax <- bayesTLS::get_ctmax_summary(btdt)  # temp_lower / temp_median / temp_upper
zsumm <- bayesTLS::get_z_summary(btdt)      # z_median / z_lower / z_upper

# 4. Classical two-stage, same standardised data and reference time.
s1  <- bayesTLS::ts_stage1(std, family = "betabinomial")
s2  <- bayesTLS::ts_stage2(s1, t_ref = 1, time_multiplier = 1)
tci <- bayesTLS::ts_ci(s2, method = "delta", level = 0.95,
                       t_ref = 1, time_multiplier = 1)  # $CTmax_1hr and $z blocks

To populate the cache, a maintainer works from a clone of the source repository and runs the repository-only script data-raw/build_benchmark_cache.R on a Stan machine, with bayesTLS installed from a pinned checkout and BAYESTLS_GIT_SHA set to its verified 40-character commit when the installed package lacks RemoteSha metadata. The builder refuses to write a cache if it cannot record that exact source commit. The script is not installed with the package; it writes inst/extdata/bayesTLS_benchmark_cache.rds (the Bayesian and two-stage summaries plus a meta provenance block: bayesTLS_version, git_sha, source_url, cmdstan_version, date_built, seed, the configuration, and the data-reconstruction note).

The freqTLS side (live)

The freqTLS fits run live and need nothing beyond this package. We use the shrimp_lethal data (ungrouped) and zebrafish_lethal (grouped by life stage). The shrimp survival counts are reconstructed from the source CSV proportions (see ?shrimp_lethal); because all three estimators share the vendored data, they inherit the same reconstructed counts.

data(shrimp_lethal)
shrimp_std <- standardize_data(
  shrimp_lethal,
  temp = "Temperature_assay", duration = "Duration_exposure_hours",
  n_total = "N_individuals_after_trial", mortality = "Mortality_after_trial",
  duration_unit = "hours"
)
shrimp_fit <- fit_4pl(shrimp_std, t_ref = 1, family = "beta_binomial", quiet = TRUE)
tls(shrimp_fit, method = "profile")$summary
#> # A tibble: 2 × 4
#>   quantity median lower upper
#>   <chr>     <dbl> <dbl> <dbl>
#> 1 CTmax     31.8  31.6  31.9 
#> 2 z          2.19  1.96  2.46

fit_4pl() returns a freq_tls workflow object (the twin of bayesTLS’s bayes_tls): it bundles the engine fit, the standardised data, and the formula. Its S3 methods — tls(), confint(), summary(), plot_confidence_eye() — delegate to the engine fit, so you call them on the workflow object directly. The underlying engine fit is also available as shrimp_fit$fit if you want to reach it.

data(zebrafish_lethal)
zebra_std <- standardize_data(
  zebrafish_lethal,
  temp = "assay_temp", duration = "duration_h",
  n_total = "n_total", n_surv = "n_surv", duration_unit = "hours"
)
zebra_fit <- suppressWarnings(fit_4pl(zebra_std, by = "life_stage",
                                      t_ref = 1, family = "beta_binomial", quiet = TRUE))
# per-stage CTmax and z with profile intervals
tls(zebra_fit, method = "profile")$summary
#> # A tibble: 6 × 5
#>   life_stage    quantity median lower upper
#>   <chr>         <chr>     <dbl> <dbl> <dbl>
#> 1 young_embryos CTmax     39.9  39.8  40.0 
#> 2 old_embryos   CTmax     41.4  41.2  41.6 
#> 3 larvae        CTmax     39.8  39.7  39.9 
#> 4 young_embryos z          2.00  1.82  2.19
#> 5 old_embryos   z          1.80  1.53  2.16
#> 6 larvae        z          1.98  1.76  2.22

(The zebrafish fit emits a data-adequacy warning about temperatures with fewer than three durations; this is the kind of identifiability signal freqTLS surfaces explicitly. It is suppressed here only to keep the vignette output tidy.)

The three-way comparison, with real numbers

The cache holds the maintainer-built bayesTLS (posterior) and classical two-stage summaries; the freqTLS column is computed live as this page renders. The two model fits use the matched beta-binomial, relative-threshold, constant-shape configuration at tref = 1 hour, so they target the same fitted curve. The classical column uses absolute LT50 and is the approximate comparator described above.

Shrimp CTmax and z shown side by side: the two model fits use the relative midpoint; the classical estimator uses absolute LT50.
Quantity Two-stage (delta CI) bayesTLS (95% CrI) freqTLS (profile CI)
CTmax (°C) 31.62 [31.34, 31.89] 31.72 [31.60, 31.85] 31.77 [31.63, 31.92]
z (°C / decade) 2.04 [1.49, 2.60] 2.17 [1.95, 2.43] 2.19 [1.96, 2.46]
Version-stamped benchmark-cache provenance.
Field Value
bayesTLS version 1.0.0
bayesTLS source commit 578740f20f3a2e6e81b3b700b1d0f0e5a06ecf8a
CmdStan version 2.36.0
Cache build date 2026-07-11
Seed 123
Model threshold relative
Shape design mid

For the shrimp data the three estimators land on essentially the same CTmax and z, with comparable interval widths: the profile-likelihood confidence interval and the Bayesian credible interval nearly coincide. That is the point of the complementary framing — under the matched configuration the likelihood and the posterior summarise the same fitted curve, one with a prior and MCMC, the other prior-free and by optimisation.

How fast, accurate, and calibrated

Accuracy and calibration below are descriptive characteristics of the likelihood path, measured by simulation (the repository-only maintainer script data-raw/performance-study.R, which is not installed with the package) — they ask whether freqTLS’s own intervals are trustworthy, not whether they beat bayesTLS (which buys priors, full posteriors, and the heat-injury sub-models). Speed, though, is one place a head-to-head is both fair and stark.

How fast — a full fit, and one CTmax profile interval, by design size:

Median wall-clock (ms): one fit, and one CTmax profile CI.
family design n_obs median_fit_ms median_profile_ms
binomial tiny 24 6 85
binomial small 105 10 206
binomial medium 252 15 331
binomial large 440 28 604
beta_binomial tiny 24 15 339
beta_binomial small 105 28 391
beta_binomial medium 252 45 1032
beta_binomial large 440 87 1898

Fits are milliseconds and a profile interval is well under a second. Putting the three estimators side by side on the shrimp benchmark makes the speed gap concrete:

Wall-clock on the shrimp benchmark. freqTLS is timed live as this page renders; the two-stage and bayesTLS times are cached from the repository-only maintainer script data-raw/timing-study.R (not installed with the package). The bayesTLS time is post-compile sampling plus overhead, and a first fit also pays a one-time Stan compilation.
Estimator Task Wall-clock Source
freqTLS fit (ML) 19 ms live
freqTLS fit + Wald CTmax & z 21 ms live
freqTLS fit + profile CTmax & z 541 ms live
classical two-stage fit + delta CI 1.4 s cached
bayesTLS fit (4 chains x 4000 MCMC) 5.0 s cached

freqTLS’s profile path runs in about a second — comparable to the classical two-stage and roughly an order of magnitude faster than the Bayesian MCMC fit — while its Wald path is near-instant. That speed is the likelihood path’s concrete advantage, and it is why freqTLS keeps profile the default while offering Wald as a fast opt-in for well-identified fits. The whole three-way table above is itself computed live as this page renders, with no Stan.

How accurate — bias and RMSE for CTmax and z:

Near-unbiased recovery (nsim = 300).
family truth_setting parameter bias rmse n_converged
binomial easy CTmax 0.0021 0.1003 300
binomial easy z 0.0103 0.1838 300
binomial harder CTmax 0.0068 0.0899 300
binomial harder z -0.0019 0.1571 300
beta_binomial easy CTmax -0.0015 0.1296 298
beta_binomial easy z 0.0017 0.2284 298
beta_binomial harder CTmax 0.0026 0.1026 283
beta_binomial harder z 0.0027 0.1994 283

How calibrated — empirical coverage of the 95% intervals:

Profile and Wald 95% interval coverage (nsim = 300).
family method parameter coverage median_width nominal
binomial profile CTmax 0.947 0.405 0.95
binomial profile z 0.953 0.734 0.95
binomial wald CTmax 0.947 0.405 0.95
binomial wald z 0.957 0.734 0.95
beta_binomial profile CTmax 0.883 0.470 0.95
beta_binomial profile z 0.887 0.851 0.95
beta_binomial wald CTmax 0.927 0.458 0.95
beta_binomial wald z 0.920 0.837 0.95

Profile coverage tracks the nominal 95% closely; where it dips (small samples, beta-binomial z), freqTLS reports it honestly rather than hiding it.

Why the beta-binomial profile can dip — and what to use instead

The dip has a specific, diagnosable cause: the dispersion parameter phi. When overdispersion is mild — phi large, the data approaching the binomial limit — phi becomes weakly identified, and its estimate runs away (its relative standard error blows up). The profile interval for CTmax / z profiles that runaway phi out at each grid point, snaps to the binomial limit, and goes too narrow. The Wald interval propagates the flat-phi uncertainty through the joint Hessian and stays calibrated; the parametric bootstrap is a middle ground. (This is not a clamping artefact — the likelihood’s numerical floors never activate in this regime; see the repository-only maintainer script data-raw/beta-binomial-phi-study.R, which is not installed with the package.)

Empirical 95% coverage of z by method as overdispersion weakens (phi up = nearer binomial, phi weakly identified; 120 sims/cell). The profile collapses; Wald holds; bootstrap is intermediate.
phi (overdispersion) profile Wald bootstrap
5 (strong) 0.925 0.925 0.925
50 (mild) 0.924 0.941 0.941
200 (very mild) 0.685 0.935 0.907

Accordingly, fit_tls() emits an advisory when phi’s relative SE is large, and confint() (with the fallback = TRUE default) routes those coordinates to Wald automatically — so the default path stays calibrated without you having to intervene. More broadly, Wald (built on the internal link scale and back-transformed) is fast and, in these simulations, as well-calibrated as the profile for fixed effects — a perfectly good choice for routine work. The profile remains the default because it respects asymmetry without a normal approximation and now degrades to Wald or the bootstrap exactly where it would be unreliable.

You can see when the automatic routing fires: the method column of confint() reads wald for the rerouted coordinate even if you asked for profile, and an informational message is emitted. To get an asymmetry-respecting interval for that coordinate specifically, request the bootstrap — for example confint(fit, "phi", method = "bootstrap").

When to use each method:

Method Use when
profile (default) the headline choice — respects asymmetry, no normal approximation, and degrades honestly when a coordinate is weakly identified.
wald fast routine work; as well-calibrated as the profile for fixed effects in these simulations; symmetric on the link scale.
bootstrap a prior-free fallback that can provide an asymmetry-respecting interval for up, a weak phi, or another weakly identified coordinate; failed or degenerate replicates can still leave an open or unavailable interval.

Bootstrap fallback when a profile stays open

When a profile does not close — for example under a weakly identified design or a boundary asymptote — confint() can fall back to a prior-free parametric bootstrap. The fallback often yields an asymmetry-respecting interval, but it does not manufacture certainty: too few valid or non-degenerate bootstrap fits can still produce an unavailable bound. Set fallback = FALSE to retain the open profile explicitly.

# A deliberately sparse design where the CTmax profile does not close.
sparse <- simulate_tls(family = "binomial", temps = c(35, 36), times = c(1, 2),
                       reps = 2, n = 10, CTmax = 36, z = 4, seed = 9)
sfit_sparse <- suppressWarnings(fit_tls(sparse, y = survived, n = total,
                                        time = duration, temp = temp,
                                        family = "binomial", tref = 1))
# Strict profile: NA on an open side. Default: a parametric bootstrap interval.
strict <- suppressWarnings(
  confint(sfit_sparse, "CTmax", method = "profile", fallback = FALSE))
boot <- suppressWarnings(
  confint(sfit_sparse, "CTmax", method = "profile", nboot = 1000, boot_seed = 1))
data.frame(
  setting   = c("strict profile (fallback = FALSE)", "default (bootstrap fallback)"),
  conf.low  = c(strict$conf.low,  boot$conf.low),
  conf.high = c(strict$conf.high, boot$conf.high),
  method    = c(strict$method,    boot$method)
)

When enough bootstrap refits remain stable and non-degenerate, freqTLS can provide a prior-free interval after an open profile. Sparse or boundary designs can still leave a bound unavailable; the status column reports that outcome. This 1,000-refit recipe is displayed rather than executed during package checks; run it interactively for the full comparison. The live strict-profile example and fallback recipe are also explained in vignette("profile-likelihood").

The teaching device: posterior density versus Confidence Eye

The clearest way to see the Bayesian-versus-likelihood distinction is to draw, for the same CTmax (or z):

plot_confidence_eye(shrimp_fit, parm = c("CTmax", "z"), method = "profile")

Confidence Eye for the shrimp CTmax and z: pale confidence lenses with hollow point estimates, the freqTLS uncertainty display.

The Confidence Eye above is the freqTLS half of that contrast. When the cache (and bayesTLS) are available, the posterior density for the same quantity can be drawn beside it; the side-by-side makes explicit that one is a posterior and the other is a likelihood confidence interval. freqTLS deliberately never renders a posterior-style density for its own intervals, and its prose uses “confidence” language, never “posterior” or “credible”.

Beyond the matched shape: stage-specific curves

The three-way comparison holds the shape constant (low, up, k shared across temperatures and groups) so all three estimators target the same curve — that is what makes the benchmark fair. freqTLS can also relax that restriction: the shape parameters low, up, and log_k may vary by a grouping factor. Re-fitting zebrafish_lethal with stage-specific shapes and comparing by AIC asks whether the life stages differ in more than thermal location (CTmax, z):

stage_shape <- suppressWarnings(fit_4pl(
  zebra_std, by = "life_stage",
  low = ~ life_stage, up = ~ life_stage, k = ~ life_stage,
  t_ref = 1, family = "beta_binomial", quiet = TRUE
))
# zebra_fit (above) is the shared-shape fit; stage_shape lets low / up / k vary.
c(shared_shape_AIC = round(AIC(zebra_fit), 1),
  stage_shape_AIC  = round(AIC(stage_shape), 1))
#> shared_shape_AIC  stage_shape_AIC 
#>           1222.5           1187.9

The stage-specific model has the substantially lower AIC, so the data support per-stage shapes. The difference is concentrated in the upper asymptote up (the maximum survival at benign exposures), not the steepness k:

stage_est <- stage_shape$fit$estimates
stage_est[grepl("^up:", stage_est$parameter), c("parameter", "estimate", "std.error")]
#>          parameter  estimate  std.error
#> 4 up:young_embryos 0.7177178 0.03159488
#> 5   up:old_embryos 0.9180763 0.01502271
#> 6        up:larvae 0.9393117 0.02523538

Young embryos have a markedly lower survival ceiling (up near 0.7) than older embryos and larvae (near 0.9) — a real biological difference that the matched constant-shape configuration used for the freqTLS and bayesTLS model-based benchmark cannot express. The classical two-stage workflow is a separate absolute-LT50 approximation and likewise does not model a stage-specific 4PL shape.

freqTLS also reads absolute critical temperatures and predicts heat injury off the same fitted curve. For the (ungrouped) shrimp fit, the absolute critical temperature for 50% survival at one hour, and the survival predicted under a four-hour exposure at 32 °C:

c(
  CTmax_50pct_1h = round(derive_ctmax(shrimp_fit$fit, surv = 0.5, duration = 1), 2),
  surv_32C_4h    = round(tail(predict_heat_injury(shrimp_fit$fit,
                     data.frame(time = seq(0, 4, by = 0.1), temp = 32))$survival, 1), 3)
)
#> CTmax_50pct_1h    surv_32C_4h 
#>         31.730          0.018

(derive_tcrit() similarly returns a rate-multiplier T_crit, with an explicit lethal-endpoint caveat.) These are deterministic transforms of the fitted CTmax / z, not new fits.

When to prefer which

The two are complementary lenses on the same model. freqTLS flags weak identifiability explicitly and never claims the profile is universally superior; see vignette("profile-likelihood") for the non-closing behaviour and the bootstrap fallback.