# 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.

- 2 levels logistic (binomial) → observed %, and observed odd-ratios
- 3+ levels logistic (multinomial) → the observed ORs are shown as a tooltip on the model cells in html exports
- gaussian (linear) → group means and their difference
- poisson (counts) → observed rate and observed rate ratio

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.

# 2. The four arguments: `family`, `link`, `measure`, `effect`

Four arguments decide what a model column contains, and they chain — each follows from the one before unless you say otherwise, which is what `"auto"` means on all four:

    outcome ──auto──▶ family ──auto──▶ link ──auto──▶ measure ──auto──▶ effect

One rule underpins them, and it is what differs from `tab()`. Only `link` changes the model that is fitted. `measure` and `effect` change what is read off it, and `display` only changes what the cell shows. In a cross-table every measure comes from the same counts, so asking for a ratio rather than a difference is a display choice; in a regression it is a step of computation — the model works your measure out from its predictions — but it is still the same fit. So a risk ratio and an odds ratio can be two readings of one logistic model, and a different model is something you ask for by name.

## The four arguments, in plain words

argument | the question it answers | `"auto"` gives you | who sets it
--- | --- | --- | ---
`family` | what kind of number is the outcome | detected from the outcome | you, on a numeric outcome
`link` | which measure the model estimates | the family’s own | rarely — experts
`measure` | which measure is reported | the link’s | the one you set
`effect` | where that number is taken from | a coefficient if there is one | rarely; for an ideal type

`effect`’s three values name three quantities:

`effect` | the question it answers | its name in the literature
--- | --- | ---
`"conditional"` | two people alike on all other predictors: what does this do? | conditional effect (the coef.)
`"marginal"` | if everyone switched it, how far would the outcome move? | average marginal effect (AME)
`"at_reference"` | the same, but for one person at the reference profile | effect at the reference profile

By hand you would get them with `coef(glm(...))` (exponentiated when the measure is a ratio), with `marginaleffects::avg_comparisons(model)`, and with the same call on a one-row profile where every other predictor sits at its reference level or its mean. You will rarely type any of them: `"auto"` picks `"conditional"` whenever the reported measure is the model’s own — which is exactly when a coefficient exists — and `"marginal"` otherwise. `"conditional"` earns its keep as an assertion: ask for it where no coefficient can carry your measure and the table says so, naming the two cures, instead of quietly giving you something else.

⚠ One clause qualifies `"auto"`: it never lands on a predicted odds ratio. A marginal odds ratio is a specialist quantity (Karlson & Jann 2023), so on a prediction route `"auto"` steps back to the outcome’s own measure — “x times as likely” for a percentage. Name `measure = "odds_ratio"` to get it.

`effect = "at_reference"` evaluates the effect at the reference profile — the abstract individual combining the reference level of every predictor — instead of averaging it over the sample. For a multinomial model it also unlocks a contrast the other two do not have: the odds ratio of each outcome category versus all the others at that profile.

    tab_reg(gss_simple, "married", c("race", "age"), effect = "at_reference")

## Which model an outcome can be fitted with

`link` is the value set of models. Its words are `measure`’s own words, because a link is a measure — the one the model estimates directly — so the statistician’s vocabulary never surfaces. † marks the family’s own link, which is what `link = "auto"` resolves to.

outcome kind | `family` | a cell is… | `link =` | the model that fits | its coef.
--- | --- | --- | --- | --- | ---
factor, 2 lvl | `binomial` | percentage | `"odds_ratio"` † | logistic regression | `OR`
|  |  | `"ratio"` | modified Poisson, robust SE | `RR`
|  |  | `"difference"` | identity-link binomial | `RD`
numeric, score | `binomial`+trials | percentage | the same three | the same, per item | `OR`/`RR`/`RD`
numeric | `gaussian` | a mean | `"difference"` † | linear regression | `diff`
|  |  | `"ratio"` | log-link Poisson pseudo-ML | `RoM`
numeric, count | `poisson` | a count | `"ratio"` † | Poisson, quasi-Poisson SEs | `IRR`
factor, 3+ un. | `multinomial` | percentage | `"odds_ratio"` † | multinomial logit | `OR`
factor, ord. | `ordinal` | a rank | `"odds_ratio"` † | proportional-odds model | `cumOR`

`family = "quasipoisson"` has exactly the row of `poisson` — it changes the variance assumption, not what is estimated.

