grid2op-openenv / scripts /train_sft.py
Sidharth1743's picture
space snapshot
a754878
Raw
History Blame Contribute Delete
14.6 kB
from __future__ import annotations
import argparse
import json
import os
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
import torch
from datasets import Dataset
from peft import LoraConfig, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from trl import SFTConfig, SFTTrainer
DEFAULT_MODEL = "Qwen/Qwen3-4B-Instruct-2507"
DEFAULT_DATASET = "outputs/datasets/grid2op_sft_v1.jsonl"
DEFAULT_OUTPUT_DIR = "outputs/models/grid2op-qwen3-4b-sft-v1"
QWEN_CHATML_TRAINING_TEMPLATE = """{%- for message in messages %}
{{- '<|im_start|>' + message['role'] + '\n' }}
{%- if message['role'] == 'assistant' %}
{% generation %}{{- message['content'] }}{% endgeneration %}
{%- else %}
{{- message['content'] }}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- endif %}
"""
def resolve_precision(precision: str) -> tuple[torch.dtype, bool, bool, str]:
if precision == "auto":
if torch.cuda.is_available() and torch.cuda.is_bf16_supported():
precision = "bf16"
else:
precision = "fp16"
if precision == "bf16":
return torch.bfloat16, True, False, precision
if precision == "fp16":
return torch.float16, False, True, precision
if precision == "fp32":
return torch.float32, False, False, precision
raise ValueError(f"Unsupported precision: {precision}")
def load_jsonl_rows(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for line_no, line in enumerate(path.read_text().splitlines(), 1):
if not line.strip():
continue
row = json.loads(line)
messages = row.get("messages")
if not isinstance(messages, list) or len(messages) < 2:
raise ValueError(f"Row {line_no} has no valid messages list")
if messages[-1].get("role") != "assistant":
raise ValueError(f"Row {line_no} final message must be assistant")
content = messages[-1].get("content")
if not isinstance(content, str):
raise ValueError(f"Row {line_no} assistant content must be a string")
json.loads(content)
rows.append(row)
if not rows:
raise ValueError(f"No rows found in {path}")
return rows
def summarize_rows(rows: list[dict[str, Any]]) -> dict[str, Any]:
tasks: Counter[str] = Counter()
actions: Counter[str] = Counter()
tiers: Counter[str] = Counter()
policies: Counter[str] = Counter()
task_actions: dict[str, Counter[str]] = defaultdict(Counter)
prompt_chars: list[int] = []
completion_chars: list[int] = []
for row in rows:
metadata = row.get("metadata", {})
task_id = str(metadata.get("task_id", "unknown"))
tasks[task_id] += 1
tiers[str(metadata.get("benchmark_tier", "unknown"))] += 1
policies[str(metadata.get("label_policy", "unknown"))] += 1
selected_action = metadata.get("selected_action", {})
action_type = selected_action_type(selected_action)
actions[action_type] += 1
task_actions[task_id][action_type] += 1
messages = row["messages"]
prompt_chars.append(sum(len(str(message.get("content", ""))) for message in messages[:-1]))
completion_chars.append(len(str(messages[-1].get("content", ""))))
return {
"rows": len(rows),
"tasks": dict(sorted(tasks.items())),
"actions": dict(sorted(actions.items())),
"benchmark_tiers": dict(sorted(tiers.items())),
"label_policies": dict(sorted(policies.items())),
"task_actions": {
task: dict(sorted(counter.items()))
for task, counter in sorted(task_actions.items())
},
"avg_prompt_chars": sum(prompt_chars) / len(prompt_chars),
"max_prompt_chars": max(prompt_chars),
"avg_completion_chars": sum(completion_chars) / len(completion_chars),
"max_completion_chars": max(completion_chars),
}
def selected_action_type(action: dict[str, Any]) -> str:
if action.get("do_nothing"):
return "do_nothing"
if action.get("redispatch"):
return "redispatch"
if action.get("line_set"):
statuses = [int(value) for value in action["line_set"].values()]
if statuses and statuses[0] == 1:
return "reconnect_line"
if statuses and statuses[0] == -1:
return "disconnect_line"
return "line_set"
return "empty"
def add_token_lengths(dataset: Dataset, tokenizer, max_samples: int) -> dict[str, Any]:
lengths: list[int] = []
sample_count = min(len(dataset), max_samples)
for index in range(sample_count):
text = tokenizer.apply_chat_template(
dataset[index]["messages"],
tokenize=False,
add_generation_prompt=False,
)
lengths.append(len(tokenizer(text, add_special_tokens=False)["input_ids"]))
if not lengths:
return {"sampled_token_lengths": 0}
lengths_sorted = sorted(lengths)
p95_index = min(len(lengths_sorted) - 1, int(0.95 * (len(lengths_sorted) - 1)))
return {
"sampled_token_lengths": len(lengths),
"avg_tokens": sum(lengths) / len(lengths),
"max_tokens": max(lengths),
"p95_tokens": lengths_sorted[p95_index],
}
def build_datasets(rows: list[dict[str, Any]], eval_ratio: float, seed: int) -> tuple[Dataset, Dataset | None]:
dataset = Dataset.from_list([{"messages": row["messages"], "metadata": row.get("metadata", {})} for row in rows])
dataset = dataset.shuffle(seed=seed)
if eval_ratio <= 0 or len(dataset) < 4:
return dataset, None
split = dataset.train_test_split(test_size=eval_ratio, seed=seed)
return split["train"], split["test"]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="QLoRA SFT for Grid2Op chat-action data.")
parser.add_argument("--dataset", type=Path, default=Path(DEFAULT_DATASET))
parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--output-dir", type=Path, default=Path(DEFAULT_OUTPUT_DIR))
parser.add_argument("--run-name", default="grid2op-qwen3-4b-sft-v1")
parser.add_argument("--wandb-project", default=os.environ.get("WANDB_PROJECT", "grid2op-openenv-sft"))
parser.add_argument("--wandb-entity", default=os.environ.get("WANDB_ENTITY"))
parser.add_argument("--eval-ratio", type=float, default=0.05)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--max-length", type=int, default=4096)
parser.add_argument("--num-train-epochs", type=float, default=1.0)
parser.add_argument("--max-steps", type=int, default=-1)
parser.add_argument("--learning-rate", type=float, default=2e-4)
parser.add_argument("--per-device-train-batch-size", type=int, default=1)
parser.add_argument("--per-device-eval-batch-size", type=int, default=1)
parser.add_argument("--gradient-accumulation-steps", type=int, default=8)
parser.add_argument("--logging-steps", type=int, default=5)
parser.add_argument("--save-steps", type=int, default=50)
parser.add_argument("--eval-steps", type=int, default=50)
parser.add_argument("--save-total-limit", type=int, default=3)
parser.add_argument("--lora-r", type=int, default=32)
parser.add_argument("--lora-alpha", type=int, default=64)
parser.add_argument("--lora-dropout", type=float, default=0.05)
parser.add_argument("--gradient-checkpointing", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--packing", action=argparse.BooleanOptionalAction, default=False)
parser.add_argument("--assistant-only-loss", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--patch-qwen-training-template", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--precision", choices=["auto", "bf16", "fp16", "fp32"], default="auto")
parser.add_argument("--use-4bit", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--use-liger-kernel", action=argparse.BooleanOptionalAction, default=False)
parser.add_argument("--attn-implementation", default=None)
parser.add_argument("--device-map", default="auto")
parser.add_argument("--pad-to-multiple-of", type=int, default=8)
parser.add_argument("--torch-empty-cache-steps", type=int, default=25)
parser.add_argument("--token-length-samples", type=int, default=256)
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.use_liger_kernel and args.device_map != "none":
raise ValueError(
"--use-liger-kernel is not safe with --device-map auto/sharded loading in this QLoRA setup. "
"Use --no-use-liger-kernel for two-GPU device_map=auto training, or use --device-map none "
"only when the full model fits on one GPU/process."
)
os.environ["WANDB_PROJECT"] = args.wandb_project
if args.wandb_entity:
os.environ["WANDB_ENTITY"] = args.wandb_entity
rows = load_jsonl_rows(args.dataset)
dataset_summary = summarize_rows(rows)
tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
if args.assistant_only_loss and args.patch_qwen_training_template and "Qwen" in args.model:
tokenizer.chat_template = QWEN_CHATML_TRAINING_TEMPLATE
train_dataset, eval_dataset = build_datasets(
rows=rows,
eval_ratio=args.eval_ratio,
seed=args.seed,
)
token_summary = add_token_lengths(
Dataset.from_list([{"messages": row["messages"]} for row in rows]),
tokenizer=tokenizer,
max_samples=args.token_length_samples,
)
model_dtype, bf16, fp16, resolved_precision = resolve_precision(args.precision)
quantization_config = None
if args.use_4bit:
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=model_dtype,
bnb_4bit_use_double_quant=True,
)
device_map = None if args.device_map == "none" else args.device_map
model_kwargs: dict[str, Any] = {
"trust_remote_code": True,
"dtype": model_dtype,
"device_map": device_map,
}
if quantization_config is not None:
model_kwargs["quantization_config"] = quantization_config
if args.attn_implementation:
model_kwargs["attn_implementation"] = args.attn_implementation
model = AutoModelForCausalLM.from_pretrained(args.model, **model_kwargs)
model.config.use_cache = False
if args.use_4bit:
model = prepare_model_for_kbit_training(
model,
use_gradient_checkpointing=args.gradient_checkpointing,
)
lora_config = LoraConfig(
r=args.lora_r,
lora_alpha=args.lora_alpha,
lora_dropout=args.lora_dropout,
bias="none",
task_type="CAUSAL_LM",
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
],
)
training_args = SFTConfig(
output_dir=str(args.output_dir),
run_name=args.run_name,
report_to=["wandb"],
max_length=args.max_length,
num_train_epochs=args.num_train_epochs,
max_steps=args.max_steps,
learning_rate=args.learning_rate,
per_device_train_batch_size=args.per_device_train_batch_size,
per_device_eval_batch_size=args.per_device_eval_batch_size,
gradient_accumulation_steps=args.gradient_accumulation_steps,
logging_steps=args.logging_steps,
logging_first_step=True,
save_strategy="steps",
save_steps=args.save_steps,
save_total_limit=args.save_total_limit,
eval_strategy="steps" if eval_dataset is not None else "no",
eval_steps=args.eval_steps if eval_dataset is not None else None,
gradient_checkpointing=args.gradient_checkpointing,
gradient_checkpointing_kwargs={"use_reentrant": False},
optim="paged_adamw_8bit" if args.use_4bit else "adamw_torch_fused",
warmup_ratio=0.03,
weight_decay=0.01,
lr_scheduler_type="cosine",
fp16=fp16,
bf16=bf16,
packing=args.packing,
pad_to_multiple_of=args.pad_to_multiple_of,
assistant_only_loss=args.assistant_only_loss,
use_liger_kernel=args.use_liger_kernel,
torch_empty_cache_steps=args.torch_empty_cache_steps,
remove_unused_columns=True,
seed=args.seed,
data_seed=args.seed,
)
import wandb
wandb.init(
project=args.wandb_project,
entity=args.wandb_entity,
name=args.run_name,
config={
"base_model": args.model,
"dataset": str(args.dataset),
"output_dir": str(args.output_dir),
"dataset_summary": dataset_summary,
"token_summary": token_summary,
"lora": {
"r": args.lora_r,
"alpha": args.lora_alpha,
"dropout": args.lora_dropout,
},
"qlora_4bit": args.use_4bit,
"max_length": args.max_length,
"precision": resolved_precision,
"torch_dtype": str(model_dtype),
"device_map": args.device_map,
"attn_implementation": args.attn_implementation,
"use_liger_kernel": args.use_liger_kernel,
"patch_qwen_training_template": args.patch_qwen_training_template,
"pad_to_multiple_of": args.pad_to_multiple_of,
"torch_empty_cache_steps": args.torch_empty_cache_steps,
},
)
wandb.summary.update({f"dataset/{key}": value for key, value in dataset_summary.items() if not isinstance(value, dict)})
wandb.summary.update({f"tokens/{key}": value for key, value in token_summary.items()})
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
peft_config=lora_config,
processing_class=tokenizer,
)
trainer.train()
trainer.save_model(str(args.output_dir))
tokenizer.save_pretrained(str(args.output_dir))
wandb.finish()
if __name__ == "__main__":
main()