238 lines
8.1 KiB
Python
238 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = [
|
|
# "sentence-transformers[train]>=5.0",
|
|
# "datasets>=2.19.0",
|
|
# "accelerate>=0.26.0",
|
|
# "trackio",
|
|
# ]
|
|
# ///
|
|
"""Production-ready sparse-encoder (SPLADE) training template.
|
|
|
|
Demonstrates:
|
|
- SpladeLoss wrapping SparseMultipleNegativesRankingLoss
|
|
- FLOPS regularization (`query_regularizer_weight` / `document_regularizer_weight`)
|
|
- SparseNanoBEIREvaluator for sparse retrieval metrics
|
|
- load_best_model_at_end on the retrieval metric
|
|
|
|
Base model must expose a masked-LM head; any `AutoModelForMaskedLM`-compatible
|
|
checkpoint works (DistilBERT, BERT, MiniLM MLM variants, existing SPLADE models).
|
|
|
|
Run locally:
|
|
pip install "sentence-transformers[train]>=5.0"
|
|
python train_sparse_encoder_example.py
|
|
|
|
Multi-GPU:
|
|
accelerate launch train_sparse_encoder_example.py
|
|
|
|
Hugging Face Jobs: paste this file's contents as the `script` in hf_jobs(...).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
from contextlib import nullcontext
|
|
|
|
import torch
|
|
from datasets import load_dataset
|
|
|
|
from sentence_transformers import (
|
|
SparseEncoder,
|
|
SparseEncoderModelCardData,
|
|
SparseEncoderTrainer,
|
|
SparseEncoderTrainingArguments,
|
|
)
|
|
from sentence_transformers.base.sampler import BatchSamplers
|
|
from sentence_transformers.sparse_encoder.evaluation import SparseNanoBEIREvaluator
|
|
from sentence_transformers.sparse_encoder.losses import (
|
|
SparseMultipleNegativesRankingLoss,
|
|
SpladeLoss,
|
|
)
|
|
|
|
|
|
def autocast_ctx():
|
|
"""bf16/fp16 autocast for evaluator calls outside the trainer (which has its own autocast)."""
|
|
if not torch.cuda.is_available():
|
|
return nullcontext()
|
|
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
return torch.autocast("cuda", dtype=dtype)
|
|
|
|
|
|
def log_trackio_dashboard():
|
|
"""Surface the Trackio dashboard URL so the user can watch training live."""
|
|
try:
|
|
from huggingface_hub import whoami
|
|
|
|
hf_user = whoami().get("name")
|
|
if hf_user:
|
|
logging.info(
|
|
f"Trackio dashboard (live training progress): https://huggingface.co/spaces/{hf_user}/trackio"
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
MODEL_NAME = "distilbert/distilbert-base-uncased"
|
|
DATASET_NAME = "sentence-transformers/gooaq"
|
|
TRAIN_SIZE = 100_000
|
|
EVAL_SIZE = 1_000
|
|
OUTPUT_DIR = "models/distilbert-splade-gooaq"
|
|
RUN_NAME = "distilbert-splade-gooaq"
|
|
|
|
QUERY_REGULARIZER_WEIGHT = 5e-5
|
|
DOCUMENT_REGULARIZER_WEIGHT = 3e-5
|
|
SMOKE_TEST = os.environ.get("SMOKE_TEST") == "1"
|
|
|
|
|
|
def setup_logging():
|
|
"""Configure logging + TF32. Tees to logs/{RUN_NAME}.log and silences HTTP spam."""
|
|
os.makedirs("logs", exist_ok=True)
|
|
logging.basicConfig(
|
|
format="%(asctime)s - %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
level=logging.INFO,
|
|
handlers=[logging.StreamHandler(), logging.FileHandler(f"logs/{RUN_NAME}.log")],
|
|
force=True,
|
|
)
|
|
for noisy in ("httpx", "httpcore", "huggingface_hub", "urllib3", "filelock", "fsspec"):
|
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
if torch.cuda.is_available():
|
|
torch.set_float32_matmul_precision("high") # TF32 on Ampere+, no quality loss
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--eval-only", type=str, default=None, help="Skip training; load this saved model and run only the evaluator."
|
|
)
|
|
cli, _ = parser.parse_known_args()
|
|
|
|
setup_logging()
|
|
|
|
if cli.eval_only:
|
|
logging.info(f"Eval-only mode: loading model from {cli.eval_only}")
|
|
model = SparseEncoder(cli.eval_only)
|
|
evaluator = SparseNanoBEIREvaluator(dataset_names=["msmarco", "nfcorpus", "nq"])
|
|
with autocast_ctx():
|
|
evaluator(model)
|
|
return
|
|
|
|
logging.info(f"Loading base model: {MODEL_NAME}")
|
|
# Prompts are optional for SPLADE: most BERT-style MLM bases don't need them.
|
|
# If you're starting from a CSR (`Transformer + Pooling + SparseAutoEncoder`)
|
|
# base like `tomaarsen/csr-mxbai-embed-large-v1-nq` that *was* trained with
|
|
# prompts, mirror them here to preserve quality:
|
|
# prompts={"query": "Represent this sentence for similarity: ", "document": ""},
|
|
# default_prompt_name="document",
|
|
model = SparseEncoder(
|
|
MODEL_NAME,
|
|
model_card_data=SparseEncoderModelCardData(
|
|
language="en",
|
|
license="apache-2.0",
|
|
model_name=f"SPLADE from {MODEL_NAME.split('/')[-1]} trained on GooAQ",
|
|
),
|
|
)
|
|
|
|
logging.info(f"Loading dataset: {DATASET_NAME}")
|
|
train_size = 50 if SMOKE_TEST else TRAIN_SIZE
|
|
eval_size = 20 if SMOKE_TEST else EVAL_SIZE
|
|
if SMOKE_TEST:
|
|
logging.info("SMOKE_TEST=1: trimmed dataset; will run max_steps=1 and skip Hub push")
|
|
full = load_dataset(DATASET_NAME, split="train")
|
|
split = full.train_test_split(test_size=eval_size, seed=12)
|
|
train_dataset = split["train"].select(range(min(train_size, len(split["train"]))))
|
|
eval_dataset = split["test"]
|
|
logging.info(f" train: {len(train_dataset):,} rows | eval: {len(eval_dataset):,} rows")
|
|
logging.info(f" columns: {train_dataset.column_names}")
|
|
|
|
loss = SpladeLoss(
|
|
model=model,
|
|
loss=SparseMultipleNegativesRankingLoss(model=model),
|
|
query_regularizer_weight=QUERY_REGULARIZER_WEIGHT,
|
|
document_regularizer_weight=DOCUMENT_REGULARIZER_WEIGHT,
|
|
)
|
|
|
|
evaluator = SparseNanoBEIREvaluator(dataset_names=["msmarco", "nfcorpus", "nq"])
|
|
logging.info("Baseline evaluation:")
|
|
with autocast_ctx():
|
|
# Must run before deriving metric_key: evaluator(model) mutates primary_metric to add the name_ prefix.
|
|
baseline_result = evaluator(model)
|
|
baseline_eval = baseline_result[evaluator.primary_metric]
|
|
metric_key = f"eval_{evaluator.primary_metric}"
|
|
|
|
args = SparseEncoderTrainingArguments(
|
|
output_dir=OUTPUT_DIR,
|
|
num_train_epochs=1,
|
|
max_steps=1 if SMOKE_TEST else -1,
|
|
per_device_train_batch_size=32,
|
|
per_device_eval_batch_size=32,
|
|
learning_rate=2e-5,
|
|
weight_decay=0.01,
|
|
warmup_steps=0.1,
|
|
lr_scheduler_type="linear",
|
|
bf16=True,
|
|
batch_sampler=BatchSamplers.NO_DUPLICATES,
|
|
eval_strategy="steps",
|
|
eval_steps=0.1,
|
|
save_strategy="steps",
|
|
save_steps=0.1,
|
|
save_total_limit=2,
|
|
logging_steps=0.01,
|
|
logging_first_step=True,
|
|
load_best_model_at_end=True,
|
|
metric_for_best_model=metric_key,
|
|
greater_is_better=True,
|
|
report_to="none" if SMOKE_TEST else "trackio",
|
|
run_name=RUN_NAME,
|
|
seed=12,
|
|
)
|
|
|
|
trainer = SparseEncoderTrainer(
|
|
model=model,
|
|
args=args,
|
|
train_dataset=train_dataset,
|
|
eval_dataset=eval_dataset,
|
|
loss=loss,
|
|
evaluator=evaluator,
|
|
)
|
|
if not SMOKE_TEST:
|
|
log_trackio_dashboard()
|
|
trainer.train()
|
|
|
|
logging.info("Post-training evaluation:")
|
|
with autocast_ctx():
|
|
result = evaluator(model)
|
|
score = result[evaluator.primary_metric]
|
|
delta = score - baseline_eval
|
|
verdict = "WIN" if delta >= 0.005 else "MARGINAL" if delta >= 0 else "REGRESSION"
|
|
# Active-dim keys come back name-prefixed (e.g. "NanoBEIR_..._query_active_dims"); suffix-match for compat.
|
|
qad = next((v for k, v in result.items() if k.endswith("query_active_dims")), "n/a")
|
|
cad = next((v for k, v in result.items() if k.endswith("corpus_active_dims")), "n/a")
|
|
logging.info(
|
|
f"VERDICT: {verdict} | score={score:.4f} | baseline={baseline_eval:.4f} | delta={delta:+.4f} "
|
|
f"| query_active={qad} corpus_active={cad}"
|
|
)
|
|
|
|
final_dir = f"{OUTPUT_DIR}/final"
|
|
model.save_pretrained(final_dir)
|
|
logging.info(f"Saved final model to {final_dir}")
|
|
|
|
if SMOKE_TEST:
|
|
logging.info("SMOKE_TEST=1: skipping Hub push")
|
|
return
|
|
|
|
try:
|
|
commit_url = model.push_to_hub(RUN_NAME)
|
|
logging.info(f"Pushed model to {commit_url.rsplit('/commit/', 1)[0]}")
|
|
except Exception:
|
|
import traceback
|
|
|
|
logging.error(f"Hub push failed:\n{traceback.format_exc()}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|