500 lines
20 KiB
Python
500 lines
20 KiB
Python
"""Raw record loading and the FieldSpec-based default conversion.
|
|
|
|
Supported sources:
|
|
- local file: .jsonl / .json / .csv / .tsv / .parquet
|
|
- local dir: looks for ``{subset}_{split}.jsonl`` etc. (and csv/tsv/parquet)
|
|
- hub id: HuggingFace ``datasets`` (optional dependency, imported on demand)
|
|
- modelscope: ``params={'hub': 'modelscope'}`` — native ModelScope raw-file
|
|
HTTP download (no ``modelscope`` package needed, immune to its datasets
|
|
version pinning). File selection mirrors the local-dir convention and
|
|
understands HF-style shards (``test-00000-of-00001.parquet``).
|
|
"""
|
|
|
|
import csv
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import re
|
|
import shutil
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
|
|
from .sample import Sample
|
|
from .spec import DatasetSpec, FieldSpec
|
|
|
|
_SUPPORTED_EXTS = ['.jsonl', '.json', '.csv', '.tsv', '.parquet']
|
|
# params keys consumed by the loader itself; never forwarded to load_dataset()
|
|
_RESERVED_PARAMS = {'hub', 'ms_files', 'hf_files', 'filter_column'}
|
|
_MS_API = 'https://www.modelscope.cn/api/v1/datasets'
|
|
|
|
|
|
def get_cache_root() -> Path:
|
|
"""Canonical evalharness cache root ($EVALHARNESS_CACHE or ~/.cache/evalharness)."""
|
|
return Path(os.environ.get('EVALHARNESS_CACHE', '~/.cache/evalharness')).expanduser()
|
|
|
|
|
|
def set_cache_root(path) -> None:
|
|
"""Override the cache root at runtime (also moves the .raw blob store)."""
|
|
os.environ['EVALHARNESS_CACHE'] = str(path)
|
|
|
|
|
|
def load_raw_records(spec: DatasetSpec, raw_dir: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
"""Load raw records (list of dicts) from the spec's source.
|
|
|
|
When ``raw_dir`` is given, a copy of the *native* source data is placed
|
|
there (downloaded blob / copy of the local file / record dump for hub
|
|
sources) so every cache entry is self-contained: raw in, samples out.
|
|
"""
|
|
source = spec.source
|
|
if os.path.exists(source):
|
|
records, native_files = _load_local(source, spec)
|
|
if raw_dir is not None and native_files:
|
|
_link_or_copy_all(native_files, raw_dir)
|
|
elif spec.params.get('hub') == 'modelscope':
|
|
records = _load_from_modelscope(spec, raw_dir)
|
|
elif spec.params.get('hub') == 'hf_raw':
|
|
records = _load_from_hf_raw(spec, raw_dir)
|
|
else:
|
|
records = _load_from_hub(spec)
|
|
if raw_dir is not None:
|
|
# hub-native packaging lives in the HF cache; keep an exact,
|
|
# pre-conversion record dump so the entry is self-contained
|
|
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
with open(raw_dir / 'records.jsonl', 'w', encoding='utf-8') as f:
|
|
for r in records:
|
|
f.write(json.dumps(r, ensure_ascii=False, default=str) + '\n')
|
|
records = _apply_subset_filter(spec, records)
|
|
return records
|
|
|
|
|
|
def _load_local(path: str, spec: DatasetSpec) -> tuple:
|
|
"""Load from a local path; also return the native file(s) to preserve."""
|
|
if os.path.isfile(path):
|
|
return _read_file(path), [Path(path)]
|
|
# directory: follow the <subset>_<split>.<ext> / <subset>.<ext> convention
|
|
for ext in _SUPPORTED_EXTS:
|
|
for candidate in (f'{spec.subset}_{spec.split}{ext}', f'{spec.subset}{ext}', f'{spec.split}{ext}'):
|
|
full = os.path.join(path, candidate)
|
|
if os.path.exists(full):
|
|
return _read_file(full), [Path(full)]
|
|
expected = [os.path.join(path, f'{spec.subset}_{spec.split}{e}') for e in _SUPPORTED_EXTS]
|
|
available = sorted(f for f in os.listdir(path) if os.path.splitext(f)[1] in _SUPPORTED_EXTS)
|
|
raise FileNotFoundError(
|
|
f'no dataset file found for subset={spec.subset!r} split={spec.split!r} in {path!r}.\n'
|
|
f'Expected one of:\n - ' + '\n - '.join(expected) + '\n'
|
|
f'Available: {available or "(none)"}'
|
|
)
|
|
|
|
|
|
def _link_or_copy_all(files: List[Path], raw_dir: Path) -> None:
|
|
"""Place native files in raw_dir (hardlink when possible, else copy)."""
|
|
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
for src in files:
|
|
dst = raw_dir / src.name
|
|
if dst.exists() or src.parent.resolve() == raw_dir.resolve():
|
|
continue
|
|
try:
|
|
os.link(src, dst)
|
|
except OSError:
|
|
shutil.copy2(src, dst)
|
|
|
|
|
|
def _read_file(path: str) -> List[Dict[str, Any]]:
|
|
ext = os.path.splitext(path)[1]
|
|
if ext == '.parquet':
|
|
try:
|
|
import pyarrow.parquet as pq
|
|
except ImportError:
|
|
raise ImportError(
|
|
f'{path} is parquet; install the reader first: pip install "evalharness[all]"'
|
|
)
|
|
return pq.read_table(path).to_pylist()
|
|
with open(path, encoding='utf-8') as f:
|
|
if ext == '.jsonl':
|
|
return [json.loads(line) for line in f if line.strip()]
|
|
if ext == '.json':
|
|
data = json.load(f)
|
|
return data if isinstance(data, list) else [data]
|
|
if ext in ('.csv', '.tsv'):
|
|
return list(csv.DictReader(f, delimiter='\t' if ext == '.tsv' else ','))
|
|
raise ValueError(f'unsupported file format: {path}')
|
|
|
|
|
|
def _apply_subset_filter(spec: DatasetSpec, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""When ``params['filter_column']`` is set, subset selects that column's value.
|
|
|
|
Used when a mirror packs all subsets into one file with a category column
|
|
(e.g. evalscope/cmmlu: one parquet, ``category`` = subject).
|
|
"""
|
|
col = spec.params.get('filter_column')
|
|
if not col or spec.subset in ('default', 'all'):
|
|
return records
|
|
return [r for r in records if r.get(col) == spec.subset]
|
|
|
|
|
|
# ---------------- ModelScope raw-file loading (no modelscope package) ----------------
|
|
|
|
|
|
def _ms_api_json(url: str) -> Dict[str, Any]:
|
|
req = urllib.request.Request(url, headers={'User-Agent': 'evalharness/0.1'})
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
return json.loads(resp.read().decode('utf-8'))
|
|
|
|
|
|
def _ms_list_files(repo: str, root: str = '', depth: int = 0) -> List[str]:
|
|
"""Recursively list blob paths under a ModelScope dataset repo directory."""
|
|
url = f'{_MS_API}/{repo}/repo/tree?Revision=master' + (f'&Root={root}' if root else '')
|
|
try:
|
|
data = _ms_api_json(url).get('Data') or {}
|
|
except Exception:
|
|
return []
|
|
files: List[str] = []
|
|
for entry in data.get('Files') or []:
|
|
if entry.get('Type') == 'blob':
|
|
files.append(entry['Path'])
|
|
elif entry.get('Type') == 'tree' and depth < 4:
|
|
files.extend(_ms_list_files(repo, entry['Path'], depth + 1))
|
|
return files
|
|
|
|
|
|
def _ms_match_files(spec: DatasetSpec, files: List[str]) -> List[str]:
|
|
"""Pick repo files for this subset/split, mirroring the local-dir convention.
|
|
|
|
Priority: explicit ``params['ms_files']`` > exact name match > HF-style
|
|
shard match (``{split}-00000-of-00001`` / ``{subset}_{split}-...`` /
|
|
``{subset}_0``) > single-data-file repos. When the subset names a parent
|
|
directory, matches inside it win (e.g. livecodebench ``release_latest/``).
|
|
"""
|
|
explicit = spec.params.get('ms_files')
|
|
if explicit:
|
|
# paths support {subset}/{split} templating (e.g. tau2 domains)
|
|
return [p.format(subset=spec.subset, split=spec.split) for p in explicit]
|
|
|
|
def match_one(path: str) -> bool:
|
|
base = os.path.basename(path)
|
|
name = os.path.splitext(base)[0]
|
|
for ext in _SUPPORTED_EXTS:
|
|
if not base.endswith(ext):
|
|
continue
|
|
stem = base[: -len(ext)]
|
|
if stem in (f'{spec.subset}_{spec.split}', spec.subset, spec.split):
|
|
return True
|
|
if stem == f'{spec.subset}{spec.split}':
|
|
return True
|
|
shard = (
|
|
# tolerate a content-hash suffix after the shard pattern
|
|
# (e.g. test-00000-of-00001-6282153cc50a2626.parquet)
|
|
rf'{re.escape(spec.split)}-\d+-of-\d+(-[0-9a-f]+)?$'
|
|
rf'|{re.escape(spec.subset)}_{re.escape(spec.split)}[-_].*'
|
|
rf'|{re.escape(spec.subset)}[-_]\d+$'
|
|
)
|
|
if re.fullmatch(shard, stem, flags=re.ASCII):
|
|
return True
|
|
return False
|
|
|
|
candidates = [f for f in files if match_one(f)]
|
|
if not candidates:
|
|
data_files = [f for f in files if os.path.splitext(f)[1] in _SUPPORTED_EXTS]
|
|
if len(data_files) == 1:
|
|
return data_files
|
|
return []
|
|
in_subset_dir = [f for f in candidates if os.path.dirname(f) == spec.subset]
|
|
return sorted(in_subset_dir or candidates)
|
|
|
|
|
|
def _parquet_ok(path: Path) -> bool:
|
|
"""Cheap integrity check: parquet files end with magic 'PAR1'."""
|
|
try:
|
|
with open(path, 'rb') as f:
|
|
f.seek(-4, 2)
|
|
return f.read(4) == b'PAR1'
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
|
|
def _download_with_progress(resp, out, filename: str) -> None:
|
|
"""Stream a download with a rich byte-level progress bar.
|
|
|
|
Used for the minute-scale dataset fetches (ModelScope blobs, HF raw
|
|
files); silent (plain streaming) when rich is unavailable or output
|
|
is redirected."""
|
|
total = 0
|
|
try:
|
|
total = int(resp.headers.get('Content-Length') or 0)
|
|
except (TypeError, ValueError):
|
|
total = 0
|
|
if not sys.stdout.isatty():
|
|
while True:
|
|
chunk = resp.read(1 << 20)
|
|
if not chunk:
|
|
break
|
|
out.write(chunk)
|
|
return
|
|
try:
|
|
from rich.progress import (BarColumn, DownloadColumn, Progress,
|
|
SpinnerColumn, TextColumn,
|
|
TimeElapsedColumn, TransferSpeedColumn)
|
|
|
|
pg = Progress(SpinnerColumn(),
|
|
TextColumn('[cyan]{task.fields[name]}[/cyan]'),
|
|
BarColumn(), DownloadColumn(), TransferSpeedColumn(),
|
|
TextColumn('•'), TimeElapsedColumn(),
|
|
transient=True)
|
|
with pg:
|
|
t = pg.add_task('download', total=total or None, name=filename)
|
|
while True:
|
|
chunk = resp.read(1 << 20)
|
|
if not chunk:
|
|
break
|
|
out.write(chunk)
|
|
pg.advance(t, len(chunk))
|
|
return
|
|
except ImportError:
|
|
pass
|
|
while True:
|
|
chunk = resp.read(1 << 20)
|
|
if not chunk:
|
|
break
|
|
out.write(chunk)
|
|
|
|
|
|
def _ms_download(repo: str, path: str, dest_dir: Path) -> Path:
|
|
"""Download one repo file into the raw cache (content-addressed, reused)."""
|
|
dest = dest_dir / os.path.basename(path)
|
|
if dest.exists() and dest.stat().st_size > 0 \
|
|
and (os.path.splitext(dest)[1] != '.parquet' or _parquet_ok(dest)):
|
|
return dest
|
|
if dest.exists(): # truncated/corrupt (e.g. an interrupted download)
|
|
dest.unlink()
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
url = f'{_MS_API}/{repo}/repo?Revision=master&FilePath={path}'
|
|
tmp = dest.with_name(dest.name + f'.part-{os.getpid()}')
|
|
req = urllib.request.Request(url, headers={'User-Agent': 'evalharness/0.1'})
|
|
with urllib.request.urlopen(req, timeout=600) as resp, open(tmp, 'wb') as out:
|
|
_download_with_progress(resp, out, dest.name)
|
|
os.replace(tmp, dest)
|
|
return dest
|
|
|
|
|
|
def _load_from_modelscope(spec: DatasetSpec, raw_dir: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
import hashlib
|
|
|
|
files = _ms_list_files(spec.source)
|
|
if not files:
|
|
raise FileNotFoundError(f'no files found on ModelScope dataset {spec.source!r}')
|
|
selected = _ms_match_files(spec, files)
|
|
if not selected:
|
|
raise FileNotFoundError(
|
|
f'no data file for subset={spec.subset!r} split={spec.split!r} in '
|
|
f'modelscope {spec.source!r}. Available (first 10): {files[:10]}'
|
|
)
|
|
# shared blob store: download once per repo, hardlink into each cache entry
|
|
blob_dir = get_cache_root() / '.raw' / hashlib.md5(spec.source.encode()).hexdigest()[:10]
|
|
records: List[Dict[str, Any]] = []
|
|
blobs: List[Path] = []
|
|
for path in selected:
|
|
blobs.append(_ms_download(spec.source, path, blob_dir))
|
|
records.extend(_read_file(str(blobs[-1])))
|
|
if raw_dir is not None:
|
|
_link_or_copy_all(blobs, raw_dir)
|
|
return records
|
|
|
|
|
|
# ---------------- HuggingFace raw-file loading (no auth, exact bytes) ----------------
|
|
# For repos whose layout datasets.load_dataset cannot express (raw json trees,
|
|
# shard dirs without configs) or that only exist as plain files. Honors
|
|
# $HF_ENDPOINT (e.g. https://hf-mirror.com).
|
|
|
|
|
|
def _hf_base() -> str:
|
|
return os.environ.get('HF_ENDPOINT', 'https://huggingface.co').rstrip('/')
|
|
|
|
|
|
def _hf_list_files(repo: str, root: str = '', depth: int = 0) -> List[str]:
|
|
"""Recursively list file paths under an HF dataset repo directory."""
|
|
url = f'{_hf_base()}/api/datasets/{repo}/tree/main' + (f'/{root}' if root else '') + '?limit=1000'
|
|
req = urllib.request.Request(url, headers={'User-Agent': 'evalharness/0.1'})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
entries = json.loads(resp.read().decode('utf-8'))
|
|
except Exception:
|
|
return []
|
|
files: List[str] = []
|
|
for entry in entries:
|
|
if entry.get('type') == 'file':
|
|
files.append(entry['path'])
|
|
elif entry.get('type') == 'directory' and depth < 4:
|
|
files.extend(_hf_list_files(repo, entry['path'], depth + 1))
|
|
return files
|
|
|
|
|
|
def _hf_match_files(spec: DatasetSpec, files: List[str]) -> List[str]:
|
|
"""Pick repo files for this subset/split (see module docstring conventions)."""
|
|
explicit = spec.params.get('hf_files')
|
|
if explicit:
|
|
return [p.format(subset=spec.subset, split=spec.split) for p in explicit]
|
|
|
|
def ext_ok(p: str) -> bool:
|
|
return os.path.splitext(p)[1] in _SUPPORTED_EXTS
|
|
|
|
data = [f for f in files if ext_ok(f)]
|
|
# multi-window subset, e.g. 'release_v2_v4' = union of release_v2..v4
|
|
# shards (LCB release-window convention: per-release files are disjoint)
|
|
m = re.fullmatch(r'(release_v\d+)((?:_v\d+)+)', spec.subset)
|
|
if m:
|
|
members = [m.group(1)] + [f'release{x}' for x in m.group(2).split('_') if x]
|
|
picked = []
|
|
for mem in members:
|
|
picked += [
|
|
f for f in data
|
|
if re.fullmatch(rf'{re.escape(mem)}[-_]\d+(-of-\d+)?'
|
|
rf'|{re.escape(mem)}_{re.escape(spec.split)}[-_].*'
|
|
rf'|{re.escape(mem)}',
|
|
os.path.splitext(os.path.basename(f))[0])
|
|
]
|
|
if picked:
|
|
# deterministic: member order, shard order within each member
|
|
return [f for mem in members for f in sorted(
|
|
p for p in picked
|
|
if os.path.splitext(os.path.basename(p))[0].startswith(mem))]
|
|
if spec.subset != 'default':
|
|
# subset wins exclusively: never ALSO match bare-split shards in the
|
|
# same dir (repos like sam-paech LCB mix test-*.parquet and
|
|
# release_v*-*.parquet in one data/ folder)
|
|
in_dir = [f for f in data if os.path.dirname(f) == spec.subset]
|
|
if in_dir:
|
|
return sorted(in_dir)
|
|
exact = []
|
|
for f in data:
|
|
stem = os.path.splitext(os.path.basename(f))[0]
|
|
if stem in (f'{spec.subset}_{spec.split}', spec.subset):
|
|
exact.append(f)
|
|
if exact:
|
|
return sorted(exact)
|
|
shards = [
|
|
f for f in data
|
|
if re.fullmatch(rf'{re.escape(spec.subset)}[-_]\d+(-of-\d+)?'
|
|
rf'|{re.escape(spec.subset)}_{re.escape(spec.split)}[-_].*',
|
|
os.path.splitext(os.path.basename(f))[0])
|
|
]
|
|
if shards:
|
|
return sorted(shards)
|
|
else:
|
|
exact = [
|
|
f for f in data
|
|
if os.path.splitext(os.path.basename(f))[0] == spec.split
|
|
]
|
|
if exact:
|
|
return sorted(exact)
|
|
shards = [
|
|
f for f in data
|
|
if re.fullmatch(rf'{re.escape(spec.split)}[-_]\d+(-of-\d+)?',
|
|
os.path.splitext(os.path.basename(f))[0])
|
|
]
|
|
if shards:
|
|
return sorted(shards)
|
|
if len(data) == 1:
|
|
return data
|
|
return []
|
|
|
|
|
|
def _hf_download(repo: str, path: str, dest_dir: Path) -> Path:
|
|
"""Download one repo file (follows the CDN redirect) into the blob store."""
|
|
dest = dest_dir / os.path.basename(path)
|
|
if dest.exists() and dest.stat().st_size > 0 \
|
|
and (os.path.splitext(dest)[1] != '.parquet' or _parquet_ok(dest)):
|
|
return dest
|
|
if dest.exists(): # truncated/corrupt (e.g. an interrupted download)
|
|
dest.unlink()
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
url = f'{_hf_base()}/datasets/{repo}/resolve/main/{path}'
|
|
tmp = dest.with_name(dest.name + f'.part-{os.getpid()}')
|
|
req = urllib.request.Request(url, headers={'User-Agent': 'evalharness/0.1'})
|
|
with urllib.request.urlopen(req, timeout=1800) as resp, open(tmp, 'wb') as out:
|
|
_download_with_progress(resp, out, dest.name)
|
|
os.replace(tmp, dest)
|
|
return dest
|
|
|
|
|
|
def _load_from_hf_raw(spec: DatasetSpec, raw_dir: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
import hashlib
|
|
|
|
files = _hf_list_files(spec.source)
|
|
if not files:
|
|
raise FileNotFoundError(f'no files found on HF dataset {spec.source!r}')
|
|
selected = _hf_match_files(spec, files)
|
|
if not selected:
|
|
raise FileNotFoundError(
|
|
f'no data file for subset={spec.subset!r} split={spec.split!r} in '
|
|
f'HF {spec.source!r}. Available (first 10): {files[:10]}'
|
|
)
|
|
blob_dir = get_cache_root() / '.raw' / hashlib.md5(spec.source.encode()).hexdigest()[:10]
|
|
records: List[Dict[str, Any]] = []
|
|
blobs: List[Path] = []
|
|
for path in selected:
|
|
blobs.append(_hf_download(spec.source, path, blob_dir))
|
|
records.extend(_read_file(str(blobs[-1])))
|
|
if raw_dir is not None:
|
|
_link_or_copy_all(blobs, raw_dir)
|
|
return records
|
|
|
|
|
|
def _load_from_hub(spec: DatasetSpec) -> List[Dict[str, Any]]:
|
|
try:
|
|
import datasets
|
|
except ImportError:
|
|
raise ImportError(
|
|
f'dataset {spec.name!r} lives on a hub ({spec.source!r}); '
|
|
'install it first: pip install evalharness (light deps are default)'
|
|
)
|
|
_probe_hub_reachable(spec)
|
|
kwargs = {k: v for k, v in spec.params.items() if k not in _RESERVED_PARAMS}
|
|
# filter_column subsets select rows by column value post-load, so the hub
|
|
# load itself must always use the default config.
|
|
subset = None if (spec.subset == 'default' or spec.params.get('filter_column')) else spec.subset
|
|
print(f'· downloading {spec.source} from HF hub ({_hf_base()}) ...', flush=True)
|
|
t0 = time.monotonic()
|
|
ds = datasets.load_dataset(spec.source, subset, split=spec.split, revision=spec.version, **kwargs)
|
|
print(f'· downloaded + parsed {len(ds)} records in {time.monotonic() - t0:.0f}s', flush=True)
|
|
return [dict(r) for r in ds]
|
|
|
|
|
|
def _probe_hub_reachable(spec: DatasetSpec) -> None:
|
|
"""Fail fast when the HF endpoint is unreachable -- otherwise the hub
|
|
client retries silently for minutes and looks like a hang."""
|
|
base = _hf_base()
|
|
try:
|
|
req = urllib.request.Request(base, method='HEAD',
|
|
headers={'User-Agent': 'evalharness/0.1'})
|
|
urllib.request.urlopen(req, timeout=8)
|
|
return
|
|
except Exception as e:
|
|
raise RuntimeError(
|
|
f'HuggingFace endpoint unreachable: {base}\n'
|
|
f' reason: {type(e).__name__}: {str(e)[:80]}\n'
|
|
f' dataset {spec.source!r} cannot download. Fix:\n'
|
|
f' evalharness eval run ... --hf-endpoint https://hf-mirror.com\n'
|
|
f' (or export HF_ENDPOINT=https://hf-mirror.com)') from e
|
|
|
|
|
|
def field_spec_to_record_fn(fields: FieldSpec) -> Callable[[Dict[str, Any]], Sample]:
|
|
"""Build the default record->Sample converter from a FieldSpec."""
|
|
|
|
def convert(record: Dict[str, Any]) -> Sample:
|
|
target = record.get(fields.target, '')
|
|
if isinstance(target, (int, float)):
|
|
target = str(target)
|
|
metadata = {k: record.get(k) for k in fields.metadata}
|
|
return Sample(
|
|
input=record.get(fields.input, ''),
|
|
choices=record.get(fields.choices) if fields.choices in record else None,
|
|
target=target,
|
|
id=record.get(fields.id) if fields.id else None,
|
|
metadata=metadata,
|
|
)
|
|
|
|
return convert
|