Skip to content

Commit bedd227

Browse files
authored
Merge pull request #45 from bigbio/fix/sdrf-condition-mapping-and-directlfq-norm-warning
Fix features2proteins LFQ correctness: per-run intensity routing, SDRF condition mapping, DirectLFQ norm warning
2 parents 38130c6 + 82d8e80 commit bedd227

4 files changed

Lines changed: 213 additions & 8 deletions

File tree

python/mokume/io/feature.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,22 +215,47 @@ def _detect_qpx_format(self) -> None:
215215
self._has_is_decoy = "is_decoy" in cols
216216
self._has_anchor_protein = "anchor_protein" in cols
217217

218+
# New-QPX LFQ vs TMT discriminator. In LFQ the ``intensities.label`` names
219+
# the run each (possibly MBR-transferred) intensity belongs to, so it — not
220+
# the row's anchor ``run_file_name`` — is the per-run/per-sample key. In TMT
221+
# the label is a reporter channel and ``run_file_name`` is the run. Detect by
222+
# checking whether every distinct label is a member of the run_file_name
223+
# domain (true for LFQ, never for TMT channels).
224+
self._label_is_run = False
225+
if self._is_new_qpx:
226+
try:
227+
n_off = self.parquet_db.execute(
228+
"SELECT count(*) FROM ("
229+
" SELECT DISTINCT unnest.label AS lbl"
230+
" FROM parquet_db_raw, UNNEST(intensities) AS unnest"
231+
") WHERE lbl NOT IN"
232+
" (SELECT DISTINCT run_file_name FROM parquet_db_raw)"
233+
).fetchone()[0]
234+
self._label_is_run = n_off == 0
235+
except duckdb.Error as exc:
236+
logger.debug("label-is-run detection failed: %s", exc)
237+
218238
def _create_unnest_view(self) -> None:
219239
"""Create the long-format DuckDB view by unnesting intensities."""
220240
if self._is_new_qpx:
241+
# LFQ: the intensity's owning run is its label (keep run_file_name only
242+
# as provenance). TMT: label is a channel, run stays run_file_name.
243+
run_key = "unnest.label" if self._label_is_run else "run_file_name"
221244
unnest_sql = (
222-
"run_file_name as sample_accession,\n"
245+
f"{run_key} as sample_accession,\n"
223246
" unnest.label as channel,\n"
224247
" unnest.intensity"
225248
)
226-
sa_default = "run_file_name"
249+
sa_default = run_key
250+
run_expr = run_key
227251
else:
228252
unnest_sql = (
229253
"unnest.sample_accession,\n"
230254
" unnest.channel,\n"
231255
" unnest.intensity"
232256
)
233257
sa_default = "unnest.sample_accession"
258+
run_expr = self._run_col
234259

235260
charge_col, run_col = self._charge_col, self._run_col
236261
# Normalize pg_accessions: extract accession strings from struct if needed
@@ -265,7 +290,7 @@ def _create_unnest_view(self) -> None:
265290
unnest_sql,
266291
",",
267292
" ",
268-
run_col,
293+
run_expr,
269294
" as run,",
270295
" ",
271296
sa_default,

