Regression models with tab_reg(): reference

library(tabxplor)

# Pin the legend language: it defaults to "auto" = the ambient locale, so building this English
# vignette on a French machine silently renders French legends and captions (the -fr articles pin
# "fr" for the same reason). Output must not depend on where it is built.
options(tabxplor.lang = "en")
Sys.setenv(LANGUAGE = "en")   # the test-summary / model-fit row labels go through gettext, not this option
library(dplyr)

# As in the introduction vignette, tables render as tabxplor's real html tables (the recommended
# everyday setting); the shared stylesheet is emitted once by tab_css() below, tooltips kept off.
options(tabxplor.print = "html")
options(tabxplor.tab_kable_css = FALSE)
options(tabxplor.tab_kable_tooltips = FALSE)

# The shape table a continuous predictor draws under the footer is the subject of one
# section below, and noise everywhere else: it is switched on there and off here.
options(tabxplor.shape_table = "no")

options(cli.num_colors = 256)
set_color_palette(theme = "light")

Une version française de ce document est disponible : Tableaux de régression avec tab_reg().

For the most common regression models, tab_reg() builds a regression table that looks and behaves like a tabxplor cross-table: one row per predictor level, significance stars, colours that grey out non-significant effects, and the same Excel, html or markdown exports. You give it a data frame, an outcome and some predictors, and it works out the right kind of model from the outcome’s type. Its distinctive feature is empirical = TRUE, which shows the observed / crude / empirical effect right next to the model’s adjusted one, so you can see what “controlling for the other variables” actually changed.

There are two articles on regression, and this is the reference one. It is organised by feature: one section per family, the full grid of what each model can report, weighted data, interactions, model checks and plots. If you are learning rather than looking something up, start with vignette("tabxplor-reading-a-regression"), which walks a single analysis from a first cross-table to a finished sentence and sends you back here for the details.

Without writing R code, the same tables are a point-and-click Regressions analysis in the tabxplor module for jamovi; its options carry the names of the arguments below, so this reads as its manual too.

We use a formatted version of the forcats::gss_cat data, from the US General Social Survey, and — for the summed-score model — facto_tea (from the FactoMineR package, with thanks; see ?facto_tea) with its six “where do you drink tea?” items reduced to one score (see Batteries of yes/no items in vignette("tabxplor") for that preparation):

gss_simple <- gss_cat_data_formatting()
tea_where_vars <- c("home", "work", "tearoom", "friends", "resto", "pub")
tea <- facto_tea |> score_from_lv1("tea_where", vars_list = tea_where_vars)

1. What a regression table is

The outcome’s type chooses the model and the observed quantity to compare with

You rarely set family by hand — tab_reg() mostly detects it:

Outcome Detected model The measure that model works in
2-level factor binomial (logistic) odds ratio (OR)
numeric (continuous) gaussian (linear) mean difference (diff)
count poisson incidence-rate ratio (IRR)
3+ level unordered factor multinomial one OR column per category vs. the reference
3+ level ordered factor ordinal (proportional odds) cumulative OR, or Somers’ D

That last column is the measure the model estimates, not a ceiling: any other measure can still be reported from the same fit, and measure = is how you ask. Four arguments chain — family says what kind of number the outcome is, link which measure the model estimates, measure which one is reported, effect where that number comes from — and each follows from the one before unless you say otherwise. The full grid is in What a model column reports below.

With empirical = TRUE, each model column is joined by a crude/observed companion column showing the observed, unadjusted (univariable) effect — the effect you would see with no controls at all, “all things being unequal”, for that predictor. The two columns are the same column twice, one estimand computed with one predictor and with all of them: same colour ladder, same layout, one legend. Each cell prints the effect with the level it sits on beside it — the observed percentage or mean on the crude side, the model-adjusted prediction on the model side — so the two effects sit next to each other in the middle and the comparison is read straight across.

One rule covers every case: the observed effect is the effect of that predictor alone. With a categorical predictor that is exactly the observed contrast between levels (a percentage difference, an odds ratio computed from the raw percentages). With a continuous predictor there is no such shortcut, so it is the univariable slope — which assumes the effect is linear on the model’s scale, something worth checking (with cut(), or splines) before trusting it.

A continuous predictor has no levels, so its cell shows the effect alone — there is no observed percentage or mean to put beside it — and its distribution (mean, standard deviation, and the mean in each outcome group) is shown in the html tooltip instead.

On weighted data, one thing is worth knowing from the start: an observed column is always measured exactly like the model column beside it, so its confidence interval accounts for the weighting (and, under a survey design, for the whole design). A tab() cross-table does not, by default — add design_effect = TRUE there if you want its percentages directly comparable with these. See Weighted and survey data below.

Every outcome has an observed counterpart

empirical = TRUE answers one question — “how much of this association survives once I adjust for the rest?” — and it works for every kind of outcome. The rule behind it is a single sentence:

The observed effect is the model’s own effect, fitted with one predictor.

That is worth saying plainly, because it is what makes the comparison fair. The crude number is not “a percentage” and the modelled one “a coefficient” — they are the same quantity, computed the same way, on the same people (the same complete cases), and shown on the same scale. Only the list of predictors differs. So the distance between them measures adjustment, and nothing else.

The “same people” part is the default, not a hope: na = "drop_by_outcome" gives every model of an outcome one complete-case population, so a compared model cannot be fitted on rows the observed columns do not cover. If you ask for na = "drop_by_model" instead, a model on a different population gets no observed effect at all — better an empty cell than a “gap” that is really listwise deletion.

A word on how to read it. The colour says how much two numbers differ, and the greying whether that difference exceeds noise. It does not say “this is a confounder”: the 10% first threshold is a convention, not a decision rule, and part of an odds-ratio gap is arithmetic rather than confounding (see One warning about odds ratios, below). “This effect was attenuated by adjustment” is the safe reading; “explained by” is not.

An ordered outcome: what changes when you adjust

Income here is an ordered factor, so it is fit as a proportional-odds model. Obs_cumOR is the same model with one predictor at a time:

tab_reg(gss_simple, "rincome", c("race", "relig"),
        empirical = TRUE, color = c(TRUE, "adjustment"))
Ordinal logistic regression: rincome by race, relig
rincome
levels n Obs_cumOR Model_cumOR
<n> <OR> <OR>
Constant Reference profile 4 692
race White 9 863 1   
Black 1 866 1/1.54*** 1/1.51***
Other 1 256 1/1.51*** 1/1.60***
relig 1-Protestant 6 282 1   
2-Catholic 3 127 1.09*   1.09*  
3-Other christian 508 1/1.10    1/1.09   
4-Jewish 223 2.29*** 2.09***
5-Buddhist/Hinduist 144 1.93*** 2.32***
6-Muslim 56 1.01    1.22   
7-Other 267 1/1.06    1/1.04   
8-None 2 378 1/1.05    1/1.08*  
Model fit N 12 985
Proportionality (Brant) 8.87%
Dispersion (robust/model SE) 1.09
Collinearity (max VIF) 1.07
Influence (max dfbetas) 0.22
LR vs null <0.01%
McFadden R2 0.006
AIC 29 559
BIC 29 649
Model: ordinal logistic regression; cumOR: cumulative odds ratio (proportional-odds model); sup%: probability of superiority, 50 % being a coin flip.
rincome, from low to high: 1-Lt $10000 (17%) < 2-$10000 to 14999 (9%) < 3-$15000 to 24999 (18%) < 4-$25000 or more (57%).
Obs_cumOR — cumOR ≥ 1.2; 1.5; 2; 4; cumOR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Background colour: the observed effect (the reference for the adjustment). Uncoloured: not significantly different from the reference category (Wald interval on the log scale, 95% confidence) or under the first colour threshold (×1.2). Part of an odds-ratio gap is non-collapsibility, not confounding: a risk ratio or a marginal effect is the collapsible comparison.
Model_cumOR — cumOR ≥ 1.2; 1.5; 2; 4; cumOR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Background colour: cumOR further from no effect (1) than the observed column, by ×1.1; ×1.25; ×1.5; ×2; cumOR closer to no effect (1) than the observed column (or inversed effect), by ÷1.1; ÷1.25; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log scale, 95% confidence) or under the first colour threshold (×1.2). Part of an odds-ratio gap is non-collapsibility, not confounding: a risk ratio or a marginal effect is the collapsible comparison.
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

Read a row left to right: the observed cumulative odds ratio, then the modelled one, with the background colouring the gap between them. Being Black is associated with lower income (1/1.54 observed), and adjusting for religion barely moves it (1/1.51) — the association is not explained by religion. Compare Buddhist/Hinduist, where the effect grows once race and the other groups are held constant: adjustment can strengthen an association as easily as it can wash one away.

On this scale the background colour is not a test: an odds ratio moves when any strong predictor is added, which is arithmetic rather than confounding. Ask for measure = "difference" and the same comparison is read on Somers’ D, where it is one — the crude value is then the cross-table’s own superiority probability, computed from the counts with no model at all, so the distance to the modelled one measures adjustment and nothing else:

tab_reg(gss_simple, "rincome", c("race", "relig"), measure = "difference",
        empirical = TRUE, display = "est_base", color = c(TRUE, "adjustment"))
Ordinal logistic regression: rincome by race, relig
rincome
levels n Obs_D Model_mD
<n> <(sup%) diff> <diff (sup%)>
Constant Population average 12 985
race White 9 863 (50%)     0%        0%    (50%)
Black 1 866 (44%) -12.1%*** -11.2%*** (44%)
Other 1 256 (44%) -11.2%*** -12.9%*** (44%)
relig 1-Protestant 6 282 (50%)     0%        0%    (50%)
2-Catholic 3 127 (51%)  +2.2%*    +2.2%*   (51%)
3-Other christian 508 (49%)  -2.7%     -2.3%    (49%)
4-Jewish 223 (60%) +19.1%*** +17.4%*** (59%)
5-Buddhist/Hinduist 144 (58%) +15.6%*** +19.5%*** (60%)
6-Muslim 56 (50%)  +0.2%     +5.3%    (53%)
7-Other 267 (49%)  -1.6%     -1.2%    (49%)
8-None 2 378 (49%)  -1.4%     -2.1%*   (49%)
Model fit N 12 985
Proportionality (Brant) 8.87%
Dispersion (robust/model SE) 1.09
Collinearity (max VIF) 1.07
Influence (max dfbetas) 0.22
LR vs null <0.01%
McFadden R2 0.006
AIC 29 559
BIC 29 649
Model: ordinal logistic regression; mD: marginal Somers’ D (how often someone from this group ends up higher than someone from the reference group, as a difference in percentage points, wins minus losses; sample-averaged); sup%: probability of superiority, 50 % being a coin flip.
rincome, from low to high: 1-Lt $10000 (17%) < 2-$10000 to 14999 (9%) < 3-$15000 to 24999 (18%) < 4-$25000 or more (57%).
Obs_D — D ≥ +5; +10; +20; +30 points; D ≤ -5; -10; -20; -30 points. Background colour: the observed effect (the reference for the adjustment). Uncoloured: not significantly different from the reference category (Wald interval, 95% confidence) or under the first colour threshold (±5 points).
Model_mD — D ≥ +5; +10; +20; +30 points; D ≤ -5; -10; -20; -30 points. Background colour: D further from no effect (0) than the observed column, by +2; +5; +10; +20 points; D closer to no effect (0) than the observed column (or inversed effect), by -2; -5; -10; -20 points. Uncoloured: not significantly different from the reference category (Wald interval, 95% confidence) or under the first colour threshold (±5 points). Some rows carry no test and are left uncoloured. Background: the same rule, applied to the gap with the observed effect (z test on the difference between two estimates fitted on the same sample, 95% confidence).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

A nominal outcome: the crude number rides in the cell

A multinomial model already spends one column per outcome category, so a second set of columns would double the table. The observed effect is printed in the cell instead, in parentheses:

tab_reg(gss_simple, "party3", c("race", "relig"), family = "multinomial",
        empirical = TRUE, color = c(TRUE, "adjustment"))
Multinomial logistic regression: party3 by race, relig
party3: OR
levels n 2-Independent,
other vs
1-Democrat
3-Republican
vs 1-Democrat
<n> <OR (adj%)>
Constant Reference profile 8 157 1/1.98*** (34%)    1.47*** (60%)
race White 16 253      1    (21%)       1    (40%)
Black 3 080 1/2.46*** (16%) 1/12.48*** ( 7%)
Other 1 921   1.28*** (31%)  1/2.29*** (22%)
relig 1-Protestant 10 794      1    (18%)       1    (42%)
2-Catholic 5 090 1/1.09*   (20%)  1/1.71*** (31%)
3-Other christian 778   1.34*** (24%)  1/1.23**  (35%)
4-Jewish 388 1/2.86*** (12%)  1/4.76*** (17%)
5-Buddhist/Hinduist 212 1/1.11    (24%)  1/3.59*** (18%)
6-Muslim 99 1/1.27    (21%)  1/3.21*** (20%)
7-Other 384   1.29**  (28%)  1/2.18*** (24%)
8-None 3 509   1.35*** (31%)  1/3.08*** (18%)
Model fit N 21 254
Dispersion (robust/model SE) 1.07
Influence (max dfbetas) 0.31
LR vs null <0.01%
McFadden R2 0.069
AIC 41 721
BIC 41 880
Model: multinomial logistic regression; OR: odds ratio (each category vs the reference); adj%: adjusted/predicted proportion.
OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Background colour: OR further from no effect (1) than the observed column, by ×1.1; ×1.25; ×1.5; ×2; OR closer to no effect (1) than the observed column (or inversed effect), by ÷1.1; ÷1.25; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence) or under the first colour threshold (×1.2). Part of an odds-ratio gap is non-collapsibility, not confounding: a risk ratio or a marginal effect is the collapsible comparison.
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

1/2.46 (1/2.43) means “modelled 1/2.46, observed 1/2.43” — nothing to see. 1/12.48 (1/10.17) means adjustment pushed the effect a little further from 1. The footer says which number is which, and hovering a cell (in an HTML export) adds the crude percentages behind it.

A summed score

With trials =, the crude column is the odds ratio of the summed items, Obs_OR, with the observed mean score beside it — the average number of places out of six. The model column shows the same two quantities adjusted, so the pair reads across.

tab_reg(tea, "tea_where", c("sex", "SPC", "Sport"),
        family = "binomial", trials = length(tea_where_vars),
        empirical = TRUE, color = c(TRUE, "adjustment"))
