Skip to content

Fix statistical validity and data alignment issues across analyses#85

Merged
cafferychen777 merged 6 commits into
mainfrom
claude/core-codebase-bugs-rigor-BERAn
Jun 5, 2026
Merged

Fix statistical validity and data alignment issues across analyses#85
cafferychen777 merged 6 commits into
mainfrom
claude/core-codebase-bugs-rigor-BERAn

Conversation

@cafferychen777

Copy link
Copy Markdown
Owner

Summary

This PR addresses multiple statistical validity and data handling issues across the microbiome analysis pipeline, including PERMANOVA exchangeability violations, missing data handling, incorrect degrees of freedom calculations, and data alignment problems.

Key Changes

PERMANOVA and Beta Diversity Analysis

  • generate_beta_test_single.R: Added warning when time.var has multiple levels but t.level is NULL, as pooling timepoints violates PERMANOVA's exchangeability assumption and inflates Type-I error
  • Implemented explicit complete-case filtering for both metadata and distance matrices before PERMANOVA, ensuring correct sample-to-row pairing regardless of input order or whether distances are provided or computed
  • Removed premature distance subsetting that could cause misalignment

Alpha Diversity Analysis

  • generate_alpha_volatility_test_long.R: Added guard against missing covariate values that would cause model.matrix() to silently drop rows and misalign residuals
  • generate_alpha_per_time_test_long.R: Fixed group reference level pinning within timepoint subsets to prevent mislabeling when a timepoint lacks the global reference group
  • mStat_calculate_alpha_diversity.R: Added validation that Chao1 and ACE estimators receive integer count data, as they are undefined for normalized/relative-abundance data

Taxa Change Analysis

  • generate_taxa_change_test_pair.R: Fixed winsorization to operate per-taxon (across samples) rather than per-sample (across taxa), matching the per-taxon pseudocount convention
  • Corrected complete-case filtering logic to properly identify subjects with complete change scores across all taxa

Adjusted Distance Calculation

  • mStat_calculate_adjusted_distance.R: Removed incorrect re-weighting by eigenvalues that was squaring the axis scaling, as cmdscale() already returns properly scaled coordinates

Linear Model Analysis

  • generate_taxa_test_single.R: Fixed residual degrees of freedom calculation to account for actual design matrix rank rather than variable count, which was overstating df for multi-level factors
  • generate_beta_volatility_test_long.R and generate_taxa_volatility_test_long.R: Reordered formula terms to place group.var last, ensuring sequential (Type-I) ANOVA F-tests equal the covariate-adjusted omnibus test

Data Normalization and Conversion

  • mStat_normalize_data.R: Added seed parameter (default 123) to make rarefaction reproducible while preserving caller's RNG state
  • mStat_convert_phyloseq_to_data_obj.R: Added drop = FALSE to preserve matrix structure when subsetting single rows; added check.names = FALSE to preserve metadata column names

Statistical Testing Functions

  • linda.R and linda2.R: Fixed winsorization to only execute when outlier.pct is not NULL, preventing unintended behavior when is.winsor = TRUE but outlier.pct = NULL; updated documentation to clarify default behavior

Notable Implementation Details

  • Complete-case filtering now consistently applied across all analysis types to prevent silent data misalignment
  • Explicit validation of data assumptions (integer counts for richness estimators, no missing covariates) with informative error messages
  • Careful preservation of matrix/data.frame structure and column names during subsetting operations
  • Proper handling of random seed management to ensure reproducibility without affecting caller's RNG state

https://claude.ai/code/session_01CDuBVvoJeszwbQGtXC7uFa

claude added 6 commits June 3, 2026 01:57
Four verified correctness bugs in core statistical code:

* linda.R / linda2.R: adaptive zero-handling assigned capitalized
  "Imputation"/"Pseudo-count" to `zero.handling`, but the dispatch
  test compares against lowercase "imputation". With the default
  `adaptive = TRUE`, the imputation branch could therefore never run
  and the code silently fell back to pseudo-count even when sequencing
  depth was confounded with the covariates -- exactly the case where
  imputation is intended. Assign lowercase values to match the test.

