Support for multiarm ancova - #520
Conversation
|
Hi @gowerc , cool, thanks for working on this! Sorry if I had a look too late - my only initial high level comment would be that I don't really "like" that we would split out ANCOVA with multiple arms from the version with two arms. I would hope that we could extend the current naming scheme from As a possible inspiration, please see https://github.com/johnsonandjohnson/junco/blob/main/R/ancova_rbmi.R where I implemented an I think in the "worst" case one could just use a condition in the What do you think? |
Interestingly despite what their documentation says I don't think they actually support more than 2 arms, in particular they have near the top of the function I am very conflicted here, I agree with everything you said and I am also not happy with splitting it out but that being said I don't see an easy way of merging them without breaking backwards compatibility as the naming scheme has to be different in order for it to make sense we currently have: In hindsight these are already bad names that confuse users. If we were to extend this whilst maintaining backwards compatibility we'd end up with something like: Which I would argue is even worse. Alternatively we could just have some code that dispatches to the different ANCOVA function based on the number of levels in group variable but this makes the documentation / explanation clunky e.g. "If you have 2 groups then it will be formatted like this, O but if you have more than 2 it would be formatted like this even though half the values are the same" Which is why I settled on just a separate function. One option though to minimise maintenance is that we could deprecate (Despite my tone I am not confident on the best path forward here as all options appear bad to me so please do challenge if you still disagree) |
Hmm... ok weird. But I am pretty sure we wanted this to work with multiple arms, that is also what the other code suggests... I will need to check in September when I am back.
Yeah I understand the need for backwards compatibility. But I wonder if an alternative solution here could be to have this old naming scheme used for 2 arms, and the new naming scheme used for more than 2 arms?
Personally I think that would also be acceptable. The user will not see these names too much anyway if they just use the
Yeah I would not have two functions necessarily but just two naming schemes. Or just go with the naming scheme just described which I think is fine.
Personally I would just go with the naming scheme you suggested, I think that is reasonable. Just for the sake of a better naming scheme I would not go the route of two functions or function deprecation. Just my 2 cents 😄 |
|
Hi @gowerc / @danielinteractive, I'd prefer to have a single As for the naming convention, I agree with Daniel that the following is acceptable. I am wondering if long term, it would be better to allow the user to match the group levels to |
|
Hi @gowerc @tobiasmuetze @danielinteractive, This functionality would be really helpful for the project I'm currently working on. Is there any chance this PR might get merged? Thanks! |
|
@wwojciech I think we were not yet fully aligned on the right API here for the function, and @gowerc is still on leave at the moment... @luwidmer what is your call on this one from maintainer perspective? |
|
Hi @danielinteractive @luwidmer, To make downstream parsing easier and more robust, have you considered exposing the components currently concatenated into the For example: for |
|
@tobiasmuetze, from your experience implementing this ad-hoc, what are your thoughts on what this should look like in rbmi? |
|
@tobiasmuetze & @wwojciech: any objections to merging into this branch the v2 version that implements user-specified contrasts (#590) for further discussion / iteration (if needed)? |
Hi @luwidmer - I have no objections. That said, I must admit I didn’t have a chance to check all the details and only had a very brief look at it. |
First draft to further iterate on, according to the discussion in #520 so far @gowerc @tobiasmuetze @bailliem @wwojciech @danielinteractive: this branch builds on the original multi-arm ANCOVA prototype (#520 ) with a more flexible contrast API. Let me know what you think, this definitely will need further review and iteration. ### Flexible custom contrasts - `group_contrasts` now accepts, in addition to pairwise `c(minuend, subtrahend)` character vectors, general linear contrasts as named numeric weight vectors over the group levels (e.g. `c(Placebo = -1, A = 0.5, B = 0.5)` for pooled active-vs-control, or dose-response trends). Weights must sum to zero; evaluation is coding-agnostic and preserves the `trt` estimand (evaluated at the covariate reference). - Default two-arm and multi-arm output is unchanged (`trt`, `lsm_ref`, `lsm_alt`, `trt_alt2`, ...). ### Correctness & robustness - Contrast coding-agnostic evaluation: `ancova_linear_contrast()` now maps group-level weights onto the model coefficients via the factor's own contrast matrix instead of parsing `rbmiGroupL*` coefficient names. Results are correct under any `options("contrasts")` (e.g. `contr.sum`), not just `contr.treatment`, and reduce exactly to the previous coefficient contrast for the pairwise/default case. Rank-deficient designs (missing/aliased/`NA` group coefficients) now error instead of silently dropping a term. - `as_data_frame_internal()` errors if pooled parameters have no matching metadata rather than emitting silent `NA` columns. ### Named contrasts & output metadata - Explicit contrasts must be named: the list name becomes the output `parameter` and a new `contrast_label` metadata column (surfaced by `pool()` / `as.data.frame()`). The auto-derived `trt_alt2_alt`-style names are used only for the default (`group_contrasts = NULL`) set, preserving backward compatibility. Naming a contrast `trt` reproduces the classic name. - Names starting with `lsm_` are rejected (reserved for least-squares means) to prevent output collisions. - `pool()` / `as.data.frame()` output gains `contrast_label` alongside the existing `estimate_type`, `group`, `group_level_1`, `group_level_2`, `visit` columns; the legacy `parameter` column is retained. ## Documentation - Fixed stale `ancova()` `@return` (now documents the extended default scheme and named custom contrasts); added an `@details` note clarifying that `trt` is the model-coefficient contrast and differs from `lsm_alt − lsm_ref` under group×covariate interactions. - Added runnable multi-arm/custom-contrast examples to `ancova()` and worked pairwise + weight-vector blocks to the FAQ and quickstart vignettes. - NEWS updated. ### Tests - Coefficient-contrast reproduction, pooled/weight-vector correctness, `contr.sum` invariance, named-label propagation through `pool()`, reserved-name and validation errors, an end-to-end `ancova()` → `pool()` → `as.data.frame()` metadata integration test. ### Housekeeping - Project formatted with `air`. ### Compatibility - Two-arm and default multi-arm results and names are unchanged. `group_contrasts` and the metadata columns are new (unreleased), so tightening explicit contrasts to require names introduces no break for released behaviour. - The covariate reference point for `trt` is fixed at 0 to preserve the historic estimand (documented in `@details`). --------- Signed-off-by: Lukas Widmer <l.widmer@gmail.com> Co-authored-by: Isaac Gravestock <83659704+gravesti@users.noreply.github.com> Co-authored-by: Craig Gower-Page <craiggower@gmail.com> Co-authored-by: Lukas Widmer <lukas_andreas.widmer@novartis.com>
Merge remote-tracking branch 'origin/main' into 145-multi-ancova # Conflicts: # NEWS.md # R/ancova.R # R/pool.R # man/ancova.Rd # man/pool.Rd # vignettes/FAQ.html # vignettes/advanced.html # vignettes/quickstart.Rmd # vignettes/quickstart.html
|
@luwidmer and @tobiasmuetze sorry to bother but any plans to merge it into main now that the new API has been merged in ? |
|
I understand that we have an ooo situation so need to wait a couple of days - that said, if there is any support needed here happy to jump in any time from my side. |
danielinteractive
left a comment
There was a problem hiding this comment.
Thanks again @luwidmer for the nice proposal, I just had a quick look through and it looks really nice. Also the testing seems to be very thorough which is great to see, especially that we get full backwards compatibility. Minor things below.
|
@gowerc could you take a look as to whether you're fine with what I did to your branch 😅 ? |
|
I reviewed the functionality from a user perspective. I have no suggestions for changes. Great work and very useful. |
|
AI review flagged 2 issues for me:
9f6c0b8 @danielinteractive / @luwidmer if you have a chance to see what you think. Other wise I think the code looks good to me. |
danielinteractive
left a comment
There was a problem hiding this comment.
Super, thanks @gowerc - I agree that the now general encoding support for the factors is a good idea.
|
On reflection think I've confused myself as to what exactly we want or expect the contrast to represent. Apologies if this was in the above conversation, I tried reading but I was struggling to follow :( So in the current implementation on main we naively force the use corner-point-constraint ( The current implementation in this PR (as of my most recent commit) slightly addresses this by making the choice of covariates explicit (internally at least) and allowing for non-corner-point-constraint codings. It does this by building a reference dataset for each treatment level where continuous covariates are set to 0 and categorical covariates are set to the reference level using the contrast coding scheme e.g. corner-point sum-to-zero This is then used by the user defined contrasts list to decide which rows / fitted values be calculated and then taking the difference between them. I'm just a bit lost now as to wether it still makes sense to be setting covariates to 0 / the reference level for categorical values? Would it not be more reasonable to just do the difference of LSMs which then has weighting schemes to determine what values to set the covariates at which is representative ? |
|
Hi @gowerc I think these are very valid considerations. A bit aside from the technical discussion, I was wondering whether the current state of the PR would allow us to split this into a separate issue, so that the custom contrast could be considered/implemented in a dedicated PR, while this PR could be completed with the multi-arm support? If this would not introduce too much mess or require significant additional work, I would be very much in favour of this option. The package I support with @danielinteractive currently requires the multi-arm support first, while, as far as I am aware, the custom contrast is less critical for us (if at all). @luwidmer , @tobiasmuetze - for your consideration. |
|
hmm, I mean the concern I raised is tangental to the custom contrast, I think the custom contrast code implemented here by @luwidmer is working as intended. My issue is more around wether it is creating a meaningful metric. Though I think this is an issue with the |
|
@gowerc yeah makes sense. Maybe @wwojciech you can investigate this concern/question with examples to check if it is a problem? |
|
I have moved the interaction conversation then to #595 , I am happy to merge but there appears to be a lingering unresolved conversation thread which is blocking the merge (I'd rather not admin force merge unless I really have to) |
|
@gowerc I think it has been resolved now |
Closes #145
Hey @danielinteractive / @tobiasmuetze,
This contains my initial code proposal for implementing multiarm ancova support (note that it is currently missing documentation and tests).
Some key points:
To preserve backwards compatibility I've left the original
ancovafunction unchanged. To support multiple groups a new naming scheme is required for the parameters which would break any existing code which is looking for the current names liketrt/lsm_rf.That is, if users want to use the new multi-group ancova they will have to explicitly set
fun=ancova_m_groupsin the call toanalyse()For the new naming convention I've just used the level numbers e.g.
lsm_L0= least squared means for the reference,lsm_L1= least squared means for the first offset / coefficient. I've also used a more explicittrt_L1_L0to mean the coefficient of the first offset from the reference.As I think we agreed I'm currently only extracting the treatment effects against the reference e.g.
L1 - L0&L2 - L0. I'm not calculating / extracting all pairwise combinations.For the naming convention I didn't want to use the user-provided group/level values as that can lead to a ton of special edge cases with whitespaces / special characters that are hard to predict and make value-extraction very error prone. Instead I opted for just renaming the group variable to
rbmiGroupand re-leveling it to beL0, L1, etc. Only complication here was that the formula provided in theory can have interaction terms so had to create a function to recursively traverse the AST updating any references ofgrouptorbmiGroup. I will need to put in extensive unit testing to make sure theres no edge cases here with things likeI()functions.If there any no main objections here I'll start trying to add docs and tests.