1pub mod input;
4pub mod order;
5pub mod output;
6pub mod rng;
7pub mod stats;
8pub mod uno_adapter;
9use anyhow::{ensure, Context, Result};
10use serde::{Deserialize, Serialize};
11use stats::{mean, median};
12use std::collections::{BTreeMap, BTreeSet};
13
14#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct Config {
16 pub min_samples: usize,
17 pub min_length: usize,
18 pub normalization: u8,
19 pub fold_change: f64,
20 pub absolute_fold_change: bool,
21 pub qvalue: f64,
22 pub decoy_prefix: String,
23 pub seed: u32,
24}
25impl Default for Config {
26 fn default() -> Self {
27 Self {
28 min_samples: 0,
29 min_length: 7,
30 normalization: 2,
31 fold_change: 2.,
32 absolute_fold_change: false,
33 qvalue: 0.05,
34 decoy_prefix: "DECOY_".into(),
35 seed: 42,
36 }
37 }
38}
39#[derive(Clone, Debug)]
40pub struct InputPeptide {
41 pub key: String,
42 pub sequence: String,
43 pub proteins: Vec<String>,
44 pub values: Vec<Option<f64>>,
45}
46#[derive(Clone, Debug)]
47pub struct Dataset {
48 pub samples: Vec<String>,
49 pub n_s1: usize,
50 pub rows: Vec<InputPeptide>,
51}
52#[derive(Clone, Debug, Serialize)]
53pub struct Peptide {
54 pub peptide: String,
55 pub sequence: String,
56 pub protein: String,
57 pub decoy: bool,
58 pub intensities: Vec<f64>,
59 pub missing: usize,
60 pub observed_s1: usize,
61 pub observed_s2: usize,
62 pub intensity: f64,
63 pub bin: usize,
64 pub raw_fc: f64,
65 pub fc: f64,
66 pub corrected_fc: f64,
67 pub pvalue: f64,
68 pub running_fc: f64,
69 pub better: usize,
70 pub significant: bool,
71 pub up: bool,
72 pub down: bool,
73}
74#[derive(Clone, Debug, Serialize)]
75pub struct Protein {
76 pub name: String,
77 pub group: usize,
78 pub n_peptides: usize,
79 pub n_changed: usize,
80 pub score: f64,
81 pub pvalue: f64,
82 pub fc: f64,
83 pub raw_fc: f64,
84 pub all_fc: f64,
85 pub all_raw_fc: f64,
86 pub fc_pass: bool,
87 pub fdr_pass: Option<bool>,
88 pub bh_pass: bool,
89}
90#[derive(Clone, Debug, Serialize)]
91pub struct Analysis {
92 pub config: Config,
93 pub samples: Vec<String>,
94 pub n_s1: usize,
95 pub normalization_factors: Vec<f64>,
96 pub calibration_mean: f64,
97 pub calibration_std: f64,
98 pub fold_change_threshold: f64,
99 pub decoy_probability: f64,
100 pub n_decoy_sequences: usize,
101 pub input_rows: usize,
102 pub eligible_precursors: usize,
103 pub peptides: Vec<Peptide>,
104 pub proteins: Vec<Protein>,
105}
106fn observed(v: &[Option<f64>]) -> Vec<f64> {
107 v.iter().flatten().copied().collect()
108}
109fn opt_mean(v: &[Option<f64>]) -> f64 {
110 mean(&observed(v))
111}
112fn bounds(v: &[f64]) -> (f64, f64) {
113 v.iter()
114 .filter(|v| v.is_finite())
115 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &x| {
116 (lo.min(x), hi.max(x))
117 })
118}
119fn fill_absent(fc: &mut [f64], counts: &[(usize, usize)]) -> Result<()> {
120 let (lo, hi) = bounds(fc);
121 ensure!(
122 lo.is_finite() && hi.is_finite(),
123 "no peptides observed in both conditions"
124 );
125 for (f, &(a, b)) in fc.iter_mut().zip(counts) {
126 if a == 0 {
127 *f = hi;
128 } else if b == 0 {
129 *f = lo;
130 }
131 }
132 Ok(())
133}
134
135pub fn analyze(data: &Dataset, cfg: &Config) -> Result<Analysis> {
136 ensure!(
137 data.n_s1 <= data.samples.len(),
138 "condition size exceeds sample count"
139 );
140 ensure!(
141 data.n_s1 >= 2 && data.samples.len() - data.n_s1 >= 2,
142 "at least two replicates per condition are required"
143 );
144 ensure!(
145 data.samples.iter().collect::<BTreeSet<_>>().len() == data.samples.len(),
146 "duplicate sample names"
147 );
148 ensure!(
149 cfg.normalization <= 2 && cfg.qvalue.is_finite() && cfg.qvalue > 0. && cfg.qvalue <= 1.,
150 "invalid normalization or qvalue"
151 );
152 ensure!(
153 cfg.fold_change.is_finite() && cfg.fold_change >= 0. && !cfg.decoy_prefix.is_empty(),
154 "invalid fold change or decoy prefix"
155 );
156 let n = data.samples.len();
157 let required = if cfg.min_samples == 0 {
158 n / 2
159 } else {
160 cfg.min_samples
161 };
162 ensure!(
163 required > 0 && required <= n,
164 "min_samples exceeds sample count"
165 );
166 let mut seen = BTreeSet::new();
167 for row in &data.rows {
168 ensure!(seen.insert(&row.key), "duplicate precursor key {}", row.key);
169 ensure!(
170 row.values.len() == n && !row.proteins.is_empty(),
171 "invalid row shape: {}",
172 row.key
173 );
174 ensure!(
175 row.values
176 .iter()
177 .flatten()
178 .all(|v| v.is_finite() && *v > 0.),
179 "intensities must be positive and finite; use missing for absent signal: {}",
180 row.key
181 );
182 }
183 let rows: Vec<_> = data
184 .rows
185 .iter()
186 .filter(|r| r.values.iter().flatten().count() >= required)
187 .collect();
188 ensure!(
189 rows.len() >= 6,
190 "at least six eligible precursor rows needed for quintiles/calibration"
191 );
192 let counts: Vec<_> = rows
193 .iter()
194 .map(|r| {
195 (
196 r.values[..data.n_s1].iter().flatten().count(),
197 r.values[data.n_s1..].iter().flatten().count(),
198 )
199 })
200 .collect();
201 let mut raw_fc: Vec<_> = rows
202 .iter()
203 .map(|r| (opt_mean(&r.values[data.n_s1..]) / opt_mean(&r.values[..data.n_s1])).log2())
204 .collect();
205 fill_absent(&mut raw_fc, &counts)?;
206 let factors: Vec<f64> = (0..n)
207 .map(|col| {
208 let mut vals: Vec<_> = rows.iter().filter_map(|r| r.values[col]).collect();
209 match cfg.normalization {
210 0 => 1.,
211 1 => median(&vals),
212 _ => {
213 vals.sort_by(|a, b| b.total_cmp(a));
214 vals.iter().take(1000).sum()
215 }
216 }
217 })
218 .collect();
219 ensure!(
220 factors.iter().all(|x| x.is_finite() && *x > 0.),
221 "a sample has no valid normalization factor"
222 );
223 let normalized: Vec<Vec<Option<f64>>> = rows
224 .iter()
225 .map(|r| {
226 r.values
227 .iter()
228 .zip(&factors)
229 .map(|(v, f)| v.map(|v| v / f))
230 .collect()
231 })
232 .collect();
233 let minima: Vec<_> = (0..n)
234 .map(|i| {
235 normalized
236 .iter()
237 .filter_map(|r| r[i])
238 .fold(f64::INFINITY, f64::min)
239 })
240 .collect();
241 ensure!(
242 minima.iter().all(|x| x.is_finite() && *x > 0.),
243 "a sample has no observed signal after filtering"
244 );
245 let means: Vec<_> = normalized
246 .iter()
247 .map(|r| (opt_mean(&r[..data.n_s1]), opt_mean(&r[data.n_s1..])))
248 .collect();
249 let intensity: Vec<_> = means
250 .iter()
251 .map(|&(a, b)| {
252 if a.is_nan() {
253 b
254 } else if b.is_nan() {
255 a
256 } else {
257 a.max(b)
258 }
259 })
260 .collect();
261 let edges: Vec<_> = (0..=5)
262 .map(|i| stats::quantile(&intensity, i as f64 / 5.))
263 .collect();
264 ensure!(
265 edges.windows(2).all(|w| w[0] < w[1]),
266 "nonunique intensity quintile edges; upstream qcut is undefined for this panel"
267 );
268 let mut fc: Vec<_> = means.iter().map(|&(a, b)| (b / a).log2()).collect();
269 let (lo, hi) = bounds(&fc);
270 let calib: Vec<_> = fc
271 .iter()
272 .filter(|x| x.is_finite() && **x >= lo / 2. && **x <= hi / 2.)
273 .copied()
274 .collect();
275 let (center, sigma) = stats::calibrate(&calib).context("fold-change calibration")?;
276 fill_absent(&mut fc, &counts)?;
277 let threshold = cfg.fold_change * if cfg.absolute_fold_change { 1. } else { sigma };
278 let mut peptides = Vec::new();
279 for (i, row) in rows.iter().enumerate() {
280 let norm = &normalized[i];
281 let completed: Vec<_> = norm
282 .iter()
283 .enumerate()
284 .map(|(c, v)| v.unwrap_or(minima[c]))
285 .collect();
286 let log_a: Vec<_> = observed(&norm[..data.n_s1])
287 .iter()
288 .map(|v| v.log10())
289 .collect();
290 let log_b: Vec<_> = observed(&norm[data.n_s1..])
291 .iter()
292 .map(|v| v.log10())
293 .collect();
294 let mut p = stats::ttest(&log_a, &log_b);
295 if !p.is_finite() {
296 p = stats::ttest(
297 &completed[..data.n_s1]
298 .iter()
299 .map(|x| x.log10())
300 .collect::<Vec<_>>(),
301 &completed[data.n_s1..]
302 .iter()
303 .map(|x| x.log10())
304 .collect::<Vec<_>>(),
305 );
306 }
307 if !p.is_finite() {
308 p = 1.;
309 }
310 let bin = edges[1..].partition_point(|x| *x < intensity[i]).min(4);
311 let decoy = row
312 .proteins
313 .iter()
314 .all(|p| p.starts_with(&cfg.decoy_prefix));
315 for protein in &row.proteins {
316 peptides.push(Peptide {
317 peptide: row.key.clone(),
318 sequence: row.sequence.clone(),
319 protein: protein.clone(),
320 decoy,
321 intensities: completed.clone(),
322 missing: n - counts[i].0 - counts[i].1,
323 observed_s1: counts[i].0,
324 observed_s2: counts[i].1,
325 intensity: intensity[i],
326 bin,
327 raw_fc: raw_fc[i],
328 fc: fc[i],
329 corrected_fc: fc[i] - center,
330 pvalue: p,
331 running_fc: 0.,
332 better: 0,
333 significant: false,
334 up: false,
335 down: false,
336 });
337 }
338 }
339 peptides.sort_by(|a, b| {
340 a.missing
341 .cmp(&b.missing)
342 .then(b.intensity.total_cmp(&a.intensity))
343 });
344 let mut seen = BTreeSet::new();
345 peptides.retain(|p| seen.insert((p.sequence.clone(), p.protein.clone())));
346 let sorted = order::argsort(
347 &peptides
348 .iter()
349 .map(|p| p.corrected_fc.abs())
350 .collect::<Vec<_>>(),
351 false,
352 );
353 peptides = sorted.into_iter().map(|i| peptides[i].clone()).collect();
354 let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
355 for (i, p) in peptides.iter().enumerate() {
356 groups.entry(p.protein.clone()).or_default().push(i);
357 }
358 for indices in groups.values() {
359 let mut sum = 0.;
360 for (k, &i) in indices.iter().enumerate() {
361 sum += peptides[i].corrected_fc;
362 peptides[i].running_fc = sum / (k + 1) as f64;
363 }
364 }
365 let mut pools = vec![Vec::new(); 5];
366 for p in &peptides {
367 if p.decoy {
368 pools[p.bin].push((p.corrected_fc, p.pvalue));
369 }
370 }
371 for (bin, pool) in pools.iter().enumerate() {
372 ensure!(
373 !pool.is_empty(),
374 "no decoy observations in intensity quintile {bin}; cannot resample null"
375 );
376 }
377 let mut rng = rng::PythonRandom::new(cfg.seed);
378 for indices in groups.values() {
379 for _ in 0..100 {
380 let mut sum = 0.;
381 for (k, &i) in indices.iter().enumerate() {
382 let p = &mut peptides[i];
383 let pair = pools[p.bin][rng.choice(pools[p.bin].len())];
384 sum += pair.0;
385 if p.running_fc.abs() >= (sum / (k + 1) as f64).abs() && p.pvalue <= pair.1 {
386 p.better += 1;
387 }
388 }
389 }
390 for &i in indices {
391 let p = &mut peptides[i];
392 p.significant = p.better >= 58;
393 p.up = p.significant && p.corrected_fc > 0.;
394 p.down = p.significant && p.corrected_fc < 0.;
395 }
396 }
397 let exported = peptides.clone();
399 for indices in groups.values() {
400 let up = indices.iter().filter(|&&i| peptides[i].up).count();
401 let down = indices.iter().filter(|&&i| peptides[i].down).count();
402 if down > up {
403 for &i in indices {
404 peptides[i].up = peptides[i].down;
405 }
406 }
407 }
408 let mut by_support: Vec<_> = (0..peptides.len()).collect();
409 by_support.sort_by(|&a, &b| {
410 peptides[a]
411 .missing
412 .cmp(&peptides[b].missing)
413 .then(peptides[b].intensity.total_cmp(&peptides[a].intensity))
414 });
415 let mut decoy_seen = BTreeSet::new();
416 let mut decoy_changed = 0usize;
417 for &i in &by_support {
418 let p = &peptides[i];
419 if p.decoy && decoy_seen.insert(&p.sequence) {
420 decoy_changed += usize::from(p.up);
421 }
422 }
423 ensure!(
424 !decoy_seen.is_empty(),
425 "no decoy sequences for protein calibration"
426 );
427 let p_up = decoy_changed as f64 / decoy_seen.len() as f64;
428 let mut signatures: BTreeMap<String, usize> = BTreeMap::new();
429 let mut proteins = Vec::new();
430 for (name, indices) in &groups {
431 let mut seqs: Vec<_> = indices
432 .iter()
433 .map(|&i| peptides[i].sequence.as_str())
434 .collect();
435 seqs.sort();
436 let signature = seqs.concat();
437 let next = signatures.len() + 1;
438 let group = *signatures.entry(signature).or_insert(next);
439 if name.starts_with(&cfg.decoy_prefix) {
440 continue;
441 }
442 let selected: Vec<_> = indices
443 .iter()
444 .copied()
445 .filter(|&i| peptides[i].up)
446 .collect();
447 let all_fc = median(
448 &indices
449 .iter()
450 .map(|&i| peptides[i].corrected_fc)
451 .collect::<Vec<_>>(),
452 );
453 let all_raw_fc = median(
454 &indices
455 .iter()
456 .map(|&i| peptides[i].raw_fc)
457 .collect::<Vec<_>>(),
458 );
459 let fc = if selected.is_empty() {
460 all_fc
461 } else {
462 median(
463 &selected
464 .iter()
465 .map(|&i| peptides[i].corrected_fc)
466 .collect::<Vec<_>>(),
467 )
468 };
469 let raw_fc = if selected.is_empty() {
470 all_raw_fc
471 } else {
472 median(
473 &selected
474 .iter()
475 .map(|&i| peptides[i].raw_fc)
476 .collect::<Vec<_>>(),
477 )
478 };
479 let score = stats::binomial_score(selected.len() as u64, indices.len() as u64, p_up);
480 proteins.push(Protein {
481 name: name.clone(),
482 group,
483 n_peptides: indices.len(),
484 n_changed: selected.len(),
485 score,
486 pvalue: 10f64.powf(-score),
487 fc,
488 raw_fc,
489 all_fc,
490 all_raw_fc,
491 fc_pass: fc.abs() >= threshold,
492 fdr_pass: None,
493 bh_pass: false,
494 });
495 }
496 let sorted = order::argsort(&proteins.iter().map(|p| p.score).collect::<Vec<_>>(), true);
497 proteins = sorted.into_iter().map(|i| proteins[i].clone()).collect();
498 let n_groups = proteins
499 .iter()
500 .filter(|p| p.n_peptides >= 3 && p.fc_pass)
501 .map(|p| p.group)
502 .collect::<BTreeSet<_>>()
503 .len();
504 let mut ranked = BTreeSet::new();
505 for p in &mut proteins {
506 if p.n_peptides >= 3 && p.fc_pass {
507 ranked.insert(p.group);
508 let bh_threshold = -(ranked.len() as f64 * cfg.qvalue / n_groups as f64).log10();
509 p.bh_pass = p.score >= bh_threshold;
510 p.fdr_pass = Some(p.score >= -cfg.qvalue.log10());
511 }
512 }
513 Ok(Analysis {
514 config: cfg.clone(),
515 samples: data.samples.clone(),
516 n_s1: data.n_s1,
517 normalization_factors: factors,
518 calibration_mean: center,
519 calibration_std: sigma,
520 fold_change_threshold: threshold,
521 decoy_probability: p_up,
522 n_decoy_sequences: decoy_seen.len(),
523 input_rows: data.rows.len(),
524 eligible_precursors: rows.len(),
525 peptides: exported,
526 proteins,
527 })
528}