Skip to main content

sklears_feature_extraction/text/
mod.rs

1//! Text feature extraction utilities
2//!
3//! This module provides comprehensive text feature extraction implementations including
4//! text preprocessing, embeddings, and various vectorization techniques.
5
6use scirs2_core::ndarray::{Array1, Array2, ArrayView2, Axis};
7use sklears_core::{
8    error::Result as SklResult,
9    prelude::{Fit, SklearsError, Transform},
10    types::Float,
11};
12use std::collections::HashMap;
13
14// Export existing modules
15pub mod advanced_sentiment;
16pub mod embeddings;
17pub mod preprocessing;
18pub mod trait_impls;
19
20/// Complete Count Vectorizer implementation for text feature extraction
21///
22/// Converts a collection of text documents to a matrix of token counts.
23/// Supports n-grams, stop word filtering, and various preprocessing options.
24#[derive(Debug, Clone)]
25pub struct CountVectorizer {
26    max_features: Option<usize>,
27    min_df: Option<usize>,
28    max_df: Option<f64>,
29    ngram_range: (usize, usize),
30    binary: bool,
31    lowercase: bool,
32    stop_words: Option<Vec<String>>,
33    vocabulary: Option<HashMap<String, usize>>,
34    feature_names: Vec<String>,
35    document_frequencies: HashMap<String, usize>,
36}
37
38impl CountVectorizer {
39    /// Create a new CountVectorizer with default parameters
40    pub fn new() -> Self {
41        Self {
42            max_features: None,
43            min_df: Some(1),
44            max_df: Some(1.0),
45            ngram_range: (1, 1),
46            binary: false,
47            lowercase: true,
48            stop_words: None,
49            vocabulary: None,
50            feature_names: Vec::new(),
51            document_frequencies: HashMap::new(),
52        }
53    }
54
55    /// Set maximum number of features to select
56    pub fn max_features(mut self, max_features: Option<usize>) -> Self {
57        self.max_features = max_features;
58        self
59    }
60
61    /// Set minimum document frequency for feature inclusion
62    pub fn min_df(mut self, min_df: usize) -> Self {
63        self.min_df = Some(min_df);
64        self
65    }
66
67    /// Set maximum document frequency for feature inclusion
68    pub fn max_df(mut self, max_df: f64) -> Self {
69        self.max_df = Some(max_df);
70        self
71    }
72
73    /// Set n-gram range (min_n, max_n)
74    pub fn ngram_range(mut self, ngram_range: (usize, usize)) -> Self {
75        self.ngram_range = ngram_range;
76        self
77    }
78
79    /// Set binary mode (presence/absence instead of counts)
80    pub fn binary(mut self, binary: bool) -> Self {
81        self.binary = binary;
82        self
83    }
84
85    /// Set whether to convert to lowercase
86    pub fn lowercase(mut self, lowercase: bool) -> Self {
87        self.lowercase = lowercase;
88        self
89    }
90
91    /// Set stop words to filter out
92    pub fn stop_words(mut self, stop_words: Option<Vec<String>>) -> Self {
93        self.stop_words = stop_words;
94        self
95    }
96
97    /// Fit the vectorizer to a collection of documents
98    pub fn fit_instance_method(&mut self, documents: &[String]) -> SklResult<()> {
99        if documents.is_empty() {
100            return Err(SklearsError::InvalidInput(
101                "Empty document collection".to_string(),
102            ));
103        }
104
105        // Extract all n-grams and count document frequencies
106        let mut term_doc_freq = HashMap::new();
107        let total_docs = documents.len();
108
109        for document in documents {
110            let tokens = self.tokenize(document);
111            let ngrams = self.extract_ngrams(&tokens);
112            let unique_ngrams: std::collections::HashSet<_> = ngrams.into_iter().collect();
113
114            for ngram in unique_ngrams {
115                *term_doc_freq.entry(ngram).or_insert(0) += 1;
116            }
117        }
118
119        // Filter terms based on document frequency constraints
120        let mut filtered_terms = Vec::new();
121        let min_df = self.min_df.unwrap_or(1);
122        let max_df_count = if let Some(max_df) = self.max_df {
123            if max_df <= 1.0 {
124                (total_docs as f64 * max_df).ceil() as usize
125            } else {
126                max_df as usize
127            }
128        } else {
129            total_docs
130        };
131
132        for (term, doc_freq) in &term_doc_freq {
133            if *doc_freq >= min_df && *doc_freq <= max_df_count {
134                if let Some(stop_words) = &self.stop_words {
135                    if !stop_words.contains(term) {
136                        filtered_terms.push(term.clone());
137                    }
138                } else {
139                    filtered_terms.push(term.clone());
140                }
141            }
142        }
143
144        // Sort terms for consistent ordering
145        filtered_terms.sort();
146
147        // Limit features if specified
148        if let Some(max_features) = self.max_features {
149            if filtered_terms.len() > max_features {
150                // Sort by document frequency and take most frequent terms
151                filtered_terms.sort_by_key(|term| std::cmp::Reverse(term_doc_freq[term]));
152                filtered_terms.truncate(max_features);
153                filtered_terms.sort(); // Sort alphabetically again
154            }
155        }
156
157        // Build vocabulary
158        let mut vocabulary = HashMap::new();
159        for (idx, term) in filtered_terms.iter().enumerate() {
160            vocabulary.insert(term.clone(), idx);
161        }
162
163        self.vocabulary = Some(vocabulary);
164        self.feature_names = filtered_terms;
165        self.document_frequencies = term_doc_freq;
166
167        Ok(())
168    }
169
170    /// Transform documents to feature vectors
171    pub fn transform(&self, documents: &[String]) -> SklResult<Array2<Float>> {
172        let vocabulary = self
173            .vocabulary
174            .as_ref()
175            .ok_or_else(|| SklearsError::InvalidInput("Vectorizer not fitted".to_string()))?;
176
177        let n_docs = documents.len();
178        let n_features = vocabulary.len();
179        let mut feature_matrix = Array2::zeros((n_docs, n_features));
180
181        for (doc_idx, document) in documents.iter().enumerate() {
182            let tokens = self.tokenize(document);
183            let ngrams = self.extract_ngrams(&tokens);
184
185            // Count n-gram frequencies
186            let mut term_counts = HashMap::new();
187            for ngram in ngrams {
188                if vocabulary.contains_key(&ngram) {
189                    *term_counts.entry(ngram).or_insert(0) += 1;
190                }
191            }
192
193            // Fill feature vector
194            for (term, count) in term_counts {
195                if let Some(&feature_idx) = vocabulary.get(&term) {
196                    let value = if self.binary { 1.0 } else { count as Float };
197                    feature_matrix[(doc_idx, feature_idx)] = value;
198                }
199            }
200        }
201
202        Ok(feature_matrix)
203    }
204
205    /// Fit and transform in one step
206    pub fn fit_transform(&mut self, documents: &[String]) -> SklResult<Array2<Float>> {
207        self.fit_instance_method(documents)?;
208        self.transform(documents)
209    }
210
211    /// Get feature names
212    pub fn get_feature_names(&self) -> &[String] {
213        &self.feature_names
214    }
215
216    /// Get vocabulary
217    pub fn get_vocabulary(&self) -> Option<&HashMap<String, usize>> {
218        self.vocabulary.as_ref()
219    }
220
221    pub(crate) fn tokenize(&self, text: &str) -> Vec<String> {
222        let text = if self.lowercase {
223            text.to_lowercase()
224        } else {
225            text.to_string()
226        };
227
228        // Simple whitespace and punctuation tokenization
229        text.split_whitespace()
230            .map(|token| {
231                // Remove punctuation from ends
232                token
233                    .trim_matches(|c: char| c.is_ascii_punctuation())
234                    .to_string()
235            })
236            .filter(|token| !token.is_empty())
237            .collect()
238    }
239
240    pub(crate) fn extract_ngrams(&self, tokens: &[String]) -> Vec<String> {
241        let mut ngrams = Vec::new();
242        let (min_n, max_n) = self.ngram_range;
243
244        for n in min_n..=max_n {
245            if n <= tokens.len() {
246                for i in 0..=tokens.len() - n {
247                    let ngram = tokens[i..i + n].join(" ");
248                    ngrams.push(ngram);
249                }
250            }
251        }
252
253        ngrams
254    }
255}
256
257impl Default for CountVectorizer {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263/// Fitted CountVectorizer that can transform new documents
264#[derive(Debug, Clone)]
265pub struct FittedCountVectorizer {
266    inner: CountVectorizer,
267}
268
269impl FittedCountVectorizer {
270    /// Transform new documents using the fitted vocabulary
271    pub fn transform(&self, documents: &[String]) -> SklResult<Array2<Float>> {
272        self.inner.transform(documents)
273    }
274
275    /// Get feature names
276    pub fn get_feature_names(&self) -> &[String] {
277        self.inner.get_feature_names()
278    }
279
280    /// Get vocabulary
281    pub fn get_vocabulary(&self) -> Option<&HashMap<String, usize>> {
282        self.inner.get_vocabulary()
283    }
284}
285
286impl Fit<Vec<String>, ()> for CountVectorizer {
287    type Fitted = FittedCountVectorizer;
288
289    fn fit(mut self, x: &Vec<String>, _y: &()) -> SklResult<Self::Fitted> {
290        self.fit_instance_method(x)?;
291        Ok(FittedCountVectorizer { inner: self })
292    }
293}
294
295impl Transform<Vec<String>, Array2<Float>> for FittedCountVectorizer {
296    fn transform(&self, x: &Vec<String>) -> SklResult<Array2<Float>> {
297        self.transform(x)
298    }
299}
300
301/// Complete TF-IDF Vectorizer implementation
302///
303/// Converts text documents to TF-IDF weighted feature vectors.
304/// Combines term frequency (TF) with inverse document frequency (IDF).
305#[derive(Debug, Clone)]
306pub struct TfidfVectorizer {
307    max_features: Option<usize>,
308    min_df: Option<usize>,
309    max_df: Option<f64>,
310    ngram_range: (usize, usize),
311    use_idf: bool,
312    sublinear_tf: bool,
313    smooth_idf: bool,
314    norm: Option<String>,
315    lowercase: bool,
316    stop_words: Option<Vec<String>>,
317    count_vectorizer: CountVectorizer,
318    idf_weights: Vec<Float>,
319}
320
321impl TfidfVectorizer {
322    /// Create a new TfidfVectorizer with default parameters
323    pub fn new() -> Self {
324        Self {
325            max_features: None,
326            min_df: Some(1),
327            max_df: Some(1.0),
328            ngram_range: (1, 1),
329            use_idf: true,
330            sublinear_tf: false,
331            smooth_idf: true,
332            norm: Some("l2".to_string()),
333            lowercase: true,
334            stop_words: None,
335            count_vectorizer: CountVectorizer::new(),
336            idf_weights: Vec::new(),
337        }
338    }
339
340    /// Set maximum number of features
341    pub fn max_features(mut self, max_features: Option<usize>) -> Self {
342        self.max_features = max_features;
343        self.count_vectorizer = self.count_vectorizer.max_features(max_features);
344        self
345    }
346
347    /// Set minimum document frequency
348    pub fn min_df(mut self, min_df: usize) -> Self {
349        self.min_df = Some(min_df);
350        self.count_vectorizer = self.count_vectorizer.min_df(min_df);
351        self
352    }
353
354    /// Set maximum document frequency
355    pub fn max_df(mut self, max_df: f64) -> Self {
356        self.max_df = Some(max_df);
357        self.count_vectorizer = self.count_vectorizer.max_df(max_df);
358        self
359    }
360
361    /// Set n-gram range
362    pub fn ngram_range(mut self, ngram_range: (usize, usize)) -> Self {
363        self.ngram_range = ngram_range;
364        self.count_vectorizer = self.count_vectorizer.ngram_range(ngram_range);
365        self
366    }
367
368    /// Set whether to use IDF weighting
369    pub fn use_idf(mut self, use_idf: bool) -> Self {
370        self.use_idf = use_idf;
371        self
372    }
373
374    /// Set whether to use sublinear TF scaling (1 + log(tf))
375    pub fn sublinear_tf(mut self, sublinear_tf: bool) -> Self {
376        self.sublinear_tf = sublinear_tf;
377        self
378    }
379
380    /// Set whether to add one to IDF weights to prevent zero divisions
381    pub fn smooth_idf(mut self, smooth_idf: bool) -> Self {
382        self.smooth_idf = smooth_idf;
383        self
384    }
385
386    /// Set normalization method ("l1", "l2", or None)
387    pub fn norm(mut self, norm: Option<String>) -> Self {
388        self.norm = norm;
389        self
390    }
391
392    /// Set whether to convert to lowercase
393    pub fn lowercase(mut self, lowercase: bool) -> Self {
394        self.lowercase = lowercase;
395        self.count_vectorizer = self.count_vectorizer.lowercase(lowercase);
396        self
397    }
398
399    /// Set stop words
400    pub fn stop_words(mut self, stop_words: Option<Vec<String>>) -> Self {
401        self.stop_words = stop_words.clone();
402        self.count_vectorizer = self.count_vectorizer.stop_words(stop_words);
403        self
404    }
405
406    /// Fit the vectorizer to documents
407    pub fn fit_instance_method(&mut self, documents: &[String]) -> SklResult<()> {
408        if documents.is_empty() {
409            return Err(SklearsError::InvalidInput(
410                "Empty document collection".to_string(),
411            ));
412        }
413
414        // Fit the count vectorizer
415        self.count_vectorizer.fit_instance_method(documents)?;
416
417        if self.use_idf {
418            // Calculate IDF weights
419            self.calculate_idf_weights(documents)?;
420        }
421
422        Ok(())
423    }
424
425    /// Transform documents to TF-IDF vectors
426    pub fn transform(&self, documents: &[String]) -> SklResult<Array2<Float>> {
427        // Get raw term frequencies
428        let mut tf_matrix = self.count_vectorizer.transform(documents)?;
429
430        // Apply sublinear TF scaling if enabled
431        if self.sublinear_tf {
432            tf_matrix.mapv_inplace(|x| if x > 0.0 { 1.0 + x.ln() } else { 0.0 });
433        }
434
435        // Apply IDF weighting if enabled
436        if self.use_idf && !self.idf_weights.is_empty() {
437            for mut row in tf_matrix.axis_iter_mut(Axis(0)) {
438                for (i, &idf_weight) in self.idf_weights.iter().enumerate() {
439                    row[i] *= idf_weight;
440                }
441            }
442        }
443
444        // Apply normalization if specified
445        if let Some(norm_type) = &self.norm {
446            self.normalize_rows(&mut tf_matrix, norm_type)?;
447        }
448
449        Ok(tf_matrix)
450    }
451
452    /// Fit and transform in one step
453    pub fn fit_transform(&mut self, documents: &[String]) -> SklResult<Array2<Float>> {
454        self.fit_instance_method(documents)?;
455        self.transform(documents)
456    }
457
458    /// Get feature names
459    pub fn get_feature_names(&self) -> &[String] {
460        self.count_vectorizer.get_feature_names()
461    }
462
463    /// Get vocabulary
464    pub fn get_vocabulary(&self) -> Option<&HashMap<String, usize>> {
465        self.count_vectorizer.get_vocabulary()
466    }
467
468    /// Get IDF weights
469    pub fn get_idf_weights(&self) -> &[Float] {
470        &self.idf_weights
471    }
472
473    fn calculate_idf_weights(&mut self, documents: &[String]) -> SklResult<()> {
474        let vocabulary = self
475            .count_vectorizer
476            .get_vocabulary()
477            .ok_or_else(|| SklearsError::InvalidInput("Count vectorizer not fitted".to_string()))?;
478
479        let n_docs = documents.len() as Float;
480        let mut idf_weights = vec![0.0; vocabulary.len()];
481
482        // Count document frequencies for each term
483        let mut doc_frequencies = vec![0; vocabulary.len()];
484
485        for document in documents {
486            let tokens = self.count_vectorizer.tokenize(document);
487            let ngrams = self.count_vectorizer.extract_ngrams(&tokens);
488            let unique_ngrams: std::collections::HashSet<_> = ngrams.into_iter().collect();
489
490            for ngram in unique_ngrams {
491                if let Some(&feature_idx) = vocabulary.get(&ngram) {
492                    doc_frequencies[feature_idx] += 1;
493                }
494            }
495        }
496
497        // Calculate IDF weights
498        for (i, &doc_freq) in doc_frequencies.iter().enumerate() {
499            let df = doc_freq as Float;
500            let idf = if self.smooth_idf {
501                (n_docs / (1.0 + df)).ln() + 1.0
502            } else {
503                (n_docs / df).ln() + 1.0
504            };
505            idf_weights[i] = idf;
506        }
507
508        self.idf_weights = idf_weights;
509        Ok(())
510    }
511
512    fn normalize_rows(&self, matrix: &mut Array2<Float>, norm_type: &str) -> SklResult<()> {
513        for mut row in matrix.axis_iter_mut(Axis(0)) {
514            let norm = match norm_type {
515                "l1" => row.iter().map(|x| x.abs()).sum::<Float>(),
516                "l2" => row.iter().map(|x| x * x).sum::<Float>().sqrt(),
517                _ => {
518                    return Err(SklearsError::InvalidInput(format!(
519                        "Unknown norm type: {}",
520                        norm_type
521                    )))
522                }
523            };
524
525            if norm > 0.0 {
526                for x in row.iter_mut() {
527                    *x /= norm;
528                }
529            }
530        }
531
532        Ok(())
533    }
534}
535
536impl Default for TfidfVectorizer {
537    fn default() -> Self {
538        Self::new()
539    }
540}
541
542/// Fitted TfidfVectorizer that can transform new documents
543#[derive(Debug, Clone)]
544pub struct FittedTfidfVectorizer {
545    inner: TfidfVectorizer,
546}
547
548impl FittedTfidfVectorizer {
549    /// Transform new documents using the fitted vectorizer
550    pub fn transform(&self, documents: &[String]) -> SklResult<Array2<Float>> {
551        self.inner.transform(documents)
552    }
553
554    /// Get feature names
555    pub fn get_feature_names(&self) -> &[String] {
556        self.inner.get_feature_names()
557    }
558
559    /// Get vocabulary
560    pub fn get_vocabulary(&self) -> Option<&HashMap<String, usize>> {
561        self.inner.get_vocabulary()
562    }
563
564    /// Get IDF weights
565    pub fn get_idf_weights(&self) -> &[Float] {
566        self.inner.get_idf_weights()
567    }
568}
569
570impl Fit<Vec<String>, ()> for TfidfVectorizer {
571    type Fitted = FittedTfidfVectorizer;
572
573    fn fit(mut self, x: &Vec<String>, _y: &()) -> SklResult<Self::Fitted> {
574        self.fit_instance_method(x)?;
575        Ok(FittedTfidfVectorizer { inner: self })
576    }
577}
578
579impl Transform<Vec<String>, Array2<Float>> for FittedTfidfVectorizer {
580    fn transform(&self, x: &Vec<String>) -> SklResult<Array2<Float>> {
581        self.inner.transform(x)
582    }
583}
584
585#[derive(Debug, Clone)]
586pub struct HashingVectorizer {
587    n_features: usize,
588    #[allow(dead_code)] // ngram_range retained for future n-gram tokenization support
589    ngram_range: (usize, usize),
590}
591
592impl HashingVectorizer {
593    pub fn new() -> Self {
594        Self {
595            n_features: 1024,
596            ngram_range: (1, 1),
597        }
598    }
599
600    pub fn n_features(mut self, n_features: usize) -> Self {
601        self.n_features = n_features;
602        self
603    }
604}
605
606impl Default for HashingVectorizer {
607    fn default() -> Self {
608        Self::new()
609    }
610}
611
612/// Sentiment Analysis extractor for text
613///
614/// Provides simple rule-based sentiment analysis with lexicon-based scoring
615/// and configurable sentiment categories.
616#[derive(Debug, Clone)]
617pub struct SentimentAnalyzer {
618    positive_words: std::collections::HashSet<String>,
619    negative_words: std::collections::HashSet<String>,
620    neutral_threshold: f64,
621    case_sensitive: bool,
622}
623
624impl SentimentAnalyzer {
625    /// Create a new sentiment analyzer with default lexicon
626    pub fn new() -> Self {
627        let mut analyzer = Self {
628            positive_words: std::collections::HashSet::new(),
629            negative_words: std::collections::HashSet::new(),
630            neutral_threshold: 0.1,
631            case_sensitive: false,
632        };
633
634        // Add basic positive words
635        for word in &[
636            "good",
637            "great",
638            "excellent",
639            "amazing",
640            "wonderful",
641            "fantastic",
642            "awesome",
643            "love",
644            "like",
645            "enjoy",
646            "happy",
647            "pleased",
648            "satisfied",
649            "perfect",
650            "best",
651            "brilliant",
652            "outstanding",
653            "superb",
654            "marvelous",
655            "incredible",
656        ] {
657            analyzer.positive_words.insert(word.to_string());
658        }
659
660        // Add basic negative words
661        for word in &[
662            "bad",
663            "terrible",
664            "awful",
665            "horrible",
666            "disgusting",
667            "hate",
668            "dislike",
669            "disappointed",
670            "frustrated",
671            "angry",
672            "sad",
673            "worst",
674            "pathetic",
675            "useless",
676            "annoying",
677            "boring",
678            "stupid",
679            "ridiculous",
680            "poor",
681        ] {
682            analyzer.negative_words.insert(word.to_string());
683        }
684
685        analyzer
686    }
687
688    /// Set neutral threshold (sentiment scores within [-threshold, threshold] are neutral)
689    pub fn neutral_threshold(mut self, threshold: f64) -> Self {
690        self.neutral_threshold = threshold;
691        self
692    }
693
694    /// Set case sensitivity
695    pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
696        self.case_sensitive = case_sensitive;
697        self
698    }
699
700    /// Add custom positive words to the lexicon
701    pub fn add_positive_words(mut self, words: Vec<String>) -> Self {
702        for word in words {
703            self.positive_words.insert(word);
704        }
705        self
706    }
707
708    /// Add custom negative words to the lexicon
709    pub fn add_negative_words(mut self, words: Vec<String>) -> Self {
710        for word in words {
711            self.negative_words.insert(word);
712        }
713        self
714    }
715
716    /// Analyze sentiment of a single text
717    pub fn analyze_sentiment(&self, text: &str) -> SentimentResult {
718        let text = if self.case_sensitive {
719            text.to_string()
720        } else {
721            text.to_lowercase()
722        };
723
724        let words: Vec<&str> = text.split_whitespace().collect();
725        let mut positive_count = 0;
726        let mut negative_count = 0;
727
728        for word in &words {
729            // Remove punctuation
730            let clean_word = word.trim_matches(|c: char| c.is_ascii_punctuation());
731
732            if self.positive_words.contains(clean_word) {
733                positive_count += 1;
734            } else if self.negative_words.contains(clean_word) {
735                negative_count += 1;
736            }
737        }
738
739        let total_sentiment_words = positive_count + negative_count;
740        let score = if total_sentiment_words == 0 {
741            0.0
742        } else {
743            (positive_count as f64 - negative_count as f64) / total_sentiment_words as f64
744        };
745
746        let polarity = if score.abs() <= self.neutral_threshold {
747            SentimentPolarity::Neutral
748        } else if score > 0.0 {
749            SentimentPolarity::Positive
750        } else {
751            SentimentPolarity::Negative
752        };
753
754        // SentimentResult
755        SentimentResult {
756            polarity,
757            score,
758            positive_words: positive_count,
759            negative_words: negative_count,
760            total_words: words.len(),
761        }
762    }
763
764    /// Extract sentiment features from multiple documents
765    pub fn extract_features(&self, documents: &[String]) -> SklResult<Array2<Float>> {
766        if documents.is_empty() {
767            return Err(SklearsError::InvalidInput(
768                "Empty document collection".to_string(),
769            ));
770        }
771
772        let mut features = Array2::zeros((documents.len(), 5));
773
774        for (i, document) in documents.iter().enumerate() {
775            let sentiment = self.analyze_sentiment(document);
776
777            // Feature vector: [score, positive_ratio, negative_ratio, sentiment_density, polarity_encoded]
778            features[(i, 0)] = sentiment.score;
779            features[(i, 1)] =
780                sentiment.positive_words as f64 / sentiment.total_words.max(1) as f64;
781            features[(i, 2)] =
782                sentiment.negative_words as f64 / sentiment.total_words.max(1) as f64;
783            features[(i, 3)] = (sentiment.positive_words + sentiment.negative_words) as f64
784                / sentiment.total_words.max(1) as f64;
785            features[(i, 4)] = match sentiment.polarity {
786                SentimentPolarity::Negative => -1.0,
787                SentimentPolarity::Neutral => 0.0,
788                SentimentPolarity::Positive => 1.0,
789            };
790        }
791
792        Ok(features)
793    }
794}
795
796impl Default for SentimentAnalyzer {
797    fn default() -> Self {
798        Self::new()
799    }
800}
801
802/// Sentiment analysis result
803#[derive(Debug, Clone)]
804pub struct SentimentResult {
805    /// polarity
806    pub polarity: SentimentPolarity,
807    /// score
808    pub score: f64,
809    /// positive_words
810    pub positive_words: usize,
811    /// negative_words
812    pub negative_words: usize,
813    /// total_words
814    pub total_words: usize,
815}
816
817/// Sentiment polarity classification
818#[derive(Debug, Clone, PartialEq)]
819pub enum SentimentPolarity {
820    /// Positive
821    Positive,
822    /// Negative
823    Negative,
824    /// Neutral
825    Neutral,
826}
827
828/// Memory-efficient streaming text processor
829///
830/// Processes large text datasets in chunks to minimize memory usage
831/// while maintaining feature extraction quality.
832#[derive(Debug, Clone)]
833pub struct StreamingTextProcessor {
834    chunk_size: usize,
835    overlap_size: usize,
836    min_chunk_words: usize,
837}
838
839impl StreamingTextProcessor {
840    /// Create a new streaming text processor
841    pub fn new() -> Self {
842        Self {
843            chunk_size: 10000, // characters per chunk
844            overlap_size: 1000,
845            min_chunk_words: 50,
846        }
847    }
848
849    /// Set chunk size in characters
850    pub fn chunk_size(mut self, chunk_size: usize) -> Self {
851        self.chunk_size = chunk_size;
852        self
853    }
854
855    /// Set overlap size between chunks
856    pub fn overlap_size(mut self, overlap_size: usize) -> Self {
857        self.overlap_size = overlap_size;
858        self
859    }
860
861    /// Set minimum words per chunk
862    pub fn min_chunk_words(mut self, min_words: usize) -> Self {
863        self.min_chunk_words = min_words;
864        self
865    }
866
867    /// Process large text with a streaming vectorizer
868    pub fn stream_process_with_count_vectorizer(
869        &self,
870        large_text: &str,
871        vectorizer: &mut CountVectorizer,
872    ) -> SklResult<Array2<Float>> {
873        let chunks = self.create_chunks(large_text);
874
875        if chunks.is_empty() {
876            return Err(SklearsError::InvalidInput(
877                "No valid chunks created".to_string(),
878            ));
879        }
880
881        // First pass: fit vocabulary on a sample of chunks
882        let sample_size = (chunks.len() / 4).max(1);
883        let sample_chunks: Vec<String> = chunks
884            .iter()
885            .step_by(chunks.len() / sample_size)
886            .take(sample_size)
887            .cloned()
888            .collect();
889
890        vectorizer.fit_instance_method(&sample_chunks)?;
891
892        // Second pass: transform all chunks
893        let chunk_features = vectorizer.transform(&chunks)?;
894
895        // Aggregate features (e.g., mean across chunks)
896        let (n_chunks, n_features) = chunk_features.dim();
897        let mut aggregated = Array2::zeros((1, n_features));
898
899        for j in 0..n_features {
900            let mut sum = 0.0;
901            for i in 0..n_chunks {
902                sum += chunk_features[(i, j)];
903            }
904            aggregated[(0, j)] = sum / n_chunks as f64;
905        }
906
907        Ok(aggregated)
908    }
909
910    /// Process large text with streaming TF-IDF
911    pub fn stream_process_with_tfidf(
912        &self,
913        large_text: &str,
914        vectorizer: &mut TfidfVectorizer,
915    ) -> SklResult<Array2<Float>> {
916        let chunks = self.create_chunks(large_text);
917
918        if chunks.is_empty() {
919            return Err(SklearsError::InvalidInput(
920                "No valid chunks created".to_string(),
921            ));
922        }
923
924        // Fit and transform in streaming fashion
925        vectorizer.fit_instance_method(&chunks)?;
926        let chunk_features = vectorizer.transform(&chunks)?;
927
928        // Aggregate using weighted average based on chunk size
929        let (n_chunks, n_features) = chunk_features.dim();
930        let mut aggregated = Array2::zeros((1, n_features));
931        let chunk_weights: Vec<f64> = chunks
932            .iter()
933            .map(|chunk| chunk.split_whitespace().count() as f64)
934            .collect();
935        let total_weight: f64 = chunk_weights.iter().sum();
936
937        for j in 0..n_features {
938            let mut weighted_sum = 0.0;
939            for i in 0..n_chunks {
940                weighted_sum += chunk_features[(i, j)] * chunk_weights[i];
941            }
942            aggregated[(0, j)] = weighted_sum / total_weight;
943        }
944
945        Ok(aggregated)
946    }
947
948    /// Extract memory-efficient statistical features
949    pub fn extract_streaming_stats(&self, large_text: &str) -> SklResult<Array1<Float>> {
950        let chunks = self.create_chunks(large_text);
951
952        if chunks.is_empty() {
953            return Err(SklearsError::InvalidInput(
954                "No valid chunks created".to_string(),
955            ));
956        }
957
958        let mut total_chars = 0;
959        let mut total_words = 0;
960        let mut total_sentences = 0;
961        let mut word_lengths = Vec::new();
962
963        for chunk in &chunks {
964            total_chars += chunk.len();
965            let words: Vec<&str> = chunk.split_whitespace().collect();
966            total_words += words.len();
967
968            // Count sentences (approximate)
969            total_sentences += chunk.matches('.').count()
970                + chunk.matches('!').count()
971                + chunk.matches('?').count();
972
973            // Collect word lengths for statistics
974            for word in words {
975                let clean_word = word.trim_matches(|c: char| c.is_ascii_punctuation());
976                if !clean_word.is_empty() {
977                    word_lengths.push(clean_word.len() as f64);
978                }
979            }
980        }
981
982        // Calculate statistics
983        let avg_word_length = if !word_lengths.is_empty() {
984            word_lengths.iter().sum::<f64>() / word_lengths.len() as f64
985        } else {
986            0.0
987        };
988
989        let avg_words_per_sentence = if total_sentences > 0 {
990            total_words as f64 / total_sentences as f64
991        } else {
992            0.0
993        };
994
995        let features = vec![
996            total_chars as f64,
997            total_words as f64,
998            total_sentences as f64,
999            avg_word_length,
1000            avg_words_per_sentence,
1001            total_chars as f64 / total_words.max(1) as f64, // avg chars per word
1002        ];
1003
1004        Ok(Array1::from_vec(features))
1005    }
1006
1007    fn create_chunks(&self, text: &str) -> Vec<String> {
1008        let mut chunks = Vec::new();
1009        let mut start = 0;
1010
1011        while start < text.len() {
1012            let end = (start + self.chunk_size).min(text.len());
1013            let chunk = &text[start..end];
1014
1015            // Try to break at word boundary
1016            let final_chunk = if end < text.len() {
1017                if let Some(last_space) = chunk.rfind(' ') {
1018                    &chunk[..last_space]
1019                } else {
1020                    chunk
1021                }
1022            } else {
1023                chunk
1024            };
1025
1026            // Only add chunk if it has enough words
1027            if final_chunk.split_whitespace().count() >= self.min_chunk_words {
1028                chunks.push(final_chunk.to_string());
1029            }
1030
1031            start += final_chunk.len().saturating_sub(self.overlap_size);
1032            if start >= text.len() {
1033                break;
1034            }
1035        }
1036
1037        chunks
1038    }
1039}
1040
1041impl Default for StreamingTextProcessor {
1042    fn default() -> Self {
1043        Self::new()
1044    }
1045}
1046
1047// LSA (Latent Semantic Analysis) placeholder
1048#[derive(Debug, Clone)]
1049pub struct LSA {
1050    n_components: usize,
1051}
1052
1053impl LSA {
1054    pub fn new() -> Self {
1055        Self { n_components: 100 }
1056    }
1057
1058    pub fn n_components(mut self, n_components: usize) -> Self {
1059        self.n_components = n_components;
1060        self
1061    }
1062
1063    pub fn algorithm(self, _algorithm: &str) -> Self {
1064        // Placeholder - algorithm selection not implemented
1065        self
1066    }
1067
1068    #[allow(non_snake_case)]
1069    pub fn fit(&self, _X: &ArrayView2<f64>) -> SklResult<LSATrained> {
1070        // Placeholder implementation
1071        Ok(LSATrained {
1072            components: Array2::zeros((self.n_components, 10)),
1073        })
1074    }
1075}
1076
1077#[derive(Debug, Clone)]
1078pub struct LSATrained {
1079    #[allow(dead_code)] // components retained for future transform/inverse_transform API
1080    components: Array2<f64>,
1081}
1082
1083impl Default for LSA {
1084    fn default() -> Self {
1085        Self::new()
1086    }
1087}
1088
1089// ============================================================================
1090// Emotion Detection
1091// ============================================================================
1092
1093/// Emotion types for classification
1094#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1095pub enum EmotionType {
1096    /// Joy, happiness, pleasure
1097    Joy,
1098    /// Sadness, sorrow, grief
1099    Sadness,
1100    /// Anger, rage, frustration
1101    Anger,
1102    /// Fear, anxiety, worry
1103    Fear,
1104    /// Surprise, amazement
1105    Surprise,
1106    /// Disgust, revulsion
1107    Disgust,
1108    /// Neutral, no strong emotion
1109    Neutral,
1110}
1111
1112impl EmotionType {
1113    /// Get all emotion types (excluding Neutral)
1114    pub fn all_emotions() -> Vec<EmotionType> {
1115        vec![
1116            EmotionType::Joy,
1117            EmotionType::Sadness,
1118            EmotionType::Anger,
1119            EmotionType::Fear,
1120            EmotionType::Surprise,
1121            EmotionType::Disgust,
1122        ]
1123    }
1124
1125    /// Get emotion name as string
1126    pub fn as_str(&self) -> &'static str {
1127        match self {
1128            EmotionType::Joy => "joy",
1129            EmotionType::Sadness => "sadness",
1130            EmotionType::Anger => "anger",
1131            EmotionType::Fear => "fear",
1132            EmotionType::Surprise => "surprise",
1133            EmotionType::Disgust => "disgust",
1134            EmotionType::Neutral => "neutral",
1135        }
1136    }
1137}
1138
1139/// Result of emotion detection for a single text
1140#[derive(Debug, Clone)]
1141pub struct EmotionResult {
1142    /// Primary detected emotion
1143    pub primary_emotion: EmotionType,
1144    /// Confidence score for primary emotion [0.0, 1.0]
1145    pub confidence: f64,
1146    /// Scores for all emotion types
1147    pub emotion_scores: std::collections::HashMap<EmotionType, f64>,
1148    /// Total emotion words detected
1149    pub total_emotion_words: usize,
1150    /// Total words in text
1151    pub total_words: usize,
1152}
1153
1154impl EmotionResult {
1155    /// Get emotion intensity (normalized score)
1156    pub fn intensity(&self) -> f64 {
1157        if self.total_words == 0 {
1158            0.0
1159        } else {
1160            self.total_emotion_words as f64 / self.total_words as f64
1161        }
1162    }
1163
1164    /// Get secondary emotion (second highest score)
1165    pub fn secondary_emotion(&self) -> Option<(EmotionType, f64)> {
1166        let mut sorted: Vec<_> = self
1167            .emotion_scores
1168            .iter()
1169            .filter(|(e, _)| **e != self.primary_emotion)
1170            .map(|(e, s)| (*e, *s))
1171            .collect();
1172        sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1173        sorted.first().copied()
1174    }
1175}
1176
1177/// Emotion Detection extractor for text
1178///
1179/// Provides multi-class emotion detection using lexicon-based approach
1180/// with support for detecting joy, sadness, anger, fear, surprise, and disgust.
1181///
1182/// # Examples
1183///
1184/// ```
1185/// use sklears_feature_extraction::text::EmotionDetector;
1186///
1187/// let detector = EmotionDetector::new();
1188/// let result = detector.detect_emotion("I am so happy and excited!");
1189/// println!("Primary emotion: {:?}", result.primary_emotion);
1190/// ```
1191#[derive(Debug, Clone)]
1192pub struct EmotionDetector {
1193    emotion_lexicons: std::collections::HashMap<EmotionType, std::collections::HashSet<String>>,
1194    case_sensitive: bool,
1195    min_confidence: f64,
1196    intensity_weight: f64,
1197}
1198
1199impl EmotionDetector {
1200    /// Create a new emotion detector with default lexicons
1201    pub fn new() -> Self {
1202        let mut detector = Self {
1203            emotion_lexicons: std::collections::HashMap::new(),
1204            case_sensitive: false,
1205            min_confidence: 0.1,
1206            intensity_weight: 0.5,
1207        };
1208
1209        // Initialize emotion lexicons
1210        detector.initialize_lexicons();
1211        detector
1212    }
1213
1214    /// Initialize default emotion word lexicons
1215    fn initialize_lexicons(&mut self) {
1216        // Joy words
1217        let joy_words = vec![
1218            "happy",
1219            "joy",
1220            "joyful",
1221            "cheerful",
1222            "delighted",
1223            "pleased",
1224            "glad",
1225            "excited",
1226            "thrilled",
1227            "ecstatic",
1228            "elated",
1229            "wonderful",
1230            "amazing",
1231            "fantastic",
1232            "great",
1233            "excellent",
1234            "love",
1235            "loving",
1236            "loved",
1237            "enjoy",
1238            "fun",
1239            "amusing",
1240            "hilarious",
1241            "laugh",
1242            "laughter",
1243            "smile",
1244            "smiling",
1245            "blessed",
1246            "grateful",
1247            "thankful",
1248            "celebration",
1249            "celebrate",
1250            "victory",
1251        ];
1252        self.add_emotion_words(EmotionType::Joy, joy_words);
1253
1254        // Sadness words
1255        let sadness_words = vec![
1256            "sad",
1257            "unhappy",
1258            "depressed",
1259            "miserable",
1260            "sorrowful",
1261            "gloomy",
1262            "melancholy",
1263            "dejected",
1264            "downcast",
1265            "heartbroken",
1266            "crying",
1267            "cry",
1268            "tears",
1269            "weeping",
1270            "grief",
1271            "grieving",
1272            "mourning",
1273            "loss",
1274            "lost",
1275            "lonely",
1276            "loneliness",
1277            "alone",
1278            "isolated",
1279            "hopeless",
1280            "despair",
1281            "disappointed",
1282            "regret",
1283            "sorry",
1284            "hurt",
1285            "pain",
1286            "painful",
1287        ];
1288        self.add_emotion_words(EmotionType::Sadness, sadness_words);
1289
1290        // Anger words
1291        let anger_words = vec![
1292            "angry",
1293            "mad",
1294            "furious",
1295            "enraged",
1296            "outraged",
1297            "livid",
1298            "irate",
1299            "annoyed",
1300            "irritated",
1301            "frustrated",
1302            "rage",
1303            "fury",
1304            "wrath",
1305            "hatred",
1306            "hate",
1307            "hating",
1308            "hostile",
1309            "aggravated",
1310            "infuriated",
1311            "resentful",
1312            "bitter",
1313            "indignant",
1314            "offensive",
1315            "insulting",
1316            "provoked",
1317            "incensed",
1318            "exasperated",
1319            "disgusted",
1320            "contempt",
1321        ];
1322        self.add_emotion_words(EmotionType::Anger, anger_words);
1323
1324        // Fear words
1325        let fear_words = vec![
1326            "afraid",
1327            "scared",
1328            "fearful",
1329            "frightened",
1330            "terrified",
1331            "horrified",
1332            "panic",
1333            "panicked",
1334            "anxious",
1335            "anxiety",
1336            "worried",
1337            "nervous",
1338            "uneasy",
1339            "alarmed",
1340            "threatened",
1341            "intimidated",
1342            "dread",
1343            "dreading",
1344            "apprehensive",
1345            "tense",
1346            "stressed",
1347            "stress",
1348            "concern",
1349            "concerned",
1350            "phobia",
1351            "terror",
1352            "horror",
1353            "nightmare",
1354            "shock",
1355            "shocked",
1356        ];
1357        self.add_emotion_words(EmotionType::Fear, fear_words);
1358
1359        // Surprise words
1360        let surprise_words = vec![
1361            "surprised",
1362            "amazed",
1363            "astonished",
1364            "astounded",
1365            "shocked",
1366            "stunned",
1367            "startled",
1368            "unexpected",
1369            "sudden",
1370            "wow",
1371            "incredible",
1372            "unbelievable",
1373            "remarkable",
1374            "extraordinary",
1375            "spectacular",
1376            "breathtaking",
1377            "dumbfounded",
1378            "flabbergasted",
1379            "bewildered",
1380            "confused",
1381            "puzzled",
1382            "wonder",
1383            "wondering",
1384            "curious",
1385            "odd",
1386            "strange",
1387            "unusual",
1388        ];
1389        self.add_emotion_words(EmotionType::Surprise, surprise_words);
1390
1391        // Disgust words
1392        let disgust_words = vec![
1393            "disgusted",
1394            "disgusting",
1395            "revolting",
1396            "repulsive",
1397            "repugnant",
1398            "nauseating",
1399            "sickening",
1400            "gross",
1401            "yuck",
1402            "nasty",
1403            "foul",
1404            "vile",
1405            "loathsome",
1406            "offensive",
1407            "appalling",
1408            "abhorrent",
1409            "detestable",
1410            "horrible",
1411            "terrible",
1412            "awful",
1413            "unpleasant",
1414            "distasteful",
1415            "repellent",
1416            "obnoxious",
1417            "hideous",
1418            "ugly",
1419            "grotesque",
1420        ];
1421        self.add_emotion_words(EmotionType::Disgust, disgust_words);
1422    }
1423
1424    /// Add words to an emotion lexicon
1425    fn add_emotion_words(&mut self, emotion: EmotionType, words: Vec<&str>) {
1426        let word_set = self.emotion_lexicons.entry(emotion).or_default();
1427
1428        for word in words {
1429            word_set.insert(word.to_string());
1430        }
1431    }
1432
1433    /// Set case sensitivity
1434    pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
1435        self.case_sensitive = case_sensitive;
1436        self
1437    }
1438
1439    /// Set minimum confidence threshold for emotion detection
1440    pub fn min_confidence(mut self, min_confidence: f64) -> Self {
1441        self.min_confidence = min_confidence.clamp(0.0, 1.0);
1442        self
1443    }
1444
1445    /// Set intensity weight (how much to weight emotion word density vs raw counts)
1446    pub fn intensity_weight(mut self, weight: f64) -> Self {
1447        self.intensity_weight = weight.clamp(0.0, 1.0);
1448        self
1449    }
1450
1451    /// Add custom words to an emotion lexicon
1452    pub fn add_custom_emotion_words(mut self, emotion: EmotionType, words: Vec<String>) -> Self {
1453        let word_set = self.emotion_lexicons.entry(emotion).or_default();
1454
1455        for word in words {
1456            word_set.insert(word);
1457        }
1458        self
1459    }
1460
1461    /// Detect emotion in a single text
1462    pub fn detect_emotion(&self, text: &str) -> EmotionResult {
1463        let text = if self.case_sensitive {
1464            text.to_string()
1465        } else {
1466            text.to_lowercase()
1467        };
1468
1469        let words: Vec<&str> = text.split_whitespace().collect();
1470        let total_words = words.len();
1471
1472        // Count emotion words for each emotion type
1473        let mut emotion_counts: std::collections::HashMap<EmotionType, usize> =
1474            std::collections::HashMap::new();
1475        let mut total_emotion_words = 0;
1476
1477        for word in &words {
1478            // Remove punctuation
1479            let clean_word = word.trim_matches(|c: char| c.is_ascii_punctuation());
1480
1481            // Check each emotion lexicon
1482            for (emotion, lexicon) in &self.emotion_lexicons {
1483                if lexicon.contains(clean_word) {
1484                    *emotion_counts.entry(*emotion).or_insert(0) += 1;
1485                    total_emotion_words += 1;
1486                }
1487            }
1488        }
1489
1490        // Calculate emotion scores
1491        let mut emotion_scores: std::collections::HashMap<EmotionType, f64> =
1492            std::collections::HashMap::new();
1493
1494        for emotion in EmotionType::all_emotions() {
1495            let count = *emotion_counts.get(&emotion).unwrap_or(&0) as f64;
1496            let intensity = if total_words > 0 {
1497                count / total_words as f64
1498            } else {
1499                0.0
1500            };
1501
1502            // Combine raw count and intensity based on intensity_weight
1503            let score = if total_emotion_words > 0 {
1504                (1.0 - self.intensity_weight) * (count / total_emotion_words as f64)
1505                    + self.intensity_weight * intensity
1506            } else {
1507                0.0
1508            };
1509
1510            emotion_scores.insert(emotion, score);
1511        }
1512
1513        // Determine primary emotion
1514        let (primary_emotion, confidence) = if total_emotion_words == 0 {
1515            (EmotionType::Neutral, 0.0)
1516        } else {
1517            let mut sorted: Vec<_> = emotion_scores.iter().map(|(e, s)| (*e, *s)).collect();
1518            sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1519
1520            let (emotion, score) = sorted[0];
1521            if score >= self.min_confidence {
1522                (emotion, score)
1523            } else {
1524                (EmotionType::Neutral, score)
1525            }
1526        };
1527
1528        EmotionResult {
1529            primary_emotion,
1530            confidence,
1531            emotion_scores,
1532            total_emotion_words,
1533            total_words,
1534        }
1535    }
1536
1537    /// Extract emotion features from multiple documents
1538    ///
1539    /// Returns a feature matrix with shape (n_documents, 14) containing:
1540    /// - Scores for each emotion (6 features: joy, sadness, anger, fear, surprise, disgust)
1541    /// - Primary emotion one-hot encoding (6 features)
1542    /// - Confidence score (1 feature)
1543    /// - Emotion intensity (1 feature)
1544    pub fn extract_features(&self, documents: &[String]) -> SklResult<Array2<Float>> {
1545        if documents.is_empty() {
1546            return Err(SklearsError::InvalidInput(
1547                "Empty document collection".to_string(),
1548            ));
1549        }
1550
1551        // Feature vector: [joy_score, sadness_score, anger_score, fear_score, surprise_score, disgust_score,
1552        //                  joy_onehot, sadness_onehot, anger_onehot, fear_onehot, surprise_onehot, disgust_onehot,
1553        //                  confidence, intensity]
1554        let mut features = Array2::zeros((documents.len(), 14));
1555
1556        for (i, document) in documents.iter().enumerate() {
1557            let result = self.detect_emotion(document);
1558
1559            // Emotion scores (0-5)
1560            features[(i, 0)] = *result.emotion_scores.get(&EmotionType::Joy).unwrap_or(&0.0);
1561            features[(i, 1)] = *result
1562                .emotion_scores
1563                .get(&EmotionType::Sadness)
1564                .unwrap_or(&0.0);
1565            features[(i, 2)] = *result
1566                .emotion_scores
1567                .get(&EmotionType::Anger)
1568                .unwrap_or(&0.0);
1569            features[(i, 3)] = *result
1570                .emotion_scores
1571                .get(&EmotionType::Fear)
1572                .unwrap_or(&0.0);
1573            features[(i, 4)] = *result
1574                .emotion_scores
1575                .get(&EmotionType::Surprise)
1576                .unwrap_or(&0.0);
1577            features[(i, 5)] = *result
1578                .emotion_scores
1579                .get(&EmotionType::Disgust)
1580                .unwrap_or(&0.0);
1581
1582            // Primary emotion one-hot encoding (6-11)
1583            let primary_idx = match result.primary_emotion {
1584                EmotionType::Joy => 6,
1585                EmotionType::Sadness => 7,
1586                EmotionType::Anger => 8,
1587                EmotionType::Fear => 9,
1588                EmotionType::Surprise => 10,
1589                EmotionType::Disgust => 11,
1590                EmotionType::Neutral => continue, // Don't encode neutral
1591            };
1592            features[(i, primary_idx)] = 1.0;
1593
1594            // Confidence and intensity (12-13)
1595            features[(i, 12)] = result.confidence;
1596            features[(i, 13)] = result.intensity();
1597        }
1598
1599        Ok(features)
1600    }
1601
1602    /// Analyze emotion distribution across multiple documents
1603    pub fn analyze_distribution(
1604        &self,
1605        documents: &[String],
1606    ) -> std::collections::HashMap<EmotionType, usize> {
1607        let mut distribution: std::collections::HashMap<EmotionType, usize> =
1608            std::collections::HashMap::new();
1609
1610        for document in documents {
1611            let result = self.detect_emotion(document);
1612            *distribution.entry(result.primary_emotion).or_insert(0) += 1;
1613        }
1614
1615        distribution
1616    }
1617}
1618
1619impl Default for EmotionDetector {
1620    fn default() -> Self {
1621        Self::new()
1622    }
1623}
1624
1625// ============================================================================
1626// Aspect-Based Sentiment Analysis
1627// ============================================================================
1628
1629/// Aspect sentiment pair representing sentiment towards a specific aspect
1630#[derive(Debug, Clone, PartialEq)]
1631pub struct AspectSentiment {
1632    /// The aspect or feature being discussed
1633    pub aspect: String,
1634    /// Sentiment polarity for this aspect
1635    pub sentiment: SentimentPolarity,
1636    /// Sentiment score for this aspect [-1.0, 1.0]
1637    pub score: f64,
1638    /// Confidence in the aspect-sentiment pairing [0.0, 1.0]
1639    pub confidence: f64,
1640    /// Opinion words associated with this aspect
1641    pub opinion_words: Vec<String>,
1642}
1643
1644/// Aspect-Based Sentiment Analysis extractor
1645///
1646/// Analyzes sentiment towards specific aspects or features mentioned in text.
1647/// This is particularly useful for product reviews, customer feedback, and opinion mining.
1648///
1649/// # Examples
1650///
1651/// ```
1652/// use sklears_feature_extraction::text::AspectBasedSentimentAnalyzer;
1653///
1654/// let analyzer = AspectBasedSentimentAnalyzer::new();
1655/// let result = analyzer.analyze("The food was great but the service was terrible.");
1656/// println!("Found {} aspects with sentiment", result.len());
1657/// ```
1658#[derive(Debug, Clone)]
1659pub struct AspectBasedSentimentAnalyzer {
1660    /// Predefined aspects to look for
1661    aspects: std::collections::HashSet<String>,
1662    /// Sentiment analyzer for sentiment detection
1663    sentiment_analyzer: SentimentAnalyzer,
1664    /// Context window size (words before and after aspect)
1665    context_window: usize,
1666    /// Minimum confidence threshold for aspect-sentiment pairs
1667    min_confidence: f64,
1668    /// Case sensitivity
1669    case_sensitive: bool,
1670    /// Use automatic aspect extraction
1671    auto_extract_aspects: bool,
1672}
1673
1674impl AspectBasedSentimentAnalyzer {
1675    /// Create a new aspect-based sentiment analyzer with default settings
1676    pub fn new() -> Self {
1677        Self {
1678            aspects: std::collections::HashSet::new(),
1679            sentiment_analyzer: SentimentAnalyzer::new(),
1680            context_window: 3,
1681            min_confidence: 0.1,
1682            case_sensitive: false,
1683            auto_extract_aspects: true,
1684        }
1685    }
1686
1687    /// Add predefined aspects to look for
1688    pub fn add_aspects(mut self, aspects: Vec<String>) -> Self {
1689        for aspect in aspects {
1690            self.aspects.insert(if self.case_sensitive {
1691                aspect
1692            } else {
1693                aspect.to_lowercase()
1694            });
1695        }
1696        self
1697    }
1698
1699    /// Set context window size (number of words before and after aspect)
1700    pub fn context_window(mut self, window: usize) -> Self {
1701        self.context_window = window;
1702        self
1703    }
1704
1705    /// Set minimum confidence threshold
1706    pub fn min_confidence(mut self, confidence: f64) -> Self {
1707        self.min_confidence = confidence.clamp(0.0, 1.0);
1708        self
1709    }
1710
1711    /// Set case sensitivity
1712    pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
1713        self.case_sensitive = case_sensitive;
1714        self
1715    }
1716
1717    /// Enable/disable automatic aspect extraction
1718    pub fn auto_extract_aspects(mut self, auto_extract: bool) -> Self {
1719        self.auto_extract_aspects = auto_extract;
1720        self
1721    }
1722
1723    /// Set custom sentiment analyzer
1724    pub fn sentiment_analyzer(mut self, analyzer: SentimentAnalyzer) -> Self {
1725        self.sentiment_analyzer = analyzer;
1726        self
1727    }
1728
1729    /// Analyze aspect-based sentiment in text
1730    pub fn analyze(&self, text: &str) -> Vec<AspectSentiment> {
1731        let normalized_text = if self.case_sensitive {
1732            text.to_string()
1733        } else {
1734            text.to_lowercase()
1735        };
1736
1737        let words: Vec<&str> = normalized_text.split_whitespace().collect();
1738        let mut aspect_sentiments = Vec::new();
1739
1740        // Find all aspect occurrences
1741        let mut aspect_positions: Vec<(usize, String)> = Vec::new();
1742
1743        // Look for predefined aspects
1744        if !self.aspects.is_empty() {
1745            for (i, word) in words.iter().enumerate() {
1746                let clean_word = word.trim_matches(|c: char| c.is_ascii_punctuation());
1747
1748                // Check for single-word aspects
1749                if self.aspects.contains(clean_word) {
1750                    aspect_positions.push((i, clean_word.to_string()));
1751                }
1752
1753                // Check for multi-word aspects (up to 3 words)
1754                for window_size in 2..=3 {
1755                    if i + window_size <= words.len() {
1756                        let multi_word = words[i..i + window_size]
1757                            .iter()
1758                            .map(|w| w.trim_matches(|c: char| c.is_ascii_punctuation()))
1759                            .collect::<Vec<_>>()
1760                            .join(" ");
1761
1762                        if self.aspects.contains(&multi_word) {
1763                            aspect_positions.push((i, multi_word));
1764                        }
1765                    }
1766                }
1767            }
1768        }
1769
1770        // Automatic aspect extraction using noun detection heuristics
1771        if self.auto_extract_aspects && self.aspects.is_empty() {
1772            aspect_positions.extend(self.extract_aspects_auto(&words));
1773        }
1774
1775        // Analyze sentiment for each aspect
1776        for (position, aspect) in aspect_positions {
1777            let (context_words, opinion_words) = self.get_context(position, &words);
1778
1779            // Analyze sentiment of context
1780            let context_text = context_words.join(" ");
1781            let sentiment_result = self.sentiment_analyzer.analyze_sentiment(&context_text);
1782
1783            // Calculate confidence based on sentiment word density in context
1784            let confidence = if context_words.is_empty() {
1785                0.0
1786            } else {
1787                (sentiment_result.positive_words + sentiment_result.negative_words) as f64
1788                    / context_words.len() as f64
1789            };
1790
1791            if confidence >= self.min_confidence {
1792                aspect_sentiments.push(AspectSentiment {
1793                    aspect,
1794                    sentiment: sentiment_result.polarity,
1795                    score: sentiment_result.score,
1796                    confidence,
1797                    opinion_words,
1798                });
1799            }
1800        }
1801
1802        aspect_sentiments
1803    }
1804
1805    /// Extract context words around an aspect
1806    fn get_context(&self, position: usize, words: &[&str]) -> (Vec<String>, Vec<String>) {
1807        let start = position.saturating_sub(self.context_window);
1808        let end = (position + self.context_window + 1).min(words.len());
1809
1810        let context_words: Vec<String> = words[start..end]
1811            .iter()
1812            .map(|w| {
1813                w.trim_matches(|c: char| c.is_ascii_punctuation())
1814                    .to_string()
1815            })
1816            .collect();
1817
1818        // Extract opinion words (words that express sentiment)
1819        let opinion_words: Vec<String> = context_words
1820            .iter()
1821            .filter(|w| {
1822                self.sentiment_analyzer.positive_words.contains(w.as_str())
1823                    || self.sentiment_analyzer.negative_words.contains(w.as_str())
1824            })
1825            .cloned()
1826            .collect();
1827
1828        (context_words, opinion_words)
1829    }
1830
1831    /// Automatically extract aspect candidates using heuristics
1832    fn extract_aspects_auto(&self, words: &[&str]) -> Vec<(usize, String)> {
1833        let mut aspects = Vec::new();
1834
1835        // Common aspect indicators (nouns that often represent aspects)
1836        let aspect_indicators = [
1837            "food",
1838            "service",
1839            "price",
1840            "quality",
1841            "staff",
1842            "location",
1843            "room",
1844            "product",
1845            "delivery",
1846            "support",
1847            "performance",
1848            "design",
1849            "battery",
1850            "screen",
1851            "camera",
1852            "sound",
1853            "comfort",
1854            "size",
1855            "color",
1856            "taste",
1857            "menu",
1858            "atmosphere",
1859            "experience",
1860            "value",
1861            "selection",
1862        ];
1863
1864        for (i, word) in words.iter().enumerate() {
1865            let clean_word = word.trim_matches(|c: char| c.is_ascii_punctuation());
1866
1867            // Check if word matches aspect indicators
1868            if aspect_indicators.contains(&clean_word) {
1869                aspects.push((i, clean_word.to_string()));
1870            }
1871        }
1872
1873        aspects
1874    }
1875
1876    /// Extract features from multiple documents
1877    ///
1878    /// Returns a feature matrix with aggregated aspect sentiments.
1879    /// Features include: [avg_aspect_score, positive_aspects, negative_aspects,
1880    ///                    neutral_aspects, aspect_diversity, avg_confidence]
1881    pub fn extract_features(&self, documents: &[String]) -> SklResult<Array2<Float>> {
1882        if documents.is_empty() {
1883            return Err(SklearsError::InvalidInput(
1884                "Empty document collection".to_string(),
1885            ));
1886        }
1887
1888        let mut features = Array2::zeros((documents.len(), 6));
1889
1890        for (i, document) in documents.iter().enumerate() {
1891            let aspect_sentiments = self.analyze(document);
1892
1893            if aspect_sentiments.is_empty() {
1894                continue;
1895            }
1896
1897            // Compute features
1898            let avg_score = aspect_sentiments.iter().map(|a| a.score).sum::<f64>()
1899                / aspect_sentiments.len() as f64;
1900
1901            let positive_count = aspect_sentiments
1902                .iter()
1903                .filter(|a| a.sentiment == SentimentPolarity::Positive)
1904                .count() as f64;
1905
1906            let negative_count = aspect_sentiments
1907                .iter()
1908                .filter(|a| a.sentiment == SentimentPolarity::Negative)
1909                .count() as f64;
1910
1911            let neutral_count = aspect_sentiments
1912                .iter()
1913                .filter(|a| a.sentiment == SentimentPolarity::Neutral)
1914                .count() as f64;
1915
1916            // Aspect diversity (unique aspects / total aspects)
1917            let unique_aspects: std::collections::HashSet<_> =
1918                aspect_sentiments.iter().map(|a| &a.aspect).collect();
1919            let diversity = unique_aspects.len() as f64 / aspect_sentiments.len() as f64;
1920
1921            let avg_confidence = aspect_sentiments.iter().map(|a| a.confidence).sum::<f64>()
1922                / aspect_sentiments.len() as f64;
1923
1924            features[(i, 0)] = avg_score;
1925            features[(i, 1)] = positive_count;
1926            features[(i, 2)] = negative_count;
1927            features[(i, 3)] = neutral_count;
1928            features[(i, 4)] = diversity;
1929            features[(i, 5)] = avg_confidence;
1930        }
1931
1932        Ok(features)
1933    }
1934
1935    /// Aggregate aspect sentiments across multiple documents
1936    pub fn aggregate_aspects(
1937        &self,
1938        documents: &[String],
1939    ) -> std::collections::HashMap<String, Vec<AspectSentiment>> {
1940        let mut aggregated: std::collections::HashMap<String, Vec<AspectSentiment>> =
1941            std::collections::HashMap::new();
1942
1943        for document in documents {
1944            let sentiments = self.analyze(document);
1945            for sentiment in sentiments {
1946                aggregated
1947                    .entry(sentiment.aspect.clone())
1948                    .or_default()
1949                    .push(sentiment);
1950            }
1951        }
1952
1953        aggregated
1954    }
1955
1956    /// Get summary statistics for each aspect
1957    pub fn aspect_summary(&self, documents: &[String]) -> Vec<(String, f64, usize)> {
1958        let aggregated = self.aggregate_aspects(documents);
1959
1960        let mut summary: Vec<(String, f64, usize)> = aggregated
1961            .iter()
1962            .map(|(aspect, sentiments)| {
1963                let avg_score =
1964                    sentiments.iter().map(|s| s.score).sum::<f64>() / sentiments.len() as f64;
1965                let count = sentiments.len();
1966                (aspect.clone(), avg_score, count)
1967            })
1968            .collect();
1969
1970        // Sort by frequency
1971        summary.sort_by_key(|b| std::cmp::Reverse(b.2));
1972        summary
1973    }
1974}
1975
1976impl Default for AspectBasedSentimentAnalyzer {
1977    fn default() -> Self {
1978        Self::new()
1979    }
1980}