Logistic regression: tea_where by sex, SPC +1 more
tea_where
levels n Obs_OR Model_OR
<n> <(obs mean) OR> <OR (adj mean)>
Constant Reference profile 16 1/1.22    (2.7)
sex F 178 (2.8)      1         1    (2.8)
M 122 (2.3) 1/1.36*** 1/1.44*** (2.3)
SPC employee 59 (2.5)      1         1    (2.5)
middle 40 (2.5) 1/1.04      1.01    (2.5)
non-worker 64 (2.4) 1/1.12    1/1.14    (2.3)
other worker 20 (2.9)   1.29      1.31    (2.9)
senior 35 (2.9)   1.25      1.44**  (3.0)
student 70 (2.7)   1.14      1.11    (2.6)
workman 12 (2.2) 1/1.21    1/1.08    (2.4)
Sport sportsman 179 (2.6)      1         1    (2.6)
Not sportsman 121 (2.6)   1.02      1.02    (2.6)
Model fit N 300
Dispersion (robust/model SE) 1.04
Pearson dispersion (phi) 1.02
Collinearity (max VIF) 1.13
Influence (max dfbetas) 0.46
LR vs null 0.99%
McFadden R2 0.021
AIC 971
BIC 1 004
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Background colour: the observed effect (the reference for the adjustment). Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; Woolf closed form) or under the first colour threshold (×1.2). Part of an odds-ratio gap is non-collapsibility, not confounding: a risk ratio or a marginal effect is the collapsible comparison.
Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Background colour: OR further from no effect (1) than the observed column, by ×1.1; ×1.25; ×1.5; ×2; OR closer to no effect (1) than the observed column (or inversed effect), by ÷1.1; ÷1.25; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence) or under the first colour threshold (×1.2). Part of an odds-ratio gap is non-collapsibility, not confounding: a risk ratio or a marginal effect is the collapsible comparison.
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

How to read it — and how not to

A gap is a clue, not a verdict: it holds for these covariates, measured this way, and both numbers are estimates, so a crude one based on few people wanders and the gap wanders with it. vignette("tabxplor-reading-a-regression") is a whole article on reading that distance.

One habit is specific to the scale, and it decides what the table will let you do. An odds ratio changes when you add a predictor even if that predictor has nothing to do with the exposure — non-collapsibility — so part of every gap on an odds-ratio column is arithmetic rather than confounding, and the colours there stay purely descriptive. For a gap you can test, ask for a collapsible measure: percentage points, or a risk ratio.

tab_reg(gss_simple, "party3", c("race", "relig"), family = "multinomial",
        measure = "difference", empirical = TRUE, color = c(TRUE, "adjustment"),
        color_signif = "grey_non_signif")
Multinomial logistic regression: party3 by race, relig
party3: mRD
levels n 1-Democrat 2-Independent,
other
3-Republican
<n> <diff (adj%)>
Constant Population average 21 254 45.4%    21.1%    33.5%   
race White 16 253     0%    (39%)     0%    (21%)     0%    (40%)
Black 3 080 +37.8%*** (77%)  -4.1%*** (16%) -33.6%*** ( 7%)
Other 1 921  +7.7%*** (47%) +10.8%*** (31%) -18.5%*** (22%)
relig 1-Protestant 10 794     0%    (41%)     0%    (18%)     0%    (42%)
2-Catholic 5 090  +8.4%*** (49%)  +2.5%*** (20%) -10.9%*** (31%)
3-Other christian 778  +0.4%    (41%)  +6.4%*** (24%)  -6.8%*** (35%)
4-Jewish 388 +30.7%*** (71%)  -6.2%*** (12%) -24.5%*** (17%)
5-Buddhist/Hinduist 212 +17.5%*** (58%)  +6.0%**  (24%) -23.5%*** (18%)
6-Muslim 99 +18.0%*** (59%)  +3.2%    (21%) -21.2%*** (20%)
7-Other 384  +7.5%*** (48%) +10.1%*** (28%) -17.6%*** (24%)
8-None 3 509 +10.1%*** (51%) +13.3%*** (31%) -23.4%*** (18%)
Model fit N 21 254
Dispersion (robust/model SE) 1.07
Influence (max dfbetas) 0.31
LR vs null <0.01%
McFadden R2 0.069
AIC 41 721
BIC 41 880
Model: multinomial logistic regression; mRD: marginal risk difference (on adjusted proportions, in percentage points, sample-averaged); adj%: adjusted/predicted proportion.
RD ≥ +5; +10; +20; +30 points; RD ≤ -5; -10; -20; -30 points. Background colour: RD further from no effect (0) than the observed column, by +2; +5; +10; +20 points; RD closer to no effect (0) than the observed column (or inversed effect), by -2; -5; -10; -20 points. Uncoloured: not significantly different from the reference category (Wald interval, 95% confidence) or under the first colour threshold (±5 points). Some rows carry no test and are left uncoloured. Background: the same rule, applied to the gap with the observed effect (z test on the difference between two estimates fitted on the same sample, 95% confidence).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

The cell now reads “modelled effect in percentage points, observed one in parentheses”, and a background that stays grey means the two are not distinguishable from noise.

3. One part per kind of outcome

Logistic regression (a binary factor)

When the outcome is a two-level factor, here “married” versus “not married”, tab_reg() chooses a binomial family to fit a logistic regression and reports odds ratios (the reference level of each predictor shows the neutral value 1).

tab_reg(gss_simple, "married", c("race", "age", "rincome", "relig"))
Logistic regression: married by race, age +2 more
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 1/1.19*** (46%)
race White 9 846 (52%)      1         1    (51%)
Black 1 860 (31%) 1/2.43*** 1/2.40*** (32%)
Other 1 254 (49%) 1/1.12*     1.11    (54%)
age per 26.9 (2SD), at 42.4 (mean)         2.12***   1.83***      
rincome 1-Lt $10000 2 142 (37%)      1         1    (39%)
2-$10000 to 14999 1 164 (41%)   1.20**    1.14*   (42%)
3-$15000 to 24999 2 322 (43%)   1.32***   1.27*** (45%)
4-$25000 or more 7 332 (55%)   2.12***   1.86*** (54%)
relig 1-Protestant 6 269 (52%)      1         1    (53%)
2-Catholic 3 121 (52%) 1/1.03    1/1.17*** (49%)
3-Other christian 507 (42%) 1/1.53*** 1/1.43*** (45%)
4-Jewish 222 (55%)   1.10    1/1.29*   (47%)
5-Buddhist/Hinduist 144 (50%) 1/1.09    1/1.36*   (46%)
6-Muslim 56 (48%) 1/1.17      1.05    (54%)
7-Other 267 (35%) 1/2.04*** 1/2.04*** (37%)
8-None 2 374 (38%) 1/1.76*** 1/1.79*** (39%)
Model fit N 12 960
Dispersion (robust/model SE) 1.03
Collinearity (max VIF) 1.08
Influence (max dfbetas) 0.19
LR vs null <0.01%
McFadden R2 0.057
AIC 16 960
BIC 17 064
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

For a simple logistic regression, empirical = TRUE adds the crude unmodelised odds ratios, each with the raw percentage it is computed from.

tab_reg(gss_simple, "married", c("race", "age", "rincome", "relig"), empirical = TRUE)
Logistic regression: married by race, age +2 more
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 1/1.19*** (46%)
race White 9 846 (52%)      1         1    (51%)
Black 1 860 (31%) 1/2.43*** 1/2.40*** (32%)
Other 1 254 (49%) 1/1.12*     1.11    (54%)
age per 26.9 (2SD), at 42.4 (mean)         2.12***   1.83***      
rincome 1-Lt $10000 2 142 (37%)      1         1    (39%)
2-$10000 to 14999 1 164 (41%)   1.20**    1.14*   (42%)
3-$15000 to 24999 2 322 (43%)   1.32***   1.27*** (45%)
4-$25000 or more 7 332 (55%)   2.12***   1.86*** (54%)
relig 1-Protestant 6 269 (52%)      1         1    (53%)
2-Catholic 3 121 (52%) 1/1.03    1/1.17*** (49%)
3-Other christian 507 (42%) 1/1.53*** 1/1.43*** (45%)
4-Jewish 222 (55%)   1.10    1/1.29*   (47%)
5-Buddhist/Hinduist 144 (50%) 1/1.09    1/1.36*   (46%)
6-Muslim 56 (48%) 1/1.17      1.05    (54%)
7-Other 267 (35%) 1/2.04*** 1/2.04*** (37%)
8-None 2 374 (38%) 1/1.76*** 1/1.79*** (39%)
Model fit N 12 960
Dispersion (robust/model SE) 1.03
Collinearity (max VIF) 1.08
Influence (max dfbetas) 0.19
LR vs null <0.01%
McFadden R2 0.057
AIC 16 960
BIC 17 064
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

The raw percentages in Obs_OR’s brackets are the observed results everything in the model is derived from: “31% of black americans are married, compared to 52% for white americans”.

The modelised odds-ratios Model_OR directly compared to the observed odds-ratios Obs_OR :

The observed odds-ratios Obs_OR are the same as those you can calculate from percentages only in a crosstable :

gss_simple |> 
  dplyr::filter(dplyr::if_all(all_of(c("race", "age", "rincome", "relig")), ~ !is.na(.) )) |> 
  tab(race, married, pct = "row", na = "drop", 
    display = "{or}", ref = "first", color = "odds_ratio", color_signif = "grey_non_signif"
   )
married
race 01-Married 02-Not married Total
<row%-OR> <n>
White 1 1 ( 9 846)
Black 1/2.43 2.43 ( 1 860)
Other 1/1.12 1.12 ( 1 254)
Total 1/1.14 1.14 (12 960)
Odds ratio: OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; Woolf closed form) or under the first colour threshold (×1.2).

Reading the same model in another measure

A logistic model works in odds ratios, but an odds ratio is a poor thing to say out loud. You do not have to leave the model to get something better — measure = asks for another one, and one rule covers every case:

If the measure you ask for is the one the model already works in, you read the model’s own coefficient. If it is not, the model works your measure out from its predictions — for every person in the file — and averages them.

That second operation is what statisticians call a marginal effect, and the column header says which you got: an unmarked Model_OR is a coefficient, the little m of Model_mRD means worked out and averaged. Since measure left alone gives the model’s own coefficient, every other measure you ask for is a marginal effect.

Percentage points: measure = "difference"

The friendliest of the three, because points are the unit people already think in. Model_mRD is the average marginal effect: the model’s predicted probability computed for every respondent as if they were at the level, then at the reference, and the two averages subtracted.

tab_reg(gss_simple, "married", c("race", "age", "rincome", "relig"),
        measure = "difference", empirical = TRUE)
Logistic regression: married by race, age +2 more
married: 01-Married
levels n Obs_RD Model_mRD
<n> <(obs%) diff> <diff (adj%)>
Constant Population average 12 960 48.7%   
race White 9 846 (52%)     0%        0%    (51%)
Black 1 860 (31%) -21.2%*** -19.8%*** (32%)
Other 1 254 (49%)  -2.7%*    +2.4%    (54%)
age per 26.9 (2SD), at 42.4 (mean)       +17.6%*** +13.6%***      
rincome 1-Lt $10000 2 142 (37%)     0%        0%    (39%)
2-$10000 to 14999 1 164 (41%)  +4.3%**   +3.0%*   (42%)
3-$15000 to 24999 2 322 (43%)  +6.7%***  +5.5%*** (45%)
4-$25000 or more 7 332 (55%) +18.5%*** +14.5%*** (54%)
relig 1-Protestant 6 269 (52%)     0%        0%    (53%)
2-Catholic 3 121 (52%)  -0.6%     -3.7%*** (49%)
3-Other christian 507 (42%) -10.6%***  -8.3%*** (45%)
4-Jewish 222 (55%)  +2.3%     -6.0%*   (47%)
5-Buddhist/Hinduist 144 (50%)  -2.2%     -7.2%*   (46%)
6-Muslim 56 (48%)  -4.0%     +1.2%    (54%)
7-Other 267 (35%) -17.4%*** -16.4%*** (37%)
8-None 2 374 (38%) -14.0%*** -13.5%*** (39%)
Model fit N 12 960
Dispersion (robust/model SE) 1.03
Collinearity (max VIF) 1.08
Influence (max dfbetas) 0.19
LR vs null <0.01%
McFadden R2 0.057
AIC 16 960
BIC 17 064
Model: logistic regression; mRD: marginal risk difference (on adjusted proportions, in percentage points, sample-averaged); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_RD, Model_mRD — RD ≥ +5; +10; +20; +30 points; RD ≤ -5; -10; -20; -30 points. Uncoloured: not significantly different from the reference category (Wald interval, 95% confidence) or under the first colour threshold (±5 points).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

Read the race block: “comparing Black and White respondents who are alike in income, age and religion, being Black is associated with a marriage rate 19.8 points lower, on average”. The crude Obs_RD beside it is the plain observed difference of percentages, -21.2 — very close, so little of that gap is explained by age, income or religion. The adjusted percentages in the brackets say it the other way round: Black respondents marry at 30.9% observed, and standardising their income, age and religion to the population mix moves that only to 31.5%, against 51.3% if everyone were White.

“How many times as likely?”: measure = "ratio"

An odds ratio is not a “times more likely”. The two are close only when the outcome is rare; above roughly 10 %, which covers most survey questions, the odds ratio sits much further from 1 than the ratio of probabilities. And an odds ratio is non-collapsible: it moves when you add a covariate even if that covariate is not a confounder, so comparing Model_OR across nested models is not valid. Risk ratios do not have that problem.

tab_reg(gss_simple, "married", c("race", "age", "rincome", "relig"),
        measure = "ratio", empirical = TRUE)
Logistic regression: married by race, age +2 more
married: 01-Married
levels n Obs_RR Model_mRR
<n> <(obs%) ratio> <ratio (adj%)>
Constant Population average 12 960 49%   
race White 9 846 (52%)     1        1    (51%)
Black 1 860 (31%) ÷1.69*** ÷1.63*** (32%)
Other 1 254 (49%) ÷1.06*   ×1.05*   (54%)
age per 26.9 (2SD), at 42.4 (mean)       ×1.36*** ×1.28***      
rincome 1-Lt $10000 2 142 (37%)     1        1    (39%)
2-$10000 to 14999 1 164 (41%) ×1.12**  ×1.08*   (42%)
3-$15000 to 24999 2 322 (43%) ×1.18*** ×1.14*** (45%)
4-$25000 or more 7 332 (55%) ×1.50*** ×1.37*** (54%)
relig 1-Protestant 6 269 (52%)     1        1    (53%)
2-Catholic 3 121 (52%) ÷1.01    ÷1.07*** (49%)
3-Other christian 507 (42%) ÷1.25*** ÷1.19*** (45%)
4-Jewish 222 (55%) ×1.04    ÷1.13*   (47%)
5-Buddhist/Hinduist 144 (50%) ÷1.04    ÷1.16    (46%)
6-Muslim 56 (48%) ÷1.08    ×1.02    (54%)
7-Other 267 (35%) ÷1.50*** ÷1.45*** (37%)
8-None 2 374 (38%) ÷1.37*** ÷1.34*** (39%)
Model fit N 12 960
Dispersion (robust/model SE) 1.03
Collinearity (max VIF) 1.08
Influence (max dfbetas) 0.19
LR vs null <0.01%
McFadden R2 0.057
AIC 16 960
BIC 17 064
Model: logistic regression; mRR: marginal risk ratio (the ratio of adjusted proportions, sample-averaged); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_RR, Model_mRR — RR ≥ ×1.1; ×1.2; ×1.5; ×2; RR ≤ ÷1.1; ÷1.25; ÷2; ÷4. Uncoloured: not significantly different from the reference category (Wald interval on the log risk-ratio, 95% confidence; matching Katz interval on the observed column) or under the first colour threshold (×1.1).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

Model_mRR reads plainly: Black respondents alike in income, age and religion are about 1.6 times less likely to be married. The cell is coherent by construction — the reference probability divided by the risk ratio gives the level’s probability (51% ÷ 1.6 ≈ 32%), the multiplicative counterpart of the additive identity above.

The odds ratio, made comparable: measure = "odds_ratio" on a prediction route

