1- // src/predict.rs
2-
1+ //! This module handles the `predict` subcommand.
2+ //!
3+ //! Its primary responsibilities are:
4+ //! 1. Loading a unified model bundle from a single file.
5+ //! 2. Reading new genome sequences from a FASTA file.
6+ //! 3. Transforming these sequences into feature vectors in parallel.
7+ //! 4. Using the loaded model to predict the class for each sequence.
8+ //! 5. Writing the predictions to an output file.
9+
10+ use anyhow:: { anyhow, Context , Result } ;
311use clap:: Parser ;
4- use chrono:: Local ;
512use flate2:: read:: GzDecoder ;
6- use indicatif:: ProgressBar ;
13+ use indicatif:: { ProgressBar , ProgressStyle } ;
14+ use log:: info;
15+ use rayon:: prelude:: * ;
716use serde:: { Deserialize , Serialize } ;
817use smartcore:: ensemble:: random_forest_classifier:: RandomForestClassifier ;
918use smartcore:: linalg:: basic:: matrix:: DenseMatrix ;
1019use std:: collections:: HashMap ;
11- use std:: error:: Error ;
1220use std:: fs:: File ;
1321use std:: io:: { BufRead , BufReader , Write } ;
14- use std:: time:: Instant ;
15-
16- const DEFAULT_KMER_SIZE : usize = 6 ;
1722
18- /// Converts a genomic sequence into overlapping k-mers.
19- fn kmerize ( sequence : & str , k : usize ) -> String {
20- if sequence. len ( ) < k {
21- return String :: new ( ) ;
22- }
23- ( 0 ..=sequence. len ( ) - k)
24- . map ( |i| & sequence[ i..i + k] )
25- . collect :: < Vec < & str > > ( )
26- . join ( " " )
23+ #[ derive( Serialize , Deserialize , Debug ) ]
24+ pub struct ModelConfig { pub kmer_size : usize , }
25+ #[ derive( Serialize , Deserialize , Debug ) ]
26+ pub struct ModelBundle {
27+ pub config : ModelConfig ,
28+ pub vectorizer : CountVectorizer ,
29+ pub label_encoder : LabelEncoder ,
30+ pub model : RandomForestClassifier < f64 , usize , DenseMatrix < f64 > , Vec < usize > > ,
2731}
2832
29- /// A simple count vectorizer that splits texts on whitespace.
30- #[ derive( Serialize , Deserialize , Debug ) ]
31- pub struct CountVectorizer {
32- pub vocabulary : HashMap < String , usize > ,
33- pub feature_names : Vec < String > ,
33+ #[ derive( Parser , Debug ) ]
34+ pub struct PredictArgs {
35+ #[ arg( short = 'i' , long) ] pub input : String ,
36+ #[ arg( short = 'm' , long) ] pub model : String ,
37+ #[ arg( short = 'o' , long) ] pub output : String ,
38+ #[ arg( short = 't' , long) ] pub threads : Option < usize > ,
3439}
3540
41+ #[ derive( Serialize , Deserialize , Debug ) ]
42+ pub struct CountVectorizer { pub vocabulary : HashMap < String , usize > , #[ allow( dead_code) ] pub feature_names : Vec < String > }
3643impl CountVectorizer {
37- pub fn new ( ) -> Self {
38- Self {
39- vocabulary : HashMap :: new ( ) ,
40- feature_names : Vec :: new ( ) ,
41- }
42- }
43- pub fn fit < T : AsRef < str > > ( & mut self , texts : & [ T ] ) {
44- let mut freq: HashMap < String , usize > = HashMap :: new ( ) ;
45- for text in texts {
46- for token in text. as_ref ( ) . split_whitespace ( ) {
47- * freq. entry ( token. to_string ( ) ) . or_insert ( 0 ) += 1 ;
48- }
49- }
50- let mut freq_vec: Vec < ( String , usize ) > = freq. into_iter ( ) . collect ( ) ;
51- freq_vec. sort_by ( |a, b| b. 1 . cmp ( & a. 1 ) ) ;
52- self . vocabulary = freq_vec
53- . iter ( )
54- . enumerate ( )
55- . map ( |( i, ( token, _) ) | ( token. clone ( ) , i) )
56- . collect ( ) ;
57- self . feature_names = freq_vec. into_iter ( ) . map ( |( token, _) | token) . collect ( ) ;
58- }
5944 pub fn transform < T : AsRef < str > + Sync > ( & self , texts : & [ T ] ) -> Vec < Vec < f64 > > {
60- texts
61- . iter ( )
62- . map ( |text| {
63- let n_features = self . vocabulary . len ( ) ;
64- let mut counts = vec ! [ 0.0 ; n_features] ;
65- for token in text. as_ref ( ) . split_whitespace ( ) {
66- if let Some ( & idx) = self . vocabulary . get ( token) {
67- counts[ idx] += 1.0 ;
68- }
69- }
70- counts
71- } )
72- . collect ( )
45+ texts. par_iter ( ) . map ( |text| {
46+ let mut counts = vec ! [ 0.0 ; self . vocabulary. len( ) ] ;
47+ for token in text. as_ref ( ) . split_whitespace ( ) { if let Some ( & idx) = self . vocabulary . get ( token) { counts[ idx] += 1.0 ; } }
48+ counts
49+ } ) . collect ( )
7350 }
7451}
75-
76- /// Label encoder that maps labels (strings) to numeric values.
7752#[ derive( Serialize , Deserialize , Debug ) ]
78- pub struct LabelEncoder {
79- pub label_to_int : HashMap < String , usize > ,
80- pub int_to_label : Vec < String > ,
81- }
53+ pub struct LabelEncoder { #[ allow( dead_code) ] pub label_to_int : HashMap < String , usize > , pub int_to_label : Vec < String > }
8254
83- impl LabelEncoder {
84- pub fn new ( ) -> Self {
85- Self {
86- label_to_int : HashMap :: new ( ) ,
87- int_to_label : Vec :: new ( ) ,
88- }
89- }
90- pub fn fit < T : AsRef < str > > ( & mut self , labels : & [ T ] ) {
91- for label in labels {
92- let label_str = label. as_ref ( ) ;
93- if !self . label_to_int . contains_key ( label_str) {
94- let index = self . int_to_label . len ( ) ;
95- self . label_to_int . insert ( label_str. to_string ( ) , index) ;
96- self . int_to_label . push ( label_str. to_string ( ) ) ;
97- }
98- }
99- }
100- pub fn transform < T : AsRef < str > > ( & self , labels : & [ T ] ) -> Vec < usize > {
101- labels
102- . iter ( )
103- . map ( |label| * self . label_to_int . get ( label. as_ref ( ) ) . unwrap ( ) )
104- . collect ( )
105- }
106- }
107-
108- /// Command-line arguments for the predict subcommand.
109- #[ derive( Parser , Debug ) ]
110- pub struct PredictArgs {
111- /// Input FASTA file (multi-FASTA; header in format "Lineage_sequenceID")
112- #[ arg( long) ]
113- pub fasta : String ,
114- /// Base name of the saved model (expects files: <model_base>_rf_model.bin.gz, etc.)
115- #[ arg( long) ]
116- pub model_base : String ,
117- /// Output file where predictions will be written.
118- #[ arg( long) ]
119- pub output : String ,
120- /// k-mer size (default is 6)
121- #[ arg( long, default_value_t = DEFAULT_KMER_SIZE ) ]
122- pub kmer_size : usize ,
55+ fn kmerize ( sequence : & str , k : usize ) -> String {
56+ if sequence. len ( ) < k { return String :: new ( ) ; }
57+ ( 0 ..=sequence. len ( ) - k) . map ( |i| & sequence[ i..i + k] ) . collect :: < Vec < & str > > ( ) . join ( " " )
12358}
12459
125- fn read_fasta_for_prediction ( path : & str ) -> Result < Vec < ( String , String ) > , Box < dyn Error > > {
60+ fn read_fasta_for_prediction ( path : & str ) -> Result < Vec < ( String , String ) > > {
12661 let file = File :: open ( path) ?;
127- let reader = BufReader :: new ( file) ;
128- let pb = ProgressBar :: new_spinner ( ) ;
129- pb. set_message ( "Processing FASTA records for prediction..." ) ;
62+ let file_size = file. metadata ( ) ?. len ( ) ;
63+ let pb = ProgressBar :: new ( file_size) ;
64+ pb. set_style ( ProgressStyle :: default_bar ( )
65+ . template ( "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})" ) ?
66+ . progress_chars ( "#>-" ) ) ;
67+
68+ let reader = BufReader :: new ( pb. wrap_read ( file) ) ;
13069 let mut records = Vec :: new ( ) ;
131- let mut current_header = String :: new ( ) ;
132- let mut current_seq = String :: new ( ) ;
70+ let mut current_header = String :: new ( ) ; let mut current_seq = String :: new ( ) ;
71+
13372 for line in reader. lines ( ) {
13473 let line = line?;
13574 if line. starts_with ( '>' ) {
136- if !current_header. is_empty ( ) {
137- records. push ( ( current_header. clone ( ) , current_seq. clone ( ) ) ) ;
138- pb. inc ( 1 ) ;
139- }
140- current_header = line. trim_start_matches ( '>' ) . to_string ( ) ;
141- current_seq. clear ( ) ;
142- } else {
143- current_seq. push_str ( line. trim ( ) ) ;
144- }
145- }
146- if !current_header. is_empty ( ) {
147- records. push ( ( current_header. clone ( ) , current_seq. clone ( ) ) ) ;
148- pb. inc ( 1 ) ;
75+ if !current_header. is_empty ( ) { records. push ( ( current_header. clone ( ) , current_seq. clone ( ) ) ) ; }
76+ current_header = line. trim_start_matches ( '>' ) . to_string ( ) ; current_seq. clear ( ) ;
77+ } else { current_seq. push_str ( line. trim ( ) ) ; }
14978 }
150- pb. finish_with_message ( "Finished processing FASTA records." ) ;
79+ if !current_header. is_empty ( ) { records. push ( ( current_header, current_seq) ) ; }
80+
81+ pb. finish_with_message ( "FASTA reading complete" ) ;
15182 Ok ( records)
15283}
15384
154- /// Main function for the predict subcommand.
155- pub fn run ( args : PredictArgs ) -> Result < ( ) , Box < dyn Error > > {
156- println ! ( "INFO: System start time: {}" , Local :: now( ) . format( "%Y-%m-%d %H:%M:%S" ) ) ;
157- let vectorizer_path = format ! ( "{}_vectorizer.bin.gz" , args. model_base) ;
158- let label_encoder_path = format ! ( "{}_label_encoder.bin.gz" , args. model_base) ;
159- let model_path = format ! ( "{}_rf_model.bin.gz" , args. model_base) ;
85+ pub fn run ( args : PredictArgs ) -> Result < ( ) > {
86+ if let Some ( n) = args. threads { rayon:: ThreadPoolBuilder :: new ( ) . num_threads ( n) . build_global ( ) ?; }
16087
161- // Load artifacts.
162- let vec_file = File :: open ( & vectorizer_path) ?;
163- let mut vec_decoder = GzDecoder :: new ( vec_file) ;
164- let vectorizer: CountVectorizer = bincode:: deserialize_from ( & mut vec_decoder) ?;
165- println ! ( "INFO: Loaded vectorizer from {}" , vectorizer_path) ;
166-
167- let label_file = File :: open ( & label_encoder_path) ?;
168- let mut label_decoder = GzDecoder :: new ( label_file) ;
169- let label_encoder: LabelEncoder = bincode:: deserialize_from ( & mut label_decoder) ?;
170- println ! ( "INFO: Loaded label encoder from {}" , label_encoder_path) ;
171-
172- let model_file = File :: open ( & model_path) ?;
88+ info ! ( "▶ Loading model bundle from {}" , args. model) ;
89+ let model_file = File :: open ( & args. model ) ?;
17390 let mut model_decoder = GzDecoder :: new ( model_file) ;
174- let model: RandomForestClassifier < f64 , usize , DenseMatrix < f64 > , Vec < usize > > =
175- bincode:: deserialize_from ( & mut model_decoder) ?;
176- println ! ( "INFO: Loaded model from {}" , model_path) ;
177-
178- println ! ( "INFO: Reading input FASTA file: {}" , args. fasta) ;
179- let records = read_fasta_for_prediction ( & args. fasta ) ?;
180- if records. is_empty ( ) {
181- return Err ( "No records found in the input FASTA file." . into ( ) ) ;
182- }
183- println ! ( "INFO: Read {} records." , records. len( ) ) ;
184-
185- let texts: Vec < String > = records. iter ( ) . map ( |( _, seq) | kmerize ( seq, args. kmer_size ) ) . collect ( ) ;
186- let x_data = vectorizer. transform ( & texts) ;
187- let x_matrix = DenseMatrix :: from_2d_vec ( & x_data)
188- . map_err ( |_| "Failed to create feature matrix" ) ?;
189-
190- let predict_start = Instant :: now ( ) ;
191- let y_pred = model. predict ( & x_matrix)
192- . map_err ( |e| format ! ( "Error during prediction: {:?}" , e) ) ?;
193- println ! ( "INFO: Prediction completed in {:.2} seconds." , predict_start. elapsed( ) . as_secs_f32( ) ) ;
194-
195- let default_prediction = String :: from ( "Unknown" ) ;
91+ let bundle: ModelBundle = bincode:: deserialize_from ( & mut model_decoder)
92+ . context ( "Failed to deserialize the model bundle." ) ?;
93+ info ! ( " Model bundle loaded. Using k-mer size: {}" , bundle. config. kmer_size) ;
94+
95+ info ! ( "▶ Reading input FASTA file: {}" , args. input) ;
96+ let records = read_fasta_for_prediction ( & args. input ) ?;
97+ if records. is_empty ( ) { return Err ( anyhow ! ( "No records found in the input FASTA file." ) ) ; }
98+
99+ info ! ( "▶ Generating k-mers and transforming features..." ) ;
100+ let texts: Vec < String > = records. par_iter ( ) . map ( |( _, seq) | kmerize ( seq, bundle. config . kmer_size ) ) . collect ( ) ;
101+ let x_data = bundle. vectorizer . transform ( & texts) ;
102+ let x_matrix = DenseMatrix :: from_2d_vec ( & x_data) ?;
103+
104+ info ! ( "▶ Predicting lineages..." ) ;
105+ let y_pred = bundle. model . predict ( & x_matrix) ?;
106+
107+ let default_prediction = "Unknown" . to_string ( ) ;
196108 let predictions: Vec < String > = y_pred. iter ( ) . map ( |& class| {
197- label_encoder. int_to_label . get ( class) . cloned ( ) . unwrap_or_else ( || default_prediction. clone ( ) )
109+ bundle . label_encoder . int_to_label . get ( class) . cloned ( ) . unwrap_or ( default_prediction. clone ( ) )
198110 } ) . collect ( ) ;
199111
200112 let mut output_file = File :: create ( & args. output ) ?;
@@ -203,7 +115,7 @@ pub fn run(args: PredictArgs) -> Result<(), Box<dyn Error>> {
203115 let pred = predictions. get ( i) . unwrap_or ( & default_prediction) ;
204116 writeln ! ( output_file, "{}\t {}" , header, pred) ?;
205117 }
206- println ! ( "INFO: Predictions written to {}" , args . output ) ;
207- println ! ( "INFO: System finish time: {}" , Local :: now ( ) . format ( "%Y-%m-%d %H:%M:%S" ) ) ;
118+
119+ info ! ( "✅ Predictions written to {}" , args . output ) ;
208120 Ok ( ( ) )
209121}
0 commit comments