Skip to main content

bies/
lib.rs

1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5// https://github.com/unicode-org/icu4x/blob/main/documents/process/boilerplate.md#library-annotations
6// #![cfg_attr(not(any(test, doc)), no_std)]
7// #![cfg_attr(
8//     not(test),
9//     deny(
10//         clippy::indexing_slicing,
11//         clippy::unwrap_used,
12//         clippy::expect_used,
13//         clippy::panic,
14//     )
15// )]
16// #![warn(missing_docs)]
17
18//! The algorithms in this project convert from a BIES matrix (the output of the LSTM segmentation neural network) to concrete segment boundaries.  In BIES, B = beginning of segment; I = inside segment; E = end of segment; and S = single segment (both beginning and end).
19//!
20//! These algorithms always produce valid breakpoint positions (at grapheme cluster boundaries); they don't assume that the neural network always predicts valid positions.
21//!
22//! # Example
23//!
24//! For example, suppose you had the following BIES matrix:
25//!
26//! <pre>
27//! |   B   |   I   |   E   |   S   |
28//! |-------|-------|-------|-------|
29//! | 0.01  | 0.01  | 0.01  | 0.97  |
30//! | 0.97  | 0.01  | 0.01  | 0.01  |
31//! | 0.01  | 0.97  | 0.01  | 0.01  |
32//! | 0.01  | 0.97  | 0.01  | 0.01  |
33//! | 0.01  | 0.01  | 0.97  | 0.01  |
34//! | 0.01  | 0.01  | 0.01  | 0.97  |
35//! | 0.97  | 0.01  | 0.01  | 0.01  |
36//! | 0.01  | 0.01  | 0.97  | 0.01  |
37//! </pre>
38//!
39//! This matrix resolves to:
40//!
41//! <pre>
42//! 01234567
43//! SBIIESBE
44//! </pre>
45//!
46//! The breakpoints are then: 0, 1, 5, and 8 (four segments).
47//!
48//! However, it could be the case that the algorithm's BIES are invalid.  For example, "BEE" is invalid, because the second "E" does not terminate any word.  The purpose of the algorithms in this project is to guarantee that valid breakpoints and BIES are always outputted.
49//!
50//! # Algorithms
51//!
52//! The following algorithms are implemented:
53//!
54//! **1a:** Step through each grapheme cluster boundary in the string. Look at the BIES vectors for the code points surrounding the boundary. The only valid results at that boundary are {EB, ES, SB, SS} (breakpoint) or {II, BI, IE, BE} (no breakpoint). Take the sum of the valid breakpoint and no-breakpoint probabilities, and decide whether to insert a breakpoint based on which sum is higher. Repeat for all grapheme cluster boundaries in the string. The output is a list of word boundaries, which can be converted back into BIES if desired.
55//!
56//! **1b:** Same as 1a, but instead of taking the sum, take the individual maximum.
57//!
58//! **2a:** Step through each element in the BIES sequence. For each element, look at the triplet containing the element and both of its neighbors. By induction, assume the first element in the triplet is correct. Now, depending on whether there is a code point boundary following the element, calculate the probabilities of all valid BIES for the triplet, and based on those results, pick the most likely value for the current element.
59//!
60//! **3a:** Exhaustively check the probabilities of all possible BIES for the string. This algorithm has exponential runtime.
61
62use core::default::Default;
63use core::fmt;
64use itertools::Itertools;
65use partial_min_max::max;
66use strum::EnumIter;
67use writeable::{LengthHint, Writeable};
68
69#[derive(Clone, Debug, PartialEq, Default)]
70#[allow(clippy::exhaustive_structs)] // todo
71pub struct Breakpoints {
72    /// An ascending list of breakpoints. All elements must be between 0 and length exclusive.
73    pub breakpoints: Vec<usize>,
74    /// The total length; i.e., the limit of the final word.
75    pub length: usize,
76}
77
78#[derive(Clone, Copy, Debug, PartialEq)]
79#[allow(clippy::exhaustive_structs)] // by definition
80pub struct BiesVector<F: fmt::Debug> {
81    pub b: F,
82    pub i: F,
83    pub e: F,
84    pub s: F,
85}
86
87// TODO: Consider parameterizing the f32 to a trait
88#[derive(Clone, Debug, PartialEq)]
89#[allow(clippy::exhaustive_structs)] // newtype
90pub struct BiesMatrix(pub Vec<BiesVector<f32>>);
91
92#[derive(Clone, PartialEq)]
93#[allow(clippy::exhaustive_structs)] // newtype
94pub struct BiesString<'a>(&'a Breakpoints);
95
96#[derive(Clone, Copy, Debug, PartialEq, EnumIter)]
97#[non_exhaustive]
98pub enum Algorithm {
99    /// Algorithm 1a: check probabilities surrounding each valid breakpoint. Switch based on the sum.
100    Alg1a,
101
102    /// Algorithm 1b: check probabilities surrounding each valid breakpoint. Switch based on the individual max.
103    Alg1b,
104
105    /// Algorithm 2: step forward through the matrix and pick the highest probability at each step
106    Alg2a,
107
108    /// Algorithm 3: exhaustively check all combinations of breakpoints to find the highest true probability
109    Alg3a,
110}
111
112impl Breakpoints {
113    pub fn from_bies_matrix(
114        algorithm: Algorithm,
115        matrix: &BiesMatrix,
116        valid_breakpoints: impl Iterator<Item = usize>,
117    ) -> Self {
118        match algorithm {
119            Algorithm::Alg1a => Self::from_bies_matrix_1a(matrix, valid_breakpoints),
120            Algorithm::Alg1b => Self::from_bies_matrix_1b(matrix, valid_breakpoints),
121            Algorithm::Alg2a => Self::from_bies_matrix_2a(matrix, valid_breakpoints),
122            Algorithm::Alg3a => Self::from_bies_matrix_3a(matrix, valid_breakpoints),
123        }
124    }
125
126    #[expect(clippy::suspicious_operation_groupings)]
127    fn from_bies_matrix_1a(
128        matrix: &BiesMatrix,
129        valid_breakpoints: impl Iterator<Item = usize>,
130    ) -> Self {
131        let mut breakpoints = vec![];
132        for i in valid_breakpoints {
133            if i == 0 || i >= matrix.0.len() {
134                // TODO: Make fail-safe
135                panic!("Invalid i value");
136            }
137            let bies1 = &matrix.0[i - 1];
138            let bies2 = &matrix.0[i];
139            let break_score =
140                bies1.e * bies2.b + bies1.e * bies2.s + bies1.s * bies2.b + bies1.s * bies2.s;
141            let nobrk_score =
142                bies1.i * bies2.i + bies1.i * bies2.e + bies1.b * bies2.i + bies1.b * bies2.e;
143            if break_score > nobrk_score {
144                breakpoints.push(i);
145            }
146        }
147        Self {
148            breakpoints,
149            length: matrix.0.len(),
150        }
151    }
152
153    fn from_bies_matrix_1b(
154        matrix: &BiesMatrix,
155        valid_breakpoints: impl Iterator<Item = usize>,
156    ) -> Self {
157        let mut breakpoints = vec![];
158        for i in valid_breakpoints {
159            if i == 0 || i >= matrix.0.len() {
160                // TODO: Make fail-safe
161                panic!("Invalid i value");
162            }
163            let bies1 = &matrix.0[i - 1];
164            let bies2 = &matrix.0[i];
165            let mut candidate = (f32::NEG_INFINITY, false);
166            candidate = max(candidate, (bies1.e * bies2.b, true));
167            candidate = max(candidate, (bies1.e * bies2.s, true));
168            candidate = max(candidate, (bies1.s * bies2.b, true));
169            candidate = max(candidate, (bies1.s * bies2.s, true));
170            candidate = max(candidate, (bies1.i * bies2.i, false));
171            candidate = max(candidate, (bies1.i * bies2.e, false));
172            candidate = max(candidate, (bies1.b * bies2.i, false));
173            candidate = max(candidate, (bies1.b * bies2.e, false));
174            if candidate.1 {
175                breakpoints.push(i);
176            }
177        }
178        Self {
179            breakpoints,
180            length: matrix.0.len(),
181        }
182    }
183
184    fn from_bies_matrix_2a(
185        matrix: &BiesMatrix,
186        mut valid_breakpoints: impl Iterator<Item = usize>,
187    ) -> Self {
188        if matrix.0.len() <= 1 {
189            return Self::default();
190        }
191        let mut breakpoints = vec![];
192        let mut inside_word = false;
193        let mut next_valid_brkpt = valid_breakpoints.next();
194        for i in 0..(matrix.0.len() - 1) {
195            let bies1 = &matrix.0[i];
196            let bies2 = &matrix.0[i + 1];
197            let is_valid_brkpt = next_valid_brkpt == Some(i + 1);
198            let mut candidate = (f32::NEG_INFINITY, false);
199            if inside_word {
200                // IE, II
201                candidate = max(candidate, (bies1.i * bies2.e, false));
202                candidate = max(candidate, (bies1.i * bies2.i, false));
203                if is_valid_brkpt {
204                    // EB, ES
205                    candidate = max(candidate, (bies1.e * bies2.b, true));
206                    candidate = max(candidate, (bies1.e * bies2.s, true));
207                }
208            } else {
209                // BI, BE
210                candidate = max(candidate, (bies1.b * bies2.i, false));
211                candidate = max(candidate, (bies1.b * bies2.e, false));
212                if is_valid_brkpt {
213                    // SB, SS
214                    candidate = max(candidate, (bies1.s * bies2.b, true));
215                    candidate = max(candidate, (bies1.s * bies2.s, true));
216                }
217            }
218            if candidate.1 {
219                breakpoints.push(i + 1);
220            }
221            inside_word = !candidate.1;
222            if is_valid_brkpt {
223                next_valid_brkpt = valid_breakpoints.next();
224            }
225        }
226        Self {
227            breakpoints,
228            length: matrix.0.len(),
229        }
230    }
231
232    fn from_bies_matrix_3a(
233        matrix: &BiesMatrix,
234        valid_breakpoints: impl Iterator<Item = usize>,
235    ) -> Self {
236        let valid_breakpoints: Vec<usize> = valid_breakpoints.collect();
237        let mut best_log_probability = f32::NEG_INFINITY;
238        let mut breakpoints: Vec<usize> = vec![];
239        for i in 0..=valid_breakpoints.len() {
240            for combo in valid_breakpoints.iter().combinations(i) {
241                let mut log_probability = 0.0;
242                let mut add_word = |i: usize, j: usize| {
243                    if i == j - 1 {
244                        log_probability += matrix.0[i].s.ln();
245                    } else {
246                        log_probability += matrix.0[i].b.ln();
247                        for k in (i + 1)..(j - 1) {
248                            log_probability += matrix.0[k].i.ln();
249                        }
250                        log_probability += matrix.0[j - 1].e.ln();
251                    }
252                };
253                let mut i = 0;
254                for j in combo.iter().copied().copied() {
255                    add_word(i, j);
256                    i = j;
257                }
258                add_word(i, matrix.0.len());
259                if log_probability > best_log_probability {
260                    best_log_probability = log_probability;
261                    breakpoints = combo.iter().copied().copied().collect();
262                }
263            }
264        }
265        Self {
266            breakpoints,
267            length: matrix.0.len(),
268        }
269    }
270}
271
272impl<'a> From<&'a Breakpoints> for BiesString<'a> {
273    fn from(other: &'a Breakpoints) -> Self {
274        Self(other)
275    }
276}
277
278impl Writeable for BiesString<'_> {
279    fn write_to<W: fmt::Write + ?Sized>(&self, sink: &mut W) -> fmt::Result {
280        let mut write_bies_word = |i: usize, j: usize| -> fmt::Result {
281            if i == j - 1 {
282                sink.write_char('s')?;
283            } else {
284                sink.write_char('b')?;
285                for _ in (i + 1)..(j - 1) {
286                    sink.write_char('i')?;
287                }
288                sink.write_char('e')?;
289            }
290            Ok(())
291        };
292        let mut i = 0;
293        for j in self.0.breakpoints.iter().copied() {
294            write_bies_word(i, j)?;
295            i = j;
296        }
297        write_bies_word(i, self.0.length)?;
298        Ok(())
299    }
300
301    fn writeable_length_hint(&self) -> LengthHint {
302        LengthHint::exact(self.0.length)
303    }
304}
305
306impl fmt::Debug for BiesString<'_> {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        self.write_to(f)
309    }
310}
311
312writeable::impl_display_with_writeable!(BiesString<'_>);