If your field publishes odds ratios you are not stuck with the conditional one. Ask for the odds ratio of the two adjusted predictions and you get a marginal odds ratio (Model_mOR), which keeps the relative-effect reading while behaving like a marginal effect across models (Karlson & Jann 2023):

tab_reg(gss_simple, "married", c("race", "age", "rincome", "relig"),
        measure = "odds_ratio", effect = "marginal", empirical = TRUE)
Logistic regression: married by race, age +2 more
married: 01-Married
levels n Obs_OR Model_mOR
<n> <(obs%) OR> <OR (adj%)>
Constant Population average 12 960 1/1.05    (49%)
race White 9 846 (52%)      1         1    (51%)
Black 1 860 (31%) 1/2.43*** 1/2.29*** (32%)
Other 1 254 (49%) 1/1.12*     1.10    (54%)
age per 26.9 (2SD), at 42.4 (mean)         2.07***   1.74***      
rincome 1-Lt $10000 2 142 (37%)      1         1    (39%)
2-$10000 to 14999 1 164 (41%)   1.20**    1.13*   (42%)
3-$15000 to 24999 2 322 (43%)   1.32***   1.26*** (45%)
4-$25000 or more 7 332 (55%)   2.12***   1.80*** (54%)
relig 1-Protestant 6 269 (52%)      1         1    (53%)
2-Catholic 3 121 (52%) 1/1.03    1/1.16*** (49%)
3-Other christian 507 (42%) 1/1.53*** 1/1.40*** (45%)
4-Jewish 222 (55%)   1.10    1/1.27*   (47%)
5-Buddhist/Hinduist 144 (50%) 1/1.09    1/1.33*   (46%)
6-Muslim 56 (48%) 1/1.17      1.05    (54%)
7-Other 267 (35%) 1/2.04*** 1/1.95*** (37%)
8-None 2 374 (38%) 1/1.76*** 1/1.73*** (39%)
Model fit N 12 960
Dispersion (robust/model SE) 1.03
Collinearity (max VIF) 1.08
Influence (max dfbetas) 0.19
LR vs null <0.01%
McFadden R2 0.057
AIC 16 960
BIC 17 064
Model: logistic regression; mOR: marginal odds ratio (the odds ratio of adjusted proportions, sample-averaged); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_mOR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

1/2.29 against the conditional 1/2.40 above — a little closer to 1, which is the non-collapsibility showing. It matters for one thing in particular: because it is collapsible, color = "adjustment" tests the crude-to-adjusted gap on this column, where on a conditional odds ratio it can only colour it (see One warning about odds ratios below). It is available on a binary outcome and on a summed score; a 3+ category outcome has to be asked “versus what?” first, so there it exists only at effect = "at_reference".

⚠ You have to name it: "auto" never lands on a predicted odds ratio, because it is a specialist quantity that should be asked for rather than arrived at by accident.

Linear regression (a numeric outcome)

A continuous outcome gives plain linear regression coefficients (here we set family explicitly, because an integer like age is ambiguous — it could also be modelled as a count):

tab_reg(gss_simple, "age", c("race", "marital", "relig", "rincome"), family = "gaussian")
Linear regression: age by race, marital +2 more
age
levels n Obs_diff Model_diff
<n> <(obs mean) diff> <diff (adj mean)>
Constant Reference profile 346 43.8   
race White 9 846 (43.3)     0        0    (42.8)
Black 1 860 (40.3)  -3.0***  -1.0*** (41.8)
Other 1 252 (38.1)  -5.3***  -3.3*** (39.5)
marital Married 6 315 (44.9)     0        0    (44.5)
Separated 446 (41.9)  -3.0***  -2.0*** (42.5)
Divorced 2 152 (47.6)  +2.7***  +2.9*** (47.4)
Widowed 401 (59.9) +15.0*** +15.2*** (59.7)
Never married 3 644 (33.0) -11.9*** -10.7*** (33.8)
relig 1-Protestant 6 268 (44.1)     0        0    (43.3)
2-Catholic 3 121 (42.0)  -2.2***  -1.0*** (42.3)
3-Other christian 507 (39.2)  -4.9***  -3.2*** (40.0)
4-Jewish 222 (48.0)  +3.9***  +3.4*** (46.7)
5-Buddhist/Hinduist 143 (41.4)  -2.7**   +0.5    (43.8)
6-Muslim 56 (38.7)  -5.4***  -2.9*   (40.4)
7-Other 267 (38.9)  -5.2***  -3.5*** (39.8)
8-None 2 374 (38.9)  -5.2***  -2.8*** (40.5)
rincome 1-Lt $10000 2 142 (38.5)     0        0    (40.3)
2-$10000 to 14999 1 163 (41.1)  +2.6***  +1.2*** (41.5)
3-$15000 to 24999 2 321 (40.8)  +2.3***  +1.0*** (41.3)
4-$25000 or more 7 332 (44.2)  +5.7***  +3.2*** (43.5)
Model fit N 12 958
Dispersion (robust/model SE) 1.18
Collinearity (max VIF) 1.09
Influence (max dfbetas) 0.27
F <0.01%
R2 0.256
Adjusted R2 0.255
Residual SD 11.62
Model: linear regression; diff: mean difference (vs the reference category); obs mean: observed mean; adj mean: adjusted/predicted mean.
Obs_diff, Model_diff — diff ≥ +0.1; +0.2; +0.4; +0.8 SD; diff ≤ -0.1; -0.2; -0.4; -0.8 SD. Uncoloured: not significantly different from the reference category (Wald interval, 95% confidence; matching pooled OLS interval on the observed column) or under the first colour threshold (±0.1 SD).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

In the case of a linear regression, the empirical/observed counterpart of the model coefficient for a categorical predictor is simply the difference of means : here, the difference of mean age, per level of the predictor, compared to the reference level.

tab_reg(gss_simple, "age", c("race", "marital", "relig", "rincome"), family = "gaussian", empirical = TRUE)
Linear regression: age by race, marital +2 more
age
levels n Obs_diff Model_diff
<n> <(obs mean) diff> <diff (adj mean)>
Constant Reference profile 346 43.8   
race White 9 846 (43.3)     0        0    (42.8)
Black 1 860 (40.3)  -3.0***  -1.0*** (41.8)
Other 1 252 (38.1)  -5.3***  -3.3*** (39.5)
marital Married 6 315 (44.9)     0        0    (44.5)
Separated 446 (41.9)  -3.0***  -2.0*** (42.5)
Divorced 2 152 (47.6)  +2.7***  +2.9*** (47.4)
Widowed 401 (59.9) +15.0*** +15.2*** (59.7)
Never married 3 644 (33.0) -11.9*** -10.7*** (33.8)
relig 1-Protestant 6 268 (44.1)     0        0    (43.3)
2-Catholic 3 121 (42.0)  -2.2***  -1.0*** (42.3)
3-Other christian 507 (39.2)  -4.9***  -3.2*** (40.0)
4-Jewish 222 (48.0)  +3.9***  +3.4*** (46.7)
5-Buddhist/Hinduist 143 (41.4)  -2.7**   +0.5    (43.8)
6-Muslim 56 (38.7)  -5.4***  -2.9*   (40.4)
7-Other 267 (38.9)  -5.2***  -3.5*** (39.8)
8-None 2 374 (38.9)  -5.2***  -2.8*** (40.5)
rincome 1-Lt $10000 2 142 (38.5)     0        0    (40.3)
2-$10000 to 14999 1 163 (41.1)  +2.6***  +1.2*** (41.5)
3-$15000 to 24999 2 321 (40.8)  +2.3***  +1.0*** (41.3)
4-$25000 or more 7 332 (44.2)  +5.7***  +3.2*** (43.5)
Model fit N 12 958
Dispersion (robust/model SE) 1.18
Collinearity (max VIF) 1.09
Influence (max dfbetas) 0.27
F <0.01%
R2 0.256
Adjusted R2 0.255
Residual SD 11.62
Model: linear regression; diff: mean difference (vs the reference category); obs mean: observed mean; adj mean: adjusted/predicted mean.
Obs_diff, Model_diff — diff ≥ +0.1; +0.2; +0.4; +0.8 SD; diff ≤ -0.1; -0.2; -0.4; -0.8 SD. Uncoloured: not significantly different from the reference category (Wald interval, 95% confidence; matching pooled OLS interval on the observed column) or under the first colour threshold (±0.1 SD).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

The empirical mean ages, and differences of mean ages from reference, can be computed in a simple table with :

tab(gss_simple, "race", "age", pct = "row", digits = 2, na = "drop",
    color = "difference", ref = 1,  ci_method = c(mean_diff = "ols")
) |> 
  mutate(diff = set_display(age, "diff"))
age
race mean diff
<mean (cv)> <mean-diff>
White 48.72 (cv 36%) ref:48.72
Black 43.90 (cv 37%) -4.83
Other 39.48 (cv 36%) -9.24
Total 47.18 (cv 37%) -1.54
Standardized mean difference: cell ≥ the reference category (in bold) +0.1; +0.2; +0.4; +0.8 SD; cell ≤ ref -0.1; -0.2; -0.4; -0.8 SD.
# ols : the variance pooled over every level of the variable, so the intervals are exactly those a
#  linear regression puts on its coefficients ("student" pools the two compared groups only).

Poisson regression (a count outcome)

tab_reg(gss_simple, "tvhours", c("race", "marital", "relig", "rincome"), family = "poisson")
Poisson regression: tvhours by race, marital +2 more
tvhours
levels n Obs_IRR Model_IRR
<n> <(obs mean) ratio> <ratio (adj mean)>
Constant Reference profile 180 2.8   
race White 5 138 (2.4)     1        1    (2.4)
Black 1 009 (3.7) ×1.55*** ×1.47*** (3.5)
Other 664 (2.4) ×1.03    ×1.01    (2.4)
marital Married 3 230 (2.4)     1        1    (2.5)
Separated 230 (2.9) ×1.21*** ×1.06    (2.6)
Divorced 1 104 (2.5) ×1.08*** ×1.05*   (2.6)
Widowed 222 (2.9) ×1.24*** ×1.11**  (2.7)
Never married 2 025 (2.8) ×1.19*** ×1.07*** (2.7)
relig 1-Protestant 3 237 (2.7)     1        1    (2.6)
2-Catholic 1 618 (2.5) ÷1.09*** ×1.00    (2.7)
3-Other christian 272 (2.3) ÷1.18*** ÷1.18*** (2.2)
4-Jewish 109 (2.1) ÷1.27*** ÷1.11    (2.4)
5-Buddhist/Hinduist 77 (1.9) ÷1.40*** ÷1.26**  (2.1)
6-Muslim 35 (1.9) ÷1.44**  ÷1.58*** (1.7)
7-Other 140 (2.6) ÷1.03    ÷1.02    (2.6)
8-None 1 323 (2.4) ÷1.15*** ÷1.11*** (2.4)
rincome 1-Lt $10000 1 152 (3.1)     1        1    (3.0)
2-$10000 to 14999 663 (3.0) ÷1.03    ÷1.04    (2.9)
3-$15000 to 24999 1 158 (2.8) ÷1.09*** ÷1.10*** (2.8)
4-$25000 or more 3 838 (2.2) ÷1.39*** ÷1.34*** (2.3)
Model fit N 6 811
Dispersion (robust/model SE) 1.38
Pearson dispersion (phi) 1.45
Collinearity (max VIF) 1.11
Influence (max dfbetas) 0.68
LR vs null <0.01%
McFadden R2 0.033
AIC 26 179
BIC 26 295
Model: Poisson regression; IRR: incidence-rate ratio (vs the reference category); obs mean: observed mean; adj mean: adjusted/predicted mean.
Obs_IRR, Model_IRR — IRR ≥ ×1.1; ×1.2; ×1.5; ×2; IRR ≤ ÷1.1; ÷1.2; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log rate-ratio, 95% confidence; matching quasi-Poisson interval on the observed column) or under the first colour threshold (×1.1).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

An incidence-rate ratio (IRR) of 1.5 means “50% more hours of TV per day”. Unweighted Poisson models automatically use dispersion-scaled (quasi-Poisson) standard errors, so over-dispersed counts get honest, wider intervals. Concretely: with an over-dispersed outcome, family = "poisson" returns CIs and p-values identical to family = "quasipoisson" and emits a warning saying so (the footer reports the dispersion); at equidispersion (≈ 1) the scaling is a no-op and the result matches a plain glm(family = poisson) — so a comparison to a hand-fit Poisson glm never surprises you.

In the case of a poisson regression, the empirical/observed counterpart of the model’s exponentiated coefficient for a categorical predictor is the ratio of means : here, the ratio of average TV hours compared to the reference level.

tab_reg(gss_simple, "tvhours", c("race", "marital", "relig", "rincome"), family = "poisson", empirical = TRUE)
Poisson regression: tvhours by race, marital +2 more
tvhours
levels n Obs_IRR Model_IRR
<n> <(obs mean) ratio> <ratio (adj mean)>
Constant Reference profile 180 2.8   
race White 5 138 (2.4)     1        1    (2.4)
Black 1 009 (3.7) ×1.55*** ×1.47*** (3.5)
Other 664 (2.4) ×1.03    ×1.01    (2.4)
marital Married 3 230 (2.4)     1        1    (2.5)
Separated 230 (2.9) ×1.21*** ×1.06    (2.6)
Divorced 1 104 (2.5) ×1.08*** ×1.05*   (2.6)
Widowed 222 (2.9) ×1.24*** ×1.11**  (2.7)
Never married 2 025 (2.8) ×1.19*** ×1.07*** (2.7)
relig 1-Protestant 3 237 (2.7)     1        1    (2.6)
2-Catholic 1 618 (2.5) ÷1.09*** ×1.00    (2.7)
3-Other christian 272 (2.3) ÷1.18*** ÷1.18*** (2.2)
4-Jewish 109 (2.1) ÷1.27*** ÷1.11    (2.4)
5-Buddhist/Hinduist 77 (1.9) ÷1.40*** ÷1.26**  (2.1)
6-Muslim 35 (1.9) ÷1.44**  ÷1.58*** (1.7)
7-Other 140 (2.6) ÷1.03    ÷1.02    (2.6)
8-None 1 323 (2.4) ÷1.15*** ÷1.11*** (2.4)
rincome 1-Lt $10000 1 152 (3.1)     1        1    (3.0)
2-$10000 to 14999 663 (3.0) ÷1.03    ÷1.04    (2.9)
3-$15000 to 24999 1 158 (2.8) ÷1.09*** ÷1.10*** (2.8)
4-$25000 or more 3 838 (2.2) ÷1.39*** ÷1.34*** (2.3)
Model fit N 6 811
Dispersion (robust/model SE) 1.38
Pearson dispersion (phi) 1.45
Collinearity (max VIF) 1.11
Influence (max dfbetas) 0.68
LR vs null <0.01%
McFadden R2 0.033
AIC 26 179
BIC 26 295
Model: Poisson regression; IRR: incidence-rate ratio (vs the reference category); obs mean: observed mean; adj mean: adjusted/predicted mean.
Obs_IRR, Model_IRR — IRR ≥ ×1.1; ×1.2; ×1.5; ×2; IRR ≤ ÷1.1; ÷1.2; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log rate-ratio, 95% confidence; matching quasi-Poisson interval on the observed column) or under the first colour threshold (×1.1).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

