# Missing values in label-free proteomics: missing not at random, imputation and filtering

> Many missing intensities in label-free proteomics are low-abundance values, not random gaps. Filter proteins by valid values per group and test observed values, or use a method that models missingness; constant imputation changes the results. Python and R code.

Canonical: https://www.licklider.ai/guides/proteomics-missing-values/
Section: Guides › Standard curves, detection limits and omics
Author: Licklider · Published 2026-11-12 · Last reviewed 2026-11-12 (UTC)

> **Quick answer.** In label-free proteomics, many missing intensities belong to **low-abundance proteins near the detection limit** (missing not at random), though real data usually mix these with gaps that are missing at random, so they cannot all be treated as random holes. Decide in advance how to handle them: **filter proteins by valid values per group** (for example at least 3 of 5 in each group; about 60% of each group's samples) and test the observed values with a moderated test such as limma, or use a method that **models missingness** (such as proDA). Replacing missing values with a constant (each sample's minimum, or a down-shifted value) changes which proteins come out: in our simulation (with Welch's t-tests, and missingness depending only on intensity), imputing each sample's minimum found 44.9% of truly changed proteins and imputing a single down-shifted value found 51.2%, against 63.2% with filtering; when nothing had changed, they produced at least one false call in 9.6% and 3.9% of studies, against 1.6% with filtering. Treat proteins present in one group and absent in the other as a separate, descriptive list. If a later step such as clustering needs complete data, impute after testing.

**Terms used below.** *Label-free quantification (LFQ)*: protein amounts estimated from mass-spectrometry signal intensity without isotope labels. *Log2 intensity*: the logarithm of the signal; 1 unit is 2-fold. *Missing value*: a protein not quantified in a sample. *Missing not at random (MNAR)*: missing because the value is low; *missing at random (MAR)*: missing for reasons unrelated to the value. *Imputation*: filling missing values with estimates. *Down-shifted value*: a low value set below the sample's distribution; this page uses one constant per sample, whereas Perseus's "down-shifted normal" imputation draws random values from a narrow distribution placed below the sample's values. *Valid-value filter*: keeping proteins with enough non-missing values. *proDA, msqrob2*: R packages for differential abundance that handle missing values without imputation. *Moderated t-test (limma, empirical Bayes)*: a t-test that borrows variance information across proteins, more stable with few samples. *Benjamini–Hochberg (BH)*: a correction that controls the false discovery rate; *q* is the adjusted p-value. *False discovery proportion*: the share of called proteins that are not truly changed. *Power*: the chance of detecting an effect that is really there. *False positive*: a "significant" result when there is no real effect.

## Which analysis for your question

| Question | Analysis | Report |
| --- | --- | --- |
| Which proteins change between two independent groups? | Valid-value filter set in advance, then limma (or, with less power, a per-protein Welch's t-test) on observed log2 intensities, BH across proteins | Log2 fold change, q, number of proteins tested after filtering |
| Paired or repeated samples (same patient before and after) | limma with the patient in the design (or a paired test per protein) | As above, paired |
| Proteins with many missing values | A method that models missingness (proDA) or peptide-level models (msqrob2, [Sticker et al. 2020](https://doi.org/10.1074/mcp.RA119.001624)) | As above, with the method named |
| Present in one group, absent in the other | List separately as "detected only in …", with the number of samples detected | Counts of detection per group, not a p-value from imputed values |
| Imputation is required by a later step (for example clustering) | Impute after testing, or impute and show that results hold without it | The imputation method and its parameters |

Lazar and colleagues explain that in mass spectrometry, "chemical species whose abundances are close enough to the limit of detection of the instrument record a higher rate of missing values", which is why missing values are often treated as "Missing Not At Random (MNAR)" ([Lazar et al. 2016](https://doi.org/10.1021/acs.jproteome.5b00981)). Comparing imputation methods, Webb-Robertson and colleagues found that "no single imputation algorithm consistently outperformed the others" and noted "the clear increase in CV(RMSE) as a result of blanket imputation" with a single low value ([Webb-Robertson et al. 2015](https://doi.org/10.1021/pr501138h)). Kammers and colleagues showed that an empirical Bayes (moderated) test "leads to far more powerful and stable inference to detect significant changes in protein abundance compared to ordinary t-tests" ([Kammers et al. 2015](https://doi.org/10.1016/j.euprot.2015.02.002)). Filters differ: the DEP workflow keeps "only proteins with no missing values in at least one treatment group" ([Sticker et al. 2020](https://doi.org/10.1074/mcp.RA119.001624)), while this page uses at least 3 valid values in each group; the proDA preprint, which models "missing values in an intensity-dependent probabilistic manner", reports that "methods based on ad hoc imputation schemes can report excessive false positives" ([Ahlmann-Eltze & Anders 2019](https://doi.org/10.1101/661496)).

## Worked example: 1,000 proteins, 5 vs 5 samples

Constructed data: log2 intensities of 1,000 proteins in 5 control and 5 treated samples. By design, **100 proteins change 2-fold** (log2 fold change ±1) and **900 stay the same**; values are missing more often at low intensity (half of values at log2 intensity 23 are missing, more below), so 20.2% of all values and 472 proteins have at least one gap. Each analysis uses Welch's t-test per protein with BH correction; a moderated test (limma) would have more power. The random seed was the first one tried; the group size and noise were changed once, after first runs with 4 per group gave almost no detections. Stored code and data: [intensities.csv](/guides/proteomics-missing-values/intensities.csv), [truth.csv](/guides/proteomics-missing-values/truth.csv), [model.py](/guides/proteomics-missing-values/model.py), [make_data.py](/guides/proteomics-missing-values/make_data.py), [analysis.py](/guides/proteomics-missing-values/analysis.py), [analysis.R](/guides/proteomics-missing-values/analysis.R) and [the analysis output](/guides/proteomics-missing-values/analysis_output.txt); analysis.py and analysis.R print the same output.

| Handling of missing values | Proteins tested | Called (q < 0.05) | Unchanged among called | Changed proteins found (of 100) |
| --- | --- | --- | --- | --- |
| Impute each sample's minimum | 1,000 | 46 | 0 | 46 |
| Impute a down-shifted value (one constant per sample: mean − 1.8 SD) | 1,000 | 54 | 0 | 54 |
| **At least 3 valid values per group, no imputation** | **773** | **66** | **1** | **65** |

Filtering tested fewer proteins but found the most changed ones: imputed constants make the groups look more variable, because they mix real values with values far below them, and that costs power. In 1,000 simulated studies of this design ([simulation.py](/guides/proteomics-missing-values/simulation.py), [the simulation output](/guides/proteomics-missing-values/simulation_output.txt)):

| Scenario | Impute sample minimum | Impute down-shifted value | Filter, no imputation |
| --- | --- | --- | --- |
| 100 proteins changed: changed proteins found | 44.9% | 51.2% | **63.2%** |
| 100 proteins changed: mean false discovery proportion | 2.5% | 2.6% | 3.2% |
| No protein changed: studies with at least one false call | **9.6%** | 3.9% | 1.6% |

With real changes present, the mean false discovery proportion stayed below 5% for all three (the correction controls it on average, not in every study), and filtering found the most changed proteins. With no real changes, the minimum imputation made false calls in about twice as many studies as the 5% target. The filter cannot say anything about the 227 proteins it set aside; report those separately. In this simulation missingness depends only on intensity, the situation where filtering on observed values works best; random gaps in real data make every method a little less certain.

The code expects one row per protein with a protein column and one column of log2 intensity per sample, named control_… and treated_…, with missing values left empty (not 0); truth.csv exists only for the constructed example. Rename your columns to match, and run the code in the folder that holds the CSV files. Python needs NumPy, pandas and SciPy 1.11 or newer; the R code uses base R only. The page snippets work for any group sizes; the stored analysis.py and analysis.R assume 5 samples per group.

```python
# Condensed: analysis.py also runs the two imputations
import numpy as np, pandas as pd
from scipy import stats

d = pd.read_csv("intensities.csv")   # raw exports: replace 0 by NaN, take log2, subtract each sample's median first
ctl, trt = d.filter(like="control_").to_numpy(), d.filter(like="treated_").to_numpy()
keep = (np.sum(~np.isnan(ctl), 1) >= 3) & (np.sum(~np.isnan(trt), 1) >= 3)   # valid-value filter, set in advance
p = [stats.ttest_ind(trt[i][~np.isnan(trt[i])], ctl[i][~np.isnan(ctl[i])], equal_var=False).pvalue for i in np.where(keep)[0]]
q = stats.false_discovery_control(p)
print(keep.sum(), (q < 0.05).sum())                                          # 773 proteins tested, 66 called
```

```r
d <- read.csv("intensities.csv")
ctl <- as.matrix(d[, grep("^control_", names(d))]); trt <- as.matrix(d[, grep("^treated_", names(d))])
keep <- rowSums(!is.na(ctl)) >= 3 & rowSums(!is.na(trt)) >= 3                # valid-value filter, set in advance
p <- sapply(which(keep), function(i) t.test(trt[i, !is.na(trt[i, ])], ctl[i, !is.na(ctl[i, ])])$p.value)
c(tested = sum(keep), called = sum(p.adjust(p, "BH") < 0.05))                # 773, 66
# with limma (Bioconductor; not run here; lmFit accepts missing values):
# x <- cbind(ctl, trt); g <- factor(rep(c("control", "treated"), c(ncol(ctl), ncol(trt))))
# fit <- eBayes(lmFit(x[keep, ], model.matrix(~ g))); topTable(fit, coef = 2, number = Inf)   # log2FC, adj.P.Val
```

## Before you analyse

1. **Set the filter and the missing-value strategy in advance**, and report how many proteins each step removed; a common filter is at least 3 valid values in each group.
2. **Keep missing values as missing**, not 0, through the export from MaxQuant, DIA-NN or Spectronaut; a 0 on the log scale is a value far below anything measured.
3. **Normalise before filtering and testing** (for example subtract each sample's median log2 intensity), so that sample loading differences do not create missingness patterns; the constructed example is already normalised.
4. **Report proteins detected only in one group separately**, with how many samples detected them; a p-value from imputed values for these proteins depends mostly on the imputed number ([values below the detection limit](/guides/values-below-detection-limit/)).
5. **If you impute, show that the main results hold without imputation**, and state the method and its parameters.

## Check what an AI assistant ran

1. **The assistant replaced missing values with 0 or a constant and ran t-tests on all proteins.** Ask for a valid-value filter and tests on observed values, or a method that models missingness.
2. **The assistant reported p-values for proteins absent in one group.** Ask for these as a separate, descriptive list.
3. **The assistant ran unmoderated t-tests without a false discovery rate correction.** Ask for BH correction, and preferably a moderated test (limma), which has more power with 3 to 5 samples per group.
4. **The assistant did not say how many proteins were tested after filtering.** Ask for the number.

## Report it

> Protein intensities were log2-transformed and median-normalised. Proteins with at least [3] valid values in each group ([773] of [1,000]) were tested with [Welch's t-tests / limma] on observed values, without imputation; p-values were adjusted with the Benjamini–Hochberg procedure. Proteins detected only in one group were listed separately (R [4.3.3]).

> [66] proteins differed between groups at q < 0.05.

Replace the bracketed values with your own.

## References

- 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. *Journal of Proteome Research* 15:1116–1125. [doi:10.1021/acs.jproteome.5b00981](https://doi.org/10.1021/acs.jproteome.5b00981)
- Webb-Robertson BJM, Wiberg HK, Matzke MM, et al. (2015). Review, evaluation, and discussion of the challenges of missing value imputation for mass spectrometry-based label-free global proteomics. *Journal of Proteome Research* 14:1993–2001. [doi:10.1021/pr501138h](https://doi.org/10.1021/pr501138h)
- Kammers K, Cole RN, Tiengwe C, Ruczinski I (2015). Detecting significant changes in protein abundance. *EuPA Open Proteomics* 7:11–19. [doi:10.1016/j.euprot.2015.02.002](https://doi.org/10.1016/j.euprot.2015.02.002)
- Sticker A, Goeminne L, Martens L, Clement L (2020). Robust summarization and inference in proteome-wide label-free quantification. *Molecular & Cellular Proteomics* 19:1209–1219. [doi:10.1074/mcp.RA119.001624](https://doi.org/10.1074/mcp.RA119.001624)
- Ahlmann-Eltze C, Anders S (2019). proDA: probabilistic dropout analysis for identifying differentially abundant proteins in label-free mass spectrometry. *bioRxiv* (preprint). [doi:10.1101/661496](https://doi.org/10.1101/661496)

## Related

- [Values below the detection limit](https://www.licklider.ai/guides/values-below-detection-limit/)
- [RNA-seq DE results](https://www.licklider.ai/guides/rna-seq-differential-expression-results/)
- [Which correction to use](https://www.licklider.ai/guides/which-multiple-comparison-correction/)
- [Statistics on fold change and normalised data](https://www.licklider.ai/guides/statistics-on-fold-change/)

## How this page was made

- Written and published by Licklider. Recommendations cite peer-reviewed methods papers and one preprint listed under References; cited passages were checked against the source text.
- The worked example uses constructed data. Its numbers were produced by stored code (Python 3.13 with SciPy 1.18.1; R 4.3.3, base R), and the two languages print identical output; the simulation was run in Python.
- Separate review passes checked the statistics, the citations and readability before publication.
