OmicVerse Bulk Metabolomics — Multivariate & Biomarker
Goal
Given a preprocessed metabolomics AnnData (output of the omicverse-bulk-metabol-preprocessing skill), discriminate two groups with PLS-DA / OPLS-DA, rank features by VIP, inspect the OPLS-DA S-plot, then build and validate a biomarker panel via per-feature ROC AUC and nested-CV multi-metabolite classification with a permutation null. The output is a ranked feature list, model-quality metrics (R²X / R²Y / Q² / mean AUC / permutation p-value), and an actionable biomarker panel.
This skill is discrimination + biomarker selection only. Imputation / normalization / transformation belong in the preprocessing skill; pathway interpretation of the resulting hits belongs in the pathway-multifactor skill.
Quick Workflow
- Confirm the input
AnnData has been PQN-normalized + log-transformed + Pareto-scaled. Multivariate models on un-Pareto-scaled data are dominated by a few high-intensity features.
- Fit
ov.metabol.plsda(...) first as a baseline; inspect r2x, r2y, q2. A positive Q² is the minimum bar for a model that beats mean prediction.
- Fit
ov.metabol.opls_da(n_ortho=1) to separate group-discriminating signal (predictive component) from within-group variation (orthogonal components). Compare r2y / q2 against the PLS-DA baseline.
- Pull the VIP table with
result.to_vip_table(adata.var_names) and visualize the top features with ov.metabol.vip_bar(...).
- Run
ov.metabol.s_plot(...) to see covariance vs. correlation against the predictive component — features in the corners are robust biomarker candidates.
- For biomarker selection: get per-feature univariate AUC + bootstrap CI with
ov.metabol.roc_feature(...).
- Build a multi-metabolite panel with
ov.metabol.biomarker_panel(...) using nested CV; include a permutation null to test whether the panel beats random feature selection.
- Plot the held-out ROC curve from
panel.outer_predictions / panel.outer_labels for the report.
Interface Summary
Multivariate fitting (both consume Pareto-scaled adata.X):
ov.metabol.plsda(adata, *, group_col='group', group_a=None, group_b=None, n_components=2, scale=False) → PLSDAResult — Partial Least Squares Discriminant Analysis.
ov.metabol.opls_da(adata, *, group_col='group', group_a=None, group_b=None, n_ortho=1, scale=False, max_iter=500, tol=1e-08) → PLSDAResult — Orthogonal PLS-DA. Sets scores (predictive) and x_ortho_scores (orthogonal) separately.
PLSDAResult attributes:
.scores — (n_samples, n_components) predictive scores.
.x_ortho_scores — (n_samples, n_ortho) orthogonal scores (OPLS-DA only).
.loadings — (n_features, n_components).
.vip — (n_features,) Variable Importance in Projection.
.coef — (n_features,) regression coefficient against the binary Y (used for VIP-bar colour).
.r2x, .r2y, .q2 — standard PLS-DA quality metrics.
.group_labels — tuple (group_a, group_b) actually fitted.
.to_vip_table(var_names) → pd.DataFrame — VIP-sorted DataFrame with vip, coef, ready for biomarker triage.
Plotting:
ov.metabol.vip_bar(result, var_names, *, top_n=15, ax=None, figsize=(5, 5)) — horizontal VIP bars; bar colour encodes the sign of the regression coefficient (red = up in group_a, blue = up in group_b); dashed line at VIP=1 (Wold's threshold).
ov.metabol.s_plot(result, adata, *, label_top_n=15, ax=None, figsize=(5.5, 4.5)) — covariance vs. correlation against the predictive component; corner features are stable, high-magnitude markers.
Per-feature ROC:
ov.metabol.roc_feature(adata, *, group_col, pos_group=None, neg_group=None, layer=None, ci=False, n_bootstrap=1000, seed=0) → pd.DataFrame with auc, optional auc_ci_lower / auc_ci_upper, plus per-feature pvalue against auc=0.5.
Multi-metabolite panel (nested CV):
ov.metabol.biomarker_panel(adata, *, group_col, features=10, classifier='rf'|'lr'|'svm', cv_outer=5, cv_inner=3, pos_group=None, neg_group=None, n_permutations=0, layer=None, seed=0) → BiomarkerPanelResult.
BiomarkerPanelResult attributes:
.features — final-panel metabolite names.
.classifier — 'rf', 'lr', or 'svm'.
.cv_outer, .cv_inner — fold counts.
.outer_aucs — (cv_outer,) held-out AUC per outer fold.
.outer_predictions, .outer_labels — out-of-fold prediction arrays for ROC plotting.
.mean_auc, .std_auc — summary across outer folds.
.permutation_pvalue — nan when n_permutations=0; otherwise the empirical p-value vs. the label-permuted null.
.feature_importance — DataFrame ranking selected features.
.pos_group, .neg_group — fitted labels.
.to_frame() → pd.DataFrame — per-fold AUC for report tables.
Boundary
Inside scope:
- PLS-DA and OPLS-DA fitting on already-Pareto-scaled
AnnData.
- VIP ranking, S-plot, score-plot inspection.
- Univariate per-feature AUC with bootstrap CI.
- Nested-CV multi-metabolite panel with permutation null.
Outside scope — separate skill:
- Imputation, normalization, transformation (preprocessing skill).
- Three-or-more-group discrimination — use
anova from the preprocessing skill or a different multi-class model (out of scope here).
- Pathway / MSEA enrichment of VIP hits (pathway-multifactor skill).
- Multi-factor designs (ASCA / mixed model / MEBA — multifactor skill).
- Running PLS-DA on raw / log-only data — refusing this is a feature, not a missing capability.
Branch Selection
PLS-DA vs. OPLS-DA
plsda for a quick discriminative baseline. Fastest to fit; a positive Q² from plsda already proves the groups separate.
opls_da(n_ortho=1) when the cohort is heterogeneous (within-group variability competes with between-group). Cleaner VIP table and clearer S-plot because orthogonal variation is partitioned out. Tutorial t_metabol_02 shows r2y jumping substantially under OPLS-DA on the cachexia data.
- Increase
n_ortho only when you have evidence (e.g. one prominent confounder still present after first orthogonal component); each additional n_ortho consumes degrees of freedom and Q² will eventually drop.
scale=False is the right default when the data was already Pareto-scaled in preprocessing. Setting scale=True here double-scales and degrades both R²Y and Q².
n_components for PLS-DA: 2 is enough for a binary discrimination view. Add a third only if the score plot suggests a structured residual.
features argument of biomarker_panel
features=int (e.g. 10) — picks the top-int features inside the inner CV loop using univariate ranking; the same algorithm runs in every outer fold so feature selection is honest.
features=list[str] — fixes the panel up-front; useful when you have a literature-defined biomarker list and want to validate its CV performance.
classifier choice
'lr' — logistic regression. Default for small cohorts (n < 50). Coefficients are interpretable; pairs naturally with PQN+log+Pareto-scaled features.
'rf' — random forest. Robust to feature scaling but interpretability comes from feature_importance, not coefficients.
'svm' — RBF-kernel SVM. Non-linear; least interpretable; use only when the linear models don't separate.
n_permutations
- Set
>= 100 for any panel you'll report. With n_permutations=0 you get a mean AUC but no calibrated false-positive control; a panel with mean_auc=0.95 on n=20 samples might still be no better than random.
n_permutations=1000 if compute is available — gives 0.001-resolution p-values.
ci=True on roc_feature when you want per-feature CIs in the report; it costs n_bootstrap bootstrap resamples per feature. For the cachexia 77-sample dataset, n_bootstrap=500 runs in seconds.
Input Contract
AnnData must come from the preprocessing skill: obs['group'] populated, X is log + Pareto-scaled for plsda / opls_da, log-only for roc_feature / biomarker_panel.
obs['group'] must take exactly two values for PLS-DA / OPLS-DA / biomarker_panel. For 3+ groups, slice or mask first.
- For
biomarker_panel with nested CV at cv_outer=5, cv_inner=3, you need at minimum 15 samples per class — fewer than that and the inner folds become trivial.
- Don't pass sparse
adata.X; densify first.
Minimal Execution Patterns
# Multivariate (assumes preprocessing already ran)
import omicverse as ov
ov.plot_set()
# X must be log + Pareto-scaled
adata = ov.metabol.read_metaboanalyst('human_cachexia.csv', group_col='Muscle loss')
adata = ov.metabol.normalize(adata, method='pqn')
adata = ov.metabol.transform(adata, method='log')
adata = ov.metabol.transform(adata, method='pareto', stash_raw=False)
pls = ov.metabol.plsda(adata, group_col='group', n_components=2, scale=False)
print(f'PLS-DA R²X={pls.r2x:.3f} R²Y={pls.r2y:.3f} Q²={pls.q2:.3f}')
opls = ov.metabol.opls_da(adata, group_col='group', n_ortho=1, scale=False)
print(f'OPLS-DA R²X={opls.r2x:.3f} R²Y={opls.r2y:.3f} Q²={opls.q2:.3f}')
vip_df = opls.to_vip_table(adata.var_names)
ov.metabol.vip_bar(opls, adata.var_names, top_n=15)
ov.metabol.s_plot(opls, adata, label_top_n=10)
# Biomarker selection (consumes log-only AnnData; do NOT Pareto-scale here)
adata_log = ov.metabol.read_metaboanalyst('human_cachexia.csv', group_col='Muscle loss')
adata_log = ov.metabol.impute(adata_log, method='qrilc', seed=0)
adata_log = ov.metabol.normalize(adata_log, method='pqn')
adata_log = ov.metabol.transform(adata_log, method='log')
# 1) per-feature AUC with bootstrap CI
auc = ov.metabol.roc_feature(
adata_log, group_col='group',
pos_group='cachexic', neg_group='control',
ci=True, n_bootstrap=500, seed=0,
)
# 2) nested-CV multi-metabolite panel
panel = ov.metabol.biomarker_panel(
adata_log, group_col='group',
pos_group='cachexic', neg_group='control',
features=10,
classifier='lr',
cv_outer=5, cv_inner=3,
n_permutations=100,
seed=0,
)
print(f'panel mean AUC: {panel.mean_auc:.3f} ± {panel.std_auc:.3f}')
print(f'permutation p : {panel.permutation_pvalue:.3f}')
print(panel.feature_importance.head())
# 3) ROC curve from out-of-fold predictions
from sklearn.metrics import roc_curve, auc as _auc
import matplotlib.pyplot as plt
fpr, tpr, _ = roc_curve(panel.outer_labels, panel.outer_predictions)
fig, ax = plt.subplots(figsize=(5, 5))
ax.plot(fpr, tpr, lw=2, label=f'nested CV AUC = {_auc(fpr, tpr):.3f}')
ax.plot([0, 1], [0, 1], '--', color='gray')
ax.set(xlabel='FPR', ylabel='TPR', aspect='equal'); ax.legend(loc='lower right')
plt.tight_layout(); plt.show()
Validation
- After fitting:
pls.q2 > 0 is the absolute minimum bar — negative Q² means the model is worse than predicting the mean. Any reportable result needs Q² > 0.1 and ideally a permutation-derived empirical p-value.
- After OPLS-DA:
opls.r2y > pls.r2y should hold on heterogeneous cohorts; if they're equal, the orthogonal direction didn't capture confounder structure and OPLS-DA is overkill.
- After
biomarker_panel: report the outer-fold AUC (mean_auc), never the inner-fold AUC (which is contaminated by feature selection).
permutation_pvalue should be < 0.05 for a publication-grade panel; if it's higher, the apparent discrimination is plausibly random.
- Confirm
panel.outer_labels lines up with adata.obs['group'] (the panel uses out-of-fold predictions, so labels are not in the original sample order — use the result's own arrays).
- For VIP-bar / S-plot interpretation: a feature being VIP > 1 alone is not enough — pair it with non-zero
coef sign and a non-zero correlation in S-plot before calling it a biomarker.
- If the cohort is < 15 per class, the nested CV is unstable; fall back to univariate
roc_feature only and don't claim panel performance.
Resource Map
- See
reference.md for copy-paste snippets.
- See
references/source-grounding.md for the verified _plsda / _biomarker / plotting signatures and notes on interpreting PLSDAResult and BiomarkerPanelResult attributes against tutorial usage.
- For the preprocessing chain that produces the input
AnnData, see the omicverse-bulk-metabol-preprocessing skill.
- For pathway-level interpretation of the VIP / panel features, see the
omicverse-bulk-metabol-pathway-multifactor skill.
Examples
- "Run PLS-DA and OPLS-DA on the Pareto-scaled cachexia data and report
R²Y, Q² for both."
- "Show the top-15 VIP metabolites with sign-coloured bars and an OPLS-DA S-plot."
- "Build a 10-metabolite logistic-regression biomarker panel with 5×3 nested CV and a 100-iteration permutation null; plot the held-out ROC curve."
- "Get per-feature AUC with 500-iteration bootstrap confidence intervals for cachexic vs. control."
References
- Tutorial notebooks:
- Live API verified against
omicverse.metabol — see references/source-grounding.md.
1---2name: omicverse-bulk-metabol-multivariate3description: Multivariate discrimination and biomarker selection on a preprocessed metabolomics AnnData. Use when running PLS-DA, OPLS-DA, VIP / S-plot inspection, per-metabolite ROC AUC with bootstrap confidence intervals, or building a multi-metabolite biomarker panel with nested CV and a permutation null. Assumes the input has already been imputed, PQN-normalized, and log-then-Pareto-transformed.4---56# OmicVerse Bulk Metabolomics — Multivariate & Biomarker78## Goal910Given a preprocessed metabolomics `AnnData` (output of the `omicverse-bulk-metabol-preprocessing` skill), discriminate two groups with PLS-DA / OPLS-DA, rank features by VIP, inspect the OPLS-DA S-plot, then build and validate a biomarker panel via per-feature ROC AUC and nested-CV multi-metabolite classification with a permutation null. The output is a ranked feature list, model-quality metrics (R²X / R²Y / Q² / mean AUC / permutation p-value), and an actionable biomarker panel.1112This skill is **discrimination + biomarker selection only**. Imputation / normalization / transformation belong in the preprocessing skill; pathway interpretation of the resulting hits belongs in the pathway-multifactor skill.1314## Quick Workflow15161. Confirm the input `AnnData` has been **PQN-normalized + log-transformed + Pareto-scaled**. Multivariate models on un-Pareto-scaled data are dominated by a few high-intensity features.172. Fit `ov.metabol.plsda(...)` first as a baseline; inspect `r2x`, `r2y`, `q2`. A positive `Q²` is the minimum bar for a model that beats mean prediction.183. Fit `ov.metabol.opls_da(n_ortho=1)` to separate group-discriminating signal (predictive component) from within-group variation (orthogonal components). Compare `r2y` / `q2` against the PLS-DA baseline.194. Pull the VIP table with `result.to_vip_table(adata.var_names)` and visualize the top features with `ov.metabol.vip_bar(...)`.205. Run `ov.metabol.s_plot(...)` to see covariance vs. correlation against the predictive component — features in the corners are robust biomarker candidates.216. For biomarker selection: get per-feature univariate AUC + bootstrap CI with `ov.metabol.roc_feature(...)`.227. Build a multi-metabolite panel with `ov.metabol.biomarker_panel(...)` using nested CV; include a permutation null to test whether the panel beats random feature selection.238. Plot the held-out ROC curve from `panel.outer_predictions` / `panel.outer_labels` for the report.2425## Interface Summary2627Multivariate fitting (both consume Pareto-scaled `adata.X`):28- `ov.metabol.plsda(adata, *, group_col='group', group_a=None, group_b=None, n_components=2, scale=False) → PLSDAResult` — Partial Least Squares Discriminant Analysis.29- `ov.metabol.opls_da(adata, *, group_col='group', group_a=None, group_b=None, n_ortho=1, scale=False, max_iter=500, tol=1e-08) → PLSDAResult` — Orthogonal PLS-DA. Sets `scores` (predictive) and `x_ortho_scores` (orthogonal) separately.3031`PLSDAResult` attributes:32- `.scores` — `(n_samples, n_components)` predictive scores.33- `.x_ortho_scores` — `(n_samples, n_ortho)` orthogonal scores (OPLS-DA only).34- `.loadings` — `(n_features, n_components)`.35- `.vip` — `(n_features,)` Variable Importance in Projection.36- `.coef` — `(n_features,)` regression coefficient against the binary Y (used for VIP-bar colour).37- `.r2x`, `.r2y`, `.q2` — standard PLS-DA quality metrics.38- `.group_labels` — tuple `(group_a, group_b)` actually fitted.39- `.to_vip_table(var_names) → pd.DataFrame` — VIP-sorted DataFrame with `vip`, `coef`, ready for biomarker triage.4041Plotting:42- `ov.metabol.vip_bar(result, var_names, *, top_n=15, ax=None, figsize=(5, 5))` — horizontal VIP bars; bar colour encodes the sign of the regression coefficient (red = up in `group_a`, blue = up in `group_b`); dashed line at VIP=1 (Wold's threshold).43- `ov.metabol.s_plot(result, adata, *, label_top_n=15, ax=None, figsize=(5.5, 4.5))` — covariance vs. correlation against the predictive component; corner features are stable, high-magnitude markers.4445Per-feature ROC:46- `ov.metabol.roc_feature(adata, *, group_col, pos_group=None, neg_group=None, layer=None, ci=False, n_bootstrap=1000, seed=0) → pd.DataFrame` with `auc`, optional `auc_ci_lower` / `auc_ci_upper`, plus per-feature `pvalue` against `auc=0.5`.4748Multi-metabolite panel (nested CV):49- `ov.metabol.biomarker_panel(adata, *, group_col, features=10, classifier='rf'|'lr'|'svm', cv_outer=5, cv_inner=3, pos_group=None, neg_group=None, n_permutations=0, layer=None, seed=0) → BiomarkerPanelResult`.5051`BiomarkerPanelResult` attributes:52- `.features` — final-panel metabolite names.53- `.classifier` — `'rf'`, `'lr'`, or `'svm'`.54- `.cv_outer`, `.cv_inner` — fold counts.55- `.outer_aucs` — `(cv_outer,)` held-out AUC per outer fold.56- `.outer_predictions`, `.outer_labels` — out-of-fold prediction arrays for ROC plotting.57- `.mean_auc`, `.std_auc` — summary across outer folds.58- `.permutation_pvalue` — `nan` when `n_permutations=0`; otherwise the empirical p-value vs. the label-permuted null.59- `.feature_importance` — DataFrame ranking selected features.60- `.pos_group`, `.neg_group` — fitted labels.61- `.to_frame() → pd.DataFrame` — per-fold AUC for report tables.6263## Boundary6465**Inside scope:**66- PLS-DA and OPLS-DA fitting on already-Pareto-scaled `AnnData`.67- VIP ranking, S-plot, score-plot inspection.68- Univariate per-feature AUC with bootstrap CI.69- Nested-CV multi-metabolite panel with permutation null.7071**Outside scope — separate skill:**72- Imputation, normalization, transformation (preprocessing skill).73- Three-or-more-group discrimination — use `anova` from the preprocessing skill or a different multi-class model (out of scope here).74- Pathway / MSEA enrichment of VIP hits (pathway-multifactor skill).75- Multi-factor designs (ASCA / mixed model / MEBA — multifactor skill).76- Running PLS-DA on raw / log-only data — refusing this is a feature, not a missing capability.7778## Branch Selection7980**PLS-DA vs. OPLS-DA**81- `plsda` for a quick discriminative baseline. Fastest to fit; a positive `Q²` from `plsda` already proves the groups separate.82- `opls_da(n_ortho=1)` when the cohort is heterogeneous (within-group variability competes with between-group). Cleaner VIP table and clearer S-plot because orthogonal variation is partitioned out. Tutorial `t_metabol_02` shows `r2y` jumping substantially under OPLS-DA on the cachexia data.83- Increase `n_ortho` only when you have evidence (e.g. one prominent confounder still present after first orthogonal component); each additional `n_ortho` consumes degrees of freedom and `Q²` will eventually drop.8485**`scale=False` is the right default** when the data was already Pareto-scaled in preprocessing. Setting `scale=True` here double-scales and degrades both `R²Y` and `Q²`.8687**`n_components` for PLS-DA**: 2 is enough for a binary discrimination view. Add a third only if the score plot suggests a structured residual.8889**`features` argument of `biomarker_panel`**90- `features=int` (e.g. 10) — picks the top-`int` features inside the inner CV loop using univariate ranking; the same algorithm runs in every outer fold so feature selection is honest.91- `features=list[str]` — fixes the panel up-front; useful when you have a literature-defined biomarker list and want to validate its CV performance.9293**`classifier` choice**94- `'lr'` — logistic regression. Default for small cohorts (n < 50). Coefficients are interpretable; pairs naturally with PQN+log+Pareto-scaled features.95- `'rf'` — random forest. Robust to feature scaling but interpretability comes from `feature_importance`, not coefficients.96- `'svm'` — RBF-kernel SVM. Non-linear; least interpretable; use only when the linear models don't separate.9798**`n_permutations`**99- Set `>= 100` for any panel you'll report. With `n_permutations=0` you get a mean AUC but no calibrated false-positive control; a panel with `mean_auc=0.95` on `n=20` samples might still be no better than random.100- `n_permutations=1000` if compute is available — gives 0.001-resolution p-values.101102**`ci=True` on `roc_feature`** when you want per-feature CIs in the report; it costs `n_bootstrap` bootstrap resamples per feature. For the cachexia 77-sample dataset, `n_bootstrap=500` runs in seconds.103104## Input Contract105106- `AnnData` must come from the preprocessing skill: `obs['group']` populated, `X` is **log + Pareto-scaled** for `plsda` / `opls_da`, **log-only** for `roc_feature` / `biomarker_panel`.107- `obs['group']` must take exactly two values for PLS-DA / OPLS-DA / `biomarker_panel`. For 3+ groups, slice or mask first.108- For `biomarker_panel` with nested CV at `cv_outer=5, cv_inner=3`, you need at minimum 15 samples per class — fewer than that and the inner folds become trivial.109- Don't pass sparse `adata.X`; densify first.110111## Minimal Execution Patterns112113```python114# Multivariate (assumes preprocessing already ran)115import omicverse as ov116ov.plot_set()117118# X must be log + Pareto-scaled119adata = ov.metabol.read_metaboanalyst('human_cachexia.csv', group_col='Muscle loss')120adata = ov.metabol.normalize(adata, method='pqn')121adata = ov.metabol.transform(adata, method='log')122adata = ov.metabol.transform(adata, method='pareto', stash_raw=False)123124pls = ov.metabol.plsda(adata, group_col='group', n_components=2, scale=False)125print(f'PLS-DA R²X={pls.r2x:.3f} R²Y={pls.r2y:.3f} Q²={pls.q2:.3f}')126127opls = ov.metabol.opls_da(adata, group_col='group', n_ortho=1, scale=False)128print(f'OPLS-DA R²X={opls.r2x:.3f} R²Y={opls.r2y:.3f} Q²={opls.q2:.3f}')129130vip_df = opls.to_vip_table(adata.var_names)131ov.metabol.vip_bar(opls, adata.var_names, top_n=15)132ov.metabol.s_plot(opls, adata, label_top_n=10)133```134135```python136# Biomarker selection (consumes log-only AnnData; do NOT Pareto-scale here)137adata_log = ov.metabol.read_metaboanalyst('human_cachexia.csv', group_col='Muscle loss')138adata_log = ov.metabol.impute(adata_log, method='qrilc', seed=0)139adata_log = ov.metabol.normalize(adata_log, method='pqn')140adata_log = ov.metabol.transform(adata_log, method='log')141142# 1) per-feature AUC with bootstrap CI143auc = ov.metabol.roc_feature(144 adata_log, group_col='group',145 pos_group='cachexic', neg_group='control',146 ci=True, n_bootstrap=500, seed=0,147)148149# 2) nested-CV multi-metabolite panel150panel = ov.metabol.biomarker_panel(151 adata_log, group_col='group',152 pos_group='cachexic', neg_group='control',153 features=10,154 classifier='lr',155 cv_outer=5, cv_inner=3,156 n_permutations=100,157 seed=0,158)159print(f'panel mean AUC: {panel.mean_auc:.3f} ± {panel.std_auc:.3f}')160print(f'permutation p : {panel.permutation_pvalue:.3f}')161print(panel.feature_importance.head())162163# 3) ROC curve from out-of-fold predictions164from sklearn.metrics import roc_curve, auc as _auc165import matplotlib.pyplot as plt166fpr, tpr, _ = roc_curve(panel.outer_labels, panel.outer_predictions)167fig, ax = plt.subplots(figsize=(5, 5))168ax.plot(fpr, tpr, lw=2, label=f'nested CV AUC = {_auc(fpr, tpr):.3f}')169ax.plot([0, 1], [0, 1], '--', color='gray')170ax.set(xlabel='FPR', ylabel='TPR', aspect='equal'); ax.legend(loc='lower right')171plt.tight_layout(); plt.show()172```173174## Validation175176- After fitting: `pls.q2 > 0` is the absolute minimum bar — negative `Q²` means the model is worse than predicting the mean. Any reportable result needs `Q² > 0.1` and ideally a permutation-derived empirical p-value.177- After OPLS-DA: `opls.r2y > pls.r2y` should hold on heterogeneous cohorts; if they're equal, the orthogonal direction didn't capture confounder structure and OPLS-DA is overkill.178- After `biomarker_panel`: report the *outer*-fold AUC (`mean_auc`), never the inner-fold AUC (which is contaminated by feature selection).179- `permutation_pvalue` should be < 0.05 for a publication-grade panel; if it's higher, the apparent discrimination is plausibly random.180- Confirm `panel.outer_labels` lines up with `adata.obs['group']` (the panel uses out-of-fold predictions, so labels are not in the original sample order — use the result's own arrays).181- For VIP-bar / S-plot interpretation: a feature being VIP > 1 alone is not enough — pair it with non-zero `coef` sign and a non-zero correlation in S-plot before calling it a biomarker.182- If the cohort is < 15 per class, the nested CV is unstable; fall back to univariate `roc_feature` only and don't claim panel performance.183184## Resource Map185186- See [`reference.md`](reference.md) for copy-paste snippets.187- See [`references/source-grounding.md`](references/source-grounding.md) for the verified `_plsda` / `_biomarker` / `plotting` signatures and notes on interpreting `PLSDAResult` and `BiomarkerPanelResult` attributes against tutorial usage.188- For the preprocessing chain that produces the input `AnnData`, see the `omicverse-bulk-metabol-preprocessing` skill.189- For pathway-level interpretation of the VIP / panel features, see the `omicverse-bulk-metabol-pathway-multifactor` skill.190191## Examples192- "Run PLS-DA and OPLS-DA on the Pareto-scaled cachexia data and report `R²Y`, `Q²` for both."193- "Show the top-15 VIP metabolites with sign-coloured bars and an OPLS-DA S-plot."194- "Build a 10-metabolite logistic-regression biomarker panel with 5×3 nested CV and a 100-iteration permutation null; plot the held-out ROC curve."195- "Get per-feature AUC with 500-iteration bootstrap confidence intervals for cachexic vs. control."196197## References198- Tutorial notebooks:199 - [`t_metabol_02_multivariate.ipynb`](https://omicverse.readthedocs.io/en/latest/Tutorials-metabol/t_metabol_02_multivariate/) — PLS-DA, OPLS-DA, VIP, S-plot.200 - [`t_metabol_08_biomarker.ipynb`](https://omicverse.readthedocs.io/en/latest/Tutorials-metabol/t_metabol_08_biomarker/) — `roc_feature`, nested-CV panel, permutation null.201- Live API verified against `omicverse.metabol` — see [`references/source-grounding.md`](references/source-grounding.md).