Skip to main content

rust_lstm/
text.rs

1//! Text generation utilities for character-level language models.
2//!
3//! Provides vocabulary management, character embeddings, and sampling strategies.
4
5use crate::optimizers::Optimizer;
6use crate::random::{random_array, unit};
7use ndarray::{Array1, Array2};
8use ndarray_rand::rand_distr::Uniform;
9use std::collections::HashMap;
10
11/// Character vocabulary for text generation tasks.
12///
13/// Maps characters to indices and vice versa.
14#[derive(Clone, Debug)]
15pub struct TextVocabulary {
16    char_to_idx: HashMap<char, usize>,
17    idx_to_char: HashMap<usize, char>,
18    vocab_size: usize,
19}
20
21impl TextVocabulary {
22    /// Create vocabulary from text, extracting unique characters.
23    pub fn from_text(text: &str) -> Self {
24        let mut chars: Vec<char> = text
25            .chars()
26            .collect::<std::collections::HashSet<_>>()
27            .into_iter()
28            .collect();
29        chars.sort();
30
31        let vocab_size = chars.len();
32        let char_to_idx: HashMap<char, usize> =
33            chars.iter().enumerate().map(|(i, &c)| (c, i)).collect();
34        let idx_to_char: HashMap<usize, char> =
35            chars.iter().enumerate().map(|(i, &c)| (i, c)).collect();
36
37        Self {
38            char_to_idx,
39            idx_to_char,
40            vocab_size,
41        }
42    }
43
44    /// Create vocabulary from explicit character list.
45    pub fn from_chars(chars: &[char]) -> Self {
46        let vocab_size = chars.len();
47        let char_to_idx: HashMap<char, usize> =
48            chars.iter().enumerate().map(|(i, &c)| (c, i)).collect();
49        let idx_to_char: HashMap<usize, char> =
50            chars.iter().enumerate().map(|(i, &c)| (i, c)).collect();
51
52        Self {
53            char_to_idx,
54            idx_to_char,
55            vocab_size,
56        }
57    }
58
59    /// Get index for a character.
60    pub fn char_to_index(&self, ch: char) -> Option<usize> {
61        self.char_to_idx.get(&ch).copied()
62    }
63
64    /// Get character for an index.
65    pub fn index_to_char(&self, idx: usize) -> Option<char> {
66        self.idx_to_char.get(&idx).copied()
67    }
68
69    /// Get vocabulary size.
70    pub fn size(&self) -> usize {
71        self.vocab_size
72    }
73
74    /// Check if character is in vocabulary.
75    pub fn contains(&self, ch: char) -> bool {
76        self.char_to_idx.contains_key(&ch)
77    }
78
79    /// Get all characters in vocabulary order.
80    pub fn chars(&self) -> Vec<char> {
81        let mut chars: Vec<_> = self.idx_to_char.iter().collect();
82        chars.sort_by_key(|(idx, _)| *idx);
83        chars.into_iter().map(|(_, &ch)| ch).collect()
84    }
85
86    /// Encode string to indices.
87    pub fn encode(&self, text: &str) -> Vec<usize> {
88        text.chars()
89            .filter_map(|ch| self.char_to_index(ch))
90            .collect()
91    }
92
93    /// Decode indices to string.
94    pub fn decode(&self, indices: &[usize]) -> String {
95        indices
96            .iter()
97            .filter_map(|&idx| self.index_to_char(idx))
98            .collect()
99    }
100}
101
102/// Gradients for character embedding layer.
103#[derive(Clone, Debug)]
104pub struct EmbeddingGradients {
105    pub weight: Array2<f64>,
106}
107
108/// Trainable character embedding layer.
109///
110/// Maps character indices to dense vectors.
111#[derive(Clone, Debug)]
112pub struct CharacterEmbedding {
113    pub weight: Array2<f64>, // (vocab_size, embed_dim)
114    vocab_size: usize,
115    embed_dim: usize,
116    input_cache: Option<Vec<usize>>,
117}
118
119impl CharacterEmbedding {
120    /// Create new embedding with random initialization.
121    pub fn new(vocab_size: usize, embed_dim: usize) -> Self {
122        let scale = (1.0 / embed_dim as f64).sqrt();
123        let weight = random_array((vocab_size, embed_dim), Uniform::new(-scale, scale));
124
125        Self {
126            weight,
127            vocab_size,
128            embed_dim,
129            input_cache: None,
130        }
131    }
132
133    /// Create embedding with zero initialization.
134    pub fn new_zeros(vocab_size: usize, embed_dim: usize) -> Self {
135        Self {
136            weight: Array2::zeros((vocab_size, embed_dim)),
137            vocab_size,
138            embed_dim,
139            input_cache: None,
140        }
141    }
142
143    /// Create embedding from existing weights.
144    pub fn from_weights(weight: Array2<f64>) -> Self {
145        let (vocab_size, embed_dim) = weight.dim();
146        Self {
147            weight,
148            vocab_size,
149            embed_dim,
150            input_cache: None,
151        }
152    }
153
154    /// Get embedding dimension.
155    pub fn embed_dim(&self) -> usize {
156        self.embed_dim
157    }
158
159    /// Get vocabulary size.
160    pub fn vocab_size(&self) -> usize {
161        self.vocab_size
162    }
163
164    /// Lookup single character embedding.
165    pub fn lookup(&self, char_idx: usize) -> Array1<f64> {
166        assert!(
167            char_idx < self.vocab_size,
168            "Index {} out of vocabulary size {}",
169            char_idx,
170            self.vocab_size
171        );
172        self.weight.row(char_idx).to_owned()
173    }
174
175    /// Forward pass for sequence of indices.
176    /// Returns (seq_len, embed_dim) matrix.
177    pub fn forward(&mut self, char_indices: &[usize]) -> Array2<f64> {
178        self.input_cache = Some(char_indices.to_vec());
179
180        let seq_len = char_indices.len();
181        let mut output = Array2::zeros((seq_len, self.embed_dim));
182
183        for (i, &idx) in char_indices.iter().enumerate() {
184            assert!(
185                idx < self.vocab_size,
186                "Index {} out of vocabulary size {}",
187                idx,
188                self.vocab_size
189            );
190            output.row_mut(i).assign(&self.weight.row(idx));
191        }
192
193        output
194    }
195
196    /// Backward pass - compute gradients.
197    /// grad_output shape: (seq_len, embed_dim)
198    pub fn backward(&self, grad_output: &Array2<f64>) -> EmbeddingGradients {
199        let indices = self
200            .input_cache
201            .as_ref()
202            .expect("No cached input for backward pass");
203
204        let mut weight_grad = Array2::zeros((self.vocab_size, self.embed_dim));
205
206        for (i, &idx) in indices.iter().enumerate() {
207            for j in 0..self.embed_dim {
208                weight_grad[[idx, j]] += grad_output[[i, j]];
209            }
210        }
211
212        EmbeddingGradients {
213            weight: weight_grad,
214        }
215    }
216
217    /// Update parameters with optimizer.
218    pub fn update_parameters<O: Optimizer>(
219        &mut self,
220        gradients: &EmbeddingGradients,
221        optimizer: &mut O,
222        prefix: &str,
223    ) {
224        optimizer.update(
225            &format!("{}_weight", prefix),
226            &mut self.weight,
227            &gradients.weight,
228        );
229    }
230
231    /// Get number of parameters.
232    pub fn num_parameters(&self) -> usize {
233        self.weight.len()
234    }
235}
236
237/// Sample from logits with temperature scaling.
238///
239/// Higher temperature = more random, lower = more deterministic.
240pub fn sample_with_temperature(logits: &Array1<f64>, temperature: f64) -> usize {
241    assert!(temperature > 0.0, "Temperature must be positive");
242
243    // Scale logits by temperature
244    let scaled: Vec<f64> = logits.iter().map(|&x| x / temperature).collect();
245
246    // Softmax with numerical stability
247    let max_val = scaled.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
248    let exp_vals: Vec<f64> = scaled.iter().map(|&x| (x - max_val).exp()).collect();
249    let sum: f64 = exp_vals.iter().sum();
250    let probs: Vec<f64> = exp_vals.iter().map(|&x| x / sum).collect();
251
252    // Sample from distribution
253    let mut rng_val = unit();
254    for (i, &prob) in probs.iter().enumerate() {
255        rng_val -= prob;
256        if rng_val <= 0.0 {
257            return i;
258        }
259    }
260
261    probs.len() - 1
262}
263
264/// Sample from top-k most likely tokens.
265///
266/// Filters to k highest probability tokens before sampling.
267pub fn sample_top_k(logits: &Array1<f64>, k: usize, temperature: f64) -> usize {
268    assert!(k > 0, "k must be positive");
269    assert!(temperature > 0.0, "Temperature must be positive");
270
271    let k = k.min(logits.len());
272
273    // Get indices sorted by logit value (descending)
274    let mut indexed: Vec<(usize, f64)> = logits.iter().enumerate().map(|(i, &v)| (i, v)).collect();
275    indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
276
277    // Keep top k
278    let top_k: Vec<(usize, f64)> = indexed.into_iter().take(k).collect();
279
280    // Apply temperature and softmax to top-k only
281    let scaled: Vec<f64> = top_k.iter().map(|(_, v)| v / temperature).collect();
282    let max_val = scaled.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
283    let exp_vals: Vec<f64> = scaled.iter().map(|&x| (x - max_val).exp()).collect();
284    let sum: f64 = exp_vals.iter().sum();
285    let probs: Vec<f64> = exp_vals.iter().map(|&x| x / sum).collect();
286
287    // Sample
288    let mut rng_val = unit();
289    for (i, &prob) in probs.iter().enumerate() {
290        rng_val -= prob;
291        if rng_val <= 0.0 {
292            return top_k[i].0;
293        }
294    }
295
296    top_k[k - 1].0
297}
298
299/// Nucleus (top-p) sampling.
300///
301/// Samples from smallest set of tokens whose cumulative probability exceeds p.
302pub fn sample_nucleus(logits: &Array1<f64>, p: f64, temperature: f64) -> usize {
303    assert!(p > 0.0 && p <= 1.0, "p must be in (0, 1]");
304    assert!(temperature > 0.0, "Temperature must be positive");
305
306    // Apply temperature and softmax
307    let scaled: Vec<f64> = logits.iter().map(|&x| x / temperature).collect();
308    let max_val = scaled.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
309    let exp_vals: Vec<f64> = scaled.iter().map(|&x| (x - max_val).exp()).collect();
310    let sum: f64 = exp_vals.iter().sum();
311    let probs: Vec<f64> = exp_vals.iter().map(|&x| x / sum).collect();
312
313    // Sort by probability (descending)
314    let mut indexed: Vec<(usize, f64)> = probs.iter().enumerate().map(|(i, &v)| (i, v)).collect();
315    indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
316
317    // Find nucleus (cumulative prob >= p)
318    let mut cumulative = 0.0;
319    let mut nucleus: Vec<(usize, f64)> = Vec::new();
320    for (idx, prob) in indexed {
321        cumulative += prob;
322        nucleus.push((idx, prob));
323        if cumulative >= p {
324            break;
325        }
326    }
327
328    // Renormalize nucleus probabilities
329    let nucleus_sum: f64 = nucleus.iter().map(|(_, prob)| prob).sum();
330    let nucleus_probs: Vec<f64> = nucleus.iter().map(|(_, prob)| prob / nucleus_sum).collect();
331
332    // Sample from nucleus
333    let mut rng_val = unit();
334    for (i, &prob) in nucleus_probs.iter().enumerate() {
335        rng_val -= prob;
336        if rng_val <= 0.0 {
337            return nucleus[i].0;
338        }
339    }
340
341    nucleus.last().map(|(idx, _)| *idx).unwrap_or(0)
342}
343
344/// Get argmax (greedy decoding).
345pub fn argmax(logits: &Array1<f64>) -> usize {
346    logits
347        .iter()
348        .enumerate()
349        .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
350        .map(|(idx, _)| idx)
351        .unwrap_or(0)
352}
353
354/// Apply softmax to logits.
355pub fn softmax(logits: &Array1<f64>) -> Array1<f64> {
356    let max_val = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
357    let exp_vals: Array1<f64> = logits.mapv(|x| (x - max_val).exp());
358    let sum: f64 = exp_vals.sum();
359    exp_vals / sum
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use ndarray::arr1;
366
367    #[test]
368    fn test_vocabulary_from_text() {
369        let vocab = TextVocabulary::from_text("hello");
370        assert_eq!(vocab.size(), 4); // h, e, l, o
371        assert!(vocab.contains('h'));
372        assert!(vocab.contains('l'));
373        assert!(!vocab.contains('x'));
374    }
375
376    #[test]
377    fn test_vocabulary_encode_decode() {
378        let vocab = TextVocabulary::from_text("abc");
379        let encoded = vocab.encode("cab");
380        let decoded = vocab.decode(&encoded);
381        assert_eq!(decoded, "cab");
382    }
383
384    #[test]
385    fn test_embedding_forward() {
386        let mut emb = CharacterEmbedding::new(10, 8);
387        let output = emb.forward(&[0, 3, 5]);
388        assert_eq!(output.shape(), &[3, 8]);
389    }
390
391    #[test]
392    fn test_embedding_lookup() {
393        let emb = CharacterEmbedding::new(10, 8);
394        let vec = emb.lookup(5);
395        assert_eq!(vec.len(), 8);
396    }
397
398    #[test]
399    fn test_sample_with_temperature() {
400        let logits = arr1(&[1.0, 2.0, 3.0]);
401        let idx = sample_with_temperature(&logits, 1.0);
402        assert!(idx < 3);
403    }
404
405    #[test]
406    fn test_sample_top_k() {
407        let logits = arr1(&[1.0, 5.0, 2.0, 0.5]);
408        let idx = sample_top_k(&logits, 2, 1.0);
409        // Should only sample from indices 1 or 2 (top 2)
410        assert!(idx == 1 || idx == 2);
411    }
412
413    #[test]
414    fn test_sample_nucleus() {
415        let logits = arr1(&[0.0, 10.0, 0.0]); // Very peaked distribution
416        let idx = sample_nucleus(&logits, 0.9, 1.0);
417        assert_eq!(idx, 1); // Should almost always be 1
418    }
419
420    #[test]
421    fn test_argmax() {
422        let logits = arr1(&[1.0, 5.0, 2.0]);
423        assert_eq!(argmax(&logits), 1);
424    }
425
426    #[test]
427    fn test_softmax() {
428        let logits = arr1(&[1.0, 2.0, 3.0]);
429        let probs = softmax(&logits);
430        assert!((probs.sum() - 1.0).abs() < 1e-6);
431    }
432}