Skip to main content

sklears_feature_extraction/text/
preprocessing.rs

1//! Text preprocessing utilities
2//!
3//! Provides basic text preprocessing functionality including tokenization,
4//! stop word removal, and stemming.
5
6use std::collections::HashSet;
7
8/// Simple tokenizer that splits text into words
9///
10/// # Examples
11///
12/// ```
13/// use sklears_feature_extraction::text::preprocessing::SimpleTokenizer;
14///
15/// let tokenizer = SimpleTokenizer::new();
16/// let tokens = tokenizer.tokenize("Hello, world! This is a test.");
17/// assert_eq!(tokens, vec!["hello", "world", "this", "is", "a", "test"]);
18/// ```
19#[derive(Debug, Clone)]
20pub struct SimpleTokenizer {
21    lowercase: bool,
22    remove_punctuation: bool,
23}
24
25impl Default for SimpleTokenizer {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl SimpleTokenizer {
32    /// Create a new simple tokenizer
33    pub fn new() -> Self {
34        Self {
35            lowercase: true,
36            remove_punctuation: true,
37        }
38    }
39
40    /// Set whether to convert to lowercase
41    pub fn lowercase(mut self, lowercase: bool) -> Self {
42        self.lowercase = lowercase;
43        self
44    }
45
46    /// Set whether to remove punctuation
47    pub fn remove_punctuation(mut self, remove_punctuation: bool) -> Self {
48        self.remove_punctuation = remove_punctuation;
49        self
50    }
51
52    /// Tokenize text into words
53    pub fn tokenize(&self, text: &str) -> Vec<String> {
54        let mut text = text.to_string();
55
56        if self.lowercase {
57            text = text.to_lowercase();
58        }
59
60        if self.remove_punctuation {
61            text = text
62                .chars()
63                .filter(|c| c.is_alphanumeric() || c.is_whitespace())
64                .collect();
65        }
66
67        text.split_whitespace()
68            .filter(|s| !s.is_empty())
69            .map(|s| s.to_string())
70            .collect()
71    }
72}
73
74/// Stop word remover
75///
76/// Removes common stop words from text.
77///
78/// # Examples
79///
80/// ```
81/// use sklears_feature_extraction::text::preprocessing::StopWordRemover;
82///
83/// let remover = StopWordRemover::english();
84/// let tokens = vec!["the", "quick", "brown", "fox"];
85/// let filtered = remover.remove_stop_words(&tokens);
86/// assert_eq!(filtered, vec!["quick", "brown", "fox"]);
87/// ```
88#[derive(Debug, Clone)]
89pub struct StopWordRemover {
90    stop_words: HashSet<String>,
91}
92
93impl StopWordRemover {
94    /// Create a new stop word remover with custom stop words
95    pub fn new(stop_words: HashSet<String>) -> Self {
96        Self { stop_words }
97    }
98
99    /// Create a stop word remover with common English stop words
100    pub fn english() -> Self {
101        let stop_words = [
102            "a", "an", "and", "are", "as", "at", "be", "been", "by", "for", "from", "has", "he",
103            "in", "is", "it", "its", "of", "on", "that", "the", "to", "was", "will", "with", "the",
104            "this", "but", "they", "have", "had", "what", "said", "each", "which", "she", "do",
105            "how", "their", "if", "up", "out", "many", "then", "them", "these", "so", "some",
106            "her", "would", "make", "like", "into", "him", "time", "two", "more", "very", "when",
107            "come", "can", "could", "say", "get", "use", "your", "may", "way", "work", "just",
108            "now", "think", "also", "after", "back", "other", "good", "new", "first", "well",
109            "year", "see", "own", "want", "over",
110        ]
111        .iter()
112        .map(|s| s.to_string())
113        .collect();
114
115        Self { stop_words }
116    }
117
118    /// Remove stop words from a list of tokens
119    pub fn remove_stop_words(&self, tokens: &[impl AsRef<str>]) -> Vec<String> {
120        tokens
121            .iter()
122            .filter_map(|token| {
123                let token_str = token.as_ref().to_lowercase();
124                if self.stop_words.contains(&token_str) {
125                    None
126                } else {
127                    Some(token.as_ref().to_string())
128                }
129            })
130            .collect()
131    }
132
133    /// Add custom stop words
134    pub fn add_stop_words<I>(&mut self, words: I)
135    where
136        I: IntoIterator<Item = String>,
137    {
138        for word in words {
139            self.stop_words.insert(word.to_lowercase());
140        }
141    }
142}
143
144/// Simple Porter stemmer implementation
145///
146/// Implements a simplified version of the Porter stemming algorithm
147/// for reducing words to their root form.
148///
149/// # Examples
150///
151/// ```
152/// use sklears_feature_extraction::text::preprocessing::PorterStemmer;
153///
154/// let stemmer = PorterStemmer::new();
155/// assert_eq!(stemmer.stem("running"), "run");
156/// assert_eq!(stemmer.stem("flies"), "fli");
157/// ```
158#[derive(Debug, Clone)]
159pub struct PorterStemmer;
160
161impl Default for PorterStemmer {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167impl PorterStemmer {
168    /// Create a new Porter stemmer
169    pub fn new() -> Self {
170        Self
171    }
172
173    /// Stem a word to its root form
174    pub fn stem(&self, word: &str) -> String {
175        let word = word.to_lowercase();
176        let mut result = word.clone();
177
178        // Step 1a: Remove plurals
179        // Both sses and ies trim the last 2 chars (intentionally identical operation)
180        #[allow(clippy::if_same_then_else)]
181        if result.ends_with("sses") {
182            result = result[..result.len() - 2].to_string();
183        } else if result.ends_with("ies") {
184            result = result[..result.len() - 2].to_string();
185        } else if result.ends_with("ss") {
186            // Keep as is
187        } else if result.ends_with("s") && result.len() > 1 {
188            result = result[..result.len() - 1].to_string();
189        }
190
191        // Step 1b: Handle -ed and -ing
192        if result.ends_with("eed") && self.get_measure(&result[..result.len() - 3]) > 0 {
193            result = result[..result.len() - 1].to_string();
194        } else if result.ends_with("ed") && self.contains_vowel(&result[..result.len() - 2]) {
195            result = result[..result.len() - 2].to_string();
196            result = self.post_process_1b(result);
197        } else if result.ends_with("ing") && self.contains_vowel(&result[..result.len() - 3]) {
198            result = result[..result.len() - 3].to_string();
199            result = self.post_process_1b(result);
200        }
201
202        // Step 2: Handle common suffix transformations
203        result = self.step_2(result);
204
205        // Step 3: Handle more suffix transformations
206        result = self.step_3(result);
207
208        // Step 4: Remove common suffixes
209        result = self.step_4(result);
210
211        // Step 5: Final cleanup
212        result = self.step_5(result);
213
214        result
215    }
216
217    fn is_vowel(&self, c: char) -> bool {
218        matches!(c, 'a' | 'e' | 'i' | 'o' | 'u')
219    }
220
221    fn contains_vowel(&self, word: &str) -> bool {
222        word.chars().any(|c| self.is_vowel(c))
223    }
224
225    fn get_measure(&self, word: &str) -> usize {
226        let mut measure = 0;
227        let mut prev_was_vowel = false;
228
229        for c in word.chars() {
230            let is_vowel = self.is_vowel(c);
231            if !is_vowel && prev_was_vowel {
232                measure += 1;
233            }
234            prev_was_vowel = is_vowel;
235        }
236
237        measure
238    }
239
240    fn post_process_1b(&self, mut word: String) -> String {
241        if word.ends_with("at") || word.ends_with("bl") || word.ends_with("iz") {
242            word.push('e');
243        } else if self.ends_with_double_consonant(&word)
244            && !word.ends_with("l")
245            && !word.ends_with("s")
246            && !word.ends_with("z")
247        {
248            word.pop();
249        } else if self.get_measure(&word) == 1 && self.ends_with_cvc(&word) {
250            word.push('e');
251        }
252        word
253    }
254
255    fn ends_with_double_consonant(&self, word: &str) -> bool {
256        if word.len() < 2 {
257            return false;
258        }
259        let chars: Vec<char> = word.chars().collect();
260        let last = chars[chars.len() - 1];
261        let second_last = chars[chars.len() - 2];
262        !self.is_vowel(last) && last == second_last
263    }
264
265    fn ends_with_cvc(&self, word: &str) -> bool {
266        if word.len() < 3 {
267            return false;
268        }
269        let chars: Vec<char> = word.chars().collect();
270        let len = chars.len();
271        !self.is_vowel(chars[len - 3])
272            && self.is_vowel(chars[len - 2])
273            && !self.is_vowel(chars[len - 1])
274            && !matches!(chars[len - 1], 'w' | 'x' | 'y')
275    }
276
277    fn step_2(&self, mut word: String) -> String {
278        let suffixes = [
279            ("ational", "ate"),
280            ("tional", "tion"),
281            ("enci", "ence"),
282            ("anci", "ance"),
283            ("izer", "ize"),
284            ("abli", "able"),
285            ("alli", "al"),
286            ("entli", "ent"),
287            ("eli", "e"),
288            ("ousli", "ous"),
289            ("ization", "ize"),
290            ("ation", "ate"),
291            ("ator", "ate"),
292            ("alism", "al"),
293            ("iveness", "ive"),
294            ("fulness", "ful"),
295            ("ousness", "ous"),
296            ("aliti", "al"),
297            ("iviti", "ive"),
298            ("biliti", "ble"),
299        ];
300
301        for (suffix, replacement) in &suffixes {
302            if word.ends_with(suffix) {
303                let stem = &word[..word.len() - suffix.len()];
304                if self.get_measure(stem) > 0 {
305                    word = format!("{}{}", stem, replacement);
306                    break;
307                }
308            }
309        }
310        word
311    }
312
313    fn step_3(&self, mut word: String) -> String {
314        let suffixes = [
315            ("icate", "ic"),
316            ("ative", ""),
317            ("alize", "al"),
318            ("iciti", "ic"),
319            ("ical", "ic"),
320            ("ful", ""),
321            ("ness", ""),
322        ];
323
324        for (suffix, replacement) in &suffixes {
325            if word.ends_with(suffix) {
326                let stem = &word[..word.len() - suffix.len()];
327                if self.get_measure(stem) > 0 {
328                    word = format!("{}{}", stem, replacement);
329                    break;
330                }
331            }
332        }
333        word
334    }
335
336    fn step_4(&self, mut word: String) -> String {
337        let suffixes = [
338            "al", "ance", "ence", "er", "ic", "able", "ible", "ant", "ement", "ment", "ent", "ion",
339            "ou", "ism", "ate", "iti", "ous", "ive", "ize",
340        ];
341
342        for suffix in &suffixes {
343            if word.ends_with(suffix) {
344                let stem = &word[..word.len() - suffix.len()];
345                if self.get_measure(stem) > 1 {
346                    // Both branches assign stem; conditions differ but result is identical (intentional)
347                    #[allow(clippy::if_same_then_else)]
348                    if *suffix == "ion" && stem.ends_with("s") || stem.ends_with("t") {
349                        word = stem.to_string();
350                    } else if *suffix != "ion" {
351                        word = stem.to_string();
352                    }
353                    break;
354                }
355            }
356        }
357        word
358    }
359
360    fn step_5(&self, mut word: String) -> String {
361        if word.ends_with("e") {
362            let stem = &word[..word.len() - 1];
363            let measure = self.get_measure(stem);
364            if measure > 1 || (measure == 1 && !self.ends_with_cvc(stem)) {
365                word = stem.to_string();
366            }
367        }
368
369        if word.ends_with("ll") && self.get_measure(&word) > 1 {
370            word.pop();
371        }
372
373        word
374    }
375}
376
377/// Text preprocessor that combines tokenization, stop word removal, and stemming
378///
379/// # Examples
380///
381/// ```
382/// use sklears_feature_extraction::text::preprocessing::TextPreprocessor;
383///
384/// let preprocessor = TextPreprocessor::new()
385///     .enable_stemming(true)
386///     .enable_stop_word_removal(true);
387///
388/// let processed = preprocessor.preprocess("The running dogs are playing");
389/// // Result will be stemmed and stop words removed
390/// ```
391#[derive(Debug, Clone)]
392pub struct TextPreprocessor {
393    tokenizer: SimpleTokenizer,
394    stop_word_remover: Option<StopWordRemover>,
395    stemmer: Option<PorterStemmer>,
396    enable_stemming: bool,
397    enable_stop_word_removal: bool,
398}
399
400impl Default for TextPreprocessor {
401    fn default() -> Self {
402        Self::new()
403    }
404}
405
406impl TextPreprocessor {
407    /// Create a new text preprocessor
408    pub fn new() -> Self {
409        Self {
410            tokenizer: SimpleTokenizer::new(),
411            stop_word_remover: Some(StopWordRemover::english()),
412            stemmer: Some(PorterStemmer::new()),
413            enable_stemming: false,
414            enable_stop_word_removal: false,
415        }
416    }
417
418    /// Enable or disable stemming
419    pub fn enable_stemming(mut self, enable: bool) -> Self {
420        self.enable_stemming = enable;
421        self
422    }
423
424    /// Enable or disable stop word removal
425    pub fn enable_stop_word_removal(mut self, enable: bool) -> Self {
426        self.enable_stop_word_removal = enable;
427        self
428    }
429
430    /// Set custom stop words
431    pub fn stop_words(mut self, stop_words: HashSet<String>) -> Self {
432        self.stop_word_remover = Some(StopWordRemover::new(stop_words));
433        self
434    }
435
436    /// Configure the tokenizer
437    pub fn tokenizer(mut self, tokenizer: SimpleTokenizer) -> Self {
438        self.tokenizer = tokenizer;
439        self
440    }
441
442    /// Preprocess text through the configured pipeline
443    pub fn preprocess(&self, text: &str) -> Vec<String> {
444        // Tokenize
445        let mut tokens = self.tokenizer.tokenize(text);
446
447        // Remove stop words
448        if self.enable_stop_word_removal {
449            if let Some(ref remover) = self.stop_word_remover {
450                tokens = remover.remove_stop_words(&tokens);
451            }
452        }
453
454        // Stem words
455        if self.enable_stemming {
456            if let Some(ref stemmer) = self.stemmer {
457                tokens = tokens.iter().map(|token| stemmer.stem(token)).collect();
458            }
459        }
460
461        tokens
462    }
463}