Spaces:
Sleeping
Sleeping
| #!/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() | |