transformer / hf_data.py
Mohith202's picture
Sync sequential-state training + /brainrl/health + /verifier replay
73f555c verified
Raw
History Blame Contribute Delete
12 kB
#!/usr/bin/env python
"""Hugging Face data sync helpers for BrainRL.
The environment is intentionally lightweight: it needs configs, the frozen
parcel manifest, participant metadata, and optional word annotations. This
module keeps those artifacts versioned in a HF Dataset repo so Colab, HF Jobs,
and HF Spaces can all run from the same data revision.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parent
DEFAULT_EXPORT_DIR = PROJECT_ROOT / "hf_data_bundle"
DEFAULT_CACHE_DIR = Path(os.getenv("BRAINRL_DATA_DIR", "/tmp/brainrl-data")).expanduser()
REQUIRED_CONFIG_FILES = (
"subset_config.yaml",
"region_priors.json",
"participant_run_info.json",
)
OPTIONAL_CONFIG_FILES = (
"parcel_candidates.json",
)
def _resolve_token(raw_token: str | None = None) -> str | None:
return raw_token or os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
def _copy_file(src: Path, dst: Path, *, required: bool) -> bool:
if not src.exists():
if required:
raise FileNotFoundError(f"Required BrainRL data file is missing: {src}")
return False
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return True
def export_data_bundle(
output_dir: Path,
*,
config_dir: Path | None = None,
annotation_dir: Path | None = None,
include_annotations: bool = True,
) -> dict[str, Any]:
"""Copy the portable BrainRL data subset into ``output_dir``."""
config_root = (config_dir or PROJECT_ROOT / "configs").expanduser()
annotation_root = (
annotation_dir
or Path(os.getenv("BRAINRL_STIMULUS_DIR", PROJECT_ROOT.parent / "data" / "annotation"))
).expanduser()
output_dir = output_dir.expanduser()
if output_dir.exists():
shutil.rmtree(output_dir)
(output_dir / "configs").mkdir(parents=True, exist_ok=True)
copied: list[str] = []
for name in REQUIRED_CONFIG_FILES:
if _copy_file(config_root / name, output_dir / "configs" / name, required=True):
copied.append(f"configs/{name}")
for name in OPTIONAL_CONFIG_FILES:
if _copy_file(config_root / name, output_dir / "configs" / name, required=False):
copied.append(f"configs/{name}")
annotation_count = 0
if include_annotations and annotation_root.exists():
out_annotation = output_dir / "annotation"
out_annotation.mkdir(parents=True, exist_ok=True)
for csv_path in sorted(annotation_root.glob("*.csv")):
shutil.copy2(csv_path, out_annotation / csv_path.name)
annotation_count += 1
copied.append(f"annotation/{csv_path.name}")
metadata = {
"format": "brainrl-hf-data-v1",
"config_files": copied,
"annotation_csv_count": annotation_count,
"source_config_dir": str(config_root),
"source_annotation_dir": str(annotation_root) if annotation_root.exists() else None,
}
with (output_dir / "metadata.json").open("w", encoding="utf-8") as handle:
json.dump(metadata, handle, indent=2)
return metadata
def _file_sha256(path: Path) -> str:
"""Stream-hash a file with sha256 so we can pin a parcel manifest revision."""
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def parcel_manifest_summary(parcel_manifest: Path) -> dict[str, Any]:
"""Extract checksum + budget metadata from the parcel manifest, if present.
Used by the Space ``/health`` endpoint so callers can confirm the running
Space is using the exact same manifest revision as the trainer.
"""
if not parcel_manifest.exists():
return {"present": False}
payload = json.loads(parcel_manifest.read_text(encoding="utf-8"))
candidates = payload.get("candidates")
return {
"present": True,
"path": str(parcel_manifest),
"sha256": _file_sha256(parcel_manifest),
"size_bytes": parcel_manifest.stat().st_size,
"selection_budget": payload.get("selection_budget"),
"max_candidates": payload.get("max_candidates"),
"candidate_count": len(candidates) if isinstance(candidates, list) else None,
}
def validate_data_root(root: Path) -> dict[str, Any]:
"""Validate a downloaded/exported HF data root and return a summary.
The summary is consumed by the Space's ``/brainrl/data_status`` and
``/health`` endpoints so a notebook caller can confirm the running Space
matches the data revision the trainer just pushed.
"""
root = root.expanduser()
config_dir = root / "configs"
missing = [name for name in REQUIRED_CONFIG_FILES if not (config_dir / name).exists()]
if missing:
raise FileNotFoundError(
f"BrainRL data root {root} is missing required config files: {missing}"
)
json_files = [config_dir / "region_priors.json", config_dir / "participant_run_info.json"]
parcel_manifest = config_dir / "parcel_candidates.json"
if parcel_manifest.exists():
json_files.append(parcel_manifest)
for json_path in json_files:
with json_path.open("r", encoding="utf-8") as handle:
json.load(handle)
annotation_dir = root / "annotation"
return {
"root": str(root),
"config_dir": str(config_dir),
"has_parcel_manifest": parcel_manifest.exists(),
"parcel_manifest": parcel_manifest_summary(parcel_manifest),
"annotation_csv_count": len(list(annotation_dir.glob("*.csv")))
if annotation_dir.exists()
else 0,
"data_repo": os.getenv("BRAINRL_DATA_REPO"),
"data_revision": os.getenv("BRAINRL_DATA_REVISION") or None,
}
def download_dataset_repo(
repo_id: str,
*,
output_dir: Path = DEFAULT_CACHE_DIR,
revision: str | None = None,
token: str | None = None,
) -> dict[str, Any]:
"""Download a HF Dataset repo into ``output_dir`` and validate it."""
try:
from huggingface_hub import snapshot_download
except ImportError as exc: # pragma: no cover
raise SystemExit(
"huggingface_hub is required for HF data sync. Install with "
"`pip install -e .[deploy]` or `pip install huggingface_hub`."
) from exc
output_dir = output_dir.expanduser()
output_dir.mkdir(parents=True, exist_ok=True)
snapshot_download(
repo_id=repo_id,
repo_type="dataset",
revision=revision,
local_dir=str(output_dir),
token=_resolve_token(token),
allow_patterns=["configs/**", "annotation/**", "metadata.json", "README.md"],
)
return validate_data_root(output_dir)
def upload_dataset_repo(
repo_id: str,
*,
bundle_dir: Path,
private: bool,
token: str | None = None,
commit_message: str = "Upload BrainRL config data",
) -> None:
"""Create/update the HF Dataset repo with a prepared data bundle."""
try:
from huggingface_hub import create_repo, upload_folder
except ImportError as exc: # pragma: no cover
raise SystemExit(
"huggingface_hub is required for HF data upload. Install with "
"`pip install -e .[deploy]` or `pip install huggingface_hub`."
) from exc
token = _resolve_token(token)
create_repo(
repo_id=repo_id,
repo_type="dataset",
private=private,
exist_ok=True,
token=token,
)
upload_folder(
folder_path=str(bundle_dir.expanduser()),
repo_id=repo_id,
repo_type="dataset",
token=token,
commit_message=commit_message,
)
def sync_data_from_env() -> dict[str, Any] | None:
"""Download HF data when ``BRAINRL_DATA_REPO`` is set.
The function also sets ``BRAINRL_CONFIG_DIR`` and ``BRAINRL_STIMULUS_DIR``
for downstream loaders if the downloaded files are present.
"""
repo_id = os.getenv("BRAINRL_DATA_REPO")
if not repo_id:
return None
revision = os.getenv("BRAINRL_DATA_REVISION") or None
output_dir = Path(os.getenv("BRAINRL_DATA_DIR", str(DEFAULT_CACHE_DIR))).expanduser()
summary = download_dataset_repo(repo_id, output_dir=output_dir, revision=revision)
os.environ.setdefault("BRAINRL_CONFIG_DIR", summary["config_dir"])
annotation_dir = output_dir / "annotation"
if annotation_dir.exists():
os.environ.setdefault("BRAINRL_STIMULUS_DIR", str(annotation_dir))
return summary
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Manage BrainRL HF Dataset artifacts")
sub = parser.add_subparsers(dest="command", required=True)
export = sub.add_parser("export", help="Export local configs/annotations to a bundle")
export.add_argument("--output-dir", type=str, default=str(DEFAULT_EXPORT_DIR))
export.add_argument("--config-dir", type=str, default=None)
export.add_argument("--annotation-dir", type=str, default=None)
export.add_argument("--no-annotations", action="store_true")
upload = sub.add_parser("upload", help="Export then upload to a HF Dataset repo")
upload.add_argument("--repo-id", type=str, required=True)
upload.add_argument("--bundle-dir", type=str, default=str(DEFAULT_EXPORT_DIR))
upload.add_argument("--config-dir", type=str, default=None)
upload.add_argument("--annotation-dir", type=str, default=None)
upload.add_argument("--no-annotations", action="store_true")
upload.add_argument("--public", action="store_true")
upload.add_argument("--token", type=str, default=None)
upload.add_argument("--commit-message", type=str, default="Upload BrainRL config data")
download = sub.add_parser("download", help="Download/validate a HF Dataset repo")
download.add_argument("--repo-id", type=str, required=True)
download.add_argument("--output-dir", type=str, default=str(DEFAULT_CACHE_DIR))
download.add_argument("--revision", type=str, default=None)
download.add_argument("--token", type=str, default=None)
validate = sub.add_parser("validate", help="Validate a local data root")
validate.add_argument("--data-root", type=str, required=True)
return parser
def main() -> None:
args = build_parser().parse_args()
if args.command == "export":
summary = export_data_bundle(
Path(args.output_dir),
config_dir=Path(args.config_dir) if args.config_dir else None,
annotation_dir=Path(args.annotation_dir) if args.annotation_dir else None,
include_annotations=not bool(args.no_annotations),
)
print(json.dumps(summary, indent=2))
return
if args.command == "upload":
bundle_dir = Path(args.bundle_dir)
export_data_bundle(
bundle_dir,
config_dir=Path(args.config_dir) if args.config_dir else None,
annotation_dir=Path(args.annotation_dir) if args.annotation_dir else None,
include_annotations=not bool(args.no_annotations),
)
upload_dataset_repo(
args.repo_id,
bundle_dir=bundle_dir,
private=not bool(args.public),
token=args.token,
commit_message=args.commit_message,
)
print(f"Uploaded BrainRL data bundle to dataset repo {args.repo_id}")
return
if args.command == "download":
summary = download_dataset_repo(
args.repo_id,
output_dir=Path(args.output_dir),
revision=args.revision,
token=args.token,
)
print(json.dumps(summary, indent=2))
return
if args.command == "validate":
print(json.dumps(validate_data_root(Path(args.data_root)), indent=2))
return
if __name__ == "__main__":
main()