File size: 6,838 Bytes
d95323c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | """Convert receipt/invoice ground-truth JSONL into OpenAI fine-tuning format.
Purpose
-------
Take one of our smoke datasets (JSONL with `text` + `ground_truth`) and produce
a `.jsonl` in OpenAI\'s chat-completions fine-tuning format:
{"messages": [
{"role": "system", "content": SYSTEM_PROMPT_RECEIPT},
{"role": "user", "content": "Extract... <document text>"},
{"role": "assistant", "content": "<envelope JSON matching ExtractionResult>"}
]}
The `system` and `user` blocks match what our production extractor sends, so
the fine-tuned model learns the *exact* input-output pair we\'ll invoke it with.
The `assistant` content is the envelope wrapping the ground-truth data with an
empty `field_confidences` + empty `warnings` list β since ground truth is by
definition confident and warning-free.
Split
-----
Randomized 80/20 train/val split, deterministic per `--seed`.
Usage
-----
# Quick β use the 5-record smoke set (fine-tuning will only complete if
# OpenAI relaxes its ~10-example minimum; consider augmenting first).
python scripts/prep_ft_dataset.py \
--input evaluation/smoke_sroie_sample.jsonl \
--doc-type receipt
# Real β after `python scripts/prep_datasets.py all` has pulled full SROIE:
python scripts/prep_ft_dataset.py \
--input data/processed/sroie.jsonl \
--doc-type receipt \
--out data/ft/sroie
"""
from __future__ import annotations
import argparse
import json
import random
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _rel(p: Path) -> str:
try:
return str(p)
except ValueError:
return str(p)
sys.path.insert(0, str(ROOT))
from src.extractors.prompts import get_prompt # noqa: E402
def _envelope_from_gt(ground_truth: dict) -> dict:
"""Wrap the ground truth in the envelope shape the model must learn to emit."""
return {
"data": ground_truth,
"field_confidences": [],
"warnings": [],
}
def _make_user_message(text: str) -> str:
"""Match the exact user-message shape the production extractor sends.
See `DocumentExtractor._build_messages()` β anything the model saw during
fine-tuning that doesn\'t match production input will hurt inference-time F1.
"""
return (
"Extract the structured data from this document. "
"The document text follows (and page images may also be attached):\n\n"
f"---BEGIN DOCUMENT TEXT---\n{text}\n---END DOCUMENT TEXT---"
)
def build_row(record: dict, system_prompt: str) -> dict:
"""Convert one smoke-dataset row into one fine-tuning row."""
rec_id = record.get("id") or "unknown"
text = record.get("text") or ""
gt = record.get("ground_truth") or {}
if not text:
raise ValueError(f"record {rec_id} has no text field β required for fine-tuning")
if not gt:
raise ValueError(f"record {rec_id} has empty ground_truth")
envelope = _envelope_from_gt(gt)
return {
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": _make_user_message(text)},
{"role": "assistant", "content": json.dumps(envelope, separators=(",", ":"))},
],
}
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--input", required=True, nargs="+", help="One or more input JSONL files β each must have text + ground_truth. Multiple files are concatenated.")
ap.add_argument("--doc-type", default="receipt", choices=["invoice", "receipt", "filing"])
ap.add_argument("--out", default="data/ft/receipt", help="Output prefix (writes _train.jsonl + _val.jsonl).")
ap.add_argument("--val-frac", type=float, default=0.20, help="Fraction held out for validation.")
ap.add_argument("--seed", type=int, default=42)
args = ap.parse_args(argv)
in_paths = [Path(p) for p in args.input]
for ip in in_paths:
if not ip.exists():
print(f"ERROR: input not found: {ip}", file=sys.stderr)
return 2
system_prompt = get_prompt(args.doc_type)
# Load + convert every row across every input file. Skip bad rows loudly.
rows = []
for ip in in_paths:
n_before = len(rows)
with ip.open() as f:
for i, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
rows.append(build_row(rec, system_prompt))
except Exception as e:
print(f"[!] {ip.name} line {i} skipped: {e}", file=sys.stderr)
print(f"loaded {len(rows) - n_before} rows from {ip.name}")
if not rows:
print("ERROR: no usable rows.", file=sys.stderr)
return 2
# Deterministic shuffle + split.
rng = random.Random(args.seed)
rng.shuffle(rows)
if args.val_frac <= 0:
# No validation split β every row goes to training. OpenAI accepts
# fine-tune jobs without a val file. Useful for tiny datasets.
val_rows, train_rows = [], rows
else:
n_val = max(1, int(round(len(rows) * args.val_frac)))
val_rows, train_rows = rows[:n_val], rows[n_val:]
# Warn early β OpenAI requires >= 10 training examples for most models.
if len(train_rows) < 10:
print(
f"[!] {len(train_rows)} train rows β OpenAI requires >= 10 for fine-tuning. "
f"Run `python scripts/prep_datasets.py all` to pull real SROIE/CORD first, "
f"then re-run this against the fuller dataset.",
file=sys.stderr,
)
_o = Path(args.out)
out_prefix = _o if _o.is_absolute() else ROOT / _o
out_prefix.parent.mkdir(parents=True, exist_ok=True)
train_path = out_prefix.with_name(out_prefix.name + "_train.jsonl")
val_path = out_prefix.with_name(out_prefix.name + "_val.jsonl")
with train_path.open("w") as f:
for r in train_rows:
f.write(json.dumps(r) + "\n")
if val_rows:
with val_path.open("w") as f:
for r in val_rows:
f.write(json.dumps(r) + "\n")
print(f"train: {len(train_rows)} rows -> {_rel(train_path)}")
if val_rows:
print(f"val: {len(val_rows)} rows -> {_rel(val_path)}")
else:
print("val: 0 rows (skipped β --val-frac was 0)")
print("\nNext: python scripts/launch_finetune.py \\")
print(f" --train {_rel(train_path)} \\")
if val_rows:
print(f" --val {_rel(val_path)}")
else:
print(" --val \'\' # omit --val entirely if you like")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|