mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-12 03:56:53 +03:00
92299e1238
Add a schema-valid eval manifest (6 cases: fine-tuning plan review, eval-set design, quantization decision, deployment plan, regression triage, training-run reproducibility), three fillable templates (training-run record, eval regression table, quantization decision record), a stdlib eval-set overlap/leakage checker with a unittest suite, routing to the llama-cpp tool skill, and a README Quick Start documenting the script. Closes #240. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
273 lines
9.3 KiB
Python
273 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Eval-set overlap/leakage checker for ml-engineering.
|
|
|
|
Compares a training corpus against an evaluation corpus and reports how much of
|
|
each eval file shares contiguous n-grams with the training text. Overlap between
|
|
train and eval is the classic test-set contamination signature: when an eval
|
|
example (or a near-copy of one) appears in the training data, large spans of its
|
|
text show up verbatim in the training corpus, so the score on that example stops
|
|
measuring generalization.
|
|
|
|
The check is static and conservative. Character shingles of size 8 (the default)
|
|
flag duplicated prose, paraphrases, and generated examples; ``--token-ngram``
|
|
flags repeated token sequences instead. Small shared fragments from common words
|
|
are expected; a single eval file is flagged only when its overlap fraction
|
|
exceeds ``--max-overlap-fraction``.
|
|
|
|
Inputs: one or more files or directories per side. Directories are searched
|
|
recursively for regular files (hidden files are skipped). Text is normalized to
|
|
lowercase alphanumerics before shingling.
|
|
|
|
Exit codes:
|
|
0 no eval file exceeds the overlap threshold
|
|
1 one or more eval files exceed the overlap threshold (leakage found)
|
|
2 usage or I/O error (missing path, unreadable file, empty corpus)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
|
|
|
|
|
def normalize(text):
|
|
"""Lowercase text and reduce it to alphanumeric runs separated by spaces."""
|
|
return NON_ALNUM.sub(" ", text.lower()).strip()
|
|
|
|
|
|
def char_shingles(text, size):
|
|
"""All contiguous character windows of ``size`` over normalized text."""
|
|
text = normalize(text)
|
|
if len(text) < size:
|
|
return []
|
|
return [text[i : i + size] for i in range(len(text) - size + 1)]
|
|
|
|
|
|
def token_ngrams(text, size):
|
|
"""All contiguous windows of ``size`` tokens over normalized text."""
|
|
tokens = normalize(text).split()
|
|
if len(tokens) < size:
|
|
return []
|
|
return [" ".join(tokens[i : i + size]) for i in range(len(tokens) - size + 1)]
|
|
|
|
|
|
def shingle_document(text, shingle_size, token_size):
|
|
if token_size:
|
|
return token_ngrams(text, token_size)
|
|
return char_shingles(text, shingle_size)
|
|
|
|
|
|
def collect_files(paths):
|
|
"""Return the sorted list of readable files under the given paths."""
|
|
found = []
|
|
for raw in paths:
|
|
path = Path(raw)
|
|
if not path.exists():
|
|
raise ValueError(f"path does not exist: {raw}")
|
|
if path.is_file():
|
|
found.append(path)
|
|
continue
|
|
if not path.is_dir():
|
|
raise ValueError(f"not a file or directory: {raw}")
|
|
for child in sorted(path.rglob("*")):
|
|
if child.is_file() and not child.name.startswith("."):
|
|
found.append(child)
|
|
return sorted(found, key=str)
|
|
|
|
|
|
def read_text(path):
|
|
try:
|
|
return path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError as exc:
|
|
raise ValueError(f"cannot read {path}: {exc.strerror}") from exc
|
|
|
|
|
|
def build_report(train_paths, eval_paths, shingle_size, token_size, threshold, show_examples):
|
|
"""Compute the per-eval-file overlap report against the train corpus."""
|
|
train_files = collect_files(train_paths)
|
|
eval_files = collect_files(eval_paths)
|
|
if not train_files:
|
|
raise ValueError("training corpus contains no files")
|
|
if not eval_files:
|
|
raise ValueError("evaluation corpus contains no files")
|
|
|
|
train_shingle_set = set()
|
|
for path in train_files:
|
|
train_shingle_set.update(shingle_document(read_text(path), shingle_size, token_size))
|
|
if not train_shingle_set:
|
|
raise ValueError(
|
|
"training corpus yields no shingles (text too short or empty); "
|
|
"lower --shingle-size or use --token-ngram"
|
|
)
|
|
|
|
files_report = []
|
|
eval_shingle_count = 0
|
|
shared_across_corpus = set()
|
|
leaked = []
|
|
for path in eval_files:
|
|
doc_shingles = set(shingle_document(read_text(path), shingle_size, token_size))
|
|
eval_shingle_count += len(doc_shingles)
|
|
shared = doc_shingles & train_shingle_set
|
|
total = len(doc_shingles)
|
|
fraction = len(shared) / total if total else 0.0
|
|
shared_across_corpus |= shared
|
|
verdict = "LEAK" if fraction > threshold else "ok"
|
|
entry = {
|
|
"path": str(path),
|
|
"shingles": total,
|
|
"shared": len(shared),
|
|
"fraction": round(fraction, 4),
|
|
"verdict": verdict,
|
|
"examples": sorted(shared)[:show_examples],
|
|
}
|
|
if verdict == "LEAK":
|
|
leaked.append(str(path))
|
|
files_report.append(entry)
|
|
|
|
return {
|
|
"tool": "check-eval-overlap.py",
|
|
"shingle_mode": f"token-{token_size}" if token_size else f"char-{shingle_size}",
|
|
"train_files": len(train_files),
|
|
"train_shingles": len(train_shingle_set),
|
|
"eval_files": len(eval_files),
|
|
"eval_shingles": eval_shingle_count,
|
|
"shared_shingles": len(shared_across_corpus),
|
|
"max_overlap_fraction": threshold,
|
|
"leaked": leaked,
|
|
"files": files_report,
|
|
}
|
|
|
|
|
|
def format_count(value):
|
|
return f"{value:,}"
|
|
|
|
|
|
def print_human(report):
|
|
threshold_pct = report["max_overlap_fraction"] * 100
|
|
print(
|
|
f"{report['shingle_mode']} overlap check: {report['train_files']} train file(s), "
|
|
f"{format_count(report['train_shingles'])} distinct train shingles; "
|
|
f"{report['eval_files']} eval file(s), {format_count(report['eval_shingles'])} distinct eval shingles"
|
|
)
|
|
for entry in report["files"]:
|
|
label = "LEAK" if entry["verdict"] == "LEAK" else "ok"
|
|
print(
|
|
f" {entry['path']}: {format_count(entry['shared'])}/{format_count(entry['shingles'])} "
|
|
f"shingles ({entry['fraction'] * 100:.1f}%) overlap -> {label}"
|
|
)
|
|
for example in entry["examples"]:
|
|
print(f" shared n-gram: {example!r}")
|
|
overall_pct = (
|
|
report["shared_shingles"] / report["eval_shingles"] * 100 if report["eval_shingles"] else 0.0
|
|
)
|
|
if report["leaked"]:
|
|
print(
|
|
f"Leakage detected in {len(report['leaked'])} of {report['eval_files']} eval file(s) "
|
|
f"(threshold {threshold_pct:.1f}%)."
|
|
)
|
|
return
|
|
print(
|
|
f"No leakage: {format_count(report['shared_shingles'])} shared shingle(s) across "
|
|
f"{report['eval_files']} eval file(s) ({overall_pct:.2f}% of eval shingles; "
|
|
f"threshold {threshold_pct:.1f}%)."
|
|
)
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(
|
|
prog="check-eval-overlap.py",
|
|
description=(
|
|
"Detect eval-set leakage: report how much of each eval corpus file shares "
|
|
"contiguous n-grams with the training corpus. Exit 0 when no eval file exceeds "
|
|
"the overlap threshold, 1 when leakage is found, 2 on usage or I/O errors."
|
|
),
|
|
epilog=(
|
|
"Example: python3 check-eval-overlap.py --train data/train --eval data/eval --json"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--train",
|
|
nargs="+",
|
|
required=True,
|
|
metavar="PATH",
|
|
help="training corpus: a file or directory (searched recursively); may be repeated",
|
|
)
|
|
parser.add_argument(
|
|
"--eval",
|
|
nargs="+",
|
|
required=True,
|
|
metavar="PATH",
|
|
help="evaluation corpus: a file or directory (searched recursively); may be repeated",
|
|
)
|
|
parser.add_argument(
|
|
"--shingle-size",
|
|
type=int,
|
|
default=8,
|
|
metavar="N",
|
|
help="character shingle size for overlap detection (default: 8)",
|
|
)
|
|
parser.add_argument(
|
|
"--token-ngram",
|
|
nargs="?",
|
|
const=5,
|
|
type=int,
|
|
default=None,
|
|
metavar="N",
|
|
help="use token n-grams of size N instead of character shingles (default when bare: 5)",
|
|
)
|
|
parser.add_argument(
|
|
"--max-overlap-fraction",
|
|
type=float,
|
|
default=0.10,
|
|
metavar="F",
|
|
help="leak threshold: an eval file whose overlap fraction exceeds F is flagged (default: 0.10)",
|
|
)
|
|
parser.add_argument(
|
|
"--show-examples",
|
|
type=int,
|
|
default=3,
|
|
metavar="N",
|
|
help="number of example shared n-grams to print per leaked file (default: 3)",
|
|
)
|
|
parser.add_argument("--json", action="store_true", help="emit a machine-readable JSON report")
|
|
return parser
|
|
|
|
|
|
def main(argv=None):
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
if args.shingle_size < 2:
|
|
parser.error("--shingle-size must be >= 2")
|
|
if args.token_ngram is not None and args.token_ngram < 2:
|
|
parser.error("--token-ngram must be >= 2")
|
|
if not 0 < args.max_overlap_fraction <= 1:
|
|
parser.error("--max-overlap-fraction must be in (0, 1]")
|
|
if args.show_examples < 0:
|
|
parser.error("--show-examples must be >= 0")
|
|
|
|
try:
|
|
report = build_report(
|
|
args.train,
|
|
args.eval,
|
|
args.shingle_size,
|
|
args.token_ngram,
|
|
args.max_overlap_fraction,
|
|
args.show_examples,
|
|
)
|
|
except ValueError as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
if args.json:
|
|
print(json.dumps(report, indent=2))
|
|
else:
|
|
print_human(report)
|
|
return 1 if report["leaked"] else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|