litsea 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/src/lib.rs ADDED
@@ -0,0 +1,84 @@
1
+ //! Ruby binding for Litsea.
2
+ //!
3
+ //! Built with [magnus](https://github.com/matsadler/magnus). Everything
4
+ //! FFI-independent lives in `litsea-binding-core`, so this crate only maps
5
+ //! that surface onto Ruby classes and exceptions.
6
+ //!
7
+ //! Long-running work releases the GVL (see [`gvl`]), so other Ruby threads
8
+ //! keep running during training - which is what lets one of them cancel a
9
+ //! run that is already going.
10
+ //!
11
+ //! ```ruby
12
+ //! require "litsea"
13
+ //!
14
+ //! seg = Litsea::Segmenter.open("japanese", "models/japanese.model")
15
+ //! seg.segment("これはテストです。")
16
+ //! ```
17
+
18
+ pub mod error;
19
+ pub mod gvl;
20
+ pub mod language;
21
+ pub mod metrics;
22
+ pub mod segmenter;
23
+ pub mod token;
24
+ pub mod trainer;
25
+
26
+ use magnus::{Ruby, error::Error, function};
27
+
28
+ /// Returns the version of the underlying `litsea` crate.
29
+ ///
30
+ /// # Returns
31
+ /// The version string, for example `"0.12.0"`.
32
+ fn version() -> String {
33
+ litsea::version().to_string()
34
+ }
35
+
36
+ /// Returns the names of every supported language.
37
+ ///
38
+ /// # Returns
39
+ /// The canonical names, in documentation order.
40
+ fn supported_languages() -> Vec<String> {
41
+ litsea_binding_core::supported_language_names()
42
+ }
43
+
44
+ /// Entry point Ruby calls when the extension is required.
45
+ ///
46
+ /// # Arguments
47
+ /// * `ruby` - The Ruby handle for the current thread.
48
+ ///
49
+ /// # Returns
50
+ /// `()` once every class has been defined.
51
+ ///
52
+ /// # Errors
53
+ /// Returns a Ruby exception if a class cannot be defined.
54
+ #[magnus::init]
55
+ fn init(ruby: &Ruby) -> Result<(), Error> {
56
+ let module = ruby.define_module("Litsea")?;
57
+
58
+ // Exceptions first: everything else raises through them.
59
+ error::define_exceptions(ruby, &module)?;
60
+ token::define(ruby, &module)?;
61
+ metrics::define(ruby, &module)?;
62
+ segmenter::define(ruby, &module)?;
63
+ trainer::define(ruby, &module)?;
64
+
65
+ module.define_module_function("version", function!(version, 0))?;
66
+ module.define_module_function("supported_languages", function!(supported_languages, 0))?;
67
+
68
+ Ok(())
69
+ }
70
+
71
+ #[cfg(test)]
72
+ mod tests {
73
+ use super::*;
74
+
75
+ #[test]
76
+ fn test_version_matches_litsea() {
77
+ assert_eq!(version(), litsea::version());
78
+ }
79
+
80
+ #[test]
81
+ fn test_supported_languages() {
82
+ assert_eq!(supported_languages(), vec!["japanese", "chinese", "korean", "english"]);
83
+ }
84
+ }
data/src/metrics.rs ADDED
@@ -0,0 +1,250 @@
1
+ //! Training metric classes.
2
+
3
+ use litsea::{BinaryMetrics, MulticlassMetrics, TwoStageMetrics};
4
+ use magnus::{Module, RModule, Ruby, error::Error};
5
+
6
+ /// Metrics from training a binary (segmentation) model.
7
+ ///
8
+ /// All percentages are 0-100.
9
+ #[magnus::wrap(class = "Litsea::BinaryMetrics", free_immediately, size)]
10
+ pub struct RbBinaryMetrics {
11
+ /// The wrapped metrics.
12
+ inner: BinaryMetrics,
13
+ }
14
+
15
+ impl RbBinaryMetrics {
16
+ /// Accuracy, as a percentage.
17
+ fn accuracy(&self) -> f64 {
18
+ self.inner.accuracy
19
+ }
20
+
21
+ /// Precision, as a percentage.
22
+ fn precision(&self) -> f64 {
23
+ self.inner.precision
24
+ }
25
+
26
+ /// Recall, as a percentage.
27
+ fn recall(&self) -> f64 {
28
+ self.inner.recall
29
+ }
30
+
31
+ /// Number of training instances.
32
+ fn num_instances(&self) -> usize {
33
+ self.inner.num_instances
34
+ }
35
+
36
+ /// True positives.
37
+ fn true_positives(&self) -> usize {
38
+ self.inner.true_positives
39
+ }
40
+
41
+ /// False positives.
42
+ fn false_positives(&self) -> usize {
43
+ self.inner.false_positives
44
+ }
45
+
46
+ /// False negatives.
47
+ fn false_negatives(&self) -> usize {
48
+ self.inner.false_negatives
49
+ }
50
+
51
+ /// True negatives.
52
+ fn true_negatives(&self) -> usize {
53
+ self.inner.true_negatives
54
+ }
55
+
56
+ /// Returns a readable representation.
57
+ ///
58
+ /// # Returns
59
+ /// For example `#<Litsea::BinaryMetrics accuracy=99.12% instances=1234>`.
60
+ fn inspect(&self) -> String {
61
+ format!(
62
+ "#<Litsea::BinaryMetrics accuracy={:.2}% instances={}>",
63
+ self.inner.accuracy, self.inner.num_instances
64
+ )
65
+ }
66
+ }
67
+
68
+ impl From<BinaryMetrics> for RbBinaryMetrics {
69
+ /// Wraps `litsea`'s metrics for Ruby.
70
+ ///
71
+ /// # Arguments
72
+ /// * `inner` - The metrics to wrap.
73
+ ///
74
+ /// # Returns
75
+ /// The corresponding [`RbBinaryMetrics`].
76
+ fn from(inner: BinaryMetrics) -> Self {
77
+ Self { inner }
78
+ }
79
+ }
80
+
81
+ /// Metrics from training a multiclass model.
82
+ ///
83
+ /// All percentages are 0-100.
84
+ #[magnus::wrap(class = "Litsea::MulticlassMetrics", free_immediately, size)]
85
+ pub struct RbMulticlassMetrics {
86
+ /// The wrapped metrics.
87
+ inner: MulticlassMetrics,
88
+ }
89
+
90
+ impl RbMulticlassMetrics {
91
+ /// Accuracy, as a percentage.
92
+ fn accuracy(&self) -> f64 {
93
+ self.inner.accuracy
94
+ }
95
+
96
+ /// Macro-averaged precision, as a percentage.
97
+ fn macro_precision(&self) -> f64 {
98
+ self.inner.macro_precision
99
+ }
100
+
101
+ /// Macro-averaged recall, as a percentage.
102
+ fn macro_recall(&self) -> f64 {
103
+ self.inner.macro_recall
104
+ }
105
+
106
+ /// Number of training instances.
107
+ fn num_instances(&self) -> usize {
108
+ self.inner.num_instances
109
+ }
110
+
111
+ /// Gold instances per class, as a Hash keyed by the label name.
112
+ fn gold_per_class(&self) -> std::collections::HashMap<String, usize> {
113
+ self.inner.gold_per_class.clone()
114
+ }
115
+
116
+ /// Correct predictions per class.
117
+ fn correct_per_class(&self) -> std::collections::HashMap<String, usize> {
118
+ self.inner.correct_per_class.clone()
119
+ }
120
+
121
+ /// Predictions made per class.
122
+ fn predicted_per_class(&self) -> std::collections::HashMap<String, usize> {
123
+ self.inner.predicted_per_class.clone()
124
+ }
125
+
126
+ /// Returns a readable representation.
127
+ ///
128
+ /// # Returns
129
+ /// For example `#<Litsea::MulticlassMetrics accuracy=97.30% instances=999>`.
130
+ fn inspect(&self) -> String {
131
+ format!(
132
+ "#<Litsea::MulticlassMetrics accuracy={:.2}% instances={}>",
133
+ self.inner.accuracy, self.inner.num_instances
134
+ )
135
+ }
136
+ }
137
+
138
+ impl From<MulticlassMetrics> for RbMulticlassMetrics {
139
+ /// Wraps `litsea`'s metrics for Ruby.
140
+ ///
141
+ /// # Arguments
142
+ /// * `inner` - The metrics to wrap.
143
+ ///
144
+ /// # Returns
145
+ /// The corresponding [`RbMulticlassMetrics`].
146
+ fn from(inner: MulticlassMetrics) -> Self {
147
+ Self { inner }
148
+ }
149
+ }
150
+
151
+ /// Metrics from training a two-stage model: one set per stage.
152
+ #[magnus::wrap(class = "Litsea::TwoStageMetrics", free_immediately, size)]
153
+ pub struct RbTwoStageMetrics {
154
+ /// The wrapped metrics.
155
+ inner: TwoStageMetrics,
156
+ }
157
+
158
+ impl RbTwoStageMetrics {
159
+ /// Stage-1 (boundary classifier) metrics.
160
+ fn stage1(&self) -> RbMulticlassMetrics {
161
+ RbMulticlassMetrics::from(self.inner.stage1.clone())
162
+ }
163
+
164
+ /// Stage-2 (word tagger) metrics.
165
+ fn stage2(&self) -> RbMulticlassMetrics {
166
+ RbMulticlassMetrics::from(self.inner.stage2.clone())
167
+ }
168
+
169
+ /// Returns a readable representation.
170
+ ///
171
+ /// # Returns
172
+ /// For example `#<Litsea::TwoStageMetrics stage1=99.10% stage2=95.40%>`.
173
+ fn inspect(&self) -> String {
174
+ format!(
175
+ "#<Litsea::TwoStageMetrics stage1={:.2}% stage2={:.2}%>",
176
+ self.inner.stage1.accuracy, self.inner.stage2.accuracy
177
+ )
178
+ }
179
+ }
180
+
181
+ impl From<TwoStageMetrics> for RbTwoStageMetrics {
182
+ /// Wraps `litsea`'s metrics for Ruby.
183
+ ///
184
+ /// # Arguments
185
+ /// * `inner` - The metrics to wrap.
186
+ ///
187
+ /// # Returns
188
+ /// The corresponding [`RbTwoStageMetrics`].
189
+ fn from(inner: TwoStageMetrics) -> Self {
190
+ Self { inner }
191
+ }
192
+ }
193
+
194
+ /// Defines the metric classes.
195
+ ///
196
+ /// # Arguments
197
+ /// * `ruby` - The Ruby handle for the current thread.
198
+ /// * `module` - The `Litsea` module to define the classes on.
199
+ ///
200
+ /// # Returns
201
+ /// `()` on success.
202
+ ///
203
+ /// # Errors
204
+ /// Returns a Ruby exception if a class cannot be defined.
205
+ pub fn define(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
206
+ let binary = module.define_class("BinaryMetrics", ruby.class_object())?;
207
+ binary.define_method("accuracy", magnus::method!(RbBinaryMetrics::accuracy, 0))?;
208
+ binary.define_method("precision", magnus::method!(RbBinaryMetrics::precision, 0))?;
209
+ binary.define_method("recall", magnus::method!(RbBinaryMetrics::recall, 0))?;
210
+ binary.define_method("num_instances", magnus::method!(RbBinaryMetrics::num_instances, 0))?;
211
+ binary.define_method("true_positives", magnus::method!(RbBinaryMetrics::true_positives, 0))?;
212
+ binary
213
+ .define_method("false_positives", magnus::method!(RbBinaryMetrics::false_positives, 0))?;
214
+ binary
215
+ .define_method("false_negatives", magnus::method!(RbBinaryMetrics::false_negatives, 0))?;
216
+ binary.define_method("true_negatives", magnus::method!(RbBinaryMetrics::true_negatives, 0))?;
217
+ binary.define_method("inspect", magnus::method!(RbBinaryMetrics::inspect, 0))?;
218
+ binary.define_method("to_s", magnus::method!(RbBinaryMetrics::inspect, 0))?;
219
+
220
+ let multiclass = module.define_class("MulticlassMetrics", ruby.class_object())?;
221
+ multiclass.define_method("accuracy", magnus::method!(RbMulticlassMetrics::accuracy, 0))?;
222
+ multiclass.define_method(
223
+ "macro_precision",
224
+ magnus::method!(RbMulticlassMetrics::macro_precision, 0),
225
+ )?;
226
+ multiclass
227
+ .define_method("macro_recall", magnus::method!(RbMulticlassMetrics::macro_recall, 0))?;
228
+ multiclass
229
+ .define_method("num_instances", magnus::method!(RbMulticlassMetrics::num_instances, 0))?;
230
+ multiclass
231
+ .define_method("gold_per_class", magnus::method!(RbMulticlassMetrics::gold_per_class, 0))?;
232
+ multiclass.define_method(
233
+ "correct_per_class",
234
+ magnus::method!(RbMulticlassMetrics::correct_per_class, 0),
235
+ )?;
236
+ multiclass.define_method(
237
+ "predicted_per_class",
238
+ magnus::method!(RbMulticlassMetrics::predicted_per_class, 0),
239
+ )?;
240
+ multiclass.define_method("inspect", magnus::method!(RbMulticlassMetrics::inspect, 0))?;
241
+ multiclass.define_method("to_s", magnus::method!(RbMulticlassMetrics::inspect, 0))?;
242
+
243
+ let two_stage = module.define_class("TwoStageMetrics", ruby.class_object())?;
244
+ two_stage.define_method("stage1", magnus::method!(RbTwoStageMetrics::stage1, 0))?;
245
+ two_stage.define_method("stage2", magnus::method!(RbTwoStageMetrics::stage2, 0))?;
246
+ two_stage.define_method("inspect", magnus::method!(RbTwoStageMetrics::inspect, 0))?;
247
+ two_stage.define_method("to_s", magnus::method!(RbTwoStageMetrics::inspect, 0))?;
248
+
249
+ Ok(())
250
+ }
data/src/segmenter.rs ADDED
@@ -0,0 +1,241 @@
1
+ //! The `Litsea::Segmenter` class.
2
+
3
+ use std::path::Path;
4
+
5
+ use litsea_binding_core::{CoreSegmenter, TokenView};
6
+ use magnus::{Module, Object, RArray, RModule, RString, Ruby, Value, error::Error};
7
+
8
+ use crate::error::map_err;
9
+ use crate::gvl::without_gvl;
10
+ use crate::language::language_from_value;
11
+ use crate::token::Token;
12
+
13
+ /// A word segmenter, optionally with POS tagging.
14
+ ///
15
+ /// Build one with `Segmenter.open`, `Segmenter.from_bytes`, or
16
+ /// `Segmenter.from_uri`. The kind of model is detected from the file itself,
17
+ /// so `has_pos?` describes what was loaded rather than something the caller
18
+ /// declares.
19
+ #[magnus::wrap(class = "Litsea::Segmenter", free_immediately, size)]
20
+ pub struct Segmenter {
21
+ /// The wrapped core segmenter.
22
+ inner: CoreSegmenter,
23
+ }
24
+
25
+ impl Segmenter {
26
+ /// Loads a model from a filesystem path.
27
+ ///
28
+ /// # Arguments
29
+ /// * `language` - A language name or ISO 639-1 code, as a String or Symbol.
30
+ /// * `path` - Path to the model file.
31
+ ///
32
+ /// # Returns
33
+ /// The new segmenter.
34
+ ///
35
+ /// # Errors
36
+ /// Raises `Litsea::InvalidArgumentError`, `Litsea::IoError`,
37
+ /// `Litsea::ParseError`, or `Litsea::ModelError`.
38
+ fn open(language: Value, path: String) -> Result<Self, Error> {
39
+ let language = language_from_value(language)?;
40
+ // Reading and compiling a model is measurable work; let other Ruby
41
+ // threads run while it happens.
42
+ let inner = without_gvl(|| CoreSegmenter::from_path(language, Path::new(&path)));
43
+ Ok(Self {
44
+ inner: map_err(inner)?,
45
+ })
46
+ }
47
+
48
+ /// Loads a model from a raw byte string.
49
+ ///
50
+ /// Takes the bytes as they are, so a String read with `File.binread`
51
+ /// (ASCII-8BIT) works as well as one read as UTF-8.
52
+ ///
53
+ /// # Arguments
54
+ /// * `language` - A language name or ISO 639-1 code, as a String or Symbol.
55
+ /// * `data` - The model file contents.
56
+ ///
57
+ /// # Returns
58
+ /// The new segmenter.
59
+ ///
60
+ /// # Errors
61
+ /// Raises `Litsea::InvalidArgumentError`, `Litsea::ParseError`, or
62
+ /// `Litsea::ModelError`.
63
+ fn from_bytes(language: Value, data: RString) -> Result<Self, Error> {
64
+ let language = language_from_value(language)?;
65
+ // SAFETY: the slice is used only within this call, and nothing here
66
+ // runs Ruby code that could move or free the string in the meantime
67
+ // (`from_bytes` parses into owned Rust structures).
68
+ let bytes = unsafe { data.as_slice() };
69
+ Ok(Self {
70
+ inner: map_err(CoreSegmenter::from_bytes(language, bytes))?,
71
+ })
72
+ }
73
+
74
+ /// Loads a model from a URI.
75
+ ///
76
+ /// Accepts a filesystem path, a `file://` path, or an `http(s)://` URL.
77
+ /// The GVL is released while the model is fetched.
78
+ ///
79
+ /// # Arguments
80
+ /// * `language` - A language name or ISO 639-1 code, as a String or Symbol.
81
+ /// * `uri` - The model URI.
82
+ ///
83
+ /// # Returns
84
+ /// The new segmenter.
85
+ ///
86
+ /// # Errors
87
+ /// Raises `Litsea::ModelError` if the download fails, plus the same
88
+ /// errors as `open`.
89
+ fn from_uri(language: Value, uri: String) -> Result<Self, Error> {
90
+ let language = language_from_value(language)?;
91
+ let inner = without_gvl(|| CoreSegmenter::from_uri_blocking(language, &uri));
92
+ Ok(Self {
93
+ inner: map_err(inner)?,
94
+ })
95
+ }
96
+
97
+ /// Returns the language this segmenter was built for.
98
+ ///
99
+ /// # Returns
100
+ /// The canonical language name, for example `"japanese"`.
101
+ fn language(&self) -> String {
102
+ self.inner.language().to_string()
103
+ }
104
+
105
+ /// Returns whether this segmenter can tag parts of speech.
106
+ ///
107
+ /// # Returns
108
+ /// `true` when a two-stage POS model was loaded.
109
+ fn has_pos(&self) -> bool {
110
+ self.inner.has_pos()
111
+ }
112
+
113
+ /// Splits a sentence into tokens.
114
+ ///
115
+ /// # Arguments
116
+ /// * `text` - The sentence to segment.
117
+ ///
118
+ /// # Returns
119
+ /// The tokens, in order.
120
+ fn segment(&self, text: String) -> Vec<String> {
121
+ self.inner.segment(&text)
122
+ }
123
+
124
+ /// Splits several sentences into tokens, releasing the GVL.
125
+ ///
126
+ /// # Arguments
127
+ /// * `texts` - The sentences to segment.
128
+ ///
129
+ /// # Returns
130
+ /// One token array per input sentence, in input order.
131
+ fn segment_batch(&self, texts: Vec<String>) -> Vec<Vec<String>> {
132
+ without_gvl(|| self.inner.segment_batch(&texts))
133
+ }
134
+
135
+ /// Splits a sentence into tokens carrying byte offsets.
136
+ ///
137
+ /// # Arguments
138
+ /// * `ruby` - The Ruby handle for the current thread.
139
+ /// * `text` - The sentence to segment.
140
+ ///
141
+ /// # Returns
142
+ /// The tokens, with `pos` set to `nil`.
143
+ ///
144
+ /// # Errors
145
+ /// Returns a Ruby exception if the result array cannot be built.
146
+ fn segment_tokens(ruby: &Ruby, rb_self: &Self, text: String) -> Result<RArray, Error> {
147
+ tokens_to_array(ruby, rb_self.inner.segment_tokens(&text))
148
+ }
149
+
150
+ /// Splits a sentence into tokens and tags each with a UPOS tag.
151
+ ///
152
+ /// # Arguments
153
+ /// * `text` - The sentence to segment and tag.
154
+ ///
155
+ /// # Returns
156
+ /// The tagged tokens, with byte offsets into `text`.
157
+ ///
158
+ /// # Errors
159
+ /// Raises `Litsea::PosUnavailableError` when this segmenter was built
160
+ /// from a segmentation-only model.
161
+ fn segment_with_pos(ruby: &Ruby, rb_self: &Self, text: String) -> Result<RArray, Error> {
162
+ let tokens = map_err(rb_self.inner.segment_with_pos(&text))?;
163
+ tokens_to_array(ruby, tokens)
164
+ }
165
+
166
+ /// Splits and tags several sentences, releasing the GVL.
167
+ ///
168
+ /// # Arguments
169
+ /// * `texts` - The sentences to segment and tag.
170
+ ///
171
+ /// # Returns
172
+ /// One tagged-token array per input sentence, in input order.
173
+ ///
174
+ /// # Errors
175
+ /// Raises `Litsea::PosUnavailableError` when this segmenter was built
176
+ /// from a segmentation-only model.
177
+ fn segment_with_pos_batch(
178
+ ruby: &Ruby,
179
+ rb_self: &Self,
180
+ texts: Vec<String>,
181
+ ) -> Result<RArray, Error> {
182
+ let batches = without_gvl(|| rb_self.inner.segment_with_pos_batch(&texts));
183
+ let outer = ruby.ary_new_capa(texts.len());
184
+ for tokens in map_err(batches)? {
185
+ outer.push(tokens_to_array(ruby, tokens)?)?;
186
+ }
187
+ Ok(outer)
188
+ }
189
+ }
190
+
191
+ /// Builds a Ruby array of [`Token`] objects.
192
+ ///
193
+ /// Wrapped types satisfy `IntoValue` but not `IntoValueFromNative`, which is
194
+ /// what `Vec<T>` conversion requires, so the array is built element by
195
+ /// element.
196
+ ///
197
+ /// # Arguments
198
+ /// * `ruby` - The Ruby handle for the current thread.
199
+ /// * `tokens` - The token views to wrap.
200
+ ///
201
+ /// # Returns
202
+ /// A Ruby array of `Litsea::Token` objects.
203
+ ///
204
+ /// # Errors
205
+ /// Returns a Ruby exception if an element cannot be pushed.
206
+ fn tokens_to_array(ruby: &Ruby, tokens: Vec<TokenView>) -> Result<RArray, Error> {
207
+ let array = ruby.ary_new_capa(tokens.len());
208
+ for view in tokens {
209
+ array.push(Token::from(view))?;
210
+ }
211
+ Ok(array)
212
+ }
213
+
214
+ /// Defines `Litsea::Segmenter`.
215
+ ///
216
+ /// # Arguments
217
+ /// * `ruby` - The Ruby handle for the current thread.
218
+ /// * `module` - The `Litsea` module to define the class on.
219
+ ///
220
+ /// # Returns
221
+ /// `()` on success.
222
+ ///
223
+ /// # Errors
224
+ /// Returns a Ruby exception if the class cannot be defined.
225
+ pub fn define(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
226
+ let class = module.define_class("Segmenter", ruby.class_object())?;
227
+ class.define_singleton_method("open", magnus::function!(Segmenter::open, 2))?;
228
+ class.define_singleton_method("from_bytes", magnus::function!(Segmenter::from_bytes, 2))?;
229
+ class.define_singleton_method("from_uri", magnus::function!(Segmenter::from_uri, 2))?;
230
+ class.define_method("language", magnus::method!(Segmenter::language, 0))?;
231
+ class.define_method("has_pos?", magnus::method!(Segmenter::has_pos, 0))?;
232
+ class.define_method("segment", magnus::method!(Segmenter::segment, 1))?;
233
+ class.define_method("segment_batch", magnus::method!(Segmenter::segment_batch, 1))?;
234
+ class.define_method("segment_tokens", magnus::method!(Segmenter::segment_tokens, 1))?;
235
+ class.define_method("segment_with_pos", magnus::method!(Segmenter::segment_with_pos, 1))?;
236
+ class.define_method(
237
+ "segment_with_pos_batch",
238
+ magnus::method!(Segmenter::segment_with_pos_batch, 1),
239
+ )?;
240
+ Ok(())
241
+ }