selfheal-sql / src /selfheal_sql /student.py
HideIron's picture
Self-healing text-to-SQL: landing page + full source
f636e22 verified
Raw History Blame Contribute Delete
6.41 kB
"""Student harness: make the model write SQL for a split, execute it, grade it, log it.
One script runs BOTH the baseline (no adapter) and the healed model (--adapter), with the
same prompt, the same greedy decoding and the same parsing, so a before/after difference
comes from training and nothing else.
Usage (inside an HF Job; see launch_job.py):
python student.py --data /data --split pool --model CohereLabs/tiny-aya-global \
--out /out/baseline_pool [--adapter path_or_repo] [--limit 20] \
[--push-repo user/runs-repo --push-path runs/baseline_pool]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import Counter
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from execcheck import check # noqa: E402
PROMPT = (
"You are an expert in SQLite. Given the database schema and a question, write ONE SQLite "
"query that answers the question. Output only the SQL query, with no explanation.\n\n"
"Database schema:\n{schema}\n\nQuestion: {question}\n\nSQL:"
)
def load_rows(data_root: str, split: str, limit: int | None):
path = os.path.join(data_root, "splits", f"{split}.jsonl")
if not os.path.exists(path): # local layout: <root>/<split>.jsonl
path = os.path.join(data_root, f"{split}.jsonl")
rows = [json.loads(line) for line in open(path, encoding="utf-8")]
return rows[:limit] if limit else rows
def build_prompt(tok, row) -> str:
msgs = [{"role": "user", "content": PROMPT.format(schema=row["schema"], question=row["question"])}]
return tok.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True, help="dataset root (contains splits/ and databases/)")
ap.add_argument("--split", required=True)
ap.add_argument("--model", default="CohereLabs/tiny-aya-global")
ap.add_argument("--adapter", default=None, help="LoRA adapter path or HF repo id")
ap.add_argument("--out", required=True)
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--batch-size", type=int, default=16)
ap.add_argument("--max-new-tokens", type=int, default=256)
ap.add_argument("--push-repo", default=None)
ap.add_argument("--push-path", default=None)
args = ap.parse_args()
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
rows = load_rows(args.data, args.split, args.limit)
print(f"[student] {len(rows)} rows from split={args.split}", flush=True)
tok = AutoTokenizer.from_pretrained(args.model)
tok.padding_side = "left"
if tok.pad_token is None:
tok.pad_token = tok.eos_token
model = AutoModelForCausalLM.from_pretrained(args.model, dtype=torch.bfloat16, device_map="cuda")
if args.adapter:
from peft import PeftModel
model = PeftModel.from_pretrained(model, args.adapter)
model.eval()
print("[student] model loaded:", args.model, "adapter:", args.adapter, flush=True)
prompts = [build_prompt(tok, r) for r in rows]
order = sorted(range(len(rows)), key=lambda i: len(prompts[i])) # batch similar lengths
raw: dict[int, tuple[str, int, int]] = {}
gen_seconds = 0.0
for b in range(0, len(order), args.batch_size):
idx = order[b : b + args.batch_size]
enc = tok([prompts[i] for i in idx], return_tensors="pt", padding=True, add_special_tokens=False).to("cuda")
t0 = time.time()
with torch.inference_mode():
out = model.generate(
**enc, max_new_tokens=args.max_new_tokens, do_sample=False, pad_token_id=tok.pad_token_id
)
torch.cuda.synchronize()
gen_seconds += time.time() - t0
new = out[:, enc["input_ids"].shape[1] :]
for j, i in enumerate(idx):
ids = new[j]
n_new = int((ids != tok.pad_token_id).sum())
raw[i] = (tok.decode(ids, skip_special_tokens=True), int(enc["attention_mask"][j].sum()), n_new)
print(f"[student] {min(b + args.batch_size, len(order))}/{len(order)}", flush=True)
os.makedirs(args.out, exist_ok=True)
statuses: Counter = Counter()
n_new_tokens = 0
with open(os.path.join(args.out, "results.jsonl"), "w", encoding="utf-8") as f:
for i, r in enumerate(rows):
text, n_prompt, n_new = raw[i]
n_new_tokens += n_new
res = check(os.path.join(args.data, r["db_path"]), text, r["gold_sql"])
statuses[res.status] += 1
f.write(
json.dumps(
{
"qid": r["qid"], "split": r["split"], "db_id": r["db_id"],
"question": r["question"], "gold_sql": r["gold_sql"],
"raw_output": text, "status": res.status,
"error_message": res.error_message,
"pred_rows": res.pred_rows, "gold_rows": res.gold_rows,
"prompt_tokens": n_prompt, "new_tokens": n_new,
},
ensure_ascii=False,
)
+ "\n"
)
n = len(rows)
summary = {
"split": args.split, "model": args.model, "adapter": args.adapter, "n": n,
"execution_accuracy": statuses["correct"] / n,
"valid_sql_rate": (n - statuses["sql_error"] - statuses["empty_sql"] - statuses["timeout"]) / n,
"status_counts": dict(statuses),
"generation_seconds": round(gen_seconds, 2),
"new_tokens": n_new_tokens,
"tokens_per_second": round(n_new_tokens / gen_seconds, 1) if gen_seconds else None,
"max_new_tokens": args.max_new_tokens, "decoding": "greedy",
}
with open(os.path.join(args.out, "summary.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2)
print("[student] SUMMARY", json.dumps(summary), flush=True)
if args.push_repo:
from huggingface_hub import HfApi
HfApi().upload_folder(
repo_id=args.push_repo, repo_type="dataset", folder_path=args.out,
path_in_repo=args.push_path or os.path.basename(args.out.rstrip("/")),
commit_message=f"student run {args.split}",
)
print("[student] pushed to", args.push_repo, args.push_path, flush=True)
if __name__ == "__main__":
main()