[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M7CVEFTKAM5RQQKHTEXQ9R0G":3,"skill-same:01M7CVEFTKAM5RQQKHTEXQ9R0G":69},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":19,"linkOnly":20,"markdownContent":21,"markdownBody":22,"frontMatter":23,"refName":26,"commitSha":27,"skillDirectory":28,"forksCount":29,"repositoryHtmlUrl":30,"repositoryHomepageUrl":6,"ownerHtmlUrl":31,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":32,"install":33,"resources":46},"01M7CVEFTKAM5RQQKHTEXQ9R0G","bio-proteomics-differential-abundance",null,"Tests for differentially abundant proteins between conditions with limma\u002FDEqMS empirical-Bayes moderation, proDA\u002Fmsqrob2\u002FMSstats missingness modeling, and Python Welch+BH alternatives. Frames missing values as left-censored MNAR (model, do not impute), makes variance moderation the load-bearing step at n=3-5, and prefers feature\u002Fpeptide-level testing. Use when identifying proteins with significant abundance changes between experimental groups. Summarization and normalization mechanics are proteomics\u002Fquantification; volcano and MA plots are data-visualization\u002Fvolcano-and-ma-plots; pathway enrichment of the hit list is pathway-analysis\u002Fgo-enrichment.","mrsonord2240\u002Fopenscience-specialists","mrsonord2240","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F76487430?v=4","01M7CTEM40S43GSJZ9A3YCCXZK","Samuel Nord","01M7CTEM4K69JD1GBYNHVJ9ZP4",2,"Apache-2.0","https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Fmass-spec-proteomics-analyst\u002Fversions\u002F1.0.1\u002Fpackage\u002Fskills\u002Fbio-proteomics-differential-abundance\u002FSKILL.md",[],[],"2026-11-15 14:19:17",false,"---\nname: bio-proteomics-differential-abundance\ndescription: Tests for differentially abundant proteins between conditions with limma\u002FDEqMS empirical-Bayes moderation, proDA\u002Fmsqrob2\u002FMSstats missingness modeling, and Python Welch+BH alternatives. Frames missing values as left-censored MNAR (model, do not impute), makes variance moderation the load-bearing step at n=3-5, and prefers feature\u002Fpeptide-level testing. Use when identifying proteins with significant abundance changes between experimental groups. Summarization and normalization mechanics are proteomics\u002Fquantification; volcano and MA plots are data-visualization\u002Fvolcano-and-ma-plots; pathway enrichment of the hit list is pathway-analysis\u002Fgo-enrichment.\ntool_type: mixed\nprimary_tool: limma\n---\n\n## Version Compatibility\n\nReference examples tested with: limma 3.62.2, DEqMS 1.24.0, proDA 1.20.0, msqrob2 1.14.1, QFeatures 1.16.0, MsCoreUtils 1.18.0, MSstats 4.14.2, ashr 2.2.63 (R 4.4.3 \u002F Bioconductor 3.20), pandas 3.0.5, scipy 1.18.1, statsmodels 0.15.0 (checked 2026-09-15)\n\nBefore using code patterns, verify installed versions match. If versions differ:\n- Python: `pip show \u003Cpackage>` then `help(module.function)` to check signatures\n- R: `packageVersion('\u003Cpkg>')` then `?function_name` to verify parameters\n\nIf code throws ImportError, AttributeError, or TypeError, introspect the installed\npackage and adapt the example to match the actual API rather than retrying.\n\n# Differential Protein Abundance -- Moderated Testing on a Log-Intensity Matrix with Honest Missingness\n\n**\"Find differentially abundant proteins between my conditions\"** -> Moderated statistical testing on a normalized log-intensity matrix, carrying missingness in the likelihood instead of filling it in -- because the missing values are low BECAUSE the protein is low, and imputing them replaces a detection limit with numbers whose fold change is set by the imputation constant and whose p-value carries no FDR guarantee.\n- R: `limma::eBayes(fit, trend=TRUE, robust=TRUE)` for empirical-Bayes moderated t-tests (the protein-level workhorse)\n- R: `DEqMS::spectraCounteBayes()` when PSM\u002Fpeptide counts are available (preferred over limma-trend when quant depth varies)\n- R: `proDA::test_diff()` when missing values are extensive (model the dropout, no imputation)\n- R: `msqrob2::msqrob()` on a `QFeatures` object, or `MSstats::groupComparison()`, to test from the peptide\u002Fprecursor table instead of a protein matrix\n- Python: `scipy.stats.ttest_ind(equal_var=False)` + `statsmodels` BH (large n only; no moderation)\n\nScope: this skill owns the statistical TEST -- design\u002Fcontrast construction, variance moderation, missingness handling, multiple-testing correction, minimum-fold-change testing, and fold-change shrinkage. Peptide-to-protein summarization and normalization mechanics -> proteomics\u002Fquantification. Volcano\u002FMA plots -> data-visualization\u002Fvolcano-and-ma-plots. Enrichment of the hit list -> pathway-analysis\u002Fgo-enrichment. OUT OF SCOPE: how MaxLFQ\u002FTMP\u002FIRS produce the matrix (quantification); how to draw a volcano (data-visualization); single-sample (n=1) comparisons, and diagnosis or treatment decisions for an individual patient.\n\n## The Single Most Important Modern Insight -- Model the Missingness, Moderate the Variance, Test at the Feature Level\n\n1. **Missing values in label-free MS are left-censored MNAR -- missing BECAUSE the intensity is low -- and imputing them (especially Perseus\u002FMaxQuant downshift) turns a detection limit into numbers the test treats as measurements. Downshift draws each missing value from a Gaussian with mean = mu - 1.8*sigma and SD = 0.3*sigma, where mu and sigma describe ALL proteins in that run (sigma is not the replicate SD). For an on\u002Foff protein (seen in all of group A, missing in all of B) the fold change is therefore set by the imputation constant and the protein's own intensity, not by biology -- the volcano-plot \"anchor\u002Fwing\" artifact (on\u002Foff proteins on a streak whose logFC tracks average intensity). The p-value carries no FDR guarantee: whether 0.3*sigma is narrower or wider than the real replicate SD decides whether imputation inflates false positives or only costs power. The honest statement is \"undetected in group B\", not \"20x lower, p=1e-6\". The correct approach is to MODEL the dropout in the likelihood -- proDA (probabilistic dropout), msqrob2, MSstats-AFT -- NOT fill it (Lazar 2016; Ahlmann-Eltze & Anders 2019).\n2. **At the n=3-5 replicates proteomics actually uses, per-protein variance has only 2-4 residual df and is unusable raw -- variance moderation is the load-bearing element, not optional.** limma borrows a prior d0 across all proteins so a 4-replicate design tests on ~10 df instead of 6; `trend=TRUE` makes the prior a function of mean intensity (effectively mandatory for label-free, where a single global prior mis-calibrates FDR across the abundance range); `robust=TRUE` Winsorizes outlier variances (Phipson 2016). DEqMS makes the prior a function of PSM\u002Fpeptide count and generally outperforms limma-trend when quantification depth varies across proteins (Zhu 2020).\n3. **Feature\u002Fpeptide-level modeling keeps information that summarize-then-test throws away -- but it is not free.** Summarizing first (one number per protein per run) discards the within-protein between-peptide variance and the correct degrees of freedom: 12 consistent peptides deserve a smaller SE than 12 disagreeing ones, but after summarization both look equally certain, and a protein with 30 observations looks as informative as one with 3. msqrob2\u002FMSstats keep every peptide as a degree of freedom (Goeminne 2016; Sticker 2020; Choi 2014). Two calibrations from running all three on the same 4 v 4 peptide table: on clean, balanced data the feature-level fit and limma on the robustly summarized matrix returned the SAME 80 calls -- the gain is real when peptides within a protein disagree or coverage is unbalanced, not automatic. And the smaller SEs cut both ways: an uncorrected global offset that a protein-level test ignores becomes proteome-wide significance at the feature level, so a feature-level result is only readable after the centred-contrast check below.\n\n## Tool Taxonomy\n\n| Tool \u002F method | Citation | Mechanism \u002F role | When |\n|---------------|----------|------------------|------|\n| limma | Ritchie 2015; Phipson 2016 | EB moderated t; posterior variance blends a prior d0 with the per-protein estimate; `trend` ties the prior to mean intensity, `robust` Winsorizes outliers | protein-level summaries, small n, the default workhorse |\n| DEqMS | Zhu 2020 | prior variance = loess of log-variance vs log2(count); precision follows quantification DEPTH not just intensity | TMT (count=PSM) and label-free DDA (count=peptide); quant depth varies; preferred over limma-trend |\n| proDA | Ahlmann-Eltze & Anders 2019 (preprint) | probabilistic dropout: missing = left-censored, integrated under a per-sample sigmoid dropout curve; EB on location and variance; no imputation | label-free DDA with many MNAR missing values, small n, proteins absent in one group |\n| msqrob2 | Sticker 2020; Goeminne 2016 | peptide-level robust ridge on a `QFeatures` object: Huber M-estimation downweights outlier peptides, ridge shrinks effects from few observations (>2 mean-model parameters only), EB variance moderation | label-free DDA, outlier-peptide \u002F unbalanced-coverage risk; best FDR in hard spike-in regimes |\n| MSstats | Choi 2014 | feature-level linear mixed model (group fixed + feature + run\u002Fsubject random); AFT censored imputation only inside `dataProcess(MBimpute = TRUE)` (`groupComparison` has no censoring argument) | SRM\u002FPRM\u002FDIA, technical replicates, nested\u002Frepeated-measures, labeled designs |\n| Welch t-test + BH | -- | per-protein two-sample t with `equal_var=False` + Benjamini-Hochberg | large n (>10\u002Fgroup), Python-only; no moderation, unusable at n=3-5 |\n| ashr | Stephens 2017 | mixture prior with a point mass at zero; posterior means shrink uncertain effects toward zero | recovering \"which proteins truly changed and by how much\" (not for GSEA ranking) |\n| volcano \u002F MA plot | -- | (route OUT) | visualization -> data-visualization\u002Fvolcano-and-ma-plots |\n| enrichment of hits | -- | (route OUT) | functional interpretation -> pathway-analysis\u002Fgo-enrichment |\n\n## Decision Tree by Scenario\n\n| Scenario | Recommended | Why |\n|----------|-------------|-----|\n| Small n (3-5\u002Fgroup), protein-level summary matrix | limma `eBayes(trend=TRUE, robust=TRUE)` | EB borrows variance across proteins; the trend calibrates FDR across abundance |\n| PSM\u002Fpeptide counts available (TMT or label-free DDA) | DEqMS `spectraCounteBayes` | prior keyed on quant depth removes single-PSM false positives limma admits |\n| Label-free with many MNAR missing values, on\u002Foff proteins | proDA `test_diff` | models the censored dropout; never imputes. It is honest but underpowered for on\u002Foff proteins at n=3-5 (0 of 11 called at n=4, best adj_pval 0.17): report those as a separate undetected list, not as non-significant |\n| A peptide\u002Fprecursor table is available, not just a protein matrix | msqrob2 (`QFeatures` + `msqrob`) or MSstats | the feature table is the only place the between-peptide spread still exists; both keep it |\n| Outlier-peptide risk, unbalanced peptide coverage | msqrob2 `robustSummary` + `msqrob(robust = TRUE)` | Huber M-estimation downweights the bad peptide instead of letting it move the protein mean. On a clean, balanced 4 v 4 it buys nothing: msqrob2 and limma on the same summarized matrix returned the identical 80 calls. Escalate for the disagreement, not by default |\n| Three or more groups, or group plus covariates, at the peptide level | msqrob2 `msqrobAggregate(..., ridge = TRUE)` | ridge shrinks effects estimated from few observations -- but it is REFUSED on a two-group design (needs >2 mean-model parameters); use `ridge = FALSE` there |\n| Technical replicates, nested\u002Frepeated-measures, labeled (SRM\u002FPRM\u002FDIA) | MSstats (feature-level mixed model) | random effects capture run\u002Fsubject structure summarize-then-test discards |\n| Any feature-level route | check `median(log2FC) ~ 0` before reading the table | small feature-level SEs turn a normalization offset into proteome-wide significance; measured 21.2% realized FDR from a -0.18 offset |\n| Batch present | batch as a covariate in the design (`~ batch + condition`) | `removeBatchEffect` is visualization-only; never feed its output to `lmFit` |\n| Minimum biologically meaningful fold change | `treat()` + `topTreat()` (or SAM s0) | tests |log2FC|>c against the moderated null; a post-hoc FC+significance double filter inflates FDR |\n| Large n (>10\u002Fgroup), Python-only | Welch t-test + BH | variance estimates reliable; no moderation needed at large n |\n\nDefault when uncertain: protein-level summary matrix at n=3-5 -> limma `eBayes(trend=TRUE, robust=TRUE)`; if PSM\u002Fpeptide counts exist, escalate to DEqMS; if missingness is extensive and intensity-dependent, escalate to proDA.\n\n## limma Workflow (R)\n\n**Goal:** Identify differentially abundant proteins using moderated statistics that borrow information across all proteins.\n\n**Approach:** Filter to proteins with enough valid values per group (rows with no or too few values give `NA` averages that stop `eBayes(trend = TRUE)`), build the design (batch as a covariate when present), fit the linear model, drop rows the design cannot estimate, then contrast, apply EB moderation with the intensity trend and robust fitting, and extract BH-corrected results. The per-condition count is NOT sufficient in a paired or blocked design (donor, subject or batch in the model): rows whose observed values fall in different blocks in the two conditions leave zero residual df or a partly `NA` coefficient vector, and limma tests them anyway on a contrast that is partly a block difference. Report proteins removed by either filter (e.g. undetected in one group) separately. Never feed `removeBatchEffect` output to `lmFit`.\n\n```r\nlibrary(limma)\n\ncond \u003C- factor(sample_info$condition)\n# Valid-value filter BEFORE lmFit: >= 2 values in every group (study choice; 3 of 4 is common)\nn_valid \u003C- sapply(levels(cond), function(g) rowSums(!is.na(protein_matrix[, cond == g, drop = FALSE])))\nprotein_matrix \u003C- protein_matrix[apply(n_valid >= 2, 1, all), ]\n\ndesign \u003C- model.matrix(~0 + condition + batch, data = sample_info)  # batch in the model, not removed first\ncolnames(design)[seq_len(nlevels(cond))] \u003C- levels(cond)\n\nfit \u003C- lmFit(protein_matrix, design)\n# Estimability filter: the per-group count above does not make the contrast estimable under a blocked\n# design ('Partial NA coefficients for N probe(s)'). Keep only fully estimated rows with residual df.\nestimable \u003C- fit$df.residual > 0 & rowSums(is.na(fit$coefficients)) == 0\nfit \u003C- fit[estimable, ]    # report the dropped rows; they are the non-estimable ones, not \"not significant\"\n\ncontrast_matrix \u003C- makeContrasts(Treatment - Control, levels = design)\nfit2 \u003C- contrasts.fit(fit, contrast_matrix)\nfit2 \u003C- eBayes(fit2, trend = TRUE, robust = TRUE)  # trend mandatory for label-free; robust Winsorizes outliers\n\nresults \u003C- topTable(fit2, coef = 1, number = Inf, adjust.method = 'BH')\n# columns: logFC, AveExpr, t, P.Value, adj.P.Val, B  (adj.P.Val is the BH p; there is no $FDR)\n```\n\n### Minimum-fold-change testing\n\n**Goal:** Call proteins whose effect exceeds a biologically meaningful threshold, not merely differ from zero.\n\n**Approach:** Use `treat()` against the moderated null and read `topTreat()`. `treat()` re-estimates the prior and its `trend`\u002F`robust` default to FALSE, so pass both again. Keep the result in its own object so `fit2` stays the eBayes fit for DEqMS and ashr. NEVER `topTable(lfc=...)` nor a post-hoc volcano double filter (`abs(logFC) > 1 & adj.P.Val \u003C 0.05`): the BH guarantee then refers to FC = 0, not to the FC threshold, and conditioning on both FC and p can select high-variance nulls. How much FDR inflates depends on the regime -- above 50% for top-ranked lists with many small effects (Ebrahimpoor & Goeman 2021), far less when true effects are large.\n\n```r\nLFC_THRESHOLD \u003C- log2(1.2)  # 1.2-fold floor; treat tests against this null, no double-filter FDR inflation\nfit_treat \u003C- treat(fit2, lfc = LFC_THRESHOLD, trend = TRUE, robust = TRUE)  # treat's trend\u002Frobust default to FALSE\nresults \u003C- topTreat(fit_treat, coef = 1, number = Inf)  # topTreat omits the B column\n```\n\n## DEqMS Workflow (R)\n\n**Goal:** Improve on limma by tying each protein's prior variance to its quantification depth -- proteins measured by more PSMs\u002Fpeptides are more precise.\n\n**Approach:** Run limma through `eBayes`, attach the count vector, then apply DEqMS's count-aware EB. Use PSM count for TMT (quant at MS2) and peptide count for label-free DDA; for multi-batch TMT use the MINIMUM count across batches (the bottleneck batch sets precision).\n\n```r\nlibrary(DEqMS)\n\n# fit2 is the limma fit through eBayes (above), after the valid-value filter\nstopifnot(all(fit2$df.residual > 0))  # NA-sigma rows make spectraCounteBayes recycle loess predictions onto the wrong proteins\nfit2$count \u003C- psm_count_per_protein[rownames(fit2$coefficients)]  # PSM for TMT, peptide for LFQ; min across batches\nfit3 \u003C- spectraCounteBayes(fit2)\n\nresults \u003C- outputResult(fit3, coef_col = 1)\n# adds sca.t, sca.P.Value, sca.adj.pval (the count-adjusted statistics; use these, not the limma columns)\n```\n\n## proDA Workflow (R)\n\n**Goal:** Test proteins with extensive MNAR missingness, including on\u002Foff proteins, without imputing a single value.\n\n**Approach:** Fit the probabilistic-dropout model directly on the log-intensity matrix; missing values contribute as left-censored observations under a per-sample dropout curve. Then test the contrast against zero. proDA keeps on\u002Foff proteins in the model honestly but rarely reaches significance for them at n=3-5 -- a censored observation carries less information than a measured one -- so list them as \"undetected in group X\" rather than reading their non-significance as evidence of no change.\n\n```r\nlibrary(proDA)\n\nfit \u003C- proDA(protein_matrix, design = ~condition + batch, col_data = sample_info,\n             reference_level = 'Control')\nresult_names(fit)  # Intercept, conditionTreatment, batch...: test a coefficient name\nresults \u003C- test_diff(fit, 'conditionTreatment')\n# columns: name, pval, adj_pval, diff (log2FC), t_statistic, se\n# do not report diff for proteins with no observed value in a group: the location prior sets it (sign can be wrong)\n```\n\n## msqrob2 Workflow (R) -- Peptide Table In, Protein Calls Out\n\n**Goal:** Test from the peptide\u002Fprecursor table itself (MaxQuant `evidence.txt`, a DIA-NN report, a PSM table) so the between-peptide spread and the number of observations set the standard error, instead of collapsing to one number per protein per run first.\n\n**Approach:** Build a `QFeatures` object from a wide peptide matrix, log-transform, keep peptides seen in at least 2 runs, aggregate to protein with `robustSummary` (Huber M-estimation, which is what downweights an outlier peptide), then fit `msqrob` -- ridge\u002Frobust regression with empirical-Bayes variance moderation -- and test the contrast. `hypothesisTest` writes its result into `rowData(pe[['protein']])`, one data frame per contrast. Two things the object does silently and you must undo: proteins with no observation in a condition come back with `adjPval = NA` rather than in a list, and any per-run normalization you apply here is applied to a *different peptide set in each run*, which is the offset trap below. Report the untestable and undetected proteins separately, as the limma section requires.\n\n```r\nlibrary(QFeatures)\nlibrary(msqrob2)\n\n# peptide_wide: one row per precursor; columns 'feature', 'protein', then one intensity column per run\nruns \u003C- sample_info$run\ncol_data \u003C- data.frame(quantCols = runs, condition = factor(sample_info$condition),\n                       sample = factor(runs), row.names = runs)  # quantCols column is required by readQFeatures\npe \u003C- readQFeatures(assayData = peptide_wide, quantCols = runs, colData = col_data, name = 'peptideRaw')\npe \u003C- zeroIsNA(pe, 'peptideRaw')\npe \u003C- logTransform(pe, base = 2, i = 'peptideRaw', name = 'peptideLog')\nrowData(pe[['peptideLog']])$nNonZero \u003C- rowSums(!is.na(assay(pe[['peptideLog']])))\npe \u003C- filterFeatures(pe, ~ nNonZero >= 2, keep = TRUE)  # keep=TRUE: the variable exists only on peptideLog\n\n# undetected list, taken BEFORE aggregation: msqrob2 reports these as adjPval = NA, not as a list\ncond \u003C- colData(pe)$condition  # colData lives on the QFeatures object; pe[['peptideLog']]$condition is NULL\nobs \u003C- sapply(levels(cond), function(g)\n  tapply(rowSums(!is.na(assay(pe[['peptideLog']])[, cond == g, drop = FALSE])),\n         rowData(pe[['peptideLog']])$protein, sum))\nundetected \u003C- rownames(obs)[apply(obs, 1, min) == 0]  # report as \"undetected in group X\", never as a fold change\n\npe \u003C- aggregateFeatures(pe, i = 'peptideLog', fcol = 'protein', name = 'protein',\n                        fun = MsCoreUtils::robustSummary, na.rm = TRUE)\npe \u003C- msqrob(pe, i = 'protein', formula = ~condition, robust = TRUE)\nL \u003C- makeContrast('conditionTreatment = 0', parameterNames = 'conditionTreatment')\npe \u003C- hypothesisTest(pe, i = 'protein', contrast = L)\n\nres \u003C- rowData(pe[['protein']])$conditionTreatment  # columns: logFC, se, df, t, pval, adjPval (no adj.P.Val)\nres$protein \u003C- rownames(pe[['protein']])\nuntestable \u003C- res$protein[is.na(res$adjPval)]       # report these; they are not \"not significant\"\nres \u003C- res[!is.na(res$adjPval), ]\n```\n\nTo keep every peptide as its own degree of freedom instead of summarizing first, fit the mixed model over the peptide assay. `sample` must be a column of `colData` and `feature` a column of `rowData`:\n\n```r\npe \u003C- msqrobAggregate(pe, i = 'peptideLog', fcol = 'protein', name = 'proteinLmer',\n                      formula = ~condition + (1 | sample) + (1 | feature), ridge = FALSE)\npe \u003C- hypothesisTest(pe, i = 'proteinLmer', contrast = L)\n```\n\n`ridge = TRUE` needs MORE than two parameters in the mean model, so it is refused outright on a plain two-group comparison (\"The mean model must have more than two parameters for ridge regression\"); use `ridge = FALSE`, or drop the intercept with `~ -1 + condition` as the error message itself suggests. Ridge is for three or more groups, or a group factor plus covariates.\n\n## MSstats Workflow (R) -- Feature-Level Mixed Model\n\n**Goal:** Test a feature-level design with run\u002Fsubject structure -- technical replicates, nested or repeated measures, SRM\u002FPRM\u002FDIA -- from the search engine's own output tables.\n\n**Approach:** Convert with the vendor-specific importer, summarize with `dataProcess`, then test the contrast with `groupComparison`. The contrast matrix is columns-by-condition and its `dimnames` must match `levels(proc$ProteinLevelData$GROUP)` exactly. Keep `MBimpute = FALSE` unless you want the AFT censored imputation: it is the only place MSstats imputes, and the Skill's position is to model dropout rather than fill it. On a 4 v 4 label-free set the two settings differed by 0.4 percentage points of realized FDR, so the AFT imputation is not buying accuracy either. Proteins present in only one condition come back with an infinite `log2FC` and `issue == 'oneConditionMissing'`; split them out and report them as undetected.\n\n```r\nlibrary(MSstats)\n\ninput \u003C- MaxQtoMSstatsFormat(evidence = evidence, proteinGroups = protein_groups,\n                             annotation = annotation, use_log_file = FALSE)  # annotation: Raw.file, Condition, BioReplicate, IsotopeLabelType\nproc \u003C- dataProcess(input, normalization = 'equalizeMedians', summaryMethod = 'TMP',\n                    censoredInt = 'NA', MBimpute = FALSE, use_log_file = FALSE)\n\ncontrast \u003C- matrix(c(-1, 1), nrow = 1,\n                   dimnames = list('Treatment-Control', c('Control', 'Treatment')))  # order = levels(GROUP)\nres \u003C- groupComparison(contrast.matrix = contrast, data = proc, use_log_file = FALSE)$ComparisonResult\nres$Protein \u003C- as.character(res$Protein)\n\nundetected \u003C- res[res$issue %in% 'oneConditionMissing', ]   # infinite log2FC; report as undetected, not as a ratio\ntested \u003C- res[is.finite(res$log2FC) & !is.na(res$adj.pvalue), ]\n# columns: Protein, Label, log2FC, SE, Tvalue, DF, pvalue, adj.pvalue, issue (adj.pvalue is the BH p)\n```\n\n### Check the contrast is centred before reading any feature-level result\n\n**Goal:** Catch the one failure that turns a feature-level test anticonservative across the whole table.\n\n**Approach:** A feature-level route has far smaller standard errors than a protein-summary test -- that is the point of it -- so a global between-condition offset of 0.1-0.2 log2 that limma would never call becomes significant for hundreds of proteins at once. The offset comes from per-run median normalization applied to the peptide table, because each run's median is taken over a *different* set of detected peptides. Measure it: the median log2FC over all tested proteins must be ~0 unless you genuinely expect most of the proteome to move. If it is not, the normalization is the suspect, not biology -- fix it upstream in proteomics\u002Fquantification (normalize on features present in every run, or at the protein level after summarization) rather than by re-centring the p-values here.\n\n```r\noffset \u003C- median(tested$log2FC, na.rm = TRUE)          # msqrob2: median(res$logFC)\nif (abs(offset) > 0.05) stop(sprintf(\n  'median log2FC = %+.3f: the contrast is not centred. Re-normalize (proteomics\u002Fquantification) before reading this table.',\n  offset))\n```\n\n## Python Workflow\n\n**Goal:** Run the full pipeline in Python when no R is available and n is large enough that moderation is unnecessary.\n\n**Approach:** Log2-transform, median-normalize, run per-protein Welch t-tests, apply Benjamini-Hochberg. Return the untestable proteins (fewer than 2 values in a group) alongside the results, as the limma section requires -- they are not \"not significant\". This has NO variance moderation and should not be used at n=3-5 -- escalate to limma\u002FDEqMS for small n.\n\n```python\nimport numpy as np\nimport pandas as pd\nfrom scipy import stats\nfrom statsmodels.stats.multitest import multipletests\n\ndef preprocess(intensities):\n    log2_data = np.log2(intensities.replace(0, np.nan))  # zeros -> NaN to avoid -inf\n    sample_medians = log2_data.median(axis=0)\n    return log2_data - sample_medians + sample_medians.median()\n\ndef differential_abundance(normalized, case_cols, ctrl_cols):\n    rows, untestable = [], []\n    for protein in normalized.index:\n        case, ctrl = normalized.loc[protein, case_cols].dropna(), normalized.loc[protein, ctrl_cols].dropna()\n        if len(case) >= 2 and len(ctrl) >= 2:\n            _, pval = stats.ttest_ind(case, ctrl, equal_var=False)  # Welch; scipy defaults to Student's True\n            rows.append({'protein': protein, 'log2fc': case.mean() - ctrl.mean(), 'pvalue': pval})\n        else:  # never drop these silently: proteins undetected in one group are often the largest real changes\n            untestable.append({'protein': protein, 'n_case': len(case), 'n_ctrl': len(ctrl)})\n    if not rows:\n        raise ValueError('No protein has >= 2 non-missing values in both groups; a two-sample test is not possible')\n    df = pd.DataFrame(rows)\n    df['padj'] = multipletests(df['pvalue'], method='fdr_bh')[1]  # default is Holm-Sidak; pass fdr_bh explicitly\n    return df, pd.DataFrame(untestable, columns=['protein', 'n_case', 'n_ctrl'])  # report the second table too\n```\n\n## Fold-Change Reporting\n\n**Goal:** Hand the right effect estimate to the right consumer.\n\n**Approach:** Report the RAW fold change (the best unbiased point estimate) for GSEA\u002Fpathway ranking and meta-analysis -- those need the full continuous distribution or FC+SE pairs. Apply shrinkage (ashr) only when recovering \"which proteins truly changed and by how much\"; it fits a mixture prior with a point mass at zero and shrinks uncertain effects smoothly toward zero. This is preferred over hard-thresholding (zeroing FCs at padj 0.05), which creates an arbitrary step function. No mature Python ashr equivalent exists.\n\n```r\nlibrary(ashr)\n\nok \u003C- !is.na(fit2$coefficients[, 1]) & !is.na(fit2$s2.post)  # ash() returns PosteriorMean 0 \u002F prior lfsr for NA rows\nse \u003C- sqrt(fit2$s2.post[ok]) * fit2$stdev.unscaled[ok, 1]\nshrunk \u003C- ash(fit2$coefficients[ok, 1], se, mixcompdist = 'normal')\nshrunken_fc \u003C- shrunk$result$PosteriorMean  # report alongside raw logFC, not as a replacement for GSEA\nlfsr \u003C- shrunk$result$lfsr\n```\n\n## Per-Method Failure Modes\n\n### Downshift \u002F any imputation feeding a variance-based test\n**Trigger:** Perseus\u002FMaxQuant downshift (or MinDet\u002FMinProb\u002FQRILC) fills NAs, then limma\u002Ft-test runs on the filled matrix.\n**Mechanism:** Imputed values come from one Gaussian set by the run-wide intensity distribution -> the fold change of an on\u002Foff protein is fixed by the downshift constant, and the imputed within-group SD (0.3 x run-wide SD) is unrelated to the real replicate SD, so the test is anticonservative or merely underpowered depending on the data.\n**Symptom:** Volcano \"anchor\u002Fwing\" -- on\u002Foff proteins on a streak whose logFC tracks average intensity; p-values and FDR without a guarantee.\n**Fix:** Model the missingness instead (proDA \u002F msqrob2 \u002F MSstats-AFT); report on\u002Foff proteins as \"undetected in group X\".\n\n### kNN imputation on left-censored data\n**Trigger:** kNN\u002Fmean imputation applied to label-free data with MNAR dropout.\n**Mechanism:** Mean-reverting -- pulls a truly-low (missing because low) value UP toward the mean.\n**Symptom:** Real down-regulation is compressed; down hits weakened or lost.\n**Fix:** Only valid under MCAR\u002FMAR; for MNAR model the dropout. Under uncertainty Lazar 2016 shows the milder MCAR error beats MNAR-imputers slamming random highs to the floor.\n\n### removeBatchEffect before testing\n**Trigger:** `removeBatchEffect()` output fed to `lmFit`.\n**Mechanism:** Subtracts the fitted batch component with no uncertainty propagation -> understated residual variance, inflated EB df; if batch is confounded with biology it deletes real signal.\n**Symptom:** Anticonservative p-values; lost true effects when cases\u002Fcontrols split by batch.\n**Fix:** Include batch as a covariate in the SAME model (`~ batch + condition`); use `removeBatchEffect` only for PCA\u002Fvisualization.\n\n### eBayes(trend=FALSE) on intensity data\n**Trigger:** Plain `eBayes` (trend off) on a log-intensity matrix.\n**Mechanism:** A single global prior over-shrinks high-abundance and under-shrinks low-abundance proteins.\n**Symptom:** Mis-calibrated FDR across the abundance range.\n**Fix:** `eBayes(trend = TRUE, robust = TRUE)`; escalate to DEqMS when quant depth varies.\n\n### Wrong DEqMS count column\n**Trigger:** Razor+unique counts vs MS2-level PSMs, or total-across-batches vs minimum-across-batches.\n**Mechanism:** The variance-vs-count prior is fit on the wrong precision proxy.\n**Symptom:** Mis-ranked proteins; the count moderation helps the wrong ones.\n**Fix:** PSM count for TMT, peptide count for label-free; minimum count across batches for multi-batch TMT.\n\n### proDA on MCAR missingness\n**Trigger:** proDA applied where dropout is random (e.g. a TMT channel lost at random), not detection-limited.\n**Mechanism:** The left-censored dropout model is mis-specified.\n**Symptom:** Biased estimates; the model fits a dropout curve that does not exist.\n**Fix:** proDA needs intensity-dependent missingness; for MCAR use limma\u002FDEqMS on the observed values.\n\n### Per-run median normalization under a feature-level test\n**Trigger:** `dataProcess(normalization = 'equalizeMedians')`, `normalize(method = 'center.median')` or any per-run median centring applied to the PEPTIDE table, then msqrob2\u002FMSstats.\n**Mechanism:** Each run's median is computed over the peptides detected in THAT run, and the detected set differs run to run. Equalizing those medians therefore transfers a detection-composition difference into a uniform between-condition offset in every protein. A protein-summary test would mostly shrug it off; a feature-level test has standard errors small enough to call it significant, so the offset becomes a proteome-wide false-positive band.\n**Symptom:** `median(log2FC)` over all tested proteins is far from 0 while the raw peptide table's is ~0; the extra calls are low-|logFC|, low-SE proteins; the true hits are recovered either way.\n**Fix:** Check `median(log2FC)` before reading the table. Normalize on features present in every run, or after summarization at the protein level (proteomics\u002Fquantification owns the mechanics). Do not \"fix\" it by re-centring p-values.\n\n### FC + significance double filter\n**Trigger:** `abs(logFC) > 1 & adj.P.Val \u003C 0.05` applied after the test.\n**Mechanism:** |logFC| is large for a true effect OR a large SE; filtering on both the FC and the p (both depend on SE) selects high-variance nulls (collider effect).\n**Symptom:** Realized FDR above nominal with no guarantee -- above 50% for top-ranked lists with many small effects (Ebrahimpoor & Goeman 2021), much lower when true effects are large.\n**Fix:** `treat()`+`topTreat()` or SAM s0, which sit inside the statistic before selection.\n\n## Quantitative Thresholds\n\n| Threshold | Source | Rationale |\n|-----------|--------|-----------|\n| n=3-5 replicates -> 2-4 residual df | -- | raw per-protein variance unusable; moderation is mandatory, not optional |\n| limma adds prior d0 (~4) df | Ritchie 2015 | a 4-replicate design tests on ~10 df vs 6; the borrowed df is the benefit |\n| downshift mean = mu - 1.8*sigma, SD = 0.3*sigma | Perseus default | sigma is the run-wide SD across proteins, not the replicate SD; the imputed FC is set by the constant, and 0.3*sigma can be wider or narrower than real replicate spread |\n| `trend=TRUE` effectively mandatory for label-free | Ritchie 2015 | a single global prior mis-calibrates FDR across abundance |\n| min-FC floor log2(1.2) (1.2-fold) via treat() | -- | example floor; common alternatives 1.5-fold (~0.58) or 2-fold (1.0); set by biology, tested against the moderated null |\n| BH adjusted p \u003C 0.05 | Benjamini-Hochberg | controls FDR over the WHOLE rejection set, not subsets carved out afterward |\n| DEqMS multi-batch TMT: minimum count across batches | Zhu 2020 | the bottleneck batch sets the realized precision |\n| realized FDR > 50% from FC+significance double filter | Ebrahimpoor & Goeman 2021 | top-100 at n=12 exceeded 50% FDR at nominal 5%; regime-specific (many small effects), not a general rate |\n| feature-level route: `abs(median(log2FC))` \u003C= 0.05 | measured | on one 4 v 4 peptide set the realized FDR tracked the offset monotonically: -0.005 -> 0.0%, -0.107 -> 7.0%, -0.184 -> 21.2%, -0.202 -> 28.6%, all at nominal 5%; the true-hit count did not change |\n| msqrob2 ridge needs > 2 mean-model parameters | msqrob2 1.14.1 | a two-group `~condition` mean model has 2, so `ridge = TRUE` errors; `ridge = FALSE` or `~ -1 + condition` |\n\n## Common Errors\n\n| Error \u002F symptom | Cause | Solution |\n|-----------------|-------|----------|\n| `results$FDR` is NULL | limma `topTable`\u002F`topTreat` have no `$FDR` column | use `adj.P.Val` (BH-adjusted p) |\n| `topTreat` row has no `B` | `topTreat` omits `B` (a `topTable` column) | read `logFC, AveExpr, t, P.Value, adj.P.Val` |\n| FDR mis-calibrated across abundance | `eBayes` with `trend=FALSE` on intensity data | `eBayes(fit, trend = TRUE, robust = TRUE)` |\n| min-FC test inflates FDR | `topTable(lfc=...)` or post-hoc volcano double filter | `treat(fit, lfc=log2(1.2), trend=TRUE, robust=TRUE)` then `topTreat()` |\n| treat() list moderated without the intensity trend | `treat()` re-estimates the prior with `trend=FALSE, robust=FALSE` by default | pass `trend = TRUE, robust = TRUE` to `treat()` |\n| `eBayes`: `prior.weights contain NA values` | rows with no valid value (MaxQuant all-zero LFQ rows) or too few per group | valid-value filter before `lmFit` |\n| `lmFit`: `Partial NA coefficients for N probe(s)`; `stopifnot(all(fit2$df.residual > 0))` fires | a paired\u002Fblocked design (donor, subject, batch) where a row's values sit in different blocks per condition; the per-condition valid-value filter does not catch this | `fit \u003C- fit[fit$df.residual > 0 & rowSums(is.na(fit$coefficients)) == 0, ]` after `lmFit` |\n| DEqMS warning `longer object length is not a multiple of shorter object length` | rows with NA sigma \u002F zero residual df reach `spectraCounteBayes` | filter before `lmFit`; require `fit2$df.residual > 0` |\n| proDA: `object 'conditionControl' not found` | intercept design with `reference_level`: coefficients are `Intercept`, `conditionTreatment` | `test_diff(fit, 'conditionTreatment')` |\n| `makeContrasts`: `object 'DrugB' not found` | design rename hard-coded to two groups | `colnames(design)[seq_len(nlevels(cond))] \u003C- levels(cond)` |\n| Python `ValueError: No protein has >= 2 non-missing values` | a group has one sample, or no protein is observed twice per group | not a two-sample test; do not run DA on n=1 |\n| anticonservative p after batch correction | `removeBatchEffect` output fed to `lmFit` | put batch in the design: `~ batch + condition` |\n| DEqMS columns missing | forgot `fit$count` or read limma columns | set `fit$count`, run `spectraCounteBayes`, read `sca.adj.pval` from `outputResult` |\n| Student's t instead of Welch | `scipy.stats.ttest_ind` defaults `equal_var=True` | pass `equal_var=False` |\n| p-values look like Holm-Sidak | `statsmodels` `multipletests` defaults to `'hs'` | pass `method='fdr_bh'` |\n| volcano \"anchor\u002Fwing\" streaks | downshift\u002Fimputation feeding the test | model dropout (proDA\u002Fmsqrob2\u002FMSstats-AFT); report on\u002Foff proteins as undetected |\n| hundreds of low-|logFC| calls from msqrob2\u002FMSstats and `median(log2FC)` far from 0 | per-run median normalization on the peptide table offsets the whole contrast | re-normalize on complete-case features or after summarization; see the centred-contrast check |\n| `readQFeatures`: `'colData' must contain a column called 'quantCols'` | QFeatures 1.16 requires the run names in `colData$quantCols` | `data.frame(quantCols = runs, condition = ..., row.names = runs)` |\n| msqrob2: `The mean model must have more than two parameters for ridge regression` | `ridge = TRUE` on a two-group design | `ridge = FALSE`, or drop the intercept (`~ -1 + condition`) |\n| msqrob2: `Variable sample is not found in coldata or rowdata` | `msqrobAggregate` formula names a term that is in neither | add `sample` to `colData`; `feature` must be a `rowData` column of the peptide assay |\n| `pe[['peptideLog']]$condition` is `NULL` | in QFeatures 1.16 `colData` is held on the QFeatures object, not on each assay | `colData(pe)$condition` (or `getWithColData(pe, i)` for a standalone assay) |\n| msqrob2 results have no `adj.P.Val`; some rows are all `NA` | `hypothesisTest` writes `logFC, se, df, t, pval, adjPval` into `rowData`, and returns `NA` for proteins it cannot fit | read `adjPval`; report the `is.na(adjPval)` rows separately, not as non-significant |\n| MSstats rows with infinite `log2FC` | `issue == 'oneConditionMissing'` | split them out and report as undetected; never as a ratio |\n| MaxQuant `evidence.txt`: the `Reverse`\u002F`Potential contaminant` filter drops every row | empty flag columns are read as logical `NA`, and `NA != '+'` is `NA` | filter with `!(ev$Reverse %in% '+')`, which is `FALSE` for `NA` |\n\n## References\n\n- Ritchie ME, Phipson B, Wu D, Hu Y, Law CW, Shi W, Smyth GK. 2015. limma powers differential expression analyses for RNA-sequencing and microarray studies. *Nucleic Acids Res* 43(7):e47.\n- Phipson B, Lee S, Majewski IJ, Alexander WS, Smyth GK. 2016. Robust hyperparameter estimation protects against hypervariable genes and improves power to detect differential expression. *Ann Appl Stat* 10(2):946-963.\n- Zhu Y, Orre LM, Zhou Tran Y, et al. 2020. DEqMS: a method for accurate variance estimation in differential protein expression analysis. *Mol Cell Proteomics* 19(6):1047-1057.\n- Ahlmann-Eltze C, Anders S. 2019. proDA: probabilistic dropout analysis for identifying differentially abundant proteins in label-free mass spectrometry. *bioRxiv* 661496 (preprint; cite `citation(\"proDA\")`, never a journal).\n- Choi M, Chang CY, Clough T, Broudy D, Killeen T, MacLean B, Vitek O. 2014. MSstats: an R package for statistical analysis of quantitative mass spectrometry-based proteomic experiments. *Bioinformatics* 30(17):2524-2526.\n- Goeminne LJE, Gevaert K, Clement L. 2016. Peptide-level robust ridge regression improves estimation, sensitivity, and specificity in data-dependent quantitative label-free shotgun proteomics. *Mol Cell Proteomics* 15(2):657-668.\n- Sticker A, Goeminne L, Martens L, Clement L. 2020. Robust summarization and inference in proteome-wide label-free quantification. *Mol Cell Proteomics* 19(7):1209-1219.\n- Lazar C, Gatto L, Ferro M, Bruley C, Burger T. 2016. Accounting for the multiple natures of missing values in label-free quantitative proteomics data sets to compare imputation strategies. *J Proteome Res* 15(4):1116-1125.\n- Stephens M. 2017. False discovery rates: a new deal. *Biostatistics* 18(2):275-294.\n- Ebrahimpoor M, Goeman JJ. 2021. Inflated false discovery rate due to volcano plots: problem and solutions. *Brief Bioinform* 22(5):bbab053.\n\n## Related Skills\n\n- quantification - peptide-to-protein summarization, normalization, and IRS that produce the matrix this skill tests\n- proteomics-qc - quality control and batch-effect assessment before testing\n- protein-inference - razor\u002Fshared-peptide ambiguity that drives which protein group gets the quantity\n- ptm-analysis - site-level differential testing for modified peptides\n- differential-expression\u002Fde-results - analogous empirical-Bayes interpretation for RNA-seq DE\n- data-visualization\u002Fvolcano-and-ma-plots - volcano and MA plots of the result table\n- pathway-analysis\u002Fgo-enrichment - functional enrichment of the significant protein hit list\n- machine-learning\u002Fbiomarker-discovery - building predictive panels from differential proteins\n- workflows\u002Fproteomics-pipeline - end-to-end pipeline that calls this skill as the testing stage\n","## Version Compatibility\n\nReference examples tested with: limma 3.62.2, DEqMS 1.24.0, proDA 1.20.0, msqrob2 1.14.1, QFeatures 1.16.0, MsCoreUtils 1.18.0, MSstats 4.14.2, ashr 2.2.63 (R 4.4.3 \u002F Bioconductor 3.20), pandas 3.0.5, scipy 1.18.1, statsmodels 0.15.0 (checked 2026-09-15)\n\nBefore using code patterns, verify installed versions match. If versions differ:\n- Python: `pip show \u003Cpackage>` then `help(module.function)` to check signatures\n- R: `packageVersion('\u003Cpkg>')` then `?function_name` to verify parameters\n\nIf code throws ImportError, AttributeError, or TypeError, introspect the installed\npackage and adapt the example to match the actual API rather than retrying.\n\n# Differential Protein Abundance -- Moderated Testing on a Log-Intensity Matrix with Honest Missingness\n\n**\"Find differentially abundant proteins between my conditions\"** -> Moderated statistical testing on a normalized log-intensity matrix, carrying missingness in the likelihood instead of filling it in -- because the missing values are low BECAUSE the protein is low, and imputing them replaces a detection limit with numbers whose fold change is set by the imputation constant and whose p-value carries no FDR guarantee.\n- R: `limma::eBayes(fit, trend=TRUE, robust=TRUE)` for empirical-Bayes moderated t-tests (the protein-level workhorse)\n- R: `DEqMS::spectraCounteBayes()` when PSM\u002Fpeptide counts are available (preferred over limma-trend when quant depth varies)\n- R: `proDA::test_diff()` when missing values are extensive (model the dropout, no imputation)\n- R: `msqrob2::msqrob()` on a `QFeatures` object, or `MSstats::groupComparison()`, to test from the peptide\u002Fprecursor table instead of a protein matrix\n- Python: `scipy.stats.ttest_ind(equal_var=False)` + `statsmodels` BH (large n only; no moderation)\n\nScope: this skill owns the statistical TEST -- design\u002Fcontrast construction, variance moderation, missingness handling, multiple-testing correction, minimum-fold-change testing, and fold-change shrinkage. Peptide-to-protein summarization and normalization mechanics -> proteomics\u002Fquantification. Volcano\u002FMA plots -> data-visualization\u002Fvolcano-and-ma-plots. Enrichment of the hit list -> pathway-analysis\u002Fgo-enrichment. OUT OF SCOPE: how MaxLFQ\u002FTMP\u002FIRS produce the matrix (quantification); how to draw a volcano (data-visualization); single-sample (n=1) comparisons, and diagnosis or treatment decisions for an individual patient.\n\n## The Single Most Important Modern Insight -- Model the Missingness, Moderate the Variance, Test at the Feature Level\n\n1. **Missing values in label-free MS are left-censored MNAR -- missing BECAUSE the intensity is low -- and imputing them (especially Perseus\u002FMaxQuant downshift) turns a detection limit into numbers the test treats as measurements. Downshift draws each missing value from a Gaussian with mean = mu - 1.8*sigma and SD = 0.3*sigma, where mu and sigma describe ALL proteins in that run (sigma is not the replicate SD). For an on\u002Foff protein (seen in all of group A, missing in all of B) the fold change is therefore set by the imputation constant and the protein's own intensity, not by biology -- the volcano-plot \"anchor\u002Fwing\" artifact (on\u002Foff proteins on a streak whose logFC tracks average intensity). The p-value carries no FDR guarantee: whether 0.3*sigma is narrower or wider than the real replicate SD decides whether imputation inflates false positives or only costs power. The honest statement is \"undetected in group B\", not \"20x lower, p=1e-6\". The correct approach is to MODEL the dropout in the likelihood -- proDA (probabilistic dropout), msqrob2, MSstats-AFT -- NOT fill it (Lazar 2016; Ahlmann-Eltze & Anders 2019).\n2. **At the n=3-5 replicates proteomics actually uses, per-protein variance has only 2-4 residual df and is unusable raw -- variance moderation is the load-bearing element, not optional.** limma borrows a prior d0 across all proteins so a 4-replicate design tests on ~10 df instead of 6; `trend=TRUE` makes the prior a function of mean intensity (effectively mandatory for label-free, where a single global prior mis-calibrates FDR across the abundance range); `robust=TRUE` Winsorizes outlier variances (Phipson 2016). DEqMS makes the prior a function of PSM\u002Fpeptide count and generally outperforms limma-trend when quantification depth varies across proteins (Zhu 2020).\n3. **Feature\u002Fpeptide-level modeling keeps information that summarize-then-test throws away -- but it is not free.** Summarizing first (one number per protein per run) discards the within-protein between-peptide variance and the correct degrees of freedom: 12 consistent peptides deserve a smaller SE than 12 disagreeing ones, but after summarization both look equally certain, and a protein with 30 observations looks as informative as one with 3. msqrob2\u002FMSstats keep every peptide as a degree of freedom (Goeminne 2016; Sticker 2020; Choi 2014). Two calibrations from running all three on the same 4 v 4 peptide table: on clean, balanced data the feature-level fit and limma on the robustly summarized matrix returned the SAME 80 calls -- the gain is real when peptides within a protein disagree or coverage is unbalanced, not automatic. And the smaller SEs cut both ways: an uncorrected global offset that a protein-level test ignores becomes proteome-wide significance at the feature level, so a feature-level result is only readable after the centred-contrast check below.\n\n## Tool Taxonomy\n\n| Tool \u002F method | Citation | Mechanism \u002F role | When |\n|---------------|----------|------------------|------|\n| limma | Ritchie 2015; Phipson 2016 | EB moderated t; posterior variance blends a prior d0 with the per-protein estimate; `trend` ties the prior to mean intensity, `robust` Winsorizes outliers | protein-level summaries, small n, the default workhorse |\n| DEqMS | Zhu 2020 | prior variance = loess of log-variance vs log2(count); precision follows quantification DEPTH not just intensity | TMT (count=PSM) and label-free DDA (count=peptide); quant depth varies; preferred over limma-trend |\n| proDA | Ahlmann-Eltze & Anders 2019 (preprint) | probabilistic dropout: missing = left-censored, integrated under a per-sample sigmoid dropout curve; EB on location and variance; no imputation | label-free DDA with many MNAR missing values, small n, proteins absent in one group |\n| msqrob2 | Sticker 2020; Goeminne 2016 | peptide-level robust ridge on a `QFeatures` object: Huber M-estimation downweights outlier peptides, ridge shrinks effects from few observations (>2 mean-model parameters only), EB variance moderation | label-free DDA, outlier-peptide \u002F unbalanced-coverage risk; best FDR in hard spike-in regimes |\n| MSstats | Choi 2014 | feature-level linear mixed model (group fixed + feature + run\u002Fsubject random); AFT censored imputation only inside `dataProcess(MBimpute = TRUE)` (`groupComparison` has no censoring argument) | SRM\u002FPRM\u002FDIA, technical replicates, nested\u002Frepeated-measures, labeled designs |\n| Welch t-test + BH | -- | per-protein two-sample t with `equal_var=False` + Benjamini-Hochberg | large n (>10\u002Fgroup), Python-only; no moderation, unusable at n=3-5 |\n| ashr | Stephens 2017 | mixture prior with a point mass at zero; posterior means shrink uncertain effects toward zero | recovering \"which proteins truly changed and by how much\" (not for GSEA ranking) |\n| volcano \u002F MA plot | -- | (route OUT) | visualization -> data-visualization\u002Fvolcano-and-ma-plots |\n| enrichment of hits | -- | (route OUT) | functional interpretation -> pathway-analysis\u002Fgo-enrichment |\n\n## Decision Tree by Scenario\n\n| Scenario | Recommended | Why |\n|----------|-------------|-----|\n| Small n (3-5\u002Fgroup), protein-level summary matrix | limma `eBayes(trend=TRUE, robust=TRUE)` | EB borrows variance across proteins; the trend calibrates FDR across abundance |\n| PSM\u002Fpeptide counts available (TMT or label-free DDA) | DEqMS `spectraCounteBayes` | prior keyed on quant depth removes single-PSM false positives limma admits |\n| Label-free with many MNAR missing values, on\u002Foff proteins | proDA `test_diff` | models the censored dropout; never imputes. It is honest but underpowered for on\u002Foff proteins at n=3-5 (0 of 11 called at n=4, best adj_pval 0.17): report those as a separate undetected list, not as non-significant |\n| A peptide\u002Fprecursor table is available, not just a protein matrix | msqrob2 (`QFeatures` + `msqrob`) or MSstats | the feature table is the only place the between-peptide spread still exists; both keep it |\n| Outlier-peptide risk, unbalanced peptide coverage | msqrob2 `robustSummary` + `msqrob(robust = TRUE)` | Huber M-estimation downweights the bad peptide instead of letting it move the protein mean. On a clean, balanced 4 v 4 it buys nothing: msqrob2 and limma on the same summarized matrix returned the identical 80 calls. Escalate for the disagreement, not by default |\n| Three or more groups, or group plus covariates, at the peptide level | msqrob2 `msqrobAggregate(..., ridge = TRUE)` | ridge shrinks effects estimated from few observations -- but it is REFUSED on a two-group design (needs >2 mean-model parameters); use `ridge = FALSE` there |\n| Technical replicates, nested\u002Frepeated-measures, labeled (SRM\u002FPRM\u002FDIA) | MSstats (feature-level mixed model) | random effects capture run\u002Fsubject structure summarize-then-test discards |\n| Any feature-level route | check `median(log2FC) ~ 0` before reading the table | small feature-level SEs turn a normalization offset into proteome-wide significance; measured 21.2% realized FDR from a -0.18 offset |\n| Batch present | batch as a covariate in the design (`~ batch + condition`) | `removeBatchEffect` is visualization-only; never feed its output to `lmFit` |\n| Minimum biologically meaningful fold change | `treat()` + `topTreat()` (or SAM s0) | tests |log2FC|>c against the moderated null; a post-hoc FC+significance double filter inflates FDR |\n| Large n (>10\u002Fgroup), Python-only | Welch t-test + BH | variance estimates reliable; no moderation needed at large n |\n\nDefault when uncertain: protein-level summary matrix at n=3-5 -> limma `eBayes(trend=TRUE, robust=TRUE)`; if PSM\u002Fpeptide counts exist, escalate to DEqMS; if missingness is extensive and intensity-dependent, escalate to proDA.\n\n## limma Workflow (R)\n\n**Goal:** Identify differentially abundant proteins using moderated statistics that borrow information across all proteins.\n\n**Approach:** Filter to proteins with enough valid values per group (rows with no or too few values give `NA` averages that stop `eBayes(trend = TRUE)`), build the design (batch as a covariate when present), fit the linear model, drop rows the design cannot estimate, then contrast, apply EB moderation with the intensity trend and robust fitting, and extract BH-corrected results. The per-condition count is NOT sufficient in a paired or blocked design (donor, subject or batch in the model): rows whose observed values fall in different blocks in the two conditions leave zero residual df or a partly `NA` coefficient vector, and limma tests them anyway on a contrast that is partly a block difference. Report proteins removed by either filter (e.g. undetected in one group) separately. Never feed `removeBatchEffect` output to `lmFit`.\n\n```r\nlibrary(limma)\n\ncond \u003C- factor(sample_info$condition)\n# Valid-value filter BEFORE lmFit: >= 2 values in every group (study choice; 3 of 4 is common)\nn_valid \u003C- sapply(levels(cond), function(g) rowSums(!is.na(protein_matrix[, cond == g, drop = FALSE])))\nprotein_matrix \u003C- protein_matrix[apply(n_valid >= 2, 1, all), ]\n\ndesign \u003C- model.matrix(~0 + condition + batch, data = sample_info)  # batch in the model, not removed first\ncolnames(design)[seq_len(nlevels(cond))] \u003C- levels(cond)\n\nfit \u003C- lmFit(protein_matrix, design)\n# Estimability filter: the per-group count above does not make the contrast estimable under a blocked\n# design ('Partial NA coefficients for N probe(s)'). Keep only fully estimated rows with residual df.\nestimable \u003C- fit$df.residual > 0 & rowSums(is.na(fit$coefficients)) == 0\nfit \u003C- fit[estimable, ]    # report the dropped rows; they are the non-estimable ones, not \"not significant\"\n\ncontrast_matrix \u003C- makeContrasts(Treatment - Control, levels = design)\nfit2 \u003C- contrasts.fit(fit, contrast_matrix)\nfit2 \u003C- eBayes(fit2, trend = TRUE, robust = TRUE)  # trend mandatory for label-free; robust Winsorizes outliers\n\nresults \u003C- topTable(fit2, coef = 1, number = Inf, adjust.method = 'BH')\n# columns: logFC, AveExpr, t, P.Value, adj.P.Val, B  (adj.P.Val is the BH p; there is no $FDR)\n```\n\n### Minimum-fold-change testing\n\n**Goal:** Call proteins whose effect exceeds a biologically meaningful threshold, not merely differ from zero.\n\n**Approach:** Use `treat()` against the moderated null and read `topTreat()`. `treat()` re-estimates the prior and its `trend`\u002F`robust` default to FALSE, so pass both again. Keep the result in its own object so `fit2` stays the eBayes fit for DEqMS and ashr. NEVER `topTable(lfc=...)` nor a post-hoc volcano double filter (`abs(logFC) > 1 & adj.P.Val \u003C 0.05`): the BH guarantee then refers to FC = 0, not to the FC threshold, and conditioning on both FC and p can select high-variance nulls. How much FDR inflates depends on the regime -- above 50% for top-ranked lists with many small effects (Ebrahimpoor & Goeman 2021), far less when true effects are large.\n\n```r\nLFC_THRESHOLD \u003C- log2(1.2)  # 1.2-fold floor; treat tests against this null, no double-filter FDR inflation\nfit_treat \u003C- treat(fit2, lfc = LFC_THRESHOLD, trend = TRUE, robust = TRUE)  # treat's trend\u002Frobust default to FALSE\nresults \u003C- topTreat(fit_treat, coef = 1, number = Inf)  # topTreat omits the B column\n```\n\n## DEqMS Workflow (R)\n\n**Goal:** Improve on limma by tying each protein's prior variance to its quantification depth -- proteins measured by more PSMs\u002Fpeptides are more precise.\n\n**Approach:** Run limma through `eBayes`, attach the count vector, then apply DEqMS's count-aware EB. Use PSM count for TMT (quant at MS2) and peptide count for label-free DDA; for multi-batch TMT use the MINIMUM count across batches (the bottleneck batch sets precision).\n\n```r\nlibrary(DEqMS)\n\n# fit2 is the limma fit through eBayes (above), after the valid-value filter\nstopifnot(all(fit2$df.residual > 0))  # NA-sigma rows make spectraCounteBayes recycle loess predictions onto the wrong proteins\nfit2$count \u003C- psm_count_per_protein[rownames(fit2$coefficients)]  # PSM for TMT, peptide for LFQ; min across batches\nfit3 \u003C- spectraCounteBayes(fit2)\n\nresults \u003C- outputResult(fit3, coef_col = 1)\n# adds sca.t, sca.P.Value, sca.adj.pval (the count-adjusted statistics; use these, not the limma columns)\n```\n\n## proDA Workflow (R)\n\n**Goal:** Test proteins with extensive MNAR missingness, including on\u002Foff proteins, without imputing a single value.\n\n**Approach:** Fit the probabilistic-dropout model directly on the log-intensity matrix; missing values contribute as left-censored observations under a per-sample dropout curve. Then test the contrast against zero. proDA keeps on\u002Foff proteins in the model honestly but rarely reaches significance for them at n=3-5 -- a censored observation carries less information than a measured one -- so list them as \"undetected in group X\" rather than reading their non-significance as evidence of no change.\n\n```r\nlibrary(proDA)\n\nfit \u003C- proDA(protein_matrix, design = ~condition + batch, col_data = sample_info,\n             reference_level = 'Control')\nresult_names(fit)  # Intercept, conditionTreatment, batch...: test a coefficient name\nresults \u003C- test_diff(fit, 'conditionTreatment')\n# columns: name, pval, adj_pval, diff (log2FC), t_statistic, se\n# do not report diff for proteins with no observed value in a group: the location prior sets it (sign can be wrong)\n```\n\n## msqrob2 Workflow (R) -- Peptide Table In, Protein Calls Out\n\n**Goal:** Test from the peptide\u002Fprecursor table itself (MaxQuant `evidence.txt`, a DIA-NN report, a PSM table) so the between-peptide spread and the number of observations set the standard error, instead of collapsing to one number per protein per run first.\n\n**Approach:** Build a `QFeatures` object from a wide peptide matrix, log-transform, keep peptides seen in at least 2 runs, aggregate to protein with `robustSummary` (Huber M-estimation, which is what downweights an outlier peptide), then fit `msqrob` -- ridge\u002Frobust regression with empirical-Bayes variance moderation -- and test the contrast. `hypothesisTest` writes its result into `rowData(pe[['protein']])`, one data frame per contrast. Two things the object does silently and you must undo: proteins with no observation in a condition come back with `adjPval = NA` rather than in a list, and any per-run normalization you apply here is applied to a *different peptide set in each run*, which is the offset trap below. Report the untestable and undetected proteins separately, as the limma section requires.\n\n```r\nlibrary(QFeatures)\nlibrary(msqrob2)\n\n# peptide_wide: one row per precursor; columns 'feature', 'protein', then one intensity column per run\nruns \u003C- sample_info$run\ncol_data \u003C- data.frame(quantCols = runs, condition = factor(sample_info$condition),\n                       sample = factor(runs), row.names = runs)  # quantCols column is required by readQFeatures\npe \u003C- readQFeatures(assayData = peptide_wide, quantCols = runs, colData = col_data, name = 'peptideRaw')\npe \u003C- zeroIsNA(pe, 'peptideRaw')\npe \u003C- logTransform(pe, base = 2, i = 'peptideRaw', name = 'peptideLog')\nrowData(pe[['peptideLog']])$nNonZero \u003C- rowSums(!is.na(assay(pe[['peptideLog']])))\npe \u003C- filterFeatures(pe, ~ nNonZero >= 2, keep = TRUE)  # keep=TRUE: the variable exists only on peptideLog\n\n# undetected list, taken BEFORE aggregation: msqrob2 reports these as adjPval = NA, not as a list\ncond \u003C- colData(pe)$condition  # colData lives on the QFeatures object; pe[['peptideLog']]$condition is NULL\nobs \u003C- sapply(levels(cond), function(g)\n  tapply(rowSums(!is.na(assay(pe[['peptideLog']])[, cond == g, drop = FALSE])),\n         rowData(pe[['peptideLog']])$protein, sum))\nundetected \u003C- rownames(obs)[apply(obs, 1, min) == 0]  # report as \"undetected in group X\", never as a fold change\n\npe \u003C- aggregateFeatures(pe, i = 'peptideLog', fcol = 'protein', name = 'protein',\n                        fun = MsCoreUtils::robustSummary, na.rm = TRUE)\npe \u003C- msqrob(pe, i = 'protein', formula = ~condition, robust = TRUE)\nL \u003C- makeContrast('conditionTreatment = 0', parameterNames = 'conditionTreatment')\npe \u003C- hypothesisTest(pe, i = 'protein', contrast = L)\n\nres \u003C- rowData(pe[['protein']])$conditionTreatment  # columns: logFC, se, df, t, pval, adjPval (no adj.P.Val)\nres$protein \u003C- rownames(pe[['protein']])\nuntestable \u003C- res$protein[is.na(res$adjPval)]       # report these; they are not \"not significant\"\nres \u003C- res[!is.na(res$adjPval), ]\n```\n\nTo keep every peptide as its own degree of freedom instead of summarizing first, fit the mixed model over the peptide assay. `sample` must be a column of `colData` and `feature` a column of `rowData`:\n\n```r\npe \u003C- msqrobAggregate(pe, i = 'peptideLog', fcol = 'protein', name = 'proteinLmer',\n                      formula = ~condition + (1 | sample) + (1 | feature), ridge = FALSE)\npe \u003C- hypothesisTest(pe, i = 'proteinLmer', contrast = L)\n```\n\n`ridge = TRUE` needs MORE than two parameters in the mean model, so it is refused outright on a plain two-group comparison (\"The mean model must have more than two parameters for ridge regression\"); use `ridge = FALSE`, or drop the intercept with `~ -1 + condition` as the error message itself suggests. Ridge is for three or more groups, or a group factor plus covariates.\n\n## MSstats Workflow (R) -- Feature-Level Mixed Model\n\n**Goal:** Test a feature-level design with run\u002Fsubject structure -- technical replicates, nested or repeated measures, SRM\u002FPRM\u002FDIA -- from the search engine's own output tables.\n\n**Approach:** Convert with the vendor-specific importer, summarize with `dataProcess`, then test the contrast with `groupComparison`. The contrast matrix is columns-by-condition and its `dimnames` must match `levels(proc$ProteinLevelData$GROUP)` exactly. Keep `MBimpute = FALSE` unless you want the AFT censored imputation: it is the only place MSstats imputes, and the Skill's position is to model dropout rather than fill it. On a 4 v 4 label-free set the two settings differed by 0.4 percentage points of realized FDR, so the AFT imputation is not buying accuracy either. Proteins present in only one condition come back with an infinite `log2FC` and `issue == 'oneConditionMissing'`; split them out and report them as undetected.\n\n```r\nlibrary(MSstats)\n\ninput \u003C- MaxQtoMSstatsFormat(evidence = evidence, proteinGroups = protein_groups,\n                             annotation = annotation, use_log_file = FALSE)  # annotation: Raw.file, Condition, BioReplicate, IsotopeLabelType\nproc \u003C- dataProcess(input, normalization = 'equalizeMedians', summaryMethod = 'TMP',\n                    censoredInt = 'NA', MBimpute = FALSE, use_log_file = FALSE)\n\ncontrast \u003C- matrix(c(-1, 1), nrow = 1,\n                   dimnames = list('Treatment-Control', c('Control', 'Treatment')))  # order = levels(GROUP)\nres \u003C- groupComparison(contrast.matrix = contrast, data = proc, use_log_file = FALSE)$ComparisonResult\nres$Protein \u003C- as.character(res$Protein)\n\nundetected \u003C- res[res$issue %in% 'oneConditionMissing', ]   # infinite log2FC; report as undetected, not as a ratio\ntested \u003C- res[is.finite(res$log2FC) & !is.na(res$adj.pvalue), ]\n# columns: Protein, Label, log2FC, SE, Tvalue, DF, pvalue, adj.pvalue, issue (adj.pvalue is the BH p)\n```\n\n### Check the contrast is centred before reading any feature-level result\n\n**Goal:** Catch the one failure that turns a feature-level test anticonservative across the whole table.\n\n**Approach:** A feature-level route has far smaller standard errors than a protein-summary test -- that is the point of it -- so a global between-condition offset of 0.1-0.2 log2 that limma would never call becomes significant for hundreds of proteins at once. The offset comes from per-run median normalization applied to the peptide table, because each run's median is taken over a *different* set of detected peptides. Measure it: the median log2FC over all tested proteins must be ~0 unless you genuinely expect most of the proteome to move. If it is not, the normalization is the suspect, not biology -- fix it upstream in proteomics\u002Fquantification (normalize on features present in every run, or at the protein level after summarization) rather than by re-centring the p-values here.\n\n```r\noffset \u003C- median(tested$log2FC, na.rm = TRUE)          # msqrob2: median(res$logFC)\nif (abs(offset) > 0.05) stop(sprintf(\n  'median log2FC = %+.3f: the contrast is not centred. Re-normalize (proteomics\u002Fquantification) before reading this table.',\n  offset))\n```\n\n## Python Workflow\n\n**Goal:** Run the full pipeline in Python when no R is available and n is large enough that moderation is unnecessary.\n\n**Approach:** Log2-transform, median-normalize, run per-protein Welch t-tests, apply Benjamini-Hochberg. Return the untestable proteins (fewer than 2 values in a group) alongside the results, as the limma section requires -- they are not \"not significant\". This has NO variance moderation and should not be used at n=3-5 -- escalate to limma\u002FDEqMS for small n.\n\n```python\nimport numpy as np\nimport pandas as pd\nfrom scipy import stats\nfrom statsmodels.stats.multitest import multipletests\n\ndef preprocess(intensities):\n    log2_data = np.log2(intensities.replace(0, np.nan))  # zeros -> NaN to avoid -inf\n    sample_medians = log2_data.median(axis=0)\n    return log2_data - sample_medians + sample_medians.median()\n\ndef differential_abundance(normalized, case_cols, ctrl_cols):\n    rows, untestable = [], []\n    for protein in normalized.index:\n        case, ctrl = normalized.loc[protein, case_cols].dropna(), normalized.loc[protein, ctrl_cols].dropna()\n        if len(case) >= 2 and len(ctrl) >= 2:\n            _, pval = stats.ttest_ind(case, ctrl, equal_var=False)  # Welch; scipy defaults to Student's True\n            rows.append({'protein': protein, 'log2fc': case.mean() - ctrl.mean(), 'pvalue': pval})\n        else:  # never drop these silently: proteins undetected in one group are often the largest real changes\n            untestable.append({'protein': protein, 'n_case': len(case), 'n_ctrl': len(ctrl)})\n    if not rows:\n        raise ValueError('No protein has >= 2 non-missing values in both groups; a two-sample test is not possible')\n    df = pd.DataFrame(rows)\n    df['padj'] = multipletests(df['pvalue'], method='fdr_bh')[1]  # default is Holm-Sidak; pass fdr_bh explicitly\n    return df, pd.DataFrame(untestable, columns=['protein', 'n_case', 'n_ctrl'])  # report the second table too\n```\n\n## Fold-Change Reporting\n\n**Goal:** Hand the right effect estimate to the right consumer.\n\n**Approach:** Report the RAW fold change (the best unbiased point estimate) for GSEA\u002Fpathway ranking and meta-analysis -- those need the full continuous distribution or FC+SE pairs. Apply shrinkage (ashr) only when recovering \"which proteins truly changed and by how much\"; it fits a mixture prior with a point mass at zero and shrinks uncertain effects smoothly toward zero. This is preferred over hard-thresholding (zeroing FCs at padj 0.05), which creates an arbitrary step function. No mature Python ashr equivalent exists.\n\n```r\nlibrary(ashr)\n\nok \u003C- !is.na(fit2$coefficients[, 1]) & !is.na(fit2$s2.post)  # ash() returns PosteriorMean 0 \u002F prior lfsr for NA rows\nse \u003C- sqrt(fit2$s2.post[ok]) * fit2$stdev.unscaled[ok, 1]\nshrunk \u003C- ash(fit2$coefficients[ok, 1], se, mixcompdist = 'normal')\nshrunken_fc \u003C- shrunk$result$PosteriorMean  # report alongside raw logFC, not as a replacement for GSEA\nlfsr \u003C- shrunk$result$lfsr\n```\n\n## Per-Method Failure Modes\n\n### Downshift \u002F any imputation feeding a variance-based test\n**Trigger:** Perseus\u002FMaxQuant downshift (or MinDet\u002FMinProb\u002FQRILC) fills NAs, then limma\u002Ft-test runs on the filled matrix.\n**Mechanism:** Imputed values come from one Gaussian set by the run-wide intensity distribution -> the fold change of an on\u002Foff protein is fixed by the downshift constant, and the imputed within-group SD (0.3 x run-wide SD) is unrelated to the real replicate SD, so the test is anticonservative or merely underpowered depending on the data.\n**Symptom:** Volcano \"anchor\u002Fwing\" -- on\u002Foff proteins on a streak whose logFC tracks average intensity; p-values and FDR without a guarantee.\n**Fix:** Model the missingness instead (proDA \u002F msqrob2 \u002F MSstats-AFT); report on\u002Foff proteins as \"undetected in group X\".\n\n### kNN imputation on left-censored data\n**Trigger:** kNN\u002Fmean imputation applied to label-free data with MNAR dropout.\n**Mechanism:** Mean-reverting -- pulls a truly-low (missing because low) value UP toward the mean.\n**Symptom:** Real down-regulation is compressed; down hits weakened or lost.\n**Fix:** Only valid under MCAR\u002FMAR; for MNAR model the dropout. Under uncertainty Lazar 2016 shows the milder MCAR error beats MNAR-imputers slamming random highs to the floor.\n\n### removeBatchEffect before testing\n**Trigger:** `removeBatchEffect()` output fed to `lmFit`.\n**Mechanism:** Subtracts the fitted batch component with no uncertainty propagation -> understated residual variance, inflated EB df; if batch is confounded with biology it deletes real signal.\n**Symptom:** Anticonservative p-values; lost true effects when cases\u002Fcontrols split by batch.\n**Fix:** Include batch as a covariate in the SAME model (`~ batch + condition`); use `removeBatchEffect` only for PCA\u002Fvisualization.\n\n### eBayes(trend=FALSE) on intensity data\n**Trigger:** Plain `eBayes` (trend off) on a log-intensity matrix.\n**Mechanism:** A single global prior over-shrinks high-abundance and under-shrinks low-abundance proteins.\n**Symptom:** Mis-calibrated FDR across the abundance range.\n**Fix:** `eBayes(trend = TRUE, robust = TRUE)`; escalate to DEqMS when quant depth varies.\n\n### Wrong DEqMS count column\n**Trigger:** Razor+unique counts vs MS2-level PSMs, or total-across-batches vs minimum-across-batches.\n**Mechanism:** The variance-vs-count prior is fit on the wrong precision proxy.\n**Symptom:** Mis-ranked proteins; the count moderation helps the wrong ones.\n**Fix:** PSM count for TMT, peptide count for label-free; minimum count across batches for multi-batch TMT.\n\n### proDA on MCAR missingness\n**Trigger:** proDA applied where dropout is random (e.g. a TMT channel lost at random), not detection-limited.\n**Mechanism:** The left-censored dropout model is mis-specified.\n**Symptom:** Biased estimates; the model fits a dropout curve that does not exist.\n**Fix:** proDA needs intensity-dependent missingness; for MCAR use limma\u002FDEqMS on the observed values.\n\n### Per-run median normalization under a feature-level test\n**Trigger:** `dataProcess(normalization = 'equalizeMedians')`, `normalize(method = 'center.median')` or any per-run median centring applied to the PEPTIDE table, then msqrob2\u002FMSstats.\n**Mechanism:** Each run's median is computed over the peptides detected in THAT run, and the detected set differs run to run. Equalizing those medians therefore transfers a detection-composition difference into a uniform between-condition offset in every protein. A protein-summary test would mostly shrug it off; a feature-level test has standard errors small enough to call it significant, so the offset becomes a proteome-wide false-positive band.\n**Symptom:** `median(log2FC)` over all tested proteins is far from 0 while the raw peptide table's is ~0; the extra calls are low-|logFC|, low-SE proteins; the true hits are recovered either way.\n**Fix:** Check `median(log2FC)` before reading the table. Normalize on features present in every run, or after summarization at the protein level (proteomics\u002Fquantification owns the mechanics). Do not \"fix\" it by re-centring p-values.\n\n### FC + significance double filter\n**Trigger:** `abs(logFC) > 1 & adj.P.Val \u003C 0.05` applied after the test.\n**Mechanism:** |logFC| is large for a true effect OR a large SE; filtering on both the FC and the p (both depend on SE) selects high-variance nulls (collider effect).\n**Symptom:** Realized FDR above nominal with no guarantee -- above 50% for top-ranked lists with many small effects (Ebrahimpoor & Goeman 2021), much lower when true effects are large.\n**Fix:** `treat()`+`topTreat()` or SAM s0, which sit inside the statistic before selection.\n\n## Quantitative Thresholds\n\n| Threshold | Source | Rationale |\n|-----------|--------|-----------|\n| n=3-5 replicates -> 2-4 residual df | -- | raw per-protein variance unusable; moderation is mandatory, not optional |\n| limma adds prior d0 (~4) df | Ritchie 2015 | a 4-replicate design tests on ~10 df vs 6; the borrowed df is the benefit |\n| downshift mean = mu - 1.8*sigma, SD = 0.3*sigma | Perseus default | sigma is the run-wide SD across proteins, not the replicate SD; the imputed FC is set by the constant, and 0.3*sigma can be wider or narrower than real replicate spread |\n| `trend=TRUE` effectively mandatory for label-free | Ritchie 2015 | a single global prior mis-calibrates FDR across abundance |\n| min-FC floor log2(1.2) (1.2-fold) via treat() | -- | example floor; common alternatives 1.5-fold (~0.58) or 2-fold (1.0); set by biology, tested against the moderated null |\n| BH adjusted p \u003C 0.05 | Benjamini-Hochberg | controls FDR over the WHOLE rejection set, not subsets carved out afterward |\n| DEqMS multi-batch TMT: minimum count across batches | Zhu 2020 | the bottleneck batch sets the realized precision |\n| realized FDR > 50% from FC+significance double filter | Ebrahimpoor & Goeman 2021 | top-100 at n=12 exceeded 50% FDR at nominal 5%; regime-specific (many small effects), not a general rate |\n| feature-level route: `abs(median(log2FC))` \u003C= 0.05 | measured | on one 4 v 4 peptide set the realized FDR tracked the offset monotonically: -0.005 -> 0.0%, -0.107 -> 7.0%, -0.184 -> 21.2%, -0.202 -> 28.6%, all at nominal 5%; the true-hit count did not change |\n| msqrob2 ridge needs > 2 mean-model parameters | msqrob2 1.14.1 | a two-group `~condition` mean model has 2, so `ridge = TRUE` errors; `ridge = FALSE` or `~ -1 + condition` |\n\n## Common Errors\n\n| Error \u002F symptom | Cause | Solution |\n|-----------------|-------|----------|\n| `results$FDR` is NULL | limma `topTable`\u002F`topTreat` have no `$FDR` column | use `adj.P.Val` (BH-adjusted p) |\n| `topTreat` row has no `B` | `topTreat` omits `B` (a `topTable` column) | read `logFC, AveExpr, t, P.Value, adj.P.Val` |\n| FDR mis-calibrated across abundance | `eBayes` with `trend=FALSE` on intensity data | `eBayes(fit, trend = TRUE, robust = TRUE)` |\n| min-FC test inflates FDR | `topTable(lfc=...)` or post-hoc volcano double filter | `treat(fit, lfc=log2(1.2), trend=TRUE, robust=TRUE)` then `topTreat()` |\n| treat() list moderated without the intensity trend | `treat()` re-estimates the prior with `trend=FALSE, robust=FALSE` by default | pass `trend = TRUE, robust = TRUE` to `treat()` |\n| `eBayes`: `prior.weights contain NA values` | rows with no valid value (MaxQuant all-zero LFQ rows) or too few per group | valid-value filter before `lmFit` |\n| `lmFit`: `Partial NA coefficients for N probe(s)`; `stopifnot(all(fit2$df.residual > 0))` fires | a paired\u002Fblocked design (donor, subject, batch) where a row's values sit in different blocks per condition; the per-condition valid-value filter does not catch this | `fit \u003C- fit[fit$df.residual > 0 & rowSums(is.na(fit$coefficients)) == 0, ]` after `lmFit` |\n| DEqMS warning `longer object length is not a multiple of shorter object length` | rows with NA sigma \u002F zero residual df reach `spectraCounteBayes` | filter before `lmFit`; require `fit2$df.residual > 0` |\n| proDA: `object 'conditionControl' not found` | intercept design with `reference_level`: coefficients are `Intercept`, `conditionTreatment` | `test_diff(fit, 'conditionTreatment')` |\n| `makeContrasts`: `object 'DrugB' not found` | design rename hard-coded to two groups | `colnames(design)[seq_len(nlevels(cond))] \u003C- levels(cond)` |\n| Python `ValueError: No protein has >= 2 non-missing values` | a group has one sample, or no protein is observed twice per group | not a two-sample test; do not run DA on n=1 |\n| anticonservative p after batch correction | `removeBatchEffect` output fed to `lmFit` | put batch in the design: `~ batch + condition` |\n| DEqMS columns missing | forgot `fit$count` or read limma columns | set `fit$count`, run `spectraCounteBayes`, read `sca.adj.pval` from `outputResult` |\n| Student's t instead of Welch | `scipy.stats.ttest_ind` defaults `equal_var=True` | pass `equal_var=False` |\n| p-values look like Holm-Sidak | `statsmodels` `multipletests` defaults to `'hs'` | pass `method='fdr_bh'` |\n| volcano \"anchor\u002Fwing\" streaks | downshift\u002Fimputation feeding the test | model dropout (proDA\u002Fmsqrob2\u002FMSstats-AFT); report on\u002Foff proteins as undetected |\n| hundreds of low-|logFC| calls from msqrob2\u002FMSstats and `median(log2FC)` far from 0 | per-run median normalization on the peptide table offsets the whole contrast | re-normalize on complete-case features or after summarization; see the centred-contrast check |\n| `readQFeatures`: `'colData' must contain a column called 'quantCols'` | QFeatures 1.16 requires the run names in `colData$quantCols` | `data.frame(quantCols = runs, condition = ..., row.names = runs)` |\n| msqrob2: `The mean model must have more than two parameters for ridge regression` | `ridge = TRUE` on a two-group design | `ridge = FALSE`, or drop the intercept (`~ -1 + condition`) |\n| msqrob2: `Variable sample is not found in coldata or rowdata` | `msqrobAggregate` formula names a term that is in neither | add `sample` to `colData`; `feature` must be a `rowData` column of the peptide assay |\n| `pe[['peptideLog']]$condition` is `NULL` | in QFeatures 1.16 `colData` is held on the QFeatures object, not on each assay | `colData(pe)$condition` (or `getWithColData(pe, i)` for a standalone assay) |\n| msqrob2 results have no `adj.P.Val`; some rows are all `NA` | `hypothesisTest` writes `logFC, se, df, t, pval, adjPval` into `rowData`, and returns `NA` for proteins it cannot fit | read `adjPval`; report the `is.na(adjPval)` rows separately, not as non-significant |\n| MSstats rows with infinite `log2FC` | `issue == 'oneConditionMissing'` | split them out and report as undetected; never as a ratio |\n| MaxQuant `evidence.txt`: the `Reverse`\u002F`Potential contaminant` filter drops every row | empty flag columns are read as logical `NA`, and `NA != '+'` is `NA` | filter with `!(ev$Reverse %in% '+')`, which is `FALSE` for `NA` |\n\n## References\n\n- Ritchie ME, Phipson B, Wu D, Hu Y, Law CW, Shi W, Smyth GK. 2015. limma powers differential expression analyses for RNA-sequencing and microarray studies. *Nucleic Acids Res* 43(7):e47.\n- Phipson B, Lee S, Majewski IJ, Alexander WS, Smyth GK. 2016. Robust hyperparameter estimation protects against hypervariable genes and improves power to detect differential expression. *Ann Appl Stat* 10(2):946-963.\n- Zhu Y, Orre LM, Zhou Tran Y, et al. 2020. DEqMS: a method for accurate variance estimation in differential protein expression analysis. *Mol Cell Proteomics* 19(6):1047-1057.\n- Ahlmann-Eltze C, Anders S. 2019. proDA: probabilistic dropout analysis for identifying differentially abundant proteins in label-free mass spectrometry. *bioRxiv* 661496 (preprint; cite `citation(\"proDA\")`, never a journal).\n- Choi M, Chang CY, Clough T, Broudy D, Killeen T, MacLean B, Vitek O. 2014. MSstats: an R package for statistical analysis of quantitative mass spectrometry-based proteomic experiments. *Bioinformatics* 30(17):2524-2526.\n- Goeminne LJE, Gevaert K, Clement L. 2016. Peptide-level robust ridge regression improves estimation, sensitivity, and specificity in data-dependent quantitative label-free shotgun proteomics. *Mol Cell Proteomics* 15(2):657-668.\n- Sticker A, Goeminne L, Martens L, Clement L. 2020. Robust summarization and inference in proteome-wide label-free quantification. *Mol Cell Proteomics* 19(7):1209-1219.\n- Lazar C, Gatto L, Ferro M, Bruley C, Burger T. 2016. Accounting for the multiple natures of missing values in label-free quantitative proteomics data sets to compare imputation strategies. *J Proteome Res* 15(4):1116-1125.\n- Stephens M. 2017. False discovery rates: a new deal. *Biostatistics* 18(2):275-294.\n- Ebrahimpoor M, Goeman JJ. 2021. Inflated false discovery rate due to volcano plots: problem and solutions. *Brief Bioinform* 22(5):bbab053.\n\n## Related Skills\n\n- quantification - peptide-to-protein summarization, normalization, and IRS that produce the matrix this skill tests\n- proteomics-qc - quality control and batch-effect assessment before testing\n- protein-inference - razor\u002Fshared-peptide ambiguity that drives which protein group gets the quantity\n- ptm-analysis - site-level differential testing for modified peptides\n- differential-expression\u002Fde-results - analogous empirical-Bayes interpretation for RNA-seq DE\n- data-visualization\u002Fvolcano-and-ma-plots - volcano and MA plots of the result table\n- pathway-analysis\u002Fgo-enrichment - functional enrichment of the significant protein hit list\n- machine-learning\u002Fbiomarker-discovery - building predictive panels from differential proteins\n- workflows\u002Fproteomics-pipeline - end-to-end pipeline that calls this skill as the testing stage\n",{"name":5,"tool_type":24,"description":7,"primary_tool":25},"mixed","limma","main","e0956dc9606356ab78468936b502e55a365282e2","specialists\u002Fmass-spec-proteomics-analyst\u002Fversions\u002F1.0.1\u002Fpackage\u002Fskills\u002Fbio-proteomics-differential-abundance",0,"https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists","https:\u002F\u002Fgithub.com\u002Fmrsonord2240","2026-10-12 03:19:04",{"source":8,"skillName":5,"commands":34,"prompt":44,"zipAvailable":45},[35,38,41],{"tool":36,"command":37},"npx","npx skills add mrsonord2240\u002Fopenscience-specialists --skill bio-proteomics-differential-abundance",{"tool":39,"command":40},"bunx","bunx skills add mrsonord2240\u002Fopenscience-specialists --skill bio-proteomics-differential-abundance",{"tool":42,"command":43},"pnpm","pnpm dlx skills add mrsonord2240\u002Fopenscience-specialists --skill bio-proteomics-differential-abundance","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Fmass-spec-proteomics-analyst\u002Fversions\u002F1.0.1\u002Fpackage\u002Fskills\u002Fbio-proteomics-differential-abundance\u002FSKILL.md",true,[47,53,59,63],{"path":48,"resourceType":49,"resourceTypeDesc":50,"sizeBytes":51,"sourceHtmlUrl":52},"examples\u002Fdifferential_abundance.py","SCRIPT","脚本",3090,"https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Fmass-spec-proteomics-analyst\u002Fversions\u002F1.0.1\u002Fpackage\u002Fskills\u002Fbio-proteomics-differential-abundance\u002Fexamples\u002Fdifferential_abundance.py",{"path":54,"resourceType":55,"resourceTypeDesc":56,"sizeBytes":57,"sourceHtmlUrl":58},"examples\u002Flimma_analysis.R","OTHER","其他",3238,"https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Fmass-spec-proteomics-analyst\u002Fversions\u002F1.0.1\u002Fpackage\u002Fskills\u002Fbio-proteomics-differential-abundance\u002Fexamples\u002Flimma_analysis.R",{"path":60,"resourceType":55,"resourceTypeDesc":56,"sizeBytes":61,"sourceHtmlUrl":62},"examples\u002Fmsqrob2_peptide_level.R",5641,"https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Fmass-spec-proteomics-analyst\u002Fversions\u002F1.0.1\u002Fpackage\u002Fskills\u002Fbio-proteomics-differential-abundance\u002Fexamples\u002Fmsqrob2_peptide_level.R",{"path":64,"resourceType":65,"resourceTypeDesc":66,"sizeBytes":67,"sourceHtmlUrl":68},"usage-guide.md","REFERENCE","参考文档",8487,"https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Fmass-spec-proteomics-analyst\u002Fversions\u002F1.0.1\u002Fpackage\u002Fskills\u002Fbio-proteomics-differential-abundance\u002Fusage-guide.md",[70,77,84,91,98,105],{"skillNo":71,"name":72,"nameZh":6,"description":73,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":74,"categories":75,"keywords":76,"publishedAt":19},"01M7CW36A6QAGWFE2C008M1XPG","bio-workflows-metabolomics-pipeline","Orchestrates the untargeted LC-MS metabolomics pipeline end-to-end (xcms 4.x feature extraction, QC\u002Fdrift\u002Fnormalization, confidence-stratified annotation, permutation-validated statistics, background-aware pathway mapping), naming what each stage decides and where it silently fails. Use when running a full LC-MS metabolomics study from raw mzML to enriched pathways and needing the honest handoffs between stages. Each stage defers to its component skill for parameters and traps; for stable-isotope flux (a separate branch, not this untargeted flow) see metabolomics\u002Fisotope-tracing.","https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Funtargeted-metabolomics-analyst\u002Fversions\u002F1.0.0\u002Fpackage\u002Fskills\u002Fbio-workflows-metabolomics-pipeline\u002FSKILL.md",[],[],{"skillNo":78,"name":79,"nameZh":6,"description":80,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":81,"categories":82,"keywords":83,"publishedAt":19},"01M7CW2Y18623NA2S1Y30FRQW1","bio-metabolomics-xcms-preprocessing","Programmatic untargeted LC-MS feature extraction in R with the modern xcms 4.x MsExperiment\u002FXcmsExperiment API, taking raw mzML to a feature table via CentWave peak detection, retention-time alignment, peak-density correspondence, gap-filling, CAMERA redundancy collapse, and built-in QC feature filtering. Use when converting centroided LC-MS runs into a features-by-samples matrix and deciding centWave\u002Fgrouping\u002Falignment parameters. For drift correction and QC\u002FCV filtering execution see metabolomics\u002Fnormalization-qc; for metabolite identification see metabolomics\u002Fmetabolite-annotation; for the MS-DIAL GUI alternative with MS2Dec deconvolution see metabolomics\u002Fmsdial-preprocessing; for downstream statistics see metabolomics\u002Fstatistical-analysis.","https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Funtargeted-metabolomics-analyst\u002Fversions\u002F1.0.0\u002Fpackage\u002Fskills\u002Fbio-metabolomics-xcms-preprocessing\u002FSKILL.md",[],[],{"skillNo":85,"name":86,"nameZh":6,"description":87,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":88,"categories":89,"keywords":90,"publishedAt":19},"01M7CW2M364HVWPNQEKE2WP4PP","bio-metabolomics-statistical-analysis","Decision-grade statistical analysis for metabolomics intensity tables. Covers transformation and scaling (Pareto vs unit-variance as a hidden hypothesis), unsupervised structure (PCA\u002FHCA for QC), permutation-validated PLS-DA\u002FOPLS-DA (R2 vs Q2, double CV, VIP as heuristic), univariate testing (Welch\u002FMann-Whitney\u002FANOVA\u002FLMM with covariate adjustment), and dependence-aware multiple testing. Use when testing which metabolites differ, building or validating a discriminant model, choosing a scaling, or correcting many correlated tests. For sample-wise normalization\u002Fdrift correction see metabolomics\u002Fnormalization-qc; for ML classifiers and selection-inside-CV leakage see machine-learning\u002Fbiomarker-discovery and machine-learning\u002Fmodel-validation; for pathway interpretation see metabolomics\u002Fpathway-mapping; for design\u002Fpower\u002Fmultiplicity regime see experimental-design\u002Fmultiple-testing.","https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Funtargeted-metabolomics-analyst\u002Fversions\u002F1.0.0\u002Fpackage\u002Fskills\u002Fbio-metabolomics-statistical-analysis\u002FSKILL.md",[],[],{"skillNo":92,"name":93,"nameZh":6,"description":94,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":95,"categories":96,"keywords":97,"publishedAt":19},"01M7CW2BCBF851WYE03Z8XPEDQ","bio-metabolomics-pathway-mapping","Maps metabolomics results to biological pathways via over-representation (ORA), metabolite-set enrichment (MSEA\u002FQEA), mummichog\u002FPSEA on raw m\u002Fz peaks, and network-diffusion enrichment (FELLA), with correct background-set construction and honest interpretive ceilings. Use when interpreting differential metabolites or an untargeted LC-MS feature table in pathway context, choosing ORA vs MSEA vs mummichog vs topology, or setting the reference\u002Fbackground set. For annotation confidence levels feeding ORA see metabolomics\u002Fmetabolite-annotation; for gene-set concepts see pathway-analysis\u002Fgo-enrichment and pathway-analysis\u002Fgsea; for joint gene+metabolite pathways see multi-omics-integration\u002Fmofa-integration.","https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Funtargeted-metabolomics-analyst\u002Fversions\u002F1.0.0\u002Fpackage\u002Fskills\u002Fbio-metabolomics-pathway-mapping\u002FSKILL.md",[],[],{"skillNo":99,"name":100,"nameZh":6,"description":101,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":102,"categories":103,"keywords":104,"publishedAt":19},"01M7CW21NSZMCPMF7D9CHJM718","bio-metabolomics-normalization-qc","Designs QC, corrects signal drift, removes batch effects, filters features, normalizes samples, and imputes missing values for untargeted LC-MS\u002FGC-MS metabolomics, framing each step as a measurement model that can create or erase biological signal. Use when processing a peak\u002Ffeature table before statistical analysis, choosing a drift-correction or sample-normalization method, deciding QC RSD vs D-ratio filtering, or handling left-censored missing values. The feature table is produced by metabolomics\u002Fxcms-preprocessing or metabolomics\u002Fmsdial-preprocessing; transformation\u002Fscaling for modeling defers to metabolomics\u002Fstatistical-analysis; cross-study design issues link to experimental-design\u002Fbatch-design.","https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Funtargeted-metabolomics-analyst\u002Fversions\u002F1.0.0\u002Fpackage\u002Fskills\u002Fbio-metabolomics-normalization-qc\u002FSKILL.md",[],[],{"skillNo":106,"name":107,"nameZh":6,"description":108,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":109,"categories":110,"keywords":111,"publishedAt":19},"01M7CW1SWVM0NYHVE6Q7MA3G7R","bio-metabolomics-msdial-preprocessing","Runs the MS-DIAL preprocessing workflow (peak picking, MS2Dec spectral deconvolution, alignment, gap-filling) and imports the alignment-result table into R or Python with honest filtering. Use when preprocessing LC-MS DDA\u002FDIA (SWATH) or GC-MS raw data with MS-DIAL, deciding MS-DIAL vs XCMS, configuring the MSDIALCUI console run, or parsing an MS-DIAL export into a clean feature matrix. For programmatic R peak detection and the feature-table-as-artifact framing see metabolomics\u002Fxcms-preprocessing; for lipid annotation mode see metabolomics\u002Flipidomics; for MSI-level confidence honesty see metabolomics\u002Fmetabolite-annotation; for drift correction and QC see metabolomics\u002Fnormalization-qc.","https:\u002F\u002Fgithub.com\u002Fmrsonord2240\u002Fopenscience-specialists\u002Fblob\u002Fmain\u002Fspecialists\u002Funtargeted-metabolomics-analyst\u002Fversions\u002F1.0.0\u002Fpackage\u002Fskills\u002Fbio-metabolomics-msdial-preprocessing\u002FSKILL.md",[],[]]