## What each measure reports, from any of them

Which measures exist is decided by what a cell is: a percentage has an identity, a log and a logit, so all three; a mean or a count has no odds, so no odds ratio; and a rank — an ordered outcome’s cell — is compared by pairs of people rather than by shares, which is what lets an ordinal model report in one column. Every cell below is the acronym the combination puts in the column header, after the constant `Model_` prefix.

a cell is… | `measure =` | `"conditional"` * | `"marginal"` | `"at_reference"`
--- | --- | --- | --- | ---
percentage | `"odds_ratio"` | `OR` | `mOR` ¹ | `refOR` ¹
percentage | `"ratio"` | `RR` | `mRR` | `refRR`
percentage | `"difference"` | `RD` | `mRD` | `refRD`
a mean | `"difference"` | `diff` | `mdiff` | `refdiff`
a mean | `"ratio"` | `RoM` | `mRoM` | `refRoM`
a mean | `"odds_ratio"` | not defined | not defined | not defined
a count | `"ratio"` | `IRR` | `mIRR` | `refIRR`
a count | `"difference"` | — | `mdiff` | `refdiff`
a count | `"odds_ratio"` | not defined | not defined | not defined
a rank | `"difference"` | — | `mD` | not offered ²
a rank | `"ratio"` | — | `mWR` | not offered ²
a rank | `"odds_ratio"` | `cumOR` | not offered ¹ | not offered ¹

The `"conditional"` column is the one `reg_measures()` shows per link; the other two are the same for every link the family fits, which is why it lists them once.

* a coefficient exists only where that measure is the `link` — see the previous table. Ask for one that is not, and the call is refused naming its two cures: drop `effect`, or fit the model that estimates it.

¹ a predicted odds ratio needs a percentage and its complement, so a 3+ category outcome has to be asked “versus what?” first: on a multinomial outcome it exists only as the versus the rest contrast at `effect = "at_reference"`, and on an ordinal one not at all.

² a rank’s measures compare two people drawn from the population, and one profile holds only one.

`reg_measures(data, outcome)` prints this grid for your outcome, and prints only what can be built: a not defined combination has no row, and the message above the table says why. Its two refusals are not the same one — a quantity has no odds to take a ratio of, whatever anyone implements, while not offered means tabxplor does not build it and the error lists what this outcome does offer, at this model and at the others.

A further state exists only at run time: a link that does not converge on your data. The call says so, and for the risk difference it falls back to the linear probability model.

`measure = "raw_coefficient"` is not a fourth column: it is the model’s own coefficient, the estimand shown un-transformed. Where the reported measure is multiplicative that is its log, and the header says which one it logs (`Model_log(OR)`, `Model_log(IRR)`, `Model_log(RoM)`); where the model is already additive there is nothing to un-exponentiate, and the coefficient IS the additive estimate the column already shows. So it answers for every family — which is what lets a table mixing a logistic outcome and a linear one be asked for its coefficients at all. Being the fit’s own number it is always the conditional one; ask it with `effect = "marginal"` and the call says so and names the cure. (`"coefficient"`, `"coef"`, `"log"`, `"log_odds"`, `"log_risk"` and `"log_rate"` are accepted spellings; the last three pin which base.)

## What the header words mean

A header names the measure; the contrast is a marker on it. So there is one acronym to look up per quantity, and one marker to read on top of it.

acronym | the quantity | how you would get it by hand
--- | --- | ---
`OR` | odds ratio | `exp(coef(glm(y ~ ., binomial)))`
`cumOR` | cumulative odds ratio | `exp(coef(MASS::polr(...)))`, the proportional-odds model
`RR` | risk ratio | modified Poisson, robust SE (Zou 2004)
`RD` | risk difference, in points | `glm(y ~ ., binomial("identity"))`, robust SE
`IRR` | incidence-rate ratio | `exp(coef(glm(y ~ ., poisson)))`
`RoM` | ratio of means | log-link pseudo-Poisson, robust SE (Santos Silva & Tenreyro 2006)
`diff` | mean difference | `coef(lm(...))`

marker | reads | example
--- | --- | ---
(none) | conditional — holding the other predictors fixed | `Model_OR`
`m` prefix | marginal — averaged over the sample | `Model_mRR`
`ref` prefix | at the reference profile | `Model_refRD`
`log(…)` | the same estimand, un-exponentiated | `Model_log(OR)`