The empirical average TV hours per day, and differences of rate of television watching from reference, can be computed in a simple table with :

tab(gss_simple, "race", "tvhours", pct = "row", digits = 2, na = "drop",
    color = "ratio", ref = 1,  ci_method = c(mean_ratio = "quasipoisson")
) |> 
  mutate(IRR = set_display(tvhours, "ratio"))
tvhours
race mean IRR
<mean (cv)> <mean-ratio>
White 2.77 (cv 84%) 1
Black 4.18 (cv 84%) ×1.51
Other 2.76 (cv 87%) ×1.00
Total 2.98 (cv 87%) ×1.08
Ratio of means: cell ≥ the reference category (in bold) ×1.1; ×1.2; ×1.5; ×2; cell ≤ ref ÷1.1; ÷1.2; ÷1.5; ÷2.
# the default method is a robust ratio of means (each group's own variance) ;
#  we use "quasipoisson" to match those computed by quasi-poisson regression -- one dispersion
#  estimated over every level (assumption : variance is proportional to mean). 

Grouped-binomial outcomes (a summed score)

When the outcome is a summed score — how many of several yes/no items a respondent answered one way — you model the number of “successes” out of a fixed number of items with trials =. tab_reg() then fits cbind(score, trials - score) as a binomial, so the odds ratios read on the per-item probability.

This is the natural model for a multiple-answer survey question — here the tea data prepared above, its six “where do you drink tea?” items summed into one score by score_from_lv1().

tab_reg(tea, "tea_where", c("sex", "SPC", "Sport"),
        family = "binomial", trials = length(tea_where_vars))
Logistic regression: tea_where by sex, SPC +1 more
tea_where
levels n Obs_OR Model_OR
<n> <(obs mean) OR> <OR (adj mean)>
Constant Reference profile 16 1/1.22    (2.7)
sex F 178 (2.8)      1         1    (2.8)
M 122 (2.3) 1/1.36*** 1/1.44*** (2.3)
SPC employee 59 (2.5)      1         1    (2.5)
middle 40 (2.5) 1/1.04      1.01    (2.5)
non-worker 64 (2.4) 1/1.12    1/1.14    (2.3)
other worker 20 (2.9)   1.29      1.31    (2.9)
senior 35 (2.9)   1.25      1.44**  (3.0)
student 70 (2.7)   1.14      1.11    (2.6)
workman 12 (2.2) 1/1.21    1/1.08    (2.4)
Sport sportsman 179 (2.6)      1         1    (2.6)
Not sportsman 121 (2.6)   1.02      1.02    (2.6)
Model fit N 300
Dispersion (robust/model SE) 1.04
Pearson dispersion (phi) 1.02
Collinearity (max VIF) 1.13
Influence (max dfbetas) 0.46
LR vs null 0.99%
McFadden R2 0.021
AIC 971
BIC 1 004
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

Each odds ratio now reads for any one place: 1/1.44 for men means that, for any of the six places, men are about 1.44 times less likely than women to drink tea there. The model treats the six items as interchangeable trials, so one odds ratio covers them all — which is exactly the assumption to keep in mind before using it.

Grouped-binomial (like Poisson) models report a Pearson dispersion check in the footer, flagging over-dispersed counts.

Ordinal and nominal outcomes (a factor with 3+ levels)

An ordered factor outcome is fit as a proportional-odds (cumulative) logistic model, and it reports in one column: its whole claim is that one number per predictor level is enough, so it never spends a column per outcome category. By default that number is the cumulative odds ratio cumOR — the model’s own coefficient, and the one measure the proportional-odds assumption makes the same at every cut:

tab_reg(gss_simple, "rincome", c("race", "age", "relig"))
Ordinal logistic regression: rincome by race, age +1 more
rincome
levels n Obs_cumOR Model_cumOR
<n> <OR> <OR>
Constant Reference profile
race White 9 846 1   
Black 1 860 1/1.55*** 1/1.40***
Other 1 254 1/1.51*** 1/1.45***
age per 34.6 (2SD), at 47.2 (mean) 2.38*** 2.28***
relig 1-Protestant 6 269 1   
2-Catholic 3 121 1.09**  1.15***
3-Other christian 507 1/1.10    1.02   
4-Jewish 222 2.33*** 2.00***
5-Buddhist/Hinduist 144 1.93*** 2.41***
6-Muslim 56 1.01    1.32   
7-Other 267 1/1.06    1.09   
8-None 2 374 1/1.05    1.05   
Model fit N 12 960
Proportionality (Brant) 0.89%
Dispersion (robust/model SE) 1.16
Collinearity (max VIF) 1.07
Influence (max dfbetas) 0.23
LR vs null <0.01%
McFadden R2 0.017
AIC 29 193
BIC 29 290
Model: ordinal logistic regression; cumOR: cumulative odds ratio (proportional-odds model); sup%: probability of superiority, 50 % being a coin flip.
rincome, from low to high: 1-Lt $10000 (17%) < 2-$10000 to 14999 (9%) < 3-$15000 to 24999 (18%) < 4-$25000 or more (57%).
Obs_cumOR, Model_cumOR — cumOR ≥ 1.2; 1.5; 2; 4; cumOR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log scale, 95% confidence) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

A cumulative odds ratio is hard to say out loud, so ask for another measure of deviation and the same fit is read as a probability. measure = "difference" gives Somers’ Dof two people, one from this group and one from the reference group, how often does the one from this group end up higher on the scale? — with that probability itself in brackets, 50 % being a coin flip:

tab_reg(gss_simple, "rincome", c("race", "age", "relig"),
        measure = "difference", display = "est_base")
Ordinal logistic regression: rincome by race, age +1 more
rincome
levels n Obs_D Model_mD
<n> <(sup%) diff> <diff (sup%)>
Constant Population average 12 960
race White 9 846 (50%)     0%        0%    (50%)
Black 1 860 (44%) -12.2%***  -9.1%*** (45%)
Other 1 254 (44%) -11.2%*** -10.0%*** (45%)
age per 34.6 (2SD), at 47.2 (mean)       +19.6%*** +18.7%***      
relig 1-Protestant 6 269 (50%)     0%        0%    (50%)
2-Catholic 3 121 (51%)  +2.3%**   +3.6%*** (52%)
3-Other christian 507 (49%)  -2.7%     +0.5%    (50%)
4-Jewish 222 (60%) +19.3%*** +16.5%*** (58%)
5-Buddhist/Hinduist 144 (58%) +15.6%*** +20.2%*** (60%)
6-Muslim 56 (50%)  +0.3%     +7.1%    (54%)
7-Other 267 (49%)  -1.6%     +2.2%    (51%)
8-None 2 374 (49%)  -1.4%     +1.3%    (51%)
Model fit N 12 960
Proportionality (Brant) 0.89%
Dispersion (robust/model SE) 1.16
Collinearity (max VIF) 1.07
Influence (max dfbetas) 0.23
LR vs null <0.01%
McFadden R2 0.017
AIC 29 193
BIC 29 290
Model: ordinal logistic regression; mD: marginal Somers’ D (how often someone from this group ends up higher than someone from the reference group, as a difference in percentage points, wins minus losses; sample-averaged); sup%: probability of superiority, 50 % being a coin flip.
rincome, from low to high: 1-Lt $10000 (17%) < 2-$10000 to 14999 (9%) < 3-$15000 to 24999 (18%) < 4-$25000 or more (57%).
Obs_D, Model_mD — D ≥ +5; +10; +20; +30 points; D ≤ -5; -10; -20; -30 points. Uncoloured: not significantly different from the reference category (Wald interval, 95% confidence) or under the first colour threshold (±5 points).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

measure = "ratio" reads the same pair multiplicatively, as a win ratio (wins to losses). Both still take one column, because both read the whole predicted distribution rather than one slice of it — and both are robust where the cumulative odds ratio is not: they barely move when the proportional-odds assumption fails, and, unlike an odds ratio, they do not drift under adjustment when there is nothing to adjust for. The footer names the scale from low to high, since a one-column table shows the outcome’s name and none of its categories.

If you want a number per outcome category — a percentage-point effect on each income band — that is a question the ordering does not help with, and family = "multinomial" answers it without assuming proportional odds.

A nominal outcome with three or more unordered levels is fit as one multinomial logistic model, giving one odds-ratio column per outcome category versus the reference category (also called relative risks ratios) :

tab_reg(gss_simple, "party3", c("race", "age", "rincome", "relig"))
Multinomial logistic regression: party3 by race, age +2 more
party3: OR
levels n 2-Independent,
other vs
1-Democrat
3-Republican
vs 1-Democrat
<n> <OR (adj%)>
Constant Reference profile 1/1.52*** (40%)    1.53*** (61%)
race White 9 821      1    (21%)       1    (41%)
Black 1 850 1/3.03*** (14%) 1/12.89*** ( 7%)
Other 1 243   1.05    (27%)  1/2.37*** (23%)
age per 26.9 (2SD), at 42.4 (mean) 1/1.37***        1/1.14***      
rincome 1-Lt $10000 2 128      1    (25%)       1    (31%)
2-$10000 to 14999 1 156   1.01    (26%)  1/1.21**  (28%)
3-$15000 to 24999 2 317   1.00    (25%)  1/1.08    (30%)
4-$25000 or more 7 313 1/1.53*** (17%)    1.17**  (37%)
relig 1-Protestant 6 253      1    (17%)       1    (44%)
2-Catholic 3 105 1/1.11    (20%)  1/1.78*** (32%)
3-Other christian 506   1.13    (21%)  1/1.27**  (37%)
4-Jewish 222 1/2.96*** (11%)  1/4.88*** (18%)
5-Buddhist/Hinduist 140 1/1.33    (22%)  1/4.83*** (16%)
6-Muslim 55 1/2.36**  (14%)  1/5.96*** (15%)
7-Other 265   1.10    (25%)  1/2.56*** (24%)
8-None 2 368   1.22*** (29%)  1/3.54*** (18%)
Model fit N 12 914
Dispersion (robust/model SE) 1.05
Influence (max dfbetas) 0.47
LR vs null <0.01%
McFadden R2 0.083
AIC 24 939
BIC 25 148
Model: multinomial logistic regression; OR: odds ratio (each category vs the reference); adj%: adjusted/predicted proportion.
OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

Relative risk ratios may be quite difficult to read because they are relative to two reference levels : not only the reference chosen for the predictor, but also the reference level chosen for the dependent variable. It’s specially difficult when it’s difficult to find a good reference level corresponding to the most common situation (like “married” for matrimonial status).

Most of the time, asking for percentage points instead is easier to interpret, because it makes the second reference level disappear and modelises, for each level of the outcome, the difference of percentages of each predictor level compared to its reference (a less abstract quantity than odds ratios). It is measure = "difference", exactly as on a binary outcome, and the resulting mRD columns are average marginal effects:

tab_reg(gss_simple, "party3", c("race", "age", "rincome", "relig"), measure = "difference", empirical = TRUE) # |> tab_export()
Multinomial logistic regression: party3 by race, age +2 more
party3: mRD
levels n 1-Democrat 2-Independent,
other
3-Republican
<n> <diff (adj%)>
Constant Population average 12 914 45.3%    20.4%    34.3%   
race White 9 821     0%    (38%)     0%    (21%)     0%    (41%)
Black 1 850 +40.6%*** (79%)  -6.6%*** (14%) -34.0%*** ( 7%)
Other 1 243 +11.0%*** (49%)  +6.8%*** (27%) -17.8%*** (23%)
age per 26.9 (2SD), at 42.4 (mean)  +4.5%***        -3.8%***        -0.7%         
rincome 1-Lt $10000 2 128     0%    (44%)     0%    (25%)     0%    (31%)
2-$10000 to 14999 1 156  +2.0%    (46%)  +1.6%    (26%)  -3.6%**  (28%)
3-$15000 to 24999 2 317  +0.9%    (45%)  +0.5%    (25%)  -1.4%    (30%)
4-$25000 or more 7 313  +1.8%    (46%)  -7.7%*** (17%)  +5.9%*** (37%)
relig 1-Protestant 6 253     0%    (39%)     0%    (17%)     0%    (44%)
2-Catholic 3 105  +9.2%*** (48%)  +2.4%*** (20%) -11.5%*** (32%)
3-Other christian 506  +2.4%    (42%)  +3.7%**  (21%)  -6.1%*** (37%)
4-Jewish 222 +31.4%*** (71%)  -6.0%*** (11%) -25.4%*** (18%)
5-Buddhist/Hinduist 140 +23.1%*** (62%)  +4.4%    (22%) -27.5%*** (16%)
6-Muslim 55 +31.6%*** (71%)  -3.1%    (14%) -28.5%*** (15%)
7-Other 265 +11.7%*** (51%)  +8.0%*** (25%) -19.6%*** (24%)
8-None 2 368 +13.4%*** (53%) +11.9%*** (29%) -25.4%*** (18%)
Model fit N 12 914
Dispersion (robust/model SE) 1.05
Influence (max dfbetas) 0.47
LR vs null <0.01%
McFadden R2 0.083
AIC 24 939
BIC 25 148
Model: multinomial logistic regression; mRD: marginal risk difference (on adjusted proportions, in percentage points, sample-averaged); adj%: adjusted/predicted proportion.
RD ≥ +5; +10; +20; +30 points; RD ≤ -5; -10; -20; -30 points. Uncoloured: not significantly different from the reference category (Wald interval, 95% confidence) or under the first colour threshold (±5 points).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

A 3+ level outcome would need one crude column per outcome category, so empirical = TRUE folds the crude effect into the model cell instead — +40.6% (+38.6%), modelled then observed — and the crude percentages and differences appear in html tooltips, at mouse hover of a cell. Ask for the columns anyway with empirical = "column".

4. Reading what adjustment did

Colouring what adjustment did

empirical = TRUE puts the crude effect next to the modelled one, but comparing them cell by cell is tedious on a real table. color = "adjustment" colours the gap between the two, so a whole table of “what did adjusting change?” reads at a glance. Put it on the background channel and the text keeps showing the effect size, so one look answers both questions:

tab_reg(gss_simple, "married", c("race", "rincome", "relig"),
        empirical = TRUE, color = c(TRUE, "adjustment"))
Logistic regression: married by race, rincome +1 more
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 715 1/1.20*** (45%)
race White 9 863 (52%)      1         1    (52%)
Black 1 866 (31%) 1/2.44*** 1/2.54*** (30%)
Other 1 256 (49%) 1/1.11*     1.01    (52%)
rincome 1-Lt $10000 2 146 (37%)      1         1    (37%)
2-$10000 to 14999 1 164 (41%)   1.20**    1.19**  (42%)
3-$15000 to 24999 2 328 (43%)   1.32***   1.32*** (44%)
4-$25000 or more 7 347 (55%)   2.12***   2.05*** (55%)
relig 1-Protestant 6 282 (52%)      1         1    (54%)
2-Catholic 3 127 (52%) 1/1.03    1/1.22*** (49%)
3-Other christian 508 (42%) 1/1.53*** 1/1.58*** (43%)
4-Jewish 223 (54%)   1.09    1/1.23    (49%)
5-Buddhist/Hinduist 144 (50%) 1/1.09    1/1.40*   (46%)
6-Muslim 56 (48%) 1/1.17    1/1.02    (53%)
7-Other 267 (35%) 1/2.04*** 1/2.26*** (35%)
8-None 2 378 (38%) 1/1.76*** 1/1.99*** (38%)
Model fit N 12 985
Dispersion (robust/model SE) 1.02
Collinearity (max VIF) 1.07
Influence (max dfbetas) 0.20
LR vs null <0.01%
McFadden R2 0.043
AIC 17 245
BIC 17 343
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Background colour: the observed effect (the reference for the adjustment). Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; Woolf closed form) or under the first colour threshold (×1.2). Part of an odds-ratio gap is non-collapsibility, not confounding: a risk ratio or a marginal effect is the collapsible comparison.
Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Background colour: OR further from no effect (1) than the observed column, by ×1.1; ×1.25; ×1.5; ×2; OR closer to no effect (1) than the observed column (or inversed effect), by ÷1.1; ÷1.25; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence) or under the first colour threshold (×1.2). Part of an odds-ratio gap is non-collapsibility, not confounding: a risk ratio or a marginal effect is the collapsible comparison.
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

