703 lines
37 KiB
Python
703 lines
37 KiB
Python
import json
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import os
|
||
import datetime
|
||
import logging
|
||
from pathlib import Path
|
||
import pandas as pd
|
||
from typing import Optional, Dict, Any, List
|
||
import traceback
|
||
|
||
from utils.fs_helpers import merge_custom_bans_into_file, set_from_json
|
||
|
||
from core.analysis import (
|
||
build_overrep_word_csv, select_overrep_words_for_ban,
|
||
update_banned_slop_phrases, analyze_iteration_outputs_core,
|
||
update_banned_ngrams_list, calculate_lexical_diversity_stats,
|
||
calculate_repetition_score
|
||
)
|
||
from core.dpo import create_dpo_dataset
|
||
from utils.whitelist import WhitelistBuilder
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# --- RESUME HELPERS -------------------------------------------------
|
||
def _load_prompt_ids(path: Path) -> set[int]:
|
||
"""Return the set of prompt_id ints found in an existing generation file."""
|
||
ids = set()
|
||
if not path.is_file(): # nothing yet
|
||
return ids
|
||
with path.open(encoding="utf-8") as fh:
|
||
for ln in fh:
|
||
try:
|
||
row = json.loads(ln)
|
||
pid = row.get("prompt_id")
|
||
if isinstance(pid, int):
|
||
ids.add(pid)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
return ids
|
||
|
||
def _copy_if_exists(src: Path, dst: Path) -> None:
|
||
if src and src.is_file():
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
|
||
def _patch_length_errors(jsonl_path: Path) -> None:
|
||
"""
|
||
Re-write rows whose error msg contains 'maximum context length'
|
||
so that later iterations treat them like refusals.
|
||
"""
|
||
if not jsonl_path.is_file():
|
||
return
|
||
changed = False
|
||
out_lines = []
|
||
with jsonl_path.open(encoding="utf-8") as fh:
|
||
for ln in fh:
|
||
try:
|
||
row = json.loads(ln)
|
||
except json.JSONDecodeError:
|
||
out_lines.append(ln); continue
|
||
if (
|
||
row.get("status") == "failed"
|
||
and isinstance(row.get("error"), str)
|
||
and "maximum context length" in row["error"]
|
||
):
|
||
row["status"] = "skipped -- too long"
|
||
row["refusal_detected"] = True
|
||
changed = True
|
||
out_lines.append(json.dumps(row, ensure_ascii=False) + "\n")
|
||
if changed:
|
||
jsonl_path.write_text("".join(out_lines), encoding="utf-8")
|
||
|
||
|
||
def _build_generation_command(
|
||
main_script_path: Path,
|
||
config: Dict[str, Any],
|
||
output_jsonl_path: Path,
|
||
iter_idx: int,
|
||
banned_ngrams_file_for_iter: Optional[Path],
|
||
slop_phrases_file_for_iter: Optional[Path],
|
||
regex_blocklist_file_for_iter: Optional[Path]
|
||
) -> List[str]:
|
||
"""
|
||
Constructs the command list for invoking the antislop-vllm generation script.
|
||
|
||
For iter_idx == 0 (baseline generation), all file-based banning mechanisms
|
||
in antislop-vllm are explicitly disabled by passing empty strings for file paths
|
||
and zero for counts, overriding any defaults in antislop-vllm's local config.yaml.
|
||
|
||
For subsequent iterations (iter_idx > 0), it uses the provided ban list file paths.
|
||
Paths for file arguments are resolved to absolute paths.
|
||
|
||
Args:
|
||
main_script_path: Absolute path to antislop-vllm/main.py.
|
||
config: The main configuration dictionary for auto-antislop.
|
||
output_jsonl_path: Absolute path for the generation output of this iteration.
|
||
iter_idx: The current iteration index (0-based).
|
||
banned_ngrams_file_for_iter: Path to the n-gram ban list to use for this iteration (if iter_idx > 0).
|
||
slop_phrases_file_for_iter: Path to the slop phrase ban list to use for this iteration (if iter_idx > 0).
|
||
regex_blocklist_file_for_iter: Path to the regex blocklist to use for this iteration (if iter_idx > 0).
|
||
|
||
Returns:
|
||
A list of strings representing the command and its arguments.
|
||
"""
|
||
|
||
def get_abs_path_str(p: Optional[Path]) -> Optional[str]:
|
||
"""Resolves a Path object to an absolute path string, or returns None."""
|
||
return str(p.resolve()) if p else None
|
||
|
||
ftpo_pairs_jsonl_path_str = get_abs_path_str(output_jsonl_path.parent / f"iter_{str(iter_idx)}_ftpo_pairs.jsonl")
|
||
experiment_dir = output_jsonl_path.parent.resolve()
|
||
|
||
# Determine the API base URL for generation requests
|
||
gen_api_base_url = config.get('generation_api_base_url')
|
||
if not gen_api_base_url:
|
||
vllm_port = config.get('vllm_port', 8000)
|
||
gen_api_base_url = f"http://127.0.0.1:{vllm_port}/v1"
|
||
logger.debug(
|
||
f"generation_api_base_url not explicitly configured, defaulting to {gen_api_base_url} "
|
||
f"based on vllm_port ({vllm_port})."
|
||
)
|
||
|
||
# Core command arguments that are always present
|
||
command_base = [
|
||
sys.executable, str(main_script_path),
|
||
"--api-base-url", gen_api_base_url,
|
||
"--api-key", config['generation_api_key'],
|
||
"--model-name", config['generation_model_id'],
|
||
"--config", str((main_script_path.parent / "config-example.yaml").resolve()), # provides pipeline defaults that we aren't passing here
|
||
"--output-jsonl", get_abs_path_str(output_jsonl_path),
|
||
"--input-hf-dataset", config['generation_hf_dataset_name'],
|
||
"--hf-dataset-split", config['generation_hf_dataset_split'],
|
||
"--threads", str(config['generation_threads']),
|
||
"--max-prompts", str(config['generation_max_prompts']),
|
||
"--logging-level", config['generation_logging_level'],
|
||
"--max-new-tokens", str(config['generation_max_new_tokens']),
|
||
"--top-logprobs-count", str(config['generation_param_top_logprobs_count']),
|
||
"--temperature", str(config['generation_param_temperature']),
|
||
"--top-p", str(config['generation_param_top_p']),
|
||
"--top-k", str(config['generation_param_top_k']),
|
||
"--min-p", str(config['generation_param_min_p']),
|
||
"--timeout", str(config['generation_param_timeout']),
|
||
"--force-backtrack", str(config['generation_force_backtrack']),
|
||
"--ngram-remove-stopwords", str(config['generation_ngram_remove_stopwords']).lower(),
|
||
"--ngram-language", config['generation_ngram_language'],
|
||
"--enable-refusal-detection", str(config.get("generation_refusal_detection", False)),
|
||
"--prompt-template", config['generation_prompt_template'],
|
||
"--system-prompt", config['generation_system_prompt'],
|
||
]
|
||
command = list(command_base) # Create a mutable copy
|
||
|
||
if iter_idx > 0:
|
||
prev_iter_jsonl_path = experiment_dir / f"iter_{iter_idx-1}_creative_writing_generations.jsonl"
|
||
command.extend(["--refusals-file", str(prev_iter_jsonl_path)])
|
||
|
||
# Use full-length chunks for the baseline run (iter_idx == 0);
|
||
# fall back to the configured chunk size for every later iteration.
|
||
chunk_size = (
|
||
config['generation_max_new_tokens']
|
||
if iter_idx == 0
|
||
else config['generation_param_chunk_size']
|
||
)
|
||
command.extend(["--chunk-size", str(chunk_size)])
|
||
|
||
# Optional command arguments based on configuration
|
||
if config.get('generation_param_stop_sequences'):
|
||
stop_sequences_str = ",".join(config['generation_param_stop_sequences'])
|
||
if stop_sequences_str: # Only add if there are actual sequences
|
||
command.extend(["--stop-sequences", stop_sequences_str])
|
||
|
||
if config.get('generation_chat_template_model_id'):
|
||
command.extend(["--chat-template-model-id", config['generation_chat_template_model_id']])
|
||
|
||
# --- Ban list arguments: behavior depends on iteration index ---
|
||
if iter_idx == 0:
|
||
# For iteration 0 (baseline), explicitly disable all file-based banning in antislop-vllm
|
||
# by passing empty strings for file paths and zero for counts.
|
||
# This overrides any defaults in antislop-vllm's local config.yaml.
|
||
logger.debug("Iteration 0: Configuring antislop-vllm for baseline generation (no ban lists).")
|
||
command.extend(["--ngram-banned-file", ""])
|
||
command.extend(["--slop-phrases-file", ""])
|
||
command.extend(["--top-n-slop-phrases", "0"])
|
||
command.extend(["--regex-blocklist-file", ""])
|
||
else:
|
||
# this seems to overlap with another param, should fix
|
||
command.extend(["--ftpo-pairs-jsonl", ftpo_pairs_jsonl_path_str])
|
||
|
||
# For iterations > 0, use the ban lists determined by the orchestrate_pipeline function.
|
||
if banned_ngrams_file_for_iter:
|
||
command.extend(["--ngram-banned-file", get_abs_path_str(banned_ngrams_file_for_iter)])
|
||
|
||
if slop_phrases_file_for_iter:
|
||
command.extend(["--slop-phrases-file", get_abs_path_str(slop_phrases_file_for_iter)])
|
||
# When providing a slop phrases file, instruct antislop-vllm to use all phrases from it.
|
||
command.extend(["--top-n-slop-phrases", str(999_999)])
|
||
|
||
if regex_blocklist_file_for_iter:
|
||
command.extend(["--regex-blocklist-file", get_abs_path_str(regex_blocklist_file_for_iter)])
|
||
|
||
return command
|
||
|
||
|
||
def run_generation_script_wrapper(
|
||
iter_idx: int,
|
||
output_jsonl_path: Path,
|
||
config: Dict[str, Any],
|
||
banned_ngrams_file_path: Optional[Path] = None,
|
||
slop_phrases_file_path: Optional[Path] = None,
|
||
regex_blocklist_file_path: Optional[Path] = None,
|
||
extra_generation_args: Optional[list[str]] = None,
|
||
) -> None:
|
||
"""
|
||
Execute antislop-vllm/main.py for a single iteration, handling all paths,
|
||
logging, errors, and now arbitrary extra CLI flags.
|
||
"""
|
||
project_root = Path(__file__).resolve().parent.parent
|
||
main_py_script = project_root / "antislop-vllm" / "main.py"
|
||
if not main_py_script.is_file():
|
||
raise FileNotFoundError(
|
||
f"antislop-vllm/main.py not found at {main_py_script}. "
|
||
"Ensure the submodule is present and initialised."
|
||
)
|
||
|
||
cmd_list = _build_generation_command(
|
||
main_script_path=main_py_script,
|
||
config=config,
|
||
output_jsonl_path=output_jsonl_path,
|
||
iter_idx=iter_idx,
|
||
banned_ngrams_file_for_iter=banned_ngrams_file_path,
|
||
slop_phrases_file_for_iter=slop_phrases_file_path,
|
||
regex_blocklist_file_for_iter=regex_blocklist_file_path,
|
||
)
|
||
|
||
# ── append any ad-hoc flags (e.g. --prompt-id-file <path>) ─────────────
|
||
if extra_generation_args:
|
||
cmd_list.extend(extra_generation_args)
|
||
|
||
# pretty-log (truncate very long paths)
|
||
def _short(s: str) -> str:
|
||
return f"...{s[-67:]}" if ("/" in s or "\\" in s) and len(s) > 70 else s
|
||
log_cmd = " ".join(_short(c) for c in cmd_list)
|
||
|
||
logger.info(f"\n┏━━ Iteration {iter_idx}: launching antislop-vllm ━━━━━━━━━━━━━┓")
|
||
logger.info(f"cwd: {main_py_script.parent}")
|
||
logger.info(log_cmd)
|
||
logger.info("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")
|
||
|
||
proc = subprocess.run(
|
||
cmd_list,
|
||
cwd=main_py_script.parent,
|
||
check=False,
|
||
)
|
||
|
||
if proc.returncode != 0:
|
||
raise RuntimeError(
|
||
f"antislop-vllm exited with code {proc.returncode} "
|
||
f"(iteration {iter_idx})"
|
||
)
|
||
|
||
logger.info(f"✅ antislop-vllm completed for iteration {iter_idx}. "
|
||
f"Output: {output_jsonl_path.name}")
|
||
|
||
|
||
def orchestrate_pipeline(config: Dict[str, Any], experiment_dir: Path, resume_mode: bool):
|
||
logger.info(f"Starting anti-slop pipeline in directory: {experiment_dir}")
|
||
generation_enabled = config.get("generation_step_enabled", True)
|
||
if not generation_enabled:
|
||
logger.info("⚠️ Generation step disabled by config/CLI flag.")
|
||
|
||
|
||
if generation_enabled:
|
||
# ------------------------------------------------------------------ #
|
||
# Build project-wide whitelist #
|
||
# ------------------------------------------------------------------ #
|
||
whitelist_set = WhitelistBuilder.build(
|
||
model_id = config.get("generation_chat_template_model_id") or config["vllm_model_id"],
|
||
extra_user_items = config.get("whitelist_strings", []),
|
||
)
|
||
wl_path = experiment_dir / config.get("whitelist_output_filename", "whitelist_strings.json")
|
||
WhitelistBuilder.write(wl_path, whitelist_set)
|
||
logger.info(f"✓ Whitelist compiled → {wl_path} ({len(whitelist_set)} entries)")
|
||
|
||
|
||
|
||
# --- NLTK Stopwords ---
|
||
try:
|
||
from nltk.corpus import stopwords # Import here to keep it local to this function
|
||
stop_words_set = set(stopwords.words('english'))
|
||
logger.info(f"Loaded {len(stop_words_set)} NLTK stopwords for 'english'.")
|
||
except LookupError:
|
||
logger.error("NLTK 'stopwords' for 'english' not found. Please run fs_helpers.download_nltk_resource or download manually.")
|
||
logger.error("Pipeline cannot continue without stopwords for analysis.")
|
||
raise # Critical for analysis
|
||
|
||
# --- Human Profile ---
|
||
human_profile_path = Path(config['human_profile_path'])
|
||
if not human_profile_path.is_file():
|
||
logger.error(f"Human profile JSON not found: {human_profile_path.resolve()}")
|
||
raise FileNotFoundError(f"Human profile not found at {human_profile_path}")
|
||
try:
|
||
with human_profile_path.open("r", encoding="utf-8") as f_hp:
|
||
human_profile_full: dict = json.load(f_hp)
|
||
except Exception as e:
|
||
logger.error(f"Could not load or parse human profile JSON '{human_profile_path}': {e}")
|
||
raise
|
||
|
||
# --- Ban Lists Paths (initialized here, files created/updated during iterations) ---
|
||
banned_ngrams_json_path = experiment_dir / "banned_ngrams.json"
|
||
if 'banned_slop_phrases_filename' not in config:
|
||
config['banned_slop_phrases_filename'] = 'banned_slop_phrases.json'
|
||
banned_slop_phrases_json_path = experiment_dir / config['banned_slop_phrases_filename']
|
||
|
||
# Ensure both ban-list files exist so we can always hand them to antislop-vllm,
|
||
# even if the associated banning feature is turned off.
|
||
for _p in (banned_ngrams_json_path, banned_slop_phrases_json_path):
|
||
if not _p.exists():
|
||
_p.write_text("[]", encoding="utf-8") # write an empty JSON array
|
||
|
||
|
||
# --- Regex Blocklist (user-supplied, written once if provided, used from iter 1+) ---
|
||
# This file is created before the loop, but only passed to generation from iter 1.
|
||
user_regex_blocklist_file: Optional[Path] = None # Renamed for clarity
|
||
extra_regex_patterns = config.get('extra_regex_patterns', [])
|
||
user_regex_blocklist_file = experiment_dir / "user_defined_regex_blocklist.json"
|
||
try:
|
||
# Write it once if resuming and it doesn't exist, or if not resuming.
|
||
# This ensures it's available for later iterations if resuming.
|
||
if not resume_mode or (resume_mode and not user_regex_blocklist_file.exists()):
|
||
user_regex_blocklist_file.write_text(
|
||
json.dumps(extra_regex_patterns, indent=2, ensure_ascii=False),
|
||
encoding="utf-8"
|
||
)
|
||
logger.info(f"📝 User-defined regex blocklist written to {user_regex_blocklist_file}")
|
||
elif user_regex_blocklist_file.exists():
|
||
logger.info(f"📝 User-defined regex blocklist already exists at {user_regex_blocklist_file}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to write user-defined regex blocklist: {e}. It will not be used.")
|
||
user_regex_blocklist_file = None # Disable if write fails
|
||
|
||
iteration_stats: list[dict] = []
|
||
iter0_output_file_for_dpo: Optional[Path] = None
|
||
final_iter_output_file_for_dpo: Optional[Path] = None # Tracks the latest successful output
|
||
|
||
start_iter_idx = 0
|
||
if resume_mode:
|
||
logger.info(f"Attempting to resume from {experiment_dir}...")
|
||
max_found_iter = -1
|
||
# Check for successfully completed iterations by looking for their output files
|
||
max_found_iter = -1
|
||
need_total = config['generation_max_prompts']
|
||
|
||
for i in range(config['num_iterations']):
|
||
gen_file = experiment_dir / f"iter_{i}_creative_writing_generations.jsonl"
|
||
|
||
# Does the file exist at all?
|
||
if not (gen_file.is_file() and gen_file.stat().st_size):
|
||
break # nothing (or zero-length) → not complete
|
||
|
||
# Does it contain the full prompt set?
|
||
ids_seen = _load_prompt_ids(gen_file)
|
||
if len(ids_seen) < need_total:
|
||
logger.info(
|
||
f"Iteration {i} resume-check: {len(ids_seen)}/{need_total} prompts present "
|
||
f"({need_total-len(ids_seen)} still missing).")
|
||
break # incomplete → resume here
|
||
|
||
max_found_iter = i # this one is done, keep going
|
||
|
||
|
||
if max_found_iter >= 0:
|
||
start_iter_idx = max_found_iter + 1
|
||
logger.info(f"Resuming from iteration {start_iter_idx}.")
|
||
# Log presence of existing ban lists if resuming past iter 0
|
||
if start_iter_idx > 0:
|
||
if banned_ngrams_json_path.exists(): logger.info(f"Resuming with existing n-gram ban list: {banned_ngrams_json_path}")
|
||
else: logger.info("No existing n-gram ban list found to resume with for subsequent iterations.")
|
||
if banned_slop_phrases_json_path.exists(): logger.info(f"Resuming with existing slop phrase ban list: {banned_slop_phrases_json_path}")
|
||
else: logger.info("No existing slop phrase ban list found to resume with for subsequent iterations.")
|
||
else:
|
||
logger.info("No fully completed iterations found to resume. Starting from iteration 0.")
|
||
# resume_mode = False # No need to change resume_mode, start_iter_idx handles it
|
||
|
||
if start_iter_idx >= config['num_iterations']:
|
||
logger.info(f"All {config['num_iterations']} iterations appear to be completed in {experiment_dir}.")
|
||
# Attempt to load existing stats for DPO if needed
|
||
summary_csv_path = experiment_dir / "final_iteration_statistics.csv"
|
||
if summary_csv_path.exists():
|
||
try:
|
||
iteration_stats_df = pd.read_csv(summary_csv_path)
|
||
iteration_stats = iteration_stats_df.to_dict('records')
|
||
# Ensure iter0_output_file_for_dpo and final_iter_output_file_for_dpo are set if possible
|
||
if not iter0_output_file_for_dpo and not iteration_stats_df.empty:
|
||
iter0_row = iteration_stats_df[iteration_stats_df['iteration'] == 0]
|
||
if not iter0_row.empty and 'output_file' in iter0_row.columns:
|
||
path_str = iter0_row.iloc[0]['output_file']
|
||
if path_str and isinstance(path_str, str): iter0_output_file_for_dpo = experiment_dir / path_str
|
||
if not final_iter_output_file_for_dpo and not iteration_stats_df.empty:
|
||
# Find the last completed iteration in the stats
|
||
last_stat_iter = iteration_stats_df['iteration'].max()
|
||
final_iter_row = iteration_stats_df[iteration_stats_df['iteration'] == last_stat_iter]
|
||
if not final_iter_row.empty and 'output_file' in final_iter_row.columns:
|
||
path_str = final_iter_row.iloc[0]['output_file']
|
||
if path_str and isinstance(path_str, str): final_iter_output_file_for_dpo = experiment_dir / path_str
|
||
except Exception as e:
|
||
logger.warning(f"Could not load or parse existing iteration statistics from {summary_csv_path}: {e}")
|
||
# Proceed to DPO creation if applicable (handled after the loop)
|
||
else: # Need to run some or all iterations
|
||
if generation_enabled:
|
||
for iter_idx in range(start_iter_idx, config['num_iterations']):
|
||
current_iter_start_time = datetime.datetime.now()
|
||
logger.info(f"\n{'='*30} ITERATION {iter_idx} (started at {current_iter_start_time.strftime('%H:%M:%S')}) {'='*30}")
|
||
|
||
iter_output_jsonl = experiment_dir / f"iter_{iter_idx}_creative_writing_generations.jsonl"
|
||
iter_analysis_dir = experiment_dir / f"iter_{iter_idx}_analysis_results"
|
||
iter_analysis_dir.mkdir(parents=True, exist_ok=True) # Ensure analysis dir exists
|
||
|
||
# --- Determine ban lists for the current iteration ---
|
||
# Iteration 0 (baseline) runs with NO BANNING.
|
||
# Subsequent iterations use the ban lists accumulated so far.
|
||
ngram_file_for_generation: Optional[Path] = None
|
||
slop_file_for_generation: Optional[Path] = None
|
||
regex_file_for_generation: Optional[Path] = None
|
||
|
||
if iter_idx > 0: # Banning starts from iteration 1
|
||
if banned_ngrams_json_path.exists():
|
||
ngram_file_for_generation = banned_ngrams_json_path
|
||
if banned_slop_phrases_json_path.exists():
|
||
slop_file_for_generation = banned_slop_phrases_json_path
|
||
if user_regex_blocklist_file and user_regex_blocklist_file.exists(): # User-defined regex
|
||
regex_file_for_generation = user_regex_blocklist_file
|
||
|
||
# If we are resuming and this is the first iteration after the resume,
|
||
# force-merge any new YAML bans into the existing files *before* generation.
|
||
if resume_mode and iter_idx == start_iter_idx:
|
||
if config['enable_ngram_ban'] and config.get('extra_ngrams_to_ban'):
|
||
merge_custom_bans_into_file(banned_ngrams_json_path,
|
||
config['extra_ngrams_to_ban'])
|
||
if config['enable_slop_phrase_ban'] and config.get('extra_slop_phrases_to_ban'):
|
||
merge_custom_bans_into_file(banned_slop_phrases_json_path,
|
||
config['extra_slop_phrases_to_ban'])
|
||
|
||
_copy_if_exists(ngram_file_for_generation,
|
||
iter_analysis_dir / "banned_ngrams_used.json")
|
||
_copy_if_exists(slop_file_for_generation,
|
||
iter_analysis_dir / "banned_slop_phrases_used.json")
|
||
_copy_if_exists(regex_file_for_generation,
|
||
iter_analysis_dir / "regex_blocklist_used.json")
|
||
|
||
# remember their current contents so we can diff later
|
||
before_ngrams = set_from_json(ngram_file_for_generation)
|
||
before_slop = set_from_json(slop_file_for_generation)
|
||
|
||
else:
|
||
before_ngrams, before_slop = set(), set()
|
||
|
||
if iter_idx == 0:
|
||
logger.info("Iteration 0: Running baseline generation with NO ban lists.")
|
||
else:
|
||
logger.info(f"Iteration {iter_idx}: Using ban lists - N-grams: {ngram_file_for_generation}, Slop: {slop_file_for_generation}, Regex: {regex_file_for_generation}")
|
||
|
||
# ────────────────────────────────────────────────────────────────────
|
||
# A. fast-path – is generation already complete?
|
||
# ────────────────────────────────────────────────────────────────────
|
||
existing_ids = _load_prompt_ids(iter_output_jsonl)
|
||
need_total = config['generation_max_prompts']
|
||
missing_ids = sorted(set(range(need_total)) - existing_ids)
|
||
|
||
if not missing_ids:
|
||
logger.info(f"Iteration {iter_idx}: found {need_total} / {need_total} prompts "
|
||
f"in {iter_output_jsonl.name} – skipping generation step.")
|
||
else:
|
||
logger.info(f"Iteration {iter_idx}: {len(missing_ids)} / {need_total} prompts "
|
||
f"still missing – resuming generation.")
|
||
# antislop-vllm already supports '--prompt-id-file' (one id per line)
|
||
# we can skip this as antislop-vllm automatically resumes now
|
||
#miss_file = _write_missing_prompt_file(missing_ids, experiment_dir, iter_idx)
|
||
|
||
try:
|
||
run_generation_script_wrapper(
|
||
iter_idx = iter_idx,
|
||
output_jsonl_path = iter_output_jsonl,
|
||
config = config,
|
||
banned_ngrams_file_path= ngram_file_for_generation,
|
||
slop_phrases_file_path = slop_file_for_generation,
|
||
regex_blocklist_file_path = regex_file_for_generation,
|
||
#extra_generation_args = ["--prompt-id-file", str(miss_file)]
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"❌ Generation script failed for iteration {iter_idx}: {e}")
|
||
# identical failure-handling block as before …
|
||
iteration_stats.append({
|
||
"iteration": iter_idx, "status": "generation_failed",
|
||
"error": str(e), "output_file": str(iter_output_jsonl.name)
|
||
})
|
||
if iter_idx == 0:
|
||
iter0_output_file_for_dpo = None
|
||
continue
|
||
|
||
# turn “max-context” failures into skips so later iterations don’t retry them
|
||
_patch_length_errors(iter_output_jsonl)
|
||
|
||
|
||
if not iter_output_jsonl.exists() or iter_output_jsonl.stat().st_size == 0:
|
||
logger.error(f"❌ Generation output file {iter_output_jsonl} is missing or empty for iteration {iter_idx}.")
|
||
iteration_stats.append({
|
||
"iteration": iter_idx, "status": "output_file_missing_or_empty",
|
||
"output_file": str(iter_output_jsonl.name)
|
||
})
|
||
if iter_idx == 0: iter0_output_file_for_dpo = None
|
||
continue
|
||
|
||
# Update DPO file pointers
|
||
if iter_idx == 0:
|
||
iter0_output_file_for_dpo = iter_output_jsonl
|
||
# final_iter_output_file_for_dpo always points to the latest successfully generated file
|
||
final_iter_output_file_for_dpo = iter_output_jsonl
|
||
|
||
# --- Analysis (runs for all iterations, including iter 0 to find initial slop) ---
|
||
analysis_results = None
|
||
try:
|
||
analysis_results = analyze_iteration_outputs_core(
|
||
generated_jsonl_path=iter_output_jsonl,
|
||
human_profile_full=human_profile_full,
|
||
iter_analysis_output_dir=iter_analysis_dir,
|
||
config=config,
|
||
stop_words_set=stop_words_set
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"❌ Text analysis failed for iteration {iter_idx}: {e}", exc_info=True)
|
||
iteration_stats.append({
|
||
"iteration": iter_idx, "status": "analysis_failed",
|
||
"error": str(e), "output_file": str(iter_output_jsonl.name)
|
||
})
|
||
continue
|
||
|
||
if analysis_results is None or analysis_results[0] is None: # DFs are first part of tuple
|
||
logger.warning(f"Analysis for iteration {iter_idx} did not produce data. Skipping ban list updates for this iteration.")
|
||
iteration_stats.append({
|
||
"iteration": iter_idx, "status": "analysis_no_data",
|
||
"output_file": str(iter_output_jsonl.name)
|
||
})
|
||
continue
|
||
|
||
df_bi_dict, df_bi_nondct, df_tri_dict, df_tri_nondct, generated_texts, total_gen_chars = analysis_results
|
||
if not generated_texts:
|
||
logger.warning(f"No generated texts found after analysis for iter {iter_idx}. Skipping ban list updates.")
|
||
iteration_stats.append({
|
||
"iteration": iter_idx, "status": "no_texts_post_analysis",
|
||
"output_file": str(iter_output_jsonl.name)
|
||
})
|
||
continue
|
||
|
||
# --- Update Ban Lists (based on current iteration's analysis) ---
|
||
# These lists will be used by the *next* iteration's generation step.
|
||
# --- Update ban lists (based on current iteration's analysis) --------------
|
||
overrep_tokens_for_ban: list[str] = []
|
||
iter_log = iter_analysis_dir / "orchestration.log"
|
||
def _iter_log(msg: str) -> None:
|
||
with iter_log.open("a", encoding="utf-8") as fh:
|
||
fh.write(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {msg}\n")
|
||
|
||
# (a) over-represented words -------------------------------------------------
|
||
if config['compute_overrep_words']:
|
||
try:
|
||
overrep_csv = iter_analysis_dir / "overrepresented_words.csv"
|
||
_, dict_words, nodict_words = build_overrep_word_csv(
|
||
texts=generated_texts,
|
||
out_csv=overrep_csv,
|
||
top_n_words_analysis=config['top_k_words_for_overrep_analysis'],
|
||
stop_words_set=stop_words_set,
|
||
)
|
||
overrep_tokens_for_ban = select_overrep_words_for_ban(
|
||
dict_words, nodict_words, (iter_idx == 0), config, whitelist=whitelist_set
|
||
)
|
||
_iter_log(f"overrep_tokens_for_ban = {len(overrep_tokens_for_ban)}")
|
||
except Exception as exc:
|
||
_iter_log("❌ build_overrep_word_csv failed:\n" +
|
||
"".join(traceback.format_exception_only(type(exc), exc)))
|
||
|
||
# (b) n-gram ban list --------------------------------------------------------
|
||
if config['enable_ngram_ban']:
|
||
try:
|
||
update_banned_ngrams_list(
|
||
banned_ngrams_json_path,
|
||
dfs=[df_bi_dict, df_bi_nondct, df_tri_dict, df_tri_nondct],
|
||
is_first_iteration=(iter_idx == 0),
|
||
config=config,
|
||
whitelist=whitelist_set,
|
||
)
|
||
_iter_log("n-gram ban list updated")
|
||
except Exception as exc:
|
||
_iter_log("❌ update_banned_ngrams_list failed:\n" +
|
||
"".join(traceback.format_exception_only(type(exc), exc)))
|
||
|
||
# (c) slop-phrase ban list ---------------------------------------------------
|
||
if config['enable_slop_phrase_ban']:
|
||
try:
|
||
phrases_to_add_count = (
|
||
config['top_n_initial_slop_ban'] if iter_idx == 0
|
||
else config['top_n_subsequent_slop_ban']
|
||
)
|
||
update_banned_slop_phrases(
|
||
json_path=banned_slop_phrases_json_path,
|
||
texts=generated_texts,
|
||
how_many_new=phrases_to_add_count,
|
||
tmp_dir=iter_analysis_dir / "phrase_tmp",
|
||
config=config,
|
||
whitelist=whitelist_set,
|
||
over_represented_words=(
|
||
overrep_tokens_for_ban
|
||
),
|
||
)
|
||
_iter_log("slop-phrase ban list updated "
|
||
f"(+{len(overrep_tokens_for_ban)} over-rep words)")
|
||
except Exception as exc:
|
||
_iter_log("❌ update_banned_slop_phrases failed:\n" +
|
||
"".join(traceback.format_exception_only(type(exc), exc)))
|
||
|
||
# ---------- diff → what was *added* this iteration -------------------------
|
||
if iter_idx > 0:
|
||
# n-grams
|
||
if config['enable_ngram_ban'] and banned_ngrams_json_path.exists():
|
||
after_ngrams = set_from_json(banned_ngrams_json_path)
|
||
new_ngrams = sorted(after_ngrams - before_ngrams)
|
||
(iter_analysis_dir / "banned_ngrams_new_this_iter.json"
|
||
).write_text(json.dumps(new_ngrams, indent=2, ensure_ascii=False), "utf-8")
|
||
|
||
# slop phrases
|
||
if config['enable_slop_phrase_ban'] and banned_slop_phrases_json_path.exists():
|
||
after_slop = set_from_json(banned_slop_phrases_json_path)
|
||
new_slop = sorted(after_slop - before_slop)
|
||
(iter_analysis_dir / "banned_slop_phrases_new_this_iter.json"
|
||
).write_text(json.dumps(new_slop, indent=2, ensure_ascii=False), "utf-8")
|
||
|
||
|
||
|
||
# --- Calculate Metrics for this iteration ---
|
||
ttr, rttr, repetition_norm = 0.0, 0.0, 0.0
|
||
try:
|
||
ttr, rttr = calculate_lexical_diversity_stats(generated_texts, config['min_word_len_for_analysis'])
|
||
repetition_norm = calculate_repetition_score(
|
||
generated_texts, total_gen_chars,
|
||
[df_bi_dict, df_bi_nondct, df_tri_dict, df_tri_nondct], config, stop_words_set
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"❌ Error calculating metrics for iteration {iter_idx}: {e}", exc_info=True)
|
||
|
||
iteration_stats.append({
|
||
"iteration": iter_idx, "status": "completed",
|
||
"generated_text_count": len(generated_texts), "generated_char_count": total_gen_chars,
|
||
"ttr": ttr, "rttr": rttr, "repetition_per_100k_chars": repetition_norm,
|
||
"output_file": str(iter_output_jsonl.name), "error": None
|
||
})
|
||
iter_duration = datetime.datetime.now() - current_iter_start_time
|
||
logger.info(f"--- Iteration {iter_idx} completed in {iter_duration} ---")
|
||
else:
|
||
logger.info("Skipping generation loop.")
|
||
|
||
if generation_enabled:
|
||
# --- Final Summary & DPO Dataset Creation ---
|
||
summary_df = pd.DataFrame(iteration_stats)
|
||
summary_csv = experiment_dir / "final_iteration_statistics.csv"
|
||
try:
|
||
summary_df.to_csv(summary_csv, index=False)
|
||
logger.info(f"\n📊 Final statistics written to {summary_csv.resolve()}")
|
||
if not summary_df.empty:
|
||
# Ensure all columns are displayed if possible
|
||
with pd.option_context('display.max_rows', None, 'display.max_columns', None, 'display.width', 1000):
|
||
logger.info("\n" + summary_df.to_string(index=False, na_rep="N/A"))
|
||
else:
|
||
logger.info("No iteration statistics were generated to summarize.")
|
||
except Exception as e:
|
||
logger.error(f"Could not write final statistics CSV to {summary_csv}: {e}")
|
||
|
||
# DPO dataset creation logic
|
||
if config['num_iterations'] >= 1 and iter0_output_file_for_dpo and final_iter_output_file_for_dpo:
|
||
if iter0_output_file_for_dpo.exists() and final_iter_output_file_for_dpo.exists():
|
||
if config['num_iterations'] == 1 and iter0_output_file_for_dpo == final_iter_output_file_for_dpo:
|
||
logger.warning("Only one iteration completed. DPO dataset 'chosen' and 'rejected' will be from the same iter_0 data. This might not be useful for training.")
|
||
|
||
dpo_output_jsonl = experiment_dir / "dpo_pairs_dataset.jsonl"
|
||
try:
|
||
create_dpo_dataset(iter0_output_file_for_dpo, final_iter_output_file_for_dpo, dpo_output_jsonl)
|
||
except Exception as e:
|
||
logger.error(f"❌ ERROR creating DPO dataset: {e}", exc_info=True)
|
||
else:
|
||
logger.warning(
|
||
f"DPO dataset creation skipped: Iteration 0 output file ({iter0_output_file_for_dpo}) "
|
||
f"or final iteration output file ({final_iter_output_file_for_dpo}) not found or generation failed."
|
||
)
|
||
elif config['num_iterations'] < 1:
|
||
logger.info("No iterations were configured to run. DPO dataset creation skipped.")
|
||
else: # Cases where DPO files might be None due to errors
|
||
logger.warning(
|
||
f"DPO dataset creation skipped due to missing DPO source files. "
|
||
f"Iter0 source: {iter0_output_file_for_dpo}, Final iter source: {final_iter_output_file_for_dpo}"
|
||
)
|
||
|
||
logger.info("Anti-slop pipeline orchestration finished.")
|
||
return experiment_dir |