unitoken 0.1.5

Fast BPE tokenizer/trainer with a Rust core and Python bindings
Documentation
use std::{collections::BTreeMap, hash::Hash, io::{Read, Write}};

use ahash::AHashMap;
use hashbrown::HashMap;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};

use crate::{
  MyError, MyResult,
  bpe::Freq,
  pretokenizer::{
    PreTokenizer, count_unicode_bigrams, for_each_pretoken, for_each_regular_chunk,
    is_unicode_bigram_script, select_unicode_bigrams,
  },
};

type WordCounts = HashMap<String, Freq, ahash::RandomState>;
type BorrowedWordCounts<'a> = HashMap<&'a str, Freq, ahash::RandomState>;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SourceBatchOptions {
  pub max_records: usize,
  pub max_bytes: usize,
}

impl Default for SourceBatchOptions {
  fn default() -> Self {
    Self {
      max_records: 4096,
      max_bytes: 64 * 1024 * 1024,
    }
  }
}

impl SourceBatchOptions {
  pub fn validate(self) -> MyResult<Self> {
    if self.max_records == 0 {
      return Err(MyError::SourceBatch("max_records must be at least 1"));
    }
    if self.max_bytes == 0 {
      return Err(MyError::SourceBatch("max_bytes must be at least 1"));
    }
    Ok(self)
  }
}

fn checked_add<K: Eq + Hash>(counts: &mut AHashMap<K, Freq>, key: K, value: Freq) -> MyResult<()> {
  let current = counts.entry(key).or_default();
  *current = current.checked_add(value).ok_or(MyError::FrequencyOverflow)?;
  Ok(())
}

fn merge_counts<K: Eq + Hash>(target: &mut AHashMap<K, Freq>, source: AHashMap<K, Freq>) -> MyResult<()> {
  for (key, value) in source {
    checked_add(target, key, value)?;
  }
  Ok(())
}

fn count_bigrams_into(
  pre_tokenizer: &PreTokenizer,
  text: &str,
  counts: &mut AHashMap<(char, char), Freq>,
) -> MyResult<()> {
  if text.is_empty() {
    return Ok(());
  }
  if pre_tokenizer.re_special_tokens.as_str() == "$^" {
    return count_unicode_bigrams(text, counts, is_unicode_bigram_script);
  }
  for_each_regular_chunk(text, &pre_tokenizer.re_special_tokens, |chunk| {
    count_unicode_bigrams(chunk, counts, is_unicode_bigram_script)
  })
}

fn count_words_borrowed<'a>(
  pre_tokenizer: &PreTokenizer,
  text: &'a str,
  counts: &mut BorrowedWordCounts<'a>,
) -> MyResult<()> {
  if text.is_empty() {
    return Ok(());
  }
  if pre_tokenizer.re_special_tokens.as_str() == "$^" {
    return count_words_in_regular_text_borrowed(pre_tokenizer, text, counts);
  }
  for_each_regular_chunk(text, &pre_tokenizer.re_special_tokens, |chunk| {
    count_words_in_regular_text_borrowed(pre_tokenizer, chunk, counts)
  })
}

fn count_words_in_regular_text_borrowed<'a>(
  pre_tokenizer: &PreTokenizer,
  text: &'a str,
  counts: &mut BorrowedWordCounts<'a>,
) -> MyResult<()> {
  for_each_pretoken(
    text,
    &pre_tokenizer.re_pat,
    pre_tokenizer.unicode_bigrams.as_ref(),
    pre_tokenizer.unicode_bigram_mixed_boundary,
    |word| {
      let frequency = counts.entry(word).or_insert(0);
      *frequency = frequency.checked_add(1).ok_or(MyError::FrequencyOverflow)?;
      Ok(())
    },
  )
}

fn merge_borrowed_word_counts<'a>(
  mut left: BorrowedWordCounts<'a>,
  mut right: BorrowedWordCounts<'a>,
) -> MyResult<BorrowedWordCounts<'a>> {
  if left.len() < right.len() {
    std::mem::swap(&mut left, &mut right);
  }
  for (word, frequency) in right {
    let current = left.entry(word).or_default();
    *current = current.checked_add(frequency).ok_or(MyError::FrequencyOverflow)?;
  }
  Ok(left)
}

fn merge_borrowed_into_word_counts(
  target: &mut WordCounts,
  source: BorrowedWordCounts<'_>,
) -> MyResult<()> {
  target.reserve(source.len());
  for (word, frequency) in source {
    let current = target.entry_ref(word).or_insert(0);
    *current = current.checked_add(frequency).ok_or(MyError::FrequencyOverflow)?;
  }
  Ok(())
}