Text colour = how strong the adjusted odds ratio is. Background = how far it sits from the observed one: one pole means adjustment strengthened the effect (it moved further from “no effect”), the other that it attenuated it (it moved closer). An effect that changed direction under adjustment — crude above 1, modelled below it — counts as attenuated, and strongly so: whatever the raw association said, the model says it is not that. You can see the reversal itself in the pair of cells, whose × and ÷ face opposite ways. The first threshold is ×1.1, the classic “a 10% change in the estimate is worth noticing” convention; the rest are ×1.25, ×1.5 and ×2 (change them with set_color_breaks(adj_ratio = ...)). An additive effect gets an additive ladder instead: percentage points for a marginal effect on a probability (±2 / ±5 / ±10 / ±20), and standard deviations of the outcome for a linear mean difference (±0.05 / ±0.1 / ±0.2 / ±0.4) — so the same model reads the same way whether the outcome is recorded in hours, minutes or days. You do not have to ask for empirical separately — the colour needs the observed effect, so it turns it on for you.

The direction is measured from the null, not up or down. A protective effect (OR below 1) that adjustment pulls toward 1 gets the same colour as a risky effect pulled toward 1, which is what you want to read: “the other variables explained part of this association” is one statement, whichever side of 1 the effect is on.

The same gap is readable as a number when you want the exact values, in the cell or on hover. display = writes the template at build time (part 5, Fine-tuning the display); on a finished table set_display() does the same — the columns that have no observed counterpart simply keep their plain display:

tab_reg(gss_simple, "married", c("race", "rincome"), empirical = TRUE) |>
  set_display("{est} (obs {obs})")
Logistic regression: married by race, rincome
married: 01-Married
levels n Obs_OR Model_OR
<n> <OR (obs)> <OR (obs)>
Constant Reference profile 1 506 1/1.51***             
race White 9 879      1         1    (obs      1)
Black 1 873 1/2.45*** 1/2.33*** (obs 1/2.45)
Other 1 263 1/1.11*   1/1.04    (obs 1/1.11)
rincome 1-Lt $10000 2 153      1         1    (obs      1)
2-$10000 to 14999 1 168   1.21**    1.21*** (obs   1.21)
3-$15000 to 24999 2 331   1.33***   1.33*** (obs   1.33)
4-$25000 or more 7 363   2.13***   2.07*** (obs   2.13)
Model fit N 13 015
Dispersion (robust/model SE) 1.00
Collinearity (max VIF) 1.00
Influence (max dfbetas) 0.04
LR vs null <0.01%
McFadden R2 0.031
AIC 17 483
BIC 17 528
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

One warning about odds ratios

An odds ratio changes when you adjust it even when there is nothing to adjust for. This is called non-collapsibility: it is a property of the odds ratio itself, not a sign of confounding. In a simulation where the extra variable is independent of the exposure — so there is strictly no confounding — the crude odds ratio still moved by about 8% once adjusted, while the risk ratio moved by 0.3% and the marginal effect by essentially nothing. Eight percent is about the size of the first colour step, so on the odds-ratio scale a faint background colour may be arithmetic rather than confounding. The legend of an exported table says so.

The comparisons that are clean are the ones the previous sections introduced: measure = "difference" (percentage points), measure = "ratio" (risk ratios), link = "ratio" (the modified Poisson’s own risk ratio), the mean difference of a linear regression — and, if you must publish odds ratios, measure = "odds_ratio", effect = "marginal", whose marginal odds ratio is collapsible. On those scales the gap is confounding by the variables you added — nothing else. If reading confounding is the point of your table, prefer one of them:

tab_reg(gss_simple, "married", c("race", "rincome", "relig"),
        measure = "ratio", empirical = TRUE, color = c(TRUE, "adjustment"))
Logistic regression: married by race, rincome +1 more
married: 01-Married
levels n Obs_RR Model_mRR
<n> <(obs%) ratio> <ratio (adj%)>
Constant Population average 12 985 49%   
race White 9 863 (52%)     1        1    (52%)
Black 1 866 (31%) ÷1.69*** ÷1.71*** (30%)
Other 1 256 (49%) ÷1.05*   ×1.00    (52%)
rincome 1-Lt $10000 2 146 (37%)     1        1    (37%)
2-$10000 to 14999 1 164 (41%) ×1.12**  ×1.11**  (42%)
3-$15000 to 24999 2 328 (43%) ×1.18*** ×1.17*** (44%)
4-$25000 or more 7 347 (55%) ×1.50*** ×1.45*** (55%)
relig 1-Protestant 6 282 (52%)     1        1    (54%)
2-Catholic 3 127 (52%) ÷1.01    ÷1.10*** (49%)
3-Other christian 508 (42%) ÷1.26*** ÷1.25*** (43%)
4-Jewish 223 (54%) ×1.04    ÷1.10    (49%)
5-Buddhist/Hinduist 144 (50%) ÷1.04    ÷1.17*   (46%)
6-Muslim 56 (48%) ÷1.08    ÷1.01    (53%)
7-Other 267 (35%) ÷1.50*** ÷1.55*** (35%)
8-None 2 378 (38%) ÷1.37*** ÷1.43*** (38%)
Model fit N 12 985
Dispersion (robust/model SE) 1.02
Collinearity (max VIF) 1.07
Influence (max dfbetas) 0.20
LR vs null <0.01%
McFadden R2 0.043
AIC 17 245
BIC 17 343
Model: logistic regression; mRR: marginal risk ratio (the ratio of adjusted proportions, sample-averaged); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_RR — RR ≥ ×1.1; ×1.2; ×1.5; ×2; RR ≤ ÷1.1; ÷1.25; ÷2; ÷4. Background colour: the observed effect (the reference for the adjustment). Uncoloured: not significantly different from the reference category (Wald interval on the log risk-ratio, 95% confidence; Katz closed form) or under the first colour threshold (×1.1).
Model_mRR — RR ≥ ×1.1; ×1.2; ×1.5; ×2; RR ≤ ÷1.1; ÷1.25; ÷2; ÷4. Background colour: RR further from no effect (1) than the observed column, by ×1.1; ×1.25; ×1.5; ×2; RR closer to no effect (1) than the observed column (or inversed effect), by ÷1.1; ÷1.25; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log risk-ratio, 95% confidence) or under the first colour threshold (×1.1). Some rows carry no test and are left uncoloured. Background: the same rule, applied to the gap with the observed effect (z test on the difference between two estimates fitted on the same sample, 95% confidence).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

Is the gap bigger than noise?

A background colour tells you the model moved an effect. It does not, on its own, tell you the move is real: with 20 000 respondents a tiny shift is solid, with 300 a large one may be luck. color_signif answers that, exactly as it does everywhere else in tabxplor — add "grey_non_signif" and a coloured background now means “the model really changed this effect”:

tab_reg(gss_simple, "married", c("race", "rincome", "relig"),
        link = "ratio", empirical = TRUE,
        color = c(TRUE, "adjustment"), color_signif = "grey_non_signif")
Logistic regression: married by race, rincome +1 more
married: 01-Married
levels n Obs_RR Model_RR
<n> <(obs%) ratio> <ratio (adj%)>
Constant Reference profile 715 44%   
race White 9 863 (52%)     1        1    (52%)
Black 1 866 (31%) ÷1.69*** ÷1.70*** (30%)
Other 1 256 (49%) ÷1.05*   ×1.01    (52%)
rincome 1-Lt $10000 2 146 (37%)     1        1    (37%)
2-$10000 to 14999 1 164 (41%) ×1.12**  ×1.11**  (42%)
3-$15000 to 24999 2 328 (43%) ×1.18*** ×1.17*** (44%)
4-$25000 or more 7 347 (55%) ×1.50*** ×1.46*** (54%)
relig 1-Protestant 6 282 (52%)     1        1    (54%)
2-Catholic 3 127 (52%) ÷1.01    ÷1.10*** (49%)
3-Other christian 508 (42%) ÷1.26*** ÷1.26*** (43%)
4-Jewish 223 (54%) ×1.04    ÷1.10    (49%)
5-Buddhist/Hinduist 144 (50%) ÷1.04    ÷1.17*   (46%)
6-Muslim 56 (48%) ÷1.08    ÷1.01    (53%)
7-Other 267 (35%) ÷1.50*** ÷1.55*** (35%)
8-None 2 378 (38%) ÷1.37*** ÷1.43*** (38%)
Model fit N 12 985
Dispersion (robust/model SE) 1.00
Collinearity (max VIF) 1.06
Influence (max dfbetas) 0.20
Wald vs null <0.01%
Model: modified Poisson regression; RR: risk ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_RR — RR ≥ ×1.1; ×1.2; ×1.5; ×2; RR ≤ ÷1.1; ÷1.25; ÷2; ÷4. Background colour: the observed effect (the reference for the adjustment). Uncoloured: not significantly different from the reference category (Wald interval on the log risk-ratio, 95% confidence; Katz closed form) or under the first colour threshold (×1.1).
Model_RR — RR ≥ ×1.1; ×1.2; ×1.5; ×2; RR ≤ ÷1.1; ÷1.25; ÷2; ÷4. Background colour: RR further from no effect (1) than the observed column, by ×1.1; ×1.25; ×1.5; ×2; RR closer to no effect (1) than the observed column (or inversed effect), by ÷1.1; ÷1.25; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log risk-ratio, 95% confidence) or under the first colour threshold (×1.1). Some rows carry no test and are left uncoloured. Background: the same rule, applied to the gap with the observed effect (z test on the difference between two estimates fitted on the same sample, 95% confidence).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

Here link = "ratio" gives the modified Poisson’s own risk ratios, one of the clean scales from the previous section. Text colour = how strong the adjusted risk ratio is. Background = how far it sits from the observed one, greyed out when that distance could be chance. Hovering a cell in the html table gives the exact numbers: the observed effect, the size of the gap, its confidence interval and its p-value.

One example is worth the whole explanation. Read the race block of that table, comparing each group with white respondents, before and after holding income and religion equal:

scale Black vs White Other vs White
odds ratio, crude → model 1/2.441/2.54 1/1.111.01
risk ratio, crude → model ÷1.7÷1.7 ÷1.1×1.0
did the risk ratio really move? no (p = 0.55) yes (p < 0.001)

Two different stories, and the test separates them. For Black respondents nothing moves: their much lower marriage rate is not explained by income or religion — it survives the adjustment untouched. For the “Other” group the crude 5% deficit disappears once income and religion are held equal, and the gap between the two numbers is far too big to be luck: that difference was explained by them.

Notice that the odds ratios move too — including for Black respondents, where the risk ratio says nothing happened. That is the non-collapsibility of the previous section, showing up as a phantom “change”. It is why color_signif is not applied to plain odds ratios: tab_reg() leaves those colours descriptive and says so once, pointing at measure = "difference", measure = "ratio" or link = "ratio".

The greying earns its keep on smaller samples. With 20 000 respondents almost every visible move is real; on 2 000 the same table keeps one background colour out of three:

set.seed(1)
small <- gss_simple[sample(nrow(gss_simple), 2000), ]
tab_reg(small, "married", c("race", "rincome", "relig"),
        link = "ratio", empirical = TRUE,
        color = c(TRUE, "adjustment"), color_signif = "grey_non_signif")
Logistic regression: married by race, rincome +1 more
married: 01-Married
levels n Obs_RR Model_RR
<n> <(obs%) ratio> <ratio (adj%)>
Constant Reference profile 73 47%   
race White 936 (52%)     1        1    (52%)
Black 166 (34%) ÷1.52*** ÷1.53*** (34%)
Other 112 (54%) ×1.04    ×1.05    (55%)
rincome 1-Lt $10000 201 (41%)     1        1    (42%)
2-$10000 to 14999 106 (41%) ÷1.02    ×1.00    (42%)
3-$15000 to 24999 237 (45%) ×1.09    ×1.09    (45%)
4-$25000 or more 670 (56%) ×1.36*** ×1.33*** (55%)
relig 1-Protestant 584 (52%)     1        1    (54%)
2-Catholic 275 (57%) ×1.08    ÷1.01    (54%)
3-Other christian 52 (38%) ÷1.36*   ÷1.38*   (39%)
4-Jewish 18 (61%) ×1.17    ÷1.02    (53%)
5-Buddhist/Hinduist 18 (61%) ×1.17    ×1.03    (56%)
6-Muslim 5 (60%) ×1.15    ×1.04    (56%)
7-Other 24 (46%) ÷1.14    ÷1.22    (45%)
8-None 238 (38%) ÷1.39*** ÷1.46*** (37%)
Model fit N 1 214
Dispersion (robust/model SE) 1.00
Collinearity (max VIF) 1.11
Influence (max dfbetas) 0.70
Wald vs null <0.01%
Model: modified Poisson regression; RR: risk ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_RR — RR ≥ ×1.1; ×1.2; ×1.5; ×2; RR ≤ ÷1.1; ÷1.25; ÷2; ÷4. Background colour: the observed effect (the reference for the adjustment). Uncoloured: not significantly different from the reference category (Wald interval on the log risk-ratio, 95% confidence; Katz closed form) or under the first colour threshold (×1.1).
Model_RR — RR ≥ ×1.1; ×1.2; ×1.5; ×2; RR ≤ ÷1.1; ÷1.25; ÷2; ÷4. Background colour: RR further from no effect (1) than the observed column, by ×1.1; ×1.25; ×1.5; ×2; RR closer to no effect (1) than the observed column (or inversed effect), by ÷1.1; ÷1.25; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log risk-ratio, 95% confidence) or under the first colour threshold (×1.1). Some rows carry no test and are left uncoloured. Background: the same rule, applied to the gap with the observed effect (z test on the difference between two estimates fitted on the same sample, 95% confidence).
***: significantly different from the reference category (in bold) at the 99% confidence level; **: at the 95% level; *: at the 90% level; no star: not significant.

For the record: what exactly is tested

The "adjustment" colour always shows the size of the gap. Whether it also tests it depends on one property of the measure, and the table decides for you:

what you asked for gap tested?
measure = "difference" — any outcome yes
measure = "ratio", or link = "ratio" (risk ratios) yes
measure = "odds_ratio", effect = "marginal" (binary or summed score) yes
Poisson incidence-rate ratios, a linear mean difference yes
conditional odds ratios — binomial, multinomial, ordinal cumulative no (non-collapsible)
a compound formula = no
a weighted 3+ level outcome it can only be read on its coefficients

