Add matched-null validation for cluster counts - #938
haomeng797-ship-it wants to merge 15 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new public helper, test_clusters(), to validate an observed (or user-supplied) cluster count against matched-null reference data generated via matchednull, with a default mclust::Mclust() (BIC) pipeline and support for custom scalar-returning cluster functions.
Changes:
- Introduces
test_clusters()(exported) with roxygen documentation and matched-null integration. - Adds a focused test suite covering custom functions, reproducibility, controls, argument forwarding, and input validation.
- Updates package metadata/documentation (NEWS entry, DESCRIPTION Suggests, WORDLIST, Rd generation, NAMESPACE export).
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
R/test_clusters.R |
Adds the new test_clusters() API plus internal validation/standardization helpers. |
tests/testthat/test-test_clusters.R |
Adds test coverage for the new function’s expected behavior and input checks. |
NEWS.md |
Documents the new user-facing function in the changelog. |
NAMESPACE |
Exports test_clusters() as part of the public API. |
man/test_clusters.Rd |
Generated documentation for test_clusters(). |
DESCRIPTION |
Adds matchednull (>= 0.2.1) to Suggests for the new functionality/tests. |
inst/WORDLIST |
Adds “Meng” to the spelling whitelist for the new reference entry. |
Files not reviewed (1)
- man/test_clusters.Rd: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| insight::check_if_installed("mclust") | ||
| mclustBIC <- mclust::mclustBIC | ||
| max_components <- min(as.integer(n_max), nrow(x) - 1L) |
| loo, | ||
| MASS, | ||
| Matrix, | ||
| matchednull (>= 0.2.1), |
|
Thanks, looks good to me! A version bump (4th digits, to increase dev version) in the DESCRIPTION is missing (or overwritten by merge main into this branch), else I have no other comments. @DominiqueMakowski anything you would like to add? |
|
Thanks! I bumped the development version to 0.17.1.11. |
|
This is a bit clunky #' # Any scalar-returning clustering pipeline can be tested.
#' pick_two <- function(data) 2
#' test_clusters(iris[, 1:4], cluster_function = pick_two, iterations = 19)Rather than having the whole Mclust complication (which is used to get a number), why not streamline the api and just have the The pipeline could then be like n <- n_clusters(data)
# this suggests 3
test_clusters(data, n_clusters = c(2, 3, 5))or am i missing somethign |
|
Thanks for pointing that out. I agree that the I’ll replace the example with a real clustering pipeline and clarify this in the documentation. |
|
You mean the function needs to be aware of the clustering method? Then why not (I'm just brainstorming I dont have strong opinions) making the function a method that runs on a cluster_analysis() result? So the workflow would go like |
|
Yes that’s what I mean. I like the idea of making it a method for a The only thing I want to preserve is that the current test evaluates the selection process, rather than just one fixed solution. |
if need be we can add more attributes to store more info :) we do that quite commonly across the easyverse |
|
I had a look. I think the only catch is a fixed So |
|
do you mean that test_clusters need some alternative scenarios (ie different n solutions)? If so, would something like:
I reread you message again, and now I am starting to doubt my understanding (sorry i should have done my homework before 😬): do you mean that the matched-null validation is not about testing a given solution, but rather testing a selection procedure? Like if If so... then perhaps |
|
Yes, your second reading is right! 😀 I think my earlier wording mixed up two possible uses of For this PR, the statistic is the number of clusters selected. We run the same selector on the real data and on every matched-null dataset. For example, if elbow selects 3 on the real data but usually selects 1 on the null data, then 3 is unusual. If it also often selects 3 or more on the null data, then the result is not much evidence for real clusters. So there is no need for the user to provide alternative values of I think the cleanest API would be something like Testing a fixed |
|
Quick update: this discussion made me go back and check the null generator before changing the API. I found a dependence-matching issue for non-Gaussian margins in matchednull, so I want to finish validating the correction before this is merged. I'm going to pause this PR for now and come back with the updated calibration. Nothing needed from you in the meantime 🙂 |
|
I opened a separate discussion for the statistical side of this, so we can keep this PR focused on the performance API: easystats/easystats#481 |
|
I'll update the main branch into this PR, so please pull commits before continue working locally :-) #945 will also be merged soon. |
|
Quick update after a couple of weeks of validation: I’ve now settled on correlation-calibrated NORTA as the main direction for the reference generator. The broader checks also uncovered a separate calibration issue for some clustering statistics, so I’m keeping the PR in draft while I work through that part. The good news is that I’m no longer rethinking the reference-model direction itself. What remains is mainly figuring out what calibration is needed and how cautiously the inferential claims should be stated. I’ll keep working through that over the next few weeks and update the PR once the results are a bit more settled. Thanks again for the earlier discussion! |
|
Okay, finally back with this one! It took a lot longer than I expected. Every time I went to finish the API, the stats side turned up something I wanted to sort out first, and I didn't want to hand you something I'd have to walk back later. The short version: it's ready for you to look at now. I just pushed a round of changes, it works with the matchednull that's already on CRAN, and all the test_clusters tests pass. I'm keeping it as a draft for now, for two reasons I'll explain at the bottom: a small matchednull patch, and a question for you about what kind of tool this should be. Also, a correction to my last comment: I said I'd probably switch matchednull over to a calibrated generator. I ended up not doing that, and the generator stays exactly as it is on CRAN. What I actually spent the time on was pinning down what a result from this function can and can't tell you, and making the function harder to misuse. What it does (the way I'd explain it to someone new)Say you run a cluster analysis and your method picks 2 clusters. The question To check that, it makes "twins" of your data. A twin has exactly the same values on every variable as your real data, so every histogram is identical. The correlations between variables are roughly the same too, and there are no groups built in. Then it runs your cluster-picking method on the real data and on a bunch of twins, and looks at whether your real number stands out. One thing to keep in mind: the twins are one specific kind of "no groups" data. They can also be missing other ways your variables hang together (anything beyond the kind of dependence a multivariate normal distribution has), so if your data stand out from the twins, it isn't necessarily because of groups. Here's iris (only 19 twins so the example runs fast; the default is 200): So mclust picks 2 clusters on iris, but it also picks 2 to 4 on the twins, which have no groups in them. The 2 on its own isn't telling you anything the twins wouldn't also tell you. You get one of three results:
Where it sits next to the other clustering functionsThe way I think about the workflow:
So it doesn't replace the Hopkins check. They're asking different questions. Any method works as long as you wrap it in a function that returns one number. That's why I kept A tidyclust workflow would go in the same way, just wrapped in a function like this. What's in this push
With the package installed, all 56 test_clusters tests pass, both with matchednull 0.2.1 from CRAN and with the 0.2.2 patch, and that includes the fresh-session one. I also checked it with two parallel workers. Two caveats I want to be upfront about (both in the docs now)
Neither of these changes how the function runs, just how you should read what it prints. What's nextI've also made a matchednull 0.2.2 that fixes the NA problem at the source and cleans up the same wording in its docs. I'll send that to CRAN on its own, and once it's through I'll bump the minimum version here. That patch fixes a software bug, though. The bigger statistical question is still open: how well the p-value is calibrated in general. So I'd really like you to look at this as a diagnostic. It compares a cluster count against one specific no-groups reference, and it can't tell you in general whether groups exist. If that sounds like something you'd want in performance, great! If it needs to be framed differently first, or you're not sure it fits, I'd rather talk about that before this leaves draft. And if the print output or argument names should look more like the rest of performance, just let me know and I'll change them 🙂 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #938 +/- ##
==========================================
+ Coverage 64.07% 64.99% +0.91%
==========================================
Files 94 98 +4
Lines 8321 8529 +208
==========================================
+ Hits 5332 5543 +211
+ Misses 2989 2986 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Merging main into this branch, to resolve conflicts. Next week I'll read your last comment, to get up-to-date on this PR. Thanks a lot for the work so far! |
Closes easystats/easystats#479.
This adds
test_clusters(), which checks whether a selected number of clusters stands out from what the same selection method gives on matched-null "twins" of the data. A twin keeps every variable's exact values, has approximately the same correlations, and has no groups built in. The twins come from thematchednullpackage.Usage
mclust::Mclust()picks the number of components by BIC.cluster_function, as long as it returns one number. The same method is rerun on every twin, since what's being tested is the selection procedure....tomatchednull::matched_null_test().Reading the result
The print method reports one of three outcomes:
The twins are one specific no-groups reference, so this is a diagnostic against that reference. It isn't a general test of whether groups exist. The p-value is a Monte Carlo comparison against twins built from the same data, not an exact p-value. The documentation says both.
Checks
tests/testthat/test-test_clusters.Ragainst the installed package: 56 passed, 0 failed, 0 skipped, with matchednull 0.2.1 (CRAN) and with the 0.2.2 patch. This includes a test that runs the default pipeline in a fresh R session without attaching mclust.devtools::test()with an older performance installed, that fresh-session test skips by design (54 passed, 1 skipped).lintrandair format --checkare clean on the changed files.