fn merge_word_counts(target: &mut WordCounts, source: WordCounts) -> MyResult<()> {
  for (word, frequency) in source {
    let current = target.entry(word).or_default();
    *current = current.checked_add(frequency).ok_or(MyError::FrequencyOverflow)?;
  }
  Ok(())
}

#[derive(Clone)]
#[cfg_attr(feature = "py", pyo3::pyclass(from_py_object))]
pub struct BigramCounter {
  pre_tokenizer: PreTokenizer,
  counts: AHashMap<(char, char), Freq>,
}

impl BigramCounter {
  pub fn new(pre_tokenizer: PreTokenizer) -> Self {
    Self {
      pre_tokenizer,
      counts: AHashMap::new(),
    }
  }

  pub fn add_text(&mut self, text: &str) -> MyResult<()> {
    count_bigrams_into(&self.pre_tokenizer, text, &mut self.counts)
  }

  pub fn add_batch<S: AsRef<str> + Sync>(&mut self, texts: &[S]) -> MyResult<()> {
    let batch_counts = texts
      .par_iter()
      .try_fold(AHashMap::new, |mut counts, text| {
        count_bigrams_into(&self.pre_tokenizer, text.as_ref(), &mut counts)?;
        Ok::<_, MyError>(counts)
      })
      .try_reduce(AHashMap::new, |mut left, right| {
        merge_counts(&mut left, right)?;
        Ok::<_, MyError>(left)
      })?;
    merge_counts(&mut self.counts, batch_counts)
  }

  pub fn add_source<I, S>(&mut self, source: I, options: SourceBatchOptions) -> MyResult<()>
  where
    I: IntoIterator<Item = S>,
    S: AsRef<str> + Sync,
  {
    let options = options.validate()?;
    let mut batch = Vec::new();
    let mut bytes: usize = 0;
    for text in source {
      let text_bytes = text.as_ref().len();
      if !batch.is_empty()
        && (batch.len() >= options.max_records
          || bytes.saturating_add(text_bytes) > options.max_bytes)
      {
        self.add_batch(&batch)?;
        batch.clear();
        bytes = 0;
      }
      bytes = bytes.checked_add(text_bytes).ok_or(MyError::SourceBatch("batch byte size overflow"))?;
      batch.push(text);
    }
    if !batch.is_empty() {
      self.add_batch(&batch)?;
    }
    Ok(())
  }

  pub fn merge(&mut self, other: Self) -> MyResult<()> {
    merge_counts(&mut self.counts, other.counts)
  }

  pub fn selected(&self, top_k: usize, min_freq: Freq) -> Vec<(char, char)> {
    let mut selected = select_unicode_bigrams(self.counts.clone(), top_k, min_freq)
      .into_iter()
      .collect::<Vec<_>>();
    selected.sort_unstable();
    selected
  }

  pub fn counts(&self) -> &AHashMap<(char, char), Freq> {
    &self.counts
  }
}

#[derive(Clone)]
#[cfg_attr(feature = "py", pyo3::pyclass(from_py_object))]
pub struct WordCounter {
  pre_tokenizer: PreTokenizer,
  counts: WordCounts,
}

impl WordCounter {
  pub fn new(pre_tokenizer: PreTokenizer) -> Self {
    Self {
      pre_tokenizer,
      counts: WordCounts::default(),
    }
  }

  pub fn add_text(&mut self, text: &str) -> MyResult<()> {
    let mut counts = BorrowedWordCounts::default();
    count_words_borrowed(&self.pre_tokenizer, text, &mut counts)?;
    merge_borrowed_into_word_counts(&mut self.counts, counts)
  }

