Skip to content

Commit 08875fd

Browse files
committed
fix: proposed fix for the word ngrams bug
1 parent 15ddfe2 commit 08875fd

2 files changed

Lines changed: 104 additions & 81 deletions

File tree

src/extractors/word_ngrams.rs

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::FeatureExtractor;
22
use lasso::{Rodeo, Spur};
3+
use std::fmt::Write;
34

45
#[derive(Clone)]
56
pub struct WordNgrams {
@@ -30,25 +31,51 @@ impl FeatureExtractor for WordNgrams {
3031
return vec![];
3132
}
3233

33-
let tokens = text.split(&self.splitter).filter(|s| !s.is_empty());
34+
let tokens: Vec<&str> = text.split(&self.splitter).collect();
35+
let mut padded_tokens = Vec::with_capacity(tokens.len() + 2);
36+
padded_tokens.push(self.padder.as_str());
37+
padded_tokens.extend(tokens.iter().copied());
38+
padded_tokens.push(self.padder.as_str());
3439

35-
// an iterator that includes padding
36-
let padded_tokens_iter = std::iter::once(self.padder.as_str())
37-
.chain(tokens)
38-
.chain(std::iter::once(self.padder.as_str()));
40+
if padded_tokens.len() < self.n {
41+
return vec![];
42+
}
3943

40-
// Use a buffer to collect tokens for each n-gram
41-
let mut buffer: Vec<&str> = Vec::with_capacity(self.n);
42-
let mut ngrams = Vec::new();
44+
let mut ngrams = Vec::with_capacity(padded_tokens.len().saturating_sub(self.n) + 1);
4345

44-
for token in padded_tokens_iter {
45-
buffer.push(token);
46-
if buffer.len() == self.n {
47-
ngrams.push(buffer.join(" "));
48-
buffer.remove(0);
49-
}
46+
for window in padded_tokens.windows(self.n) {
47+
ngrams.push(encode_word_window(window));
5048
}
5149

5250
super::append_feature_counts(interner, ngrams)
5351
}
5452
}
53+
54+
fn encode_word_window(window: &[&str]) -> String {
55+
let estimated_capacity: usize = window
56+
.iter()
57+
.map(|token| token.len() + digits(token.len()) + 1)
58+
.sum::<usize>()
59+
.saturating_sub(1);
60+
61+
let mut encoded = String::with_capacity(estimated_capacity);
62+
63+
for (index, token) in window.iter().enumerate() {
64+
if index > 0 {
65+
encoded.push('|');
66+
}
67+
let _ = write!(&mut encoded, "{}:", token.len());
68+
encoded.push_str(token);
69+
}
70+
71+
encoded
72+
}
73+
74+
fn digits(mut value: usize) -> usize {
75+
let mut digits = 1;
76+
while value >= 10 {
77+
value /= 10;
78+
digits += 1;
79+
}
80+
digits
81+
}

tests/test_features.rs

Lines changed: 63 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use lasso::Rodeo;
1+
use lasso::{Rodeo, Spur};
22
use simstring_rust::{CharacterNgrams, FeatureExtractor, WordNgrams};
33

44
#[test]
@@ -92,6 +92,21 @@ fn test_character_trigrams_prepress() {
9292
assert_eq!(resolved_features, expected);
9393
}
9494

