hotchpotch's picture
Release multilingual v1 with evaluated FP16 static weights
1bf933f verified
Raw History Blame Contribute Delete
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
@dataclass(frozen=True)
class RunSettings:
mode: Literal["smoke", "search", "full"]
name: str
output_dir: Path
@dataclass(frozen=True)
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
@dataclass(frozen=True)
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
@dataclass(frozen=True)
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
@dataclass(frozen=True)
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
@dataclass(frozen=True)
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
@dataclass(frozen=True)
class EvaluationTargetSettings:
dataset: str | None
collection: str | None
splits: tuple[str, ...]
query_limit: int
@dataclass(frozen=True)
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
@dataclass(frozen=True)
class TrackingSettings:
enabled: bool
project: str
@dataclass(frozen=True)
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
@lru_cache(maxsize=200_000)
def _porter_word(word: str) -> str:
return _restore_case(word, _PORTER.stem(word.lower(), to_lowercase=False))
@lru_cache(maxsize=200_000)
def _snowball_word(word: str) -> str:
return _restore_case(word, _SNOWBALL.stem(word.lower()))
@lru_cache(maxsize=200_000)
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",
)
@classmethod
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",
)
@classmethod
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
@dataclass(frozen=True)
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",
)
@classmethod
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
@dataclass(frozen=True)
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] = {}
@property
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()