Source code for mojolearn.lm_corpus

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""Tokenize a training corpus once and train a language model on the ids.

The tokenizer is part of the model: it is chosen before the first training
step, and the model's ids mean something only through it. This module
prepares a corpus with a BPE vocabulary, caches the token ids, serves
training batches, and checks that a tokenizer is the one a model was trained
on.

- With no ``vocab``, :func:`prepare` trains a byte-level BPE vocabulary on
  the corpus's training range (never validation or test data), tokenizes the
  whole corpus once and caches the ids.
- With ``vocab=``, your own token map is used and nothing is trained: a
  rank file, an ``(encoder.json, vocab.bpe)`` pair, a
  :class:`~mojolearn.tokenizer.BpeTokenizer` or a ``TrainedBpeVocabulary``.

Cache
-----
Files are written under ``cache_dir`` (default ``$MOJOLEARN_LM_CACHE``, else
``~/.cache/mojolearn/lm``)::

    vocab/<corpus sha16>-v<vocab_size>-f<min_frequency>-s<sample bytes>/
        ranks.tsv, vocabulary.json            (a trained vocabulary)
    vocab/user-<vocabulary sha16>/ranks.tsv, vocabulary.json   (yours)
    tokens/<corpus sha16>-<vocabulary sha16>-d<document bytes>/
        tokens.i32, manifest.json

A rerun with the same corpus and vocabulary reuses the cache after
re-hashing it against its manifest; a mismatch raises ValueError rather than
silently rebuilding. Nothing is written into the package.

Vocabulary identity
-------------------
:meth:`TokenBatches.data_schedule` records the vocabulary's identity (the
SHA-256 of its canonical rank file and its ``n_vocab``). The trainer refuses
a schedule whose ``n_vocab`` differs from its ``vocab_size`` and keeps the
schedule in every checkpoint, and :func:`tokenizer_for` only returns a
tokenizer that matches it, so ids are never decoded with the wrong table.

Documents
---------
BPE never merges across a pre-token boundary, and where a long file is cut
changes the ids at every cut, so the cut is fixed and recorded: a document
never spans the corpus manifest's train / validation / test boundaries, is
at most ``document_bytes`` long, and ends at the last newline at or before
that limit (or exactly at the limit when there is none). Each document is
encoded alone and the ids are concatenated. ``<|endoftext|>`` is neither
inserted nor recognized in the text.

