Sentence Similarity
sentence-transformers
ONNX
Safetensors
OpenVINO
static-embedding
embeddings
feature-extraction
matryoshka
retrieval
Instructions to use hotchpotch/bekko-static-embedding-v1 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- sentence-transformers
How to use hotchpotch/bekko-static-embedding-v1 with sentence-transformers:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("hotchpotch/bekko-static-embedding-v1") sentences = [ "The weather is lovely today.", "It's so sunny outside!", "He drove to the stadium." ] embeddings = model.encode(sentences) similarities = model.similarity(embeddings, embeddings) print(similarities.shape) # [3, 3] - Notebooks
- Google Colab
- Kaggle
Download training_scripts/train_model.py from hotchpotch/bekko-static-embedding-v1: direct link, hf CLI and curl.
- Browser
- Download file 139 kB
-
https://huggingface.co/hotchpotch/bekko-static-embedding-v1/resolve/main/training_scripts/train_model.py
- Command line
-
hf download hf://hotchpotch/bekko-static-embedding-v1/training_scripts/train_model.py
-
curl -L -o train_model.py https://huggingface.co/hotchpotch/bekko-static-embedding-v1/resolve/main/training_scripts/train_model.py
139 kB
| #!/usr/bin/env -S uv run python | |
| """Reproduce the Bekko Static Embedding Multilingual v1 model. | |
| What this script does | |
| --------------------- | |
| This is the standalone production trainer for v1. From a YAML recipe, it | |
| validates and materializes selected rows from the pinned public Bekko | |
| unsupervised dataset, builds deterministic locality-aware batches, and trains a | |
| mean-pooled static embedding table with Multiple Negatives Ranking Loss (MNRL) | |
| and Matryoshka Representation Learning. It runs sampled HAKARI reranking | |
| evaluations during training, saves the final SentenceTransformers artifact, raises only the final | |
| tokenizer ceiling to the configured 32,768 tokens, and can launch the full | |
| HAKARI evaluation after training. | |
| How to run it | |
| ------------- | |
| Install the dependencies listed in the repository's v1 reproduction guide, | |
| prepare the YAML and data-selection JSON, then run:: | |
| python train_model.py --config path/to/v1-reproduction.yaml | |
| Run ``python train_model.py --help`` for materialization-only and sharded | |
| materialization options. The YAML must use portable paths chosen by the person | |
| running the recipe. Full evaluation requires a checkout of the public | |
| ``hakari-bench`` repository referenced by ``evaluation.hakari_repo``. | |
| Reproducing v1 | |
| -------------- | |
| First run ``train_tokenizer.py`` and point ``model.tokenizer_model`` at its | |
| output. Use the checked-in v1 YAML and data-selection JSON without changing the | |
| model, objective, sampling, or optimizer settings. Machine-specific storage and | |
| HAKARI checkout paths may be changed. The public Bekko ``shared_id`` field is | |
| retained during resumable materialization; no UniR checkout, private dataset, | |
| or project-local Python package is required. Exact commands, dependency pins, | |
| artifact hashes, and completion checks are in the v1 reproduction guide. | |
| """ | |
| from __future__ import annotations | |
| # ============================================================================== | |
| # Configuration | |
| # ============================================================================== | |
| import math | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any, Literal, cast | |
| import yaml | |
| class RunSettings: | |
| mode: Literal["smoke", "search", "full"] | |
| name: str | |
| output_dir: Path | |
| class ModelSettings: | |
| tokenizer_model: str | |
| text_preprocessing: Literal["none", "porter", "snowball", "lemma"] | |
| embedding_dim: int | |
| max_seq_length: int | |
| final_max_seq_length: int | |
| matryoshka_dims: tuple[int, ...] | |
| matryoshka_weights: tuple[float, ...] | |
| architecture: Literal["static", "sse"] | |
| sse_preprocessing: Literal["official_tokenize", "sentence_transformers"] | |
| sse_alpha_init: float | |
| sse_train_alpha: bool | |
| sse_train_beta: bool | |
| sse_train_bias: bool | |
| class SupplementalDataSettings: | |
| """A separately sampled Hugging Face component with auditable provenance. | |
| ``provenance_note`` is mandatory because released rows can be sampled or | |
| expanded instances rather than independent, query-unique observations. | |
| """ | |
| name: str | |
| dataset: str | |
| config: str | |
| revision: str | |
| split: str | |
| task: Literal["pair", "triplet"] | |
| pair_type: Literal["query_doc", "doc_doc"] | |
| fraction: float | |
| provenance_note: str | |
| query_column: str = "query" | |
| positive_column: str = "pos" | |
| negative_column: str = "neg" | |
| batch_deduplication: Literal["text", "model_input"] = "text" | |
| fingerprint_cache: Path | None = None | |
| class DataSettings: | |
| spec: Path | |
| fraction: float | |
| seed: int | |
| tasks: tuple[str, ...] | |
| require_cache: bool | |
| shared_id_column: str | |
| shared_id_block_size: int | |
| supplements: tuple[SupplementalDataSettings, ...] | |
| stage_cache_root: Path | None = None | |
| require_stage_cache: bool = False | |
| direct_source_cache: bool = False | |
| materialize_workers: int = 1 | |
| materialize_max_attempts: int = 3 | |
| materialize_retry_delay_seconds: float = 30.0 | |
| class TrainingSettings: | |
| epochs: float | |
| batch_size: int | |
| eval_batch_size: int | |
| learning_rate: float | |
| sse_learning_rate: float | None | |
| adam_beta2: float | |
| adam_epsilon: float | |
| weight_decay: float | |
| warmup_ratio: float | |
| target_evaluations: int | |
| exact_target_evaluations: bool | |
| dataloader_num_workers: int | |
| dataloader_prefetch_factor: int | |
| dataloader_locality_block_size: int | None | |
| save_checkpoints: bool | |
| save_total_limit: int | |
| seed: int | |
| class LossSettings: | |
| name: Literal["mnrl", "qwen3_improved_infonce"] | |
| temperature: float | |
| margin: float | None | |
| hard_negative_margin: float | None | |
| pair_type_routing: Literal["shared", "split"] | |
| doc_doc_bidirectional: bool | |
| query_query_weight: float | |
| document_document_weight: float | |
| cross_document_weight: float | |
| hard_negative_weight: float | |
| qat_int8_weight: float | |
| qat_binary_weight: float | |
| qat_n_quantized_per_step: int | |
| class EvaluationTargetSettings: | |
| dataset: str | None | |
| collection: str | None | |
| splits: tuple[str, ...] | |
| query_limit: int | |
| class EvaluationSettings: | |
| hakari_repo: Path | |
| batch_size: int | |
| query_limit: int | |
| query_seed: int | |
| rerank_top_k: int | |
| candidate_ranking: str | |
| targets: tuple[EvaluationTargetSettings, ...] | |
| full_after_training: bool | |
| full_all: bool | |
| full_results_dir: Path | None | |
| full_model_alias: str | None | |
| full_dtype: Literal["bf16", "fp16", "fp32"] | |
| full_device: str | |
| full_retrieval_score_device: Literal["auto", "cpu", "cuda"] | |
| full_result_format: Literal["json.xz", "json"] | |
| full_overwrite: bool | |
| class TrackingSettings: | |
| enabled: bool | |
| project: str | |
| class TrainConfig: | |
| run: RunSettings | |
| model: ModelSettings | |
| data: DataSettings | |
| training: TrainingSettings | |
| loss: LossSettings | |
| evaluation: EvaluationSettings | |
| tracking: TrackingSettings | |
| source_path: Path | |
| def _literal_choice(value: object, choices: set[str], name: str) -> Any: | |
| text = str(value) | |
| if text not in choices: | |
| expected = ", ".join(sorted(choices)) | |
| raise ValueError(f"{name} must be one of {expected}; got {text!r}") | |
| return text | |
| def load_config(path: Path | str) -> TrainConfig: | |
| source_path = Path(path).resolve() | |
| with source_path.open(encoding="utf-8") as handle: | |
| raw = yaml.safe_load(handle) | |
| if not isinstance(raw, dict): | |
| raise TypeError("Configuration root must be a mapping") | |
| run = _mapping(raw, "run") | |
| model = _mapping(raw, "model") | |
| data = _mapping(raw, "data") | |
| training = _mapping(raw, "training") | |
| evaluation = _mapping(raw, "evaluation") | |
| tracking = _mapping(raw, "tracking") | |
| loss = raw.get("loss", {}) | |
| if not isinstance(loss, dict): | |
| raise TypeError("Configuration section 'loss' must be a mapping") | |
| mode = str(run["mode"]) | |
| if mode not in {"smoke", "search", "full"}: | |
| raise ValueError(f"Unsupported run mode: {mode}") | |
| fraction = float(data["fraction"]) | |
| if not 0 < fraction <= 1: | |
| raise ValueError(f"data.fraction must be in (0, 1], got {fraction}") | |
| batch_size = int(training["batch_size"]) | |
| if batch_size <= 0: | |
| raise ValueError("training.batch_size must be positive") | |
| materialize_workers = int(data.get("materialize_workers", 1)) | |
| if materialize_workers <= 0: | |
| raise ValueError("data.materialize_workers must be positive") | |
| materialize_max_attempts = int(data.get("materialize_max_attempts", 3)) | |
| if materialize_max_attempts <= 0: | |
| raise ValueError("data.materialize_max_attempts must be positive") | |
| materialize_retry_delay_seconds = float( | |
| data.get("materialize_retry_delay_seconds", 30.0) | |
| ) | |
| if materialize_retry_delay_seconds < 0: | |
| raise ValueError("data.materialize_retry_delay_seconds must be non-negative") | |
| tracking_enabled = bool(tracking.get("enabled", mode != "smoke")) | |
| if mode == "smoke": | |
| tracking_enabled = False | |
| return TrainConfig( | |
| run=RunSettings( | |
| mode=mode, # type: ignore[arg-type] | |
| name=str(run["name"]), | |
| output_dir=Path(run["output_dir"]), | |
| ), | |
| model=_load_model_settings(model), | |
| data=DataSettings( | |
| spec=Path(data["spec"]), | |
| fraction=fraction, | |
| seed=int(data.get("seed", 42)), | |
| tasks=tuple( | |
| str(value) | |
| for value in data.get("tasks", ["pair", "triplet", "fw2ir-triplet"]) | |
| ), | |
| require_cache=bool(data.get("require_cache", True)), | |
| shared_id_column=str(data.get("shared_id_column", "shared_id")), | |
| shared_id_block_size=int(data.get("shared_id_block_size", 8192)), | |
| supplements=_load_supplemental_data_settings(data), | |
| stage_cache_root=( | |
| Path(str(data["stage_cache_root"])).expanduser() | |
| if data.get("stage_cache_root") is not None | |
| else None | |
| ), | |
| require_stage_cache=bool(data.get("require_stage_cache", False)), | |
| direct_source_cache=bool(data.get("direct_source_cache", False)), | |
| materialize_workers=materialize_workers, | |
| materialize_max_attempts=materialize_max_attempts, | |
| materialize_retry_delay_seconds=materialize_retry_delay_seconds, | |
| ), | |
| training=_load_training_settings(training, batch_size=batch_size), | |
| loss=_load_loss_settings(loss), | |
| evaluation=EvaluationSettings( | |
| hakari_repo=Path(evaluation["hakari_repo"]), | |
| batch_size=int(evaluation.get("batch_size", 1024)), | |
| query_limit=int(evaluation.get("query_limit", 10)), | |
| query_seed=int(evaluation.get("query_seed", 13)), | |
| rerank_top_k=int(evaluation.get("rerank_top_k", 100)), | |
| candidate_ranking=str( | |
| evaluation.get("candidate_ranking", "reranking_hybrid") | |
| ), | |
| targets=_load_evaluation_targets(evaluation), | |
| full_after_training=bool(evaluation.get("full_after_training", False)), | |
| full_all=bool(evaluation.get("full_all", False)), | |
| full_results_dir=( | |
| Path(str(evaluation["full_results_dir"])).expanduser() | |
| if evaluation.get("full_results_dir") is not None | |
| else None | |
| ), | |
| full_model_alias=( | |
| str(evaluation["full_model_alias"]) | |
| if evaluation.get("full_model_alias") is not None | |
| else None | |
| ), | |
| full_dtype=_literal_choice( | |
| evaluation.get("full_dtype", "bf16"), | |
| {"bf16", "fp16", "fp32"}, | |
| "evaluation.full_dtype", | |
| ), | |
| full_device=str(evaluation.get("full_device", "cuda:0")), | |
| full_retrieval_score_device=_literal_choice( | |
| evaluation.get("full_retrieval_score_device", "cpu"), | |
| {"auto", "cpu", "cuda"}, | |
| "evaluation.full_retrieval_score_device", | |
| ), | |
| full_result_format=_literal_choice( | |
| evaluation.get("full_result_format", "json.xz"), | |
| {"json.xz", "json"}, | |
| "evaluation.full_result_format", | |
| ), | |
| full_overwrite=bool(evaluation.get("full_overwrite", False)), | |
| ), | |
| tracking=TrackingSettings( | |
| enabled=tracking_enabled, | |
| project=str(tracking.get("project", "bekko-static-embedding")), | |
| ), | |
| source_path=source_path, | |
| ) | |
| def _load_evaluation_targets( | |
| evaluation: dict[str, Any], | |
| ) -> tuple[EvaluationTargetSettings, ...]: | |
| raw_targets = evaluation.get("targets", []) | |
| if not isinstance(raw_targets, list): | |
| raise TypeError("evaluation.targets must be a list") | |
| targets: list[EvaluationTargetSettings] = [] | |
| default_query_limit = int(evaluation.get("query_limit", 10)) | |
| for index, raw in enumerate(raw_targets): | |
| if not isinstance(raw, dict): | |
| raise TypeError(f"evaluation.targets[{index}] must be a mapping") | |
| target = cast(dict[str, Any], raw) | |
| dataset = str(target["dataset"]) if target.get("dataset") is not None else None | |
| collection = ( | |
| str(target["collection"]) if target.get("collection") is not None else None | |
| ) | |
| if (dataset is None) == (collection is None): | |
| raise ValueError( | |
| f"evaluation.targets[{index}] requires exactly one of dataset or collection" | |
| ) | |
| splits_raw = target.get("splits", []) | |
| if not isinstance(splits_raw, list): | |
| raise TypeError(f"evaluation.targets[{index}].splits must be a list") | |
| query_limit = int(target.get("query_limit", default_query_limit)) | |
| if query_limit <= 0: | |
| raise ValueError( | |
| f"evaluation.targets[{index}].query_limit must be positive" | |
| ) | |
| targets.append( | |
| EvaluationTargetSettings( | |
| dataset=dataset, | |
| collection=collection, | |
| splits=tuple(str(value) for value in splits_raw), | |
| query_limit=query_limit, | |
| ) | |
| ) | |
| return tuple(targets) | |
| def _load_supplemental_data_settings( | |
| data: dict[str, Any], | |
| ) -> tuple[SupplementalDataSettings, ...]: | |
| raw_supplements = data.get("supplements", []) | |
| if not isinstance(raw_supplements, list): | |
| raise TypeError("data.supplements must be a list") | |
| supplements: list[SupplementalDataSettings] = [] | |
| names: set[str] = set() | |
| for index, raw in enumerate(raw_supplements): | |
| if not isinstance(raw, dict): | |
| raise TypeError(f"data.supplements[{index}] must be a mapping") | |
| raw_mapping = cast(dict[str, Any], raw) | |
| name = str(raw_mapping.get("name", "")).strip() | |
| if not name: | |
| raise ValueError(f"data.supplements[{index}].name must not be empty") | |
| if name in names: | |
| raise ValueError(f"duplicate supplemental data name: {name}") | |
| names.add(name) | |
| task = str(raw_mapping.get("task", "")) | |
| if task not in {"pair", "triplet"}: | |
| raise ValueError(f"Unsupported supplemental task: {task}") | |
| pair_type = str(raw_mapping.get("pair_type", "")) | |
| if pair_type not in {"query_doc", "doc_doc"}: | |
| raise ValueError(f"Unsupported supplemental pair_type: {pair_type}") | |
| fraction = float(raw_mapping.get("fraction", 1.0)) | |
| if not 0 < fraction <= 1: | |
| raise ValueError( | |
| f"data.supplements[{index}].fraction must be in (0, 1], got {fraction}" | |
| ) | |
| provenance_note = str(raw_mapping.get("provenance_note", "")).strip() | |
| if not provenance_note: | |
| raise ValueError( | |
| f"data.supplements[{index}].provenance_note must not be empty" | |
| ) | |
| revision = str(raw_mapping.get("revision", "")).strip() | |
| if not revision: | |
| raise ValueError(f"data.supplements[{index}].revision must not be empty") | |
| batch_deduplication = str(raw_mapping.get("batch_deduplication", "text")) | |
| if batch_deduplication not in {"text", "model_input"}: | |
| raise ValueError( | |
| "data.supplements" | |
| f"[{index}].batch_deduplication must be text or model_input" | |
| ) | |
| fingerprint_cache_raw = raw_mapping.get("fingerprint_cache") | |
| supplements.append( | |
| SupplementalDataSettings( | |
| name=name, | |
| dataset=str(raw_mapping["dataset"]), | |
| config=str(raw_mapping["config"]), | |
| revision=revision, | |
| split=str(raw_mapping.get("split", "train")), | |
| task=task, # type: ignore[arg-type] | |
| pair_type=pair_type, # type: ignore[arg-type] | |
| fraction=fraction, | |
| provenance_note=provenance_note, | |
| query_column=str(raw_mapping.get("query_column", "query")), | |
| positive_column=str(raw_mapping.get("positive_column", "pos")), | |
| negative_column=str(raw_mapping.get("negative_column", "neg")), | |
| batch_deduplication=batch_deduplication, # type: ignore[arg-type] | |
| fingerprint_cache=( | |
| Path(str(fingerprint_cache_raw)) | |
| if fingerprint_cache_raw is not None | |
| else None | |
| ), | |
| ) | |
| ) | |
| return tuple(supplements) | |
| def _load_loss_settings(raw: dict[str, Any]) -> LossSettings: | |
| name = str(raw.get("name", "mnrl")) | |
| if name not in {"mnrl", "qwen3_improved_infonce"}: | |
| raise ValueError(f"Unsupported loss.name: {name}") | |
| temperature = float(raw.get("temperature", 0.01)) | |
| if temperature <= 0: | |
| raise ValueError("loss.temperature must be positive") | |
| margin_raw = raw.get("margin", 0.1) | |
| hard_margin_raw = raw.get("hard_negative_margin") | |
| pair_type_routing = str(raw.get("pair_type_routing", "shared")) | |
| if pair_type_routing not in {"shared", "split"}: | |
| raise ValueError(f"Unsupported loss.pair_type_routing: {pair_type_routing}") | |
| weights = { | |
| name: float(raw.get(name, 1.0)) | |
| for name in ( | |
| "query_query_weight", | |
| "document_document_weight", | |
| "cross_document_weight", | |
| "hard_negative_weight", | |
| ) | |
| } | |
| for weight_name, value in weights.items(): | |
| if not math.isfinite(value) or value < 0: | |
| raise ValueError(f"loss.{weight_name} must be finite and non-negative") | |
| qat_weights = { | |
| name: float(raw.get(name, 0.0)) | |
| for name in ("qat_int8_weight", "qat_binary_weight") | |
| } | |
| for weight_name, value in qat_weights.items(): | |
| if not math.isfinite(value) or value < 0: | |
| raise ValueError(f"loss.{weight_name} must be finite and non-negative") | |
| qat_n_quantized_per_step = int(raw.get("qat_n_quantized_per_step", -1)) | |
| if qat_n_quantized_per_step not in {-1, 1, 2}: | |
| raise ValueError("loss.qat_n_quantized_per_step must be -1, 1, or 2") | |
| return LossSettings( | |
| name=name, # type: ignore[arg-type] | |
| temperature=temperature, | |
| margin=None if margin_raw is None else float(margin_raw), | |
| hard_negative_margin=None | |
| if hard_margin_raw is None | |
| else float(hard_margin_raw), | |
| pair_type_routing=pair_type_routing, # type: ignore[arg-type] | |
| doc_doc_bidirectional=bool(raw.get("doc_doc_bidirectional", False)), | |
| **weights, | |
| **qat_weights, | |
| qat_n_quantized_per_step=qat_n_quantized_per_step, | |
| ) | |
| def _load_model_settings(raw: dict[str, Any]) -> ModelSettings: | |
| embedding_dim = int(raw.get("embedding_dim", 1024)) | |
| max_seq_length = int(raw.get("max_seq_length", 512)) | |
| final_max_seq_length = int(raw.get("final_max_seq_length", max_seq_length)) | |
| architecture = str(raw.get("architecture", "static")) | |
| if architecture not in {"static", "sse"}: | |
| raise ValueError(f"Unsupported model.architecture: {architecture}") | |
| sse_preprocessing = str(raw.get("sse_preprocessing", "sentence_transformers")) | |
| if sse_preprocessing not in {"official_tokenize", "sentence_transformers"}: | |
| raise ValueError(f"Unsupported model.sse_preprocessing: {sse_preprocessing}") | |
| sse_alpha_init = float(raw.get("sse_alpha_init", 0.5)) | |
| text_preprocessing = str(raw.get("text_preprocessing", "none")) | |
| if text_preprocessing not in {"none", "porter", "snowball", "lemma"}: | |
| raise ValueError(f"Unsupported model.text_preprocessing: {text_preprocessing}") | |
| if architecture == "sse" and text_preprocessing != "none": | |
| raise ValueError( | |
| "model.text_preprocessing is supported only for static architecture" | |
| ) | |
| if not math.isfinite(sse_alpha_init) or sse_alpha_init <= 0: | |
| raise ValueError("model.sse_alpha_init must be finite and positive") | |
| dims = tuple(int(value) for value in raw.get("matryoshka_dims", [embedding_dim])) | |
| weights = tuple( | |
| float(value) for value in raw.get("matryoshka_weights", [1.0] * len(dims)) | |
| ) | |
| if embedding_dim <= 0: | |
| raise ValueError("model.embedding_dim must be positive") | |
| if max_seq_length <= 0: | |
| raise ValueError("model.max_seq_length must be positive") | |
| if final_max_seq_length < max_seq_length: | |
| raise ValueError( | |
| "model.final_max_seq_length must be at least model.max_seq_length" | |
| ) | |
| if any(value <= 0 or value > embedding_dim for value in dims): | |
| raise ValueError( | |
| "model.matryoshka_dims must be positive and no larger than embedding_dim" | |
| ) | |
| is_ascending = tuple(sorted(set(dims))) == dims | |
| is_descending = tuple(sorted(set(dims), reverse=True)) == dims | |
| if not (is_ascending or is_descending): | |
| raise ValueError("model.matryoshka_dims must be unique and strictly monotonic") | |
| if len(weights) != len(dims): | |
| raise ValueError("model.matryoshka_weights must match model.matryoshka_dims") | |
| if any(not math.isfinite(value) or value < 0 for value in weights): | |
| raise ValueError("model.matryoshka_weights must be finite and non-negative") | |
| if weights and weights[0] <= 0: | |
| raise ValueError("the largest Matryoshka dimension must have positive weight") | |
| return ModelSettings( | |
| tokenizer_model=str(raw["tokenizer_model"]), | |
| text_preprocessing=text_preprocessing, # type: ignore[arg-type] | |
| embedding_dim=embedding_dim, | |
| max_seq_length=max_seq_length, | |
| final_max_seq_length=final_max_seq_length, | |
| matryoshka_dims=dims, | |
| matryoshka_weights=weights, | |
| architecture=architecture, # type: ignore[arg-type] | |
| sse_preprocessing=sse_preprocessing, # type: ignore[arg-type] | |
| sse_alpha_init=sse_alpha_init, | |
| sse_train_alpha=bool(raw.get("sse_train_alpha", True)), | |
| sse_train_beta=bool(raw.get("sse_train_beta", True)), | |
| sse_train_bias=bool(raw.get("sse_train_bias", True)), | |
| ) | |
| def _load_training_settings( | |
| raw: dict[str, Any], *, batch_size: int | |
| ) -> TrainingSettings: | |
| learning_rate = float(raw["learning_rate"]) | |
| sse_learning_rate_raw = raw.get("sse_learning_rate") | |
| sse_learning_rate = ( | |
| None if sse_learning_rate_raw is None else float(sse_learning_rate_raw) | |
| ) | |
| adam_beta2 = float(raw.get("adam_beta2", 0.999)) | |
| adam_epsilon = float(raw.get("adam_epsilon", 1e-8)) | |
| weight_decay = float(raw.get("weight_decay", 0.0)) | |
| locality_raw = raw.get("dataloader_locality_block_size") | |
| locality_block_size = None if locality_raw is None else int(locality_raw) | |
| if not math.isfinite(learning_rate) or learning_rate <= 0: | |
| raise ValueError("training.learning_rate must be finite and positive") | |
| if sse_learning_rate is not None and ( | |
| not math.isfinite(sse_learning_rate) or sse_learning_rate < 0 | |
| ): | |
| raise ValueError("training.sse_learning_rate must be finite and non-negative") | |
| if not math.isfinite(adam_beta2) or not 0 < adam_beta2 < 1: | |
| raise ValueError("training.adam_beta2 must be finite and in (0, 1)") | |
| if not math.isfinite(adam_epsilon) or adam_epsilon <= 0: | |
| raise ValueError("training.adam_epsilon must be finite and positive") | |
| if not math.isfinite(weight_decay) or weight_decay < 0: | |
| raise ValueError("training.weight_decay must be finite and non-negative") | |
| if locality_block_size is not None and locality_block_size < batch_size: | |
| raise ValueError( | |
| "training.dataloader_locality_block_size must be at least batch_size" | |
| ) | |
| return TrainingSettings( | |
| epochs=float(raw.get("epochs", 1)), | |
| batch_size=batch_size, | |
| eval_batch_size=int(raw.get("eval_batch_size", 2048)), | |
| learning_rate=learning_rate, | |
| sse_learning_rate=sse_learning_rate, | |
| adam_beta2=adam_beta2, | |
| adam_epsilon=adam_epsilon, | |
| weight_decay=weight_decay, | |
| warmup_ratio=float(raw.get("warmup_ratio", 0.1)), | |
| target_evaluations=int(raw["target_evaluations"]), | |
| exact_target_evaluations=bool(raw.get("exact_target_evaluations", False)), | |
| dataloader_num_workers=int(raw.get("dataloader_num_workers", 15)), | |
| dataloader_prefetch_factor=int(raw.get("dataloader_prefetch_factor", 4)), | |
| dataloader_locality_block_size=locality_block_size, | |
| save_checkpoints=bool(raw.get("save_checkpoints", True)), | |
| save_total_limit=int(raw.get("save_total_limit", 3)), | |
| seed=int(raw.get("seed", 42)), | |
| ) | |
| def _mapping(raw: dict[str, Any], key: str) -> dict[str, Any]: | |
| value = raw.get(key) | |
| if not isinstance(value, dict): | |
| raise TypeError(f"Configuration section {key!r} must be a mapping") | |
| return value | |
| # ============================================================================== | |
| # Text preprocessing | |
| # ============================================================================== | |
| import json | |
| import re | |
| from functools import lru_cache | |
| from typing import Self | |
| import numpy as np | |
| import simplemma | |
| import torch | |
| from nltk.stem import PorterStemmer | |
| from nltk.stem.snowball import EnglishStemmer | |
| from sentence_transformers.sentence_transformer.modules import StaticEmbedding | |
| from tokenizers import Tokenizer | |
| from transformers import PreTrainedTokenizerFast | |
| TextPreprocessing = Literal["none", "porter", "snowball", "lemma"] | |
| _WORD_RE = re.compile(r"[^\W\d_]+", flags=re.UNICODE) | |
| _PORTER = PorterStemmer() | |
| _SNOWBALL = EnglishStemmer() | |
| def _restore_case(source: str, transformed: str) -> str: | |
| if source.isupper(): | |
| return transformed.upper() | |
| if source[:1].isupper() and source[1:].islower(): | |
| return transformed[:1].upper() + transformed[1:] | |
| return transformed | |
| def _porter_word(word: str) -> str: | |
| return _restore_case(word, _PORTER.stem(word.lower(), to_lowercase=False)) | |
| def _snowball_word(word: str) -> str: | |
| return _restore_case(word, _SNOWBALL.stem(word.lower())) | |
| def _lemma_word(word: str) -> str: | |
| return _restore_case(word, simplemma.lemmatize(word.lower(), lang="en")) | |
| def preprocess_text(text: str, mode: TextPreprocessing) -> str: | |
| if mode == "none": | |
| return text | |
| if mode == "porter": | |
| transform = _porter_word | |
| elif mode == "snowball": | |
| transform = _snowball_word | |
| elif mode == "lemma": | |
| transform = _lemma_word | |
| else: | |
| raise ValueError(f"Unsupported text preprocessing mode: {mode}") | |
| return _WORD_RE.sub(lambda match: transform(match.group(0)), text) | |
| class PreprocessedStaticEmbedding(StaticEmbedding): | |
| """StaticEmbedding with a serialized lexical preprocessing step.""" | |
| config_filename = "text_preprocessing_config.json" | |
| def __init__( | |
| self, | |
| tokenizer: Tokenizer | PreTrainedTokenizerFast, | |
| embedding_weights: np.ndarray | torch.Tensor | None = None, | |
| embedding_dim: int | None = None, | |
| *, | |
| text_preprocessing: TextPreprocessing, | |
| **kwargs: Any, | |
| ) -> None: | |
| if text_preprocessing not in {"porter", "snowball", "lemma"}: | |
| raise ValueError( | |
| "PreprocessedStaticEmbedding requires porter, snowball, or lemma preprocessing" | |
| ) | |
| super().__init__( | |
| tokenizer, | |
| embedding_weights=embedding_weights, | |
| embedding_dim=embedding_dim, | |
| **kwargs, | |
| ) | |
| self.text_preprocessing = text_preprocessing | |
| def preprocess( | |
| self, | |
| inputs: list[str], | |
| prompt: str | None = None, | |
| **kwargs: Any, | |
| ) -> dict[str, torch.Tensor]: | |
| transformed = [ | |
| preprocess_text(text, self.text_preprocessing) for text in inputs | |
| ] | |
| return super().preprocess(transformed, prompt=prompt, **kwargs) | |
| def save( | |
| self, | |
| output_path: str, | |
| *args: Any, | |
| safe_serialization: bool = True, | |
| **kwargs: Any, | |
| ) -> None: | |
| super().save( | |
| output_path, | |
| *args, | |
| safe_serialization=safe_serialization, | |
| **kwargs, | |
| ) | |
| (Path(output_path) / self.config_filename).write_text( | |
| json.dumps( | |
| {"text_preprocessing": self.text_preprocessing}, | |
| indent=2, | |
| sort_keys=True, | |
| ) | |
| + "\n", | |
| encoding="utf-8", | |
| ) | |
| def load( | |
| cls, | |
| model_name_or_path: str, | |
| subfolder: str = "", | |
| token: bool | str | None = None, | |
| cache_folder: str | None = None, | |
| revision: str | None = None, | |
| local_files_only: bool = False, | |
| **kwargs: Any, | |
| ) -> Self: | |
| hub_kwargs = { | |
| "subfolder": subfolder, | |
| "token": token, | |
| "cache_folder": cache_folder, | |
| "revision": revision, | |
| "local_files_only": local_files_only, | |
| } | |
| tokenizer_path = cls.load_file_path( | |
| model_name_or_path, filename="tokenizer.json", **hub_kwargs | |
| ) | |
| config_path = cls.load_file_path( | |
| model_name_or_path, filename=cls.config_filename, **hub_kwargs | |
| ) | |
| if tokenizer_path is None or config_path is None: | |
| raise FileNotFoundError( | |
| "PreprocessedStaticEmbedding artifact is incomplete" | |
| ) | |
| tokenizer = Tokenizer.from_file(tokenizer_path) | |
| config = json.loads(Path(config_path).read_text(encoding="utf-8")) | |
| weights = cls.load_torch_weights( | |
| model_name_or_path=model_name_or_path, | |
| subfolder=subfolder, | |
| token=token, | |
| cache_folder=cache_folder, | |
| revision=revision, | |
| local_files_only=local_files_only, | |
| ) | |
| model = cls( | |
| tokenizer, | |
| embedding_weights=weights["embedding.weight"], | |
| text_preprocessing=config["text_preprocessing"], | |
| ) | |
| model.load_state_dict(weights) | |
| return model | |
| # ============================================================================== | |
| # Stable static embedding | |
| # ============================================================================== | |
| from typing import Literal | |
| from sentence_transformers.sentence_transformer.modules import StaticEmbedding | |
| from torch import nn | |
| SSEPreprocessing = Literal["official_tokenize", "sentence_transformers"] | |
| class SeparableDyT(nn.Module): | |
| """Per-dimension Dynamic Tanh from the official SSE implementation.""" | |
| def __init__(self, hidden_dim: int, alpha_init: float = 0.5) -> None: | |
| super().__init__() | |
| self.alpha = nn.Parameter(alpha_init * torch.ones(hidden_dim)) | |
| self.beta = nn.Parameter(torch.ones(hidden_dim)) | |
| self.bias = nn.Parameter(torch.zeros(hidden_dim)) | |
| def forward(self, values: torch.Tensor) -> torch.Tensor: | |
| return self.beta * torch.tanh(self.alpha * values + self.bias) | |
| class StableStaticEmbedding(StaticEmbedding): | |
| """Sentence Transformers StaticEmbedding followed by Separable DyT. | |
| The module exposes both the literal token-flattening path from the official | |
| SSE repository and the current Sentence Transformers ``preprocess`` path. | |
| They are intentionally selectable so upstream API migrations can be tested | |
| without changing the model architecture. | |
| """ | |
| config_filename = "sse_config.json" | |
| def __init__( | |
| self, | |
| tokenizer: Tokenizer | PreTrainedTokenizerFast, | |
| embedding_weights: np.ndarray | torch.Tensor | None = None, | |
| embedding_dim: int | None = None, | |
| *, | |
| preprocessing: SSEPreprocessing = "sentence_transformers", | |
| alpha_init: float = 0.5, | |
| train_alpha: bool = True, | |
| train_beta: bool = True, | |
| train_bias: bool = True, | |
| **kwargs: Any, | |
| ) -> None: | |
| if preprocessing not in {"official_tokenize", "sentence_transformers"}: | |
| raise ValueError(f"Unsupported SSE preprocessing mode: {preprocessing}") | |
| if alpha_init <= 0: | |
| raise ValueError("SSE alpha_init must be positive") | |
| super().__init__( | |
| tokenizer, | |
| embedding_weights=embedding_weights, | |
| embedding_dim=embedding_dim, | |
| **kwargs, | |
| ) | |
| self.preprocessing: SSEPreprocessing = preprocessing | |
| self.alpha_init = float(alpha_init) | |
| self.dyt = SeparableDyT(self.embedding_dim, alpha_init=self.alpha_init) | |
| self.train_alpha = bool(train_alpha) | |
| self.train_beta = bool(train_beta) | |
| self.train_bias = bool(train_bias) | |
| self.dyt.alpha.requires_grad_(self.train_alpha) | |
| self.dyt.beta.requires_grad_(self.train_beta) | |
| self.dyt.bias.requires_grad_(self.train_bias) | |
| def official_tokenize( | |
| self, texts: list[str], **kwargs: Any | |
| ) -> dict[str, torch.Tensor]: | |
| """Literal flat ``input_ids``/``offsets`` algorithm used by official SSE.""" | |
| del kwargs | |
| encodings = self.tokenizer.encode_batch(texts, add_special_tokens=False) | |
| encodings_ids = [encoding.ids for encoding in encodings] | |
| offsets = torch.from_numpy( | |
| np.cumsum([0] + [len(token_ids) for token_ids in encodings_ids[:-1]]) | |
| ) | |
| input_ids = torch.tensor( | |
| [token_id for token_ids in encodings_ids for token_id in token_ids], | |
| dtype=torch.long, | |
| ) | |
| return {"input_ids": input_ids, "offsets": offsets} | |
| def sentence_transformers_preprocess( | |
| self, | |
| inputs: list[str], | |
| prompt: str | None = None, | |
| **kwargs: Any, | |
| ) -> dict[str, torch.Tensor]: | |
| return super().preprocess(inputs, prompt=prompt, **kwargs) | |
| def preprocess( | |
| self, | |
| inputs: list[str], | |
| prompt: str | None = None, | |
| **kwargs: Any, | |
| ) -> dict[str, torch.Tensor]: | |
| if self.preprocessing == "official_tokenize": | |
| if prompt: | |
| raise ValueError( | |
| "The official SSE tokenize path does not support prompts" | |
| ) | |
| return self.official_tokenize(inputs, **kwargs) | |
| return self.sentence_transformers_preprocess(inputs, prompt=prompt, **kwargs) | |
| def tokenize(self, texts: list[str], **kwargs: Any) -> dict[str, torch.Tensor]: | |
| if self.preprocessing == "official_tokenize": | |
| return self.official_tokenize(texts, **kwargs) | |
| return self.sentence_transformers_preprocess(texts, **kwargs) | |
| def forward( | |
| self, | |
| features: dict[str, torch.Tensor], | |
| **kwargs: Any, | |
| ) -> dict[str, torch.Tensor]: | |
| features = super().forward(features, **kwargs) | |
| features["sentence_embedding"] = self.dyt(features["sentence_embedding"]) | |
| return features | |
| def get_sentence_embedding_dimension(self) -> int: | |
| return self.embedding_dim | |
| def save( | |
| self, | |
| output_path: str, | |
| *args: Any, | |
| safe_serialization: bool = True, | |
| **kwargs: Any, | |
| ) -> None: | |
| super().save( | |
| output_path, | |
| *args, | |
| safe_serialization=safe_serialization, | |
| **kwargs, | |
| ) | |
| config = { | |
| "preprocessing": self.preprocessing, | |
| "alpha_init": self.alpha_init, | |
| "train_alpha": self.train_alpha, | |
| "train_beta": self.train_beta, | |
| "train_bias": self.train_bias, | |
| } | |
| (Path(output_path) / self.config_filename).write_text( | |
| json.dumps(config, indent=2, sort_keys=True) + "\n", | |
| encoding="utf-8", | |
| ) | |
| def load( | |
| cls, | |
| model_name_or_path: str, | |
| subfolder: str = "", | |
| token: bool | str | None = None, | |
| cache_folder: str | None = None, | |
| revision: str | None = None, | |
| local_files_only: bool = False, | |
| **kwargs: Any, | |
| ) -> Self: | |
| hub_kwargs = { | |
| "subfolder": subfolder, | |
| "token": token, | |
| "cache_folder": cache_folder, | |
| "revision": revision, | |
| "local_files_only": local_files_only, | |
| } | |
| tokenizer_path = cls.load_file_path( | |
| model_name_or_path, | |
| filename="tokenizer.json", | |
| **hub_kwargs, | |
| ) | |
| if tokenizer_path is None: | |
| raise FileNotFoundError("SSE tokenizer.json was not found") | |
| tokenizer = Tokenizer.from_file(tokenizer_path) | |
| weights = cls.load_torch_weights( | |
| model_name_or_path=model_name_or_path, | |
| subfolder=subfolder, | |
| token=token, | |
| cache_folder=cache_folder, | |
| revision=revision, | |
| local_files_only=local_files_only, | |
| ) | |
| config_path = cls.load_file_path( | |
| model_name_or_path, | |
| filename=cls.config_filename, | |
| **hub_kwargs, | |
| ) | |
| config: dict[str, Any] = {} | |
| if config_path is not None: | |
| config = json.loads(Path(config_path).read_text(encoding="utf-8")) | |
| embedding_weights = weights["embedding.weight"] | |
| model = cls( | |
| tokenizer, | |
| embedding_weights=embedding_weights, | |
| preprocessing=config.get("preprocessing", "sentence_transformers"), | |
| alpha_init=float(config.get("alpha_init", 0.5)), | |
| train_alpha=bool(config.get("train_alpha", True)), | |
| train_beta=bool(config.get("train_beta", True)), | |
| train_bias=bool(config.get("train_bias", True)), | |
| ) | |
| model.load_state_dict(weights) | |
| return model | |
| # ============================================================================== | |
| # Model construction | |
| # ============================================================================== | |
| from sentence_transformers import SentenceTransformer | |
| from sentence_transformers.sentence_transformer.modules import StaticEmbedding | |
| def _wrap_sentence_transformer( | |
| static_embedding: StaticEmbedding, | |
| ) -> SentenceTransformer: | |
| return SentenceTransformer(modules=[static_embedding]) | |
| def build_scratch_model( | |
| tokenizer_model: str, | |
| embedding_dim: int = 1024, | |
| max_seq_length: int = 512, | |
| architecture: str = "static", | |
| sse_preprocessing: SSEPreprocessing = "sentence_transformers", | |
| sse_alpha_init: float = 0.5, | |
| sse_train_alpha: bool = True, | |
| sse_train_beta: bool = True, | |
| sse_train_bias: bool = True, | |
| text_preprocessing: TextPreprocessing = "none", | |
| ) -> SentenceTransformer: | |
| local_path = Path(tokenizer_model) | |
| local_tokenizer_path = ( | |
| local_path / "tokenizer.json" if local_path.is_dir() else local_path | |
| ) | |
| if local_tokenizer_path.is_file(): | |
| tokenizer = Tokenizer.from_file(str(local_tokenizer_path)) | |
| else: | |
| # Static training needs only the tokenizer. Loading a SentenceTransformer | |
| # first can unnecessarily materialize multi-billion-parameter backbones | |
| # (for example Gemma 2) before immediately discarding them. | |
| tokenizer = Tokenizer.from_pretrained(tokenizer_model) | |
| tokenizer.enable_truncation(max_length=max_seq_length) | |
| if architecture == "sse": | |
| static_embedding = StableStaticEmbedding( | |
| tokenizer, | |
| embedding_dim=embedding_dim, | |
| preprocessing=sse_preprocessing, | |
| alpha_init=sse_alpha_init, | |
| train_alpha=sse_train_alpha, | |
| train_beta=sse_train_beta, | |
| train_bias=sse_train_bias, | |
| ) | |
| elif architecture == "static": | |
| if text_preprocessing == "none": | |
| static_embedding = StaticEmbedding(tokenizer, embedding_dim=embedding_dim) | |
| else: | |
| static_embedding = PreprocessedStaticEmbedding( | |
| tokenizer, | |
| embedding_dim=embedding_dim, | |
| text_preprocessing=text_preprocessing, | |
| ) | |
| else: | |
| raise ValueError(f"Unsupported architecture: {architecture}") | |
| return _wrap_sentence_transformer(static_embedding) | |
| # ============================================================================== | |
| # Dataset loading | |
| # ============================================================================== | |
| import gc | |
| import hashlib | |
| import os | |
| import random | |
| import shutil | |
| from typing import Literal | |
| from datasets import ( | |
| Dataset, | |
| DatasetDict, | |
| concatenate_datasets, | |
| load_dataset, | |
| load_from_disk, | |
| ) | |
| Manifest = dict[str, dict[str, int | str]] | |
| def staged_cache_path(source: Path, *, stage_root: Path) -> Path: | |
| """Map an absolute cache file to a collision-free path under ``stage_root``.""" | |
| resolved = source.resolve() | |
| return stage_root.resolve() / resolved.as_posix().lstrip("/") | |
| def stage_dataset_cache_files( | |
| dataset: Dataset, | |
| *, | |
| stage_root: Path, | |
| force: bool = False, | |
| ) -> dict[str, int]: | |
| """Copy dataset Arrow files from slower filesystems into a local stage. | |
| Files already on the stage filesystem are intentionally left in place. Each | |
| cross-device copy is published atomically so interrupted staging cannot be | |
| mistaken for a complete cache file by a later training process. | |
| """ | |
| stage_root = stage_root.resolve() | |
| stage_root.mkdir(parents=True, exist_ok=True) | |
| stage_device = stage_root.stat().st_dev | |
| copied_files = 0 | |
| copied_bytes = 0 | |
| reused_files = 0 | |
| local_files = 0 | |
| for cache_file in dataset.cache_files: | |
| source = Path(str(cache_file["filename"])).resolve() | |
| source_size = source.stat().st_size | |
| if not force and source.stat().st_dev == stage_device: | |
| local_files += 1 | |
| continue | |
| destination = staged_cache_path(source, stage_root=stage_root) | |
| if destination.is_file() and destination.stat().st_size == source_size: | |
| reused_files += 1 | |
| continue | |
| destination.parent.mkdir(parents=True, exist_ok=True) | |
| temporary = destination.with_name(f".{destination.name}.partial-{os.getpid()}") | |
| try: | |
| shutil.copyfile(source, temporary) | |
| if temporary.stat().st_size != source_size: | |
| raise OSError(f"staged size mismatch for {source}") | |
| temporary.replace(destination) | |
| finally: | |
| temporary.unlink(missing_ok=True) | |
| copied_files += 1 | |
| copied_bytes += source_size | |
| return { | |
| "copied_files": copied_files, | |
| "copied_bytes": copied_bytes, | |
| "reused_files": reused_files, | |
| "local_files": local_files, | |
| } | |
| def remap_dataset_to_stage( | |
| dataset: Dataset, | |
| *, | |
| stage_root: Path, | |
| require: bool, | |
| ) -> Dataset: | |
| """Reopen cross-device Arrow shards from a completed local stage.""" | |
| if len(dataset) == 0: | |
| return dataset | |
| cache_files = dataset.cache_files | |
| if not cache_files: | |
| return dataset | |
| stage_root = stage_root.resolve() | |
| if not stage_root.is_dir(): | |
| if require: | |
| raise FileNotFoundError( | |
| f"required stage cache does not exist: {stage_root}" | |
| ) | |
| return dataset | |
| stage_device = stage_root.stat().st_dev | |
| reopened: list[Dataset] = [] | |
| for cache_file in cache_files: | |
| source = Path(str(cache_file["filename"])).resolve() | |
| source_size = source.stat().st_size | |
| staged = staged_cache_path(source, stage_root=stage_root) | |
| if staged.is_file() and staged.stat().st_size == source_size: | |
| selected = staged | |
| elif source.stat().st_dev == stage_device: | |
| selected = source | |
| elif require: | |
| raise FileNotFoundError(f"required staged cache file is missing: {staged}") | |
| else: | |
| selected = source | |
| reopened.append(Dataset.from_file(str(selected))) | |
| result = reopened[0] if len(reopened) == 1 else concatenate_datasets(reopened) | |
| if len(result) != len(dataset): | |
| raise ValueError( | |
| "staged dataset row count mismatch: " | |
| f"source={len(dataset)} staged={len(result)}" | |
| ) | |
| return result | |
| def select_fraction( | |
| dataset: Dataset, | |
| *, | |
| fraction: float, | |
| seed: int, | |
| shared_id_column: str = "shared_id", | |
| shared_id_block_size: int | None = None, | |
| ) -> Dataset: | |
| if not 0 < fraction <= 1: | |
| raise ValueError(f"fraction must be in (0, 1], got {fraction}") | |
| if fraction == 1 or len(dataset) == 0: | |
| return dataset | |
| if shared_id_column not in dataset.column_names: | |
| count = max(1, int(len(dataset) * fraction)) | |
| start = random.Random(seed).randrange(len(dataset)) | |
| end = min(len(dataset), start + count) | |
| segments = [dataset.select(range(start, end))] | |
| wrapped_count = count - (end - start) | |
| if wrapped_count: | |
| segments.append(dataset.select(range(wrapped_count))) | |
| return segments[0] if len(segments) == 1 else concatenate_datasets(segments) | |
| if shared_id_block_size: | |
| ranges = [ | |
| (start, min(len(dataset), start + shared_id_block_size)) | |
| for start in range(0, len(dataset), shared_id_block_size) | |
| ] | |
| else: | |
| values = dataset[shared_id_column] | |
| ranges = [] | |
| start = 0 | |
| for index in range(1, len(values) + 1): | |
| if index == len(values) or values[index] != values[start]: | |
| ranges.append((start, index)) | |
| start = index | |
| block_count = max(1, min(len(ranges), round(len(ranges) * fraction))) | |
| start_block = random.Random(seed).randrange(len(ranges)) | |
| first_block_count = min(block_count, len(ranges) - start_block) | |
| first_begin = ranges[start_block][0] | |
| first_end = ranges[start_block + first_block_count - 1][1] | |
| segments = [dataset.select(range(first_begin, first_end))] | |
| wrapped_block_count = block_count - first_block_count | |
| if wrapped_block_count: | |
| segments.append( | |
| dataset.select(range(ranges[0][0], ranges[wrapped_block_count - 1][1])) | |
| ) | |
| return segments[0] if len(segments) == 1 else concatenate_datasets(segments) | |
| def adapt_unir_dataset(dataset: Dataset, *, task: str) -> Dataset: | |
| required = {"query", "pos"} | |
| if task != "pair": | |
| required.add("neg") | |
| missing = required.difference(dataset.column_names) | |
| if missing: | |
| raise ValueError(f"Dataset for {task!r} is missing columns: {sorted(missing)}") | |
| rename = {"query": "anchor", "pos": "positive"} | |
| keep = ["query", "pos"] | |
| if task != "pair": | |
| rename["neg"] = "negative" | |
| keep.append("neg") | |
| dataset = dataset.select_columns(keep).rename_columns(rename) | |
| return dataset | |
| def adapt_supplemental_dataset( | |
| dataset: Dataset, | |
| *, | |
| settings: SupplementalDataSettings, | |
| ) -> Dataset: | |
| source_columns = [settings.query_column, settings.positive_column] | |
| target_columns = ["anchor", "positive"] | |
| if settings.task == "triplet": | |
| source_columns.append(settings.negative_column) | |
| target_columns.append("negative") | |
| missing = set(source_columns).difference(dataset.column_names) | |
| if missing: | |
| raise ValueError( | |
| f"Supplement {settings.name!r} is missing columns: {sorted(missing)}" | |
| ) | |
| adapted = dataset.select_columns(source_columns) | |
| adapted = adapted.rename_columns( | |
| dict(zip(source_columns, target_columns, strict=True)) | |
| ) | |
| metadata_target = cast(Any, adapted) | |
| metadata_target._bekko_batch_deduplication = settings.batch_deduplication | |
| metadata_target._bekko_fingerprint_cache = settings.fingerprint_cache | |
| metadata_target._bekko_supplement_name = settings.name | |
| metadata_target._bekko_supplement_identity = { | |
| "dataset": settings.dataset, | |
| "config": settings.config, | |
| "revision": settings.revision, | |
| "split": settings.split, | |
| "source_rows": len(dataset), | |
| } | |
| return adapted | |
| def dataset_row_count(datasets: DatasetDict) -> int: | |
| return sum(len(dataset) for dataset in datasets.values()) | |
| def resolve_pair_type( | |
| item: Any, *, subset_name: str | |
| ) -> Literal["query_doc", "doc_doc"]: | |
| metadata = getattr(item, "metadata", None) | |
| pair_type = getattr(metadata, "pair_type", None) | |
| if pair_type not in {"query_doc", "doc_doc"}: | |
| raise ValueError( | |
| f"UniR subset {subset_name!r} is missing valid pair_type metadata" | |
| ) | |
| return pair_type | |
| def load_bekko_datasets( | |
| *, | |
| spec: Path, | |
| tasks: tuple[str, ...], | |
| fraction: float, | |
| seed: int, | |
| require_cache: bool, | |
| shared_id_column: str, | |
| shared_id_block_size: int = 8192, | |
| supplements: tuple[SupplementalDataSettings, ...] = (), | |
| stage_cache_root: Path | None = None, | |
| require_stage_cache: bool = False, | |
| direct_source_cache: bool = False, | |
| ) -> tuple[ | |
| DatasetDict, | |
| Manifest, | |
| dict[str, Literal["query_doc", "doc_doc"]], | |
| ]: | |
| # This public trainer deliberately accepts only the auditable direct JSON | |
| # manifest. It never consults UniR specs or a private dataset cache. | |
| if supplements: | |
| raise ValueError( | |
| "The standalone direct-Bekko trainer does not support supplemental datasets." | |
| ) | |
| del tasks | |
| direct_manifest = _read_direct_huggingface_manifest(spec) | |
| if direct_manifest is None: | |
| raise ValueError( | |
| "The standalone trainer requires a direct Bekko JSON manifest " | |
| "containing 'dataset', 'revision', and 'configs'." | |
| ) | |
| return load_direct_huggingface_datasets( | |
| manifest=direct_manifest, | |
| fraction=fraction, | |
| seed=seed, | |
| require_cache=require_cache or require_stage_cache, | |
| shared_id_block_size=shared_id_block_size, | |
| materialized_root=stage_cache_root, | |
| source_cache_direct=direct_source_cache, | |
| shared_id_column=shared_id_column, | |
| ) | |
| def _read_direct_huggingface_manifest(path: Path) -> dict[str, Any] | None: | |
| if path.suffix.lower() != ".json": | |
| return None | |
| payload = json.loads(path.read_text(encoding="utf-8")) | |
| if ( | |
| not isinstance(payload, dict) | |
| or "dataset" not in payload | |
| or "configs" not in payload | |
| ): | |
| return None | |
| return cast(dict[str, Any], payload) | |
| def _direct_materialized_path( | |
| root: Path, config_name: str, fraction: float, seed: int | |
| ) -> Path: | |
| identity = f"{config_name}\0{fraction:.12g}\0{seed}".encode() | |
| digest = hashlib.sha256(identity).hexdigest()[:16] | |
| readable = config_name[:80].replace("/", "_") | |
| return root / "direct-huggingface" / f"{readable}-{digest}" | |
| def _prepare_direct_training_dataset( | |
| dataset: Dataset, | |
| *, | |
| task: str, | |
| shared_id_column: str, | |
| ) -> Dataset: | |
| """Expose model text columns while retaining shared IDs for provenance.""" | |
| if "anchor" in dataset.column_names: | |
| text_columns = ["anchor", "positive"] | |
| rename_columns: dict[str, str] = {} | |
| if task == "triplet": | |
| text_columns.append("negative") | |
| else: | |
| text_columns = ["query", "pos"] | |
| rename_columns = {"query": "anchor", "pos": "positive"} | |
| if task == "triplet": | |
| text_columns.append("neg") | |
| rename_columns["neg"] = "negative" | |
| missing = set(text_columns).difference(dataset.column_names) | |
| if missing: | |
| raise ValueError(f"direct dataset is missing text columns: {sorted(missing)}") | |
| if shared_id_column not in dataset.column_names: | |
| # Backward compatibility for previously materialized smoke/search slices. | |
| prepared = dataset.select_columns(text_columns) | |
| return prepared.rename_columns(rename_columns) if rename_columns else prepared | |
| source_ids = dataset.select_columns([shared_id_column]) | |
| prepared = dataset.select_columns(text_columns) | |
| if rename_columns: | |
| prepared = prepared.rename_columns(rename_columns) | |
| metadata_target = cast(Any, prepared) | |
| metadata_target._bekko_shared_id_source = source_ids | |
| metadata_target._bekko_shared_id_column = shared_id_column | |
| return prepared | |
| def load_direct_huggingface_datasets( | |
| *, | |
| manifest: dict[str, Any], | |
| fraction: float, | |
| seed: int, | |
| require_cache: bool, | |
| shared_id_block_size: int, | |
| materialized_root: Path | None, | |
| source_cache_direct: bool = False, | |
| shared_id_column: str = "shared_id", | |
| ) -> tuple[DatasetDict, Manifest, dict[str, Literal["query_doc", "doc_doc"]]]: | |
| """Load a deterministic smoke slice directly from the published Bekko dataset. | |
| The released rows are already shuffled in shared-id blocks. Taking an aligned | |
| prefix therefore avoids scanning or downloading the remaining 99% while | |
| preserving batches that are safe for in-batch duplicate filtering. | |
| """ | |
| if materialized_root is None and not source_cache_direct: | |
| raise ValueError("direct Hugging Face manifests require data.stage_cache_root") | |
| if source_cache_direct and fraction != 1.0: | |
| raise ValueError( | |
| "direct source-cache loading currently requires data.fraction: 1.0" | |
| ) | |
| dataset_id = str(manifest["dataset"]) | |
| revision = str(manifest["revision"]) | |
| raw_configs = manifest["configs"] | |
| if not isinstance(raw_configs, list): | |
| raise TypeError("direct manifest configs must be a list") | |
| loaded: dict[str, Dataset] = {} | |
| data_manifest: Manifest = {} | |
| pair_types: dict[str, Literal["query_doc", "doc_doc"]] = {} | |
| config_count = len(raw_configs) | |
| for config_index, raw in enumerate(raw_configs, start=1): | |
| if not isinstance(raw, dict): | |
| raise TypeError("direct manifest config entries must be mappings") | |
| config_name = str(raw["name"]) | |
| source_rows = int(raw["source_rows_all_splits"]) | |
| task = str(raw["task"]) | |
| pair_type = str(raw["pair_type"]) | |
| if task not in {"pair", "triplet"} or pair_type not in {"query_doc", "doc_doc"}: | |
| raise ValueError(f"invalid direct manifest metadata for {config_name}") | |
| selected_rows = min( | |
| source_rows, | |
| max( | |
| shared_id_block_size, | |
| math.ceil(source_rows * fraction / shared_id_block_size) | |
| * shared_id_block_size, | |
| ), | |
| ) | |
| cache_path = ( | |
| _direct_materialized_path( | |
| materialized_root.resolve(), config_name, fraction, seed | |
| ) | |
| if materialized_root is not None | |
| else None | |
| ) | |
| if source_cache_direct: | |
| source = load_dataset( | |
| dataset_id, | |
| config_name, | |
| revision=revision, | |
| split="train", | |
| streaming=False, | |
| cache_dir=( | |
| str(materialized_root.resolve()) | |
| if materialized_root is not None | |
| else None | |
| ), | |
| ) | |
| if not isinstance(source, Dataset): | |
| raise TypeError(f"direct source config is not a Dataset: {config_name}") | |
| if len(source) != source_rows: | |
| raise ValueError( | |
| f"direct source row count mismatch for {config_name}: " | |
| f"manifest={source_rows} actual={len(source)}" | |
| ) | |
| required_columns = {"query", "pos", shared_id_column} | |
| if task == "triplet": | |
| required_columns.add("neg") | |
| missing = required_columns.difference(source.column_names) | |
| if missing: | |
| raise ValueError( | |
| f"direct source {config_name} is missing columns: {sorted(missing)}" | |
| ) | |
| dataset = _prepare_direct_training_dataset( | |
| source, | |
| task=task, | |
| shared_id_column=shared_id_column, | |
| ) | |
| metadata_target = cast(Any, dataset) | |
| metadata_target._bekko_direct_source_cache = True | |
| elif cache_path is not None and cache_path.is_dir(): | |
| print( | |
| "Loading materialized Bekko cache " | |
| f"{config_index}/{config_count}: {config_name}", | |
| flush=True, | |
| ) | |
| dataset = load_from_disk(cache_path) | |
| if not isinstance(dataset, Dataset): | |
| raise TypeError( | |
| f"materialized direct dataset is not a Dataset: {cache_path}" | |
| ) | |
| else: | |
| if require_cache: | |
| raise FileNotFoundError( | |
| f"required direct dataset slice is missing: {cache_path}" | |
| ) | |
| stream = load_dataset( | |
| dataset_id, | |
| config_name, | |
| revision=revision, | |
| split="train", | |
| streaming=True, | |
| ) | |
| def rows( | |
| source: Any = stream, | |
| count: int = selected_rows, | |
| row_task: str = task, | |
| ) -> Any: | |
| for row in source.take(count): | |
| output = { | |
| "anchor": row["query"], | |
| "positive": row["pos"], | |
| shared_id_column: row[shared_id_column], | |
| } | |
| if row_task == "triplet": | |
| output["negative"] = row["neg"] | |
| yield output | |
| dataset = Dataset.from_generator( | |
| rows, | |
| cache_dir=str(materialized_root.resolve() / "direct-builder-cache"), | |
| ) | |
| del rows | |
| del stream | |
| gc.collect() | |
| assert cache_path is not None | |
| cache_path.parent.mkdir(parents=True, exist_ok=True) | |
| dataset.save_to_disk(cache_path) | |
| reopened = load_from_disk(cache_path) | |
| if not isinstance(reopened, Dataset): | |
| raise TypeError( | |
| f"materialized direct dataset is not a Dataset: {cache_path}" | |
| ) | |
| dataset = reopened | |
| dataset = _prepare_direct_training_dataset( | |
| dataset, | |
| task=task, | |
| shared_id_column=shared_id_column, | |
| ) | |
| key = config_name | |
| metadata_target = cast(Any, dataset) | |
| metadata_target._bekko_component_name = key | |
| metadata_target._bekko_cache_path = str(cache_path) if cache_path else None | |
| loaded[key] = dataset | |
| pair_types[key] = pair_type | |
| data_manifest[key] = { | |
| "source_rows": source_rows, | |
| "selected_rows": len(dataset), | |
| "pair_type": pair_type, | |
| "source_dataset": dataset_id, | |
| "source_config": config_name, | |
| "source_revision": revision, | |
| } | |
| return DatasetDict(loaded), data_manifest, pair_types # ty: ignore[no-matching-overload] | |
| def load_huggingface_supplements( | |
| *, | |
| supplements: tuple[SupplementalDataSettings, ...], | |
| seed: int, | |
| shared_id_column: str, | |
| shared_id_block_size: int, | |
| ) -> tuple[ | |
| dict[str, Dataset], | |
| Manifest, | |
| dict[str, Literal["query_doc", "doc_doc"]], | |
| ]: | |
| loaded: dict[str, Dataset] = {} | |
| manifest: Manifest = {} | |
| pair_types: dict[str, Literal["query_doc", "doc_doc"]] = {} | |
| for settings in supplements: | |
| source = load_dataset( | |
| settings.dataset, | |
| settings.config, | |
| revision=settings.revision, | |
| split=settings.split, | |
| ) | |
| if not isinstance(source, Dataset): | |
| raise TypeError(f"Supplement {settings.name!r} did not return a Dataset") | |
| selected = select_fraction( | |
| source, | |
| fraction=settings.fraction, | |
| seed=seed, | |
| shared_id_column=shared_id_column, | |
| shared_id_block_size=shared_id_block_size, | |
| ) | |
| key = f"supplement__{settings.name}".replace("/", "_") | |
| loaded[key] = adapt_supplemental_dataset(selected, settings=settings) | |
| manifest[key] = { | |
| "source_rows": len(source), | |
| "selected_rows": len(selected), | |
| "pair_type": settings.pair_type, | |
| "dataset": settings.dataset, | |
| "config": settings.config, | |
| "revision": settings.revision, | |
| "provenance_note": settings.provenance_note, | |
| "batch_deduplication": settings.batch_deduplication, | |
| } | |
| pair_types[key] = settings.pair_type | |
| return loaded, manifest, pair_types | |
| def build_loss_eval_dataset(train_dataset: DatasetDict, *, limit: int = 256) -> Dataset: | |
| first = next(iter(train_dataset.values())) | |
| return first.select(range(min(limit, len(first)))) | |
| # ============================================================================== | |
| # Representation-aware batching | |
| # ============================================================================== | |
| from array import array | |
| from collections.abc import Mapping | |
| from dataclasses import dataclass | |
| import xxhash | |
| from torch.utils.data import BatchSampler | |
| REPRESENTATION_BATCH_PLAN_ATTR = "_bekko_representation_batch_plan" | |
| TEXT_COLUMNS = ("anchor", "positive", "negative") | |
| def _tokenizer_file(path: Path) -> Path: | |
| resolved = path / "tokenizer.json" if path.is_dir() else path | |
| if not resolved.is_file(): | |
| raise FileNotFoundError(f"Tokenizer JSON not found: {resolved}") | |
| return resolved | |
| def _token_ids_fingerprint(token_ids: list[int]) -> tuple[np.uint64, np.uint64]: | |
| payload = array("I", token_ids).tobytes() | |
| digest = xxhash.xxh3_128_digest(payload) | |
| values = np.frombuffer(digest, dtype=np.dtype(">u8")) | |
| return np.uint64(values[0]), np.uint64(values[1]) | |
| def build_model_input_fingerprints( | |
| dataset: Dataset, | |
| *, | |
| tokenizer_path: Path, | |
| text_preprocessing: TextPreprocessing, | |
| max_seq_length: int, | |
| batch_size: int = 8192, | |
| output_path: Path | None = None, | |
| ) -> np.ndarray: | |
| """Fingerprint the exact lexical representation consumed by StaticEmbedding.""" | |
| if max_seq_length <= 0 or batch_size <= 0: | |
| raise ValueError("max_seq_length and batch_size must be positive") | |
| columns = [column for column in TEXT_COLUMNS if column in dataset.column_names] | |
| if len(columns) < 2: | |
| raise ValueError( | |
| "representation deduplication requires at least two text columns" | |
| ) | |
| tokenizer = Tokenizer.from_file(str(_tokenizer_file(tokenizer_path))) | |
| tokenizer.enable_truncation(max_length=max_seq_length) | |
| shape = (len(dataset), len(columns), 2) | |
| if output_path is None: | |
| fingerprints = np.empty(shape, dtype=np.uint64) | |
| else: | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| fingerprints = np.lib.format.open_memmap( | |
| output_path, | |
| mode="w+", | |
| dtype=np.uint64, | |
| shape=shape, | |
| ) | |
| offset = 0 | |
| for batch in dataset.iter(batch_size=batch_size): | |
| row_count = len(batch[columns[0]]) | |
| for column_index, column in enumerate(columns): | |
| raw_texts = [str(text) for text in batch[column]] | |
| unique_raw_texts = list(dict.fromkeys(raw_texts)) | |
| transformed = [ | |
| preprocess_text(text, text_preprocessing) for text in unique_raw_texts | |
| ] | |
| encodings = tokenizer.encode_batch(transformed, add_special_tokens=False) | |
| by_raw_text = { | |
| raw_text: _token_ids_fingerprint(encoding.ids) | |
| for raw_text, encoding in zip(unique_raw_texts, encodings, strict=True) | |
| } | |
| for row_index, raw_text in enumerate(raw_texts): | |
| fingerprints[offset + row_index, column_index] = by_raw_text[raw_text] | |
| offset += row_count | |
| if offset != len(dataset): | |
| raise RuntimeError(f"fingerprinted {offset} rows, expected {len(dataset)}") | |
| if isinstance(fingerprints, np.memmap): | |
| fingerprints.flush() | |
| return fingerprints | |
| class RepresentationBatchPlan: | |
| indices: np.ndarray | |
| offsets: np.ndarray | |
| source_rows: int | |
| batch_size: int | |
| seed: int | |
| def __post_init__(self) -> None: | |
| if self.indices.ndim != 1 or self.offsets.ndim != 1: | |
| raise ValueError("plan indices and offsets must be one-dimensional") | |
| if len(self.offsets) == 0 or int(self.offsets[0]) != 0: | |
| raise ValueError("plan offsets must begin at zero") | |
| if int(self.offsets[-1]) != len(self.indices): | |
| raise ValueError("plan offsets must end at the number of indices") | |
| if len(self.indices) != self.source_rows: | |
| raise ValueError("representation plan must retain every source row") | |
| def __len__(self) -> int: | |
| return len(self.offsets) - 1 | |
| def batches(self) -> list[np.ndarray]: | |
| return [ | |
| self.indices[int(start) : int(end)] | |
| for start, end in zip(self.offsets[:-1], self.offsets[1:], strict=True) | |
| ] | |
| def to_manifest(self) -> dict[str, int]: | |
| sizes = np.diff(self.offsets) | |
| return { | |
| "source_rows": self.source_rows, | |
| "retained_rows": len(self.indices), | |
| "dropped_rows": self.source_rows - len(self.indices), | |
| "batch_size": self.batch_size, | |
| "batch_count": len(self), | |
| "partial_batch_count": int(np.count_nonzero(sizes < self.batch_size)), | |
| "smallest_batch": int(sizes.min()) if len(sizes) else 0, | |
| "seed": self.seed, | |
| } | |
| def save(self, directory: Path) -> None: | |
| directory.mkdir(parents=True, exist_ok=True) | |
| np.save(directory / "plan_indices.npy", self.indices, allow_pickle=False) | |
| np.save(directory / "plan_offsets.npy", self.offsets, allow_pickle=False) | |
| (directory / "plan.json").write_text( | |
| json.dumps(self.to_manifest(), indent=2, sort_keys=True) + "\n", | |
| encoding="utf-8", | |
| ) | |
| def load(cls, directory: Path) -> RepresentationBatchPlan: | |
| metadata = json.loads((directory / "plan.json").read_text(encoding="utf-8")) | |
| return cls( | |
| indices=np.load(directory / "plan_indices.npy", mmap_mode="r"), | |
| offsets=np.load(directory / "plan_offsets.npy", mmap_mode="r"), | |
| source_rows=int(metadata["source_rows"]), | |
| batch_size=int(metadata["batch_size"]), | |
| seed=int(metadata["seed"]), | |
| ) | |
| def build_representation_batch_plan( | |
| fingerprints: np.ndarray, | |
| *, | |
| batch_size: int, | |
| seed: int, | |
| ) -> RepresentationBatchPlan: | |
| """Greedily color rows into full, collision-free batches without dropping rows.""" | |
| if fingerprints.ndim != 3 or fingerprints.shape[2] != 2: | |
| raise ValueError("fingerprints must have shape (rows, columns, 2)") | |
| if fingerprints.dtype != np.uint64: | |
| raise ValueError("fingerprints must use uint64") | |
| if batch_size <= 0: | |
| raise ValueError("batch_size must be positive") | |
| row_count = len(fingerprints) | |
| index_dtype = np.int32 if row_count <= np.iinfo(np.int32).max else np.int64 | |
| generator = torch.Generator().manual_seed(seed) | |
| shuffled = torch.randperm(row_count, generator=generator, dtype=torch.int64).numpy() | |
| shuffled = shuffled.astype(index_dtype, copy=False) | |
| next_dtype = np.int32 if row_count + 1 <= np.iinfo(np.int32).max else np.int64 | |
| next_positions = np.arange(1, row_count + 1, dtype=next_dtype) | |
| if row_count: | |
| next_positions[-1] = -1 | |
| planned = np.empty(row_count, dtype=index_dtype) | |
| offsets = [0] | |
| write_offset = 0 | |
| head_position = 0 if row_count else -1 | |
| while head_position != -1: | |
| batch_values: set[tuple[int, int]] = set() | |
| current_position = head_position | |
| previous_position = -1 | |
| batch_start = write_offset | |
| while current_position != -1: | |
| next_position = int(next_positions[current_position]) | |
| index = int(shuffled[current_position]) | |
| row_values = { | |
| (int(high), int(low)) for high, low in fingerprints[index].tolist() | |
| } | |
| if not row_values.isdisjoint(batch_values): | |
| previous_position = current_position | |
| current_position = next_position | |
| continue | |
| planned[write_offset] = index | |
| write_offset += 1 | |
| batch_values.update(row_values) | |
| if previous_position == -1: | |
| head_position = next_position | |
| else: | |
| next_positions[previous_position] = next_position | |
| if write_offset - batch_start == batch_size: | |
| break | |
| current_position = next_position | |
| if write_offset == batch_start: | |
| raise RuntimeError("unable to make progress while constructing batch plan") | |
| offsets.append(write_offset) | |
| if write_offset != row_count: | |
| raise RuntimeError(f"planned {write_offset} rows, expected {row_count}") | |
| return RepresentationBatchPlan( | |
| indices=planned, | |
| offsets=np.asarray(offsets, dtype=np.int64), | |
| source_rows=row_count, | |
| batch_size=batch_size, | |
| seed=seed, | |
| ) | |
| def attach_representation_batch_plan( | |
| dataset: Dataset, plan: RepresentationBatchPlan | |
| ) -> None: | |
| if len(dataset) != plan.source_rows: | |
| raise ValueError("dataset and representation batch plan row counts differ") | |
| setattr(dataset, REPRESENTATION_BATCH_PLAN_ATTR, plan) | |
| def representation_batch_plan(dataset: Any) -> RepresentationBatchPlan | None: | |
| value = getattr(dataset, REPRESENTATION_BATCH_PLAN_ATTR, None) | |
| return value if isinstance(value, RepresentationBatchPlan) else None | |
| class RepresentationNoDuplicatesBatchSampler(BatchSampler): | |
| """Yield a pre-audited model-input-unique plan, shuffling only batch order.""" | |
| def __init__( | |
| self, | |
| dataset: Dataset, | |
| batch_size: int, | |
| drop_last: bool, | |
| valid_label_columns: list[str] | None = None, | |
| generator: torch.Generator | None = None, | |
| seed: int = 0, | |
| ) -> None: | |
| del valid_label_columns | |
| super().__init__(dataset, batch_size=batch_size, drop_last=drop_last) | |
| plan = representation_batch_plan(dataset) | |
| if plan is None: | |
| raise ValueError("dataset has no representation batch plan") | |
| if batch_size != plan.batch_size: | |
| raise ValueError( | |
| f"batch size {batch_size} differs from planned size {plan.batch_size}" | |
| ) | |
| if drop_last: | |
| raise ValueError( | |
| "representation-safe training does not permit dropping rows" | |
| ) | |
| self.plan = plan | |
| self.generator = generator | |
| self.seed = seed | |
| self.epoch = 0 | |
| def set_epoch(self, epoch: int) -> None: | |
| self.epoch = epoch | |
| def __iter__(self): # type: ignore[no-untyped-def] | |
| generator = self.generator or torch.Generator() | |
| generator.manual_seed(self.seed + self.epoch) | |
| order = torch.randperm(len(self.plan), generator=generator).tolist() | |
| batches = self.plan.batches() | |
| for batch_index in order: | |
| yield batches[batch_index].tolist() | |
| def __len__(self) -> int: | |
| return len(self.plan) | |
| def tokenizer_sha256(tokenizer_path: Path) -> str: | |
| return hashlib.sha256(_tokenizer_file(tokenizer_path).read_bytes()).hexdigest() | |
| def _identity_digest(identity: dict[str, Any]) -> str: | |
| payload = json.dumps(identity, sort_keys=True, separators=(",", ":")).encode() | |
| return hashlib.sha256(payload).hexdigest()[:20] | |
| def _cache_identity( | |
| dataset: Dataset, | |
| *, | |
| tokenizer_path: Path, | |
| text_preprocessing: TextPreprocessing, | |
| max_seq_length: int, | |
| ) -> dict[str, Any]: | |
| return { | |
| "schema_version": 1, | |
| "supplement": getattr(dataset, "_bekko_supplement_identity", {}), | |
| "dataset_fingerprint": getattr(dataset, "_fingerprint", None), | |
| "rows": len(dataset), | |
| "columns": list(dataset.column_names), | |
| "tokenizer_sha256": tokenizer_sha256(tokenizer_path), | |
| "text_preprocessing": text_preprocessing, | |
| "max_seq_length": max_seq_length, | |
| "hash": "xxh3-128-token-ids-v1", | |
| } | |
| def _load_or_build_fingerprints( | |
| dataset: Dataset, | |
| *, | |
| cache_root: Path, | |
| tokenizer_path: Path, | |
| text_preprocessing: TextPreprocessing, | |
| max_seq_length: int, | |
| fingerprint_batch_size: int, | |
| ) -> tuple[np.ndarray, Path, dict[str, Any]]: | |
| identity = _cache_identity( | |
| dataset, | |
| tokenizer_path=tokenizer_path, | |
| text_preprocessing=text_preprocessing, | |
| max_seq_length=max_seq_length, | |
| ) | |
| cache_dir = cache_root / _identity_digest(identity) | |
| metadata_path = cache_dir / "fingerprints.json" | |
| fingerprints_path = cache_dir / "fingerprints.npy" | |
| if metadata_path.is_file() and fingerprints_path.is_file(): | |
| cached_identity = json.loads(metadata_path.read_text(encoding="utf-8")) | |
| if cached_identity == identity: | |
| fingerprints = np.load(fingerprints_path, mmap_mode="r") | |
| expected_shape = (len(dataset), len(dataset.column_names), 2) | |
| if fingerprints.shape == expected_shape and fingerprints.dtype == np.uint64: | |
| return fingerprints, cache_dir, identity | |
| cache_dir.mkdir(parents=True, exist_ok=True) | |
| fingerprints = build_model_input_fingerprints( | |
| dataset, | |
| tokenizer_path=tokenizer_path, | |
| text_preprocessing=text_preprocessing, | |
| max_seq_length=max_seq_length, | |
| batch_size=fingerprint_batch_size, | |
| output_path=fingerprints_path, | |
| ) | |
| metadata_path.write_text( | |
| json.dumps(identity, indent=2, sort_keys=True) + "\n", | |
| encoding="utf-8", | |
| ) | |
| return fingerprints, cache_dir, identity | |
| def audit_representation_batch_plan( | |
| fingerprints: np.ndarray, | |
| plan: RepresentationBatchPlan, | |
| ) -> dict[str, int]: | |
| collisions = 0 | |
| audited_rows = 0 | |
| observed_indices: list[np.ndarray] = [] | |
| for batch in plan.batches(): | |
| seen: set[tuple[int, int]] = set() | |
| observed_indices.append(batch) | |
| for index in batch: | |
| row_values = { | |
| (int(high), int(low)) for high, low in fingerprints[int(index)].tolist() | |
| } | |
| collisions += len(seen.intersection(row_values)) | |
| seen.update(row_values) | |
| audited_rows += 1 | |
| flattened = np.concatenate(observed_indices) if observed_indices else np.asarray([]) | |
| unique_indices = len(np.unique(flattened)) | |
| if ( | |
| collisions | |
| or audited_rows != plan.source_rows | |
| or unique_indices != plan.source_rows | |
| ): | |
| raise RuntimeError( | |
| "representation batch audit failed: " | |
| f"collisions={collisions}, rows={audited_rows}, unique_indices={unique_indices}, " | |
| f"expected={plan.source_rows}" | |
| ) | |
| return { | |
| "audited_rows": audited_rows, | |
| "unique_indices": unique_indices, | |
| "representation_collisions": collisions, | |
| } | |
| def prepare_representation_batch_plans( | |
| datasets: Mapping[Any, Dataset], | |
| *, | |
| tokenizer_path: Path, | |
| text_preprocessing: TextPreprocessing, | |
| max_seq_length: int, | |
| batch_size: int, | |
| seed: int, | |
| default_cache_root: Path, | |
| fingerprint_batch_size: int = 8192, | |
| ) -> dict[str, dict[str, int | str]]: | |
| manifests: dict[str, dict[str, int | str]] = {} | |
| for raw_name, dataset in datasets.items(): | |
| name = str(raw_name) | |
| if getattr(dataset, "_bekko_batch_deduplication", "text") != "model_input": | |
| continue | |
| configured_cache = getattr(dataset, "_bekko_fingerprint_cache", None) | |
| cache_root = ( | |
| Path(configured_cache) if configured_cache else default_cache_root / name | |
| ) | |
| fingerprints, cache_dir, identity = _load_or_build_fingerprints( | |
| dataset, | |
| cache_root=cache_root, | |
| tokenizer_path=tokenizer_path, | |
| text_preprocessing=text_preprocessing, | |
| max_seq_length=max_seq_length, | |
| fingerprint_batch_size=fingerprint_batch_size, | |
| ) | |
| plan_dir = cache_dir / f"plan-bs{batch_size}-seed{seed}" | |
| if (plan_dir / "plan.json").is_file(): | |
| plan = RepresentationBatchPlan.load(plan_dir) | |
| if plan.batch_size != batch_size or plan.seed != seed: | |
| raise ValueError(f"cached plan settings do not match: {plan_dir}") | |
| else: | |
| plan = build_representation_batch_plan( | |
| fingerprints, | |
| batch_size=batch_size, | |
| seed=seed, | |
| ) | |
| plan.save(plan_dir) | |
| audit = audit_representation_batch_plan(fingerprints, plan) | |
| attach_representation_batch_plan(dataset, plan) | |
| manifests[name] = { | |
| **plan.to_manifest(), | |
| **audit, | |
| "fingerprint_cache": str(cache_dir), | |
| "plan_cache": str(plan_dir), | |
| "fingerprint_identity": _identity_digest(identity), | |
| } | |
| return manifests | |
| # ============================================================================== | |
| # Locality-aware batching | |
| # ============================================================================== | |
| import warnings | |
| from collections.abc import Iterator | |
| from sentence_transformers.base.sampler import NoDuplicatesBatchSampler | |
| _EXCLUDED_COLUMNS = {"dataset_name"} | |
| def block_local_permutation( | |
| num_rows: int, | |
| *, | |
| block_size: int, | |
| generator: torch.Generator | None, | |
| ) -> np.ndarray: | |
| """Shuffle blocks and rows within blocks without interleaving their pages.""" | |
| if num_rows < 0: | |
| raise ValueError("num_rows must be non-negative") | |
| if block_size <= 0: | |
| raise ValueError("block_size must be positive") | |
| if num_rows == 0: | |
| return np.empty(0, dtype=np.int32) | |
| index_dtype = torch.int32 if num_rows <= np.iinfo(np.int32).max else torch.int64 | |
| block_count = math.ceil(num_rows / block_size) | |
| block_order = torch.randperm(block_count, generator=generator, dtype=index_dtype) | |
| pieces: list[torch.Tensor] = [] | |
| for block_index in block_order.tolist(): | |
| start = int(block_index) * block_size | |
| length = min(block_size, num_rows - start) | |
| pieces.append( | |
| torch.randperm(length, generator=generator, dtype=index_dtype).add_(start) | |
| ) | |
| return torch.cat(pieces).numpy() | |
| class LocalityAwareNoDuplicatesBatchSampler(NoDuplicatesBatchSampler): | |
| """No-duplicates batching with bounded random-access working sets.""" | |
| def __init__( | |
| self, | |
| dataset: Dataset, | |
| batch_size: int, | |
| drop_last: bool, | |
| valid_label_columns: list[str] | None = None, | |
| generator: torch.Generator | None = None, | |
| seed: int = 0, | |
| *, | |
| locality_block_size: int, | |
| ) -> None: | |
| super().__init__( | |
| dataset, | |
| batch_size=batch_size, | |
| drop_last=drop_last, | |
| valid_label_columns=valid_label_columns, | |
| generator=generator, | |
| seed=seed, | |
| ) | |
| if locality_block_size < batch_size: | |
| raise ValueError("locality_block_size must be at least batch_size") | |
| self.locality_block_size = locality_block_size | |
| def __iter__(self) -> Iterator[list[int]]: | |
| if self.generator and self.seed is not None: | |
| self.generator.manual_seed(self.seed + self.epoch) | |
| num_rows = len(self.dataset) | |
| if num_rows == 0: | |
| return | |
| remaining_indices = block_local_permutation( | |
| num_rows, | |
| block_size=self.locality_block_size, | |
| generator=self.generator, | |
| ) | |
| position_dtype = ( | |
| np.int32 if num_rows + 1 <= np.iinfo(np.int32).max else np.int64 | |
| ) | |
| next_positions = np.arange(1, num_rows + 1, dtype=position_dtype) | |
| next_positions[-1] = -1 | |
| head_position = 0 | |
| while head_position != -1: | |
| batch_values: set[str] = set() | |
| batch_indices: list[int] = [] | |
| current_position = head_position | |
| previous_position = -1 | |
| full_batch = False | |
| while current_position != -1: | |
| next_position = int(next_positions[current_position]) | |
| index = int(remaining_indices[current_position]) | |
| try: | |
| row = self.dataset[index] | |
| except UnicodeDecodeError as error: | |
| # A malformed UTF-8 scalar cannot be collated or tokenized. Remove | |
| # that row from the sampler's linked list and retain every other | |
| # row instead of losing a multi-hour full-corpus run. | |
| component = getattr( | |
| self.dataset, | |
| "_bekko_component_name", | |
| getattr(self.dataset, "_fingerprint", "unknown"), | |
| ) | |
| cache_path = getattr(self.dataset, "_bekko_cache_path", None) | |
| location = f" component={component!r} row={index}" | |
| if cache_path is not None: | |
| location += f" cache={cache_path!r}" | |
| warnings.warn( | |
| f"Skipping malformed UTF-8 training row:{location}: {error}", | |
| RuntimeWarning, | |
| stacklevel=2, | |
| ) | |
| if previous_position == -1: | |
| head_position = next_position | |
| else: | |
| next_positions[previous_position] = next_position | |
| current_position = next_position | |
| continue | |
| sample_values = { | |
| str(value) | |
| for key, value in row.items() | |
| if key not in _EXCLUDED_COLUMNS | |
| } | |
| if not sample_values.isdisjoint(batch_values): | |
| previous_position = current_position | |
| current_position = next_position | |
| continue | |
| batch_indices.append(index) | |
| if previous_position == -1: | |
| head_position = next_position | |
| else: | |
| next_positions[previous_position] = next_position | |
| if len(batch_indices) == self.batch_size: | |
| full_batch = True | |
| yield batch_indices | |
| break | |
| batch_values.update(sample_values) | |
| current_position = next_position | |
| if not full_batch and not self.drop_last and batch_indices: | |
| yield batch_indices | |
| # ============================================================================== | |
| # Step scheduling | |
| # ============================================================================== | |
| from dataclasses import dataclass | |
| class StepSchedule: | |
| total_steps: int | |
| eval_steps: int | |
| save_steps: int | |
| def derive_step_schedule( | |
| *, | |
| dataset_rows: int, | |
| batch_size: int, | |
| epochs: float, | |
| target_evaluations: int, | |
| exact_target_evaluations: bool = False, | |
| ) -> StepSchedule: | |
| if dataset_rows <= 0: | |
| raise ValueError("dataset_rows must be positive") | |
| if batch_size <= 0 or epochs <= 0 or target_evaluations <= 0: | |
| raise ValueError("batch_size, epochs, and target_evaluations must be positive") | |
| total_steps = math.ceil(dataset_rows / batch_size) * math.ceil(epochs) | |
| # A floor interval guarantees that a non-divisible run reaches the requested | |
| # number of evaluations. Keep the historical ceil behavior unless a config | |
| # explicitly asks for an exact count. | |
| if exact_target_evaluations: | |
| eval_interval = max(1, total_steps // target_evaluations) | |
| else: | |
| eval_interval = max(1, math.ceil(total_steps / target_evaluations)) | |
| save_interval = max(1, math.ceil(total_steps / 10)) | |
| return StepSchedule( | |
| total_steps=total_steps, | |
| eval_steps=eval_interval, | |
| save_steps=save_interval, | |
| ) | |
| def derive_multidataset_step_schedule( | |
| *, | |
| component_rows: list[int], | |
| batch_size: int, | |
| epochs: float, | |
| target_evaluations: int, | |
| exact_target_evaluations: bool = False, | |
| ) -> StepSchedule: | |
| positive_rows = [rows for rows in component_rows if rows > 0] | |
| if not positive_rows: | |
| raise ValueError("component_rows must contain at least one positive row count") | |
| rows_as_batches = sum(math.ceil(rows / batch_size) for rows in positive_rows) | |
| return derive_step_schedule( | |
| dataset_rows=rows_as_batches * batch_size, | |
| batch_size=batch_size, | |
| epochs=epochs, | |
| target_evaluations=target_evaluations, | |
| exact_target_evaluations=exact_target_evaluations, | |
| ) | |
| # ============================================================================== | |
| # Qwen3 embedding loss | |
| # ============================================================================== | |
| from collections.abc import Iterable | |
| from sentence_transformers import util | |
| from torch import Tensor, nn | |
| def fake_quantize_embeddings(embeddings: Tensor, precision: str) -> Tensor: | |
| """Fake-quantize sentence embeddings with straight-through gradients.""" | |
| if precision == "float32": | |
| return embeddings | |
| if precision == "int8": | |
| mins = embeddings.amin(dim=0, keepdim=True) | |
| maxs = embeddings.amax(dim=0, keepdim=True) | |
| steps = torch.clamp((maxs - mins) / 255.0, min=1e-8) | |
| normalized = (embeddings - mins) / steps | |
| quantized = normalized + (torch.round(normalized) - normalized).detach() | |
| quantized = torch.clamp(quantized - 128, -128, 127) | |
| return (quantized + 128) * steps + mins | |
| if precision == "binary": | |
| quantized = torch.sign(embeddings) | |
| quantized = torch.where(quantized == 0, torch.ones_like(quantized), quantized) | |
| return embeddings + (quantized - embeddings).detach() | |
| raise ValueError(f"Unsupported fake-quantization precision: {precision}") | |
| class Qwen3EmbeddingLoss(nn.Module): | |
| """Qwen3-Embedding improved InfoNCE with false-negative masking.""" | |
| def __init__( | |
| self, | |
| model: SentenceTransformer, | |
| *, | |
| temperature: float = 0.01, | |
| margin: float | None = 0.1, | |
| hard_negative_margin: float | None = None, | |
| bidirectional: bool = False, | |
| query_query_weight: float = 1.0, | |
| document_document_weight: float = 1.0, | |
| cross_document_weight: float = 1.0, | |
| hard_negative_weight: float = 1.0, | |
| qat_int8_weight: float = 0.0, | |
| qat_binary_weight: float = 0.0, | |
| qat_n_quantized_per_step: int = -1, | |
| ) -> None: | |
| super().__init__() | |
| if temperature <= 0: | |
| raise ValueError("temperature must be > 0") | |
| weights = { | |
| "query_query_weight": query_query_weight, | |
| "document_document_weight": document_document_weight, | |
| "cross_document_weight": cross_document_weight, | |
| "hard_negative_weight": hard_negative_weight, | |
| } | |
| for name, value in weights.items(): | |
| if not math.isfinite(value) or value < 0: | |
| raise ValueError(f"{name} must be finite and non-negative") | |
| qat_weights = {"int8": qat_int8_weight, "binary": qat_binary_weight} | |
| for name, value in qat_weights.items(): | |
| if not math.isfinite(value) or value < 0: | |
| raise ValueError(f"qat_{name}_weight must be finite and non-negative") | |
| if qat_n_quantized_per_step not in {-1, 1, 2}: | |
| raise ValueError("qat_n_quantized_per_step must be -1, 1, or 2") | |
| self.model = model | |
| self.temperature = temperature | |
| self.margin = margin | |
| self.hard_negative_margin = hard_negative_margin | |
| self.bidirectional = bidirectional | |
| self.query_query_weight = query_query_weight | |
| self.document_document_weight = document_document_weight | |
| self.cross_document_weight = cross_document_weight | |
| self.hard_negative_weight = hard_negative_weight | |
| self.qat_weights = qat_weights | |
| self.qat_n_quantized_per_step = qat_n_quantized_per_step | |
| self.last_loss_components: dict[str, Tensor] = {} | |
| def scale(self) -> float: | |
| return 1.0 / self.temperature | |
| def compute_loss_from_embeddings(self, embeddings: list[Tensor]) -> Tensor: | |
| if len(embeddings) < 2: | |
| raise ValueError( | |
| f"Expected at least two embedding tensors, got {len(embeddings)}" | |
| ) | |
| queries, positives, *negative_sets = embeddings | |
| if queries.shape != positives.shape: | |
| raise ValueError("Query and positive embedding shapes must match") | |
| if any(negative.shape != queries.shape for negative in negative_sets): | |
| raise ValueError( | |
| "Every hard-negative embedding shape must match the queries" | |
| ) | |
| float_loss = self._compute_loss_for_precision(queries, positives, negative_sets) | |
| components = {"float32": float_loss} | |
| enabled = [name for name, weight in self.qat_weights.items() if weight > 0] | |
| if 0 < self.qat_n_quantized_per_step < len(enabled): | |
| enabled = random.sample(enabled, self.qat_n_quantized_per_step) | |
| total = float_loss | |
| for precision in enabled: | |
| transformed = [ | |
| fake_quantize_embeddings(value, precision) | |
| for value in (queries, positives, *negative_sets) | |
| ] | |
| quantized_loss = self._compute_loss_for_precision( | |
| transformed[0], transformed[1], transformed[2:] | |
| ) | |
| components[precision] = quantized_loss | |
| total = total + self.qat_weights[precision] * quantized_loss | |
| dimension = queries.shape[-1] | |
| self.last_loss_components.update( | |
| { | |
| f"{name}_d{dimension}": value.detach() | |
| for name, value in components.items() | |
| } | |
| ) | |
| return total | |
| def _compute_loss_for_precision( | |
| self, | |
| queries: Tensor, | |
| positives: Tensor, | |
| negative_sets: list[Tensor], | |
| ) -> Tensor: | |
| forward_loss = self._compute_directional_loss(queries, positives, negative_sets) | |
| if not self.bidirectional: | |
| return forward_loss | |
| reverse_loss = self._compute_directional_loss(positives, queries, []) | |
| return 0.5 * (forward_loss + reverse_loss) | |
| def _compute_directional_loss( | |
| self, | |
| queries: Tensor, | |
| positives: Tensor, | |
| negative_sets: list[Tensor], | |
| ) -> Tensor: | |
| batch_size = len(queries) | |
| indices = torch.arange(batch_size, device=queries.device) | |
| off_diagonal = ~torch.eye(batch_size, dtype=torch.bool, device=queries.device) | |
| query_positive = util.cos_sim(queries, positives) | |
| query_query = util.cos_sim(queries, queries) | |
| positive_positive = util.cos_sim(positives, positives) | |
| positive_scores = query_positive[indices, indices] | |
| threshold = ( | |
| None if self.margin is None else positive_scores[:, None] + self.margin | |
| ) | |
| denominator_parts = [positive_scores[:, None] * self.scale] | |
| for similarities, weight in zip( | |
| (query_query, positive_positive, query_positive), | |
| ( | |
| self.query_query_weight, | |
| self.document_document_weight, | |
| self.cross_document_weight, | |
| ), | |
| strict=True, | |
| ): | |
| keep = ( | |
| off_diagonal | |
| if threshold is None | |
| else off_diagonal & (similarities <= threshold) | |
| ) | |
| denominator_parts.append(self._weighted_logits(similarities, keep, weight)) | |
| for negatives in negative_sets: | |
| similarities = util.cos_sim(queries, negatives) | |
| if threshold is None: | |
| keep = torch.ones_like(similarities, dtype=torch.bool) | |
| else: | |
| keep = similarities <= threshold | |
| if self.hard_negative_margin is None: | |
| keep[indices, indices] = True | |
| else: | |
| owner_threshold = positive_scores + self.hard_negative_margin | |
| keep[indices, indices] = ( | |
| similarities[indices, indices] <= owner_threshold | |
| ) | |
| denominator_parts.append( | |
| self._weighted_logits(similarities, keep, self.hard_negative_weight) | |
| ) | |
| logits = torch.cat(denominator_parts, dim=1) | |
| return -(positive_scores * self.scale - torch.logsumexp(logits, dim=1)).mean() | |
| def _weighted_logits( | |
| self, similarities: Tensor, keep: Tensor, weight: float | |
| ) -> Tensor: | |
| if weight == 0: | |
| return torch.full_like(similarities, -torch.inf) | |
| return torch.where( | |
| keep, similarities * self.scale + math.log(weight), -torch.inf | |
| ) | |
| def forward( | |
| self, sentence_features: Iterable[dict[str, Tensor]], labels: Tensor | |
| ) -> Tensor: | |
| del labels | |
| embeddings = [ | |
| self.model(features)["sentence_embedding"] for features in sentence_features | |
| ] | |
| return self.compute_loss_from_embeddings(embeddings) | |
| def get_config_dict(self) -> dict[str, Any]: | |
| return { | |
| "temperature": self.temperature, | |
| "margin": self.margin, | |
| "hard_negative_margin": self.hard_negative_margin, | |
| "bidirectional": self.bidirectional, | |
| "query_query_weight": self.query_query_weight, | |
| "document_document_weight": self.document_document_weight, | |
| "cross_document_weight": self.cross_document_weight, | |
| "hard_negative_weight": self.hard_negative_weight, | |
| "qat_int8_weight": self.qat_weights["int8"], | |
| "qat_binary_weight": self.qat_weights["binary"], | |
| "qat_n_quantized_per_step": self.qat_n_quantized_per_step, | |
| } | |
| # ============================================================================== | |
| # Hakari evaluation | |
| # ============================================================================== | |
| import importlib | |
| import sys | |
| from collections import defaultdict | |
| from collections.abc import Sequence | |
| from types import ModuleType | |
| from typing import Literal | |
| from sentence_transformers.sentence_transformer.evaluation import SequentialEvaluator | |
| def default_hakari_targets() -> list[dict[str, list[str] | str]]: | |
| return [ | |
| {"dataset": "NanoBEIR-en", "splits": []}, | |
| {"dataset": "NanoMIRACL", "splits": ["en"]}, | |
| ] | |
| def import_hakari(repo: str | Path) -> ModuleType: | |
| path = Path(repo).resolve() | |
| if not (path / "hakari_bench").is_dir(): | |
| raise FileNotFoundError(f"hakari-bench checkout not found: {path}") | |
| path_text = str(path) | |
| if path_text not in sys.path: | |
| sys.path.insert(0, path_text) | |
| hakari = importlib.import_module("hakari_bench.sentence_transformers") | |
| _install_fast_hakari_candidate_index() | |
| return hakari | |
| def _install_fast_hakari_candidate_index() -> None: | |
| """Use Arrow kernels when HAKARI restricts a corpus to reranking candidates. | |
| HAKARI's portable fallback iterates every corpus ID in Python to construct a | |
| candidate-only lazy corpus. That is correct, but the first small mid-run | |
| evaluation can spend minutes scanning large multilingual Nano corpora. A | |
| Hugging Face ``Dataset`` normally exposes its ID column as a PyArrow table, | |
| where ``is_in`` and ``indices_nonzero`` build the same ID-to-row map without | |
| a Python row loop. The hook is deliberately local to this trainer: it does | |
| not alter the user's HAKARI checkout, and it falls back to HAKARI's original | |
| class for another dataset implementation or an older PyArrow version. | |
| Set ``HAKARI_BENCH_FAST_CANDIDATE_INDEX=0`` to opt out when diagnosing an | |
| evaluation environment. | |
| """ | |
| if os.environ.get("HAKARI_BENCH_FAST_CANDIDATE_INDEX", "1") == "0": | |
| return | |
| try: | |
| evaluation = importlib.import_module("hakari_bench.evaluation") | |
| original = evaluation._LazyCorpusById | |
| except (AttributeError, ImportError): | |
| return | |
| if getattr(original, "_bekko_arrow_index", False): | |
| return | |
| class ArrowIndexedLazyCorpus(original): | |
| """Drop-in HAKARI lazy corpus whose ID index uses vectorized Arrow.""" | |
| _bekko_arrow_index = True | |
| def __init__(self, dataset: Any, ids: set[str]) -> None: | |
| try: | |
| import pyarrow as pa | |
| import pyarrow.compute as pc | |
| self._dataset = dataset | |
| self._max_text_chars = evaluation._reranker_document_max_chars() | |
| ids_only = ( | |
| dataset.select_columns(["_id"]) | |
| if hasattr(dataset, "select_columns") | |
| else dataset | |
| ) | |
| storage = getattr(ids_only, "_data", None) | |
| table = getattr(storage, "table", storage) | |
| if table is None or not hasattr(table, "column"): | |
| raise TypeError("dataset does not expose an Arrow ID column") | |
| id_column = table.column("_id") | |
| matching_rows = pc.indices_nonzero( | |
| pc.is_in(id_column, value_set=pa.array(list(ids))) | |
| ) | |
| matching_ids = pc.take(id_column, matching_rows).to_pylist() | |
| row_indices = [int(index) for index in matching_rows.to_pylist()] | |
| # ``Dataset.__getitem__`` is inexpensive for a single row but | |
| # becomes the dominant cost when HAKARI asks for tens of | |
| # thousands of candidate documents one by one. Gather the | |
| # same Arrow rows once, then preserve the Mapping interface | |
| # expected by HAKARI's scorer. | |
| matching_texts = dataset.select(row_indices)["text"] | |
| self._index_by_id = { | |
| str(corpus_id): int(row_index) | |
| for corpus_id, row_index in zip( | |
| matching_ids, row_indices, strict=True | |
| ) | |
| } | |
| self._text_by_id = { | |
| str(corpus_id): str(text) | |
| for corpus_id, text in zip( | |
| matching_ids, matching_texts, strict=True | |
| ) | |
| } | |
| except Exception: # noqa: BLE001 - preserve HAKARI's safe fallback. | |
| # Keep exact HAKARI semantics when its internal Dataset layout | |
| # changes or Arrow cannot provide the required kernel. | |
| super().__init__(dataset, ids) | |
| def __getitem__(self, key: str) -> str: | |
| if not hasattr(self, "_text_by_id"): | |
| return super().__getitem__(key) | |
| text = self._text_by_id[key] | |
| if self._max_text_chars is not None and len(text) > self._max_text_chars: | |
| return text[: self._max_text_chars] | |
| return text | |
| evaluation._LazyCorpusById = ArrowIndexedLazyCorpus | |
| def build_hakari_evaluator( | |
| *, | |
| hakari_repo: str | Path, | |
| mode: Literal["mid", "full"], | |
| batch_size: int, | |
| query_seed: int, | |
| embedding_variants: list[dict[str, Any]] | None = None, | |
| target_specs: Sequence[Mapping[str, Any]] | None = None, | |
| query_limit: int = 10, | |
| candidate_ranking: str = "reranking_hybrid", | |
| rerank_top_k: int = 100, | |
| ) -> Any: | |
| hakari = import_hakari(hakari_repo) | |
| is_mid = mode == "mid" | |
| if is_mid: | |
| # Candidate reranking evaluation only monitors training. Static v1 | |
| # encodes at most 512 tokens, so retaining arbitrarily long candidate | |
| # text merely spends CPU tokenizing suffixes that cannot reach the | |
| # model. This HAKARI setting is read by its candidate-only lazy corpus | |
| # and does not affect the separate full HAKARI command after training. | |
| os.environ.setdefault("HAKARI_RERANKER_DOCUMENT_MAX_CHARS", "8192") | |
| raw_specs = list(target_specs or default_hakari_targets()) | |
| grouped_specs: dict[int | None, list[Mapping[str, Any]]] = defaultdict(list) | |
| for spec in raw_specs: | |
| raw_limit = spec.get("query_limit", query_limit) | |
| if is_mid and not isinstance(raw_limit, (int, str)): | |
| raise TypeError("evaluation target query_limit must be an integer") | |
| limit = int(cast(int | str, raw_limit)) if is_mid else None | |
| grouped_specs[limit].append(spec) | |
| evaluators = [] | |
| for group_index, (limit, specs) in enumerate(grouped_specs.items()): | |
| targets = [] | |
| for spec in specs: | |
| kwargs: dict[str, Any] = {"splits": list(spec.get("splits", []))} | |
| if spec.get("collection") is not None: | |
| kwargs["collection"] = spec["collection"] | |
| else: | |
| kwargs["dataset"] = spec["dataset"] | |
| targets.append(hakari.HakariNanoTarget(**kwargs)) | |
| if len(grouped_specs) == 1: | |
| name = "mid_eval" if is_mid else "full_eval" | |
| else: | |
| labels = [ | |
| str(spec.get("dataset") or spec.get("collection")) for spec in specs | |
| ] | |
| slug = "_".join(label.lower().replace("-", "_") for label in labels) | |
| name = f"mid_eval_{slug}" if is_mid else f"full_eval_{group_index}" | |
| evaluators.append( | |
| hakari.HakariNanoEmbeddingEvaluator( | |
| targets=targets, | |
| name=name, | |
| batch_size=batch_size, | |
| metrics=("nDCG@10", "mAP@10"), | |
| query_limit=limit, | |
| query_sample_seed=query_seed, | |
| corpus_policy="sampled_candidates" if is_mid else "full", | |
| candidate_ranking=candidate_ranking if is_mid else None, | |
| rerank_top_k=rerank_top_k if is_mid else None, | |
| cache_datasets=True, | |
| embedding_variants=embedding_variants, | |
| ) | |
| ) | |
| if len(evaluators) == 1: | |
| return evaluators[0] | |
| return SequentialEvaluator( | |
| evaluators, | |
| main_score_function=lambda scores: sum(scores) / len(scores), | |
| ) | |
| def build_dimension_quantization_variants( | |
| hakari_repo: str | Path, | |
| *, | |
| dimensions: tuple[int, ...], | |
| precisions: tuple[Literal["int8", "binary"], ...] = (), | |
| ) -> list[dict[str, Any]]: | |
| if not dimensions: | |
| return [] | |
| if any(dim <= 0 for dim in dimensions): | |
| raise ValueError("dimensions must be positive") | |
| import_hakari(hakari_repo) | |
| truncate = "truncate:" + ",".join(str(dim) for dim in dimensions) | |
| precision_spec = ",".join(precisions) | |
| values = [truncate] | |
| cross_values: list[list[str]] = [] | |
| if precisions: | |
| values.append(precision_spec) | |
| cross_values.append([truncate, precision_spec]) | |
| variants_module = importlib.import_module("hakari_bench.embedding_variants") | |
| return variants_module.parse_embedding_variants(values, cross_values) | |
| def write_evaluation_result( | |
| path: Path, | |
| metrics: dict[str, float], | |
| metadata: dict[str, Any], | |
| *, | |
| variants: dict[str, dict[str, float]] | None = None, | |
| ) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| payload: dict[str, Any] = {"metadata": metadata, "metrics": metrics} | |
| if variants is not None: | |
| payload["variants"] = variants | |
| path.write_text( | |
| json.dumps(payload, indent=2, sort_keys=True) + "\n", | |
| encoding="utf-8", | |
| ) | |
| def evaluate_hakari_with_variants( | |
| evaluator: Any, | |
| model: Any, | |
| *, | |
| output_path: str | None, | |
| ) -> tuple[dict[str, float], dict[str, dict[str, float]]]: | |
| """Preserve Hakari embedding-variant metrics that its ST adapter omits.""" | |
| hakari_evaluation = importlib.import_module("hakari_bench.evaluation") | |
| base_task_metrics: list[dict[str, float]] = [] | |
| variant_task_metrics: dict[str, list[dict[str, float]]] = {} | |
| for task in evaluator.tasks: | |
| dataset = evaluator._load_dataset(task) | |
| evaluation = hakari_evaluation.evaluate_dense_task( | |
| model=model, | |
| dataset=dataset, | |
| batch_size=evaluator.batch_size, | |
| show_progress=evaluator.show_progress_bar, | |
| query_prompt=evaluator.query_prompt, | |
| corpus_prompt=evaluator.document_prompt, | |
| query_prompt_name=evaluator.query_prompt_name, | |
| corpus_prompt_name=evaluator.document_prompt_name, | |
| query_task=evaluator.query_encode_task, | |
| corpus_task=evaluator.document_encode_task, | |
| truncate_dim=evaluator.truncate_dim, | |
| truncate_sparse_query_max_dims=evaluator.sparse_query_max_active_dims, | |
| truncate_sparse_docs_max_dims=evaluator.sparse_document_max_active_dims, | |
| embedding_variants=evaluator.embedding_variants, | |
| aggregate_metric=evaluator.aggregate_metric_suffix, | |
| score_device=evaluator.retrieval_score_device, | |
| rerank_top_n=evaluator.rerank_top_k, | |
| candidate_ranking_name=evaluator.candidate_ranking, | |
| metric_names=evaluator.metric_suffixes, | |
| ) | |
| base_task_metrics.append(evaluation.metrics) | |
| for embedding_evaluation in evaluation.embedding_evaluations: | |
| name = str(embedding_evaluation["name"]) | |
| if name == "base": | |
| continue | |
| variant_task_metrics.setdefault(name, []).append( | |
| dict(embedding_evaluation["metrics"]) | |
| ) | |
| base = evaluator._finalize( | |
| model=model, | |
| metrics=base_task_metrics, | |
| output_path=output_path, | |
| epoch=-1, | |
| steps=-1, | |
| ) | |
| expected_tasks = len(evaluator.tasks) | |
| variants = { | |
| name: evaluator._finalize( | |
| model=model, metrics=task_metrics, output_path=None, epoch=-1, steps=-1 | |
| ) | |
| for name, task_metrics in variant_task_metrics.items() | |
| if len(task_metrics) == expected_tasks | |
| } | |
| return base, variants | |
| # ============================================================================== | |
| # Training orchestration and CLI | |
| # ============================================================================== | |
| import argparse | |
| import subprocess | |
| import time | |
| def materialize_config_shard( | |
| config: TrainConfig, | |
| *, | |
| shard_index: int, | |
| num_shards: int, | |
| ) -> dict[str, int]: | |
| if num_shards <= 0 or not 0 <= shard_index < num_shards: | |
| raise ValueError( | |
| f"invalid materialization shard: index={shard_index} count={num_shards}" | |
| ) | |
| manifest = _read_direct_huggingface_manifest(config.data.spec) | |
| if manifest is None: | |
| raise ValueError("parallel materialization requires a direct Bekko manifest") | |
| raw_configs = manifest["configs"] | |
| if not isinstance(raw_configs, list): | |
| raise TypeError("direct manifest configs must be a list") | |
| manifest["configs"] = raw_configs[shard_index::num_shards] | |
| datasets, data_manifest, _ = load_direct_huggingface_datasets( | |
| manifest=manifest, | |
| fraction=config.data.fraction, | |
| seed=config.data.seed, | |
| require_cache=False, | |
| shared_id_block_size=config.data.shared_id_block_size, | |
| materialized_root=config.data.stage_cache_root, | |
| source_cache_direct=False, | |
| shared_id_column=config.data.shared_id_column, | |
| ) | |
| return { | |
| "shard_index": shard_index, | |
| "configs": len(datasets), | |
| "selected_rows": sum( | |
| int(item["selected_rows"]) for item in data_manifest.values() | |
| ), | |
| } | |
| def materialize_named_configs( | |
| config: TrainConfig, *, config_names: list[str] | |
| ) -> dict[str, int | list[str]]: | |
| """Materialize selected manifest configs for targeted cache recovery.""" | |
| if not config_names: | |
| raise ValueError("at least one materialization config name is required") | |
| manifest = _read_direct_huggingface_manifest(config.data.spec) | |
| if manifest is None: | |
| raise ValueError("named materialization requires a direct Bekko manifest") | |
| raw_configs = manifest["configs"] | |
| if not isinstance(raw_configs, list): | |
| raise TypeError("direct manifest configs must be a list") | |
| requested = set(config_names) | |
| selected = [raw for raw in raw_configs if str(raw["name"]) in requested] | |
| found = {str(raw["name"]) for raw in selected} | |
| missing = sorted(requested.difference(found)) | |
| if missing: | |
| raise ValueError(f"unknown direct manifest configs: {missing}") | |
| manifest["configs"] = selected | |
| datasets, data_manifest, _ = load_direct_huggingface_datasets( | |
| manifest=manifest, | |
| fraction=config.data.fraction, | |
| seed=config.data.seed, | |
| require_cache=False, | |
| shared_id_block_size=config.data.shared_id_block_size, | |
| materialized_root=config.data.stage_cache_root, | |
| source_cache_direct=False, | |
| shared_id_column=config.data.shared_id_column, | |
| ) | |
| return { | |
| "config_names": [str(raw["name"]) for raw in selected], | |
| "configs": len(datasets), | |
| "selected_rows": sum( | |
| int(item["selected_rows"]) for item in data_manifest.values() | |
| ), | |
| } | |
| def _materialized_cache_is_complete( | |
| cache_path: Path, | |
| *, | |
| expected_rows: int, | |
| task: str, | |
| shared_id_column: str, | |
| allow_fewer_rows: bool = False, | |
| ) -> bool: | |
| """Validate a saved Dataset from its small metadata files only. | |
| Restarting a full run must not reopen and project every Arrow shard merely to | |
| discover that materialization already finished. The files written last by | |
| ``Dataset.save_to_disk`` describe the expected row count, schema, and Arrow | |
| members, so they provide a cheap and deterministic completion check. | |
| """ | |
| try: | |
| state = json.loads((cache_path / "state.json").read_text(encoding="utf-8")) | |
| info = json.loads( | |
| (cache_path / "dataset_info.json").read_text(encoding="utf-8") | |
| ) | |
| train_info = info["splits"]["train"] | |
| actual_rows = int(train_info["num_examples"]) | |
| rows_match = ( | |
| 0 < actual_rows <= expected_rows | |
| if allow_fewer_rows | |
| else actual_rows == expected_rows | |
| ) | |
| if not rows_match: | |
| return False | |
| required_columns = {"anchor", "positive", shared_id_column} | |
| if task == "triplet": | |
| required_columns.add("negative") | |
| if not required_columns.issubset(info["features"]): | |
| return False | |
| data_files = state["_data_files"] | |
| if not isinstance(data_files, list) or not data_files: | |
| return False | |
| for item in data_files: | |
| filename = item["filename"] | |
| path = cache_path / filename | |
| if not path.is_file() or path.stat().st_size == 0: | |
| return False | |
| except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError): | |
| return False | |
| return True | |
| def all_direct_materialized_caches_complete(config: TrainConfig) -> bool: | |
| """Return whether every configured direct Bekko cache is complete.""" | |
| if config.data.stage_cache_root is None: | |
| return False | |
| manifest = _read_direct_huggingface_manifest(config.data.spec) | |
| if manifest is None: | |
| return False | |
| raw_configs = manifest.get("configs") | |
| if not isinstance(raw_configs, list) or not raw_configs: | |
| return False | |
| root = config.data.stage_cache_root.resolve() | |
| for raw in raw_configs: | |
| if not isinstance(raw, dict): | |
| return False | |
| source_rows = int(raw["source_rows_all_splits"]) | |
| expected_rows = min( | |
| source_rows, | |
| max( | |
| config.data.shared_id_block_size, | |
| math.ceil( | |
| source_rows | |
| * config.data.fraction | |
| / config.data.shared_id_block_size | |
| ) | |
| * config.data.shared_id_block_size, | |
| ), | |
| ) | |
| cache_path = _direct_materialized_path( | |
| root, | |
| str(raw["name"]), | |
| config.data.fraction, | |
| config.data.seed, | |
| ) | |
| if not _materialized_cache_is_complete( | |
| cache_path, | |
| expected_rows=expected_rows, | |
| task=str(raw["task"]), | |
| shared_id_column=config.data.shared_id_column, | |
| # The public manifest records an aligned all-splits upper bound. | |
| # A full train split can legitimately end before that bound. | |
| allow_fewer_rows=config.data.fraction == 1.0, | |
| ): | |
| return False | |
| return True | |
| def materialize_in_parallel(config: TrainConfig) -> None: | |
| workers = config.data.materialize_workers | |
| if workers == 1 or config.data.direct_source_cache: | |
| return | |
| if all_direct_materialized_caches_complete(config): | |
| print( | |
| "All direct Bekko caches are complete; skipping materialization workers.", | |
| flush=True, | |
| ) | |
| return | |
| def command(index: int) -> list[str]: | |
| return [ | |
| sys.executable, | |
| str(Path(__file__).resolve()), | |
| "--config", | |
| str(config.source_path), | |
| "--materialize-shard", | |
| str(index), | |
| "--materialize-num-shards", | |
| str(workers), | |
| ] | |
| pending = list(range(workers)) | |
| failures: list[tuple[int, int]] = [] | |
| for attempt in range(1, config.data.materialize_max_attempts + 1): | |
| processes = {index: subprocess.Popen(command(index)) for index in pending} | |
| failures = [ | |
| (index, return_code) | |
| for index, process in processes.items() | |
| if (return_code := process.wait()) != 0 | |
| ] | |
| if not failures: | |
| return | |
| if attempt < config.data.materialize_max_attempts: | |
| pending = [index for index, _ in failures] | |
| print( | |
| "Materialization attempt " | |
| f"{attempt}/{config.data.materialize_max_attempts} failed for " | |
| f"shards {failures}; retrying those shards after " | |
| f"{config.data.materialize_retry_delay_seconds:g}s.", | |
| flush=True, | |
| ) | |
| time.sleep(config.data.materialize_retry_delay_seconds) | |
| raise RuntimeError( | |
| "parallel materialization exhausted " | |
| f"{config.data.materialize_max_attempts} attempts: {failures}" | |
| ) | |
| from typing import Literal | |
| from sentence_transformers import ( | |
| SentenceTransformerTrainer, | |
| SentenceTransformerTrainingArguments, | |
| ) | |
| from sentence_transformers.sentence_transformer.losses import ( | |
| MatryoshkaLoss, | |
| MultipleNegativesRankingLoss, | |
| ) | |
| from sentence_transformers.sentence_transformer.training_args import ( | |
| BatchSamplers, | |
| MultiDatasetBatchSamplers, | |
| ) | |
| from torch import nn | |
| from torch.optim import Optimizer | |
| from torch.utils.data import BatchSampler | |
| from transformers import ( | |
| TrainerCallback, | |
| TrainerControl, | |
| TrainerState, | |
| TrainingArguments, | |
| ) | |
| class LossComponentLoggingCallback(TrainerCallback): | |
| """Expose per-dimension float/QAT loss components in trainer and W&B logs.""" | |
| def __init__(self, losses: list[Qwen3EmbeddingLoss]) -> None: | |
| self.losses = losses | |
| def on_log( | |
| self, | |
| args: TrainingArguments, | |
| state: TrainerState, | |
| control: TrainerControl, | |
| logs: dict[str, float] | None = None, | |
| **kwargs: object, | |
| ) -> None: | |
| del args, state, control, kwargs | |
| if logs is None: | |
| return | |
| for loss_index, loss in enumerate(self.losses): | |
| prefix = ( | |
| "loss_component" | |
| if len(self.losses) == 1 | |
| else f"loss_{loss_index}_component" | |
| ) | |
| for name, value in loss.last_loss_components.items(): | |
| logs[f"{prefix}/{name}"] = value.item() | |
| class EvaluationProgressCallback(TrainerCallback): | |
| """Print every sampled Hakari result as one machine-readable progress line.""" | |
| def __init__(self, target_evaluations: int) -> None: | |
| if target_evaluations <= 0: | |
| raise ValueError("target_evaluations must be positive") | |
| self.target_evaluations = target_evaluations | |
| self.completed_evaluations = 0 | |
| def on_evaluate( | |
| self, | |
| args: TrainingArguments, | |
| state: TrainerState, | |
| control: TrainerControl, | |
| metrics: dict[str, float] | None = None, | |
| **kwargs: object, | |
| ) -> None: | |
| del args, control, kwargs | |
| self.completed_evaluations += 1 | |
| numeric_metrics = { | |
| key: float(value) | |
| for key, value in (metrics or {}).items() | |
| if isinstance(value, (int, float, np.number)) | |
| } | |
| payload = { | |
| "event": "mid_eval_result", | |
| "evaluation": self.completed_evaluations, | |
| "target_evaluations": self.target_evaluations, | |
| "global_step": int(state.global_step), | |
| "max_steps": int(state.max_steps), | |
| "training_progress_percent": ( | |
| 100.0 * state.global_step / state.max_steps if state.max_steps else 0.0 | |
| ), | |
| "metrics": numeric_metrics, | |
| } | |
| print("MID_EVAL_RESULT " + json.dumps(payload, sort_keys=True), flush=True) | |
| def split_sse_optimizer_groups( | |
| optimizer: Optimizer, | |
| model: nn.Module, | |
| *, | |
| sse_learning_rate: float, | |
| ) -> None: | |
| """Move trainable SeparableDyT parameters into LR-specific optimizer groups.""" | |
| dyt_parameter_ids = { | |
| id(parameter) | |
| for name, parameter in model.named_parameters() | |
| if parameter.requires_grad and (name.startswith("dyt.") or ".dyt." in name) | |
| } | |
| if not dyt_parameter_ids: | |
| return | |
| new_groups: list[dict[str, object]] = [] | |
| for group in optimizer.param_groups: | |
| regular = [ | |
| parameter | |
| for parameter in group["params"] | |
| if id(parameter) not in dyt_parameter_ids | |
| ] | |
| dyt = [ | |
| parameter | |
| for parameter in group["params"] | |
| if id(parameter) in dyt_parameter_ids | |
| ] | |
| if regular: | |
| new_groups.append({**group, "params": regular}) | |
| if dyt: | |
| dyt_group = {**group, "params": dyt, "lr": sse_learning_rate} | |
| if "initial_lr" in dyt_group: | |
| dyt_group["initial_lr"] = sse_learning_rate | |
| new_groups.append(dyt_group) | |
| optimizer.param_groups[:] = new_groups | |
| class SSESentenceTransformerTrainer(SentenceTransformerTrainer): | |
| def __init__( | |
| self, | |
| *args: object, | |
| sse_learning_rate: float | None = None, | |
| locality_block_size: int | None = None, | |
| **kwargs: object, | |
| ) -> None: | |
| self.sse_learning_rate = sse_learning_rate | |
| self.locality_block_size = locality_block_size | |
| super().__init__(*args, **kwargs) | |
| def create_optimizer(self, model: nn.Module | None = None) -> Optimizer: | |
| optimizer = super().create_optimizer(model=model) | |
| if self.sse_learning_rate is not None: | |
| split_sse_optimizer_groups( | |
| optimizer, | |
| self.model if model is None else model, | |
| sse_learning_rate=self.sse_learning_rate, | |
| ) | |
| return optimizer | |
| def get_eval_dataloader(self, eval_dataset: Any = None) -> Any: | |
| """Build evaluation loaders without forking after CUDA initialization. | |
| Training benefits substantially from parallel reads over the materialized | |
| Arrow collection. Evaluation is different: its small loss dataset is first | |
| opened after CUDA and tokenizer thread pools are active. Forking workers at | |
| that point can hang before the first batch. Keep the configured workers for | |
| training, but construct each evaluation loader in the trainer process. | |
| """ | |
| workers = self.args.dataloader_num_workers | |
| prefetch = self.args.dataloader_prefetch_factor | |
| persistent = self.args.dataloader_persistent_workers | |
| try: | |
| self.args.dataloader_num_workers = 0 | |
| self.args.dataloader_prefetch_factor = None | |
| self.args.dataloader_persistent_workers = False | |
| return super().get_eval_dataloader(eval_dataset) | |
| finally: | |
| self.args.dataloader_num_workers = workers | |
| self.args.dataloader_prefetch_factor = prefetch | |
| self.args.dataloader_persistent_workers = persistent | |
| def get_batch_sampler( | |
| self, | |
| dataset: Dataset, | |
| batch_size: int, | |
| drop_last: bool, | |
| valid_label_columns: list[str] | None = None, | |
| generator: torch.Generator | None = None, | |
| seed: int = 0, | |
| ) -> BatchSampler | None: | |
| if representation_batch_plan(dataset) is not None: | |
| return RepresentationNoDuplicatesBatchSampler( | |
| dataset, | |
| batch_size=batch_size, | |
| drop_last=drop_last, | |
| valid_label_columns=valid_label_columns, | |
| generator=generator, | |
| seed=seed, | |
| ) | |
| if self.locality_block_size is not None: | |
| return LocalityAwareNoDuplicatesBatchSampler( | |
| dataset, | |
| batch_size=batch_size, | |
| drop_last=drop_last, | |
| valid_label_columns=valid_label_columns, | |
| generator=generator, | |
| seed=seed, | |
| locality_block_size=self.locality_block_size, | |
| ) | |
| return super().get_batch_sampler( | |
| dataset, | |
| batch_size=batch_size, | |
| drop_last=drop_last, | |
| valid_label_columns=valid_label_columns, | |
| generator=generator, | |
| seed=seed, | |
| ) | |
| def train(config: TrainConfig) -> Path: | |
| _seed_everything(config.training.seed) | |
| output_dir = config.run.output_dir.resolve() | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| train_dataset, data_manifest, pair_types = load_bekko_datasets( | |
| spec=config.data.spec, | |
| tasks=config.data.tasks, | |
| fraction=config.data.fraction, | |
| seed=config.data.seed, | |
| require_cache=config.data.require_cache, | |
| shared_id_column=config.data.shared_id_column, | |
| shared_id_block_size=config.data.shared_id_block_size, | |
| supplements=config.data.supplements, | |
| stage_cache_root=config.data.stage_cache_root, | |
| require_stage_cache=config.data.require_stage_cache, | |
| direct_source_cache=config.data.direct_source_cache, | |
| ) | |
| model = build_scratch_model( | |
| config.model.tokenizer_model, | |
| embedding_dim=config.model.embedding_dim, | |
| max_seq_length=config.model.max_seq_length, | |
| architecture=config.model.architecture, | |
| sse_preprocessing=config.model.sse_preprocessing, | |
| sse_alpha_init=config.model.sse_alpha_init, | |
| sse_train_alpha=config.model.sse_train_alpha, | |
| sse_train_beta=config.model.sse_train_beta, | |
| sse_train_bias=config.model.sse_train_bias, | |
| text_preprocessing=config.model.text_preprocessing, | |
| ) | |
| representation_manifests = prepare_representation_batch_plans( | |
| train_dataset, | |
| tokenizer_path=Path(config.model.tokenizer_model), | |
| text_preprocessing=config.model.text_preprocessing, | |
| max_seq_length=config.model.max_seq_length, | |
| batch_size=config.training.batch_size, | |
| seed=config.training.seed, | |
| default_cache_root=output_dir / "representation-cache", | |
| ) | |
| for name, manifest in representation_manifests.items(): | |
| data_manifest[name].update( | |
| {f"representation_{key}": value for key, value in manifest.items()} | |
| ) | |
| component_batches = [] | |
| for name, dataset in train_dataset.items(): | |
| plan = representation_batch_plan(dataset) | |
| component_batches.append( | |
| len(plan) | |
| if plan is not None | |
| else math.ceil(len(dataset) / config.training.batch_size) | |
| ) | |
| schedule = derive_multidataset_step_schedule( | |
| component_rows=[ | |
| count * config.training.batch_size for count in component_batches | |
| ], | |
| batch_size=config.training.batch_size, | |
| epochs=config.training.epochs, | |
| target_evaluations=config.training.target_evaluations, | |
| exact_target_evaluations=config.training.exact_target_evaluations, | |
| ) | |
| (output_dir / "data_manifest.json").write_text( | |
| json.dumps(data_manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" | |
| ) | |
| loss = build_training_loss( | |
| model, | |
| settings=config.loss, | |
| matryoshka_dims=config.model.matryoshka_dims, | |
| matryoshka_weights=config.model.matryoshka_weights, | |
| pair_types=pair_types, | |
| ) | |
| evaluator = build_hakari_evaluator( | |
| hakari_repo=config.evaluation.hakari_repo, | |
| mode="mid", | |
| batch_size=config.evaluation.batch_size, | |
| query_seed=config.evaluation.query_seed, | |
| target_specs=[ | |
| { | |
| "dataset": target.dataset, | |
| "collection": target.collection, | |
| "splits": list(target.splits), | |
| "query_limit": target.query_limit, | |
| } | |
| for target in config.evaluation.targets | |
| ] | |
| or None, | |
| query_limit=config.evaluation.query_limit, | |
| candidate_ranking=config.evaluation.candidate_ranking, | |
| rerank_top_k=config.evaluation.rerank_top_k, | |
| ) | |
| if config.tracking.enabled: | |
| os.environ["WANDB_PROJECT"] = config.tracking.project | |
| report_to = ["wandb"] if config.tracking.enabled else [] | |
| args = SentenceTransformerTrainingArguments( | |
| output_dir=str(output_dir), | |
| num_train_epochs=config.training.epochs, | |
| per_device_train_batch_size=config.training.batch_size, | |
| per_device_eval_batch_size=config.training.eval_batch_size, | |
| learning_rate=config.training.learning_rate, | |
| adam_beta2=config.training.adam_beta2, | |
| adam_epsilon=config.training.adam_epsilon, | |
| weight_decay=config.training.weight_decay, | |
| lr_scheduler_type="cosine", | |
| warmup_steps=config.training.warmup_ratio, | |
| bf16=True, | |
| fp16=False, | |
| batch_sampler=BatchSamplers.NO_DUPLICATES, | |
| multi_dataset_batch_sampler=MultiDatasetBatchSamplers.PROPORTIONAL, | |
| eval_strategy="steps", | |
| eval_steps=schedule.eval_steps, | |
| save_strategy="steps" if config.training.save_checkpoints else "no", | |
| save_steps=schedule.save_steps, | |
| save_total_limit=config.training.save_total_limit, | |
| logging_steps=max(1, math.ceil(schedule.eval_steps / 5)), | |
| logging_first_step=True, | |
| dataloader_num_workers=config.training.dataloader_num_workers, | |
| dataloader_prefetch_factor=config.training.dataloader_prefetch_factor, | |
| report_to=report_to, | |
| run_name=config.run.name, | |
| seed=config.training.seed, | |
| data_seed=config.data.seed, | |
| load_best_model_at_end=False, | |
| ) | |
| trainer = SSESentenceTransformerTrainer( | |
| model=model, | |
| args=args, | |
| train_dataset=train_dataset, | |
| # Intermediate evaluation is retrieval-only. Passing a training-derived | |
| # eval dataset makes Trainer run an additional loss-evaluation DataLoader | |
| # before NanoMIRACL/MNanoBEIR. Besides being outside the intended metric, | |
| # that loader can deadlock while training workers hold the very large | |
| # memory-mapped Arrow collection. With an evaluator and no eval dataset, | |
| # SentenceTransformers uses an empty loss loader and directly runs the | |
| # small fixed-candidate retrieval evaluator. | |
| eval_dataset=None, | |
| loss=loss, | |
| evaluator=evaluator, | |
| callbacks=[ | |
| LossComponentLoggingCallback(_qwen_losses(loss)), | |
| EvaluationProgressCallback(config.training.target_evaluations), | |
| ], | |
| sse_learning_rate=config.training.sse_learning_rate, | |
| locality_block_size=config.training.dataloader_locality_block_size, | |
| ) | |
| # SentenceTransformers 5.6 records heterogeneous DatasetDict metadata in a | |
| # shape its model-card template cannot render. Keep checkpoints quiet and | |
| # generate the final card separately once the run metadata is complete. | |
| model.model_card_data.train_datasets = [] | |
| model.model_card_data.eval_datasets = [] | |
| model.model_card_data.generate_widget_examples = False | |
| trainer.train() | |
| final_dir = output_dir / "final" | |
| model.save_pretrained(str(final_dir)) | |
| set_final_tokenizer_max_length(final_dir, config.model.final_max_seq_length) | |
| return final_dir | |
| def set_final_tokenizer_max_length(model_dir: Path, max_length: int) -> None: | |
| """Expand only the exported tokenizer after bounded-length training. | |
| Training and checkpoints retain ``model.max_seq_length``. The final model is | |
| rewritten after serialization so inference can consume much longer inputs | |
| without allocating unbounded tokenizer output. | |
| """ | |
| if max_length <= 0: | |
| raise ValueError("final tokenizer max_length must be positive") | |
| tokenizer_path = model_dir / "tokenizer.json" | |
| if not tokenizer_path.is_file(): | |
| raise FileNotFoundError(f"final tokenizer not found: {tokenizer_path}") | |
| tokenizer = Tokenizer.from_file(str(tokenizer_path)) | |
| tokenizer.enable_truncation(max_length=max_length) | |
| temporary_path = tokenizer_path.with_name(tokenizer_path.name + ".tmp") | |
| tokenizer.save(str(temporary_path)) | |
| os.replace(temporary_path, tokenizer_path) | |
| config_path = model_dir / "tokenizer_config.json" | |
| if config_path.is_file(): | |
| payload = json.loads(config_path.read_text(encoding="utf-8")) | |
| if not isinstance(payload, dict): | |
| raise TypeError(f"tokenizer config must be a mapping: {config_path}") | |
| payload["model_max_length"] = max_length | |
| temporary_config = config_path.with_name(config_path.name + ".tmp") | |
| temporary_config.write_text( | |
| json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" | |
| ) | |
| os.replace(temporary_config, config_path) | |
| def _qwen_losses(loss: nn.Module | dict[str, nn.Module]) -> list[Qwen3EmbeddingLoss]: | |
| wrappers = list(loss.values()) if isinstance(loss, dict) else [loss] | |
| result: list[Qwen3EmbeddingLoss] = [] | |
| for wrapper in wrappers: | |
| if isinstance(wrapper, Qwen3EmbeddingLoss): | |
| result.append(wrapper) | |
| elif isinstance(wrapper, MatryoshkaLoss) and isinstance( | |
| wrapper.loss, Qwen3EmbeddingLoss | |
| ): | |
| result.append(wrapper.loss) | |
| return result | |
| def build_full_hakari_command(config: TrainConfig, final_dir: Path) -> list[str]: | |
| """Build the resumable full-evaluation command for the saved final model.""" | |
| settings = config.evaluation | |
| results_dir = ( | |
| settings.full_results_dir | |
| if settings.full_results_dir is not None | |
| else config.run.output_dir / "full-eval" / "hakari-results" | |
| ).resolve() | |
| command = [ | |
| "uv", | |
| "run", | |
| "--project", | |
| str(settings.hakari_repo.resolve()), | |
| "hakari-bench", | |
| "evaluate", | |
| "dense", | |
| "--model", | |
| str(final_dir.resolve()), | |
| "--model-alias", | |
| settings.full_model_alias or config.run.name, | |
| "--dtype", | |
| settings.full_dtype, | |
| "--device", | |
| settings.full_device, | |
| "--retrieval-score-device", | |
| settings.full_retrieval_score_device, | |
| "--batch-size", | |
| str(settings.batch_size), | |
| "--results-dir", | |
| str(results_dir), | |
| "--result-format", | |
| settings.full_result_format, | |
| "--show-progress", | |
| ] | |
| truncate_dims = sorted( | |
| (dim for dim in config.model.matryoshka_dims if dim < config.model.embedding_dim), | |
| reverse=True, | |
| ) | |
| if truncate_dims: | |
| command.extend( | |
| ["--embedding-variant", "truncate:" + ",".join(map(str, truncate_dims))] | |
| ) | |
| if settings.full_all: | |
| command.append("--all") | |
| else: | |
| for target in settings.targets: | |
| if target.collection is not None: | |
| command.extend(["--collection", target.collection]) | |
| elif target.dataset is not None: | |
| command.extend(["--dataset", target.dataset]) | |
| for split in target.splits: | |
| command.extend(["--split", split]) | |
| if settings.full_overwrite: | |
| command.append("--overwrite") | |
| return command | |
| def run_full_hakari_evaluation(config: TrainConfig, final_dir: Path) -> Path | None: | |
| """Run all-query/full-corpus Hakari evaluation after successful training.""" | |
| if not config.evaluation.full_after_training: | |
| return None | |
| command = build_full_hakari_command(config, final_dir) | |
| manifest_path = config.run.output_dir.resolve() / "full-eval" / "run.json" | |
| manifest_path.parent.mkdir(parents=True, exist_ok=True) | |
| started_at = time.time() | |
| payload: dict[str, Any] = { | |
| "status": "running", | |
| "started_at_unix": started_at, | |
| "command": command, | |
| "model": str(final_dir.resolve()), | |
| } | |
| manifest_path.write_text( | |
| json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" | |
| ) | |
| print("FULL_EVAL_START " + json.dumps(payload, sort_keys=True), flush=True) | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| try: | |
| subprocess.run(command, check=True) | |
| except BaseException as error: | |
| payload.update( | |
| { | |
| "status": "failed", | |
| "finished_at_unix": time.time(), | |
| "error": f"{type(error).__name__}: {error}", | |
| } | |
| ) | |
| manifest_path.write_text( | |
| json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" | |
| ) | |
| print("FULL_EVAL_FAILED " + json.dumps(payload, sort_keys=True), flush=True) | |
| raise | |
| payload.update( | |
| { | |
| "status": "completed", | |
| "finished_at_unix": time.time(), | |
| "runtime_seconds": time.time() - started_at, | |
| } | |
| ) | |
| manifest_path.write_text( | |
| json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" | |
| ) | |
| print("FULL_EVAL_COMPLETE " + json.dumps(payload, sort_keys=True), flush=True) | |
| return manifest_path | |
| def build_training_loss( | |
| model: SentenceTransformer, | |
| *, | |
| settings: LossSettings, | |
| matryoshka_dims: tuple[int, ...], | |
| matryoshka_weights: tuple[float, ...], | |
| pair_types: Mapping[str, Literal["query_doc", "doc_doc"]], | |
| ) -> nn.Module | dict[str, nn.Module]: | |
| def make_loss(*, bidirectional: bool) -> nn.Module: | |
| if settings.name == "qwen3_improved_infonce": | |
| base_loss = Qwen3EmbeddingLoss( | |
| model, | |
| temperature=settings.temperature, | |
| margin=settings.margin, | |
| hard_negative_margin=settings.hard_negative_margin, | |
| bidirectional=bidirectional, | |
| query_query_weight=settings.query_query_weight, | |
| document_document_weight=settings.document_document_weight, | |
| cross_document_weight=settings.cross_document_weight, | |
| hard_negative_weight=settings.hard_negative_weight, | |
| qat_int8_weight=settings.qat_int8_weight, | |
| qat_binary_weight=settings.qat_binary_weight, | |
| qat_n_quantized_per_step=settings.qat_n_quantized_per_step, | |
| ) | |
| else: | |
| # Keep ``loss.temperature`` meaningful for both objective families. | |
| # Sentence Transformers exposes MNRL temperature as its reciprocal | |
| # ``scale`` argument instead of accepting temperature directly. | |
| base_loss = MultipleNegativesRankingLoss( | |
| model, scale=1.0 / settings.temperature | |
| ) | |
| if not matryoshka_dims: | |
| return base_loss | |
| return MatryoshkaLoss( | |
| model, | |
| base_loss, | |
| matryoshka_dims=list(matryoshka_dims), | |
| matryoshka_weights=list(matryoshka_weights), | |
| ) | |
| if settings.pair_type_routing == "shared": | |
| return make_loss(bidirectional=False) | |
| return { | |
| key: make_loss( | |
| bidirectional=pair_type == "doc_doc" and settings.doc_doc_bidirectional | |
| ) | |
| for key, pair_type in pair_types.items() | |
| } | |
| def _seed_everything(seed: int) -> None: | |
| random.seed(seed) | |
| np.random.seed(seed) | |
| torch.manual_seed(seed) | |
| if torch.cuda.is_available(): | |
| torch.cuda.manual_seed_all(seed) | |
| def configure_live_console_logging() -> None: | |
| """Flush trainer metrics promptly when output is redirected to a log file.""" | |
| for stream in (sys.stdout, sys.stderr): | |
| reconfigure = getattr(stream, "reconfigure", None) | |
| if reconfigure is not None: | |
| reconfigure(line_buffering=True) | |
| def main() -> None: | |
| configure_live_console_logging() | |
| parser = argparse.ArgumentParser( | |
| description="Train multilingual static embeddings directly from public Bekko data" | |
| ) | |
| parser.add_argument( | |
| "--config", | |
| type=Path, | |
| required=True, | |
| help="YAML experiment configuration (paths are resolved from the working directory)", | |
| ) | |
| parser.add_argument("--materialize-shard", type=int, default=None) | |
| parser.add_argument("--materialize-num-shards", type=int, default=None) | |
| parser.add_argument( | |
| "--materialize-config", | |
| action="append", | |
| default=[], | |
| help="Materialize only this manifest config; repeat for multiple configs", | |
| ) | |
| args = parser.parse_args() | |
| config = load_config(args.config) | |
| if args.materialize_config: | |
| if args.materialize_shard is not None: | |
| parser.error("--materialize-config cannot be combined with --materialize-shard") | |
| result = materialize_named_configs( | |
| config, config_names=list(args.materialize_config) | |
| ) | |
| print(json.dumps(result, sort_keys=True), flush=True) | |
| os._exit(0) | |
| if args.materialize_shard is not None: | |
| if args.materialize_num_shards is None: | |
| parser.error("--materialize-shard requires --materialize-num-shards") | |
| result = materialize_config_shard( | |
| config, | |
| shard_index=args.materialize_shard, | |
| num_shards=args.materialize_num_shards, | |
| ) | |
| print(json.dumps(result, sort_keys=True), flush=True) | |
| # CPython 3.13 can trip a PyArrow finalizer after every durable Arrow | |
| # slice has already been closed. Shard workers have no further work. | |
| os._exit(0) | |
| materialize_in_parallel(config) | |
| final_dir = train(config) | |
| print(final_dir, flush=True) | |
| run_full_hakari_evaluation(config, final_dir) | |
| if __name__ == "__main__": | |
| main() | |