Bootstrap model building for the HVTI CORR group - the R port of the SAS
%bootreg / %SUMBOOT / %cluster macros.
Fit a model on each of many bootstrap replicates, record which variables survive selection, and report how often each appeared.
Status: both branches shipped, three extensions deferred. The selection
branch is complete — boot_select(), boot_summary(), boot_clusters() and
the three fitters fit_logistic(), fit_linear() and fit_cox(), with the
pooling and reporting layers around them. The interval branch is
boot_predict_ci(), the port of %BNMNR and %BNPREV. What is deferred, and
why this is still 0.x: the hazard fitter, the quantile fitter
(#16) and penalised
selection, each held back for a design pass rather than improvised.
Know the parity scope before comparing against SAS. boot_summary() and
boot_clusters() are specified to match %SUMBOOT and %cluster exactly, and
the interval arithmetic is checked against PROC STDIZE PCTLDEF=1 by a fixture
test. Resampling and model fitting are not parity-tested and cannot be — the
two languages draw different samples.
Destination for 31 macro-library files, assigned by the allocation map in
hvtiRtemplates:specs/2026-08-14-macro-allocation-design.md. Design and scope:
docs/specs/2026-08-14-hvtirbootstrap-design.md.
hvtiRbootstrap is part of the HVTI R family. Install it with the rest of the family through hvtiR:
# install.packages("pak")
pak::pak("ehrlinger/hvtiR")
hvtiR::install()
hvtiR::install() installs every member in one call, so the versions the
members require of one another resolve together. Update later with
hvtiR::update(), from a fresh R session: it will not overwrite a package
that is already loaded.
To install hvtiRbootstrap on its own:
# pak::pak("ehrlinger/hvtiRbootstrap")
install.packages("hvtiRbootstrap", repos = c('https://ehrlinger.r-universe.dev', 'https://cloud.r-project.org'))
Three macros, three functions. To run a screen, that is all there is to learn.
| SAS | R | Notes |
|---|---|---|
%bootreg |
boot_select() |
PROC= becomes the fitter argument |
%SUMBOOT |
boot_summary() |
parity-tested against the macro |
%cluster |
boot_clusters() |
parity-tested against the macro |
The %bootreg arguments you know carry the same meaning:
| SAS | R |
|---|---|
RESAMPL= |
n_rep - still counts valid models |
FRACTION= |
fraction - but see D1 below |
PROC=LOGISTIC | REG | PHREG |
fit_logistic | fit_linear | fit_cox
|
SELECT= / FIXED=1
|
select = "stepwise" / select = "none"
|
SLE= / SLS=
|
sle / sls - but see D2 below |
MAXSTEP= |
max_steps |
SEED= |
seed |
Note that PROC=PHREG maps to fit_cox(), which is Cox proportional hazards.
The additive hazard family (bootstrap.hazard*.sas) is a different thing
and is not in v1 at all - it needs TemporalHazard and has its own spec. When
it lands, the per-analysis copies (_jr, _p, 101703, jr1) will not become
separate functions: they differ from bootstrap.hazard.sas by a changed default
and a dropped selection option, so they collapse into arguments you pass.
_tvc is the exception, carrying genuine time-varying-covariate behaviour.
Bootstrapping here does one of two jobs, and they are not the same job.
Selection - %bootreg, %SUMBOOT, %cluster - refits on every replicate
and counts which terms survived. The replicates are a vote, and a term the
model did not select is NA. That is the whole of what this package ships
today: boot_select(), boot_summary(), boot_clusters(), the fitters, and
the pooling and reporting layers around them.
Intervals - %BNMNR, %BNPREV, bl_ord.* - resample to put a band around
an estimate. Nothing is selected, so there is no NA semantics; the replicates
are a distribution. That branch ships today as boot_predict_ci().
No function here takes a confidence level. The macros do not either: they
hardcode PCTLPTS=2.5 16 50 84 97.5 and return both the 95% and the 68% band
in columns named for their coverage, so there is no level to mislabel.
library(hvtiRbootstrap)
set.seed(1)
n <- 300
x1 <- rnorm(n)
df <- data.frame(y = 2 * x1 + rnorm(n), x1 = x1,
x2 = rnorm(n), noise = rnorm(n))
fit <- boot_select(df, y ~ x1 + x2 + noise, fit_linear,
n_rep = 200, seed = 42)
fit
#> <boot_selection>
#> replicates: 200 valid of 200 attempts
#> terms: 4
#> Use boot_summary() for per-variable selection frequencies.
boot_summary(fit)
#> variable n pct mean sd min max
#> 1 (Intercept) 200 100.0 -0.008863727 0.06139726 -0.15297676 0.1901373
#> 2 x1 200 100.0 2.026035162 0.06718477 1.88008851 2.2223719
#> 3 x2 57 28.5 0.094760094 0.04738065 -0.09018219 0.1573203
#> 4 noise 39 19.5 0.106355941 0.05397506 -0.10429045 0.1834056
boot_clusters(fit, list(size = c("x1", "x2")))
#> cluster n_any pct_any members
#> 1 size 200 100 x1, x2
x1 really drives y here, and it survives every replicate. x2 and noise
carry no signal by construction, so the 20-30% they show is stepwise keeping a
useless term by chance - a reminder that a non-zero pct is not on its own
evidence of anything.
n counts the replicates in which a term was selected, so pct reads as a
selection frequency. boot_clusters() is not the sum of its members' counts: a
replicate selecting two members counts once, which is why size reads 100 and
not 128.5.
Two layers have grown around that core. Neither has a macro behind it, because a SAS batch job never needed one.
Pooling, for a screen too long to run in one go. A bootstrap that writes nothing until its last replicate is unrestartable, and a real screen can be days of compute — a run that dies at 90% yields nothing.
| function | does |
|---|---|
boot_bag() |
assembles one completed selection run with its provenance, replicate table and summary |
boot_pool_chunks() |
folds chunk files into one object of the same shape, refusing chunks that disagree on the data or the screen |
boot_chunk_files() |
finds them, in a deterministic order |
boot_shortfall() |
whether what you pooled is the run you launched — which nothing inside a chunk can know |
Reporting, for turning a finished screen into tables. Each takes an optional
phase, so a multiphase hazard screen and a single-phase logistic one run down
the same code path.
| function | does |
|---|---|
boot_validate() |
checks the screen has the shape a report reads — shapes, not just presence |
boot_provenance(), boot_seeds()
|
where the screen came from, and every seed |
boot_frequencies(), boot_dropped()
|
the per-term view with its Monte-Carlo error, and the candidates never screened |
boot_concepts() |
groups competing forms of one thing, counting a replicate that took two once |
boot_health() |
whether the screen ran at all — not the same question as whether it finished |
These are computation only. Nothing here renders: no kable(), no ggplot2, no
figure. A report decides what to say; this package decides what is true.
| Component | Standard |
|---|---|
boot_summary() |
Exact. Same replicate table in, same n, pct, mean, std, min, max out. |
boot_clusters() |
Exact. Same appeared / at-least-one counts. |
| Resampling | Not parity-tested. Stochastic; matching SAS's RNG stream is a research project, not a port. |
| Model fitting | Not parity-tested. That belongs to glm, lm and coxph. |
The parity fixtures are synthetic and checked in, so nothing here needs cohort data and no PHI enters this repo.
Under development, and at 0.9.4 rather than 1.0.0 deliberately. The selection core, chunk pooling and the reporting layer are all in place, with logistic, linear and Cox fitters.
The hazard and quantile fitters, and penalised selection are each deferred to
their own spec — and the hazard fitter is why this is not 1.0.0. Its
_CP_*evnt and _tvc variants encode
competing-risks and time-varying-covariate structures that deserve reading
before an API is fixed, and a 1.0.0 that then grew a whole new fitter family
would be making a promise it had not earned.
This port is correct first, faithful second: where the macro's behaviour and its documented intent disagree, it implements the intent and says so here.
D1 - fraction is applied. %bootreg documents FRACTION= but never uses
it: it computes ds_size * fraction, prints it, and always draws ds_size rows.
boot_select() draws round(n * fraction). Pass fraction = 1 (the default) to
match SAS exactly. A filed result run with FRACTION other than 1.0 was not
subsampled, so R will disagree with it.
D2 - stepwise now selects on p-values, applied per family. SAS
SELECTION=STEPWISE uses SLE=/SLS= as p-value thresholds; sle and sls
now mean exactly that, tested by boot_select()'s own p-value stepwise
(R/stepwise.R) rather than by stats::step(), which selected on AIC and
never applied them. The test is pinned per family to match the PROC= it
ports: fit_linear() tests partial F to enter and to remove, matching
PROC REG; fit_logistic() enters on the score chi-square and removes on
Wald, matching PROC LOGISTIC. fit_cox()'s own criteria are D4, below.
D3 - the retry loop is capped. %bootreg resamples until it has RESAMPL
valid models and never gives up, so a model that fails on every replicate spins
forever. boot_select() budgets max_attempts = 10 * n_rep and errors with a
diagnostic when it runs out. Pass max_attempts = Inf for the macro's
behaviour.
D4 - fit_cox() enters on the likelihood ratio, not the score test.
PROC PHREG SELECTION=STEPWISE enters a term on the score chi-square. R has
no score test for a Cox model - anova.coxph() accepts test = "Rao" but
silently ignores it and always returns the likelihood-ratio test - so entry
in fit_cox() is by likelihood ratio instead. The two agree asymptotically
and differ only for a term sitting on the entry threshold, so a screen will
usually select the same set and may occasionally differ on a borderline
candidate. Removal is Wald, matching the macro.
D5 - the two removal tests can disagree with each other on model
hierarchy. fit_linear()'s removal test (drop1()) respects marginality -
a main effect under an interaction is not removable. The Wald removal test
fit_logistic() and fit_cox() use tests every term independently, so
boot_select() can return a model with an interaction but neither of its
main effects; the forward step can likewise admit an interaction before
either main effect enters, since candidates are flat term labels rather than
a hierarchy. PROC LOGISTIC defaults to HIERARCHY=SINGLE and would not do
this. Realistic %bootreg candidate pools are main effects, so the case is
narrow, but it is not implemented and the two paths do not currently agree
with each other.
A warning is not a failed fit. %bootreg gates on ®rc, a return code that
SAS warnings do not set, and the fitters match that: a converged model that
merely warned - "fitted probabilities numerically 0 or 1" on a quasi-separated
replicate, say - is kept. Errors and non-convergence still cause a redraw.
vignette("hvtiRbootstrap"), runs one small simulated screen from boot_select() through the reporting layer to prediction intervals, naming the macro each step ports.