The observed companion carries the measure without a marker (`Obs_RR` beside `Model_mRR`): it is a univariable effect, and where its levels come from the counts themselves a marginal and a conditional contrast are the same number.

## The one argument that changes the model: `link`

Everything else changes what is reported. `link` changes what is fitted, and that is the whole of the difference — so it is the one argument that can put an assumption in your table you did not intend. Each link buys a directly readable coefficient at the price of one:

`family` and `link` | what is fitted | what the coefficient assumes
--- | --- | ---
binomial · `"odds_ratio"` (default) | logistic regression | a constant odds ratio across profiles
binomial · `"ratio"` | modified Poisson, robust SE | a constant risk ratio
binomial · `"difference"` | identity-link binomial, robust SE | a constant risk difference
gaussian · `"difference"` (default) | linear regression | a constant mean difference
gaussian · `"ratio"` | log-link Poisson pseudo-ML, robust SE | E(y) = exp(xβ), so a constant ratio of means
poisson · `"ratio"` (default) | Poisson, quasi-Poisson SEs | a constant rate ratio
ordinal · `"odds_ratio"` (default) | proportional-odds model | one odds ratio, shared by every cut
multinomial · `"odds_ratio"` (default) | multinomial logit | a constant odds ratio per category

What the literature says about the three non-default links:

- The risk ratio is a well-established route, not a workaround. `link = "ratio"` on a binary outcome fits Zou’s modified Poisson, which exists precisely to avoid the convergence failures of a log-binomial fit and is standard practice in epidemiology. Its own limits are different in kind from the risk difference’s: fitted risks are not bounded above by 1, and in small or sparse samples both the estimate and the sandwich standard error are biased — penalised variants exist for that case.
- The risk difference is the fragile one. An identity link is bounded on neither side, so it can predict impossible risks and can simply fail to converge. When it does, tabxplor fits the linear probability model instead and the footer says so — the two target the same quantity, but they are different estimators and agree only if the model is right.
- The ratio of means is consistent whenever the mean function is right. Poisson pseudo-likelihood does not claim your outcome is a count: it is a device for the log link, and its robust standard error does not require the Poisson variance to be correct. Nothing, however, rescues a wrong mean function — and the outcome must be non-negative.
- The default is not assumption-free either. A logistic coefficient assumes a constant odds ratio, an ordinal one assumes the same odds ratio at every cut (the footer’s Brant test checks it), and the odds ratio is non-collapsible, so it moves when a covariate is added even where there is nothing to confound.

Which route to take. If a risk ratio or a risk difference is what you want to report, the safer default is to leave the model on its family’s own scale and name the measure with `measure =`. Three reasons, none of them about taste: the logit always converges and can never predict a probability outside 0–100 %, while the log and identity links can do both; a marginal effect imposes no constant-effect assumption on the scale you report, since it averages the effect the fitted model actually implies at each respondent’s own profile; and every `measure` runs on the same fit, so changing which measure you show never changes what was estimated.

That said, the two are different questions rather than two spellings of one. A coefficient is a conditional effect — “two people alike on every other predictor” — and a marginal effect a population average. Take the `link` route when the conditional quantity is what you want: to line up with a published conditional estimate, or when “the effect is the same size everywhere on this scale” is the claim you actually mean to make. `reg_formulas()` tells you which model ran.

## Caveats worth knowing

- A binary outcome with `family = "poisson"` is not a count model, and is refused rather than quietly rewritten. `family` says what kind of number the outcome is and never picks a link behind your back; the message names the two things that spelling could have meant — `link = "ratio"` (the modified Poisson’s conditional risk ratio) and `measure = "ratio"` (the marginal one).
- `link = "ratio"` on a numeric outcome stops on a negative outcome. A ratio of means is not defined there; the call suggests modelling `log()` of a positive outcome, or leaving `link` alone.
- `Model_RD` and `Model_mRD` are both percentage points, and are not the same number. `Model_RD` is a conditional risk difference from an identity-link fit (`link = "difference"`), `Model_mRD` a marginal one standardised over the logistic fit (`measure = "difference"`) — which is what the `m` marker says.
- Conditional odds ratios are non-collapsible, so `color = "adjustment"` colours the gap on such a column but never tests it (see How to read it — and how not to, above). Ask for `measure = "difference"` or `measure = "ratio"` — or, if you must publish odds ratios, `measure = "odds_ratio", effect = "marginal"`, whose marginal odds ratio is collapsible and whose gap the table does test.
- One combination has no observed companion. At `effect = "at_reference"` the model is conditional on one profile while the observed columns stay marginal over the whole sample, so the two are shown but not compared — `color = "adjustment"` and `{obs}` stay empty.
- What needs an extra package, and what has no method. `effect = "at_reference"` goes through marginaleffects; a survey-weighted multinomial model has no marginal-effects method at all, so there it can only be read on its coefficients — keep the model’s own measure and the call goes through. A survey-weighted ordinal model is the exception: its rank measures (`measure = "difference"` / `"ratio"`) run on tabxplor’s own g-computation and take the design-based variance straight from the fit, so they work under any design — only the crude/adjusted gap test is unavailable there, and `color = "adjustment"` then colours without testing. (`display` itself reaches every column, marginal or not.)