Where there is no test, color_signif is ignored for that channel and the colours read descriptively — the table never pretends to a significance it does not have.

The null is that the modelled and observed effects are equal, on the effect’s own scale: the log-ratio for a risk ratio, an odds ratio or an incidence-rate ratio, the plain difference for a mean difference or a marginal effect in points. That is the scale the colour already folds around, so the test and the colour cannot drift apart.

The two estimates come from the same rows, so they are correlated and their difference has a smaller standard error than either alone. The correct variance is the sampling variance of the difference of their influence functions — what Stata calls seemingly-unrelated estimation (Weesie 1999; Mize, Doan & Long 2019 is the sociological statement). Two things make it exact rather than approximate here: every observed effect tabxplor prints is the coefficient of a saturated one-predictor model, so its influence function is a closed-form expression whose standard error is the interval already shown; and with survey weights or a design the variance goes through survey’s own linearization, so strata, clusters and finite-population corrections are respected. The reference distribution is the normal (z), which is mildly conservative in small samples.

The three policies read that same interval:

color_signif what the background shows
"ignore" (default in tab()) the size of the gap, coloured whether or not it is significant
"grey_non_signif" (default in tab_reg()) the same, greyed unless the gap’s interval excludes “no change”
"guaranteed_effect" the floor of the gap: “adjustment moved this effect by at least ×1.1” (on a difference scale, “by at least 2 points”)

Stars and colour do not say the same thing, and the combination is the useful part. The stars keep reading each estimate’s own p-value — “is this effect different from 1?” — while the colour reads the gap’s. All four combinations occur in one small table:

level stars p (effect) p (gap) coloured
Black *** < 0.0001 0.55 no — the gap is far below the first threshold
Jewish 0.12 < 0.0001 yes
Muslim 0.92 0.12 no
Buddhist/Hinduist * 0.057 < 0.0001 yes

Read starred and uncoloured as a robust effect — adjustment barely touched it — and unstarred and coloured as an effect that only the crude table showed. Jewish is the instructive row: no effect worth reporting, but adjustment moved it a lot, and significantly.

Two more details. The direction is measured from the null, so a protective effect pulled toward 1 colours like a risky one pulled toward 1. And the test needs both estimates computed on the same people: at effect = "at_reference", or under na = "drop_by_model" when a compared model drops different missing rows than the observed columns, the comparison is not made at all. (By default it always is — see just below.)

5. Shaping the table

Fine-tuning the display, and scaling a predictor

A few arguments change what each cell shows, or how a predictor is scaled — without changing the model itself.

display chooses the cell layout — the same named layouts tab() offers, in the same {} grammar. "est_ci" shows the confidence interval beside every estimate (any family):

tab_reg(gss_simple, "married", c("race", "age"), display = "est_ci")
Logistic regression: married by race, age
married: 01-Married
levels n Obs_OR Model_OR
<n> <OR ci> <OR ci>
Constant Reference profile   1.02      [1/1.01;1.05]
race White 16 341      1                         1                   
Black 3 115 1/2.68*** [1/2.91;1/2.46] 1/2.58*** [1/2.81;1/2.37]
Other 1 951 1/1.14*** [1/1.25;1/1.04] 1/1.05      [1/1.16;1.04]
age per 34.6 (2SD), at 47.2 (mean)   1.40***     [1.33;1.48]   1.34***     [1.27;1.41]
Model fit N 21 407
Dispersion (robust/model SE) 1.00
Collinearity (max VIF) 1.03
Influence (max dfbetas) 0.03
LR vs null <0.01%
McFadden R2 0.023
AIC 28 933
BIC 28 965
Model: logistic regression; OR: odds ratio (vs the reference category).
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

"est_base" folds the model-adjusted prediction into the estimate cell — an adjusted probability on a logistic model, an adjusted mean on a linear or a count one, each column answering with its own quantity. "base_est" swaps them, so the table reads as adjusted predictions graded by the effect, and "base" shows the predictions alone:

tab_reg(gss_simple, "married", c("race", "age"), display = "est_base")
Logistic regression: married by race, age
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile   1.02    (50%)
race White 16 341 (51%)      1         1    (50%)
Black 3 115 (28%) 1/2.68*** 1/2.58*** (28%)
Other 1 951 (48%) 1/1.14*** 1/1.05    (49%)
age per 34.6 (2SD), at 47.2 (mean)         1.40***   1.34***      
Model fit N 21 407
Dispersion (robust/model SE) 1.00
Collinearity (max VIF) 1.03
Influence (max dfbetas) 0.03
LR vs null <0.01%
McFadden R2 0.023
AIC 28 933
BIC 28 965
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

Note the difference: display = "est_base" keeps the odds-ratio column and adds the adjusted probability beside it, whereas measure = "difference" (seen above) turns the whole column into a marginal effect in points.

multiplier chooses the unit a continuous predictor’s effect is reported per. One unit is rarely a readable amount: a one-year change in age barely moves the odds, so its odds ratio sits near 1, never crosses a colour threshold, and the row reads as “no effect” — while a whole standard deviation of age multiplies the odds by about 0.66. So the default is per two standard deviations, and the row says which (per 34.6 (2SD)) — the variable is named in the column beside it. Two standard deviations is roughly the span of a binary predictor, which is the point: it puts a continuous row and a two-level row on the same footing, so a whole table can be read down its column:

tab_reg(gss_simple, "married", c("race", "age"))
Logistic regression: married by race, age
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile   1.02    (50%)
race White 16 341 (51%)      1         1    (50%)
Black 3 115 (28%) 1/2.68*** 1/2.58*** (28%)
Other 1 951 (48%) 1/1.14*** 1/1.05    (49%)
age per 34.6 (2SD), at 47.2 (mean)         1.40***   1.34***      
Model fit N 21 407
Dispersion (robust/model SE) 1.00
Collinearity (max VIF) 1.03
Influence (max dfbetas) 0.03
LR vs null <0.01%
McFadden R2 0.023
AIC 28 933
BIC 28 965
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

Pass a single value to change it for every continuous predictor, or a named vector to override chosen ones — "sd", "2sd" (roughly bottom to top of the distribution), or a number of units. multiplier = 1 gives the raw per-one-unit effect:

tab_reg(gss_simple, "married", c("race", "age"), multiplier = c(age = 10))
Logistic regression: married by race, age
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile   1.02    (50%)
race White 16 341 (51%)      1         1    (50%)
Black 3 115 (28%) 1/2.68*** 1/2.58*** (28%)
Other 1 951 (48%) 1/1.14*** 1/1.05    (49%)
age per 10, at 47.2 (mean)         1.10***   1.09***      
Model fit N 21 407
Dispersion (robust/model SE) 1.00
Collinearity (max VIF) 1.03
Influence (max dfbetas) 0.03
LR vs null <0.01%
McFadden R2 0.023
AIC 28 933
BIC 28 965
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

Everything scales together — the estimate, its interval, the observed Obs_* companion and the model-versus-observed comparison — and the p-value never changes. Two things worth knowing: because the default is not 1, a continuous predictor’s Model_OR will not match exp(coef(glm(...))) unless you ask for multiplier = 1; and the standard deviation is measured once, on the predictors’ complete cases, so the same variable keeps the same unit across several outcomes, compared models and tab_vars groups.

The reference a predictor’s effect is measured from

ref names the reference — for a factor, the level the others are compared against; for a continuous predictor, the value it is anchored at:

tab_reg(gss_simple, "married", c("race", "age"), ref = c(race = "Black", age = 40))
Logistic regression: married by race, age
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 1/2.69*** (27%)
race Black 3 115 (28%)    1         1    (28%)
White 16 341 (51%) 2.68***   2.58*** (50%)
Other 1 951 (48%) 2.35***   2.45*** (49%)
age per 34.6 (2SD), at 40       1.40***   1.34***      
Model fit N 21 407
Dispersion (robust/model SE) 1.00
Collinearity (max VIF) 1.03
Influence (max dfbetas) 0.03
LR vs null <0.01%
McFadden R2 0.023
AIC 28 933
BIC 28 965
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

The two halves are one idea, and they share one grammar with multiplier and shape: a value written without a variable name is the default for every predictor it can apply to, a named one overrides that variable. So shape = "quintiles" cuts every continuous predictor, multiplier = c("2sd", age = 10) reads “per two standard deviations, except age, per decade”, and ref = c("median", "last", race = "Black") sets both defaults at once — the value itself says which kind of predictor it is for (a number or "mean" / "median" / "min" / "max" for a continuous one, "first" / "last" for a factor).

Anchoring a continuous predictor does not change its own effect: a slope is the same wherever you start reading it from. The predictor’s row says where its anchor sits, right beside the unit its effect is per — per 34.6 (2SD), at 47.2 (mean), or at 0 (min) — so the Constant row’s profile is readable from the table itself. What it changes is the Constant row, and any term the predictor interacts with. That is why the default is the mean rather than zero: nobody is 0 years old, so the intercept of a raw fit describes nobody. Read the Constant row as the baseline the rest of the column moves away from — a baseline odds on an odds-ratio column, a baseline probability or mean on an additive one — and its label says where that baseline sits (Reference profile, or Population average on a marginal column). If a predictor’s zero is meaningful, say so: ref = c(tvhours = 0).

Order matters in one place, and it is stated rather than guessed: shape recodes the column first (it defines what the model’s variable is), then the anchor applies to the result. So ref on a "log"-shaped predictor anchors the log, and a "quartiles"-shaped one has become a factor and takes a level name.

Interactions: one predictor’s effect depending on another

The section above compares sub-populations — one model per group. An interaction asks the same question inside one model, where a third variable can be adjusted for across the whole sample. Write it in predictors with R’s own *, bare or quoted:

tab_reg(gss_simple, "married", c(race*party3, relig), empirical = TRUE)
Logistic regression: married by race*party3, relig
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 2 648 1/1.04    (49%)
race*party3 White · 1-Democrat 6 369 (45%)      1         1         
Black · 1-Democrat 2 338 (29%) 1/2.07*** 1/2.25***      
Other · 1-Democrat 942 (44%) 1/1.06    1/1.06         
White · 2-Independent, other 3 347 (46%)   1.01      1.04         
Black · 2-Independent, other 506 (25%) 1/2.45*** 1/2.52***      
Other · 2-Independent, other 624 (50%)   1.18**    1.19**       
White · 3-Republican 6 537 (59%)   1.71***   1.61***      
Black · 3-Republican 236 (27%) 1/2.23*** 1/2.38***      
Other · 3-Republican 355 (54%)   1.42***   1.36***      
relig 1-Protestant 10 794 (50%)      1         1         
2-Catholic 5 090 (50%)   1.00    1/1.11***      
3-Other christian 778 (44%) 1/1.24*** 1/1.25***      
4-Jewish 388 (51%)   1.04    1/1.02         
5-Buddhist/Hinduist 212 (51%)   1.07      1.07         
6-Muslim 99 (54%)   1.17      1.53**       
7-Other 384 (38%) 1/1.62*** 1/1.63***      
8-None 3 509 (37%) 1/1.70*** 1/1.70***      
Model fit N 21 254
Dispersion (robust/model SE) 1.01
Collinearity (max VIF) 1.03
Influence (max dfbetas) 0.15
Interaction (LR): race*party3 0.12%
LR vs null <0.01%
McFadden R2 0.035
AIC 28 385
BIC 28 513
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

race*party3 reads “race’s effect, allowed to vary with party”, and it is a predictor like any other: one row per cell of the pair, each compared to one common reference cell, with its own count, the observed rate and the adjusted one beside it. Nothing else changes — the colours, the stars, the crude companion and the footer all work as they do on an ordinary variable, because the pair really is one variable now.

Read one row at a time: “Black Republicans have the odds of being married of the reference cell, and % against % predicted”. That is the presentation epidemiologists recommend for an interaction (Knol & VanderWeele 2012): one effect per stratum against a single common reference, with the actual rates shown.

a*b is R’s own spelling and means here exactly what it means in glm(): a + b + a:b. The order picks the presentation, never the model — a*b and b*a are the same fit, and the rows are about whichever you put first. (R’s a:b, the interaction term without its main effects, is a different model whose fit depends on where each variable’s zero happens to be, so tab_reg() refuses it by name rather than treat it as a synonym.)

The parts of a pair are supplied by the interaction, so do not list them beside it: writing c(race, party3, race*party3) is an error, not a richer model. What that buys is the natural way to ask whether the interaction is worth it at all:

tab_reg(gss_simple, "married",
        list(additive = c(race, party3), crossed = c(race*party3)),
        stats = "compare_sequential")
Logistic regressions (models comparison): married, ‘01-Married’ (OR)
levels n Obs_OR additive crossed
<n> <(obs%) OR> <OR> <OR>
Constant Reference profile 6 390 1/1.19*** 1/1.20***
race White 16 301 (51%)      1    1   
Black 3 093 (28%) 1/2.67*** 1/2.28***
Other 1 934 (48%) 1/1.13*** 1/1.02   
party3 1-Democrat 9 679 (41%)      1    1   
2-Independent, other 4 512 (44%)   1.11*** 1.01   
3-Republican 7 137 (57%)   1.93*** 1.65***
race*party3 White · 1-Democrat 6 390 (45%)      1    1   
Black · 1-Democrat 2 344 (29%) 1/2.08*** 1/2.08***
Other · 1-Democrat 945 (44%) 1/1.06    1/1.06   
White · 2-Independent, other 3 365 (46%)   1.01    1.01   
Black · 2-Independent, other 513 (25%) 1/2.47*** 1/2.47***
Other · 2-Independent, other 634 (50%)   1.18**  1.18** 
White · 3-Republican 6 546 (59%)   1.71*** 1.71***
Black · 3-Republican 236 (27%) 1/2.23*** 1/2.23***
Other · 3-Republican 355 (54%)   1.42*** 1.42***
Model fit N 21 328 21 328
Dispersion (robust/model SE) 1.00 1.00
Collinearity (max VIF) 1.04
Influence (max dfbetas) 0.03 0.11
Interaction (LR): race*party3 <0.01%
LR vs null <0.01% <0.01%
McFadden R2 0.028 0.029
AIC 28 675 28 659
BIC 28 715 28 730
LR vs previous <0.01%
Model: logistic regression of married (‘01-Married’); OR: odds ratio (vs the reference category).
Obs_OR, additive, crossed — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

The footer answers it twice, with the same number: the model comparison, and the Interaction (LR) row, which is in the default footer of a linear or logistic model. That row is the one to quote — a cell’s own stars say it differs from the reference cell, which is mostly the two main effects talking; the footer row says whether the pattern departs from what those main effects alone predict.

A continuous predictor: slopes within groups

A continuous variable has no cells to cross, so age*race gives the other honest reading — age’s slope within each level of race, straight out of the fit, in the unit the row names:

