Skip to main content

data_beans/aux/
cell_annotations.rs

1use rustc_hash::FxHashMap as HashMap;
2
3use anyhow::{bail, Result};
4use log::info;
5use nalgebra::DMatrix;
6
7use legume_numeric::matrix::common_io::read_lines_of_words_delim;
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/// Read cell annotations from a delimited file (TSV, CSV, or space-separated).
49///
50/// Supports gzip-compressed files (.gz) and multiple delimiters (tab, comma, space).
51/// First line is treated as a header and skipped.
52///
53/// Requires at least 2 columns: `cell_id individual_id`.
54/// A 3rd column (`cell_type`) is accepted but ignored here;
55/// use [`build_onehot_membership`] to convert it into a membership matrix.
56pub fn read_cell_annotations(path: &str) -> Result<CellAnnotations> {
57    info!("Reading cell annotations from {}", path);
58
59    let parsed = read_lines_of_words_delim(path, &['\t', ',', ' '], 0)?;
60
61    let mut individual_to_idx: HashMap<Box<str>, usize> = Default::default();
62    let mut individual_ids: Vec<Box<str>> = Vec::new();
63    let mut cell_to_individual: HashMap<Box<str>, usize> = Default::default();
64
65    for words in &parsed.lines {
66        if words.len() < 2 {
67            continue;
68        }
69
70        let cell_id = words[0].clone();
71        let ind_id = words[1].clone();
72
73        let ind_idx = *individual_to_idx.entry(ind_id.clone()).or_insert_with(|| {
74            let idx = individual_ids.len();
75            individual_ids.push(ind_id);
76            idx
77        });
78
79        cell_to_individual.insert(cell_id, ind_idx);
80    }
81
82    info!(
83        "Loaded {} cell annotations: {} individuals",
84        cell_to_individual.len(),
85        individual_ids.len(),
86    );
87
88    Ok(CellAnnotations {
89        cell_to_individual,
90        individual_ids,
91    })
92}
93
94/// Infer cell annotations from cell names by splitting on `@`.
95///
96/// Cell names like `ACGT@IND_A` map to individual `IND_A`.
97/// Names without `@` are assigned to a single individual `"all"`.
98pub fn infer_cell_annotations(column_names: &[Box<str>]) -> CellAnnotations {
99    info!("Inferring individuals from cell names (barcode@indiv)");
100
101    let mut individual_to_idx: HashMap<Box<str>, usize> = Default::default();
102    let mut individual_ids: Vec<Box<str>> = Vec::new();
103    let mut cell_to_individual: HashMap<Box<str>, usize> = Default::default();
104
105    for cell_name in column_names {
106        let indiv: Box<str> = if let Some(pos) = cell_name.rfind('@') {
107            Box::from(&cell_name[pos + 1..])
108        } else {
109            Box::from("all")
110        };
111        let idx = *individual_to_idx.entry(indiv.clone()).or_insert_with(|| {
112            let i = individual_ids.len();
113            individual_ids.push(indiv);
114            i
115        });
116        cell_to_individual.insert(cell_name.clone(), idx);
117    }
118
119    info!(
120        "Inferred {} individuals from cell names",
121        individual_ids.len()
122    );
123
124    CellAnnotations {
125        cell_to_individual,
126        individual_ids,
127    }
128}
129
130/// Build a one-hot membership matrix from hard cell-type annotations.
131///
132/// Reads a delimited file with 3+ columns: `cell_id individual_id cell_type`.
133/// The resulting matrix has one-hot rows aligned to the SC backend `column_names`.
134pub fn build_onehot_membership(
135    path: &str,
136    column_names: &[Box<str>],
137) -> Result<CellTypeMembership> {
138    info!("Building one-hot membership from {}", path);
139
140    let parsed = read_lines_of_words_delim(path, &['\t', ',', ' '], 0)?;
141
142    let mut celltype_to_idx: HashMap<Box<str>, usize> = Default::default();
143    let mut cell_type_names: Vec<Box<str>> = Vec::new();
144    let mut cell_to_ct: HashMap<Box<str>, usize> = Default::default();
145
146    for words in &parsed.lines {
147        if words.len() < 3 {
148            continue;
149        }
150
151        let cell_id = words[0].clone();
152        let ct_name = words[2].clone();
153
154        let ct_idx = *celltype_to_idx.entry(ct_name.clone()).or_insert_with(|| {
155            let idx = cell_type_names.len();
156            cell_type_names.push(ct_name);
157            idx
158        });
159
160        cell_to_ct.insert(cell_id, ct_idx);
161    }
162
163    let n_cells = column_names.len();
164    let n_ct = cell_type_names.len();
165    let mut matrix = DMatrix::<f32>::zeros(n_cells, n_ct);
166    let mut matched = 0usize;
167
168    for (cell_idx, cell_name) in column_names.iter().enumerate() {
169        if let Some(&ct_idx) = cell_to_ct.get(cell_name) {
170            matrix[(cell_idx, ct_idx)] = 1.0;
171            matched += 1;
172        }
173    }
174
175    info!(
176        "One-hot membership: {}/{} cells matched, {} cell types",
177        matched, n_cells, n_ct
178    );
179
180    if matched == 0 {
181        bail!("No cells matched between SC backend and annotation file");
182    }
183
184    Ok(CellTypeMembership {
185        matrix,
186        cell_type_names,
187    })
188}
189
190/// Read soft cell-type membership proportions from a parquet file.
191///
192/// The parquet should have cell IDs as row names and cell type names as
193/// column headers.  Values are probabilities (rows sum to ~1).
194/// Cells are matched to the SC backend by `column_names`.
195pub fn read_membership_proportions(
196    file_path: &str,
197    column_names: &[Box<str>],
198) -> Result<CellTypeMembership> {
199    info!("Reading membership proportions from {}", file_path);
200
201    let mat_with_names = DMatrix::<f32>::from_parquet(file_path)?;
202    let cell_type_names = mat_with_names.cols;
203    let n_cell_types = cell_type_names.len();
204    let n_cells = column_names.len();
205
206    // Build lookup: parquet row name -> parquet row index
207    let parquet_cell_lookup: HashMap<&str, usize> = mat_with_names
208        .rows
209        .iter()
210        .enumerate()
211        .map(|(i, name)| (name.as_ref(), i))
212        .collect();
213
214    // Build membership matrix aligned to column_names order
215    let mut matrix = DMatrix::<f32>::zeros(n_cells, n_cell_types);
216    let mut matched = 0usize;
217
218    for (cell_idx, cell_name) in column_names.iter().enumerate() {
219        if let Some(&pq_row) = parquet_cell_lookup.get(cell_name.as_ref()) {
220            for k in 0..n_cell_types {
221                matrix[(cell_idx, k)] = mat_with_names.mat[(pq_row, k)];
222            }
223            matched += 1;
224        }
225    }
226
227    info!(
228        "Matched {}/{} cells to membership parquet ({} cell types)",
229        matched, n_cells, n_cell_types
230    );
231
232    if matched == 0 {
233        bail!("No cells matched between SC backend and membership parquet");
234    }
235
236    Ok(CellTypeMembership {
237        matrix,
238        cell_type_names,
239    })
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_read_cell_annotations_roundtrip() -> Result<()> {
248        use std::io::Write;
249
250        let dir = tempfile::tempdir()?;
251        let path = dir.path().join("cells.tsv");
252        let path_str = path.to_str().unwrap();
253
254        let mut f = std::fs::File::create(&path)?;
255        writeln!(f, "cell_id\tindividual_id\tcell_type")?;
256        writeln!(f, "cell_0\tIND_A\tT_cell")?;
257        writeln!(f, "cell_1\tIND_A\tB_cell")?;
258        writeln!(f, "cell_2\tIND_B\tT_cell")?;
259        writeln!(f, "cell_3\tIND_B\tT_cell")?;
260        f.flush()?;
261
262        let anno = read_cell_annotations(path_str)?;
263        assert_eq!(anno.individual_ids.len(), 2);
264        assert_eq!(anno.cell_to_individual.len(), 4);
265
266        let &ind_idx = anno.cell_to_individual.get(&Box::from("cell_0")).unwrap();
267        assert_eq!(anno.individual_ids[ind_idx].as_ref(), "IND_A");
268
269        Ok(())
270    }
271
272    #[test]
273    fn test_infer_cell_annotations() {
274        let names: Vec<Box<str>> = vec![
275            "ACGT@IND_A".into(),
276            "TGCA@IND_A".into(),
277            "GGCC@IND_B".into(),
278        ];
279        let anno = infer_cell_annotations(&names);
280        assert_eq!(anno.individual_ids.len(), 2);
281        assert_eq!(anno.cell_to_individual.len(), 3);
282    }
283
284    #[test]
285    fn test_to_column_aligned_vec() {
286        let names: Vec<Box<str>> = vec!["ACGT@IND_A".into(), "TGCA@IND_B".into(), "XXXX".into()];
287        let anno = infer_cell_annotations(&names);
288        let aligned = anno.to_column_aligned_vec(&names, "missing");
289        assert_eq!(aligned[0].as_ref(), "IND_A");
290        assert_eq!(aligned[1].as_ref(), "IND_B");
291        assert_eq!(aligned[2].as_ref(), "all");
292    }
293}