pos_tagger/tagger.rs
1//! Part-of-speech tagger.
2//!
3//! Tagging runs in two phases. First each word gets an initial tag from the
4//! lexicon, falling back to a lowercase lookup and then to `NN`. Second the eight
5//! contextual rules rewrite tags left to right.
6
7use crate::lexicon;
8use crate::rules;
9use std::collections::HashMap;
10
11/// A Brill-style English part-of-speech tagger.
12///
13/// Each tagger owns its lexicon, so [`Tagger::extend_lexicon`] affects only that
14/// instance. Construct one with [`Tagger::new`].
15///
16/// ```
17/// use pos_tagger::Tagger;
18/// let tagger = Tagger::new();
19/// let tagged = tagger.tag(&["the", "dog"]);
20/// assert_eq!(tagged, vec![
21/// ("the".to_string(), "DT".to_string()),
22/// ("dog".to_string(), "NN".to_string()),
23/// ]);
24/// ```
25#[derive(Debug, Clone)]
26pub struct Tagger {
27 lexicon: HashMap<String, Vec<String>>,
28}
29
30impl Default for Tagger {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl Tagger {
37 /// Create a tagger with the built-in English lexicon.
38 pub fn new() -> Self {
39 Tagger {
40 lexicon: lexicon::load(),
41 }
42 }
43
44 /// Tag each word in `words`.
45 ///
46 /// Returns a vector the same length as the input. Each entry pairs the
47 /// original word with its tag. The input is never modified.
48 ///
49 /// `words` is any slice whose elements borrow as `str`, so `&[&str]`,
50 /// `&[String]`, and the lexer's `Vec<String>` output all pass without an
51 /// intermediate `Vec<&str>`. Write `tagger.tag(&lexer.lex(text))` directly.
52 ///
53 /// Each result pair owns its word as a fresh `String`. The pair shape is the
54 /// public contract, so the word is copied out of the borrowed input. Callers
55 /// that only need tags can read the second field and drop the first.
56 ///
57 /// An empty token tags as `NN`. It is a lexicon key with an empty tag list,
58 /// and the empty list falls back to `NN` instead of panicking.
59 pub fn tag<S: AsRef<str>>(&self, words: &[S]) -> Vec<(String, String)> {
60 let mut sentence: Vec<(String, String)> = words
61 .iter()
62 .map(|word| {
63 let word = word.as_ref();
64 (word.to_string(), self.initial_tag(word))
65 })
66 .collect();
67
68 for index in 0..sentence.len() {
69 rules::apply_all(&mut sentence, index);
70 }
71 sentence
72 }
73
74 /// Compute the initial tag for one word before the rules run.
75 ///
76 /// Look up the exact word first. On a miss, look up the lowercase form. On a
77 /// second miss, default to `NN`. When a lookup hits, use the first tag in the
78 /// candidate list.
79 fn initial_tag(&self, word: &str) -> String {
80 if let Some(tags) = self.lexicon.get(word) {
81 return first_tag(tags);
82 }
83 if let Some(tags) = self.lexicon.get(&word.to_lowercase()) {
84 return first_tag(tags);
85 }
86 "NN".to_string()
87 }
88
89 /// Report whether `word` is in the lexicon.
90 ///
91 /// Returns true when the exact word is a key or when its lowercase form is a
92 /// key. The empty string is a key with an empty tag list, so
93 /// `word_in_lexicon("")` returns true.
94 ///
95 /// The lexicon is a `HashMap`, so object member names such as `constructor`,
96 /// `valueOf`, `hasOwnProperty`, `toString`, and `__proto__` return false
97 /// unless they are real keys. None of them are. A lookup over a plain object
98 /// would inherit these names and report true. This crate keeps the `HashMap`
99 /// result on purpose.
100 pub fn word_in_lexicon(&self, word: &str) -> bool {
101 if self.lexicon.contains_key(word) {
102 return true;
103 }
104 self.lexicon.contains_key(&word.to_lowercase())
105 }
106
107 /// Add entries from `entries`, keeping existing keys untouched.
108 ///
109 /// Only keys absent from the current lexicon are inserted. Existing entries
110 /// are never overwritten.
111 ///
112 /// `entries` is any iterable of `(word, tags)` pairs. A `HashMap`, a
113 /// `BTreeMap`, a `Vec`, an array, or a `map` adaptor all work, so callers do
114 /// not build a `HashMap` first.
115 pub fn extend_lexicon<I>(&mut self, entries: I)
116 where
117 I: IntoIterator<Item = (String, Vec<String>)>,
118 {
119 for (word, tags) in entries {
120 self.lexicon.entry(word).or_insert(tags);
121 }
122 }
123}
124
125/// First tag of a candidate list, or `NN` when the list is empty.
126///
127/// The lexicon has one empty-tag entry, the key for the empty string. The lexer
128/// never emits an empty token, so this path is unreachable through the normal
129/// pipeline. Returning `NN` keeps a direct `tag(&[""])` call total instead of
130/// panicking.
131fn first_tag(tags: &[String]) -> String {
132 tags.first().cloned().unwrap_or_else(|| "NN".to_string())
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn unknown_word_defaults_to_nn() {
141 let t = Tagger::new();
142 assert_eq!(t.tag(&["Zzqxxw"]), vec![("Zzqxxw".into(), "NN".into())]);
143 }
144
145 #[test]
146 fn lowercase_fallback() {
147 let t = Tagger::new();
148 assert_eq!(t.tag(&["THE"]), vec![("THE".into(), "DT".into())]);
149 }
150}