tab_reg(gss_simple, "married", c(age*race, relig), empirical = TRUE)
Logistic regression: married by race, age*race +1 more
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile   1.20*** (54%)
race White 16 274 (51%)      1         1    (51%)
Black 3 097 (28%) 1/2.67*** 1/2.77*** (28%)
Other 1 936 (48%) 1/1.14***   1.10    (53%)
age*race age per 34.6 (2SD) — White 16 274         1.25***   1.15***      
age per 34.6 (2SD) — Black 3 097         1.58***   1.48***      
age per 34.6 (2SD) — Other 1 936         2.44***   2.32***      
relig 1-Protestant 10 817 (50%)      1         1    (51%)
2-Catholic 5 101 (50%)   1.00    1/1.15*** (47%)
3-Other christian 782 (44%) 1/1.25*** 1/1.24*** (46%)
4-Jewish 387 (51%)   1.04    1/1.16    (47%)
5-Buddhist/Hinduist 217 (51%)   1.06    1/1.03    (50%)
6-Muslim 103 (52%)   1.12      1.47*   (60%)
7-Other 386 (37%) 1/1.66*** 1/1.74*** (38%)
8-None 3 514 (37%) 1/1.70*** 1/1.80*** (37%)
Model fit N 21 307
Dispersion (robust/model SE) 1.06
Collinearity (max VIF) 1.20
Influence (max dfbetas) 0.16
Interaction (LR): age*race <0.01%
LR vs null <0.01%
McFadden R2 0.032
AIC 28 553
BIC 28 657
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

The moderator keeps its own block (the model contains it), and every effect works here too: measure = "difference" gives each group’s average marginal effect, in points. To get the cell table instead, cut the continuous variable into groups — which is usually the more readable answer:

tab_reg(gss_simple, "married", c(age*race, relig), shape = c(age = "quartiles"))

The order does not have to be right: if you write race*age, tabxplor reads it as age*race and says so in one line — * is symmetric in the fit, and only a continuous variable has slopes to show within groups, so there is exactly one table that can exist.

And if both variables are continuous there are no cells to cross at all, so the second one is cut into quartiles, again in one line:

tab_reg(gss_simple, "married", c(age*tvhours, race), empirical = TRUE)
Logistic regression: married by tvhours, age*tvhours +1 more
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 1/1.42*** (41%)
tvhours 0 674 (37%)      1         1    (39%)
1 2 337 (53%)   1.91***   1.95*** (54%)
2 or 3 4 980 (49%)   1.64***   1.54*** (49%)
4 to 24 3 308 (36%) 1/1.05    1/1.05    (37%)
age*tvhours age per 34.8 (2SD) — 0 674         1.86***   1.85***      
age per 34.8 (2SD) — 1 2 337         2.25***   2.24***      
age per 34.8 (2SD) — 2 or 3 4 980         1.48***   1.40***      
age per 34.8 (2SD) — 4 to 24 3 308         1.44***   1.30***      
race White 8 583 (49%)      1         1    (49%)
Black 1 691 (26%) 1/2.76*** 1/2.42*** (29%)
Other 1 025 (46%) 1/1.14*   1/1.03    (48%)
Model fit N 11 299
Dispersion (robust/model SE) 1.09
Collinearity (max VIF) 1.08
Influence (max dfbetas) 0.14
Interaction (LR): age*tvhours <0.01%
LR vs null <0.01%
McFadden R2 0.039
AIC 14 993
BIC 15 067
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

That cut is a modelling choice, not a presentation one — the footer’s interaction test moves with the number of bins — which is why it is stated rather than silent. Choose it yourself with shape (shape = c(tvhours = "quintiles")), or write tvhours*age to cut age instead. If you want the bare product coefficient with no bins at all, write the model as a formula: outcome = y ~ ... + age * tvhours.

The scale carries the interaction

One caveat worth stating plainly, because it is not a defect of any method: an interaction depends on the scale it is measured on. A model with no interaction on the odds-ratio scale generally has one on the probability scale, and the reverse — that is what a link function and non-collapsibility do (Ai & Norton 2003). tabxplor’s answer is to make the scale visible rather than to pick one: print the same table both ways, measure = "odds_ratio" and measure = "difference", and say which one you are reading.

For a specification the argument surface cannot express — custom contrasts, a hand-written offset, a three-way term — a model formula in outcome is still the expert exit door (tab_reg(d, married ~ race * age * relig)). It fits exactly what you write, but it steps outside the framework: its rows are raw coefficient names, with no counts, no observed companion and no unit.

Three ways to get this wrong

A coloured cell is a difference in that measure, not proof that the cause differs. Groups can differ because the outcome is simply rarer in one of them, or more variable — and then their effects differ on every scale, odds ratios, risk ratios and marginal effects alike. There is no scale that escapes this, so the reading has to be careful rather than clever.

So read the pattern, not the single cell. If a whole column is shifted the same way, the groups probably differ in level or in variability — that is a property of the group, not of the predictor. If one row stands out while the others sit still, that is what genuine effect modification looks like. The colours are useful precisely because they make that shape visible at a glance. Adding empirical = TRUE shows each group’s base rate, in the crude column’s brackets, which usually settles the question.

Each cell is tested on its own, with no correction for the number of tests. In a table with seven comparisons, about one table in five will show a spurious coloured cell. The footer line is free of this: it asks one question per predictor.

For the record: what exactly is tested

The two groups are disjoint samples, so the two estimates are independent and the standard error of their difference is sqrt(SE_A² + SE_B²) — the textbook test for a difference between two independent estimates (Altman & Bland 2003). Both standard errors are read back from the confidence intervals the table already prints, so the test and the printed intervals cannot disagree. The difference is measured on the effect’s own scale — the log-ratio for an odds ratio, a risk ratio or an incidence-rate ratio, the plain difference for a beta or a marginal effect — and compared to a normal (z) threshold, which is mildly conservative in small samples.

The three color_signif policies then read that interval exactly as they do in part 4 — here “no change” reads “no difference between the two groups”.

Two details worth knowing. The significance stars in the cells keep reading each estimate’s own p-value — “is this effect different from 1?” — not the gap’s; the gap’s p-value is in the tooltip. And the footer’s aggregated test is a likelihood-ratio test (an F test for linear and quasi models, a design-based Wald test when you supply survey weights — the same rule as the model comparison below) computed on one extra pooled model, on the coefficients: on a marginal column the cells show marginal effects while the footer tests the coefficients, two related but distinct questions, and the line says so.

Comparing several models

Pass a named list of predictor sets instead of a vector to fit and show several models side by side. A comparison Likelihood ratio test is one more footer key: stats = "compare_baseline" tests each model against the first (name another with stats = c(compare_baseline = "M2")), stats = "compare_sequential" tests each against the previous one:

tab_reg(gss_simple,
        "married",
        list("Race only"    = "race",
             "+ age"        = c("race", "age"),
             "+ party"      = c("race", "age", "party3")),
        stats = "compare_sequential")
Logistic regressions (models comparison): married, ‘01-Married’ (OR)
levels n Obs_OR Race only + age + party
<n> <(obs%) OR> <OR> <OR> <OR>
Constant Reference profile 16 253 1.03**  1.02    1/1.21***
race White 16 253 (51%)      1    1    1    1   
Black 3 081 (28%) 1/2.67*** 1/2.67*** 1/2.58*** 1/2.19***
Other 1 927 (48%) 1/1.14*** 1/1.14*** 1/1.05    1.05   
age per 34.6 (2SD), at 47.2 (mean)         1.40*** 1.33*** 1.33***
party3 1-Democrat 9 651 (41%)      1    1   
2-Independent, other 4 491 (44%)   1.11*** 1.05   
3-Republican 7 119 (57%)   1.93*** 1.65***
Model fit N 21 261 21 261 21 261
Dispersion (robust/model SE) 1.00 1.00 1.00
Collinearity (max VIF) 1.03 1.05
Influence (max dfbetas) 0.03 0.03 0.03
LR vs null <0.01% <0.01% <0.01%
McFadden R2 0.019 0.023 0.032
AIC 28 847 28 744 28 489
BIC 28 871 28 776 28 537
LR vs previous <0.01% <0.01%
Model: logistic regression of married (‘01-Married’); OR: odds ratio (vs the reference category).
Obs_OR, Race only, + age, + party — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

The same model within sub-populations

tab_vars = is the regression analogue of tab()’s tab_vars: the same model is fitted within each level of a grouping variable, and the per-group tables are stacked into one grouped table. It answers “does this effect hold in every subgroup?”. With just one dependent variable, it uses tab_spread() internally to lay the groups out as side-by-side columns for an easy comparison :

tab_reg(gss_simple, "married", c("race", "rincome"), tab_vars = "year")
Logistic regression: married by race, rincome (tabbed by year)
married: 01-Married
levels 2000 2002 2004 2006 2008 2010 2012 2014 2000 2002 2004 2006 2008 2010 2012 2014
<OR (adj%)> <OR (adj%)> <OR (adj%)> <OR (adj%)> <OR (adj%)> <OR (adj%)> <OR (adj%)> <OR (adj%)> <n> <n> <n> <n> <n> <n> <n> <n>
Constant Reference profile 1/1.54*** (39%) 1/1.45*** (41%) 1/1.23    (45%) 1/1.22*   (45%) 1/1.60*** (38%) 1/1.77*** (36%) 1/2.02*** (33%) 1/1.92*** (34%) 241 226 193 271 118 165 147 145
race White      1    (49%)      1    (50%)      1    (56%)      1    (53%)      1    (52%)      1    (49%)      1    (50%)      1    (52%) 1 428 1 395 1 330 1 933 901 920 853 1 119
Black 1/1.89*** (34%) 1/2.15*** (32%) 1/2.42*** (35%) 1/2.44*** (32%) 1/2.21*** (33%) 1/2.89*** (26%) 1/2.16*** (33%) 1/2.86*** (28%) 257 266 236 361 168 173 169 243
Other 1/1.10    (47%)   1.12    (53%)   1.04    (57%)   1.01    (53%) 1/1.10    (50%) 1/1.19    (45%)   1.17    (54%) 1/1.36*   (44%) 133 119 122 375 120 109 124 161
rincome 1-Lt $10000      1    (37%)      1    (38%)      1    (42%)      1    (42%)      1    (36%)      1    (33%)      1    (31%)      1    (31%) 325 317 256 409 180 235 200 231
2-$10000 to 14999   1.44**  (46%)   1.48**  (48%)   1.11    (45%) 1/1.20    (38%)   1.13    (39%)   1.44    (41%) 1/1.01    (31%)   1.72**  (43%) 197 163 143 231 110 110 103 111
3-$15000 to 24999   1.28    (43%)   1.22    (43%)   1.29    (48%)   1.08    (44%)   1.43*   (44%)   1.35    (40%)   1.67**  (43%)   1.76*** (43%) 381 336 324 469 202 195 194 230
4-$25000 or more   1.85*** (52%)   1.79*** (53%)   2.04*** (59%)   1.70*** (55%)   2.25*** (55%)   2.30*** (52%)   2.99*** (57%)   2.58*** (52%) 915 964 965 1 560 697 662 649 951
Model fit N 1 818 1 780 1 688 2 669 1 189 1 202 1 146 1 523
Dispersion (robust/model SE) 1.00 1.00 1.00 1.01 1.00 1.00 1.01 1.00
Collinearity (max VIF) 1.01 1.00 1.01 1.01 1.01 1.01 1.00 1.00
Influence (max dfbetas) 0.11 0.10 0.11 0.09 0.12 0.14 0.14 0.12
LR vs null <0.01% <0.01% <0.01% <0.01% <0.01% <0.01% <0.01% <0.01%
McFadden R2 0.020 0.024 0.033 0.029 0.034 0.043 0.050 0.046
AIC 2 475 2 418 2 267 3 604 1 604 1 598 1 519 2 021
BIC 2 508 2 451 2 299 3 640 1 635 1 628 1 549 2 053
Model: logistic regression; OR: odds ratio (vs the reference category); adj%: adjusted/predicted proportion.
OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

Reading a row across the groups is the same chore as reading a model against its observed effect, and it has the same answer: color = "between_groups" colours how far each group’s effect sits from the first group’s, on the same row. Where the model-comparison test below says “these models differ” once for the whole model, this says which effects differ between groups — a per-predictor reading of what statisticians call effect modification.

Here the groups are separate people, so the difference between two of their effects can be tested — and it is, so color_signif works as everywhere else. Add "grey_non_signif" and a background colour then means “this really is a different effect, not just noise”:

tab_reg(gss_simple, "married", c("race", "rincome"), tab_vars = "party3",
        color = c(TRUE, "between_groups"), color_signif = "grey_non_signif")
Logistic regression: married by race, rincome (tabbed by party3)
married: 01-Married
levels 1-Democrat 2-Independent, other 3-Republican 1-Democrat 2-Independent, other 3-Republican
<OR (adj%)> <OR (adj%)> <OR (adj%)> <n> <n> <n>
Constant Reference profile 1/1.89*** (35%) 1/1.88*** (35%) 1/1.04    (49%) 576 383 542
race White      1    (47%)      1    (46%)      1    (60%) 3 802 2 005 4 047
Black 1/1.90*** (32%) 1/1.91*** (31%) 1/3.30*** (31%) 1 435 277 150
Other   1.06    (48%)   1.18    (49%) 1/1.11    (57%) 639 370 241
rincome 1-Lt $10000      1    (32%)      1    (34%)      1    (48%) 955 548 636
2-$10000 to 14999   1.32**  (38%)   1.21    (38%)   1.18    (52%) 542 304 314
3-$15000 to 24999   1.50*** (41%)   1.27*   (39%)   1.17    (52%) 1 060 590 675
4-$25000 or more   2.04*** (48%)   2.26*** (53%)   1.86*** (63%) 3 319 1 210 2 813
Model fit N 5 876 2 652 4 438
Dispersion (robust/model SE) 1.00 1.01 1.00
Collinearity (max VIF) 1.00 1.00 1.00
Influence (max dfbetas) 0.05 0.10 0.14
LR vs null <0.01% <0.01% <0.01%
McFadden R2 0.027 0.029 0.021
AIC 7 836 3 552 5 907
BIC 7 876 3 587 5 946
Model: logistic regression; OR: odds ratio (vs the reference category); adj%: adjusted/predicted proportion.
Interaction with party3 (likelihood ratio): race p = 3.66%**, rincome p = 30.1%.
1-Democrat married: 01-Married — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Background colour: reference group. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence) or under the first colour threshold (×1.2).
2-Independent, other married: 01-Married, 3-Republican married: 01-Married — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Background colour: OR further from no effect (1) than the reference group’s effect, by ×1.1; ×1.25; ×1.5; ×2; OR closer to no effect (1) than the reference group’s effect (or inversed effect), by ÷1.1; ÷1.25; ÷1.5; ÷2. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence) or under the first colour threshold (×1.2). Some rows carry no test and are left uncoloured. Background: the same rule, applied to the gap with the reference group’s effect (z test on the difference between two independent estimates, 95% confidence).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

Text colour = how strong the effect is inside that group. Background = how far it sits from the first group’s effect on the same row, greyed out when the difference could be chance. The first group is the baseline and stays blank (nothing is compared to itself); reorder the levels with forcats::fct_relevel() to pick another one. Hovering a cell in the html table gives the exact numbers: the other group’s effect, the size of the gap, its confidence interval and its p-value.

The footer gained a line at the same time: one test per predictor, asking “does this predictor act differently between the groups?” for all its levels at once. That is the aggregated version of the same question, and it is the one to quote — because it is asked once, not once per cell. You can ask for it alone, without the colours, with stats = c("n", "group_interaction").

Weighted and survey data

There are only two ways to hand tabxplor your weights, and that is deliberate. Either you give wt = a weight column, or — when your file also carries strata, clusters, a finite-population correction or calibration — you build the design once with the survey package and pass the design itself as data:

