Skip to content

355 svyrep updates - #359

Open
szimmer wants to merge 17 commits into
pharmaverse:mainfrom
szimmer:355_svyrep_updates
Open

szimmer wants to merge 17 commits into
pharmaverse:mainfrom
szimmer:355_svyrep_updates

Conversation

@szimmer

@szimmer szimmer commented Sep 6, 2026

Copy link
Copy Markdown

Added svyrep.design support across survey, statistical test, CI, and emmeans methods, allowing these functions to work with replicate-weight designs created by survey::svrepdesign() or survey::as.svrepdesign(). Previously, S3 dispatch and explicit class checks rejected these designs even though the underlying survey functions already supported them. Statistics continue to use the same survey implementations, so variance estimates correctly account for replicate weights. construct_model() also now supports svyrep.design, and emmeans methods use inherits(data, c("survey.design", "svyrep.design")) to correctly identify both survey design classes. Additionally, tbl_svy produced from the {srvyr} package are also supported because tbl_svy objects are also of class survey.design or svyrep.design. (#355, @szimmer)

Closes #355


Pre-review Checklist (if item does not apply, mark is as complete)

  • All GitHub Action workflows pass with a ✅
  • PR branch has pulled the most recent updates from master branch: usethis::pr_merge_main()
  • If a bug was fixed, a unit test was added.
  • If a new ard_*() function was added, it passes the ARD structural checks from cards::check_ard_structure().
  • If a new ard_*() function was added, set_cli_abort_call() has been set.
  • If a new ard_*() function was added and it depends on another package (such as, broom), is_pkg_installed("broom") has been set in the function call and the following added to the roxygen comments: @examplesIf do.call(asNamespace("cardx")$is_pkg_installed, list(pkg = "broom""))
  • Code coverage is suitable for any new functions/features (generally, 100% coverage for new code): devtools::test_coverage()

Reviewer Checklist (if item does not apply, mark is as complete)

  • If a bug was fixed, a unit test was added.
  • Code coverage is suitable for any new functions/features: devtools::test_coverage()

When the branch is ready to be merged:

  • Update NEWS.md with the changes from this pull request under the heading "# cardx (development version)". If there is an issue associated with the pull request, reference it in parentheses at the end update (see NEWS.md for examples).
  • All GitHub Action workflows pass with a ✅
  • Approve Pull Request
  • Merge the PR. Please use "Squash and merge" or "Rebase and merge".

szimmer and others added 9 commits August 30, 2026 10:42
`svyrep.design` is a sibling class of `survey.design` rather than a
subclass, so replicate-weight designs failed S3 dispatch before reaching
any statistical code.

Register `svyrep.design` methods for the six survey ard_* generics. The
implementations are shared with the existing `survey.design` methods:
statistics are computed by survey::svymean(), svytotal(), svyvar(),
svyquantile(), svytable() and svyby(), all of which already handle
replicate designs, so no separate calculation is needed.

Tests cover dispatch, ARD structure, agreement of weighted point
estimates with the linearized design, that replicate variance is
genuinely used rather than silently falling back to linearization, and
all replicate types (JK1, JKn, bootstrap, subbootstrap, Fay, BRR).

Refs pharmaverse#355

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The methods were previously alias assignments
(`ard_tabulate.svyrep.design <- ard_tabulate.survey.design`). That form
is invisible to tooling that reasons about the source statically:

  - covr reported no rows at all for R/svyrep.design.R -- the file was
    absent from the coverage report rather than scoring 0% or 100%,
    because top-level assignments carry no instrumentable expressions
  - covr::file_coverage() errored outright with
    "object 'ard_attributes.survey.design' not found", since the aliases
    resolve only at load time and depend on file collation order
  - static analysis could not resolve the right-hand sides

Defining each method as a thin wrapper that forwards to its
`survey.design` counterpart fixes all three: R/svyrep.design.R now
reports 100% coverage (6/6 lines hit by the test suite) and no longer
depends on files being sourced in a particular order. Behaviour is
unchanged -- the same `survey.design` implementations do the work, and
tidyselect arguments pass through `...` untouched.

Also:

  - add an @examplesIf block to each of the six shared help topics
    showing a replicate design built with as.svrepdesign(), and
    regenerate the .Rd files
  - scope the NEWS entry to the six functions actually covered, and note
    that the survey test and confidence interval functions do not yet
    accept replicate designs, so the release notes do not promise more
    than the change delivers
  - satisfy lintr in the tests: rename BRRrep to brr_rep (snake_case) and
    load the survey data into an explicit environment so apiclus1 is not
    flagged as an undefined global inside the helper

Refs pharmaverse#355

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ard_survey_svychisq()`, `ard_survey_svyttest()`, `ard_survey_svyranktest()`,
`ard_categorical_ci()` and `ard_continuous_ci()` rejected replicate-weight
designs with a `check_class(data, "survey.design")` guard, even though the
`survey` functions they wrap already support them:

    svychisq(~stype + both, des)  #> p = 0.06135439
    svychisq(~stype + both, rep)  #> p = 0.07992227

Relax those five guards to accept `svyrep.design`, and register
`svyrep.design` methods for `ard_categorical_ci()` and `ard_continuous_ci()`,
which are S3 methods rather than plain functions -- relaxing the guard alone
would not route a replicate design to them.

The new methods are placed alongside their `survey.design` counterparts rather
than in R/svyrep.design.R, to avoid a merge conflict with the PR that adds
that file.

This is what gtsummary's add_p() and add_ci() need. Because add_p() traps
errors per variable, the guard previously surfaced to users not as an error
but as a silently empty p-value column. With both halves in place add_p()
returns 0.07992227 for the replicate design above, matching svychisq()
directly, where it previously returned NA.

Tests assert that each result matches `survey` computed directly on the
replicate design, and differs from the linearized design, so a silent fallback
to linearization would fail rather than pass.

Refs pharmaverse#355

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ard_emmeans_emmeans()` and `ard_emmeans_contrast()` rejected replicate-weight
designs with a class guard. Underneath that guard sat a second problem: both
selected the model data with

    data_in <- if (dplyr::last(class(data)) == "survey.design") ...

which happens to identify `survey.design2` -- whose last class element is
"survey.design" -- but does not identify a `svyrep.design` at all. Relaxing
only the guard would have passed the design object where a data frame was
expected. The test is now `inherits(data, c("survey.design",
"svyrep.design"))`, which is also less brittle for `survey.design` subclasses
generally.

`construct_model()` needed a `svyrep.design` method for the same
sibling-class reason; it delegates to the `survey.design` method, and the
model is fit by `survey::svyglm()`, which already handles replicate designs.

emmeans itself required no changes -- the estimated marginal means come back
with replicate-based standard errors, differing from the linearized design
and matching `emmeans::emmeans()` applied to the replicate model directly:

    linearized SEs: 28.98769  23.42657
    replicate  SEs: 32.48311  26.07184

Tests cover both functions, `construct_model()`, and a `data.frame` case to
confirm the changed class test did not alter the non-survey path.

Refs pharmaverse#355

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@ddsjoberg ddsjoberg left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a quick look. Can you please address and I'll continue

Comment thread DESCRIPTION Outdated
lme4 (>= 1.1-37),
parameters (>= 0.20.2),
smd (>= 0.6.6),
srvyr,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's remove this, and drop any tests for these objects specifically. Fewer deps the better for us! (And srvry is already handling much of that testing to ensure their objects work with the survey infrastructure)

Comment thread tests/testthat/test-svyrep.design.R Outdated
@@ -0,0 +1,159 @@
skip_if_pkg_not_installed("survey")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review how the other test files are named, and follow those naming conventions/organizational structure.

#' @rdname ard_attributes
#' @param data (`survey.design`)\cr
#' a design object often created with [`survey::svydesign()`].
#' @param data (`survey.design`) or (`svyrep.design`)\cr

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only change for this file. Don't you also need to add an S3 method for the svrep.design objects?

Comment thread NEWS.md Outdated
@@ -1,5 +1,7 @@
# cardx (development version)

* Added `svyrep.design` support across survey, statistical test, CI, and emmeans methods, allowing these functions to work with replicate-weight designs created by `survey::svrepdesign()` or `survey::as.svrepdesign()`. Previously, S3 dispatch and explicit class checks rejected these designs even though the underlying survey functions already supported them. Statistics continue to use the same `survey` implementations, so variance estimates correctly account for replicate weights. `construct_model()` also now supports `svyrep.design`, and emmeans methods use `inherits(data, c("survey.design", "svyrep.design"))` to correctly identify both survey design classes. Additionally, `tbl_svy` produced from the {srvyr} package are also supported because `tbl_svy` objects are also of class `survey.design` or `svyrep.design`. (#355, @szimmer)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explicit class checks rejected these designs

Update this language. We didn't explicitly reject the class.

Comment thread R/svyrep.design.R Outdated
#' variables = c(sname, dname),
#' label = list(sname = "School Name", dname = "District Name")
#' )
ard_attributes.svyrep.design <- function(data, ...) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see this function now. The survey methods can be documented together instead of in a separate file.

@ddsjoberg

Copy link
Copy Markdown
Collaborator

@szimmer Below is a Claude review of the PR. Let me know your thoughts on the raised points.

Review follow-ups for #359 (svyrep.design support)

Context

PR #359 (355_svyrep_updates, @szimmer) adds svyrep.design support to cardx: 6 new
ard_* S3 methods in a new R/svyrep.design.R, 2 CI methods next to their
survey.design counterparts, construct_model.svyrep.design, and relaxed
check_class() guards in the 5 test/CI functions plus the 2 emmeans functions.

The approach is right — svyrep.design is a sibling of survey.design, the
underlying survey functions already handle replicate designs, and the guards
were genuinely over-restrictive. But extending the survey.design
implementations to a class whose weights() method returns a matrix re-opens
the exact bug #352 just closed, and the thin-wrapper method form drops
caller_env(). Both are demonstrable. Three CI jobs are also red.

Below is the fix list. It assumes the maintainer has already asked (in PR
comments) to drop the srvyr dependency and its tests, and to rename/reorganize
the test files.

Blocking fixes

1. min/max are computed against the replicate-weight matrix

R/ard_summary.survey.design.R:212-213

min(design$variables[[all.vars(x)]][stats::weights(design) > 0], na.rm = na.rm)

For svyrep.design, stats::weights() defaults to type = "replication" and
returns an n × R matrix (183 × 15 for as.svrepdesign(dclus1)). Used as a
logical index against a length-183 column it silently drops every row whose
first replicate weight is 0 — i.e. one whole PSU — and pads the rest with NA
(which na.rm = TRUE then removes).

Reproduction (max should be 999, the value carried only by PSU 1):

ac <- apiclus1; ac$mv <- 0; ac$mv[ac$dnum == ac$dnum[1]] <- 999
d <- survey::svydesign(id = ~dnum, weights = ~pw, data = ac, fpc = ~fpc)
r <- survey::as.svrepdesign(d)
x <- r$variables$mv
max(x[stats::weights(r) > 0], na.rm = TRUE)  #> 0   (truth: 999)

Fix — request sampling weights explicitly. Verified to return the same 183
sampling weights for survey.design2, svyrep.design, and both tbl_svy
flavours:

stats::weights(design, type = "sampling") > 0

Add a test asserting min/max agree between the linearized and replicate
designs. The current tests never reach this code path because
ard_summary()'s default statistic is c("median", "p25", "p75").

2. construct_model.svyrep.design swallows env = caller_env()

R/construction_helpers.R:363-365

construct_model.survey.design() defaults env = caller_env() and evaluates the
fitted call with eval_tidy(call_to_run, env = env). Because the new method is
function(data, ...), that default resolves to the wrapper's frame — which
contains only data and ... — so NSE method.args referencing caller-local
objects stop resolving:

fit <- function(formula, design, tag) structure(list(tag = tag), class = "myfit")
f <- function(des) { mytag <- "hello"
  construct_model(des, api00 ~ sch.wide, method = fit,
                  method.args = list(tag = mytag), package = "survey") }
f(d)  # survey.design  -> myfit
f(r)  # svyrep.design  -> Error: object 'mytag' not found

Fix:

construct_model.svyrep.design <- function(data, ..., env = caller_env()) {
  construct_model.survey.design(data = data, ..., env = env)
}

(Confirmed to work; note survey::svyglm on a replicate design re-evaluates its
own call, so the method = "svyglm" path masks this — a custom method or
another fitter exposes it.)

Red CI

  1. Spell Check — add srvyr to inst/WORDLIST (or remove it from NEWS along
    with the dependency, per the maintainer's comment). spelling::update_wordlist().
  2. Style Check — 9 files fail styler; the R-file failures are trailing
    whitespace after …, or in the new @param data roxygen. Run:
    styler::style_file(c("tests/testthat/test-svyrep.design.R", "tests/testthat/test-svyrep.design_emmeans.R", "tests/testthat/test-svyrep.design_tests_ci.R", "R/ard_attributes.survey.design.R", "R/ard_summary.survey.design.R", "R/ard_survey_svychisq.R", "R/ard_survey_svyranktest.R", "R/ard_survey_svyttest.R", "R/ard_tabulate.survey.design.R"))
    Also add the missing trailing newline to test-svyrep.design.R.
  3. ubuntu-latest (devel) — fails in test-ard_stats_mantelhaen_test.R:11/:32,
    unrelated to this PR. Confirm against a clean main run and leave alone.

Non-blocking

  1. File organization. The 6 methods live in R/svyrep.design.R while the two
    CI methods and construct_model sit beside their survey.design
    counterparts. The commit message justifies this as merge-conflict avoidance
    between two now-merged branches, so the justification is spent — and it
    already caused a reviewer to ask "don't you also need an S3 method?" on
    R/ard_attributes.survey.design.R. Move each method next to its
    survey.design sibling, matching the repo's one-file-per-function layout.
  2. @param data convention. Repo style is a single parens with /
    (R/ard_emmeans_contrast.R:13): use
    (`survey.design`/`svyrep.design`), not
    (`survey.design`) or (`svyrep.design`).
  3. Rd usage hides the real arguments. \method{ard_summary}{svyrep.design}(data, ...)
    gives a reader no signal that variables/by/statistic are accepted.
    Either give the wrappers the real formals or add one @details sentence
    saying the replicate methods take the same arguments.
  4. Duplicated example blocks. Nine help topics now carry a second
    @examplesIf block; ard_categorical_ci/ard_continuous_ci reuse dclus1
    from the preceding block. Works today because both guards test
    pkg = "survey", but it breaks silently if the guards ever diverge — make the
    second block self-contained or fold it into the first.
  5. NEWS.md. Six-line paragraph naming internals (inherits(data, c(...))).
    Trim to one or two sentences stating the user-facing change; drop the
    implementation detail and the srvyr sentence.
  6. Test-suite consistency. make_designs() is duplicated in
    test-svyrep.design.R and test-svyrep.design_tests_ci.R and inlined again
    in the emmeans file — move to tests/testthat/helper-*.R. The "all replicate
    types" test uses bare data(api, package = "survey") while the helper uses
    new.env(); pick one. That test also checks ard_tabulate + ard_summary
    for JK1/JKn/bootstrap/subbootstrap/Fay but only ard_summary for BRR.
  7. Missing replicate-variance assertions. ard_survey_svyttest() and
    ard_survey_svyranktest() assert only that point estimates match; the
    svychisq/CI tests additionally assert the replicate result differs from the
    linearized one. Add the same guard so a silent fallback to linearization
    would fail.
  8. ard_smd_smd() is the remaining gap. R/ard_smd_smd.R:41 uses
    inherits(data, "survey.design"), so a replicate design falls to the
    data-frame branch and errors at check_data_frame(). It also calls
    stats::weights(design) (same matrix hazard as Add ARDs for proportion confidence intervals #1). Either extend it — with
    type = "sampling" — or say plainly in NEWS that add_difference() on a
    replicate design is not yet supported.
  9. Deprecated generics. ard_continuous/ard_categorical/ard_dichotomous
    .survey.design methods (R/deprecated.R:31,44,57) have no replicate
    counterpart, so old user code hits "no applicable method" instead of a
    deprecation warning. Defensible for deprecated API; worth a one-line note.

Verification

Rscript -e 'devtools::document(); devtools::test(); devtools::run_examples()'
Rscript -e 'styler::style_pkg(); spelling::update_wordlist()'

Targeted checks for the two blocking fixes:

  • ard_summary(rep_design, variables = mv, statistic = ~ c("min", "max")) returns
    the same extremes as the linearized design on the mv fixture above, for JK1,
    JKn, bootstrap, and BRR designs.
  • construct_model() with a custom method function and a caller-local object in
    method.args succeeds on a replicate design.

@szimmer
szimmer marked this pull request as draft September 7, 2026 14:44
@szimmer

szimmer commented Sep 7, 2026

Copy link
Copy Markdown
Author

Changing this to a draft while I work on updates. Update plans include:

  • Moving relevant replicate functions/methods to same files as survey. This will be easier for maintenance
  • Similarly, with tests, add to same tests as test-*.survey.design and repeat a lot of the same tests for replicate but add a few more
  • Remove srvyr as suggested package
  • Style files - note the Contribution guide for this repo mentions a .lintr file which doesn't exist @ddsjoberg

@ddsjoberg

Copy link
Copy Markdown
Collaborator

Style files - note the Contribution guide for this repo mentions a .lintr file which doesn't exist @ddsjoberg

Ah, thanks for letting me know. Just updated the guide

@szimmer

szimmer commented Sep 7, 2026

Copy link
Copy Markdown
Author

I got stuck on some tests in ard_tabulate not passing with a replicate object. No standard errors were being output. I figured out the root of the issue but trying to think of a graceful way to handle this. The svyby output isn't labeled as expected

svy_titanic <- survey::svydesign(~1, data = as.data.frame(Titanic), weights = ~Freq)
rsvy_titanic <- survey::as.svrepdesign(svy_titanic)
survey::svyby(
    formula = ~Class,
    by = ~Survived,
    design = svy_titanic,
    FUN = survey::svymean,
    na.rm = TRUE,
    deff = TRUE
  )
#>     Survived   Class1st  Class2nd  Class3rd ClassCrew se.Class1st se.Class2nd
#> No        No 0.08187919 0.1120805 0.3543624 0.4516779   0.0862186   0.1113057
#> Yes      Yes 0.28551336 0.1659634 0.2503516 0.2981716   0.1820787   0.1175809
#>     se.Class3rd se.ClassCrew DEff.Class1st DEff.Class2nd DEff.Class3rd
#> No    0.2434649    0.2853846     0.8959744      1.127972      2.347487
#> Yes   0.1485929    0.2121769     2.1551529      1.324513      1.560158
#>     DEff.ClassCrew
#> No        2.979638
#> Yes       2.852852
survey::svyby(
    formula = ~Class,
    by = ~Survived,
    design = rsvy_titanic,
    FUN = survey::svymean,
    na.rm = TRUE,
    deff = TRUE
  )
#>     Survived   Class1st  Class2nd  Class3rd ClassCrew       se1       se2
#> No        No 0.08187919 0.1120805 0.3543624 0.4516779 0.1061470 0.1411537
#> Yes      Yes 0.28551336 0.1659634 0.2503516 0.2981716 0.2191396 0.1332299
#>           se3       se4 DEff.Class1st DEff.Class2nd DEff.Class3rd
#> No  0.3676938 0.4723809      2.272596      3.035715      8.960170
#> Yes 0.1710918 0.2746881      3.612413      1.967805      2.393461
#>     DEff.ClassCrew
#> No       13.661553
#> Yes       5.532971

So then this doesn't work as intended:

stat =
        dplyr::case_when(
          startsWith(.data$name, paste0("se.", by)) | startsWith(.data$name, paste0("se.`", by, "`")) ~ "p.std.error",
          startsWith(.data$name, paste0("DEff.", by)) | startsWith(.data$name, paste0("DEff.`", by, "`")) ~ "deff",
          TRUE ~ "p"
        )

@ddsjoberg

Copy link
Copy Markdown
Collaborator

@szimmer I recall when looking at this the first time that some of the output changed labels, and I just got overwhelmed and stopped looking at it. These survey objects are hard to work with. I think we'll need tests for every summary type with and without by variables to ensure everything works as expected.

@szimmer
szimmer marked this pull request as ready for review September 8, 2026 02:37
2 api00 survey_continuous_ci std.error std.error 23.54224 2
3 api00 survey_continuous_ci conf.low conf.low 593.6763 2
4 api00 survey_continuous_ci conf.high conf.high 694.6625 2
1 api00 survey_continuous_ci estimate estimate 634.96 2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are so many numbers changing in the snapshot?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make the tests run faster - I subset the api data to be smaller - https://github.com/pharmaverse/cardx/pull/359/changes#diff-7d19f54ffc8b540b9abe12066e988386b33c41569ff83e136a16afe412e94b2aR4

I did this across several tests. We can roll it back as it's in an isolated commit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't change the existing tests so we can assess whether the current changes break existing code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've rolled this back so the only changes in test snapshots are because of additional tests and not change in existing tests.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Support svyrep.design (replicate-weight) survey objects

2 participants