Skip to main content

fibertools_rs/utils/
fire.rs

1use crate::cli::FireOptions;
2use crate::fiber::FiberseqData;
3use crate::utils::bamannotations::AnnotationTypeView;
4use crate::utils::platform::SeqPlatform;
5use crate::*;
6use anyhow;
7use derive_builder::Builder;
8use gbdt::decision_tree::{Data, DataVec};
9use gbdt::gradient_boost::GBDT;
10use itertools::Itertools;
11use ordered_float::OrderedFloat;
12use serde::Deserialize;
13use std::collections::BTreeMap;
14use std::fs;
15use tempfile::NamedTempFile;
16
17pub static FIRE_MODEL: &str = include_str!("../../models/FIRE.gbdt.json");
18pub static FIRE_CONF_JSON: &str = include_str!("../../models/FIRE.conf.json");
19
20pub fn get_model(fire_opts: &FireOptions) -> (GBDT, MapPrecisionValues) {
21    let mut remove_temp_file = false;
22    // load defaults or passed in options
23    let (model_file, fdr_table_file) = match (&fire_opts.model, &fire_opts.fdr_table) {
24        (Some(model_file), Some(b)) => {
25            let fdr_table = fs::read_to_string(b).expect("Unable to read file");
26            (model_file.clone(), fdr_table)
27        }
28        _ => {
29            let temp_file = NamedTempFile::new().expect("Unable to make a temp file");
30            let (mut temp_file, path) = temp_file.keep().expect("Unable to keep temp file");
31            let temp_file_name = path
32                .as_os_str()
33                .to_str()
34                .expect("Unable to convert the path of the named temp file to an &str.");
35            temp_file
36                .write_all(FIRE_MODEL.as_bytes())
37                .expect("Unable to write file");
38            //fs::write(temp, FIRE_MODEL).expect("Unable to write file");
39            remove_temp_file = true;
40            (temp_file_name.to_string(), FIRE_CONF_JSON.to_string())
41        }
42    };
43    log::info!("Using model: {model_file}");
44    // load model
45    let model =
46        GBDT::from_xgboost_dump(&model_file, "binary:logistic").expect("failed to load FIRE model");
47    if remove_temp_file {
48        fs::remove_file(model_file).expect("Unable to remove temp file");
49    }
50
51    // load precision table
52    let precision_table: PrecisionTable =
53        serde_json::from_str(&fdr_table_file).expect("Precision table JSON was not well-formatted");
54    let precision_converter = MapPrecisionValues::new(&precision_table);
55
56    // return
57    (model, precision_converter)
58}
59
60fn get_mid_point(start: i64, end: i64) -> i64 {
61    (start + end) / 2
62}
63
64/// ```
65/// use fibertools_rs::utils::fire::get_bins;
66/// let bins = get_bins(50, 5, 20, 200);
67/// assert_eq!(bins, vec![(0, 20), (20, 40), (40, 60), (60, 80), (80, 100)]);
68/// ```
69pub fn get_bins(mid_point: i64, bin_num: i64, bin_width: i64, max_end: i64) -> Vec<(i64, i64)> {
70    let mut bins = Vec::new();
71    for i in 0..bin_num {
72        let mut bin_start = mid_point - (bin_num / 2 - i) * bin_width - bin_width / 2;
73        let mut bin_end = bin_start + bin_width;
74        if bin_start < 0 {
75            bin_start = 0;
76        }
77        if bin_end < 0 {
78            bin_end = 0;
79        }
80        if bin_start > max_end {
81            bin_start = max_end - 1;
82        }
83        if bin_end > max_end {
84            bin_end = max_end;
85        }
86        bins.push((bin_start, bin_end));
87    }
88    bins
89}
90
91/// get the maximum and median rle of m6a in a window
92fn get_m6a_rle_data(m6a_view: &AnnotationTypeView, start: i64, end: i64) -> (f32, f32) {
93    let mut m6a_rles = vec![];
94    let mut max = 0;
95    let mut _max_pos = 0;
96    // if you are a position, on average you will be in an rle length of weighted_rle
97    let mut weighted_rle = 0.0;
98    for (m6a_1, m6a_2) in m6a_view
99        .infos()
100        .iter()
101        .map(|a| a.query_start as i64)
102        .tuple_windows()
103    {
104        // we only want m6a in the window
105        if m6a_1 < start || m6a_1 > end || m6a_2 < start || m6a_2 > end {
106            continue;
107        }
108        // distance between m6a sites
109        let rle = (m6a_2 - m6a_1).abs();
110        m6a_rles.push(rle);
111        // update max
112        if rle > max {
113            max = rle;
114            _max_pos = ((m6a_1 + m6a_2) / 2 - (end + start) / 2).abs();
115        }
116        // update weighted rle
117        weighted_rle += (rle * rle) as f32;
118    }
119    weighted_rle /= (end - start) as f32;
120
121    if m6a_rles.is_empty() {
122        return (-1.0, -1.0);
123    }
124    let mid_length = m6a_rles.len() / 2;
125    let (_, median, _) = m6a_rles.select_nth_unstable(mid_length);
126    (weighted_rle, *median as f32)
127}
128
129const FEATS_IN_USE: [&str; 3] = [
130    "m6a_count",
131    //"at_count",
132    //"count_5mc",
133    "frac_m6a",
134    "m6a_fc",
135    //"max_m6a_rle",
136    //"max_m6a_rle_pos",
137    // "weighted_m6a_rle",
138    //"median_m6a_rle",
139];
140#[derive(Debug, Clone, Builder)]
141pub struct FireFeatsInRange {
142    pub m6a_count: f32,
143    pub at_count: f32,
144    #[allow(unused)]
145    pub count_5mc: f32,
146    pub frac_m6a: f32,
147    pub m6a_fc: f32,
148    pub weighted_m6a_rle: f32,
149    pub median_m6a_rle: f32,
150}
151
152impl FireFeatsInRange {
153    pub fn header(tag: &str) -> String {
154        let mut out = "".to_string();
155        for col in FEATS_IN_USE.iter() {
156            out += &format!("\t{tag}_{col}");
157        }
158        out
159    }
160}
161
162#[derive(Debug)]
163pub struct FireFeats<'a> {
164    rec: &'a FiberseqData,
165    /// Views built once per record. Memoize their lifted infos, so the
166    /// per-base windowing loop in `msp_get_fire_features` scans cached
167    /// query coordinates instead of re-lifting every annotation per call.
168    m6a_view: AnnotationTypeView<'a>,
169    cpg_view: AnnotationTypeView<'a>,
170    #[allow(unused)]
171    at_count: usize,
172    m6a_count: usize,
173    frac_m6a: f32,
174    //frac_m6a_in_msps: f32,
175    fire_opts: &'a FireOptions,
176    /// Whether the ONT single-strand heuristic applies to this read. Set from
177    /// `--ont` (forces it for all reads) or per-read platform detection.
178    is_ont: bool,
179    seq: Vec<u8>,
180    fire_feats: Vec<(i64, i64, Vec<f32>)>,
181}
182
183impl<'a> FireFeats<'a> {
184    pub fn new(rec: &'a FiberseqData, fire_opts: &'a FireOptions) -> Self {
185        let seq_len = rec.record.seq_len();
186        let seq = rec.record.seq().as_bytes();
187
188        // Apply the ONT single-strand m6A heuristic when the user forces it
189        // (`--ont`) or when this read is detected as ONT. Reads matching no
190        // known platform convention default to PacBio (no heuristic).
191        let is_ont = fire_opts.ont || matches!(rec.platform(), SeqPlatform::Ont);
192
193        let mut rtn = Self {
194            rec,
195            m6a_view: rec.m6a(),
196            cpg_view: rec.cpg(),
197            at_count: 0,
198            m6a_count: 0,
199            frac_m6a: 0.0,
200            //frac_m6a_in_msps,
201            fire_opts,
202            is_ont,
203            seq,
204            fire_feats: vec![],
205        };
206
207        // add in the m6a and AT counts
208        rtn.at_count = rtn.get_at_count(0, seq_len as i64);
209        rtn.m6a_count = rtn.get_m6a_count(0, seq_len as i64);
210        rtn.frac_m6a = if rtn.at_count > 0 {
211            rtn.m6a_count as f32 / rtn.at_count as f32
212        } else {
213            0.0
214        };
215
216        rtn.get_fire_features();
217        if rtn.is_ont {
218            rtn.validate_that_ont_is_single_strand();
219        }
220        rtn
221    }
222
223    fn validate_that_ont_is_single_strand(&self) {
224        let sequenced_bp = if self.rec.record.is_reverse() {
225            b'T'
226        } else {
227            b'A'
228        };
229        for info in self.m6a_view.infos() {
230            let m6a_st = info.query_start as i64;
231            let m6a_bp = self.seq[m6a_st as usize];
232            if m6a_bp != sequenced_bp {
233                log::warn!(
234                    "m6A site at {} is not the same as the sequenced base {}",
235                    m6a_st,
236                    sequenced_bp as char
237                );
238            }
239        }
240    }
241
242    fn get_bp_count(&self, start: i64, end: i64, bp: u8) -> usize {
243        let subseq = &self.seq[start as usize..end as usize];
244        subseq.iter().filter(|&&b| b == bp).count()
245    }
246
247    fn get_at_count(&self, start: i64, end: i64) -> usize {
248        self.get_bp_count(start, end, b'A') + self.get_bp_count(start, end, b'T')
249    }
250
251    fn get_5mc_count(&self, start: i64, end: i64) -> usize {
252        self.cpg_view.count_query_in(start, end)
253    }
254
255    fn get_m6a_count(&self, start: i64, end: i64) -> usize {
256        let mut m6a_count = self.m6a_view.count_query_in(start, end);
257
258        // estimate what the count would be if we sequenced the other strand
259        if self.is_ont {
260            let mut sequenced_bp = self.get_bp_count(start, end, b'A');
261            let mut un_sequenced_bp = self.get_bp_count(start, end, b'T');
262            if self.rec.record.is_reverse() {
263                // swap the counts
264                std::mem::swap(&mut sequenced_bp, &mut un_sequenced_bp);
265            }
266            let m6a_frac = if sequenced_bp > 0 {
267                m6a_count as f32 / sequenced_bp as f32
268            } else {
269                0.0
270            };
271            m6a_count += (un_sequenced_bp as f32 * m6a_frac).round() as usize;
272        }
273        m6a_count
274    }
275
276    fn m6a_fc_over_expected(&self, m6a_count: usize, at_count: usize) -> f32 {
277        //let expected = self.frac_m6a_in_msps * at_count as f32;
278        // ^ this didn't work well
279        let expected = self.frac_m6a * at_count as f32;
280        let observed = m6a_count as f32;
281        if expected == 0.0 || observed == 0.0 {
282            return 0.0;
283        }
284        let fc = observed / expected;
285        fc.log2()
286    }
287
288    fn feats_in_range(&self, start: i64, end: i64) -> FireFeatsInRange {
289        let m6a_count = self.get_m6a_count(start, end);
290        let at_count = self.get_at_count(start, end);
291        let count_5mc = self.get_5mc_count(start, end);
292        let frac_m6a = if at_count > 0 {
293            m6a_count as f32 / at_count as f32
294        } else {
295            0.0
296        };
297        let m6a_fc = self.m6a_fc_over_expected(m6a_count, at_count);
298        let (weighted_m6a_rle, median_m6a_rle) = get_m6a_rle_data(&self.m6a_view, start, end);
299
300        FireFeatsInRange {
301            m6a_count: m6a_count as f32,
302            at_count: at_count as f32,
303            count_5mc: count_5mc as f32,
304            frac_m6a,
305            m6a_fc,
306            weighted_m6a_rle,
307            median_m6a_rle,
308        }
309    }
310
311    pub fn fire_feats_header(fire_opts: &FireOptions) -> String {
312        let mut out = "#chrom\tstart\tend\tfiber".to_string();
313        out += "\tmsp_len\tmsp_len_times_m6a_fc\tccs_passes";
314        out += "\tfiber_m6a_count\tfiber_m6a_frac";
315        out += &FireFeatsInRange::header("msp");
316        out += &FireFeatsInRange::header("best");
317        out += &FireFeatsInRange::header("worst");
318        for bin_num in 0..fire_opts.bin_num {
319            out += &FireFeatsInRange::header(&format!("bin_{bin_num}"));
320        }
321        out += "\n";
322        out
323    }
324
325    fn msp_get_fire_features(&self, start: i64, end: i64) -> Vec<f32> {
326        let msp_len = end - start;
327        // skip predicting (or outputting) on short windows
328        if msp_len < self.fire_opts.min_msp_length_for_positive_fire_call {
329            return vec![];
330        }
331        let ccs_passes = if self.is_ont { 4.0 } else { self.rec.ec };
332
333        // find the 100bp window within the range with the most m6a
334        let mut max_m6a_count = 0;
335        let mut max_m6a_start = 0;
336        let mut max_m6a_end = 0;
337        let mut min_m6a_count = usize::MAX;
338        let mut min_m6a_start = 0;
339        let mut min_m6a_end = 0;
340        let mut centering_pos = get_mid_point(start, end);
341        for st_idx in start..end {
342            let en_idx = (st_idx + self.fire_opts.best_window_size).min(end);
343            // this analysis is only interesting if we have a larger msp that could have two ~ distinct windows. Thus we need to check that we have a window larger than 2X the best window size
344            if (end - start) < (2 * self.fire_opts.best_window_size) {
345                log::trace!("MSP window is not large enough for best and worst window analysis");
346                break;
347            }
348            let m6a_count = self.get_m6a_count(st_idx, en_idx);
349            if m6a_count > max_m6a_count {
350                max_m6a_count = m6a_count;
351                max_m6a_start = st_idx;
352                max_m6a_end = en_idx;
353                // center my bins around the highest density m6A region instead of the middle of the MSP
354                centering_pos = get_mid_point(st_idx, en_idx);
355            }
356            if m6a_count < min_m6a_count {
357                min_m6a_count = m6a_count;
358                min_m6a_start = st_idx;
359                min_m6a_end = en_idx;
360            }
361            if en_idx == end {
362                break;
363            }
364        }
365        let best_fire_feats = self.feats_in_range(max_m6a_start, max_m6a_end);
366        let worst_fire_feats = self.feats_in_range(min_m6a_start, min_m6a_end);
367
368        let msp_feats = self.feats_in_range(start, end);
369        let bins = get_bins(
370            centering_pos,
371            self.fire_opts.bin_num,
372            self.fire_opts.width_bin,
373            self.rec.record.seq_len() as i64,
374        );
375        let bin_feats = bins
376            .into_iter()
377            .map(|(start, end)| self.feats_in_range(start, end))
378            .collect::<Vec<FireFeatsInRange>>();
379        let msp_len_times_m6a_fc = msp_feats.m6a_fc * (msp_len as f32);
380        let mut rtn = vec![
381            msp_len as f32,
382            msp_len_times_m6a_fc,
383            ccs_passes,
384            self.m6a_count as f32,
385            self.frac_m6a,
386        ];
387        let feat_sets = vec![&msp_feats, &best_fire_feats, &worst_fire_feats]
388            .into_iter()
389            .chain(bin_feats.iter());
390        for feat_set in feat_sets {
391            rtn.push(feat_set.m6a_count);
392            //rtn.push(feat_set.at_count);
393            //rtn.push(feat_set.count_5mc);
394            rtn.push(feat_set.frac_m6a);
395            rtn.push(feat_set.m6a_fc);
396            //rtn.push(feat_set.weighted_m6a_rle);
397            //rtn.push(feat_set.median_m6a_rle);
398        }
399        rtn
400    }
401
402    pub fn get_fire_features(&mut self) {
403        let msp_data: Vec<_> = self.rec.msp().into_iter().collect();
404        self.fire_feats = msp_data
405            .into_iter()
406            .map(|annotation| {
407                let s = annotation.query_start as i64;
408                let e = annotation.query_end as i64;
409                let (rs, re) = match (annotation.ref_start, annotation.ref_end) {
410                    (Some(rs), Some(re)) => (rs as i64, re as i64),
411                    _ => (0, 0),
412                };
413                (rs, re, self.msp_get_fire_features(s, e))
414            })
415            .collect();
416    }
417
418    pub fn dump_fire_feats(&self, out_buffer: &mut Box<dyn Write>) -> Result<(), anyhow::Error> {
419        for (s, e, row) in self.fire_feats.iter() {
420            if row.is_empty() {
421                continue;
422            }
423            let lead_feats = format!(
424                "{}\t{}\t{}\t{}\t",
425                self.rec.target_name,
426                s,
427                e,
428                String::from_utf8_lossy(self.rec.record.qname())
429            );
430            out_buffer.write_all(lead_feats.as_bytes())?;
431            out_buffer.write_all(row.iter().join("\t").as_bytes())?;
432            out_buffer.write_all(b"\n")?;
433        }
434        Ok(())
435    }
436
437    pub fn predict_with_xgb(
438        &self,
439        gbdt_model: &GBDT,
440        precision_converter: &MapPrecisionValues,
441    ) -> Vec<u8> {
442        let count = self.fire_feats.len();
443        if count == 0 {
444            return vec![];
445        }
446        // predict on windows of sufficient length
447        let mut gbdt_data: DataVec = Vec::new();
448        for (_st, _en, window) in self.fire_feats.iter() {
449            if window.is_empty() {
450                continue;
451            }
452            let d = Data::new_test_data(window.to_vec(), None);
453            gbdt_data.push(d);
454        }
455        let predictions_without_short_ones = gbdt_model.predict(&gbdt_data);
456
457        // convert predictions to precision values, restoring empty windows
458        let mut precisions = Vec::with_capacity(count);
459        let mut cur_pos = 0;
460        for (_st, _en, window) in self.fire_feats.iter() {
461            if window.is_empty() {
462                precisions.push(0);
463            } else {
464                let precision = precision_converter
465                    .precision_from_float(predictions_without_short_ones[cur_pos]);
466                precisions.push(precision);
467                cur_pos += 1;
468            }
469        }
470        // check outputs
471        assert_eq!(cur_pos, predictions_without_short_ones.len());
472        assert_eq!(precisions.len(), count);
473        precisions
474    }
475}
476
477#[derive(Debug, Deserialize)]
478pub struct PrecisionTable {
479    pub columns: Vec<String>,
480    /// vec of (mokapot score, mokapot q-value)
481    pub data: Vec<(f32, f32)>,
482}
483
484pub struct MapPrecisionValues {
485    pub map: BTreeMap<OrderedFloat<f32>, u8>,
486}
487
488impl MapPrecisionValues {
489    pub fn new(pt: &PrecisionTable) -> Self {
490        // set up a precision table
491        let mut map = BTreeMap::new();
492
493        for (mokapot_score, mokapot_q_value) in pt.data.iter() {
494            let precision = ((1.0 - mokapot_q_value) * 255.0).round() as u8;
495            map.insert(OrderedFloat(*mokapot_score), precision);
496        }
497        // if we dont have a zero value insert one
498        map.insert(
499            OrderedFloat(0.0),
500            *map.get(&OrderedFloat(0.0)).unwrap_or(&0),
501        );
502        Self { map }
503    }
504
505    /// function to find closest value in a btree based on precision
506    pub fn precision_from_float(&self, value: f32) -> u8 {
507        let key = OrderedFloat(value);
508        // maximum in map less than key
509        let (less_key, less_val) = self
510            .map
511            .range(..key)
512            .next_back()
513            .unwrap_or((&OrderedFloat(0.0), &0));
514        // minimum in map greater than or equal to key
515        let (more_key, more_val) = self
516            .map
517            .range(key..)
518            .next()
519            .unwrap_or((&OrderedFloat(1.0), &255));
520        if (more_key - key).abs() < (less_key - key).abs() {
521            *more_val
522        } else {
523            *less_val
524        }
525    }
526}