Skip to main content

quantrs2_ml/
nlp.rs

1//! Quantum natural language processing (QNLP) models and utilities.
2//!
3//! Provides quantum circuit encodings for text data and [`QuantumNLPModel`]
4//! for tasks such as classification, sequence labelling, and question
5//! answering using quantum neural network backends.
6
7use crate::error::{MLError, Result};
8use crate::qnn::QuantumNeuralNetwork;
9use scirs2_core::ndarray::{Array1, Array2};
10use scirs2_core::random::prelude::*;
11use std::collections::HashMap;
12use std::fmt;
13
14/// Type of NLP task
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum NLPTaskType {
17    /// Text classification
18    Classification,
19
20    /// Sequence labeling
21    SequenceLabeling,
22
23    /// Machine translation
24    Translation,
25
26    /// Language generation
27    Generation,
28
29    /// Sentiment analysis
30    SentimentAnalysis,
31
32    /// Text summarization
33    Summarization,
34}
35
36/// Strategy for text embedding
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum EmbeddingStrategy {
39    /// Bag of words
40    BagOfWords,
41
42    /// Term frequency-inverse document frequency
43    TFIDF,
44
45    /// Word2Vec
46    Word2Vec,
47
48    /// Custom embedding
49    Custom,
50}
51
52impl From<usize> for EmbeddingStrategy {
53    fn from(value: usize) -> Self {
54        match value {
55            0 => EmbeddingStrategy::BagOfWords,
56            1 => EmbeddingStrategy::TFIDF,
57            2 => EmbeddingStrategy::Word2Vec,
58            _ => EmbeddingStrategy::Custom,
59        }
60    }
61}
62
63/// Text preprocessing for NLP
64#[derive(Debug, Clone)]
65pub struct TextPreprocessor {
66    /// Whether to convert to lowercase
67    pub lowercase: bool,
68
69    /// Whether to remove stopwords
70    pub remove_stopwords: bool,
71
72    /// Whether to lemmatize
73    pub lemmatize: bool,
74
75    /// Whether to stem
76    pub stem: bool,
77
78    /// Custom stopwords
79    pub stopwords: Vec<String>,
80}
81
82impl TextPreprocessor {
83    /// Creates a new text preprocessor with default settings
84    pub fn new() -> Self {
85        TextPreprocessor {
86            lowercase: true,
87            remove_stopwords: true,
88            lemmatize: false,
89            stem: false,
90            stopwords: Vec::new(),
91        }
92    }
93
94    /// Sets whether to convert to lowercase
95    pub fn with_lowercase(mut self, lowercase: bool) -> Self {
96        self.lowercase = lowercase;
97        self
98    }
99
100    /// Sets whether to remove stopwords
101    pub fn with_remove_stopwords(mut self, remove_stopwords: bool) -> Self {
102        self.remove_stopwords = remove_stopwords;
103        self
104    }
105
106    /// Sets whether to lemmatize
107    pub fn with_lemmatize(mut self, lemmatize: bool) -> Self {
108        self.lemmatize = lemmatize;
109        self
110    }
111
112    /// Sets whether to stem
113    pub fn with_stem(mut self, stem: bool) -> Self {
114        self.stem = stem;
115        self
116    }
117
118    /// Sets custom stopwords
119    pub fn with_stopwords(mut self, stopwords: Vec<String>) -> Self {
120        self.stopwords = stopwords;
121        self
122    }
123
124    /// Preprocesses text
125    pub fn preprocess(&self, text: &str) -> Result<String> {
126        // This is a dummy implementation
127        // In a real system, this would apply the specified preprocessing steps
128
129        let mut processed = text.to_string();
130
131        if self.lowercase {
132            processed = processed.to_lowercase();
133        }
134
135        if self.remove_stopwords {
136            for stopword in &self.stopwords {
137                processed = processed.replace(stopword, "");
138            }
139        }
140
141        Ok(processed)
142    }
143
144    /// Tokenizes text
145    pub fn tokenize(&self, text: &str) -> Result<Vec<String>> {
146        // This is a dummy implementation
147        // In a real system, this would use a proper tokenizer
148
149        let processed = self.preprocess(text)?;
150        let tokens = processed
151            .split_whitespace()
152            .map(|s| s.to_string())
153            .collect::<Vec<_>>();
154
155        Ok(tokens)
156    }
157}
158
159/// Word embedding for text representation
160#[derive(Debug, Clone)]
161pub struct WordEmbedding {
162    /// Embedding strategy
163    pub strategy: EmbeddingStrategy,
164
165    /// Embedding dimension
166    pub dimension: usize,
167
168    /// Word-to-embedding mapping
169    pub embeddings: HashMap<String, Array1<f64>>,
170
171    /// Vocabulary
172    pub vocabulary: Vec<String>,
173}
174
175impl WordEmbedding {
176    /// Creates a new word embedding
177    pub fn new(strategy: EmbeddingStrategy, dimension: usize) -> Self {
178        WordEmbedding {
179            strategy,
180            dimension,
181            embeddings: HashMap::new(),
182            vocabulary: Vec::new(),
183        }
184    }
185
186    /// Fits the embedding on a corpus using Random Indexing (Kanerva et al.;
187    /// see also Sahlgren, 2005): each vocabulary word is assigned a fixed,
188    /// sparse, near-orthogonal random "index vector"; a word's embedding is
189    /// then the (L2-normalized) sum of the index vectors of every word that
190    /// co-occurs with it within a sliding window across the corpus.
191    ///
192    /// This makes embeddings depend on real corpus co-occurrence statistics
193    /// -- words that tend to appear in similar contexts end up with
194    /// correlated embeddings -- unlike drawing an independent random vector
195    /// per word regardless of context (which was the previous behavior for
196    /// every [`EmbeddingStrategy`]). Random Indexing is used here as the one
197    /// concrete real backend for all strategy variants; a full trained
198    /// skip-gram/CBOW Word2Vec model and TF-IDF-weighted bag-of-words are
199    /// not implemented separately in this release.
200    pub fn fit(&mut self, corpus: &[&str]) -> Result<()> {
201        const WINDOW_RADIUS: usize = 2;
202        const INDEX_VECTOR_NONZEROS: usize = 4;
203
204        let mut word_counts: HashMap<String, usize> = HashMap::new();
205        let tokenized_corpus: Vec<Vec<String>> = corpus
206            .iter()
207            .map(|text| {
208                let tokens: Vec<String> = text.split_whitespace().map(|s| s.to_string()).collect();
209                for token in &tokens {
210                    *word_counts.entry(token.clone()).or_insert(0) += 1;
211                }
212                tokens
213            })
214            .collect();
215
216        // Build the vocabulary, sorted by descending frequency.
217        let mut vocab_items: Vec<(String, usize)> = word_counts
218            .iter()
219            .map(|(word, count)| (word.clone(), *count))
220            .collect();
221        vocab_items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
222
223        self.vocabulary = vocab_items
224            .into_iter()
225            .map(|(word, _)| word)
226            .take(10000)
227            .collect();
228
229        self.embeddings.clear();
230        if self.vocabulary.is_empty() {
231            return Ok(());
232        }
233
234        let word_index: HashMap<&str, usize> = self
235            .vocabulary
236            .iter()
237            .enumerate()
238            .map(|(i, w)| (w.as_str(), i))
239            .collect();
240
241        // Fixed, sparse, near-orthogonal index vector per vocabulary word.
242        let mut rng = thread_rng();
243        let index_vectors: Vec<Array1<f64>> = (0..self.vocabulary.len())
244            .map(|_| {
245                let mut vector = Array1::<f64>::zeros(self.dimension);
246                let nonzeros = INDEX_VECTOR_NONZEROS.min(self.dimension);
247                let mut placed = 0;
248                let mut attempts = 0;
249                while placed < nonzeros && attempts < nonzeros * 20 {
250                    attempts += 1;
251                    let raw_position = (rng.random::<f64>() * self.dimension as f64) as usize;
252                    let position = raw_position.min(self.dimension.saturating_sub(1));
253                    if vector[position] == 0.0 {
254                        let sign = if rng.random::<f64>() < 0.5 { -1.0 } else { 1.0 };
255                        vector[position] = sign;
256                        placed += 1;
257                    }
258                }
259                vector
260            })
261            .collect();
262
263        // Accumulate context vectors from real corpus co-occurrence within a
264        // sliding window, restricted to in-vocabulary words.
265        let mut context_vectors: Vec<Array1<f64>> = (0..self.vocabulary.len())
266            .map(|_| Array1::<f64>::zeros(self.dimension))
267            .collect();
268
269        for tokens in &tokenized_corpus {
270            let indices: Vec<Option<usize>> = tokens
271                .iter()
272                .map(|token| word_index.get(token.as_str()).copied())
273                .collect();
274
275            for (position, target_idx_opt) in indices.iter().enumerate() {
276                let target_idx = match target_idx_opt {
277                    Some(idx) => *idx,
278                    None => continue,
279                };
280                let window_start = position.saturating_sub(WINDOW_RADIUS);
281                let window_end = (position + WINDOW_RADIUS + 1).min(indices.len());
282                for context_position in window_start..window_end {
283                    if context_position == position {
284                        continue;
285                    }
286                    if let Some(context_idx) = indices[context_position] {
287                        context_vectors[target_idx] =
288                            &context_vectors[target_idx] + &index_vectors[context_idx];
289                    }
290                }
291            }
292        }
293
294        for (i, word) in self.vocabulary.iter().enumerate() {
295            let mut embedding = context_vectors[i].clone();
296            let norm = embedding.dot(&embedding).sqrt();
297            if norm > 1e-10 {
298                embedding.mapv_inplace(|x| x / norm);
299            } else {
300                // Word never co-occurred with any in-vocabulary word within
301                // the window (e.g. it only appears in single-word corpus
302                // entries): fall back to its own deterministic index vector
303                // rather than leaving an all-zero embedding.
304                embedding = index_vectors[i].clone();
305            }
306            self.embeddings.insert(word.clone(), embedding);
307        }
308
309        Ok(())
310    }
311
312    /// Gets the embedding for a word
313    pub fn get_embedding(&self, word: &str) -> Option<&Array1<f64>> {
314        self.embeddings.get(word)
315    }
316
317    /// Gets the embedding for a sentence
318    pub fn embed_text(&self, text: &str) -> Result<Array1<f64>> {
319        // This is a simplified implementation
320        // In a real system, this would properly combine word embeddings
321
322        let words = text.split_whitespace().collect::<Vec<_>>();
323        let mut embedding = Array1::zeros(self.dimension);
324        let mut count = 0;
325
326        for word in words {
327            if let Some(word_embedding) = self.get_embedding(word) {
328                embedding += word_embedding;
329                count += 1;
330            }
331        }
332
333        if count > 0 {
334            embedding /= count as f64;
335        }
336
337        Ok(embedding)
338    }
339}
340
341/// Quantum language model for NLP tasks
342#[derive(Debug, Clone)]
343pub struct QuantumLanguageModel {
344    /// Number of qubits
345    pub num_qubits: usize,
346
347    /// Embedding strategy
348    pub embedding_strategy: EmbeddingStrategy,
349
350    /// Text preprocessor
351    pub preprocessor: TextPreprocessor,
352
353    /// Word embedding
354    pub embedding: WordEmbedding,
355
356    /// Quantum neural network
357    pub qnn: QuantumNeuralNetwork,
358
359    /// Type of NLP task
360    pub task: NLPTaskType,
361
362    /// Class labels (for classification tasks)
363    pub labels: Vec<String>,
364}
365
366impl QuantumLanguageModel {
367    /// Creates a new quantum language model
368    pub fn new(
369        num_qubits: usize,
370        embedding_dimension: usize,
371        strategy: EmbeddingStrategy,
372        task: NLPTaskType,
373        labels: Vec<String>,
374    ) -> Result<Self> {
375        let preprocessor = TextPreprocessor::new();
376        let embedding = WordEmbedding::new(strategy, embedding_dimension);
377
378        // Create a QNN architecture suitable for the task
379        let layers = vec![
380            crate::qnn::QNNLayerType::EncodingLayer {
381                num_features: embedding_dimension,
382            },
383            crate::qnn::QNNLayerType::VariationalLayer {
384                num_params: 2 * num_qubits,
385            },
386            crate::qnn::QNNLayerType::EntanglementLayer {
387                connectivity: "full".to_string(),
388            },
389            crate::qnn::QNNLayerType::VariationalLayer {
390                num_params: 2 * num_qubits,
391            },
392            crate::qnn::QNNLayerType::MeasurementLayer {
393                measurement_basis: "computational".to_string(),
394            },
395        ];
396
397        let output_dim = match task {
398            NLPTaskType::Classification | NLPTaskType::SentimentAnalysis => labels.len(),
399            NLPTaskType::SequenceLabeling => labels.len(),
400            NLPTaskType::Translation => embedding_dimension,
401            NLPTaskType::Generation => embedding_dimension,
402            NLPTaskType::Summarization => embedding_dimension,
403        };
404
405        let qnn = QuantumNeuralNetwork::new(layers, num_qubits, embedding_dimension, output_dim)?;
406
407        Ok(QuantumLanguageModel {
408            num_qubits,
409            embedding_strategy: strategy,
410            preprocessor,
411            embedding,
412            qnn,
413            task,
414            labels,
415        })
416    }
417
418    /// Fits the model on a corpus
419    pub fn fit(&mut self, texts: &[&str], labels: &[usize]) -> Result<()> {
420        // First, fit the embedding on the corpus
421        self.embedding.fit(texts)?;
422
423        // Convert texts to embeddings
424        let mut embeddings = Vec::with_capacity(texts.len());
425
426        for text in texts {
427            let embedding = self.embedding.embed_text(text)?;
428            embeddings.push(embedding);
429        }
430
431        // Convert to ndarray
432        let x_train = Array2::from_shape_vec(
433            (embeddings.len(), self.embedding.dimension),
434            embeddings.iter().flat_map(|e| e.iter().cloned()).collect(),
435        )
436        .map_err(|e| MLError::DataError(format!("Failed to create training data: {}", e)))?;
437
438        // Convert labels to one-hot encoding
439        let y_train = Array1::from_vec(labels.iter().map(|&l| l as f64).collect());
440
441        // Train the QNN
442        self.qnn.train_1d(&x_train, &y_train, 100, 0.01)?;
443
444        Ok(())
445    }
446
447    /// Predicts the label for a text
448    pub fn predict(&self, text: &str) -> Result<(String, f64)> {
449        // Embed the text
450        let embedding = self.embedding.embed_text(text)?;
451
452        // Run the QNN
453        let output = self.qnn.forward(&embedding)?;
454
455        // Find the label with the highest score
456        let mut best_label = 0;
457        let mut best_score = output[0];
458
459        for i in 1..output.len() {
460            if output[i] > best_score {
461                best_score = output[i];
462                best_label = i;
463            }
464        }
465
466        if best_label < self.labels.len() {
467            Ok((self.labels[best_label].clone(), best_score))
468        } else {
469            Err(MLError::MLOperationError(format!(
470                "Invalid prediction index: {}",
471                best_label
472            )))
473        }
474    }
475}
476
477/// Sentiment analyzer using quantum language models
478#[derive(Debug, Clone)]
479pub struct SentimentAnalyzer {
480    /// Quantum language model
481    model: QuantumLanguageModel,
482}
483
484impl SentimentAnalyzer {
485    /// Creates a new sentiment analyzer
486    pub fn new(num_qubits: usize) -> Result<Self> {
487        let model = QuantumLanguageModel::new(
488            num_qubits,
489            32, // embedding dimension
490            EmbeddingStrategy::BagOfWords,
491            NLPTaskType::SentimentAnalysis,
492            vec![
493                "negative".to_string(),
494                "neutral".to_string(),
495                "positive".to_string(),
496            ],
497        )?;
498
499        Ok(SentimentAnalyzer { model })
500    }
501
502    /// Analyzes the sentiment of text
503    pub fn analyze(&self, text: &str) -> Result<(String, f64)> {
504        self.model.predict(text)
505    }
506
507    /// Trains the sentiment analyzer
508    pub fn train(&mut self, texts: &[&str], labels: &[usize]) -> Result<()> {
509        self.model.fit(texts, labels)
510    }
511}
512
513/// Text summarizer using quantum language models
514#[derive(Debug, Clone)]
515pub struct TextSummarizer {
516    /// Quantum language model
517    model: QuantumLanguageModel,
518
519    /// Maximum summary length
520    max_length: usize,
521}
522
523impl TextSummarizer {
524    /// Creates a new text summarizer
525    pub fn new(num_qubits: usize) -> Result<Self> {
526        let model = QuantumLanguageModel::new(
527            num_qubits,
528            64, // embedding dimension
529            EmbeddingStrategy::BagOfWords,
530            NLPTaskType::Summarization,
531            Vec::new(), // No specific labels for summarization
532        )?;
533
534        Ok(TextSummarizer {
535            model,
536            max_length: 100,
537        })
538    }
539
540    /// Sets the maximum summary length
541    pub fn with_max_length(mut self, max_length: usize) -> Self {
542        self.max_length = max_length;
543        self
544    }
545
546    /// Summarizes text
547    pub fn summarize(&self, text: &str) -> Result<String> {
548        // This is a dummy implementation
549        // In a real system, this would use the quantum language model to generate a summary
550
551        let sentences = text.split('.').collect::<Vec<_>>();
552        let num_sentences = sentences.len();
553
554        // Generate a summary by selecting key sentences
555        let num_summary_sentences = (num_sentences / 4).max(1);
556        let selected_indices = vec![0, num_sentences / 2, num_sentences - 1];
557
558        let mut summary = String::new();
559
560        for &index in selected_indices.iter().take(num_summary_sentences) {
561            if index < sentences.len() {
562                summary.push_str(sentences[index]);
563                summary.push('.');
564            }
565        }
566
567        // Truncate to max length if needed
568        if summary.len() > self.max_length {
569            let truncated = summary.chars().take(self.max_length).collect::<String>();
570            let last_space = truncated.rfind(' ').unwrap_or(truncated.len());
571            summary = truncated[..last_space].to_string();
572            summary.push_str("...");
573        }
574
575        Ok(summary)
576    }
577}
578
579impl fmt::Display for NLPTaskType {
580    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
581        match self {
582            NLPTaskType::Classification => write!(f, "Classification"),
583            NLPTaskType::SequenceLabeling => write!(f, "Sequence Labeling"),
584            NLPTaskType::Translation => write!(f, "Translation"),
585            NLPTaskType::Generation => write!(f, "Generation"),
586            NLPTaskType::SentimentAnalysis => write!(f, "Sentiment Analysis"),
587            NLPTaskType::Summarization => write!(f, "Summarization"),
588        }
589    }
590}
591
592impl fmt::Display for EmbeddingStrategy {
593    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
594        match self {
595            EmbeddingStrategy::BagOfWords => write!(f, "Bag of Words"),
596            EmbeddingStrategy::TFIDF => write!(f, "TF-IDF"),
597            EmbeddingStrategy::Word2Vec => write!(f, "Word2Vec"),
598            EmbeddingStrategy::Custom => write!(f, "Custom"),
599        }
600    }
601}
602
603/// Implementation of missing methods for QuantumLanguageModel
604impl QuantumLanguageModel {
605    /// Builds vocabulary from a set of texts
606    pub fn build_vocabulary(&mut self, texts: &[String]) -> Result<usize> {
607        // In a full implementation, this would analyze texts and build vocabulary
608        // For now, just return a dummy vocabulary size
609        let vocab_size = texts
610            .iter()
611            .flat_map(|text| text.split_whitespace())
612            .collect::<std::collections::HashSet<_>>()
613            .len();
614
615        Ok(vocab_size)
616    }
617
618    /// Trains word embeddings
619    pub fn train_embeddings(&mut self, texts: &[String]) -> Result<()> {
620        // Dummy implementation that would train word embeddings
621        // In reality, this would update the embedding matrix based on texts
622        println!(
623            "  Training embeddings for {} texts with strategy: {}",
624            texts.len(),
625            self.embedding_strategy
626        );
627
628        Ok(())
629    }
630
631    /// Trains the language model
632    pub fn train(
633        &mut self,
634        texts: &[String],
635        labels: &[usize],
636        epochs: usize,
637        learning_rate: f64,
638    ) -> Result<()> {
639        // Convert texts to feature vectors using the embedding
640        let num_samples = texts.len();
641        let mut features = Array2::zeros((num_samples, self.embedding.dimension));
642
643        // Create dummy features
644        for (i, text) in texts.iter().enumerate() {
645            // Simple hash-based feature extraction
646            let feature_vec = text
647                .chars()
648                .enumerate()
649                .map(|(j, c)| (c as u32 % 8) as f64 / 8.0 + j as f64 * 0.001)
650                .take(self.embedding.dimension)
651                .collect::<Vec<_>>();
652
653            for (j, &val) in feature_vec
654                .iter()
655                .enumerate()
656                .take(self.embedding.dimension)
657            {
658                if j < features.ncols() {
659                    features[[i, j]] = val;
660                }
661            }
662        }
663
664        // Convert labels to float array
665        let y_train = Array1::from_vec(labels.iter().map(|&l| l as f64).collect());
666
667        // Train the underlying QNN
668        self.qnn
669            .train_1d(&features, &y_train, epochs, learning_rate)?;
670
671        Ok(())
672    }
673
674    /// Classifies a text
675    pub fn classify(&self, text: &str) -> Result<(String, f64)> {
676        // In a real implementation, this would encode the text and run it through the QNN
677
678        // Simple hash-based classification for demonstration
679        let hash = text.chars().map(|c| c as u32).sum::<u32>();
680        let class_idx = (hash % self.labels.len() as u32) as usize;
681        let confidence = 0.7 + 0.3 * (hash % 100) as f64 / 100.0;
682
683        Ok((self.labels[class_idx].clone(), confidence))
684    }
685}
686
687#[cfg(test)]
688mod regression_tests {
689    use super::*;
690
691    /// Regression test for the "every word gets an independent random
692    /// embedding" fabrication bug: words that share contexts across the
693    /// corpus should end up with embeddings that are meaningfully more
694    /// similar (higher cosine similarity) than words that never co-occur
695    /// with anything, which is only possible if `fit` actually derives
696    /// embeddings from real co-occurrence statistics.
697    #[test]
698    fn fit_produces_context_correlated_embeddings_not_pure_noise() {
699        let corpus = [
700            "king queen throne royal palace",
701            "queen king throne royal crown",
702            "throne king queen royal power",
703            "banana apple fruit sweet tasty",
704            "apple banana fruit juicy sweet",
705            "fruit apple banana tasty juicy",
706        ];
707
708        let mut embedding = WordEmbedding::new(EmbeddingStrategy::Word2Vec, 64);
709        embedding.fit(&corpus).expect("fit should succeed");
710
711        assert!(!embedding.vocabulary.is_empty());
712        assert!(embedding.get_embedding("king").is_some());
713        assert!(embedding.get_embedding("apple").is_some());
714
715        let cosine = |a: &Array1<f64>, b: &Array1<f64>| -> f64 {
716            let dot = a.dot(b);
717            let norm_a = a.dot(a).sqrt();
718            let norm_b = b.dot(b).sqrt();
719            if norm_a > 1e-12 && norm_b > 1e-12 {
720                dot / (norm_a * norm_b)
721            } else {
722                0.0
723            }
724        };
725
726        let king = embedding.get_embedding("king").expect("king embedded");
727        let queen = embedding.get_embedding("queen").expect("queen embedded");
728        let apple = embedding.get_embedding("apple").expect("apple embedded");
729
730        // "king" and "queen" repeatedly co-occur with the same royalty
731        // context words across the corpus, so their embeddings should be
732        // noticeably more similar to each other than "king" is to the
733        // unrelated "apple" -- a signal that cannot exist if embeddings are
734        // independent random noise per word.
735        let king_queen_similarity = cosine(king, queen);
736        let king_apple_similarity = cosine(king, apple);
737        assert!(
738            king_queen_similarity > king_apple_similarity,
739            "expected king~queen similarity ({king_queen_similarity}) to exceed \
740             king~apple similarity ({king_apple_similarity})"
741        );
742
743        // Fitting twice on the same corpus must reproduce the same
744        // vocabulary (a real, deterministic frequency-based selection).
745        let mut embedding2 = WordEmbedding::new(EmbeddingStrategy::Word2Vec, 64);
746        embedding2.fit(&corpus).expect("fit should succeed");
747        assert_eq!(embedding.vocabulary, embedding2.vocabulary);
748    }
749
750    #[test]
751    fn fit_on_empty_corpus_yields_empty_vocabulary_and_no_panic() {
752        let mut embedding = WordEmbedding::new(EmbeddingStrategy::BagOfWords, 16);
753        embedding
754            .fit(&[])
755            .expect("fit on empty corpus should succeed");
756        assert!(embedding.vocabulary.is_empty());
757        assert!(embedding.embeddings.is_empty());
758    }
759}