chritsysajii's picture
deploy: fresh upload via HF API β€” force rebuild with latest code
2b2b102 verified
Raw
History Blame Contribute Delete
25 kB
"""
Training script for the DevOps Incident Triage Environment.
Uses Unsloth + TRL for a two-phase training pipeline:
Phase 1 β€” SFT Warm-start (NEW)
1 epoch of supervised fine-tuning on the optimal action sequences from all
6 task scenarios. This gives the model a non-random starting policy so that
GRPO receives non-zero rewards from step 1.
Phase 2 β€” GRPO RL Training
Curriculum design (all 6 tasks):
- 15 prompts per task initial state (easy/medium/hard/network/memory_leak/disk_full)
- 15 mid-episode prompts (1-2 random steps taken first across all tasks)
Shuffled before training. max_steps bumped to 200 for a more visible reward curve.
Reward functions:
1. format_reward_func β€” valid action string check
2. step_reward_func β€” environment step reward (task-aware)
3. anti_cheat_reward_func β€” penalise no_op / premature resolve
4. task_alignment_reward_func β€” rewards diagnostics/mitigations matching the task
Note: torch / unsloth / trl are imported lazily inside main() so that the
reward functions and dataset helpers can be imported on machines without a GPU.
"""
import csv
import json
import os
import random
from typing import List
from datasets import Dataset
from env.environment import DevOpsEnv
from env.models import VALID_ACTIONS
from graders.grader import compute_score
from tasks.task_config import TASK_CONFIGS
# ---------------------------------------------------------------------------
# Hackathon parameters
# ---------------------------------------------------------------------------
# ⚠️ IMPORTANT: whatever MODEL_ID you set here MUST be the same model you
# pass as --base-model in compare_inference.py. Comparing a trained 1B
# against a base 3B is an unfair comparison β€” the 3B will always win on
# raw capability regardless of training quality.
#
# Recommended: "unsloth/Llama-3.2-1B-Instruct" (T4, ~30 min training)
# "unsloth/Llama-3.2-3B-Instruct" (T4, ~70 min training)
# "unsloth/Meta-Llama-3.1-8B-Instruct" (A100 / HF Endpoint)
# ---------------------------------------------------------------------------
MODEL_ID = "unsloth/Llama-3.2-1B-Instruct" # change both here AND in compare_inference.py
MAX_SEQ_LENGTH = 1024
LORA_RANK = 32 # bumped from 16 β†’ 32 for better expressivity at 1B scale
REWARD_LOG_PATH = "outputs_grpo/reward_log.csv"
system_prompt = (
"You are an On-call SRE. Pick exactly one action from the valid actions.\n"
"Valid actions: {}\n"
"Do not explain your reasoning. Just output the action word."
).format(", ".join(VALID_ACTIONS))
# ---------------------------------------------------------------------------
# 1. Reward Functions (4 independent signals)
# ---------------------------------------------------------------------------
def format_reward_func(prompts, completions, **kwargs) -> List[float]:
"""Reward 1: Did the model output a single valid action string?
Reduced from 1.0 β†’ 0.3 so this baseline signal no longer dominates
the task-specific rewards and the model has a real incentive to explore
diagnostic / mitigation actions rather than any valid action.
"""
rewards = []
for completion in completions:
text = completion[0]["content"].strip()
rewards.append(0.3 if text in VALID_ACTIONS else -0.5)
return rewards
def step_reward_func(prompts, completions, **kwargs) -> List[float]:
"""Reward 2: Does this action improve the incident state?
Restores approximate mid-episode state by replaying recent_actions from the
serialised observation in the user message before scoring the candidate action.
This fixes the bug where mid-episode prompts were always evaluated from step 0.
NaN safety: the entire env instantiation + step is wrapped in try/except.
A bad task name, corrupted prompt, or any env error returns -0.5 instead
of propagating an exception that would produce a NaN reward in the batch.
The return value is also explicitly clamped to [-1.0, 1.0].
"""
rewards = []
for prompt, completion in zip(prompts, completions):
action = completion[0]["content"].strip()
if action not in VALID_ACTIONS:
rewards.append(-0.5)
continue
try:
task = "easy"
recent_actions: List[str] = []
try:
state_dict = json.loads(prompt[-1]["content"])
task = state_dict.get("task", "easy")
if task not in TASK_CONFIGS: # guard against unknown task names
task = "easy"
recent_actions = state_dict.get("recent_actions", [])
except Exception:
pass
env = DevOpsEnv(task=task)
env.reset()
# Replay the recent actions to reconstruct approximate mid-episode state
for prev in recent_actions:
if prev in VALID_ACTIONS:
env.step(prev)
_, step_reward, _, _ = env.step(action)
# Clamp to [-1, 1] β€” env already clamps to [-0.25, 1.0] but be explicit
rewards.append(float(max(-1.0, min(1.0, step_reward))))
except Exception:
# Fallback: any env error β†’ neutral negative reward, not NaN
rewards.append(-0.5)
return rewards
# Actions that give trivially safe small rewards β€” the model's previous local optimum
_FILLER_ACTIONS = {"acknowledge_incident", "post_status_update"}
def anti_cheat_reward_func(prompts, completions, **kwargs) -> List[float]:
"""Reward 3: Penalise reward hacking.
BUG FIX (v2): The previous version read from `recent_actions` which the
environment caps at the last 5 actions. More critically, most GRPO
training prompts come from *initial* episode states where recent_actions
is [], so the repetition penalty (-0.8) never fired during training.
This version reads `actions_taken` (the full episode history injected into
mid-episode prompts by generate_prompts), falling back to recent_actions
when actions_taken is not present. The loop-detection branch fires an
even stronger penalty when the last 3 actions are all the same token.
"""
rewards = []
for prompt, completion in zip(prompts, completions):
action = completion[0]["content"].strip()
all_actions_taken: List[str] = []
try:
state_dict = json.loads(prompt[-1]["content"])
# Prefer full history; fall back to sliding window.
all_actions_taken = state_dict.get(
"actions_taken", state_dict.get("recent_actions", [])
)
except Exception:
pass
if action == "no_op":
rewards.append(-0.5) # strong no-op penalty
elif action == "resolve_incident":
rewards.append(-0.2) # premature resolve penalty
elif action in _FILLER_ACTIONS:
rewards.append(-0.15) # penalise spamming safe filler actions
elif len(all_actions_taken) >= 3 and len(set(all_actions_taken[-3:])) == 1:
# Last 3 actions are identical β€” loop detected, hard break
rewards.append(-1.0)
elif action in all_actions_taken:
# Action already taken somewhere in the episode
rewards.append(-0.8)
else:
rewards.append(0.4) # reward any NEW genuine diagnostic/mitigation
return rewards
def task_alignment_reward_func(prompts, completions, **kwargs) -> List[float]:
"""Reward 4: Strong bonus for picking the task-correct diagnostic or mitigation.
Key fix: reward for required actions raised from 0.3 β†’ 0.8, making the total
reward for a correct diagnostic action ~1.4 vs ~-0.35 for a filler action.
This gap is large enough for a 1B model to discover via GRPO exploration.
Also ensures no reward is given for redundant actions already taken.
"""
rewards = []
for prompt, completion in zip(prompts, completions):
action = completion[0]["content"].strip()
if action not in VALID_ACTIONS:
rewards.append(-0.3)
continue
task = "easy"
recent_actions = []
try:
state_dict = json.loads(prompt[-1]["content"])
task = state_dict.get("task", "easy")
recent_actions = state_dict.get("recent_actions", [])
except Exception:
pass
if action in recent_actions and action not in _FILLER_ACTIONS:
# Remove any task alignment incentive to farm repeated actions
rewards.append(-0.5)
continue
config = TASK_CONFIGS.get(task, TASK_CONFIGS["easy"])
required_diag = config.get("required_diagnostics", [])
required_mit = config.get("required_mitigations", [])
good_followups = config.get("good_followups", [])
if action in required_diag or action in required_mit:
rewards.append(0.8) # massive reward for the right tool on the right task
elif action in good_followups:
rewards.append(0.3) # good follow-up action
elif action in _FILLER_ACTIONS:
rewards.append(0.0) # no alignment credit for filler
elif action == "no_op":
rewards.append(-0.5)
else:
rewards.append(-0.2) # valid action but wrong task
return rewards
def sequence_progress_reward_func(prompts, completions, **kwargs) -> List[float]:
"""Reward 5: Bonus when the agent has completed required diagnostics
before attempting mitigations β€” reinforces the correct SRE workflow:
investigate β†’ mitigate β†’ communicate β†’ resolve.
BUG FIX (v2): Now reads `actions_taken` (full history) in addition to
`recent_actions` so that mid-episode prompts carry complete context.
"""
rewards = []
for prompt, completion in zip(prompts, completions):
action = completion[0]["content"].strip()
if action not in VALID_ACTIONS:
rewards.append(0.0)
continue
task = "easy"
all_actions_taken: List[str] = []
try:
state_dict = json.loads(prompt[-1]["content"])
task = state_dict.get("task", "easy")
all_actions_taken = state_dict.get(
"actions_taken", state_dict.get("recent_actions", [])
)
except Exception:
pass
config = TASK_CONFIGS.get(task, TASK_CONFIGS["easy"])
required_diag = set(config.get("required_diagnostics", []))
required_mit = set(config.get("required_mitigations", []))
done_so_far = set(all_actions_taken)
diag_complete = required_diag.issubset(done_so_far)
if action in required_mit and diag_complete:
# Agent is attempting mitigation AFTER completing diagnosis β€” perfect sequence
rewards.append(0.5)
elif action in required_diag and not diag_complete:
# Agent is still in the diagnostic phase β€” good
rewards.append(0.2)
elif action in required_mit and not diag_complete:
# Attempting mitigation before diagnosis β€” penalise slightly
rewards.append(-0.1)
else:
rewards.append(0.0)
return rewards
def diversity_reward_func(prompts, completions, **kwargs) -> List[float]:
"""Reward 6 (NEW): Penalise GRPO group-level mode collapse.
GRPO samples `num_generations` completions per prompt and learns from
relative advantage. When all completions are identical the reward_std
is 0 and there is NO gradient signal β€” the model stops learning entirely.
This function detects that collapse at the batch level and applies a
uniform negative signal so the KL term pushes the policy back toward
exploration. It returns 0.0 when the group shows healthy diversity.
"""
actions = [c[0]["content"].strip() for c in completions]
valid_actions = [a for a in actions if a in VALID_ACTIONS]
unique_count = len(set(valid_actions)) if valid_actions else 0
if unique_count <= 1:
# Complete collapse β€” penalise uniformly to force exploration
return [-0.25] * len(completions)
else:
return [0.0] * len(completions)
# ---------------------------------------------------------------------------
# 2. Reward Curve Logger Callback
# ---------------------------------------------------------------------------
try:
from transformers import TrainerCallback as _TrainerCallbackBase
except ImportError: # allow import on CPU-only machines without transformers
_TrainerCallbackBase = object # type: ignore[misc,assignment]
class RewardLoggerCallback(_TrainerCallbackBase): # type: ignore[valid-type]
"""Saves per-step reward metrics to a CSV for demo reward curves.
Handles both TRL naming conventions for per-function reward keys:
- Old TRL (< ~0.15): reward_format_reward_func (underscore prefix)
- New TRL (>= ~0.15): rewards/format_reward_func (slash prefix)
All keys are normalised to the underscore form before writing so the
CSV column names are stable regardless of which TRL version is installed.
Only rows where 'reward' is present are written β€” non-training log
events (LR warmup, checkpoint saves) are skipped.
"""
# Canonical CSV column names.
CSV_COLUMNS = [
"step",
"reward",
"reward_format_reward_func",
"reward_step_reward_func",
"reward_anti_cheat_reward_func",
"reward_task_alignment_reward_func",
"reward_sequence_progress_reward_func",
]
def __init__(self, log_path: str = REWARD_LOG_PATH):
self.log_path = log_path
self._header_written = False
os.makedirs(os.path.dirname(log_path), exist_ok=True)
@staticmethod
def _normalize(key: str) -> str:
"""Convert TRL's slash-format to underscore-format.
rewards/format_reward_func -> reward_format_reward_func
reward_format_reward_func -> reward_format_reward_func (unchanged)
"""
if key.startswith("rewards/"):
return "reward_" + key[len("rewards/"):]
return key
def on_log(self, args, state, control, logs=None, **kwargs):
if logs is None or "reward" not in logs:
return
# Capture every reward-related key TRL emits and normalize it.
row: dict = {"step": state.global_step}
for raw_key, value in logs.items():
if raw_key == "reward" or raw_key.startswith("reward_") or raw_key.startswith("rewards/"):
col = self._normalize(raw_key)
row[col] = value
# Fill any canonical column that TRL didn't emit this step.
for col in self.CSV_COLUMNS:
row.setdefault(col, "")
write_header = not self._header_written and not os.path.exists(self.log_path)
with open(self.log_path, "a", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=self.CSV_COLUMNS, extrasaction="ignore"
)
if write_header:
writer.writeheader()
self._header_written = True
writer.writerow(row)
# ---------------------------------------------------------------------------
# 3. Curriculum Dataset Generation
# ---------------------------------------------------------------------------
def _make_prompt(state: dict) -> dict:
return {
"prompt": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(state)},
]
}
def generate_sft_dataset(seed: int = 42) -> Dataset:
"""Generate a supervised fine-tuning dataset from optimal action sequences.
For each of the 6 tasks we replay the full optimal trajectory and record
every (observation, optimal_next_action) pair as a prompt/completion.
This gives the model a strong starting policy before GRPO kicks in.
"""
random.seed(seed)
data = []
for task_name, config in TASK_CONFIGS.items():
optimal_actions = config.get("optimal_actions", [])
if not optimal_actions:
continue
env = DevOpsEnv(task=task_name)
state = env.reset()
for action in optimal_actions:
prompt = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(state)},
]
# SFT target: the optimal action at this point in the episode
data.append({
"prompt": prompt,
"completion": [{"role": "assistant", "content": action}],
})
state, _, done, _ = env.step(action)
if done:
break
random.shuffle(data)
print(f" SFT dataset: {len(data)} (state, optimal_action) pairs across all 6 tasks.")
return Dataset.from_list(data)
def _make_prompt_with_history(state: dict, actions_taken: List[str]) -> dict:
"""Like _make_prompt but injects the full actions_taken list so that
anti_cheat_reward_func and sequence_progress_reward_func can correctly
penalise repeated actions in mid-episode prompts.
BUG FIX: Previously mid-episode prompts only carried `recent_actions`
(the last-5 sliding window), so the anti-cheat penalty never fired for
actions taken more than 5 steps ago, and the repetition check was always
empty for initial-state prompts.
"""
state_with_history = {**state, "actions_taken": actions_taken}
return {
"prompt": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(state_with_history)},
]
}
def generate_prompts(
per_task_n: int = 15,
mid_episode_n: int = 15,
seed: int = 42,
easy_n: int = None,
medium_n: int = None,
hard_n: int = None,
) -> Dataset:
"""Generate a curriculum-mixed prompt dataset for GRPO.
Distribution:
- per_task_n prompts per task initial state (all 6 tasks)
OR easy_n/medium_n/hard_n for per-task control (easy/medium/hard only)
- mid_episode_n : states captured after 1-2 random valid steps
BUG FIX: Mid-episode prompts now include `actions_taken` (the full episode
history) via _make_prompt_with_history so that reward functions can penalise
repeated actions correctly. Initial-state prompts have an empty
actions_taken by definition and are unchanged.
"""
random.seed(seed)
data = []
# Support per-task counts (easy_n / medium_n / hard_n) or fall back to per_task_n
per_task_counts = {}
if easy_n is not None or medium_n is not None or hard_n is not None:
per_task_counts = {
"easy": easy_n if easy_n is not None else per_task_n,
"medium": medium_n if medium_n is not None else per_task_n,
"hard": hard_n if hard_n is not None else per_task_n,
}
all_tasks = list(per_task_counts.keys())
else:
all_tasks = list(TASK_CONFIGS.keys())
per_task_counts = {task: per_task_n for task in all_tasks}
# Initial states β€” actions_taken is empty at episode start, so no history needed
for task in all_tasks:
env = DevOpsEnv(task=task)
state = env.reset()
for _ in range(per_task_counts[task]):
data.append(_make_prompt(state))
# Mid-episode states β€” inject full action history for correct anti-cheat scoring
for _ in range(mid_episode_n):
task = random.choice(all_tasks)
n_warm = random.randint(1, 3)
env = DevOpsEnv(task=task)
state = env.reset()
for _ in range(n_warm):
action = random.choice(VALID_ACTIONS)
state, _, done, _ = env.step(action)
if done:
state = env.reset()
break
# Pass the full actions_taken so reward functions see real episode history
data.append(_make_prompt_with_history(state, env._state["actions_taken"]))
random.shuffle(data)
print(f" GRPO dataset: {len(data)} prompts across {len(all_tasks)} tasks + mid-episode states.")
return Dataset.from_list(data)
# ---------------------------------------------------------------------------
# 4. Main Training Loop
# ---------------------------------------------------------------------------
def main():
# GPU-only imports β€” kept here so the module can be imported without a GPU
import torch # noqa: F401
from transformers import TrainerCallback, TrainerControl, TrainerState, TrainingArguments
from unsloth import FastLanguageModel, PatchDPOTrainer
from trl import GRPOTrainer, GRPOConfig, SFTTrainer, SFTConfig
print("[1] Loading Unsloth Model...")
PatchDPOTrainer() # Required optimisations
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=MODEL_ID,
max_seq_length=MAX_SEQ_LENGTH,
dtype=None,
load_in_4bit=True,
)
model = FastLanguageModel.get_peft_model(
model,
r=LORA_RANK,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
lora_alpha=16,
use_gradient_checkpointing="unsloth",
)
# ── Phase 1: SFT Warm-start ────────────────────────────────────────────
# Run 1 epoch of supervised fine-tuning on optimal action sequences.
# This gives GRPO a non-random starting policy so rewards are non-zero
# from the very first update step.
print("[2] Phase 1 β€” SFT Warm-start on optimal trajectories...")
sft_dataset = generate_sft_dataset()
def format_sft_sample(example):
"""Convert prompt+completion into a single flat text for SFT."""
messages = example["prompt"] + example["completion"]
return {"text": tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=False
)}
sft_dataset = sft_dataset.map(format_sft_sample)
sft_args = SFTConfig(
output_dir="outputs_sft",
num_train_epochs=1,
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
learning_rate=2e-4,
lr_scheduler_type="cosine",
logging_steps=1,
dataset_text_field="text",
max_seq_length=MAX_SEQ_LENGTH,
fp16=not torch.cuda.is_bf16_supported(),
bf16=torch.cuda.is_bf16_supported(),
)
sft_trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
args=sft_args,
train_dataset=sft_dataset,
)
print(" Running SFT warm-start epoch...")
sft_trainer.train()
print(" SFT warm-start complete.")
# ── Phase 2: GRPO RL Training ──────────────────────────────────────────
print("[3] Phase 2 β€” Generating GRPO Curriculum (all 6 tasks)...")
grpo_dataset = generate_prompts()
print("[4] Setting up GRPO Config (max_steps=300)...")
training_args = GRPOConfig(
output_dir="outputs_grpo",
learning_rate=5e-6, # halved from 1e-5 β€” reduces KL explosion risk
lr_scheduler_type="cosine",
warmup_steps=20, # NaN fix: let optimizer calibrate before full LR kicks in
max_steps=300,
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
logging_steps=1,
# NaN prevention β€” the three settings below address the KL spike β†’ NaN chain
max_grad_norm=0.1, # aggressive clip (default=1.0); stops gradient overflow when KL spikes
# GRPO-specific
num_generations=8, # more group samples β†’ reward_std stays non-zero even during partial collapse
max_prompt_length=512,
max_completion_length=32,
temperature=0.9, # adds generation stochasticity so completions are not all identical
)
trainer = GRPOTrainer(
model=model,
processing_class=tokenizer,
reward_funcs=[
format_reward_func,
step_reward_func,
anti_cheat_reward_func,
task_alignment_reward_func,
sequence_progress_reward_func,
diversity_reward_func, # NEW: breaks GRPO variance collapse
],
args=training_args,
train_dataset=grpo_dataset,
callbacks=[RewardLoggerCallback()],
)
print("[5] Beginning GRPO RL Training Loop...")
trainer.train()
print("[6] Saving merged model...")
model.save_pretrained_merged("trained_sre_agent", tokenizer, save_method="merged_16bit")
print(f"Training Complete! Reward log saved to {REWARD_LOG_PATH}")
if __name__ == "__main__":
main()