chess-tutor / src /chess_tutor /export_training_dataset.py
github-actions[bot]
deploy prod from 06dbd16a01ddcfe02b2d936c681e3e4eaa9b141f
8e756fd
Raw
History Blame Contribute Delete
15.2 kB
#!/usr/bin/env python3
"""
Export positions for ML training: Parquet (metadata + heuristics) + HDF5 (full ONNX tensors).
Reads rows from ``sampled_positions`` or ``evaluated_positions``. For each row, recomputes
the twelve heuristic scores from the FEN and runs ONNX once. Probe tensors default to
``tensors_to_expose`` in ``onnx_config.yaml`` (override with ``--probes``).
Stores:
* ``<out_dir>/parquet/part-*.parquet`` — ``sample_idx``, ``db_id``, ``fen``, heuristics,
and SQLite metadata (excludes ``onnx_eval_json``).
* ``<out_dir>/tensors.h5`` — HDF5 datasets (float32), row-aligned by ``sample_idx``:
``policy``, ``wdl`` or ``value_head``, and one dataset per probe present in the ONNX
outputs (names derived from ONNX tensor names; mapping in dataset attrs).
Example::
python -m chess_tutor.export_training_dataset --table sampled_positions \\
--out-dir training_export --limit 500000 --parquet-shard-rows 50000
Requires: onnxruntime, lczero.backends, numpy, pyarrow, h5py (see requirements.txt).
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
import h5py
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import lczero.backends as lc0
from chess_tutor.heuristics import HeuristicFeatures, extract_heuristic_features
from chess_tutor.lc0_onnx import (
input_channels_from_onnx_shape,
make_onnx_input_tensor,
run_session,
)
from chess_tutor.compute import add_compute_arguments, log_compute_plan, resolve_lc0_bindings_backend
from chess_tutor.lc0_onnx.session import create_inference_session
from chess_tutor.onnx_probe_config import load_onnx_probe_config
from chess_tutor.paths import default_positions_db_path
from chess_tutor.training.heuristics_export import heuristics_row_dict
from chess_tutor.training.tensor_layout import (
classify_outputs,
merge_layout_batch,
stack_batch,
)
from chess_tutor.training.tensor_names import tensor_dataset_name
# Backward-compatible re-exports (prefer ``chess_tutor.training``).
__all__ = [
"ALLOWED_TABLES",
"PARQUET_SKIP",
"classify_outputs",
"heuristics_row_dict",
"iter_source_rows",
"main",
"merge_layout_batch",
"sqlite_row_dict",
"stack_batch",
"table_column_names",
"tensor_dataset_name",
"write_parquet_shard",
]
ALLOWED_TABLES = frozenset({"sampled_positions", "evaluated_positions"})
PARQUET_SKIP = frozenset({"onnx_eval_json"})
def sqlite_row_dict(
column_names: Sequence[str], row: Tuple[Any, ...]
) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for name, val in zip(column_names, row):
if name in PARQUET_SKIP:
continue
if name == "id":
out["db_id"] = val
else:
out[name] = val
return out
def table_column_names(conn: sqlite3.Connection, table: str) -> List[str]:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return [r[1] for r in rows]
def iter_source_rows(
conn: sqlite3.Connection,
table: str,
limit: Optional[int],
) -> Tuple[List[str], Any]:
"""Return (column_names, cursor) for ``SELECT * FROM table ORDER BY id``."""
names = table_column_names(conn, table)
if not names:
raise ValueError(f"Table {table!r} has no columns")
col_list = ", ".join(f'"{n}"' for n in names)
q = f"SELECT {col_list} FROM {table} ORDER BY id"
params: Tuple[Any, ...] = ()
if limit is not None:
q += " LIMIT ?"
params = (int(limit),)
cur = conn.execute(q, params)
return names, cur
def write_parquet_shard(rows: List[Dict[str, Any]], path: Path) -> None:
table = pa.Table.from_pylist(rows)
path.parent.mkdir(parents=True, exist_ok=True)
pq.write_table(table, path, compression="zstd")
def main() -> None:
cfg = load_onnx_probe_config()
default_probe_names: Sequence[str] = list(cfg.tensors_to_expose)
p = argparse.ArgumentParser(
description="Export Parquet (metadata + heuristics) and HDF5 (full ONNX tensors)."
)
p.add_argument("--db", type=Path, default=default_positions_db_path())
p.add_argument(
"--table",
default="sampled_positions",
choices=sorted(ALLOWED_TABLES),
help="SQLite table to read (must contain id, fen)",
)
p.add_argument(
"--out-dir",
type=Path,
default=Path("training_export"),
help="Output directory (creates parquet/ and tensors.h5)",
)
p.add_argument("--limit", type=int, default=None, help="Max rows (default: all)")
p.add_argument(
"--write-batch-rows",
type=int,
default=256,
metavar="N",
help="Accumulate N successful rows before appending to HDF5",
)
p.add_argument(
"--parquet-shard-rows",
type=int,
default=50_000,
metavar="N",
help="Start a new Parquet file every N exported rows",
)
p.add_argument(
"--hdf5-chunk-rows",
type=int,
default=8192,
metavar="N",
help="HDF5 chunk size along the sample (row) dimension",
)
p.add_argument("--onnx", type=Path, default=Path(cfg.onnx_path))
p.add_argument("--weights", type=Path, default=Path(cfg.weights_path))
p.add_argument(
"--probes",
nargs="*",
default=None,
help="Probe tensor names (default: tensors_to_expose from onnx_config.yaml)",
)
add_compute_arguments(p, include_ort=True, include_lc0_bindings=True)
args = p.parse_args()
log_compute_plan(ort_spec=args.ort_provider, lc0_bindings_spec=args.lc0_bindings_backend)
probe_names: Sequence[str] = (
args.probes if args.probes is not None else default_probe_names
)
if not args.db.is_file():
print(f"Database not found: {args.db}", file=sys.stderr)
sys.exit(1)
if not args.onnx.is_file():
print(f"ONNX not found: {args.onnx}", file=sys.stderr)
sys.exit(1)
if not args.weights.is_file():
print(f"Weights not found: {args.weights}", file=sys.stderr)
sys.exit(1)
if args.write_batch_rows < 1 or args.parquet_shard_rows < 1 or args.hdf5_chunk_rows < 1:
print("Batch and chunk sizes must be >= 1.", file=sys.stderr)
sys.exit(1)
args.out_dir.mkdir(parents=True, exist_ok=True)
h5_path = args.out_dir / "tensors.h5"
parquet_dir = args.out_dir / "parquet"
parquet_dir.mkdir(parents=True, exist_ok=True)
if h5_path.exists():
print(
f"Refusing to overwrite existing {h5_path}; remove it or pick a new --out-dir.",
file=sys.stderr,
)
sys.exit(1)
total_written = 0
shard_idx = 0
errors = 0
h5_datasets: Dict[str, h5py.Dataset] = {}
t0 = time.perf_counter()
warned_missing_probes = False
conn = sqlite3.connect(str(args.db))
try:
col_names, cursor = iter_source_rows(conn, args.table, args.limit)
if "id" not in col_names or "fen" not in col_names:
print(f"Table {args.table!r} must have id and fen columns.", file=sys.stderr)
sys.exit(1)
onnx_path = str(args.onnx.resolve())
sess = create_inference_session(args.onnx.resolve(), ort_provider=args.ort_provider)
onnx_in = sess.get_inputs()[0]
if len(onnx_in.shape) != 4:
raise ValueError(f"Unexpected ONNX input shape: {onnx_in.shape}")
num_planes = input_channels_from_onnx_shape(onnx_in.shape)
weights = lc0.Weights(str(args.weights.resolve()))
backend = lc0.Backend(
weights, backend=resolve_lc0_bindings_backend(args.lc0_bindings_backend)
)
out_names = {o.name for o in sess.get_outputs()}
missing_cfg = [n for n in probe_names if n not in out_names]
if missing_cfg:
print(
"Note: these names from --probes / onnx_config are not ONNX graph outputs "
f"(often fixed by re-running the probe generator + reload): {missing_cfg}",
file=sys.stderr,
)
h5f = h5py.File(h5_path, "w")
try:
parquet_buffer: List[Dict[str, Any]] = []
pending_layout: List[Dict[str, Any]] = []
reference_layout: Optional[Dict[str, Any]] = None
def flush_h5_batch(layout_batch: List[Dict[str, Any]]) -> None:
nonlocal h5_datasets, total_written
stacked = stack_batch(layout_batch)
b = stacked["policy"].shape[0]
if not h5_datasets:
for key, arr in stacked.items():
tail = arr.shape[1:]
chunk_rows = max(1, min(args.hdf5_chunk_rows, b))
chunks = (chunk_rows,) + tail
h5_datasets[key] = h5f.create_dataset(
key,
shape=(0,) + tail,
maxshape=(None,) + tail,
dtype=np.float32,
chunks=chunks,
compression="gzip",
compression_opts=4,
shuffle=True,
)
probe_meta = [
{"onnx_name": p["onnx_name"], "dataset_key": p["dataset_key"]}
for p in layout_batch[0]["probes"]
]
h5f.attrs["probes"] = json.dumps(probe_meta)
h5f.attrs["onnx_path"] = str(args.onnx.resolve())
h5f.attrs["sqlite_table"] = args.table
h5f.attrs["db_path"] = str(args.db.resolve())
h5f.attrs["tensor_datasets"] = json.dumps(list(stacked.keys()))
pos = total_written
new_len = pos + b
for key, arr in stacked.items():
ds = h5_datasets[key]
ds.resize((new_len,) + ds.shape[1:])
ds[pos:new_len] = arr
total_written = new_len
def flush_parquet_shards(*, final: bool) -> None:
nonlocal shard_idx, parquet_buffer
if final:
while parquet_buffer:
n = min(args.parquet_shard_rows, len(parquet_buffer))
chunk = parquet_buffer[:n]
del parquet_buffer[:n]
out_p = parquet_dir / f"part-{shard_idx:05d}.parquet"
write_parquet_shard(chunk, out_p)
shard_idx += 1
else:
while len(parquet_buffer) >= args.parquet_shard_rows:
chunk = parquet_buffer[: args.parquet_shard_rows]
del parquet_buffer[: args.parquet_shard_rows]
out_p = parquet_dir / f"part-{shard_idx:05d}.parquet"
write_parquet_shard(chunk, out_p)
shard_idx += 1
for row in cursor:
meta = sqlite_row_dict(col_names, row)
db_id = meta.pop("db_id", None)
fen = meta.get("fen")
if db_id is None or not fen:
errors += 1
continue
try:
h = extract_heuristic_features(str(fen))
hdict = heuristics_row_dict(h)
except Exception:
errors += 1
continue
try:
inp = make_onnx_input_tensor(str(fen), sess, backend, num_planes)
onnx_out = run_session(inp, sess)
layout = classify_outputs(sess, onnx_out, probe_names)
if not warned_missing_probes:
got = {p["onnx_name"] for p in layout["probes"]}
wanted = {n for n in probe_names if n in out_names}
skipped = sorted(wanted - got)
if skipped:
print(
"Note: configured probes not present in this run's ONNX outputs "
f"(skipped): {skipped}",
file=sys.stderr,
)
warned_missing_probes = True
reference_layout = merge_layout_batch(reference_layout, layout)
except Exception:
errors += 1
continue
sample_idx = total_written + len(pending_layout)
pending_layout.append(layout)
record: Dict[str, Any] = {
"sample_idx": sample_idx,
"db_id": db_id,
"fen": str(fen),
**hdict,
**{k: v for k, v in meta.items() if k != "fen"},
}
parquet_buffer.append(record)
flush_parquet_shards(final=False)
if len(pending_layout) >= args.write_batch_rows:
flush_h5_batch(pending_layout)
pending_layout.clear()
n_live = total_written + len(pending_layout)
if n_live % 5000 == 0 and n_live > 0:
dt = time.perf_counter() - t0
rate = n_live / dt if dt > 0 else 0
print(
f" exported {n_live} rows ({rate:.1f} rows/s) errors={errors}",
flush=True,
)
if pending_layout:
flush_h5_batch(pending_layout)
pending_layout.clear()
flush_parquet_shards(final=True)
finally:
h5f.close()
finally:
conn.close()
if total_written == 0:
print(
"No rows exported (all errors or empty table). Removing tensors.h5.",
file=sys.stderr,
)
h5_path.unlink(missing_ok=True)
sys.exit(1)
h5_shapes: Dict[str, List[int]] = {}
with h5py.File(h5_path, "r") as hf_r:
for k in sorted(hf_r.keys()):
h5_shapes[k] = list(hf_r[k].shape)
manifest = {
"exported_rows": total_written,
"errors_skipped": errors,
"parquet_shards": shard_idx,
"parquet_dir": str(parquet_dir.relative_to(args.out_dir)),
"hdf5_file": h5_path.name,
"hdf5_tensor_shapes": h5_shapes,
"elapsed_sec": round(time.perf_counter() - t0, 3),
"write_batch_rows": args.write_batch_rows,
"parquet_shard_rows": args.parquet_shard_rows,
"hdf5_chunk_rows": args.hdf5_chunk_rows,
}
with open(args.out_dir / "export_manifest.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
print(
f"Done. exported={total_written} errors_skipped={errors} "
f"out_dir={args.out_dir.resolve()}",
flush=True,
)
if __name__ == "__main__":
main()