Expanded ebayes ttest#240
Conversation
…sting implementation#
… special focus on preventing any misalignments during the nan_lfit step, wehre the matrices with skipped coefficients are scattered back to full size
…d on control samples, and nan_lmfit that purely handles linear fitting on the adata with a design matrix and an optional feature mask
…s gated with MNAR, where even if there are too few samples to report a fold change, the samples that are there are still used by the Bayesian model. however, the high condition is gated by high_max_missing, which excludes features altogether if their high-signal condition has too many missing values. The logic here is that if a feature drops out randomly when the signal should be high, it is unreliable and we don't want to use it for modeling
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #240 +/- ##
==========================================
+ Coverage 73.19% 73.94% +0.74%
==========================================
Files 35 36 +1
Lines 2067 2268 +201
==========================================
+ Hits 1513 1677 +164
- Misses 554 591 +37
🚀 New features to boost your workflow:
|
…drop the asymmetrical gates so that differential expression doesn't require knowledge of which side would/could be mnar and which not --> this type of decision is now made upstream with pp.filter_data_completeness for MAR and internally with symmetrical thresholds for MNAR for either comparison side
…me functionality to small helper functions to make filter_data_completeness less complex
…and cover with unit tests
…and cover with unit tests
| ) | ||
|
|
||
|
|
||
| def _validate_max_missing( |
| ) | ||
|
|
||
|
|
||
| def build_design_matrix( |
There was a problem hiding this comment.
could this be private? (check also the rest of the public API of this module)
| return dm, {"condition_col_idxs": condition_col_idxs, "covariate_col_idxs": covariate_col_idxs} | ||
|
|
||
|
|
||
| def summarize_design_matrix( |
There was a problem hiding this comment.
print_design_matrix_summary() ?
| if between_column not in adata.obs.columns: | ||
| raise ValueError(f"Column '{between_column}' not found in adata.obs.") | ||
| between_levels = adata.obs[between_column].unique() | ||
|
|
||
| # Validate the B condition (the single reference, comparison[1]) | ||
| b_condition = comparison[1] | ||
| if b_condition not in between_levels: | ||
| raise ValueError(f"Condition '{b_condition}' not found in column '{between_column}'.") | ||
|
|
||
| # Validate the A conditions (comparison[0]) | ||
| a_conditions = comparison[0] | ||
| if a_conditions == "_ALL_": | ||
| a_conditions = [level for level in between_levels if level != b_condition] | ||
| elif isinstance(a_conditions, str): | ||
| a_conditions = [a_conditions] | ||
|
|
||
| for a_condition in a_conditions: | ||
| if a_condition not in between_levels: | ||
| raise ValueError(f"Condition '{a_condition}' not found in column '{between_column}'.") |
There was a problem hiding this comment.
this method is quite long, move this to a dedicated method _validate_input() or smth?
| contrast_names = contrasts_from_matrix(contrast_matrix, b_condition) | ||
| if len(contrast_names) != contrast_results["log2fc"].shape[0]: | ||
| raise ValueError("Number of contrast names does not match number of contrasts in results.") | ||
|
|
||
| results = {} | ||
| for contrast_idx, contrast_name in enumerate(contrast_names): | ||
| # By convention the contrast is named "A_VS_B" (comparison[0] vs comparison[1]). | ||
| a_level, b_level = contrast_name.split("_VS_") | ||
|
|
||
| p_values = ebayes_results["p"][contrast_idx].copy() | ||
| log2fc = contrast_results["log2fc"][contrast_idx].copy() | ||
|
|
||
| # Replicate gate: suppress the fold change unless both conditions have enough observed values | ||
| keep = np.ones(adata.n_vars, dtype=bool) | ||
| if a_min_required is not None: | ||
| keep &= _sufficient_values_mask(adata, between_column, a_level, a_min_required) | ||
| if b_min_required is not None: | ||
| keep &= _sufficient_values_mask(adata, between_column, b_level, b_min_required) | ||
| p_values[~keep] = np.nan | ||
| log2fc[~keep] = np.nan | ||
|
|
||
| # preprocess p-values and FDR for the current contrast | ||
| fdr_pvalues = nan_safe_bh_correction(p_values) | ||
| neg_log10_fdr = np.array([negative_log10_pvalue(fdr) for fdr in fdr_pvalues]) | ||
| neg_log10_pvalues = np.array([negative_log10_pvalue(p) for p in p_values]) | ||
|
|
||
| # Sample counts per level, mirroring ebayes.py. The name is "A_VS_B". | ||
| max_level_1_samples, max_level_2_samples = determine_max_replicates(adata, between_column, a_level, b_level) | ||
|
|
||
| results[contrast_name] = pd.DataFrame( | ||
| { | ||
| "condition_pair": contrast_name, | ||
| "protein": adata.var_names, | ||
| "log2fc": log2fc, | ||
| "p_value": p_values, | ||
| "-log10(p_value)": neg_log10_pvalues, | ||
| "fdr": fdr_pvalues, | ||
| "-log10(fdr)": neg_log10_fdr, | ||
| "method": "limma_ebayes_inmoose_expanded", | ||
| "max_level_1_samples": max_level_1_samples, | ||
| "max_level_2_samples": max_level_2_samples, | ||
| }, | ||
| index=adata.var_names, | ||
| ) |
There was a problem hiding this comment.
this has a different level of abstraction than the rest of this method's code, move it to a dedicated method
| log2fc = contrast_results["log2fc"][contrast_idx].copy() | ||
|
|
||
| # Replicate gate: suppress the fold change unless both conditions have enough observed values | ||
| keep = np.ones(adata.n_vars, dtype=bool) |
| results = {} | ||
| for contrast_idx, contrast_name in enumerate(contrast_names): | ||
| # By convention the contrast is named "A_VS_B" (comparison[0] vs comparison[1]). | ||
| a_level, b_level = contrast_name.split("_VS_") |
There was a problem hiding this comment.
introduce a string constant for _VS_ to make the coupling in the code explicit
| m = y_obs.shape[0] | ||
| df = m - rank | ||
|
|
||
| # DF check: if df <= 0, skip this feature |
There was a problem hiding this comment.
please check for redundant comments
| n_contrasts, P = log2fcs.shape | ||
|
|
||
| # Remove features that have no fit at all, i.e. NaN columns in log2fcs, stdevs_unscaled, or nan in sigma2, or dfs | ||
| valid = np.isfinite(sigma2) & np.isfinite(dfs) & (dfs > 0) |
| ) | ||
| fit = limma.eBayes(fit) | ||
|
|
||
| # scatter back to full (88, P), excluded features stay NaN |
Purpose of this PR
The existing implementation of
diff_exp_ebayes()works well for differential expression data but has some critical shortcomings, the first of which is directly caused by the underlyinglmFit()implementation ofinmoose:lmFit()ebayes_expanded.pyaddresses all of these points in its new version ofdiff_exp_ebayes, which consists of six new custom functions to sidestep allinmoosedependencies exceptlimma.ebayesitself. Its critical component isnan_lmfit(), performs the same kind of linear fitting aslmFit()but tolerates missing values:build_design_matrixto construct a 1/0 matrix for fitting the linear coefficientsgenerate_feature_maskto construct a selection mask for features with sufficient control samples (some tests experimental setups require an up-front check to only assess features with sufficient control replicates. This function separates that logic from the actual linear fitting belownan_lmfitto perform the actual feature-wise fitting while tolerating missing features. Can optionally take afeature_maskfromgenerate_feature_maskmake_contrastsconstructs a contrasts matrix to construct fold changes from the coefficient's delta (i.e. Beta_treatment - Beta_control) results in the respective fold-changerun_contrastsActually compute the fold changes, i.e. the t-test but keep unscaled variance and standard deviation in a separate vector; Since the coefficients (i.e. also the coefficient's delta's) standard error consists of the standard deviation and the variance, and empirical Bayes moderation adjusts the standard deviation, these two must remain separate hereebayes_moderationtakes the coefficients and the unscaled variance and standard deviation arrays and performs empirical Bayes moderation. This results in modified t-values and p-values, and is also the only point where we still needinmooseThese scripts are coordinated in the new version of
diff_exp_ebayes, which can now handle nans, multiple contrasts and linear covariates, i.e. to model batch effects.Critical tests
The key test is numerical equivalence between the new and old
diff_exp_ebayesversions intest_diff_exp.pyon a vanilla dataset without missing values or covariates and with only one contrast. Furthermore, all new functions are covered by tests, with special attention onnan_lmfitto ensure that there are no index misalignments of features or coefficients when arrays are brought back to full shape after fitting with filtered design matrices that avoid missing samples for the respective featureOutlook
The new function can be used largely as-is in place of the existing
diff_exp_ebayeswith one breaking change: results are now returned in a {contrast_name: dataframe} structured dict, rather than a simple dataframe in the previous single-comparison case. This is a direct result of supporting multiple contrasts now.Once a port of the
limma.ebayesfunction frominmooseexists,inmoosecan be dropped as a dependency entirely.