  pub fn add_batch<'a, S: AsRef<str> + Sync>(&mut self, texts: &'a [S]) -> MyResult<()> {
    let batch_counts = texts
      .par_iter()
      .try_fold(BorrowedWordCounts::default, |mut counts, text| {
        count_words_borrowed(&self.pre_tokenizer, text.as_ref(), &mut counts)?;
        Ok::<_, MyError>(counts)
      })
      .try_reduce(BorrowedWordCounts::default, merge_borrowed_word_counts)?;
    merge_borrowed_into_word_counts(&mut self.counts, batch_counts)
  }

  pub fn add_source<I, S>(&mut self, source: I, options: SourceBatchOptions) -> MyResult<()>
  where
    I: IntoIterator<Item = S>,
    S: AsRef<str> + Sync,
  {
    let options = options.validate()?;
    let mut batch = Vec::new();
    let mut bytes: usize = 0;
    for text in source {
      let text_bytes = text.as_ref().len();
      if !batch.is_empty()
        && (batch.len() >= options.max_records
          || bytes.saturating_add(text_bytes) > options.max_bytes)
      {
        self.add_batch(&batch)?;
        batch.clear();
        bytes = 0;
      }
      bytes = bytes.checked_add(text_bytes).ok_or(MyError::SourceBatch("batch byte size overflow"))?;
      batch.push(text);
    }
    if !batch.is_empty() {
      self.add_batch(&batch)?;
    }
    Ok(())
  }

  pub fn merge(&mut self, other: Self) -> MyResult<()> {
    merge_word_counts(&mut self.counts, other.counts)
  }

  pub fn words(&self) -> BTreeMap<String, Freq> {
    self.counts.iter().map(|(word, frequency)| (word.clone(), *frequency)).collect()
  }

  pub fn len(&self) -> usize {
    self.counts.len()
  }

  pub fn is_empty(&self) -> bool {
    self.counts.is_empty()
  }

  pub fn clear(&mut self) {
    self.counts.clear();
  }

  pub fn take_counts(&mut self) -> WordCounts {
    std::mem::take(&mut self.counts)
  }

  pub fn save<W: Write>(&self, writer: W) -> MyResult<()> {
    serde_json::to_writer(writer, &self.counts)?;
    Ok(())
  }

  pub fn load<R: Read>(pre_tokenizer: PreTokenizer, reader: R) -> MyResult<Self> {
    let counts = serde_json::from_reader(reader)?;
    Ok(Self { pre_tokenizer, counts })
  }

  pub fn counts(&self) -> &WordCounts {
    &self.counts
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  use crate::pretokenizer::parse_unicode_bigrams;

  #[test]
  fn bigram_counter_batches_and_merges() {
    let pre_tokenizer = PreTokenizer::new(&[], None);
    let mut left = BigramCounter::new(pre_tokenizer.clone());
    left.add_source(
      ["你好世界", "你好"],
      SourceBatchOptions { max_records: 1, max_bytes: 8 },
    ).unwrap();
    let mut right = BigramCounter::new(pre_tokenizer);
    right.add_text("世界").unwrap();
    left.merge(right).unwrap();

    assert_eq!(left.counts().get(&('', '')), Some(&2));
    assert_eq!(left.counts().get(&('', '')), Some(&2));
  }

  #[test]
  fn word_counter_uses_frozen_bigrams_and_skips_special_tokens() {
    let bigrams = parse_unicode_bigrams(&["你好".to_string()]).unwrap();
    let pre_tokenizer = PreTokenizer::new(&["<eot>".to_string()], Some("<eot>"))
      .with_unicode_bigrams(bigrams);
    let mut counter = WordCounter::new(pre_tokenizer);
    counter.add_batch(&["你好世界<eot>", "你好"]).unwrap();

    let words = counter.words();
    assert_eq!(words.get("你好"), Some(&2));
    assert_eq!(words.get(""), Some(&1));
    assert_eq!(words.get(""), Some(&1));
    assert!(!words.contains_key("<eot>"));
  }

  #[test]
  fn word_counter_borrowed_batch_matches_owned_pretokenization() {
    let bigrams = parse_unicode_bigrams(&["世界".to_string(), "你好".to_string()]).unwrap();
    let pre_tokenizer = PreTokenizer::new(&[], None).with_unicode_bigrams(bigrams);
    let texts = ["Hello 世界你好 world", "世界你好", "Hello"];
    let mut expected = BTreeMap::new();
    for text in texts {
      for (word, frequency) in pre_tokenizer.get_words_owned(text).unwrap() {
        *expected.entry(word).or_default() += frequency;
      }
    }

    let mut counter = WordCounter::new(pre_tokenizer);
    counter.add_batch(&texts).unwrap();

    assert_eq!(counter.words(), expected);
  }

  #[test]
  fn word_counter_streams_around_adjacent_special_tokens() {
    let pre_tokenizer = PreTokenizer::new(&["<eot>".to_string()], Some("<eot>"));
    let mut counter = WordCounter::new(pre_tokenizer);
    counter.add_text("<eot>Hello<eot><eot> world<eot>").unwrap();

    assert_eq!(
      counter.words(),
      [("Hello".to_string(), 1), (" world".to_string(), 1)].into_iter().collect(),
    );
  }

  #[test]
  fn word_counter_preserves_empty_custom_pattern_matches() {
    let pre_tokenizer = PreTokenizer::try_new(&[], None, Some("")).unwrap()
      .with_unicode_bigrams(ahash::AHashSet::new());
    let expected = pre_tokenizer.get_words_owned("ab").unwrap();
    let mut counter = WordCounter::new(pre_tokenizer);
    counter.add_text("ab").unwrap();

    assert_eq!(counter.words(), expected);
  }
}