Spaces:
Running on Zero
Running on Zero
Download src/diffusion_lm/dataset_builder.py from Ruurd/llada-8b-instruct: direct link, hf CLI and curl.
- Browser
- Download file 28.4 kB
-
https://huggingface.co/spaces/Ruurd/llada-8b-instruct/resolve/main/src/diffusion_lm/dataset_builder.py
- Command line
-
hf download hf://spaces/Ruurd/llada-8b-instruct/src/diffusion_lm/dataset_builder.py
-
curl -L -o dataset_builder.py https://huggingface.co/spaces/Ruurd/llada-8b-instruct/resolve/main/src/diffusion_lm/dataset_builder.py
28.4 kB
| """Build a split-safe, length-bounded instruction-tuning mixture.""" | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import random | |
| import re | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any, Callable, Iterable | |
| from .data import apply_neutral_chat_template | |
| DEFAULT_WEIGHTS = {"general": 0.45, "reasoning": 0.18, "math": 0.18, "code": 0.19} | |
| DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant." | |
| SYSTEM_PROMPTS = { | |
| "general": [ | |
| "Respond helpfully, accurately, and clearly.", | |
| "You are a knowledgeable assistant. Give a direct and useful response.", | |
| "You are a thoughtful assistant. Follow the user's instructions carefully.", | |
| ], | |
| "reasoning": [ | |
| "You are a careful reasoning assistant. Answer accurately and follow the requested format.", | |
| "Analyze the question carefully and select the best-supported answer.", | |
| ], | |
| "math": [ | |
| "You are a careful mathematics tutor. Explain the solution clearly.", | |
| "Solve mathematical problems accurately and show the relevant reasoning.", | |
| ], | |
| "code": [ | |
| "You are an expert programming assistant. Produce correct and readable code.", | |
| "Help with programming tasks using robust, clear solutions.", | |
| ], | |
| } | |
| class BuildConfig: | |
| tokenizer_name: str = "meta-llama/Llama-3.1-8B-Instruct" | |
| total_examples: int = 500_000 | |
| max_prompt_tokens: int = 256 | |
| max_sequence_tokens: int = 512 | |
| truncate_long_answers: bool = False | |
| validation_fraction: float = 0.01 | |
| test_fraction: float = 0.01 | |
| seed: int = 42 | |
| weights: dict[str, float] = field(default_factory=lambda: dict(DEFAULT_WEIGHTS)) | |
| cache_dir: str = "data/huggingface" | |
| exclude_dataset: str | None = None | |
| allow_excluded_fallback: bool = False | |
| build_report: dict[str, Any] = field(default_factory=dict, init=False, repr=False) | |
| def normalized_prompt(text: str) -> str: | |
| """Normalize a prompt for conservative exact-match decontamination.""" | |
| return re.sub(r"\s+", " ", re.sub(r"[^\w\s]", " ", (text or "").casefold())).strip() | |
| def prompt_hash(text: str) -> str: | |
| return hashlib.sha256(normalized_prompt(text).encode()).hexdigest() | |
| def row_prompt_hashes(row: dict[str, Any]) -> set[str]: | |
| """Return wrapped and raw-input hashes used for overlap detection.""" | |
| instruction = str(row.get("instruction") or "").strip() | |
| input_text = str(row.get("input") or "").strip() | |
| user = "\n\n".join(part for part in (instruction, input_text) if part) | |
| hashes = {prompt_hash(user)} | |
| # Code-instruction datasets commonly have an empty separate input field; | |
| # its hash must not make every such prompt collide with every other one. | |
| if input_text: | |
| hashes.add(prompt_hash(input_text)) | |
| return hashes | |
| def format_mc(question: str, choices: list[Any], answer: int | str) -> dict[str, str] | None: | |
| labels = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ"[: len(choices)]) | |
| if isinstance(answer, str) and answer in labels: | |
| index = labels.index(answer) | |
| else: | |
| try: | |
| index = int(answer) | |
| except (TypeError, ValueError): | |
| return None | |
| if not 0 <= index < len(choices): | |
| return None | |
| options = "\n".join(f"{label}: {choice}" for label, choice in zip(labels, choices)) | |
| return { | |
| "instruction": "Answer the following multiple-choice question.", | |
| "input": f"{question.strip()}\n\n{options}", | |
| "output": f"{labels[index]}: {choices[index]}", | |
| } | |
| def format_tulu(row: dict[str, Any]) -> dict[str, str] | None: | |
| """Convert the final user/assistant exchange; include short prior turns as context.""" | |
| # Tulu is itself a broad mixture. Keep its explicit math/code subsets out of | |
| # the general bucket because those categories are independently controlled. | |
| source = str(row.get("source", "")).casefold() | |
| if "math" in source or "code" in source: | |
| return None | |
| messages = [m for m in row.get("messages", []) if (m.get("content") or "").strip()] | |
| assistant_positions = [i for i, m in enumerate(messages) if m.get("role") == "assistant" and i] | |
| if not assistant_positions: | |
| return None | |
| end = assistant_positions[-1] | |
| user = next((i for i in range(end - 1, -1, -1) if messages[i].get("role") == "user"), None) | |
| if user is None: | |
| return None | |
| native_system = next((m["content"].strip() for m in messages if m.get("role") == "system"), "") | |
| history = [m for m in messages[:user] if m.get("role") != "system"] | |
| history_text = "\n\n".join(f"{m.get('role', 'user').title()}: {m['content'].strip()}" for m in history) | |
| question = messages[user]["content"].strip() | |
| return { | |
| "system": native_system, | |
| "instruction": "Continue the conversation helpfully." if history_text else "", | |
| "input": f"Conversation so far:\n{history_text}\n\nUser: {question}" if history_text else question, | |
| "output": messages[end]["content"].strip(), | |
| } | |
| def format_hellaswag(row: dict[str, Any]) -> dict[str, str] | None: | |
| """Make HellaSwag's sentence/event-completion task explicit.""" | |
| choices = row.get("endings", []) | |
| formatted = format_mc(row.get("ctx", ""), choices, row.get("label")) | |
| if formatted is None: | |
| return None | |
| options = "\n".join(f"{label}: {choice}" for label, choice in zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ", choices)) | |
| return { | |
| "instruction": "Choose the option that most plausibly continues the described event.", | |
| "input": ( | |
| f"Beginning of the event:\n{row.get('ctx', '').strip()}\n\n" | |
| f"What most plausibly happens next?\n{options}" | |
| ), | |
| "output": formatted["output"], | |
| } | |
| def choose_system_prompt(item: dict[str, str], source: str, prompt_key: str) -> str: | |
| """Preserve native systems; otherwise vary a compatible prompt deterministically.""" | |
| native = (item.get("system") or "").strip() | |
| if native: | |
| return native | |
| category = source.split(":", 1)[0] | |
| digest = int(prompt_key[:16], 16) | |
| # Retain the established default for 70% of generated rows. The remaining | |
| # rows use safe category-specific wording that does not conflict with the | |
| # expected response style. | |
| if digest % 10 < 7: | |
| return DEFAULT_SYSTEM_PROMPT | |
| variants = SYSTEM_PROMPTS[category] | |
| return variants[(digest // 10) % len(variants)] | |
| def _targets(total: int, weights: dict[str, float]) -> dict[str, int]: | |
| if set(weights) != set(DEFAULT_WEIGHTS) or any(v < 0 for v in weights.values()): | |
| raise ValueError(f"weights must contain exactly {sorted(DEFAULT_WEIGHTS)} with non-negative values") | |
| scale = sum(weights.values()) | |
| if scale <= 0: | |
| raise ValueError("at least one mixture weight must be positive") | |
| targets = {key: int(total * value / scale) for key, value in weights.items()} | |
| targets[max(targets, key=targets.get)] += total - sum(targets.values()) | |
| return targets | |
| def _take(rows: Iterable[dict[str, Any]], formatter: Callable[[dict[str, Any]], dict[str, str] | None], count: int, | |
| tokenizer: Any, config: BuildConfig, blocked: set[str], source: str, | |
| progress: Any | None = None, *, excluded: set[str] | None = None, | |
| used: set[str] | None = None, stats: dict[str, int] | None = None, | |
| sample_origin: str = "new_source") -> list[dict[str, Any]]: | |
| accepted = [] | |
| scanned = 0 | |
| for row in rows: | |
| scanned += 1 | |
| if progress is not None and scanned % 500 == 0: | |
| progress.set_postfix_str(f"{source}, scanned={scanned:,}", refresh=True) | |
| item = formatter(row) | |
| if not item or not item["output"].strip(): | |
| continue | |
| user = "\n\n".join(x for x in (item["instruction"].strip(), item["input"].strip()) if x) | |
| # Reject pathological upstream records before regex normalization or | |
| # hashing. One observed prompt contains more than a million tokens; | |
| # running Unicode regexes over it can look like a hung process. | |
| if len(user) > config.max_prompt_tokens * 50 or len(item["output"]) > config.max_sequence_tokens * 50: | |
| continue | |
| key = prompt_hash(user) | |
| input_text = item["input"].strip() | |
| # Compare both the raw task text and its instruction-wrapped form: held-out | |
| # benchmark hashes contain the raw question, while general datasets vary. | |
| keys = {key} | |
| if input_text: | |
| keys.add(prompt_hash(input_text)) | |
| if keys & blocked: | |
| if stats is not None: | |
| stats["benchmark_blocked"] = stats.get("benchmark_blocked", 0) + 1 | |
| continue | |
| if excluded is not None and keys & excluded: | |
| if stats is not None: | |
| stats["excluded_overlap"] = stats.get("excluded_overlap", 0) + 1 | |
| continue | |
| if used is not None and keys & used: | |
| if stats is not None: | |
| stats["within_build_duplicate"] = stats.get("within_build_duplicate", 0) + 1 | |
| continue | |
| resolved_source = str(item.pop("_lad_source", source)) | |
| system = choose_system_prompt(item, resolved_source, key) | |
| if len(system) > config.max_prompt_tokens * 50: | |
| continue | |
| prompt_messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] | |
| # Tokenize to one token beyond the prompt limit. This proves that a row | |
| # is oversized without creating enormous arrays or triggering the | |
| # model's max-length warning. | |
| prompt_ids = apply_neutral_chat_template( | |
| tokenizer, prompt_messages, tokenize=True, add_generation_prompt=True, | |
| truncation=True, max_length=config.max_prompt_tokens + 1, | |
| ) | |
| if len(prompt_ids) > config.max_prompt_tokens: | |
| continue | |
| # Do not render and tokenize the prompt a second time. Complete answers | |
| # reserve one position for EOS. With truncation enabled, an overlong | |
| # answer may use the whole remaining context and deliberately has no | |
| # EOS because the source answer did not actually finish. | |
| answer_capacity = config.max_sequence_tokens - len(prompt_ids) | |
| complete_answer_limit = answer_capacity - 1 | |
| if complete_answer_limit < 1: | |
| continue | |
| answer_ids = tokenizer( | |
| item["output"].strip(), add_special_tokens=False, truncation=True, | |
| max_length=answer_capacity + 1, | |
| )["input_ids"] | |
| answer_truncated = len(answer_ids) > complete_answer_limit | |
| if answer_truncated and config.truncate_long_answers: | |
| # Keep the raw text and stored IDs consistent. Decoding and then | |
| # re-encoding can occasionally change a boundary token, so shorten | |
| # until the normalized truncated text fills no more than the | |
| # remaining context. Truncated answers intentionally omit EOS. | |
| candidate_ids = list(answer_ids[:answer_capacity]) | |
| while candidate_ids: | |
| truncated_output = tokenizer.decode( | |
| candidate_ids, | |
| skip_special_tokens=True, | |
| clean_up_tokenization_spaces=False, | |
| ).strip() | |
| normalized_ids = tokenizer.encode(truncated_output, add_special_tokens=False) | |
| if truncated_output and len(normalized_ids) <= answer_capacity: | |
| item = {**item, "output": truncated_output} | |
| answer_ids = normalized_ids | |
| break | |
| candidate_ids.pop() | |
| else: | |
| continue | |
| if answer_truncated and not config.truncate_long_answers: | |
| continue | |
| if tokenizer.eos_token_id is None: | |
| raise ValueError(f"Tokenizer {tokenizer.name_or_path} has no eos_token_id") | |
| terminal = [] if answer_truncated else [tokenizer.eos_token_id] | |
| full_ids = list(prompt_ids) + list(answer_ids) + terminal | |
| # Share one immutable-in-practice Python list while rows are buffered; | |
| # Dataset.from_list materializes the two required Arrow columns later. | |
| # Keeping two Python list copies here roughly doubles peak preparation | |
| # memory and causes severe slowdown from memory pressure on Colab. | |
| clean_ids = list(full_ids) | |
| accepted.append({ | |
| **item, | |
| "system": system, | |
| "input_ids": clean_ids, | |
| "labels": clean_ids, | |
| "category": resolved_source.split(":", 1)[0], | |
| "source": resolved_source, | |
| "answer_truncated": answer_truncated, | |
| "sample_origin": sample_origin, | |
| }) | |
| if used is not None: | |
| used.update(keys) | |
| if progress is not None: | |
| progress.update(1) | |
| if len(accepted) >= count: | |
| break | |
| return accepted | |
| def _evaluation_hashes(load: Callable[..., Any]) -> set[str]: | |
| """Hash held-out prompts from every benchmark represented in the mixture.""" | |
| blocked: set[str] = set() | |
| specs = [ | |
| ("allenai/ai2_arc", "ARC-Easy", ("validation", "test"), "question"), | |
| ("allenai/ai2_arc", "ARC-Challenge", ("validation", "test"), "question"), | |
| ("cais/mmlu", "all", ("validation", "test"), "question"), | |
| ("Rowan/hellaswag", None, ("validation", "test"), "ctx"), | |
| ("openai/gsm8k", "main", ("test",), "question"), | |
| ("google-research-datasets/mbpp", "sanitized", ("validation", "test"), "prompt"), | |
| ] | |
| for path, name, splits, field_name in specs: | |
| for split in splits: | |
| for row in load(path, name, split=split): | |
| blocked.add(prompt_hash(row.get(field_name, ""))) | |
| return blocked | |
| def _repeat_dataset(dataset: Any, count: int, seed: int, concatenate: Callable) -> Any: | |
| """Return exactly ``count`` rows, cycling the full unique pool before repeats. | |
| Arrow datasets are concatenated without expanding repeated rows into a | |
| giant Python list. A shuffled partial cycle prevents always favoring the | |
| beginning of the pool when the target is not an exact multiple. | |
| """ | |
| if not len(dataset): | |
| raise RuntimeError("cannot oversample an empty category") | |
| if len(dataset) >= count: | |
| return dataset.select(range(count)) | |
| cycles, remainder = divmod(count, len(dataset)) | |
| pieces = [dataset] * cycles | |
| if remainder: | |
| pieces.append(dataset.shuffle(seed=seed).select(range(remainder))) | |
| return concatenate(pieces) | |
| def build_dataset(config: BuildConfig, token: str | None = None): | |
| """Download, normalize, balance, split, and return a DatasetDict.""" | |
| from datasets import Dataset, DatasetDict, Features, Sequence, Value, concatenate_datasets, load_dataset, load_from_disk | |
| from transformers import AutoTokenizer | |
| from tqdm.auto import tqdm | |
| cache = config.cache_dir | |
| load = lambda path, name=None, **kw: load_dataset(path, name, cache_dir=cache, token=token, **kw) | |
| tokenizer = AutoTokenizer.from_pretrained(config.tokenizer_name, token=token, cache_dir=cache) | |
| targets = _targets(config.total_examples, config.weights) | |
| print(f"Building {config.total_examples:,} examples with category targets: {targets}") | |
| output_features = Features({ | |
| "system": Value("string"), "instruction": Value("string"), "input": Value("string"), "output": Value("string"), | |
| # All supported vocabularies fit int32. The collator converts these | |
| # lists to torch.long, so training behavior is unchanged. | |
| "input_ids": Sequence(Value("int32")), "labels": Sequence(Value("int32")), | |
| "category": Value("string"), "source": Value("string"), "answer_truncated": Value("bool"), | |
| "sample_origin": Value("string"), | |
| }) | |
| heldout = config.validation_fraction + config.test_fraction | |
| if not 0 < heldout < 1: | |
| raise ValueError("validation_fraction + test_fraction must be between zero and one") | |
| if config.allow_excluded_fallback and not config.exclude_dataset: | |
| raise ValueError("allow_excluded_fallback requires exclude_dataset") | |
| excluded_data = None | |
| excluded_hashes: set[str] = set() | |
| build_targets = dict(targets) | |
| reused_heldout = {} | |
| if config.exclude_dataset: | |
| exclusion_path = Path(config.exclude_dataset).expanduser() | |
| print(f"Loading exclusion dataset {config.exclude_dataset}...", flush=True) | |
| excluded_data = ( | |
| load_from_disk(str(exclusion_path)) | |
| if exclusion_path.is_dir() | |
| else load_dataset(config.exclude_dataset, cache_dir=cache, token=token) | |
| ) | |
| required_text = {"instruction", "input"} | |
| for split_name, rows in excluded_data.items(): | |
| missing = required_text - set(rows.column_names) | |
| if missing: | |
| raise ValueError( | |
| f"Exclusion dataset split {split_name!r} lacks required columns: {sorted(missing)}" | |
| ) | |
| for row in rows.select_columns(sorted(required_text)): | |
| excluded_hashes.update(row_prompt_hashes(row)) | |
| print(f"Loaded {len(excluded_hashes):,} exclusion prompt hashes.", flush=True) | |
| if config.allow_excluded_fallback: | |
| required_splits = {"train", "validation", "test"} | |
| missing_splits = required_splits - set(excluded_data) | |
| if missing_splits: | |
| raise ValueError( | |
| "Excluded fallback requires train/validation/test splits; missing " | |
| f"{sorted(missing_splits)}" | |
| ) | |
| # Preserve the earlier held-out rows exactly. This keeps validation | |
| # comparable and prevents previously trained fallback rows from | |
| # leaking into validation or test after a fresh random split. | |
| for split_name in ("validation", "test"): | |
| rows = excluded_data[split_name] | |
| missing = set(output_features) - {"sample_origin"} - set(rows.column_names) | |
| if missing: | |
| raise ValueError( | |
| f"Excluded fallback split {split_name!r} lacks required columns: {sorted(missing)}" | |
| ) | |
| category_counts = {name: rows["category"].count(name) for name in DEFAULT_WEIGHTS} | |
| for category, count in category_counts.items(): | |
| build_targets[category] -= count | |
| if build_targets[category] < 0: | |
| raise ValueError( | |
| f"Excluded {split_name} contains more {category} rows than the requested " | |
| "mixture can accommodate" | |
| ) | |
| reused_heldout[split_name] = rows | |
| print("Loading held-out benchmark prompts for decontamination...", flush=True) | |
| blocked = _evaluation_hashes(load) | |
| print(f"Loaded {len(blocked):,} held-out prompt hashes.", flush=True) | |
| rng = random.Random(config.seed) | |
| used: set[str] = set() | |
| if reused_heldout: | |
| for rows in reused_heldout.values(): | |
| for row in rows.select_columns(["instruction", "input"]): | |
| used.update(row_prompt_hashes(row)) | |
| filter_stats: dict[str, int] = {} | |
| def shuffled(path: str, name: str | None = None, split: str = "train"): | |
| return load(path, name, split=split).shuffle(seed=config.seed) | |
| print("Loading source datasets (cached sources should open without downloading)...", flush=True) | |
| sources: dict[str, list[tuple[str, Iterable[dict[str, Any]], Callable, float]]] = { | |
| "general": [ | |
| ("general:clean-instruct", shuffled("crumb/Clean-Instruct-3M", split="train"), lambda x: {"instruction": x.get("instruction", ""), "input": x.get("input", ""), "output": x.get("output", "")}, .40), | |
| ("general:tulu-3", shuffled("allenai/tulu-3-sft-mixture"), format_tulu, .35), | |
| ("general:alpaca-gpt4", shuffled("vicgalle/alpaca-gpt4"), lambda x: {k: x.get(k, "") for k in ("instruction", "input", "output")}, .20), | |
| ("general:alpaca", shuffled("tatsu-lab/alpaca"), lambda x: {k: x.get(k, "") for k in ("instruction", "input", "output")}, .05), | |
| ], | |
| "reasoning": [ | |
| ("reasoning:mmlu", shuffled("cais/mmlu", "all", "auxiliary_train"), lambda x: format_mc(x["question"], x["choices"], x["answer"]), .73), | |
| ("reasoning:hellaswag", shuffled("Rowan/hellaswag"), format_hellaswag, .25), | |
| ("reasoning:arc-easy", shuffled("allenai/ai2_arc", "ARC-Easy"), lambda x: format_mc(x["question"], x["choices"]["text"], x["choices"]["label"].index(x["answerKey"]) if x["answerKey"] in x["choices"]["label"] else -1), .02), | |
| ], | |
| "math": [ | |
| ("math:orca", shuffled("microsoft/orca-math-word-problems-200k"), lambda x: {"instruction": "Solve the following math problem step by step.", "input": x.get("question", ""), "output": x.get("answer", "")}, .95), | |
| ("math:gsm8k", shuffled("openai/gsm8k", "main"), lambda x: {"instruction": "Solve the following math problem step by step.", "input": x.get("question", ""), "output": x.get("answer", "")}, .05), | |
| ], | |
| "code": [ | |
| ("code:opencoder", shuffled("OpenCoder-LLM/opc-sft-stage2", "educational_instruct"), lambda x: {"instruction": x.get("instruction", ""), "input": "", "output": x.get("output", "")}, .997), | |
| ("code:mbpp", shuffled("google-research-datasets/mbpp", "sanitized"), lambda x: {"instruction": "Write Python code to solve the following task.", "input": x.get("prompt", ""), "output": x.get("code", "")}, .003), | |
| ], | |
| } | |
| print("Source datasets loaded; formatting and tokenization are starting.", flush=True) | |
| fallback_train = None | |
| if config.allow_excluded_fallback: | |
| fallback_columns = ["system", "instruction", "input", "output", "category", "source"] | |
| missing = set(fallback_columns) - set(excluded_data["train"].column_names) | |
| if missing: | |
| raise ValueError(f"Excluded fallback train split lacks required columns: {sorted(missing)}") | |
| fallback_train = excluded_data["train"].select_columns(fallback_columns).shuffle(seed=config.seed) | |
| groups = [] | |
| category_report = {} | |
| for category, entries in sources.items(): | |
| wanted = build_targets[category] | |
| progress = tqdm(total=wanted, desc=f"Preparing {category}", unit="rows") | |
| allocations = [int(wanted * share) for *_, share in entries] | |
| allocations[0] += wanted - sum(allocations) | |
| rows = [] | |
| # Keep iterators alive after the preferred-share pass. This lets a | |
| # larger source contribute additional unused rows when a smaller source | |
| # cannot meet its allocation, without rescanning or duplicating rows. | |
| prepared = [(source, data, formatter, iter(data)) for source, data, formatter, _ in entries] | |
| for (source, _, formatter, iterator), count in zip(prepared, allocations): | |
| rows.extend(_take( | |
| iterator, formatter, count, tokenizer, config, blocked, source, progress, | |
| excluded=excluded_hashes, used=used, stats=filter_stats, | |
| )) | |
| if len(rows) < wanted: | |
| # Exhaust still-unused rows from the largest sources first. Dataset | |
| # size is only a priority heuristic; exact token filtering remains | |
| # authoritative. | |
| for source, data, formatter, iterator in sorted(prepared, key=lambda item: len(item[1]), reverse=True): | |
| rows.extend(_take( | |
| iterator, formatter, wanted - len(rows), tokenizer, config, blocked, source, progress, | |
| excluded=excluded_hashes, used=used, stats=filter_stats, | |
| )) | |
| if len(rows) >= wanted: | |
| break | |
| novel_count = len(rows) | |
| if len(rows) < wanted and fallback_train is not None: | |
| def fallback_rows(): | |
| for old_row in fallback_train: | |
| if old_row.get("category") == category: | |
| yield old_row | |
| def format_fallback(old_row): | |
| return { | |
| "system": old_row.get("system", ""), | |
| "instruction": old_row.get("instruction", ""), | |
| "input": old_row.get("input", ""), | |
| "output": old_row.get("output", ""), | |
| "_lad_source": old_row.get("source", f"{category}:excluded-fallback"), | |
| } | |
| rows.extend(_take( | |
| fallback_rows(), format_fallback, wanted - len(rows), tokenizer, config, | |
| blocked, f"{category}:excluded-fallback", progress, | |
| used=used, stats=filter_stats, sample_origin="excluded_training_fallback", | |
| )) | |
| fallback_unique = len(rows) - novel_count | |
| unique_count = len(rows) | |
| if not unique_count: | |
| hint = ( | |
| "; add new source datasets or pass --allow-excluded-fallback" | |
| if config.exclude_dataset and not config.allow_excluded_fallback else "" | |
| ) | |
| raise RuntimeError(f"{category}: no rows survived filtering{hint}") | |
| rng.shuffle(rows) | |
| group = Dataset.from_list(rows, features=output_features) | |
| if unique_count < wanted: | |
| print( | |
| f"{category}: {unique_count:,} unique eligible rows; oversampling to {wanted:,} " | |
| f"({wanted / unique_count:.2f}x exposure)" | |
| ) | |
| progress.update(wanted - unique_count) | |
| progress.set_postfix_str(f"{unique_count:,} unique", refresh=True) | |
| progress.close() | |
| repeated_group = _repeat_dataset(group, wanted, config.seed, concatenate_datasets) | |
| category_report[category] = { | |
| "target_rows": wanted, | |
| "new_unique_rows": novel_count, | |
| "excluded_training_fallback_unique_rows": fallback_unique, | |
| "oversampled_rows": wanted - unique_count, | |
| "final_sample_origins": { | |
| origin: repeated_group["sample_origin"].count(origin) | |
| for origin in sorted(set(repeated_group["sample_origin"])) | |
| }, | |
| } | |
| groups.append(repeated_group) | |
| combined = concatenate_datasets(groups).shuffle(seed=config.seed) | |
| if reused_heldout: | |
| normalized_heldout = {} | |
| for split_name, rows in reused_heldout.items(): | |
| if "sample_origin" in rows.column_names: | |
| rows = rows.remove_columns("sample_origin") | |
| rows = rows.map(lambda _: {"sample_origin": "excluded_heldout"}) | |
| normalized_heldout[split_name] = rows.select_columns(list(output_features)).cast(output_features) | |
| result = DatasetDict( | |
| train=combined, | |
| validation=normalized_heldout["validation"], | |
| test=normalized_heldout["test"], | |
| ) | |
| else: | |
| first = combined.train_test_split(test_size=heldout, seed=config.seed) | |
| second = first["test"].train_test_split(test_size=config.test_fraction / heldout, seed=config.seed) | |
| result = DatasetDict(train=first["train"], validation=second["train"], test=second["test"]) | |
| config.build_report = { | |
| "exclude_dataset": config.exclude_dataset, | |
| "allow_excluded_fallback": config.allow_excluded_fallback, | |
| "excluded_prompt_hashes": len(excluded_hashes), | |
| "filter_counts": filter_stats, | |
| "categories": category_report, | |
| "reused_heldout_rows": {name: len(rows) for name, rows in reused_heldout.items()}, | |
| } | |
| return result | |
| def write_manifest(dataset: Any, config: BuildConfig, path: str | Path) -> None: | |
| counts: dict[str, dict[str, int]] = {} | |
| truncated: dict[str, int] = {} | |
| origins: dict[str, dict[str, int]] = {} | |
| for split, rows in dataset.items(): | |
| counts[split] = {name: rows["category"].count(name) for name in DEFAULT_WEIGHTS} | |
| truncated[split] = sum(rows["answer_truncated"]) | |
| origins[split] = { | |
| name: rows["sample_origin"].count(name) | |
| for name in sorted(set(rows["sample_origin"])) | |
| } | |
| serialized_config = {key: value for key, value in config.__dict__.items() if key != "build_report"} | |
| Path(path).write_text(json.dumps({ | |
| "config": serialized_config, | |
| "rows": counts, | |
| "truncated_answers": truncated, | |
| "sample_origins": origins, | |
| "novelty": config.build_report, | |
| }, indent=2, sort_keys=True) + "\n") | |