tab_reg(data, "outcome", c("pred1", "pred2"), wt = "weight")

library(survey)
d <- svydesign(ids = ~psu, strata = ~stratum, weights = ~w, data = my_survey, nest = TRUE)
tab_reg(d, "outcome", c("pred1", "pred2"), empirical = TRUE)

Either way estimation switches to survey::svyglm() and everything follows that design in one regime: the coefficients and their intervals, the average marginal effects, the scaling of numeric predictors, the model-versus-observed gap test — and the observed Obs_* companion columns, whose intervals are built on the design variance of each cell. That last point is what makes this vignette’s central comparison work: the model column and the observed column beside it are measured the same way, so a difference between them is about adjustment, not about two notions of uncertainty.

One consequence is worth stating, because it differs from tab(): a regression is always design-based, while a cross-table starts on the plain unweighted sample size and needs design_effect = TRUE to join it. vignette("tabxplor-weights") explains that ladder, the two honest limits of a design-based observed column, and how to tell whether your own file deserves a full design at all.

6. Checking the model

The five model checks

The same footer carries five model checks, and there is nothing to learn beyond the five nouns: each names an assumption, and the parenthesis names the instrument that measured it. Four are printed by default. Linearity is the one you ask for by name, or with stats = "all" — because it refits the model once per continuous predictor, and because the free half of it is on screen anyway (see the shape table below).

tab_reg(gss_simple, "married", c("race", "age", "rincome", "relig"), stats = c("n", "linearity", "dispersion", "influence", "collinearity"))
Logistic regression: married by race, age +2 more
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 1/1.19*** (46%)
race White 9 846 (52%)      1         1    (51%)
Black 1 860 (31%) 1/2.43*** 1/2.40*** (32%)
Other 1 254 (49%) 1/1.12*     1.11    (54%)
age per 26.9 (2SD), at 42.4 (mean)         2.12***   1.83***      
rincome 1-Lt $10000 2 142 (37%)      1         1    (39%)
2-$10000 to 14999 1 164 (41%)   1.20**    1.14*   (42%)
3-$15000 to 24999 2 322 (43%)   1.32***   1.27*** (45%)
4-$25000 or more 7 332 (55%)   2.12***   1.86*** (54%)
relig 1-Protestant 6 269 (52%)      1         1    (53%)
2-Catholic 3 121 (52%) 1/1.03    1/1.17*** (49%)
3-Other christian 507 (42%) 1/1.53*** 1/1.43*** (45%)
4-Jewish 222 (55%)   1.10    1/1.29*   (47%)
5-Buddhist/Hinduist 144 (50%) 1/1.09    1/1.36*   (46%)
6-Muslim 56 (48%) 1/1.17      1.05    (54%)
7-Other 267 (35%) 1/2.04*** 1/2.04*** (37%)
8-None 2 374 (38%) 1/1.76*** 1/1.79*** (39%)
Model fit N 12 960
Linearity (LR): age <0.01%
Dispersion (robust/model SE) 1.03
Collinearity (max VIF) 1.08
Influence (max dfbetas) 0.19
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

Read them in the order they print, which is the order of what each one threatens — first what the number means, then whether its interval can be trusted, then how fragile it is:

Two things worth knowing. A curvature test on one predictor can pick up another predictor’s wrong shape when the two are strongly correlated — one more reason to read the collinearity row beside it. And with a large survey sample almost any diagnostic p-value ends up significant, which is why three of the five report a magnitude instead.

The shape of a continuous predictor

The linearity row tells you whether one straight line is enough. The small shape table printed under the footer tells you what shape the data has — and it costs nothing, because no model is involved: the outcome is simply cut into ten equal-sized slices of the predictor, and the average is taken in each.

The table below carries one of each, which is why it is worth reading closely: tvhours has exactly the shape a coefficient assumes — a straight fall, so its single number describes it honestly — while age has a shape no coefficient can express.

tab_reg(gss_simple, "married", c("race", "age", "tvhours"), family = "binomial")
Logistic regression: married by race, age +1 more
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 1/1.06*** (48%)
race White 8 583 (49%)      1         1    (49%)
Black 1 691 (26%) 1/2.76*** 1/2.35*** (29%)
Other 1 025 (46%) 1/1.14*   1/1.02    (48%)
age per 34.8 (2SD), at 47.2 (mean)         1.45***   1.50***      
tvhours per 5.18 (2SD), at 2.98 (mean)       1/1.69*** 1/1.64***      
Model fit N 11 299
Dispersion (robust/model SE) 1.05
Collinearity (max VIF) 1.07
Influence (max dfbetas) 0.17
LR vs null <0.01%
McFadden R2 0.034
AIC 15 067
BIC 15 103
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.
outcome numeric predictor observed range observed shape (central 95%)
p = %Married ; log(p/(1-p)) age 13-57% (OR 8.7)
tvhours 31-53% (OR 2.6)

One row per continuous predictor. Read it in three steps.

1. Read the range first, not the picture. 13-57% is the plainest sentence in the whole table: in the slice of age where marriage is rarest, 13 % are married; in the slice where it is commonest, 57 % are. Those are real percentages of real people, counted, with no model in them. The figure in brackets is the same distance written as the model’s own comparison — (OR 8.7), an odds ratio of nearly nine — so you can hold it against the colour ladder you already use for the cells (1.2, 1.5, 2, 4). Nine is far past the last rung: age matters a great deal.

The range always speaks the outcome’s own language — a percentage of people for a yes/no outcome, an average for a numeric one — and it does not change when you change measure or link, because it is what was counted. Only the bracket does.

This is the one number a continuous predictor never has in the table itself. A cell like 1/2.35*** (28 %) shows an effect and the percentage it sits on — but age has no categories, so there is no single percentage to put there. The curve has one for every slice, and the observed range is simply its two ends.

2. Then read the shape, and only for its shape. The horizontal axis is the predictor as the model sees it, and every point rests on the same number of people, so what you see is where things happen. Three readings matter:

The damage does not stay in the offending row. Letting age curve instead of run straight moves the top income category’s odds ratio by about a quarter and flips another income level’s verdict at the 5 % threshold — in a model where nothing but this shape table hints at a problem. That is the whole reason to look.

Do not compare two pictures by eye. Each curve is drawn at its own scale and fills the height whatever its size — that is what makes small shapes visible at all. age and tvhours look about equally dramatic above; the ranges say one is a nine-fold change and the other closer to two and a half. The picture answers what shape, the range answers how big. Only the numbers compare.

3. Check for grey and ns before believing any of it. A curve smaller than its own sampling noise is greyed and marked ns: read it as a flat line, however convincing it looks. The same two predictors, on a 200-row sample of the same data:

set.seed(20260823)
small <- gss_simple[sample(nrow(gss_simple), 200), ]
tab_reg(small, "married", c("race", "age", "tvhours"), family = "binomial")
Logistic regression: married by race, age +1 more
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 1/1.10   (48%)
race White 84 (51%)      1        1   (49%)
Black 13 (23%) 1/3.50*  1/1.85   (35%)
Other 8 (75%)   2.86     3.51   (74%)
age per 33.9 (2SD), at 44.3 (mean)         1.67     2.82**      
tvhours per 5.29 (2SD), at 3.07 (mean)       1/3.57** 1/5.12**      
Model fit N 105
Dispersion (robust/model SE) 1.04
Collinearity (max VIF) 1.29
Influence (max dfbetas) 0.52
LR vs null 0.33%
McFadden R2 0.109
AIC 140
BIC 153
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.
outcome numeric predictor observed range observed shape (central 95%)
p = %Married ; log(p/(1-p)) age 11-69% (OR 18.3) ns
tvhours 16-72% (OR 13.7) ns
“ns”: the curve is inside its own sampling noise – read it as flat.

Still curves, to the eye. They are not: on so few people the slices wobble by that much on their own — and the ranges, 11-69 % and 16-72 %, are wider than the ones measured on the whole sample, which is the giveaway.

ns is about the shape, not about the predictor. Ten slices carry ten averages, not two hundred respondents, so a real effect can be perfectly significant in the table and still unreadable as a shape. The stars in the predictor’s own row judge the effect; the shape table judges only whether the picture can be trusted.

For an ordinal or multinomial outcome the table draws only the first cut or category — use reg_check_plots() there, which draws them all, and which is also where a proportional-odds departure becomes visible.

options(tabxplor.shape_table = "console") keeps the shape table where you are working and out of your exports; "no" removes it everywhere.

Curing a shape

shape = is how you fix what the table just showed you, without leaving the table. The most readable answer is usually to cut the predictor into groups — it then becomes an ordinary factor, so it gets one odds ratio per group, its own observed companion, counts and colours, and the shape becomes visible in the printed numbers themselves:

tab_reg(gss_simple, "married", c("race", "age"), family = "binomial",
        shape = c(age = "quintiles"), empirical = TRUE)
Logistic regression: married by race, age
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile 2 908 1/2.37*** (30%)
race White 16 341 (51%)      1         1    (50%)
Black 3 115 (28%) 1/2.68*** 1/2.72*** (28%)
Other 1 951 (48%) 1/1.14*** 1/1.06    (49%)
age 18 to 30 4 249 (27%)      1         1    (27%)
31 to 40 4 278 (54%)   3.17***   3.24*** (54%)
41 to 50 4 251 (55%)   3.35***   3.38*** (55%)
51 to 62 4 247 (56%)   3.48***   3.44*** (56%)
63 to 89 4 382 (45%)   2.22***   2.10*** (44%)
Model fit N 21 407
Dispersion (robust/model SE) 1.00
Collinearity (max VIF) 1.02
Influence (max dfbetas) 0.03
LR vs null <0.01%
McFadden R2 0.055
AIC 27 990
BIC 28 046
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.

The parsimonious alternative keeps one variable and adds a curvature term, so age takes two rows: the slope at the mean, and age², which says whether the slope flattens (below 1) or accelerates (above 1) as you move away from it.

tab_reg(gss_simple, "married", c("race", "age"), family = "binomial",
        shape = c(age = "quadratic"))
Logistic regression: married by race, age
married: 01-Married
levels n Obs_OR Model_OR
<n> <(obs%) OR> <OR (adj%)>
Constant Reference profile   1.62*** (62%)
race White 16 341 (51%)      1         1    (50%)
Black 3 115 (28%) 1/2.68*** 1/2.76*** (28%)
Other 1 951 (48%) 1/1.14*** 1/1.03    (50%)
age per 34.6 (2SD), at 47.2 (mean)         2.03***   1.96***      
age²       1/1.59*** 1/1.61***      
Model fit N 21 407
Dispersion (robust/model SE) 1.01
Collinearity (max VIF) 1.16
Influence (max dfbetas) 0.06
LR vs null <0.01%
McFadden R2 0.065
AIC 27 682
BIC 27 722
Model: logistic regression; OR: odds ratio (vs the reference category); obs%: observed proportion; adj%: adjusted/predicted proportion.
Obs_OR, Model_OR — OR ≥ 1.2; 1.5; 2; 4; OR ≤ 1/1.2; 1/1.5; 1/2; 1/4. Uncoloured: not significantly different from the reference category (Wald interval on the log odds-ratio, 95% confidence; matching Woolf interval on the observed column) or under the first colour threshold (×1.2).
***: significantly different from the reference category (in bold) at the 99% confidence level (from 1 for the Constant); **: at the 95% level; *: at the 90% level; no star: not significant.
outcome numeric predictor observed range observed shape (central 95%)
p = %Married ; log(p/(1-p)) age 13-57% (OR 8.7)

shape = c(x = "log") and "sqrt" are the other two — diminishing returns, the shape income data usually has. Everything else keeps working: the observed Obs_* companion is fitted with the same shape, so the model-versus-observed comparison still compares like with like, and the linearity row disappears for a predictor you have already cured.

(A poly() or spline basis is deliberately never emitted: the marginal-effects engine silently returns zero for those. If you reach one through a formula, a warning says so.)

The check plots: reg_check_plots()

reg_check_plots(t)                       # the default panels, one titled grid per model
reg_check_plots(t, check = "all")        # plus dispersion and collinearity
reg_check_plots(t, check = "linearity")  # just one

The same nouns as the footer rows, one panel each, and one titled grid per model — each drawing the panels its own family allows, so a table mixing a binomial and an ordinal outcome is diagnosed correctly. The data is normally found on its own, from the name the table was built with; pass data = when the table came from an expression rather than a named object. check = "auto" leaves out dispersion and collinearity, whose footer row already says the whole thing; check = "all" restores them.

These are a teaching companion, not a decision tool: every verdict they illustrate is already a footer row, in every export, with no plotting package installed. They exist to show what a violation looks like. theme = "print_ready" gives a greyscale version for a thesis appendix; you can also pass a plain fitted model instead of a table.

Two panels repay a second look. Linearity compares the observed curve to the shape the model fits — a straight line normally, a parabola once shape = "quadratic" put a curvature term in — so it stays honest about a predictor you have already cured. With an ordinal or a multinomial outcome it draws one curve per cut of the outcome (per category, against the reference, for a multinomial): parallel curves are the proportional-odds assumption holding, which the Brant test in the footer scores but the Proportionality panel, being about factors, cannot show for a continuous predictor.

Note the opposite contracts: forest_plot() never re-fits (the results are in the table), reg_check_plots() always does (residuals are not).

7. Plots

The results: forest_plot()

forest_plot() draws the finished table — every effect with its confidence interval and its colour, one panel per model column:

t <- tab_reg(gss_simple, "married", c("race", "rincome"), family = "binomial")
forest_plot(t)

It reads the table and never re-fits anything, so the figure cannot disagree with the numbers you printed: each whisker spans the interval in the cell, each gridline is one of your colour breaks (move them with set_color_breaks() and the axis moves too), and the whisker takes the cell’s colour whole — which is why the plot needs no stars. The value is printed just above it, and the square at its centre is as big as the number of people behind it (center = "estimate" prints the value alone). A table that mixes families gets one axis per panel, each in its own unit. It returns an ordinary ggplot, so + ggplot2::labs(...) and ggsave() work as usual.

The three ways of reading significance become three things you can see:

color_signif in the table in the plot
"ignore" colour by the size of the effect where the point sits, relative to the null line
"grey_non_signif" grey unless the interval excludes the null whether the whisker crosses the null line
"guaranteed_effect" colour the interval bound nearest the null how far the near end of the whisker is from it

Observed versus modelled, drawn honestly

With empirical = TRUE, each modelled effect carries its observed (crude) counterpart, and the plot draws it as a hollow point with a bracket — the margin of error of the difference between the two:

t <- tab_reg(gss_simple, "married", c("race", "rincome"),
             link = "ratio", empirical = TRUE)          # risk ratios
forest_plot(t)

Read it as one question: is the filled point outside the bracket? If it is, adjustment moved the effect by more than noise — and that is exactly the test the table performs, to the last digit.

It is deliberately not two intervals side by side. Comparing two intervals by whether they overlap is a known mistake (Schenker & Gentleman 2001), and it is worse here, because the crude and the adjusted estimate are computed on the same people and are therefore correlated: the right interval is the one around the difference, which is what the bracket is. observed = "ci" draws the classic two-interval figure if you want it anyway.

A dotted connector with no bracket means the gap could not be tested — most often a conditional odds ratio, which moves under adjustment even with nothing to adjust for (see Three ways to get this wrong above).

Where to go next