Skip to main content

fibertools_rs/subcommands/
predict_m6a.rs

1use crate::cli::PredictM6AOptions;
2use crate::utils::basemods;
3use crate::utils::bio_io;
4use crate::utils::ma_io;
5use crate::utils::nucleosome;
6use crate::*;
7use bio::alphabets::dna::revcomp;
8use burn::tensor::backend::Backend;
9use fiber::FiberseqData;
10use ordered_float::OrderedFloat;
11use rayon::iter::IndexedParallelIterator;
12use rayon::iter::ParallelIterator;
13use rayon::prelude::IntoParallelRefMutIterator;
14use rust_htslib::{bam, bam::Read};
15use serde::Deserialize;
16use std::collections::BTreeMap;
17use std::sync::Once;
18
19pub const WINDOW: usize = 15;
20pub const LAYERS: usize = 6;
21pub const MIN_F32_PRED: f32 = 1.0e-46;
22static LOG_ONCE: Once = Once::new();
23// json precision tables
24pub static SEMI_JSON_2_0: &str = include_str!("../../models/2.0_semi_torch.json");
25pub static SEMI_JSON_2_2: &str = include_str!("../../models/2.2_semi_torch.json");
26pub static SEMI_JSON_3_2: &str = include_str!("../../models/3.2_semi_torch.json");
27pub static SEMI_JSON_REVIO: &str = include_str!("../../models/Revio_semi_torch.json");
28
29#[derive(Debug, Deserialize)]
30pub struct PrecisionTable {
31    pub columns: Vec<String>,
32    pub data: Vec<(f32, u8)>,
33}
34
35#[derive(Debug, Clone)]
36pub struct PredictOptions<B>
37where
38    B: Backend<Device = m6a_burn::BurnDevice>,
39{
40    pub keep: bool,
41    pub min_ml_score: Option<u8>,
42    pub all_calls: bool,
43    pub polymerase: PbChem,
44    pub batch_size: usize,
45    map: BTreeMap<OrderedFloat<f32>, u8>,
46    pub model: Vec<u8>,
47    pub min_ml: u8,
48    pub nuc_opts: cli::NucleosomeParameters,
49    pub burn_models: m6a_burn::BurnModels<B>,
50    pub fake: bool,
51}
52
53impl<B> PredictOptions<B>
54where
55    B: Backend<Device = m6a_burn::BurnDevice>,
56{
57    #[allow(clippy::too_many_arguments)]
58    #[allow(clippy::too_many_arguments)]
59    pub fn new(
60        keep: bool,
61        min_ml_score: Option<u8>,
62        all_calls: bool,
63        polymerase: PbChem,
64        batch_size: usize,
65        nuc_opts: cli::NucleosomeParameters,
66        fake: bool,
67    ) -> Self {
68        // set up a precision table
69        let mut map = BTreeMap::new();
70        map.insert(OrderedFloat(0.0), 0);
71
72        // return prediction options
73        let mut options = PredictOptions {
74            keep,
75            min_ml_score,
76            all_calls,
77            polymerase: polymerase.clone(),
78            batch_size,
79            map,
80            model: vec![],
81            min_ml: 0,
82            nuc_opts,
83            burn_models: m6a_burn::BurnModels::new(&polymerase),
84            fake,
85        };
86        options.add_model().expect("Error loading model");
87        options
88    }
89
90    fn get_precision_table_and_ml(&self) -> Result<(Option<PrecisionTable>, u8)> {
91        let mut precision_json = "".to_string();
92        let min_ml = if let Ok(_file) = std::env::var("FT_MODEL") {
93            244
94        } else {
95            LOG_ONCE.call_once(|| {
96                log::info!("Using semi-supervised CNN m6A model.");
97            });
98            match self.polymerase {
99                PbChem::Two => {
100                    precision_json = SEMI_JSON_2_0.to_string();
101                    230
102                }
103                PbChem::TwoPointTwo => {
104                    precision_json = SEMI_JSON_2_2.to_string();
105                    244
106                }
107                PbChem::ThreePointTwo => {
108                    precision_json = SEMI_JSON_3_2.to_string();
109                    244
110                }
111                PbChem::Revio => {
112                    precision_json = SEMI_JSON_REVIO.to_string();
113                    254
114                }
115            }
116        };
117
118        // load precision json from env var if needed
119        if let Ok(json) = std::env::var("FT_JSON") {
120            log::info!("Loading precision table from environment variable.");
121            precision_json =
122                std::fs::read_to_string(json).expect("Unable to read file specified by FT_JSON");
123        }
124
125        // load the precision table
126        let precision_table: Option<PrecisionTable> = Some(
127            serde_json::from_str(&precision_json)
128                .expect("Precision table JSON was not well-formatted"),
129        );
130
131        // set the variables for ML
132        let final_min_ml = match self.min_ml_score {
133            Some(x) => {
134                log::info!("Using provided minimum ML tag score: {x}");
135                x
136            }
137            None => min_ml,
138        };
139        Ok((precision_table, final_min_ml))
140    }
141
142    fn add_model(&mut self) -> Result<()> {
143        self.model = vec![];
144
145        let (precision_table, min_ml) = self.get_precision_table_and_ml()?;
146
147        // load precision table into map if not None
148        if let Some(precision_table) = precision_table {
149            for (cnn_score, precision) in precision_table.data {
150                self.map.insert(OrderedFloat(cnn_score), precision);
151            }
152        }
153
154        self.min_ml = min_ml;
155        Ok(())
156    }
157
158    pub fn progress_style(&self) -> &str {
159        // {percent:>3.green}%
160        "[PREDICTING m6A] [Elapsed {elapsed:.yellow} ETA {eta:.yellow}] {bar:30.cyan/blue} {human_pos:>5.cyan}/{human_len:.blue} (batches/s {per_sec:.green})"
161    }
162
163    /// function to find closest value in a btree based on precision
164    pub fn precision_from_float(&self, value: f32) -> u8 {
165        let key = OrderedFloat(value);
166        // maximum in map less than key
167        let (less_key, less_val) = self
168            .map
169            .range(..key)
170            .next_back()
171            .unwrap_or((&OrderedFloat(0.0), &0));
172        // minimum in map greater than or equal to key
173        let (more_key, more_val) = self
174            .map
175            .range(key..)
176            .next()
177            .unwrap_or((&OrderedFloat(1.0), &255));
178        if (more_key - key).abs() < (less_key - key).abs() {
179            *more_val
180        } else {
181            *less_val
182        }
183    }
184
185    pub fn min_ml_value(&self) -> u8 {
186        if self.all_calls {
187            0
188        } else {
189            self.min_ml
190        }
191    }
192
193    pub fn float_to_u8(&self, x: f32) -> u8 {
194        self.precision_from_float(x)
195    }
196
197    /// group reads together for predictions so we have to move data to the GPU less often
198    pub fn predict_m6a_on_records(
199        opts: &Self,
200        records: Vec<&mut rust_htslib::bam::Record>,
201        //records: &mut [rust_htslib::bam::Record],
202    ) -> usize {
203        // data windows for all the records in this chunk
204        let data: Vec<Option<(DataWidows, DataWidows)>> = records
205            .iter()
206            .map(|rec| get_m6a_data_windows(rec))
207            .collect();
208        // collect ml windows into one vector
209        let mut all_ml_data = vec![];
210        let mut all_count = 0;
211        data.iter().flatten().for_each(|(a, t)| {
212            all_ml_data.extend(a.windows.clone());
213            all_count += a.count;
214            all_ml_data.extend(t.windows.clone());
215            all_count += t.count;
216        });
217        let predictions = opts.apply_model(&all_ml_data, all_count);
218        assert_eq!(predictions.len(), all_count);
219        // split ml results back to all the records and modify the MM ML tags
220        assert_eq!(data.len(), records.len());
221        let mut cur_predict_st = 0;
222        for (option_data, record) in data.iter().zip(records) {
223            // Load existing annotations, then drop any prior m6a calls (the
224            // model's predictions replace them); cpg and other types are kept.
225            let mut annot = ma_io::read_record(record).unwrap_or_else(|e| {
226                log::warn!(
227                    "read_record failed for {:?}: {e}",
228                    String::from_utf8_lossy(record.qname())
229                );
230                molecular_annotation::MolecularAnnotations::from_record(record)
231            });
232            annot
233                .annotation_types
234                .retain(|t| t.name != basemods::M6A_TYPE);
235
236            // check if there is any data
237            let (a_data, t_data) = match option_data {
238                Some((a_data, t_data)) => (a_data, t_data),
239                None => continue,
240            };
241            // Iterate over A and then T basemods, collecting their forward
242            // positions + ML qualities into a single sorted m6a list. Each call
243            // carries its canonical (skip-base, strand) so the library's MM/ML
244            // serializer emits it under the right group: A-base m6a is `A+a`
245            // (forward), T-base is `T-a` (reverse). The strand must live in
246            // `Strand` — the writer derives the group `+`/`-` from it, not from
247            // the skip-base name.
248            let mut m6a_calls: Vec<(u32, u8, &'static str, molecular_annotation::Strand)> =
249                Vec::new();
250            for (data, base) in [(a_data, b'A'), (t_data, b'T')] {
251                let cur_predict_en = cur_predict_st + data.count;
252                let cur_predictions = &predictions[cur_predict_st..cur_predict_en];
253                cur_predict_st += data.count;
254                let (poss, quals) =
255                    opts.basemod_from_ml(record, cur_predictions, &data.positions, &data.base_mod);
256                let (skip_base, strand) = basemods::canonical_basemod(basemods::M6A_TYPE, base)
257                    .expect("A/T are canonical m6a bases");
258                m6a_calls.extend(
259                    poss.into_iter()
260                        .zip(quals)
261                        .map(|(p, q)| (p, q, skip_base, strand)),
262                );
263            }
264            if !m6a_calls.is_empty() {
265                m6a_calls.sort_by_key(|&(p, _, _, _)| p);
266                let qspec = "Q"
267                    .parse::<molecular_annotation::QualitySpec>()
268                    .expect("Q parses");
269                let t = annot.add_annotation_type(
270                    basemods::M6A_TYPE,
271                    qspec,
272                    molecular_annotation::Encoding::mm_ml(),
273                );
274                for (pos, qual, skip_base, strand) in &m6a_calls {
275                    t.add(*pos, 1, *strand, vec![*qual], Some(skip_base.to_string()));
276                }
277            }
278
279            // Compute nucleosomes + MSPs from the forward m6a positions.
280            let modified_bases_forward: Vec<i64> =
281                m6a_calls.iter().map(|&(p, _, _, _)| p as i64).collect();
282            nucleosome::add_nucleosomes_to_annotations(
283                record,
284                &mut annot,
285                &modified_bases_forward,
286                &opts.nuc_opts,
287            );
288
289            ma_io::write_record_with_basemods(record, &annot);
290
291            // clear the existing data
292            if !opts.keep {
293                record.remove_aux(b"fp").unwrap_or(());
294                record.remove_aux(b"fi").unwrap_or(());
295                record.remove_aux(b"rp").unwrap_or(());
296                record.remove_aux(b"ri").unwrap_or(());
297            }
298        }
299        assert_eq!(cur_predict_st, predictions.len());
300        data.iter().flatten().count()
301    }
302
303    /// Filter raw model predictions into (forward_position, ML_quality)
304    /// pairs suitable for adding to a `MolecularAnnotations` m6a
305    /// annotation type. Drops predictions below the ML threshold and
306    /// within the first/last `WINDOW/2` bases of the read.
307    pub fn basemod_from_ml(
308        &self,
309        record: &mut bam::Record,
310        predictions: &[f32],
311        positions: &[usize],
312        _base_mod: &str,
313    ) -> (Vec<u32>, Vec<u8>) {
314        // do not report predictions for the first and last 7 bases
315        let min_pos = (WINDOW / 2) as i64;
316        let max_pos = (record.seq_len() - WINDOW / 2) as i64;
317
318        let mut poss: Vec<u32> = Vec::new();
319        let mut quals: Vec<u8> = Vec::new();
320        let mut low_nonzero = 0usize;
321        let mut zero = 0usize;
322        for (&pred, &pos) in predictions.iter().zip(positions.iter()) {
323            let ml = self.float_to_u8(pred);
324            let pos_i = pos as i64;
325            if pred > 0.0 && pred <= 1.0 / 255.0 {
326                low_nonzero += 1;
327            }
328            if pred <= 0.0 && pred > -0.00000001 {
329                zero += 1;
330            }
331            if ml >= self.min_ml_value() && pos_i >= min_pos && pos_i < max_pos {
332                poss.push(pos as u32);
333                quals.push(ml);
334            }
335        }
336
337        log::debug!(
338            "Low but non zero values: {:?}\tZero values: {:?}\tlength:{:?}",
339            low_nonzero,
340            zero,
341            predictions.len()
342        );
343
344        (poss, quals)
345    }
346
347    pub fn apply_model(&self, windows: &[f32], count: usize) -> Vec<f32> {
348        self.burn_models.forward(self, windows, count)
349    }
350
351    fn _fake_apply_model(&self, _: &[f32], count: usize) -> Vec<f32> {
352        vec![0.0; count]
353    }
354}
355
356/// ```
357/// use fibertools_rs::subcommands::predict_m6a::hot_one_dna;
358/// let x: Vec<u8> = vec![b'A', b'G', b'T', b'C', b'A'];
359/// let ho = hot_one_dna(&x);
360/// let e: Vec<f32> = vec![
361///                          1.0, 0.0, 0.0, 0.0, 1.0,
362///                          0.0, 0.0, 0.0, 1.0, 0.0,
363///                          0.0, 1.0, 0.0, 0.0, 0.0,
364///                          0.0, 0.0, 1.0, 0.0, 0.0
365///                         ];
366/// assert_eq!(ho, e);
367/// ```
368pub fn hot_one_dna(seq: &[u8]) -> Vec<f32> {
369    let len = seq.len() * 4;
370    let mut out = vec![0.0; len];
371    for (row, base) in [b'A', b'C', b'G', b'T'].into_iter().enumerate() {
372        let already_done = seq.len() * row;
373        for i in 0..seq.len() {
374            if seq[i] == base {
375                out[already_done + i] = 1.0;
376            }
377        }
378    }
379    out
380}
381
382struct DataWidows {
383    pub windows: Vec<f32>,
384    pub positions: Vec<usize>,
385    pub count: usize,
386    pub base_mod: String,
387}
388
389fn get_m6a_data_windows(record: &bam::Record) -> Option<(DataWidows, DataWidows)> {
390    // skip invalid or redundant records
391    if record.is_secondary() {
392        log::warn!(
393            "Skipping secondary alignment of {}",
394            String::from_utf8_lossy(record.qname())
395        );
396        return None;
397    }
398
399    let extend = WINDOW / 2;
400    let mut f_ip = bio_io::get_u8_tag(record, b"fi");
401    let r_ip;
402    let f_pw;
403    let r_pw;
404    // check if we maybe are getting u16 input instead of u8
405    if f_ip.is_empty() {
406        f_ip = bio_io::get_pb_u16_tag_as_u8(record, b"fi");
407        if f_ip.is_empty() {
408            // missing u16 as well, set all to empty arrays
409            r_ip = vec![];
410            f_pw = vec![];
411            r_pw = vec![];
412        } else {
413            r_ip = bio_io::get_pb_u16_tag_as_u8(record, b"ri");
414            f_pw = bio_io::get_pb_u16_tag_as_u8(record, b"fp");
415            r_pw = bio_io::get_pb_u16_tag_as_u8(record, b"rp");
416        }
417    } else {
418        r_ip = bio_io::get_u8_tag(record, b"ri");
419        f_pw = bio_io::get_u8_tag(record, b"fp");
420        r_pw = bio_io::get_u8_tag(record, b"rp");
421    }
422    // return if missing kinetics
423    if f_ip.is_empty() || r_ip.is_empty() || f_pw.is_empty() || r_pw.is_empty() {
424        log::debug!(
425            "Hifi kinetics are missing for: {}",
426            String::from_utf8_lossy(record.qname())
427        );
428        return None;
429    }
430    // reverse for reverse strand
431    let r_ip = r_ip.into_iter().rev().collect::<Vec<_>>();
432    let r_pw = r_pw.into_iter().rev().collect::<Vec<_>>();
433
434    let mut seq = record.seq().as_bytes();
435    if record.is_reverse() {
436        seq = revcomp(seq);
437    }
438
439    assert_eq!(f_ip.len(), seq.len());
440    let mut a_count = 0;
441    let mut t_count = 0;
442    let mut a_windows = vec![];
443    let mut t_windows = vec![];
444    let mut a_positions = vec![];
445    let mut t_positions = vec![];
446    for (pos, base) in seq.iter().enumerate() {
447        if !((*base == b'A') || (*base == b'T')) {
448            continue;
449        }
450        // get the data window
451        let data_window = if (pos < extend) || (pos + extend + 1 > record.seq_len()) {
452            // make fake data for leading and trailing As
453            vec![0.0; WINDOW * LAYERS]
454        } else {
455            let start = pos - extend;
456            let end = pos + extend + 1;
457            let ip: Vec<f32>;
458            let pw: Vec<f32>;
459            let hot_one;
460            if *base == b'A' {
461                let w_seq = &revcomp(&seq[start..end]);
462                hot_one = hot_one_dna(w_seq);
463                ip = (r_ip[start..end])
464                    .iter()
465                    .copied()
466                    .rev()
467                    .map(|x| x as f32 / 255.0)
468                    .collect();
469                pw = (r_pw[start..end])
470                    .iter()
471                    .copied()
472                    .rev()
473                    .map(|x| x as f32 / 255.0)
474                    .collect();
475            } else {
476                let w_seq = &seq[start..end];
477                hot_one = hot_one_dna(w_seq);
478                ip = (f_ip[start..end])
479                    .iter()
480                    .copied()
481                    .map(|x| x as f32 / 255.0)
482                    .collect();
483                pw = (f_pw[start..end])
484                    .iter()
485                    .copied()
486                    .map(|x| x as f32 / 255.0)
487                    .collect();
488            }
489            let mut data_window = vec![];
490            data_window.extend(hot_one);
491            data_window.extend(ip);
492            data_window.extend(pw);
493            data_window
494        };
495
496        // add to data windows and record positions
497        if *base == b'A' {
498            a_windows.extend(data_window);
499            a_count += 1;
500            a_positions.push(pos);
501        } else {
502            t_windows.extend(data_window);
503            t_count += 1;
504            t_positions.push(pos);
505        }
506    }
507    let a_data = DataWidows {
508        windows: a_windows,
509        positions: a_positions,
510        count: a_count,
511        base_mod: "A+a".to_string(),
512    };
513    let t_data = DataWidows {
514        windows: t_windows,
515        positions: t_positions,
516        count: t_count,
517        base_mod: "T-a".to_string(),
518    };
519    Some((a_data, t_data))
520}
521
522pub fn read_bam_into_fiberdata(opts: &mut PredictM6AOptions) {
523    let mut bam = opts.input.bam_reader();
524    let mut out = opts.input.bam_writer(&opts.out);
525    let header = bam::Header::from_template(bam.header());
526    // log the options
527    log::info!(
528        "{} reads included at once in batch prediction.",
529        opts.batch_size
530    );
531
532    #[cfg(feature = "tch")]
533    type MlBackend = burn::backend::LibTorch;
534    #[cfg(feature = "tch")]
535    log::info!("Using LibTorch for ML backend.");
536
537    #[cfg(not(feature = "tch"))]
538    type MlBackend = burn::backend::Candle;
539    #[cfg(not(feature = "tch"))]
540    log::info!("Using Candle for ML backend.");
541
542    // switch to the internal predict options
543    let predict_options: PredictOptions<MlBackend> = PredictOptions::new(
544        opts.keep,
545        opts.force_min_ml_score,
546        opts.all_calls,
547        find_pb_polymerase(&header),
548        opts.batch_size,
549        opts.nuc.clone(),
550        opts.fake,
551    );
552    // get default fire options
553    let fire_opts = crate::cli::FireOptions::default();
554    let (model, precision_table) = crate::utils::fire::get_model(&fire_opts);
555
556    // read in bam data
557    let bam_chunk_iter = BamChunk::new(bam.records(), None);
558    // iterate over chunks
559    for mut chunk in bam_chunk_iter {
560        // add m6a calls
561
562        let number_of_reads_with_predictions = chunk
563            .par_iter_mut()
564            .chunks(predict_options.batch_size)
565            .map(|records| {
566                // Create a fresh PredictOptions instance for this thread
567                let thread_opts = PredictOptions::<MlBackend>::new(
568                    predict_options.all_calls,
569                    predict_options.min_ml_score,
570                    predict_options.all_calls,
571                    predict_options.polymerase.clone(),
572                    predict_options.batch_size,
573                    predict_options.nuc_opts.clone(),
574                    predict_options.fake,
575                );
576                PredictOptions::predict_m6a_on_records(&thread_opts, records)
577            })
578            .sum::<usize>() as f32;
579
580        let frac_called = number_of_reads_with_predictions / chunk.len() as f32;
581        if frac_called < 0.05 {
582            log::warn!("More than 5% ({:.2}%) of reads were not predicted on. Are HiFi kinetics missing from this file? Enable Debug logging level to show which reads lack kinetics.", 100.0-100.0*frac_called);
583        }
584
585        // covert to FiberData and do FIRE predictions
586        let mut fd_recs =
587            FiberseqData::from_records(chunk, &opts.input.header_view(), &opts.input.filters);
588        fd_recs.par_iter_mut().for_each(|fd| {
589            crate::subcommands::fire::add_fire_to_rec(fd, &fire_opts, &model, &precision_table);
590        });
591
592        // write to output
593        fd_recs.iter().for_each(|fd| out.write(&fd.record).unwrap());
594    }
595}
596
597/// tests
598#[cfg(test)]
599mod tests {
600    use super::*;
601    #[test]
602    fn test_precision_json_validity() {
603        for file in [SEMI_JSON_2_0, SEMI_JSON_2_2, SEMI_JSON_3_2, SEMI_JSON_REVIO] {
604            let _p: PrecisionTable =
605                serde_json::from_str(file).expect("Precision table JSON was not well-formatted");
606        }
607    }
608}