DUSUNEN-Atlas-278M-v1 / train_matryoshka.py
GoktugD's picture
Add reproducible training and dimension evaluation scripts
d3f51fa verified
Raw
History Blame Contribute Delete
11.4 kB
#!/usr/bin/env python3
"""Train a resource-guarded Turkish Matryoshka E5 retriever."""
from __future__ import annotations
import argparse
import json
import os
import platform
import random
import re
import subprocess
import time
from pathlib import Path
import datasets
import numpy as np
import sentence_transformers
import torch
import transformers
import yaml
from datasets import load_dataset
from sentence_transformers import (
SentenceTransformer,
SentenceTransformerTrainer,
SentenceTransformerTrainingArguments,
losses,
)
from sentence_transformers.evaluation import TripletEvaluator
from sentence_transformers.training_args import BatchSamplers
from transformers import TrainerCallback
def seed_everything(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
def e5_query(text: str) -> str:
return f"query: {text}"
def e5_passage(text: str) -> str:
return f"passage: {text}"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--config", type=Path, default=Path("configs/train_matryoshka.yaml"))
parser.add_argument("--data-dir", type=Path, default=Path("data/hard-negatives-v1"))
parser.add_argument(
"--output-dir", type=Path, default=Path("outputs/goktugtr-e5-matryoshka-278m-v1")
)
parser.add_argument("--max-train-rows", type=int)
parser.add_argument("--max-validation-rows", type=int)
parser.add_argument("--max-steps", type=int, default=-1)
parser.add_argument("--protected-pid", type=int)
parser.add_argument("--protected-command-marker", default="kamera/.venv/bin/python")
return parser.parse_args()
def latest_complete_checkpoint(output_dir: Path) -> Path | None:
candidates: list[tuple[int, Path]] = []
for path in output_dir.glob("checkpoint-*"):
match = re.fullmatch(r"checkpoint-(\d+)", path.name)
if not match:
continue
required = (
"model.safetensors",
"optimizer.pt",
"scheduler.pt",
"trainer_state.json",
"rng_state.pth",
)
if all((path / filename).is_file() for filename in required):
candidates.append((int(match.group(1)), path))
return max(candidates, default=(0, None), key=lambda item: item[0])[1]
def process_command(pid: int) -> str | None:
try:
return Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode().strip()
except (FileNotFoundError, PermissionError, ProcessLookupError):
return None
def require_protected_process(pid: int | None, marker: str) -> str | None:
if pid is None:
return None
command = process_command(pid)
if command is None:
raise RuntimeError(f"Protected camera process {pid} is not running; refusing to train")
if marker not in command:
raise RuntimeError(
f"PID {pid} no longer matches protected command marker {marker!r}; refusing to train"
)
return command
def gpu_snapshot() -> dict[str, int | str] | None:
command = [
"nvidia-smi",
"--query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits",
]
try:
values = subprocess.check_output(command, text=True, timeout=10).strip().split(", ")
except (FileNotFoundError, subprocess.SubprocessError):
return None
if len(values) != 6:
return None
return {
"name": values[0],
"memory_total_mib": int(values[1]),
"memory_used_mib": int(values[2]),
"memory_free_mib": int(values[3]),
"utilization_percent": int(values[4]),
"temperature_c": int(values[5]),
}
class CameraSafetyCallback(TrainerCallback):
"""Yield between optimizer steps and stop if the protected camera exits."""
def __init__(self, pid: int | None, marker: str, pause_seconds: float) -> None:
self.pid = pid
self.marker = marker
self.pause_seconds = pause_seconds
def on_step_end(self, args, state, control, **kwargs):
require_protected_process(self.pid, self.marker)
if self.pause_seconds > 0:
time.sleep(self.pause_seconds)
return control
def main() -> None:
args = parse_args()
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))
seed_everything(int(config["seed"]))
args.output_dir.mkdir(parents=True, exist_ok=True)
protected_command = require_protected_process(
args.protected_pid, args.protected_command_marker
)
before_gpu = gpu_snapshot()
if before_gpu and before_gpu["memory_free_mib"] < 8192:
raise RuntimeError(
f"Only {before_gpu['memory_free_mib']} MiB GPU memory is free; refusing to train"
)
safety = {
"protected_pid": args.protected_pid,
"protected_command": protected_command,
"cuda_memory_fraction": float(config["cuda_memory_fraction"]),
"step_pause_seconds": float(config["step_pause_seconds"]),
"gpu_before": before_gpu,
"launcher_pid": os.getpid(),
}
(args.output_dir / "safety.json").write_text(
json.dumps(safety, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
dataset = load_dataset(
"parquet",
data_files={
"train": str(args.data_dir / "train.parquet"),
"validation": str(args.data_dir / "validation.parquet"),
},
)
train = dataset["train"]
validation = dataset["validation"]
if args.max_train_rows:
train = train.select(range(min(args.max_train_rows, len(train))))
if args.max_validation_rows:
validation = validation.select(range(min(args.max_validation_rows, len(validation))))
def format_triplet(row: dict[str, str]) -> dict[str, str]:
return {
"anchor": e5_query(row["query"]),
"positive": e5_passage(row["positive"]),
"negative": e5_passage(row["negative"]),
}
remove_columns = dataset["train"].column_names
train = train.map(format_triplet, remove_columns=remove_columns, desc="Formatting E5 train")
validation = validation.map(
format_triplet, remove_columns=remove_columns, desc="Formatting E5 validation"
)
columns = ["anchor", "positive", "negative"]
train = train.select_columns(columns)
validation = validation.select_columns(columns)
if not torch.cuda.is_available():
raise RuntimeError("CUDA is required for this training run")
torch.cuda.set_per_process_memory_fraction(float(config["cuda_memory_fraction"]), 0)
model = SentenceTransformer(
config["base_model"],
revision=config["base_model_revision"],
model_kwargs={"dtype": torch.bfloat16},
)
model.max_seq_length = int(config["max_seq_length"])
evaluator = TripletEvaluator(
anchors=validation["anchor"],
positives=validation["positive"],
negatives=validation["negative"],
name="goktugtr-matryoshka-validation",
batch_size=8,
show_progress_bar=True,
truncate_dim=min(int(dim) for dim in config["matryoshka_dims"]),
)
baseline = evaluator(model, output_path=str(args.output_dir), epoch=0, steps=0)
(args.output_dir / "baseline_triplet.json").write_text(
json.dumps(baseline, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
training_args = SentenceTransformerTrainingArguments(
output_dir=str(args.output_dir),
num_train_epochs=float(config["epochs"]),
max_steps=args.max_steps,
per_device_train_batch_size=int(config["per_device_batch_size"]),
per_device_eval_batch_size=8,
gradient_accumulation_steps=int(config["gradient_accumulation_steps"]),
learning_rate=float(config["learning_rate"]),
warmup_ratio=float(config["warmup_ratio"]),
bf16=bool(config["bf16"]),
tf32=True,
gradient_checkpointing=bool(config["gradient_checkpointing"]),
gradient_checkpointing_kwargs={"use_reentrant": False},
optim="adamw_torch_fused",
batch_sampler=BatchSamplers.NO_DUPLICATES,
eval_strategy="steps",
eval_steps=int(config["eval_steps"]),
save_strategy="steps",
save_steps=int(config["save_steps"]),
save_total_limit=2,
logging_steps=int(config["logging_steps"]),
dataloader_num_workers=0,
dataloader_pin_memory=True,
report_to="none",
run_name=config["project_name"],
seed=int(config["seed"]),
)
base_loss = losses.CachedMultipleNegativesRankingLoss(
model,
mini_batch_size=int(config["loss_mini_batch_size"]),
scale=20.0,
)
loss = losses.MatryoshkaLoss(
model,
base_loss,
matryoshka_dims=[int(dim) for dim in config["matryoshka_dims"]],
n_dims_per_step=int(config["matryoshka_dims_per_step"]),
)
trainer = SentenceTransformerTrainer(
model=model,
args=training_args,
train_dataset=train,
eval_dataset=validation,
loss=loss,
evaluator=evaluator,
callbacks=[
CameraSafetyCallback(
args.protected_pid,
args.protected_command_marker,
float(config["step_pause_seconds"]),
)
],
)
resume_checkpoint = latest_complete_checkpoint(args.output_dir)
torch.cuda.reset_peak_memory_stats()
started = time.perf_counter()
train_output = trainer.train(
resume_from_checkpoint=str(resume_checkpoint) if resume_checkpoint else None
)
training_seconds = time.perf_counter() - started
trainer.state.save_to_json(str(args.output_dir / "trainer_state.json"))
final_dir = args.output_dir / "final"
model.save_pretrained(str(final_dir), safe_serialization=True)
final_metrics = evaluator(model, output_path=str(args.output_dir), epoch=1, steps=-1)
(args.output_dir / "final_triplet.json").write_text(
json.dumps(final_metrics, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
environment = {
"python": platform.python_version(),
"torch": torch.__version__,
"transformers": transformers.__version__,
"sentence_transformers": sentence_transformers.__version__,
"datasets": datasets.__version__,
"cuda": torch.version.cuda,
"gpu": torch.cuda.get_device_name(0),
"config": config,
"train_rows": len(train),
"validation_rows": len(validation),
"training_seconds": training_seconds,
"resumed_from_checkpoint": str(resume_checkpoint) if resume_checkpoint else None,
"training_metrics": train_output.metrics,
"max_gpu_memory_gb": round(torch.cuda.max_memory_allocated() / 2**30, 3),
"gpu_after": gpu_snapshot(),
"protected_process_alive_after": process_command(args.protected_pid)
if args.protected_pid
else None,
}
(args.output_dir / "environment.json").write_text(
json.dumps(environment, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps({"baseline": baseline, "final": final_metrics}, indent=2))
if __name__ == "__main__":
main()