95+
fn sorted_features(interner: &Rodeo, features: &[Spur]) -> Vec<String> {
96+
let mut resolved: Vec<String> = features
97+
.iter()
98+
.map(|spur| interner.resolve(spur).to_string())
99+
.collect();
100+
resolved.sort();
101+
resolved
102+
}
103+
104+
fn sorted_strings(items: Vec<&str>) -> Vec<String> {
105+
let mut owned: Vec<String> = items.into_iter().map(String::from).collect();
106+
owned.sort();
107+
owned
108+
}
109+
95110
#[cfg(test)]
96111
mod word_ngrams_tests {
97112
use super::*;
@@ -101,12 +116,8 @@ mod word_ngrams_tests {
101116
let mut interner = Rodeo::default();
102117
let extractor = WordNgrams::default(); // n=2, splitter=" ", padder=" "
103118
let features = extractor.features("a b", &mut interner);
104-
let resolved: Vec<String> = features
105-
.iter()
106-
.map(|s| interner.resolve(s).to_string())
107-
.collect();
108-
// Corrected expectation: The padder " " and the joiner " " create two spaces.
109-
let expected = vec![" a1", "a b1", "b 1"];
119+
let resolved = sorted_features(&interner, &features);
120+
let expected = sorted_strings(vec!["1: |1:a1", "1:a|1:b1", "1:b|1: 1"]);
110121
assert_eq!(resolved, expected);
111122
}
112123

@@ -115,11 +126,8 @@ mod word_ngrams_tests {
115126
let mut interner = Rodeo::default();
116127
let extractor = WordNgrams::new(2, " ", "-");
117128
let features = extractor.features("word", &mut interner);
118-
let resolved: Vec<String> = features
119-
.iter()
120-
.map(|s| interner.resolve(s).to_string())
121-
.collect();
122-
let expected = vec!["- word1", "word -1"];
129+
let resolved = sorted_features(&interner, &features);
130+
let expected = sorted_strings(vec!["1:-|4:word1", "4:word|1:-1"]);
123131
assert_eq!(resolved, expected);
124132
}
125133

@@ -128,17 +136,14 @@ mod word_ngrams_tests {
128136
let mut interner = Rodeo::default();
129137
let extractor = WordNgrams::new(3, " ", "<PAD>");
130138
let features = extractor.features("this is a simple test", &mut interner);
131-
let resolved: Vec<String> = features
132-
.iter()
133-
.map(|s| interner.resolve(s).to_string())
134-
.collect();
135-
let expected = vec![
136-
"<PAD> this is1",
137-
"this is a1",
138-
"is a simple1",
139-
"a simple test1",
140-
"simple test <PAD>1",
141-
];
139+
let resolved = sorted_features(&interner, &features);
140+
let expected = sorted_strings(vec![
141+
"5:<PAD>|4:this|2:is1",
142+
"4:this|2:is|1:a1",
143+
"2:is|1:a|6:simple1",
144+
"1:a|6:simple|4:test1",
145+
"6:simple|4:test|5:<PAD>1",
146+
]);
142147
assert_eq!(resolved, expected);
143148
}
144149

@@ -148,42 +153,44 @@ mod word_ngrams_tests {
148153
let extractor = WordNgrams::new(2, " ", "$");
149154
let s = "You are a really really really cool dude 😄🍕";
150155
let features = extractor.features(s, &mut interner);
151-
let resolved: Vec<String> = features
152-
.iter()
153-
.map(|s| interner.resolve(s).to_string())
154-
.collect();
155-
let expected = vec![
156-
"$ You1",
157-
"You are1",
158-
"are a1",
159-
"a really1",
160-
"really really1",
161-
"really really2",
162-
"really cool1",
163-
"cool dude1",
164-
"dude 😄🍕1",
165-
"😄🍕 $1",
166-
];
156+
let resolved = sorted_features(&interner, &features);
157+
let expected = sorted_strings(vec![
158+
"1:$|3:You1",
159+
"3:You|3:are1",
160+
"3:are|1:a1",
161+
"1:a|6:really1",
162+
"6:really|6:really1",
163+
"6:really|6:really2",
164+
"6:really|4:cool1",
165+
"4:cool|4:dude1",
166+
"4:dude|8:😄🍕1",
167+
"8:😄🍕|1:$1",
168+
]);
167169
assert_eq!(resolved, expected);
168170
}
169171

170172
#[test]
171173
fn test_word_ngram_edge_cases() {
172174
let mut interner = Rodeo::default();
173175
let extractor = WordNgrams::new(2, " ", "$");
176+
174177
let features_empty = extractor.features("", &mut interner);
175-
let resolved_empty: Vec<String> = features_empty
176-
.iter()
177-
.map(|s| interner.resolve(s).to_string())
178-
.collect();
179-
assert_eq!(resolved_empty, vec!["$ $1"]);
178+
let resolved_empty = sorted_features(&interner, &features_empty);
179+
assert_eq!(resolved_empty, sorted_strings(vec!["1:$|0:1", "0:|1:$1"]));
180180

181181
let features_spaces = extractor.features(" ", &mut interner);
182-
let resolved_spaces: Vec<String> = features_spaces
183-
.iter()
184-
.map(|s| interner.resolve(s).to_string())
185-
.collect();
186-
assert_eq!(resolved_spaces, vec!["$ $1"]);
182+
let resolved_spaces = sorted_features(&interner, &features_spaces);
183+
assert_eq!(
184+
resolved_spaces,
185+
sorted_strings(vec!["1:$|0:1", "0:|0:1", "0:|0:2", "0:|0:3", "0:|1:$1"])
186+
);
187+
188+
let features_double_space = extractor.features("a b", &mut interner);
189+
let resolved_double_space = sorted_features(&interner, &features_double_space);
190+
assert_eq!(
191+
resolved_double_space,
192+
sorted_strings(vec!["1:$|1:a1", "1:a|0:1", "0:|1:b1", "1:b|1:$1"])
193+
);
187194
}
188195

189196
#[test]
@@ -193,22 +200,15 @@ mod word_ngrams_tests {
193200
// Case 1: n=2, input="abcd", splitter=" ", padder=" "
194201
let extractor_case1 = WordNgrams::new(2, " ", " ");
195202
let features_case1 = extractor_case1.features("abcd", &mut interner);
196-
let resolved1: Vec<String> = features_case1
197-
.iter()
198-
.map(|s| interner.resolve(s).to_string())
199-
.collect();
200-
// Corrected expectation
201-
let expected_case1 = vec![" abcd1", "abcd 1"];
203+
let resolved1 = sorted_features(&interner, &features_case1);
204+
let expected_case1 = sorted_strings(vec!["1: |4:abcd1", "4:abcd|1: 1"]);
202205
assert_eq!(resolved1, expected_case1, "Failed on: n=2, input='abcd'");
203206

204207
// Case 2: n=2, input="hello world", splitter=" ", padder=" "
205208
let features_case2 = extractor_case1.features("hello world", &mut interner);
206-
let resolved2: Vec<String> = features_case2
207-
.iter()
208-
.map(|s| interner.resolve(s).to_string())
209-
.collect();
210-
// Corrected expectation
211-
let expected_case2 = vec![" hello1", "hello world1", "world 1"];
209+
let resolved2 = sorted_features(&interner, &features_case2);
210+
let expected_case2 =
211+
sorted_strings(vec!["1: |5:hello1", "5:hello|5:world1", "5:world|1: 1"]);
212212
assert_eq!(
213213
resolved2, expected_case2,
214214
"Failed on: n=2, input='hello world'"
@@ -217,12 +217,8 @@ mod word_ngrams_tests {
217217
// Case 3: n=3, input="hello world", splitter=" ", padder=" "
218218
let extractor_case3 = WordNgrams::new(3, " ", " ");
219219
let features_case3 = extractor_case3.features("hello world", &mut interner);
220-
let resolved3: Vec<String> = features_case3
221-
.iter()
222-
.map(|s| interner.resolve(s).to_string())
223-
.collect();
224-
// Corrected expectation
225-
let expected_case3 = vec![" hello world1", "hello world 1"];
220+
let resolved3 = sorted_features(&interner, &features_case3);
221+
let expected_case3 = sorted_strings(vec!["1: |5:hello|5:world1", "5:hello|5:world|1: 1"]);
226222
assert_eq!(
227223
resolved3, expected_case3,
228224
"Failed on: n=3, input='hello world'"

0 commit comments

Comments
 (0)