Files
2026-07-13 12:03:03 +08:00

223 lines
8.5 KiB
Python

"""
Base class for all Tasks.
A Task is basically a dataset of conversations, together with some
metadata and often also evaluation criteria.
Example tasks: MMLU, ARC-Easy, ARC-Challenge, GSM8K, HumanEval, SmolTalk.
"""
import os
import json
import random
import urllib.request
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
from filelock import FileLock
from nanochat.common import get_base_dir
class HubDataset:
"""
Minimal stand-in for a HuggingFace datasets Dataset: wraps a pyarrow
Table and offers lazy row access and a seeded shuffle.
"""
def __init__(self, table, permutation=None):
self.table = table
self.permutation = permutation
def __len__(self):
return self.table.num_rows
def shuffle(self, seed):
# matches datasets.Dataset.shuffle(seed=seed) exactly, row order comes out identical
permutation = np.random.default_rng(seed).permutation(len(self))
return HubDataset(self.table, permutation)
def __getitem__(self, index):
physical_index = index if self.permutation is None else int(self.permutation[index])
row = {column: self.table[column][physical_index].as_py() for column in self.table.column_names}
return row
def load_hub_dataset(repo_id, subset="default", split="train"):
"""
Minimal stand-in for HuggingFace datasets.load_dataset(repo_id, subset, split=split).
Every dataset on the hub has an auto-generated parquet export. We list the parquet
shards via the hub API, download them (once) into the local cache directory, and
read them with pyarrow. Under torchrun, only one rank downloads, the others wait.
"""
base_dir = get_base_dir()
slug = repo_id.replace("/", "--")
shards_dir = os.path.join(base_dir, "task_data", slug, subset, split)
# the manifest is written last, so its existence means the download completed
manifest_path = os.path.join(shards_dir, "manifest.json")
if not os.path.exists(manifest_path):
os.makedirs(shards_dir, exist_ok=True)
with FileLock(manifest_path + ".lock"):
# only a single rank acquires the lock and downloads, the others block
# here and then skip the download because they recheck the manifest
if not os.path.exists(manifest_path):
listing_url = f"https://huggingface.co/api/datasets/{repo_id}/parquet/{subset}/{split}"
with urllib.request.urlopen(listing_url) as response:
shard_urls = json.loads(response.read())
filenames = []
for shard_index, shard_url in enumerate(shard_urls):
filename = f"{shard_index:05d}.parquet"
print(f"Downloading {shard_url} ...")
with urllib.request.urlopen(shard_url) as response:
content = response.read()
with open(os.path.join(shards_dir, filename), "wb") as f:
f.write(content)
filenames.append(filename)
with open(manifest_path, "w") as f:
json.dump(filenames, f)
with open(manifest_path, "r") as f:
filenames = json.load(f)
shard_paths = [os.path.join(shards_dir, filename) for filename in filenames]
tables = [pq.read_table(path) for path in shard_paths]
table = pa.concat_tables(tables)
return HubDataset(table)
class Task:
"""
Base class of a Task. Allows for lightweight slicing of the underlying dataset.
"""
def __init__(self, start=0, stop=None, step=1):
# allows a lightweight logical view over a dataset
assert start >= 0, f"Start must be non-negative, got {start}"
assert stop is None or stop >= start, f"Stop should be greater than or equal to start, got {stop} and {start}"
assert step >= 1, f"Step must be strictly positive, got {step}"
self.start = start
self.stop = stop # could be None here
self.step = step
@property
def eval_type(self):
# one of 'generative' | 'categorical'
raise NotImplementedError
def num_examples(self):
raise NotImplementedError
def get_example(self, index):
raise NotImplementedError
def __len__(self):
start = self.start
stop = self.num_examples() if self.stop is None else self.stop
step = self.step
span = stop - start
num = (span + step - 1) // step # ceil_div(span, step)
assert num >= 0, f"Negative number of examples???: {num}" # prevent footguns
return num
def __getitem__(self, index: int):
assert isinstance(index, int), f"Index must be an integer, got {type(index)}"
physical_index = self.start + index * self.step
conversation = self.get_example(physical_index)
return conversation
def evaluate(self, problem, completion):
raise NotImplementedError
class TaskMixture(Task):
"""
For SFT Training it becomes useful to train on a mixture of datasets.
Fun trick: if you wish to oversample any task, just pass it in multiple times in the list.
"""
def __init__(self, tasks, **kwargs):
super().__init__(**kwargs)
# tasks is a list of Task objects
self.tasks = tasks
self.lengths = [len(task) for task in self.tasks]
self.num_conversations = sum(self.lengths)
# Build list of all (task_idx, local_idx) pairs
self.index_map = []
for task_idx, task_length in enumerate(self.lengths):
for local_idx in range(task_length):
self.index_map.append((task_idx, local_idx))
# Deterministically shuffle to mix tasks throughout training
rng = random.Random(42)
rng.shuffle(self.index_map)
# Note: this is not the most elegant or best solution, but it's ok for now
def num_examples(self):
return self.num_conversations
def get_example(self, index):
"""
Access conversations according to a deterministic shuffle of all examples.
This ensures tasks are mixed throughout training, regardless of dataset size.
"""
assert 0 <= index < self.num_conversations, f"Index {index} out of range for mixture with {self.num_conversations} conversations"
task_idx, local_idx = self.index_map[index]
return self.tasks[task_idx][local_idx]
class TaskSequence(Task):
"""
For SFT Training sometimes we want to sequentially train on a list of tasks.
This is useful for cases that require a training curriculum.
"""
def __init__(self, tasks, **kwargs):
super().__init__(**kwargs)
self.tasks = tasks
self.lengths = [len(task) for task in self.tasks]
self.num_conversations = sum(self.lengths)
def num_examples(self):
return self.num_conversations
def get_example(self, index):
assert 0 <= index < self.num_conversations, f"Index {index} out of range for sequence with {self.num_conversations} conversations"
for task_idx, task_length in enumerate(self.lengths):
if index < task_length:
return self.tasks[task_idx][index]
index -= task_length
def render_mc(question, letters, choices):
"""
The common multiple choice rendering format we will use.
Note two important design decisions:
1)
Bigger models don't care as much, but smaller models prefer to have
the letter *after* the choice, which results in better binding.
2)
There is no whitespace between the delimiter (=) and the letter.
This is actually critical because the tokenizer has different token ids
for " A" vs. "A". The assistant responses will be just the letter itself,
i.e. "A", so it is important that here in the prompt it is the exact same
token, i.e. "A" with no whitespace before it. Again, bigger models don't care
about this too much, but smaller models do care about some of these details.
"""
query = f"Multiple Choice question: {question}\n"
query += "".join([f"- {choice}={letter}\n" for letter, choice in zip(letters, choices)])
query += "\nRespond only with the letter of the correct answer."
return query
if __name__ == "__main__":
# very lightweight test of slicing
from tasks.mmlu import MMLU
ds = MMLU(subset="all", split="auxiliary_train")
print("Length of MMLU: ", len(ds))
ex = ds[5]
print("5th example: ", ex)
ds = MMLU(subset="all", split="auxiliary_train", start=5, stop=10)
print("Length of sliced MMLU[5:10]: ", len(ds))
print("0th example of sliced MMLU: ", ds[0])
print("They match: ", ex == ds[0])