## Asking your own outcome

`reg_measures()` prints this grid for one outcome of your own data — it reads the very table `tab_reg()` resolves against, so what it prints is what the function does:

    reg_measures(gss_simple, "married")

    #> # A tibble: 4 × 5
    #>   link       measure    effect                header    reads_as
    #>   <chr>      <chr>      <chr>                 <chr>     <chr>
    #> 1 odds_ratio odds_ratio conditional           Model_OR  odds ratio
    #> 2 (any)      difference marginal|at_reference Model_mRD marginal risk difference
    #> 3 (any)      ratio      marginal|at_reference Model_mRR marginal risk ratio
    #> 4 (any)      odds_ratio marginal|at_reference Model_mOR marginal odds ratio

It comes in two blocks. The first is the model itself: its `link`, and the measure that model’s own coefficients carry. The second is what is read off the model’s predictions — the same whichever link you fit, which is why those rows say `link = "(any)"`. So what `link` changes is only which measure has a coefficient; every measure stays reportable from any of them.

By default the first block holds each family’s own model alone. `link = "all"` adds every other link it can be fitted at — specialist choices — and marks the family’s own with `base_link`:

    reg_measures(gss_simple, "married", link = "all")

    #> # A tibble: 6 × 6
    #>   link       base_link measure    effect                header    reads_as
    #>   <chr>      <lgl>     <chr>      <chr>                 <chr>     <chr>
    #> 1 odds_ratio TRUE      odds_ratio conditional           Model_OR  odds ratio
    #> 2 ratio      FALSE     ratio      conditional           Model_RR  risk ratio
    #> 3 difference FALSE     difference conditional           Model_RD  risk differen…
    #> 4 (any)      NA        difference marginal|at_reference Model_mRD marginal risk…
    #> 5 (any)      NA        ratio      marginal|at_reference Model_mRR marginal risk…
    #> 6 (any)      NA        odds_ratio marginal|at_reference Model_mOR marginal odds…

Name a link to read the table at that model alone, and a family to read it as that kind of outcome:

    reg_measures(gss_simple, "married", link = "ratio")

    #> # A tibble: 4 × 5
    #>   link  measure    effect                header    reads_as
    #>   <chr> <chr>      <chr>                 <chr>     <chr>
    #> 1 ratio ratio      conditional           Model_RR  risk ratio
    #> 2 (any) difference marginal|at_reference Model_mRD marginal risk difference
    #> 3 (any) ratio      marginal|at_reference Model_mRR marginal risk ratio
    #> 4 (any) odds_ratio marginal|at_reference Model_mOR marginal odds ratio

The same list is in `?tab_reg`, section “Which models each outcome offers, and which measures”, generated from the same source.

# 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`).

- Like in every regression model, the effect of a predictor level on the dependent variable reads “all the others chosen predictors being equals”.
- Colors read like any `tabxplor` table: an odds ratio above 1 (blue) means more likely to be married than the reference level, below 1 (red) means less likely; stars and colors both flag significance.

    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 plain difference from reference is colored (31% - 52% = -21 points).
- Cells that are non-significantly different from the reference are greyed out (based on a Newcombe confidence interval for differences of proportions).

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

- Comparing the two tells you what adjustment the model did: if a predictor’s model OR is much closer to 1 than its crude OR, the raw association was largely explained by the other predictors.
- Here, “all things being unequal”, black americans have `1/2.43` the odds of being married of white americans. “All things being equal” (more precisely : income, age and religion being equal), they still have `1/2.40`. The result holds, it’s not explained by income differences or religious differences.

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

