Files
wehub-resource-sync 593b94c120
pytest / Unit Tests (push) Has been cancelled
pytest / Integration (integration_tests_a) (push) Has been cancelled
pytest / Integration (integration_tests_b) (push) Has been cancelled
pytest / Integration (integration_tests_c) (push) Has been cancelled
pytest / Integration (integration_tests_d) (push) Has been cancelled
pytest / Integration (integration_tests_e) (push) Has been cancelled
pytest / Integration (integration_tests_f) (push) Has been cancelled
pytest / Integration (integration_tests_g) (push) Has been cancelled
pytest / Integration (integration_tests_h) (push) Has been cancelled
pytest / Integration (integration_tests_i) (push) Has been cancelled
pytest / Integration (integration_tests_j) (push) Has been cancelled
pytest / Distributed (distributed_a) (push) Has been cancelled
pytest / Distributed (distributed_b) (push) Has been cancelled
pytest / Distributed (distributed_c) (push) Has been cancelled
pytest / Distributed (distributed_d) (push) Has been cancelled
pytest / Distributed (distributed_e) (push) Has been cancelled
pytest / Distributed (distributed_f) (push) Has been cancelled
pytest / Minimal Install (push) Has been cancelled
pytest / Event File (push) Has been cancelled
pytest (slow) / py-slow (push) Has been cancelled
Publish JSON Schema / publish-schema (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:49:20 +08:00

611 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""1-epoch Ludwig smoke test for every dataset config.
Streams 1000 rows directly from HF (no full download), builds a minimal
Ludwig config from the YAML, runs 1 epoch, then wipes the HF cache entry
before moving to the next dataset.
Usage:
python scripts/dataset_smoke_test.py [--names ds1 ds2 ...] [--resume]
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import shutil
import tempfile
import traceback
from collections import Counter
from typing import Any
import datasets as hf_datasets
import pandas as pd
import yaml
logging.basicConfig(level=logging.WARNING)
os.environ.setdefault("LUDWIG_DISABLE_PROGRESS_BAR", "1")
CONFIGS_DIR = os.path.join(os.path.dirname(__file__), "..", "ludwig", "datasets", "configs")
RESULTS_PATH = os.path.join(os.path.dirname(__file__), "smoke_results.json")
HF_HUB_CACHE = os.path.expanduser("~/.cache/huggingface/hub")
HF_DS_CACHE = os.path.expanduser("~/.cache/huggingface/datasets")
SAMPLE_ROWS = 1000
MIN_ROWS = 32
# Skip: gated/no supervised task/already covered
SKIP = {"imagenet1k", "gigaspeech", "hugging_face"}
ENCODER_OVERRIDES: dict[str, dict] = {
"text": {"encoder": {"type": "embed", "embedding_size": 16, "trainable": True}},
"image": {"encoder": {"type": "stacked_cnn", "num_conv_layers": 1, "num_filters": 8, "output_size": 16}},
"audio": {"encoder": {"type": "stacked_cnn", "num_conv_layers": 1, "num_filters": 8, "output_size": 16}},
"sequence": {"encoder": {"type": "embed", "embedding_size": 16}},
"set": {"encoder": {"type": "embed", "embedding_size": 16}},
}
def load_dataset_config(name: str) -> dict:
path = os.path.join(CONFIGS_DIR, f"{name}.yaml")
with open(path) as f:
return yaml.safe_load(f)
_MAX_AUDIO_ARRAY_SAMPLES = 2 * 16000 # 2 s at 16 kHz; scaled for other rates
def _truncate_audio_in_row(row: dict) -> dict:
"""Truncate decoded audio arrays in a streaming row to bound per-row RAM use.
HF datasets decode audio columns to {'array': ndarray, 'sampling_rate': int}
before we can filter. Long clips (e.g. multi-minute Quran recitations) can be
100+ MB each; 1000 such rows exhaust RAM before any other processing occurs.
We truncate to 2 s worth of samples right here in the streaming loop.
"""
truncated = {}
for key, val in row.items():
if isinstance(val, dict) and "array" in val and "sampling_rate" in val:
sr = int(val.get("sampling_rate") or 16000)
max_samp = int(_MAX_AUDIO_ARRAY_SAMPLES * sr / 16000)
arr = val["array"]
if hasattr(arr, "__len__") and len(arr) > max_samp:
val = {**val, "array": arr[:max_samp]}
truncated[key] = val
return truncated
def stream_sample(
hf_id: str,
hf_sub: str | None,
n: int = SAMPLE_ROWS,
shuffle_buffer: int = 100000,
skip: int = 0,
revision: str | None = None,
data_files: dict | None = None,
truncate_audio: bool = False,
select_columns: list[str] | None = None,
) -> pd.DataFrame | None:
"""Stream up to n rows from HF without downloading the full dataset.
select_columns: if provided, drop all other columns from each row before
building the DataFrame, keeping memory bounded for datasets with large
unneeded columns (e.g. image bytes in a tabular dataset).
"""
try:
kwargs: dict = {
"path": hf_id,
"name": hf_sub,
"trust_remote_code": False,
"streaming": True,
}
if revision:
kwargs["revision"] = revision
if data_files:
kwargs["data_files"] = data_files
ds_stream = hf_datasets.load_dataset(**kwargs)
split_name = "train" if "train" in ds_stream else list(ds_stream.keys())[0]
ds = ds_stream[split_name]
if skip:
ds = ds.skip(skip)
# Shuffle to ensure label diversity in sorted datasets (e.g. dbpedia, imdb).
# Use a smaller buffer for media datasets to avoid streaming 100k large files.
ds = ds.shuffle(seed=42, buffer_size=min(n * 100, shuffle_buffer))
_keep = set(select_columns) if select_columns else None
if truncate_audio:
rows = [_truncate_audio_in_row(r) for r in ds.take(n)]
else:
rows = list(ds.take(n))
if _keep:
rows = [{k: v for k, v in row.items() if k in _keep} for row in rows]
if len(rows) < MIN_ROWS:
return None
return pd.DataFrame(rows)
except Exception as e:
raise RuntimeError(f"Stream failed: {e}") from e
def wipe_hf_cache_for(hf_id: str, hf_sub: str | None) -> None:
"""Delete hub and dataset cache entries for a specific dataset."""
# Hub cache dirs are named datasets--org--repo
normalized = hf_id.replace("/", "--")
for cache_root in [HF_HUB_CACHE, HF_DS_CACHE]:
if not os.path.isdir(cache_root):
continue
for entry in os.listdir(cache_root):
if normalized in entry or (hf_sub and hf_sub in entry):
path = os.path.join(cache_root, entry)
try:
shutil.rmtree(path)
except Exception:
pass
def build_ludwig_config(cfg: dict) -> dict:
out_names = {f["name"] for f in cfg.get("output_features", [])}
input_features = []
for col in cfg.get("columns", []):
if col["name"] in out_names:
continue
feat = {"name": col["name"], "type": col["type"]}
feat.update(ENCODER_OVERRIDES.get(col["type"], {}))
input_features.append(feat)
output_features = [{"name": f["name"], "type": f["type"]} for f in cfg.get("output_features", [])]
return {
"input_features": input_features,
"output_features": output_features,
"combiner": {"type": "concat", "fc_size": 32},
"trainer": {"epochs": 1, "batch_size": 32, "learning_rate": 0.001, "eval_batch_size": 32},
"preprocessing": {"split": {"type": "random", "probabilities": [0.7, 0.1, 0.2]}},
}
def apply_custom_loader(name: str, df: pd.DataFrame) -> pd.DataFrame:
"""Run the custom loader's _transform on the raw dataframe."""
cfg = load_dataset_config(name)
loader_spec = cfg.get("loader", "")
if not loader_spec or loader_spec == "hugging_face.HFLoader":
return df
module_name, cls_name = loader_spec.rsplit(".", 1)
full_module = f"ludwig.datasets.loaders.{module_name}"
try:
import importlib
mod = importlib.import_module(full_module)
cls = getattr(mod, cls_name)
# processed_dataset_dir is a read-only @property — override via subclass
_tmpdir = tempfile.mkdtemp(prefix=f"ludwig_smoke_{name}_")
PatchedCls = type(
f"_{cls_name}",
(cls,),
{
"processed_dataset_dir": property(lambda self, d=_tmpdir: d),
},
)
instance = object.__new__(PatchedCls)
return instance._transform(df)
except Exception as e:
raise RuntimeError(f"Loader {loader_spec} failed: {e}") from e
def _materialize_media_columns(df: pd.DataFrame, tmpdir: str, col_types: dict[str, str] | None = None) -> pd.DataFrame:
"""Replace PIL images and HF audio objects with file paths.
col_types maps column name → declared type ('audio' or 'image') so that
raw-bytes audio dicts {'bytes': ..., 'path': ...} are not misidentified as images.
"""
import io
try:
from PIL import Image as PILImage
_pil_available = True
except ImportError:
_pil_available = False
df = df.copy()
for col in df.columns:
sample = df[col].dropna().iloc[:1]
if sample.empty:
continue
val = sample.iloc[0]
declared = (col_types or {}).get(col) # 'audio', 'image', or None
# HF audio dict {'array': ..., 'sampling_rate': ...} or TorchCodec object
# or raw bytes dict {'bytes': ..., 'path': ...} for a declared audio column
_is_raw_bytes_dict = isinstance(val, dict) and ("bytes" in val or "path" in val) and "array" not in val
is_audio = (
(isinstance(val, dict) and "array" in val and "sampling_rate" in val)
or (hasattr(val, "__class__") and "AudioDecoder" in type(val).__name__)
or (declared == "audio" and _is_raw_bytes_dict)
)
# PIL image or HF image dict — but NOT if the column is declared as audio
is_image = (
(not is_audio)
and _pil_available
and (isinstance(val, PILImage.Image) or (_is_raw_bytes_dict and declared != "audio"))
)
if is_image:
img_dir = os.path.join(tmpdir, col)
os.makedirs(img_dir, exist_ok=True)
MAX_IMG_SIZE = (128, 128)
paths = []
for idx, v in df[col].items():
path = os.path.join(img_dir, f"{idx}.jpg")
try:
if isinstance(v, PILImage.Image):
img = v.convert("RGB")
elif isinstance(v, dict) and "bytes" in v and v["bytes"]:
img = PILImage.open(io.BytesIO(v["bytes"])).convert("RGB")
elif isinstance(v, dict) and "path" in v and v["path"]:
img = PILImage.open(v["path"]).convert("RGB")
else:
path = ""
paths.append(path)
continue
# Cap resolution to avoid OOM during Ludwig preprocessing
img.thumbnail(MAX_IMG_SIZE, PILImage.LANCZOS)
img.save(path, format="JPEG")
except Exception:
path = ""
paths.append(path)
df[col] = paths
elif is_audio:
import numpy as np
try:
import soundfile as sf
_sf_available = True
except ImportError:
_sf_available = False
aud_dir = os.path.join(tmpdir, col)
os.makedirs(aud_dir, exist_ok=True)
MAX_AUDIO_SAMPLES = 5 * 16000 # 5 seconds at 16 kHz
paths = []
for idx, v in df[col].items():
path = os.path.join(aud_dir, f"{idx}.wav")
try:
if isinstance(v, dict) and "array" in v:
arr = np.array(v["array"], dtype=np.float32)
sr = int(v.get("sampling_rate", 16000))
max_samples = int(MAX_AUDIO_SAMPLES * sr / 16000)
arr = arr[:max_samples]
if _sf_available:
sf.write(path, arr, sr)
else:
from scipy.io import wavfile
wavfile.write(path, sr, (arr * 32767).astype(np.int16))
elif isinstance(v, dict) and "bytes" in v and v["bytes"]:
# Raw audio bytes {'bytes': b'RIFF...', 'path': '...'} — read with soundfile
import io as _io
bio = _io.BytesIO(v["bytes"])
if _sf_available:
arr, sr = sf.read(bio, dtype="float32", always_2d=False)
else:
from scipy.io import wavfile
sr, arr = wavfile.read(bio)
arr = arr.astype(np.float32) / 32768.0
if arr.ndim > 1:
arr = arr[:, 0] # first channel
max_samples = int(MAX_AUDIO_SAMPLES * sr / 16000)
arr = arr[:max_samples]
if _sf_available:
sf.write(path, arr, sr)
else:
from scipy.io import wavfile
wavfile.write(path, sr, (arr * 32767).astype(np.int16))
elif hasattr(v, "get_all_samples"):
samples = v.get_all_samples()
arr = samples.data.numpy()
sr = int(samples.sample_rate)
if arr.ndim > 1:
arr = arr[0] # first channel
arr = arr.astype(np.float32)
max_samples = int(MAX_AUDIO_SAMPLES * sr / 16000)
arr = arr[:max_samples]
if _sf_available:
sf.write(path, arr, sr)
else:
from scipy.io import wavfile
wavfile.write(path, sr, (arr * 32767).astype(np.int16))
else:
path = ""
except Exception:
path = ""
paths.append(path)
df[col] = paths
return df
def run_smoke_test(name: str) -> dict[str, Any]:
result: dict[str, Any] = {"name": name, "status": "unknown", "error": None, "rows": 0}
if name in SKIP:
result["status"] = "skipped"
return result
try:
cfg = load_dataset_config(name)
except Exception as e:
result["status"] = "error"
result["error"] = f"YAML load failed: {e}"
return result
hf_id = cfg.get("huggingface_dataset_id")
hf_sub = cfg.get("huggingface_subsample")
hf_revision = cfg.get("huggingface_revision")
hf_data_files = cfg.get("huggingface_data_files")
if not hf_id:
result["status"] = "skipped"
result["error"] = "Not an HF dataset (pre-existing Ludwig dataset)"
return result
# 1. Stream 1000 rows — use small shuffle buffer for media datasets to avoid
# streaming 100k large files; text datasets can afford the large buffer
# to ensure label diversity in sorted datasets (e.g. dbpedia_14, imdb).
# Only category/binary outputs need large buffers (text/number outputs have no
# "at least 2 distinct values" requirement). Media datasets use small buffers
# to avoid streaming hundreds of thousands of large files into RAM.
loader_spec = cfg.get("loader", "")
has_custom_loader = bool(loader_spec and loader_spec != "hugging_face.HFLoader")
col_types = {col["type"] for col in cfg.get("columns", [])}
has_media = bool(col_types & {"audio", "image"})
# Only stream the columns we actually need — avoids loading large unneeded columns
# (e.g. image bytes in a dataset where we only use tabular metadata columns).
# Custom loaders may read different column names than declared (e.g. TranslationLoader
# reads 'translation' and splits it into 'de'/'en'), so skip column filtering for them.
cfg_col_names = [col["name"] for col in cfg.get("columns", [])]
if has_media:
shuffle_buf = 5000
else:
# Start small for all non-media datasets to avoid streaming huge amounts
# of data. We'll escalate to 100k only if the diversity check fails below.
shuffle_buf = 2000
try:
df = stream_sample(
hf_id,
hf_sub,
SAMPLE_ROWS,
shuffle_buffer=shuffle_buf,
revision=hf_revision,
data_files=hf_data_files,
truncate_audio=has_media,
select_columns=cfg_col_names if (cfg_col_names and not has_custom_loader) else None,
)
except Exception as e:
result["status"] = "error"
result["error"] = str(e)[:200]
return result
if df is None:
result["status"] = "error"
result["error"] = f"Too few rows (< {MIN_ROWS})"
return result
result["rows"] = len(df)
# 2. Apply custom loader transform if needed
try:
df = apply_custom_loader(name, df)
except Exception as e:
result["status"] = "error"
result["error"] = f"Custom loader failed: {e}"
return result
# 3. Materialize any PIL images/audio to disk before passing to Ludwig.
# Images are capped at 256×256 and audio at 5 s to avoid OOM during
# Ludwig's upfront preprocessing of the full sample.
_img_tmp = tempfile.mkdtemp(prefix=f"ludwig_imgs_{name}_")
try:
col_type_map = {col["name"]: col["type"] for col in cfg.get("columns", [])}
try:
df = _materialize_media_columns(df, _img_tmp, col_types=col_type_map)
except ImportError:
pass # PIL not available — skip
# 4. Keep only columns declared in the config; drop extras.
# Also drop rows where media columns failed to materialize (empty path).
cfg_cols = {f["name"] for f in cfg.get("columns", [])}
df = df[[c for c in df.columns if c in cfg_cols or c == "split"]]
media_col_types = {f["name"]: f["type"] for f in cfg.get("columns", []) if f["type"] in ("audio", "image")}
for mc in media_col_types:
if mc in df.columns:
# Keep only rows where the column is a non-empty string path
# (materialization replaces dicts with paths; dicts/None/empty → drop row)
df = df[df[mc].apply(lambda x: isinstance(x, str) and x.strip() != "")]
df = df.reset_index(drop=True)
# 5. Verify output columns exist and have values; retry with skip for sorted datasets
out_names = [f["name"] for f in cfg.get("output_features", [])]
out_types = {f["name"]: f["type"] for f in cfg.get("output_features", [])}
for oc in out_names:
if oc not in df.columns:
result["status"] = "error"
result["error"] = f"Output column '{oc}' missing after transform. Cols: {list(df.columns)}"
return result
if df[oc].isna().all():
result["status"] = "error"
result["error"] = f"Output column '{oc}' is all-null"
return result
# For category/binary outputs: if only 1 distinct non-null value, the dataset
# is likely sorted. Retry by skipping 40k rows to sample from a different region.
try:
_nunique = df[oc].dropna().nunique()
except TypeError:
_nunique = 2 # unhashable (list labels) — skip diversity retry
if out_types.get(oc) in ("category", "binary") and _nunique < 2 and not has_media:
# Try a larger shuffle buffer first (catches balanced datasets that appeared
# uniform at buffer=2000 due to unlucky sampling, before paying the cost
# of a full skip-to-40k retry).
try:
df_retry = stream_sample(
hf_id,
hf_sub,
SAMPLE_ROWS,
shuffle_buffer=50000,
revision=hf_revision,
data_files=hf_data_files,
truncate_audio=False,
select_columns=cfg_col_names if (cfg_col_names and not has_custom_loader) else None,
)
if df_retry is not None:
df_retry = apply_custom_loader(name, df_retry)
df_retry = df_retry[[c for c in df_retry.columns if c in cfg_cols or c == "split"]]
_r2 = df_retry[oc].dropna().nunique() if oc in df_retry.columns else 0
if _r2 >= 2:
df = df_retry
_nunique = _r2
except Exception:
pass
if out_types.get(oc) in ("category", "binary") and _nunique < 2:
try:
# Use buffer=1 for skip-sampled data to avoid OOM on large image datasets
df2 = stream_sample(
hf_id,
hf_sub,
SAMPLE_ROWS // 2,
shuffle_buffer=1,
skip=40000,
revision=hf_revision,
data_files=hf_data_files,
truncate_audio=has_media,
select_columns=cfg_col_names if (cfg_col_names and not has_custom_loader) else None,
)
if df2 is not None:
df2 = apply_custom_loader(name, df2)
df2 = _materialize_media_columns(df2, _img_tmp, col_types=col_type_map)
df2 = df2[[c for c in df2.columns if c in cfg_cols or c == "split"]]
for mc in media_col_types:
if mc in df2.columns:
df2 = df2[df2[mc].apply(lambda x: isinstance(x, str) and x.strip() != "")]
df = pd.concat([df[: SAMPLE_ROWS // 2], df2], ignore_index=True)
except Exception:
pass # keep original df
# 6. Build Ludwig config and train
ludwig_cfg = build_ludwig_config(cfg)
if not ludwig_cfg["input_features"]:
result["status"] = "error"
result["error"] = "No input features"
return result
try:
from ludwig.api import LudwigModel
with tempfile.TemporaryDirectory() as tmpdir:
model = LudwigModel(ludwig_cfg, logging_level=logging.ERROR)
model.train(
dataset=df,
output_directory=tmpdir,
skip_save_training_description=True,
skip_save_training_statistics=True,
skip_save_model=True,
skip_save_progress=True,
skip_save_log=True,
skip_save_processed_input=True,
)
result["status"] = "pass"
except Exception as e:
result["status"] = "fail"
result["error"] = f"{type(e).__name__}: {str(e)[:200]}"
result["traceback"] = traceback.format_exc()[-600:]
finally:
shutil.rmtree(_img_tmp, ignore_errors=True)
return result
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--names", nargs="*", default=None)
parser.add_argument("--resume", action="store_true")
args = parser.parse_args()
all_names = sorted(
f.replace(".yaml", "") for f in os.listdir(CONFIGS_DIR) if f.endswith(".yaml") and not f.startswith("__")
)
names = [n for n in args.names if n in set(all_names)] if args.names else all_names
existing: dict[str, dict] = {}
if os.path.exists(RESULTS_PATH):
try:
for r in json.load(open(RESULTS_PATH)):
existing[r["name"]] = r
except Exception:
pass
results: list[dict] = []
total = len(names)
for i, name in enumerate(names):
if args.resume and name in existing and existing[name]["status"] == "pass":
results.append(existing[name])
print(f"[{i + 1}/{total}] SKIP {name} (already passed)")
continue
cfg = {}
try:
cfg = load_dataset_config(name)
except Exception:
pass
hf_id = cfg.get("huggingface_dataset_id", "")
hf_sub = cfg.get("huggingface_subsample")
print(f"[{i + 1}/{total}] {name}...", end=" ", flush=True)
r = run_smoke_test(name)
results.append(r)
sym = {"pass": "✓", "fail": "✗", "error": "E", "skipped": "—"}.get(r["status"], "?")
print(f"{sym} [{r['status']}]")
if r.get("error"):
print(f" {r['error'][:120]}")
# Wipe HF cache for this dataset immediately
if hf_id:
wipe_hf_cache_for(hf_id, hf_sub)
# Encourage Python to release memory from the completed test
import gc
gc.collect()
# Save results after every dataset
all_results = {**existing, **{rr["name"]: rr for rr in results}}
with open(RESULTS_PATH, "w") as f:
json.dump(list(all_results.values()), f, indent=2)
status_counts = Counter(r["status"] for r in results)
print("\n=== Smoke Test Summary ===")
for s, n in status_counts.most_common():
print(f" {s}: {n}")
print(f" Total: {len(results)}")
print("\nFAILED:")
for r in results:
if r["status"] == "fail":
print(f" {r['name']}: {(r.get('error') or '')[:100]}")
print("\nERRORS:")
for r in results:
if r["status"] == "error":
print(f" {r['name']}: {(r.get('error') or '')[:100]}")
if __name__ == "__main__":
main()