Examples
--------
>>> from mojolearn import lm_corpus, LanguageModelConfig, LanguageModelTrainer
>>> corpus = lm_corpus.prepare("corpus/input.txt")               # train a vocabulary
>>> corpus = lm_corpus.prepare("corpus/input.txt", vocab="ranks.tsv")  # use yours
>>> batches = corpus.batches(batch=1, length=2048)
>>> schedule = batches.data_schedule()
>>> ids = batches.ids(0)
>>> tok = lm_corpus.tokenizer_for(schedule, corpus.vocabulary_path)
"""
# Byte training (ids = raw bytes, vocabulary 256) stays available by explicit
# choice: `tools/lm_train.py --bytes`, whose batches are
# `tools/lm_step_memory_probe.py::CorpusBatches`, unchanged.
import hashlib
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
import time

from . import tokenizer as _tokenizer

__all__ = ["prepare", "TokenizedCorpus", "TokenBatches", "require_vocabulary",
           "tokenizer_for"]

TOKENS_SCHEMA = "mojolearn.byte-lm.tokens.v1"
CORPUS_SCHEMA = "mojolearn.byte-lm.corpus.v1"
VOCABULARY_SCHEMA = _tokenizer.VOCABULARY_SCHEMA
#: GPT-2 / GPT-3 Small's vocabulary size is 50,257 ids: 50,256 ranks and
#: `<|endoftext|>`. The default trains to that size.
DEFAULT_VOCAB_SIZE = 50256
DEFAULT_MIN_FREQUENCY = 2
#: the vocabulary is trained on at most this many bytes from the start of the
#: TRAIN range (see `prepare`)
DEFAULT_VOCAB_SAMPLE_BYTES = 10_000_000
DEFAULT_DOCUMENT_BYTES = 1 << 20
DEFAULT_BATCH_DOCUMENTS = 16
_SCHEDULE = "train-range-modulo.v1"


def _sha_bytes(data):
    return hashlib.sha256(data).hexdigest()


def _sha_file(path, chunk=1 << 22):
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        while True:
            block = fh.read(chunk)
            if not block:
                return h.hexdigest()
            h.update(block)


def default_cache_dir():
    """The default cache directory: ``$MOJOLEARN_LM_CACHE``, else ``~/.cache/mojolearn/lm``."""
    return Path(os.environ.get("MOJOLEARN_LM_CACHE") or Path.home() / ".cache" / "mojolearn" / "lm")


# ---------------------------------------------------------------- the corpus

def _source(corpus_path):
    """`(bytes, source dict, ranges)` for a corpus file. A pinned corpus
    (`manifest.json` beside it, schema `mojolearn.byte-lm.corpus.v1`) is
    checked against its sha256 and length, and its declared ranges are used.
    Any other file is one train range covering the whole file."""
    path = Path(corpus_path).expanduser().resolve()
    data = path.read_bytes()
    sha = _sha_bytes(data)
    manifest_path = path.with_name("manifest.json")
    ranges = {}
    src = dict(path=str(path), sha256=sha, bytes=len(data), schema=None)
    if manifest_path.is_file():
        raw = manifest_path.read_bytes()
        m = json.loads(raw)
        if isinstance(m, dict) and m.get("schema") == CORPUS_SCHEMA:
            if m.get("sha256") != sha or m.get("bytes") != len(data):
                raise ValueError(f"mojolearn: pinned corpus length/SHA mismatch for {path} "
                                 f"(manifest {m.get('sha256')}, {m.get('bytes')} bytes; file {sha}, {len(data)})")
            src.update(schema=CORPUS_SCHEMA, manifest_sha256=_sha_bytes(raw), source_url=m.get("source_url"))
            for key in ("train_range", "validation_range", "test_range"):  # glue: three named range arguments
                r = m.get(key)
                if r:
                    ranges[key] = [int(r[0]), int(r[1])]
    if "train_range" not in ranges:
        ranges = {"train_range": [0, len(data)]}
    return data, src, ranges


def _split_points(ranges, n):
    points = {0, n}
    for r in ranges.values():  # cpu-route: split points of the corpus text ranges (text input)
        for x in r:  # cpu-route: split points of the corpus text ranges (text input)
            if 0 <= x <= n:
                points.add(x)
    return sorted(points)  # cpu-route: sorts the corpus split points (text input)


# The document rule (no document spans a declared range boundary; at most
# `document_bytes` bytes, ending at the last 0x0A at or before the limit) is
# `_cut_documents` in `bindings/_mojolearn_tokenizer_host.mojo`, applied
# inside the one binding call that trains (`bpe_train_corpus`) or encodes
# (`bpe_encode_corpus`); Python never walks the corpus.


def _atomic_dir(final, fill):
    """Build `final` in a temporary sibling and rename it into place, so a
    killed run never leaves a half-written cache entry that a rerun trusts."""
    final = Path(final)
    final.parent.mkdir(parents=True, exist_ok=True)
    tmp = Path(tempfile.mkdtemp(prefix=final.name + ".partial-", dir=final.parent))
    try:
        fill(tmp)
        if final.exists():
            shutil.rmtree(tmp)
        else:
            os.rename(tmp, final)
    except BaseException:
        shutil.rmtree(tmp, ignore_errors=True)
        raise
    return final


def _vocabulary_record(identity, recipe):
    return dict(schema=VOCABULARY_SCHEMA, identity=identity, recipe=recipe,
                scope="trained on or loaded for the caller's corpus; never shipped with mojolearn")


def _check_vocab_dir(d):
    rec = json.loads((d / "vocabulary.json").read_text())
    ident = _tokenizer._identity_of_ranks_file(str(d / "ranks.tsv"))
    if ident["sha256"] != rec["identity"]["sha256"]:
        raise ValueError(f"mojolearn: cached vocabulary {d} does not match its vocabulary.json "
                         f"({ident['sha256']} against {rec['identity']['sha256']}); remove it to rebuild")
    return rec


def _trained_vocabulary(data, ranges, sha, cache, vocab_size, min_frequency, sample_bytes, document_bytes,
                        progress=None):
    lo, hi = ranges["train_range"]
    hi = min(hi, lo + sample_bytes)
    key = f"{sha[:16]}-v{vocab_size}-f{min_frequency}-s{hi - lo}"
    final = cache / "vocab" / key
    if (final / "vocabulary.json").is_file():
        return final, _check_vocab_dir(final)

    def fill(tmp):
        t0 = time.perf_counter()
        if progress:
            progress(f"training a {vocab_size}-rank vocabulary on {hi - lo} bytes")
        v = _tokenizer._train_corpus(data, _split_points(ranges, len(data)), lo, hi, document_bytes,
                                     vocab_size, min_frequency)
        seconds = time.perf_counter() - t0
        v.write_ranks(str(tmp / "ranks.tsv"))
        recipe = dict(trainer="mojolearn.tokenizer.BpeVocabularyTrainer", backend=v.stats.get("backend"),
                      format=_tokenizer._bpe_trainer.FORMAT,
                      tie_break=v.tie_break, vocab_size=vocab_size, min_frequency=min_frequency,
                      corpus_sha256=sha, sample=[lo, hi], document_bytes=document_bytes,
                      n_tokens=v.n_tokens, n_merges=len(v.merges), n_ties_broken=v.n_ties_broken,
                      train_seconds=seconds)
        (tmp / "vocabulary.json").write_text(json.dumps(_vocabulary_record(v.identity, recipe), indent=2) + "\n")

    final = _atomic_dir(final, fill)
    return final, _check_vocab_dir(final)


def _user_vocabulary(vocab, cache):
    """A caller's token map, loaded and written canonically into the cache."""
    if isinstance(vocab, _tokenizer.TrainedBpeVocabulary):
        text = vocab.render_ranks()
        n_ranks = vocab.n_tokens
        source = "TrainedBpeVocabulary"
    else:
        if isinstance(vocab, _tokenizer.BpeTokenizer):
            tok = vocab
        elif isinstance(vocab, (tuple, list)) and len(vocab) == 2:
            tok = _tokenizer.BpeTokenizer.from_files(os.fspath(vocab[0]), os.fspath(vocab[1]))
        elif isinstance(vocab, (str, os.PathLike)):
            tok = _tokenizer.BpeTokenizer.from_ranks_file(os.fspath(vocab))
        else:
            raise TypeError("mojolearn: vocab must be a rank file path, (encoder_json, vocab_bpe), a BpeTokenizer "
                            f"or a TrainedBpeVocabulary, got {type(vocab).__name__}")
        text = tok._m.bpe_ranks_text(tok._handle)
        n_ranks = tok.n_vocab - 1
        source = tok.vocabulary_source
    identity = _tokenizer._identity_from_tokens_text(text, n_ranks)
    final = cache / "vocab" / f"user-{identity['sha256'][:16]}"
    if not (final / "vocabulary.json").is_file():
        def fill(tmp):
            (tmp / "ranks.tsv").write_text(text, encoding="ascii")
            (tmp / "vocabulary.json").write_text(json.dumps(
                _vocabulary_record(identity, dict(source=str(source), trainer=None)), indent=2) + "\n")
        _atomic_dir(final, fill)
    return final, _check_vocab_dir(final)


