|
| 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 | + |
0 commit comments