Skip to content

Commit 5a62bf7

Browse files
committed
Add drop1.loglm() prototype for term-level loglm tests
Development prototype (dev/anova-like/) for the term-level analysis described in issues/anova-like.md, moved here as dev/anova-like/anova-like.md. drop1.loglm() is the primary interface: single-term deletion tests for a loglm model's generating class, refitting via update() rather than reconstructing formulas by hand (the latter is what broke the first draft, anova-like.R, on data.frame/Freq ~ ... fits). Reports both LR (G^2) and Pearson (X^2) statistics per term, attaches the fitted models as a loglmlist via attr(., "models") for reuse with mosaic()/LRstats()/ get_models(), and supports abbrev for compact headings. LRanova() wraps it with a partial R^2 column against a nested baseline model. assoc_strength() wraps it with a partial Cramer's V/Cohen's w effect-size column, generalized to terms of any order. Roxygen docs for all three are merged under one @Rdname drop1.loglm topic, each with @examples and a worked @details example of what a model's generating class is. drop1-test.R is a lightweight stopifnot() check suite (testthat coverage deferred per priority call).
1 parent 5f52f96 commit 5a62bf7

5 files changed

Lines changed: 1033 additions & 175 deletions

File tree

‎dev/anova-like/README.md‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Term-level analysis for `loglm` models
2+
3+
This directory is a development prototype for `dev/anova-like.md`.
4+
5+
- `anova-like.R` -- first draft (kept for reference/history). Implements
6+
`LRanova()` only.
7+
8+
- `drop1.loglm.R` -- second draft. `drop1.loglm()` is now the primary
9+
function, with `LRanova()` as a thin wrapper that adds a partial R^2
10+
column, and `assoc_strength()` adding a partial Cramer's V / Cohen's w
11+
column. See "What changed" below. Each function also has a roxygen
12+
`@examples` block illustrating its main variations.
13+
14+
- `drop1-test.R` -- lightweight `stopifnot()`-based check suite (not
15+
`testthat`, per the priority call below) covering the worked-example
16+
values, the `models` attribute, the DaytonSurvey data.frame case, and the
17+
nesting-check error path. Run with `Rscript dev/anova-like/drop1-test.R`
18+
after any change; it should print `All checks passed.`
19+
20+
Both drop each member of the fitted model's generating class (`$margin`),
21+
refit the reduced hierarchical model, and report changes in likelihood-ratio
22+
and Pearson statistics, degrees of freedom, and the likelihood-ratio p-value.
23+
`LRanova()` additionally reports a partial R^2 relative to a baseline model
24+
(default: mutual independence over the variables in the generating class).
25+
26+
## What changed from the first draft, and why
27+
28+
`anova-like.R`'s `LRanova()` failed on the `DaytonSurvey` example:
29+
30+
```
31+
Error in loglm1.data.frame(formula, data, ..., .call = .call, .formula = .formula) :
32+
formula specifies no response
33+
```
34+
35+
Root cause: `.margin_formula()`/`.drop_loglm_margin()` rebuilt each reduced
36+
model's formula from `$margin` alone (`~ A:B + A:C + ...`), which has no
37+
LHS. That's fine for array/table-based fits (`loglm(~ (A+B+C)^2, data =
38+
UCBAdmissions)`, no response in the formula to begin with) but wrong for
39+
data.frame-based fits like `DaytonSurvey`, which use `Freq ~ ...` -- the
40+
rebuilt formula silently drops the response, and `loglm1.data.frame`
41+
requires one.
42+
43+
Rather than patch the formula-rebuilding to reattach the response, the
44+
second draft (`drop1.loglm.R`) doesn't rebuild formulas at all. It turns out
45+
`MASS` already supplies `update.loglm()`, `extractAIC.loglm()`, and a
46+
working `terms()`/`formula()` for `"loglm"` objects, which means
47+
**`stats::drop1()` and `MASS::dropterm()` already work correctly on `loglm`
48+
objects today**, response variable included -- they dispatch to
49+
`drop1.default()`/`dropterm.default()`, which refit via `update()`. Verified
50+
against `UCBAdmissions`, `HairEyeColor`, and `DaytonSurvey` (MASS 7.3-66, R
51+
4.6.1); `drop.scope()` on a hierarchical model already restricts to exactly
52+
the generating-class terms, matching what we want.
53+
54+
So the "gap" in `issues/anova-like.md` is narrower than it first looked: the
55+
significance-testing half is already available via base R, just not
56+
presented in loglinear-friendly form and missing the Pearson X^2 column.
57+
`drop1.loglm()` in the second draft refits via `update(object, ~ . - term)`
58+
-- the same mechanism `drop1.default()` uses -- instead of reconstructing
59+
formulas, which is both simpler and avoids the response-variable bug by
60+
construction. It adds:
61+
62+
- the Pearson X^2 column alongside LR G^2 (read directly off `$pearson` on
63+
each refit -- `loglm` objects store both `$lrt` and `$pearson`, but
64+
`extractAIC.loglm()`/`drop1.default()` only expose a deviance-derived LRT)
65+
- a `scope` argument restricted to (a subset of) the generating class, with
66+
validation
67+
- bracket-notation headings via `get_model()`, consistent with
68+
`LRstats()`/`seq_loglm()` elsewhere in the package
69+
70+
`LRanova()` is now a wrapper around `drop1.loglm()` and additionally
71+
validates that a user-supplied `baseline` is nested within `object` (see
72+
resolved open question below) before computing partial R^2.
73+
74+
## Open questions
75+
76+
- Should the public API be `LRanova()`, `drop1.loglm()`, or both?
77+
MF: The primary one should be `drop1.loglm`. `LRanova` can be a wrapper.
78+
**Done in `drop1.loglm.R`.**
79+
80+
- Is the generating class exactly the desired set of tested terms, or should
81+
lower-order terms receive Type II tests as well?
82+
MF: Just the generating class.
83+
**Done** -- `scope` defaults to `$margin` term labels, and a user-supplied
84+
`scope` must be a subset of them.
85+
86+
- Should a supplied baseline be required to be nested below the fitted model?
87+
MF: Yes, ensure nesting.
88+
**Done** -- `LRanova()` checks that every term in `terms(baseline)` appears
89+
in `terms(object)` (which already includes implied lower-order relatives
90+
for a hierarchical model, so a plain subset check is sufficient) and that
91+
`baseline$df >= object$df`. Both checks are exercised in the worked
92+
examples at the bottom of `drop1.loglm.R`.
93+
94+
- Which sample-size adjustment, if any, should accompany partial R-squared?
95+
MF: Not clear what this means; what are the possibilities?
96+
**Proposal (not yet implemented):** the current `Partial R^2 =
97+
Delta-G^2_term / G^2_baseline` is an *entropy R^2*, directly analogous to
98+
eta-squared -- it's biased upward for small/noisy terms, especially when
99+
`Df` is large relative to `N`, because it doesn't discount the G^2 you'd
100+
expect to see by chance alone.
101+
102+
The standard fix (paralleling omega-squared
103+
vs. eta-squared in ANOVA) is exactly the "partial omega^2" already listed
104+
in `issues/anova-like.md`'s candidate table:
105+
`(Delta-G^2_term - Delta-df_term) / (G^2_baseline + N)`. The `- Delta-df`
106+
in the numerator subtracts the expected contribution of a null term
107+
(E[chi-sq] = df under H0); the `+ N` in the denominator is the standard
108+
omega^2-style rescaling. This would sit alongside (not replace) the plain
109+
entropy R^2, as a second, debiased column -- worth confirming before
110+
adding it.
111+
112+
- For a two-factor term in a multiway table, what conditioning convention and
113+
effective sample size should define partial Cramer's V?
114+
MF: Not clear what this means; what are the possibilities? ... Could this
115+
be an option, `c("Cramer", "Cohen")`, allowing either?
116+
**Done** -- `assoc_strength(object, scope, method = c("Cramer", "Cohen"))`
117+
is implemented in `drop1.loglm.R`, defaulting to `"Cramer"`:
118+
119+
- `"Cramer"`: `V = sqrt(Delta-X^2_term / (N * (k - 1)))`, where `k` is the
120+
*smallest* factor level count among the term's variables (not just the
121+
two-way `min(r,c)` -- this generalizes to terms of any order, and
122+
reduces exactly to the ordinary two-way Cramer's V when the term has two
123+
factors).
124+
125+
- `"Cohen"`: `w = sqrt(Delta-X^2_term / N)`, unbounded, no reference to
126+
table shape.
127+
128+
- When every factor in a term is binary, `k - 1 = 1` and the two methods
129+
coincide exactly -- seen in the DaytonSurvey worked example, where every
130+
variable is binary and the `"Cramer"`/`"Cohen"` columns are identical;
131+
they diverge properly on HairEyeColor's `Hair:Eye` term (4x4 levels).
132+
- Sanity check: on UCBAdmissions, the *partial* `Admit:Gender` V comes out
133+
at 0.016 (barely any association once Dept is accounted for), versus a
134+
*marginal* V of 0.143 from `vcd::assocstats()` on the raw 2-way margin
135+
-- exactly the well-known Simpson's-paradox story for this dataset (the
136+
apparent gender gap is really a department effect). Good evidence the
137+
formula is behaving sensibly and not just running without error.
138+
139+
- N is read off `object$fitted`; if the model wasn't originally fit with
140+
`fitted = TRUE`, `assoc_strength()` calls `update(object, fitted = TRUE)`
141+
once to get it (loglm objects don't store `$fitted`/`$frequencies` by
142+
default).
143+
144+
## Next steps
145+
146+
- Confirm (or amend) the partial-omega^2 proposal above (partial R^2's
147+
sample-size adjustment is still open; `assoc_strength()`/Cramer's V vs.
148+
Cohen's w is now resolved and implemented).
149+
150+
- Decide whether `drop1.loglm()`/`LRanova()` should also handle `glm`
151+
(Poisson) models directly (the issue's "Handling the glm case" section),
152+
or stay `loglm`-only with `car::Anova()` left as the `glm` path.
153+
154+
- Move to `R/drop1.loglm.R` with roxygen docs (markdown syntax, no Rd
155+
macros) once the API above is confirmed, and register `S3method(drop1,
156+
loglm)` in `NAMESPACE`.
157+
158+
- Could something more generally useful be added to what `drop1.loglm()` returns?
159+
It is an object of class `c("anova", "data.frame")` for its' print() method,
160+
but it could also carry an attribute, e.g., `models`, giving either the symbolic
161+
formulas for the models, or the result of fitting them. I'm thinking of an
162+
extension of `mosaic()` that could plot the collection models, using `mosaic.loglmlist()`
163+
164+
**Done** -- turns out no extension of `mosaic()` was needed: `mosaic.loglmlist()`,
165+
`LRstats.loglmlist()`, and `get_models.loglmlist()` already exist in the
166+
package and work on any object of class `loglmlist`. `drop1.loglm()` was
167+
already fitting every drop-one model internally to compute the
168+
Delta-statistics, so `attr(result, "models")` now attaches them as a
169+
`loglmlist` (`"<none>"` + one per dropped term) at no extra fitting cost.
170+
`LRanova()`/`assoc_strength()` inherit it automatically since they build on
171+
`drop1.loglm()`'s return value. Verified end-to-end: `mosaic(attr(result,
172+
"models"), ask = FALSE)` and `LRstats(attr(result, "models"))` both render
173+
correctly against the UCBAdmissions example. `LRanova()`'s baseline model
174+
stays separate as `attr(., "baseline")` (a single reference model, not a
175+
`<none>`/drop-one row) rather than being folded into the same list.
176+
177+
- `testthat` coverage: low priority for now. Worked examples in
178+
`drop1.loglm.R` are wrapped in `if (FALSE) { ... }` and run via `source()`
179+
for interactive/manual checking in the meantime.
180+

‎dev/anova-like/anova-like.R‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Prototype term-level likelihood-ratio tests for MASS::loglm models.
2+
#
3+
# This file deliberately lives under dev/: the API and the definitions of the
4+
# effect-size columns still need review before promotion to R/.
5+
6+
.margin_formula <- function(margin) {
7+
if (!length(margin)) {
8+
return(stats::as.formula("~ 1"))
9+
}
10+
11+
labels <- vapply(
12+
margin,
13+
function(x) paste(x, collapse = ":"),
14+
character(1L)
15+
)
16+
stats::reformulate(labels)
17+
}
18+
19+
.drop_loglm_margin <- function(object, index) {
20+
reduced_margin <- object$margin[-index]
21+
reduced_formula <- .margin_formula(reduced_margin)
22+
23+
model_call <- object$call
24+
model_call$formula <- reduced_formula
25+
model_call[[1L]] <- quote(MASS::loglm)
26+
eval(model_call, envir = environment(stats::formula(object)))
27+
}
28+
29+
LRanova <- function(model, baseline = NULL) {
30+
if (!inherits(model, "loglm")) {
31+
stop("`model` must inherit from class 'loglm'.", call. = FALSE)
32+
}
33+
if (is.null(model$margin) || !length(model$margin)) {
34+
stop("The model does not contain a non-empty generating class.", call. = FALSE)
35+
}
36+
37+
reduced <- lapply(seq_along(model$margin), function(i) {
38+
.drop_loglm_margin(model, i)
39+
})
40+
41+
delta_g2 <- vapply(reduced, function(x) x$lrt - model$lrt, numeric(1L))
42+
delta_x2 <- vapply(reduced, function(x) x$pearson - model$pearson, numeric(1L))
43+
delta_df <- vapply(reduced, function(x) x$df - model$df, numeric(1L))
44+
45+
if (is.null(baseline)) {
46+
variables <- unique(unlist(model$margin, use.names = FALSE))
47+
baseline_call <- model$call
48+
baseline_call$formula <- stats::reformulate(variables)
49+
baseline_call[[1L]] <- quote(MASS::loglm)
50+
baseline <- eval(
51+
baseline_call,
52+
envir = environment(stats::formula(model))
53+
)
54+
}
55+
if (!inherits(baseline, "loglm")) {
56+
stop("`baseline` must be NULL or a fitted 'loglm' model.", call. = FALSE)
57+
}
58+
59+
baseline_g2 <- baseline$lrt
60+
partial_r2 <- if (is.finite(baseline_g2) && baseline_g2 > 0) {
61+
delta_g2 / baseline_g2
62+
} else {
63+
rep(NA_real_, length(delta_g2))
64+
}
65+
66+
terms <- vapply(
67+
model$margin,
68+
function(x) paste(x, collapse = ":"),
69+
character(1L)
70+
)
71+
72+
result <- data.frame(
73+
`Delta-G^2` = delta_g2,
74+
`Delta-X^2` = delta_x2,
75+
Df = delta_df,
76+
`Pr(>Chisq)` = stats::pchisq(delta_g2, delta_df, lower.tail = FALSE),
77+
`Partial R^2` = partial_r2,
78+
check.names = FALSE,
79+
row.names = terms
80+
)
81+
attr(result, "heading") <- "Term-level Analysis of Association"
82+
attr(result, "baseline.G2") <- baseline_g2
83+
class(result) <- c("anova", "data.frame")
84+
result
85+
}
86+
87+
88+
# Worked examples ---------------------------------------------------------
89+
90+
library(MASS)
91+
ucb_all_2way <- MASS::loglm(
92+
~ (Admit + Gender + Dept)^2,
93+
data = UCBAdmissions
94+
) |> print()
95+
96+
97+
ucb_anova <- LRanova(ucb_all_2way) |>
98+
print()
99+
100+
str(ucb_anova)
101+
102+
MASS::dropterm(ucb_all_2way, test = "Chisq")
103+
104+
105+
## ------------------------------------------------------------
106+
107+
108+
hair_eye_all_2way <- MASS::loglm(
109+
~ (Hair + Eye + Sex)^2,
110+
data = HairEyeColor
111+
)
112+
LRanova(hair_eye_all_2way)
113+
114+
## ------------------------------------------------------------
115+
##
116+
data("DaytonSurvey")
117+
118+
DS_indep <- loglm(
119+
Freq ~ (cigarette + alcohol + marijuana + sex + race),
120+
data = DaytonSurvey
121+
) |> print()
122+
123+
# add all two-way terms
124+
DS_all_two_way <- update(DS_indep, . ~ .^2) |>
125+
print()
126+
127+
128+
DS_anova_2 <- LRanova(DS_all_two_way) |>
129+
print()
130+
131+
# Gives:
132+
# Error in loglm1.data.frame(formula, data, ..., .call = .call, .formula = .formula) :
133+
# formula specifies no response
134+

0 commit comments

Comments
 (0)