Source: scripts/collect_action_targets.py · Plain text

scripts/collect_action_targets.py

"""Collect offline all-action teacher rows for dynamic train episodes."""

from __future__ import annotations

import argparse
import json
from collections.abc import Mapping, Sequence
from pathlib import Path

import torch

from mcss.training.action_teacher import (
    DEFAULT_HORIZON,
    DEFAULT_MAX_UNITS,
    DEFAULT_RAY_CHUNK_SIZE,
    DEFAULT_RENDER_SAMPLES,
    collect_action_targets,
)
from mcss.training.experiment import load_train_episode_index

PROJECT = Path(__file__).resolve().parents[1]


def main(argv: Sequence[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--index", type=Path, required=True)
    parser.add_argument("--checkpoint", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="cpu")
    parser.add_argument("--horizon", type=int, default=DEFAULT_HORIZON)
    parser.add_argument("--max-episodes", type=int)
    parser.add_argument("--prefix-stride", type=int, default=1)
    parser.add_argument("--max-prefixes-per-episode", type=int)
    parser.add_argument(
        "--selected-prefixes",
        "--selected_prefixes",
        type=Path,
        default=None,
        dest="selected_prefixes",
    )
    parser.add_argument("--rollin-policy", type=str, default="OFF")
    parser.add_argument("--rollin-seed", type=int, default=0)
    parser.add_argument("--max-units", type=float, default=DEFAULT_MAX_UNITS)
    parser.add_argument(
        "--renderer-samples",
        "--render-samples",
        type=int,
        default=DEFAULT_RENDER_SAMPLES,
        dest="renderer_samples",
    )
    parser.add_argument(
        "--ray-chunk-size",
        type=int,
        default=DEFAULT_RAY_CHUNK_SIZE,
    )
    parser.add_argument("--resume", action="store_true")
    args = parser.parse_args(argv)

    index_path = args.index.resolve()
    entries = load_train_episode_index(index_path)
    selected = _load_selected_prefixes(args.selected_prefixes)
    result = collect_action_targets(
        entries,
        args.checkpoint,
        args.output,
        index_path=index_path,
        device=_resolve_device(args.device),
        horizon=args.horizon,
        max_episodes=args.max_episodes,
        prefix_stride=args.prefix_stride,
        max_prefixes_per_episode=args.max_prefixes_per_episode,
        selected_prefixes=selected,
        rollin_policy=args.rollin_policy,
        rollin_seed=args.rollin_seed,
        max_units=args.max_units,
        renderer_samples=args.renderer_samples,
        ray_chunk_size=args.ray_chunk_size,
        resume=args.resume,
    )
    print(json.dumps(result, sort_keys=True, separators=(",", ":"), allow_nan=False))
    return 0


def _resolve_device(value: str) -> torch.device:
    if value == "cuda":
        if not torch.cuda.is_available():
            raise RuntimeError("CUDA was requested but is unavailable")
        return torch.device("cuda")
    if value == "auto" and torch.cuda.is_available():
        return torch.device("cuda")
    return torch.device("cpu")


def _load_selected_prefixes(path: Path | None) -> dict[str, list[int]] | None:
    if path is None:
        return None
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as error:
        raise ValueError(f"invalid selected-prefix JSON: {path}") from error
    if isinstance(value, Mapping):
        if set(value) == {"episodes"}:
            value = value["episodes"]
        if not isinstance(value, Mapping):
            raise ValueError("selected-prefix episodes must be an object")
        return _normalize_selected_mapping(value)
    if isinstance(value, list):
        result: dict[str, list[int]] = {}
        for index, record in enumerate(value):
            if not isinstance(record, Mapping):
                raise ValueError(f"selected-prefix record {index} must be an object")
            if set(record) != {"episode_id", "prefix_steps"}:
                raise ValueError(
                    f"selected-prefix record {index} fields must be episode_id and prefix_steps"
                )
            episode_id = record["episode_id"]
            if not isinstance(episode_id, str) or not episode_id:
                raise ValueError(f"selected-prefix record {index} has an invalid episode_id")
            if episode_id in result:
                raise ValueError(f"selected-prefix record duplicates episode_id: {episode_id}")
            result[episode_id] = _normalize_steps(record["prefix_steps"], episode_id)
        return result
    raise ValueError("selected-prefix JSON must be an episode mapping or record list")


def _normalize_selected_mapping(value: Mapping[object, object]) -> dict[str, list[int]]:
    result: dict[str, list[int]] = {}
    for episode_id, steps in value.items():
        if not isinstance(episode_id, str) or not episode_id:
            raise ValueError("selected-prefix episode IDs must be nonempty strings")
        if episode_id in result:
            raise ValueError(f"selected-prefix mapping duplicates episode_id: {episode_id}")
        result[episode_id] = _normalize_steps(steps, episode_id)
    return result


def _normalize_steps(value: object, episode_id: str) -> list[int]:
    if isinstance(value, (str, bytes)) or not isinstance(value, list):
        raise ValueError(f"selected prefixes for {episode_id!r} must be a list of integers")
    if any(type(step) is not int or step < 0 for step in value):
        raise ValueError(f"selected prefixes for {episode_id!r} must be nonnegative integers")
    if value != sorted(set(value)) or not value:
        raise ValueError(f"selected prefixes for {episode_id!r} must be sorted and unique")
    return list(value)


if __name__ == "__main__":
    raise SystemExit(main())