| """ |
| Negotiation SDPO+GRPO training for bilateral price negotiation. |
| |
| This script is a separate experimental sibling of train_negotiation_pure.py. |
| It keeps the negotiation paper's buyer-only RLVR setup but augments the buyer |
| update with Self-Distillation Policy Optimization (SDPO): |
| |
| - Trainable buyer policy. |
| - Frozen regulated seller model as the environment counterparty; no reference-policy model. |
| - Buyer always starts; only buyer turns receive updates. |
| - Verifiable reward remains the paper's economic-surplus scalar. |
| - SDPO adds feedback-conditioned self-teacher log-probs for dense token credit. |
| |
| Default run policy: |
| - Use Qwen/Qwen3.5-9B by default. Qwen3.5 is a newer multimodal |
| ImageTextToText/ForConditionalGeneration family, so this script keeps text-only |
| wrappers around processor/model calls and runs a startup canary by default. |
| - Start GRPO-heavy and gradually hand off to SDPO shaping: by default |
| A_total decays from 0.9 * A_GRPO + 0.1 * A_SDPO to a balanced 0.5/0.5 mix by iter 20. |
| - Use strict feedback by default: no exact seller cost or private floor is placed |
| into the teacher prompt. Oracle feedback is an explicit ablation only. |
| - Keep the HF Jobs shape analogous to train_negotiation_pure.py: one standalone |
| file, env-var config, W&B logging, and periodic Hub checkpoints. |
| """ |
|
|
| import gc |
| import importlib.metadata |
| import json |
| import os |
| import random |
| import re |
| import shutil |
| import sys |
| import time |
| import traceback |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Dict, List, Optional, Tuple |
|
|
| |
| os.environ["PYTHONUNBUFFERED"] = "1" |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") |
| if hasattr(sys.stdout, "reconfigure"): |
| sys.stdout.reconfigure(line_buffering=True) |
|
|
| import torch |
| from transformers import AutoConfig, AutoModelForCausalLM, AutoProcessor, AutoTokenizer |
| try: |
| from transformers import AutoModelForImageTextToText |
| except ImportError: |
| AutoModelForImageTextToText = None |
|
|
|
|
| |
| |
| |
| |
| |
| _EXPLICIT_MEMORY_CAP_REQUESTED = bool(os.environ.get("MAX_MEMORY_PER_GPU_GIB", "").strip()) |
| _DEFAULT_USE_LIGER = "0" if _EXPLICIT_MEMORY_CAP_REQUESTED else "1" |
| USE_LIGER = os.environ.get("USE_LIGER", _DEFAULT_USE_LIGER) == "1" |
| if _EXPLICIT_MEMORY_CAP_REQUESTED and USE_LIGER: |
| print("[LIGER] WARNING: USE_LIGER=1 with explicit max_memory caps; disable it if device_map offloads to CPU") |
| if USE_LIGER: |
| try: |
| from liger_kernel.transformers import apply_liger_kernel_to_qwen3 |
|
|
| apply_liger_kernel_to_qwen3() |
| print("[LIGER] Qwen3 kernels patched (SwiGLU, RMSNorm, RoPE, FusedLinearCE)") |
| except ImportError: |
| print("[LIGER] liger-kernel not installed, skipping") |
| USE_LIGER = False |
| except Exception as e: |
| print(f"[LIGER] Patch failed (non-fatal): {e}") |
| USE_LIGER = False |
|
|
|
|
| _IMAGE_TEXT_MODEL_TYPES = {"qwen3_5", "qwen3_5_moe"} |
| _TEXT_BATCH_KEYS = {"input_ids", "attention_mask", "position_ids", "labels"} |
| _QWEN35_FASTPATH_IMPORTS = ( |
| ("causal_conv1d", "causal_conv1d_fn"), |
| ("causal_conv1d", "causal_conv1d_update"), |
| ("fla.modules", "FusedRMSNormGated"), |
| ("fla.ops.gated_delta_rule", "chunk_gated_delta_rule"), |
| ("fla.ops.gated_delta_rule", "fused_recurrent_gated_delta_rule"), |
| ) |
| |
| |
| |
| _QWEN35_FASTPATH_PACKAGES = ("causal-conv1d", "flash-linear-attention") |
| _QWEN35_FASTPATH_INSTALL_HINT = " ".join(_QWEN35_FASTPATH_PACKAGES) |
|
|
|
|
| |
| |
| |
| MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen3.5-9B") |
| SELLER_MODEL_NAME = os.environ.get("SELLER_MODEL_NAME", MODEL_NAME) |
| NUM_ITERS = int(os.environ.get("NUM_ITERS", "60")) |
| BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "16")) |
| GROUP_SIZE = int(os.environ.get("GROUP_SIZE", "8")) |
| MAX_TURNS = int(os.environ.get("MAX_TURNS", "6")) |
| |
| |
| |
| LR = float(os.environ.get("LR", "3e-6")) |
| WEIGHT_DECAY = float(os.environ.get("WEIGHT_DECAY", "0.01")) |
| WARMUP_STEPS = int(os.environ.get("WARMUP_STEPS", "10")) |
| GRAD_CLIP_NORM = float(os.environ.get("GRAD_CLIP_NORM", "1.0")) |
| EPSILON = float(os.environ.get("EPSILON", "0.2")) |
| |
| |
| |
| KL_COEF = float(os.environ.get("KL_COEF", "0.0")) |
| MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "300")) |
| BUYER_TEMP = float(os.environ.get("BUYER_TEMP", "1.0")) |
| SELLER_TEMP = float(os.environ.get("SELLER_TEMP", "0.7")) |
| OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/tmp/model") |
| HUB_MODEL_ID = os.environ.get("HUB_MODEL_ID", "") |
| GRADIENT_CHECKPOINTING = os.environ.get("GRADIENT_CHECKPOINTING", "1") == "1" |
| MODEL_DEVICE_MAP = os.environ.get("MODEL_DEVICE_MAP", "auto") |
| MAX_MEMORY_PER_GPU_GIB = os.environ.get("MAX_MEMORY_PER_GPU_GIB", "").strip() |
| GEN_BATCH_LIMIT = int(os.environ.get("GEN_BATCH_LIMIT", "128")) |
| NUM_INNER_EPOCHS = int(os.environ.get("NUM_INNER_EPOCHS", "1")) |
| NORMALIZE_ADVANTAGES = os.environ.get("NORMALIZE_ADVANTAGES", "1") == "1" |
| CHECKPOINT_EVERY = int(os.environ.get("CHECKPOINT_EVERY", "10")) |
| SEED = int(os.environ.get("SEED", "42")) |
| TRAIN_SPLIT_SIZE = int(os.environ.get("TRAIN_SPLIT_SIZE", "802")) |
| TEST_SPLIT_SIZE = int(os.environ.get("TEST_SPLIT_SIZE", "128")) |
| WANDB_PROJECT = os.environ.get("WANDB_PROJECT", "anchor-negotiation-sdpo") |
| WANDB_ENTITY = os.environ.get("WANDB_ENTITY", "chalk") or None |
| WANDB_MODE = os.environ.get("WANDB_MODE", "online") |
| WANDB_TAGS = [t.strip() for t in os.environ.get("WANDB_TAGS", "sdpo,negotiation,rlvr").split(",") if t.strip()] |
| WANDB_JOB_TYPE = os.environ.get("WANDB_JOB_TYPE", "train") |
| RUN_NAME = os.environ.get("RUN_NAME", "") |
| PUSH_TRAINING_SCRIPT = os.environ.get("PUSH_TRAINING_SCRIPT", "1") == "1" |
| QWEN35_TEXT_CANARY = os.environ.get("QWEN35_TEXT_CANARY", "1") == "1" |
| |
| |
| |
| |
| |
| |
| |
| |
| REASONING_MODE = os.environ.get("REASONING_MODE", "option_a").strip().lower().replace("-", "_") |
| ENABLE_NATIVE_THINKING = REASONING_MODE in {"option_b", "native", "native_thinking", "thinking", "qwen", "qwen_thinking"} |
| CHAT_TEMPLATE_ENABLE_THINKING = os.environ.get("CHAT_TEMPLATE_ENABLE_THINKING", "1" if ENABLE_NATIVE_THINKING else "0") == "1" |
| STRIP_NATIVE_THINKING_FROM_HISTORY = os.environ.get("STRIP_NATIVE_THINKING_FROM_HISTORY", "0" if ENABLE_NATIVE_THINKING else "1") == "1" |
| NATIVE_REASONING_PROTOCOL = ENABLE_NATIVE_THINKING or CHAT_TEMPLATE_ENABLE_THINKING |
| DEBUG_SAMPLE_BUYER_OUTPUTS = int(os.environ.get("DEBUG_SAMPLE_BUYER_OUTPUTS", "0")) |
| NATIVE_PUBLIC_FINALIZER = os.environ.get("NATIVE_PUBLIC_FINALIZER", "1" if NATIVE_REASONING_PROTOCOL else "0") == "1" |
| NATIVE_THINK_TOKENS = int(os.environ.get("NATIVE_THINK_TOKENS", str(MAX_NEW_TOKENS))) |
| NATIVE_FINAL_TOKENS = int(os.environ.get("NATIVE_FINAL_TOKENS", "96")) |
| SDPO_LAMBDA = float(os.environ.get("SDPO_LAMBDA", "0.9")) |
| |
| |
| SDPO_LAMBDA_FINAL = float(os.environ.get("SDPO_LAMBDA_FINAL", "0.5")) |
| SDPO_LAMBDA_DECAY_ITERS = int(os.environ.get("SDPO_LAMBDA_DECAY_ITERS", "20")) |
| SDPO_FEEDBACK_MODE = os.environ.get("SDPO_FEEDBACK_MODE", "strict").lower() |
| SDPO_ADV_CLIP = float(os.environ.get("SDPO_ADV_CLIP", "5.0")) |
| SDPO_MAX_DEMO_CHARS = int(os.environ.get("SDPO_MAX_DEMO_CHARS", "1400")) |
| SDPO_MAX_FEEDBACK_CHARS = int(os.environ.get("SDPO_MAX_FEEDBACK_CHARS", "1800")) |
| |
| |
| ADAMW_FOREACH = os.environ.get("ADAMW_FOREACH", "0") == "1" |
| |
| |
| |
| |
| OPTIMIZER = os.environ.get("OPTIMIZER", "adamw_cpu").lower() |
| |
| |
| |
| |
| UPDATE_MICROBATCH_SIZE = int(os.environ.get("UPDATE_MICROBATCH_SIZE", "4")) |
| OPTIM_STEP_EVERY_GROUPS = int(os.environ.get("OPTIM_STEP_EVERY_GROUPS", "16")) |
| UPDATE_PAD_TO_MULTIPLE_OF = int(os.environ.get("UPDATE_PAD_TO_MULTIPLE_OF", "8")) |
| |
| |
| UPDATE_LENGTH_BUCKETING = os.environ.get("UPDATE_LENGTH_BUCKETING", "1") == "1" |
| ROLLOUT_MAX_LENGTH = int(os.environ.get("ROLLOUT_MAX_LENGTH", "3072")) |
| UPDATE_MAX_LENGTH = int(os.environ.get("UPDATE_MAX_LENGTH", "3072")) |
| |
| |
| |
| DISTILLATION_LEVEL = os.environ.get("DISTILLATION_LEVEL", "token") |
| TOP_K_DISTILLATION = int(os.environ.get("TOP_K_DISTILLATION", "0")) |
| DISTILLATION_DIVERGENCE = os.environ.get("DISTILLATION_DIVERGENCE", "token-logprob-gap") |
| TRUST_REGION_INTERPOLATION = os.environ.get("TRUST_REGION_INTERPOLATION", "0") == "1" |
| TEACHER_EMA_DECAY = os.environ.get("TEACHER_EMA_DECAY", "") |
| FORMAT_WARN_THRESHOLD = int(os.environ.get("FORMAT_WARN_THRESHOLD", "5")) |
| FORMAT_STOP_THRESHOLD = int(os.environ.get("FORMAT_STOP_THRESHOLD", "10")) |
| FORMAT_STOP_PATIENCE = int(os.environ.get("FORMAT_STOP_PATIENCE", "2")) |
| BUDGET_WARN_THRESHOLD = int(os.environ.get("BUDGET_WARN_THRESHOLD", "32")) |
| BUDGET_STOP_THRESHOLD = int(os.environ.get("BUDGET_STOP_THRESHOLD", "40")) |
| BUDGET_STOP_PATIENCE = int(os.environ.get("BUDGET_STOP_PATIENCE", "2")) |
| REWARD_STOP_THRESHOLD = float(os.environ.get("REWARD_STOP_THRESHOLD", "-0.25")) |
| REWARD_STOP_PATIENCE = int(os.environ.get("REWARD_STOP_PATIENCE", "2")) |
| EARLY_STOP_SAVE_CHECKPOINT = os.environ.get("EARLY_STOP_SAVE_CHECKPOINT", "1") == "1" |
|
|
|
|
| def _fmt_run_value(value): |
| text = f"{value:g}" if isinstance(value, float) else str(value) |
| return text.replace("e-0", "e-").replace("e+0", "e+").replace(".", "p") |
|
|
|
|
| def _model_slug(model_name): |
| slug = model_name.split("/")[-1].lower() |
| slug = slug.replace("qwen3", "q3").replace("instruct", "i") |
| slug = slug.replace("-2507", "2507") |
| slug = re.sub(r"[^a-z0-9.]+", "-", slug).strip("-") |
| slug = slug.replace("-i-", "-i") |
| return slug |
|
|
|
|
| def _distill_slug(): |
| if DISTILLATION_LEVEL == "topk-logit": |
| div = DISTILLATION_DIVERGENCE.lower().replace("jensen-shannon", "js").replace("jshannon", "js") |
| div = re.sub(r"[^a-z0-9]+", "", div) or "kl" |
| interp = "tri" if TRUST_REGION_INTERPOLATION else "notri" |
| ema = f"ema{_fmt_run_value(TEACHER_EMA_DECAY)}" if TEACHER_EMA_DECAY else "noema" |
| return f"topk{TOP_K_DISTILLATION}-{div}-{interp}-{ema}" |
| return "tokgap" |
|
|
|
|
| def default_run_name(): |
| return ( |
| f"sdpo__{_model_slug(MODEL_NAME)}__l{_fmt_run_value(SDPO_LAMBDA)}__{_distill_slug()}" |
| f"__i{NUM_ITERS}_b{BATCH_SIZE}xg{GROUP_SIZE}" |
| f"__fb{SDPO_FEEDBACK_MODE}_clip{_fmt_run_value(SDPO_ADV_CLIP)}" |
| f"__lr{_fmt_run_value(LR)}_kl{_fmt_run_value(KL_COEF)}__s{SEED}" |
| ) |
|
|
|
|
| def default_wandb_group(): |
| return f"sdpo__{_model_slug(MODEL_NAME)}__{_distill_slug()}__fb{SDPO_FEEDBACK_MODE}" |
|
|
|
|
| def active_sdpo_lambda(iteration): |
| """GRPO-heavy -> SDPO-balanced schedule for Qwen3.5 experiments.""" |
| if SDPO_LAMBDA_DECAY_ITERS <= 0: |
| return SDPO_LAMBDA_FINAL |
| frac = min(max(float(iteration) / float(SDPO_LAMBDA_DECAY_ITERS), 0.0), 1.0) |
| return SDPO_LAMBDA + frac * (SDPO_LAMBDA_FINAL - SDPO_LAMBDA) |
|
|
|
|
| def _consecutive_count(history, predicate): |
| count = 0 |
| for row in reversed(history): |
| if predicate(row): |
| count += 1 |
| else: |
| break |
| return count |
|
|
|
|
| def evaluate_early_stop(metrics, n_episodes): |
| """Return (should_stop, reasons) from recent rollout health metrics.""" |
| if not metrics: |
| return False, [] |
| latest = metrics[-1] |
| outcomes = latest.get("outcomes", {}) |
| format_errors = outcomes.get("BUYER_FORMAT_ERROR", 0) |
| budget_violations = outcomes.get("BUYER_BUDGET_VIOLATION", 0) |
| mean_reward = latest.get("mean_reward", 0.0) |
| reasons = [] |
|
|
| format_bad = _consecutive_count( |
| metrics, |
| lambda row: row.get("outcomes", {}).get("BUYER_FORMAT_ERROR", 0) >= FORMAT_STOP_THRESHOLD, |
| ) |
| budget_bad = _consecutive_count( |
| metrics, |
| lambda row: row.get("outcomes", {}).get("BUYER_BUDGET_VIOLATION", 0) >= BUDGET_STOP_THRESHOLD, |
| ) |
| reward_bad = _consecutive_count( |
| metrics, |
| lambda row: row.get("mean_reward", 0.0) <= REWARD_STOP_THRESHOLD, |
| ) |
|
|
| if FORMAT_STOP_PATIENCE > 0 and format_bad >= FORMAT_STOP_PATIENCE: |
| reasons.append( |
| f"format_errors={format_errors}/{n_episodes} for {format_bad} consecutive iters " |
| f"(threshold={FORMAT_STOP_THRESHOLD}; next run: lower LR or slow SDPO handoff)" |
| ) |
| if BUDGET_STOP_PATIENCE > 0 and budget_bad >= BUDGET_STOP_PATIENCE: |
| reasons.append( |
| f"budget_violations={budget_violations}/{n_episodes} for {budget_bad} consecutive iters " |
| f"(threshold={BUDGET_STOP_THRESHOLD}; next run: strengthen budget prompt or lower LR)" |
| ) |
| if REWARD_STOP_PATIENCE > 0 and reward_bad >= REWARD_STOP_PATIENCE: |
| reasons.append( |
| f"mean_reward={mean_reward:.4f} for {reward_bad} consecutive iters " |
| f"(threshold={REWARD_STOP_THRESHOLD}; next run: lower LR or inspect product mix)" |
| ) |
| return bool(reasons), reasons |
|
|
|
|
| random.seed(SEED) |
| torch.manual_seed(SEED) |
|
|
|
|
| def _model_load_kwargs(): |
| """Shared model loading kwargs.""" |
| kwargs = { |
| "dtype": torch.bfloat16, |
| "device_map": MODEL_DEVICE_MAP, |
| "trust_remote_code": True, |
| } |
| if MAX_MEMORY_PER_GPU_GIB: |
| if not torch.cuda.is_available(): |
| return kwargs |
| n_gpu = torch.cuda.device_count() |
| |
| |
| |
| kwargs["max_memory"] = {i: f"{MAX_MEMORY_PER_GPU_GIB}GiB" for i in range(n_gpu)} |
| kwargs["max_memory"]["cpu"] = "240GiB" |
| return kwargs |
|
|
|
|
| def _dependency_version(package_name): |
| try: |
| return importlib.metadata.version(package_name) |
| except importlib.metadata.PackageNotFoundError: |
| return None |
| except Exception: |
| return "unknown" |
|
|
|
|
| def _probe_qwen35_fastpath(): |
| missing = [] |
| for module_name, attr_name in _QWEN35_FASTPATH_IMPORTS: |
| try: |
| module = __import__(module_name, fromlist=[attr_name]) |
| obj = getattr(module, attr_name, None) |
| if obj is None: |
| missing.append(f"{module_name}.{attr_name}=None") |
| except Exception as exc: |
| missing.append(f"{module_name}.{attr_name}: {type(exc).__name__}: {exc}") |
| versions = {pkg: _dependency_version(pkg) for pkg in _QWEN35_FASTPATH_PACKAGES} |
| ok = not missing |
| return ok, missing, versions |
|
|
|
|
| def _log_qwen35_fastpath_status(): |
| ok, missing, versions = _probe_qwen35_fastpath() |
| version_text = ", ".join(f"{k}={v or 'missing'}" for k, v in versions.items()) |
| if ok: |
| print(f"[QWEN3.5 FASTPATH] OK: optimized gated-delta/conv kernels import. Versions: {version_text}") |
| else: |
| print("[QWEN3.5 FASTPATH] MISSING: transformers will fall back to slower torch gated-delta path.") |
| print(f"[QWEN3.5 FASTPATH] Versions: {version_text}") |
| print(f"[QWEN3.5 FASTPATH] Install with: pip install {_QWEN35_FASTPATH_INSTALL_HINT}") |
| for item in missing[:8]: |
| print(f"[QWEN3.5 FASTPATH] Missing detail: {item}") |
| return ok |
|
|
|
|
| def _config_model_type(config): |
| return str(getattr(config, "model_type", "") or "") |
|
|
|
|
| def _is_image_text_config(config): |
| return _config_model_type(config) in _IMAGE_TEXT_MODEL_TYPES and hasattr(config, "vision_config") |
|
|
|
|
| def _is_image_text_model(model): |
| return _is_image_text_config(getattr(model, "config", None)) |
|
|
|
|
| def _first_model_device(model): |
| return next(model.parameters()).device |
|
|
|
|
| def _model_input_device(model): |
| """Device where tokenized inputs should be placed. |
| |
| Accelerate-dispatched models can span multiple GPUs. For those, inputs must |
| start on the first layer's device, not necessarily on the lm_head device. |
| If the map starts on CPU, fail early: generation with CPU-dispatched input |
| layers is incompatible with the CUDA/Triton path we use for training. |
| """ |
| hf_map = getattr(model, "hf_device_map", None) |
| if isinstance(hf_map, dict): |
| for key in ("model.embed_tokens", "transformer.wte", "model"): |
| if key in hf_map: |
| dev = torch.device(hf_map[key]) |
| if dev.type == "cpu": |
| raise RuntimeError(f"Input layer {key} is on CPU in hf_device_map; use larger GPU memory or lower MAX_MEMORY_PER_GPU_GIB") |
| return dev |
| first = next(iter(hf_map.values())) |
| dev = torch.device(first) |
| if dev.type == "cpu": |
| raise RuntimeError("First model shard is on CPU; use larger GPU memory or lower MAX_MEMORY_PER_GPU_GIB") |
| return dev |
| return _first_model_device(model) |
|
|
|
|
| def _load_text_or_image_text_stack(model_name): |
| """Load Qwen3/Qwen3.5 text-compatible model plus processor/tokenizer. |
| |
| Qwen3.5/3.6 official checkpoints are ImageTextToText wrappers with a text |
| backbone. For text-only negotiation, use AutoProcessor + |
| AutoModelForImageTextToText when available, while exposing the underlying |
| tokenizer to the rest of the script. Older CausalLM checkpoints keep the |
| original AutoTokenizer + AutoModelForCausalLM path. |
| """ |
| cfg = AutoConfig.from_pretrained(model_name, trust_remote_code=True) |
| if _is_image_text_config(cfg): |
| if AutoModelForImageTextToText is None: |
| raise ImportError( |
| f"{model_name} is {_config_model_type(cfg)} and requires AutoModelForImageTextToText. " |
| "Install a recent transformers version (Qwen3.5 model card uses >=4.57)." |
| ) |
| processor = AutoProcessor.from_pretrained(model_name, trust_remote_code=True) |
| tokenizer = getattr(processor, "tokenizer", None) |
| if tokenizer is None: |
| raise RuntimeError(f"AutoProcessor for {model_name} does not expose .tokenizer") |
| model = AutoModelForImageTextToText.from_pretrained(model_name, **_model_load_kwargs()) |
| return model, processor, tokenizer |
|
|
| tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) |
| model = AutoModelForCausalLM.from_pretrained(model_name, **_model_load_kwargs()) |
| return model, tokenizer, tokenizer |
|
|
|
|
| def _text_only_batch(batch): |
| return {k: v for k, v in batch.items() if k in _TEXT_BATCH_KEYS and v is not None} |
|
|
|
|
| def _move_batch_to_device(batch, device): |
| return {k: (v.to(device) if hasattr(v, "to") else v) for k, v in batch.items()} |
|
|
|
|
| def _apply_chat_template_text(processor_or_tokenizer, messages, *, tokenize, add_generation_prompt, **kwargs): |
| try: |
| return processor_or_tokenizer.apply_chat_template( |
| messages, |
| tokenize=tokenize, |
| add_generation_prompt=add_generation_prompt, |
| **kwargs, |
| ) |
| except TypeError: |
| |
| kwargs.pop("enable_thinking", None) |
| return processor_or_tokenizer.apply_chat_template( |
| messages, |
| tokenize=tokenize, |
| add_generation_prompt=add_generation_prompt, |
| **kwargs, |
| ) |
|
|
|
|
| def _qwen35_text_canary(model, processor_or_tokenizer, tokenizer, device, label): |
| if not QWEN35_TEXT_CANARY or not _is_image_text_model(model): |
| return |
| print(f"[QWEN3.5 CANARY] Text-only compatibility check for {label}...") |
| msgs = [{"role": "user", "content": "Reply with exactly: OK"}] |
| try: |
| inputs = _apply_chat_template_text( |
| processor_or_tokenizer, |
| msgs, |
| tokenize=True, |
| add_generation_prompt=True, |
| return_dict=True, |
| return_tensors="pt", |
| enable_thinking=False, |
| ) |
| except TypeError: |
| prompt = _apply_chat_template_text( |
| tokenizer, |
| msgs, |
| tokenize=False, |
| add_generation_prompt=True, |
| enable_thinking=False, |
| ) |
| inputs = tokenizer(prompt, return_tensors="pt") |
| if hasattr(inputs, "items"): |
| inputs = dict(inputs.items()) |
| elif not isinstance(inputs, dict): |
| inputs = {"input_ids": inputs} |
| else: |
| inputs = dict(inputs) |
| if "input_ids" in inputs and not torch.is_tensor(inputs["input_ids"]): |
| inputs["input_ids"] = torch.as_tensor(inputs["input_ids"]) |
| if "attention_mask" in inputs and not torch.is_tensor(inputs["attention_mask"]): |
| inputs["attention_mask"] = torch.as_tensor(inputs["attention_mask"]) |
| if "attention_mask" not in inputs: |
| inputs["attention_mask"] = torch.ones_like(inputs["input_ids"]) |
| if any(k.startswith("pixel") or "image" in k or "video" in k for k in inputs): |
| raise AssertionError(f"{label} text-only canary unexpectedly produced multimodal tensors: {sorted(inputs)}") |
| bad_token_ids = [ |
| getattr(processor_or_tokenizer, "image_token_id", None), |
| getattr(processor_or_tokenizer, "video_token_id", None), |
| getattr(getattr(model, "config", None), "image_token_id", None), |
| getattr(getattr(model, "config", None), "video_token_id", None), |
| ] |
| ids = inputs["input_ids"] |
| for bad in {x for x in bad_token_ids if x is not None}: |
| if bool((ids == int(bad)).any().item()): |
| raise AssertionError(f"{label} text-only canary contains multimodal token id {bad}") |
| inputs = _move_batch_to_device(_text_only_batch(inputs), device) |
| with torch.no_grad(): |
| out = model(**inputs, use_cache=False) |
| if not hasattr(out, "logits") or out.logits.shape[:2] != inputs["input_ids"].shape: |
| raise AssertionError(f"{label} text-only forward did not return expected logits shape") |
| with torch.no_grad(): |
| gen = model.generate(**inputs, max_new_tokens=4, do_sample=False, pad_token_id=tokenizer.pad_token_id, eos_token_id=tokenizer.eos_token_id) |
| text = tokenizer.batch_decode(gen[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True)[0] |
| print(f"[QWEN3.5 CANARY] {label} OK; sample={text!r}") |
|
|
|
|
| def _assert_no_cpu_offload(model, name): |
| """Fail fast if Accelerate placed any module on CPU/disk. |
| |
| Full fine-tuning and rollout generation require all modules on GPU. CPU/disk |
| offload is especially unsafe with Triton/Liger kernels and would make dense |
| optimizer updates invalid or unusably slow. |
| """ |
| hf_map = getattr(model, "hf_device_map", None) |
| if not isinstance(hf_map, dict): |
| return |
| bad = {k: v for k, v in hf_map.items() if str(v).startswith("cpu") or str(v).startswith("disk")} |
| if bad: |
| sample = list(bad.items())[:8] |
| raise RuntimeError( |
| f"{name} has CPU/disk-offloaded modules in hf_device_map (sample={sample}). " |
| "This script does full-parameter training/generation and requires all modules on GPU. " |
| "Use A100/L40S-class memory or reduce per-GPU memory caps; do not use Liger with CPU offload." |
| ) |
|
|
|
|
| |
| DATASET_URL_BASE = ( |
| "https://raw.githubusercontent.com/TianXiaSJTU/AmazonPriceHistory" |
| "/main/data/AmazonHistoryPrice/" |
| ) |
| |
| |
| CATEGORIES = [ |
| "automotive", |
| "baby-products", |
| "beauty", |
| "books", |
| "electronics", |
| "health-personal-care", |
| "home-kitchen", |
| "industrial-scientific", |
| "movies-tv", |
| "music", |
| "other", |
| "patio-lawn-garden", |
| "pet-supplies", |
| "software", |
| "sports-outdoors", |
| "tools-home-improvement", |
| "toys-games", |
| "video-games", |
| ] |
|
|
|
|
| def parse_price(s): |
| return float(str(s).replace("$", "").replace(",", "").strip()) |
|
|
|
|
| def load_products(seed=SEED): |
| import urllib.request |
|
|
| all_items = [] |
| category_counts = {} |
| for cat in CATEGORIES: |
| url = DATASET_URL_BASE + f"{cat}.json" |
| try: |
| with urllib.request.urlopen(url, timeout=20) as resp: |
| items = json.loads(resp.read().decode("utf-8")) |
| except Exception as e: |
| print(f" [WARN] Skip {cat}: {e}") |
| continue |
| n_valid = 0 |
| for idx, it in enumerate(items): |
| try: |
| lp = parse_price(it.get("list_price", "0")) |
| cost = parse_price(it.get("lowest_price", "0")) |
| if lp <= 0 or cost <= 0: |
| continue |
| budget = round(lp * 0.8, 2) |
| all_items.append( |
| { |
| "codename": f"{cat}_{idx}", |
| "title": it.get("title", "")[:120], |
| "description": it.get("description", "")[:200], |
| "features": it.get("features", "")[:300], |
| "current_price": parse_price(it.get("current_price", lp)), |
| "average_price": parse_price(it.get("average_price", lp)), |
| "highest_price": parse_price(it.get("highest_price", lp)), |
| "category": cat, |
| "list_price": lp, |
| "cost": cost, |
| "budget": budget, |
| "mi": budget > cost, |
| } |
| ) |
| n_valid += 1 |
| except Exception: |
| continue |
| category_counts[cat] = n_valid |
|
|
| random.seed(seed) |
| random.shuffle(all_items) |
| if len(all_items) >= TRAIN_SPLIT_SIZE + TEST_SPLIT_SIZE: |
| train = all_items[:TRAIN_SPLIT_SIZE] |
| test = all_items[TRAIN_SPLIT_SIZE : TRAIN_SPLIT_SIZE + TEST_SPLIT_SIZE] |
| else: |
| split = int(len(all_items) * 0.8623655913978494) |
| train, test = all_items[:split], all_items[split:] |
|
|
| mi_total = sum(1 for p in all_items if p["mi"]) |
| mi_train = sum(1 for p in train if p["mi"]) |
| mi_test = sum(1 for p in test if p["mi"]) |
| print( |
| f"[DATA] Products={len(all_items)} train={len(train)} test={len(test)} " |
| f"MI={mi_total} CI={len(all_items)-mi_total}", |
| flush=True, |
| ) |
| print(f"[DATA] Train MI={mi_train} CI={len(train)-mi_train}; Test MI={mi_test} CI={len(test)-mi_test}") |
| print(f"[DATA] Category counts: {category_counts}") |
| return train, test |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| BUYER_SYSTEM_EXPLICIT = """You are a buyer looking forward to buying things on your Shopping List from me, the seller. |
| You have access to the seller's Inventory List and you can bargain about the prices. |
| Your task is to bargain with the seller and reach a deal with the price as low as possible in limited turns. |
| You can only buy things on the Shopping List in the limited quantity. Use the codename of the product, instead of the title. |
| You can only buy things that cost less than your budget. Never offer above your private budget. Never accept or DEAL above your private budget; if no legal deal is possible, choose [QUIT]. |
| Again, try to make deal with a price as low as possible. That is, your goal is to spend as little money as possible, not just reaching your budget. |
| |
| Protocol compliance is mandatory. Every reply must contain exactly one Thought line, exactly one Talk line, and exactly one Action line. The final Action line must be parseable and must use one of the allowed action tags exactly. |
| |
| Your Reply should include 3 parts: Thought, Talk, and Action. |
| Thought: your inner strategic thinking of this bargaining session; |
| Talk: short talk that you are going to say to the seller. Speak concisely and cut to the chase. Generate authentic and diverse sentences, avoiding repetition of sentences that have already appeared in the conversation; |
| Action: one of the limited actions that define the real intention of your Talk. The type of your Action must be one of "[BUY],[REJECT],[DEAL],[QUIT]". |
| 1. '[BUY] $M (N codename_1)' if you wish to offer the seller $M to purchase all N items of the product with the codename "codename_1". |
| 2. '[REJECT]' if you choose to reject the other side's offer and await a new offer from the seller. |
| 3. '[DEAL] $M (N codename_1)' if you finally accept on a former offer proposed by the seller. $M (N codename_1) is an exact copy of seller's previous offer. You should not use this action to propose a new price. This action will immediately end the conversation and close the deal. |
| 4. '[QUIT]' if you believe that a mutually acceptable deal cannot be reached in limited turns. This action will immediately end the conversation. |
| You shouldn't choose action '[DEAL] $M' before seller's action '[SELL] $M'. Your first action should be '[BUY] $M (N codename_1)' or '[REJECT]'. |
| '[DEAL] $M (N codename_1)' can only be chosen to accept the seller's previous offer '[SELL] $M (N codename_1)' and only if $M is within your budget. Otherwise, you always choose from '[BUY]', '[REJECT]' and '[QUIT]'. |
| Do not output any extra text before Thought or after the Action line. Do not output multiple Action lines. |
| |
| Your reply should STRICTLY follow this format (not following the format will directly lead to failure), for example: |
| Thought: I'm a buyer and I want to bargain. The listing price of codename "apple_1" is $15, which is too expensive, so I try to buy an apple for $10. |
| Talk: Hello, I'm tight on budget. can you sell it for 10$? |
| Action: [BUY] $10 (1x apple_1)""" |
|
|
| BUYER_SYSTEM_NATIVE = """You are a buyer looking forward to buying things on your Shopping List from me, the seller. |
| You have access to the seller's Inventory List and you can bargain about the prices. |
| Your task is to bargain with the seller and reach a deal with the price as low as possible in limited turns. |
| You can only buy things on the Shopping List in the limited quantity. Use the codename of the product, instead of the title. |
| You can only buy things that cost less than your budget. Never offer above your private budget. Never accept or DEAL above your private budget; if no legal deal is possible, choose [QUIT]. |
| Again, try to make deal with a price as low as possible. That is, your goal is to spend as little money as possible, not just reaching your budget. |
| |
| Use your native private thinking block to reason carefully about budget, seller offers, strategy, and exact action legality. That private thinking is hidden from the seller. |
| After the private thinking block, output ONLY the public message below. Do not include a public Thought line. Do not reveal your budget. Do not output extra text after the Action line. |
| |
| After </think>, the final public answer must be exactly two lines: |
| Talk: one short seller-facing sentence in your own words |
| Action: one concrete legal buyer action |
| |
| Do not write labels, markdown, explanations, examples, placeholders, or quoted actions in the public answer. Never output strings like "short talk", "Talk content here", "One of", "[Action]", "[ACTION]", or "must be". For BUY or DEAL, the Action line must include a concrete price and the current product codename. |
| |
| Legal buyer actions: |
| 1. '[BUY] $M (N codename_1)' if you wish to offer the seller $M to purchase all N items of the product with the codename "codename_1". |
| 2. '[REJECT]' if you choose to reject the other side's offer and await a new offer from the seller. |
| 3. '[DEAL] $M (N codename_1)' if you finally accept on a former offer proposed by the seller. $M (N codename_1) is an exact copy of seller's previous offer. You should not use this action to propose a new price. This action will immediately end the conversation and close the deal. |
| 4. '[QUIT]' if you believe that a mutually acceptable deal cannot be reached in limited turns. This action will immediately end the conversation. |
| You shouldn't choose action '[DEAL] $M' before seller's action '[SELL] $M'. Your first action should be '[BUY] $M (N codename_1)' or '[REJECT]'. |
| '[DEAL] $M (N codename_1)' can only be chosen to accept the seller's previous offer '[SELL] $M (N codename_1)' and only if $M is within your budget. Otherwise, you always choose from '[BUY]', '[REJECT]' and '[QUIT]'. |
| |
| Example final public reply after your hidden thinking: |
| Talk: Hello, I'm tight on budget. can you sell it for 10$? |
| Action: [BUY] $10 (1x apple_1)""" |
|
|
| SELLER_SYSTEM_EXPLICIT = """You are a seller looking forward to selling things on your Inventory List to me, the buyer. |
| Your task is to bargain with the buyer and reach a deal with the price as high as possible in limited turns. |
| You can only sell things that are on the Inventory List. Use the codename of the product, instead of the title. |
| You have access to private information: the cost price of each product in the Inventory List, and do not disclose the real cost to the buyer. |
| You should only agree on a deal when the selling price is higher than the cost, otherwise, you should quit negotiating. |
| |
| Your Reply should include 3 parts: Thought, Talk, and Action. |
| Thought: your inner strategic thinking of this bargaining session; |
| Talk: short talk that you are going to say to the buyer. Speak concisely and cut to the chase. Generate authentic and diverse sentences, avoiding repetition of sentences that have already appeared in the conversation; |
| Action: one of the limited actions that define the real intention of your Talk. The type of your Action must be one of "[SELL],[REJECT],[DEAL],[QUIT]". |
| 1. '[SELL] $M (N codename_1)' if you want to propose selling N items of the product with the codename "codename_1" to the buyer for the total price of $M. |
| 2. '[REJECT]' if you choose to reject the other side's offer and await a new offer from the buyer. |
| 3. '[DEAL] $M (N codename_1)' if you finally agree on a former offer proposed by the buyer, and sell N items of the product with the codename "codename_1" to the buyer for the total price of $M. $M (N codename_1) is an exact copy of buyer's previous offer. You should not use this action to propose a new price. This action will immediately end the conversation and close the deal. |
| 4. '[QUIT]' if you believe that a mutually acceptable deal cannot be reached in limited turns. This action will immediately end the conversation. |
| You shouldn't choose action '[DEAL]' before buyer's action '[BUY]'. |
| '[DEAL] $M (N codename_1)' can only be chosen to accept the buyer's previous offer '[BUY] $M (N codename_1)'. Otherwise, you always choose from '[SELL]', '[REJECT]' and '[QUIT]'. |
| |
| Your reply should strictly follow this format, for example: |
| Thought: I'm a seller, so I must sell the product with codename "apple_1" higher than its cost. |
| Talk: blah, blah... |
| Action: [SELL] $15 (1x apple_1)""" |
|
|
| SELLER_SYSTEM_NATIVE = """You are a seller looking forward to selling things on your Inventory List to me, the buyer. |
| Your task is to bargain with the buyer and reach a deal with the price as high as possible in limited turns. |
| You can only sell things that are on the Inventory List. Use the codename of the product, instead of the title. |
| You have access to private information: the cost price of each product in the Inventory List, and do not disclose the real cost to the buyer. |
| You should only agree on a deal when the selling price is higher than the cost, otherwise, you should quit negotiating. |
| |
| Use your native private thinking block to reason carefully about your cost, the buyer offer, strategy, and exact action legality. That private thinking is hidden from the buyer. |
| After the private thinking block, output ONLY the public message below. Do not include a public Thought line. Do not reveal the cost. Do not output extra text after the Action line. |
| |
| After </think>, the final public answer must be exactly two lines: |
| Talk: one short buyer-facing sentence in your own words |
| Action: one concrete legal seller action |
| |
| Do not write labels, markdown, explanations, examples, placeholders, or quoted actions in the public answer. Never output strings like "short talk", "Talk content here", "One of", "[Action]", "[ACTION]", or "must be". For SELL or DEAL, the Action line must include a concrete price and the current product codename. |
| |
| Legal seller actions: |
| 1. '[SELL] $M (N codename_1)' if you want to propose selling N items of the product with the codename "codename_1" to the buyer for the total price of $M. |
| 2. '[REJECT]' if you choose to reject the other side's offer and await a new offer from the buyer. |
| 3. '[DEAL] $M (N codename_1)' if you finally agree on a former offer proposed by the buyer, and sell N items of the product with the codename "codename_1" to the buyer for the total price of $M. $M (N codename_1) is an exact copy of buyer's previous offer. You should not use this action to propose a new price. This action will immediately end the conversation and close the deal. |
| 4. '[QUIT]' if you believe that a mutually acceptable deal cannot be reached in limited turns. This action will immediately end the conversation. |
| You shouldn't choose action '[DEAL]' before buyer's action '[BUY]'. |
| '[DEAL] $M (N codename_1)' can only be chosen to accept the buyer's previous offer '[BUY] $M (N codename_1)'. Otherwise, you always choose from '[SELL]', '[REJECT]' and '[QUIT]'. |
| |
| Example final public reply after your hidden thinking: |
| Talk: I can offer it for $15. |
| Action: [SELL] $15 (1x apple_1)""" |
|
|
| BUYER_SYSTEM = BUYER_SYSTEM_NATIVE if NATIVE_REASONING_PROTOCOL else BUYER_SYSTEM_EXPLICIT |
| SELLER_SYSTEM = SELLER_SYSTEM_NATIVE if NATIVE_REASONING_PROTOCOL else SELLER_SYSTEM_EXPLICIT |
|
|
|
|
| def build_buyer_prompt(product): |
| inv = ( |
| f"Inventory List\n" |
| f"- codename: {product['codename']}\n" |
| f" title: {product['title']}\n" |
| f" description: {product['description']}\n" |
| f" features: {product.get('features', '')}\n" |
| f" category: {product['category']}\n" |
| f" list_price: ${product['list_price']:.2f}\n" |
| f" current_price: ${product.get('current_price', product['list_price']):.2f}\n" |
| f" average_price: ${product.get('average_price', product['list_price']):.2f}\n" |
| f" highest_price: ${product.get('highest_price', product['list_price']):.2f}" |
| ) |
| need = ( |
| f"Shopping List\n" |
| f"- codename: {product['codename']}\n" |
| f" quantity: 1\n" |
| f" budget_limit: ${product['budget']:.2f}" |
| ) |
| user = ( |
| f"{inv}\n\n{need}\n\n" |
| f"Now, I play the role of seller and you play the role of buyer. " |
| f"We are going to negotiate based on the Inventory List in {MAX_TURNS} turns." |
| ) |
| return [ |
| {"role": "system", "content": BUYER_SYSTEM}, |
| {"role": "user", "content": user}, |
| ] |
|
|
|
|
| def build_seller_prompt(product): |
| inv = ( |
| f"Inventory List\n" |
| f"- codename: {product['codename']}\n" |
| f" title: {product['title']}\n" |
| f" description: {product['description']}\n" |
| f" features: {product.get('features', '')}\n" |
| f" category: {product['category']}\n" |
| f" list_price: ${product['list_price']:.2f}\n" |
| f" current_price: ${product.get('current_price', product['list_price']):.2f}\n" |
| f" average_price: ${product.get('average_price', product['list_price']):.2f}\n" |
| f" highest_price: ${product.get('highest_price', product['list_price']):.2f}\n" |
| f" cost_price (private): ${product['cost']:.2f}" |
| ) |
| user = ( |
| f"{inv}\n\n" |
| f"Now, I play the role of buyer and you play the role of seller. " |
| f"We are going to negotiate based on the Inventory List in {MAX_TURNS} turns." |
| ) |
| return [ |
| {"role": "system", "content": SELLER_SYSTEM}, |
| {"role": "user", "content": user}, |
| ] |
|
|
|
|
| |
| |
| |
| |
| PRICE_PATTERN = r"([0-9][0-9,]*(?:\.[0-9]+)?)" |
| ACTION_PATTERN = r"\[(BUY|SELL|DEAL|REJECT|QUIT)\](?:\s*\$" + PRICE_PATTERN + r")?(?:\s*\(([^)]*)\))?" |
| ACTION_RE = re.compile(ACTION_PATTERN, re.IGNORECASE) |
| ACTION_LINE_RE = re.compile(r"(?:^|\n)\s*Action\s*:\s*" + ACTION_PATTERN, re.IGNORECASE) |
| QWEN_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL) |
| QWEN_THINK_OPEN_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE) |
| QWEN_THINK_CLOSE_RE = re.compile(r"</think\s*>", re.IGNORECASE) |
|
|
|
|
| def strip_qwen_native_thinking(text): |
| """Remove Qwen3 native <think>...</think> content from visible text. |
| |
| Qwen3 thinking mode emits private reasoning wrapped in native think tags. That |
| content must never be passed to the opponent or used as the parsed economic |
| action. If an opening tag is unterminated, keep only content after the first |
| public protocol marker (Thought/Talk/Action), otherwise drop the tail. |
| """ |
| text = text or "" |
| had_native_marker = bool(QWEN_THINK_OPEN_RE.search(text) or QWEN_THINK_CLOSE_RE.search(text)) |
| text = QWEN_THINK_BLOCK_RE.sub("", text) |
|
|
| closes = list(QWEN_THINK_CLOSE_RE.finditer(text)) |
| if closes and not QWEN_THINK_OPEN_RE.search(text): |
| |
| |
| |
| text = text[closes[-1].end() :] |
|
|
| m = QWEN_THINK_OPEN_RE.search(text) |
| if m: |
| tail = text[m.end() :] |
| public = re.search(r"(?:^|\n)\s*(?:Thought|Talk|Action)\s*:", tail, re.IGNORECASE) |
| text = text[: m.start()] + (tail[public.start() :] if public else "") |
| return text.strip() |
|
|
|
|
| def _parse_action_match(m): |
| ps = m.group(2) |
| price = float(ps.replace(",", "")) if ps else None |
| return {"type": m.group(1).upper(), "price": price, "objects": m.group(3)} |
|
|
|
|
| def extract_action(text): |
| |
| |
| |
| |
| public_text = strip_thought(text or "") |
|
|
| line_matches = list(ACTION_LINE_RE.finditer(public_text)) |
| if line_matches: |
| return _parse_action_match(line_matches[-1]) |
|
|
| if not re.search(r"(?:^|\n)\s*Action\s*:", public_text or "", re.IGNORECASE): |
| matches = list(ACTION_RE.finditer(public_text or "")) |
| m = matches[-1] if matches else None |
| if m: |
| return _parse_action_match(m) |
| return {"type": "UNKNOWN", "price": None, "objects": None} |
|
|
|
|
| def replace_final_action(text, action_type, price, product): |
| """Replace the first public structured action after environment regulation. |
| |
| Keep any raw private Thought tail for the acting model, but modify only the |
| canonical public Action that downstream parsing/visibility will use. |
| """ |
| replacement = f"[{action_type}] ${price:.2f} (1x {product['codename']})" |
| text = strip_qwen_native_thinking(text or "") |
|
|
| talk_match = re.search(r"(?:^|\n)\s*Talk\s*:", text, re.IGNORECASE) |
| action_match = re.search(r"(?:^|\n)\s*Action\s*:", text, re.IGNORECASE) |
| thought_match = re.search(r"(?:^|\n)\s*Thought\s*:", text, re.IGNORECASE) |
| if talk_match: |
| public_start = talk_match.start() |
| elif action_match and thought_match and thought_match.start() < action_match.start(): |
| public_start = action_match.start() |
| else: |
| public_start = 0 |
|
|
| public_tail = text[public_start:] |
| line_match = ACTION_LINE_RE.search(public_tail) |
| if line_match: |
| old_match = list(ACTION_RE.finditer(line_match.group(0)))[-1] |
| start = public_start + line_match.start() + old_match.start() |
| end = public_start + line_match.start() + old_match.end() |
| return text[:start] + replacement + text[end:] |
|
|
| visible = strip_thought(text) |
| matches = list(ACTION_RE.finditer(visible)) |
| if not matches: |
| return text.rstrip() + f"\nAction: {replacement}" |
| old = matches[0].group(0) |
| idx = text.find(old, public_start) |
| if idx < 0: |
| return text.rstrip() + f"\nAction: {replacement}" |
| return text[:idx] + replacement + text[idx + len(old) :] |
|
|
|
|
| def canonical_public_message(text): |
| """Return the counterparty-visible Talk/Action surface form. |
| |
| Option A: preserve the raw generation in the acting model's private history, |
| but canonicalize anything shown to the opponent. The visible message starts at |
| the first public Talk: marker when available, drops private Thought: content, |
| and hard-truncates immediately after the first complete Action: line. This |
| handles reasoning models that produce a valid public action and then continue |
| with a second private Thought/self-correction block. |
| """ |
| raw_text = text or "" |
| had_native_marker = bool(QWEN_THINK_OPEN_RE.search(raw_text) or QWEN_THINK_CLOSE_RE.search(raw_text)) |
| text = strip_qwen_native_thinking(raw_text) |
|
|
| talk_match = re.search(r"(?:^|\n)\s*Talk\s*:", text, re.IGNORECASE) |
| action_match = re.search(r"(?:^|\n)\s*Action\s*:", text, re.IGNORECASE) |
| thought_match = re.search(r"(?:^|\n)\s*Thought\s*:", text, re.IGNORECASE) |
|
|
| if talk_match: |
| public = text[talk_match.start() :] |
| elif action_match and thought_match and thought_match.start() < action_match.start(): |
| public = text[action_match.start() :] |
| elif thought_match: |
| return "" |
| elif NATIVE_REASONING_PROTOCOL and CHAT_TEMPLATE_ENABLE_THINKING and not had_native_marker and "<|im_start|>" not in text: |
| return "" |
| else: |
| public = text |
|
|
| public = re.sub(r"\[(Action|ACTION)\]", "", public) |
| public = re.sub(r"\[ACTION\]", "", public) |
|
|
| action_line = ACTION_LINE_RE.search(public) |
| if action_line: |
| |
| |
| |
| |
| line_end = public.find("\n", action_line.end()) |
| public = public[: line_end if line_end >= 0 else len(public)] |
| else: |
| |
| |
| leaked_thought = re.search(r"\n\s*Thought\s*:", public, re.IGNORECASE) |
| if leaked_thought: |
| public = public[: leaked_thought.start()] |
|
|
| result = public.strip() |
| _assert_strip_thought_complete(result, text) |
| return result |
|
|
|
|
| def strip_thought(text): |
| """Remove hidden scratchpads, keeping only canonical Talk + Action text.""" |
| return canonical_public_message(text) |
|
|
|
|
| def _assert_strip_thought_complete(stripped_text, original_text): |
| has_structured_thought = bool(re.search(r"(?:^|\n)\s*Thought\s*:", original_text or "")) |
| leaked_thought = bool(re.search(r"(?:^|\n)\s*Thought\s*:", stripped_text or "")) |
| leaked_qwen_think = bool(QWEN_THINK_OPEN_RE.search(stripped_text or "") or QWEN_THINK_CLOSE_RE.search(stripped_text or "")) |
| if (has_structured_thought and leaked_thought) or leaked_qwen_think: |
| raise AssertionError( |
| "strip_thought() INCOMPLETE: hidden reasoning block survived. " |
| f"Original={original_text[:200]!r}; Stripped={stripped_text[:200]!r}" |
| ) |
|
|
|
|
| def _assert_no_private_info_leak(prompt_text, product, role): |
| """Crash on clear counterparty-private-info leakage. |
| |
| Keep the guard focused on structured private fields. A buyer may naturally say |
| "shopping list" in public Talk text; that phrase alone is not a private-info |
| leak. The actual private buyer data is the structured Shopping List block and |
| especially the budget_limit field, which must never appear in seller prompts. |
| """ |
| budget_str = f"${product['budget']:.2f}" |
| if role == "buyer": |
| if "cost_price" in prompt_text: |
| raise AssertionError( |
| f"INFORMATION LEAK: buyer prompt contains seller cost field. Product={product['codename']}" |
| ) |
| elif role == "seller": |
| leaked_budget_field = "budget_limit:" in prompt_text or f"budget_limit: {budget_str}" in prompt_text |
| leaked_structured_shopping_block = bool( |
| re.search(r"(?:^|\n)\s*Shopping List\s*\n(?:(?!\n\s*\n).)*budget_limit\s*:", prompt_text, re.IGNORECASE | re.DOTALL) |
| ) |
| if leaked_structured_shopping_block: |
| raise AssertionError( |
| f"INFORMATION LEAK: seller prompt contains structured buyer Shopping List block. Product={product['codename']}" |
| ) |
| if leaked_budget_field: |
| raise AssertionError( |
| f"INFORMATION LEAK: seller prompt contains buyer budget_limit field. " |
| f"Product={product['codename']}" |
| ) |
|
|
|
|
| |
| def regulate_seller(seller_action, buyer_price, product): |
| """Regulate seller per paper: prevent below-cost accepts/proposals.""" |
| cost = product["cost"] |
| at = seller_action["type"] |
| price = seller_action["price"] |
|
|
| if at == "UNKNOWN": |
| return None, True, "SELLER_FORMAT_ERROR" |
| if at == "QUIT": |
| return None, True, "SELLER_QUIT" |
| if at == "DEAL": |
| if buyer_price is None: |
| return None, True, "NO_PRIOR_BUYER_OFFER" |
| if buyer_price < cost: |
| return None, True, "SELLER_CANNOT_ACCEPT_BELOW_COST" |
| return buyer_price, True, "DEAL_SELLER_ACCEPTS" |
| if at == "SELL": |
| if price is None: |
| return None, True, "NO_PRICE_IN_SELL" |
| if price < cost: |
| price = round(cost * 1.05, 2) |
| return price, False, "SELL" |
| if at == "REJECT": |
| return None, False, "REJECT" |
| return None, True, f"UNEXPECTED_{at}" |
|
|
|
|
| def compute_buyer_reward(final_price, budget, cost, outcome): |
| """Buyer reward per paper Eq. 1, with terminal penalties.""" |
| if "FORMAT_ERROR" in outcome or "UNEXPECTED" in outcome: |
| return -1.0 |
| if outcome in {"BUYER_BUDGET_VIOLATION", "BUYER_DEAL_INVALID_SELLER_OFFER", "BUYER_DEAL_PRICE_MISMATCH"}: |
| return -1.0 |
| if final_price is None: |
| return 0.0 |
| if final_price > budget: |
| return -1.0 |
| denom = abs(budget - cost) |
| if denom < 1e-6: |
| return 0.0 |
| r = (budget - final_price) / denom |
| return max(-1.0, min(1.0, r)) |
|
|
|
|
| |
| def _append_native_public_finalizer(prompt_text, decoded_thinking): |
| """Close the native thinking block and ask for the public Talk/Action only. |
| |
| Qwen3.5 often uses the entire native-thinking budget for useful private |
| reasoning and does not reach </think> by itself. Rather than truncating that |
| reasoning or training on format errors, use a second short decode after the |
| same hidden scratchpad that explicitly starts the public answer. This mirrors |
| how an interactive user would let the model finish thinking, then require the |
| final answer in the task protocol. |
| """ |
| body = (decoded_thinking or "").strip() |
| if QWEN_THINK_CLOSE_RE.search(body): |
| prefix = prompt_text + body |
| if not prefix.rstrip().endswith("\n"): |
| prefix += "\n" |
| else: |
| prefix = prompt_text + body.rstrip() + "\n</think>\n\n" |
| |
| |
| |
| |
| prefix += "Talk:" |
| return prefix |
|
|
|
|
| @torch.no_grad() |
| def generate_batched(model, tokenizer, prompts_text_list, max_new, temp, device): |
| """Generate completions for a list of prompts using sub-batched HF generate.""" |
| if not prompts_text_list: |
| return [] |
|
|
| all_results = [] |
| for batch_start in range(0, len(prompts_text_list), GEN_BATCH_LIMIT): |
| batch_prompts = prompts_text_list[batch_start : batch_start + GEN_BATCH_LIMIT] |
| orig_side = tokenizer.padding_side |
| tokenizer.padding_side = "left" |
| inputs = tokenizer( |
| batch_prompts, |
| return_tensors="pt", |
| padding=True, |
| truncation=True, |
| max_length=ROLLOUT_MAX_LENGTH, |
| ) |
| inputs = _move_batch_to_device(_text_only_batch(inputs), device) |
| tokenizer.padding_side = orig_side |
|
|
| think_budget = max_new |
| if NATIVE_PUBLIC_FINALIZER and NATIVE_REASONING_PROTOCOL and CHAT_TEMPLATE_ENABLE_THINKING: |
| think_budget = max(1, min(max_new, NATIVE_THINK_TOKENS)) |
|
|
| output_ids = model.generate( |
| **inputs, |
| max_new_tokens=think_budget, |
| do_sample=True, |
| temperature=max(temp, 0.01), |
| top_p=1.0, |
| repetition_penalty=1.1, |
| pad_token_id=tokenizer.pad_token_id, |
| eos_token_id=tokenizer.eos_token_id, |
| ) |
| prompt_len = inputs["input_ids"].shape[1] |
| decoded_batch = [] |
| for i in range(len(batch_prompts)): |
| gen_tokens = output_ids[i][prompt_len:] |
| gen_tokens = gen_tokens[gen_tokens != tokenizer.pad_token_id] |
| |
| |
| |
| |
| |
| decoded = tokenizer.decode(gen_tokens, skip_special_tokens=True) |
| decoded_batch.append(decoded) |
|
|
| if NATIVE_PUBLIC_FINALIZER and NATIVE_REASONING_PROTOCOL and CHAT_TEMPLATE_ENABLE_THINKING: |
| unfinished = [i for i, decoded in enumerate(decoded_batch) if not ACTION_LINE_RE.search(strip_qwen_native_thinking(decoded))] |
| if unfinished: |
| finalizer_prompts = [_append_native_public_finalizer(batch_prompts[i], decoded_batch[i]) for i in unfinished] |
| orig_side = tokenizer.padding_side |
| tokenizer.padding_side = "left" |
| fin_inputs = tokenizer( |
| finalizer_prompts, |
| return_tensors="pt", |
| padding=True, |
| truncation=True, |
| max_length=ROLLOUT_MAX_LENGTH, |
| ) |
| fin_inputs = _move_batch_to_device(_text_only_batch(fin_inputs), device) |
| tokenizer.padding_side = orig_side |
| fin_output_ids = model.generate( |
| **fin_inputs, |
| max_new_tokens=max(1, NATIVE_FINAL_TOKENS), |
| do_sample=True, |
| temperature=max(temp, 0.01), |
| top_p=1.0, |
| repetition_penalty=1.05, |
| pad_token_id=tokenizer.pad_token_id, |
| eos_token_id=tokenizer.eos_token_id, |
| ) |
| fin_prompt_len = fin_inputs["input_ids"].shape[1] |
| for local_i, orig_i in enumerate(unfinished): |
| fin_tokens = fin_output_ids[local_i][fin_prompt_len:] |
| fin_tokens = fin_tokens[fin_tokens != tokenizer.pad_token_id] |
| fin_decoded = tokenizer.decode(fin_tokens, skip_special_tokens=True) |
| decoded_batch[orig_i] = ( |
| (decoded_batch[orig_i] or "").rstrip() |
| + "\n</think>\n\nTalk:" |
| + fin_decoded.lstrip() |
| ) |
|
|
| for decoded in decoded_batch: |
| |
| |
| |
| |
| if CHAT_TEMPLATE_ENABLE_THINKING and QWEN_THINK_CLOSE_RE.search(decoded) and not QWEN_THINK_OPEN_RE.search(decoded): |
| decoded = "<think>\n" + decoded |
| if STRIP_NATIVE_THINKING_FROM_HISTORY: |
| decoded = strip_qwen_native_thinking(decoded) |
| all_results.append(decoded) |
| return all_results |
|
|
|
|
| |
| @dataclass |
| class EpisodeState: |
| product: dict |
| idx: int |
| buyer_texts: List[str] = field(default_factory=list) |
| seller_texts: List[str] = field(default_factory=list) |
| all_turns: List[Tuple[str, str]] = field(default_factory=list) |
| final_price: Optional[float] = None |
| outcome: str = "TIMEOUT" |
| done: bool = False |
| last_buyer_price: Optional[float] = None |
|
|
|
|
| @dataclass |
| class Episode: |
| product: dict |
| turns: List[Tuple[str, str]] |
| final_price: Optional[float] |
| reward: float |
| num_turns: int |
| outcome: str |
| first_offer_price: Optional[float] |
| budget_violations: int |
|
|
|
|
| def run_episodes_batched(buyer_model, seller_model, tokenizer, products_expanded, device, seller_tokenizer=None): |
| """Run buyer-only negotiation episodes with frozen seller, batched per turn.""" |
| seller_tokenizer = seller_tokenizer or tokenizer |
| states = [EpisodeState(product=p, idx=i) for i, p in enumerate(products_expanded)] |
|
|
| for turn_round in range(MAX_TURNS): |
| active_buyer = [s for s in states if not s.done] |
| if not active_buyer: |
| break |
|
|
| buyer_prompts = [] |
| for s in active_buyer: |
| msgs = build_buyer_prompt(s.product) |
| for bt, st in zip(s.buyer_texts, s.seller_texts): |
| msgs.append({"role": "assistant", "content": bt}) |
| msgs.append({"role": "user", "content": strip_thought(st)}) |
| prompt_text = _apply_chat_template_text( |
| tokenizer, msgs, tokenize=False, add_generation_prompt=True, enable_thinking=CHAT_TEMPLATE_ENABLE_THINKING |
| ) |
| _assert_no_private_info_leak(prompt_text, s.product, "buyer") |
| buyer_prompts.append(prompt_text) |
|
|
| buyer_texts = generate_batched(buyer_model, tokenizer, buyer_prompts, MAX_NEW_TOKENS, BUYER_TEMP, device) |
|
|
| still_active_for_seller = [] |
| for idx, (s, b_text) in enumerate(zip(active_buyer, buyer_texts)): |
| if DEBUG_SAMPLE_BUYER_OUTPUTS > 0 and idx < DEBUG_SAMPLE_BUYER_OUTPUTS: |
| print(f" [DEBUG buyer raw turn={turn_round} ep={s.idx}] {b_text[:1600]!r}", flush=True) |
| print(f" [DEBUG buyer public turn={turn_round} ep={s.idx}] {strip_thought(b_text)[:500]!r}", flush=True) |
| b_act = extract_action(b_text) |
| s.buyer_texts.append(b_text) |
| s.all_turns.append(("buyer", b_text)) |
|
|
| if b_act["type"] == "QUIT": |
| s.outcome = "BUYER_QUIT" |
| s.done = True |
| elif b_act["type"] == "UNKNOWN": |
| s.outcome = "BUYER_FORMAT_ERROR" |
| s.done = True |
| elif b_act["type"] == "DEAL": |
| if not s.seller_texts: |
| s.outcome = "BUYER_DEAL_NO_SELLER_OFFER" |
| s.done = True |
| else: |
| last_s_act = extract_action(s.seller_texts[-1]) |
| if last_s_act["type"] != "SELL" or last_s_act.get("price") is None: |
| s.outcome = "BUYER_DEAL_INVALID_SELLER_OFFER" |
| s.done = True |
| elif b_act.get("price") is not None and abs(b_act["price"] - last_s_act["price"]) > 0.01: |
| s.outcome = "BUYER_DEAL_PRICE_MISMATCH" |
| s.done = True |
| else: |
| s.final_price = last_s_act["price"] |
| s.outcome = "DEAL_BUYER_ACCEPTS" |
| s.done = True |
| elif b_act["type"] == "BUY": |
| b_price = b_act["price"] |
| if b_price is not None and b_price > s.product["budget"]: |
| s.outcome = "BUYER_BUDGET_VIOLATION" |
| s.done = True |
| else: |
| s.last_buyer_price = b_price |
| still_active_for_seller.append(s) |
| elif b_act["type"] == "REJECT": |
| s.last_buyer_price = None |
| still_active_for_seller.append(s) |
| else: |
| s.outcome = f"UNEXPECTED_{b_act['type']}" |
| s.done = True |
|
|
| if not still_active_for_seller: |
| continue |
|
|
| seller_prompts = [] |
| for s in still_active_for_seller: |
| msgs = build_seller_prompt(s.product) |
| for bt, st in zip(s.buyer_texts, s.seller_texts): |
| msgs.append({"role": "user", "content": strip_thought(bt)}) |
| msgs.append({"role": "assistant", "content": st}) |
| if len(s.buyer_texts) > len(s.seller_texts): |
| msgs.append({"role": "user", "content": strip_thought(s.buyer_texts[-1])}) |
| prompt_text = _apply_chat_template_text( |
| seller_tokenizer, msgs, tokenize=False, add_generation_prompt=True, enable_thinking=CHAT_TEMPLATE_ENABLE_THINKING |
| ) |
| _assert_no_private_info_leak(prompt_text, s.product, "seller") |
| seller_prompts.append(prompt_text) |
|
|
| seller_texts = generate_batched(seller_model, seller_tokenizer, seller_prompts, MAX_NEW_TOKENS, SELLER_TEMP, device) |
|
|
| for s, s_text in zip(still_active_for_seller, seller_texts): |
| s_act = extract_action(s_text) |
| r_price, done, reason = regulate_seller(s_act, s.last_buyer_price, s.product) |
| if reason == "SELL" and r_price is not None and s_act.get("price") != r_price: |
| |
| |
| |
| s_text = replace_final_action(s_text, "SELL", r_price, s.product) |
| s_act = extract_action(s_text) |
|
|
| s.seller_texts.append(s_text) |
| s.all_turns.append(("seller", s_text)) |
|
|
| if done: |
| if reason == "DEAL_SELLER_ACCEPTS": |
| s.final_price = r_price |
| s.outcome = reason |
| s.done = True |
|
|
| episodes = [] |
| for s in states: |
| first_offer = None |
| budget_violations = 0 |
| for role, text in s.all_turns: |
| if role != "buyer": |
| continue |
| act = extract_action(text) |
| if act["type"] == "BUY" and act["price"] is not None: |
| if first_offer is None: |
| first_offer = act["price"] |
| if act["price"] > s.product["budget"]: |
| budget_violations += 1 |
| reward = compute_buyer_reward(s.final_price, s.product["budget"], s.product["cost"], s.outcome) |
| episodes.append( |
| Episode( |
| product=s.product, |
| turns=s.all_turns, |
| final_price=s.final_price, |
| reward=reward, |
| num_turns=len(s.all_turns), |
| outcome=s.outcome, |
| first_offer_price=first_offer, |
| budget_violations=budget_violations, |
| ) |
| ) |
| return episodes |
|
|
|
|
| |
| def _token_logprobs(model, input_ids, attention_mask): |
| """Per-token log-probs using gather + logsumexp, avoiding full softmax tensor.""" |
| out = model(input_ids=input_ids, attention_mask=attention_mask, use_cache=False) |
| if not hasattr(out, "logits"): |
| raise RuntimeError(f"Model forward returned {type(out)} without .logits") |
| logits = out.logits[:, :-1, :] |
| target = input_ids[:, 1:].unsqueeze(-1) |
| target_logit = torch.gather(logits, 2, target).squeeze(-1) |
| log_z = torch.logsumexp(logits, dim=-1) |
| return target_logit - log_z |
|
|
|
|
| def _sync_cuda(): |
| if torch.cuda.is_available(): |
| torch.cuda.synchronize() |
|
|
|
|
| def _reset_peak_memory_stats_all_gpus(): |
| if not torch.cuda.is_available(): |
| return |
| for idx in range(torch.cuda.device_count()): |
| torch.cuda.reset_peak_memory_stats(idx) |
|
|
|
|
| def _peak_memory_allocated_all_gpus_gb(): |
| if not torch.cuda.is_available(): |
| return 0.0, [] |
| per_gpu = [torch.cuda.max_memory_allocated(idx) / 1e9 for idx in range(torch.cuda.device_count())] |
| return max(per_gpu) if per_gpu else 0.0, per_gpu |
|
|
|
|
| def _peak_memory_reserved_all_gpus_gb(): |
| if not torch.cuda.is_available(): |
| return 0.0, [] |
| per_gpu = [torch.cuda.max_memory_reserved(idx) / 1e9 for idx in range(torch.cuda.device_count())] |
| return max(per_gpu) if per_gpu else 0.0, per_gpu |
|
|
|
|
| def _memory_allocated_all_gpus_gb(): |
| if not torch.cuda.is_available(): |
| return 0.0, [] |
| per_gpu = [torch.cuda.memory_allocated(idx) / 1e9 for idx in range(torch.cuda.device_count())] |
| return sum(per_gpu), per_gpu |
|
|
|
|
| def _memory_reserved_all_gpus_gb(): |
| if not torch.cuda.is_available(): |
| return 0.0, [] |
| per_gpu = [torch.cuda.memory_reserved(idx) / 1e9 for idx in range(torch.cuda.device_count())] |
| return sum(per_gpu), per_gpu |
|
|
|
|
| def _fmt_gpu_gb(values): |
| return "[" + ", ".join(f"{v:.1f}" for v in values) + "]" |
|
|
|
|
| def _timer_start(): |
| _sync_cuda() |
| return time.perf_counter() |
|
|
|
|
| def _timer_add(timers, key, start): |
| _sync_cuda() |
| timers[key] = timers.get(key, 0.0) + (time.perf_counter() - start) |
|
|
|
|
| def _chunked(seq, size): |
| size = max(int(size), 1) |
| for start in range(0, len(seq), size): |
| yield seq[start : start + size] |
|
|
|
|
| def _norm_advantages(t): |
| if t.numel() < 2 or not NORMALIZE_ADVANTAGES: |
| return t |
| return (t - t.mean()) / (t.std() + 1e-8) |
|
|
|
|
| def build_buyer_turn_prompt(ep, turn_idx): |
| """Reconstruct buyer prompt for ep.turns[turn_idx].""" |
| role, _ = ep.turns[turn_idx] |
| assert role == "buyer" |
| prompt_msgs = build_buyer_prompt(ep.product) |
| for j in range(turn_idx): |
| prev_role, prev_text = ep.turns[j] |
| if prev_role == "buyer": |
| prompt_msgs.append({"role": "assistant", "content": prev_text}) |
| else: |
| prompt_msgs.append({"role": "user", "content": strip_thought(prev_text)}) |
| return prompt_msgs |
|
|
|
|
| def _public_transcript(ep, max_chars=SDPO_MAX_DEMO_CHARS): |
| """Public Talk/Action-only transcript for feedback demos.""" |
| lines = [] |
| for role, text in ep.turns: |
| speaker = "Buyer" if role == "buyer" else "Seller" |
| lines.append(f"{speaker}: {strip_thought(text)}") |
| transcript = "\n".join(lines).strip() |
| if len(transcript) <= max_chars: |
| return transcript |
| return transcript[:max_chars].rstrip() + "\n[truncated]" |
|
|
|
|
| def _quality_label(reward): |
| if reward >= 0.75: |
| return "strong surplus" |
| if reward >= 0.35: |
| return "moderate surplus" |
| if reward > 0: |
| return "weak surplus" |
| if reward == 0: |
| return "no positive surplus" |
| return "negative outcome" |
|
|
|
|
| def _best_demo_for(ep, group_eps): |
| """Pick an on-policy same-product demo without using any external teacher.""" |
| better = [ |
| other |
| for other in group_eps |
| if other.final_price is not None and other.reward > max(ep.reward + 1e-6, 0.0) |
| ] |
| if better: |
| return max(better, key=lambda other: other.reward), "sibling" |
| if ep.final_price is not None and ep.reward > 0: |
| return ep, "self" |
| return None, "" |
|
|
|
|
| def _format_outcome_feedback(ep): |
| product = ep.product |
| budget = product["budget"] |
| lines = [ |
| "Verifier feedback for the previous negotiation rollout:", |
| f"- Outcome: {ep.outcome}.", |
| ] |
|
|
| if "FORMAT_ERROR" in ep.outcome: |
| lines.append("- Diagnosis: the buyer output was not parseable as Thought/Talk/Action with a valid Action tag.") |
| lines.append("- Fix: keep the exact format and end with one explicit Action line.") |
| elif ep.outcome in {"BUYER_BUDGET_VIOLATION", "BUYER_DEAL_PRICE_MISMATCH", "BUYER_DEAL_INVALID_SELLER_OFFER"}: |
| lines.append("- Diagnosis: the buyer violated a hard protocol or budget constraint.") |
| lines.append("- Fix: never offer above the private budget; only DEAL an exact prior seller SELL offer.") |
| elif ep.final_price is None: |
| lines.append("- Diagnosis: no deal was reached, so the buyer captured no surplus.") |
| lines.append("- Fix: use an opening anchor and concessions that keep the seller engaged without revealing the budget.") |
| else: |
| price_ratio = ep.final_price / max(budget, 1e-6) |
| lines.append(f"- Final price: ${ep.final_price:.2f} against buyer budget ${budget:.2f}.") |
| lines.append(f"- Verifier label: {_quality_label(ep.reward)}.") |
| if price_ratio > 0.85: |
| lines.append("- Fix: the deal was too close to the budget; anchor lower and concede more slowly.") |
| elif price_ratio > 0.60: |
| lines.append("- Fix: valid deal, but there may be room for stronger anchoring or persuasion.") |
| else: |
| lines.append("- Keep: the price was meaningfully below budget; preserve the pressure-and-concession pattern.") |
|
|
| if SDPO_FEEDBACK_MODE == "oracle": |
| lines.extend( |
| [ |
| "", |
| "Oracle-only ablation details:", |
| f"- Seller private cost: ${product['cost']:.2f}.", |
| f"- Mutual-interest instance: {product['mi']}.", |
| f"- Numeric reward: {ep.reward:.4f}.", |
| ] |
| ) |
|
|
| return "\n".join(lines) |
|
|
|
|
| def build_sdpo_feedback(ep, group_eps): |
| """Build concise feedback for the self-teacher prompt. |
| |
| Strict mode intentionally avoids exact seller cost/private floor text. It may |
| use qualitative verifier labels and on-policy public sibling demos. |
| """ |
| if SDPO_FEEDBACK_MODE not in {"strict", "oracle"}: |
| raise ValueError(f"Unsupported SDPO_FEEDBACK_MODE={SDPO_FEEDBACK_MODE!r}") |
|
|
| feedback_parts = [_format_outcome_feedback(ep)] |
| demo, demo_kind = _best_demo_for(ep, group_eps) |
| has_demo = demo is not None |
| if demo is not None: |
| if demo_kind == "sibling": |
| feedback_parts.append( |
| "\nA better rollout sampled by the current policy for this same product is shown below. " |
| "It is a public Talk/Action transcript, not an external teacher answer." |
| ) |
| else: |
| feedback_parts.append( |
| "\nThis rollout itself reached a positive deal. Use the public transcript below to reinforce " |
| "the useful parts without overfitting to wording." |
| ) |
| feedback_parts.append(_public_transcript(demo)) |
|
|
| feedback_parts.append( |
| "\nCorrectly continue the original buyer turn. Prefer valid format, budget discipline, " |
| "low but plausible anchoring, and concise persuasion." |
| ) |
| feedback = "\n".join(feedback_parts).strip() |
| if len(feedback) > SDPO_MAX_FEEDBACK_CHARS: |
| feedback = feedback[:SDPO_MAX_FEEDBACK_CHARS].rstrip() + "\n[feedback truncated]" |
|
|
| if SDPO_FEEDBACK_MODE == "strict" and "cost_price" in feedback: |
| raise AssertionError("Strict SDPO feedback leaked cost_price field.") |
| return feedback, has_demo |
|
|
|
|
| def build_sdpo_teacher_turn_prompt(ep, turn_idx, feedback): |
| """Prompt the same buyer model as a hindsight self-teacher.""" |
| prompt_msgs = build_buyer_turn_prompt(ep, turn_idx) |
| prompt_msgs.append( |
| { |
| "role": "user", |
| "content": ( |
| "Hindsight training feedback is available for your previous negotiation attempt. " |
| "Use it only to judge what the next buyer message should make more or less likely.\n\n" |
| f"{feedback}" |
| ), |
| } |
| ) |
| return prompt_msgs |
|
|
|
|
| def _current_lr(step_idx): |
| """Linear warmup only; no decay for the short 42-step RLVR run. |
| |
| ``step_idx`` is the zero-based optimizer-step index. With WARMUP_STEPS=10, |
| the first step uses 10% of LR and the 10th step reaches the configured LR. |
| """ |
| if WARMUP_STEPS <= 0: |
| return LR |
| return LR * min(1.0, float(step_idx + 1) / float(WARMUP_STEPS)) |
|
|
|
|
| def _cpu_adamw_step(params, state, lr, beta1=0.9, beta2=0.95, eps=1e-8, weight_decay=WEIGHT_DECAY): |
| """Exact AdamW step with optimizer state kept on CPU. |
| |
| This preserves full fine-tuning and AdamW semantics while removing ~2x |
| parameter-size optimizer state from CUDA memory. Gradients are copied to CPU |
| one parameter at a time, so peak VRAM stays close to forward/backward memory. |
| """ |
| with torch.no_grad(): |
| for p in params: |
| if p.grad is None: |
| continue |
| sid = id(p) |
| st = state.get(sid) |
| if st is None: |
| st = { |
| "step": 0, |
| "exp_avg": torch.zeros_like(p.detach(), device="cpu", dtype=torch.float32), |
| "exp_avg_sq": torch.zeros_like(p.detach(), device="cpu", dtype=torch.float32), |
| } |
| state[sid] = st |
| st["step"] += 1 |
| grad = p.grad.detach().to(device="cpu", dtype=torch.float32) |
| exp_avg = st["exp_avg"] |
| exp_avg_sq = st["exp_avg_sq"] |
| exp_avg.mul_(beta1).add_(grad, alpha=1.0 - beta1) |
| exp_avg_sq.mul_(beta2).addcmul_(grad, grad, value=1.0 - beta2) |
| bias_correction1 = 1.0 - beta1 ** st["step"] |
| bias_correction2 = 1.0 - beta2 ** st["step"] |
| denom = (exp_avg_sq.sqrt() / (bias_correction2 ** 0.5)).add_(eps) |
| update = exp_avg / bias_correction1 / denom |
| update = update.to(device=p.device, dtype=p.dtype) |
| if weight_decay: |
| |
| |
| p.add_(p, alpha=-lr * weight_decay) |
| p.add_(update, alpha=-lr) |
| p.grad = None |
| del grad, update |
|
|
|
|
| def _optimizer_step(buyer_model, optimizer, cpu_adamw_state, step_idx): |
| grad_norm = torch.nn.utils.clip_grad_norm_(buyer_model.parameters(), GRAD_CLIP_NORM) |
| step_lr = _current_lr(step_idx) |
| if OPTIMIZER == "adamw_cpu": |
| _cpu_adamw_step([p for p in buyer_model.parameters() if p.requires_grad], cpu_adamw_state, lr=step_lr) |
| else: |
| for group in optimizer.param_groups: |
| group["lr"] = step_lr |
| optimizer.step() |
| optimizer.zero_grad(set_to_none=True) |
| return float(grad_norm.detach().cpu().item() if torch.is_tensor(grad_norm) else grad_norm), step_lr |
|
|
|
|
| def _normalize_completion_for_update(completion_text): |
| """Return completion tokens consistent with the prompt's chat-template state. |
| |
| In Qwen3.5 native-thinking mode, add_generation_prompt=True puts an open |
| ``<think>\n`` in the prompt. If we reattach that marker to the decoded private |
| history for auditing, do not train the model to emit the marker again. |
| """ |
| text = completion_text or "" |
| if CHAT_TEMPLATE_ENABLE_THINKING: |
| text = re.sub(r"^\s*<think\b[^>]*>\s*", "", text, count=1, flags=re.IGNORECASE) |
| if NATIVE_REASONING_PROTOCOL: |
| |
| |
| |
| |
| |
| if QWEN_THINK_CLOSE_RE.search(text): |
| public_suffix = QWEN_THINK_CLOSE_RE.split(text, maxsplit=1)[-1] |
| text = "</think>" + public_suffix |
| else: |
| text = strip_qwen_native_thinking(text) |
| return text |
|
|
|
|
| def _encode_prompt_completion(tokenizer, prompt_text, completion_text): |
| """Pre-tokenize prompt and completion once and keep completion-mask metadata. |
| |
| Prompt and completion are tokenized separately, then concatenated. That avoids |
| BPE boundary drift from tokenizing ``prompt`` and ``prompt + completion`` in |
| separate calls while still matching the causal-LM shifted-logprob objective. |
| If the pair exceeds UPDATE_MAX_LENGTH, left-truncate the prompt first so at |
| least the generated completion remains trainable. |
| """ |
| prompt_ids = tokenizer(prompt_text, add_special_tokens=False)["input_ids"] |
| completion_ids = tokenizer(_normalize_completion_for_update(completion_text), add_special_tokens=False)["input_ids"] |
| if not completion_ids: |
| completion_ids = [tokenizer.eos_token_id] |
|
|
| max_len = max(int(UPDATE_MAX_LENGTH), 2) |
| if len(completion_ids) >= max_len: |
| completion_ids = completion_ids[: max_len - 1] |
| prompt_ids = prompt_ids[-1:] |
| else: |
| prompt_budget = max_len - len(completion_ids) |
| if len(prompt_ids) > prompt_budget: |
| prompt_ids = prompt_ids[-prompt_budget:] |
|
|
| input_ids = prompt_ids + completion_ids |
| if len(input_ids) < 2: |
| input_ids = [tokenizer.eos_token_id] + input_ids |
| prompt_len = min(len(prompt_ids), len(input_ids) - 1) |
| completion_shift_start = max(prompt_len - 1, 0) |
| completion_shift_end = max(len(input_ids) - 1, completion_shift_start) |
| return { |
| "input_ids": input_ids, |
| "prompt_len": prompt_len, |
| "completion_shift_start": completion_shift_start, |
| "completion_shift_end": completion_shift_end, |
| } |
|
|
|
|
| def _encoded_padded_len(item): |
| seq_len = len(item["input_ids"]) |
| if UPDATE_PAD_TO_MULTIPLE_OF > 1: |
| rem = seq_len % UPDATE_PAD_TO_MULTIPLE_OF |
| if rem: |
| seq_len += UPDATE_PAD_TO_MULTIPLE_OF - rem |
| return seq_len |
|
|
|
|
| def _update_example_bucket_key(example): |
| |
| |
| |
| return max(_encoded_padded_len(example["student"]), _encoded_padded_len(example["teacher"])) |
|
|
|
|
| def _maybe_length_bucket_examples(examples): |
| if not UPDATE_LENGTH_BUCKETING or len(examples) <= UPDATE_MICROBATCH_SIZE: |
| return examples |
| return sorted(examples, key=_update_example_bucket_key) |
|
|
|
|
| def _pad_encoded_batch(encoded_items, tokenizer, device): |
| if not encoded_items: |
| raise ValueError("Cannot collate an empty update microbatch") |
| pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id |
| max_len = max(len(item["input_ids"]) for item in encoded_items) |
| if UPDATE_PAD_TO_MULTIPLE_OF > 1: |
| rem = max_len % UPDATE_PAD_TO_MULTIPLE_OF |
| if rem: |
| max_len += UPDATE_PAD_TO_MULTIPLE_OF - rem |
| input_rows, attn_rows, mask_rows = [], [], [] |
| for item in encoded_items: |
| ids = item["input_ids"] |
| seq_len = len(ids) |
| pad_len = max_len - seq_len |
| input_rows.append(ids + [pad_id] * pad_len) |
| attn_rows.append([1] * seq_len + [0] * pad_len) |
| |
| |
| shifted_len = max(max_len - 1, 0) |
| mask = [0] * shifted_len |
| start = max(min(item.get("completion_shift_start", item["prompt_len"] - 1), shifted_len), 0) |
| end = max(min(item.get("completion_shift_end", seq_len - 1), shifted_len), start) |
| for j in range(start, end): |
| mask[j] = 1 |
| mask_rows.append(mask) |
| return { |
| "input_ids": torch.tensor(input_rows, dtype=torch.long, device=device), |
| "attention_mask": torch.tensor(attn_rows, dtype=torch.long, device=device), |
| "completion_mask": torch.tensor(mask_rows, dtype=torch.float32, device=device), |
| } |
|
|
|
|
| def _build_group_update_examples(tokenizer, group_eps, advantages, timers): |
| """Flatten a GRPO group into buyer-turn update examples and pre-tokenize.""" |
| start = _timer_start() |
| examples = [] |
| sdpo_demo_count = 0 |
| for i, ep in enumerate(group_eps): |
| feedback, has_demo = build_sdpo_feedback(ep, group_eps) |
| sdpo_demo_count += int(has_demo) |
| for turn_idx, (role, text) in enumerate(ep.turns): |
| if role != "buyer": |
| continue |
| prompt_msgs = build_buyer_turn_prompt(ep, turn_idx) |
| prompt_text = _apply_chat_template_text( |
| tokenizer, prompt_msgs, tokenize=False, add_generation_prompt=True, enable_thinking=CHAT_TEMPLATE_ENABLE_THINKING |
| ) |
| _assert_no_private_info_leak(prompt_text, ep.product, "buyer") |
|
|
| teacher_prompt_msgs = build_sdpo_teacher_turn_prompt(ep, turn_idx, feedback) |
| teacher_prompt_text = _apply_chat_template_text( |
| tokenizer, teacher_prompt_msgs, tokenize=False, add_generation_prompt=True, enable_thinking=CHAT_TEMPLATE_ENABLE_THINKING |
| ) |
| _assert_no_private_info_leak(teacher_prompt_text, ep.product, "buyer") |
|
|
| student_enc = _encode_prompt_completion(tokenizer, prompt_text, text) |
| teacher_enc = _encode_prompt_completion(tokenizer, teacher_prompt_text, text) |
| if len(student_enc["input_ids"]) < 2 or len(teacher_enc["input_ids"]) < 2: |
| continue |
| examples.append( |
| { |
| "student": student_enc, |
| "teacher": teacher_enc, |
| "grpo_adv": float(advantages[i].detach().cpu().item()), |
| } |
| ) |
| _timer_add(timers, "update_pretokenize_s", start) |
| return examples, sdpo_demo_count |
|
|
|
|
| def _maybe_optimizer_step(buyer_model, optimizer, cpu_adamw_state, timers, step_idx, force=False): |
| start = _timer_start() |
| grad_present = any(p.grad is not None for p in buyer_model.parameters() if p.requires_grad) |
| _timer_add(timers, "update_grad_check_s", start) |
| if not grad_present: |
| return False, 0.0, _current_lr(step_idx) |
| start = _timer_start() |
| grad_norm, step_lr = _optimizer_step(buyer_model, optimizer, cpu_adamw_state, step_idx) |
| _timer_add(timers, "update_optimizer_s", start) |
| return True, grad_norm, step_lr |
|
|
|
|
| def _rowwise_sdpo_adv(pol_lp, teacher_lp, student_mask, teacher_mask): |
| """Align teacher/student completion log-prob gaps independently per row.""" |
| sdpo_adv = torch.zeros_like(pol_lp) |
| total_tokens = 0 |
| abs_adv = 0.0 |
| for row in range(pol_lp.shape[0]): |
| student_idx = student_mask[row].bool().nonzero(as_tuple=False).flatten() |
| teacher_idx = teacher_mask[row].bool().nonzero(as_tuple=False).flatten() |
| n_align = min(student_idx.numel(), teacher_idx.numel()) |
| if not n_align: |
| continue |
| s_idx = student_idx[:n_align] |
| t_idx = teacher_idx[:n_align] |
| sdpo_values = (teacher_lp[row, t_idx] - pol_lp.detach()[row, s_idx]).clamp(-SDPO_ADV_CLIP, SDPO_ADV_CLIP) |
| sdpo_adv[row, s_idx] = sdpo_values.to(sdpo_adv.dtype) |
| total_tokens += int(n_align) |
| abs_adv += float(sdpo_values.abs().sum().item()) |
| return sdpo_adv, total_tokens, abs_adv |
|
|
|
|
| def sdpo_grpo_update( |
| buyer_model, |
| tokenizer, |
| episodes, |
| optimizer, |
| device, |
| cpu_adamw_state=None, |
| optimizer_step_start=0, |
| sdpo_lambda_value=None, |
| ): |
| """Buyer-only ref-free/on-policy GRPO update plus feedback-conditioned SDPO. |
| |
| Performance notes: |
| - Buyer turns are flattened to pre-tokenized update examples per GRPO group. |
| - Forward/backward runs over UPDATE_MICROBATCH_SIZE examples rather than one |
| buyer turn at a time, improving A100 occupancy. |
| - CPU AdamW steps every OPTIM_STEP_EVERY_GROUPS groups by default. Losses are |
| scaled by the active accumulation window to preserve update magnitude. |
| |
| Objective note: |
| - This is true ref-free/on-policy training: no frozen reference-policy model, |
| no reference forward, and no KL penalty. The loss is the sampled-token policy |
| gradient ``-A * log πθ(token)`` over buyer completion tokens. The SDPO |
| self-teacher is still the current buyer model under hindsight feedback. |
| """ |
| buyer_model.train() |
| sdpo_lambda_value = SDPO_LAMBDA if sdpo_lambda_value is None else float(sdpo_lambda_value) |
| cpu_adamw_state = {} if cpu_adamw_state is None else cpu_adamw_state |
| G = GROUP_SIZE |
| num_groups = len(episodes) // G |
| total_loss = 0.0 |
| turn_count = 0 |
| sdpo_tokens = 0 |
| sdpo_abs_adv = 0.0 |
| sdpo_demo_count = 0 |
| optimizer_steps = 0 |
| grad_norm_last = 0.0 |
| global_step = int(optimizer_step_start) |
| lr_last = _current_lr(global_step) |
| timers: Dict[str, float] = { |
| "update_pretokenize_s": 0.0, |
| "update_collate_s": 0.0, |
| "update_policy_forward_s": 0.0, |
| |
| "update_ref_forward_s": 0.0, |
| "update_teacher_forward_s": 0.0, |
| "update_loss_backward_s": 0.0, |
| "update_optimizer_s": 0.0, |
| "update_grad_check_s": 0.0, |
| } |
|
|
| optim_every = max(1, OPTIM_STEP_EVERY_GROUPS) |
| accum_groups = 0 |
| loss_count = 0 |
|
|
| for g in range(num_groups): |
| group_eps = episodes[g * G : (g + 1) * G] |
| rewards = torch.tensor([ep.reward for ep in group_eps], dtype=torch.float32, device=device) |
| advantages = _norm_advantages(rewards - rewards.mean()) |
| group_examples, demo_count = _build_group_update_examples(tokenizer, group_eps, advantages, timers) |
| sdpo_demo_count += demo_count |
| if not group_examples: |
| continue |
| group_examples = _maybe_length_bucket_examples(group_examples) |
|
|
| group_turn_count = len(group_examples) |
| window_start_group = (g // optim_every) * optim_every |
| loss_scale_groups = max(1, min(optim_every, num_groups - window_start_group)) |
| for _inner in range(NUM_INNER_EPOCHS): |
| for mb_examples in _chunked(group_examples, UPDATE_MICROBATCH_SIZE): |
| start = _timer_start() |
| student_batch = _pad_encoded_batch([ex["student"] for ex in mb_examples], tokenizer, device) |
| teacher_batch = _pad_encoded_batch([ex["teacher"] for ex in mb_examples], tokenizer, device) |
| grpo_adv = torch.tensor([ex["grpo_adv"] for ex in mb_examples], dtype=torch.float32, device=device) |
| _timer_add(timers, "update_collate_s", start) |
|
|
| start = _timer_start() |
| pol_lp = _token_logprobs(buyer_model, student_batch["input_ids"], student_batch["attention_mask"]) |
| _timer_add(timers, "update_policy_forward_s", start) |
|
|
| start = _timer_start() |
| with torch.inference_mode(): |
| teacher_lp = _token_logprobs( |
| buyer_model, teacher_batch["input_ids"], teacher_batch["attention_mask"] |
| ) |
| _timer_add(timers, "update_teacher_forward_s", start) |
|
|
| student_mask = student_batch["completion_mask"] |
| teacher_mask = teacher_batch["completion_mask"] |
| sdpo_adv, n_align, mb_abs_adv = _rowwise_sdpo_adv(pol_lp, teacher_lp, student_mask, teacher_mask) |
| sdpo_abs_adv += mb_abs_adv |
| sdpo_tokens += n_align |
|
|
| adv = (sdpo_lambda_value * grpo_adv.view(-1, 1) + (1.0 - sdpo_lambda_value) * sdpo_adv).detach() |
| policy_loss = -adv * pol_lp |
|
|
| token_counts = student_mask.sum(dim=1).clamp_min(1.0) |
| row_losses = (policy_loss * student_mask).sum(dim=1) / token_counts |
| |
| |
| |
| unscaled_loss = row_losses.sum() |
| loss = unscaled_loss / loss_scale_groups |
|
|
| start = _timer_start() |
| loss.backward() |
| _timer_add(timers, "update_loss_backward_s", start) |
|
|
| total_loss += float(row_losses.detach().sum().item()) |
| loss_count += int(row_losses.numel()) |
|
|
| turn_count += group_turn_count |
| accum_groups += 1 |
|
|
| if accum_groups >= optim_every: |
| stepped, grad_norm_last, lr_last = _maybe_optimizer_step( |
| buyer_model, optimizer, cpu_adamw_state, timers, global_step |
| ) |
| if stepped: |
| optimizer_steps += 1 |
| global_step += 1 |
| accum_groups = 0 |
|
|
| if accum_groups > 0: |
| |
| stepped, grad_norm_last, lr_last = _maybe_optimizer_step( |
| buyer_model, optimizer, cpu_adamw_state, timers, global_step, force=True |
| ) |
| if stepped: |
| optimizer_steps += 1 |
| global_step += 1 |
|
|
| return { |
| "loss": total_loss / max(loss_count, 1), |
| "sdpo_tokens": sdpo_tokens, |
| "sdpo_mean_abs_adv": sdpo_abs_adv / max(sdpo_tokens, 1), |
| "sdpo_demo_count": sdpo_demo_count, |
| "update_examples": turn_count, |
| "optimizer_steps": optimizer_steps, |
| "optimizer_global_step": global_step, |
| "grad_norm_last": grad_norm_last, |
| "lr_last": lr_last, |
| "sdpo_lambda_active": sdpo_lambda_value, |
| **timers, |
| } |
|
|
|
|
| |
| def compute_product_mix_metrics(products): |
| if not products: |
| return { |
| "sample_product_count": 0, |
| "sample_unique_product_count": 0, |
| "sample_product_hash": "", |
| "sample_mean_budget": 0.0, |
| "sample_mean_cost": 0.0, |
| "sample_mean_list_price": 0.0, |
| "sample_mean_current_price": 0.0, |
| "sample_mean_average_price": 0.0, |
| "sample_mean_highest_price": 0.0, |
| "sample_mean_budget_cost_spread": 0.0, |
| "sample_mean_budget_cost_spread_ratio": 0.0, |
| "sample_mi_rate": 0.0, |
| "sample_category_counts": {}, |
| "sample_product_ids": [], |
| } |
|
|
| import hashlib |
|
|
| ids = [str(p.get("codename", "")) for p in products] |
| categories: Dict[str, int] = {} |
| for p in products: |
| cat = str(p.get("category", "unknown")) |
| categories[cat] = categories.get(cat, 0) + 1 |
|
|
| def mean_key(key): |
| return sum(float(p.get(key, 0.0) or 0.0) for p in products) / len(products) |
|
|
| spreads = [float(p.get("budget", 0.0) or 0.0) - float(p.get("cost", 0.0) or 0.0) for p in products] |
| budgets = [max(float(p.get("budget", 0.0) or 0.0), 1e-6) for p in products] |
| digest = hashlib.sha1(",".join(ids).encode("utf-8")).hexdigest()[:12] |
| return { |
| "sample_product_count": len(products), |
| "sample_unique_product_count": len(set(ids)), |
| "sample_product_hash": digest, |
| "sample_mean_budget": mean_key("budget"), |
| "sample_mean_cost": mean_key("cost"), |
| "sample_mean_list_price": mean_key("list_price"), |
| "sample_mean_current_price": mean_key("current_price"), |
| "sample_mean_average_price": mean_key("average_price"), |
| "sample_mean_highest_price": mean_key("highest_price"), |
| "sample_mean_budget_cost_spread": sum(spreads) / len(spreads), |
| "sample_mean_budget_cost_spread_ratio": sum(s / b for s, b in zip(spreads, budgets)) / len(spreads), |
| "sample_mi_rate": sum(1 for p in products if p.get("mi")) / len(products), |
| "sample_category_counts": dict(sorted(categories.items())), |
| "sample_product_ids": ids, |
| } |
|
|
|
|
| def compute_iter_metrics(episodes): |
| rewards = [ep.reward for ep in episodes] |
| mean_r = sum(rewards) / max(len(rewards), 1) |
| deals = [ep for ep in episodes if ep.final_price is not None] |
| deal_rate = len(deals) / max(len(episodes), 1) |
| mean_price = sum(ep.final_price for ep in deals) / max(len(deals), 1) if deals else 0.0 |
| mean_turns = sum(ep.num_turns for ep in episodes) / max(len(episodes), 1) |
| outcomes: Dict[str, int] = {} |
| for ep in episodes: |
| outcomes[ep.outcome] = outcomes.get(ep.outcome, 0) + 1 |
| role_confusions = 0 |
| for ep in episodes: |
| for role, text in ep.turns: |
| act = extract_action(text) |
| if role == "buyer" and act["type"] == "SELL": |
| role_confusions += 1 |
| elif role == "seller" and act["type"] == "BUY": |
| role_confusions += 1 |
| budget_violations = sum(1 for ep in episodes if ep.budget_violations > 0) |
| total_turns = sum(len(ep.turns) for ep in episodes) |
| native_think_marker_turns = sum( |
| 1 |
| for ep in episodes |
| for _, text in ep.turns |
| if QWEN_THINK_OPEN_RE.search(text or "") or QWEN_THINK_CLOSE_RE.search(text or "") |
| ) |
| structured_thought_turns = sum( |
| 1 |
| for ep in episodes |
| for _, text in ep.turns |
| if re.search(r"(?:^|\n)\s*Thought\s*:", text or "", re.IGNORECASE) |
| ) |
| first_offer_ratios = [ep.first_offer_price / ep.product["budget"] for ep in episodes if ep.first_offer_price] |
| mean_first_offer_ratio = sum(first_offer_ratios) / max(len(first_offer_ratios), 1) if first_offer_ratios else None |
|
|
| return { |
| "mean_reward": mean_r, |
| "deal_rate": deal_rate, |
| "mean_price": mean_price, |
| "mean_turns": mean_turns, |
| "outcomes": dict(sorted(outcomes.items(), key=lambda x: -x[1])), |
| "role_confusions": role_confusions, |
| "native_think_marker_turns": native_think_marker_turns, |
| "native_think_marker_rate": native_think_marker_turns / max(total_turns, 1), |
| "structured_thought_turns": structured_thought_turns, |
| "structured_thought_rate": structured_thought_turns / max(total_turns, 1), |
| "price_overshoot_rate": budget_violations / max(len(episodes), 1), |
| "first_offer_ratio": mean_first_offer_ratio, |
| } |
|
|
|
|
| def save_and_push_checkpoint(buyer_model, tokenizer, metrics, iteration, final=False, processor=None): |
| if not HUB_MODEL_ID: |
| return |
| branch = "main" if final else f"iter-{iteration + 1}" |
| label = "FINAL" if final else f"iter {iteration + 1}" |
| path = Path(OUTPUT_DIR if final else f"/tmp/sdpo-ckpt-{iteration+1}") |
| path.mkdir(parents=True, exist_ok=True) |
| buyer_model.save_pretrained(path) |
| (processor or tokenizer).save_pretrained(path) |
| with open(path / "metrics.json", "w") as f: |
| json.dump(metrics, f, indent=2) |
| if PUSH_TRAINING_SCRIPT: |
| try: |
| shutil.copyfile(__file__, path / "train_negotiation_sdpo.py") |
| except Exception: |
| pass |
|
|
| try: |
| from huggingface_hub import HfApi, create_repo |
|
|
| token = os.environ.get("HF_TOKEN") |
| api = HfApi(token=token) |
| create_repo(HUB_MODEL_ID, exist_ok=True, token=token) |
| if not final: |
| try: |
| api.create_branch(HUB_MODEL_ID, branch=branch, repo_type="model") |
| except Exception: |
| pass |
| api.upload_folder( |
| folder_path=path, |
| repo_id=HUB_MODEL_ID, |
| repo_type="model", |
| revision=branch, |
| commit_message=f"SDPO negotiation {label}", |
| ) |
| print(f" [CHECKPOINT] ✅ Pushed {label} to {HUB_MODEL_ID}@{branch}") |
| except Exception as e: |
| print(f" [CHECKPOINT] ⚠️ Push failed (non-fatal): {e}") |
| finally: |
| if not final: |
| shutil.rmtree(path, ignore_errors=True) |
|
|
|
|
| |
| def check_cuda(): |
| print(f"PyTorch: {torch.__version__}") |
| print(f"CUDA available: {torch.cuda.is_available()}") |
| _log_qwen35_fastpath_status() |
| if not torch.cuda.is_available(): |
| print("FATAL: No CUDA") |
| sys.exit(1) |
| print(f"CUDA device count: {torch.cuda.device_count()}") |
| for idx in range(torch.cuda.device_count()): |
| print(f"GPU[{idx}]: {torch.cuda.get_device_name(idx)} VRAM={torch.cuda.get_device_properties(idx).total_memory / 1e9:.1f} GB") |
| x = torch.randn(2, 2).cuda() @ torch.randn(2, 2).cuda() |
| print(f"Compute test: {x.device} OK") |
| print("=" * 70, flush=True) |
|
|
|
|
| def main(): |
| check_cuda() |
|
|
| print("[CONFIG] Negotiation SDPO+GRPO buyer-only training") |
| print(f"[CONFIG] BuyerModel={MODEL_NAME} SellerModel={SELLER_MODEL_NAME}") |
| print(f"[CONFIG] Iters={NUM_ITERS} Batch={BATCH_SIZE} Group={GROUP_SIZE} Episodes/iter={BATCH_SIZE*GROUP_SIZE}") |
| print(f"[CONFIG] Turns={MAX_TURNS} LR={LR} WarmupSteps={WARMUP_STEPS} WD={WEIGHT_DECAY} GradClip={GRAD_CLIP_NORM}") |
| print(f"[CONFIG] RefFree=True KL={KL_COEF} Eps={EPSILON} (unused ref-free)") |
| print(f"[CONFIG] BuyerTemp={BUYER_TEMP} SellerTemp={SELLER_TEMP} MaxNew={MAX_NEW_TOKENS}") |
| print( |
| f"[CONFIG] ReasoningMode={REASONING_MODE} NativeThinking={ENABLE_NATIVE_THINKING} " |
| f"NativeProtocol={NATIVE_REASONING_PROTOCOL} ChatTemplateThinking={CHAT_TEMPLATE_ENABLE_THINKING} " |
| f"NativeFinalizer={NATIVE_PUBLIC_FINALIZER} ThinkTokens={NATIVE_THINK_TOKENS} FinalTokens={NATIVE_FINAL_TOKENS} " |
| f"StripNativeFromHistory={STRIP_NATIVE_THINKING_FROM_HISTORY} DebugBuyerOutputs={DEBUG_SAMPLE_BUYER_OUTPUTS}" |
| ) |
| print(f"[CONFIG] GradCheckpoint={GRADIENT_CHECKPOINTING} GenBatchLimit={GEN_BATCH_LIMIT} RolloutMaxLength={ROLLOUT_MAX_LENGTH}") |
| print(f"[CONFIG] DeviceMap={MODEL_DEVICE_MAP} MaxMemoryPerGPUGiB={MAX_MEMORY_PER_GPU_GIB or '(unset)'}") |
| print(f"[CONFIG] InnerEpochs={NUM_INNER_EPOCHS} NormAdvantages={NORMALIZE_ADVANTAGES}") |
| print( |
| f"[CONFIG] SDPO_LambdaStart={SDPO_LAMBDA} Final={SDPO_LAMBDA_FINAL} " |
| f"DecayIters={SDPO_LAMBDA_DECAY_ITERS} FeedbackMode={SDPO_FEEDBACK_MODE} " |
| f"AdvClip={SDPO_ADV_CLIP} MaxFeedbackChars={SDPO_MAX_FEEDBACK_CHARS} AdamWForeach={ADAMW_FOREACH}" |
| ) |
| print( |
| f"[CONFIG] UpdateMicrobatch={UPDATE_MICROBATCH_SIZE} " |
| f"OptimStepEveryGroups={OPTIM_STEP_EVERY_GROUPS} " |
| f"UpdatePadMultiple={UPDATE_PAD_TO_MULTIPLE_OF} UpdateLengthBucketing={UPDATE_LENGTH_BUCKETING} " |
| f"UpdateMaxLength={UPDATE_MAX_LENGTH}" |
| ) |
| print( |
| f"[CONFIG] EarlyStop format>={FORMAT_STOP_THRESHOLD}x{FORMAT_STOP_PATIENCE} " |
| f"budget>={BUDGET_STOP_THRESHOLD}x{BUDGET_STOP_PATIENCE} " |
| f"reward<={REWARD_STOP_THRESHOLD}x{REWARD_STOP_PATIENCE} " |
| f"save={EARLY_STOP_SAVE_CHECKPOINT}" |
| ) |
| print(f"[CONFIG] CheckpointEvery={CHECKPOINT_EVERY} Hub={HUB_MODEL_ID or '(disabled)'}") |
| print("=" * 70, flush=True) |
|
|
| try: |
| import wandb |
|
|
| run_name = RUN_NAME or default_run_name() |
| wandb_run = wandb.init( |
| entity=WANDB_ENTITY, |
| project=WANDB_PROJECT, |
| name=run_name, |
| group=os.environ.get("WANDB_GROUP", default_wandb_group()), |
| job_type=WANDB_JOB_TYPE, |
| mode=WANDB_MODE, |
| tags=WANDB_TAGS, |
| save_code=False, |
| config={ |
| "method": "negotiation_sdpo_grpo_qwen35_ref_free", |
| "buyer_model": MODEL_NAME, |
| "seller_model": SELLER_MODEL_NAME, |
| "num_iters": NUM_ITERS, |
| "batch_size": BATCH_SIZE, |
| "group_size": GROUP_SIZE, |
| "max_turns": MAX_TURNS, |
| "lr": LR, |
| "weight_decay": WEIGHT_DECAY, |
| "warmup_steps": WARMUP_STEPS, |
| "grad_clip_norm": GRAD_CLIP_NORM, |
| "epsilon": EPSILON, |
| "kl_coef": KL_COEF, |
| "ref_free_objective": True, |
| "reference_model_used": False, |
| "max_new_tokens": MAX_NEW_TOKENS, |
| "buyer_temp": BUYER_TEMP, |
| "seller_temp": SELLER_TEMP, |
| "reasoning_mode": REASONING_MODE, |
| "enable_native_thinking": ENABLE_NATIVE_THINKING, |
| "native_reasoning_protocol": NATIVE_REASONING_PROTOCOL, |
| "chat_template_enable_thinking": CHAT_TEMPLATE_ENABLE_THINKING, |
| "native_public_finalizer": NATIVE_PUBLIC_FINALIZER, |
| "native_think_tokens": NATIVE_THINK_TOKENS, |
| "native_final_tokens": NATIVE_FINAL_TOKENS, |
| "strip_native_thinking_from_history": STRIP_NATIVE_THINKING_FROM_HISTORY, |
| "debug_sample_buyer_outputs": DEBUG_SAMPLE_BUYER_OUTPUTS, |
| "normalize_advantages": NORMALIZE_ADVANTAGES, |
| "num_inner_epochs": NUM_INNER_EPOCHS, |
| "sdpo_lambda": SDPO_LAMBDA, |
| "sdpo_lambda_final": SDPO_LAMBDA_FINAL, |
| "sdpo_lambda_decay_iters": SDPO_LAMBDA_DECAY_ITERS, |
| "sdpo_feedback_mode": SDPO_FEEDBACK_MODE, |
| "sdpo_adv_clip": SDPO_ADV_CLIP, |
| "distillation_level": DISTILLATION_LEVEL, |
| "top_k_distillation": TOP_K_DISTILLATION, |
| "distillation_divergence": DISTILLATION_DIVERGENCE, |
| "trust_region_interpolation": TRUST_REGION_INTERPOLATION, |
| "teacher_ema_decay": TEACHER_EMA_DECAY, |
| "sdpo_max_demo_chars": SDPO_MAX_DEMO_CHARS, |
| "sdpo_max_feedback_chars": SDPO_MAX_FEEDBACK_CHARS, |
| "format_warn_threshold": FORMAT_WARN_THRESHOLD, |
| "format_stop_threshold": FORMAT_STOP_THRESHOLD, |
| "format_stop_patience": FORMAT_STOP_PATIENCE, |
| "budget_warn_threshold": BUDGET_WARN_THRESHOLD, |
| "budget_stop_threshold": BUDGET_STOP_THRESHOLD, |
| "budget_stop_patience": BUDGET_STOP_PATIENCE, |
| "reward_stop_threshold": REWARD_STOP_THRESHOLD, |
| "reward_stop_patience": REWARD_STOP_PATIENCE, |
| "early_stop_save_checkpoint": EARLY_STOP_SAVE_CHECKPOINT, |
| "optimizer": OPTIMIZER, |
| "adamw_foreach": ADAMW_FOREACH, |
| "update_microbatch_size": UPDATE_MICROBATCH_SIZE, |
| "optim_step_every_groups": OPTIM_STEP_EVERY_GROUPS, |
| "update_pad_to_multiple_of": UPDATE_PAD_TO_MULTIPLE_OF, |
| "update_length_bucketing": UPDATE_LENGTH_BUCKETING, |
| "rollout_max_length": ROLLOUT_MAX_LENGTH, |
| "update_max_length": UPDATE_MAX_LENGTH, |
| "model_device_map": MODEL_DEVICE_MAP, |
| "max_memory_per_gpu_gib": MAX_MEMORY_PER_GPU_GIB, |
| "liger_kernel": USE_LIGER, |
| "dataset_categories": CATEGORIES, |
| }, |
| ) |
| WANDB_OK = True |
| print(f"[WANDB] Run: {wandb_run.url}") |
| except Exception as e: |
| print(f"[WANDB] Init failed (non-fatal): {e}") |
| WANDB_OK = False |
| wandb = None |
| wandb_run = None |
|
|
| print("\n[1/5] Loading dataset...") |
| train_products, _ = load_products(seed=SEED) |
|
|
| print(f"\n[2/5] Loading tokenizer/processor ({MODEL_NAME})...") |
| buyer_processor = None |
| seller_processor = None |
| print(" [OK] Deferred to model loader so Qwen3.5 can use AutoProcessor") |
|
|
| print("\n[3/5] Loading trainable buyer model...") |
| buyer_model, buyer_processor, tokenizer = _load_text_or_image_text_stack(MODEL_NAME) |
| _assert_no_cpu_offload(buyer_model, "buyer_model") |
| if GRADIENT_CHECKPOINTING: |
| buyer_model.gradient_checkpointing_enable() |
| if hasattr(buyer_model, "config"): |
| buyer_model.config.use_cache = False |
| dev = _model_input_device(buyer_model) |
| if tokenizer.pad_token is None: |
| tokenizer.pad_token = tokenizer.eos_token |
| _qwen35_text_canary(buyer_model, buyer_processor, tokenizer, dev, "buyer_model") |
| print(f" [OK] InputDevice={dev} FirstParamDevice={_first_model_device(buyer_model)} VRAM={torch.cuda.memory_allocated()/1e9:.1f}GB") |
|
|
| print("\n[4/5] Loading frozen seller/environment model (no reference-policy model)...") |
| seller_model, seller_processor, seller_tokenizer = _load_text_or_image_text_stack(SELLER_MODEL_NAME) |
| _assert_no_cpu_offload(seller_model, "seller_model") |
| if seller_tokenizer.pad_token is None: |
| seller_tokenizer.pad_token = seller_tokenizer.eos_token |
| if SELLER_MODEL_NAME != MODEL_NAME: |
| print(" [WARN] Separate seller tokenizer loaded; rollouts use buyer tokenizer for shared prompt formatting") |
| _qwen35_text_canary(seller_model, seller_processor, seller_tokenizer, _model_input_device(seller_model), "seller_model") |
| seller_model.eval() |
| for p in seller_model.parameters(): |
| p.requires_grad = False |
| seller_trainable_params = sum(p.numel() for p in seller_model.parameters() if p.requires_grad) |
| if seller_trainable_params != 0: |
| raise RuntimeError(f"seller_model unexpectedly has {seller_trainable_params:,} trainable params") |
| print(f" [OK] Frozen seller trainable_params={seller_trainable_params:,} VRAM={torch.cuda.memory_allocated()/1e9:.1f}GB") |
|
|
| print( |
| f"\n[5/5] Optimizer (AdamW, lr={LR}, warmup_steps={WARMUP_STEPS}, " |
| f"betas=(0.9,0.95), wd={WEIGHT_DECAY}, grad_clip={GRAD_CLIP_NORM}, " |
| f"optimizer={OPTIMIZER}, foreach={ADAMW_FOREACH})..." |
| ) |
| if OPTIMIZER == "adamw_cpu": |
| optimizer = None |
| cpu_adamw_state = {} |
| print(" [OK] AdamW optimizer state will be stored on CPU to avoid CUDA optimizer-step OOM") |
| elif OPTIMIZER == "adamw_cuda": |
| optimizer = torch.optim.AdamW( |
| buyer_model.parameters(), |
| lr=LR, |
| betas=(0.9, 0.95), |
| weight_decay=WEIGHT_DECAY, |
| foreach=ADAMW_FOREACH, |
| ) |
| cpu_adamw_state = None |
| else: |
| raise ValueError(f"Unsupported OPTIMIZER={OPTIMIZER}; use adamw_cpu or adamw_cuda") |
| n_params = sum(p.numel() for p in buyer_model.parameters() if p.requires_grad) |
| print(f" Trainable params: {n_params:,}") |
|
|
| print(f"\n{'=' * 70}\nNEGOTIATION SDPO+GRPO TRAINING\n{'=' * 70}") |
| metrics = [] |
| optimizer_global_step = 0 |
| t0 = time.time() |
|
|
| for iteration in range(NUM_ITERS): |
| t_iter = time.time() |
| print(f"\n--- Iteration {iteration} ---") |
| current_vram_total, current_vram_per_gpu = _memory_allocated_all_gpus_gb() |
| print(f" VRAM: total={current_vram_total:.1f}GB per_gpu={_fmt_gpu_gb(current_vram_per_gpu)}") |
|
|
| products = random.sample(train_products, min(BATCH_SIZE, len(train_products))) |
| product_mix = compute_product_mix_metrics(products) |
| products_expanded = [p for p in products for _ in range(GROUP_SIZE)] |
| n_episodes = len(products_expanded) |
| print(f" Sampling {len(products)} products × {GROUP_SIZE} rollouts = {n_episodes} episodes...") |
| print( |
| f" Product mix: hash={product_mix['sample_product_hash']} " |
| f"budget=${product_mix['sample_mean_budget']:.2f} cost=${product_mix['sample_mean_cost']:.2f} " |
| f"list=${product_mix['sample_mean_list_price']:.2f} spread=${product_mix['sample_mean_budget_cost_spread']:.2f} " |
| f"MI={product_mix['sample_mi_rate']:.1%} cats={product_mix['sample_category_counts']}" |
| ) |
|
|
| lambda_active = active_sdpo_lambda(iteration) |
| print(f" Active SDPO_LAMBDA={lambda_active:.4f} (GRPO weight; SDPO weight={1.0 - lambda_active:.4f})") |
| buyer_model.eval() |
| seller_model.eval() |
| _reset_peak_memory_stats_all_gpus() |
| rollout_t0 = time.time() |
| episodes = run_episodes_batched(buyer_model, seller_model, tokenizer, products_expanded, dev, seller_tokenizer=seller_tokenizer) |
| rollout_time = time.time() - rollout_t0 |
| rollout_peak_vram, rollout_peak_vram_per_gpu = _peak_memory_allocated_all_gpus_gb() |
| rollout_reserved_peak_vram, rollout_reserved_peak_vram_per_gpu = _peak_memory_reserved_all_gpus_gb() |
| print(f" Rollout: {n_episodes} episodes in {rollout_time:.0f}s ({rollout_time/n_episodes:.1f}s/ep)") |
| torch.cuda.empty_cache() |
| gc.collect() |
|
|
| buyer_model.train() |
| print(" SDPO+GRPO update on buyer turns only...") |
| _reset_peak_memory_stats_all_gpus() |
| update_stats = sdpo_grpo_update( |
| buyer_model, |
| tokenizer, |
| episodes, |
| optimizer, |
| dev, |
| cpu_adamw_state, |
| optimizer_global_step, |
| sdpo_lambda_value=lambda_active, |
| ) |
| update_peak_vram, update_peak_vram_per_gpu = _peak_memory_allocated_all_gpus_gb() |
| update_reserved_peak_vram, update_reserved_peak_vram_per_gpu = _peak_memory_reserved_all_gpus_gb() |
| optimizer_global_step = update_stats["optimizer_global_step"] |
| loss = update_stats["loss"] |
| torch.cuda.empty_cache() |
| gc.collect() |
|
|
| iter_metrics = compute_iter_metrics(episodes) |
| fmt_errors = iter_metrics["outcomes"].get("BUYER_FORMAT_ERROR", 0) |
| budget_violations = iter_metrics["outcomes"].get("BUYER_BUDGET_VIOLATION", 0) |
| elapsed = time.time() - t_iter |
| update_time = elapsed - rollout_time |
| current_vram, current_vram_per_gpu = _memory_allocated_all_gpus_gb() |
| current_reserved_vram, current_reserved_vram_per_gpu = _memory_reserved_all_gpus_gb() |
| peak_vram = max(rollout_peak_vram, update_peak_vram) |
| peak_vram_per_gpu = [ |
| max(r, u) for r, u in zip(rollout_peak_vram_per_gpu, update_peak_vram_per_gpu) |
| ] |
| reserved_peak_vram = max(rollout_reserved_peak_vram, update_reserved_peak_vram) |
| reserved_peak_vram_per_gpu = [ |
| max(r, u) for r, u in zip(rollout_reserved_peak_vram_per_gpu, update_reserved_peak_vram_per_gpu) |
| ] |
|
|
| print( |
| f" Loss={loss:.4f} Reward={iter_metrics['mean_reward']:.4f} " |
| f"Deal={iter_metrics['deal_rate']:.1%} Price=${iter_metrics['mean_price']:.2f} " |
| f"Turns={iter_metrics['mean_turns']:.1f}" |
| ) |
| print( |
| f" SDPO tokens={update_stats['sdpo_tokens']} " |
| f"mean|A|={update_stats['sdpo_mean_abs_adv']:.4f} " |
| f"demos={update_stats['sdpo_demo_count']}" |
| ) |
| print(f" FirstOfferRatio={iter_metrics['first_offer_ratio']} Overshoot={iter_metrics['price_overshoot_rate']:.1%}") |
| print( |
| f" Update: examples={update_stats['update_examples']} " |
| f"optimizer_steps={update_stats['optimizer_steps']} lr={update_stats['lr_last']:.2e} " |
| f"grad_norm={update_stats['grad_norm_last']:.4f}" |
| ) |
| print(f" Time={elapsed:.0f}s (rollout={rollout_time:.0f}s update={update_time:.0f}s)") |
| print( |
| f" Phase VRAM allocated peaks: rollout={rollout_peak_vram:.1f}GB per_gpu={_fmt_gpu_gb(rollout_peak_vram_per_gpu)} " |
| f"update={update_peak_vram:.1f}GB per_gpu={_fmt_gpu_gb(update_peak_vram_per_gpu)} " |
| f"overall={peak_vram:.1f}GB per_gpu={_fmt_gpu_gb(peak_vram_per_gpu)}" |
| ) |
| print( |
| f" Phase VRAM reserved peaks: rollout={rollout_reserved_peak_vram:.1f}GB per_gpu={_fmt_gpu_gb(rollout_reserved_peak_vram_per_gpu)} " |
| f"update={update_reserved_peak_vram:.1f}GB per_gpu={_fmt_gpu_gb(update_reserved_peak_vram_per_gpu)} " |
| f"overall={reserved_peak_vram:.1f}GB per_gpu={_fmt_gpu_gb(reserved_peak_vram_per_gpu)}" |
| ) |
| print( |
| " Update timers: " |
| f"pretokenize={update_stats['update_pretokenize_s']:.1f}s " |
| f"collate={update_stats['update_collate_s']:.1f}s " |
| f"policy_fwd={update_stats['update_policy_forward_s']:.1f}s " |
| f"ref_fwd={update_stats['update_ref_forward_s']:.1f}s(ref-free) " |
| f"teacher_fwd={update_stats['update_teacher_forward_s']:.1f}s " |
| f"backward={update_stats['update_loss_backward_s']:.1f}s " |
| f"optimizer={update_stats['update_optimizer_s']:.1f}s " |
| f"grad_check={update_stats['update_grad_check_s']:.1f}s" |
| ) |
| print(f" Outcomes: {dict(list(iter_metrics['outcomes'].items())[:6])}") |
| if fmt_errors >= FORMAT_WARN_THRESHOLD: |
| print( |
| f" ⚠️ BUYER FORMAT ERRORS: {fmt_errors}/{n_episodes} " |
| f"(stop if >= {FORMAT_STOP_THRESHOLD} for {FORMAT_STOP_PATIENCE} consecutive iters)" |
| ) |
| if budget_violations >= BUDGET_WARN_THRESHOLD: |
| print( |
| f" ⚠️ BUYER BUDGET VIOLATIONS: {budget_violations}/{n_episodes} " |
| f"(stop if >= {BUDGET_STOP_THRESHOLD} for {BUDGET_STOP_PATIENCE} consecutive iters)" |
| ) |
| if iter_metrics["role_confusions"]: |
| print(f" ⚠️ ROLE CONFUSIONS: {iter_metrics['role_confusions']}") |
| print( |
| f" Reasoning markers: native_think={iter_metrics['native_think_marker_turns']} " |
| f"({iter_metrics['native_think_marker_rate']:.1%} of turns), " |
| f"explicit Thought={iter_metrics['structured_thought_turns']} " |
| f"({iter_metrics['structured_thought_rate']:.1%} of turns)" |
| ) |
| print( |
| f" VRAM allocated: total_current={current_vram:.1f}GB per_gpu={_fmt_gpu_gb(current_vram_per_gpu)} " |
| f"peak={peak_vram:.1f}GB per_gpu={_fmt_gpu_gb(peak_vram_per_gpu)}", |
| flush=True, |
| ) |
| print( |
| f" VRAM reserved: total_current={current_reserved_vram:.1f}GB per_gpu={_fmt_gpu_gb(current_reserved_vram_per_gpu)} " |
| f"peak={reserved_peak_vram:.1f}GB per_gpu={_fmt_gpu_gb(reserved_peak_vram_per_gpu)}", |
| flush=True, |
| ) |
|
|
| row = { |
| "iteration": iteration, |
| "loss": loss, |
| **iter_metrics, |
| **product_mix, |
| "sdpo_tokens": update_stats["sdpo_tokens"], |
| "sdpo_mean_abs_adv": update_stats["sdpo_mean_abs_adv"], |
| "sdpo_demo_count": update_stats["sdpo_demo_count"], |
| "update_examples": update_stats["update_examples"], |
| "optimizer_steps": update_stats["optimizer_steps"], |
| "optimizer_global_step": update_stats["optimizer_global_step"], |
| "lr_last": update_stats["lr_last"], |
| "grad_norm_last": update_stats["grad_norm_last"], |
| "sdpo_lambda_active": update_stats["sdpo_lambda_active"], |
| "time": elapsed, |
| "rollout_time": rollout_time, |
| "update_time": update_time, |
| "update_pretokenize_s": update_stats["update_pretokenize_s"], |
| "update_collate_s": update_stats["update_collate_s"], |
| "update_policy_forward_s": update_stats["update_policy_forward_s"], |
| "update_ref_forward_s": update_stats["update_ref_forward_s"], |
| "update_teacher_forward_s": update_stats["update_teacher_forward_s"], |
| "update_loss_backward_s": update_stats["update_loss_backward_s"], |
| "update_optimizer_s": update_stats["update_optimizer_s"], |
| "update_grad_check_s": update_stats["update_grad_check_s"], |
| "vram_current_gb": current_vram, |
| "vram_current_per_gpu_gb": current_vram_per_gpu, |
| "vram_reserved_current_gb": current_reserved_vram, |
| "vram_reserved_current_per_gpu_gb": current_reserved_vram_per_gpu, |
| "vram_peak_gb": peak_vram, |
| "vram_peak_per_gpu_gb": peak_vram_per_gpu, |
| "vram_reserved_peak_gb": reserved_peak_vram, |
| "vram_reserved_peak_per_gpu_gb": reserved_peak_vram_per_gpu, |
| "rollout_vram_peak_gb": rollout_peak_vram, |
| "rollout_vram_peak_per_gpu_gb": rollout_peak_vram_per_gpu, |
| "rollout_vram_reserved_peak_gb": rollout_reserved_peak_vram, |
| "rollout_vram_reserved_peak_per_gpu_gb": rollout_reserved_peak_vram_per_gpu, |
| "update_vram_peak_gb": update_peak_vram, |
| "update_vram_peak_per_gpu_gb": update_peak_vram_per_gpu, |
| "update_vram_reserved_peak_gb": update_reserved_peak_vram, |
| "update_vram_reserved_peak_per_gpu_gb": update_reserved_peak_vram_per_gpu, |
| } |
| metrics.append(row) |
|
|
| if WANDB_OK: |
| try: |
| wandb.log( |
| { |
| "train/loss": loss, |
| "reward/buyer": iter_metrics["mean_reward"], |
| "negotiation/deal_rate": iter_metrics["deal_rate"], |
| "negotiation/mean_price": iter_metrics["mean_price"], |
| "negotiation/mean_turns": iter_metrics["mean_turns"], |
| "negotiation/first_offer_ratio": iter_metrics["first_offer_ratio"] or 0.0, |
| "negotiation/price_overshoot_rate": iter_metrics["price_overshoot_rate"], |
| "sample/product_count": product_mix["sample_product_count"], |
| "sample/unique_product_count": product_mix["sample_unique_product_count"], |
| "sample/mean_budget": product_mix["sample_mean_budget"], |
| "sample/mean_cost": product_mix["sample_mean_cost"], |
| "sample/mean_list_price": product_mix["sample_mean_list_price"], |
| "sample/mean_current_price": product_mix["sample_mean_current_price"], |
| "sample/mean_average_price": product_mix["sample_mean_average_price"], |
| "sample/mean_highest_price": product_mix["sample_mean_highest_price"], |
| "sample/mean_budget_cost_spread": product_mix["sample_mean_budget_cost_spread"], |
| "sample/mean_budget_cost_spread_ratio": product_mix["sample_mean_budget_cost_spread_ratio"], |
| "sample/mi_rate": product_mix["sample_mi_rate"], |
| **{f"sample/category_count/{k}": v for k, v in product_mix["sample_category_counts"].items()}, |
| "sdpo/tokens": update_stats["sdpo_tokens"], |
| "sdpo/mean_abs_adv": update_stats["sdpo_mean_abs_adv"], |
| "sdpo/demo_count": update_stats["sdpo_demo_count"], |
| "perf/iter_time_s": elapsed, |
| "perf/rollout_time_s": rollout_time, |
| "perf/update_time_s": update_time, |
| "objective/ref_free": 1, |
| "objective/reference_model_used": 0, |
| "objective/kl_coef": KL_COEF, |
| "objective/sdpo_lambda_active": update_stats["sdpo_lambda_active"], |
| "perf/update_pretokenize_s": update_stats["update_pretokenize_s"], |
| "perf/update_collate_s": update_stats["update_collate_s"], |
| "perf/update_policy_forward_s": update_stats["update_policy_forward_s"], |
| "perf/update_ref_forward_s": update_stats["update_ref_forward_s"], |
| "perf/update_teacher_forward_s": update_stats["update_teacher_forward_s"], |
| "perf/update_loss_backward_s": update_stats["update_loss_backward_s"], |
| "perf/update_optimizer_s": update_stats["update_optimizer_s"], |
| "perf/update_grad_check_s": update_stats["update_grad_check_s"], |
| "perf/update_examples": update_stats["update_examples"], |
| "perf/optimizer_steps": update_stats["optimizer_steps"], |
| "train/optimizer_global_step": update_stats["optimizer_global_step"], |
| "train/lr": update_stats["lr_last"], |
| "train/grad_norm_last": update_stats["grad_norm_last"], |
| "perf/vram_gb": current_vram, |
| "perf/vram_peak_gb": peak_vram, |
| "perf/vram_reserved_gb": current_reserved_vram, |
| "perf/vram_reserved_peak_gb": reserved_peak_vram, |
| "perf/rollout_vram_peak_gb": rollout_peak_vram, |
| "perf/rollout_vram_reserved_peak_gb": rollout_reserved_peak_vram, |
| "perf/update_vram_peak_gb": update_peak_vram, |
| "perf/update_vram_reserved_peak_gb": update_reserved_peak_vram, |
| **{f"perf/vram_current_gpu{i}_gb": v for i, v in enumerate(current_vram_per_gpu)}, |
| **{f"perf/vram_peak_gpu{i}_gb": v for i, v in enumerate(peak_vram_per_gpu)}, |
| **{f"perf/vram_reserved_current_gpu{i}_gb": v for i, v in enumerate(current_reserved_vram_per_gpu)}, |
| **{f"perf/vram_reserved_peak_gpu{i}_gb": v for i, v in enumerate(reserved_peak_vram_per_gpu)}, |
| **{f"perf/rollout_vram_peak_gpu{i}_gb": v for i, v in enumerate(rollout_peak_vram_per_gpu)}, |
| **{f"perf/rollout_vram_reserved_peak_gpu{i}_gb": v for i, v in enumerate(rollout_reserved_peak_vram_per_gpu)}, |
| **{f"perf/update_vram_peak_gpu{i}_gb": v for i, v in enumerate(update_peak_vram_per_gpu)}, |
| **{f"perf/update_vram_reserved_peak_gpu{i}_gb": v for i, v in enumerate(update_reserved_peak_vram_per_gpu)}, |
| "sanity/role_confusions": iter_metrics["role_confusions"], |
| }, |
| step=iteration, |
| ) |
| if iteration == 0: |
| wandb.alert( |
| "sdpo_negotiation_started", |
| f"iter=0 reward={iter_metrics['mean_reward']:.4f} deal_rate={iter_metrics['deal_rate']:.3f}; continue 42-iter run if format errors stay low", |
| level=wandb.AlertLevel.INFO, |
| ) |
| if iter_metrics["mean_reward"] < -0.5: |
| wandb.alert( |
| "low_reward_warning", |
| f"reward={iter_metrics['mean_reward']:.4f} at iter={iteration}; if persistent, reduce LR or lower SDPO weight", |
| level=wandb.AlertLevel.WARN, |
| ) |
| if fmt_errors >= FORMAT_WARN_THRESHOLD: |
| wandb.alert( |
| "format_error_warning", |
| f"buyer_format_errors={fmt_errors}/{n_episodes} at iter={iteration}; if repeated, stop and reduce LR or slow SDPO handoff", |
| level=wandb.AlertLevel.WARN, |
| ) |
| if fmt_errors >= FORMAT_STOP_THRESHOLD: |
| wandb.alert( |
| "format_collapse_error", |
| f"buyer_format_errors={fmt_errors}/{n_episodes} at iter={iteration}; early-stop threshold hit, checkpoint will be saved before exit if patience is met", |
| level=wandb.AlertLevel.ERROR, |
| ) |
| if budget_violations >= BUDGET_WARN_THRESHOLD: |
| wandb.alert( |
| "budget_violation_warning", |
| f"buyer_budget_violations={budget_violations}/{n_episodes} at iter={iteration}; if repeated, strengthen budget prompt or reduce LR", |
| level=wandb.AlertLevel.WARN, |
| ) |
| if budget_violations >= BUDGET_STOP_THRESHOLD: |
| wandb.alert( |
| "budget_violation_error", |
| f"buyer_budget_violations={budget_violations}/{n_episodes} at iter={iteration}; early-stop threshold hit, checkpoint will be saved before exit if patience is met", |
| level=wandb.AlertLevel.ERROR, |
| ) |
| except Exception as e: |
| print(f" [WANDB] Log/alert failed (non-fatal): {e}") |
|
|
| should_stop, stop_reasons = evaluate_early_stop(metrics, n_episodes) |
| if should_stop: |
| reason_text = "; ".join(stop_reasons) |
| print(f" [EARLY STOP] {reason_text}", flush=True) |
| if WANDB_OK: |
| try: |
| wandb.alert( |
| "sdpo_early_stop", |
| f"iter={iteration}; {reason_text}; recommended next run: lower LR and/or slow SDPO handoff", |
| level=wandb.AlertLevel.ERROR, |
| ) |
| except Exception as e: |
| print(f" [WANDB] Early-stop alert failed (non-fatal): {e}") |
| if EARLY_STOP_SAVE_CHECKPOINT and HUB_MODEL_ID: |
| print(f" [CHECKPOINT] Saving early-stop iter {iteration+1}...") |
| save_and_push_checkpoint(buyer_model, tokenizer, metrics, iteration, final=False, processor=buyer_processor) |
| break |
|
|
| should_ckpt = ( |
| CHECKPOINT_EVERY > 0 |
| and (iteration + 1) % CHECKPOINT_EVERY == 0 |
| and iteration < NUM_ITERS - 1 |
| ) |
| if should_ckpt: |
| print(f" [CHECKPOINT] Saving iter {iteration+1}...") |
| save_and_push_checkpoint(buyer_model, tokenizer, metrics, iteration, final=False, processor=buyer_processor) |
|
|
| print(f"\n{'=' * 70}\nSAVING FINAL\n{'=' * 70}") |
| save_path = Path(OUTPUT_DIR) |
| save_path.mkdir(parents=True, exist_ok=True) |
| buyer_model.save_pretrained(save_path) |
| (buyer_processor or tokenizer).save_pretrained(save_path) |
| with open(save_path / "metrics.json", "w") as f: |
| json.dump(metrics, f, indent=2) |
| if PUSH_TRAINING_SCRIPT: |
| try: |
| shutil.copyfile(__file__, save_path / "train_negotiation_sdpo.py") |
| except Exception: |
| pass |
| print(f" Saved to {save_path}") |
|
|
| if HUB_MODEL_ID: |
| save_and_push_checkpoint(buyer_model, tokenizer, metrics, NUM_ITERS - 1, final=True, processor=buyer_processor) |
|
|
| total = time.time() - t0 |
| print(f"\n{'=' * 70}") |
| print(f"COMPLETE Total time: {total:.1f}s ({total/60:.1f} min)") |
| print(f"{'=' * 70}") |
|
|
| if WANDB_OK: |
| try: |
| wandb.alert("sdpo_negotiation_complete", f"iters={NUM_ITERS}; final_reward={metrics[-1]['mean_reward']:.4f}; final_deal_rate={metrics[-1]['deal_rate']:.3f}", level=wandb.AlertLevel.INFO) |
| wandb.finish() |
| print(f"[WANDB] Finished. Run: {wandb_run.url if wandb_run else '(unavailable)'}") |
| except Exception as e: |
| print(f"[WANDB] Finish failed (non-fatal): {e}") |
|
|
|
|
| if __name__ == "__main__": |
| try: |
| main() |
| except Exception as e: |
| print(f"\nFATAL: {e}") |
| traceback.print_exc() |
| sys.exit(1) |
|
|