The structured view: programmatic access to regression tables
Source:vignettes/as-structured.Rmd
as-structured.Rmdtable_regression() returns a display
representation: a character table with (ref.) annotations,
en-dashes, bracketed [LL, UL] intervals, and APA-formatted
p-values. That is the right thing to print and the right thing to
publish – and the wrong thing to compute on. The companion accessor
as_structured() returns the typed view underneath:
a fully numeric body, confidence intervals pre-split into their own
columns, NA where a cell is structurally empty, plus the
metadata a renderer needs to reconstruct the display. It is the same
contract spicy’s own output engines (Excel, gt, tinytable, flextable,
clipboard) consume internally – nothing about it is second-class.
Two sibling tools serve different needs, and choosing well saves
work. broom::tidy() returns the statistical long
form – one row per coefficient with estimate,
std.error, conf.low, p.value –
and is the right input for meta-analysis, further computation on
estimates, or ggplot. as_structured() returns the
display geometry – rows and columns exactly as the rendered
table lays them out, factor headers and fit statistics included – and is
the right input for filtering what a reader will see, post-processing
numbers in table shape, or rendering the table with an engine spicy does
not ship. This vignette covers the second tool. The shared mechanics of
table_regression() are in Publication-ready regression
tables; the class-by-class map is Supported
models.
The schema on one model
fit <- lm(wellbeing_score ~ age + sex + smoking, data = sochealth)
tbl <- table_regression(fit)
s <- as_structured(tbl)
names(s)
#> [1] "body" "reference_rows"
#> [3] "reference_models_by_row" "outcome_labels_by_col"
#> [5] "factor_header_rows" "fit_stat_rows"
#> [7] "level_rows" "outcome_row"
#> [9] "col_meta" "spanners"
#> [11] "ci_pairs" "format_spec"The centrepiece is body: a data frame with the
Variable column and one numeric column per displayed
statistic. The confidence interval that prints as one bracketed column
arrives split into LL / UL:
s$body
#> Variable B SE 95% CI: LL 95% CI: UL
#> 1 (Intercept) 65.20085505 1.65670747 61.95040841 68.4513017
#> 2 age 0.04649213 0.03069709 -0.01373531 0.1067196
#> 3 sex: NA NA NA NA
#> 4 Female (ref.) NA NA NA NA
#> 5 Male 3.85579323 0.90528970 2.07962217 5.6319643
#> 6 smoking: NA NA NA NA
#> 7 No (ref.) NA NA NA NA
#> 8 Yes -1.71871310 1.10751281 -3.89164426 0.4542181
#> 9 n 1175.00000000 NA NA NA
#> 10 R² 0.01901139 NA NA NA
#> 11 Adj.R² 0.01649818 NA NA NA
#> p
#> 1 1.591088e-216
#> 2 1.301575e-01
#> 3 NA
#> 4 NA
#> 5 2.216170e-05
#> 6 NA
#> 7 NA
#> 8 1.209641e-01
#> 9 NA
#> 10 NA
#> 11 NARead it against the printed table and the structure becomes obvious:
factor header rows (sex:, smoking:) and
reference rows carry NA across the statistics –
structurally empty, not missing data – and the fit-statistics rows
(n, R², …) put their value in the first
numeric column. The row-role indices name each of these regions so you
never have to guess from the text:
s$reference_rows
#> [1] 4 7
s$factor_header_rows
#> [1] 3 6
s$fit_stat_rows
#> [1] 9 10 11
s$level_rows
#> [1] 4 5 7 8col_meta describes each column: which token produced it,
which model it belongs to, its display precision, and – for p-value
columns – the APA style and below-threshold rule.
format_spec carries the global defaults (decimal mark,
digits, CI level):
s$col_meta$B
#> $token
#> [1] "b"
#>
#> $model_id
#> [1] "M1"
#>
#> $source_field
#> [1] "estimate"
#>
#> $precision
#> [1] 2
#>
#> $p_style
#> NULL
#>
#> $threshold
#> NULL
#>
#> $signif
#> NULL
#>
#> $ci_role
#> NULL
#>
#> $ci_pair
#> NULL
#>
#> $ci_label
#> NULL
#>
#> $is_df
#> [1] FALSE
#>
#> $display_label
#> [1] "B"
#>
#> $fit_stat_overrides
#> $fit_stat_overrides[[1]]
#> $fit_stat_overrides[[1]]$fit_stat
#> [1] "nobs"
#>
#> $fit_stat_overrides[[1]]$precision
#> [1] 0
#>
#> $fit_stat_overrides[[1]]$p_style
#> NULL
#>
#> $fit_stat_overrides[[1]]$threshold
#> NULL
#>
#> $fit_stat_overrides[[1]]$row
#> [1] 9
#>
#>
#> $fit_stat_overrides[[2]]
#> $fit_stat_overrides[[2]]$fit_stat
#> [1] "r2"
#>
#> $fit_stat_overrides[[2]]$precision
#> [1] 2
#>
#> $fit_stat_overrides[[2]]$p_style
#> NULL
#>
#> $fit_stat_overrides[[2]]$threshold
#> NULL
#>
#> $fit_stat_overrides[[2]]$row
#> [1] 10
#>
#>
#> $fit_stat_overrides[[3]]
#> $fit_stat_overrides[[3]]$fit_stat
#> [1] "adj_r2"
#>
#> $fit_stat_overrides[[3]]$precision
#> [1] 2
#>
#> $fit_stat_overrides[[3]]$p_style
#> NULL
#>
#> $fit_stat_overrides[[3]]$threshold
#> NULL
#>
#> $fit_stat_overrides[[3]]$row
#> [1] 11
s$format_spec$ci_level
#> [1] 0.95Filtering and aggregating
Because the body is numeric, ordinary data-frame idioms work. Keep the coefficient rows a reader would call significant:
s$body[which(s$body$p < 0.05), c("Variable", "B", "p")]
#> Variable B p
#> 1 (Intercept) 65.200855 1.591088e-216
#> 5 Male 3.855793 2.216170e-05Note the which(): the structurally empty rows are
NA, and which() drops them where a bare
logical index would keep them as NA rows. To restrict
attention to coefficient rows before any filter, exclude the structural
regions:
structural <- c(s$reference_rows, s$factor_header_rows, s$fit_stat_rows)
coef_rows <- setdiff(seq_len(nrow(s$body)), structural)
s$body[coef_rows, c("Variable", "B", "95% CI: LL", "95% CI: UL")]
#> Variable B 95% CI: LL 95% CI: UL
#> 1 (Intercept) 65.20085505 61.95040841 68.4513017
#> 2 age 0.04649213 -0.01373531 0.1067196
#> 5 Male 3.85579323 2.07962217 5.6319643
#> 8 Yes -1.71871310 -3.89164426 0.4542181Aggregation is just as direct – the mean absolute coefficient, the range of CI widths:
Multi-model tables: spanners and per-model columns
With several models, columns are prefixed by the model label and two
more components become useful. spanners maps each model
label to its column indices in body;
reference_models_by_row records, for each reference row,
which models actually contain the factor – the rule renderers use to
decide where the reference en-dash belongs:
fit2 <- lm(wellbeing_score ~ age + sex + smoking + bmi, data = sochealth)
s2 <- as_structured(table_regression(list(Base = fit, Extended = fit2)))
s2$spanners
#> $Base
#> [1] 2 3 4
#>
#> $Extended
#> [1] 5 6 7
names(s2$body)
#> [1] "Variable" "Base: B" "Base: SE" "Base: p" "Extended: B"
#> [6] "Extended: SE" "Extended: p"Model-wise extraction follows from the spanner map:
s2$body[, c(1, s2$spanners$Extended)]
#> Variable Extended: B Extended: SE Extended: p
#> 1 (Intercept) 80.57147725 3.36672527 3.650589e-103
#> 2 age 0.07246631 0.03080401 1.881456e-02
#> 3 sex: NA NA NA
#> 4 Female (ref.) NA NA NA
#> 5 Male 4.20911545 0.90218288 3.437611e-06
#> 6 smoking: NA NA NA
#> 7 No (ref.) NA NA NA
#> 8 Yes -1.71463460 1.09832504 1.187649e-01
#> 9 bmi -0.64943694 0.12243755 1.354657e-07
#> 10 n 1163.00000000 NA NA
#> 11 R² 0.04260253 NA NA
#> 12 Adj.R² 0.03929546 NA NABuilding your own renderer
The structured view carries everything a renderer needs. A compact
knitr::kable() rendering, using the display precision from
col_meta and marking the structural rows, takes a few
lines:
render_kable <- function(tbl) {
s <- as_structured(tbl)
out <- s$body
for (nm in names(out)[-1]) {
prec <- s$col_meta[[nm]]$precision
if (is.null(prec)) prec <- s$format_spec$digits
out[[nm]] <- ifelse(
is.na(out[[nm]]),
"",
formatC(out[[nm]], format = "f", digits = prec)
)
}
knitr::kable(out, align = c("l", rep("r", ncol(out) - 1)))
}
render_kable(tbl)| Variable | B | SE | 95% CI: LL | 95% CI: UL | p |
|---|---|---|---|---|---|
| (Intercept) | 65.20 | 1.66 | 61.95 | 68.45 | 0.000 |
| age | 0.05 | 0.03 | -0.01 | 0.11 | 0.130 |
| sex: | |||||
| Female (ref.) | |||||
| Male | 3.86 | 0.91 | 2.08 | 5.63 | 0.000 |
| smoking: | |||||
| No (ref.) | |||||
| Yes | -1.72 | 1.11 | -3.89 | 0.45 | 0.121 |
| n | 1175.00 | ||||
| R² | 0.02 | ||||
| Adj.R² | 0.02 |
Anything more ambitious – a DT::datatable() with
significant rows highlighted, a LaTeX booktabs layout, an HTML widget –
follows the same pattern: body for the cells,
col_meta for per-column formatting, the row indices for
styling regions, and format_spec for the global
conventions. The note and title stay available on the original object
(attr(tbl, "note"), attr(tbl, "title")), so a
custom renderer can reproduce spicy’s self-documenting footer
verbatim.
The contract
Three properties make the structured view safe to build on:
-
Engine parity. The built-in engines consume this
exact structure: what you read in
bodyis, cell for cell, what the Excel workbook contains and what the gt table displays at its precision. This parity is pinned by tests. -
Stability tier.
as_structured()sits in the stabilising tier of the API (see?spicy, section API stability): components will not be silently renamed or change semantics within0.y.z, and additions are announced inNEWS.md. - Version guard. Objects built by a spicy version before the structured contract existed are refused with an actionable message rather than mis-read.
For the statistical long form – estimates with standard errors and
unformatted p-values, one row per term across models – reach for
broom::tidy() on the same object; for one-line model
summaries, broom::glance(). The three views are built from
the same frames, so their numbers always agree.