rust/crates/mokume-io/src/qpx.rs

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,42 @@ pub fn flatten_qpx_batch(batch: &RecordBatch) -> Result<Vec<QpxFeatureRecord>> {
140140
.transpose()?
141141
.flatten();
142142

143-
for entry in intensity_entries(intensities, row)? {
143+
let entries = intensity_entries(intensities, row)?;
144+
// In quantms.io LFQ the per-run intensities are labeled by run file (the
145+
// row's anchor run appears among them); in isobaric (TMT/iTRAQ) the labels
146+
// are reporter channels and never equal a run file. When the labels are
147+
// runs, the run/sample for each intensity is its own `label`, not the row's
148+
// anchor `run_file_name` -- otherwise every run collapses onto the anchor
149+
// sample and all other runs are dropped (see mokume-io qpx tests).
150+
let row_key = crate::sdrf::normalize_file_key(&run_file_name);
151+
let labels_are_runs = entries.iter().any(|entry| {
152+
entry
153+
.label
154+
.as_deref()
155+
.is_some_and(|label| crate::sdrf::normalize_file_key(label) == row_key)
156+
});
157+
for entry in entries {
144158
if entry.intensity.is_finite() {
159+
let (entry_run_file_name, entry_label) = if labels_are_runs {
160+
(
161+
entry.label.clone().unwrap_or_else(|| run_file_name.clone()),
162+
None,
163+
)
164+
} else {
165+
(run_file_name.clone(), entry.label)
166+
};
145167
records.push(QpxFeatureRecord {
146168
sequence: sequence.clone(),
147169
peptidoform: peptidoform.clone(),
148170
charge,
149-
run_file_name: run_file_name.clone(),
171+
run_file_name: entry_run_file_name,
150172
sample_accession: entry.sample_accession,
151173
protein_accessions: protein_accessions.clone(),
152174
anchor_protein: anchor_protein.clone(),
153175
unique,
154176
is_decoy,
155177
pg_global_qvalue,
156-
label: entry.label,
178+
label: entry_label,
157179
intensity: entry.intensity,
158180
});
159181
}
@@ -507,6 +529,50 @@ mod tests {
507529
Ok(())
508530
}
509531

532+
#[test]
533+
fn routes_lfq_run_labels_to_distinct_runs() -> Result<(), Box<dyn std::error::Error>> {
534+
// LFQ: the intensity labels are run files (the row's anchor run appears
535+
// among them), so each intensity must map to its own run/sample instead of
536+
// collapsing onto the anchor `run_file_name`.
537+
let intensities = lfq_run_intensities()?;
538+
let proteins = new_qpx_proteins()?;
539+
let schema = Arc::new(Schema::new(vec![
540+
Field::new("sequence", DataType::Utf8, false),
541+
Field::new("peptidoform", DataType::Utf8, false),
542+
Field::new("charge", DataType::Int16, false),
543+
Field::new("run_file_name", DataType::Utf8, false),
544+
Field::new("anchor_protein", DataType::Utf8, false),
545+
Field::new("unique", DataType::Boolean, false),
546+
Field::new("is_decoy", DataType::Boolean, false),
547+
Field::new("intensities", intensities.data_type().clone(), true),
548+
Field::new("pg_accessions", proteins.data_type().clone(), true),
549+
]));
550+
let batch = arrow::record_batch::RecordBatch::try_new(
551+
schema,
552+
vec![
553+
Arc::new(StringArray::from(vec!["PEPTIDEK"])) as ArrayRef,
554+
Arc::new(StringArray::from(vec!["PEPTIDEK"])) as ArrayRef,
555+
Arc::new(Int16Array::from(vec![2])) as ArrayRef,
556+
Arc::new(StringArray::from(vec!["Run_A_01.mzML"])) as ArrayRef,
557+
Arc::new(StringArray::from(vec!["sp|P12345|PROT_HUMAN"])) as ArrayRef,
558+
Arc::new(BooleanArray::from(vec![true])) as ArrayRef,
559+
Arc::new(BooleanArray::from(vec![false])) as ArrayRef,
560+
intensities,
561+
proteins,
562+
],
563+
)?;
564+
565+
let records = flatten_qpx_batch(&batch)?;
566+
567+
assert_eq!(records.len(), 3);
568+
let runs: Vec<_> = records.iter().map(|r| r.run_file_name.as_str()).collect();
569+
assert_eq!(runs, ["Run_A_01.mzML", "Run_B_01.mzML", "Run_B_02.mzML"]);
570+
// LFQ has no reporter channel; run-file labels must not leak into `label`.
571+
assert!(records.iter().all(|record| record.label.is_none()));
572+
assert_eq!(records[1].intensity, 20.0);
573+
Ok(())
574+
}
575+
510576
#[test]
511577
fn reads_integer_unique_and_decoy_flags() -> Result<(), Box<dyn std::error::Error>> {
512578
let intensities = new_qpx_intensities()?;
@@ -597,6 +663,33 @@ mod tests {
597663
Ok(Arc::new(builder.finish()) as ArrayRef)
598664
}
599665

666+
fn lfq_run_intensities() -> Result<ArrayRef, Box<dyn std::error::Error>> {
667+
// Labels are run files; the anchor run (`Run_A_01.mzML`) appears among them.
668+
let fields = Fields::from(vec![
669+
Field::new("label", DataType::Utf8, false),
670+
Field::new("intensity", DataType::Float32, false),
671+
]);
672+
let struct_builder = StructBuilder::new(
673+
fields,
674+
vec![
675+
Box::new(StringBuilder::new()),
676+
Box::new(Float32Builder::new()),
677+
],
678+
);
679+
let mut builder = ListBuilder::new(struct_builder);
680+
for (label, value) in [
681+
("Run_A_01.mzML", 10.0f32),
682+
("Run_B_01.mzML", 20.0),
683+
("Run_B_02.mzML", 30.0),
684+
] {
685+
append_string_field(builder.values(), 0, label)?;
686+
append_f32_field(builder.values(), 1, value)?;
687+
builder.values().append(true);
688+
}
689+
builder.append(true);
690+
Ok(Arc::new(builder.finish()) as ArrayRef)
691+
}
692+
600693
fn new_qpx_proteins() -> Result<ArrayRef, Box<dyn std::error::Error>> {
601694
let fields = Fields::from(vec![
602695
Field::new("accession", DataType::Utf8, false),

rust/crates/mokume-io/src/sdrf.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,10 +305,20 @@ fn push_key(keys: &mut Vec<String>, value: &str) {
305305
}
306306

307307
pub fn normalize_file_key(value: &str) -> String {
308-
basename(value)
308+
let key = basename(value)
309309
.trim()
310310
.trim_start_matches("file://")
311-
.to_ascii_lowercase()
311+
.to_ascii_lowercase();
312+
// Strip a trailing run extension so the SDRF `comment[data file]` key matches
313+
// the QPX `run_file_name`, which is stored extension-less. Without this the
314+
// per-feature SDRF lookup misses and the Condition column falls back to the
315+
// run filename. Mirrors `mokume-pipeline` de.rs::strip_run_extension.
316+
for ext in [".raw", ".mzml", ".d", ".wiff"] {
317+
if let Some(stem) = key.strip_suffix(ext) {
318+
return stem.to_owned();
319+
}
320+
}
321+
key
312322
}
313323

314324
pub fn normalize_label_key(value: &str) -> String {
@@ -452,4 +462,43 @@ mod tests {
452462
assert_eq!(other.pooled_sample.as_deref(), Some("not pooled"));
453463
Ok(())
454464
}
465+
466+
#[test]
467+
fn normalize_file_key_strips_run_extensions() {
468+
// The QPX `run_file_name` is stored extension-less while the SDRF
469+
// `comment[data file]` carries a run extension; both must normalize to
470+
// the same key so the per-feature lookup matches.
471+
for name in [
472+
"Run_Condition_A_01.raw",
473+
"Run_Condition_A_01.mzML",
474+
"Run_Condition_A_01.d",
475+
"Run_Condition_A_01.wiff",
476+
"/data/Run_Condition_A_01.raw",
477+
"Run_Condition_A_01",
478+
] {
479+
assert_eq!(
480+
normalize_file_key(name),
481+
"run_condition_a_01",
482+
"key for {name}"
483+
);
484+
}
485+
}
486+
487+
#[test]
488+
fn lookup_matches_extensionless_run_key() -> Result<()> {
489+
// Regression: SDRF data files carry `.raw`, but the QPX run key is
490+
// extension-less. Without extension stripping the lookup misses and the
491+
// Condition column falls back to the run filename.
492+
let input = concat!(
493+
"source name\tcomment[data file]\tfactor value[group]\n",
494+
"A1\tRun_Condition_A_01.raw\tA\n",
495+
"B1\tRun_Condition_B_01.raw\tB\n",
496+
);
497+
let table = SdrfTable::from_reader(input.as_bytes())?;
498+
let a = table
499+
.lookup("Run_Condition_A_01", None)
500+
.ok_or_else(|| invalid_input("missing extension-less lookup"))?;
501+
assert_eq!(a.condition.as_deref(), Some("A"));
502+
Ok(())
503+
}
455504
}

rust/crates/mokume-pipeline/src/lib.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4642,6 +4642,16 @@ pub fn run_features_to_proteins(config: &FeatureToProteinsConfig) -> Result<()>
46424642

46434643
validate_features_to_proteins(config)?;
46444644
validate_implemented_subset(config)?;
4645+
if let Some(method) = ignored_directlfq_sample_normalization(config) {
4646+
warn!(
4647+
sample_normalization = method,
4648+
quant_method = %config.quantification,
4649+
"`--sample-normalization {method}` has no effect on the DirectLFQ route \
4650+
(used by `directlfq` and, unless `maxlfq.force_builtin` is set, `maxlfq`): \
4651+
DirectLFQ performs its own internal sample normalization, so mokume does \
4652+
not apply another. The protein matrix is unchanged by this option."
4653+
);
4654+
}
46454655
configure_thread_pool(config.runtime.threads.or(config.directlfq.cores));
46464656

46474657
let sdrf = config
@@ -5283,6 +5293,34 @@ fn is_cell_based_linear_quant(method: QuantMethod) -> bool {
52835293
/// they center the summed-canonical-peptide matrix in log2 space, NOT the raw
52845294
/// per-feature intensities, so they belong on the cell path rather than the
52855295
/// ingest-time factor path.
5296+
/// Returns the requested `--sample-normalization` method name when it is a
5297+
/// dataset-level method that will be silently ignored because the effective
5298+
/// quantification route is DirectLFQ (which performs its own internal sample
5299+
/// normalization; see `Aggregation::applies_dataset_normalization`). Used to warn
5300+
/// the user rather than dropping the option silently.
5301+
fn ignored_directlfq_sample_normalization(config: &FeatureToProteinsConfig) -> Option<&str> {
5302+
let routes_to_directlfq = config.quantification == QuantMethod::DirectLfq
5303+
|| (config.quantification == QuantMethod::MaxLfq && !config.maxlfq.force_builtin);
5304+
if !routes_to_directlfq {
5305+
return None;
5306+
}
5307+
match parse_sample_normalization_method(&config.normalization.sample_method)
5308+
.ok()
5309+
.flatten()
5310+
{
5311+
Some(
5312+
SampleNormalizationMethod::Quantile
5313+
| SampleNormalizationMethod::Rlr
5314+
| SampleNormalizationMethod::Loess
5315+
| SampleNormalizationMethod::Hierarchical
5316+
| SampleNormalizationMethod::MedianCenter
5317+
| SampleNormalizationMethod::MeanCenter
5318+
| SampleNormalizationMethod::Tmm,
5319+
) => Some(config.normalization.sample_method.as_str()),
5320+
_ => None,
5321+
}
5322+
}
5323+
52865324
fn dataset_sample_normalization_method(
52875325
config: &FeatureToProteinsConfig,
52885326
) -> Result<Option<SampleNormalizationMethod>> {

0 commit comments

Comments
 (0)