Files

215 lines
7.7 KiB
Python

# /// script
# dependencies = [
# "openai>=1.0",
# "azure-identity",
# ]
# ///
"""
score_dataset.py — Assess training data quality using an LLM judge.
Scores each example on correctness and relevance, optionally filters
out low-quality examples.
Usage:
# Score all examples
python score_dataset.py --input training.jsonl --output scored.jsonl
# Score and filter (keep only score >= 7)
python score_dataset.py --input training.jsonl --output filtered.jsonl --min-score 7
# Custom scoring dimensions
python score_dataset.py --input training.jsonl --output scored.jsonl \
--dimensions "correctness,clarity,completeness"
"""
import json
import os
import re
import sys
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
pass # Stream not reconfigurable (older Python or non-tty); default encoding is fine
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import HelpOnErrorParser, get_clients, _clamp_score
QUALITY_PROMPT = """You are a data quality assessor for machine learning training data.
## Task
Evaluate this training example for quality.
## User input (what the model receives)
{user_content}
## Assistant output (what the model should learn to produce)
{assistant_content}
## Scoring dimensions
{dimensions_text}
Rate each dimension on a scale of 1-10.
Return ONLY a JSON object with dimension names as keys and integer scores as values.
Example: {example_json}"""
DEFAULT_DIMENSIONS = {
"correctness": "Is the assistant's output factually/functionally correct?",
"relevance": "Does the output directly address the user's request?",
"quality": "Is the output well-written, well-formatted, and professional?",
}
def score_example(client, model, user_content, assistant_content, dimensions):
"""Score a single training example."""
dims_text = "\n".join(f"**{k}** (1-10): {v}" for k, v in dimensions.items())
example = {k: 8 for k in dimensions}
prompt = QUALITY_PROMPT.format(
user_content=user_content[:2000],
assistant_content=assistant_content[:2000],
dimensions_text=dims_text,
example_json=json.dumps(example),
)
for attempt in range(3):
try:
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
max_completion_tokens=200,
)
text = (resp.choices[0].message.content or "").strip()
match = re.search(r'\{[^}]+\}', text)
if match:
scores = json.loads(match.group())
return {k: _clamp_score(scores.get(k)) for k in dimensions}
except Exception:
if attempt < 2:
time.sleep(2)
return {k: 0 for k in dimensions}
def main():
parser = HelpOnErrorParser(description="Score training data quality with LLM judge")
parser.add_argument("--base-url", default=os.environ.get("OPENAI_BASE_URL"),
help="Project /v1/ URL (preferred)")
parser.add_argument("--endpoint", default=os.environ.get("AZURE_OPENAI_ENDPOINT"),
help="Azure OpenAI endpoint (fallback)")
parser.add_argument("--project-endpoint", default=os.environ.get("AZURE_AI_PROJECT_ENDPOINT"),
help="Azure AI project endpoint (Foundry SDK)")
parser.add_argument("--api-key", default=os.environ.get("AZURE_OPENAI_API_KEY"))
parser.add_argument("--model", default="gpt-4o", help="Judge model")
parser.add_argument("--input", required=True, help="Input JSONL file")
parser.add_argument("--output", required=True, help="Output JSONL file (with scores)")
parser.add_argument("--min-score", type=float, default=None,
help="Minimum average score to keep (filters below this)")
parser.add_argument("--dimensions", default=None,
help="Comma-separated dimension names (default: correctness,relevance,quality)")
parser.add_argument("--concurrency", type=int, default=4, help="Parallel scoring workers")
parser.add_argument("--strip-metadata", action="store_true",
help="Remove _quality_scores and _avg_quality from output (safe for training input)")
args = parser.parse_args()
client, method = get_clients(
base_url=args.base_url, azure_endpoint=args.endpoint,
project_endpoint=args.project_endpoint, api_key=args.api_key
)
# Parse dimensions
if args.dimensions:
dim_names = [d.strip() for d in args.dimensions.split(",")]
dimensions = {d: f"Rate the {d} of the output" for d in dim_names}
else:
dimensions = DEFAULT_DIMENSIONS
# Load data
examples = []
with open(args.input, encoding="utf-8") as f:
for i, line in enumerate(f):
if not line.strip():
continue
try:
ex = json.loads(line)
except json.JSONDecodeError as e:
print(f"⚠️ Skipping malformed JSON on line {i+1}: {e}")
continue
msgs = ex.get("messages", [])
user = next((m["content"] for m in msgs if m["role"] == "user"), "")
asst = next((m["content"] for m in msgs if m["role"] == "assistant"), "")
examples.append({"data": ex, "user": user, "assistant": asst})
print(f"Loaded {len(examples)} examples. Scoring with {args.model}...")
# Score in parallel
def score_one(idx):
ex = examples[idx]
scores = score_example(client, args.model, ex["user"], ex["assistant"], dimensions)
return idx, scores
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
futures = {pool.submit(score_one, i): i for i in range(len(examples))}
done = 0
for future in as_completed(futures):
idx, scores = future.result()
examples[idx]["scores"] = scores
done += 1
if done % 25 == 0:
print(f" Scored {done}/{len(examples)}")
# Calculate stats
all_avgs = []
for ex in examples:
scores = ex.get("scores", {})
if scores and any(v > 0 for v in scores.values()):
avg = sum(scores.values()) / len(scores)
ex["avg_score"] = avg
all_avgs.append(avg)
if all_avgs:
print(f"\nQuality Distribution:")
print(f" Mean: {sum(all_avgs)/len(all_avgs):.1f}")
print(f" Min: {min(all_avgs):.1f}")
print(f" Max: {max(all_avgs):.1f}")
sorted_avgs = sorted(all_avgs)
n_avgs = len(sorted_avgs)
if n_avgs % 2 == 1:
median = sorted_avgs[n_avgs // 2]
else:
median = (sorted_avgs[n_avgs // 2 - 1] + sorted_avgs[n_avgs // 2]) / 2
print(f" Median: {median:.1f}")
# Filter and write
kept = 0
filtered = 0
with open(args.output, "w", encoding="utf-8") as f:
for ex in examples:
if not args.strip_metadata:
ex["data"]["_quality_scores"] = ex.get("scores", {})
ex["data"]["_avg_quality"] = ex.get("avg_score", 0)
if args.min_score and ex.get("avg_score", 0) < args.min_score:
filtered += 1
continue
f.write(json.dumps(ex["data"], ensure_ascii=False) + "\n")
kept += 1
print(f"\nKept: {kept}, Filtered: {filtered}")
if args.min_score:
print(f"(min_score threshold: {args.min_score})")
if args.strip_metadata:
print("(metadata stripped — output is safe for training input)")
print(f"Output: {args.output}")
if __name__ == "__main__":
main()