Files
2025-02-01 10:40:46 -08:00

896 lines
35 KiB
Python

# Copyright 2024 AllenAI. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import dataclasses
import functools
import json
import logging
import os
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from typing import Any, List, NewType, Optional, Tuple, Union
import requests
from accelerate.logging import get_logger
from datasets import DatasetDict, concatenate_datasets, load_dataset, load_from_disk
from datasets.builder import DatasetGenerationError
from dateutil import parser
from huggingface_hub import HfApi
from rich.pretty import pprint
from transformers import MODEL_FOR_CAUSAL_LM_MAPPING, HfArgumentParser
MODEL_CONFIG_CLASSES = list(MODEL_FOR_CAUSAL_LM_MAPPING.keys())
MODEL_TYPES = tuple(conf.model_type for conf in MODEL_CONFIG_CLASSES)
logger = get_logger(__name__)
DataClassType = NewType("DataClassType", Any)
"""
Notes:
Inspired by Alignment Handbook Parser and Dataset Mixer
https://github.com/huggingface/alignment-handbook/blob/main/src/alignment/configs.py
https://github.com/huggingface/alignment-handbook/blob/main/src/alignment/data.py
Migrated Args from
https://github.com/allenai/open-instruct/blob/98ccfb460ae4fb98140783b6cf54241926160a06/open_instruct/finetune_trainer.py
Commented out Args not currently used
"""
# ----------------------------------------------------------------------------
# Dataset utilities
def is_openai_format(messages: Any) -> bool:
"""
Check if the input messages are in OpenAI format.
Args:
messages (`Any`):
Messages to check.
Returns:
`bool`: Whether the messages are in OpenAI format.
"""
if isinstance(messages, list) and all(isinstance(message, dict) for message in messages):
return all("role" in message and "content" in message for message in messages)
return False
# functions for handling different formats of messages
def convert_alpaca_gpt4_to_messages(example):
"""
Convert an instruction in inst-output to a list of messages.
e.g. vicgalle/alpaca-gpt4"""
messages = [
{
"role": "user",
"content": (
"Below is an instruction that describes a task, paired with an input that provides "
"further context. Write a response that appropriately completes the request.\n\n"
f"### Instruction:\n{example['instruction']}\n\n"
f"### Input:\n{example['input']}\n\n"
"### Response:"
),
},
{"role": "assistant", "content": example["output"]},
]
example["messages"] = messages
return example
def convert_codefeedback_single_turn_to_messages(example):
"""
Convert a query-answer pair to a list of messages.
e.g. m-a-p/CodeFeedback-Filtered-Instruction"""
messages = [
{"role": "user", "content": example["query"]},
{"role": "assistant", "content": example["answer"]},
]
example["messages"] = messages
return example
def convert_metamath_qa_to_messages(example):
"""
Convert a query-response pair to a list of messages.
e.g. meta-math/MetaMathQA"""
messages = [
{"role": "user", "content": example["query"]},
{"role": "assistant", "content": example["response"]},
]
example["messages"] = messages
return example
def convert_code_alpaca_to_messages(example):
"""
Convert a prompt-completion pair to a list of messages.
e.g. HuggingFaceH4/CodeAlpaca_20K"""
messages = [
{"role": "user", "content": example["prompt"]},
{"role": "assistant", "content": example["completion"]},
]
example["messages"] = messages
return example
def convert_open_orca_to_messages(example):
"""
Convert a question-response pair to a list of messages.
e.g. Open-Orca/OpenOrca"""
messages = [
{"role": "system", "content": example["system_prompt"]},
{"role": "user", "content": example["question"]},
{"role": "assistant", "content": example["response"]},
]
example["messages"] = messages
return example
def conversations_to_messages(example):
"""
Convert from conversations format to messages.
E.g. change "from": "user" to "role": "user"
and "value" to "content"
and "gpt" to "assistant"
WizardLMTeam/WizardLM_evol_instruct_V2_196k
"""
name_mapping = {
"gpt": "assistant",
"Assistant": "assistant",
"assistant": "assistant",
"user": "user",
"User": "user",
"human": "user",
}
messages = [{"role": name_mapping[conv["from"]], "content": conv["value"]} for conv in example["conversations"]]
example["messages"] = messages
return example
def convert_rejection_samples_to_messages(example):
"""
Convert a rejection sampling dataset to messages.
"""
example["messages"] = example["chosen"]
return example
def get_datasets(
dataset_mixer: Union[dict, list],
splits: Optional[List[str]] = None,
configs: Optional[List[str]] = None,
columns_to_keep: Optional[List[str]] = None,
shuffle: bool = True,
save_data_dir: Optional[str] = None,
need_columns: Optional[List[str]] = None,
keep_ids: bool = False,
add_source_col: bool = False,
) -> DatasetDict:
"""
Loads and mixes datasets according to proportions specified in `dataset_mixer`.
Args:
dataset_mixer (`list` or `dict`):
Dictionary or list containing the dataset names and their training proportions.
By default, all test proportions are 1. Lists are formatted as
`key1 value1 key2 value2 ...` If a list is passed in, it will be converted to a dictionary.
splits (Optional[List[str]], *optional*, defaults to `None`):
Dataset splits to load and mix. Assumes the splits exist in
all datasets and have a `train_` or `test_` prefix.
configs (Optional[List[str]], *optional*, defaults to `None`):
List of dataset config names. If given must be the same length as 'dataset_mixer' keys.
columns_to_keep (Optional[List[str]], *optional*, defaults to `None`):
Column names to keep in the dataset. Useful in the datamixer to avoid schema conflicts,
and for cpt this should be (at least) the text column.
shuffle (`bool`, *optional*, defaults to `True`):
Whether to shuffle the training and testing/validation data.
save_data_dir (Optional[str], *optional*, defaults to `None`):
Optional directory to save training/test mixes on.
need_columns (Optional[List[str]], *optional*, defaults to `None`):
Column names that are required to be in the dataset.
Quick debugging when mixing heterogeneous datasets.
keep_ids (`bool`, *optional*, defaults to `False`):
Whether to keep ids for training that are added during mixing.
Used primarily in mix_data.py for saving, or the saved dataset has IDs already.
add_source_col (`bool`, *optional*, defaults to `False`):
Whether to add a column to the dataset that indicates the source of the data explicitly.
"""
if isinstance(dataset_mixer, list):
assert len(dataset_mixer) % 2 == 0, f"Data mixer list length is not even: {dataset_mixer}"
mixer_dict = {}
i = 0
while i < len(dataset_mixer) - 1:
assert isinstance(dataset_mixer[i], str), f"Invalid type in data mixer: {dataset_mixer}"
if "." in dataset_mixer[i + 1]:
value = float(dataset_mixer[i + 1])
else:
value = int(dataset_mixer[i + 1])
mixer_dict[dataset_mixer[i]] = value
i += 2
dataset_mixer = mixer_dict
splits = ["train", "test"] if splits is None else splits
configs = [None] * len(dataset_mixer) if not configs else configs
columns_to_keep = [] if columns_to_keep is None else columns_to_keep
if configs is not None and len(configs) != len(dataset_mixer):
raise ValueError("The number of given dataset config names must be the same as the given number of datasets.")
# print save location
if save_data_dir:
print(f"Saving mixed dataset to {save_data_dir}")
raw_datasets = DatasetDict()
raw_train_datasets = []
raw_val_datasets = []
frac_or_sample_list = []
for (ds, frac_or_samples), ds_config in zip(dataset_mixer.items(), configs):
frac_or_sample_list.append(frac_or_samples)
for split in splits:
# if dataset ends with .json or .jsonl, load from file
if ds.endswith(".json") or ds.endswith(".jsonl"):
dataset = load_dataset("json", data_files=ds, split=split)
else:
try:
# Try first if dataset on a Hub repo
dataset = load_dataset(ds, ds_config, split=split)
except DatasetGenerationError:
# If not, check local dataset
dataset = load_from_disk(os.path.join(ds, split))
# shuffle dataset if set
if shuffle:
dataset = dataset.shuffle(seed=42)
# assert that needed columns are present
if need_columns:
if not all(col in dataset.column_names for col in need_columns):
raise ValueError(f"Needed column {need_columns} not found in dataset {dataset.column_names}.")
# handle per-case conversions
# if "instruction" and "output" columns are present and "messages" is not, convert to messages
if (
"instruction" in dataset.column_names
and "output" in dataset.column_names
and "messages" not in dataset.column_names
):
dataset = dataset.map(convert_alpaca_gpt4_to_messages, num_proc=10)
elif (
"prompt" in dataset.column_names
and "completion" in dataset.column_names
and "messages" not in dataset.column_names
):
dataset = dataset.map(convert_code_alpaca_to_messages, num_proc=10)
elif "conversations" in dataset.column_names and "messages" not in dataset.column_names:
dataset = dataset.map(conversations_to_messages, num_proc=10)
elif (
"question" in dataset.column_names
and "response" in dataset.column_names
and "messages" not in dataset.column_names
):
dataset = dataset.map(convert_open_orca_to_messages, num_proc=10)
elif (
"query" in dataset.column_names
and "answer" in dataset.column_names
and "messages" not in dataset.column_names
):
dataset = dataset.map(convert_codefeedback_single_turn_to_messages, num_proc=10)
elif (
"query" in dataset.column_names
and "response" in dataset.column_names
and "messages" not in dataset.column_names
):
dataset = dataset.map(convert_metamath_qa_to_messages, num_proc=10)
elif (
"chosen" in dataset.column_names
and "rejected" in dataset.column_names
and "reference_completion" in dataset.column_names
and "messages" not in dataset.column_names
):
dataset = dataset.map(convert_rejection_samples_to_messages, num_proc=10)
# if id not in dataset, create it as ds-{index}
if "id" not in dataset.column_names:
id_col = [f"{ds}_{i}" for i in range(len(dataset))]
dataset = dataset.add_column("id", id_col)
# Remove redundant columns to avoid schema conflicts on load
dataset = dataset.remove_columns(
[col for col in dataset.column_names if col not in (columns_to_keep + ["id"])]
)
# if add_source_col, add that column
if add_source_col:
source_col = [ds] * len(dataset)
dataset = dataset.add_column("source", source_col)
# for cols in columns_to_keep, if one is not present, add "None" to the column
for col in columns_to_keep:
if col not in dataset.column_names:
dataset = dataset.add_column(col, [None] * len(dataset))
# add tag to the dataset corresponding to where it was sourced from, for
if "train" in split:
raw_train_datasets.append(dataset)
elif "test" in split:
raw_val_datasets.append(dataset)
else:
raise ValueError(f"Split type {split} not recognized as one of test or train.")
if len(raw_val_datasets) == 0 and len(raw_train_datasets) == 0:
raise ValueError("No datasets loaded.")
elif len(raw_train_datasets) == 0:
# target features are the features of the first dataset post load
target_features = raw_val_datasets[0].features
else:
# target features are the features of the first dataset post load
target_features = raw_train_datasets[0].features
if any(frac_or_samples < 0 for frac_or_samples in frac_or_sample_list):
raise ValueError("Dataset fractions / lengths cannot be negative.")
# if any > 1, use count
if any(frac_or_samples > 1 for frac_or_samples in frac_or_sample_list):
is_count = True
# assert that all are integers
if not all(isinstance(frac_or_samples, int) for frac_or_samples in frac_or_sample_list):
raise NotImplementedError("Cannot mix fractions and counts, yet.")
else:
is_count = False
if len(raw_train_datasets) > 0:
train_subsets = []
# Manage proportions
for dataset, frac_or_samples in zip(raw_train_datasets, frac_or_sample_list):
# cast features (TODO, add more feature regularization)
dataset = dataset.cast(target_features)
# TODO selection can be randomized.
if is_count:
train_subset = dataset.select(range(frac_or_samples))
else:
train_subset = dataset.select(range(int(frac_or_samples * len(dataset))))
train_subsets.append(train_subset)
raw_datasets["train"] = concatenate_datasets(train_subsets)
# No subsampling for test datasets to enable fair comparison across models
if len(raw_val_datasets) > 0:
for dataset in raw_val_datasets:
# cast features (TODO, add more feature regularization)
dataset = dataset.cast(target_features)
raw_datasets["test"] = concatenate_datasets(raw_val_datasets)
if len(raw_datasets) == 0:
raise ValueError(
f"Dataset {dataset_mixer} not recognized with splits {splits}."
"Check the dataset has been correctly formatted."
)
# optional save
if save_data_dir:
for split in raw_datasets:
raw_datasets[split].to_json(save_data_dir + f"mixed_ds_{split}.json")
if not keep_ids:
# remove id column
if len(raw_train_datasets) > 0:
if "id" in raw_datasets["train"].column_names:
raw_datasets["train"] = raw_datasets["train"].remove_columns("id")
if len(raw_val_datasets) > 0:
if "id" in raw_datasets["test"].column_names:
raw_datasets["test"] = raw_datasets["test"].remove_columns("id")
return raw_datasets
def combine_dataset(
dataset_mixer: Union[dict, list],
splits: List[str],
configs: Optional[List[str]] = None,
columns_to_keep: Optional[List[str]] = None,
shuffle: bool = False,
save_data_dir: Optional[str] = None,
keep_ids: bool = False,
) -> DatasetDict:
"""
Loads and mixes datasets according to proportions specified in `dataset_mixer`.
Args:
dataset_mixer (`dict`):
Dictionary containing the dataset names and their training proportions.
splits (Optional[List[str]], *optional*, defaults to `None`):
Dataset splits to load and mix. Assumes the splits exist in
all datasets and have a `train_` or `test_` prefix.
configs (Optional[List[str]], *optional*, defaults to `None`):
List of dataset config names. If given must be the same length as 'dataset_mixer' keys.
columns_to_keep (Optional[List[str]], *optional*, defaults to `None`):
Column names to keep in the dataset. Useful in the datamixer to avoid schema conflicts,
and for cpt this should be (at least) the text column.
shuffle (`bool`, *optional*, defaults to `False`):
Whether to shuffle the training and testing/validation data.
save_data_dir (Optional[str], *optional*, defaults to `None`):
Optional directory to save training/test mixes on.
keep_ids (`bool`, *optional*, defaults to `False`):
Whether to keep ids for training that are added during mixing.
Used primarily in mix_data.py for saving, or the saved dataset has IDs already.
"""
assert len(splits) == len(dataset_mixer), "Number of splits must match the number of datasets."
if isinstance(dataset_mixer, list):
assert len(dataset_mixer) % 2 == 0, f"Data mixer list length is not even: {dataset_mixer}"
mixer_dict = {}
i = 0
while i < len(dataset_mixer) - 1:
assert isinstance(dataset_mixer[i], str), f"Invalid type in data mixer: {dataset_mixer}"
if "." in dataset_mixer[i + 1]:
value = float(dataset_mixer[i + 1])
else:
value = int(dataset_mixer[i + 1])
mixer_dict[dataset_mixer[i]] = value
i += 2
dataset_mixer = mixer_dict
if any(frac_or_samples < 0 for frac_or_samples in dataset_mixer.values()):
raise ValueError("Dataset fractions / lengths cannot be negative.")
configs = [None] * len(dataset_mixer) if not configs else configs
columns_to_keep = [] if columns_to_keep is None else columns_to_keep
if configs is not None and len(configs) != len(dataset_mixer):
raise ValueError("The number of given dataset config names must be the same as the given number of datasets.")
# print save location
if save_data_dir:
print(f"Saving mixed dataset to {save_data_dir}")
datasets = []
for (ds, frac_or_samples), ds_config, split in zip(dataset_mixer.items(), configs, splits):
# if dataset ends with .json or .jsonl, load from file
if ds.endswith(".json") or ds.endswith(".jsonl"):
dataset = load_dataset("json", data_files=ds, split=split)
else:
try:
# Try first if dataset on a Hub repo
dataset = load_dataset(ds, ds_config, split=split)
except DatasetGenerationError:
# If not, check local dataset
dataset = load_from_disk(os.path.join(ds, split))
# shuffle dataset if set
if shuffle:
dataset = dataset.shuffle(seed=42)
# select a fraction of the dataset
if frac_or_samples > 1.0:
samples = int(frac_or_samples)
else:
samples = int(frac_or_samples * len(dataset))
dataset = dataset.select(range(samples))
# if id not in dataset, create it as ds-{index}
if "id" not in dataset.column_names:
id_col = [f"{ds}_{i}_{split}" for i in range(len(dataset))]
dataset = dataset.add_column("id", id_col)
# Remove redundant columns to avoid schema conflicts on load
dataset = dataset.remove_columns(
[col for col in dataset.column_names if col not in (columns_to_keep + ["id"])]
)
datasets.append(dataset)
datasets = concatenate_datasets(datasets)
# optional save
if save_data_dir:
datasets.to_json(save_data_dir + "mixed_ds.json")
if not keep_ids:
# remove id column
if "id" in datasets.column_names:
datasets = datasets.remove_columns("id")
return datasets
# ----------------------------------------------------------------------------
# Arguments utilities
class ArgumentParserPlus(HfArgumentParser):
def parse_yaml_and_args(self, yaml_arg: str, other_args: Optional[List[str]] = None) -> List[dataclass]:
"""
Parse a YAML file and overwrite the default/loaded values with the values provided to the command line.
Args:
yaml_arg (`str`):
The path to the config file used
other_args (`List[str]`, *optional`):
A list of strings to parse as command line arguments, e.g. ['--arg=val', '--arg2=val2'].
Returns:
[`List[dataclass]`]: a list of dataclasses with the values from the YAML file and the command line
"""
arg_list = self.parse_yaml_file(os.path.abspath(yaml_arg))
outputs = []
# strip other args list into dict of key-value pairs
other_args = {arg.split("=")[0].strip("-"): arg.split("=")[1] for arg in other_args}
used_args = {}
# overwrite the default/loaded value with the value provided to the command line
# noqa adapted from https://github.com/huggingface/transformers/blob/d0b5002378daabf62769159add3e7d66d3f83c3b/src/transformers/hf_argparser.py#L327
for data_yaml, data_class in zip(arg_list, self.dataclass_types):
keys = {f.name for f in dataclasses.fields(data_yaml) if f.init}
inputs = {k: v for k, v in vars(data_yaml).items() if k in keys}
for arg, val in other_args.items():
# add only if in keys
if arg in keys:
base_type = data_yaml.__dataclass_fields__[arg].type
inputs[arg] = val
# cast type for ints, floats (default to strings)
if base_type in [int, float]:
inputs[arg] = base_type(val)
if base_type == List[str]:
inputs[arg] = [str(v) for v in val.split(",")]
# bool of a non-empty string is True, so we manually check for bools
if base_type == bool:
if val in ["true", "True"]:
inputs[arg] = True
else:
inputs[arg] = False
# add to used-args so we can check if double add
if arg not in used_args:
used_args[arg] = val
else:
raise ValueError(f"Duplicate argument provided: {arg}, may cause unexpected behavior")
obj = data_class(**inputs)
outputs.append(obj)
return outputs
def parse(self) -> Union[DataClassType, Tuple[DataClassType]]:
if len(sys.argv) == 2 and sys.argv[1].endswith(".yaml"):
# If we pass only one argument to the script and it's the path to a YAML file,
# let's parse it to get our arguments.
output = self.parse_yaml_file(os.path.abspath(sys.argv[1]))
# parse command line args and yaml file
elif len(sys.argv) > 2 and sys.argv[1].endswith(".yaml"):
output = self.parse_yaml_and_args(os.path.abspath(sys.argv[1]), sys.argv[2:])
# parse command line args only
else:
output = self.parse_args_into_dataclasses()
if len(output) == 1:
output = output[0]
return output
# ----------------------------------------------------------------------------
# Experiment tracking utilities
def get_git_tag() -> str:
"""Try to get the latest Git tag (e.g., `no-tag-404-g98dc659` or `v1.0.0-4-g98dc659`)"""
git_tag = ""
try:
git_tag = (
subprocess.check_output(["git", "describe", "--tags"], stderr=subprocess.DEVNULL).decode("ascii").strip()
)
except subprocess.CalledProcessError as e:
logging.debug(f"Failed to get Git tag: {e}")
# If no Git tag found, create a custom tag based on commit count and hash
if len(git_tag) == 0:
try:
count = int(
subprocess.check_output(["git", "rev-list", "--count", "HEAD"], stderr=subprocess.DEVNULL)
.decode("ascii")
.strip()
)
hash = (
subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL)
.decode("ascii")
.strip()
)
git_tag = f"no-tag-{count}-g{hash}"
except subprocess.CalledProcessError as e:
logging.debug(f"Failed to get commit count and hash: {e}")
return git_tag
def get_pr_tag() -> str:
"""Try to find associated pull request on GitHub (e.g., `pr-123`)"""
pr_tag = ""
try:
git_commit = (
subprocess.check_output(["git", "rev-parse", "--verify", "HEAD"], stderr=subprocess.DEVNULL)
.decode("ascii")
.strip()
)
# try finding the pull request number on github
prs = requests.get(f"https://api.github.com/search/issues?q=repo:allenai/open-instruct+is:pr+{git_commit}")
if prs.status_code == 200:
prs = prs.json()
if len(prs["items"]) > 0:
pr = prs["items"][0]
pr_number = pr["number"]
pr_tag = f"pr-{pr_number}"
except Exception as e:
logging.debug(f"Failed to get PR number: {e}")
return pr_tag
def get_wandb_tags() -> List[str]:
"""Get tags for Weights & Biases (e.g., `no-tag-404-g98dc659,pr-123`)"""
existing_wandb_tags = os.environ.get("WANDB_TAGS", "")
git_tag = get_git_tag()
pr_tag = get_pr_tag()
non_empty_tags = [tag for tag in [existing_wandb_tags, git_tag, pr_tag] if len(tag) > 0]
return non_empty_tags
# ----------------------------------------------------------------------------
# Check pointing utilities
def get_last_checkpoint(folder: str, incomplete: bool = False) -> Optional[str]:
content = os.listdir(folder)
checkpoint_steps = [path for path in content if path.startswith("step_")]
checkpoint_epochs = [path for path in content if path.startswith("epoch_")]
if len(checkpoint_steps) > 0 and len(checkpoint_epochs) > 0:
logger.info("Mixed step and epoch checkpoints found. Using step checkpoints.")
checkpoints = checkpoint_steps
elif len(checkpoint_steps) == 0:
checkpoints = checkpoint_epochs
else:
checkpoints = checkpoint_steps
if not incomplete:
checkpoints = [path for path in checkpoints if os.path.exists(os.path.join(folder, path, "COMPLETED"))]
if len(checkpoints) == 0:
return
return os.path.join(folder, max(checkpoints, key=lambda x: int(x.split("_")[-1])))
def get_last_checkpoint_path(args, incomplete: bool = False) -> str:
# if output already exists and user does not allow overwriting, resume from there.
# otherwise, resume if the user specifies a checkpoint.
# else, start from scratch.
# if incomplete is true, include folders without "COMPLETE" in the folder.
last_checkpoint_path = None
if args.output_dir and os.path.isdir(args.output_dir) and not args.overwrite_output_dir:
last_checkpoint_path = get_last_checkpoint(args.output_dir, incomplete=incomplete)
if last_checkpoint_path is None:
logger.warning("Output directory exists but no checkpoint found. Starting from scratch.")
elif args.resume_from_checkpoint:
last_checkpoint_path = args.resume_from_checkpoint
return last_checkpoint_path
def is_checkpoint_folder(dir: str, folder: str) -> bool:
return (folder.startswith("step_") or folder.startswith("epoch_")) and os.path.isdir(os.path.join(dir, folder))
def clean_last_n_checkpoints(output_dir: str, keep_last_n_checkpoints: int) -> None:
# remove the last checkpoint to save space
folders = [f for f in os.listdir(output_dir) if is_checkpoint_folder(output_dir, f)]
# find the checkpoint with the largest step
checkpoints = sorted(folders, key=lambda x: int(x.split("_")[-1]))
if keep_last_n_checkpoints >= 0 and len(checkpoints) > keep_last_n_checkpoints:
for checkpoint in checkpoints[: len(checkpoints) - keep_last_n_checkpoints]:
logger.info(f"Removing checkpoint {checkpoint}")
shutil.rmtree(os.path.join(output_dir, checkpoint))
logger.info("Remaining files:" + str(os.listdir(output_dir)))
# ----------------------------------------------------------------------------
# Ai2 user utilities
@dataclass
class BeakerRuntimeConfig:
beaker_workload_id: str
beaker_node_hostname: Optional[List[str]] = None
beaker_experiment_url: Optional[List[str]] = None
beaker_dataset_ids: Optional[List[str]] = None
beaker_dataset_id_urls: Optional[List[str]] = None
def is_beaker_job() -> bool:
return "BEAKER_JOB_ID" in os.environ
def get_beaker_experiment_info(experiment_id: str) -> Optional[dict]:
get_experiment_command = f"beaker experiment get {experiment_id} --format json"
process = subprocess.Popen(["bash", "-c", get_experiment_command], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = process.communicate()
if process.returncode != 0:
print(f"Failed to get Beaker experiment: {stderr}")
return None
return json.loads(stdout)[0]
def beaker_experiment_succeeded(experiment_id: str) -> bool:
experiment = get_beaker_experiment_info(experiment_id)
if "replicas" in experiment["jobs"][0]["execution"]["spec"]:
num_replicas = experiment["jobs"][0]["execution"]["spec"]["replicas"]
else:
num_replicas = 1
if not experiment:
return False
pprint(experiment)
finalizeds = [
"finalized" in job["status"] and "exitCode" in job["status"] and job["status"]["exitCode"] == 0
for job in experiment["jobs"]
]
pprint(finalizeds)
return sum(finalizeds) == num_replicas
@dataclass
class DatasetInfo:
id: str
committed: Any
non_empty: bool
def get_beaker_dataset_ids(experiment_id: str, sort=False) -> Optional[List[str]]:
"""if sort is True, the non-empty latest dataset will be availble at the end of the list"""
experiment = get_beaker_experiment_info(experiment_id)
if not experiment:
return None
result_ids = [job["result"]["beaker"] for job in experiment["jobs"]]
dataset_infos = []
for result_id in result_ids:
get_dataset_command = f"beaker dataset get {result_id} --format json"
process = subprocess.Popen(["bash", "-c", get_dataset_command], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = process.communicate()
if process.returncode != 0:
print(f"Failed to get Beaker dataset: {stderr}")
return None
datasets = json.loads(stdout)
dataset_infos.extend(
[
DatasetInfo(
id=dataset["id"],
committed=dataset["committed"],
non_empty=(
False if dataset["storage"]["totalSize"] is None else dataset["storage"]["totalSize"] > 0
),
)
for dataset in datasets
]
)
if sort:
# sort based on empty, then commited
dataset_infos.sort(key=lambda x: (x.non_empty, parser.parse(x.committed)))
pprint(dataset_infos)
return [dataset.id for dataset in dataset_infos]
def get_beaker_whoami() -> Optional[str]:
get_beaker_whoami_command = "beaker account whoami --format json"
process = subprocess.Popen(
["bash", "-c", get_beaker_whoami_command], stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
stdout, stderr = process.communicate()
if process.returncode != 0:
print(f"Failed to get Beaker account: {stderr}")
return None
accounts = json.loads(stdout)
return accounts[0]["name"]
def maybe_get_beaker_config():
beaker_dataset_ids = get_beaker_dataset_ids(os.environ["BEAKER_WORKLOAD_ID"])
# fix condition on basic interactive jobs
if beaker_dataset_ids is None:
beaker_dataset_id_urls = []
else:
beaker_dataset_id_urls = [f"https://beaker.org/ds/{dataset_id}" for dataset_id in beaker_dataset_ids]
return BeakerRuntimeConfig(
beaker_workload_id=os.environ["BEAKER_WORKLOAD_ID"],
beaker_node_hostname=os.environ["BEAKER_NODE_HOSTNAME"],
beaker_experiment_url=f"https://beaker.org/ex/{os.environ['BEAKER_WORKLOAD_ID']}/",
beaker_dataset_ids=get_beaker_dataset_ids(os.environ["BEAKER_WORKLOAD_ID"]),
beaker_dataset_id_urls=beaker_dataset_id_urls,
)
def retry_on_exception(max_attempts=4, delay=1, backoff=2):
"""
Retry a function on exception. Useful for HF API calls that may fail due to
network issues. E.g., https://beaker.org/ex/01J69P87HJQQ7X5DXE1CPWF974
`huggingface_hub.utils._errors.HfHubHTTPError: 429 Client Error`
We can test it with the following code.
@retry_on_exception(max_attempts=4, delay=1, backoff=2)
def test():
raise Exception("Test exception")
test()
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
attempts = 0
local_delay = delay
while attempts < max_attempts:
try:
return func(*args, **kwargs)
except Exception as e:
attempts += 1
if attempts == max_attempts:
raise e
print(f"Attempt {attempts} failed. Retrying in {local_delay} seconds...")
time.sleep(local_delay)
local_delay *= backoff
return None
return wrapper
return decorator
@retry_on_exception()
def maybe_use_ai2_wandb_entity() -> Optional[str]:
"""Ai2 internal logic: try use the ai2-llm team if possible. Should not affect external users."""
import wandb
wandb.login()
api = wandb.Api()
current_user = api.viewer
teams = current_user.teams
if "ai2-llm" in teams:
return "ai2-llm"
else:
return None
@retry_on_exception()
def maybe_use_ai2_hf_entity() -> Optional[str]:
"""Ai2 internal logic: try use the allenai entity if possible. Should not affect external users."""
orgs = HfApi().whoami()
orgs = [item["name"] for item in orgs["orgs"]]
if "allenai" in orgs:
return "allenai"
else:
return None
@retry_on_exception()
def upload_metadata_to_hf(
metadata_dict,
filename,
hf_dataset_name,
hf_dataset_save_dir,
):
# upload a random dict to HF. Originally for uploading metadata to HF
# about a model for leaderboard displays.
with open("tmp.json", "w") as f:
json.dump(metadata_dict, f)
api = HfApi()
api.upload_file(
path_or_fileobj="tmp.json",
path_in_repo=f"{hf_dataset_save_dir}/{filename}",
repo_id=hf_dataset_name,
repo_type="dataset",
)
os.remove("tmp.json")