- Colors and significance stars use a Woolf OR confidence interval that matches what is done in the regression model.
- The population of the table must match the complete-cases population of the model, filtering out indivivuals with `NA` at any involved variable.

    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.

### The other risk ratio: `link = "ratio"`

Everything above changed what was reported, never what was fitted. One argument changes the model itself, and it gives the other risk ratio — the one epidemiologists mean:

    tab_reg(gss_simple, "married", c("race", "age", "rincome", "relig"),
            link = "ratio", empirical = TRUE)

Logistic regression: married by race, age +2 more

| married: 01-Married
--- | ---
| levels | n | Obs_RR | Model_RR
<n> | <(obs%) ratio> | <ratio (adj%)>
Constant | Reference profile |  |  | 44%
race | White | 9 846 | (52%) 1 | 1(51%)
Black | 1 860 | (31%) ÷1.69*** | ÷1.63*** (31%)
Other | 1 254 | (49%) ÷1.06* | ×1.06*   (54%)
age | per 26.9 (2SD), at 42.4 (mean) |  | ×1.44*** | ×1.34***
rincome | 1-Lt $10000 | 2 142 | (37%) 1 | 1(39%)
2-$10000 to 14999 | 1 164 | (41%) ×1.12** | ×1.09*   (42%)
3-$15000 to 24999 | 2 322 | (43%) ×1.18*** | ×1.16*** (45%)
4-$25000 or more | 7 332 | (55%) ×1.50*** | ×1.40*** (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*** (44%)
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.03(54%)
7-Other | 267 | (35%) ÷1.50*** | ÷1.46*** (36%)
8-None | 2 374 | (38%) ÷1.37*** | ÷1.35*** (39%)
Model fit | N |  |  | 12 960
Dispersion (robust/model SE) |  |  | 1.00
Collinearity (max VIF) |  |  | 1.07
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, Model_RR — 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_RR` is unmarked, so it is a coefficient: this is the modified Poisson regression (Zou 2004), whose coefficients are risk ratios directly. Compare it with the `Model_mRR` above — they nearly agree here — and check which model actually ran:

    reg_formulas(tab_reg(gss_simple, "married", c("race", "age"), measure = "ratio"))

    #> # A tibble: 1 × 6
    #>   model           outcome family   link       fit                        formula
    #>   <chr>           <chr>   <chr>    <chr>      <chr>                      <chr>
    #> 1 01-Married: mRR married binomial odds_ratio "glm(binomial(\"logit\"))" marrie…

    reg_formulas(tab_reg(gss_simple, "married", c("race", "age"), link    = "ratio"))

    #> # A tibble: 1 × 6
    #>   model          outcome family   link  fit                             formula
    #>   <chr>          <chr>   <chr>    <chr> <chr>                           <chr>
    #> 1 01-Married: RR married binomial ratio "svyglm(quasipoisson(\"log\"))" married…

Two columns answer it. `link` is the measure the model estimates — `odds_ratio` for the first, so the ratio is worked out from its predictions; `ratio` for the second, which estimates it directly — and that word is what you type into `link =`. `fit` is the R call: `glm(binomial("logit"))` against `svyglm(quasipoisson("log"))`, a different model altogether. A `svyglm()` row also means robust (Huber-White) standard errors, which is exactly why a Poisson likelihood may be put on a 0/1 outcome.

Which to use. Prefer `measure = "ratio"`, the marginal one, in almost every case: the logistic model always converges, can never predict a probability above 100%, and the number it gives describes the people you surveyed. Reach for `link = "ratio"` when you need the conditional risk ratio — the one that assumes the same ratio holds for everybody, which is what a reader coming from epidemiology expects, and what you must use to line up with a published modified-Poisson estimate. The two are different estimands, not two spellings of one.

For readers who want the technical detail: standard errors are handled consistently in both cases. Fitting a Poisson likelihood to a 0/1 outcome is a deliberate misspecification, so the naive standard errors would be too wide; tabxplor replaces them with the robust Huber–White sandwich, via `survey::svyglm()` — which means the design-based variance when you supply survey weights, and the equivalent of `HC0` otherwise. The observed companion follows the same estimand throughout: `Obs_RR` is the crude risk ratio with a Katz interval, never the crude odds ratio. The modified Poisson needs a reasonable sample (n of at least about 100), and since a quasi-likelihood has no true likelihood its footer reports N and a Wald-vs-null test instead of AIC/BIC.

## 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’ `D` — of 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.44` → `1/2.54` | `1/1.11` → `1.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 <that> the odds of being married of the reference cell, and <x> % against <y> % 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

## Reading the footer statistics

The footer summarises model fit; which statistics appear depends on the family:

- N — the number of observations used.
- LR vs null — a likelihood-ratio test of the whole model against the intercept-only model (is the model worth anything?).
- McFadden R² — a pseudo-R² for binomial and poisson models (higher = the predictors explain more; values are much smaller than a linear R²).
- AIC / BIC — information criteria for comparing models on the same data (lower = better; BIC penalises complexity more).
- Overall association — one test per predictor: is this variable associated with the outcome at all? A block of stars against a reference category cannot answer that, which is why a multi-level factor needs more than its cells.
- Linear models (gaussian) add the usual R² / adjusted R² / F / σ; count models add the Pearson dispersion (φ).

For weighted models a reduced set is reported (a Wald test against the null, Nagelkerke / Cox–Snell pseudo-R², a Rao–Scott AIC), because the likelihood-based quantities do not apply to a design-based fit.

## 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:

- Proportionality (Brant) — for an ordinal outcome, is one cumulative odds ratio enough for every cut? Printed by default there, and a refit anyway, because a cumulative odds ratio that fails it is not one number but a fiction. When it fails, `measure = "difference"` is the robust way to keep reading the same fit (Somers’ `D` barely moves when the assumption breaks); `family = "multinomial"` is the way to stop assuming it at all.
- Linearity — is this predictor’s effect really one straight line? One row per continuous predictor, and the row a wrong shape shows up in is rarely the only row it damages. The next two sections are about reading it and curing it.
- Dispersion (robust/model SE) — are the standard errors wide enough? About 1 means the family’s variance assumption holds; 1.4 means the intervals should be about 40 % wider than they are. For a count outcome, `family = "quasipoisson"` fixes exactly that, and the row then returns to about 1 (while the Pearson dispersion φ still reports the over-dispersion itself). Under a survey design it simply says how much the design mattered — the intervals are already the design’s.
- Collinearity (max VIF) — can the data tell these predictors apart? From about 5 two predictors are measuring much the same thing and their intervals are inflated; the footer marks the cell from 10, the textbook figure. Collinearity biases nothing, so this is the one check that is a caution rather than a problem.
- Influence (max dfbetas) — does one respondent carry the result? The largest amount, in standard errors, by which dropping a single respondent moves any coefficient. With thousands of respondents this is normally reassuringly small, and that reassurance is the point. Careful: influence is not the same as being an outlier — a surprising answer from an otherwise ordinary respondent moves nothing.

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:

- a straight rise or fall — one number describes it, nothing to do;
- a bend or a plateau — the effect is not the same everywhere, and the cures below fix it;
- a reversal, up then down, like `age` here — the one a single coefficient cannot express at all, because it reports one direction for a variable that has two. `age` climbs steeply, levels off, then falls: the single `1.45` per two standard deviations printed in its row is the average of a rise and a fall, a number that describes nobody. `tvhours` is the contrast, and the reassuring case: it falls throughout, so its `1/1.69` really is the effect of watching more television, and there is nothing to do about it.

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

- `vignette("tabxplor-reading-a-regression")` — the same material taught as one continuous analysis, with the reading habits that go with it.
- `vignette("tabxplor")` — cross-tables and the colour helpers.
- `vignette("tabxplor-weights")` — weighted and survey data, for both producers.
- `?tab_reg` for every argument (grouped by purpose), and `?tab_reg` → Details for the modelling choices.
- Out of scope, and best fitted with their own packages: survival / Cox models, mixed (multilevel) models, and pooling over multiply-imputed datasets.
- On comparing a crude and an adjusted coefficient, the canonical reference is Clogg, Petkova & Haritou (1995, AJS 100(5)), with Allison’s comment in the same issue — what `color = "adjustment"` computes, generalised to GLMs, survey designs and marginal effects. For nested logit models, the KHB decomposition (Karlson, Holm & Breen 2012) separates the part of the change that is confounding from the part that is rescaling.