* linda.R / linda2.R: `outlier.pct = NULL` (the documented "winsorization
  off" value) caused `1 - NULL` -> numeric(0) and broke winsorization.
  Guard the call with `!is.null(outlier.pct)` and correct the roxygen
  docs to state the real default (0.03) and NULL-skips semantics.

* generate_taxa_change_test_pair.R: winsorization used apply(MARGIN = 2),
  capping values within each sample across unrelated taxa rather than
  per taxon across samples. This distorts each sample's composition and
  biases every taxon's change score. Switch to per-taxon (MARGIN = 1),
  matching winsor_feature_table() and the per-taxon pseudocount above.

* generate_taxa_change_test_pair.R: subject filtering used
  colnames(na.omit(matrix)); na.omit drops rows (taxa), so it returned
  all subjects when any taxon was complete and none when every taxon had
  an NA -- never the intended per-subject filter. Select complete
  subject columns via colSums(is.na(...)) == 0.

https://claude.ai/code/session_01CDuBVvoJeszwbQGtXC7uFa
* generate_{alpha,taxa,beta}_volatility_test_long.R: the omnibus group
  F-test for >2-level groups used anova() (Type-I sequential) with the
  group term entered FIRST, so the test ignored adj.vars even when the
  user requested covariate adjustment. Reorder terms so group.var is
  entered last; with no interaction in the volatility model this makes
  SS(group | adj.vars) the covariate-adjusted test (equivalent to
  Type-II/III for a main effect).

* mStat_calculate_adjusted_distance.R: stats::cmdscale() returns
  principal coordinates already scaled by sqrt(eig), but the code
  multiplied the residualized coordinates by sqrt(eig) again, weighting
  axis i by eig_i instead of sqrt(eig_i) and over-emphasizing leading
  axes. The buggy version also failed to reduce to the original distance
  when covariates explain nothing. Take dist() of the residuals directly.

* mStat_calculate_alpha_diversity.R: Chao1 and ACE are defined only for
  integer counts (singleton/doubleton based). Error clearly when given
  non-integer/normalized data instead of silently returning meaningless
  estimates.

* generate_alpha_volatility_test_long.R: guard the inline covariate
  adjustment against NA covariates, which otherwise shorten the residual
  vector and misalign/error the assignment back into alpha_df, matching
  the existing check in mStat_calculate_adjusted_alpha_diversity().

https://claude.ai/code/session_01CDuBVvoJeszwbQGtXC7uFa
* mStat_convert_phyloseq_to_data_obj.R: the zero-sum feature filter used
  feature.tab[rowSums(...) > 0, ] without drop = FALSE, so a single
  surviving feature collapsed the matrix to a vector, dropping dim and
  dimnames and breaking every downstream rowname-based alignment (tree,
  feature.ann, analyses). The DESeq2 and SummarizedExperiment converters
  already use drop = FALSE; make phyloseq consistent.

* mStat_convert_phyloseq_to_data_obj.R: building meta.dat via
  data.frame() applied check.names = TRUE, silently mangling metadata
  column names that contain spaces/special characters (e.g. "Body Site"
  -> "Body.Site") so downstream group.var/condition lookups failed. Add
  check.names = FALSE.

* mStat_normalize_data.R: vegan::rrarefy() draws a random subsample but
  no seed was set, making "Rarefy" and "Rarefy-TSS" normalization
  non-reproducible across runs. Add a seed argument (default 123) and
  wrap the call in mStat_with_local_seed(), which restores the global RNG
  state so the caller's random stream is unaffected.

https://claude.ai/code/session_01CDuBVvoJeszwbQGtXC7uFa
generate_beta_test_single() computes PERMANOVA via GUniFrac::PermanovaG2,
which (through vegan::adonis2) pairs each distance matrix with the `data`
rows by POSITION, not by sample name. When the distance object was freshly
computed (dist.obj = NULL), the distances were labeled in feature.tab
column order while the predictors were taken from data.obj$meta.dat in its
own row order, with no realignment step -- only the provided-dist.obj
branch reordered the distances to the metadata. A data.obj whose meta.dat
rows were not already in feature.tab column order therefore produced
PERMANOVA F/R2/p-values computed on shuffled group labels, with no error
or warning. NA values in the group/adjustment variables were likewise not
dropped consistently from both the distance matrix and the metadata.

Align the metadata and every distance matrix to the same complete-case
sample set, in a consistent order, for both the freshly-computed and
provided-dist.obj paths before calling PermanovaG2.

https://claude.ai/code/session_01CDuBVvoJeszwbQGtXC7uFa
… use

* generate_alpha_per_time_test_long.R: the global group reference level was
  applied as the output label, but the within-timepoint lm fell back to the
  first factor level present in each subset. When a timepoint was missing
  the global reference group, the lm baseline differed from the label,
  mislabelling the contrast. Relevel the subset's group factor to the global
  reference_level when present (a no-op in the common case) so the fitted
  baseline matches the label.

* generate_beta_test_single.R: warn when time.var has multiple levels but
  t.level is NULL. In that case PERMANOVA pools all timepoints and treats
  repeated measurements of the same subject as independent, violating
  exchangeability and inflating the Type-I error rate.

https://claude.ai/code/session_01CDuBVvoJeszwbQGtXC7uFa
generate_taxa_test_single()'s perform_lm_analysis path reported the
residual degrees of freedom as nrow(meta.dat) - length(all.vars(formula))
- 1. all.vars() counts each predictor once, but a k-level factor
contributes k - 1 design columns (and interactions add more), so the
reported df was overstated for any multi-level factor predictor. The
fitted p-values come from lm's summary and were already correct; only
this reported df column was wrong. Derive df from the actual model-matrix
column count so it accounts for factor expansion.

https://claude.ai/code/session_01CDuBVvoJeszwbQGtXC7uFa
@cafferychen777 cafferychen777 merged commit 0cfc362 into main Jun 5, 2026
3 of 4 checks passed
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.

2 participants