Skip to main content

data_beans/aux/
cell_annotations.rs

1use rustc_hash::FxHashMap as HashMap;
2
3use anyhow::{bail, Result};
4use log::{info, warn};
5use nalgebra::DMatrix;
6
7use legume_numeric::matrix::common_io::{read_lines, unquote_field};
8use legume_numeric::matrix::traits::IoOps;
9
10/// Parsed cell annotations: maps each cell to an individual (and optionally a cell type).
11#[derive(Debug, Clone)]
12pub struct CellAnnotations {
13    /// cell_id -> individual_idx
14    pub cell_to_individual: HashMap<Box<str>, usize>,
15    /// Ordered individual IDs (index corresponds to individual_idx)
16    pub individual_ids: Vec<Box<str>>,
17}
18
19impl CellAnnotations {
20    /// Convert to a flat vector aligned to the given column names.
21    ///
22    /// Each entry is the individual ID for the corresponding column,
23    /// or `missing` for unmatched cells.
24    pub fn to_column_aligned_vec(&self, column_names: &[Box<str>], missing: &str) -> Vec<Box<str>> {
25        column_names
26            .iter()
27            .map(|name| {
28                self.cell_to_individual
29                    .get(name)
30                    .map(|&idx| self.individual_ids[idx].clone())
31                    .unwrap_or_else(|| Box::from(missing))
32            })
33            .collect()
34    }
35}
36
37/// Cell-type membership matrix with cell type names.
38///
39/// Note: this is different from `legume_numeric::matrix::membership::Membership`
40/// which is a key→group string map. This holds a dense probability matrix.
41pub struct CellTypeMembership {
42    /// Membership matrix: cells × cell_types (aligned to SC backend column order)
43    pub matrix: DMatrix<f32>,
44    /// Ordered cell type names (columns of the membership matrix)
45    pub cell_type_names: Vec<Box<str>>,
46}
47
48/// The data rows of an annotation table (TSV, CSV, or whitespace-separated,
49/// plain or gzipped), unsplit, and the delimiter of their fields (`None`:
50/// runs of whitespace); see [`id_and_label`].
51///
52/// The header line names the delimiter for the whole file: tab when it holds
53/// a tab, else comma when it holds a comma; a header with neither leaves it
54/// to the rows, tab when one holds a tab, else runs of whitespace. A tab-
55/// or comma-separated field therefore keeps its spaces (`CD4 T cell`), and is
56/// trimmed and unquoted, while a comma inside a label of a
57/// whitespace-separated table (`B,naive`) is part of the label. Comment lines
58/// (`#`, `%`) and blank lines are skipped; the first remaining line is the
59/// header and is dropped.
60fn read_annotation_rows(path: &str) -> Result<(Vec<Box<str>>, Option<char>)> {
61    let mut lines: Vec<Box<str>> = read_lines(path)?
62        .into_iter()
63        .filter(|line| !line.trim().is_empty() && !line.starts_with(['#', '%']))
64        .collect();
65    // A header of one column name holds no delimiter; tabs in the rows
66    // under it then tell (a comma there may be part of a label).
67    let delim = lines
68        .first()
69        .and_then(|header| ['\t', ','].into_iter().find(|&d| header.contains(d)))
70        .or_else(|| {
71            lines
72                .iter()
73                .skip(1)
74                .any(|line| line.contains('\t'))
75                .then_some('\t')
76        });
77    if !lines.is_empty() {
78        lines.remove(0);
79    }
80    Ok((lines, delim))
81}
82
83/// The cell id (field 0) and the label (field `label_idx`, 1 or later) of
84/// the row `line`, whose fields `delim` separates; `None` for a row too short
85/// to hold the label.
86fn id_and_label(line: &str, delim: Option<char>, label_idx: usize) -> Option<(&str, &str)> {
87    debug_assert!(label_idx > 0, "field 0 is the cell id");
88    match delim {
89        Some(d) => {
90            let mut fields = line.split(d).map(unquote_field);
91            Some((fields.next()?, fields.nth(label_idx - 1)?))
92        }
93        None => {
94            let mut fields = line.split_whitespace();
95            Some((fields.next()?, fields.nth(label_idx - 1)?))
96        }
97    }
98}
99
100/// Each row's cell id and its label in field `label_idx` (1 or later), in
101/// file order.
102/// A row too short to hold the label is skipped; so is a row whose cell id or
103/// label is empty. Both are counted in a warning, and a file that yields no
104/// row at all is an error: its rows do not follow the header's delimiter, or
105/// it has no such column.
106fn read_cell_labels(path: &str, label_idx: usize) -> Result<Vec<(Box<str>, Box<str>)>> {
107    let mut n_short = 0usize;
108    let mut n_empty = 0usize;
109    let mut out = Vec::new();
110    let (rows, delim) = read_annotation_rows(path)?;
111    for row in &rows {
112        let Some((cell_id, label)) = id_and_label(row, delim, label_idx) else {
113            n_short += 1;
114            continue;
115        };
116        if cell_id.is_empty() || label.is_empty() {
117            n_empty += 1;
118            continue;
119        }
120        out.push((cell_id.into(), label.into()));
121    }
122    let n_fields = label_idx + 1;
123    if out.is_empty() {
124        bail!(
125            "{path}: no row holds a cell id and a label in field {n_fields} \
126             ({n_short} rows with fewer than {n_fields} fields, {n_empty} rows \
127             with an empty cell id or label); the header line sets the delimiter"
128        );
129    }
130    if n_short > 0 || n_empty > 0 {
131        warn!(
132            "{path}: skipped {n_short} rows with fewer than {n_fields} fields \
133             and {n_empty} rows with an empty cell id or label"
134        );
135    }
136    Ok(out)
137}
138
139/// Read cell annotations from a delimited file (TSV, CSV, or space-separated).
140///
141/// Supports gzip-compressed files (.gz). The file is split on one delimiter
142/// (tab, else comma, else whitespace; see [`read_annotation_rows`]), so a
143/// field of a TSV or CSV may hold spaces.
144/// First line is treated as a header and skipped.
145///
146/// Requires at least 2 columns: `cell_id individual_id`.
147/// A 3rd column (`cell_type`) is accepted but ignored here;
148/// use [`build_onehot_membership`] to convert it into a membership matrix.
149pub fn read_cell_annotations(path: &str) -> Result<CellAnnotations> {
150    info!("Reading cell annotations from {}", path);
151
152    let mut individual_to_idx: HashMap<Box<str>, usize> = Default::default();
153    let mut individual_ids: Vec<Box<str>> = Vec::new();
154    let mut cell_to_individual: HashMap<Box<str>, usize> = Default::default();
155
156    for (cell_id, ind_id) in read_cell_labels(path, 1)? {
157        let ind_idx = *individual_to_idx.entry(ind_id.clone()).or_insert_with(|| {
158            let idx = individual_ids.len();
159            individual_ids.push(ind_id);
160            idx
161        });
162
163        cell_to_individual.insert(cell_id, ind_idx);
164    }
165
166    info!(
167        "Loaded {} cell annotations: {} individuals",
168        cell_to_individual.len(),
169        individual_ids.len(),
170    );
171
172    Ok(CellAnnotations {
173        cell_to_individual,
174        individual_ids,
175    })
176}
177
178/// Infer cell annotations from cell names by splitting on `@`.
179///
180/// Cell names like `ACGT@IND_A` map to individual `IND_A`.
181/// Names without `@` are assigned to a single individual `"all"`.
182pub fn infer_cell_annotations(column_names: &[Box<str>]) -> CellAnnotations {
183    info!("Inferring individuals from cell names (barcode@indiv)");
184
185    let mut individual_to_idx: HashMap<Box<str>, usize> = Default::default();
186    let mut individual_ids: Vec<Box<str>> = Vec::new();
187    let mut cell_to_individual: HashMap<Box<str>, usize> = Default::default();
188
189    for cell_name in column_names {
190        let indiv: Box<str> = if let Some(pos) = cell_name.rfind('@') {
191            Box::from(&cell_name[pos + 1..])
192        } else {
193            Box::from("all")
194        };
195        let idx = *individual_to_idx.entry(indiv.clone()).or_insert_with(|| {
196            let i = individual_ids.len();
197            individual_ids.push(indiv);
198            i
199        });
200        cell_to_individual.insert(cell_name.clone(), idx);
201    }
202
203    info!(
204        "Inferred {} individuals from cell names",
205        individual_ids.len()
206    );
207
208    CellAnnotations {
209        cell_to_individual,
210        individual_ids,
211    }
212}
213
214/// Build a one-hot membership matrix from hard cell-type annotations.
215///
216/// Reads a delimited file with 3+ columns: `cell_id individual_id cell_type`,
217/// split as [`read_cell_annotations`] splits it.
218/// The resulting matrix has one-hot rows aligned to the SC backend `column_names`.
219pub fn build_onehot_membership(
220    path: &str,
221    column_names: &[Box<str>],
222) -> Result<CellTypeMembership> {
223    info!("Building one-hot membership from {}", path);
224
225    let mut celltype_to_idx: HashMap<Box<str>, usize> = Default::default();
226    let mut cell_type_names: Vec<Box<str>> = Vec::new();
227    let mut cell_to_ct: HashMap<Box<str>, usize> = Default::default();
228
229    for (cell_id, ct_name) in read_cell_labels(path, 2)? {
230        let ct_idx = *celltype_to_idx.entry(ct_name.clone()).or_insert_with(|| {
231            let idx = cell_type_names.len();
232            cell_type_names.push(ct_name);
233            idx
234        });
235
236        cell_to_ct.insert(cell_id, ct_idx);
237    }
238
239    let n_cells = column_names.len();
240    let n_ct = cell_type_names.len();
241    let mut matrix = DMatrix::<f32>::zeros(n_cells, n_ct);
242    let mut matched = 0usize;
243
244    for (cell_idx, cell_name) in column_names.iter().enumerate() {
245        if let Some(&ct_idx) = cell_to_ct.get(cell_name) {
246            matrix[(cell_idx, ct_idx)] = 1.0;
247            matched += 1;
248        }
249    }
250
251    info!(
252        "One-hot membership: {}/{} cells matched, {} cell types",
253        matched, n_cells, n_ct
254    );
255
256    if matched == 0 {
257        bail!("No cells matched between SC backend and annotation file");
258    }
259
260    Ok(CellTypeMembership {
261        matrix,
262        cell_type_names,
263    })
264}
265
266/// Read soft cell-type membership proportions from a parquet file.
267///
268/// The parquet should have cell IDs as row names and cell type names as
269/// column headers.  Values are probabilities (rows sum to ~1).
270/// Cells are matched to the SC backend by `column_names`.
271pub fn read_membership_proportions(
272    file_path: &str,
273    column_names: &[Box<str>],
274) -> Result<CellTypeMembership> {
275    info!("Reading membership proportions from {}", file_path);
276
277    let mat_with_names = DMatrix::<f32>::from_parquet(file_path)?;
278    let cell_type_names = mat_with_names.cols;
279    let n_cell_types = cell_type_names.len();
280    let n_cells = column_names.len();
281
282    // Build lookup: parquet row name -> parquet row index
283    let parquet_cell_lookup: HashMap<&str, usize> = mat_with_names
284        .rows
285        .iter()
286        .enumerate()
287        .map(|(i, name)| (name.as_ref(), i))
288        .collect();
289
290    // Build membership matrix aligned to column_names order
291    let mut matrix = DMatrix::<f32>::zeros(n_cells, n_cell_types);
292    let mut matched = 0usize;
293
294    for (cell_idx, cell_name) in column_names.iter().enumerate() {
295        if let Some(&pq_row) = parquet_cell_lookup.get(cell_name.as_ref()) {
296            for k in 0..n_cell_types {
297                matrix[(cell_idx, k)] = mat_with_names.mat[(pq_row, k)];
298            }
299            matched += 1;
300        }
301    }
302
303    info!(
304        "Matched {}/{} cells to membership parquet ({} cell types)",
305        matched, n_cells, n_cell_types
306    );
307
308    if matched == 0 {
309        bail!("No cells matched between SC backend and membership parquet");
310    }
311
312    Ok(CellTypeMembership {
313        matrix,
314        cell_type_names,
315    })
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_read_cell_annotations_roundtrip() -> Result<()> {
324        use std::io::Write;
325
326        let dir = tempfile::tempdir()?;
327        let path = dir.path().join("cells.tsv");
328        let path_str = path.to_str().unwrap();
329
330        let mut f = std::fs::File::create(&path)?;
331        writeln!(f, "cell_id\tindividual_id\tcell_type")?;
332        writeln!(f, "cell_0\tIND_A\tT_cell")?;
333        writeln!(f, "cell_1\tIND_A\tB_cell")?;
334        writeln!(f, "cell_2\tIND_B\tT_cell")?;
335        writeln!(f, "cell_3\tIND_B\tT_cell")?;
336        f.flush()?;
337
338        let anno = read_cell_annotations(path_str)?;
339        assert_eq!(anno.individual_ids.len(), 2);
340        assert_eq!(anno.cell_to_individual.len(), 4);
341
342        let &ind_idx = anno.cell_to_individual.get(&Box::from("cell_0")).unwrap();
343        assert_eq!(anno.individual_ids[ind_idx].as_ref(), "IND_A");
344
345        Ok(())
346    }
347
348    fn write_table(dir: &tempfile::TempDir, name: &str, lines: &[&str]) -> String {
349        let path = dir.path().join(name);
350        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
351        path.to_str().unwrap().to_string()
352    }
353
354    fn individual_of<'a>(anno: &'a CellAnnotations, cell: &str) -> &'a str {
355        let &idx = anno.cell_to_individual.get(cell).unwrap();
356        anno.individual_ids[idx].as_ref()
357    }
358
359    #[test]
360    fn fields_of_a_tsv_or_csv_keep_their_spaces() -> Result<()> {
361        let dir = tempfile::tempdir()?;
362        let tsv = write_table(
363            &dir,
364            "cells.tsv",
365            &[
366                "cell_id\tindividual_id\tcell_type",
367                "cell_0\tdonor 1\tCD4 T cell",
368                "cell_1\tdonor 2\tCD4 T cell",
369                "cell_2\tdonor 2\tB cell, naive",
370            ],
371        );
372        let csv = write_table(
373            &dir,
374            "cells.csv",
375            &[
376                "cell_id, individual_id, cell_type",
377                "cell_0, donor 1, CD4 T cell",
378                "cell_1, \"donor 2\", CD4 T cell",
379                "cell_2, donor 2, B cell",
380            ],
381        );
382        let columns: Vec<Box<str>> = vec!["cell_0".into(), "cell_1".into(), "cell_2".into()];
383        for (path, b_cell) in [(&tsv, "B cell, naive"), (&csv, "B cell")] {
384            let anno = read_cell_annotations(path)?;
385            assert_eq!(
386                anno.individual_ids,
387                vec![Box::<str>::from("donor 1"), "donor 2".into()],
388                "{path}"
389            );
390            assert_eq!(individual_of(&anno, "cell_0"), "donor 1");
391            assert_eq!(individual_of(&anno, "cell_2"), "donor 2");
392
393            let membership = build_onehot_membership(path, &columns)?;
394            assert_eq!(
395                membership.cell_type_names,
396                vec![Box::<str>::from("CD4 T cell"), b_cell.into()],
397                "{path}"
398            );
399            assert_eq!(membership.matrix[(1, 0)], 1.0);
400            assert_eq!(membership.matrix[(2, 1)], 1.0);
401        }
402        Ok(())
403    }
404
405    #[test]
406    fn whitespace_tables_and_rows_with_an_empty_field() -> Result<()> {
407        let dir = tempfile::tempdir()?;
408        // Aligned with runs of spaces: no field is empty.
409        let spaced = write_table(
410            &dir,
411            "cells.txt",
412            &[
413                "cell  indiv  type",
414                "cell_0   IND_A  T",
415                "cell_1   IND_B  B",
416            ],
417        );
418        let anno = read_cell_annotations(&spaced)?;
419        assert_eq!(individual_of(&anno, "cell_0"), "IND_A");
420        assert_eq!(individual_of(&anno, "cell_1"), "IND_B");
421
422        // A row without a cell id or without an individual names no one.
423        let gapped = write_table(
424            &dir,
425            "gapped.tsv",
426            &[
427                "cell\tindiv",
428                "cell_0\tIND_A",
429                "cell_1\t",
430                "\tIND_B",
431                "",
432                "cell_2\tIND_A",
433            ],
434        );
435        let anno = read_cell_annotations(&gapped)?;
436        assert_eq!(anno.individual_ids, vec![Box::<str>::from("IND_A")]);
437        assert_eq!(anno.cell_to_individual.len(), 2);
438        Ok(())
439    }
440
441    /// A header with no tab or comma over tab-separated rows: the rows'
442    /// tabs separate, so a label keeps its spaces.
443    #[test]
444    fn rows_with_tabs_set_the_delimiter_a_header_does_not_name() -> Result<()> {
445        let dir = tempfile::tempdir()?;
446        let columns: Vec<Box<str>> = vec!["cell_0".into(), "cell_1".into()];
447        let table = write_table(
448            &dir,
449            "cells.txt",
450            &[
451                "cell indiv type",
452                "cell_0\tIND_A\tCD4 T cell",
453                "cell_1\tIND_B\tB",
454            ],
455        );
456        let membership = build_onehot_membership(&table, &columns)?;
457        assert_eq!(
458            membership.cell_type_names,
459            vec![Box::<str>::from("CD4 T cell"), "B".into()]
460        );
461        Ok(())
462    }
463
464    #[test]
465    fn the_header_line_sets_the_delimiter() -> Result<()> {
466        let dir = tempfile::tempdir()?;
467        let columns: Vec<Box<str>> = vec!["cell_0".into(), "cell_1".into(), "cell_2".into()];
468
469        // Whitespace-separated: a comma is part of the label it sits in.
470        let spaced = write_table(
471            &dir,
472            "cells.txt",
473            &[
474                "cell indiv type",
475                "cell_0 IND_A T",
476                "cell_1 IND_B B,naive",
477                "cell_2 IND_B T",
478            ],
479        );
480        let anno = read_cell_annotations(&spaced)?;
481        assert_eq!(anno.cell_to_individual.len(), 3);
482        assert_eq!(individual_of(&anno, "cell_1"), "IND_B");
483        let membership = build_onehot_membership(&spaced, &columns)?;
484        assert_eq!(
485            membership.cell_type_names,
486            vec![Box::<str>::from("T"), "B,naive".into()]
487        );
488        assert_eq!(membership.matrix[(1, 1)], 1.0);
489        assert_eq!(membership.matrix[(2, 0)], 1.0);
490
491        let tsv = write_table(
492            &dir,
493            "cells.tsv",
494            &[
495                "cell\tindiv\ttype",
496                "cell_0\tIND_A\tT",
497                "cell_1\tIND_B\tB",
498                "cell_2\tIND_B\tT",
499            ],
500        );
501        let anno = read_cell_annotations(&tsv)?;
502        assert_eq!(anno.cell_to_individual.len(), 3);
503        assert_eq!(individual_of(&anno, "cell_0"), "IND_A");
504        assert_eq!(individual_of(&anno, "cell_2"), "IND_B");
505        assert_eq!(
506            build_onehot_membership(&tsv, &columns)?.cell_type_names,
507            vec![Box::<str>::from("T"), "B".into()]
508        );
509        Ok(())
510    }
511
512    #[test]
513    fn a_table_where_no_row_parses_is_an_error() {
514        let dir = tempfile::tempdir().unwrap();
515        // The rows do not use the header's delimiter: each is one field.
516        let mixed = write_table(
517            &dir,
518            "mixed.tsv",
519            &["cell\tindiv", "cell_0 IND_A", "cell_1 IND_B"],
520        );
521        let err = read_cell_annotations(&mixed).unwrap_err().to_string();
522        assert!(err.contains("2 rows with fewer than 2 fields"), "{err}");
523
524        // Two columns hold no cell type.
525        let two = write_table(&dir, "two.tsv", &["cell\tindiv", "cell_0\tIND_A"]);
526        assert!(read_cell_annotations(&two).is_ok());
527        let columns: Vec<Box<str>> = vec!["cell_0".into()];
528        assert!(build_onehot_membership(&two, &columns).is_err());
529
530        let header_only = write_table(&dir, "empty.tsv", &["cell\tindiv"]);
531        assert!(read_cell_annotations(&header_only).is_err());
532    }
533
534    #[test]
535    fn test_infer_cell_annotations() {
536        let names: Vec<Box<str>> = vec![
537            "ACGT@IND_A".into(),
538            "TGCA@IND_A".into(),
539            "GGCC@IND_B".into(),
540        ];
541        let anno = infer_cell_annotations(&names);
542        assert_eq!(anno.individual_ids.len(), 2);
543        assert_eq!(anno.cell_to_individual.len(), 3);
544    }
545
546    #[test]
547    fn test_to_column_aligned_vec() {
548        let names: Vec<Box<str>> = vec!["ACGT@IND_A".into(), "TGCA@IND_B".into(), "XXXX".into()];
549        let anno = infer_cell_annotations(&names);
550        let aligned = anno.to_column_aligned_vec(&names, "missing");
551        assert_eq!(aligned[0].as_ref(), "IND_A");
552        assert_eq!(aligned[1].as_ref(), "IND_B");
553        assert_eq!(aligned[2].as_ref(), "all");
554    }
555}