1use std::fmt;
21
22use super::adapt::{adapt, adapt_with_df};
23use super::bag::{BagKind, BagOptions, bag};
24use super::error::TextError;
25use super::limits::TextLimits;
26use super::split::Split;
27use super::standardize::Standardize;
28use super::transform::{TransformOptions, transform};
29use super::vocab::{
30 RESERVED_COUNT, Vocabulary, check_max_tokens, check_vocabulary_within_max_tokens,
31};
32use crate::Tensor;
33
34#[non_exhaustive]
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
51pub struct TextVectorizationConfig {
52 pub max_tokens: Option<usize>,
54 pub standardize: Standardize,
56 pub split: Split,
58 pub ngrams: Option<usize>,
60 pub output_sequence_length: Option<usize>,
62 pub pad_to_max_tokens: bool,
64 pub limits: TextLimits,
66}
67
68impl TextVectorizationConfig {
69 pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
71 self.max_tokens = Some(max_tokens);
72 self
73 }
74 pub fn with_standardize(mut self, standardize: Standardize) -> Self {
76 self.standardize = standardize;
77 self
78 }
79 pub fn with_split(mut self, split: Split) -> Self {
81 self.split = split;
82 self
83 }
84 pub fn with_ngrams(mut self, n: usize) -> Self {
86 self.ngrams = Some(n);
87 self
88 }
89 pub fn with_output_sequence_length(mut self, len: usize) -> Self {
91 self.output_sequence_length = Some(len);
92 self
93 }
94 pub fn with_pad_to_max_tokens(mut self, pad: bool) -> Self {
96 self.pad_to_max_tokens = pad;
97 self
98 }
99 pub fn with_limits(mut self, limits: TextLimits) -> Self {
101 self.limits = limits;
102 self
103 }
104}
105
106#[derive(Clone)]
113pub struct TextVectorization {
114 config: TextVectorizationConfig,
115 vocabulary: Vocabulary,
116 idf: Option<Vec<f32>>,
119}
120
121fn check_pad(config: &TextVectorizationConfig) -> Result<(), TextError> {
123 if config.pad_to_max_tokens && config.max_tokens.is_none() {
124 return Err(TextError::PadToMaxTokensWithoutMaxTokens);
125 }
126 Ok(())
127}
128
129fn assemble_idf(vocab_weights: &[f32]) -> Vec<f32> {
133 let oov = if vocab_weights.is_empty() {
134 0.0f32
135 } else {
136 let sum: f64 = vocab_weights.iter().map(|&w| f64::from(w)).sum();
137 (sum / vocab_weights.len() as f64) as f32
138 };
139 let mut idf = Vec::with_capacity(vocab_weights.len() + RESERVED_COUNT);
140 idf.push(0.0f32);
141 idf.push(oov);
142 idf.extend_from_slice(vocab_weights);
143 idf
144}
145
146fn check_config_numbers(config: &TextVectorizationConfig) -> Result<(), TextError> {
148 if let Some(n) = config.ngrams {
149 config.limits.check_ngrams(n)?;
150 }
151 if let Some(l) = config.output_sequence_length {
152 config.limits.check_output_sequence_length(l)?;
153 }
154 Ok(())
155}
156
157impl TextVectorization {
158 pub fn from_vocabulary<S: AsRef<str>>(
166 config: TextVectorizationConfig,
167 vocabulary: &[S],
168 ) -> Result<Self, TextError> {
169 check_pad(&config)?;
170 check_max_tokens(config.max_tokens)?;
171 check_vocabulary_within_max_tokens(config.max_tokens, vocabulary.len())?;
172 check_config_numbers(&config)?;
173 let vocabulary = Vocabulary::from_tokens(&config.limits, vocabulary)?;
174 Ok(Self {
175 config,
176 vocabulary,
177 idf: None,
178 })
179 }
180
181 pub fn from_vocabulary_with_idf<S: AsRef<str>>(
189 config: TextVectorizationConfig,
190 vocabulary: &[S],
191 idf_weights: &[f32],
192 ) -> Result<Self, TextError> {
193 check_pad(&config)?;
194 check_max_tokens(config.max_tokens)?;
195 check_vocabulary_within_max_tokens(config.max_tokens, vocabulary.len())?;
196 check_config_numbers(&config)?;
197 if idf_weights.len() != vocabulary.len() {
198 return Err(TextError::IdfWeightsLengthMismatch {
199 len: idf_weights.len(),
200 expected: vocabulary.len(),
201 });
202 }
203 if let Some(index) = idf_weights
204 .iter()
205 .position(|w| !w.is_finite() || w.is_sign_negative() && *w != 0.0)
206 {
207 return Err(TextError::InvalidIdfWeight { index });
208 }
209 let vocabulary = Vocabulary::from_tokens(&config.limits, vocabulary)?;
210 let idf = Some(assemble_idf(idf_weights));
211 Ok(Self {
212 config,
213 vocabulary,
214 idf,
215 })
216 }
217
218 pub fn adapt<S: AsRef<str>>(
226 config: TextVectorizationConfig,
227 corpus: &[S],
228 ) -> Result<Self, TextError> {
229 check_pad(&config)?;
230 check_max_tokens(config.max_tokens)?;
231 check_config_numbers(&config)?;
232 let vocabulary = adapt(
233 corpus,
234 config.standardize,
235 config.split,
236 config.ngrams,
237 config.max_tokens,
238 &config.limits,
239 )?;
240 Ok(Self {
241 config,
242 vocabulary,
243 idf: None,
244 })
245 }
246
247 pub fn adapt_tf_idf<S: AsRef<str>>(
255 config: TextVectorizationConfig,
256 corpus: &[S],
257 ) -> Result<Self, TextError> {
258 check_pad(&config)?;
259 check_max_tokens(config.max_tokens)?;
260 check_config_numbers(&config)?;
261 let (vocabulary, df) = adapt_with_df(
262 corpus,
263 config.standardize,
264 config.split,
265 config.ngrams,
266 config.max_tokens,
267 &config.limits,
268 )?;
269 let n = corpus.len() as f64;
271 let weights: Vec<f32> = df
272 .iter()
273 .map(|&d| (1.0f64 + n / (1.0f64 + d as f64)).ln() as f32)
274 .collect();
275 let idf = Some(assemble_idf(&weights));
276 Ok(Self {
277 config,
278 vocabulary,
279 idf,
280 })
281 }
282
283 pub fn transform<S: AsRef<str>>(&self, inputs: &[S]) -> Result<Tensor<i32>, TextError> {
290 let opts = TransformOptions {
291 standardize: self.config.standardize,
292 split: self.config.split,
293 ngrams: self.config.ngrams,
294 output_sequence_length: self.config.output_sequence_length,
295 };
296 transform(&self.vocabulary, &opts, &self.config.limits, inputs)
297 }
298
299 fn bag_width(&self) -> usize {
302 match (self.config.pad_to_max_tokens, self.config.max_tokens) {
303 (true, Some(m)) => m,
304 _ => self.vocabulary.vocabulary_size(),
305 }
306 }
307
308 fn bag_options(&self) -> BagOptions {
309 BagOptions {
310 standardize: self.config.standardize,
311 split: self.config.split,
312 ngrams: self.config.ngrams,
313 width: self.bag_width(),
314 }
315 }
316
317 pub fn transform_multi_hot<S: AsRef<str>>(
327 &self,
328 inputs: &[S],
329 ) -> Result<Tensor<f32>, TextError> {
330 bag(
331 &self.vocabulary,
332 &self.bag_options(),
333 &self.config.limits,
334 inputs,
335 BagKind::Presence,
336 )
337 }
338
339 pub fn transform_count<S: AsRef<str>>(&self, inputs: &[S]) -> Result<Tensor<f32>, TextError> {
344 bag(
345 &self.vocabulary,
346 &self.bag_options(),
347 &self.config.limits,
348 inputs,
349 BagKind::Count,
350 )
351 }
352
353 pub fn transform_tf_idf<S: AsRef<str>>(&self, inputs: &[S]) -> Result<Tensor<f32>, TextError> {
360 let Some(idf) = self.idf.as_deref() else {
361 return Err(TextError::MissingIdfWeights);
362 };
363 bag(
364 &self.vocabulary,
365 &self.bag_options(),
366 &self.config.limits,
367 inputs,
368 BagKind::Weighted(idf),
369 )
370 }
371
372 pub fn idf_weights(&self) -> Option<&[f32]> {
375 self.idf.as_deref()
376 }
377
378 pub fn vocabulary(&self) -> &[String] {
380 self.vocabulary.tokens()
381 }
382
383 pub fn vocabulary_size(&self) -> usize {
385 self.vocabulary.vocabulary_size()
386 }
387
388 pub fn config(&self) -> &TextVectorizationConfig {
390 &self.config
391 }
392}
393
394impl fmt::Debug for TextVectorization {
396 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397 f.debug_struct("TextVectorization")
398 .field("config", &self.config)
399 .field("vocabulary_size", &self.vocabulary.vocabulary_size())
400 .field("idf_weights_len", &self.idf.as_ref().map(Vec::len))
402 .finish()
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn default_config_matches_recorded_defaults() {
412 let c = TextVectorizationConfig::default();
413 assert_eq!(c.max_tokens, None);
414 assert_eq!(c.standardize, Standardize::LowerAndStripPunctuation);
415 assert_eq!(c.split, Split::Whitespace);
416 assert_eq!(c.ngrams, None);
417 assert_eq!(c.output_sequence_length, None);
418 assert_eq!(c.limits, TextLimits::default());
419 }
420
421 #[test]
422 fn builders_set_some_and_fields() {
423 let c = TextVectorizationConfig::default()
424 .with_max_tokens(10)
425 .with_standardize(Standardize::None)
426 .with_split(Split::Character)
427 .with_ngrams(2)
428 .with_output_sequence_length(5);
429 assert_eq!(c.max_tokens, Some(10));
430 assert_eq!(c.standardize, Standardize::None);
431 assert_eq!(c.split, Split::Character);
432 assert_eq!(c.ngrams, Some(2));
433 assert_eq!(c.output_sequence_length, Some(5));
434 }
435
436 #[test]
437 fn from_vocabulary_max_tokens_is_fail_closed() {
438 let cfg = |m| TextVectorizationConfig::default().with_max_tokens(m);
439 let v = ["a", "b", "c"];
440 assert!(matches!(
441 TextVectorization::from_vocabulary(cfg(2), &v),
442 Err(TextError::InvalidMaxTokens { max_tokens: 2 })
443 ));
444 assert!(matches!(
445 TextVectorization::from_vocabulary(cfg(4), &v),
446 Err(TextError::VocabularyTooLarge { len: 5, max: 4 })
447 ));
448 assert_eq!(
449 TextVectorization::from_vocabulary(cfg(5), &v)
450 .unwrap()
451 .vocabulary_size(),
452 5
453 );
454 }
455
456 #[test]
457 fn construction_checks_numbers_before_vocabulary() {
458 let empty: [&str; 0] = [];
459 let bad_n = TextVectorizationConfig::default().with_ngrams(0);
460 assert!(matches!(
461 TextVectorization::from_vocabulary(bad_n.clone(), &empty),
462 Err(TextError::InvalidNgrams { .. })
463 ));
464 assert!(matches!(
465 TextVectorization::adapt(bad_n, &empty),
466 Err(TextError::InvalidNgrams { .. })
467 ));
468 let too_long = TextVectorizationConfig::default().with_output_sequence_length(usize::MAX);
469 assert!(matches!(
470 TextVectorization::from_vocabulary(too_long.clone(), &empty),
471 Err(TextError::OutputSequenceLengthTooLarge { .. })
472 ));
473 assert!(matches!(
474 TextVectorization::adapt(too_long, &empty),
475 Err(TextError::OutputSequenceLengthTooLarge { .. })
476 ));
477 }
478
479 #[test]
480 fn debug_hides_vocabulary_contents() {
481 let tv = TextVectorization::from_vocabulary(
482 TextVectorizationConfig::default(),
483 &["SECRET_MARKER_TOKEN"],
484 )
485 .unwrap();
486 let s = format!("{tv:?}");
487 assert!(!s.contains("SECRET_MARKER_TOKEN"));
488 assert!(s.contains("vocabulary_size"));
489 }
490
491 fn idf_of(n: usize, df: usize) -> f32 {
492 (1.0f64 + n as f64 / (1.0 + df as f64)).ln() as f32
493 }
494
495 fn oov_mean(ws: &[f32]) -> f32 {
496 if ws.is_empty() {
497 return 0.0;
498 }
499 (ws.iter().map(|&w| f64::from(w)).sum::<f64>() / ws.len() as f64) as f32
500 }
501
502 #[test]
503 fn pad_field_defaults_false_and_builder_sets_it() {
504 assert!(!TextVectorizationConfig::default().pad_to_max_tokens);
505 let c = TextVectorizationConfig::default().with_pad_to_max_tokens(true);
506 assert!(c.pad_to_max_tokens);
507 }
508
509 #[test]
510 fn pad_without_max_tokens_is_rejected_by_all_constructors() {
511 let c = TextVectorizationConfig::default().with_pad_to_max_tokens(true);
512 let v = ["a"];
513 assert!(matches!(
514 TextVectorization::from_vocabulary(c.clone(), &v),
515 Err(TextError::PadToMaxTokensWithoutMaxTokens)
516 ));
517 assert!(matches!(
518 TextVectorization::adapt(c.clone(), &v),
519 Err(TextError::PadToMaxTokensWithoutMaxTokens)
520 ));
521 assert!(matches!(
522 TextVectorization::from_vocabulary_with_idf(c.clone(), &v, &[1.0]),
523 Err(TextError::PadToMaxTokensWithoutMaxTokens)
524 ));
525 assert!(matches!(
526 TextVectorization::adapt_tf_idf(c, &v),
527 Err(TextError::PadToMaxTokensWithoutMaxTokens)
528 ));
529 }
530
531 #[test]
532 fn width_follows_vocabulary_or_max_tokens_when_padded() {
533 let cfg = TextVectorizationConfig::default().with_max_tokens(8);
534 let tv = TextVectorization::from_vocabulary(cfg.clone(), &["a", "b"]).unwrap();
535 assert_eq!(tv.transform_count(&["a"]).unwrap().shape(), &[1, 4]);
536 let padded = cfg.with_pad_to_max_tokens(true);
537 let tv = TextVectorization::from_vocabulary(padded, &["a", "b"]).unwrap();
538 let out = tv.transform_count(&["a b b"]).unwrap();
539 assert_eq!(out.shape(), &[1, 8]);
540 assert_eq!(
541 out.host_slice().to_vec(),
542 [0.0, 0.0, 1.0, 2.0, 0.0, 0.0, 0.0, 0.0]
543 );
544 assert_eq!(
545 tv.transform_multi_hot::<&str>(&[]).unwrap().shape(),
546 &[0, 8]
547 );
548 }
549
550 #[test]
551 fn output_sequence_length_is_ignored_by_bag_methods() {
552 let a = TextVectorizationConfig::default();
553 let b = a.clone().with_output_sequence_length(1);
554 let v = ["x", "y"];
555 let ta = TextVectorization::from_vocabulary(a, &v).unwrap();
556 let tb = TextVectorization::from_vocabulary(b, &v).unwrap();
557 let ins = ["x y y z"];
558 assert_eq!(
559 ta.transform_count(&ins).unwrap().host_slice(),
560 tb.transform_count(&ins).unwrap().host_slice()
561 );
562 }
563
564 #[test]
565 fn tf_idf_without_weights_is_missing_even_for_bad_input() {
566 let tv =
567 TextVectorization::from_vocabulary(TextVectorizationConfig::default(), &["a"]).unwrap();
568 assert!(tv.idf_weights().is_none());
569 assert!(matches!(
570 tv.transform_tf_idf(&["a"]),
571 Err(TextError::MissingIdfWeights)
572 ));
573 let tv = TextVectorization::adapt(TextVectorizationConfig::default(), &["a"]).unwrap();
574 assert!(matches!(
575 tv.transform_tf_idf::<&str>(&[]),
576 Err(TextError::MissingIdfWeights)
577 ));
578 }
579
580 #[test]
581 fn idf_weights_are_validated_in_documented_order() {
582 let cfg = TextVectorizationConfig::default;
583 assert!(matches!(
584 TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "b"], &[1.0]),
585 Err(TextError::IdfWeightsLengthMismatch {
586 len: 1,
587 expected: 2
588 })
589 ));
590 for (i, bad) in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0]
591 .into_iter()
592 .enumerate()
593 {
594 let r = TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "b"], &[0.5, bad]);
595 assert!(
596 matches!(r, Err(TextError::InvalidIdfWeight { index: 1 })),
597 "{i}"
598 );
599 }
600 assert!(
601 TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "b"], &[-0.0, 0.0]).is_ok()
602 );
603 assert!(matches!(
605 TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "a"], &[f32::NAN]),
606 Err(TextError::IdfWeightsLengthMismatch { .. })
607 ));
608 assert!(matches!(
610 TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "a"], &[f32::NAN, 1.0]),
611 Err(TextError::InvalidIdfWeight { index: 0 })
612 ));
613 let small = cfg().with_max_tokens(3);
615 assert!(matches!(
616 TextVectorization::from_vocabulary_with_idf(small, &["a", "b"], &[f32::NAN]),
617 Err(TextError::VocabularyTooLarge { .. })
618 ));
619 let pad = cfg().with_pad_to_max_tokens(true);
620 assert!(matches!(
621 TextVectorization::from_vocabulary_with_idf(pad, &["a"], &[f32::NAN, 1.0]),
622 Err(TextError::PadToMaxTokensWithoutMaxTokens)
623 ));
624 }
625
626 #[test]
627 fn explicit_idf_layout_and_oov_mean() {
628 let tv = TextVectorization::from_vocabulary_with_idf(
629 TextVectorizationConfig::default(),
630 &["a", "b"],
631 &[1.0, 2.0],
632 )
633 .unwrap();
634 assert_eq!(tv.idf_weights().unwrap(), [0.0, 1.5, 1.0, 2.0]);
635 let empty: [&str; 0] = [];
636 let tv = TextVectorization::from_vocabulary_with_idf(
637 TextVectorizationConfig::default(),
638 &empty,
639 &[],
640 )
641 .unwrap();
642 assert_eq!(tv.idf_weights().unwrap(), [0.0, 0.0]);
643 }
644
645 #[test]
646 fn adapt_tf_idf_matches_formula_bit_for_bit_and_round_trips() {
647 let corpus = ["a a b", "a c", "b b b d"];
648 let cfg = TextVectorizationConfig::default();
649 let tv = TextVectorization::adapt_tf_idf(cfg.clone(), &corpus).unwrap();
650 assert_eq!(tv.vocabulary(), ["", "[UNK]", "b", "a", "c", "d"]);
652 let dfs = [2usize, 2, 1, 1];
653 let ws: Vec<f32> = dfs.iter().map(|&d| idf_of(3, d)).collect();
654 let idf = tv.idf_weights().unwrap();
655 assert_eq!(idf.len(), tv.vocabulary_size());
656 assert_eq!(idf[0].to_bits(), 0.0f32.to_bits());
657 assert_eq!(idf[1].to_bits(), oov_mean(&ws).to_bits());
658 for (g, w) in idf[2..].iter().zip(&ws) {
659 assert_eq!(g.to_bits(), w.to_bits());
660 }
661 let out = tv.transform_tf_idf(&["b b a zzz"]).unwrap();
662 let o = out.host_slice();
663 assert_eq!(o[0], 0.0);
664 assert_eq!(o[1].to_bits(), (1.0f32 * oov_mean(&ws)).to_bits());
665 assert_eq!(o[2].to_bits(), (2.0f32 * ws[0]).to_bits());
666 assert_eq!(o[3].to_bits(), (1.0f32 * ws[1]).to_bits());
667 assert_eq!(o[4], 0.0);
668 let words: Vec<String> = tv.vocabulary()[2..].to_vec();
670 let back = TextVectorization::from_vocabulary_with_idf(cfg, &words, &idf[2..]).unwrap();
671 let bi = back.idf_weights().unwrap();
672 assert!(idf.iter().zip(bi).all(|(a, b)| a.to_bits() == b.to_bits()));
673 let again = back.transform_tf_idf(&["b b a zzz"]).unwrap();
674 assert!(
675 o.iter()
676 .zip(again.host_slice().iter())
677 .all(|(a, b)| a.to_bits() == b.to_bits())
678 );
679 }
680
681 #[test]
682 fn tf_idf_never_reads_idf_for_pad_columns() {
683 let cfg = TextVectorizationConfig::default()
684 .with_max_tokens(6)
685 .with_pad_to_max_tokens(true);
686 let tv = TextVectorization::from_vocabulary_with_idf(cfg, &["a"], &[2.0]).unwrap();
687 let out = tv.transform_tf_idf(&["a a q"]).unwrap();
688 assert_eq!(out.shape(), &[1, 6]);
689 assert_eq!(out.host_slice().to_vec(), [0.0, 2.0, 4.0, 0.0, 0.0, 0.0]);
690 }
691
692 #[test]
693 fn debug_shows_only_idf_count() {
694 let tv = TextVectorization::from_vocabulary_with_idf(
695 TextVectorizationConfig::default(),
696 &["SECRET_MARKER_TOKEN"],
697 &[0.123_456_79],
698 )
699 .unwrap();
700 let s = format!("{tv:?}");
701 assert!(!s.contains("SECRET_MARKER_TOKEN"));
702 assert!(!s.contains("0.1234"));
703 assert!(s.contains("idf_weights_len: Some(3)"));
704 }
705}