diff --git a/model2vec/distill/distillation.py b/model2vec/distill/distillation.py index 0c88066..3cf5c0b 100644 --- a/model2vec/distill/distillation.py +++ b/model2vec/distill/distillation.py @@ -16,6 +16,7 @@ from model2vec.model import StaticModel from model2vec.quantization import DType, quantize_embeddings from model2vec.tokenizer import clean_and_create_vocabulary, turn_tokens_into_ids +from model2vec.types import StaticModelConfig from model2vec.vocabulary_quantization import quantize_vocabulary logger = logging.getLogger(__name__) @@ -125,7 +126,7 @@ def distill_from_model( model_name = getattr(model, "name_or_path", "") - config = { + config: StaticModelConfig = { "model_type": "model2vec", "architectures": ["StaticModel"], "tokenizer_name": model_name, diff --git a/model2vec/inference/model.py b/model2vec/inference/model.py index ffd3845..5b28712 100644 --- a/model2vec/inference/model.py +++ b/model2vec/inference/model.py @@ -4,7 +4,7 @@ from collections.abc import Sequence from pathlib import Path from tempfile import TemporaryDirectory -from typing import Any, TypeVar, cast +from typing import TypeVar, cast import huggingface_hub import numpy as np @@ -15,6 +15,7 @@ from model2vec.inference.mlp import Activation, Layer, MLPHead from model2vec.model import PathLike, StaticModel from model2vec.persistence import save_pretrained +from model2vec.types import _UNSET, StaticModelConfig, _UnsetType _DEFAULT_HEAD_FILENAME = "head.safetensors" _LEGACY_HEAD_FILENAME = "pipeline.skops" @@ -74,7 +75,7 @@ def _encode_and_coerce_to_2d( self, X: Sequence[str], show_progress_bar: bool, - max_length: int | None, + max_length: int | None | _UnsetType, batch_size: int, use_multiprocessing: bool, multiprocessing_threshold: int, @@ -97,7 +98,7 @@ def predict( self, X: Sequence[str], show_progress_bar: bool = False, - max_length: int | None = 512, + max_length: int | None | _UnsetType = _UNSET, batch_size: int = 1024, use_multiprocessing: bool = True, multiprocessing_threshold: int = 10_000, @@ -107,7 +108,8 @@ def predict( :param X: The input data to predict. Can be a list of strings or a single string. :param show_progress_bar: Whether to display a progress bar during prediction. Defaults to False. - :param max_length: The maximum length of the input sequences. Defaults to 512. + :param max_length: The maximum length of the input sequences. If not passed, the encoder model's + `max_length` is used. Pass `max_length=None` to disable truncation. :param batch_size: The batch size for prediction. Defaults to 1024. :param use_multiprocessing: Whether to use multiprocessing for encoding. Defaults to True. :param multiprocessing_threshold: The threshold for the number of samples to use multiprocessing. Defaults to 10,000. @@ -139,7 +141,7 @@ def predict_proba( self, X: Sequence[str], show_progress_bar: bool = False, - max_length: int | None = 512, + max_length: int | None | _UnsetType = _UNSET, batch_size: int = 1024, use_multiprocessing: bool = True, multiprocessing_threshold: int = 10_000, @@ -148,7 +150,8 @@ def predict_proba( :param X: The input data to predict. Can be a list of strings or a single string. :param show_progress_bar: Whether to display a progress bar during prediction. Defaults to False. - :param max_length: The maximum length of the input sequences. Defaults to 512. + :param max_length: The maximum length of the input sequences. If not passed, the encoder model's + `max_length` is used. Pass `max_length=None` to disable truncation. :param batch_size: The batch size for prediction. Defaults to 1024. :param use_multiprocessing: Whether to use multiprocessing for encoding. Defaults to True. :param multiprocessing_threshold: The threshold for the number of samples to use multiprocessing. Defaults to 10,000. @@ -220,7 +223,7 @@ def _load_pipeline(folder_or_repo_path: PathLike, token: str | None = None) -> t model = StaticModel.from_pretrained(folder_or_repo_path) - head_config = cast(dict[str, Any], model.config.get("head_config", {})) + head_config = model.config.get("head_config", {}) activation = Activation(head_config.get("activation", Activation.IDENTITY.value)) n_layers = head_config.get("n_layers", 0) classes = head_config.get("classes") @@ -337,7 +340,7 @@ def _save_pipeline(pipeline: StaticModelPipeline, folder_path: str | Path) -> No save_file(tensors, folder_path / _DEFAULT_HEAD_FILENAME) model = pipeline.model - config = dict(model.config) + config: StaticModelConfig = {**model.config} config["head_config"] = { "n_layers": len(head.layers), "activation": head.activation.value, diff --git a/model2vec/model.py b/model2vec/model.py index 3a63206..b910f24 100644 --- a/model2vec/model.py +++ b/model2vec/model.py @@ -3,12 +3,11 @@ import json import math import os -import warnings -from collections.abc import Iterator, Sequence +from collections.abc import Iterator, Mapping, Sequence from logging import getLogger from pathlib import Path from tempfile import TemporaryDirectory -from typing import Any, overload +from typing import Any, cast, overload import numpy as np from joblib import delayed @@ -16,31 +15,38 @@ from tqdm import tqdm from model2vec.quantization import DType, quantize_and_reduce_dim +from model2vec.types import _UNSET, StaticModelConfig, _UnsetType from model2vec.utils import ProgressParallel PathLike = Path | str logger = getLogger(__name__) +DEFAULT_MAX_LENGTH = 512 +_DEFAULT_NORMALIZE = False + class StaticModel: def __init__( self, vectors: np.ndarray, tokenizer: Tokenizer, - config: dict[str, Any] | None = None, - normalize: bool | None = None, + config: Mapping[str, Any] | None = None, + normalize: bool = _DEFAULT_NORMALIZE, base_model_name: str | None = None, language: list[str] | None = None, weights: np.ndarray | None = None, token_mapping: np.ndarray | None = None, + max_length: int | None = DEFAULT_MAX_LENGTH, ) -> None: """Initialize the StaticModel. :param vectors: The vectors to use. :param tokenizer: The Transformers tokenizer to use. - :param config: Any metadata config. - :param normalize: Whether to normalize the embeddings. + :param config: Any metadata config. Stored as a copy, so mutating it afterwards does not affect + the model, and vice versa. + :param normalize: Whether to normalize the embeddings. Defaults to False. This value is authoritative: + it is written to `config["normalize"]`, and any conflicting value already in `config` is overridden. :param base_model_name: The used base model name. Used for creating a model card. :param language: The language of the model. Used for creating a model card. :param weights: The weights to use for the embeddings. If None, no weights are used. @@ -49,6 +55,10 @@ def __init__( :param token_mapping: A mapping from token ids to indices in the vectors. If None, we don't remap the tokens during inference. This is only used for models that have undergone vocabulary quantization. + :param max_length: The default maximum sequence length (in tokens) used by `encode` when its own + `max_length` argument is not passed. Defaults to 512; pass None to disable truncation. This value + is authoritative: it is written to `config["max_length"]`, and any conflicting value already in + `config` is overridden. :raises ValueError: if the number of tokens does not match the number of vectors. """ super().__init__() @@ -71,18 +81,11 @@ def __init__( self.unk_token_id = _get_unk_token_id(self.tokenizer) self.median_token_length = int(np.median([len(token) for token in self.tokens])) - self.config = config or {} + self.config: StaticModelConfig = cast(StaticModelConfig, {**config}) if config is not None else {} self.base_model_name = base_model_name self.language = language - if hasattr(self.tokenizer, "encode_batch_fast"): - self._can_encode_fast = True - else: - self._can_encode_fast = False - - if normalize is not None: - self.normalize = normalize - else: - self.normalize = self.config.get("normalize", False) + self.max_length = max_length + self.normalize = normalize @property def dim(self) -> int: @@ -99,7 +102,7 @@ def normalize(self) -> bool: @normalize.setter def normalize(self, value: bool) -> None: - """Update the config if the value of normalize changes.""" + """Update the config.""" config_normalize = self.config.get("normalize") self._normalize = value if config_normalize is not None and value != config_normalize: @@ -108,6 +111,25 @@ def normalize(self, value: bool) -> None: ) self.config["normalize"] = value + @property + def max_length(self) -> int | None: + """Get the max_length value. + + :return: The max_length value. + """ + return self._max_length + + @max_length.setter + def max_length(self, value: int | None) -> None: + """Update the config.""" + config_max_length = self.config.get("max_length") + self._max_length = value + if config_max_length is not None and value != config_max_length: + logger.warning( + f"Set max_length to `{value}`, which does not match config value `{config_max_length}`. Updating config." + ) + self.config["max_length"] = value + @property def embedding_dtype(self) -> str: """Get the dtype (precision) of the embedding matrix.""" @@ -153,10 +175,7 @@ def tokenize(self, sentences: Sequence[str], max_length: int | None = None) -> l m = max_length * self.median_token_length sentences = [sentence[:m] for sentence in sentences] - if self._can_encode_fast: - encodings: list[Encoding] = self.tokenizer.encode_batch_fast(sentences, add_special_tokens=False) - else: - encodings = self.tokenizer.encode_batch(sentences, add_special_tokens=False) + encodings: list[Encoding] = self.tokenizer.encode_batch_fast(sentences, add_special_tokens=False) encodings_ids = [encoding.ids for encoding in encodings] @@ -180,6 +199,7 @@ def from_pretrained( quantize_to: str | DType | None = None, dimensionality: int | None = None, vocabulary_quantization: int | None = None, + max_length: int | None | _UnsetType = _UNSET, force_download: bool = True, ) -> StaticModel: """Load a StaticModel from a local path or huggingface hub path. @@ -188,7 +208,8 @@ def from_pretrained( :param path: The path to load your static model from. :param token: The huggingface token to use. - :param normalize: Whether to normalize the embeddings. + :param normalize: Whether to normalize the embeddings. If not passed, the value from the model's config + is used, falling back to False if the config does not specify one. :param subfolder: The subfolder to load from. :param quantize_to: The dtype to quantize the model to. If None, no quantization is done. If a string is passed, it is converted to a DType. @@ -196,6 +217,9 @@ def from_pretrained( This is useful if you want to load a model with a lower dimensionality. Note that this only applies if you have trained your model using mrl or PCA. :param vocabulary_quantization: The number of clusters to use for vocabulary quantization. + :param max_length: The default maximum sequence length (in tokens) for `encode`. If not passed, the + value from the model's config is used, falling back to 512 if the config does not specify one. + Pass None to disable truncation. :param force_download: Whether to force the download of the model. If False, the model is only downloaded if it is not already present in the cache. :return: A StaticModel. @@ -210,35 +234,7 @@ def from_pretrained( normalize=normalize, subfolder=subfolder, force_download=force_download, - ) - - @classmethod - def from_sentence_transformers( - cls: type[StaticModel], - path: PathLike, - token: str | None = None, - normalize: bool | None = None, - quantize_to: str | DType | None = None, - dimensionality: int | None = None, - vocabulary_quantization: int | None = None, - force_download: bool = True, - ) -> StaticModel: - """Deprecated: use from_pretrained.""" - warnings.warn( - "StaticModel.from_sentence_transformers() is deprecated; use from_pretrained() instead.", - DeprecationWarning, - stacklevel=2, - ) - return _loading_helper( - cls=cls, - path=path, - token=token, - vocabulary_quantization=vocabulary_quantization, - quantize_to=quantize_to, - dimensionality=dimensionality, - normalize=normalize, - subfolder=None, - force_download=force_download, + max_length=max_length, ) @overload @@ -341,8 +337,10 @@ def _encode_batch_as_sequence(self, sentences: Sequence[str], max_length: int | def encode( self, sentences: Sequence[str], + *, show_progress_bar: bool = False, - max_length: int | None = 512, + max_length: int | None | _UnsetType = _UNSET, + normalize: bool | None = None, batch_size: int = 1024, use_multiprocessing: bool = True, multiprocessing_threshold: int = 10_000, @@ -359,7 +357,9 @@ def encode( :param sentences: The list of sentences to encode. You can also pass a single sentence. :param show_progress_bar: Whether to show the progress bar. :param max_length: The maximum length of the sentences. Any tokens beyond this length will be truncated. - If this is None, no truncation is done. + If this is None, no truncation is done. If not passed, the model's `max_length` is used. + :param normalize: Whether to normalize the resulting embeddings. If not passed, the model's `normalize` + setting is used. :param batch_size: The batch size to use. :param use_multiprocessing: Whether to use multiprocessing. By default, this is enabled for inputs > multiprocessing_threshold sentences and disabled otherwise. @@ -371,6 +371,10 @@ def encode( if isinstance(sentences, str): sentences = [sentences] was_single = True + if isinstance(max_length, _UnsetType): + max_length = self.max_length + if normalize is None: + normalize = self.normalize # Prepare all batches sentence_batches = list(self._batch(sentences, batch_size)) @@ -382,7 +386,7 @@ def encode( os.environ["TOKENIZERS_PARALLELISM"] = "false" results = ProgressParallel(n_jobs=-1, use_tqdm=show_progress_bar, total=total_batches)( - delayed(self._encode_batch)(batch, max_length) for batch in sentence_batches + delayed(self._encode_batch)(batch, max_length, normalize) for batch in sentence_batches ) out_array = np.concatenate(results, axis=0) else: @@ -393,7 +397,7 @@ def encode( total=total_batches, disable=not show_progress_bar, ): - out_arrays.append(self._encode_batch(batch, max_length)) + out_arrays.append(self._encode_batch(batch, max_length, normalize)) out_array = np.concatenate(out_arrays, axis=0) if was_single: @@ -420,7 +424,7 @@ def _encode_helper(self, id_list: list[int]) -> np.ndarray: return emb - def _encode_batch(self, sentences: Sequence[str], max_length: int | None) -> np.ndarray: + def _encode_batch(self, sentences: Sequence[str], max_length: int | None, normalize: bool) -> np.ndarray: """Encode a batch of sentences.""" ids = self.tokenize(sentences=sentences, max_length=max_length) out: list[np.ndarray] = [] @@ -432,7 +436,7 @@ def _encode_batch(self, sentences: Sequence[str], max_length: int | None) -> np. out.append(np.zeros(self.dim)) out_array = np.stack(out) - if self.normalize: + if normalize: norm = np.linalg.norm(out_array, axis=1, keepdims=True) + 1e-32 out_array = out_array / norm @@ -504,12 +508,13 @@ def quantize_model( return StaticModel( vectors=embeddings, tokenizer=model.tokenizer, - config=dict(model.config), + config=model.config, weights=weights, token_mapping=token_mapping, normalize=model.normalize, base_model_name=model.base_model_name, language=model.language, + max_length=model.max_length, ) @@ -523,6 +528,7 @@ def _loading_helper( normalize: bool | None, subfolder: str | None, force_download: bool, + max_length: int | None | _UnsetType, ) -> StaticModel: """Helper function to load a model from a directory.""" from model2vec.persistence import load_pretrained @@ -534,6 +540,11 @@ def _loading_helper( force_download=force_download, ) + normalize = normalize if normalize is not None else config.get("normalize", _DEFAULT_NORMALIZE) + resolved_max_length = ( + config.get("max_length", DEFAULT_MAX_LENGTH) if isinstance(max_length, _UnsetType) else max_length + ) + model = cls( vectors=embeddings, tokenizer=tokenizer, @@ -543,6 +554,7 @@ def _loading_helper( normalize=normalize, base_model_name=metadata.get("base_model"), language=metadata.get("language"), + max_length=resolved_max_length, ) # If no quantization or dimensionality reduction is requested, diff --git a/model2vec/onnx.py b/model2vec/onnx.py index b842d37..37a719b 100644 --- a/model2vec/onnx.py +++ b/model2vec/onnx.py @@ -18,9 +18,9 @@ from tokenizers import Tokenizer from torch.export import Dim -from model2vec import StaticModel from model2vec.inference import StaticModelPipeline from model2vec.inference.mlp import Activation +from model2vec.model import DEFAULT_MAX_LENGTH, StaticModel from model2vec.modelcards import create_model_card logger = logging.getLogger(__name__) @@ -187,7 +187,7 @@ def _export_encoder_to_onnx( logger.info(f"Model has been successfully exported to {onnx_model_path}") # Save the tokenizer files required for transformers.js, and a config.json for ONNX runtime providers - _save_tokenizer_and_config(model.tokenizer, save_path, remove_post_processor) + _save_tokenizer_and_config(model.tokenizer, save_path, remove_post_processor, model.max_length) logger.info(f"Tokenizer files have been saved to {save_path}") _save_model_card( @@ -241,7 +241,7 @@ def _export_pipeline_to_onnx( logger.info(f"Pipeline has been successfully exported to {onnx_model_path}") # Save the tokenizer files required for transformers.js, and a config.json for ONNX runtime providers - _save_tokenizer_and_config(pipeline.model.tokenizer, save_path, remove_post_processor) + _save_tokenizer_and_config(pipeline.model.tokenizer, save_path, remove_post_processor, pipeline.model.max_length) logger.info(f"Tokenizer files have been saved to {save_path}") _save_model_card( @@ -295,12 +295,15 @@ def _resolve_pad_token_id(tokenizer: Tokenizer, tokenizer_model: TokenizerModel) return 0 -def _save_tokenizer_and_config(tokenizer: Tokenizer, save_directory: Path, remove_post_processor: bool) -> None: +def _save_tokenizer_and_config( + tokenizer: Tokenizer, save_directory: Path, remove_post_processor: bool, max_length: int | None +) -> None: """Save tokenizer files in a format compatible with Transformers, plus config.json and special_tokens_map.json. :param tokenizer: The tokenizer from the StaticModel. :param save_directory: The directory to save the tokenizer and config files. :param remove_post_processor: Whether to remove the post processor. + :param max_length: The max length of the model. """ tokenizer_model = TokenizerModel.from_tokenizer(tokenizer) if tokenizer_model.post_processor is not None and remove_post_processor: @@ -313,8 +316,10 @@ def _save_tokenizer_and_config(tokenizer: Tokenizer, save_directory: Path, remov if pad_token: tokenizer_model.pad_token = pad_token hf = tokenizer_model.to_transformers() - # Hardcoded max length of 512 - hf.model_max_length = 512 + if max_length is None: + logger.warning(f"Your model had no max length (i.e., unlimited), defaulting to {DEFAULT_MAX_LENGTH}.") + max_length = DEFAULT_MAX_LENGTH + hf.model_max_length = max_length hf.save_pretrained(save_directory) config = {"pad_token_id": pad_token_id} diff --git a/model2vec/persistence/persistence.py b/model2vec/persistence/persistence.py index 9e398c9..105744b 100644 --- a/model2vec/persistence/persistence.py +++ b/model2vec/persistence/persistence.py @@ -16,6 +16,7 @@ from model2vec.persistence.datamodels import FOLDER_LAYOUTS, Layout from model2vec.persistence.hf import maybe_get_cached_model_path from model2vec.persistence.utils import SilentTqdm +from model2vec.types import StaticModelConfig from model2vec.utils import SafeOpenProtocol logger = logging.getLogger(__name__) @@ -25,7 +26,7 @@ def save_pretrained( folder_path: Path, embeddings: np.ndarray, tokenizer: Tokenizer, - config: dict[str, Any], + config: StaticModelConfig, create_model_card: bool = True, subfolder: str | None = None, weights: np.ndarray | None = None, @@ -84,7 +85,7 @@ def load_pretrained( subfolder: str | None, token: str | None, force_download: bool, -) -> tuple[np.ndarray, Tokenizer, dict[str, Any], dict[str, Any], np.ndarray | None, np.ndarray | None]: +) -> tuple[np.ndarray, Tokenizer, StaticModelConfig, dict[str, Any], np.ndarray | None, np.ndarray | None]: """Loads a pretrained model from a folder. :param folder_or_repo_path: The folder or repo path to load from. diff --git a/model2vec/train/base.py b/model2vec/train/base.py index 16a032c..7409072 100644 --- a/model2vec/train/base.py +++ b/model2vec/train/base.py @@ -12,7 +12,7 @@ from tqdm import trange from model2vec.inference import StaticModelPipeline -from model2vec.model import PathLike, StaticModel +from model2vec.model import DEFAULT_MAX_LENGTH, PathLike, StaticModel from model2vec.train.dataset import TextDataset from model2vec.train.trainer import MetricsFn, default_metrics, resolve_device, run_training_loop from model2vec.train.utils import ( @@ -43,6 +43,7 @@ def __init__( freeze: bool = False, normalize: bool = True, freeze_weights: bool = False, + max_length: int | None = DEFAULT_MAX_LENGTH, ) -> None: """Initialize a trainable StaticModel from a StaticModel. @@ -57,6 +58,8 @@ def __init__( :param freeze: Whether to freeze the embeddings. This should be set to False in most cases. :param normalize: Whether to normalize the embeddings. :param freeze_weights: Whether to freeze the learned token weights. + :param max_length: The default maximum sequence length (in tokens) used to tokenize inputs. + Matches `StaticModel.max_length`, defaulting to 512. """ super().__init__() self.pad_id = pad_id @@ -66,6 +69,7 @@ def __init__( self.n_layers = n_layers self.normalize = normalize self.freeze_weights = freeze_weights + self.max_length = max_length self.vectors = vectors if self.vectors.dtype != torch.float32: @@ -154,9 +158,18 @@ def from_static_model( *, model: StaticModel, pad_token: str | None = None, + max_length: int | None = None, **kwargs: Any, ) -> ModelType: - """Load the model from a static model.""" + """Load the model from a static model. + + :param model: The static model to load from. + :param pad_token: The token to use for padding. If None, it is inferred from the tokenizer. + :param max_length: The default maximum sequence length to use for tokenization. If None, the + static model's `max_length` is used. + :param **kwargs: Any additional keyword arguments to pass to the constructor. + :return: The initialized model. + """ model.embedding = np.nan_to_num(model.embedding) weights = torch.from_numpy(model.weights) if model.weights is not None else None embeddings_converted = torch.from_numpy(model.embedding) @@ -168,12 +181,15 @@ def from_static_model( pad_id = model.tokenizer.get_vocab()[pad_token] else: pad_id = get_probable_pad_token_id(model.tokenizer) + if max_length is None: + max_length = model.max_length return cls( vectors=embeddings_converted, pad_id=pad_id, tokenizer=model.tokenizer, token_mapping=token_mapping, weights=weights, + max_length=max_length, **kwargs, ) @@ -222,15 +238,15 @@ def forward(self, input_ids: torch.Tensor) -> torch.Tensor: """Forward pass through the mean, and a classifier layer after.""" return self.head(self._encode(input_ids)) - def tokenize(self, texts: list[str], max_length: int | None = 512) -> torch.Tensor: + def tokenize(self, texts: list[str]) -> torch.Tensor: """Tokenize a bunch of strings into a single padded 2D tensor. Note that this is not used during training. :param texts: The texts to tokenize. - :param max_length: If this is None, the sequence lengths are truncated to 512. :return: A 2D padded tensor """ + max_length = self.max_length encoded: list[Encoding] = self.tokenizer.encode_batch_fast(texts, add_special_tokens=False) encoded_ids: list[torch.Tensor] = [torch.Tensor(encoding.ids[:max_length]).long() for encoding in encoded] return pad_sequence(encoded_ids, batch_first=True, padding_value=self.pad_id) @@ -256,6 +272,7 @@ def to_static_model(self) -> StaticModel: tokenizer=self.tokenizer, normalize=self.normalize, token_mapping=None, + max_length=self.max_length, ) return StaticModel( vectors=emb, @@ -263,6 +280,7 @@ def to_static_model(self) -> StaticModel: tokenizer=self.tokenizer, normalize=self.normalize, token_mapping=self.token_mapping.numpy(), + max_length=self.max_length, ) def to_pipeline(self) -> StaticModelPipeline: @@ -366,21 +384,21 @@ def _determine_val_check_interval( return val_check_interval, check_val_every_epoch - def _prepare_dataset(self, X: list[str], y: torch.Tensor, max_length: int = 512) -> TextDataset: + def _prepare_dataset(self, X: list[str], y: torch.Tensor, max_length: int | None) -> TextDataset: """Prepare a dataset. :param X: The texts. :param y: The labels. - :param max_length: The maximum length of the input. + :param max_length: The maximum length of the input in tokens. If this is None, no truncation is done. :return: A TextDataset. """ - # This is a speed optimization. - # assumes a mean token length of 10, which is really high, so safe. - truncate_length = max_length * 10 batch_size = 1024 tokenized: list[list[int]] = [] for batch_idx in trange(0, len(X), 1024, desc="Tokenizing data"): - batch = [x[:truncate_length] for x in X[batch_idx : batch_idx + batch_size]] + batch = X[batch_idx : batch_idx + batch_size] + if max_length is not None: + truncate_length = max_length * 10 + batch = [x[:truncate_length] for x in batch] encoded = self.tokenizer.encode_batch_fast(batch, add_special_tokens=False) tokenized.extend([encoding.ids[:max_length] for encoding in encoded]) @@ -405,9 +423,9 @@ def _create_datasets( y_val_tensor = self._labels_to_tensor(validation_labels) logger.info("Preparing train dataset.") - train_dataset = self._prepare_dataset(train_texts, y_tensor) + train_dataset = self._prepare_dataset(train_texts, y_tensor, self.max_length) logger.info("Preparing validation dataset.") - val_dataset = self._prepare_dataset(validation_texts, y_val_tensor) + val_dataset = self._prepare_dataset(validation_texts, y_val_tensor, self.max_length) return train_dataset, val_dataset diff --git a/model2vec/train/classifier.py b/model2vec/train/classifier.py index 7190fc1..779700e 100644 --- a/model2vec/train/classifier.py +++ b/model2vec/train/classifier.py @@ -12,6 +12,7 @@ from tqdm import trange from model2vec.inference import evaluate_single_or_multi_label +from model2vec.model import DEFAULT_MAX_LENGTH from model2vec.train.base import BaseFinetuneable from model2vec.train.utils import DEFAULT_RANDOM_SEED, seed_everything @@ -59,6 +60,7 @@ def __init__( freeze: bool = False, normalize: bool = True, freeze_weights: bool = False, + max_length: int | None = DEFAULT_MAX_LENGTH, ) -> None: """Initialize a standard classifier model.""" # Alias: Follows scikit-learn. Set to dummy classes @@ -77,6 +79,7 @@ def __init__( n_layers=n_layers, normalize=normalize, freeze_weights=freeze_weights, + max_length=max_length, ) @property diff --git a/model2vec/train/similarity.py b/model2vec/train/similarity.py index d2e7068..a98d2a8 100644 --- a/model2vec/train/similarity.py +++ b/model2vec/train/similarity.py @@ -7,6 +7,7 @@ from tokenizers import Tokenizer from torch import nn +from model2vec.model import DEFAULT_MAX_LENGTH from model2vec.train.base import BaseFinetuneable from model2vec.train.utils import DEFAULT_RANDOM_SEED, seed_everything @@ -44,6 +45,7 @@ def __init__( freeze: bool = False, normalize: bool = True, freeze_weights: bool = False, + max_length: int | None = DEFAULT_MAX_LENGTH, ) -> None: """Initialize a standard similarity model.""" super().__init__( @@ -58,6 +60,7 @@ def __init__( n_layers=n_layers, normalize=normalize, freeze_weights=freeze_weights, + max_length=max_length, ) def fit( diff --git a/model2vec/types.py b/model2vec/types.py new file mode 100644 index 0000000..6fcd257 --- /dev/null +++ b/model2vec/types.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any, TypedDict + + +class _UnsetType: + pass + + +_UNSET = _UnsetType() + + +class StaticModelConfig(TypedDict, total=False): + """The metadata config stored alongside a model2vec model, e.g. in `config.json`.""" + + normalize: bool + max_length: int | None + model_type: str + architectures: list[str] + tokenizer_name: str + apply_pca: int | float | str | None + sif_coefficient: float | None + hidden_dim: int + seq_length: int + pooling: str + embedding_dtype: str + vocabulary_quantization: int + head_config: dict[str, Any] diff --git a/tests/integration/data/pretrained/minishlab___potion-base-32m_baseline.json b/tests/integration/data/pretrained/minishlab___potion-base-32m_baseline.json index 8a29850..7bf4a7d 100644 --- a/tests/integration/data/pretrained/minishlab___potion-base-32m_baseline.json +++ b/tests/integration/data/pretrained/minishlab___potion-base-32m_baseline.json @@ -8,6 +8,7 @@ "StaticModel" ], "hidden_dim": 512, + "max_length": 512, "model_type": "model2vec", "normalize": true, "seq_length": 1000000, @@ -21,8 +22,8 @@ "embedding_rows": 63091, "embedding_std": 4.876269, "encoding_speed": { - "sentences_per_second": 119227.47, - "tokens_per_second": 1341309.0 + "sentences_per_second": 96840.92, + "tokens_per_second": 1089460.4 }, "first_tokens": [ "[PAD]", diff --git a/tests/integration/data/pretrained/minishlab___potion-base-8m_baseline.json b/tests/integration/data/pretrained/minishlab___potion-base-8m_baseline.json index 18e7133..60451de 100644 --- a/tests/integration/data/pretrained/minishlab___potion-base-8m_baseline.json +++ b/tests/integration/data/pretrained/minishlab___potion-base-8m_baseline.json @@ -8,6 +8,7 @@ "StaticModel" ], "hidden_dim": 256, + "max_length": 512, "model_type": "model2vec", "normalize": true, "seq_length": 1000000, @@ -21,8 +22,8 @@ "embedding_rows": 29528, "embedding_std": 5.989001, "encoding_speed": { - "sentences_per_second": 133169.33, - "tokens_per_second": 1614678.17 + "sentences_per_second": 129334.78, + "tokens_per_second": 1568184.16 }, "first_tokens": [ "[PAD]", diff --git a/tests/integration/data/pretrained/minishlab___potion-multilingual-128m_baseline.json b/tests/integration/data/pretrained/minishlab___potion-multilingual-128m_baseline.json index dc5fa78..9e95164 100644 --- a/tests/integration/data/pretrained/minishlab___potion-multilingual-128m_baseline.json +++ b/tests/integration/data/pretrained/minishlab___potion-multilingual-128m_baseline.json @@ -8,6 +8,7 @@ "StaticModel" ], "hidden_dim": 256, + "max_length": 512, "model_type": "model2vec", "normalize": true, "seq_length": 1000000, @@ -22,8 +23,8 @@ "embedding_rows": 500353, "embedding_std": 0.857525, "encoding_speed": { - "sentences_per_second": 117518.51, - "tokens_per_second": 1498360.99 + "sentences_per_second": 111130.95, + "tokens_per_second": 1416919.65 }, "first_tokens": [ "[PAD]", @@ -170,7 +171,7 @@ "normalize": true, "token_order_hash": "0b2eac292630557804d59ed065289d80954e3d56d793d0f7c4460fd19ec7cf22", "tokenizer_type": "Unigram", - "unk_token_id": null, + "unk_token_id": 1, "vocabulary_quantization": null }, "model": "minishlab/potion-multilingual-128m" diff --git a/tests/integration/data/pretrained/minishlab___potion-retrieval-32m_baseline.json b/tests/integration/data/pretrained/minishlab___potion-retrieval-32m_baseline.json index d471192..dd0c733 100644 --- a/tests/integration/data/pretrained/minishlab___potion-retrieval-32m_baseline.json +++ b/tests/integration/data/pretrained/minishlab___potion-retrieval-32m_baseline.json @@ -8,6 +8,7 @@ "StaticModel" ], "hidden_dim": 512, + "max_length": 512, "model_type": "model2vec", "normalize": true, "seq_length": 1000000, @@ -21,8 +22,8 @@ "embedding_rows": 63091, "embedding_std": 5.912311, "encoding_speed": { - "sentences_per_second": 120182.5, - "tokens_per_second": 1352053.17 + "sentences_per_second": 105162.27, + "tokens_per_second": 1183075.49 }, "first_tokens": [ "[PAD]", diff --git a/tests/test_export_to_onnx.py b/tests/test_export_to_onnx.py index 22f4d76..4feed2d 100644 --- a/tests/test_export_to_onnx.py +++ b/tests/test_export_to_onnx.py @@ -17,6 +17,7 @@ from model2vec import StaticModel from model2vec.inference import StaticModelPipeline from model2vec.inference.mlp import Activation +from model2vec.model import DEFAULT_MAX_LENGTH from model2vec.onnx import ( TorchStaticModel, TorchStaticModelPipeline, @@ -116,6 +117,19 @@ def test_resolve_pad_token_id_falls_back_to_unk_when_no_pad_registered() -> None assert _resolve_pad_token_id(tokenizer, tokenizer_model) == tokenizer.token_to_id("[UNK]") +def test_resolve_pad_token_id_falls_back_to_zero_when_no_pad_or_unk() -> None: + """With no registered pad token, no literal "[PAD]" entry, and no unk token, fall back to id 0.""" + vocab = ["!", "hello", "world"] + tokenizer = Tokenizer(BPE(vocab={t: i for i, t in enumerate(vocab)}, merges=[])) + tokenizer.pre_tokenizer = Whitespace() # type: ignore[assignment] + tokenizer_model = TokenizerModel.from_tokenizer(tokenizer) + assert tokenizer_model.pad_token_id is None + assert tokenizer.token_to_id("[PAD]") is None + assert tokenizer_model.unk_token_id is None + + assert _resolve_pad_token_id(tokenizer, tokenizer_model) == 0 + + def test_pipeline_onnx_matches_projector( mock_inference_pipeline_projector: StaticModelPipeline, tmp_path: Path ) -> None: @@ -139,7 +153,9 @@ def test_save_tokenizer_and_config_removes_post_processor_by_default( tokenizer_model = TokenizerModel.from_tokenizer(mock_static_model.tokenizer) assert tokenizer_model.post_processor is not None - _save_tokenizer_and_config(mock_static_model.tokenizer, tmp_path, remove_post_processor=True) + _save_tokenizer_and_config( + mock_static_model.tokenizer, tmp_path, remove_post_processor=True, max_length=mock_static_model.max_length + ) saved_tokenizer = AutoTokenizer.from_pretrained(tmp_path) with_special = saved_tokenizer("hello", add_special_tokens=True)["input_ids"] @@ -154,7 +170,9 @@ def test_save_tokenizer_and_config_keeps_post_processor_when_disabled( tokenizer_model = TokenizerModel.from_tokenizer(mock_static_model.tokenizer) assert tokenizer_model.post_processor is not None - _save_tokenizer_and_config(mock_static_model.tokenizer, tmp_path, remove_post_processor=False) + _save_tokenizer_and_config( + mock_static_model.tokenizer, tmp_path, remove_post_processor=False, max_length=mock_static_model.max_length + ) saved_tokenizer = AutoTokenizer.from_pretrained(tmp_path) with_special = saved_tokenizer("hello", add_special_tokens=True)["input_ids"] @@ -167,7 +185,9 @@ def test_save_tokenizer_and_config_warns_when_removing_post_processor( ) -> None: """A warning is logged when a post processor is actually present and removed.""" with caplog.at_level(logging.WARNING, logger="model2vec.onnx"): - _save_tokenizer_and_config(mock_static_model.tokenizer, tmp_path, remove_post_processor=True) + _save_tokenizer_and_config( + mock_static_model.tokenizer, tmp_path, remove_post_processor=True, max_length=mock_static_model.max_length + ) assert "removing a post processor" in caplog.text @@ -182,11 +202,24 @@ def test_save_tokenizer_and_config_no_warning_without_post_processor( assert TokenizerModel.from_tokenizer(tokenizer).post_processor is None with caplog.at_level(logging.WARNING, logger="model2vec.onnx"): - _save_tokenizer_and_config(tokenizer, tmp_path, remove_post_processor=True) + _save_tokenizer_and_config(tokenizer, tmp_path, remove_post_processor=True, max_length=512) assert "removing a post processor" not in caplog.text +def test_save_tokenizer_and_config_defaults_max_length_when_none( + mock_static_model: StaticModel, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A `None` max_length is warned about and replaced with `DEFAULT_MAX_LENGTH` in the saved tokenizer.""" + with caplog.at_level(logging.WARNING, logger="model2vec.onnx"): + _save_tokenizer_and_config(mock_static_model.tokenizer, tmp_path, remove_post_processor=True, max_length=None) + + assert "no max length" in caplog.text + + saved_tokenizer = AutoTokenizer.from_pretrained(tmp_path) + assert saved_tokenizer.model_max_length == DEFAULT_MAX_LENGTH + + def test_export_model_to_onnx_remove_post_processor_default_true( mock_static_model: StaticModel, tmp_path: Path ) -> None: diff --git a/tests/test_model.py b/tests/test_model.py index 0d4285b..de999c4 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -9,11 +9,13 @@ def test_initialization(mock_vectors: np.ndarray, mock_tokenizer: Tokenizer, mock_config: dict[str, str]) -> None: """Test successful initialization of StaticModel.""" + original_config = dict(mock_config) model = StaticModel(vectors=mock_vectors, tokenizer=mock_tokenizer, config=mock_config) assert model.embedding.shape == (5, 2) assert len(model.tokens) == 5 assert model.tokenizer == mock_tokenizer - assert model.config == mock_config + assert model.config == {**original_config, "normalize": False, "max_length": 512} + assert mock_config == original_config def test_initialization_token_vector_mismatch(mock_tokenizer: Tokenizer, mock_config: dict[str, str]) -> None: @@ -26,22 +28,9 @@ def test_initialization_token_vector_mismatch(mock_tokenizer: Tokenizer, mock_co def test_tokenize(mock_vectors: np.ndarray, mock_tokenizer: Tokenizer, mock_config: dict[str, str]) -> None: """Test tokenization of a sentence.""" model = StaticModel(vectors=mock_vectors, tokenizer=mock_tokenizer, config=mock_config) - model._can_encode_fast = True - tokens_fast = model.tokenize(["word1 word2"]) - model._can_encode_fast = False - tokens_slow = model.tokenize(["word1 word2"]) + tokens = model.tokenize(["word1 word2"]) - assert tokens_fast == tokens_slow - - -def test_encode_batch_fast( - mock_vectors: np.ndarray, mock_berttokenizer: Tokenizer, mock_config: dict[str, str] -) -> None: - """Test tokenization of a sentence.""" - if hasattr(mock_berttokenizer, "encode_batch_fast"): - del mock_berttokenizer.encode_batch_fast - model = StaticModel(vectors=mock_vectors, tokenizer=mock_berttokenizer, config=mock_config) - assert not model._can_encode_fast + assert tokens def test_encode_single_sentence( @@ -303,9 +292,17 @@ def test_set_normalize(mock_vectors: np.ndarray, mock_tokenizer: Tokenizer) -> N """Tests whether the normalize is set correctly.""" model = StaticModel(mock_vectors, mock_tokenizer, {}, normalize=True) model.normalize = False - assert model.config == {"normalize": False} + assert model.config == {"normalize": False, "max_length": 512} model.normalize = True - assert model.config == {"normalize": True} + assert model.config == {"normalize": True, "max_length": 512} + + +def test_set_max_length(mock_vectors: np.ndarray, mock_tokenizer: Tokenizer) -> None: + """Tests whether the max_length is set correctly.""" + model = StaticModel(mock_vectors, mock_tokenizer, {}, max_length=128) + assert model.config == {"normalize": False, "max_length": 128} + model.max_length = 256 + assert model.config == {"normalize": False, "max_length": 256} def test_dim(mock_vectors: np.ndarray, mock_tokenizer: Tokenizer, mock_config: dict[str, str]) -> None: