--- title: "Profile-likelihood intervals" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Profile-likelihood intervals} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4, dpi = 96 ) ``` `freqTLS` reports **profile-likelihood confidence intervals** as its default. This vignette explains what the profile is, why its intervals can be asymmetric, how it differs from the Wald interval, and — importantly — how `freqTLS` behaves honestly when a profile does not close. These are likelihood intervals, not posteriors; the language throughout is "confidence", never "posterior" or "credible". See `vignette("model-math")` for the 4PL parameterisation that the profile follows. Before interpreting an interval, run `check_tls(fit)`; its help page maps each data-adequacy warning to a concrete design or analysis response. ```{r setup} library(freqTLS) ``` ## What the profile does For a scalar target $\psi$ (say `CTmax`): 1. Fit the maximum-likelihood estimate, obtaining $(\hat\theta, \hat\ell)$. 2. Fix the internal coordinate that maps to $\psi$ at a candidate value and re-optimise all the other coordinates, giving the profile log-likelihood $\ell_p(\psi)$. 3. Form the deviance $D(\psi) = 2\,(\hat\ell - \ell_p(\psi))$. 4. The confidence interval is the set $\{\psi : D(\psi) \le t^2_{1-\alpha/2,\,\nu}\}$, found by root-finding on each side of the MLE. At the MLE the deviance is (numerically) zero. The cutoff is the **squared Student-$t$ quantile** $t^2_{1-\alpha/2,\,\nu}$ on $\nu = n - p$ residual degrees of freedom (data rows minus free fixed-effect coordinates), not the $\chi^2_1$ quantile: this is the Bates–Watts profile-$t$ calibration, which is less optimistic at small $n$ and converges to $\chi^2_1$ as $\nu \to \infty$ (see `vignette("frequentist-and-bayesian")`). `profile()` returns the whole deviance curve plus the interval: ```{r profile-object} set.seed(1) dat <- simulate_tls(family = "beta_binomial", CTmax = 36, z = 4, phi = 50, seed = 1) fit <- fit_tls(dat, y = survived, n = total, time = duration, temp = temp, family = "beta_binomial", tref = 1) pc <- profile(fit, "CTmax") c(estimate = pc$estimate, conf.low = pc$conf.low, conf.high = pc$conf.high, cutoff = pc$cutoff, min_deviance = min(pc$deviance)) ``` The deviance minimum sits essentially at zero, at the estimate. Plotting the profile shows the deviance curve, the profile-$t$ cutoff line, and the interval where the curve dips below it: ```{r profile-plot, fig.alt = "Profile-likelihood deviance curve for CTmax: a U-shaped curve with a horizontal profile-t cutoff line; the confidence interval is where the curve lies below the cutoff."} plot(pc) ``` ## Asymmetry and equivariance A profile interval need not be symmetric about the estimate, and `freqTLS` preserves the asymmetry rather than forcing a symmetric $\pm$ band. For a parameter on a log scale, such as `z`, the profile is taken on the internal `log_z` coordinate and the endpoints are exponentiated. This makes the interval **equivariant**: the `z` interval is exactly $\exp()$ of the internal `log_z` interval. ```{r equivariance} ci_z <- confint(fit, "z", method = "profile") ci_log_z <- confint(fit, "log_z", method = "profile") # z endpoints equal exp() of the log_z endpoints rbind( z = c(ci_z$conf.low, ci_z$conf.high), exp_log_z = exp(c(ci_log_z$conf.low, ci_log_z$conf.high)) ) # the interval is asymmetric about the estimate (in general) with(ci_z, c(lower_gap = estimate - conf.low, upper_gap = conf.high - estimate)) ``` ## Profile versus Wald The Wald interval is $\hat\psi \pm z_{\alpha/2}\,\mathrm{se}$ computed on the internal (link) scale and back-transformed. It is fast and first-order, but symmetric on the link scale and blind to the curvature of the likelihood. The profile interval inverts the likelihood-ratio test directly. On well-identified data the two agree closely; they diverge when the likelihood is skewed or the estimate approaches a boundary. ```{r profile-vs-wald} rbind( profile = unlist(confint(fit, "CTmax", method = "profile")[c("conf.low", "conf.high")]), wald = unlist(confint(fit, "CTmax", method = "wald")[c("conf.low", "conf.high")]) ) ``` `tidy_parameters()` switches between the two for the whole parameter table: ```{r tidy-methods} tidy_parameters(fit, method = "profile")[, c("parameter", "estimate", "conf.low", "conf.high", "interval_type")] ``` ### The upper asymptote `up` Under the disjoint-bounds parameterisation `up` has its own coordinate `beta_up`, but `freqTLS` does not yet profile it (the profile path is wired for `low` but not `up`). `freqTLS` reports `up` with the delta-method Wald interval and labels it honestly — `confint(fit, "up", method = "profile")` returns `interval_type = "wald"` and emits an informational message rather than silently substituting a different quantity. If you need a prior-free, asymmetry-respecting interval for `up`, request the bootstrap: `confint(fit, "up", method = "bootstrap")`. ## Recovery when a profile does not close The headline value-add over the Bayesian path is that `freqTLS` tells you when the data do not identify a parameter, instead of letting a prior quietly fill the gap. When a profile does not rise above the cutoff on one side — because the data are too sparse to pin the parameter down — the default `fallback = TRUE` first attempts a parametric bootstrap. A successful bootstrap returns a prior-free percentile interval and labels its `method` as `"bootstrap"`; too few stable or non-degenerate refits still leave an unavailable interval. Set `fallback = FALSE` when you specifically need to inspect the unmodified profile geometry. In that strict mode `freqTLS`: * emits a warning that the parameter is **weakly identified** ("consider `bayesTLS` or a bootstrap"), * returns `NA` on the open side (never a fabricated bound), and * sets a `conf.status` marker (`open_lower`, `open_upper`, or `open_both`). Here is a deliberately sparse design — few temperatures and little mortality contrast — that does not identify `CTmax`. We fit it live, then show the user-facing bootstrap recovery recipe separately. A 1,000-refit bootstrap is intentionally not executed during package checks; run that displayed chunk interactively when you need the fallback interval. ```{r non-closing} set.seed(7) sparse <- simulate_tls( temps = c(35, 36), # only two temperatures times = c(1, 2), # only two durations reps = 2, n = 8, CTmax = 36, z = 4, family = "binomial", seed = 7 ) sparse_fit <- suppressWarnings( fit_tls(sparse, y = survived, n = total, time = duration, temp = temp, family = "binomial", tref = 1) ) ``` ```{r bootstrap-fallback-recipe, eval = FALSE} ci_default <- tryCatch( withCallingHandlers( confint(sparse_fit, "CTmax", method = "profile", fallback = TRUE, nboot = 1000, boot_seed = 7), warning = function(w) { message("caught warning: ", conditionMessage(w)) invokeRestart("muffleWarning") } ), error = function(e) e ) ci_default[, c("parameter", "conf.low", "conf.high", "estimate", "method", "conf.status")] ``` Now disable fallback to inspect the strict profile. The open side remains `NA`, with an `open_*` status, rather than becoming a confident but unsupported bound. ```{r strict-non-closing} ci_strict <- tryCatch( withCallingHandlers( confint(sparse_fit, "CTmax", method = "profile", fallback = FALSE), warning = function(w) { message("caught warning: ", conditionMessage(w)) invokeRestart("muffleWarning") } ), error = function(e) e ) ci_strict[, c("parameter", "conf.low", "conf.high", "estimate", "method", "conf.status")] ``` The Confidence Eye follows the same contract. With its default `fallback = TRUE`, a successful bootstrap recovery draws a bootstrap lens. For the strict diagnostic below, `fallback = FALSE` preserves the open profile and draws a **hollow point with no lens**. ```{r non-closing-eye, fig.alt = "Confidence Eye for a weakly identified fit: a hollow point estimate with no confidence lens, signalling that the profile did not close."} # fallback = FALSE so the eye refuses to draw a lens when the profile does not # close (a hollow point only), matching the honest non-closing contract above. suppressWarnings(plot_confidence_eye(sparse_fit, parm = "CTmax", method = "profile", fallback = FALSE)) ``` ## Calibration: how well do the intervals cover? A confidence interval is only as good as its coverage. The repository-only coverage study simulates 200 datasets at a known `CTmax`/`z` under each family, fits every one by ML, builds 95% profile intervals, and records the empirical (frequentist) coverage. Its summary is shipped with the package: ```{r coverage} cov_path <- system.file("extdata", "coverage_results.rds", package = "freqTLS") if (nzchar(cov_path)) { cov <- readRDS(cov_path) knitr::kable( cov$coverage, digits = 3, caption = sprintf("Empirical coverage of 95%% profile CIs (nsim = %d; nominal 0.95).", cov$meta$nsim) ) } ``` For the **binomial** model the profile intervals are well calibrated — `CTmax` coverage sits at the nominal 0.95 and every profile closed. For the **beta-binomial** model the intervals **under-cover** (`CTmax` ≈ 0.84, `z` ≈ 0.87) at this sample size: estimating the extra overdispersion parameter makes the likelihood-ratio interval optimistic, and a small fraction of profiles do not close. The honest reading is to treat beta-binomial profile intervals as approximate and, when coverage matters, calibrate them with a parametric bootstrap or use `bayesTLS`. This is exactly the regime the ship stance below warns about. Repository maintainers can reproduce the study from a source checkout with the repository-only `data-raw/coverage-study.R` script; that script is not installed with the package. ## Ship stance The profile gives fast, prior-free, asymmetry-respecting confidence intervals **when the MLE is interior and the data identify the target**. For boundary asymptotes, very sparse designs, overdispersion concentrated at zero, or weak variance components, use the interval route appropriate to the fitted model. Fixed-effect coordinates in a random-intercept fit can be profiled under the Laplace approximation; variance components use log-scale Wald intervals unless you request the random-effects-aware parametric bootstrap. `freqTLS` warns you when the requested route is weak or unavailable. It never claims the profile is universally superior to the Bayesian path. See `vignette("comparing-to-bayesTLS")` for the side-by-side comparison.