1pub mod config;
10pub mod corpus;
11pub mod feature_extractor;
12pub mod feature_rewriter;
13pub mod model;
14
15use std::collections::HashMap;
16use std::num::NonZeroU32;
17
18use anyhow::Result;
19
20use self::feature_extractor::TemplateContext;
21
22macro_rules! log_info {
24 ($($arg:tt)*) => {
25 println!($($arg)*)
26 };
27}
28
29macro_rules! log_debug {
30 ($($arg:tt)*) => {
31 if cfg!(debug_assertions) {
32 println!("DEBUG: {}", format!($($arg)*))
33 }
34 };
35}
36
37macro_rules! log_progress {
38 ($($arg:tt)*) => {
39 println!($($arg)*)
40 };
41}
42
43pub use self::config::TrainerConfig;
44pub use self::corpus::{Corpus, Example, Word};
45pub use self::model::{Model, SerializableModel};
46
47pub struct Trainer {
91 config: TrainerConfig,
92
93 max_grouping_len: Option<usize>,
99
100 provider: lindera_crf::FeatureProvider,
101
102 label_id_map: std::collections::HashMap<String, std::collections::HashMap<char, NonZeroU32>>,
104
105 label_id_map_unk: Vec<NonZeroU32>,
114
115 regularization_cost: f64,
116 use_l2: bool,
117 elastic_net_l1_ratio: Option<f64>,
118 max_iter: u64,
119 num_threads: usize,
120}
121
122impl Trainer {
123 pub fn new(mut config: TrainerConfig) -> Result<Self> {
125 let mut provider = lindera_crf::FeatureProvider::default();
126 let mut label_id_map: HashMap<String, HashMap<char, NonZeroU32>> = HashMap::new();
127
128 let default_features = if let Some(first_feature) = config.features.first() {
131 let field_count = first_feature.split(',').count();
132 vec!["*"; field_count].join(",")
133 } else {
134 "*".to_string()
135 };
136
137 for (i, surface) in config.surfaces.iter().enumerate() {
138 let feature_str: &str = if i < config.features.len() {
140 &config.features[i]
141 } else {
142 &default_features
143 };
144
145 let (ufeature, lfeature, rfeature) = config.dictionary_rewriter.rewrite(feature_str);
149 let u_vec: Vec<&str> = ufeature.split(',').collect();
150 let l_vec: Vec<&str> = lfeature.split(',').collect();
151 let r_vec: Vec<&str> = rfeature.split(',').collect();
152
153 let cate_id = if let Some(first_char) = surface.chars().next() {
155 let categories = config
156 .dict
157 .character_definition
158 .lookup_categories(first_char);
159 if !categories.is_empty() {
160 categories[0].0 as u32
161 } else {
162 0 }
164 } else {
165 0
166 };
167
168 let feature_extractor = &mut config.feature_extractor;
170 let ctx = TemplateContext {
171 surface: Some(surface),
172 ufeature: Some(&ufeature),
173 lfeature: Some(&lfeature),
174 rfeature: Some(&rfeature),
175 };
176
177 let unigram_ids =
178 feature_extractor.extract_unigram_feature_ids_with_ctx(&u_vec, cate_id, &ctx);
179 let left_ids = feature_extractor.extract_left_feature_ids_with_ctx(&l_vec, &ctx);
180 let right_ids = feature_extractor.extract_right_feature_ids_with_ctx(&r_vec, &ctx);
181
182 let feature_set = lindera_crf::FeatureSet::new(&unigram_ids, &right_ids, &left_ids);
183
184 let label_id = provider.add_feature_set(feature_set)?;
186
187 let char_map = label_id_map.entry(feature_str.to_string()).or_default();
189 if let Some(first_char) = surface.chars().next() {
190 char_map.insert(first_char, label_id);
191 }
192 }
193
194 let mut label_id_map_unk = Vec::new();
196 let char_def = &config.dict.character_definition;
197 let unk_category_names = char_def.categories();
198
199 for (i, category) in unk_category_names.iter().enumerate() {
200 let unk_feature: &str = config
202 .unk_categories
203 .get(category)
204 .map(|s| s.as_str())
205 .unwrap_or(&default_features);
206
207 let (ufeature, lfeature, rfeature) = config.dictionary_rewriter.rewrite(unk_feature);
210 let u_vec: Vec<&str> = ufeature.split(',').collect();
211 let l_vec: Vec<&str> = lfeature.split(',').collect();
212 let r_vec: Vec<&str> = rfeature.split(',').collect();
213
214 let feature_extractor = &mut config.feature_extractor;
216 let ctx = TemplateContext {
217 surface: None,
218 ufeature: Some(&ufeature),
219 lfeature: Some(&lfeature),
220 rfeature: Some(&rfeature),
221 };
222
223 let unigram_ids =
224 feature_extractor.extract_unigram_feature_ids_with_ctx(&u_vec, i as u32, &ctx);
225 let left_ids = feature_extractor.extract_left_feature_ids_with_ctx(&l_vec, &ctx);
226 let right_ids = feature_extractor.extract_right_feature_ids_with_ctx(&r_vec, &ctx);
227
228 let feature_set = lindera_crf::FeatureSet::new(&unigram_ids, &right_ids, &left_ids);
229
230 let unk_label_id = provider.add_feature_set(feature_set)?;
232 label_id_map_unk.push(unk_label_id);
233 }
234
235 Ok(Self {
236 config,
237 max_grouping_len: None, provider,
239 label_id_map,
240 label_id_map_unk,
241 regularization_cost: 0.01,
242 use_l2: false,
243 elastic_net_l1_ratio: None,
244 max_iter: 100,
245 num_threads: 8,
246 })
247 }
248
249 pub fn regularization_cost(mut self, cost: f64) -> Self {
251 self.regularization_cost = cost;
252 self
253 }
254
255 pub fn use_l2(mut self, l2: bool) -> Self {
257 self.use_l2 = l2;
258 self
259 }
260
261 pub fn elastic_net_l1_ratio(mut self, ratio: f64) -> Self {
265 self.elastic_net_l1_ratio = Some(ratio);
266 self
267 }
268
269 pub fn max_iter(mut self, iter: u64) -> Self {
271 self.max_iter = iter;
272 self
273 }
274
275 pub fn num_threads(mut self, threads: usize) -> Self {
277 self.num_threads = threads;
278 self
279 }
280
281 pub fn max_grouping_len(mut self, len: Option<usize>) -> Self {
283 self.max_grouping_len = len;
284 self
285 }
286
287 pub fn get_regularization_cost(&self) -> f64 {
289 self.regularization_cost
290 }
291
292 pub fn get_max_iter(&self) -> u64 {
294 self.max_iter
295 }
296
297 pub fn get_num_threads(&self) -> usize {
299 self.num_threads
300 }
301
302 pub fn train(mut self, corpus: Corpus) -> Result<Model> {
304 let lattices = self.build_lattices_from_corpus(&corpus)?;
305 let labels = self.extract_labels();
306 let crf_model = self.train_crf_model(lattices)?;
307
308 self.create_final_model(crf_model, labels, corpus)
309 }
310
311 fn build_lattices_from_corpus(&mut self, corpus: &Corpus) -> Result<Vec<lindera_crf::Lattice>> {
313 log_info!("Building feature lattices...");
314
315 let mut lattices = Vec::new();
316 for (i, example) in corpus.examples.iter().enumerate() {
317 log_progress!("Processing example {}/{}", i + 1, corpus.examples.len());
318
319 let lattice = self.build_lattice(example)?;
324 lattices.push(lattice);
325 }
326
327 Ok(lattices)
328 }
329
330 fn train_crf_model(
332 &mut self,
333 lattices: Vec<lindera_crf::Lattice>,
334 ) -> Result<lindera_crf::RawModel> {
335 log_info!("Starting CRF training with {} lattices...", lattices.len());
336
337 let reg_type = if let Some(l1_ratio) = self.elastic_net_l1_ratio {
339 log_info!(
340 "Training parameters: regularization={} (ElasticNet, l1_ratio={}), max_iter={}, threads={}",
341 self.regularization_cost,
342 l1_ratio,
343 self.max_iter,
344 self.num_threads
345 );
346 lindera_crf::Regularization::ElasticNet { l1_ratio }
347 } else if self.use_l2 {
348 log_info!(
349 "Training parameters: regularization={} (L2), max_iter={}, threads={}",
350 self.regularization_cost,
351 self.max_iter,
352 self.num_threads
353 );
354 lindera_crf::Regularization::L2
355 } else {
356 log_info!(
357 "Training parameters: regularization={} (L1), max_iter={}, threads={}",
358 self.regularization_cost,
359 self.max_iter,
360 self.num_threads
361 );
362 lindera_crf::Regularization::L1
363 };
364
365 let trainer = lindera_crf::Trainer::new()
367 .regularization(reg_type, self.regularization_cost)?
368 .max_iter(self.max_iter)?
369 .n_threads(self.num_threads)?;
370
371 self.execute_training(trainer, lattices)
372 }
373
374 fn execute_training(
376 &mut self,
377 trainer: lindera_crf::Trainer,
378 lattices: Vec<lindera_crf::Lattice>,
379 ) -> Result<lindera_crf::RawModel> {
380 println!("L-BFGS optimization starting...");
381 println!(
382 "Note: This may take several minutes for large datasets. Progress will be shown by L-BFGS iterations above."
383 );
384 println!("Each 'iter:' line indicates training progress. Please wait...");
385
386 let start_time = std::time::Instant::now();
387
388 let provider = std::mem::take(&mut self.provider);
390 let model = trainer.train(&lattices, provider);
391 let training_duration = start_time.elapsed();
392
393 self.log_training_results(&model);
394 println!(
395 "Training completed successfully in {:.2}s!",
396 training_duration.as_secs_f64()
397 );
398
399 Ok(model)
400 }
401
402 fn log_training_results(&self, model: &lindera_crf::RawModel) {
404 log_debug!("Training completed, checking raw model...");
405 log_debug!("Raw model weights count: {}", model.weights().len());
406 log_debug!(
407 "Raw model first 5 weights: {:?}",
408 &model.weights()[..std::cmp::min(5, model.weights().len())]
409 );
410
411 let weights = model.weights();
413 let nan_count = weights.iter().filter(|&&w| w.is_nan()).count();
414 let inf_count = weights.iter().filter(|&&w| w.is_infinite()).count();
415 let zero_count = weights.iter().filter(|&&w| w == 0.0).count();
416 log_debug!(
417 "Weight analysis - NaN: {}, Inf: {}, Zero: {}, Total: {}",
418 nan_count,
419 inf_count,
420 zero_count,
421 weights.len()
422 );
423
424 let weight_sum: f64 = weights.iter().sum();
425 log_debug!("Sum of all weights: {:.16}", weight_sum);
426
427 log_debug!(
428 "Model unigram_weight_indices len: {}",
429 model.unigram_weight_indices().len()
430 );
431 log_debug!(
432 "Model bigram_weight_indices len: {}",
433 model.bigram_weight_indices().len()
434 );
435 }
436
437 fn create_final_model(
439 mut self,
440 crf_model: lindera_crf::RawModel,
441 labels: Vec<String>,
442 _corpus: Corpus,
443 ) -> Result<Model> {
444 self.remove_unused_features(&crf_model);
446
447 let feature_weights = self.extract_feature_weights(&crf_model);
449
450 Ok(Model::new_with_metadata(
452 crf_model,
453 self.config,
454 feature_weights,
455 labels,
456 self.regularization_cost,
457 self.max_iter,
458 ))
459 }
460
461 fn extract_feature_weights(&self, crf_model: &lindera_crf::RawModel) -> Vec<f64> {
463 log_info!("Extracting feature weights from trained model...");
464
465 let mut feature_weights = Vec::new();
466 match crf_model.merge() {
467 Ok(merged_model) => {
468 for (i, _surface) in self.config.surfaces.iter().enumerate() {
470 if i < merged_model.feature_sets.len() {
471 feature_weights.push(merged_model.feature_sets[i].weight);
472 } else {
473 feature_weights.push(0.0);
474 }
475 }
476
477 let non_zero_count = feature_weights.iter().filter(|&&w| w != 0.0).count();
478 log_info!(
479 "Extracted {} weights ({} non-zero) from merged model",
480 feature_weights.len(),
481 non_zero_count
482 );
483 }
484 Err(e) => {
485 log_info!("Model merge failed: {e}, falling back to raw weights");
486 self.use_raw_weights(crf_model, &mut feature_weights);
487 }
488 }
489
490 feature_weights
491 }
492
493 fn use_raw_weights(&self, crf_model: &lindera_crf::RawModel, feature_weights: &mut Vec<f64>) {
495 let raw_weights = crf_model.weights();
496
497 for i in 0..self.config.surfaces.len() {
498 if i < raw_weights.len() {
499 feature_weights.push(raw_weights[i]);
500 } else {
501 feature_weights.push(0.0);
502 }
503 }
504 log_info!("Used {} raw model weights", feature_weights.len());
505 }
506
507 fn extract_labels(&self) -> Vec<String> {
509 let mut labels = self.config.surfaces.clone();
510 let char_def = &self.config.dict.character_definition;
513 for category_name in char_def.categories() {
514 labels.push(category_name.to_string());
515 }
516 labels
517 }
518
519 fn build_lattice(&mut self, example: &Example) -> Result<lindera_crf::Lattice> {
520 use lindera_crf::{Edge, Lattice};
521
522 let input_chars: Vec<char> = example.sentence.chars().collect();
523 let input_len = input_chars.len();
524
525 let mut edges = vec![];
527 let mut pos = 0;
528 for token in &example.tokens {
529 let token_len = token.surface().chars().count();
530 let first_char = input_chars[pos];
531
532 let label_id = self
534 .label_id_map
535 .get(token.feature())
536 .and_then(|hm| hm.get(&first_char))
537 .cloned()
538 .map(Ok)
539 .unwrap_or_else(|| {
540 self.config
542 .dict()
543 .unknown_dictionary
544 .compatible_unk_index(
545 &example.sentence,
546 pos,
547 pos + token_len,
548 token.feature(),
549 )
550 .map_or_else(
551 || {
552 self.provider.add_feature_set(lindera_crf::FeatureSet::new(
553 &[],
554 &[],
555 &[],
556 ))
557 },
558 |unk_index| Ok(self.label_id_map_unk[unk_index.word_id as usize]),
559 )
560 })?;
561
562 edges.push((pos, Edge::new(pos + token_len, label_id)));
563 pos += token_len;
564 }
565 assert_eq!(pos, input_len);
566
567 let mut lattice = Lattice::new(input_len)?;
568
569 for (pos, edge) in edges {
571 lattice.add_edge(pos, edge)?;
572 }
573
574 for start_word in 0..input_len {
576 let mut has_matched = false;
577
578 let suffix = &input_chars[start_word..];
579
580 for m in self.config.system_lexicon().common_prefix_iterator(suffix) {
582 has_matched = true;
583 let label_id = NonZeroU32::new(m.word_idx.word_id + 1).unwrap(); let pos = start_word;
585 let target = pos + m.end_char;
586 let edge = Edge::new(target, label_id);
587
588 if let Some(first_edge) = lattice.nodes()[pos].edges().first()
590 && edge == *first_edge
591 {
592 continue;
593 }
594 lattice.add_edge(pos, edge)?;
595 }
596
597 let sentence: String = input_chars.iter().collect();
599 self.config.unk_handler().gen_unk_words(
600 &sentence,
601 start_word,
602 has_matched,
603 self.max_grouping_len,
604 |w| {
605 let id_offset = self.config.surfaces.len() as u32;
606 let label_id = NonZeroU32::new(id_offset + w.word_idx().word_id + 1).unwrap(); let pos = start_word;
608 let target = w.end_char();
609 let edge = lindera_crf::Edge::new(target, label_id);
610
611 if let Some(first_edge) = lattice.nodes()[pos].edges().first()
613 && edge == *first_edge
614 {
615 return;
616 }
617 lattice.add_edge(pos, edge).unwrap();
618 },
619 );
620 }
621
622 Ok(lattice)
623 }
624
625 fn remove_unused_features(&mut self, model: &lindera_crf::RawModel) {
627 println!("Removing unused features...");
628
629 use std::collections::HashSet;
630
631 let mut used_right_features = HashSet::new();
632
633 let unigram_feature_keys: Vec<_> = self
635 .config
636 .feature_extractor
637 .unigram_feature_ids
638 .keys()
639 .cloned()
640 .collect();
641 let left_feature_keys: Vec<_> = self
642 .config
643 .feature_extractor
644 .left_feature_ids
645 .keys()
646 .cloned()
647 .collect();
648 let right_feature_keys: Vec<_> = self
649 .config
650 .feature_extractor
651 .right_feature_ids
652 .keys()
653 .cloned()
654 .collect();
655
656 for k in &unigram_feature_keys {
658 let id = self
659 .config
660 .feature_extractor
661 .unigram_feature_ids
662 .get(k)
663 .unwrap();
664 if model
665 .unigram_weight_indices()
666 .get((id.get() - 1) as usize)
667 .cloned()
668 .flatten()
669 .is_none()
670 {
671 self.config.feature_extractor.unigram_feature_ids.remove(k);
672 }
673 }
674
675 for feature_ids in model.bigram_weight_indices() {
677 for (feature_id, _) in feature_ids {
678 used_right_features.insert(*feature_id);
679 }
680 }
681
682 for k in &left_feature_keys {
684 let id = self
685 .config
686 .feature_extractor
687 .left_feature_ids
688 .get(k)
689 .unwrap();
690 if let Some(x) = model.bigram_weight_indices().get(id.get() as usize)
691 && x.is_empty()
692 {
693 self.config.feature_extractor.left_feature_ids.remove(k);
694 }
695 }
696
697 for k in &right_feature_keys {
699 let id = self
700 .config
701 .feature_extractor
702 .right_feature_ids
703 .get(k)
704 .unwrap();
705 if !used_right_features.contains(&id.get()) {
706 self.config.feature_extractor.right_feature_ids.remove(k);
707 }
708 }
709
710 println!("Feature cleanup completed");
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use std::io::Cursor;
718
719 #[test]
720 fn test_train_with_bigram_template() {
721 let seed_csv = "これ,0,0,0,名詞,代名詞,一般,*,*,*,これ,コレ,コレ\nは,0,0,0,助詞,係助詞,*,*,*,*,は,ハ,ワ\nテスト,0,0,0,名詞,サ変接続,*,*,*,*,テスト,テスト,テスト\n";
722 let char_def = "DEFAULT 0 1 0\nHIRAGANA 1 1 0\nKATAKANA 1 1 0\nKANJI 0 0 2\nALPHA 1 1 0\nNUMERIC 1 1 0\n\n0x3041..0x3096 HIRAGANA\n0x30A1..0x30F6 KATAKANA\n0x4E00..0x9FAF KANJI\n0x0030..0x0039 NUMERIC\n0x0041..0x005A ALPHA\n0x0061..0x007A ALPHA\n";
723 let unk_def = "DEFAULT,0,0,0,名詞,一般,*,*,*,*,*,*,*\nHIRAGANA,0,0,0,名詞,一般,*,*,*,*,*,*,*\nKATAKANA,0,0,0,名詞,一般,*,*,*,*,*,*,*\nKANJI,0,0,0,名詞,一般,*,*,*,*,*,*,*\nALPHA,0,0,0,名詞,固有名詞,一般,*,*,*,*,*,*\nNUMERIC,0,0,0,名詞,数,*,*,*,*,*,*,*\n";
724 let feature_def = "UNIGRAM U00:%F[0]\nUNIGRAM U01:%F[0],%F?[1]\nBIGRAM B00:%L[0]/%R[0]\n";
725 let rewrite_def = "名詞,一般\tNOUN,GENERAL\n";
726 let corpus_text = "これ\t名詞,代名詞,一般,*,*,*,これ,コレ,コレ\nは\t助詞,係助詞,*,*,*,*,は,ハ,ワ\nテスト\t名詞,サ変接続,*,*,*,*,テスト,テスト,テスト\nEOS\n";
727
728 let config = TrainerConfig::from_readers(
729 Cursor::new(seed_csv),
730 Cursor::new(char_def),
731 Cursor::new(unk_def),
732 Cursor::new(feature_def),
733 Cursor::new(rewrite_def),
734 )
735 .unwrap();
736
737 let trainer = Trainer::new(config)
738 .unwrap()
739 .regularization_cost(0.01)
740 .max_iter(5)
741 .num_threads(1);
742
743 let corpus = Corpus::from_reader(Cursor::new(corpus_text)).unwrap();
744
745 let model = trainer.train(corpus).unwrap();
746
747 let mut buf = Vec::new();
749 model.write_model(&mut buf).unwrap();
750 assert!(!buf.is_empty(), "Serialized model should not be empty");
751
752 let serializable: model::SerializableModel =
753 model::Model::read_model(Cursor::new(&buf)).unwrap();
754
755 assert!(
757 !serializable.labels.is_empty(),
758 "Labels should not be empty"
759 );
760
761 assert!(
763 !serializable.connection_matrix.is_empty(),
764 "Connection matrix should not be empty"
765 );
766
767 assert!(
769 !serializable.left_id_map.is_empty(),
770 "Left ID map should not be empty"
771 );
772 assert!(
773 !serializable.right_id_map.is_empty(),
774 "Right ID map should not be empty"
775 );
776
777 assert!(
779 !serializable.unk_categories.is_empty(),
780 "Unknown word categories should not be empty"
781 );
782
783 assert!(
785 serializable.cost_factor > 0,
786 "Cost factor should be positive"
787 );
788
789 let mut lex_buf = Vec::new();
793 serializable.write_lexicon(&mut lex_buf).unwrap();
794 let lex_content = String::from_utf8(lex_buf).unwrap();
795 assert!(!lex_content.is_empty(), "lex.csv should not be empty");
796 for surface in &["これ", "は", "テスト"] {
797 assert!(
798 lex_content.contains(surface),
799 "lex.csv should contain {surface}"
800 );
801 }
802 for line in lex_content.lines() {
804 let fields: Vec<&str> = line.split(',').collect();
805 assert!(
806 fields.len() >= 4,
807 "lex.csv line should have at least 4 fields: {line}"
808 );
809 fields[1]
811 .parse::<i32>()
812 .unwrap_or_else(|_| panic!("Invalid left_id in lex.csv: {line}"));
813 fields[2]
814 .parse::<i32>()
815 .unwrap_or_else(|_| panic!("Invalid right_id in lex.csv: {line}"));
816 fields[3]
818 .parse::<i32>()
819 .unwrap_or_else(|_| panic!("Invalid cost in lex.csv: {line}"));
820 }
821
822 let mut matrix_buf = Vec::new();
824 serializable
825 .write_connection_costs(&mut matrix_buf)
826 .unwrap();
827 let matrix_content = String::from_utf8(matrix_buf).unwrap();
828 let mut lines = matrix_content.lines();
829 let header = lines.next().unwrap();
830 let dims: Vec<usize> = header
831 .split_whitespace()
832 .map(|s| s.parse().unwrap())
833 .collect();
834 assert_eq!(dims.len(), 2, "matrix.def header should have 2 dimensions");
835 let (rows, cols) = (dims[0], dims[1]);
836 assert!(rows > 0 && cols > 0, "Matrix dimensions should be positive");
837 let mut entry_count = 0;
839 for line in lines {
840 let parts: Vec<&str> = line.split_whitespace().collect();
841 assert_eq!(
842 parts.len(),
843 3,
844 "matrix.def entry should have 3 fields: {line}"
845 );
846 parts[0]
847 .parse::<usize>()
848 .unwrap_or_else(|_| panic!("Invalid right_id: {line}"));
849 parts[1]
850 .parse::<usize>()
851 .unwrap_or_else(|_| panic!("Invalid left_id: {line}"));
852 parts[2]
853 .parse::<i32>()
854 .unwrap_or_else(|_| panic!("Invalid cost: {line}"));
855 entry_count += 1;
856 }
857 assert_eq!(
858 entry_count,
859 rows * cols,
860 "Dense matrix should have rows*cols entries"
861 );
862
863 let mut unk_buf = Vec::new();
865 serializable.write_unknown_dictionary(&mut unk_buf).unwrap();
866 let unk_content = String::from_utf8(unk_buf).unwrap();
867 assert!(!unk_content.is_empty(), "unk.def should not be empty");
868 assert!(
869 unk_content.contains("DEFAULT"),
870 "unk.def should contain DEFAULT category"
871 );
872
873 let mut char_buf = Vec::new();
875 serializable.write_char_def(&mut char_buf).unwrap();
876 let char_content = String::from_utf8(char_buf).unwrap();
877 assert!(!char_content.is_empty(), "char.def should not be empty");
878
879 let mut feat_buf = Vec::new();
881 serializable.write_feature_def(&mut feat_buf).unwrap();
882 let feat_content = String::from_utf8(feat_buf).unwrap();
883 assert!(!feat_content.is_empty(), "feature.def should not be empty");
884 assert!(
885 feat_content.contains("UNIGRAM"),
886 "feature.def should contain UNIGRAM"
887 );
888 assert!(
889 feat_content.contains("BIGRAM"),
890 "feature.def should contain BIGRAM"
891 );
892
893 let mut rewrite_buf = Vec::new();
895 serializable.write_rewrite_def(&mut rewrite_buf).unwrap();
896 let rewrite_content = String::from_utf8(rewrite_buf).unwrap();
897 assert!(
898 !rewrite_content.is_empty(),
899 "rewrite.def should not be empty"
900 );
901
902 let mut left_id_buf = Vec::new();
904 serializable.write_left_id_def(&mut left_id_buf).unwrap();
905 let left_id_content = String::from_utf8(left_id_buf).unwrap();
906 assert!(
907 !left_id_content.is_empty(),
908 "left-id.def should not be empty"
909 );
910 for line in left_id_content.lines() {
912 let parts: Vec<&str> = line.splitn(2, ' ').collect();
913 assert_eq!(
914 parts.len(),
915 2,
916 "left-id.def line should have id and feature: {line}"
917 );
918 parts[0]
919 .parse::<u32>()
920 .unwrap_or_else(|_| panic!("Invalid id in left-id.def: {line}"));
921 }
922
923 let mut right_id_buf = Vec::new();
925 serializable.write_right_id_def(&mut right_id_buf).unwrap();
926 let right_id_content = String::from_utf8(right_id_buf).unwrap();
927 assert!(
928 !right_id_content.is_empty(),
929 "right-id.def should not be empty"
930 );
931 for line in right_id_content.lines() {
932 let parts: Vec<&str> = line.splitn(2, ' ').collect();
933 assert_eq!(
934 parts.len(),
935 2,
936 "right-id.def line should have id and feature: {line}"
937 );
938 parts[0]
939 .parse::<u32>()
940 .unwrap_or_else(|_| panic!("Invalid id in right-id.def: {line}"));
941 }
942
943 let left_id_count = left_id_content.lines().count();
945 assert_eq!(
946 left_id_count, cols,
947 "left-id.def entries ({left_id_count}) should match matrix columns ({cols})"
948 );
949
950 let right_id_count = right_id_content.lines().count();
952 assert_eq!(
953 right_id_count, rows,
954 "right-id.def entries ({right_id_count}) should match matrix rows ({rows})"
955 );
956 }
957}
958
959#[cfg(test)]
966pub(crate) mod test_fixtures {
967 use crate::{Corpus, Trainer, TrainerConfig};
968 use std::io::Cursor;
969
970 pub(crate) fn wide_seed_and_corpus() -> (String, String) {
980 let pos = [
983 ("名詞", "一般"),
984 ("名詞", "固有名詞"),
985 ("名詞", "サ変接続"),
986 ("名詞", "代名詞"),
987 ("動詞", "自立"),
988 ("動詞", "非自立"),
989 ("形容詞", "自立"),
990 ("副詞", "一般"),
991 ("助詞", "係助詞"),
992 ("助詞", "格助詞"),
993 ("助動詞", "*"),
994 ("接続詞", "*"),
995 ];
996 let mut seed = String::new();
997 let mut corpus = String::new();
998 for (i, (major, sub)) in pos.iter().enumerate() {
999 for variant in 0..4 {
1000 let surface = format!("語{i}{variant}");
1001 let feature = format!("{major},{sub},*,*,*,*,{surface},カナ,カナ");
1002 seed.push_str(&format!("{surface},0,0,0,{feature}\n"));
1003 corpus.push_str(&format!("{surface}\t{feature}\n"));
1004 }
1005 corpus.push_str("EOS\n");
1006 }
1007 (seed, corpus)
1008 }
1009
1010 pub(crate) fn fixtures() -> (
1013 &'static str,
1014 &'static str,
1015 &'static str,
1016 &'static str,
1017 &'static str,
1018 &'static str,
1019 ) {
1020 (
1021 "これ,0,0,0,名詞,代名詞,一般,*,*,*,これ,コレ,コレ\nは,0,0,0,助詞,係助詞,*,*,*,*,は,ハ,ワ\nテスト,0,0,0,名詞,サ変接続,*,*,*,*,テスト,テスト,テスト\n",
1022 "DEFAULT 0 1 0\nHIRAGANA 1 1 0\nKATAKANA 1 1 0\nKANJI 0 0 2\nALPHA 1 1 0\nNUMERIC 1 1 0\n\n0x3041..0x3096 HIRAGANA\n0x30A1..0x30F6 KATAKANA\n0x4E00..0x9FAF KANJI\n0x0030..0x0039 NUMERIC\n0x0041..0x005A ALPHA\n0x0061..0x007A ALPHA\n",
1023 "DEFAULT,0,0,0,名詞,一般,*,*,*,*,*,*,*\nHIRAGANA,0,0,0,名詞,一般,*,*,*,*,*,*,*\nKATAKANA,0,0,0,名詞,一般,*,*,*,*,*,*,*\nKANJI,0,0,0,名詞,一般,*,*,*,*,*,*,*\nALPHA,0,0,0,名詞,固有名詞,一般,*,*,*,*,*,*\nNUMERIC,0,0,0,名詞,数,*,*,*,*,*,*,*\n",
1024 "UNIGRAM U00:%F[0]\nUNIGRAM U01:%F[0],%F?[1]\nBIGRAM B00:%L[0]/%R[0]\n",
1025 "名詞,一般\tNOUN,GENERAL\n",
1026 "これ\t名詞,代名詞,一般,*,*,*,これ,コレ,コレ\nは\t助詞,係助詞,*,*,*,*,は,ハ,ワ\nテスト\t名詞,サ変接続,*,*,*,*,テスト,テスト,テスト\nEOS\n",
1027 )
1028 }
1029
1030 pub(crate) fn train_wide_model(threads: usize) -> crate::model::Model {
1033 let (_, char_def, unk_def, feature_def, rewrite_def, _) = fixtures();
1034 let (seed, corpus_text) = wide_seed_and_corpus();
1035
1036 let config = match TrainerConfig::from_readers(
1037 Cursor::new(seed.as_bytes()),
1038 Cursor::new(char_def),
1039 Cursor::new(unk_def),
1040 Cursor::new(feature_def),
1041 Cursor::new(rewrite_def),
1042 ) {
1043 Ok(config) => config,
1044 Err(err) => panic!("failed to build config: {err}"),
1045 };
1046
1047 let trainer = match Trainer::new(config) {
1048 Ok(trainer) => trainer,
1049 Err(err) => panic!("failed to build trainer: {err}"),
1050 }
1051 .regularization_cost(0.01)
1052 .max_iter(5)
1053 .num_threads(threads);
1054
1055 let corpus = match Corpus::from_reader(Cursor::new(corpus_text.as_bytes())) {
1056 Ok(corpus) => corpus,
1057 Err(err) => panic!("failed to read corpus: {err}"),
1058 };
1059
1060 match trainer.train(corpus) {
1061 Ok(model) => model,
1062 Err(err) => panic!("training failed: {err}"),
1063 }
1064 }
1065}
1066
1067#[cfg(test)]
1068mod export_determinism_tests {
1069 use crate::test_fixtures::train_wide_model;
1070 use std::io::Cursor;
1071
1072 #[test]
1079 fn user_lexicon_roundtrips_in_input_order() {
1080 let user_csv = "外食,0,0,0,名詞,一般,*,*,*,*,外食,ガイショク,ガイショク\n\
1084 ラー油,3,3,912,名詞,一般,*,*,*,*,ラー油,ラーユ,ラーユ\n";
1085
1086 let write_all = |model: &crate::model::Model| -> Vec<u8> {
1087 let (mut lex, mut conn, mut unk, mut user) =
1088 (Vec::new(), Vec::new(), Vec::new(), Vec::new());
1089 if let Err(err) = model.write_dictionary(&mut lex, &mut conn, &mut unk, &mut user) {
1090 panic!("write_dictionary failed: {err}");
1091 }
1092 user
1093 };
1094
1095 let mut model = train_wide_model(1);
1096 if let Err(err) = model.read_user_lexicon(Cursor::new(user_csv.as_bytes())) {
1097 panic!("read_user_lexicon failed: {err}");
1098 }
1099
1100 let first = write_all(&model);
1101 assert!(
1102 !first.is_empty(),
1103 "user lexicon output is empty: write_user_lexicon is not reading user_entries"
1104 );
1105
1106 let text = match String::from_utf8(first.clone()) {
1107 Ok(text) => text,
1108 Err(err) => panic!("user lexicon output is not UTF-8: {err}"),
1109 };
1110 let lines: Vec<&str> = text.lines().collect();
1111 assert_eq!(lines.len(), 2, "expected both user entries: {text}");
1112
1113 assert!(
1115 lines[0].starts_with("外食,"),
1116 "first line must be the first input entry: {}",
1117 lines[0]
1118 );
1119 assert_eq!(
1121 lines[1], "ラー油,3,3,912,名詞,一般,*,*,*,*,ラー油,ラーユ,ラーユ",
1122 "non-zero parameters must be preserved as-is"
1123 );
1124 assert!(
1126 lines[0].ends_with(",名詞,一般,*,*,*,*,外食,ガイショク,ガイショク"),
1127 "inferred line must keep the input features: {}",
1128 lines[0]
1129 );
1130
1131 let second = write_all(&model);
1133 assert_eq!(first, second, "user lexicon output must be reproducible");
1134 }
1135
1136 #[test]
1144 fn bigram_details_are_reproducible_and_named() {
1145 let model = train_wide_model(1);
1146
1147 let dump = || -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1148 let (mut left, mut right, mut cost) = (Vec::new(), Vec::new(), Vec::new());
1149 if let Err(err) = model.write_bigram_details(&mut left, &mut right, &mut cost) {
1150 panic!("write_bigram_details failed: {err}");
1151 }
1152 (left, right, cost)
1153 };
1154
1155 let (left_a, right_a, cost_a) = dump();
1156 let (left_b, right_b, cost_b) = dump();
1157 assert_eq!(left_a, left_b, "left file must be reproducible");
1158 assert_eq!(right_a, right_b, "right file must be reproducible");
1159 assert_eq!(cost_a, cost_b, "cost file must be reproducible");
1160
1161 let cost_text = match String::from_utf8(cost_a) {
1162 Ok(text) => text,
1163 Err(err) => panic!("cost file is not UTF-8: {err}"),
1164 };
1165 assert!(
1166 cost_text.lines().any(|line| line.starts_with("BOS/")),
1167 "cost file must label bigram feature id 0 as BOS: {cost_text}"
1168 );
1169 assert!(
1170 cost_text.lines().any(|line| line.contains("/EOS\t")),
1171 "cost file must label right feature id 0 as EOS: {cost_text}"
1172 );
1173 assert!(
1174 cost_text.contains("名詞"),
1175 "cost file must carry real feature names, not fabricated ids: {cost_text}"
1176 );
1177
1178 let left_text = match String::from_utf8(left_a) {
1179 Ok(text) => text,
1180 Err(err) => panic!("left file is not UTF-8: {err}"),
1181 };
1182 assert!(
1183 left_text.contains("名詞"),
1184 "left file must carry real feature names: {left_text}"
1185 );
1186 }
1187}
1188
1189#[cfg(test)]
1190mod train_determinism_tests {
1191 use crate::test_fixtures::fixtures;
1192 use crate::{Corpus, Trainer, TrainerConfig};
1193 use std::io::Cursor;
1194
1195 fn train_and_serialize(threads: usize) -> Vec<u8> {
1203 let model = crate::test_fixtures::train_wide_model(threads);
1204 let mut buf = Vec::new();
1205 if let Err(err) = model.write_model(&mut buf) {
1206 panic!("failed to serialize model: {err}");
1207 }
1208 buf
1209 }
1210
1211 #[test]
1214 fn training_twice_produces_identical_bytes() {
1215 let first = train_and_serialize(1);
1216 let second = train_and_serialize(1);
1217
1218 assert!(!first.is_empty(), "serialized model must not be empty");
1219 assert_eq!(
1220 first.len(),
1221 second.len(),
1222 "two training runs produced different model sizes"
1223 );
1224 assert_eq!(
1225 first, second,
1226 "two training runs over identical inputs must produce identical bytes"
1227 );
1228 }
1229
1230 #[test]
1234 fn training_is_identical_across_thread_counts() {
1235 let single = train_and_serialize(1);
1236 let parallel = train_and_serialize(4);
1237
1238 assert!(!single.is_empty(), "serialized model must not be empty");
1239 assert_eq!(
1240 single, parallel,
1241 "1-thread and 4-thread training must produce identical bytes"
1242 );
1243 }
1244
1245 #[test]
1249 fn writing_one_model_twice_produces_identical_bytes() {
1250 let (seed, char_def, unk_def, feature_def, rewrite_def, corpus_text) = fixtures();
1251
1252 let config = match TrainerConfig::from_readers(
1253 Cursor::new(seed),
1254 Cursor::new(char_def),
1255 Cursor::new(unk_def),
1256 Cursor::new(feature_def),
1257 Cursor::new(rewrite_def),
1258 ) {
1259 Ok(config) => config,
1260 Err(err) => panic!("failed to build config: {err}"),
1261 };
1262 let trainer = match Trainer::new(config) {
1263 Ok(trainer) => trainer,
1264 Err(err) => panic!("failed to build trainer: {err}"),
1265 }
1266 .regularization_cost(0.01)
1267 .max_iter(5)
1268 .num_threads(1);
1269 let corpus = match Corpus::from_reader(Cursor::new(corpus_text)) {
1270 Ok(corpus) => corpus,
1271 Err(err) => panic!("failed to read corpus: {err}"),
1272 };
1273 let model = match trainer.train(corpus) {
1274 Ok(model) => model,
1275 Err(err) => panic!("training failed: {err}"),
1276 };
1277
1278 let mut first = Vec::new();
1279 let mut second = Vec::new();
1280 if let Err(err) = model.write_model(&mut first) {
1281 panic!("first serialization failed: {err}");
1282 }
1283 if let Err(err) = model.write_model(&mut second) {
1284 panic!("second serialization failed: {err}");
1285 }
1286
1287 assert_eq!(first, second, "two writes of one model must match");
1288 }
1289}