215 lines
7.7 KiB
Python
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()
|