Skip to main content

english_pos_tagger/
lib.rs

1//! Part-of-speech tagging and lemmatization of English text.
2//!
3//! This crate assigns Penn-Treebank-style POS tags to tokens and adds base-form
4//! lemmas for nouns (excluding proper nouns), verbs, and adjectives. It follows
5//! a Brill-style transformation-based approach: a unigram pass proposes
6//! candidate tags, ordered context rules rewrite them from neighboring tokens,
7//! and a final pass lemmatizes and applies proper-noun and year fixups.
8//!
9//! Every token gets a normal form, and lemmas are always produced for the
10//! parts of speech that carry them.
11//!
12//! # Example
13//!
14//! ```
15//! use english_pos_tagger::Tagger;
16//!
17//! let tagger = Tagger::new();
18//! let tokens = tagger.tag_sentence("A bear just crossed the road.");
19//! assert_eq!(tokens[1].value, "bear");
20//! assert_eq!(tokens[1].pos.as_deref(), Some("NN"));
21//! assert_eq!(tokens[1].lemma.as_deref(), Some("bear"));
22//! ```
23//!
24//! # Lexicon state
25//!
26//! [`Tagger::update_lexicon`] adds or replaces word entries on that tagger
27//! instance. State stays local to the instance, not shared across taggers.
28
29#![forbid(unsafe_code)]
30#![warn(missing_docs)]
31
32mod data;
33mod lemmatize;
34mod normalize;
35mod rules;
36mod token;
37mod tokenizer;
38mod unigram;
39
40use std::collections::HashMap;
41
42pub use token::Token;
43
44// Contraction surface forms whose lemma is fixed regardless of POS.
45const LEMMA_EXCEPTIONS: &[(&str, &str)] = &[
46    ("ai", "be"),
47    ("ca", "can"),
48    ("sha", "shall"),
49    ("'ll", "will"),
50    ("wo", "will"),
51    ("'ve", "have"),
52    ("'m", "am"),
53    ("'re", "be"),
54    ("n't", "not"),
55    ("'d", "would"),
56];
57
58// Year and mid-decade forms forced to CD.
59const YEARS: &[&str] = &[
60    "1990s",
61    "1980s",
62    "1970s",
63    "1960s",
64    "1950s",
65    "1940s",
66    "1930s",
67    "1910s",
68    "mid-1990s",
69    "mid-1980s",
70    "mid-1970s",
71    "mid-1960s",
72    "mid-1950s",
73    "mid-1940s",
74    "mid-1930s",
75    "mid-1920s",
76    "mid-1910s",
77];
78
79fn lemma_exception(word: &str) -> Option<&'static str> {
80    LEMMA_EXCEPTIONS
81        .iter()
82        .find(|(k, _)| *k == word)
83        .map(|(_, v)| *v)
84}
85
86fn is_year(word: &str) -> bool {
87    YEARS.contains(&word)
88}
89
90/// A part-of-speech tagger with its own lexicon.
91pub struct Tagger {
92    lexicon: HashMap<String, Vec<String>>,
93}
94
95impl Default for Tagger {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl Tagger {
102    /// Creates a tagger seeded with the built-in lexicon.
103    pub fn new() -> Self {
104        Tagger {
105            lexicon: data::LEXICON.clone(),
106        }
107    }
108
109    /// Adds or replaces lexicon entries. Each key normalizes before storage.
110    /// Each value is a candidate POS list with the most frequent tag first.
111    pub fn update_lexicon<I, K, V>(&mut self, entries: I)
112    where
113        I: IntoIterator<Item = (K, V)>,
114        K: AsRef<str>,
115        V: IntoIterator,
116        V::Item: Into<String>,
117    {
118        for (key, value) in entries {
119            let normalized = normalize::normalize(key.as_ref());
120            let poses: Vec<String> = value.into_iter().map(Into::into).collect();
121            self.lexicon.insert(normalized, poses);
122        }
123    }
124
125    /// Tags `tokens` and returns them. Sets `normal`, `pos`, and, where
126    /// applicable, `lemma` on each token.
127    pub fn tag(&self, mut tokens: Vec<Token>) -> Vec<Token> {
128        let mut poses: Vec<Vec<String>> = Vec::with_capacity(tokens.len());
129        for token in tokens.iter_mut() {
130            token.normal = Some(normalize::normalize(&token.value));
131            poses.push(unigram::unigram_pos_tagger(token, &self.lexicon));
132        }
133        rules::apply_all_context_rules(&mut tokens, &poses);
134        self.lemmatize(&mut tokens);
135        tokens
136    }
137
138    /// Alias of [`Tagger::tag`].
139    pub fn tag_tokens(&self, tokens: Vec<Token>) -> Vec<Token> {
140        self.tag(tokens)
141    }
142
143    /// Classifies each raw string as number, punctuation, or word, then tags.
144    pub fn tag_raw_tokens<S: AsRef<str>>(&self, raw_tokens: &[S]) -> Vec<Token> {
145        let tokens: Vec<Token> = raw_tokens
146            .iter()
147            .map(|t| {
148                let t = t.as_ref();
149                let tag = if rgx::is_number(t) {
150                    "number"
151                } else if rgx::is_punctuation(t) {
152                    "punctuation"
153                } else {
154                    "word"
155                };
156                Token::new(t, tag)
157            })
158            .collect();
159        self.tag(tokens)
160    }
161
162    /// Tokenizes `sentence` with the default configuration, then tags it.
163    pub fn tag_sentence(&self, sentence: &str) -> Vec<Token> {
164        self.tag(tokenizer::tokenize(sentence))
165    }
166
167    // Lemmatizes tokens and applies the capitalized-NNP and year fixups.
168    fn lemmatize(&self, tokens: &mut [Token]) {
169        for token in tokens.iter_mut() {
170            let w = token.normal.clone().unwrap_or_default();
171            let v0 = token.value.chars().next();
172            let cap = matches!(v0, Some(c) if c.is_ascii_uppercase());
173
174            if is_year(&w) {
175                token.pos = Some("CD".to_string());
176            }
177
178            if let Some(lemma) = lemma_exception(&w) {
179                token.lemma = Some(lemma.to_string());
180                continue;
181            }
182
183            let pos = match &token.pos {
184                Some(p) => p.clone(),
185                None => continue,
186            };
187            let first = pos.chars().next();
188            match first {
189                Some('J') => {
190                    if cap {
191                        token.lemma = Some(w.clone());
192                        token.pos = Some("NNP".to_string());
193                    } else if pos.len() > 2 {
194                        token.lemma = Some(lemmatize::adjective(&w));
195                    } else {
196                        token.lemma = Some(w.clone());
197                    }
198                }
199                Some('V') => {
200                    if pos.len() > 2 {
201                        let lemma = if w == "'s" {
202                            "be".to_string()
203                        } else {
204                            lemmatize::verb(&w)
205                        };
206                        token.lemma = Some(lemma);
207                    } else {
208                        token.lemma = Some(w.clone());
209                    }
210                }
211                Some('N') => {
212                    if cap {
213                        token.lemma = Some(w.clone());
214                        token.pos = Some("NNP".to_string());
215                    } else if pos != "NNP" && pos.len() > 2 {
216                        token.lemma = Some(lemmatize::noun(&w));
217                    } else {
218                        token.lemma = Some(w.clone());
219                    }
220                }
221                Some('M') => {
222                    token.lemma = Some(lemmatize::verb(&w));
223                }
224                _ => {}
225            }
226        }
227    }
228}
229
230// Raw-token classification regexes. Anchoring is deliberate.
231mod rgx {
232    use once_cell::sync::Lazy;
233    use regex::Regex;
234
235    static NUMBER: Lazy<Regex> =
236        Lazy::new(|| Regex::new(r"(?-u:^(?:\d+/\d+|\d(?:[.,\-/]?\d)*(?:\.\d+)?)$)").unwrap());
237    static PUNCTUATION: Lazy<Regex> = Lazy::new(|| {
238        Regex::new("^[\u{2019}'\u{2018}\u{2019}`\u{201C}\u{201D}\"\\[\\](){}\u{2026},.!;?\\-:]+$")
239            .unwrap()
240    });
241
242    pub fn is_number(s: &str) -> bool {
243        NUMBER.is_match(s)
244    }
245
246    pub fn is_punctuation(s: &str) -> bool {
247        PUNCTUATION.is_match(s)
248    }
249}