# ---------------------------------------------------------------- tokenizing

def _tokenize(data, src, ranges, tok, identity, out, document_bytes, batch_documents, progress=None):
    """Cut, encode and concatenate the corpus in ONE binding call
    (`BpeTokenizer._encode_corpus`). `batch_documents` is kept for the
    recipe's signature; the binding takes every document at once."""
    t_start = time.perf_counter()
    ids, boundaries, n_documents, max_id, above_255 = tok._encode_corpus(
        data, _split_points(ranges, len(data)), document_bytes)
    encode_seconds = seconds = time.perf_counter() - t_start
    at_token = len(ids)
    if progress:
        progress(f"tokenized {len(data)} bytes, {at_token} ids, {seconds:.1f} s, "
                 f"{len(data) / max(seconds, 1e-9) / 1e6:.3f} MB/s")
    if sys.byteorder != "little":
        ids.byteswap()
    payload = ids.tobytes()
    token_ranges = {key: [boundaries[a], boundaries[b]] for key, (a, b) in ranges.items()}  # glue: maps three named ranges to token offsets
    manifest = dict(
        schema=TOKENS_SCHEMA,
        source=src,
        vocabulary=identity,
        encoder="mojolearn.tokenizer.BpeTokenizer.encode_batch (host binding _mojolearn_tokenizer_host, "
                "tokenizer/encoding.mojo); integers and tables only",
        endoftext="not inserted and not recognized (allow_endoftext=False)",
        document_rule=f"no document spans a declared range boundary; at most {document_bytes} bytes, ending at "
                      "the last 0x0A at or before the limit (or at the limit when the window holds none); each "
                      "encoded alone, ids concatenated in document order",
        document_bytes=document_bytes, n_documents=n_documents,
        dtype="int32", byte_order="little",
        sha256=_sha_bytes(payload), bytes=len(payload), tokens=at_token,
        bytes_per_token=(len(data) / at_token) if at_token else None,
        max_id=max_id, ids_above_255=above_255,
        **{k: v for k, v in token_ranges.items()},  # glue: copies three named token ranges
        schedule=_SCHEDULE,
        tokenize_seconds=seconds, encode_seconds=encode_seconds,
        encode_bytes_per_second=(len(data) / encode_seconds) if encode_seconds else None,
    )
    (out / "tokens.i32").write_bytes(payload)
    (out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")


[docs] class TokenizedCorpus: """A prepared corpus: cached token ids and the vocabulary they use. Returned by :func:`prepare`. Attributes ---------- tokens_dir : pathlib.Path Directory holding ``tokens.i32`` (the int32 ids) and ``manifest.json``. vocabulary_path : pathlib.Path The vocabulary's canonical rank file. manifest : dict The parsed token manifest. """ def __init__(self, tokens_dir, vocabulary_path): self.tokens_dir = Path(tokens_dir) self.vocabulary_path = Path(vocabulary_path) self.manifest = json.loads((self.tokens_dir / "manifest.json").read_text()) if self.manifest.get("schema") != TOKENS_SCHEMA: raise ValueError(f"mojolearn: {self.tokens_dir}/manifest.json is not {TOKENS_SCHEMA}") @property def vocabulary(self): """The vocabulary identity record (schema, ``sha256``, ``n_vocab``).""" return dict(self.manifest["vocabulary"]) @property def n_vocab(self): """Number of token ids in the vocabulary, ``<|endoftext|>`` included.""" return int(self.manifest["vocabulary"]["n_vocab"])
[docs] def tokenizer(self): """A :class:`~mojolearn.tokenizer.BpeTokenizer` for this corpus's vocabulary.""" return tokenizer_for(self.manifest, self.vocabulary_path)
[docs] def batches(self, batch, length): """Training batches over this corpus; see :class:`TokenBatches`.""" return TokenBatches(self.tokens_dir, batch, length)
def __repr__(self): m = self.manifest return (f"TokenizedCorpus(tokens={m['tokens']}, n_vocab={self.n_vocab}, " f"sha256={m['sha256'][:16]}, dir={str(self.tokens_dir)!r})")
# Tokenize `corpus` (a file path) once and return a `TokenizedCorpus`. # # `vocab=None` trains a `vocab_size`-rank vocabulary (n_vocab = vocab_size # + 1 with `<|endoftext|>`) with `BpeVocabularyTrainer` on the first # `vocab_sample_bytes` of the corpus's TRAIN range. Anything else is the # caller's own token map (see the module docstring) and nothing is # trained. Both are cached; a rerun with the same corpus and vocabulary # reuses the id array after re-hashing it.
[docs] def prepare(corpus, *, vocab=None, cache_dir=None, vocab_size=DEFAULT_VOCAB_SIZE, min_frequency=DEFAULT_MIN_FREQUENCY, vocab_sample_bytes=DEFAULT_VOCAB_SAMPLE_BYTES, document_bytes=DEFAULT_DOCUMENT_BYTES, batch_documents=DEFAULT_BATCH_DOCUMENTS, progress=None): """Tokenize a corpus file once and return a :class:`TokenizedCorpus`. If a ``manifest.json`` with schema ``mojolearn.byte-lm.corpus.v1`` sits beside the corpus, the file is checked against its SHA-256 and length and its train / validation / test ranges are used; otherwise the whole file is the training range. Results are cached (see the module documentation). Parameters ---------- corpus : str or path-like Path to the corpus text file. vocab : str, path-like, tuple, BpeTokenizer, TrainedBpeVocabulary or None, default None None trains a vocabulary of ``vocab_size`` ranks (``n_vocab = vocab_size + 1`` with ``<|endoftext|>``) on the first ``vocab_sample_bytes`` of the training range. Otherwise your token map: a rank file, an ``(encoder.json, vocab.bpe)`` pair, a ``BpeTokenizer`` or a ``TrainedBpeVocabulary``; nothing is trained. ``"bytes"`` raises ValueError (byte-level training needs no vocabulary). cache_dir : str, path-like or None, default None Cache location; None uses :func:`default_cache_dir`. vocab_size : int, default 50256 Ranks to train (GPT-2's vocabulary size, without ``<|endoftext|>``). min_frequency : int, default 2 Minimum pair frequency for a merge. vocab_sample_bytes : int, default 10_000_000 Bytes of the training range used to train the vocabulary. document_bytes : int, default 1048576 Maximum document length; see the module documentation. batch_documents : int, default 16 Documents tokenized per batch. progress : callable or None, default None Optional progress callback. Returns ------- TokenizedCorpus Raises ------ ValueError For a non-positive size argument, a corpus that does not match its manifest, or a cache that does not match its manifest. """ for name, value in (("vocab_size", vocab_size), ("min_frequency", min_frequency), # glue: validates three trainer arguments ("vocab_sample_bytes", vocab_sample_bytes), ("document_bytes", document_bytes), ("batch_documents", batch_documents)): if type(value) is not int or value < 1: raise ValueError(f"mojolearn: {name} must be a positive int, got {value!r}") if isinstance(vocab, str) and vocab == "bytes": raise ValueError("mojolearn: byte training has no vocabulary to prepare; use the byte schedule " "(tools/lm_train.py --bytes) instead of lm_corpus.prepare") cache = Path(cache_dir).expanduser() if cache_dir is not None else default_cache_dir() data, src, ranges = _source(corpus) if vocab is None: vdir, rec = _trained_vocabulary(data, ranges, src["sha256"], cache, vocab_size, min_frequency, vocab_sample_bytes, document_bytes, progress) else: vdir, rec = _user_vocabulary(vocab, cache) identity = rec["identity"] ranks = vdir / "ranks.tsv" key = f"{src['sha256'][:16]}-{identity['sha256'][:16]}-d{document_bytes}" final = cache / "tokens" / key if (final / "manifest.json").is_file(): out = TokenizedCorpus(final, ranks) m = out.manifest if m["source"]["sha256"] != src["sha256"] or m["vocabulary"]["sha256"] != identity["sha256"]: raise ValueError(f"mojolearn: cached tokens {final} were made from another corpus or vocabulary; " "remove the directory to rebuild") got = _sha_file(final / "tokens.i32") if got != m["sha256"]: raise ValueError(f"mojolearn: cached tokens {final}/tokens.i32 hash {got}, its manifest says " f"{m['sha256']}; remove the directory to rebuild") return out tok = _tokenizer.BpeTokenizer.from_ranks_file(str(ranks)) if tok.identity["sha256"] != identity["sha256"]: raise ValueError(f"mojolearn: {ranks} loads as {tok.identity['sha256']}, not {identity['sha256']}") _atomic_dir(final, lambda tmp: _tokenize(data, src, ranges, tok, identity, tmp, document_bytes, batch_documents, progress)) return TokenizedCorpus(final, ranks)
# ---------------------------------------------------------------- batches # Token batches from a pinned id array, with `CorpusBatches`' interface # (`ids(step_index)`, `describe()`), plus `data_schedule()` for the # trainer. # # Step `k` (zero-based), row `b` reads ids # `[lo + (k*batch*length + b*length) % (hi - lo - length - 1) : + length + 1]` # where `[lo, hi)` is the TRAIN range in tokens, so a run of any length # reads no validation or test id. (`CorpusBatches` takes the same form over # the WHOLE byte file, test range included; it is kept that way so byte # runs hash as they always have.)
[docs] class TokenBatches: """Training batches from a tokenized corpus. Step ``k`` (zero-based), row ``b`` reads the ``length + 1`` ids starting at ``lo + (k * batch * length + b * length) % (hi - lo - length - 1)``, where ``[lo, hi)`` is the training range in tokens, so a run of any length reads no validation or test id. Targets are the inputs shifted by one id. The token file is verified against its manifest when opened. Parameters ---------- tokens_dir : str or path-like A :class:`TokenizedCorpus` ``tokens_dir``. batch : int Rows per batch. length : int Tokens per row (each row holds ``length + 1`` ids). Raises ------ ValueError If the token file does not match its manifest or the training range is shorter than one row. """ def __init__(self, tokens_dir, batch, length): import mmap from ._array import Array self.dir = Path(tokens_dir) raw = (self.dir / "manifest.json").read_bytes() self.manifest = json.loads(raw) if self.manifest.get("schema") != TOKENS_SCHEMA: raise ValueError(f"mojolearn: {self.dir}/manifest.json is not {TOKENS_SCHEMA}") self.manifest_sha256 = _sha_bytes(raw) path = self.dir / "tokens.i32" self.sha256 = _sha_file(path) if self.sha256 != self.manifest["sha256"] or path.stat().st_size != self.manifest["bytes"]: raise ValueError(f"mojolearn: pinned tokens length/SHA mismatch for {path}") if path.stat().st_size % 4: raise ValueError("mojolearn: token payload is not an int32 stream") if path.stat().st_size: with path.open("rb") as stream: mapping = mmap.mmap(stream.fileno(), 0, access=mmap.ACCESS_READ) # The borrowed Array pins the memoryview and its mmap owner. self.ids_all = Array.from_buffer(memoryview(mapping).cast("i")) else: self.ids_all = Array((0,), "<i4") self.batch, self.length = int(batch), int(length) lo, hi = self.manifest.get("train_range") or [0, int(self.ids_all.size)] self.lo, self.hi = int(lo), int(hi) if self.hi - self.lo < self.length + 2: raise ValueError("mojolearn: the train range is shorter than one batch row") self.modulus = self.hi - self.lo - self.length - 1 @property def vocabulary(self): """The vocabulary identity record (schema, ``sha256``, ``n_vocab``).""" return dict(self.manifest["vocabulary"])
[docs] def ids(self, step_index): """The batch for training step ``step_index``. Returns ------- Array of shape (batch, length + 1), int32 """ from ._array import Array width = self.length + 1 out = Array((self.batch, width), "<i4") # Row b reads ids[lo + (k*batch*length + b*length) % modulus : +width], # copied in one binding call (`tokens_gather_rows`). _tokenizer._gather_rows(self.ids_all, out, self.batch, self.length, self.lo, self.modulus, step_index * self.batch * self.length) return out
# Yield ``(step, ids)`` in order. # # Each batch is one `tokens_gather_rows` copy out of the already # verified mmap, made when the consumer asks for it; there is no host # thread (lane/pyglue-text-io, 2026-10-03), so ``depth`` only bounds # how many batches may be live and is validated for the old callers. # A failure is raised at its exact logical step, before any later # batch is made.
[docs] def prefetch(self, start_step, steps, *, depth=2): """Iterate over ``(step, ids)`` for ``steps`` steps from ``start_step``. Each batch is made when the consumer asks for it, and an error is raised at its own step, before any later batch is made. Parameters ---------- start_step : int First step, at least 0. steps : int Number of steps, at least 0. depth : int, default 2 Maximum number of batches alive at once; at least 1. Returns ------- iterator of (int, Array) """ if type(start_step) is not int or start_step < 0: raise ValueError(f"mojolearn: start_step must be a nonnegative int, got {start_step!r}") if type(steps) is not int or steps < 0: raise ValueError(f"mojolearn: steps must be a nonnegative int, got {steps!r}") if type(depth) is not int or depth < 1: raise ValueError(f"mojolearn: prefetch depth must be a positive int, got {depth!r}") return self._batches(start_step, steps)
def _batches(self, start_step, steps): for step in range(start_step, start_step + steps): # glue: yields one batch handle per step (steps-sized: training steps) yield step, self.ids(step)
[docs] def describe(self): """A dict describing the token file, its hashes, the training range and the schedule.""" v = self.manifest["vocabulary"] return dict(path=str(self.dir / "tokens.i32"), sha256=self.sha256, manifest_sha256=self.manifest_sha256, tokens=int(self.ids_all.size), train_range=[self.lo, self.hi], corpus_sha256=self.manifest["source"]["sha256"], vocabulary_sha256=v["sha256"], n_vocab=v["n_vocab"], schedule='step k row b: ids[lo + (k*batch*length + b*length) % (hi - lo - length - 1) ' ': +length+1] over the TRAIN range [lo, hi) in tokens; targets shifted one id')
[docs] def data_schedule(self, **extra): """The trainer's ``data_schedule``: which ids, in which order, from which vocabulary. Pass it to the language model trainer, which keeps it in every checkpoint. Keyword arguments are added to the record. Returns ------- dict """ v = self.manifest["vocabulary"] out = dict(schema=TOKENS_SCHEMA, schedule=_SCHEDULE, tokens_sha256=self.sha256, corpus_sha256=self.manifest["source"]["sha256"], batch=self.batch, length=self.length, train_range=[self.lo, self.hi], vocabulary=dict(schema=v["schema"], sha256=v["sha256"], n_vocab=v["n_vocab"])) out.update(extra) return out
# ---------------------------------------------------------------- identity def _expected_vocabulary(model): """The vocabulary identity a model, checkpoint state, schedule or manifest carries, or None when it carries none.""" if hasattr(model, "data_schedule") and not isinstance(model, dict): model = model.data_schedule if isinstance(model, dict): if "data_schedule" in model and isinstance(model["data_schedule"], dict): model = model["data_schedule"] v = model.get("vocabulary") if isinstance(v, dict) and "sha256" in v: return v return None
[docs] def require_vocabulary(model, tokenizer): """Check that ``tokenizer`` is the vocabulary ``model`` was trained on. Parameters ---------- model : trainer, dict A trainer, a checkpoint state, a data schedule or a tokens manifest. tokenizer : BpeTokenizer The tokenizer to check. Returns ------- BpeTokenizer ``tokenizer``, unchanged. Raises ------ ValueError If ``model`` carries no vocabulary identity, or the tokenizer's SHA-256 or ``n_vocab`` differs from it. """ expected = _expected_vocabulary(model) if expected is None: raise ValueError("mojolearn: this model carries no vocabulary identity (a byte model, or trained " "without lm_corpus); there is nothing to check a tokenizer against") got = tokenizer.identity if got["sha256"] != expected["sha256"] or got["n_vocab"] != expected["n_vocab"]: raise ValueError( f"mojolearn: vocabulary mismatch: the model was trained on vocabulary {expected['sha256']} " f"(n_vocab {expected['n_vocab']}) and this tokenizer is {got['sha256']} (n_vocab {got['n_vocab']}); " "its ids would decode to the wrong text") return tokenizer
[docs] def tokenizer_for(model, vocabulary): """A tokenizer for ``vocabulary``, checked against the vocabulary ``model`` was trained on. Parameters ---------- model : trainer, dict A trainer, a checkpoint state, a data schedule or a tokens manifest. vocabulary : str, path-like, tuple, BpeTokenizer or TrainedBpeVocabulary Any form :func:`prepare` accepts as ``vocab``. Returns ------- BpeTokenizer Raises ------ ValueError If the vocabulary does not match the model's (see :func:`require_vocabulary`). """ if isinstance(vocabulary, _tokenizer.BpeTokenizer): tok = vocabulary elif isinstance(vocabulary, _tokenizer.TrainedBpeVocabulary): tok = vocabulary.tokenizer() elif isinstance(vocabulary, (tuple, list)) and len(vocabulary) == 2: tok = _tokenizer.BpeTokenizer.from_files(os.fspath(vocabulary[0]), os.fspath(vocabulary[1])) else: tok = _tokenizer.BpeTokenizer.from_ranks_file(os.fspath(vocabulary)) return require_vocabulary(model, tok)