From fac5fdcf3f9b85b126ce94bb2e36c8ae0ba661e0 Mon Sep 17 00:00:00 2001 From: plaguss Date: Mon, 20 May 2024 16:23:00 +0200 Subject: [PATCH 01/40] Prepare branch for v1.2.0 --- src/distilabel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/distilabel/__init__.py b/src/distilabel/__init__.py index 50a9003385..574194b287 100644 --- a/src/distilabel/__init__.py +++ b/src/distilabel/__init__.py @@ -14,6 +14,6 @@ from rich import traceback as rich_traceback -__version__ = "1.1.0" +__version__ = "1.2.0" rich_traceback.install(show_locals=True) From 7ba83d249c04934acfebca9c59bbb7e0d5c8e1be Mon Sep 17 00:00:00 2001 From: Alvaro Bartolome Date: Wed, 22 May 2024 08:31:52 +0200 Subject: [PATCH 02/40] Add `prometheus.md` (#656) * Fix anchor in `structured_generation.md` * Fix reference to `ultrafeedback.md` in `argilla.md` * Add `prometheus.md` * Apply suggestions from code review Co-authored-by: Agus --------- Co-authored-by: Agus --- docs/sections/learn/advanced/argilla.md | 2 +- .../learn/advanced/structured_generation.md | 2 +- .../pipeline_samples/examples/index.md | 2 +- .../pipeline_samples/papers/prometheus.md | 121 ++++++++++++++++++ mkdocs.yml | 2 +- 5 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 docs/sections/pipeline_samples/papers/prometheus.md diff --git a/docs/sections/learn/advanced/argilla.md b/docs/sections/learn/advanced/argilla.md index 23f9649ded..bea24b74b7 100644 --- a/docs/sections/learn/advanced/argilla.md +++ b/docs/sections/learn/advanced/argilla.md @@ -115,4 +115,4 @@ pipeline.run() ![Preference to Argilla](../../../assets/images/sections/learn/steps/argilla/preference.png) !!! NOTE - If you are willing to also add the suggestions, feel free to check ["UltraFeedback: Boosting Language Models with High-quality Feedback"](../../examples/papers/ultrafeedback.md) where the [`UltraFeedback`][distilabel.steps.tasks.UltraFeedback] task is used to generate both ratings and rationales for each of the generations of a given instruction. + If you are willing to also add the suggestions, feel free to check ["UltraFeedback: Boosting Language Models with High-quality Feedback"](../../pipeline_samples/papers/ultrafeedback.md) where the [`UltraFeedback`][distilabel.steps.tasks.UltraFeedback] task is used to generate both ratings and rationales for each of the generations of a given instruction. diff --git a/docs/sections/learn/advanced/structured_generation.md b/docs/sections/learn/advanced/structured_generation.md index c9d86c2816..c0ba743ad4 100644 --- a/docs/sections/learn/advanced/structured_generation.md +++ b/docs/sections/learn/advanced/structured_generation.md @@ -103,7 +103,7 @@ These were some simple examples, but one can see the options this opens. !!! NOTE A full pipeline example can be seen in the following script: - [`examples/structured_generation_with_outlines.py`](../../examples/index.md/#structured_generation_with_outlines) + [`examples/structured_generation_with_outlines.py`](../../pipeline_samples/examples/index.md#llama-cpp-with-outlines) [^1]: You can check the variable type by importing it from: diff --git a/docs/sections/pipeline_samples/examples/index.md b/docs/sections/pipeline_samples/examples/index.md index c6117e3ddb..3958fe55c0 100644 --- a/docs/sections/pipeline_samples/examples/index.md +++ b/docs/sections/pipeline_samples/examples/index.md @@ -2,7 +2,7 @@ This section contains different example pipelines that showcase different tasks, maybe you can take inspiration from them. -### llama.cpp with outlines +### [llama.cpp with outlines](#llama-cpp-with-outlines) Generate RPG characters following a `pydantic.BaseModel` with `outlines` in `distilabel`. diff --git a/docs/sections/pipeline_samples/papers/prometheus.md b/docs/sections/pipeline_samples/papers/prometheus.md new file mode 100644 index 0000000000..ca148d00ac --- /dev/null +++ b/docs/sections/pipeline_samples/papers/prometheus.md @@ -0,0 +1,121 @@ +# Prometheus 2 + +["Prometheus 2: An Open Source Language Model Specialized in Evaluating Other Language Models"](https://arxiv.org/pdf/2405.01535) presents Prometheus 2, a new and more powerful evaluator LLM compared to Prometheus (its predecessor) presented in ["Prometheus: Inducing Fine-grained Evaluation Capability in Language Models"](https://arxiv.org/abs/2310.08491); since GPT-4, as well as other proprietary LLMs, are commonly used to asses the quality of the responses for various LLMs, but there are concerns about transparency, controllability, and affordability, that motivate the need of open-source LLMs specialized in evaluations. + +Existing open evaluator LMs exhibit critical shortcomings: + +1. They issue scores that significantly diverge from those assigned by humans. +2. They lack the flexibility to perform both direct assessment and pairwise ranking, the two most prevalent forms of assessment. + +Additionally, they do not possess the ability to evaluate based on custom evaluation criteria, focusing instead on general attributes like helpfulness and harmlessness. Prometheus 2 is capable of processing both direct assessment and pair-wise ranking formats grouped with a user-defined evaluation criteria. + +Prometheus 2 released two variants: + +- [`prometheus-eval/prometheus-7b-v2.0`](https://hf.co/prometheus-eval/prometheus-7b-v2.0): fine-tuned on top of [`mistralai/Mistral-7B-Instruct-v0.2`](https://hf.co/mistralai/Mistral-7B-Instruct-v0.2) +- [`prometheus-eval/prometheus-8x7b-v2.0`](https://hf.co/prometheus-eval/prometheus-8x7b-v2.0): fine-tuned on top of [`mistralai/Mixtral-8x7B-Instruct-v0.1`](https://hf.co/mistralai/Mixtral-8x7B-Instruct-v0.1) + +Both models have been fine-tuned for both direct assessment and pairwise ranking tasks i.e. assessing the quality of a single isolated response for a given instruction with or without a reference answer, and assessing the quality of one response against another one for a given instruction with or without a reference answer, respectively. + +On four direct assessment benchmarks and four pairwise ranking benchmarks, Prometheus 2 scores the highest correlation and agreement with humans and proprietary LM judges among all tested open evaluator LMs. Their models, code, and data are all publicly available at [`prometheus-eval/prometheus-eval`](https://github.com/prometheus-eval/prometheus-eval). + +### Replication + +!!! NOTE + The section is named `Replication` but in this case we're not replicating the Prometheus 2 paper per se, but rather showing how to use the [`PrometheusEval`][distilabel.steps.tasks.PrometheusEval] task implemented within `distilabel` to evaluate the quality of the responses from a given instruction using the Prometheus 2 model. + +To showcase Prometheus 2 we will be using the [`PrometheusEval`][distilabel.steps.tasks.PrometheusEval] task implemented in `distilabel` and a smaller dataset created by the Hugging Face H4 team named [`HuggingFaceH4/instruction-dataset`](https://hf.co/datasets/HuggingFaceH4/instruction-dataset) for testing purposes. + +#### Installation + +To reproduce the code below, one will need to install `distilabel` as it follows: + +```bash +pip install "distilabel[vllm]>=1.1.0" +``` + +Alternatively, it's recommended to install [`Dao-AILab/flash-attention`](https://github.com/Dao-AILab/flash-attention) to benefit from Flash Attention 2 speed ups during inference via `vllm`. + +```bash +pip install flash-attn --no-build-isolation +``` + +!!! NOTE + The installation notes above assume that you are using a VM with one GPU accelerator with at least the required VRAM to fit [`prometheus-eval/prometheus-7b-v2.0`](https://hf.co/prometheus-eval/prometheus-7b-v2.0) in bfloat16 (28GB); but if you have enough VRAM to fit their 8x7B model in bfloat16 (~90GB) you can use [`prometheus-eval/prometheus-8x7b-v2.0`](https://hf.co/prometheus-eval/prometheus-8x7b-v2.0) instead. + +#### Building blocks + +- [`LoadHubDataset`][distilabel.steps.LoadHubDataset]: [`GeneratorStep`][distilabel.steps.GeneratorStep] to load a dataset from the Hugging Face Hub. + +- [`PrometheusEval`][distilabel.steps.tasks.PrometheusEval]: [`Task`][distilabel.steps.tasks.Task] that assesses the quality of a response for a given instruction using any of the Prometheus 2 models. + - [`vLLM`][distilabel.llms.vLLM]: [`LLM`][distilabel.llms.LLM] that loads a model from the Hugging Face Hub via [vllm-project/vllm](https://github.com/vllm-project/vllm). + + !!! NOTE + Since the Prometheus 2 models use a slightly different chat template than [`mistralai/Mistral-7B-Instruct-v0.2`](https://hf.co/mistralai/Mistral-7B-Instruct-v0.2), we need to set the `chat_template` parameter to `[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]` so as to properly format the input for Prometheus 2. + +- (Optional) [`KeepColumns`][distilabel.steps.KeepColumns]: [`Task`][distilabel.steps.tasks.Task] that keeps only the specified columns in the dataset, used to remove the undesired columns. + +#### Code + +As mentioned before, we will put the previously mentioned building blocks together to see how Prometheus 2 can be used via `distilabel`. + +```python +from distilabel.llms import vLLM +from distilabel.pipeline import Pipeline +from distilabel.steps import KeepColumns, LoadHubDataset +from distilabel.steps.tasks import PrometheusEval + +if __name__ == "__main__": + with Pipeline(name="prometheus") as pipeline: + load_dataset = LoadHubDataset( + name="load_dataset", + repo_id="HuggingFaceH4/instruction-dataset", + split="test", + output_mappings={"prompt": "instruction", "completion": "generation"}, + ) + + task = PrometheusEval( + name="task", + llm=vLLM( + model="prometheus-eval/prometheus-7b-v2.0", + chat_template="[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]", + ), + mode="absolute", + rubric="factual-validity", + reference=False, + num_generations=1, + group_generations=False, + ) + + keep_columns = KeepColumns( + name="keep_columns", + columns=["instruction", "generation", "feedback", "result", "model_name"], + ) + + load_dataset >> task >> keep_columns +``` + +Then we need to call `pipeline.run` with the runtime parameters so that the pipeline can be launched. + +```python +distiset = pipeline.run( + parameters={ + task.name: { + "llm": { + "generation_kwargs": { + "max_new_tokens": 1024, + "temperature": 0.7, + }, + }, + }, + }, +) +``` + +Finally, we can optionally push the generated dataset, named [`Distiset`][distilabel.distiset.Distiset], to the Hugging Face Hub via the `push_to_hub` method, so that each subset generated in the leaf steps is pushed to the Hub. + +```python +distiset.push_to_hub( + "instruction-dataset-prometheus", + private=True, +) +``` diff --git a/mkdocs.yml b/mkdocs.yml index 5665237fbf..d525d1828e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -149,7 +149,7 @@ nav: - "sections/pipeline_samples/papers/index.md" - DEITA: "sections/pipeline_samples/papers/deita.md" - Instruction Backtranslation: "sections/pipeline_samples/papers/instruction_backtranslation.md" - # - Prometheus: "sections/examples/papers/prometheus.md" + - Prometheus 2: "sections/pipeline_samples/papers/prometheus.md" - UltraFeedback: "sections/pipeline_samples/papers/ultrafeedback.md" - FAQ: "sections/faq.md" - API Reference: From 7210537ed1ac6b831a6b2ea26b182f25f6130d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Fri, 24 May 2024 19:50:44 +0200 Subject: [PATCH 03/40] Reduce time required to execute `_cache` method (#672) * Use `orjson` to serialize * Fix `KeyError` when leaf step didn't produce any data * Add `get_data` method * Update save and load methods for `_BatchManager` so is fast * Fix no cache after receiving batch from generator step * Override `from_json` instead of `from_dict` * Add `cache` and `load_from_cache` methods * Update unit tests * Add missing unit tests * Fix key has to be `str` --- pyproject.toml | 1 + src/distilabel/pipeline/base.py | 279 ++++++---- src/distilabel/pipeline/local.py | 16 +- src/distilabel/utils/serialization.py | 32 +- tests/unit/pipeline/test_base.py | 751 +++++++++++++++++++++----- 5 files changed, 820 insertions(+), 259 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3317bf5b5..e22c80a16e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "scipy >= 1.10.0", "typer >= 0.9.0", "tblib >= 3.0.0", + "orjson >= 3.10.0", ] dynamic = ["version"] diff --git a/src/distilabel/pipeline/base.py b/src/distilabel/pipeline/base.py index f67158dfc4..56069b6c7b 100644 --- a/src/distilabel/pipeline/base.py +++ b/src/distilabel/pipeline/base.py @@ -17,7 +17,7 @@ import logging import os from collections import defaultdict -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from pathlib import Path from typing import ( TYPE_CHECKING, @@ -44,7 +44,12 @@ STEP_ATTR_NAME, ) from distilabel.utils.files import list_files_in_dir -from distilabel.utils.serialization import TYPE_INFO_KEY, _Serializable +from distilabel.utils.serialization import ( + TYPE_INFO_KEY, + _check_is_dir, + _Serializable, + read_json, +) if TYPE_CHECKING: from os import PathLike @@ -52,7 +57,7 @@ from distilabel.distiset import Distiset from distilabel.pipeline.routing_batch_function import RoutingBatchFunction from distilabel.steps.base import _Step - from distilabel.utils.serialization import SaveFormats, StrOrPath + from distilabel.utils.serialization import StrOrPath BASE_CACHE_DIR = Path.home() / ".cache" / "distilabel" / "pipelines" @@ -252,14 +257,14 @@ def dry_run( """Do a dry run to test the pipeline runs as expected. Running a `Pipeline` in dry run mode will set all the `batch_size` of generator steps - to the specified batch_size, and run just with a single batch, effectively - running the whole pipeline with a single example. The cache will be set to False. + to the specified `batch_size`, and run just with a single batch, effectively + running the whole pipeline with a single example. The cache will be set to `False`. Args: - parameters: The same parameters variable from `BasePipeline.run`. Defaults to None. - Will be passed to the parent method, but with the batch_size of the generator steps - fixed to 1. - batch_size: The batch size to test the pipeline. Defaults to 1. + parameters: A dictionary with the step name as the key and a dictionary with + the runtime parameters for the step as the value. Defaults to `None`. + batch_size: The batch size of the unique batch generated by the generators + steps of the pipeline. Defaults to `1`. Returns: Will return the `Distiset` as the main run method would do. @@ -416,10 +421,7 @@ def _cache(self) -> None: format=self._cache_location["pipeline"].suffix.replace(".", ""), # type: ignore ) if self._batch_manager is not None: - self._batch_manager.save( - self._cache_location["batch_manager"], - format=self._cache_location["batch_manager"].suffix.replace(".", ""), # type: ignore - ) + self._batch_manager.cache(self._cache_location["batch_manager"]) self._logger.debug("Pipeline and batch manager saved to cache.") def _load_from_cache(self) -> None: @@ -429,7 +431,7 @@ def _load_from_cache(self) -> None: cache_loc = self._cache_location if cache_loc["pipeline"].exists(): if cache_loc["batch_manager"].exists(): - self._batch_manager = _BatchManager.from_json( + self._batch_manager = _BatchManager.load_from_cache( cache_loc["batch_manager"] ) self._logger.info("💾 Load pipeline from cache") @@ -454,6 +456,7 @@ class _Batch(_Serializable): step_name: str last_batch: bool data: List[List[Dict[str, Any]]] = field(default_factory=list, repr=False) + data_hash: Optional[str] = None accumulated: bool = False created_from: Dict[str, List[Tuple[int, int]]] = field(default_factory=dict) batch_routed_to: List[str] = field(default_factory=list) @@ -480,6 +483,33 @@ def set_data(self, data: List[List[Dict[str, Any]]]) -> None: """ self.data = data self.size = len(data[0]) + self._update_data_hash() + + def get_data(self, num_rows: Union[int, None] = None) -> List[Dict[str, Any]]: + """Takes `num_rows` from the data of the batch and returns it. This method will + also remove the data from the batch and update the hash of the data. + + Args: + num_rows: The number of rows to take from the data. If `None`, then all the + data will be taken. Defaults to `None`. + + Returns: + A list with the data taken from the batch. + """ + + if num_rows is None: + data = self.data[0] + self.data = [] + else: + data = self.data[0][:num_rows] + self.data[0] = self.data[0][num_rows:] + + self._update_data_hash() + return data + + def _update_data_hash(self) -> None: + """Updates the hash of the data of the batch.""" + self.data_hash = hashlib.sha1(str(self.data).encode()).hexdigest() @classmethod def accumulate(cls, step_name: str, batches: List[List["_Batch"]]) -> "_Batch": @@ -513,7 +543,24 @@ def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: Returns: A `dict` containing the internal representation of the `_Batch`. """ - return asdict(self) + + include_batch_data = kwargs.get("include_batch_data", True) + + dump = { + "seq_no": self.seq_no, + "step_name": self.step_name, + "last_batch": self.last_batch, + "data_hash": self.data_hash, + "accumulated": self.accumulated, + "created_from": self.created_from, + "batch_routed_to": self.batch_routed_to, + "size": self.size, + } + + if include_batch_data: + dump["data"] = self.data + + return dump def copy(self) -> "_Batch": """Creates a copy of the `_Batch` instance. @@ -564,7 +611,7 @@ class _BatchManagerStep(_Serializable): seq_no: int = 0 last_batch_received: List[str] = field(default_factory=list) convergence_step: bool = False - convergence_step_batches_consumed: Dict[int, Dict[str, int]] = field( + convergence_step_batches_consumed: Dict[str, Dict[str, int]] = field( default_factory=dict ) next_expected_created_from_batch_seq_no: int = 0 @@ -716,7 +763,7 @@ def _get_data_for_accumulate( batches_used[step_name] = [] for batch in batches: batches_used[step_name].append((batch.seq_no, batch.size)) - data.append([row for batch in batches for row in batch.data[0]]) + data.append([row for batch in batches for row in batch.get_data()]) # Reset the data buffer self.data = {step_name: [] for step_name in self.data} return data, batches_used @@ -734,25 +781,26 @@ def _get_data_for_convergence_step( """ grouped_batches = self._group_batches_by_created_from() seq_no, batches = grouped_batches[0] + str_seq_no = str(seq_no) - remaining_rows_per_step = { - step_name: self.input_batch_size for step_name in self.data + remaining_rows_per_step: Dict[str, int] = { + step_name: self.input_batch_size + for step_name in self.data # type: ignore } batches_used = defaultdict(list) data = defaultdict(list) for batch, batch_size in batches: - batch_data = batch.data[0] remaining_rows = remaining_rows_per_step[batch.step_name] - selected_data = batch_data[:remaining_rows] + selected_data = batch.get_data(remaining_rows) data[batch.step_name].extend(selected_data) # If A -> [B, C] -> D, then in D (this step) we keep track of the remaining # rows from the batches of A that B and C used to create the `batches`. batch_size = self.convergence_step_batches_consumed.setdefault( - seq_no, {} + str_seq_no, {} ).get(batch.step_name, batch_size) remaining_rows_in_batch = batch_size - len(selected_data) - self.convergence_step_batches_consumed[seq_no].update( + self.convergence_step_batches_consumed[str_seq_no].update( {batch.step_name: remaining_rows_in_batch} ) @@ -764,22 +812,16 @@ def _get_data_for_convergence_step( batches_used[batch.step_name].append((batch.seq_no, batch.size)) # If the batch was entirely consumed, then remove it from the buffer - if num_rows >= len(batch_data): + if len(batch.data[0]) == 0: self.data[batch.step_name].remove(batch) continue - # The batch was not entirely consumed. so we need to update the batch - # with the remaining data - batch_idx = self.data[batch.step_name].index(batch) - batch_ref = self.data[batch.step_name][batch_idx] - batch_ref.data[0] = batch_data[len(selected_data) :] - # If all the batches grouped by the `seq_no` in `created_from` were consumed, then # we can update the `next_expected_created_from_batch_seq_no` to the next one # to avoid skipping batches. no_remaining_rows = all( count == 0 - for count in self.convergence_step_batches_consumed[seq_no].values() + for count in self.convergence_step_batches_consumed[str_seq_no].values() ) if no_remaining_rows: self.next_expected_created_from_batch_seq_no += 1 @@ -816,8 +858,7 @@ def _get_data_normal( # Get `remaining_rows` or the remaining rows in the batch and add it to # the step data that will be used to create the batch - batch_data = batch.data[0] - selected_data = batch_data[:remaining_rows] + selected_data = batch.get_data(remaining_rows) step_data.extend(selected_data) batch_routed_to = batch.batch_routed_to @@ -829,14 +870,10 @@ def _get_data_normal( batches_used[step_name].append((batch.seq_no, batch.size)) # If the batch was entirely consumed, then remove it from the buffer - if num_rows >= len(batch_data): + if len(batch.data[0]) == 0: idx_drop_batches.append(idx) continue - # The batch was not entirely consumed. so we need to update the batch - # with the remaining data - batch.data[0] = batch_data[len(selected_data) :] - # Remove the batches that were entirely consumed idx_drop_batches.reverse() for idx in idx_drop_batches: @@ -1039,7 +1076,20 @@ def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: Returns: Internal representation of the `_BatchManagerStep`. """ - return asdict(self) + return { + "step_name": self.step_name, + "accumulate": self.accumulate, + "input_batch_size": self.input_batch_size, + "data": { + step_name: [batch.dump(**kwargs) for batch in batches] + for step_name, batches in self.data.items() + }, + "seq_no": self.seq_no, + "last_batch_received": self.last_batch_received, + "convergence_step": self.convergence_step, + "convergence_step_batches_consumed": self.convergence_step_batches_consumed, + "next_expected_created_from_batch_seq_no": self.next_expected_created_from_batch_seq_no, + } LAST_BATCH_SENT_FLAG = "last_batch_sent" @@ -1238,83 +1288,111 @@ def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: Dict[str, Any]: Internal representation of the `_BatchManager`. """ return { - "steps": {name: step.dump() for name, step in self._steps.items()}, + "steps": {name: step.dump(**kwargs) for name, step in self._steps.items()}, "last_batch_received": { - step_name: batch.dump() if batch is not None else None + step_name: batch.dump(**kwargs) if batch is not None else None for step_name, batch in self._last_batch_received.items() }, "last_batch_sent": { - step_name: batch.dump() if batch is not None else None + step_name: batch.dump(**kwargs) if batch is not None else None for step_name, batch in self._last_batch_sent.items() }, "last_batch_flag_sent_to": self._last_batch_flag_sent_to, } - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "_BatchManager": - """Loads a `_BatchManager` from its serialized content in a dictionary. - - Args: - data: The serialized batch manager. - - Returns: - A `_BatchManager` instance. - """ - # Remove the type info, we already know its a `_BatchManager`, and there aren't subclasses of it - data.pop(TYPE_INFO_KEY) - # Also there is only one type of `_BatchManagerStep`, so we can call it directly instead of generically - # via `_get_module_attr` - return cls( - { - name: _BatchManagerStep.from_file(step_path) - for name, step_path in data["steps"].items() - }, - { - step_name: _Batch.from_dict(batch) if batch is not None else None - for step_name, batch in data["last_batch_received"].items() - }, - { - step_name: _Batch.from_dict(batch) if batch is not None else None - for step_name, batch in data["last_batch_sent"].items() - }, - data["last_batch_flag_sent_to"], - ) - - def save( - self, - path: Union["StrOrPath", None] = None, - format: "SaveFormats" = "json", - dump: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> None: - """Overrides the parent method to save the each `_BatchManagerStep` to a file, and the contents - keep in the `_BatchManager` dump the paths to those files. - - Note: - Not expected to be used directly, but through the `Pipeline._cache` class. + def cache(self, path: "StrOrPath") -> None: + """Cache the `_BatchManager` to a file. Args: - path: filename of the object to save. If a folder is given, will create the object - inside. If None is given, the file will be created at the current - working directory. Defaults to None. - format: the format to use when saving the file. Valid options are 'json' and - 'yaml'. Defaults to `"json"`. - dump: the serialized object to save. If None, the object will be serialized using - the default self.dump. This variable is here to allow extra customization, in - general should be set as None. + path: The path to the file where the `_BatchManager` will be cached. If `None`, + then the `_BatchManager` will be cached in the default cache folder. """ path = Path(path) - dump = self.dump() + + # Do not include `_Batch` data so `dump` is fast + dump = self.dump(include_batch_data=False) batch_manager_step_files = {} + # Do this to avoid modifying the dictionary while iterating over it batch_manager_steps = set(dump["steps"].keys()) for step_name in batch_manager_steps: step_dump = dump["steps"].pop(step_name) - filename = str(path.parent / f"batch_manager_steps/{step_name}.json") - batch_manager_step_files[step_name] = filename - super().save(path=filename, format=format, dump=step_dump) + + # Create a directory for each batch manager step to store their batches + batch_manager_step_dir = path.parent / "batch_manager_steps" / step_name + batch_manager_step_dir.mkdir(parents=True, exist_ok=True) + + # Store each `_BatchManagerStep` `_Batch`es in a separete file + for buffered_step_name in step_dump["data"]: + step_batches_dir = batch_manager_step_dir / buffered_step_name + step_batches_dir.mkdir(parents=True, exist_ok=True) + + # Store each `_Batch` in a separate file + keep_batches = [] + for batch_dump in step_dump["data"][buffered_step_name]: + # Generate a hash for the data of the batch + seq_no = batch_dump["seq_no"] + data_hash = batch_dump["data_hash"] + batch_file = step_batches_dir / f"batch_{seq_no}_{data_hash}.json" + + # Save the batch if it doesn't exist + if not batch_file.exists(): + # Get the data of the batch before saving it + batch = next( + batch + for batch in self._steps[step_name].data[buffered_step_name] + if batch.seq_no == seq_no + ) + batch_dump["data"] = batch.data + self.save(path=batch_file, format="json", dump=batch_dump) + + keep_batches.append(batch_file) + + step_dump["data"][buffered_step_name] = [ + str(file_batch) for file_batch in keep_batches + ] + + # Remove `_Batch`es that were consumed from cache + files = list_files_in_dir(step_batches_dir, key=None) + remove = set(files) - set(keep_batches) + for file in remove: + file.unlink() + + # Store the `_BatchManagerStep` info + batch_manager_step_file = str( + path.parent / f"batch_manager_steps/{step_name}/batch_manager_step.json" + ) + self.save(path=batch_manager_step_file, format="json", dump=step_dump) + + # Store the path to the `_BatchManagerStep` file + batch_manager_step_files[step_name] = batch_manager_step_file + dump["steps"] = batch_manager_step_files - super().save(path=path, format=format, dump=dump) + self.save(path=path, format="json", dump=dump) + + @classmethod + def load_from_cache(cls, path: "StrOrPath") -> "_BatchManager": + """Loads the `_BatchManager` from a cache file. + + Args: + path: The path to the cache file. + """ + _check_is_dir(path) + content = read_json(path) + + # Read each `_BatchManagerStep` from file + steps = {} + for step_name, step_file in content["steps"].items(): + steps[step_name] = read_json(step_file) + + # Read each `_Batch` from file + for buffered_step_name, batch_files in steps[step_name]["data"].items(): + steps[step_name]["data"][buffered_step_name] = [ + read_json(batch_file) for batch_file in batch_files + ] + + content["steps"] = steps + return cls.from_dict(content) class _WriteBuffer: @@ -1444,5 +1522,8 @@ def close(self) -> None: # is correct. Otherwise, the first parquets won't have the last schema and # then we will have issues when reading them. for file in list_files_in_dir(self._path / step_name): - table = pq.read_table(file, schema=self._buffer_last_schema[step_name]) - pq.write_table(table, file) + if step_name in self._buffer_last_schema: + table = pq.read_table( + file, schema=self._buffer_last_schema[step_name] + ) + pq.write_table(table, file) diff --git a/src/distilabel/pipeline/local.py b/src/distilabel/pipeline/local.py index 5bb7fd7991..98c5f00a37 100644 --- a/src/distilabel/pipeline/local.py +++ b/src/distilabel/pipeline/local.py @@ -277,15 +277,13 @@ def _manage_batch_flow(self, batch: "_Batch") -> None: if new_batch := self._batch_manager.get_batch(successor): self._send_batch_to_step(new_batch) - if step.is_generator: - return - - # Step ("this", the one from which the batch was received) has enough data on its - # buffers to create a new batch - if new_batch := self._batch_manager.get_batch(step.name): # type: ignore - self._send_batch_to_step(new_batch) - else: - self._request_more_batches_if_needed(step) + if not step.is_generator: + # Step ("this", the one from which the batch was received) has enough data on its + # buffers to create a new batch + if new_batch := self._batch_manager.get_batch(step.name): # type: ignore + self._send_batch_to_step(new_batch) + else: + self._request_more_batches_if_needed(step) self._cache() diff --git a/src/distilabel/utils/serialization.py b/src/distilabel/utils/serialization.py index b97669b809..7862a8b9c5 100644 --- a/src/distilabel/utils/serialization.py +++ b/src/distilabel/utils/serialization.py @@ -13,11 +13,12 @@ # limitations under the License. import importlib -import json import os import sys from enum import Enum +import orjson + if sys.version_info < (3, 11): from enum import EnumMeta as EnumType else: @@ -50,7 +51,7 @@ def _get_module_attr(module: str, name: str) -> Type: return getattr(mod, name) -def load_from_dict(class_: Dict[str, Any]) -> Any: +def load_with_type_info(class_: Any) -> Any: """Creates an instance of a class from a dictionary containing the type info and the serialized data of the class. @@ -60,17 +61,24 @@ def load_from_dict(class_: Dict[str, Any]) -> Any: Returns: An instance of the class with the data loaded from the dictionary. """ - type_info = class_.pop(TYPE_INFO_KEY) - if TYPE_INFO_KEY in type_info: - # There is a nested type_info, load the class recursively - type_info = load_from_dict(type_info) + if not isinstance(class_, (list, dict)): + return class_ - cls = _get_module_attr(type_info["module"], type_info["name"]) + if isinstance(class_, list): + return [load_with_type_info(x) for x in class_] for k, v in class_.items(): + class_[k] = load_with_type_info(v) if isinstance(v, (dict, list)) else v + if isinstance(v, dict) and "_type" in v and v["_type"] == "enum": class_[k] = Enum(v["_name"], v["_values"], type=eval(v["_enum_type"])) + if TYPE_INFO_KEY not in class_: + return class_ + + type_info = class_.pop(TYPE_INFO_KEY) + cls = _get_module_attr(type_info["module"], type_info["name"]) + instance = cls(**class_) return instance @@ -83,8 +91,8 @@ def write_json(filename: Path, data: Any) -> None: data: the data to write to the file. """ filename.parent.mkdir(parents=True, exist_ok=True) - with open(filename, "w") as file: - json.dump(data, file, indent=2) + with open(filename, "wb") as f: + f.write(orjson.dumps(data, option=orjson.OPT_SERIALIZE_NUMPY)) def read_json(filename: StrOrPath) -> Any: @@ -96,8 +104,8 @@ def read_json(filename: StrOrPath) -> Any: Returns: The data from the file. """ - with open(filename, "r") as file: - return json.load(file) + with open(filename, "rb") as f: + return orjson.loads(f.read()) def write_yaml(filename: Path, data: Dict[str, Any]) -> None: @@ -237,7 +245,7 @@ def from_dict(cls, data: Dict[str, Any]) -> Self: Returns: An instance of the class with the data loaded from the dictionary. """ - return load_from_dict(data) + return load_with_type_info(data) @classmethod def from_json(cls, path: StrOrPath) -> Self: diff --git a/tests/unit/pipeline/test_base.py b/tests/unit/pipeline/test_base.py index 76a48ec4be..60a624c9a0 100644 --- a/tests/unit/pipeline/test_base.py +++ b/tests/unit/pipeline/test_base.py @@ -261,6 +261,45 @@ def test_infer_step_names_big_pipeline(self) -> None: class TestBatch: + def test_get_data(self) -> None: + batch = _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[ + [ + {"a": 0}, + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + {"a": 6}, + ] + ], + ) + + batch.set_data( + [ + [ + {"a": 0}, + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + {"a": 6}, + ] + ] + ) + + old_hash = batch.data_hash + + data = batch.get_data(5) + assert data == [{"a": 0}, {"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}] + assert batch.data == [[{"a": 5}, {"a": 6}]] + assert batch.data_hash != old_hash + def test_set_data(self) -> None: batch = _Batch(seq_no=0, step_name="step1", last_batch=False) data = [[{"i": i} for i in range(5000)]] @@ -325,6 +364,7 @@ def test_dump(self) -> None: "step_name": "step1", "last_batch": False, "data": [], + "data_hash": None, "accumulated": False, "created_from": {}, "batch_routed_to": [], @@ -336,8 +376,9 @@ def test_dump(self) -> None: step_name="step1", last_batch=False, data=[[{"a": 1}, {"a": 2}, {"a": 3}]], + data_hash="hash", accumulated=False, - created_from={"step0": [0, 1]}, + created_from={"step0": [(0, 5), (1, 5)]}, batch_routed_to=["step2", "step3"], ) assert batch.dump() == { @@ -346,30 +387,35 @@ def test_dump(self) -> None: "step_name": "step1", "last_batch": False, "data": [[{"a": 1}, {"a": 2}, {"a": 3}]], + "data_hash": "hash", "accumulated": False, - "created_from": {"step0": [0, 1]}, + "created_from": {"step0": [(0, 5), (1, 5)]}, "batch_routed_to": ["step2", "step3"], "type_info": {"module": "distilabel.pipeline.base", "name": "_Batch"}, } def test_from_dict(self) -> None: - assert isinstance( - _Batch.from_dict( - { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [[{"a": 1}, {"a": 2}, {"a": 3}]], - "accumulated": False, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ), - _Batch, + batch = _Batch.from_dict( + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [[{"a": 1}, {"a": 2}, {"a": 3}]], + "accumulated": False, + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } ) + assert isinstance(batch, _Batch) + assert batch.seq_no == 0 + assert batch.step_name == "step1" + assert batch.last_batch is False + assert batch.data == [[{"a": 1}, {"a": 2}, {"a": 3}]] + assert batch.accumulated is False + class TestBatchManagerStep: def test_add_batch(self) -> None: @@ -655,43 +701,39 @@ def test_get_seq_no(self) -> None: assert batch_manager_step.seq_no == 1 def test_get_data(self) -> None: + batch_step_1 = _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}]], + size=6, + batch_routed_to=["step1", "step2"], + ) + batch_step_2 = _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + size=7, + batch_routed_to=["step1", "step2"], + ) batch_manager_step = _BatchManagerStep( step_name="step3", accumulate=False, input_batch_size=5, data={ - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[ - [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}] - ], - size=6, - batch_routed_to=["step1", "step2"], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - size=7, - batch_routed_to=["step1", "step2"], - ) - ], + "step1": [batch_step_1], + "step2": [batch_step_2], }, ) @@ -710,6 +752,7 @@ def test_get_data(self) -> None: step_name="step1", last_batch=False, data=[[{"a": 6}]], + data_hash=batch_step_1.data_hash, size=6, batch_routed_to=["step1", "step2"], ) @@ -720,6 +763,7 @@ def test_get_data(self) -> None: step_name="step2", last_batch=False, data=[[{"b": 6}, {"b": 7}]], + data_hash=batch_step_2.data_hash, size=7, batch_routed_to=["step1", "step2"], ) @@ -1326,6 +1370,7 @@ def test_dump(self) -> None: step_name="step1", last_batch=True, data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}]], + data_hash="hash0", size=6, ) batch_step_2 = _Batch( @@ -1335,6 +1380,7 @@ def test_dump(self) -> None: data=[ [{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}, {"b": 6}, {"b": 7}] ], + data_hash="hash1", size=7, ) batch_manager_step = _BatchManagerStep( @@ -1367,10 +1413,15 @@ def test_dump(self) -> None: {"a": 6}, ] ], + "data_hash": "hash0", "size": 6, "accumulated": False, "created_from": {}, "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, } ], "step2": [ @@ -1389,10 +1440,15 @@ def test_dump(self) -> None: {"b": 7}, ] ], + "data_hash": "hash1", "size": 7, "accumulated": False, "created_from": {}, "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, } ], }, @@ -1807,6 +1863,7 @@ def test_dump(self) -> None: "created_from": {}, "last_batch": False, "data": [], + "data_hash": None, "size": 0, "accumulated": False, "type_info": { @@ -1823,6 +1880,7 @@ def test_dump(self) -> None: "created_from": {}, "last_batch": False, "data": [], + "data_hash": None, "size": 0, "accumulated": False, "type_info": { @@ -1839,115 +1897,530 @@ def test_dump(self) -> None: } def test_from_dict(self) -> None: - batch_manager_step = _BatchManagerStep.from_dict( + batch_manager = _BatchManager.from_dict( { - "step_name": "step3", - "accumulate": True, - "convergence_step": False, - "input_batch_size": None, - "data": { - "step1": [ - { - "seq_no": 0, - "step_name": "step1", - "last_batch": True, - "data": [ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - {"a": 6}, - ] + "steps": { + "step1": { + "step_name": "step1", + "accumulate": True, + "convergence_step": False, + "convergence_step_batches_consumed": {0: {"Z": 1234}}, + "input_batch_size": None, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } ], - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - } - ], - "step2": [ - { - "seq_no": 0, - "step_name": "step2", - "last_batch": True, - "data": [ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] + }, + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_BatchManagerStep", + }, + }, + "step2": { + "step_name": "step2", + "accumulate": False, + "convergence_step": False, + "convergence_step_batches_consumed": {0: {"Z": 1234}}, + "input_batch_size": 50, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } ], - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - } - ], + }, + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_BatchManagerStep", + }, + }, }, - "seq_no": 0, - "last_batch_received": [], + "last_batch_received": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + }, + "last_batch_sent": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + }, + "last_batch_flag_sent_to": ["step3"], "type_info": { "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", + "name": "_BatchManager", }, } ) - with tempfile.TemporaryDirectory() as tmpdirname: - batch_manager_step.save(Path(tmpdirname) / "batch_manager_step3.json") + assert isinstance(batch_manager, _BatchManager) - batch_manager = _BatchManager.from_dict( - { - "steps": { - "step3": str(Path(tmpdirname) / "batch_manager_step3.json") + assert len(batch_manager._steps) == 2 + for step in batch_manager._steps.values(): + assert isinstance(step, _BatchManagerStep) + + assert len(batch_manager._last_batch_received) == 2 + for step in batch_manager._last_batch_received.values(): + assert isinstance(step, _Batch) + + assert len(batch_manager._last_batch_sent) == 2 + for step in batch_manager._last_batch_sent.values(): + assert isinstance(step, _Batch) + + assert batch_manager._last_batch_flag_sent_to == ["step3"] + + def test_cache(self) -> None: + batch_manager = _BatchManager.from_dict( + { + "steps": { + "step1": { + "step_name": "step1", + "accumulate": True, + "convergence_step": False, + "convergence_step_batches_consumed": {"0": {"Z": 1234}}, + "input_batch_size": None, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "data_hash": "1234", + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], + }, + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_BatchManagerStep", + }, }, - "last_batch_received": { - "step3": { - "seq_no": 0, - "step_name": "step3", - "last_batch": False, - "data": [], - "accumulated": False, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } + "step2": { + "step_name": "step2", + "accumulate": False, + "convergence_step": False, + "convergence_step_batches_consumed": {"0": {"Z": 1234}}, + "input_batch_size": 50, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "data_hash": "1234", + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], + }, + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_BatchManagerStep", + }, }, - "last_batch_sent": { - "step3": { - "seq_no": 0, - "step_name": "step3", - "last_batch": False, - "data": [], - "accumulated": False, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, + }, + "last_batch_received": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", }, }, - "last_batch_flag_sent_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManager", + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, }, - } - ) - assert isinstance(batch_manager, _BatchManager) - assert all( - isinstance(step, _BatchManagerStep) - for _, step in batch_manager._steps.items() + }, + "last_batch_sent": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + }, + "last_batch_flag_sent_to": ["step3"], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_BatchManager", + }, + } ) - assert all( - isinstance(batch, _Batch) - for _, batch in batch_manager._last_batch_received.items() + + with tempfile.TemporaryDirectory() as tmp_dir: + batch_manager_path = Path(tmp_dir) / "batch_manager.json" + batch_manager.cache(batch_manager_path) + + assert batch_manager_path.exists() and batch_manager_path.is_file() + + for step_name, step in batch_manager._steps.items(): + batch_manager_step_dir = ( + Path(tmp_dir) / "batch_manager_steps" / step_name + ) + assert ( + batch_manager_step_dir.exists() and batch_manager_step_dir.is_dir() + ) + + batch_manager_step_path = ( + batch_manager_step_dir / "batch_manager_step.json" + ) + assert ( + batch_manager_step_path.exists() + and batch_manager_step_path.is_file() + ) + + for buffered_step_name in step.data: + buffered_step_dir = batch_manager_step_dir / buffered_step_name + assert buffered_step_dir.exists() and buffered_step_dir.is_dir() + + for batch in step.data[buffered_step_name]: + batch_path = ( + buffered_step_dir + / f"batch_{batch.seq_no}_{batch.data_hash}.json" + ) + assert batch_path.exists() and batch_path.is_file() + + def test_load_from_cache(self) -> None: + batch_manager = _BatchManager.from_dict( + { + "steps": { + "step1": { + "step_name": "step1", + "accumulate": True, + "convergence_step": False, + "convergence_step_batches_consumed": {"0": {"Z": 1234}}, + "input_batch_size": None, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "data_hash": "1234", + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], + }, + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_BatchManagerStep", + }, + }, + "step2": { + "step_name": "step2", + "accumulate": False, + "convergence_step": False, + "convergence_step_batches_consumed": {"0": {"Z": 1234}}, + "input_batch_size": 50, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "data_hash": "1234", + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], + }, + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_BatchManagerStep", + }, + }, + }, + "last_batch_received": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + }, + "last_batch_sent": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + }, + }, + "last_batch_flag_sent_to": ["step3"], + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_BatchManager", + }, + } ) + with tempfile.TemporaryDirectory() as tmp_dir: + batch_manager_path = Path(tmp_dir) / "batch_manager.json" + batch_manager.cache(batch_manager_path) + loaded_batch_manager = _BatchManager.load_from_cache(batch_manager_path) + + assert batch_manager.dump() == loaded_batch_manager.dump() + class TestPipelineSerialization: def test_base_pipeline_dump(self): From 6eddaf84d407b0ef192b5fa6df91afcc2750cef9 Mon Sep 17 00:00:00 2001 From: Leire Date: Mon, 27 May 2024 09:02:53 +0200 Subject: [PATCH 04/40] [DOCS] Update theme styles and images (#667) * include svg logos * update badge font * fix dark mode logo * update theme color and remove scrollbar bg * remove file --- docs/assets/distilabel-badge-light.png | Bin 15551 -> 7834 bytes docs/assets/distilabel-black.svg | 56 +++++++++++++++++++++++++ docs/assets/distilabel-white.svg | 56 +++++++++++++++++++++++++ docs/index.md | 9 ++-- docs/stylesheets/extra.css | 20 +++++---- 5 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 docs/assets/distilabel-black.svg create mode 100644 docs/assets/distilabel-white.svg diff --git a/docs/assets/distilabel-badge-light.png b/docs/assets/distilabel-badge-light.png index dafd37d76fda70b50402885dfb7c9e98181a2166..3d9c7f7ee016396c40e59bf09898eabbc093c8ef 100644 GIT binary patch literal 7834 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 aQr!A*xuSiCJr9@>5M(74B`U?g2K*mNOGaJ* literal 15551 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

X;xUQ&xGfwY+O2qQW43w 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/distilabel-white.svg b/docs/assets/distilabel-white.svg new file mode 100644 index 0000000000..f4bd5f10a8 --- /dev/null +++ b/docs/assets/distilabel-white.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md index 097d3b5c80..7547e1a19c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ --- description: Distilabel is an AI Feedback (AIF) framework for building datasets with and for LLMs. -hide: +hide: - toc --- @@ -8,8 +8,8 @@ hide:

- - Distilabel Logo + Distilabel Logo + Distilabel Logo
@@ -36,10 +36,10 @@ hide:

- Distilabel is the **framework for synthetic data and AI feedback for AI engineers** that require **high-quality outputs, full data ownership, and overall efficiency**. If you just want to get started, we recommend you check the [documentation](http://distilabel.argilla.io/). Curious, and want to know more? Keep reading! + ## Why use Distilabel? @@ -160,4 +160,3 @@ If you build something cool with `distilabel` consider adding one of these badge ## Contribute To directly contribute with `distilabel`, check our [good first issues](https://github.com/argilla-io/distilabel/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) or [open a new one](https://github.com/argilla-io/distilabel/issues/new/choose). - diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index e32447d5d9..9e14bf3c36 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,15 +1,19 @@ :root { - --md-primary-fg-color: #FF675F; - --md-primary-fg-color--light: #FF675F; - --md-primary-fg-color--dark: #FF675F; + --md-primary-fg-color: #84b0c1; + --md-primary-fg-color--light: #84b0c1; + --md-primary-fg-color--dark: #84b0c1; } [data-md-color-scheme="default"] { --md-primary-fg-color: #000000; - --md-typeset-a-color: #FF675F; - --md-accent-fg-color: #F7A399; + --md-typeset-a-color: #279bc8; + --md-accent-fg-color: #0ba5e1; } [data-md-color-scheme="slate"] { --md-primary-fg-color: #000000; - --md-typeset-a-color: #F7A399; - --md-accent-fg-color: #FF675F; -} \ No newline at end of file + --md-typeset-a-color: #66bada; + --md-accent-fg-color: #6cd0f7; +} + +.md-sidebar__scrollwrap:focus-within, .md-sidebar__scrollwrap:hover { + scrollbar-color: var(--md-default-fg-color--lighter) #0000; + } \ No newline at end of file From c9623fcbfacb396e92005983bf52b7f0acc45514 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 28 May 2024 12:26:43 +0200 Subject: [PATCH 05/40] Fix circular import due to DISTILABEL_METADATA_KEY (#675) * Fix circular import due to DISTILABEL_METADATA_KEY * Update src/distilabel/distiset.py Co-authored-by: Alvaro Bartolome --------- Co-authored-by: Alvaro Bartolome --- src/distilabel/distiset.py | 3 ++- src/distilabel/steps/constants.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/distilabel/steps/constants.py diff --git a/src/distilabel/distiset.py b/src/distilabel/distiset.py index b9bd1b0e03..cdcdc66604 100644 --- a/src/distilabel/distiset.py +++ b/src/distilabel/distiset.py @@ -21,7 +21,6 @@ from huggingface_hub import DatasetCardData, HfApi from pyarrow.lib import ArrowInvalid -from distilabel.steps.tasks.base import DISTILABEL_METADATA_KEY from distilabel.utils.card.dataset_card import ( DistilabelDatasetCard, size_categories_parser, @@ -224,6 +223,8 @@ def create_distiset( # noqa: C901 The dataset created from the buffer folder, where the different leaf steps will correspond to different configurations of the dataset. """ + from distilabel.steps.constants import DISTILABEL_METADATA_KEY + logger = logging.getLogger("distilabel.distiset") data_dir = Path(data_dir) diff --git a/src/distilabel/steps/constants.py b/src/distilabel/steps/constants.py new file mode 100644 index 0000000000..8d50ae4774 --- /dev/null +++ b/src/distilabel/steps/constants.py @@ -0,0 +1,15 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +DISTILABEL_METADATA_KEY = "distilabel_metadata" From bce7da1e086a400af3262ac121aa0000b778dd5e Mon Sep 17 00:00:00 2001 From: Alvaro Bartolome Date: Wed, 29 May 2024 08:31:57 +0200 Subject: [PATCH 06/40] Deprecate conversation support in `TextGeneration` in favour of `ChatGeneration` (#676) * Deprecate conversation support in `TextGeneration` * Fix linting issue from `develop` merge --- src/distilabel/distiset.py | 2 +- src/distilabel/steps/tasks/text_generation.py | 8 ++----- .../unit/steps/tasks/test_text_generation.py | 23 +++++-------------- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/src/distilabel/distiset.py b/src/distilabel/distiset.py index cdcdc66604..98c11009f2 100644 --- a/src/distilabel/distiset.py +++ b/src/distilabel/distiset.py @@ -224,7 +224,7 @@ def create_distiset( # noqa: C901 correspond to different configurations of the dataset. """ from distilabel.steps.constants import DISTILABEL_METADATA_KEY - + logger = logging.getLogger("distilabel.distiset") data_dir = Path(data_dir) diff --git a/src/distilabel/steps/tasks/text_generation.py b/src/distilabel/steps/tasks/text_generation.py index 41eb4444dc..ece5344caf 100644 --- a/src/distilabel/steps/tasks/text_generation.py +++ b/src/distilabel/steps/tasks/text_generation.py @@ -62,14 +62,10 @@ def format_input(self, input: Dict[str, Any]) -> ChatType: is the first interaction from the user within a conversation.""" if is_openai_format(input["instruction"]): - warnings.warn( + raise ValueError( "Providing `instruction` formatted as an OpenAI chat / conversation is" - " about to be deprecated in `distilabel v1.2.0`, please make sure to use" - " `ChatTextGeneration` with `messages` as input instead.", - DeprecationWarning, - stacklevel=2, + " deprecated, you should use `ChatGeneration` with `messages` as input instead.", ) - return input["instruction"] if not isinstance(input["instruction"], str): raise ValueError( diff --git a/tests/unit/steps/tasks/test_text_generation.py b/tests/unit/steps/tasks/test_text_generation.py index ecff0e1d90..d07ba464a3 100644 --- a/tests/unit/steps/tasks/test_text_generation.py +++ b/tests/unit/steps/tasks/test_text_generation.py @@ -53,6 +53,12 @@ def test_format_input_errors(self) -> None: name="task", llm=llm, pipeline=pipeline, use_system_prompt=True ) + with pytest.raises( + ValueError, + match=r"Providing \`instruction\` formatted as an OpenAI chat / conversation is deprecated", + ): + task.format_input({"instruction": [{"role": "user", "content": "test"}]}) + with pytest.raises( ValueError, match=r"Input \`instruction\` must be a string. Got: 1." ): @@ -79,23 +85,6 @@ def test_process(self) -> None: } ] - def test_deprecation_warning(self) -> None: - pipeline = Pipeline(name="unit-test-pipeline") - llm = DummyLLM() - task = TextGeneration(name="task", llm=llm, pipeline=pipeline) - - with pytest.warns( - DeprecationWarning, - match=r"Providing \`instruction\` formatted as an OpenAI chat \/ conversation is about to be deprecated in \`distilabel v1.2.0\`", - ): - task.format_input( - { - "instruction": [ - {"role": "user", "content": "Tell me a joke."}, - ] - } - ) - class TestChatGeneration: def test_format_input(self) -> None: From 7e9230b34ec3fc930beacf75509da03a29ee27ff Mon Sep 17 00:00:00 2001 From: Agus Date: Wed, 29 May 2024 10:36:31 +0200 Subject: [PATCH 07/40] Add functionality to load/save distisets to/from disk (#673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add functionality to load/save distisets to/from disk * Add tests for saving/loading distiset from disk * Add functionality to load/save distisets to/from disk * Update docs * Include code blocks from Examples in docstrings * Add tests for the dataset card * Fix call to yaml.safe_load found in code review * Copy path movements from hugging face load_from_disk definition * Add universal_pathlib dependency to better deal with remote paths when calling Distiset.load_from_disk * Fix download of distiset and add option to write the data to a user specified dir * Remove parameter in test as it isn't really tested with a remote filesystem * Remove unnecessary markdown extension and fix type from variables * Update src/distilabel/distiset.py Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/distiset.py Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/distiset.py Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/distiset.py Co-authored-by: Gabriel Martín Blázquez * Cast Path to str --------- Co-authored-by: Gabriel Martín Blázquez --- docs/sections/learn/advanced/distiset.md | 25 ++ pyproject.toml | 1 + src/distilabel/distiset.py | 315 ++++++++++++++++++++--- tests/unit/test_distiset.py | 134 ++++++++++ 4 files changed, 435 insertions(+), 40 deletions(-) diff --git a/docs/sections/learn/advanced/distiset.md b/docs/sections/learn/advanced/distiset.md index 17be198ae3..7c5e122613 100644 --- a/docs/sections/learn/advanced/distiset.md +++ b/docs/sections/learn/advanced/distiset.md @@ -70,6 +70,31 @@ distiset.push_to_hub( ) ``` +### Save and load from disk + +Saves the [`Distiset`][distilabel.distiset.Distiset] to disk, and optionally (will be done by default) saves the dataset card, the pipeline config file and logs: + +```python +distiset.save_to_disk( + "my-dataset", + save_card=True, + save_pipeline_config=True, + save_pipeline_log=True +) +``` + +And load a [`Distiset`][distilabel.distiset.Distiset] that was saved using [`Distiset.save_to_disk`][distilabel.distiset.Distiset.save_to_disk] from disk just the same way: + +```python +from distilabel.distiset import Distiset + +distiset = Distiset.save_to_disk("my-dataset") +``` + +Take into account that these methods pass work as `datasets.load_from_disk` and `datasets.Dataset.save_to_disk` so the arguments are directly passed to those methods. This means you can also make use of `storage_options` argument to save your [`Distiset`][distilabel.distiset.Distiset] in your cloud provider, including the distilabel artifacts (`pipeline.yaml`, `pipeline.log` and the `README.md` with the dataset card), you can read more in `datasets` documentation [here](https://huggingface.co/docs/datasets/filesystems#saving-serialized-datasets). + +Take a look at the remaining arguments at [`Distiset.save_to_disk`][distilabel.distiset.Distiset.save_to_disk]. + ## Dataset card Having this special type of dataset comes with an added advantage when calling [`Distiset.push_to_hub`][distilabel.distiset.Distiset], which is the automatically generated dataset card in the Hugging Face Hub. Note that it is enabled by default, but can be disabled by setting `generate_card=False`: diff --git a/pyproject.toml b/pyproject.toml index e22c80a16e..8883eb0bca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "typer >= 0.9.0", "tblib >= 3.0.0", "orjson >= 3.10.0", + "universal_pathlib >= 0.2.2", ] dynamic = ["version"] diff --git a/src/distilabel/distiset.py b/src/distilabel/distiset.py index 98c11009f2..3538bccd37 100644 --- a/src/distilabel/distiset.py +++ b/src/distilabel/distiset.py @@ -13,13 +13,20 @@ # limitations under the License. import logging +import os.path as posixpath import re +from os import PathLike from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Final, Optional, Union -from datasets import load_dataset +import fsspec +import yaml +from datasets import Dataset, load_dataset, load_from_disk +from datasets.filesystems import is_remote_filesystem from huggingface_hub import DatasetCardData, HfApi +from huggingface_hub.file_download import hf_hub_download from pyarrow.lib import ArrowInvalid +from typing_extensions import Self from distilabel.utils.card.dataset_card import ( DistilabelDatasetCard, @@ -27,6 +34,10 @@ ) from distilabel.utils.files import list_files_in_dir +DISTISET_CONFIG_FOLDER: Final[str] = "distiset_configs" +PIPELINE_CONFIG_FILENAME: Final[str] = "pipeline.yaml" +PIPELINE_LOG_FILENAME: Final[str] = "pipeline.log" + class Distiset(dict): """Convenient wrapper around `datasets.Dataset` to push to the Hugging Face Hub. @@ -40,8 +51,8 @@ class Distiset(dict): pipeline. """ - pipeline_path: Optional[Path] = None - log_filename_path: Optional[Path] = None + _pipeline_path: Optional[Path] = None + _log_filename_path: Optional[Path] = None def push_to_hub( self, @@ -83,14 +94,23 @@ def push_to_hub( if generate_card: self._generate_card(repo_id, token) - def _generate_card(self, repo_id: str, token: Optional[str]) -> None: - """Generates a dataset card and pushes it to the Hugging Face Hub, and - if the `pipeline.yaml` path is available in the `Distiset`, uploads that - to the same repository. + def _get_card( + self, repo_id: str, token: Optional[str] = None + ) -> DistilabelDatasetCard: + """Generates the dataset card for the `Distiset`. + + Note: + If `repo_id` and `token` are provided, it will extract the metadata from the README.md file + on the hub. Args: - repo_id: The ID of the repository to push to, from the `push_to_hub` method. - token: The token to authenticate with the Hugging Face Hub, from the `push_to_hub` method. + repo_id: Name of the repository to push to, or the path for the distiset if saved to disk. + token: The token to authenticate with the Hugging Face Hub. + We assume that if it's provided, the dataset will be in the Hugging Face Hub, + so the README metadata will be extracted from there. + + Returns: + The dataset card for the `Distiset`. """ sample_records = {} for name, dataset in self.items(): @@ -98,8 +118,12 @@ def _generate_card(self, repo_id: str, token: Optional[str]) -> None: dataset[0] if not isinstance(dataset, dict) else dataset["train"][0] ) + readme_metadata = {} + if repo_id and token: + readme_metadata = self._extract_readme_metadata(repo_id, token) + metadata = { - **self._extract_readme_metadata(repo_id, token), + **readme_metadata, "size_categories": size_categories_parser( max(len(dataset) for dataset in self.values()) ), @@ -111,29 +135,8 @@ def _generate_card(self, repo_id: str, token: Optional[str]) -> None: repo_id=repo_id, sample_records=sample_records, ) - card.push_to_hub( - repo_id, - repo_type="dataset", - token=token, - ) - if self.pipeline_path: - # If the pipeline.yaml is available, upload it to the Hugging Face Hub as well. - HfApi().upload_file( - path_or_fileobj=self.pipeline_path, - path_in_repo="pipeline.yaml", - repo_id=repo_id, - repo_type="dataset", - token=token, - ) - if self.log_filename_path: - # The same we had with "pipeline.yaml" but with the log file. - HfApi().upload_file( - path_or_fileobj=self.log_filename_path, - path_in_repo="pipeline.log", - repo_id=repo_id, - repo_type="dataset", - token=token, - ) + + return card def _extract_readme_metadata( self, repo_id: str, token: Optional[str] @@ -150,11 +153,6 @@ def _extract_readme_metadata( Returns: The metadata extracted from the README.md file of the dataset repository as a dict. """ - import re - - import yaml - from huggingface_hub.file_download import hf_hub_download - readme_path = Path( hf_hub_download(repo_id, "README.md", repo_type="dataset", token=token) ) @@ -163,12 +161,47 @@ def _extract_readme_metadata( metadata = yaml.safe_load(metadata) return metadata + def _generate_card(self, repo_id: str, token: str) -> None: + """Generates a dataset card and pushes it to the Hugging Face Hub, and + if the `pipeline.yaml` path is available in the `Distiset`, uploads that + to the same repository. + + Args: + repo_id: The ID of the repository to push to, from the `push_to_hub` method. + token: The token to authenticate with the Hugging Face Hub, from the `push_to_hub` method. + """ + card = self._get_card(repo_id=repo_id, token=token) + + card.push_to_hub( + repo_id, + repo_type="dataset", + token=token, + ) + if self.pipeline_path: + # If the pipeline.yaml is available, upload it to the Hugging Face Hub as well. + HfApi().upload_file( + path_or_fileobj=self.pipeline_path, + path_in_repo=PIPELINE_CONFIG_FILENAME, + repo_id=repo_id, + repo_type="dataset", + token=token, + ) + if self.log_filename_path: + # The same we had with "pipeline.yaml" but with the log file. + HfApi().upload_file( + path_or_fileobj=self.log_filename_path, + path_in_repo=PIPELINE_LOG_FILENAME, + repo_id=repo_id, + repo_type="dataset", + token=token, + ) + def train_test_split( self, train_size: float, shuffle: bool = True, seed: Optional[int] = None, - ) -> "Distiset": + ) -> Self: """Return a `Distiset` whose values will be a `datasets.DatasetDict` with two random train and test subsets. Splits are created from the dataset according to `train_size` and `shuffle`. @@ -192,6 +225,198 @@ def train_test_split( ) return self + def save_to_disk( + self, + distiset_path: PathLike, + max_shard_size: Optional[Union[str, int]] = None, + num_shards: Optional[int] = None, + num_proc: Optional[int] = None, + storage_options: Optional[dict] = None, + save_card: bool = True, + save_pipeline_config: bool = True, + save_pipeline_log: bool = True, + ) -> None: + r""" + Saves a `Distiset` to a dataset directory, or in a filesystem using any implementation of `fsspec.spec.AbstractFileSystem`. + + In case you want to save the `Distiset` in a remote filesystem, you can pass the `storage_options` parameter + as you would do with `datasets`'s `Dataset.save_to_disk` method: [see example](https://huggingface.co/docs/datasets/filesystems#saving-serialized-datasets) + + Args: + distiset_path: Path where you want to save the `Distiset`. It can be a local path + (e.g. `dataset/train`) or remote URI (e.g. `s3://my-bucket/dataset/train`) + max_shard_size: The maximum size of the dataset shards to be uploaded to the hub. + If expressed as a string, needs to be digits followed by a unit (like `"50MB"`). + Defaults to `None`. + num_shards: Number of shards to write. By default the number of shards depends on + `max_shard_size` and `num_proc`. Defaults to `None`. + num_proc: Number of processes when downloading and generating the dataset locally. + Multiprocessing is disabled by default. Defaults to `None`. + storage_options: Key/value pairs to be passed on to the file-system backend, if any. + Defaults to `None`. + save_card: Whether to save the dataset card. Defaults to `True`. + save_pipeline_config: Whether to save the pipeline configuration file (aka the `pipeline.yaml` file). + Defaults to `True`. + save_pipeline_log: Whether to save the pipeline log file (aka the `pipeline.log` file). + Defaults to `True`. + + Examples: + ```python + # Save your distiset in a local folder: + >>> distiset.save_to_disk(dataset_path="my-distiset") + # Save your distiset in a remote storage: + >>> storage_options = { + ... "key": os.environ["S3_ACCESS_KEY"], + ... "secret": os.environ["S3_SECRET_KEY"], + ... "client_kwargs": { + ... "endpoint_url": os.environ["S3_ENDPOINT_URL"], + ... "region_name": os.environ["S3_REGION"], + ... }, + ... } + >>> distiset.save_to_disk(dataset_path="my-distiset", storage_options=storage_options) + ``` + """ + distiset_path = str(distiset_path) + for name, dataset in self.items(): + dataset.save_to_disk( + f"{distiset_path}/{name}", + max_shard_size=max_shard_size, + num_shards=num_shards, + num_proc=num_proc, + storage_options=storage_options, + ) + + distiset_config_folder = posixpath.join(distiset_path, DISTISET_CONFIG_FOLDER) + + fs: fsspec.AbstractFileSystem + fs, _, _ = fsspec.get_fs_token_paths( + distiset_config_folder, storage_options=storage_options + ) + fs.makedirs(distiset_config_folder, exist_ok=True) + + if save_card: + # NOTE: Currently the card is not the same if we write to disk or push to the HF hub, + # as we aren't generating the README copying/updating the data from the dataset repo. + card = self._get_card(repo_id=Path(distiset_path).stem, token=None) + new_filename = posixpath.join(distiset_config_folder, "README.md") + if storage_options: + # Write the card the same way as DatasetCard.save does: + with fs.open(new_filename, "w", newline="", encoding="utf-8") as f: + f.write(str(card)) + else: + card.save(new_filename) + + # Write our internal files to the distiset folder by copying them to the distiset folder. + if save_pipeline_config and self.pipeline_path: + new_filename = posixpath.join( + distiset_config_folder, PIPELINE_CONFIG_FILENAME + ) + if self.pipeline_path.exists() and (not fs.isfile(new_filename)): + data = yaml.safe_load(self.pipeline_path.read_text()) + with fs.open(new_filename, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False) + + if save_pipeline_log and self.log_filename_path: + new_filename = posixpath.join(distiset_config_folder, PIPELINE_LOG_FILENAME) + if self.log_filename_path.exists() and (not fs.isfile(new_filename)): + data = self.log_filename_path.read_text() + with fs.open(new_filename, "w", encoding="utf-8") as f: + f.write(data) + + @classmethod + def load_from_disk( + cls, + distiset_path: PathLike, + keep_in_memory: Optional[bool] = None, + storage_options: Optional[Dict[str, Any]] = None, + download_dir: Optional[PathLike] = None, + ) -> Self: + """Loads a dataset that was previously saved using `Distiset.save_to_disk` from a dataset + directory, or from a filesystem using any implementation of `fsspec.spec.AbstractFileSystem`. + + Args: + distiset_path: Path ("dataset/train") or remote URI ("s3://bucket/dataset/train"). + keep_in_memory: Whether to copy the dataset in-memory, see `datasets.Dataset.load_from_disk`` + for more information. Defaults to `None`. + storage_options: Key/value pairs to be passed on to the file-system backend, if any. + Defaults to `None`. + download_dir: Optional directory to download the dataset to. Defaults to None, + in which case it will create a temporary directory. + + Returns: + A `Distiset` loaded from disk, it should be a `Distiset` object created using `Distiset.save_to_disk`. + """ + original_distiset_path = str(distiset_path) + + fs: fsspec.AbstractFileSystem + fs, _, [distiset_path] = fsspec.get_fs_token_paths( + original_distiset_path, storage_options=storage_options + ) + dest_distiset_path = distiset_path + + assert fs.isdir( + original_distiset_path + ), "`distiset_path` must be a `PathLike` object pointing to a folder or a URI of a remote filesystem." + + has_config = False + distiset = cls() + + if is_remote_filesystem(fs): + src_dataset_path = distiset_path + if download_dir: + dest_distiset_path = download_dir + else: + dest_distiset_path = Dataset._build_local_temp_path(src_dataset_path) + fs.download(src_dataset_path, dest_distiset_path.as_posix(), recursive=True) + + # Now we should have the distiset locally, so we can read those files + for folder in Path(dest_distiset_path).iterdir(): + if folder.stem == DISTISET_CONFIG_FOLDER: + has_config = True + continue + distiset[folder.stem] = load_from_disk( + str(folder), + keep_in_memory=keep_in_memory, + ) + # From the config folder we just need to point to the files. Once downloaded we set the path + # to wherever they are. + if has_config: + distiset_config_folder = posixpath.join( + dest_distiset_path, DISTISET_CONFIG_FOLDER + ) + + pipeline_path = posixpath.join( + distiset_config_folder, PIPELINE_CONFIG_FILENAME + ) + if Path(pipeline_path).exists(): + distiset.pipeline_path = Path(pipeline_path) + + log_filename_path = posixpath.join( + distiset_config_folder, PIPELINE_LOG_FILENAME + ) + if Path(log_filename_path).exists(): + distiset.log_filename_path = Path(log_filename_path) + + return distiset + + @property + def pipeline_path(self) -> Union[Path, None]: + """Returns the path to the `pipeline.yaml` file that generated the `Pipeline`.""" + return self._pipeline_path + + @pipeline_path.setter + def pipeline_path(self, path: PathLike) -> None: + self._pipeline_path = Path(path) + + @property + def log_filename_path(self) -> Union[Path, None]: + """Returns the path to the `pipeline.log` file that generated the `Pipeline`.""" + return self._log_filename_path + + @log_filename_path.setter + def log_filename_path(self, path: PathLike) -> None: + self._log_filename_path = Path(path) + def __repr__(self): # Copy from `datasets.DatasetDict.__repr__`. repr = "\n".join([f"{k}: {v}" for k, v in self.items()]) @@ -207,6 +432,9 @@ def create_distiset( # noqa: C901 ) -> Distiset: """Creates a `Distiset` from the buffer folder. + This function is intended to be used as a helper to create a `Distiset` from from the folder + where the cached data was written by the `_WriteBuffer`. + Args: data_dir: Folder where the data buffers were written by the `_WriteBuffer`. It should correspond to `CacheLocation.data`. @@ -222,6 +450,13 @@ def create_distiset( # noqa: C901 Returns: The dataset created from the buffer folder, where the different leaf steps will correspond to different configurations of the dataset. + + Examples: + + ```python + >>> from pathlib import Path + >>> distiset = create_distiset(Path.home() / ".cache/distilabel/pipelines/path-to-pipe-hashname") + ``` """ from distilabel.steps.constants import DISTILABEL_METADATA_KEY diff --git a/tests/unit/test_distiset.py b/tests/unit/test_distiset.py index 18de3f8769..07e6549d7b 100644 --- a/tests/unit/test_distiset.py +++ b/tests/unit/test_distiset.py @@ -12,9 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy +import re +import tempfile +from pathlib import Path +from typing import Any, Dict, Optional + import pytest +import yaml from datasets import Dataset, DatasetDict from distilabel.distiset import Distiset +from upath import UPath @pytest.fixture(scope="function") @@ -27,6 +35,24 @@ def distiset(): ) +def make_fake_file(filename: Path) -> None: + if not filename.parent.exists(): + filename.parent.mkdir(parents=True) + filename.touch() + + +def add_config_to_distiset(distiset: Distiset, folder: Path) -> Distiset: + from distilabel.distiset import DISTISET_CONFIG_FOLDER + + pipeline_yaml = folder / DISTISET_CONFIG_FOLDER / "pipeline.yaml" + pipeline_log = folder / DISTISET_CONFIG_FOLDER / "pipeline.log" + make_fake_file(pipeline_yaml) + make_fake_file(pipeline_log) + distiset.pipeline_path = pipeline_yaml + distiset.pipeline_log_path = pipeline_log + return distiset + + class TestDistiset: def test_train_test_split(self, distiset: Distiset) -> None: assert isinstance(distiset["leaf_step_1"], Dataset) @@ -34,3 +60,111 @@ def test_train_test_split(self, distiset: Distiset) -> None: assert isinstance(ds, Distiset) assert len(ds) == 2 assert isinstance(ds["leaf_step_1"], DatasetDict) + + @pytest.mark.parametrize("storage_options", [None, {"test": "option"}]) + @pytest.mark.parametrize("with_config", [False, True]) + def test_save_to_disk( + self, + distiset: Distiset, + with_config: bool, + storage_options: Optional[Dict[str, Any]], + ) -> None: + full_distiset = copy.deepcopy(distiset) + # Distiset with Distiset + with tempfile.TemporaryDirectory() as tmpdirname: + folder = Path(tmpdirname) / "distiset_folder" + if with_config: + full_distiset = add_config_to_distiset(full_distiset, folder) + + full_distiset.save_to_disk( + folder, + save_card=with_config, + save_pipeline_config=with_config, + save_pipeline_log=with_config, + storage_options=storage_options, + ) + assert folder.is_dir() + assert len(list(folder.iterdir())) == 3 + + full_distiset = copy.deepcopy(distiset) + # Distiset with DatasetDict + distiset_with_dict = full_distiset.train_test_split(0.8) + with tempfile.TemporaryDirectory() as tmpdirname: + folder = Path(tmpdirname) / "distiset_folder" + if with_config: + distiset_with_dict = add_config_to_distiset(distiset_with_dict, folder) + + distiset_with_dict.save_to_disk( + folder, + save_card=with_config, + save_pipeline_config=with_config, + save_pipeline_log=with_config, + ) + + assert folder.is_dir() + assert len(list(folder.iterdir())) == 3 + + @pytest.mark.parametrize("pathlib_implementation", [Path, UPath]) + @pytest.mark.parametrize("storage_options", [None, {"project": "experiments"}]) + @pytest.mark.parametrize("with_config", [False, True]) + def test_load_from_disk( + self, + distiset: Distiset, + with_config: bool, + storage_options: Optional[Dict[str, Any]], + pathlib_implementation: type, + ) -> None: + full_distiset = copy.deepcopy(distiset) + # Distiset with Distiset + with tempfile.TemporaryDirectory() as tmpdirname: + # This way we can test also we work with UPath, using FilePath protocol, as it should + # do the same as S3Path, GCSPath, etc. + folder = pathlib_implementation(tmpdirname) / "distiset_folder" + if with_config: + full_distiset = add_config_to_distiset(full_distiset, folder) + full_distiset.save_to_disk( + folder, + save_card=with_config, + save_pipeline_config=with_config, + save_pipeline_log=with_config, + storage_options=storage_options, + ) + ds = Distiset.load_from_disk( + folder, + storage_options=storage_options, + ) + assert isinstance(ds, Distiset) + assert isinstance(ds["leaf_step_1"], Dataset) + + if with_config: + assert ds.pipeline_path.exists() + assert ds.log_filename_path.exists() + + full_distiset = copy.deepcopy(distiset) + # Distiset with DatasetDict + distiset_with_dict = full_distiset.train_test_split(0.8) + with tempfile.TemporaryDirectory() as tmpdirname: + folder = pathlib_implementation(tmpdirname) / "distiset_folder" + if with_config: + distiset_with_dict = add_config_to_distiset(distiset_with_dict, folder) + + distiset_with_dict.save_to_disk(folder) + ds = Distiset.load_from_disk(folder, storage_options=storage_options) + + assert folder.is_dir() + assert isinstance(ds["leaf_step_1"], DatasetDict) + + if with_config: + assert ds.pipeline_path.exists() + assert ds.log_filename_path.exists() + + def test_dataset_card(self, distiset: Distiset) -> None: + # Test the the metadata we generate by default without extracting the already generated content from the HF hub. + # We parse the content and check it's the same as the one we generate. + distiset_card = distiset._get_card("repo_name_or_path") + metadata = re.findall(r"---\n(.*?)\n---", str(distiset_card), re.DOTALL)[0] + metadata = yaml.safe_load(metadata) + assert metadata == { + "size_categories": "n<1K", + "tags": ["synthetic", "distilabel", "rlaif"], + } From 01b4292952bbc828a3d8700fed98e5c82de191a7 Mon Sep 17 00:00:00 2001 From: Agus Date: Wed, 29 May 2024 11:14:55 +0200 Subject: [PATCH 08/40] Integration instructor (#654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * New module for the integration with instructor * Mode common functions related to structured outputs to it's own module * Draft instructor integration with openai * Add tests for openai integration * Add unit tests for the instructor integrations * Add tests for anthropic integration * Fix including anthropic wrapper * Update llms to deal with instructor * Update dependencies with instructor * Run tests with instructor only on python>=3.9 * Fix circular import with create_distiset * Define _prepare_structured_output as staticmethod * Remove rewritten variable * Remove dead code * Check on Enum.value instead of Enum class as it isn't pickleable * Add tests for utilities related to generation of BaseModel objects from json schema dicts * Add fix to deal with nested BaseModel objects * Fix call from instructor, this should be done on instructor end, but works for the moment * Add docstirngs and typing info * Add script to generate a sample dataset and visualize the result * Update the docstring of the structured output expected format * Add reference in the docs to structured outputs with instructor * Add reference to the dependency installation * Update typing info * Fix test with new mocked client for mistral * Update docs/sections/learn/advanced/structured_generation.md Co-authored-by: Gabriel Martín Blázquez * Update docs/sections/learn/advanced/structured_generation.md Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/steps/tasks/structured_outputs/instructor.py Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/steps/tasks/structured_outputs/instructor.py Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/steps/tasks/structured_outputs/utils.py Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/steps/tasks/structured_outputs/instructor.py Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/steps/tasks/structured_outputs/utils.py Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/steps/tasks/structured_outputs/utils.py Co-authored-by: Gabriel Martín Blázquez * Update src/distilabel/steps/tasks/structured_outputs/utils.py Co-authored-by: Gabriel Martín Blázquez * Add changes from code review * Fix type hint per code review * Update docs/sections/learn/advanced/structured_generation.md Co-authored-by: Alvaro Bartolome * Remove repeated line --------- Co-authored-by: Gabriel Martín Blázquez Co-authored-by: Alvaro Bartolome --- .github/workflows/test.yml | 2 +- .../examples/knowledge-graph-example.png | Bin 0 -> 200852 bytes .../learn/advanced/structured_generation.md | 76 ++++++++- .../pipeline_samples/examples/index.md | 41 ++++- examples/draw_kg.py | 82 +++++++++ .../structured_generation_with_instructor.py | 87 ++++++++++ pyproject.toml | 1 + src/distilabel/llms/anthropic.py | 43 +++-- src/distilabel/llms/azure.py | 9 +- src/distilabel/llms/base.py | 83 +++++++++ src/distilabel/llms/cohere.py | 51 ++++-- src/distilabel/llms/groq.py | 42 +++-- src/distilabel/llms/litellm.py | 74 ++++++--- src/distilabel/llms/mistral.py | 39 ++++- src/distilabel/llms/openai.py | 50 ++++-- src/distilabel/pipeline/local.py | 5 - src/distilabel/steps/tasks/base.py | 4 +- .../tasks/structured_outputs/instructor.py | 140 ++++++++++++++++ .../tasks/structured_outputs/outlines.py | 13 +- .../steps/tasks/structured_outputs/utils.py | 157 ++++++++++++++++++ tests/unit/llms/test_anthropic.py | 82 ++++++++- tests/unit/llms/test_azure.py | 82 +++++++-- tests/unit/llms/test_cohere.py | 98 +++++++++-- tests/unit/llms/test_groq.py | 101 +++++++++-- tests/unit/llms/test_mistral.py | 83 ++++++++- tests/unit/llms/test_openai.py | 103 ++++++++++-- tests/unit/llms/utils.py | 20 +++ .../tasks/structured_outputs/test_utils.py | 75 +++++++++ 28 files changed, 1472 insertions(+), 171 deletions(-) create mode 100644 docs/assets/images/sections/examples/knowledge-graph-example.png create mode 100644 examples/draw_kg.py create mode 100644 examples/structured_generation_with_instructor.py create mode 100644 src/distilabel/steps/tasks/structured_outputs/instructor.py create mode 100644 src/distilabel/steps/tasks/structured_outputs/utils.py create mode 100644 tests/unit/llms/utils.py create mode 100644 tests/unit/steps/tasks/structured_outputs/test_utils.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01f1ebcb9a..56c39f169b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,7 +47,7 @@ jobs: pip install -e .[dev,tests,anthropic,argilla,cohere,groq,hf-inference-endpoints,hf-transformers,litellm,llama-cpp,ollama,openai,outlines,vertexai,vllm] if [ "${python_version}" != "(3, 8)" ]; then - pip install -e .[mistralai] + pip install -e .[mistralai,instructor] fi; pip install git+https://github.com/argilla-io/LLM-Blender.git diff --git a/docs/assets/images/sections/examples/knowledge-graph-example.png b/docs/assets/images/sections/examples/knowledge-graph-example.png new file mode 100644 index 0000000000000000000000000000000000000000..05c80c6cafd0f113193cffcaf7e9d6572a21a4d2 GIT binary patch literal 200852 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
  • - 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 z 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

    _C+ieGX(L%O3mzQpSGo#_{q^tK+VLG<{w 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ﻶ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 z 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 K0axJ>a_UbM+vFGk literal 0 HcmV?d00001 diff --git a/docs/sections/learn/advanced/structured_generation.md b/docs/sections/learn/advanced/structured_generation.md index c0ba743ad4..579427434d 100644 --- a/docs/sections/learn/advanced/structured_generation.md +++ b/docs/sections/learn/advanced/structured_generation.md @@ -8,6 +8,14 @@ The [`LLM`][distilabel.llms.LLM] has an argument named `structured_output`[^1] that determines how we can generate structured outputs with it, let's see an example using [`LlamaCppLLM`][distilabel.llms.LlamaCppLLM]. +!!! Note + + For `outlines` integration to work you may need to install the corresponding dependencies: + + ```bash + pip install distilabel[outlines] + ``` + ### JSON We will start with a JSON example, where we initially define a `pydantic.BaseModel` schema to guide the generation of the structured output. @@ -101,7 +109,7 @@ if match: These were some simple examples, but one can see the options this opens. -!!! NOTE +!!! Tip A full pipeline example can be seen in the following script: [`examples/structured_generation_with_outlines.py`](../../pipeline_samples/examples/index.md#llama-cpp-with-outlines) @@ -119,6 +127,72 @@ These were some simple examples, but one can see the options this opens. curl -L -o ~/Downloads/openhermes-2.5-mistral-7b.Q4_K_M.gguf https://huggingface.co/TheBloke/OpenHermes-2.5-Mistral-7B-GGUF/resolve/main/openhermes-2.5-mistral-7b.Q4_K_M.gguf ``` +## Instructor + +When working with model providers behind an API, there's no direct way of accesing the internal logit processor as `outlines` does, but thanks to [`instructor`](https://python.useinstructor.com/) we can generate structured output from LLM providers. We have integrated `instructor` to deal with the [`AsyncLLM`][distilabel.llms.AsyncLLM], so you can work with the following LLMs: [`OpenAILLM`][distilabel.llms.OpenAILLM], [`AzureOpenAILLM`][distilabel.llms.AzureOpenAILLM], [`CohereLLM`][distilabel.llms.CohereLLM], [`GroqLLM`][distilabel.llms.GroqLLM], [`LiteLLM`][distilabel.llms.LiteLLM] and [`MistralLLM`][distilabel.llms.MistralLLM]. + +`instructor` works with `pydantic.BaseModel` objects internally but in `distilabel` the examples generated would result in the string representation of them, from which the `BaseModel` object can be regenerated. + +!!! Note + For `instructor` integration to work you may need to install the corresponding dependencies: + + ```bash + pip install distilabel[instructor] + ``` + +!!! Note + Take a look at [`InstructorStructuredOutputType`][distilabel.steps.tasks.structured_outputs.instructor.InstructorStructuredOutputType] to see the expected format + of the `structured_output` dict variable. + +The following is the same example you can see with `outlines`'s `JSON` section for comparison purposes. + +```python +from pydantic import BaseModel + +class User(BaseModel): + name: str + last_name: str + id: int +``` + +And then we provide that schema to the `structured_output` argument of the LLM: + +!!! Note + In this example we are using *open-mixtral-8x22b*, keep in mind not all the models work with the function calling functionality required for this example to work. + +```python +from distilabel.llms import MistralLLM + +llm = MistralLLM( + model="open-mixtral-8x22b", + structured_output={"schema": User} +) +llm.load() +``` + +And we are ready to pass our instruction as usual: + +```python +import json + +result = llm.generate( + [[{"role": "user", "content": "Create a user profile for the following marathon"}]], + max_new_tokens=256 +) + +data = json.loads(result[0][0]) +data +# {'name': 'John', 'last_name': 'Doe', 'id': 12345} +User(**data) +# User(name='John', last_name='Doe', id=12345) +``` + +We get back a Python dictionary (formatted as a string) that we can parse using `json.loads`, or validate it directly using the `User`, which is a `pydantic.BaseModel` instance. + +!!! Tip + A full pipeline example can be seen in the following script: + [`examples/structured_generation_with_instructor.py`](../../pipeline_samples/examples/index.md#mistralai-with-instructor) + ## OpenAI JSON OpenAI offers a [JSON Mode](https://platform.openai.com/docs/guides/text-generation/json-mode) to deal with structured output via their API, let's see how to make use of them. The JSON mode instructs the model to always return a JSON object following the instruction required. diff --git a/docs/sections/pipeline_samples/examples/index.md b/docs/sections/pipeline_samples/examples/index.md index 3958fe55c0..efa512cfeb 100644 --- a/docs/sections/pipeline_samples/examples/index.md +++ b/docs/sections/pipeline_samples/examples/index.md @@ -2,7 +2,7 @@ This section contains different example pipelines that showcase different tasks, maybe you can take inspiration from them. -### [llama.cpp with outlines](#llama-cpp-with-outlines) +### [llama.cpp with `outlines`](#llama-cpp-with-outlines) Generate RPG characters following a `pydantic.BaseModel` with `outlines` in `distilabel`. @@ -21,3 +21,42 @@ Generate RPG characters following a `pydantic.BaseModel` with `outlines` in `dis ```python title="structured_generation_with_outlines.py" --8<-- "examples/structured_generation_with_outlines.py" ``` + + +### [MistralAI with `instructor`](#mistralai-with-instructor) + +Answer instructions with knowledge graphs defined as `pydantic.BaseModel` objects using `instructor` in `distilabel`. + +??? Example "See example" + + This script makes use of [`MistralLLM`][distilabel.llms.mistral.MistralLLM] and the structured output capabilities thanks to [`instructor`](https://python.useinstructor.com/) to generate knowledge graphs from complex topics. + + This example is translated from this [awesome example](https://python.useinstructor.com/examples/knowledge_graph/) from `instructor` cookbook. + + ??? Run + + ```python + python examples/structured_generation_with_instructor.py + ``` + + ```python title="structured_generation_with_instructor.py" + --8<-- "examples/structured_generation_with_instructor.py" + ``` + + ??? "Visualizing the graphs" + + Want to see how to visualize the graphs? You can test it using the following script. Generate some samples on your own and take a look: + + !!! NOTE + + This example uses graphviz to render the graph, you can install with `pip` in the following way: + + ```console + pip install graphviz + ``` + + ```python + python examples/draw_kg.py 2 # You can pass 0,1,2 to visualize each of the samples. + ``` + + ![Knowledge graph figure](../../../assets/images/sections/examples/knowledge-graph-example.png) diff --git a/examples/draw_kg.py b/examples/draw_kg.py new file mode 100644 index 0000000000..8d45e40b85 --- /dev/null +++ b/examples/draw_kg.py @@ -0,0 +1,82 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 json +from typing import Any, Dict, List, Union + +from graphviz import Digraph +from pydantic import BaseModel, Field + + +class Node(BaseModel): + id: int + label: str + color: str + + +class Edge(BaseModel): + source: int + target: int + label: str + color: str = "black" + + +class KnowledgeGraph(BaseModel): + nodes: List[Node] = Field(..., default_factory=list) + edges: List[Edge] = Field(..., default_factory=list) + + +def visualize_knowledge_graph(kg: KnowledgeGraph): + dot = Digraph(comment="Knowledge Graph") + + # Add nodes + for node in kg.nodes: + dot.node(str(node.id), node.label, color=node.color) + + # Add edges + for edge in kg.edges: + dot.edge( + str(edge.source), + str(edge.target), + label=edge.label, + color=edge.color or "black", + ) + + # Render the graph + dot.render("knowledge_graph.gv", view=True) + + +def create_knowledge_graph(data: str) -> Union[KnowledgeGraph, None]: + data: Dict[str, Any] = json.loads(data) + + nodes = [Node(**node) for node in data["nodes"]] + edges = [] + for edge in data["edges"]: + if edge.get("color") is None: + edge["color"] = "black" + edges.append(Edge(**edge)) + + return KnowledgeGraph(nodes=nodes, edges=edges) + + +if __name__ == "__main__": + import sys + + args = sys.argv[1:] + + from datasets import load_dataset + + ds = load_dataset("distilabel-internal-testing/knowledge_graphs", split="train") + graphs = [create_knowledge_graph(g) for g in ds["generation"]] + visualize_knowledge_graph(graphs[int(args[0])]) diff --git a/examples/structured_generation_with_instructor.py b/examples/structured_generation_with_instructor.py new file mode 100644 index 0000000000..48082886f4 --- /dev/null +++ b/examples/structured_generation_with_instructor.py @@ -0,0 +1,87 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from typing import List + +from distilabel.llms import MistralLLM +from distilabel.pipeline import Pipeline +from distilabel.steps import LoadDataFromDicts +from distilabel.steps.tasks import TextGeneration +from pydantic import BaseModel, Field + + +class Node(BaseModel): + id: int + label: str + color: str + + +class Edge(BaseModel): + source: int + target: int + label: str + color: str = "black" + + +class KnowledgeGraph(BaseModel): + nodes: List[Node] = Field(..., default_factory=list) + edges: List[Edge] = Field(..., default_factory=list) + + +with Pipeline( + name="Knowledge-Graphs", + description=( + "Generate knowledge graphs to answer questions, this type of dataset can be used to " + "steer a model to answer questions with a knowledge graph." + ), +) as pipeline: + sample_questions = [ + "Teach me about quantum mechanics", + "Who is who in The Simpsons family?", + "Tell me about the evolution of programming languages", + ] + + load_dataset = LoadDataFromDicts( + name="load_instructions", + data=[ + { + "system_prompt": "You are a knowledge graph expert generator. Help me understand by describing everything as a detailed knowledge graph.", + "instruction": f"{question}", + } + for question in sample_questions + ], + ) + + text_generation = TextGeneration( + name="knowledge_graph_generation", + llm=MistralLLM( + model="open-mixtral-8x22b", structured_output={"schema": KnowledgeGraph} + ), + input_batch_size=8, + output_mappings={"model_name": "generation_model"}, + ) + load_dataset >> text_generation + + +if __name__ == "__main__": + distiset = pipeline.run( + parameters={ + text_generation.name: { + "llm": {"generation_kwargs": {"max_new_tokens": 2048}} + } + }, + use_cache=False, + ) + + distiset.push_to_hub("distilabel-internal-testing/knowledge_graphs") diff --git a/pyproject.toml b/pyproject.toml index 8883eb0bca..79bad96369 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ cohere = ["cohere >= 5.2.0"] groq = ["groq >= 0.4.1"] hf-inference-endpoints = ["huggingface_hub >= 0.19.0"] hf-transformers = ["transformers >= 4.34.1", "torch >= 2.0.0"] +instructor = ["instructor >= 1.2.3"] litellm = ["litellm >= 1.30.0"] llama-cpp = ["llama-cpp-python >= 0.2.0"] mistralai = ["mistralai >= 0.1.0"] diff --git a/src/distilabel/llms/anthropic.py b/src/distilabel/llms/anthropic.py index f472aca664..fb4f6dc03c 100644 --- a/src/distilabel/llms/anthropic.py +++ b/src/distilabel/llms/anthropic.py @@ -59,6 +59,8 @@ class AnthropicLLM(AsyncLLM): to `6`. http_client: if provided, an alternative HTTP client to use for calling Anthropic API. Defaults to `None`. + structured_output: a dictionary containing the structured output configuration configuration + using `instructor`. Defaults to None. _api_key_env_var: the name of the environment variable to use for the API key. It is meant to be used internally. _aclient: the `AsyncAnthropic` client to use for the Anthropic API. It is meant @@ -143,6 +145,15 @@ def load(self) -> None: http_client=self.http_client, max_retries=self.max_retries, ) + if self.structured_output: + result = self._prepare_structured_output( + structured_output=self.structured_output, + client=self._aclient, + framework="anthropic", + ) + self._aclient = result.get("client") + if structured_output := result.get("structured_output"): + self.structured_output = structured_output @property def model_name(self) -> str: @@ -174,22 +185,32 @@ async def agenerate( # type: ignore """ from anthropic._types import NOT_GIVEN - completion = await self._aclient.messages.create( # type: ignore - model=self.model, - system=( + kwargs = { + "messages": input, # type: ignore + "model": self.model, + "system": ( input.pop(0)["content"] if input and input[0]["role"] == "system" else NOT_GIVEN ), - messages=input, # type: ignore - max_tokens=max_tokens, - stream=False, - stop_sequences=NOT_GIVEN if stop_sequences is None else stop_sequences, - temperature=temperature, - top_p=NOT_GIVEN if top_p is None else top_p, - top_k=NOT_GIVEN if top_k is None else top_k, - ) + "max_tokens": max_tokens, + "stream": False, + "stop_sequences": NOT_GIVEN if stop_sequences is None else stop_sequences, + "temperature": temperature, + "top_p": NOT_GIVEN if top_p is None else top_p, + "top_k": NOT_GIVEN if top_k is None else top_k, + } + + if self.structured_output: + kwargs = self._prepare_kwargs(kwargs, self.structured_output) + generations = [] + + completion = await self._aclient.messages.create(**kwargs) # type: ignore + if self.structured_output: + generations.append(completion.model_dump_json()) + return generations + if (content := completion.content[0].text) is None: self._logger.warning( f"Received no response using Anthropic client (model: '{self.model}')." diff --git a/src/distilabel/llms/azure.py b/src/distilabel/llms/azure.py index 58d455d65e..3fa1f7cde4 100644 --- a/src/distilabel/llms/azure.py +++ b/src/distilabel/llms/azure.py @@ -14,6 +14,7 @@ import os from typing import TYPE_CHECKING, Optional +from unittest.mock import patch from pydantic import Field, PrivateAttr, SecretStr from typing_extensions import override @@ -68,7 +69,10 @@ class AzureOpenAILLM(OpenAILLM): @override def load(self) -> None: """Loads the `AsyncAzureOpenAI` client to benefit from async requests.""" - super().load() + # This is a workaround to avoid the `OpenAILLM` calling the _prepare_structured_output + # in the load method before we have the proper client. + with patch("OpenAILLM._prepare_structured_output", lambda x: x): + super().load() try: from openai import AsyncAzureOpenAI @@ -93,3 +97,6 @@ def load(self) -> None: max_retries=self.max_retries, # type: ignore timeout=self.timeout, ) + + if self.structured_output: + self._prepare_structured_output(self.structured_output) diff --git a/src/distilabel/llms/base.py b/src/distilabel/llms/base.py index 5bab1c603c..b61c72cf02 100644 --- a/src/distilabel/llms/base.py +++ b/src/distilabel/llms/base.py @@ -33,6 +33,9 @@ if TYPE_CHECKING: from distilabel.llms.typing import GenerateOutput, HiddenState from distilabel.mixins.runtime_parameters import RuntimeParametersNames + from distilabel.steps.tasks.structured_outputs.instructor import ( + InstructorStructuredOutputType, + ) from distilabel.steps.tasks.structured_outputs.outlines import StructuredOutputType from distilabel.steps.tasks.typing import ChatType from distilabel.utils.docstring import Docstring @@ -300,3 +303,83 @@ def __del__(self) -> None: return if self.event_loop is not None: self.event_loop.close() + + @staticmethod + def _prepare_structured_output( + structured_output: "InstructorStructuredOutputType", + client: Any = None, + framework: Optional[str] = None, + ) -> Dict[str, Union[str, Any]]: + """Wraps the client and updates the schema to work store it internally as a json schema. + + Args: + structured_output: The configuration dict to prepare the structured output. + client: The client to wrap to generate structured output. Implemented to work + with `instructor`. + framework: The name of the framework. + + Returns: + A dictionary containing the wrapped client and the schema to update the structured_output + variable in case it is a pydantic model. + """ + from distilabel.steps.tasks.structured_outputs.instructor import ( + prepare_instructor, + ) + + result = {} + client = prepare_instructor( + client, + mode=structured_output.get("mode"), + framework=framework, + ) + result["client"] = client + + schema = structured_output.get("schema") + if not schema: + raise ValueError( + f"The `structured_output` argument must contain a schema: {structured_output}" + ) + if issubclass(schema, BaseModel): + # We want a json schema for the serialization, but instructor wants a pydantic BaseModel. + structured_output["schema"] = schema.model_json_schema() + result["structured_output"] = structured_output + + return result + + @staticmethod + def _prepare_kwargs( + arguments: Dict[str, Any], structured_output: Dict[str, Any] + ) -> Dict[str, Any]: + """Helper method to update the kwargs with the structured output configuration, + used in case they are defined. + + Args: + arguments: The arguments that would be passed to the LLM as **kwargs. + to update with the structured output configuration. + structured_outputs: The structured output configuration to update the arguments. + + Returns: + kwargs updated with the special arguments used by `instructor`. + """ + # We can deal with json schema or BaseModel, but we need to convert it to a BaseModel + # for the Instructor client. + schema = structured_output.get("schema") + if not issubclass(schema, BaseModel): + from distilabel.steps.tasks.structured_outputs.utils import ( + json_schema_to_model, + ) + + try: + schema = json_schema_to_model(schema) + except Exception as e: + raise ValueError( + f"Failed to convert the schema to a pydantic model, the model is too complex currently: {e}" + ) from e + + arguments.update( + **{ + "response_model": schema, + "max_retries": structured_output.get("max_retries", 1), + }, + ) + return arguments diff --git a/src/distilabel/llms/cohere.py b/src/distilabel/llms/cohere.py index b7ecafcace..a49b203f3d 100644 --- a/src/distilabel/llms/cohere.py +++ b/src/distilabel/llms/cohere.py @@ -38,6 +38,7 @@ from distilabel.llms.typing import GenerateOutput + _COHERE_API_KEY_ENV_VAR_NAME = "COHERE_API_KEY" @@ -54,6 +55,9 @@ class CohereLLM(AsyncLLM): to `120`. client_name: the name of the client to use for the API requests. Defaults to `"distilabel"`. + structured_output: a dictionary containing the structured output configuration configuration + using `instructor`. You can take a look at the dictionary structure in + `InstructorStructuredOutputType` from `distilabel.steps.tasks.structured_outputs.instructor`. _ChatMessage: the `ChatMessage` class from the `cohere` package. _aclient: the `AsyncClient` client from the `cohere` package. @@ -117,6 +121,16 @@ def load(self) -> None: timeout=self.timeout, ) + if self.structured_output: + result = self._prepare_structured_output( + structured_output=self.structured_output, + client=self._aclient, + framework="cohere", + ) + self._aclient = result.get("client") + if structured_output := result.get("structured_output"): + self.structured_output = structured_output + def _format_chat_to_cohere( self, input: "ChatType" ) -> Tuple[Union[str, None], List["ChatMessage"], str]: @@ -192,21 +206,28 @@ async def agenerate( # type: ignore """ system, chat_history, message = self._format_chat_to_cohere(input) - response = await self._aclient.chat( # type: ignore - message=message, - model=self.model, - preamble=system, - chat_history=chat_history, - temperature=temperature, - max_tokens=max_tokens, - k=k, - p=p, - seed=seed, - stop_sequences=stop_sequences, - frequency_penalty=frequency_penalty, - presence_penalty=presence_penalty, - raw_prompting=raw_prompting, - ) + kwargs = { + "message": message, + "model": self.model, + "preamble": system, + "chat_history": chat_history, + "temperature": temperature, + "max_tokens": max_tokens, + "k": k, + "p": p, + "seed": seed, + "stop_sequences": stop_sequences, + "frequency_penalty": frequency_penalty, + "presence_penalty": presence_penalty, + "raw_prompting": raw_prompting, + } + if self.structured_output: + kwargs = self._prepare_kwargs(kwargs, self.structured_output) + + response = await self._aclient.chat(**kwargs) # type: ignore + + if self.structured_output: + return response.model_dump_json() if (text := response.text) == "": self._logger.warning( diff --git a/src/distilabel/llms/groq.py b/src/distilabel/llms/groq.py index f7fbda1dc8..4905f82839 100644 --- a/src/distilabel/llms/groq.py +++ b/src/distilabel/llms/groq.py @@ -28,6 +28,7 @@ if TYPE_CHECKING: from groq import AsyncGroq + _GROQ_API_BASE_URL_ENV_VAR_NAME = "GROQ_BASE_URL" _GROQ_API_KEY_ENV_VAR_NAME = "GROQ_API_KEY" @@ -45,6 +46,9 @@ class GroqLLM(AsyncLLM): failing. Defaults to `2`. timeout: the maximum time in seconds to wait for a response from the API. Defaults to `120`. + structured_output: a dictionary containing the structured output configuration configuration + using `instructor`. You can take a look at the dictionary structure in + `InstructorStructuredOutputType` from `distilabel.steps.tasks.structured_outputs.instructor`. _api_key_env_var: the name of the environment variable to use for the API key. _aclient: the `AsyncGroq` client from the `groq` package. @@ -109,6 +113,16 @@ def load(self) -> None: timeout=self.timeout, ) + if self.structured_output: + result = self._prepare_structured_output( + structured_output=self.structured_output, + client=self._aclient, + framework="groq", + ) + self._aclient = result.get("client") + if structured_output := result.get("structured_output"): + self.structured_output = structured_output + @property def model_name(self) -> str: """Returns the model name used for the LLM.""" @@ -142,17 +156,25 @@ async def agenerate( # type: ignore References: - https://console.groq.com/docs/text-chat """ - completion = await self._aclient.chat.completions.create( # type: ignore - messages=input, # type: ignore - model=self.model, - seed=seed, # type: ignore - temperature=temperature, - max_tokens=max_new_tokens, - top_p=top_p, - stream=False, - stop=stop, - ) + kwargs = { + "messages": input, # type: ignore + "model": self.model, + "seed": seed, + "temperature": temperature, + "max_tokens": max_new_tokens, + "top_p": top_p, + "stream": False, + "stop": stop, + } + if self.structured_output: + kwargs = self._prepare_kwargs(kwargs, self.structured_output) + generations = [] + completion = await self._aclient.chat.completions.create(**kwargs) # type: ignore + if self.structured_output: + generations.append(completion.model_dump_json()) + return generations + for choice in completion.choices: if (content := choice.message.content) is None: self._logger.warning( # type: ignore diff --git a/src/distilabel/llms/litellm.py b/src/distilabel/llms/litellm.py index 1b0add14ac..d3660c5ea0 100644 --- a/src/distilabel/llms/litellm.py +++ b/src/distilabel/llms/litellm.py @@ -33,6 +33,9 @@ class LiteLLM(AsyncLLM): model: the model name to use for the LLM e.g. "gpt-3.5-turbo" or "mistral/mistral-large", etc. verbose: whether to log the LiteLLM client's logs. Defaults to `False`. + structured_output: a dictionary containing the structured output configuration configuration + using `instructor`. You can take a look at the dictionary structure in + `InstructorStructuredOutputType` from `distilabel.steps.tasks.structured_outputs.instructor`. Runtime parameters: - `verbose`: whether to log the LiteLLM client's logs. Defaults to `False`. @@ -69,6 +72,16 @@ def load(self) -> None: continue logging.getLogger(key).setLevel(logging.CRITICAL) + if self.structured_output: + result = self._prepare_structured_output( + structured_output=self.structured_output, + client=self._aclient, + framework="litellm", + ) + self._aclient = result.get("client") + if structured_output := result.get("structured_output"): + self.structured_output = structured_output + @property def model_name(self) -> str: """Returns the model name used for the LLM.""" @@ -141,34 +154,40 @@ async def agenerate( # type: ignore """ import litellm + kwargs = { + "model": self.model, + "messages": input, + "n": num_generations, + "functions": functions, + "function_call": function_call, + "temperature": temperature, + "top_p": top_p, + "stream": False, + "stop": stop, + "max_tokens": max_tokens, + "presence_penalty": presence_penalty, + "frequency_penalty": frequency_penalty, + "logit_bias": logit_bias, + "user": user, + "metadata": metadata, + "api_base": api_base, + "api_version": api_version, + "api_key": api_key, + "model_list": model_list, + "mock_response": mock_response, + "force_timeout": force_timeout, + "custom_llm_provider": custom_llm_provider, + } + if self.structured_output: + kwargs = self._prepare_kwargs(kwargs, self.structured_output) + async def _call_aclient_until_n_choices() -> List["Choices"]: choices = [] while len(choices) < num_generations: - completion = await self._aclient( # type: ignore - model=self.model, - messages=input, - n=num_generations, - functions=functions, - function_call=function_call, - temperature=temperature, - top_p=top_p, - stream=False, - stop=stop, - max_tokens=max_tokens, - presence_penalty=presence_penalty, - frequency_penalty=frequency_penalty, - logit_bias=logit_bias, - user=user, - metadata=metadata, - api_base=api_base, - api_version=api_version, - api_key=api_key, - model_list=model_list, - mock_response=mock_response, - force_timeout=force_timeout, - custom_llm_provider=custom_llm_provider, - ) - choices.extend(completion.choices) + completion = await self._aclient(**kwargs) # type: ignore + if not self.structured_output: + completion = completion.choices + choices.extend(completion) return choices # litellm.drop_params is used to en/disable sending **kwargs parameters to the API if they cannot be used @@ -183,6 +202,11 @@ async def _call_aclient_until_n_choices() -> List["Choices"]: raise e generations = [] + + if self.structured_output: + generations.append([choice.model_dump_json() for choice in choices]) + return generations + for choice in choices: if (content := choice.message.content) is None: self._logger.warning( diff --git a/src/distilabel/llms/mistral.py b/src/distilabel/llms/mistral.py index d05d9d3f65..dd96cae91f 100644 --- a/src/distilabel/llms/mistral.py +++ b/src/distilabel/llms/mistral.py @@ -45,6 +45,9 @@ class MistralLLM(AsyncLLM): timeout: the maximum time in seconds to wait for a response. Defaults to `120`. max_concurrent_requests: the maximum number of concurrent requests to send. Defaults to `64`. + structured_output: a dictionary containing the structured output configuration configuration + using `instructor`. You can take a look at the dictionary structure in + `InstructorStructuredOutputType` from `distilabel.steps.tasks.structured_outputs.instructor`. _api_key_env_var: the name of the environment variable to use for the API key. It is meant to be used internally. _aclient: the `MistralAsyncClient` to use for the Mistral API. It is meant to be used internally. @@ -107,6 +110,16 @@ def load(self) -> None: max_concurrent_requests=self.max_concurrent_requests, ) + if self.structured_output: + result = self._prepare_structured_output( + structured_output=self.structured_output, + client=self._aclient, + framework="mistral", + ) + self._aclient = result.get("client") + if structured_output := result.get("structured_output"): + self.structured_output = structured_output + @property def model_name(self) -> str: """Returns the model name used for the LLM.""" @@ -134,14 +147,26 @@ async def agenerate( # type: ignore Returns: A list of lists of strings containing the generated responses for each input. """ - completion = await self._aclient.chat( # type: ignore - messages=input, - model=self.model, - temperature=temperature, - max_tokens=max_new_tokens, - top_p=top_p, - ) + kwargs = { + "messages": input, # type: ignore + "model": self.model, + "max_tokens": max_new_tokens, + "temperature": temperature, + "top_p": top_p, + } generations = [] + if self.structured_output: + kwargs = self._prepare_kwargs(kwargs, self.structured_output) + # TODO: This should work just with the _aclient.chat method, but it's not working. + # We need to check instructor and see if we can create a PR. + completion = await self._aclient.chat.completions.create(**kwargs) + else: + completion = await self._aclient.chat(**kwargs) + + if self.structured_output: + generations.append(completion.model_dump_json()) + return generations + for choice in completion.choices: if (content := choice.message.content) is None: self._logger.warning( diff --git a/src/distilabel/llms/openai.py b/src/distilabel/llms/openai.py index 7314f7c74d..6dedc2387c 100644 --- a/src/distilabel/llms/openai.py +++ b/src/distilabel/llms/openai.py @@ -45,8 +45,9 @@ class OpenAILLM(AsyncLLM): failing. Defaults to `6`. timeout: the maximum time in seconds to wait for a response from the API. Defaults to `120`. - structured_output: a dictionary containing the structured output configuration or if more - fine-grained control is needed, an instance of `OutlinesStructuredOutput`. Defaults to None. + structured_output: a dictionary containing the structured output configuration configuration + using `instructor`. You can take a look at the dictionary structure in + `InstructorStructuredOutputType` from `distilabel.steps.tasks.structured_outputs.instructor`. Runtime parameters: - `base_url`: the base URL to use for the OpenAI API requests. Defaults to `None`. @@ -110,6 +111,16 @@ def load(self) -> None: timeout=self.timeout, ) + if self.structured_output: + result = self._prepare_structured_output( + structured_output=self.structured_output, + client=self._aclient, + framework="openai", + ) + self._aclient = result.get("client") + if structured_output := result.get("structured_output"): + self.structured_output = structured_output + @property def model_name(self) -> str: """Returns the model name used for the LLM.""" @@ -162,20 +173,29 @@ async def agenerate( # type: ignore f"Invalid response format '{response_format}'. Must be either 'text' or 'json'." ) - completion = await self._aclient.chat.completions.create( # type: ignore - messages=input, # type: ignore - model=self.model, - max_tokens=max_new_tokens, - n=num_generations, - frequency_penalty=frequency_penalty, - presence_penalty=presence_penalty, - temperature=temperature, - top_p=top_p, - stop=stop, - timeout=50, - response_format={"type": response_format}, - ) + kwargs = { + "messages": input, # type: ignore + "model": self.model, + "max_tokens": max_new_tokens, + "n": num_generations, + "frequency_penalty": frequency_penalty, + "presence_penalty": presence_penalty, + "temperature": temperature, + "top_p": top_p, + "stop": stop, + "timeout": 50, + "response_format": {"type": response_format}, + } + if self.structured_output: + kwargs = self._prepare_kwargs(kwargs, self.structured_output) + generations = [] + completion = await self._aclient.chat.completions.create(**kwargs) + + if self.structured_output: + generations.append(completion.model_dump_json()) + return generations + for choice in completion.choices: if (content := choice.message.content) is None: self._logger.warning( # type: ignore diff --git a/src/distilabel/pipeline/local.py b/src/distilabel/pipeline/local.py index 98c5f00a37..a88f263810 100644 --- a/src/distilabel/pipeline/local.py +++ b/src/distilabel/pipeline/local.py @@ -691,11 +691,6 @@ def _stop( if _STOP_CALLED: global _STOP_CALLS _STOP_CALLS += 1 - # if _STOP_CALLS == 1: - # self._logger.warning( - # "🛑 Stop has already been called. Ignoring subsequent calls and waiting" - # " for the pipeline to finish..." - # ) if _STOP_CALLS == 1: self._logger.warning( "🛑 Press again to force the pipeline to stop." diff --git a/src/distilabel/steps/tasks/base.py b/src/distilabel/steps/tasks/base.py index b2a4734879..120f2758fe 100644 --- a/src/distilabel/steps/tasks/base.py +++ b/src/distilabel/steps/tasks/base.py @@ -25,6 +25,7 @@ StepInput, _Step, ) +from distilabel.steps.constants import DISTILABEL_METADATA_KEY from distilabel.utils.dicts import combine_dicts if TYPE_CHECKING: @@ -33,9 +34,6 @@ from distilabel.steps.typing import StepOutput -DISTILABEL_METADATA_KEY = "distilabel_metadata" - - class _Task(_Step, ABC): """_Task is an abstract class that implements the `_Step` interface and adds the `format_input` and `format_output` methods to format the inputs and outputs of the diff --git a/src/distilabel/steps/tasks/structured_outputs/instructor.py b/src/distilabel/steps/tasks/structured_outputs/instructor.py new file mode 100644 index 0000000000..e9ec1ea431 --- /dev/null +++ b/src/distilabel/steps/tasks/structured_outputs/instructor.py @@ -0,0 +1,140 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 importlib.util +from typing import ( + TYPE_CHECKING, + Callable, + Literal, + Optional, + Tuple, + Type, + TypeAlias, + TypedDict, + Union, + get_args, +) + +from pydantic import BaseModel + +if TYPE_CHECKING: + import instructor + from anthropic import AsyncAnthropic + from cohere import AsyncClient as AsyncCohere + from groq import AsyncGroq + from mistralai.async_client import MistralAsyncClient + from openai import AsyncAzureOpenAI, AsyncOpenAI + + +InstructorFrameworks = Literal[ + "openai", "azure_openai", "anthropic", "cohere", "groq", "litellm", "mistral" +] +"""Available frameworks for the structured output configuration with `instructor`. """ + +InstructorAvailableClients: TypeAlias = Union[ + "AsyncAnthropic", + "AsyncAzureOpenAI", + "AsyncCohere", + "AsyncGroq", + "AsyncOpenAI", + "MistralAsyncClient", +] +"""Available clients that can be wrapped with `instructor`. """ + + +class InstructorStructuredOutputType(TypedDict): + """TypedDict to represent the structured output configuration from `instructor`.""" + + schema: Type[BaseModel] + """The schema to use for the structured output, a `pydantic.BaseModel` class. """ + mode: Optional["instructor.Mode"] + """Generation mode. Take a look at `instructor.Mode` for more information, if not informed it will + be determined automatically. """ + max_retries: int + """Number of times to reask the model in case of error, if not set will default to the model's default. """ + + +def _client_patcher(framework: InstructorFrameworks) -> Tuple[Callable, str]: + """Helper function to return the appropriate instructor client for the given framework. + + Args: + framework: The framework to use for the instructor client. + + Raises: + ValueError: If the framework is not one of the available frameworks. + + Returns: + Tuple of Callable and string, with the builder of the client patch and the + default mode to use. + """ + import instructor + + if framework in {"openai", "azure_openai"}: + patch = instructor.from_openai, instructor.Mode.TOOLS + elif framework == "anthropic": + patch = instructor.from_anthropic, instructor.Mode.ANTHROPIC_JSON + elif framework == "litellm": + patch = instructor.from_litellm, instructor.Mode.TOOLS + elif framework == "mistral": + patch = instructor.from_mistral, instructor.Mode.MISTRAL_TOOLS + elif framework == "cohere": + patch = instructor.from_cohere, instructor.Mode.COHERE_TOOLS + elif framework == "groq": + patch = instructor.from_groq, instructor.Mode.TOOLS + else: + raise ValueError( + f"Invalid framework '{framework}'. Must be one of {get_args(InstructorFrameworks)}" + ) + + return patch + + +def prepare_instructor( + client: InstructorAvailableClients, + mode: Optional["instructor.Mode"] = None, + framework: Optional[InstructorFrameworks] = None, +) -> "instructor.AsyncInstructor": + """Wraps the given client with the instructor client for the given framework. + + Args: + client: The client to wrap with the instructor client, corresponds to the internal + client we wrap on `LLM`, and one of the implemented in `instructor`. + mode: One of the `instructor.Mode` values. Defaults to None. + framework: The framework corresponding to the client. Defaults to None. + + Raises: + ImportError: If `instructor` is not installed. + ValueError: If the mode is not one of the available modes. + + Returns: + patched_client: The instructor wrapping the original client to be used for + structured generation. + """ + if not importlib.util.find_spec("instructor"): + raise ImportError( + "`instructor` is not installed. Please install it using `pip install instructor`." + ) + import instructor + + builder, default_mode = _client_patcher(framework) + + mode = mode or default_mode + if mode.value not in [m.value for m in instructor.mode.Mode]: + raise ValueError( + f"Invalid mode '{mode}'. Must be one of {[m.value for m in instructor.mode.Mode]}" + ) + + patched_client: instructor.AsyncInstructor = builder(client, mode=mode) + + return patched_client diff --git a/src/distilabel/steps/tasks/structured_outputs/outlines.py b/src/distilabel/steps/tasks/structured_outputs/outlines.py index 087f4913bc..7bc53623de 100644 --- a/src/distilabel/steps/tasks/structured_outputs/outlines.py +++ b/src/distilabel/steps/tasks/structured_outputs/outlines.py @@ -31,6 +31,8 @@ from pydantic import BaseModel +from distilabel.steps.tasks.structured_outputs.utils import schema_as_dict + Frameworks = Literal["transformers", "llamacpp", "vllm"] """Available frameworks for the structured output configuration. """ @@ -59,15 +61,6 @@ def model_to_schema(schema: Type[BaseModel]) -> Dict[str, Any]: return json.dumps(schema.model_json_schema()) -def _schema_as_dict(schema: Union[str, Type[BaseModel]]) -> Dict[str, Any]: - """Helper function to obtain the schema and simplify serialization.""" - if type(schema) == type(BaseModel): - return schema.model_json_schema() - elif isinstance(schema, str): - return json.loads(schema) - return schema - - def _get_logits_processor(framework: Frameworks) -> Tuple[Callable, Callable]: """Helper function to return the appropriate logits processor for the given framework.""" if framework == "transformers": @@ -137,7 +130,7 @@ def prepare_guided_output( llm, whitespace_pattern=structured_output.get("whitespace_pattern"), ), - "schema": _schema_as_dict(schema), + "schema": schema_as_dict(schema), } if format == "regex": diff --git a/src/distilabel/steps/tasks/structured_outputs/utils.py b/src/distilabel/steps/tasks/structured_outputs/utils.py new file mode 100644 index 0000000000..8bcebcb819 --- /dev/null +++ b/src/distilabel/steps/tasks/structured_outputs/utils.py @@ -0,0 +1,157 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 json +from typing import Any, Dict, List, Optional, Type, Union + +from pydantic import BaseModel, Field, create_model + + +def schema_as_dict(schema: Union[str, Type[BaseModel]]) -> Dict[str, Any]: + """Helper function to obtain the schema and simplify serialization.""" + if type(schema) == type(BaseModel): + return schema.model_json_schema() + elif isinstance(schema, str): + return json.loads(schema) + return schema + + +# NOTE: The following functions were copied from: +# https://github.com/pydantic/pydantic/issues/643#issuecomment-1999755873 +# and slightly modified to work with nested models. +# It would be nice to find the original source of this code to give credit. +# Other option would be working with this library: https://github.com/c32168/dyntamic + + +def json_schema_to_model(json_schema: Dict[str, Any]) -> Type[BaseModel]: + """Converts a JSON schema to a `pydantic.BaseModel` class. + + Args: + json_schema: The JSON schema to convert. + + Returns: + A `pydantic.BaseModel` class. + """ + + # Extract the model name from the schema title. + model_name = json_schema.get("title") + if defs := json_schema.get("$defs", None): + # This is done to grab the content of nested classes that need to dereference + # the objects (those should be in a higher level). + pass + + # Extract the field definitions from the schema properties. + field_definitions = { + name: json_schema_to_pydantic_field( + name, prop, json_schema.get("required", []), defs=defs + ) + for name, prop in json_schema.get("properties", {}).items() + } + + # Create the BaseModel class using create_model(). + return create_model(model_name, **field_definitions) + + +def json_schema_to_pydantic_field( + name: str, + json_schema: Dict[str, Any], + required: List[str], + defs: Optional[Dict[str, Any]] = None, +) -> Any: + """Converts a JSON schema property to a `pydantic.Field`. + + Args: + name: The field name. + json_schema: The JSON schema property. + required: The list of required fields. + defs: The definitions of the JSON schema. It's used to dereference nested classes, + so we can grab the original definition from the json schema (it won't + work out of the box with just the reference). + + Returns: + A `pydantic.Field`. + """ + + # NOTE(plaguss): This needs more testing, nested classes need extra work to be converted + # here if we pass a reference to another class it will crash, we have to find the original + # definition and insert it here + # This takes into account single items referred to other classes + if ref := json_schema.get("$ref"): + json_schema = defs.get(ref.split("/")[-1]) + + # This takes into account lists of items referred to other classes + if "items" in json_schema and (ref := json_schema["items"].get("$ref")): + json_schema["items"] = defs.get(ref.split("/")[-1]) + + # Get the field type. + type_ = json_schema_to_pydantic_type(json_schema) + + # Get the field description. + description = json_schema.get("description") + + # Get the field examples. + examples = json_schema.get("examples") + + # Create a Field object with the type, description, and examples. + # The "required" flag will be set later when creating the model. + return ( + type_, + Field( + description=description, + examples=examples, + default=... if name in required else None, + ), + ) + + +def json_schema_to_pydantic_type(json_schema: Dict[str, Any]) -> Any: + """Converts a JSON schema type to a Pydantic type. + + Args: + json_schema: The JSON schema to convert. + + Returns: + A Pydantic type. + """ + type_ = json_schema.get("type") + + if type_ == "string": + type_val = str + elif type_ == "integer": + type_val = int + elif type_ == "number": + type_val = float + elif type_ == "boolean": + type_val = bool + elif type_ == "array": + items_schema = json_schema.get("items") + if items_schema: + item_type = json_schema_to_pydantic_type(items_schema) + type_val = List[item_type] + else: + type_val = List + elif type_ == "object": + # Handle nested models. + properties = json_schema.get("properties") + if properties: + nested_model = json_schema_to_model(json_schema) + type_val = nested_model + else: + type_val = Dict + elif type_ == "null": + type_val = Optional[Any] # Use Optional[Any] for nullable fields + else: + raise ValueError(f"Unsupported JSON schema type: {type_}") + + return type_val diff --git a/tests/unit/llms/test_anthropic.py b/tests/unit/llms/test_anthropic.py index 28e486756b..75e7bcbf62 100644 --- a/tests/unit/llms/test_anthropic.py +++ b/tests/unit/llms/test_anthropic.py @@ -13,12 +13,16 @@ # limitations under the License. import os +import sys +from typing import Any, Dict from unittest.mock import AsyncMock, MagicMock, Mock, patch import nest_asyncio import pytest from distilabel.llms.anthropic import AnthropicLLM +from .utils import DummyUserDetail + @patch("anthropic.AsyncAnthropic") class TestAnthropicLLM: @@ -47,6 +51,37 @@ async def test_agenerate(self, mock_anthropic: MagicMock) -> None: ] ) + @pytest.mark.asyncio + async def test_agenerate_structured(self, mock_openai: MagicMock) -> None: + llm = AnthropicLLM( + model="claude-3-opus-20240229", + api_key="api.key", + structured_output={ + "schema": DummyUserDetail, + "mode": "tool_call", + "max_retries": 1, + }, + ) # type: ignore + llm._aclient = mock_openai + + sample_user = DummyUserDetail(name="John Doe", age=30) + + llm._aclient.messages.create = AsyncMock(return_value=sample_user) + + generation = await llm.agenerate( + input=[ + {"role": "system", "content": ""}, + { + "role": "user", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + }, + ] + ) + assert generation[0] == sample_user.model_dump_json() + + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="`mistralai` requires Python 3.9 or higher" + ) @pytest.mark.asyncio async def test_generate(self, mock_anthropic: MagicMock) -> None: llm = AnthropicLLM(model="claude-3-opus-20240229") # type: ignore @@ -71,7 +106,52 @@ async def test_generate(self, mock_anthropic: MagicMock) -> None: ] ) - def test_serialization(self, _: MagicMock) -> None: + @pytest.mark.parametrize( + "structured_output, dump", + [ + ( + None, + { + "base_url": "https://api.anthropic.com", + "generation_kwargs": {}, + "max_retries": 6, + "model": "claude-3-opus-20240229", + "timeout": 600.0, + "structured_output": None, + "type_info": { + "module": "distilabel.llms.anthropic", + "name": "AnthropicLLM", + }, + }, + ), + ( + { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + { + "base_url": "https://api.anthropic.com", + "generation_kwargs": {}, + "max_retries": 6, + "model": "claude-3-opus-20240229", + "timeout": 600.0, + "structured_output": { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + "type_info": { + "module": "distilabel.llms.anthropic", + "name": "AnthropicLLM", + }, + }, + ), + ], + ) + def test_serialization( + self, _: MagicMock, structured_output: Dict[str, Any], dump: Dict[str, Any] + ) -> None: os.environ["ANTHROPIC_API_KEY"] = "api.key" llm = AnthropicLLM(model="claude-3-opus-20240229") # type: ignore diff --git a/tests/unit/llms/test_azure.py b/tests/unit/llms/test_azure.py index a5208da95f..e8af5d7b8f 100644 --- a/tests/unit/llms/test_azure.py +++ b/tests/unit/llms/test_azure.py @@ -13,10 +13,14 @@ # limitations under the License. import os +from typing import Any, Dict from unittest import mock +import pytest from distilabel.llms.azure import AzureOpenAILLM +from .utils import DummyUserDetail + class TestAzureOpenAILLM: model_id: str = "gpt-4" @@ -56,20 +60,70 @@ def test_azure_openai_llm_env_vars(self) -> None: assert llm.api_key.get_secret_value() == "another.api.key" # type: ignore assert llm.api_version == self.api_version - def test_serialization(self) -> None: + @pytest.mark.parametrize( + "structured_output, dump", + [ + ( + None, + { + "model": "gpt-4", + "api_version": "preview", + "generation_kwargs": {}, + "max_retries": 6, + "base_url": "https://example-resource.azure.openai.com/", + "timeout": 120, + "structured_output": None, + "type_info": { + "module": "distilabel.llms.azure", + "name": "AzureOpenAILLM", + }, + }, + ), + ( + { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + { + "model": "gpt-4", + "api_version": "preview", + "generation_kwargs": {}, + "max_retries": 6, + "base_url": "https://example-resource.azure.openai.com/", + "timeout": 120, + "structured_output": { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + "type_info": { + "module": "distilabel.llms.azure", + "name": "AzureOpenAILLM", + }, + }, + ), + ], + ) + def test_serialization( + self, structured_output: Dict[str, Any], dump: Dict[str, Any] + ) -> None: llm = AzureOpenAILLM( - model=self.model_id, base_url=self.base_url, api_version=self.api_version + model=self.model_id, + base_url=self.base_url, + api_version=self.api_version, + structured_output=structured_output, ) - _dump = { - "generation_kwargs": {}, - "model": "gpt-4", - "base_url": "https://example-resource.azure.openai.com/", - "max_retries": 6, - "timeout": 120, - "api_version": "preview", - "structured_output": None, - "type_info": {"module": "distilabel.llms.azure", "name": "AzureOpenAILLM"}, - } - assert llm.dump() == _dump - assert isinstance(AzureOpenAILLM.from_dict(_dump), AzureOpenAILLM) + # _dump = { + # "generation_kwargs": {}, + # "model": "gpt-4", + # "base_url": "https://example-resource.azure.openai.com/", + # "max_retries": 6, + # "timeout": 120, + # "api_version": "preview", + # "structured_output": None, + # "type_info": {"module": "distilabel.llms.azure", "name": "AzureOpenAILLM"}, + # } + assert llm.dump() == dump + assert isinstance(AzureOpenAILLM.from_dict(dump), AzureOpenAILLM) diff --git a/tests/unit/llms/test_cohere.py b/tests/unit/llms/test_cohere.py index 0c2e2e213c..3cba9611d8 100644 --- a/tests/unit/llms/test_cohere.py +++ b/tests/unit/llms/test_cohere.py @@ -13,12 +13,16 @@ # limitations under the License. import os +import sys +from typing import Any, Dict from unittest import mock import nest_asyncio import pytest from distilabel.llms.cohere import CohereLLM +from .utils import DummyUserDetail + @mock.patch("cohere.AsyncClient") class TestCohereLLM: @@ -64,6 +68,38 @@ async def test_agenerate(self, mock_async_client: mock.MagicMock) -> None: ] ) + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="`mistralai` requires Python 3.9 or higher" + ) + @pytest.mark.asyncio + async def test_agenerate_structured( + self, mock_async_client: mock.MagicMock + ) -> None: + llm = CohereLLM( + model="command-r", + structured_output={ + "schema": DummyUserDetail, + "mode": "tool_call", + "max_retries": 1, + }, + ) + llm._aclient = mock_async_client # type: ignore + + sample_user = DummyUserDetail(name="John Doe", age=30) + + llm._aclient.chat = mock.AsyncMock(return_value=sample_user) + + generation = await llm.agenerate( + input=[ + {"role": "system", "content": ""}, + { + "role": "user", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + }, + ] + ) + assert generation == sample_user.model_dump_json() + @pytest.mark.asyncio async def test_generate(self, mock_async_client: mock.MagicMock) -> None: llm = CohereLLM(model="command-r") @@ -92,21 +128,53 @@ async def test_generate(self, mock_async_client: mock.MagicMock) -> None: ] ) - def test_serialization(self, _: mock.MagicMock) -> None: - llm = CohereLLM(model="command-r") - - dump = { - "model": "command-r", - "generation_kwargs": {}, - "base_url": "https://api.cohere.ai/v1", - "timeout": 120, - "client_name": "distilabel", - "structured_output": None, - "type_info": { - "module": "distilabel.llms.cohere", - "name": "CohereLLM", - }, - } + @pytest.mark.parametrize( + "structured_output, dump", + [ + ( + None, + { + "model": "command-r", + "generation_kwargs": {}, + "base_url": "https://api.cohere.ai/v1", + "timeout": 120, + "client_name": "distilabel", + "structured_output": None, + "type_info": { + "module": "distilabel.llms.cohere", + "name": "CohereLLM", + }, + }, + ), + ( + { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + { + "model": "command-r", + "generation_kwargs": {}, + "base_url": "https://api.cohere.ai/v1", + "timeout": 120, + "client_name": "distilabel", + "structured_output": { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + "type_info": { + "module": "distilabel.llms.cohere", + "name": "CohereLLM", + }, + }, + ), + ], + ) + def test_serialization( + self, _: mock.MagicMock, structured_output: Dict[str, Any], dump: Dict[str, Any] + ) -> None: + llm = CohereLLM(model="command-r", structured_output=structured_output) assert llm.dump() == dump assert isinstance(CohereLLM.from_dict(dump), CohereLLM) diff --git a/tests/unit/llms/test_groq.py b/tests/unit/llms/test_groq.py index e75166ce97..7607ab2cb2 100644 --- a/tests/unit/llms/test_groq.py +++ b/tests/unit/llms/test_groq.py @@ -13,12 +13,16 @@ # limitations under the License. import os +import sys +from typing import Any, Dict from unittest.mock import AsyncMock, MagicMock, Mock, patch import nest_asyncio import pytest from distilabel.llms.groq import GroqLLM +from .utils import DummyUserDetail + @patch("groq._client.AsyncGroq") class TestGroqLLM: @@ -47,6 +51,37 @@ async def test_agenerate(self, mock_groq: MagicMock) -> None: ] ) == [" Aenean hendrerit aliquam velit. ..."] + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="`mistralai` requires Python 3.9 or higher" + ) + @pytest.mark.asyncio + async def test_agenerate_structured(self, mock_openai: MagicMock) -> None: + llm = GroqLLM( + model="llama3-70b-8192", + api_key="api.key", + structured_output={ + "schema": DummyUserDetail, + "mode": "tool_call", + "max_retries": 1, + }, + ) # type: ignore + llm._aclient = mock_openai + + sample_user = DummyUserDetail(name="John Doe", age=30) + + llm._aclient.chat.completions.create = AsyncMock(return_value=sample_user) + + generation = await llm.agenerate( + input=[ + {"role": "system", "content": ""}, + { + "role": "user", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + }, + ] + ) + assert generation[0] == sample_user.model_dump_json() + @pytest.mark.asyncio async def test_generate(self, mock_groq: MagicMock) -> None: llm = GroqLLM(model="llama3-70b-8192", api_key="api.key") # type: ignore @@ -71,22 +106,54 @@ async def test_generate(self, mock_groq: MagicMock) -> None: ] ) == [(" Aenean hendrerit aliquam velit. ...",)] - def test_serialization(self, mock_groq: MagicMock) -> None: + @pytest.mark.parametrize( + "structured_output, dump", + [ + ( + None, + { + "model": "llama3-70b-8192", + "base_url": "https://api.groq.com", + "generation_kwargs": {}, + "max_retries": 2, + "timeout": 120, + "structured_output": None, + "type_info": { + "module": "distilabel.llms.groq", + "name": "GroqLLM", + }, + }, + ), + ( + { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + { + "model": "llama3-70b-8192", + "base_url": "https://api.groq.com", + "generation_kwargs": {}, + "max_retries": 2, + "timeout": 120, + "structured_output": { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + "type_info": { + "module": "distilabel.llms.groq", + "name": "GroqLLM", + }, + }, + ), + ], + ) + def test_serialization( + self, _: MagicMock, structured_output: Dict[str, Any], dump: Dict[str, Any] + ) -> None: os.environ["GROQ_API_KEY"] = "api.key" - llm = GroqLLM(model="llama3-70b-8192") - - _dump = { - "model": "llama3-70b-8192", - "base_url": "https://api.groq.com", - "generation_kwargs": {}, - "max_retries": 2, - "timeout": 120, - "structured_output": None, - "type_info": { - "module": "distilabel.llms.groq", - "name": "GroqLLM", - }, - } + llm = GroqLLM(model="llama3-70b-8192", structured_output=structured_output) - assert llm.dump() == _dump - assert isinstance(GroqLLM.from_dict(_dump), GroqLLM) # type: ignore + assert llm.dump() == dump + assert isinstance(GroqLLM.from_dict(dump), GroqLLM) # type: ignore diff --git a/tests/unit/llms/test_mistral.py b/tests/unit/llms/test_mistral.py index f31e903d3e..5bb2337481 100644 --- a/tests/unit/llms/test_mistral.py +++ b/tests/unit/llms/test_mistral.py @@ -14,11 +14,14 @@ import os import sys +from typing import Any, Dict from unittest.mock import AsyncMock, MagicMock, Mock, patch import nest_asyncio import pytest +from .utils import DummyUserDetail + try: from distilabel.llms.mistral import MistralLLM except ImportError: @@ -55,6 +58,37 @@ async def test_agenerate(self, mock_mistral: MagicMock) -> None: ] ) + @pytest.mark.asyncio + async def test_agenerate_structured(self, mock_mistral: MagicMock) -> None: + llm = MistralLLM( + model="mistral-tiny", + api_key="api.key", + structured_output={ + "schema": DummyUserDetail, + "mode": "tool_call", + "max_retries": 1, + }, + ) # type: ignore + llm._aclient = mock_mistral + + sample_user = DummyUserDetail(name="John Doe", age=30) + + llm._aclient.chat.completions.create = AsyncMock(return_value=sample_user) + # This should work just with the _aclient.chat method once it's fixed in instructor, and + # then in our code. + # llm._aclient.chat = AsyncMock(return_value=sample_user) + + generation = await llm.agenerate( + input=[ + {"role": "system", "content": ""}, + { + "role": "user", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + }, + ] + ) + assert generation[0] == sample_user.model_dump_json() + @pytest.mark.asyncio async def test_generate(self, mock_mistral: MagicMock) -> None: llm = MistralLLM(model="mistral-tiny", api_key="api.key") # type: ignore @@ -79,7 +113,54 @@ async def test_generate(self, mock_mistral: MagicMock) -> None: ] ) - def test_serialization(self, mock_mistral: MagicMock) -> None: + @pytest.mark.parametrize( + "structured_output, dump", + [ + ( + None, + { + "model": "mistral-tiny", + "endpoint": "https://api.mistral.ai", + "generation_kwargs": {}, + "max_retries": 6, + "timeout": 120, + "max_concurrent_requests": 64, + "structured_output": None, + "type_info": { + "module": "distilabel.llms.mistral", + "name": "MistralLLM", + }, + }, + ), + ( + { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + { + "model": "mistral-tiny", + "endpoint": "https://api.mistral.ai", + "generation_kwargs": {}, + "max_retries": 6, + "timeout": 120, + "max_concurrent_requests": 64, + "structured_output": { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + "type_info": { + "module": "distilabel.llms.mistral", + "name": "MistralLLM", + }, + }, + ), + ], + ) + def test_serialization( + self, _: MagicMock, structured_output: Dict[str, Any], dump: Dict[str, Any] + ) -> None: os.environ["MISTRAL_API_KEY"] = "api.key" llm = MistralLLM(model="mistral-tiny") # type: ignore diff --git a/tests/unit/llms/test_openai.py b/tests/unit/llms/test_openai.py index 3562b6588b..7f90f513a2 100644 --- a/tests/unit/llms/test_openai.py +++ b/tests/unit/llms/test_openai.py @@ -13,6 +13,8 @@ # limitations under the License. import os +import sys +from typing import Any, Dict from unittest import mock from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -20,6 +22,8 @@ import pytest from distilabel.llms.openai import OpenAILLM +from .utils import DummyUserDetail + @patch("openai.AsyncOpenAI") class TestOpenAILLM: @@ -63,6 +67,37 @@ async def test_agenerate(self, mock_openai: MagicMock) -> None: ] ) + @pytest.mark.asyncio + async def test_agenerate_structured(self, mock_openai: MagicMock) -> None: + llm = OpenAILLM( + model=self.model_id, + api_key="api.key", + structured_output={ + "schema": DummyUserDetail, + "mode": "tool_call", + "max_retries": 1, + }, + ) # type: ignore + llm._aclient = mock_openai + + sample_user = DummyUserDetail(name="John Doe", age=30) + + llm._aclient.chat.completions.create = AsyncMock(return_value=sample_user) + + generation = await llm.agenerate( + input=[ + {"role": "system", "content": ""}, + { + "role": "user", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + }, + ] + ) + assert generation[0] == sample_user.model_dump_json() + + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="`mistralai` requires Python 3.9 or higher" + ) @pytest.mark.asyncio async def test_generate(self, mock_openai: MagicMock) -> None: llm = OpenAILLM(model=self.model_id, api_key="api.key") # type: ignore @@ -101,21 +136,53 @@ async def test_generate(self, mock_openai: MagicMock) -> None: response_format="unkown_format", ) - def test_serialization(self, _: MagicMock) -> None: - llm = OpenAILLM(model=self.model_id) - - _dump = { - "model": self.model_id, - "generation_kwargs": {}, - "max_retries": 6, - "base_url": "https://api.openai.com/v1", - "timeout": 120, - "structured_output": None, - "type_info": { - "module": "distilabel.llms.openai", - "name": "OpenAILLM", - }, - } - - assert llm.dump() == _dump - assert isinstance(OpenAILLM.from_dict(_dump), OpenAILLM) + @pytest.mark.parametrize( + "structured_output, dump", + [ + ( + None, + { + "model": "gpt-4", + "generation_kwargs": {}, + "max_retries": 6, + "base_url": "https://api.openai.com/v1", + "timeout": 120, + "structured_output": None, + "type_info": { + "module": "distilabel.llms.openai", + "name": "OpenAILLM", + }, + }, + ), + ( + { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + { + "model": "gpt-4", + "generation_kwargs": {}, + "max_retries": 6, + "base_url": "https://api.openai.com/v1", + "timeout": 120, + "structured_output": { + "schema": DummyUserDetail.model_json_schema(), + "mode": "tool_call", + "max_retries": 1, + }, + "type_info": { + "module": "distilabel.llms.openai", + "name": "OpenAILLM", + }, + }, + ), + ], + ) + def test_serialization( + self, _: MagicMock, structured_output: Dict[str, Any], dump: Dict[str, Any] + ) -> None: + llm = OpenAILLM(model=self.model_id, structured_output=structured_output) + + assert llm.dump() == dump + assert isinstance(OpenAILLM.from_dict(dump), OpenAILLM) diff --git a/tests/unit/llms/utils.py b/tests/unit/llms/utils.py new file mode 100644 index 0000000000..7b899253bb --- /dev/null +++ b/tests/unit/llms/utils.py @@ -0,0 +1,20 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from pydantic import BaseModel + + +class DummyUserDetail(BaseModel): + name: str + age: int diff --git a/tests/unit/steps/tasks/structured_outputs/test_utils.py b/tests/unit/steps/tasks/structured_outputs/test_utils.py new file mode 100644 index 0000000000..6238c8567f --- /dev/null +++ b/tests/unit/steps/tasks/structured_outputs/test_utils.py @@ -0,0 +1,75 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from enum import Enum +from typing import List + +from distilabel.steps.tasks.structured_outputs.utils import json_schema_to_model +from pydantic import BaseModel, Field, StringConstraints, conint +from typing_extensions import Annotated + + +class Node(BaseModel): + id: int + label: str + color: str + + +class Edge(BaseModel): + source: int + target: int + label: str + color: str = "black" + + +class KnowledgeGraph(BaseModel): + nodes: List[Node] = Field(..., default_factory=list) + edges: List[Edge] = Field(..., default_factory=list) + + +class Weapon(str, Enum): + sword = "sword" + axe = "axe" + mace = "mace" + spear = "spear" + bow = "bow" + crossbow = "crossbow" + + +class Armor(str, Enum): + leather = "leather" + chainmail = "chainmail" + plate = "plate" + mithril = "mithril" + + +class Character(BaseModel): + name: Annotated[str, StringConstraints(max_length=30)] + age: conint(gt=1, lt=3000) + armor: Armor + weapon: Weapon + + +def test_json_schema_to_model(): + assert type(json_schema_to_model(Node.model_json_schema())) == type(Node) + + +def test_json_schema_to_model_with_enum(): + assert type(json_schema_to_model(Character.model_json_schema())) == type(Character) + + +def test_json_schema_to_model_nested(): + assert type(json_schema_to_model(KnowledgeGraph.model_json_schema())) == type( + KnowledgeGraph + ) From 37f970e53cf84d120b11b7cc07c2ac2c31317daa Mon Sep 17 00:00:00 2001 From: Agus Date: Wed, 29 May 2024 17:38:12 +0200 Subject: [PATCH 09/40] Fix docs of saving/loading distiset from disk (#679) --- docs/sections/learn/advanced/distiset.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/sections/learn/advanced/distiset.md b/docs/sections/learn/advanced/distiset.md index 7c5e122613..199b607fa5 100644 --- a/docs/sections/learn/advanced/distiset.md +++ b/docs/sections/learn/advanced/distiset.md @@ -83,17 +83,30 @@ distiset.save_to_disk( ) ``` -And load a [`Distiset`][distilabel.distiset.Distiset] that was saved using [`Distiset.save_to_disk`][distilabel.distiset.Distiset.save_to_disk] from disk just the same way: +And load a [`Distiset`][distilabel.distiset.Distiset] that was saved using [`Distiset.save_to_disk`][distilabel.distiset.Distiset.save_to_disk] just the same way: ```python from distilabel.distiset import Distiset -distiset = Distiset.save_to_disk("my-dataset") +distiset = Distiset.load_from_disk("my-dataset") ``` -Take into account that these methods pass work as `datasets.load_from_disk` and `datasets.Dataset.save_to_disk` so the arguments are directly passed to those methods. This means you can also make use of `storage_options` argument to save your [`Distiset`][distilabel.distiset.Distiset] in your cloud provider, including the distilabel artifacts (`pipeline.yaml`, `pipeline.log` and the `README.md` with the dataset card), you can read more in `datasets` documentation [here](https://huggingface.co/docs/datasets/filesystems#saving-serialized-datasets). +or from your cloud provider if that's where it was stored: -Take a look at the remaining arguments at [`Distiset.save_to_disk`][distilabel.distiset.Distiset.save_to_disk]. +```python +distiset = Distiset.load_from_disk( + "s3://path/to/my_dataset", # gcs:// or any filesystem tolerated by fsspec + storage_options={ + "key": os.environ["S3_ACCESS_KEY"], + "secret": os.environ["S3_SECRET_KEY"], + ... + } +) +``` + +Take into account that these methods work as `datasets.load_from_disk` and `datasets.Dataset.save_to_disk` so the arguments are directly passed to those methods. This means you can also make use of `storage_options` argument to save your [`Distiset`][distilabel.distiset.Distiset] in your cloud provider, including the distilabel artifacts (`pipeline.yaml`, `pipeline.log` and the `README.md` with the dataset card). You can read more in `datasets` documentation [here](https://huggingface.co/docs/datasets/filesystems#saving-serialized-datasets). + +Take a look at the remaining arguments at [`Distiset.save_to_disk`][distilabel.distiset.Distiset.save_to_disk] and [`Distiset.load_from_disk`][distilabel.distiset.Distiset.load_from_disk]. ## Dataset card From ac41e7f884755c5cf3eea59dc47952874a37c7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Fri, 31 May 2024 10:26:58 +0200 Subject: [PATCH 10/40] Pass data of batches using file system (#678) * Refactor `BasePipeline.__init__` method * Setup `_WriteBuffer` in base and do not cache if `dry_run` * Add `write_batch` method * Add methods to write and read from filesystem * Make `log_queue` optional * Update unit tests * Add docs for passing data with file system * Add integration tests for fs * Remove integration tests timeout * Make integration test lighter * Remove test * Update `mkdocs.yml` * Verbose * Add `tmate` for debugging * Clean handlers only if not pytest * Fix loading batch manager * Add testing fs to pass data again This reverts commit 9246c4caab6642747e3bad82208e2cd26d5a3579. * Remove verbose * Fix test * Increase tests timeout * `join` after `terminate` * Set thread daemon * Proper termination of manager and pool * Fix pytest hanging because of queue was never closed and the thread of the queue was being kept alive (frustration) * Terminate pool first and call `stop_logging` * Remove `protocol` Co-authored-by: plaguss --------- Co-authored-by: plaguss --- .github/workflows/test.yml | 11 +- .../learn/advanced/fs_to_pass_data.md | 24 ++ mkdocs.yml | 7 +- src/distilabel/pipeline/base.py | 264 +++++++++++++++++- src/distilabel/pipeline/local.py | 188 +++++++------ src/distilabel/utils/logging.py | 26 +- tests/integration/conftest.py | 27 ++ tests/integration/test_pipe_simple.py | 11 +- .../test_routing_batch_function.py | 6 +- .../integration/test_using_fs_to_pass_data.py | 68 +++++ tests/unit/pipeline/test_base.py | 208 +++++++++----- tests/unit/pipeline/test_local.py | 3 +- 12 files changed, 646 insertions(+), 197 deletions(-) create mode 100644 docs/sections/learn/advanced/fs_to_pass_data.md create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_using_fs_to_pass_data.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 56c39f169b..2221528276 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,12 @@ on: types: - opened - synchronize + workflow_dispatch: + inputs: + tmate_session: + description: Starts the workflow with tmate enabled. + required: false + default: "false" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -51,6 +57,10 @@ jobs: fi; pip install git+https://github.com/argilla-io/LLM-Blender.git + - name: Setup tmate session + if: ${{ github.event_name == 'workflow_dispatch' && matrix.python-version == '3.11' && github.event.inputs.tmate_session == 'true' }} + uses: mxschmitt/action-tmate@v3 + - name: Lint run: make lint @@ -59,4 +69,3 @@ jobs: - name: Integration Tests run: make integration-tests - timeout-minutes: 5 diff --git a/docs/sections/learn/advanced/fs_to_pass_data.md b/docs/sections/learn/advanced/fs_to_pass_data.md new file mode 100644 index 0000000000..2851c3bc3c --- /dev/null +++ b/docs/sections/learn/advanced/fs_to_pass_data.md @@ -0,0 +1,24 @@ +# Using a file system to pass data of batches between steps + +In some situations, it can happen that the batches contains so much data that is faster to write it to disk and read it back in the next step, instead of passing it using the queue. To solve this issue, `distilabel` uses [`fsspec`](https://filesystem-spec.readthedocs.io/en/latest/) to allow providing a file system configuration and whether if this file system should be used to pass data between steps in the `run` method of the `distilabel` pipelines: + +```python +from distilabel.pipeline import Pipeline + +with Pipeline(name="my-pipeline") as pipeline: + ... + +if __name__ == "__main__": + distiset = pipeline.run( + ..., + storage_parameters={"protocol": "gcs", "path": "gcs://my-bucket"}, + use_fs_to_pass_data=True + ) +``` + +The code above setups a file system (in this case Google Cloud Storage) and sets the flag `use_fs_to_pass_data` to specify that the data of the batches should be passed to the steps using the file system.The `storage_parameters` argument is optional, and in the case it's not provided but `use_fs_to_pass_data==True`, `distilabel` will use the local file system. + +!!! NOTE + + As `GlobalStep`s receives all the data from the previous steps in one single batch accumulating all the data, it's very likely that the data of the batch will be too big to be passed using the queue. In this case and even if `use_fs_to_pass_data==False`, `distilabel` will use the file system to pass the data to the `GlobalStep`. + diff --git a/mkdocs.yml b/mkdocs.yml index d525d1828e..6bf897561b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -142,6 +142,7 @@ nav: - Caching: "sections/learn/advanced/caching.md" - Distiset: "sections/learn/advanced/distiset.md" - Structured Generation: "sections/learn/advanced/structured_generation.md" + - Using the file system to pass batch data: "sections/learn/advanced/fs_to_pass_data.md" - Pipeline Samples: - "sections/pipeline_samples/index.md" - Examples: "sections/pipeline_samples/examples/index.md" @@ -164,9 +165,9 @@ nav: - GlobalStep: "api/step/global_step.md" - "@step": "api/step/decorator.md" - Step Gallery: - - Argilla: "api/step_gallery/argilla.md" - - Columns: "api/step_gallery/columns.md" - - Extra: "api/step_gallery/extra.md" + - Argilla: "api/step_gallery/argilla.md" + - Columns: "api/step_gallery/columns.md" + - Extra: "api/step_gallery/extra.md" - Task: - "api/task/index.md" - GeneratorTask: "api/task/generator_task.md" diff --git a/src/distilabel/pipeline/base.py b/src/distilabel/pipeline/base.py index 56069b6c7b..39138918e8 100644 --- a/src/distilabel/pipeline/base.py +++ b/src/distilabel/pipeline/base.py @@ -32,11 +32,14 @@ Union, ) +import fsspec import pyarrow as pa import pyarrow.parquet as pq from typing_extensions import Self +from upath import UPath from distilabel import __version__ +from distilabel.distiset import create_distiset from distilabel.pipeline._dag import DAG from distilabel.pipeline.constants import ( RECEIVES_ROUTED_BATCHES_ATTR_NAME, @@ -44,6 +47,7 @@ STEP_ATTR_NAME, ) from distilabel.utils.files import list_files_in_dir +from distilabel.utils.logging import setup_logging, stop_logging from distilabel.utils.serialization import ( TYPE_INFO_KEY, _check_is_dir, @@ -76,6 +80,7 @@ class _CacheLocation(TypedDict): pipeline: Path batch_manager: Path data: Path + batch_input_data: Path log_file: Path @@ -118,14 +123,31 @@ class BasePipeline(_Serializable): _cache_dir: The directory where the pipeline will be cached. _logger: The logger instance that will be used by the pipeline. _batch_manager: The batch manager that will manage the batches received from the - steps while running the pipeline. + steps while running the pipeline. It will be created when the pipeline is run, + from scratch or from cache. Defaults to `None`. + _write_buffer: The buffer that will store the data of the leaf steps of the pipeline + while running, so the `Distiset` can be created at the end. It will be created + when the pipeline is run. Defaults to `None`. + _logging_parameters: A dictionary containing the parameters that will passed to + `setup_logging` function to initialize the logging. Defaults to `{}`. + _fs: The `fsspec` filesystem to be used to store the data of the `_Batch`es passed + between the steps. It will be set when the pipeline is run. Defaults to `None`. + _storage_base_path: The base path where the data of the `_Batch`es passed between + the steps will be stored. It will be set then the pipeline is run. Defaults + to `None`. + _use_fs_to_pass_data: Whether to use the file system to pass the data of the + `_Batch`es between the steps. Even if this parameter is `False`, the `Batch`es + received by `GlobalStep`s will always use the file system to pass the data. + Defaults to `False`. + _dry_run: A flag to indicate if the pipeline is running in dry run mode. Defaults + to `False`. """ def __init__( self, name: str, description: Optional[str] = None, - cache_dir: Optional["PathLike"] = None, + cache_dir: Optional[Union[str, "PathLike"]] = None, enable_metadata: bool = False, ) -> None: """Initialize the `BasePipeline` instance. @@ -153,8 +175,15 @@ def __init__( self._logger = logging.getLogger("distilabel.pipeline") - # It's set to None here, will be created in the call to run self._batch_manager: Optional["_BatchManager"] = None + self._write_buffer: Optional["_WriteBuffer"] = None + self._logging_parameters: Dict[str, Any] = { + "filename": self._cache_location["log_file"] + } + + self._fs: Optional[fsspec.AbstractFileSystem] = None + self._storage_base_path: Optional[str] = None + self._use_fs_to_pass_data: bool = False self._dry_run: bool = False def __enter__(self) -> Self: @@ -224,10 +253,22 @@ def _create_signature(self) -> str: return hasher.hexdigest() + def _set_logging_parameters(self, parameters: Dict[str, Any]) -> None: + """Set the parameters that will be passed to the `setup_logging` function to + initialize the logging. + + Args: + parameters: A dictionary with the parameters that will be passed to the + `setup_logging` function. + """ + self._logging_parameters = parameters + def run( self, parameters: Optional[Dict[str, Dict[str, Any]]] = None, use_cache: bool = True, + storage_parameters: Optional[Dict[str, Any]] = None, + use_fs_to_pass_data: bool = False, ) -> "Distiset": # type: ignore """Run the pipeline. It will set the runtime parameters for the steps and validate the pipeline. @@ -240,15 +281,59 @@ def run( the runtime parameters for the step as the value. Defaults to `None`. use_cache: Whether to use the cache from previous pipeline runs. Defaults to `True`. + storage_parameters: A dictionary with the storage parameters (`fsspec` and path) + that will be used to store the data of the `_Batch`es passed between the + steps if `use_fs_to_pass_data` is `True` (for the batches received by a + `GlobalStep` it will be always used). It must have at least the "path" key, + and it can contain additional keys depending on the protocol. By default, + it will use the local file system and a directory in the cache directory. + Defaults to `None`. + use_fs_to_pass_data: Whether to use the file system to pass the data of + the `_Batch`es between the steps. Even if this parameter is `False`, the + `Batch`es received by `GlobalStep`s will always use the file system to + pass the data. Defaults to `False`. Returns: The `Distiset` created by the pipeline. """ - if use_cache: - self._load_from_cache() + + setup_logging(**self._logging_parameters) + + # Set the runtime parameters that will be used during the pipeline execution self._set_runtime_parameters(parameters or {}) + + # Validate the pipeline DAG to check that all the steps are chainable, there are + # no missing runtime parameters, batch sizes are correct, etc. self.dag.validate() + # Load the `_BatchManager` from cache or create one from scratch + self._load_batch_manager(use_cache) + + # Setup the filesystem that will be used to pass the data of the `_Batch`es + self._setup_fsspec(storage_parameters) + self._use_fs_to_pass_data = use_fs_to_pass_data + + if self._dry_run: + self._logger.info("🌵 Dry run mode") + + # If the batch manager is not able to generate batches, that means that the loaded + # `_BatchManager` from cache didn't have any remaining batches to process i.e. + # the previous pipeline execution was completed successfully. + if not self._batch_manager.can_generate(): # type: ignore + self._logger.info( + "💾 Loaded batch manager from cache doesn't contain any remaining data." + " Returning `Distiset` from cache data..." + ) + stop_logging() + return create_distiset( + self._cache_location["data"], + pipeline_path=self._cache_location["pipeline"], + log_filename_path=self._cache_location["log_file"], + enable_metadata=self._enable_metadata, + ) + + self._setup_write_buffer() + def dry_run( self, parameters: Optional[Dict[str, Dict[str, Any]]] = None, @@ -297,6 +382,40 @@ def get_runtime_parameters_info(self) -> Dict[str, List[Dict[str, Any]]]: runtime_parameters[step_name] = step.get_runtime_parameters_info() return runtime_parameters + def _setup_fsspec( + self, storage_parameters: Optional[Dict[str, Any]] = None + ) -> None: + """Setups the `fsspec` filesystem to be used to store the data of the `_Batch`es + passed between the steps. + + Args: + storage_parameters: A dictionary with the storage parameters (`fsspec` and path) + that will be used to store the data of the `_Batch`es passed between the + steps if `use_fs_to_pass_data` is `True` (for the batches received by a + `GlobalStep` it will be always used). It must have at least the "path" key, + and it can contain additional keys depending on the protocol. By default, + it will use the local file system and a directory in the cache directory. + Defaults to `None`. + """ + if not storage_parameters: + self._fs = fsspec.filesystem("file") + self._storage_base_path = ( + f"file://{self._cache_location['batch_input_data']}" + ) + return + + if "path" not in storage_parameters: + raise ValueError( + "The 'path' key must be present in the `storage_parameters` dictionary" + " if it's not `None`." + ) + + path = storage_parameters.pop("path") + protocol = UPath(path).protocol + + self._fs = fsspec.filesystem(protocol, **storage_parameters) + self._storage_base_path = path + def _add_step(self, step: "_Step") -> None: """Add a step to the pipeline. @@ -411,11 +530,15 @@ def _cache_location(self) -> _CacheLocation: "pipeline": folder / "pipeline.yaml", "batch_manager": folder / "batch_manager.json", "data": folder / "data", + "batch_input_data": folder / "batch_input_data", "log_file": folder / "pipeline.log", } def _cache(self) -> None: """Saves the `BasePipeline` using the `_cache_filename`.""" + if self._dry_run: + return + self.save( path=self._cache_location["pipeline"], format=self._cache_location["pipeline"].suffix.replace(".", ""), # type: ignore @@ -424,17 +547,54 @@ def _cache(self) -> None: self._batch_manager.cache(self._cache_location["batch_manager"]) self._logger.debug("Pipeline and batch manager saved to cache.") - def _load_from_cache(self) -> None: - """Will try to load the `BasePipeline` from the cache dir if found, updating - the internal `DAG` and `_BatchManager`. + def _load_batch_manager(self, use_cache: bool = True) -> None: + """Will try to load the `_BatchManager` from the cache dir if found. Otherwise, + it will create one from scratch. """ - cache_loc = self._cache_location - if cache_loc["pipeline"].exists(): - if cache_loc["batch_manager"].exists(): - self._batch_manager = _BatchManager.load_from_cache( - cache_loc["batch_manager"] - ) - self._logger.info("💾 Load pipeline from cache") + batch_manager_cache_loc = self._cache_location["batch_manager"] + if use_cache and batch_manager_cache_loc.exists(): + self._logger.info( + f"💾 Loading `_BatchManager` from cache: '{batch_manager_cache_loc}'" + ) + self._batch_manager = _BatchManager.load_from_cache(batch_manager_cache_loc) + else: + self._batch_manager = _BatchManager.from_dag(self.dag) + + def _setup_write_buffer(self) -> None: + """Setups the `_WriteBuffer` that will store the data of the leaf steps of the + pipeline while running, so the `Distiset` can be created at the end. + """ + buffer_data_path = self._cache_location["data"] + self._logger.info(f"📝 Pipeline data will be written to '{buffer_data_path}'") + self._write_buffer = _WriteBuffer(buffer_data_path, self.dag.leaf_steps) + + def _send_batch_to_step(self, batch: "_Batch") -> None: + """Sends a batch to the input queue of a step, writing the data of the batch + to the filesystem and setting `batch.data_path` with the path where the data + was written (if requiered i.e. the step is a global step or `use_fs_to_pass_data`) + + This method should be extended by the specific pipeline implementation, adding + the logic to send the batch to the step. + + Args: + batch: The batch to send. + """ + self._logger.debug( + f"Setting batch {batch.seq_no} as last batch sent to '{batch.step_name}': {batch}" + ) + self._batch_manager.set_last_batch_sent(batch) # type: ignore + + step: "_Step" = self.dag.get_step(batch.step_name)[STEP_ATTR_NAME] + if not step.is_generator and (step.is_global or self._use_fs_to_pass_data): + base_path = UPath(self._storage_base_path) / step.name # type: ignore + self._logger.debug( + f"Writing {batch.seq_no} batch for '{batch.step_name}' step to filesystem: {base_path}" + ) + batch.write_batch_data_to_fs(self._fs, base_path) # type: ignore + + self._logger.debug( + f"Sending batch {batch.seq_no} to step '{batch.step_name}': {batch}" + ) @dataclass @@ -446,6 +606,8 @@ class _Batch(_Serializable): step_name: The name of the step that will process the batch. last_batch: A flag to indicate if the batch is the last one. data: The data to be processed. + data_hash: The hash of the data. Defaults to `None`. + data_path: The path where the data of the batch is stored. Defaults to `None`. accumulated: A flag to indicate if the batch is accumulated. created_from: A dictionary containing the `seq_no` of the batches of the steps that were used to create this batch. @@ -457,10 +619,12 @@ class _Batch(_Serializable): last_batch: bool data: List[List[Dict[str, Any]]] = field(default_factory=list, repr=False) data_hash: Optional[str] = None + data_path: Optional[str] = None accumulated: bool = False created_from: Dict[str, List[Tuple[int, int]]] = field(default_factory=dict) batch_routed_to: List[str] = field(default_factory=list) size: int = 0 + _fs: Optional[fsspec.AbstractFileSystem] = None def next_batch(self) -> "_Batch": """Create a new `_Batch` instance with the next batch of data. @@ -497,6 +661,9 @@ def get_data(self, num_rows: Union[int, None] = None) -> List[Dict[str, Any]]: A list with the data taken from the batch. """ + if self.data == [] and self.data_path is not None: + pass + if num_rows is None: data = self.data[0] self.data = [] @@ -570,6 +737,73 @@ def copy(self) -> "_Batch": """ return copy.deepcopy(self) + def write_batch_data_to_fs( + self, + fs: Optional[fsspec.AbstractFileSystem] = None, + base_path: Optional[UPath] = None, + ) -> None: + """Writes the content of the batch to the filesystem. + + Args + fs: The `fsspec` filesystem to be used to write the data. If not provided, the + one set in the `_fs` attribute will be used. Defaults to `None`. + base_path: The base path where the data of the batch will be stored. If not + provided, the one set in the `data_path` attribute will be used. Defaults + to `None`. + + Raises: + ValueError: If `fs` is not provided and the `_fs` attribute is not set. + """ + + if not fs and not self._fs: + raise ValueError( + "The `fs` parameter must be provided if the `_fs` attribute is not set." + ) + + if fs: + self._fs = fs + + if not base_path and not self.data_path: + raise ValueError( + "The `base_path` parameter must be provided if the `data_path` attribute" + " is not set." + ) + + seq_no_dir = ( + base_path / f"seq_no_{self.seq_no}" if base_path else UPath(self.data_path) + ) + seq_no_dir._fs_cached = self._fs # type: ignore + seq_no_dir.mkdir(parents=True, exist_ok=True) + + for i, data in enumerate(self.data): + table = pa.Table.from_pylist(data) + with self._fs.open(seq_no_dir / f"data_index_{i}.parquet", "wb") as f: # type: ignore + pq.write_table(table, f) + + self.data = [] + self.data_path = str(seq_no_dir) + + def read_batch_data_from_fs(self) -> None: + """Reads the content of the batch from the filesystem.""" + if not self.data_path: + raise ValueError( + "`data_path` attribute must be set to read the data from the filesystem." + " Use `write_batch_data_to_fs` method to set the `data_path` attribute." + ) + + if not self._fs: + raise ValueError( + "`_fs` attribute must be set to read the data from the filesystem." + " Use `write_batch_data_to_fs` method to set the `_fs` attribute." + ) + + for file in self._fs.ls(self.data_path): + with self._fs.open(file, "rb") as f: + table = pq.read_table(f) + self.data.append(table.to_pylist()) + + self._fs.rm(self.data_path, recursive=True) + @dataclass class _BatchManagerStep(_Serializable): diff --git a/src/distilabel/pipeline/local.py b/src/distilabel/pipeline/local.py index a88f263810..36f41e16b9 100644 --- a/src/distilabel/pipeline/local.py +++ b/src/distilabel/pipeline/local.py @@ -12,9 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import logging import multiprocessing as mp import signal +import sys import threading import time import traceback @@ -28,8 +28,6 @@ LAST_BATCH_SENT_FLAG, BasePipeline, _Batch, - _BatchManager, - _WriteBuffer, ) from distilabel.pipeline.constants import ( CONVERGENCE_STEP_ATTR_NAME, @@ -68,9 +66,14 @@ _SUBPROCESS_EXCEPTION: Union[Exception, None] = None -def _init_worker(queue: "Queue[Any]") -> None: +def _init_worker(log_queue: "Queue[Any]") -> None: + """Init function for the child processes that will execute the `Step`s of the `Pipeline`. + + Args: + log_queue: The queue to send the logs to the main process. + """ signal.signal(signal.SIGINT, signal.SIG_IGN) - setup_logging(queue) + setup_logging(log_queue) class Pipeline(BasePipeline): @@ -80,6 +83,8 @@ def run( self, parameters: Optional[Dict[str, Dict[str, Any]]] = None, use_cache: bool = True, + storage_parameters: Optional[Dict[str, Any]] = None, + use_fs_to_pass_data: bool = False, ) -> "Distiset": """Runs the pipeline. @@ -88,6 +93,17 @@ def run( the runtime parameters for the step as the value. Defaults to `None`. use_cache: Whether to use the cache from previous pipeline runs. Defaults to `True`. + storage_parameters: A dictionary with the storage parameters (`fsspec` and path) + that will be used to store the data of the `_Batch`es passed between the + steps if `use_fs_to_pass_data` is `True` (for the batches received by a + `GlobalStep` it will be always used). It must have at least the "path" key, + and it can contain additional keys depending on the protocol. By default, + it will use the local file system and a directory in the cache directory. + Defaults to `None`. + use_fs_to_pass_data: Whether to use the file system to pass the data of + the `_Batch`es between the steps. Even if this parameter is `False`, the + `Batch`es received by `GlobalStep`s will always use the file system to + pass the data. Defaults to `False`. Returns: The `Distiset` created by the pipeline. @@ -96,37 +112,15 @@ def run( RuntimeError: If the pipeline fails to load all the steps. """ log_queue = mp.Queue() - # We must place the runtime parameters before calling setup_logging to ensure consistency - super().run(parameters, use_cache) - setup_logging(log_queue, filename=str(self._cache_location["log_file"])) # type: ignore - self._logger = logging.getLogger("distilabel.pipeline.local") - - if self._dry_run: - # This message is placed here to ensure we are using the already setup logger. - self._logger.info("🌵 Dry run mode") - - if self._batch_manager is None: - self._batch_manager = _BatchManager.from_dag(self.dag) - - # If the batch manager is not able to generate batches, that means that the loaded - # `_BatchManager` from cache didn't have any remaining batches to process i.e. - # the previous pipeline execution was completed successfully. - if not self._batch_manager.can_generate(): - self._logger.info( - "💾 Loaded batch manager from cache doesn't have any remaining data. Returning" - " `Distiset` from cache data..." - ) - stop_logging() - return create_distiset( - self._cache_location["data"], - pipeline_path=self._cache_location["pipeline"], - log_filename_path=self._cache_location["log_file"], - enable_metadata=self._enable_metadata, - ) - buffer_data_path = self._cache_location["data"] - self._logger.info(f"📝 Pipeline data will be written to '{buffer_data_path}'") - write_buffer = _WriteBuffer(buffer_data_path, self.dag.leaf_steps) + self._set_logging_parameters( + {"log_queue": log_queue, "filename": self._cache_location["log_file"]} + ) + + if distiset := super().run( + parameters, use_cache, storage_parameters, use_fs_to_pass_data + ): + return distiset num_processes = len(self.dag) ctx = mp.get_context() # type: ignore @@ -144,7 +138,7 @@ def run( # Wait for all the steps to be loaded correctly if not self._all_steps_loaded(): - write_buffer.close() + self._write_buffer.close() # type: ignore self._batch_manager = None stop_logging() raise RuntimeError( @@ -156,15 +150,17 @@ def run( self._request_initial_batches() # Start a loop to receive the output batches from the steps - self._run_output_queue_loop_in_thread(write_buffer) + self._run_output_queue_loop_in_thread() # Send `None` to steps `input_queue`s just in case some step is still waiting self._notify_steps_to_stop() - pool.close() - pool.join() + # `Pool.__exit__` has already called `terminate`, `join` the pool to make sure + # all the processes have finished + pool.join() + manager.join() - write_buffer.close() + self._write_buffer.close() # type: ignore distiset = create_distiset( self._cache_location["data"], pipeline_path=self._cache_location["pipeline"], @@ -174,15 +170,11 @@ def run( stop_logging() return distiset - def _run_output_queue_loop_in_thread(self, write_buffer: "_WriteBuffer") -> None: + def _run_output_queue_loop_in_thread(self) -> None: """Runs the output queue loop in a separate thread to receive the output batches from the steps. This is done to avoid the signal handler to block the loop, which - would prevent the pipeline from stopping correctly. - - Args: - write_buffer: The write buffer to write the data from the leaf steps to disk. - """ - thread = threading.Thread(target=self._output_queue_loop, args=(write_buffer,)) + would prevent the pipeline from stopping correctly.""" + thread = threading.Thread(target=self._output_queue_loop) thread.start() thread.join() @@ -193,21 +185,28 @@ def _notify_steps_to_stop(self) -> None: if input_queue := self.dag.get_step(step_name).get(INPUT_QUEUE_ATTR_NAME): input_queue.put(None) - def _output_queue_loop(self, write_buffer: "_WriteBuffer") -> None: + def _output_queue_loop(self) -> None: """Loop to receive the output batches from the steps and manage the flow of the - batches through the pipeline. - - Args: - write_buffer: The write buffer to write the data from the leaf steps to disk. - """ + batches through the pipeline.""" while self._batch_manager.can_generate() and not _STOP_CALLED: # type: ignore self._logger.debug("Waiting for output batch from step...") if (batch := self.output_queue.get()) is None: self._logger.debug("Received `None` from output queue. Breaking loop.") break + self._logger.debug( + f"Received batch with seq_no {batch.seq_no} from step '{batch.step_name}'" + f" from output queue: {batch}" + ) + + if batch.data_path: + self._logger.debug( + f"Reading {batch.seq_no} batch data from '{batch.step_name}': '{batch.data_path}'" + ) + batch.read_batch_data_from_fs() + if batch.step_name in self.dag.leaf_steps: - write_buffer.add_batch(batch) + self._write_buffer.add_batch(batch) # type: ignore # If `_STOP_CALLED` was set to `True` while waiting for the output queue, then # we need to handle the stop of the pipeline and break the loop to avoid @@ -217,15 +216,10 @@ def _output_queue_loop(self, write_buffer: "_WriteBuffer") -> None: self._handle_batch_on_stop(batch) break - self._logger.debug( - f"Received batch with seq_no {batch.seq_no} from step '{batch.step_name}'" - f" from output queue: {batch}" - ) - self._manage_batch_flow(batch) if _STOP_CALLED: - self._handle_stop(write_buffer) + self._handle_stop() def _manage_batch_flow(self, batch: "_Batch") -> None: """Checks if the step that generated the batch has more data in its buffer to @@ -357,14 +351,10 @@ def _request_more_batches_if_needed(self, step: "Step") -> None: ) self._send_batch_to_step(last_batch.next_batch()) - def _handle_stop(self, write_buffer: "_WriteBuffer") -> None: + def _handle_stop(self) -> None: """Handles the stop of the pipeline execution, which will stop the steps from processing more batches and wait for the output queue to be empty, to not lose - any data that was already processed by the steps before the stop was called. - - Args: - write_buffer: The write buffer to write the data from the leaf steps to disk. - """ + any data that was already processed by the steps before the stop was called.""" self._logger.debug("Handling stop of the pipeline execution...") # Add the remaining batches in the input queues back to the batch manager @@ -399,7 +389,7 @@ def _handle_stop(self, write_buffer: "_WriteBuffer") -> None: continue if batch.step_name in self.dag.leaf_steps: - write_buffer.add_batch(batch) + self._write_buffer.add_batch(batch) # type: ignore self._handle_batch_on_stop(batch) @@ -522,14 +512,7 @@ def _send_batch_to_step(self, batch: "_Batch") -> None: Args: batch: The batch to send. """ - self._logger.debug( - f"Setting batch {batch.seq_no} as last batch sent to '{batch.step_name}': {batch}" - ) - self._batch_manager.set_last_batch_sent(batch) # type: ignore - - self._logger.debug( - f"Sending batch {batch.seq_no} to step '{batch.step_name}': {batch}" - ) + super()._send_batch_to_step(batch) input_queue = self.dag.get_step(batch.step_name)[INPUT_QUEUE_ATTR_NAME] input_queue.put(batch) @@ -582,7 +565,7 @@ def _run_steps_in_loop( for step_name in self.dag: step: "Step" = self.dag.get_step(step_name)[STEP_ATTR_NAME] input_queue = manager.Queue() - self.dag.set_step_attr(step.name, INPUT_QUEUE_ATTR_NAME, input_queue) + self.dag.set_step_attr(step.name, INPUT_QUEUE_ATTR_NAME, input_queue) # type: ignore # Set `pipeline` to `None` as in some Python environments the pipeline is not # picklable and it will raise an error when trying to send the step to the process. @@ -623,20 +606,21 @@ def _error_callback(self, e: BaseException) -> None: with self.shared_info[_STEPS_LOADED_LOCK_KEY]: self.shared_info[_STEPS_LOADED_KEY] = [_STEPS_LOADED_ERROR_CODE] _SUBPROCESS_EXCEPTION = e.subprocess_exception - _SUBPROCESS_EXCEPTION.__traceback__ = tblib.Traceback.from_string( + _SUBPROCESS_EXCEPTION.__traceback__ = tblib.Traceback.from_string( # type: ignore e.formatted_traceback ).as_traceback() return # If the step is global, is not in the last trophic level and has no successors, # then we can ignore the error and continue executing the pipeline + step_name: str = e.step.name # type: ignore if ( e.step.is_global - and not self.dag.step_in_last_trophic_level(e.step.name) - and list(self.dag.get_step_successors(e.step.name)) == [] + and not self.dag.step_in_last_trophic_level(step_name) + and list(self.dag.get_step_successors(step_name)) == [] ): self._logger.error( - f"✋ An error occurred when running global step '{e.step.name}' with no" + f"✋ An error occurred when running global step '{step_name}' with no" " successors and not in the last trophic level. Pipeline execution can" f" continue. Error will be ignored." ) @@ -644,7 +628,7 @@ def _error_callback(self, e: BaseException) -> None: return # Global step with successors failed - self._logger.error(f"An error occurred in global step '{e.step.name}'") + self._logger.error(f"An error occurred in global step '{step_name}'") self._logger.error(f"Subprocess traceback:\n\n{e.formatted_traceback}") self._cache() self._stop() @@ -697,17 +681,16 @@ def _stop( ) elif _STOP_CALLS > 1: self._logger.warning("🛑 Forcing pipeline interruption.") - import gc - import sys - - if manager: - manager.shutdown() if pool: - pool.close() pool.terminate() + pool.join() + + if manager: + manager.shutdown() + manager.join() - gc.collect() + stop_logging() sys.exit(1) @@ -953,6 +936,11 @@ def _non_generator_process_loop(self) -> None: self.step._logger.info( f"📦 Processing batch {batch.seq_no} in '{batch.step_name}'" ) + + if batch.data_path is not None: + self.step._logger.debug(f"Reading batch data from '{batch.data_path}'") + batch.read_batch_data_from_fs() + result = [] try: if self.step.has_multiple_inputs: @@ -964,11 +952,7 @@ def _non_generator_process_loop(self) -> None: raise _ProcessWrapperException(str(e), self.step, 2, e) from e # Impute step outputs columns with `None` - for row in batch.data[0]: - data = row.copy() - for output in self.step.outputs: - data[output] = None - result.append(data) + result = self._impute_step_outputs(batch) # if the step is not global then we can skip the batch which means sending # an empty batch to the output queue @@ -986,8 +970,26 @@ def _non_generator_process_loop(self) -> None: if batch.last_batch: break + def _impute_step_outputs(self, batch: "_Batch") -> List[Dict[str, Any]]: + """Imputes the step outputs columns with `None` in the batch data. + + Args: + batch: The batch to impute. + """ + result = [] + for row in batch.data[0]: + data = row.copy() + for output in self.step.outputs: + data[output] = None + result.append(data) + return result + def _send_batch(self, batch: _Batch) -> None: """Sends a batch to the `output_queue`.""" + if batch.data_path is not None: + self.step._logger.debug(f"Writing batch data to '{batch.data_path}'") + batch.write_batch_data_to_fs() + self.step._logger.info( f"📨 Step '{batch.step_name}' sending batch {batch.seq_no} to output queue" ) diff --git a/src/distilabel/utils/logging.py b/src/distilabel/utils/logging.py index 15a737f448..af1b26a18b 100644 --- a/src/distilabel/utils/logging.py +++ b/src/distilabel/utils/logging.py @@ -42,7 +42,9 @@ queue_listener: Union[QueueListener, None] = None -def setup_logging(log_queue: "Queue[Any]", filename: Optional[str] = None) -> None: +def setup_logging( + log_queue: Optional["Queue[Any]"] = None, filename: Optional[str] = None +) -> None: """Sets up logging to use a queue across all processes.""" global queue_listener @@ -53,7 +55,7 @@ def setup_logging(log_queue: "Queue[Any]", filename: Optional[str] = None) -> No # If the current process is the main process, set up a `QueueListener` # to handle logs from all subprocesses - if mp.current_process().name == "MainProcess": + if mp.current_process().name == "MainProcess" and filename: formatter = logging.Formatter("['%(name)s'] %(message)s") handler = RichHandler(rich_tracebacks=True) handler.setFormatter(formatter) @@ -66,10 +68,11 @@ def setup_logging(log_queue: "Queue[Any]", filename: Optional[str] = None) -> No ) file_handler.setFormatter(file_formatter) - queue_listener = QueueListener( - log_queue, handler, file_handler, respect_handler_level=True - ) - queue_listener.start() + if log_queue is not None: + queue_listener = QueueListener( + log_queue, handler, file_handler, respect_handler_level=True + ) + queue_listener.start() log_level = os.environ.get("DISTILABEL_LOG_LEVEL", "INFO").upper() if log_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: @@ -80,9 +83,15 @@ def setup_logging(log_queue: "Queue[Any]", filename: Optional[str] = None) -> No log_level = "INFO" root_logger = logging.getLogger() - root_logger.handlers.clear() + + running_test = "PYTEST_CURRENT_TEST" in os.environ + if not running_test: + root_logger.handlers.clear() + + if log_queue is not None: + root_logger.addHandler(QueueHandler(log_queue)) + root_logger.setLevel(log_level) - root_logger.addHandler(QueueHandler(log_queue)) def stop_logging() -> None: @@ -90,4 +99,5 @@ def stop_logging() -> None: global queue_listener if queue_listener is not None: queue_listener.stop() + queue_listener.queue.close() queue_listener = None diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000000..8337c9aaa9 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,27 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 os +import tempfile +from typing import Generator + +import pytest + + +@pytest.fixture(autouse=True) +def temp_cache_dir() -> Generator[None, None, None]: + """Set the cache directory to a temporary directory for all tests.""" + with tempfile.TemporaryDirectory() as tmpdirname: + os.environ["DISTILABEL_CACHE_DIR"] = tmpdirname + yield diff --git a/tests/integration/test_pipe_simple.py b/tests/integration/test_pipe_simple.py index 8ab1fff29a..31a624b15f 100644 --- a/tests/integration/test_pipe_simple.py +++ b/tests/integration/test_pipe_simple.py @@ -166,15 +166,8 @@ def run_pipeline(): ) -def test_pipeline_cached(): - ds = run_pipeline() - print() - print("----- RUNNING PIPELINE AGAIN -----") - print() +def test_pipeline_cached() -> None: + run_pipeline() ds = run_pipeline() assert isinstance(ds, Distiset) assert len(ds["default"]["train"]) == 80 - - -if __name__ == "__main__": - test_pipeline_cached() diff --git a/tests/integration/test_routing_batch_function.py b/tests/integration/test_routing_batch_function.py index 0ea2ee3cdc..228fb1c43e 100644 --- a/tests/integration/test_routing_batch_function.py +++ b/tests/integration/test_routing_batch_function.py @@ -74,7 +74,7 @@ def CombineGenerations(*inputs: StepInput) -> "StepOutput": yield combined_list -@pytest.mark.timeout(120) +@pytest.mark.timeout(240) def test_routing_batch_function() -> None: with Pipeline(name="test") as pipeline: load_dataset = LoadDataFromDicts( @@ -95,7 +95,7 @@ def test_routing_batch_function() -> None: assert len(row["generations"]) == 2 -@pytest.mark.timeout(120) +@pytest.mark.timeout(240) def test_routing_batch_function_irregular_batch_sizes() -> None: with Pipeline(name="test") as pipeline: load_dataset = LoadDataFromDicts( @@ -120,7 +120,7 @@ def test_routing_batch_function_irregular_batch_sizes() -> None: assert len(row["generations"]) == 2 -@pytest.mark.timeout(120) +@pytest.mark.timeout(240) def test_multiple_routing_batch_function() -> None: batch_size = 200 diff --git a/tests/integration/test_using_fs_to_pass_data.py b/tests/integration/test_using_fs_to_pass_data.py new file mode 100644 index 0000000000..811885e356 --- /dev/null +++ b/tests/integration/test_using_fs_to_pass_data.py @@ -0,0 +1,68 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from typing import TYPE_CHECKING, List + +import numpy as np +from distilabel.pipeline import Pipeline +from distilabel.steps import GeneratorStep, StepInput, step + +if TYPE_CHECKING: + from distilabel.steps import GeneratorStepOutput, StepOutput + + +class NumpyBigArrayGenerator(GeneratorStep): + num_batches: int + + @property + def outputs(self) -> List[str]: + return ["array"] + + def process(self, offset: int = 0) -> "GeneratorStepOutput": + for i in range(self.num_batches): + yield ( + [{"array": np.random.randn(128)} for _ in range(self.batch_size)], # type: ignore + i == self.num_batches - 1, + ) # type: ignore + + +@step(step_type="global") +def ReceiveArrays(inputs: StepInput) -> "StepOutput": + yield inputs + + +def test_passing_data_through_fs_only_global_steps() -> None: + with Pipeline(name="dummy") as pipeline: + numpy_generator = NumpyBigArrayGenerator(num_batches=5, batch_size=100) + + receive_arrays = ReceiveArrays() + + numpy_generator >> receive_arrays + + distiset = pipeline.run(use_fs_to_pass_data=False, use_cache=False) + + assert len(distiset["default"]["train"]) == 500 + + +def test_passing_data_through_fs() -> None: + with Pipeline(name="dummy") as pipeline: + numpy_generator = NumpyBigArrayGenerator(num_batches=2, batch_size=200) + + receive_arrays = ReceiveArrays() + + numpy_generator >> receive_arrays + + distiset = pipeline.run(use_fs_to_pass_data=True, use_cache=False) + + assert len(distiset["default"]["train"]) == 400 diff --git a/tests/unit/pipeline/test_base.py b/tests/unit/pipeline/test_base.py index 60a624c9a0..8ae456a319 100644 --- a/tests/unit/pipeline/test_base.py +++ b/tests/unit/pipeline/test_base.py @@ -32,10 +32,19 @@ ) from distilabel.pipeline.local import Pipeline from distilabel.steps.base import GlobalStep, Step, StepInput +from distilabel.steps.typing import StepOutput from distilabel.utils.serialization import TYPE_INFO_KEY +from fsspec.implementations.local import LocalFileSystem from pydantic import Field - -from .utils import DummyGeneratorStep, DummyStep1, DummyStep2, batch_gen +from upath import UPath + +from .utils import ( + DummyGeneratorStep, + DummyGlobalStep, + DummyStep1, + DummyStep2, + batch_gen, +) if TYPE_CHECKING: from distilabel.steps.base import GeneratorStep @@ -70,6 +79,134 @@ def test_context_manager(self) -> None: assert _GlobalPipelineManager.get_pipeline() is None + @pytest.mark.parametrize("use_cache", [False, True]) + def test_load_batch_manager(self, use_cache: bool) -> None: + pipeline = BasePipeline(name="unit-test-pipeline") + pipeline._load_batch_manager(use_cache=True) + pipeline._cache() + + with mock.patch( + "distilabel.pipeline.base._BatchManager.load_from_cache" + ) as mock_load_from_cache, mock.patch( + "distilabel.pipeline.base._BatchManager.from_dag" + ) as mock_from_dag: + pipeline._load_batch_manager(use_cache=use_cache) + + if use_cache: + mock_load_from_cache.assert_called_once_with( + pipeline._cache_location["batch_manager"] + ) + mock_from_dag.assert_not_called() + else: + mock_load_from_cache.assert_not_called() + mock_from_dag.assert_called_once_with(pipeline.dag) + + def test_setup_write_buffer(self) -> None: + pipeline = BasePipeline(name="unit-test-pipeline") + + pipeline._setup_write_buffer() + assert isinstance(pipeline._write_buffer, _WriteBuffer) + + def test_set_logging_parameters(self) -> None: + pipeline = BasePipeline(name="unit-test-pipeline") + pipeline._set_logging_parameters({"unit-test": "yes"}) + + assert pipeline._logging_parameters == {"unit-test": "yes"} + + def test_setup_fsspec(self) -> None: + pipeline = BasePipeline(name="unit-test-pipeline") + + with mock.patch("fsspec.filesystem") as mock_filesystem: + pipeline._setup_fsspec({"path": "gcs://my-bucket", "extra": "stuff"}) + + mock_filesystem.assert_called_once_with("gcs", **{"extra": "stuff"}) + + def test_setup_fsspec_default(self) -> None: + pipeline = BasePipeline(name="unit-test-pipeline") + pipeline._setup_fsspec() + + assert isinstance(pipeline._fs, LocalFileSystem) + assert ( + pipeline._storage_base_path + == f"file://{pipeline._cache_location['batch_input_data']}" + ) + + def test_setup_fsspec_raises_value_error(self) -> None: + pipeline = BasePipeline(name="unit-test-pipeline") + + with pytest.raises(ValueError, match="The 'path' key must be present"): + pipeline._setup_fsspec({"key": "random"}) + + def test_send_batch_to_step(self) -> None: + with BasePipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + global_step = DummyGlobalStep() + + generator >> [step, global_step] + + pipeline._batch_manager = mock.MagicMock() + pipeline._setup_fsspec() + + with mock.patch( + "distilabel.pipeline.base._Batch.write_batch_data_to_fs" + ) as mock_write: + batch = _Batch(seq_no=0, step_name=generator.name, last_batch=False) # type: ignore + pipeline._send_batch_to_step(batch) + pipeline._batch_manager.set_last_batch_sent.assert_called_once_with(batch) + + pipeline._send_batch_to_step( + _Batch(seq_no=0, step_name=step.name, last_batch=False) # type: ignore + ) + + mock_write.assert_not_called() + + with mock.patch( + "distilabel.pipeline.base._Batch.write_batch_data_to_fs" + ) as mock_write: + pipeline._send_batch_to_step( + _Batch(seq_no=0, step_name=global_step.name, last_batch=False) # type: ignore + ) + + mock_write.assert_called_once_with( + pipeline._fs, + UPath(pipeline._storage_base_path) / global_step.name, + ) + + pipeline._use_fs_to_pass_data = True + + with mock.patch( + "distilabel.pipeline.base._Batch.write_batch_data_to_fs" + ) as mock_write: + pipeline._send_batch_to_step( + _Batch(seq_no=0, step_name=generator.name, last_batch=False) # type: ignore + ) + + mock_write.assert_not_called() + + with mock.patch( + "distilabel.pipeline.base._Batch.write_batch_data_to_fs" + ) as mock_write: + pipeline._send_batch_to_step( + _Batch(seq_no=0, step_name=step.name, last_batch=False) # type: ignore + ) + pipeline._send_batch_to_step( + _Batch(seq_no=0, step_name=global_step.name, last_batch=False) # type: ignore + ) + + mock_write.assert_has_calls( + [ + mock.call( + pipeline._fs, + UPath(pipeline._storage_base_path) / step.name, + ), + mock.call( + pipeline._fs, + UPath(pipeline._storage_base_path) / global_step.name, + ), + ] + ) + def test_get_runtime_parameters_info(self) -> None: class DummyStep1(Step): runtime_param1: RuntimeParameter[str] = Field( @@ -180,8 +317,8 @@ class DummyStep1(Step): default=None, description="runtime_param2 description" ) - def process(self, inputs: StepInput) -> None: - pass + def process(self, inputs: StepInput) -> StepOutput: # type: ignore + yield [{}] class DummyStep2(Step): runtime_param3: RuntimeParameter[str] = Field( @@ -191,8 +328,8 @@ class DummyStep2(Step): default=None, description="runtime_param4 description" ) - def process(self, inputs: StepInput) -> None: - pass + def process(self, inputs: StepInput) -> StepOutput: # type: ignore + yield [{}] with BasePipeline(name="unit-test-pipeline") as pipeline: gen_step = DummyGeneratorStep(name="dummy_generator_step") @@ -205,7 +342,8 @@ def process(self, inputs: StepInput) -> None: if expected: assert expected in caplog.text else: - assert caplog.text == expected + assert "Did you mean any of:" not in expected + assert "Available runtime parameters for the step" not in expected def test_cache_dir_env_variable(self) -> None: with mock.patch.dict(os.environ, clear=True): @@ -2509,62 +2647,6 @@ def test_base_pipeline_signature(self): signature = pipeline._create_signature() assert signature == "a11ac46253598e6fe126420b23b9ad31c6422c92" - @pytest.mark.parametrize("use_cache", [True, False]) - def test_run_pipe_and_load_from_cache(self, use_cache: bool): - # Maybe not the best place for this test, but does the work for now - from distilabel.pipeline.base import BasePipeline - from distilabel.pipeline.routing_batch_function import sample_n_steps - - from tests.unit.pipeline.utils import DummyGeneratorStep, DummyStep1, DummyStep2 - - sample_two_steps = sample_n_steps(2) - - with tempfile.TemporaryDirectory() as tmpdirname: - with BasePipeline( - name="unit-test-pipeline", cache_dir=tmpdirname - ) as pipeline: - dummy_generator = DummyGeneratorStep() - dummy_step_1_0 = DummyStep1() - dummy_step_1_1 = DummyStep1() - dummy_step_1_2 = DummyStep1() - dummy_step_2 = DummyStep2() - - ( - dummy_generator - >> sample_two_steps - >> [dummy_step_1_0, dummy_step_1_1, dummy_step_1_2] - >> dummy_step_2 - ) - - pipeline.run({}, use_cache=use_cache) - - assert not pipeline._cache_location["pipeline"].exists() - # Set the _BatchManager to the pipeline to check it exists afterwards - pipeline._batch_manager = _BatchManager.from_dag(pipeline.dag) - pipeline._cache() - - assert pipeline._cache_location["pipeline"].exists() - - with BasePipeline(name="unit-test-pipeline", cache_dir=tmpdirname) as pipe: - dummy_generator = DummyGeneratorStep() - dummy_step_1_0 = DummyStep1() - dummy_step_1_1 = DummyStep1() - dummy_step_1_2 = DummyStep1() - dummy_step_2 = DummyStep2() - - ( - dummy_generator - >> sample_two_steps - >> [dummy_step_1_0, dummy_step_1_1, dummy_step_1_2] - >> dummy_step_2 - ) - - pipe.run({}, use_cache=use_cache) - if use_cache: - assert pipe._batch_manager - else: - assert not pipe._batch_manager - def test_binary_rshift_operator(self) -> None: # Tests the steps can be connected using the >> operator. from distilabel.pipeline.local import Pipeline diff --git a/tests/unit/pipeline/test_local.py b/tests/unit/pipeline/test_local.py index 3c4a15b534..511f8f5040 100644 --- a/tests/unit/pipeline/test_local.py +++ b/tests/unit/pipeline/test_local.py @@ -58,8 +58,7 @@ def test_send_batch_to_step(self, dummy_generator_step: "GeneratorStep") -> None ) pipeline._send_batch_to_step(batch=batch) # type: ignore - batch_manager_mock.set_last_batch_sent.assert_called_once_with(batch) - get_step_mock.assert_called_once_with(dummy_generator_step.name) + get_step_mock.assert_has_calls([mock.call(dummy_generator_step.name)]) input_queue.put.assert_called_once_with(batch) @mock.patch("distilabel.pipeline.local._ProcessWrapper") From 42efe6d479b31957b47d698e6350c91123973412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Fri, 31 May 2024 13:40:45 +0200 Subject: [PATCH 11/40] Add `python==3.12` (#615) * Add Python 3.12 * Add `install_dependencies.sh` script * Update to `ruff==0.4.5` * Apply format * Update commands * Update to `argilla >= 1.29.0` * Update to setup tmate in 3.12 * Update `vllm` dependency * Use `uv` to install dependencies * Update dependencies * Fix regex message for 3.12 --- .github/workflows/test.yml | 13 +++---------- .pre-commit-config.yaml | 5 ++--- Makefile | 4 ++-- pyproject.toml | 7 ++++--- scripts/install_dependencies.sh | 12 ++++++++++++ src/distilabel/steps/argilla/base.py | 6 ++---- src/distilabel/steps/base.py | 9 +++------ src/distilabel/steps/decorator.py | 9 +++------ tests/unit/steps/argilla/test_base.py | 5 ++++- tests/unit/steps/tasks/test_base.py | 5 ++++- 10 files changed, 39 insertions(+), 36 deletions(-) create mode 100755 scripts/install_dependencies.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2221528276..f46fd02735 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] fail-fast: false steps: @@ -48,17 +48,10 @@ jobs: - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' - run: | - python_version=$(python -c "import sys; print(sys.version_info[:2])") - - pip install -e .[dev,tests,anthropic,argilla,cohere,groq,hf-inference-endpoints,hf-transformers,litellm,llama-cpp,ollama,openai,outlines,vertexai,vllm] - if [ "${python_version}" != "(3, 8)" ]; then - pip install -e .[mistralai,instructor] - fi; - pip install git+https://github.com/argilla-io/LLM-Blender.git + run: ./scripts/install_dependencies.sh - name: Setup tmate session - if: ${{ github.event_name == 'workflow_dispatch' && matrix.python-version == '3.11' && github.event.inputs.tmate_session == 'true' }} + if: ${{ github.event_name == 'workflow_dispatch' && matrix.python-version == '3.12' && github.event.inputs.tmate_session == 'true' }} uses: mxschmitt/action-tmate@v3 - name: Lint diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59ac8caa8c..24ab3d19a2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,11 +11,10 @@ repos: - --fuzzy-match-generates-todo - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.1.4 + rev: v0.4.5 hooks: - id: ruff - args: - - --fix + args: [--fix] - id: ruff-format ci: diff --git a/Makefile b/Makefile index 9634d016f7..16cc0c92c7 100644 --- a/Makefile +++ b/Makefile @@ -2,12 +2,12 @@ sources = src/distilabel tests .PHONY: format format: - ruff --fix $(sources) + ruff check --fix $(sources) ruff format $(sources) .PHONY: lint lint: - ruff $(sources) + ruff check $(sources) ruff format --check $(sources) .PHONY: unit-tests diff --git a/pyproject.toml b/pyproject.toml index 79bad96369..c49a8c702b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] @@ -45,7 +46,7 @@ distilabel = "distilabel.cli.app:app" "distilabel/components-gallery" = "distilabel.utils.mkdocs.components_gallery:ComponentsGalleryPlugin" [project.optional-dependencies] -dev = ["ruff == 0.2.2", "pre-commit >= 3.5.0"] +dev = ["ruff == 0.4.5", "pre-commit >= 3.5.0"] docs = [ "mkdocs-material >= 9.5.0", "mkdocstrings[python] >= 0.24.0", @@ -61,7 +62,7 @@ tests = ["pytest >= 7.4.0", "pytest-asyncio", "nest-asyncio", "pytest-timeout"] # Optional LLMs, integrations, etc anthropic = ["anthropic >= 0.20.0"] -argilla = ["argilla >= 1.23.0"] +argilla = ["argilla >= 1.29.0"] cohere = ["cohere >= 5.2.0"] groq = ["groq >= 0.4.1"] hf-inference-endpoints = ["huggingface_hub >= 0.19.0"] @@ -74,7 +75,7 @@ ollama = ["ollama >= 0.1.7"] openai = ["openai >= 1.0.0"] outlines = ["outlines >= 0.0.40"] vertexai = ["google-cloud-aiplatform >= 1.38.0"] -vllm = ["vllm >= 0.2.1", "filelock >= 3.13.4"] +vllm = ["vllm >= 0.4.0", "outlines == 0.0.34", "filelock >= 3.13.4"] [project.urls] Documentation = "https://distilabel.argilla.io/" diff --git a/scripts/install_dependencies.sh b/scripts/install_dependencies.sh new file mode 100755 index 0000000000..9344ac472c --- /dev/null +++ b/scripts/install_dependencies.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +python_version=$(python -c "import sys; print(sys.version_info[:2])") + +python -m pip install uv + +uv pip install --system -e ".[dev,tests,anthropic,argilla,cohere,groq,hf-inference-endpoints,hf-transformers,litellm,llama-cpp,ollama,openai,outlines,vertexai]" +if [ "${python_version}" != "(3, 8)" ]; then + uv pip install --system -e .[mistralai,instructor] +fi + +uv pip install --system git+https://github.com/argilla-io/LLM-Blender.git diff --git a/src/distilabel/steps/argilla/base.py b/src/distilabel/steps/argilla/base.py index 1460e55f99..57cd1863bf 100644 --- a/src/distilabel/steps/argilla/base.py +++ b/src/distilabel/steps/argilla/base.py @@ -137,9 +137,7 @@ def load(self) -> None: @property @abstractmethod - def inputs(self) -> List[str]: - ... + def inputs(self) -> List[str]: ... @abstractmethod - def process(self, *inputs: StepInput) -> "StepOutput": - ... + def process(self, *inputs: StepInput) -> "StepOutput": ... diff --git a/src/distilabel/steps/base.py b/src/distilabel/steps/base.py index fcac454447..9aab121815 100644 --- a/src/distilabel/steps/base.py +++ b/src/distilabel/steps/base.py @@ -220,18 +220,15 @@ def _set_routing_batch_function( routing_batch_function._step = self @overload - def __rshift__(self, other: "RoutingBatchFunction") -> "RoutingBatchFunction": - ... + def __rshift__(self, other: "RoutingBatchFunction") -> "RoutingBatchFunction": ... @overload def __rshift__( self, other: List["DownstreamConnectableSteps"] - ) -> List["DownstreamConnectableSteps"]: - ... + ) -> List["DownstreamConnectableSteps"]: ... @overload - def __rshift__(self, other: "DownstreamConnectable") -> "DownstreamConnectable": - ... + def __rshift__(self, other: "DownstreamConnectable") -> "DownstreamConnectable": ... def __rshift__( self, diff --git a/src/distilabel/steps/decorator.py b/src/distilabel/steps/decorator.py index 1d7c1853cb..da2cbb8dcc 100644 --- a/src/distilabel/steps/decorator.py +++ b/src/distilabel/steps/decorator.py @@ -53,8 +53,7 @@ def step( inputs: Union[List[str], None] = None, outputs: Union[List[str], None] = None, step_type: Literal["normal"] = "normal", -) -> Callable[..., Type["Step"]]: - ... +) -> Callable[..., Type["Step"]]: ... @overload @@ -62,8 +61,7 @@ def step( inputs: Union[List[str], None] = None, outputs: Union[List[str], None] = None, step_type: Literal["global"] = "global", -) -> Callable[..., Type["GlobalStep"]]: - ... +) -> Callable[..., Type["GlobalStep"]]: ... @overload @@ -71,8 +69,7 @@ def step( inputs: None = None, outputs: Union[List[str], None] = None, step_type: Literal["generator"] = "generator", -) -> Callable[..., Type["GeneratorStep"]]: - ... +) -> Callable[..., Type["GeneratorStep"]]: ... def step( diff --git a/tests/unit/steps/argilla/test_base.py b/tests/unit/steps/argilla/test_base.py index c816c8bcac..dbb8773923 100644 --- a/tests/unit/steps/argilla/test_base.py +++ b/tests/unit/steps/argilla/test_base.py @@ -13,6 +13,7 @@ # limitations under the License. import os +import sys from typing import TYPE_CHECKING, List import pytest @@ -83,7 +84,9 @@ def test_with_errors(self, caplog) -> None: with pytest.raises( TypeError, - match="Can't instantiate abstract class Argilla with abstract methods inputs, process", + match="Can't instantiate abstract class Argilla with abstract methods inputs, process" + if sys.version_info < (3, 12) + else "Can't instantiate abstract class Argilla without an implementation for abstract methods 'inputs', 'process'", ): Argilla(name="step", pipeline=Pipeline(name="unit-test-pipeline")) # type: ignore diff --git a/tests/unit/steps/tasks/test_base.py b/tests/unit/steps/tasks/test_base.py index ed1fd956cf..0cccbd5c9d 100644 --- a/tests/unit/steps/tasks/test_base.py +++ b/tests/unit/steps/tasks/test_base.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys from dataclasses import field from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union @@ -77,7 +78,9 @@ def test_with_errors(self, caplog: pytest.LogCaptureFixture) -> None: with pytest.raises( TypeError, - match="Can't instantiate abstract class Task with abstract methods format_input, format_output", + match="Can't instantiate abstract class Task with abstract methods format_input, format_output" + if sys.version_info < (3, 12) + else "Can't instantiate abstract class Task without an implementation for abstract methods 'format_input', 'format_output'", ): Task(name="task", llm=DummyLLM()) # type: ignore From 0dc464ec4ed1909b43bb00c8efee0a7668df7fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Fri, 31 May 2024 14:02:26 +0200 Subject: [PATCH 12/40] Add `codspeed` benchmarks (#674) * Add `codspeed` benchmarks * Make the test lighter * Make test ultra light * Use `python==3.12` for `codspeed` * Add concurrency config for `codspeed` workflow --- .github/workflows/codspeed.yml | 42 +++++++++++++++++++++++++ .github/workflows/test.yml | 4 --- pyproject.toml | 8 ++++- tests/integration/test_cache.py | 54 +++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/codspeed.yml create mode 100644 tests/integration/test_cache.py diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000000..6da0a611be --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,42 @@ +name: Benchmarks + +on: + push: + branches: + - "main" + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + benchmarks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + # Looks like it's not working very well for other people: + # https://github.com/actions/setup-python/issues/436 + # cache: "pip" + # cache-dependency-path: pyproject.toml + + - uses: actions/cache@v3 + id: cache + with: + path: ${{ env.pythonLocation }} + key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-benchmarks-v00 + + - name: Install dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: ./scripts/install_dependencies.sh + + - name: Run benchmarks + uses: CodSpeedHQ/action@v2 + with: + token: ${{ secrets.CODSPEED_TOKEN }} + run: pytest tests/ --codspeed diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f46fd02735..b80e88d2e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,10 +50,6 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: ./scripts/install_dependencies.sh - - name: Setup tmate session - if: ${{ github.event_name == 'workflow_dispatch' && matrix.python-version == '3.12' && github.event.inputs.tmate_session == 'true' }} - uses: mxschmitt/action-tmate@v3 - - name: Lint run: make lint diff --git a/pyproject.toml b/pyproject.toml index c49a8c702b..80fe4714ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,13 @@ docs = [ "CairoSVG >= 2.7.1", "mknotebooks >= 0.8.0", ] -tests = ["pytest >= 7.4.0", "pytest-asyncio", "nest-asyncio", "pytest-timeout"] +tests = [ + "pytest >= 7.4.0", + "pytest-asyncio", + "nest-asyncio", + "pytest-timeout", + "pytest-codspeed", +] # Optional LLMs, integrations, etc anthropic = ["anthropic >= 0.20.0"] diff --git a/tests/integration/test_cache.py b/tests/integration/test_cache.py new file mode 100644 index 0000000000..d37ace8e76 --- /dev/null +++ b/tests/integration/test_cache.py @@ -0,0 +1,54 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from typing import TYPE_CHECKING, List + +import numpy as np +from distilabel.pipeline import Pipeline +from distilabel.steps import GeneratorStep, StepInput, step + +if TYPE_CHECKING: + from distilabel.steps import GeneratorStepOutput, StepOutput + from pytest_codspeed import BenchmarkFixture + + +class NumpyBigArrayGenerator(GeneratorStep): + num_batches: int + + @property + def outputs(self) -> List[str]: + return ["array"] + + def process(self, offset: int = 0) -> "GeneratorStepOutput": + for i in range(self.num_batches): + yield ( + [{"array": np.random.randn(256)} for _ in range(self.batch_size)], # type: ignore + i == self.num_batches - 1, + ) # type: ignore + + +@step(step_type="global") +def ReceiveArrays(inputs: StepInput) -> "StepOutput": + yield inputs + + +def test_cache_time(benchmark: "BenchmarkFixture") -> None: + with Pipeline(name="dummy") as pipeline: + numpy_generator = NumpyBigArrayGenerator(num_batches=2, batch_size=100) + + receive_arrays = ReceiveArrays() + + numpy_generator >> receive_arrays + + benchmark(pipeline.run, use_cache=False) From 1624b1e66a053c1243933b950b90b2ae67dcd2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Fri, 31 May 2024 14:27:32 +0200 Subject: [PATCH 13/40] Use `pytest` decorator for benchmark --- tests/integration/test_cache.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_cache.py b/tests/integration/test_cache.py index d37ace8e76..6eddd6f7ca 100644 --- a/tests/integration/test_cache.py +++ b/tests/integration/test_cache.py @@ -15,12 +15,12 @@ from typing import TYPE_CHECKING, List import numpy as np +import pytest from distilabel.pipeline import Pipeline from distilabel.steps import GeneratorStep, StepInput, step if TYPE_CHECKING: from distilabel.steps import GeneratorStepOutput, StepOutput - from pytest_codspeed import BenchmarkFixture class NumpyBigArrayGenerator(GeneratorStep): @@ -43,7 +43,8 @@ def ReceiveArrays(inputs: StepInput) -> "StepOutput": yield inputs -def test_cache_time(benchmark: "BenchmarkFixture") -> None: +@pytest.mark.benchmark +def test_cache_time() -> None: with Pipeline(name="dummy") as pipeline: numpy_generator = NumpyBigArrayGenerator(num_batches=2, batch_size=100) @@ -51,4 +52,4 @@ def test_cache_time(benchmark: "BenchmarkFixture") -> None: numpy_generator >> receive_arrays - benchmark(pipeline.run, use_cache=False) + pipeline.run(use_cache=False) From 918c19fec76ba429014e6fe83c47d7ab2ed75861 Mon Sep 17 00:00:00 2001 From: Alvaro Bartolome Date: Mon, 3 Jun 2024 13:05:58 +0200 Subject: [PATCH 14/40] Add `StructuredGeneration` task and support for `grammar` in `InferenceEndpointsLLM` (#680) * Fix linting issue from `develop` branch * Add `grammar` arg in `agenerate` (WIP) * Run `codespell` in `src/` and `docs/` * Add support for `StructuredGeneration` (WIP) - Now the `generate` method in the `LLM` can receive either a chat or a tuple with the chat and the grammar for that chat - `grammar` is an arg at `LLM` level - The `grammar` can be specified per row via the `StructuredGeneration`, while when specifying a global `grammar` then the `grammar` arg within the `LLM` can be used via the `TextGeneration` task instead * Add `flatten_dict` to avoid `pyarrow` issues with nested dicts * Handle `pyarrow.lib.ArrowInvalid` when nested unaligned dicts * Add `StructuredGeneration` docstrings * Fix `TextGeneration` docstring for `model_name` output * Rename `DefaultInput` to `StandardInput` and add missing docstrings * Update `LLM` subclasses type-hints * Add `StructuredGeneration` import in `distilabel.steps.tasks` * Add `InferenceEndpointsLLM` and `StructuredGeneration` --- .../pipeline_samples/examples/index.md | 2 +- src/distilabel/llms/anthropic.py | 8 +- src/distilabel/llms/base.py | 14 +- src/distilabel/llms/cohere.py | 10 +- src/distilabel/llms/groq.py | 8 +- .../llms/huggingface/inference_endpoints.py | 22 +++- .../llms/huggingface/transformers.py | 10 +- src/distilabel/llms/litellm.py | 4 +- src/distilabel/llms/llamacpp.py | 4 +- src/distilabel/llms/mistral.py | 8 +- src/distilabel/llms/ollama.py | 4 +- src/distilabel/llms/openai.py | 4 +- src/distilabel/llms/vertexai.py | 6 +- src/distilabel/llms/vllm.py | 6 +- src/distilabel/pipeline/base.py | 14 +- src/distilabel/steps/tasks/__init__.py | 2 + src/distilabel/steps/tasks/base.py | 8 +- .../steps/tasks/structured_generation.py | 104 +++++++++++++++ src/distilabel/steps/tasks/text_generation.py | 2 +- src/distilabel/steps/tasks/typing.py | 15 ++- src/distilabel/utils/dicts.py | 5 + .../huggingface/test_inference_endpoints.py | 47 +++++++ .../steps/tasks/test_structured_generation.py | 124 ++++++++++++++++++ tests/unit/test_imports.py | 1 + 24 files changed, 377 insertions(+), 55 deletions(-) create mode 100644 src/distilabel/steps/tasks/structured_generation.py create mode 100644 tests/unit/steps/tasks/test_structured_generation.py diff --git a/docs/sections/pipeline_samples/examples/index.md b/docs/sections/pipeline_samples/examples/index.md index efa512cfeb..aa74004357 100644 --- a/docs/sections/pipeline_samples/examples/index.md +++ b/docs/sections/pipeline_samples/examples/index.md @@ -10,7 +10,7 @@ Generate RPG characters following a `pydantic.BaseModel` with `outlines` in `dis This script makes use of [`LlamaCppLLM`][distilabel.llms.llamacpp.LlamaCppLLM] and the structured output capabilities thanks to [`outlines`](https://outlines-dev.github.io/outlines/welcome/) to generate RPG characters that adhere to a JSON schema. - It makes use of a local model which can be downlaoded using curl (explained in the script itself), and can be exchanged with other `LLMs` like [`vLLM`][distilabel.llms.vllm.vLLM]. + It makes use of a local model which can be downloaded using curl (explained in the script itself), and can be exchanged with other `LLMs` like [`vLLM`][distilabel.llms.vllm.vLLM]. ??? Run diff --git a/src/distilabel/llms/anthropic.py b/src/distilabel/llms/anthropic.py index fb4f6dc03c..af0fdbc76e 100644 --- a/src/distilabel/llms/anthropic.py +++ b/src/distilabel/llms/anthropic.py @@ -33,7 +33,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -163,7 +163,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: ChatType, + input: StandardInput, max_tokens: int = 128, stop_sequences: Union[List[str], None] = None, temperature: float = 1.0, @@ -223,7 +223,7 @@ async def agenerate( # type: ignore @override def generate( self, - inputs: List["ChatType"], + inputs: List["StandardInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -232,7 +232,7 @@ def generate( """ async def agenerate( - inputs: List["ChatType"], **kwargs: Any + inputs: List["StandardInput"], **kwargs: Any ) -> "GenerateOutput": """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/base.py b/src/distilabel/llms/base.py index b61c72cf02..a320642615 100644 --- a/src/distilabel/llms/base.py +++ b/src/distilabel/llms/base.py @@ -37,7 +37,7 @@ InstructorStructuredOutputType, ) from distilabel.steps.tasks.structured_outputs.outlines import StructuredOutputType - from distilabel.steps.tasks.typing import ChatType + from distilabel.steps.tasks.typing import FormattedInput, StandardInput from distilabel.utils.docstring import Docstring if in_notebook(): @@ -94,7 +94,7 @@ def model_name(self) -> str: @abstractmethod def generate( self, - inputs: List["ChatType"], + inputs: List["FormattedInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -187,7 +187,9 @@ def generate_parsed_docstring(self) -> "Docstring": """ return parse_google_docstring(self.generate) - def get_last_hidden_states(self, inputs: List["ChatType"]) -> List["HiddenState"]: + def get_last_hidden_states( + self, inputs: List["StandardInput"] + ) -> List["HiddenState"]: """Method to get the last hidden states of the model for a list of inputs. Args: @@ -264,7 +266,7 @@ def event_loop(self) -> "asyncio.AbstractEventLoop": @abstractmethod async def agenerate( - self, input: "ChatType", num_generations: int = 1, **kwargs: Any + self, input: "FormattedInput", num_generations: int = 1, **kwargs: Any ) -> List[Union[str, None]]: """Method to generate a `num_generations` responses for a given input asynchronously, and executed concurrently in `generate` method. @@ -273,7 +275,7 @@ async def agenerate( def generate( self, - inputs: List["ChatType"], + inputs: List["FormattedInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -282,7 +284,7 @@ def generate( """ async def agenerate( - inputs: List["ChatType"], **kwargs: Any + inputs: List["FormattedInput"], **kwargs: Any ) -> List[List[Union[str, None]]]: """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/cohere.py b/src/distilabel/llms/cohere.py index a49b203f3d..c4a9c361c5 100644 --- a/src/distilabel/llms/cohere.py +++ b/src/distilabel/llms/cohere.py @@ -30,7 +30,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -132,7 +132,7 @@ def load(self) -> None: self.structured_output = structured_output def _format_chat_to_cohere( - self, input: "ChatType" + self, input: "StandardInput" ) -> Tuple[Union[str, None], List["ChatMessage"], str]: """Formats the chat input to the Cohere Chat API conversational format. @@ -169,7 +169,7 @@ def _format_chat_to_cohere( @validate_call async def agenerate( # type: ignore self, - input: ChatType, + input: StandardInput, temperature: Optional[float] = None, max_tokens: Optional[int] = None, k: Optional[int] = None, @@ -241,7 +241,7 @@ async def agenerate( # type: ignore @override def generate( self, - inputs: List["ChatType"], + inputs: List["StandardInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -249,7 +249,7 @@ def generate( synchronously awaiting for the response of each input sent to `agenerate`.""" async def agenerate( - inputs: List["ChatType"], **kwargs: Any + inputs: List["StandardInput"], **kwargs: Any ) -> "GenerateOutput": """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/groq.py b/src/distilabel/llms/groq.py index 4905f82839..75fb8d5b32 100644 --- a/src/distilabel/llms/groq.py +++ b/src/distilabel/llms/groq.py @@ -22,7 +22,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.steps.base import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -131,7 +131,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: ChatType, + input: StandardInput, seed: Optional[int] = None, max_new_tokens: int = 128, temperature: float = 1.0, @@ -188,7 +188,7 @@ async def agenerate( # type: ignore @override def generate( self, - inputs: List["ChatType"], + inputs: List["StandardInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -197,7 +197,7 @@ def generate( """ async def agenerate( - inputs: List["ChatType"], **kwargs: Any + inputs: List["StandardInput"], **kwargs: Any ) -> "GenerateOutput": """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/huggingface/inference_endpoints.py b/src/distilabel/llms/huggingface/inference_endpoints.py index 4570b93d1d..201f8237aa 100644 --- a/src/distilabel/llms/huggingface/inference_endpoints.py +++ b/src/distilabel/llms/huggingface/inference_endpoints.py @@ -31,7 +31,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import FormattedInput, Grammar, StandardInput from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -148,6 +148,11 @@ class InferenceEndpointsLLM(AsyncLLM): model_display_name: Optional[str] = None use_openai_client: bool = False + grammar: Optional[RuntimeParameter[Grammar]] = Field( + default=None, + description="The grammar to use across all the generations.", + ) + _model_name: Optional[str] = PrivateAttr(default=None) _tokenizer: Optional["PreTrainedTokenizer"] = PrivateAttr(default=None) _api_key_env_var: str = PrivateAttr(_INFERENCE_ENDPOINTS_API_KEY_ENV_VAR_NAME) @@ -290,7 +295,7 @@ def model_name(self) -> Union[str, None]: # type: ignore async def _openai_agenerate( self, - input: "ChatType", + input: "StandardInput", max_new_tokens: int = 128, frequency_penalty: float = 0.0, presence_penalty: float = 0.0, @@ -322,7 +327,7 @@ async def _openai_agenerate( @validate_call async def agenerate( # type: ignore self, - input: ChatType, + input: "FormattedInput", max_new_tokens: int = 128, frequency_penalty: float = 0.0, presence_penalty: float = 0.0, @@ -379,6 +384,10 @@ async def agenerate( # type: ignore ) stop_sequences = stop_sequences[:4] + grammar = None + if isinstance(input, tuple): + input, grammar = input + if self.use_openai_client: return await self._openai_agenerate( input=input, @@ -413,6 +422,9 @@ async def agenerate( # type: ignore stop_sequences=stop_sequences, return_full_text=return_full_text, watermark=watermark, + # NOTE: `self.grammar` applies to all the generations, while `grammar` is intended + # to be different per each input, and those are not intended to be used together + grammar=grammar or self.grammar, # type: ignore # NOTE: here to ensure that the cache is not used and a different response is # generated every time seed=seed or random.randint(0, 2147483647), @@ -429,7 +441,7 @@ async def agenerate( # type: ignore @override def generate( self, - inputs: List["ChatType"], + inputs: List["FormattedInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -438,7 +450,7 @@ def generate( """ async def agenerate( - inputs: List["ChatType"], **kwargs: Any + inputs: List["FormattedInput"], **kwargs: Any ) -> "GenerateOutput": """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/huggingface/transformers.py b/src/distilabel/llms/huggingface/transformers.py index 19ac43b41b..1f654b7a7a 100644 --- a/src/distilabel/llms/huggingface/transformers.py +++ b/src/distilabel/llms/huggingface/transformers.py @@ -21,7 +21,7 @@ from distilabel.llms.chat_templates import CHATML_TEMPLATE from distilabel.llms.mixins import CudaDevicePlacementMixin from distilabel.llms.typing import GenerateOutput -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput if TYPE_CHECKING: from transformers import Pipeline @@ -130,7 +130,7 @@ def model_name(self) -> str: """Returns the model name used for the LLM.""" return self.model - def prepare_input(self, input: "ChatType") -> str: + def prepare_input(self, input: "StandardInput") -> str: """Prepares the input by applying the chat template to the input, which is formatted as an OpenAI conversation, and adding the generation prompt. """ @@ -143,7 +143,7 @@ def prepare_input(self, input: "ChatType") -> str: @validate_call def generate( # type: ignore self, - inputs: List[ChatType], + inputs: List[StandardInput], num_generations: int = 1, max_new_tokens: int = 128, temperature: float = 0.1, @@ -189,7 +189,9 @@ def generate( # type: ignore for output in outputs ] - def get_last_hidden_states(self, inputs: List["ChatType"]) -> List["HiddenState"]: + def get_last_hidden_states( + self, inputs: List["StandardInput"] + ) -> List["HiddenState"]: """Gets the last `hidden_states` of the model for the given inputs. It doesn't execute the task head. diff --git a/src/distilabel/llms/litellm.py b/src/distilabel/llms/litellm.py index d3660c5ea0..c664133012 100644 --- a/src/distilabel/llms/litellm.py +++ b/src/distilabel/llms/litellm.py @@ -20,7 +20,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput if TYPE_CHECKING: from litellm import Choices @@ -90,7 +90,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: ChatType, + input: StandardInput, num_generations: int = 1, functions: Optional[List] = None, function_call: Optional[str] = None, diff --git a/src/distilabel/llms/llamacpp.py b/src/distilabel/llms/llamacpp.py index f8f50ff154..94548baa69 100644 --- a/src/distilabel/llms/llamacpp.py +++ b/src/distilabel/llms/llamacpp.py @@ -19,7 +19,7 @@ from distilabel.llms.base import LLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput if TYPE_CHECKING: from llama_cpp import CreateChatCompletionResponse, Llama, LogitsProcessorList @@ -128,7 +128,7 @@ def model_name(self) -> str: @validate_call def generate( # type: ignore self, - inputs: List[ChatType], + inputs: List[StandardInput], num_generations: int = 1, max_new_tokens: int = 128, frequency_penalty: float = 0.0, diff --git a/src/distilabel/llms/mistral.py b/src/distilabel/llms/mistral.py index dd96cae91f..8eafae87f7 100644 --- a/src/distilabel/llms/mistral.py +++ b/src/distilabel/llms/mistral.py @@ -22,7 +22,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -129,7 +129,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: ChatType, + input: StandardInput, max_new_tokens: Optional[int] = None, temperature: Optional[float] = None, top_p: Optional[float] = None, @@ -180,7 +180,7 @@ async def agenerate( # type: ignore @override def generate( self, - inputs: List["ChatType"], + inputs: List["StandardInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -189,7 +189,7 @@ def generate( """ async def agenerate( - inputs: List["ChatType"], **kwargs: Any + inputs: List["StandardInput"], **kwargs: Any ) -> "GenerateOutput": """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/ollama.py b/src/distilabel/llms/ollama.py index fb06f1eed3..491e273279 100644 --- a/src/distilabel/llms/ollama.py +++ b/src/distilabel/llms/ollama.py @@ -19,7 +19,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput if TYPE_CHECKING: from ollama import AsyncClient @@ -117,7 +117,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: ChatType, + input: StandardInput, num_generations: int = 1, format: Literal["", "json"] = "", # TODO: include relevant options from `Options` in `agenerate` method. diff --git a/src/distilabel/llms/openai.py b/src/distilabel/llms/openai.py index 6dedc2387c..a659ae5499 100644 --- a/src/distilabel/llms/openai.py +++ b/src/distilabel/llms/openai.py @@ -20,7 +20,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput if TYPE_CHECKING: from openai import AsyncOpenAI @@ -129,7 +129,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: ChatType, + input: StandardInput, num_generations: int = 1, max_new_tokens: int = 128, frequency_penalty: float = 0.0, diff --git a/src/distilabel/llms/vertexai.py b/src/distilabel/llms/vertexai.py index 28ceee3a0c..34cc9484f8 100644 --- a/src/distilabel/llms/vertexai.py +++ b/src/distilabel/llms/vertexai.py @@ -18,7 +18,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput if TYPE_CHECKING: from vertexai.generative_models import Content, GenerativeModel @@ -87,7 +87,7 @@ def model_name(self) -> str: """Returns the model name used for the LLM.""" return self.model - def _chattype_to_content(self, input: "ChatType") -> List["Content"]: + def _chattype_to_content(self, input: "StandardInput") -> List["Content"]: """Converts a chat type to a list of content items expected by the API. Args: @@ -114,7 +114,7 @@ def _chattype_to_content(self, input: "ChatType") -> List["Content"]: @validate_call async def agenerate( # type: ignore self, - input: ChatType, + input: StandardInput, num_generations: int = 1, temperature: Optional[float] = None, top_p: Optional[float] = None, diff --git a/src/distilabel/llms/vllm.py b/src/distilabel/llms/vllm.py index 00b3807465..373fb241df 100644 --- a/src/distilabel/llms/vllm.py +++ b/src/distilabel/llms/vllm.py @@ -21,7 +21,7 @@ from distilabel.llms.mixins import CudaDevicePlacementMixin from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.tasks.typing import StandardInput if TYPE_CHECKING: from transformers import PreTrainedTokenizer @@ -153,7 +153,7 @@ def model_name(self) -> str: """Returns the model name used for the LLM.""" return self.model - def prepare_input(self, input: "ChatType") -> str: + def prepare_input(self, input: "StandardInput") -> str: """Prepares the input by applying the chat template to the input, which is formatted as an OpenAI conversation, and adding the generation prompt. """ @@ -166,7 +166,7 @@ def prepare_input(self, input: "ChatType") -> str: @validate_call def generate( # type: ignore self, - inputs: List[ChatType], + inputs: List[StandardInput], num_generations: int = 1, max_new_tokens: int = 128, frequency_penalty: float = 0.0, diff --git a/src/distilabel/pipeline/base.py b/src/distilabel/pipeline/base.py index 39138918e8..bc77a53536 100644 --- a/src/distilabel/pipeline/base.py +++ b/src/distilabel/pipeline/base.py @@ -46,6 +46,7 @@ ROUTING_BATCH_FUNCTION_ATTR_NAME, STEP_ATTR_NAME, ) +from distilabel.utils.dicts import flatten_dict from distilabel.utils.files import list_files_in_dir from distilabel.utils.logging import setup_logging, stop_logging from distilabel.utils.serialization import ( @@ -1556,7 +1557,7 @@ def cache(self, path: "StrOrPath") -> None: batch_manager_step_dir = path.parent / "batch_manager_steps" / step_name batch_manager_step_dir.mkdir(parents=True, exist_ok=True) - # Store each `_BatchManagerStep` `_Batch`es in a separete file + # Store each `_BatchManagerStep` `_Batch`es in a separate file for buffered_step_name in step_dump["data"]: step_batches_dir = batch_manager_step_dir / buffered_step_name step_batches_dir.mkdir(parents=True, exist_ok=True) @@ -1718,7 +1719,16 @@ def _write(self, step_name: str) -> None: ) step_parquet_dir.mkdir() - table = pa.Table.from_pylist(self._buffers[step_name]) + try: + table = pa.Table.from_pylist(self._buffers[step_name]) + except pa.lib.ArrowInvalid as pae: + if ( + repr(pae) + != "ArrowInvalid('cannot mix struct and non-struct, non-null values')" + ): + raise pae + flattened_buffers = [flatten_dict(buf) for buf in self._buffers[step_name]] + table = pa.Table.from_pylist(flattened_buffers) last_schema = self._buffer_last_schema.get(step_name) if last_schema is None: diff --git a/src/distilabel/steps/tasks/__init__.py b/src/distilabel/steps/tasks/__init__.py index 9fcb882c15..f785f9eb6a 100644 --- a/src/distilabel/steps/tasks/__init__.py +++ b/src/distilabel/steps/tasks/__init__.py @@ -30,6 +30,7 @@ from distilabel.steps.tasks.prometheus_eval import PrometheusEval from distilabel.steps.tasks.quality_scorer import QualityScorer from distilabel.steps.tasks.self_instruct import SelfInstruct +from distilabel.steps.tasks.structured_generation import StructuredGeneration from distilabel.steps.tasks.text_generation import ChatGeneration, TextGeneration from distilabel.steps.tasks.typing import ChatItem, ChatType from distilabel.steps.tasks.ultrafeedback import UltraFeedback @@ -53,6 +54,7 @@ "PrometheusEval", "QualityScorer", "SelfInstruct", + "StructuredGeneration", "TextGeneration", "UltraFeedback", ] diff --git a/src/distilabel/steps/tasks/base.py b/src/distilabel/steps/tasks/base.py index 120f2758fe..7e2cfc2520 100644 --- a/src/distilabel/steps/tasks/base.py +++ b/src/distilabel/steps/tasks/base.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: from distilabel.llms.typing import GenerateOutput - from distilabel.steps.tasks.typing import ChatType + from distilabel.steps.tasks.typing import FormattedInput from distilabel.steps.typing import StepOutput @@ -110,7 +110,7 @@ def _output_on_failure( """ # Create a dictionary with the outputs of the task (every output set to None) outputs = {output: None for output in self.outputs} - outputs["model_name"] = self.llm.model_name + outputs["model_name"] = self.llm.model_name # type: ignore outputs = self._maybe_add_raw_output( outputs, output, add_raw_output=self.add_raw_output ) @@ -142,12 +142,12 @@ class Task(_Task, Step): """ @abstractmethod - def format_input(self, input: Dict[str, Any]) -> "ChatType": + def format_input(self, input: Dict[str, Any]) -> "FormattedInput": """Abstract method to format the inputs of the task. It needs to receive an input as a Python dictionary, and generates an OpenAI chat-like list of dicts.""" pass - def _format_inputs(self, inputs: List[Dict[str, Any]]) -> List["ChatType"]: + def _format_inputs(self, inputs: List[Dict[str, Any]]) -> List["FormattedInput"]: """Formats the inputs of the task using the `format_input` method. Args: diff --git a/src/distilabel/steps/tasks/structured_generation.py b/src/distilabel/steps/tasks/structured_generation.py new file mode 100644 index 0000000000..ca43f9beba --- /dev/null +++ b/src/distilabel/steps/tasks/structured_generation.py @@ -0,0 +1,104 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 warnings +from typing import Any, Dict, List, Union + +from distilabel.steps.tasks.base import Task +from distilabel.steps.tasks.typing import StructuredInput + + +class StructuredGeneration(Task): + """Generate structured content for a given `instruction` using an `LLM`. + + `StructuredGeneration` is a pre-defined task that defines the `instruction` and the `grammar` + as the inputs, and `generation` as the output. This task is used to generate structured content based on + the input instruction and following the schema provided within the `grammar` column per each + `instruction`. The `model_name` also returned as part of the output in order to enhance it. + + Attributes: + use_system_prompt: Whether to use the system prompt in the generation. Defaults to `True`, + which means that if the column `system_prompt` is defined within the input batch, then + the `system_prompt` will be used, otherwise, it will be ignored. + + Input columns: + - instruction (`str`): The instruction to generate structured content from. + - grammar (`Dict[str, Any]`): The grammar to generate structured content from. It should be a + Python dictionary with the keys `type` and `value`, where `type` should be one of `json` or + `regex`, and the `value` should be either the JSON schema or the regex pattern, respectively. + + Output columns: + - generation (`str`): The generated text matching the provided schema, if possible. + - model_name (`str`): The name of the model used to generate the text. + + Categories: + - outlines + - structured-generation + + Examples: + ```python + from distilabel.steps.tasks import StructuredGeneration + + task = StructuredGeneration(llm=LLM(...)) + ``` + """ + + use_system_prompt: bool = False + + @property + def inputs(self) -> List[str]: + """The input for the task are the `instruction` and the `grammar`. + Optionally, if the `use_system_prompt` flag is set to True, then the + `system_prompt` will be used too.""" + columns = ["instruction", "grammar"] + if self.use_system_prompt: + columns = ["system_prompt"] + columns + return columns + + def format_input(self, input: Dict[str, Any]) -> StructuredInput: + """The input is formatted as a `ChatType` assuming that the instruction + is the first interaction from the user within a conversation.""" + if not isinstance(input["instruction"], str): + raise ValueError( + f"Input `instruction` must be a string. Got: {input['instruction']}." + ) + + messages = [{"role": "user", "content": input["instruction"]}] + if self.use_system_prompt: + if "system_prompt" in input: + messages.insert( + 0, {"role": "system", "content": input["system_prompt"]} + ) + else: + warnings.warn( + "`use_system_prompt` is set to `True`, but no `system_prompt` in input batch, so it will be ignored.", + UserWarning, + stacklevel=2, + ) + + return (messages, input.get("grammar", None)) # type: ignore + + @property + def outputs(self) -> List[str]: + """The output for the task is the `generation` and the `model_name`.""" + return ["generation", "model_name"] + + def format_output( + self, output: Union[str, None], input: Dict[str, Any] + ) -> Dict[str, Any]: + """The output is formatted as a dictionary with the `generation`. The `model_name` + will be automatically included within the `process` method of `Task`. Note that even + if the `grammar` is defined to produce a JSON schema, this method will return the raw + output i.e. a string without any parsing.""" + return {"generation": output} diff --git a/src/distilabel/steps/tasks/text_generation.py b/src/distilabel/steps/tasks/text_generation.py index ece5344caf..28c207c287 100644 --- a/src/distilabel/steps/tasks/text_generation.py +++ b/src/distilabel/steps/tasks/text_generation.py @@ -37,7 +37,7 @@ class TextGeneration(Task): Output columns: - generation (`str`): The generated text. - - model_name (`str`): The model name used to generate the text. + - model_name (`str`): The name of the model used to generate the text. Categories: - text-generation diff --git a/src/distilabel/steps/tasks/typing.py b/src/distilabel/steps/tasks/typing.py index cbd6ffc09c..71e068cab1 100644 --- a/src/distilabel/steps/tasks/typing.py +++ b/src/distilabel/steps/tasks/typing.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import List +from typing import Any, Dict, List, Literal, Tuple, Union from typing_extensions import TypedDict @@ -24,3 +24,16 @@ class ChatItem(TypedDict): ChatType = List[ChatItem] """ChatType is a type alias for a `list` of `dict`s following the OpenAI conversational format.""" + + +class Grammar(TypedDict): + type: Literal["json", "regex"] + value: Union[str, Dict[str, Any]] + + +StandardInput = ChatType +"""StandardInput is an alias for ChatType that defines the default / standard input produced by `format_input`.""" +StructuredInput = Tuple[StandardInput, Union[Grammar, None]] +"""StructuredInput defines a type produced by `format_input` when using either `StructuredGeneration` or a subclass of it.""" +FormattedInput = Union[StandardInput, StructuredInput] +"""FormattedInput is an alias for the union of `StandardInput` and `StructuredInput` as generated by `format_input` and expected by the `LLM`s.""" diff --git a/src/distilabel/utils/dicts.py b/src/distilabel/utils/dicts.py index 0ce96334f9..53d33d47f5 100644 --- a/src/distilabel/utils/dicts.py +++ b/src/distilabel/utils/dicts.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json from collections import defaultdict from typing import Any, Dict, List, TypeVar @@ -33,3 +34,7 @@ def combine_dicts(*dicts: Dict[_K, Any]) -> Dict[_K, List[Any]]: for key, value in d.items(): combined_dict[key].append(value) return dict(combined_dict) + + +def flatten_dict(x: Dict[Any, Any]) -> Dict[Any, Any]: + return {k: json.dumps(v) if isinstance(v, dict) else v for k, v in x.items()} diff --git a/tests/unit/llms/huggingface/test_inference_endpoints.py b/tests/unit/llms/huggingface/test_inference_endpoints.py index 9caccf43c4..554cc44fec 100644 --- a/tests/unit/llms/huggingface/test_inference_endpoints.py +++ b/tests/unit/llms/huggingface/test_inference_endpoints.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import random from unittest.mock import AsyncMock, MagicMock, Mock, patch import nest_asyncio @@ -145,6 +146,7 @@ async def test_generate_via_openai_client( ) llm._aclient.chat.completions.create = AsyncMock(return_value=mocked_completion) + ... nest_asyncio.apply() assert llm.generate( @@ -159,6 +161,50 @@ async def test_generate_via_openai_client( ] ) == [(" Aenean hendrerit aliquam velit. ...",)] + @pytest.mark.asyncio + async def test_agenerate_with_grammar( + self, mock_inference_client: MagicMock, _: MagicMock + ) -> None: + llm = InferenceEndpointsLLM( + model_id="distilabel-internal-testing/tiny-random-mistral", + grammar={"type": "regex", "value": r"\b[A-Z][a-z]*\b"}, + ) + llm._aclient = mock_inference_client + + llm._aclient.text_generation = AsyncMock( + return_value=" Aenean hendrerit aliquam velit. ..." + ) + + # Since there's a pseudo-random number within the generation kwargs, we set the seed + # here first to ensure reproducibility within the tests + random.seed(42) + + assert await llm.agenerate( + input=[ + { + "role": "user", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + }, + ] + ) == [" Aenean hendrerit aliquam velit. ..."] + + kwargs = { + "prompt": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "max_new_tokens": 128, + "do_sample": False, + "typical_p": None, + "repetition_penalty": None, + "temperature": 1.0, + "top_p": None, + "top_k": None, + "stop_sequences": None, + "return_full_text": False, + "watermark": False, + "grammar": {"type": "regex", "value": "\\b[A-Z][a-z]*\\b"}, + "seed": 478163327, # pre-computed random value with `random.seed(42)` + } + mock_inference_client.text_generation.assert_called_with(**kwargs) + def test_serialization( self, mock_inference_client: MagicMock, mock_openai_client: MagicMock ) -> None: @@ -173,6 +219,7 @@ def test_serialization( "base_url": None, "tokenizer_id": None, "generation_kwargs": {}, + "grammar": None, "model_display_name": None, "use_openai_client": False, "structured_output": None, diff --git a/tests/unit/steps/tasks/test_structured_generation.py b/tests/unit/steps/tasks/test_structured_generation.py new file mode 100644 index 0000000000..c4766aaa57 --- /dev/null +++ b/tests/unit/steps/tasks/test_structured_generation.py @@ -0,0 +1,124 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 json +from typing import Any, List + +from distilabel.llms.base import LLM +from distilabel.llms.typing import GenerateOutput +from distilabel.pipeline.local import Pipeline +from distilabel.steps.tasks.structured_generation import StructuredGeneration +from distilabel.steps.tasks.typing import StructuredInput +from typing_extensions import override + + +class DummyStructuredLLM(LLM): + def load(self) -> None: + pass + + @property + def model_name(self) -> str: + return "test" + + @override + def generate( # type: ignore + self, inputs: List["StructuredInput"], num_generations: int = 1, **kwargs: Any + ) -> List["GenerateOutput"]: + return [ + [json.dumps({"test": "output"}) for _ in range(num_generations)] + for _ in inputs + ] + + +class TestStructuredGeneration: + def test_format_input(self) -> None: + pipeline = Pipeline(name="unit-test-pipeline") + llm = DummyStructuredLLM() + task = StructuredGeneration(name="task", llm=llm, pipeline=pipeline) + + # 1. Including the `grammar` field within the input + assert task.format_input( + { + "instruction": "test", + "system_prompt": "test", + "grammar": {"type": "regex", "value": r"[a-zA-Z]+"}, + } + ) == ( + [{"role": "user", "content": "test"}], + {"type": "regex", "value": r"[a-zA-Z]+"}, + ) + + # 2. Not including the `grammar` field within the input + assert task.format_input({"instruction": "test", "system_prompt": "test"}) == ( + [{"role": "user", "content": "test"}], + None, + ) + + def test_format_input_with_system_prompt(self) -> None: + pipeline = Pipeline(name="unit-test-pipeline") + llm = DummyStructuredLLM() + task = StructuredGeneration( + name="task", + llm=llm, + pipeline=pipeline, + use_system_prompt=True, + ) + + assert task.format_input({"instruction": "test", "system_prompt": "test"}) == ( + [ + {"role": "system", "content": "test"}, + {"role": "user", "content": "test"}, + ], + None, + ) + + def test_process(self) -> None: + pipeline = Pipeline(name="unit-test-pipeline") + llm = DummyStructuredLLM() + task = StructuredGeneration(name="task", llm=llm, pipeline=pipeline) + assert next( + task.process( + [ + { + "instruction": "test", + "grammar": { + "type": "json", + "value": { + "properties": { + "test": {"title": "Test", "type": "string"} + }, + "required": ["test"], + "title": "Test", + "type": "object", + }, + }, + } + ] + ) + ) == [ + { + "instruction": "test", + "grammar": { + "type": "json", + "value": { + "properties": {"test": {"title": "Test", "type": "string"}}, + "required": ["test"], + "title": "Test", + "type": "object", + }, + }, + "generation": '{"test": "output"}', + "model_name": "test", + } + ] diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py index cfccb1585f..9c3ff44d57 100644 --- a/tests/unit/test_imports.py +++ b/tests/unit/test_imports.py @@ -77,6 +77,7 @@ def test_imports() -> None: PrometheusEval, QualityScorer, SelfInstruct, + StructuredGeneration, TextGeneration, UltraFeedback, ) From e61b5987f0dbf0f85db76b32219b6c1517983ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Mon, 3 Jun 2024 13:17:14 +0200 Subject: [PATCH 15/40] Fix `InferenceEndpointsLLM` not using cached token (#690) * Fix `RuntimeError` closing event loop if not created by `AsyncLLM` * Update `InferenceEndpointsLLM` so it uses cached token * Fix test --- src/distilabel/llms/base.py | 10 ++++-- .../llms/huggingface/inference_endpoints.py | 14 ++++++--- .../huggingface/test_inference_endpoints.py | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/distilabel/llms/base.py b/src/distilabel/llms/base.py index a320642615..e94e06e8ec 100644 --- a/src/distilabel/llms/base.py +++ b/src/distilabel/llms/base.py @@ -233,6 +233,7 @@ class AsyncLLM(LLM): """ _event_loop: "asyncio.AbstractEventLoop" = PrivateAttr(default=None) + _new_event_loop: bool = PrivateAttr(default=False) @property def generate_parameters(self) -> List[inspect.Parameter]: @@ -259,8 +260,10 @@ def event_loop(self) -> "asyncio.AbstractEventLoop": self._event_loop = asyncio.get_running_loop() if self._event_loop.is_closed(): self._event_loop = asyncio.new_event_loop() # type: ignore + self._new_event_loop = True except RuntimeError: self._event_loop = asyncio.new_event_loop() + self._new_event_loop = True asyncio.set_event_loop(self._event_loop) return self._event_loop @@ -303,8 +306,11 @@ def __del__(self) -> None: """Closes the event loop when the object is deleted.""" if sys.meta_path is None: return - if self.event_loop is not None: - self.event_loop.close() + + if self._new_event_loop: + if self._event_loop.is_running(): + self._event_loop.stop() + self._event_loop.close() @staticmethod def _prepare_structured_output( diff --git a/src/distilabel/llms/huggingface/inference_endpoints.py b/src/distilabel/llms/huggingface/inference_endpoints.py index 201f8237aa..015c022b1b 100644 --- a/src/distilabel/llms/huggingface/inference_endpoints.py +++ b/src/distilabel/llms/huggingface/inference_endpoints.py @@ -16,6 +16,7 @@ import os import random import warnings +from pathlib import Path from typing import TYPE_CHECKING, Any, List, Optional, Union from pydantic import ( @@ -206,6 +207,7 @@ def load(self) -> None: # noqa: C901 from huggingface_hub import ( AsyncInferenceClient, InferenceClient, + constants, get_inference_endpoint, ) except ImportError as ie: @@ -215,10 +217,14 @@ def load(self) -> None: # noqa: C901 ) from ie if self.api_key is None: - raise ValueError( - f"To use `{self.__class__.__name__}` an API key must be provided via `api_key`" - f" attribute or runtime parameter, or set the environment variable `{self._api_key_env_var}`." - ) + if not Path(constants.HF_TOKEN_PATH).exists(): + raise ValueError( + f"To use `{self.__class__.__name__}` an API key must be provided via" + " `api_key` attribute or runtime parameter, set the environment variable" + f" `{self._api_key_env_var}` or use the `huggingface-hub` CLI to login" + " with `huggingface-cli login`." + ) + self.api_key = SecretStr(open(constants.HF_TOKEN_PATH).read().strip()) if self.model_id is not None: client = InferenceClient() diff --git a/tests/unit/llms/huggingface/test_inference_endpoints.py b/tests/unit/llms/huggingface/test_inference_endpoints.py index 554cc44fec..c6ce40ff94 100644 --- a/tests/unit/llms/huggingface/test_inference_endpoints.py +++ b/tests/unit/llms/huggingface/test_inference_endpoints.py @@ -13,6 +13,7 @@ # limitations under the License. import random +from unittest import mock from unittest.mock import AsyncMock, MagicMock, Mock, patch import nest_asyncio @@ -23,6 +24,36 @@ @patch("huggingface_hub.AsyncInferenceClient") @patch("openai.AsyncOpenAI") class TestInferenceEndpointsLLM: + def test_load_no_api_key( + self, mock_inference_client: MagicMock, mock_openai_client: MagicMock + ) -> None: + llm = InferenceEndpointsLLM( + model_id="distilabel-internal-testing/tiny-random-mistral" + ) + + # Mock `huggingface_hub.constants.HF_TOKEN_PATH` to not exist + with mock.patch("pathlib.Path.exists") as mock_exists: + mock_exists.return_value = False + with pytest.raises( + ValueError, + match="To use `InferenceEndpointsLLM` an API key must be provided", + ): + llm.load() + + def test_load_with_cached_token( + self, mock_inference_client: MagicMock, mock_openai_client: MagicMock + ) -> None: + llm = InferenceEndpointsLLM( + model_id="distilabel-internal-testing/tiny-random-mistral" + ) + + # Mock `huggingface_hub.constants.HF_TOKEN_PATH` to exist + with mock.patch("pathlib.Path.exists", return_value=True), mock.patch( + "builtins.open", new_callable=mock.mock_open, read_data="hf_token" + ): + # Should not raise any errors + llm.load() + def test_serverless_inference_endpoints_llm( self, mock_inference_client: MagicMock, mock_openai_client: MagicMock ) -> None: From e4a96092dc58a29f83beafb5955dc310f2ce17fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Tue, 4 Jun 2024 15:00:51 +0200 Subject: [PATCH 16/40] Add `GenerateSentencePair` task (#689) * Add `GenerateSentencePair` task * Update task to use system prompt * Fix `setup_logging` file location * Update `add_raw_output` to be `RuntimeParamater` and `True` by default * Fix system prompt for negative sentences * Add `GenerateSentencePair` unit tests * Fix unit tests after updating `add_raw_output` * Update docs to mention `add_raw_output` attribute * Update `add_raw_output` description Co-authored-by: alvarobartt * Fix columns Co-authored-by: alvarobartt * Add missing docstrings * Fix tests * Add `answer` generation action * Fix examples not being correctly rendered * Add examples --------- Co-authored-by: alvarobartt --- docs/sections/learn/tutorial/task/index.md | 14 +- src/distilabel/mixins/runtime_parameters.py | 4 +- src/distilabel/pipeline/base.py | 13 +- src/distilabel/steps/tasks/__init__.py | 10 +- src/distilabel/steps/tasks/base.py | 8 +- .../steps/tasks/sentence_transformers.py | 254 ++++++++++++++++++ .../templates/generate-sentence-pair.jinja2 | 4 + .../components-gallery/step-detail.jinja2 | 2 +- .../steps/tasks/evol_instruct/test_base.py | 7 +- .../tasks/evol_instruct/test_generator.py | 7 +- .../steps/tasks/evol_quality/test_base.py | 9 +- tests/unit/steps/tasks/test_base.py | 15 +- .../tasks/test_instruction_backtranslation.py | 3 + .../steps/tasks/test_sentence_transformers.py | 129 +++++++++ .../steps/tasks/test_structured_generation.py | 1 + .../unit/steps/tasks/test_text_generation.py | 4 + tests/unit/steps/tasks/test_ultrafeedback.py | 6 + 17 files changed, 473 insertions(+), 17 deletions(-) create mode 100644 src/distilabel/steps/tasks/sentence_transformers.py create mode 100644 src/distilabel/steps/tasks/templates/generate-sentence-pair.jinja2 create mode 100644 tests/unit/steps/tasks/test_sentence_transformers.py diff --git a/docs/sections/learn/tutorial/task/index.md b/docs/sections/learn/tutorial/task/index.md index 6f6f1259bd..2e322895e7 100644 --- a/docs/sections/learn/tutorial/task/index.md +++ b/docs/sections/learn/tutorial/task/index.md @@ -8,6 +8,7 @@ The subclasses of [`Task`][distilabel.steps.tasks.Task] are intended to be used For example, the most basic task is the [`TextGeneration`][distilabel.steps.tasks.TextGeneration] task, which generates text based on a given instruction, and it can be used standalone as well as within a [`Pipeline`][distilabel.pipeline.Pipeline]. +```python ```python from distilabel.steps.tasks import TextGeneration @@ -18,12 +19,23 @@ task = TextGeneration( task.load() next(task.process([{"instruction": "What's the capital of Spain?"}])) -# [{'instruction': "What's the capital of Spain?", "generation": "The capital of Spain is Madrid.", "model_name": "gpt-4"}] +# [ +# { +# "instruction": "What's the capital of Spain?", +# "generation": "The capital of Spain is Madrid.", +# "model_name": "gpt-4", +# "distilabel_metadata": { +# "raw_output_text-generation": "The capital of Spain is Madrid" +# } +# } +# ] ``` !!! NOTE The `load` method needs to be called ALWAYS if using the tasks as standalone, otherwise, if the [`Pipeline`][distilabel.pipeline.Pipeline] context manager is used, there's no need to call that method, since it will be automatically called on `Pipeline.run`; but in any other case the method `load` needs to be called from the parent class e.g. a [`Task`][distilabel.steps.tasks.Task] with an [`LLM`][distilabel.llms.LLM] will need to call `Task.load` to load both the task and the LLM. +As we can see in the comment of the code snippet above, the task has enriched the input dictionaries adding the `generation`, the `model_name` that was used to generate, and finally the `distilabel_metadata` dictionary that contains the raw output (without post-processing) from the LLM. In this case, the `TextGeneration` task does no post-processing, so the `generation` and the raw output is the same, but some other tasks do post-processing, which in some situations it can fail. That's why is useful to have the raw output available in the `distilabel_metadata` dictionary. If this default behaviour is not desired, then all the `Task`s has a `add_raw_output` attribute that we can set to `False` when creating the instance of the task or at run time. + ## Defining custom Tasks In order to define custom tasks, we need to inherit from the [`Task`][distilabel.steps.tasks.Task] class and implement the `format_input` and `format_output` methods, as well as setting the properties `inputs` and `outputs`, as for [`Step`][distilabel.steps.Step] subclasses. diff --git a/src/distilabel/mixins/runtime_parameters.py b/src/distilabel/mixins/runtime_parameters.py index 5959244803..9a6fec512b 100644 --- a/src/distilabel/mixins/runtime_parameters.py +++ b/src/distilabel/mixins/runtime_parameters.py @@ -115,13 +115,13 @@ def set_runtime_parameters(self, runtime_parameters: Dict[str, Any]) -> None: name, runtime_parameters_names, cutoff=0.5 ) msg = ( - f"⚠️ Runtime parameter '{name}' unknown in step '{self.name}'." + f"⚠️ Runtime parameter '{name}' unknown in step '{self.name}'." # type: ignore ) if closest: msg += f" Did you mean any of: {closest}" else: msg += f" Available runtime parameters for the step: {runtime_parameters_names}." - self.pipeline._logger.warning(msg) + self.pipeline._logger.warning(msg) # type: ignore continue attr = getattr(self, name) diff --git a/src/distilabel/pipeline/base.py b/src/distilabel/pipeline/base.py index bc77a53536..9150d8c3d0 100644 --- a/src/distilabel/pipeline/base.py +++ b/src/distilabel/pipeline/base.py @@ -298,11 +298,18 @@ def run( The `Distiset` created by the pipeline. """ - setup_logging(**self._logging_parameters) - - # Set the runtime parameters that will be used during the pipeline execution + # Set the runtime parameters that will be used during the pipeline execution. + # They are used to generate the signature of the pipeline that is used to hit the + # cache when the pipeline is run, so it's important to do it first. self._set_runtime_parameters(parameters or {}) + setup_logging( + **{ + **self._logging_parameters, + "filename": str(self._cache_location["log_file"]), + } + ) + # Validate the pipeline DAG to check that all the steps are chainable, there are # no missing runtime parameters, batch sizes are correct, etc. self.dag.validate() diff --git a/src/distilabel/steps/tasks/__init__.py b/src/distilabel/steps/tasks/__init__.py index f785f9eb6a..72e2216e71 100644 --- a/src/distilabel/steps/tasks/__init__.py +++ b/src/distilabel/steps/tasks/__init__.py @@ -30,17 +30,15 @@ from distilabel.steps.tasks.prometheus_eval import PrometheusEval from distilabel.steps.tasks.quality_scorer import QualityScorer from distilabel.steps.tasks.self_instruct import SelfInstruct +from distilabel.steps.tasks.sentence_transformers import GenerateSentencePair from distilabel.steps.tasks.structured_generation import StructuredGeneration from distilabel.steps.tasks.text_generation import ChatGeneration, TextGeneration from distilabel.steps.tasks.typing import ChatItem, ChatType from distilabel.steps.tasks.ultrafeedback import UltraFeedback __all__ = [ - "Task", "GeneratorTask", - "ChatGeneration", - "ChatItem", - "ChatType", + "Task", "ComplexityScorer", "EvolInstruct", "EvolComplexity", @@ -54,7 +52,11 @@ "PrometheusEval", "QualityScorer", "SelfInstruct", + "GenerateSentencePair", "StructuredGeneration", + "ChatGeneration", "TextGeneration", + "ChatItem", + "ChatType", "UltraFeedback", ] diff --git a/src/distilabel/steps/tasks/base.py b/src/distilabel/steps/tasks/base.py index 7e2cfc2520..2c19d8c8a4 100644 --- a/src/distilabel/steps/tasks/base.py +++ b/src/distilabel/steps/tasks/base.py @@ -52,7 +52,13 @@ class _Task(_Step, ABC): llm: LLM group_generations: bool = False - add_raw_output: bool = False + add_raw_output: RuntimeParameter[bool] = Field( + default=True, + description=( + "Whether to include the raw output of the LLM in the key `raw_output_`" + " of the `distilabel_metadata` dictionary output column" + ), + ) num_generations: RuntimeParameter[int] = Field( default=1, description="The number of generations to be produced per input." ) diff --git a/src/distilabel/steps/tasks/sentence_transformers.py b/src/distilabel/steps/tasks/sentence_transformers.py new file mode 100644 index 0000000000..12e39eb08e --- /dev/null +++ b/src/distilabel/steps/tasks/sentence_transformers.py @@ -0,0 +1,254 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 re +import sys +from typing import TYPE_CHECKING, Any, Dict, Final, List, Literal, Optional, Union + +from jinja2 import Template + +from distilabel.steps.tasks.base import Task + +if sys.version_info < (3, 9): + import importlib_resources +else: + import importlib.resources as importlib_resources + +if TYPE_CHECKING: + from distilabel.steps.tasks.typing import ChatType + +GenerationAction = Literal["paraphrase", "semantically-similar", "query", "answer"] + +POSITIVE_NEGATIVE_PAIR_REGEX = re.compile( + r"## Positive\s+(.*?)(?:\s+## Negative\s+(.*?))?\s*$", + re.DOTALL, +) + +GENERATION_ACTION_SENTENCES: Final[Dict[GenerationAction, str]] = { + "paraphrase": "paraphrase", + "semantically-similar": "be semantically similar to", + "query": "be a query for", + "answer": "be an answer for", +} + +POSITIVE_SYSTEM_PROMPT: str = ( + "Your task is to generate a positive sentence given an anchor sentence. The positive" + " sentence has to {action_sentence} the anchor sentence. You must output only one new" + " section: `## Positive`." +) + +POSITIVE_NEGATIVE_SYSTEM_PROMPT: str = ( + "Your task is to generate a positive and a negative sentence given an anchor sentence." + " The positive sentence has to {action_sentence} the anchor sentence, while the negative" + " sentence can use similar words but must not be related to the anchor sentence. You" + " must output only two new sections: `## Positive` and `## Negative`." +) + + +class GenerateSentencePair(Task): + """Generate a positive and negative (optionally) sentences given an anchor sentence. + + `GenerateSentencePair` is a pre-defined task that given an anchor sentence generates + a positive sentence related to the anchor and optionally a negative sentence unrelated + to the anchor. This task is useful to generate training datasets for training embeddings + models. + + Attributes: + triplet: a flag to indicate if the task should generate a triplet of sentences + (anchor, positive, negative). Defaults to `False`. + action: the action to perform to generate the positive sentence. + + Input columns: + - anchor (`str`): The anchor sentence to generate the positive and negative sentences. + + Output columns: + - positive (`str`): The positive sentence related to the `anchor`. + - negative (`str`): The negative sentence unrelated to the `anchor` if `triplet=True`. + - model_name (`str`): The name of the model that was used to generate the sentences. + + Categories: + - embedding + + Examples: + + Paraphrasing: + + ```python + from distilabel.steps.tasks import GenerateSentencePair + from distilabel.llms import InferenceEndpointsLLM + + generate_sentence_pair = GenerateSentencePair( + triplet=True, # `False` to generate only positive + action="paraphrase", + llm=InferenceEndpointsLLM( + model_id="meta-llama/Meta-Llama-3-70B-Instruct", + tokenizer_id="meta-llama/Meta-Llama-3-70B-Instruct", + ), + input_batch_size=10, + ) + + generate_sentence_pair.load() + + result = generate_sentence_pair.process([{"anchor": "What Game of Thrones villain would be the most likely to give you mercy?"}]) + ``` + + Generating semantically similar sentences: + + ```python + from distilabel.llms import InferenceEndpointsLLM + from distilabel.steps.tasks import GenerateSentencePair + + generate_sentence_pair = GenerateSentencePair( + triplet=True, # `False` to generate only positive + action="semantically-similar", + llm=InferenceEndpointsLLM( + model_id="meta-llama/Meta-Llama-3-70B-Instruct", + tokenizer_id="meta-llama/Meta-Llama-3-70B-Instruct", + ), + input_batch_size=10, + ) + + generate_sentence_pair.load() + + result = generate_sentence_pair.process([{"anchor": "How does 3D printing work?"}]) + ``` + + Generating queries: + + ```python + from distilabel.steps.tasks import GenerateSentencePair + from distilabel.llms import InferenceEndpointsLLM + + generate_sentence_pair = GenerateSentencePair( + triplet=True, # `False` to generate only positive + action="query", + llm=InferenceEndpointsLLM( + model_id="meta-llama/Meta-Llama-3-70B-Instruct", + tokenizer_id="meta-llama/Meta-Llama-3-70B-Instruct", + ), + input_batch_size=10, + ) + + generate_sentence_pair.load() + + result = generate_sentence_pair.process([{"anchor": "Argilla is an open-source data curation platform for LLMs. Using Argilla, ..."}]) + ``` + + Generating answers: + + ```python + from distilabel.steps.tasks import GenerateSentencePair + from distilabel.llms import InferenceEndpointsLLM + + generate_sentence_pair = GenerateSentencePair( + triplet=True, # `False` to generate only positive + action="answer", + llm=InferenceEndpointsLLM( + model_id="meta-llama/Meta-Llama-3-70B-Instruct", + tokenizer_id="meta-llama/Meta-Llama-3-70B-Instruct", + ), + input_batch_size=10, + ) + + generate_sentence_pair.load() + + result = generate_sentence_pair.process([{"anchor": "What Game of Thrones villain would be the most likely to give you mercy?"}]) + ``` + """ + + triplet: bool = False + action: GenerationAction + + def load(self) -> None: + """Loads the Jinja2 template.""" + super().load() + + _path = str( + importlib_resources.files("distilabel") + / "steps" + / "tasks" + / "templates" + / "generate-sentence-pair.jinja2" + ) + + self._template = Template(open(_path).read()) + + @property + def inputs(self) -> List[str]: + """The inputs for the task is the `anchor` sentence.""" + return ["anchor"] + + def format_input(self, input: Dict[str, Any]) -> "ChatType": + """The inputs are formatted as a `ChatType`, with a system prompt describing the + task of generating a positive and negative sentences for the anchor sentence. The + anchor is provided as the first user interaction in the conversation. + + Args: + input: The input containing the `anchor` sentence. + + Returns: + A list of dictionaries containing the system and user interactions. + """ + action_sentence = GENERATION_ACTION_SENTENCES[self.action] + system_prompt = ( + POSITIVE_NEGATIVE_SYSTEM_PROMPT if self.triplet else POSITIVE_SYSTEM_PROMPT + ).format(action_sentence=action_sentence) + + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": self._template.render(anchor=input["anchor"])}, + ] + + @property + def outputs(self) -> List[str]: + """The outputs for the task are the `positive` and `negative` sentences, as well + as the `model_name` used to generate the sentences.""" + columns = ["positive", "negative"] if self.triplet else ["positive"] + columns += ["model_name"] + return columns + + def format_output( + self, output: Union[str, None], input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Formats the output of the LLM, to extract the `positive` and `negative` sentences + generated. If the output is `None` or the regex doesn't match, then the outputs + will be set to `None` as well. + + Args: + output: The output of the LLM. + input: The input used to generate the output. + + Returns: + The formatted output containing the `positive` and `negative` sentences. + """ + if output is None: + return {"positive": None, "negative": None} + + match = POSITIVE_NEGATIVE_PAIR_REGEX.match(output) + if match is None: + formatted_output = {"positive": None} + if self.triplet: + formatted_output["negative"] = None + return formatted_output + + groups = match.groups() + if self.triplet: + return { + "positive": groups[0].strip(), + "negative": groups[1].strip() + if len(groups) > 1 and groups[1] is not None + else None, + } + + return {"positive": groups[0].strip()} diff --git a/src/distilabel/steps/tasks/templates/generate-sentence-pair.jinja2 b/src/distilabel/steps/tasks/templates/generate-sentence-pair.jinja2 new file mode 100644 index 0000000000..82594f18a8 --- /dev/null +++ b/src/distilabel/steps/tasks/templates/generate-sentence-pair.jinja2 @@ -0,0 +1,4 @@ +## Anchor + +{{ anchor }} + diff --git a/src/distilabel/utils/mkdocs/templates/components-gallery/step-detail.jinja2 b/src/distilabel/utils/mkdocs/templates/components-gallery/step-detail.jinja2 index 486fc4b299..4be5fdc1ab 100644 --- a/src/distilabel/utils/mkdocs/templates/components-gallery/step-detail.jinja2 +++ b/src/distilabel/utils/mkdocs/templates/components-gallery/step-detail.jinja2 @@ -56,7 +56,7 @@ {% for example_title, code in step.docstring.examples.items() %} #### {{ example_title }} ```python -{{ code | e }} +{{ code | replace("\n", "\n") }} ``` {% endfor %} {% endif %} diff --git a/tests/unit/steps/tasks/evol_instruct/test_base.py b/tests/unit/steps/tasks/evol_instruct/test_base.py index d3999684c1..cea6fd75ca 100644 --- a/tests/unit/steps/tasks/evol_instruct/test_base.py +++ b/tests/unit/steps/tasks/evol_instruct/test_base.py @@ -121,7 +121,7 @@ def test_serialization(self, dummy_llm: LLM) -> None: task.load() assert task.dump() == { "name": "task", - "add_raw_output": False, + "add_raw_output": True, "input_mappings": task.input_mappings, "output_mappings": task.output_mappings, "input_batch_size": task.input_batch_size, @@ -163,6 +163,11 @@ def test_serialization(self, dummy_llm: LLM) -> None: } ], }, + { + "description": "Whether to include the raw output of the LLM in the key `raw_output_` of the `distilabel_metadata` dictionary output column", + "name": "add_raw_output", + "optional": True, + }, { "name": "num_generations", "optional": True, diff --git a/tests/unit/steps/tasks/evol_instruct/test_generator.py b/tests/unit/steps/tasks/evol_instruct/test_generator.py index fee2234083..bdc09c9162 100644 --- a/tests/unit/steps/tasks/evol_instruct/test_generator.py +++ b/tests/unit/steps/tasks/evol_instruct/test_generator.py @@ -123,7 +123,7 @@ def test_serialization(self, dummy_llm: LLM) -> None: "name": task.llm.__class__.__name__, }, }, - "add_raw_output": False, + "add_raw_output": True, "input_mappings": task.input_mappings, "output_mappings": task.output_mappings, "batch_size": task.batch_size, @@ -158,6 +158,11 @@ def test_serialization(self, dummy_llm: LLM) -> None: }, ], }, + { + "description": "Whether to include the raw output of the LLM in the key `raw_output_` of the `distilabel_metadata` dictionary output column", + "name": "add_raw_output", + "optional": True, + }, { "name": "num_generations", "optional": True, diff --git a/tests/unit/steps/tasks/evol_quality/test_base.py b/tests/unit/steps/tasks/evol_quality/test_base.py index 251e377d9e..d4445a4613 100644 --- a/tests/unit/steps/tasks/evol_quality/test_base.py +++ b/tests/unit/steps/tasks/evol_quality/test_base.py @@ -80,7 +80,7 @@ def test_serialization(self, dummy_llm: LLM) -> None: task.load() assert task.dump() == { "name": "task", - "add_raw_output": False, + "add_raw_output": True, "input_mappings": task.input_mappings, "output_mappings": task.output_mappings, "input_batch_size": task.input_batch_size, @@ -112,9 +112,14 @@ def test_serialization(self, dummy_llm: LLM) -> None: "name": "generation_kwargs", "description": "The kwargs to be propagated to either `generate` or `agenerate` methods within each `LLM`.", "keys": [], - } + }, ], }, + { + "description": "Whether to include the raw output of the LLM in the key `raw_output_` of the `distilabel_metadata` dictionary output column", + "name": "add_raw_output", + "optional": True, + }, { "name": "num_generations", "optional": True, diff --git a/tests/unit/steps/tasks/test_base.py b/tests/unit/steps/tasks/test_base.py index 0cccbd5c9d..4a9f566c6a 100644 --- a/tests/unit/steps/tasks/test_base.py +++ b/tests/unit/steps/tasks/test_base.py @@ -94,16 +94,19 @@ def test_with_errors(self, caplog: pytest.LogCaptureFixture) -> None: "instruction": "test", "output": "output", "model_name": "test", + "distilabel_metadata": {"raw_output_task": "output"}, }, { "instruction": "test", "output": "output", "model_name": "test", + "distilabel_metadata": {"raw_output_task": "output"}, }, { "instruction": "test", "output": "output", "model_name": "test", + "distilabel_metadata": {"raw_output_task": "output"}, }, ], ), @@ -114,6 +117,11 @@ def test_with_errors(self, caplog: pytest.LogCaptureFixture) -> None: "instruction": "test", "output": ["output", "output", "output"], "model_name": "test", + "distilabel_metadata": [ + {"raw_output_task": "output"}, + {"raw_output_task": "output"}, + {"raw_output_task": "output"}, + ], }, ], ), @@ -188,7 +196,7 @@ def test_serialization(self) -> None: task = DummyTask(name="task", llm=llm, pipeline=pipeline) assert task.dump() == { "name": "task", - "add_raw_output": False, + "add_raw_output": True, "input_mappings": {}, "output_mappings": {}, "input_batch_size": 50, @@ -224,6 +232,11 @@ def test_serialization(self) -> None: }, ], }, + { + "description": "Whether to include the raw output of the LLM in the key `raw_output_` of the `distilabel_metadata` dictionary output column", + "name": "add_raw_output", + "optional": True, + }, { "name": "num_generations", "description": "The number of generations to be produced per input.", diff --git a/tests/unit/steps/tasks/test_instruction_backtranslation.py b/tests/unit/steps/tasks/test_instruction_backtranslation.py index 4c8a8df7fe..a6f2793285 100644 --- a/tests/unit/steps/tasks/test_instruction_backtranslation.py +++ b/tests/unit/steps/tasks/test_instruction_backtranslation.py @@ -86,5 +86,8 @@ def test_process(self) -> None: "score": 1, "reason": "This is the reason.", "model_name": "instruction-backtranslation-model", + "distilabel_metadata": { + "raw_output_instruction-backtranslation": "This is the reason. Score: 1" + }, } ] diff --git a/tests/unit/steps/tasks/test_sentence_transformers.py b/tests/unit/steps/tasks/test_sentence_transformers.py new file mode 100644 index 0000000000..3e50e7e3f1 --- /dev/null +++ b/tests/unit/steps/tasks/test_sentence_transformers.py @@ -0,0 +1,129 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from typing import Any, Dict + +import pytest +from distilabel.steps.tasks.sentence_transformers import ( + POSITIVE_NEGATIVE_SYSTEM_PROMPT, + POSITIVE_SYSTEM_PROMPT, + GenerateSentencePair, + GenerationAction, +) + +from tests.unit.steps.tasks.utils import DummyLLM + + +class TestGenerateSentencePair: + @pytest.mark.parametrize( + "action,triplet,system_prompt", + [ + ( + "paraphrase", + True, + POSITIVE_NEGATIVE_SYSTEM_PROMPT.format(action_sentence="paraphrase"), + ), + ( + "paraphrase", + False, + POSITIVE_SYSTEM_PROMPT.format(action_sentence="paraphrase"), + ), + ( + "semantically-similar", + True, + POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( + action_sentence="be semantically similar to" + ), + ), + ( + "semantically-similar", + False, + POSITIVE_SYSTEM_PROMPT.format( + action_sentence="be semantically similar to" + ), + ), + ( + "query", + True, + POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( + action_sentence="be a query for" + ), + ), + ( + "query", + False, + POSITIVE_SYSTEM_PROMPT.format(action_sentence="be a query for"), + ), + ( + "answer", + True, + POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( + action_sentence="be an answer for" + ), + ), + ( + "answer", + False, + POSITIVE_SYSTEM_PROMPT.format(action_sentence="be an answer for"), + ), + ], + ) + def test_format_input( + self, action: GenerationAction, triplet: bool, system_prompt: str + ) -> None: + task = GenerateSentencePair(llm=DummyLLM(), action=action, triplet=triplet) + task.load() + + assert task.format_input({"anchor": "This is a unit test"}) == [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "## Anchor\n\nThis is a unit test\n"}, + ] + + @pytest.mark.parametrize( + "output,triplet,expected", + [ + ( + "## Positive\n\nThis is a paraphrase\n## Negative\n\nThis is not a paraphrase", + True, + { + "positive": "This is a paraphrase", + "negative": "This is not a paraphrase", + }, + ), + ( + "## Positive\n\nThis is a paraphrase", + True, + {"positive": "This is a paraphrase", "negative": None}, + ), + ( + "## Positive\n\nThis is a paraphrase", + False, + {"positive": "This is a paraphrase"}, + ), + ( + "random", + False, + {"positive": None}, + ), + ], + ) + def test_format_output( + self, output: str, triplet: bool, expected: Dict[str, Any] + ) -> None: + task = GenerateSentencePair( + llm=DummyLLM(), action="paraphrase", triplet=triplet + ) + task.load() + + assert task.format_output(output) == expected diff --git a/tests/unit/steps/tasks/test_structured_generation.py b/tests/unit/steps/tasks/test_structured_generation.py index c4766aaa57..febc7f698f 100644 --- a/tests/unit/steps/tasks/test_structured_generation.py +++ b/tests/unit/steps/tasks/test_structured_generation.py @@ -120,5 +120,6 @@ def test_process(self) -> None: }, "generation": '{"test": "output"}', "model_name": "test", + "distilabel_metadata": {"raw_output_task": '{"test": "output"}'}, } ] diff --git a/tests/unit/steps/tasks/test_text_generation.py b/tests/unit/steps/tasks/test_text_generation.py index d07ba464a3..545cf6a7b8 100644 --- a/tests/unit/steps/tasks/test_text_generation.py +++ b/tests/unit/steps/tasks/test_text_generation.py @@ -82,6 +82,9 @@ def test_process(self) -> None: "instruction": "test", "generation": "output", "model_name": "test", + "distilabel_metadata": { + "raw_output_task": "output", + }, } ] @@ -139,5 +142,6 @@ def test_process(self) -> None: "messages": [{"role": "user", "content": "Tell me a joke."}], "generation": "output", "model_name": "test", + "distilabel_metadata": {"raw_output_task": "output"}, } ] diff --git a/tests/unit/steps/tasks/test_ultrafeedback.py b/tests/unit/steps/tasks/test_ultrafeedback.py index 69e9326570..fa72ff9442 100644 --- a/tests/unit/steps/tasks/test_ultrafeedback.py +++ b/tests/unit/steps/tasks/test_ultrafeedback.py @@ -63,6 +63,9 @@ def test_process_with_simple_aspect(self) -> None: "ratings": [1, 2], "rationales": ["text", "text"], "model_name": "ultrafeedback-model", + "distilabel_metadata": { + "raw_output_ultrafeedback": "Type: 1\nRationale: text\nRating: 1\nRationale: text\n\nType: 2\nRationale: text\nRating: 2\nRationale: text" + }, } ] @@ -89,5 +92,8 @@ def test_process_with_complex_aspect(self) -> None: "ratings": [1, 2], "rationales-for-ratings": ["text", "text"], "model_name": "ultrafeedback-model", + "distilabel_metadata": { + "raw_output_ultrafeedback": "Type: 1\nRationale: text\nRating: 1\nRationale: text\n\nType: 2\nRationale: text\nRating: 2\nRationale: text" + }, } ] From 062f4fb26ee730cbd42c77c520b25dea7035d447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Wed, 5 Jun 2024 17:38:15 +0200 Subject: [PATCH 17/40] Fix prepend batches (#696) * Add `built_batches` attribute * Fix saving `built_batches` and tests --- src/distilabel/pipeline/base.py | 95 +++++++++----- tests/unit/pipeline/test_base.py | 206 +++++++++++++++++++++++++++++-- 2 files changed, 263 insertions(+), 38 deletions(-) diff --git a/src/distilabel/pipeline/base.py b/src/distilabel/pipeline/base.py index 9150d8c3d0..88eb127ebb 100644 --- a/src/distilabel/pipeline/base.py +++ b/src/distilabel/pipeline/base.py @@ -827,6 +827,9 @@ class _BatchManagerStep(_Serializable): If `None`, then `accumulate` must be `True`. Defaults to `None`. data: A dictionary with the predecessor step name as the key and a list of dictionaries (rows) as the value. + built_batches: A list with the batches that were built and sent to the step queue, + but the step was stopped before processing the batch, so the batch doesn't get + lost. Defaults to an empty list. seq_no: The sequence number of the next batch to be created. It will be incremented for each batch created. last_batch_received: A list with the names of the steps that sent the last @@ -850,6 +853,7 @@ class _BatchManagerStep(_Serializable): accumulate: bool input_batch_size: Union[int, None] = None data: Dict[str, List[_Batch]] = field(default_factory=dict) + built_batches: List[_Batch] = field(default_factory=list) seq_no: int = 0 last_batch_received: List[str] = field(default_factory=list) convergence_step: bool = False @@ -864,13 +868,15 @@ def add_batch(self, batch: _Batch, prepend: bool = False) -> None: Args: batch: The output batch of an step to be processed by the step. - prepend: If `True`, the content of the batch will be added at the start of - the buffer. + prepend: If `True`, the content of the batch will be added to the `built_batches` + list. This is done so if a `_Batch` was already built and send to the step + queue, and the step is stopped before processing the batch, the batch doesn't + get lost. Defaults to `False`. """ from_step = batch.step_name if prepend: - self.data[from_step].insert(0, batch) + self.built_batches.append(batch) else: self.data[from_step].append(batch) @@ -888,6 +894,11 @@ def get_batch(self) -> Union[_Batch, None]: if not self._ready_to_create_batch(): return None + # If there are batches in the `built_batches` list, then return the first one + # and remove it from the list. + if self.built_batches: + return self.built_batches.pop(0) + # `_last_batch` must be called before `_get_data`, as `_get_data` will update the # list of data which is used to determine if the batch to be created is the last one. # TODO: remove `_last_batch` method and integrate logic in `_get_data` @@ -1326,6 +1337,7 @@ def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: step_name: [batch.dump(**kwargs) for batch in batches] for step_name, batches in self.data.items() }, + "built_batches": [batch.dump(**kwargs) for batch in self.built_batches], "seq_no": self.seq_no, "last_batch_received": self.last_batch_received, "convergence_step": self.convergence_step, @@ -1549,6 +1561,29 @@ def cache(self, path: "StrOrPath") -> None: path: The path to the file where the `_BatchManager` will be cached. If `None`, then the `_BatchManager` will be cached in the default cache folder. """ + + def save_batch( + batches_dir: Path, batch_dump: Dict[str, Any], batch_list: List[_Batch] + ) -> Path: + seq_no = batch_dump["seq_no"] + data_hash = batch_dump["data_hash"] + batch_file = batches_dir / f"batch_{seq_no}_{data_hash}.json" + + # Save the batch if it doesn't exist + if not batch_file.exists(): + # Get the data of the batch before saving it + batch = next(batch for batch in batch_list if batch.seq_no == seq_no) + batch_dump["data"] = batch.data + self.save(path=batch_file, format="json", dump=batch_dump) + + return batch_file + + def remove_files(keep_files: List[str], dir: Path) -> None: + files = list_files_in_dir(dir, key=None) + remove = set(files) - {Path(file) for file in keep_files} + for file in remove: + file.unlink() + path = Path(path) # Do not include `_Batch` data so `dump` is fast @@ -1564,41 +1599,41 @@ def cache(self, path: "StrOrPath") -> None: batch_manager_step_dir = path.parent / "batch_manager_steps" / step_name batch_manager_step_dir.mkdir(parents=True, exist_ok=True) + # Store each built `_Batch` in a separate file + built_batches_dir = batch_manager_step_dir / "built_batches" + built_batches_dir.mkdir(parents=True, exist_ok=True) + step_dump["built_batches"] = [ + str( + save_batch( + batches_dir=built_batches_dir, + batch_dump=batch_dump, + batch_list=self._steps[step_name].built_batches, + ) + ) + for batch_dump in step_dump["built_batches"] + ] + # Remove built `_Batch`es that were consumed from cache + remove_files(step_dump["built_batches"], built_batches_dir) + # Store each `_BatchManagerStep` `_Batch`es in a separate file for buffered_step_name in step_dump["data"]: step_batches_dir = batch_manager_step_dir / buffered_step_name step_batches_dir.mkdir(parents=True, exist_ok=True) # Store each `_Batch` in a separate file - keep_batches = [] - for batch_dump in step_dump["data"][buffered_step_name]: - # Generate a hash for the data of the batch - seq_no = batch_dump["seq_no"] - data_hash = batch_dump["data_hash"] - batch_file = step_batches_dir / f"batch_{seq_no}_{data_hash}.json" - - # Save the batch if it doesn't exist - if not batch_file.exists(): - # Get the data of the batch before saving it - batch = next( - batch - for batch in self._steps[step_name].data[buffered_step_name] - if batch.seq_no == seq_no - ) - batch_dump["data"] = batch.data - self.save(path=batch_file, format="json", dump=batch_dump) - - keep_batches.append(batch_file) - step_dump["data"][buffered_step_name] = [ - str(file_batch) for file_batch in keep_batches + str( + save_batch( + batches_dir=step_batches_dir, + batch_dump=batch_dump, + batch_list=self._steps[step_name].data[buffered_step_name], + ) + ) + for batch_dump in step_dump["data"][buffered_step_name] ] # Remove `_Batch`es that were consumed from cache - files = list_files_in_dir(step_batches_dir, key=None) - remove = set(files) - set(keep_batches) - for file in remove: - file.unlink() + remove_files(step_dump["data"][buffered_step_name], step_batches_dir) # Store the `_BatchManagerStep` info batch_manager_step_file = str( @@ -1628,6 +1663,10 @@ def load_from_cache(cls, path: "StrOrPath") -> "_BatchManager": steps[step_name] = read_json(step_file) # Read each `_Batch` from file + steps[step_name]["built_batches"] = [ + read_json(batch) for batch in steps[step_name]["built_batches"] + ] + for buffered_step_name, batch_files in steps[step_name]["data"].items(): steps[step_name]["data"][buffered_step_name] = [ read_json(batch_file) for batch_file in batch_files diff --git a/tests/unit/pipeline/test_base.py b/tests/unit/pipeline/test_base.py index 8ae456a319..4c26db132d 100644 --- a/tests/unit/pipeline/test_base.py +++ b/tests/unit/pipeline/test_base.py @@ -589,13 +589,14 @@ def test_add_batch_with_prepend(self) -> None: batch_0 = _Batch( seq_no=0, - step_name="step1", + step_name="step2", last_batch=False, data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], ) batch_manager_step.add_batch(batch_0, prepend=True) - assert batch_manager_step.data["step1"] == [batch_0, batch_1] + assert batch_manager_step.built_batches == [batch_0] + assert batch_manager_step.data["step1"] == [batch_1] assert batch_manager_step.last_batch_received == [] def test_add_batch_last_batch(self) -> None: @@ -616,14 +617,31 @@ def test_add_batch_last_batch(self) -> None: assert batch_manager_step.last_batch_received == ["step1"] def test_get_batch(self) -> None: + previously_built_batch = _Batch( + seq_no=0, + step_name="step3", + last_batch=False, + data=[ + [ + {"a": -1}, + {"a": 0}, + ], + [ + {"b": -1}, + {"b": 0}, + ], + ], + ) + batch_manager_step = _BatchManagerStep( step_name="step3", accumulate=False, input_batch_size=2, + seq_no=1, data={ "step1": [ _Batch( - seq_no=0, + seq_no=1, step_name="step1", last_batch=False, data=[ @@ -640,7 +658,7 @@ def test_get_batch(self) -> None: ], "step2": [ _Batch( - seq_no=0, + seq_no=1, step_name="step2", last_batch=False, data=[ @@ -657,13 +675,18 @@ def test_get_batch(self) -> None: ) ], }, + built_batches=[previously_built_batch], ) batch = batch_manager_step.get_batch() + assert batch == previously_built_batch + + batch = batch_manager_step.get_batch() + assert batch == _Batch( step_name="step3", - seq_no=0, + seq_no=1, last_batch=False, data=[ [ @@ -675,14 +698,14 @@ def test_get_batch(self) -> None: {"b": 2}, ], ], - created_from={"step1": [(0, 5)], "step2": [(0, 5)]}, + created_from={"step1": [(1, 5)], "step2": [(1, 5)]}, ) batch = batch_manager_step.get_batch() assert batch == _Batch( step_name="step3", - seq_no=1, + seq_no=2, last_batch=False, data=[ [ @@ -694,7 +717,7 @@ def test_get_batch(self) -> None: {"b": 4}, ], ], - created_from={"step1": [(0, 5)], "step2": [(0, 5)]}, + created_from={"step1": [(1, 5)], "step2": [(1, 5)]}, ) def test_get_batches_accumulate(self) -> None: @@ -1424,7 +1447,7 @@ def test_last_batch_convergence_step( ) def test_ready_to_create_batch( self, - data: Dict[str, List[Dict[str, Any]]], + data: Dict[str, List[_Batch]], last_batch_received: List[str], expected: bool, ) -> None: @@ -1521,6 +1544,14 @@ def test_dump(self) -> None: data_hash="hash1", size=7, ) + batch_step_3 = _Batch( + seq_no=0, + step_name="step3", + last_batch=True, + data=[[{"c": 1}, {"c": 2}, {"c": 3}, {"c": 4}, {"c": 5}]], + data_hash="hash2", + size=5, + ) batch_manager_step = _BatchManagerStep( step_name="step3", accumulate=True, @@ -1528,6 +1559,7 @@ def test_dump(self) -> None: "step1": [batch_step_1], "step2": [batch_step_2], }, + built_batches=[batch_step_3], ) assert batch_manager_step.dump() == { "step_name": "step3", @@ -1590,6 +1622,23 @@ def test_dump(self) -> None: } ], }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step3", + "last_batch": True, + "data": [[{"c": 1}, {"c": 2}, {"c": 3}, {"c": 4}, {"c": 5}]], + "data_hash": "hash2", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], "seq_no": 0, "last_batch_received": [], "next_expected_created_from_batch_seq_no": 0, @@ -1873,8 +1922,9 @@ def test_add_batch_with_prepend(self) -> None: data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], ) batch_manager.add_batch(to_step="step3", batch=batch_0, prepend=True) + assert batch_manager._steps["step3"].built_batches == [batch_0] assert batch_manager._steps["step3"].data == { - "step1": [batch_0, batch_1], + "step1": [batch_1], "step2": [], } @@ -1949,6 +1999,14 @@ def test_can_generate(self) -> None: assert not batch_manager.can_generate() def test_dump(self) -> None: + built_batch = _Batch( + seq_no=0, + last_batch=False, + step_name="step3", + data=[[]], + data_hash="hash", + ) + batch_manager = _BatchManager( steps={ "step3": _BatchManagerStep( @@ -1956,6 +2014,7 @@ def test_dump(self) -> None: accumulate=False, input_batch_size=5, data={"step1": [], "step2": []}, + built_batches=[built_batch], seq_no=1, ) }, @@ -1984,6 +2043,23 @@ def test_dump(self) -> None: "convergence_step_batches_consumed": {}, "input_batch_size": 5, "data": {"step1": [], "step2": []}, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step3", + "last_batch": False, + "data": [[]], + "data_hash": "hash", + "size": 0, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], "seq_no": 1, "last_batch_received": [], "next_expected_created_from_batch_seq_no": 0, @@ -2244,6 +2320,31 @@ def test_cache(self) -> None: } ], }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + "data_hash": "1234", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], "seq_no": 0, "last_batch_received": [], "type_info": { @@ -2286,6 +2387,31 @@ def test_cache(self) -> None: } ], }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + "data_hash": "1234", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], "seq_no": 0, "last_batch_received": [], "type_info": { @@ -2384,6 +2510,16 @@ def test_cache(self) -> None: and batch_manager_step_path.is_file() ) + built_batches_dir = batch_manager_step_dir / "built_batches" + assert built_batches_dir.exists() + + for batch in step.built_batches: + batch_path = ( + built_batches_dir + / f"batch_{batch.seq_no}_{batch.data_hash}.json" + ) + assert batch_path.exists() and batch_path.is_file() + for buffered_step_name in step.data: buffered_step_dir = batch_manager_step_dir / buffered_step_name assert buffered_step_dir.exists() and buffered_step_dir.is_dir() @@ -2434,6 +2570,31 @@ def test_load_from_cache(self) -> None: } ], }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + "data_hash": "1234", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], "seq_no": 0, "last_batch_received": [], "type_info": { @@ -2476,6 +2637,31 @@ def test_load_from_cache(self) -> None: } ], }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + "data_hash": "1234", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.base", + "name": "_Batch", + }, + } + ], "seq_no": 0, "last_batch_received": [], "type_info": { From 20aa24ee04428fe8aacb5702398389a5b6842c7e Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Thu, 6 Jun 2024 10:53:44 +0200 Subject: [PATCH 18/40] Fix `EvolQuality._apply_random_mutation` not properly injecting `response` in template (#703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove selecting final index from `response` for `EvolQuality.apply_mutation_template` * Add `_apply_random_mutation` unit test --------- Co-authored-by: Gabriel Martín Blázquez --- src/distilabel/steps/tasks/evol_quality/base.py | 2 +- tests/unit/steps/tasks/evol_quality/test_base.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/distilabel/steps/tasks/evol_quality/base.py b/src/distilabel/steps/tasks/evol_quality/base.py index 931d994912..0b50e568cc 100644 --- a/src/distilabel/steps/tasks/evol_quality/base.py +++ b/src/distilabel/steps/tasks/evol_quality/base.py @@ -149,7 +149,7 @@ def _apply_random_mutation(self, instruction: str, response: str) -> str: return ( self.mutation_templates[mutation] .replace("", instruction) - .replace("", response[-1]) + .replace("", response) ) def _evolve_reponses(self, inputs: "StepInput") -> List[List[str]]: diff --git a/tests/unit/steps/tasks/evol_quality/test_base.py b/tests/unit/steps/tasks/evol_quality/test_base.py index d4445a4613..c5c7ecbce4 100644 --- a/tests/unit/steps/tasks/evol_quality/test_base.py +++ b/tests/unit/steps/tasks/evol_quality/test_base.py @@ -34,6 +34,18 @@ def test_with_errors( EvolQuality(name="task", llm=dummy_llm, num_evolutions=2) assert "Step 'task' hasn't received a pipeline" in caplog.text + def test_apply_random_mutation(self, dummy_llm: LLM) -> None: + pipeline = Pipeline(name="unit-test-pipeline") + task = EvolQuality( + name="task", llm=dummy_llm, num_evolutions=2, pipeline=pipeline + ) + task.load() + + mutated = task._apply_random_mutation("I'm an instruction", "I'm a response") + + assert "I'm an instruction" in mutated + assert "I'm a response" in mutated + def test_process(self, dummy_llm: LLM) -> None: pipeline = Pipeline(name="unit-test-pipeline") task = EvolQuality( From 34ac77207bdfe3daaa65b876362b6ffbd3641a14 Mon Sep 17 00:00:00 2001 From: Agus Date: Fri, 7 Jun 2024 10:13:12 +0200 Subject: [PATCH 19/40] [FEATURE] Include new `GeneratorStep` classes to load datasets from different formats (#691) * Add a GeneratorStep to read files from disk as datasets * Add tests for the new LoadFromDisk loader * Refactor generator step classes to new naming * Add deprecation warnings for previous loaders * Add assertion to remind removing the deprecated classes * Add docstrings for the new steps * Apply comments from code review and update dataset info read using exposed function from datasets * Fix dataloader tests with new class names * Fix import tests --- src/distilabel/steps/__init__.py | 10 +- .../steps/generators/huggingface.py | 340 +++++++++++++++--- .../steps/generators/sample_functions.jsonl | 11 + tests/unit/steps/generators/test_data.py | 2 +- .../unit/steps/generators/test_huggingface.py | 143 +++++++- tests/unit/test_imports.py | 2 + 6 files changed, 446 insertions(+), 62 deletions(-) create mode 100644 tests/unit/steps/generators/sample_functions.jsonl diff --git a/src/distilabel/steps/__init__.py b/src/distilabel/steps/__init__.py index efba7b3938..77c8818442 100644 --- a/src/distilabel/steps/__init__.py +++ b/src/distilabel/steps/__init__.py @@ -29,7 +29,12 @@ FormatTextGenerationSFT, ) from distilabel.steps.generators.data import LoadDataFromDicts -from distilabel.steps.generators.huggingface import LoadHubDataset +from distilabel.steps.generators.huggingface import ( + LoadDataFromDisk, + LoadDataFromFileSystem, + LoadDataFromHub, + LoadHubDataset, +) from distilabel.steps.globals.huggingface import PushToHub from distilabel.steps.keep import KeepColumns from distilabel.steps.typing import GeneratorStepOutput, StepOutput @@ -49,6 +54,9 @@ "GlobalStep", "KeepColumns", "LoadDataFromDicts", + "LoadDataFromDisk", + "LoadDataFromFileSystem", + "LoadDataFromHub", "LoadHubDataset", "PushToHub", "Step", diff --git a/src/distilabel/steps/generators/huggingface.py b/src/distilabel/steps/generators/huggingface.py index da4a6d7f52..96bbbb4882 100644 --- a/src/distilabel/steps/generators/huggingface.py +++ b/src/distilabel/steps/generators/huggingface.py @@ -12,15 +12,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os -from functools import lru_cache -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union - -import requests -from datasets import DatasetInfo, IterableDataset, load_dataset +import warnings +from collections import defaultdict +from functools import cached_property +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Mapping, + Optional, + Sequence, + Tuple, + Union, +) + +from datasets import ( + Dataset, + DatasetInfo, + IterableDataset, + get_dataset_infos, + load_dataset, + load_from_disk, +) from pydantic import Field, PrivateAttr -from requests.exceptions import ConnectionError +from upath import UPath +from distilabel.distiset import Distiset from distilabel.mixins.runtime_parameters import RuntimeParameter from distilabel.steps.base import GeneratorStep @@ -28,7 +47,7 @@ from distilabel.steps.typing import GeneratorStepOutput -class LoadHubDataset(GeneratorStep): +class LoadDataFromHub(GeneratorStep): """Loads a dataset from the Hugging Face Hub. `GeneratorStep` that loads a dataset from the Hugging Face Hub using the `datasets` @@ -50,6 +69,8 @@ class LoadHubDataset(GeneratorStep): `False`. - `num_examples`: The number of examples to load from the dataset. By default will load all examples. + - `storage_options`: Key/value pairs to be passed on to the file-system backend, if any. + Defaults to `None`. Output columns: - dynamic (`all`): The columns that will be generated by this step, based on the @@ -80,8 +101,12 @@ class LoadHubDataset(GeneratorStep): default=None, description="The number of examples to load from the dataset. By default will load all examples.", ) + storage_options: Optional[Dict[str, Any]] = Field( + default=None, + description="The storage options to use when loading the dataset.", + ) - _dataset: Union[IterableDataset, None] = PrivateAttr(...) + _dataset: Union[IterableDataset, Dataset, None] = PrivateAttr(...) def load(self) -> None: """Load the dataset from the Hugging Face Hub""" @@ -155,11 +180,11 @@ def _get_dataset_num_examples(self) -> int: Returns: The number of examples in the dataset. """ - dataset_info = self._get_dataset_info() - split = self.split - if self.config: - return dataset_info["splits"][split]["num_examples"] - return dataset_info["default"]["splits"][split]["num_examples"] + return ( + self._dataset_info[self.config if self.config else "default"] + .splits[self.split] + .num_examples + ) def _get_dataset_columns(self) -> List[str]: """Get the columns of the dataset, based on the `config` runtime parameter provided. @@ -167,18 +192,14 @@ def _get_dataset_columns(self) -> List[str]: Returns: The columns of the dataset. """ - dataset_info = self._get_dataset_info() - - if isinstance(dataset_info, DatasetInfo): - if self.config: - return list(self._dataset[self.config].info.features.keys()) - return list(self._dataset.info.features.keys()) - - if self.config: - return list(dataset_info["features"].keys()) - return list(dataset_info["default"]["features"].keys()) + return list( + self._dataset_info[ + self.config if self.config else "default" + ].features.keys() + ) - def _get_dataset_info(self) -> Dict[str, Any]: + @cached_property + def _dataset_info(self) -> Dict[str, DatasetInfo]: """Calls the Datasets Server API from Hugging Face to obtain the dataset information. Returns: @@ -188,47 +209,256 @@ def _get_dataset_info(self) -> Dict[str, Any]: config = self.config try: - return _get_hf_dataset_info(repo_id, config) - except ConnectionError: + return get_dataset_infos(repo_id) + except Exception as e: # The previous could fail in case of a internet connection issues. # Assuming the dataset is already loaded and we can get the info from the loaded dataset, otherwise it will fail anyway. - self.load() + self._logger.warning( + f"Failed to get dataset info from Hugging Face Hub, trying to get it loading the dataset. Error: {e}" + ) + ds = load_dataset(repo_id, config=self.config, split=self.split) if config: - return self._dataset[config].info - return self._dataset.info + return ds[config].info + return ds.info + + +class LoadHubDataset(LoadDataFromHub): + def __init__(self, **data: Any) -> None: + warnings.warn( + "`LoadHubDataset` is deprecated and will be removed in version 1.3.0, use `LoadFromHub` instead.", + DeprecationWarning, + stacklevel=2, + ) + return super().__init__(**data) + + +class LoadDataFromFileSystem(LoadDataFromHub): + """Loads a dataset from a file in your filesystem. + `GeneratorStep` that creates a dataset from a file in the filesystem, uses Hugging Face `datasets` + library. Take a look at [Hugging Face Datasets](https://huggingface.co/docs/datasets/loading) + for more information of the supported file types. -@lru_cache -def _get_hf_dataset_info( - repo_id: str, config: Union[str, None] = None -) -> Dict[str, Any]: - """Calls the Datasets Server API from Hugging Face to obtain the dataset information. - The results are cached to avoid making multiple requests to the server. + Attributes: + data_files: The path to the file, or directory containing the files that conform + the dataset. + split: The split of the dataset to load (typically will be `train`, `test` or `validation`). - Args: - repo_id: The Hugging Face Hub repository ID of the dataset. - config: The configuration of the dataset. This is optional and only needed if the - dataset has multiple configurations. + Runtime parameters: + - `batch_size`: The batch size to use when processing the data. + - `data_files`: The path to the file, or directory containing the files that conform + the dataset. + - `split`: The split of the dataset to load. Defaults to 'train'. + - `streaming`: Whether to load the dataset in streaming mode or not. Defaults to + `False`. + - `num_examples`: The number of examples to load from the dataset. + By default will load all examples. + - `storage_options`: Key/value pairs to be passed on to the file-system backend, if any. + Defaults to `None`. + - `filetype`: The expected filetype. If not provided, it will be inferred from the file extension. + For more than one file, it will be inferred from the first file. + + Output columns: + - dynamic (`all`): The columns that will be generated by this step, based on the + datasets loaded from the Hugging Face Hub. - Returns: - The dataset information. + Categories: + - load """ - params = {"dataset": repo_id} - if config is not None: - params["config"] = config + data_files: RuntimeParameter[Union[str, Path]] = Field( + default=None, + description="The data files, or directory containing the data files, to generate the dataset from.", + ) + filetype: Optional[RuntimeParameter[str]] = Field( + default=None, + description="The expected filetype. If not provided, it will be inferred from the file extension.", + ) + + def load(self) -> None: + """Load the dataset from the file/s in disk.""" + super(GeneratorStep, self).load() + + data_path = UPath(self.data_files, storage_options=self.storage_options) + + (data_files, self.filetype) = self._prepare_data_files(data_path) + + self._dataset = load_dataset( + self.filetype, + data_files=data_files, + split=self.split, + streaming=self.streaming, + storage_options=self.storage_options, + ) + + if not self.streaming and self.num_examples: + self._dataset = self._dataset.select(range(self.num_examples)) + if not self.num_examples: + if self.streaming: + # There's no better way to get the number of examples in a streaming dataset, + # load it again for the moment. + self.num_examples = len( + load_dataset( + self.filetype, data_files=self.data_files, split=self.split + ) + ) + else: + self.num_examples = len(self._dataset) + + @staticmethod + def _prepare_data_files( + data_path: UPath, + ) -> Tuple[Union[str, Sequence[str], Mapping[str, Union[str, Sequence[str]]]], str]: + """Prepare the loading process by setting the `data_files` attribute. - if "HF_TOKEN" in os.environ: - headers = {"Authorization": f"Bearer {os.environ['HF_TOKEN']}"} - else: - headers = None + Args: + data_path: The path to the data files, or directory containing the data files. + + Returns: + Tuple with the data files and the filetype. + """ - response = requests.get( - "https://datasets-server.huggingface.co/info", params=params, headers=headers + def get_filetype(data_path: UPath) -> str: + filetype = data_path.suffix.lstrip(".") + if filetype == "jsonl": + filetype = "json" + return filetype + + if data_path.is_file(): + filetype = get_filetype(data_path) + data_files = str(data_path) + elif data_path.is_dir(): + file_sequence = [] + file_map = defaultdict(list) + for file_or_folder in data_path.iterdir(): + if file_or_folder.is_file(): + file_sequence.append(str(file_or_folder)) + elif file_or_folder.is_dir(): + for file in file_or_folder.iterdir(): + file_sequence.append(str(file)) + file_map[str(file_or_folder)].append(str(file)) + + data_files = file_sequence or file_map + # Try to obtain the filetype from any of the files, assuming all files have the same type. + if file_sequence: + filetype = get_filetype(UPath(file_sequence[0])) + else: + filetype = get_filetype(UPath(file_map[list(file_map.keys())[0]][0])) + return data_files, filetype + + @property + def outputs(self) -> List[str]: + """The columns that will be generated by this step, based on the datasets from a file + in disk. + + Returns: + The columns that will be generated by this step. + """ + # We assume there are Dataset/IterableDataset, not it's ...Dict counterparts + if self._dataset is Ellipsis: + raise ValueError( + "Dataset not loaded yet, you must call `load` method first." + ) + + return self._dataset.column_names + + +class LoadDataFromDisk(LoadDataFromHub): + """Load a dataset that was previously saved to disk. + + If you previously saved your dataset using the `save_to_disk` method, or + `Distiset.save_to_disk` you can load it again to build a new pipeline using this class. + + Attributes: + dataset_path: The path to the dataset or distiset. + split: The split of the dataset to load (typically will be `train`, `test` or `validation`). + config: The configuration of the dataset to load. This is optional and only needed + if the dataset has multiple configurations. + + Runtime parameters: + - `batch_size`: The batch size to use when processing the data. + - `dataset_path`: The path to the dataset or distiset. + - `is_distiset`: Whether the dataset to load is a `Distiset` or not. Defaults to False. + - `split`: The split of the dataset to load. Defaults to 'train'. + - `config`: The configuration of the dataset to load. This is optional and only + needed if the dataset has multiple configurations. + - `num_examples`: The number of examples to load from the dataset. + By default will load all examples. + - `storage_options`: Key/value pairs to be passed on to the file-system backend, if any. + Defaults to `None`. + + Output columns: + - dynamic (`all`): The columns that will be generated by this step, based on the + datasets loaded from the Hugging Face Hub. + + Categories: + - load + """ + + dataset_path: RuntimeParameter[Union[str, Path]] = Field( + default=None, + description="_summary_", + ) + config: RuntimeParameter[str] = Field( + default=None, + description="The configuration of the dataset to load. This is optional and only" + " needed if the dataset has multiple configurations.", + ) + is_distiset: Optional[RuntimeParameter[bool]] = Field( + default=False, + description="Whether the dataset to load is a `Distiset` or not. Defaults to False.", + ) + keep_in_memory: Optional[RuntimeParameter[bool]] = Field( + default=None, + description="Whether to copy the dataset in-memory, see `datasets.Dataset.load_from_disk` " + " for more information. Defaults to `None`.", + ) + split: Optional[RuntimeParameter[str]] = Field( + default=None, + description="The split of the dataset to load. By default will load the whole Dataset/Distiset.", ) - assert ( - response.status_code == 200 - ), f"Failed to get '{repo_id}' dataset info. Make sure you have set the HF_TOKEN environment variable if it is a private dataset." + def load(self) -> None: + """Load the dataset from the file/s in disk.""" + super(GeneratorStep, self).load() + if self.is_distiset: + ds = Distiset.load_from_disk( + self.dataset_path, + keep_in_memory=self.keep_in_memory, + storage_options=self.storage_options, + ) + if self.config: + ds = ds[self.config] + + else: + ds = load_from_disk( + self.dataset_path, + keep_in_memory=self.keep_in_memory, + storage_options=self.storage_options, + ) + + if self.split: + ds = ds[self.split] + + self._dataset = ds + + if self.num_examples: + self._dataset = self._dataset.select(range(self.num_examples)) + else: + self.num_examples = len(self._dataset) + + @property + def outputs(self) -> List[str]: + """The columns that will be generated by this step, based on the datasets from a file + in disk. + + Returns: + The columns that will be generated by this step. + """ + # We assume there are Dataset/IterableDataset, not it's ...Dict counterparts + if self._dataset is Ellipsis: + raise ValueError( + "Dataset not loaded yet, you must call `load` method first." + ) - return response.json()["dataset_info"] + return self._dataset.column_names diff --git a/tests/unit/steps/generators/sample_functions.jsonl b/tests/unit/steps/generators/sample_functions.jsonl new file mode 100644 index 0000000000..700d21ad5b --- /dev/null +++ b/tests/unit/steps/generators/sample_functions.jsonl @@ -0,0 +1,11 @@ +{"type": "function", "function": {"name": "code_interpreter", "description": "Execute the provided Python code string on the terminal using exec.\n\n The string should contain valid, executable and pure Python code in markdown syntax.\n Code should also import any required Python packages.\n\n Args:\n code_markdown (str): The Python code with markdown syntax to be executed.\n For example: ```python\n\n```\n\n Returns:\n dict | str: A dictionary containing variables declared and values returned by function calls,\n or an error message if an exception occurred.\n\n Note:\n Use this function with caution, as executing arbitrary code can pose security risks.", "parameters": {"type": "object", "properties": {"code_markdown": {"type": "string"}}, "required": ["code_markdown"]}}} +{"type": "function", "function": {"name": "google_search_and_scrape", "description": "Performs a Google search for the given query, retrieves the top search result URLs,\nand scrapes the text content and table data from those pages in parallel.\n\nArgs:\n query (str): The search query.\nReturns:\n list: A list of dictionaries containing the URL, text content, and table data for each scraped page.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}} +{"type": "function", "function": {"name": "get_current_stock_price", "description": "Get the current stock price for a given symbol.\n\nArgs:\n symbol (str): The stock symbol.\n\nReturns:\n float: The current stock price, or None if an error occurs.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}}} +{"type": "function", "function": {"name": "get_company_news", "description": "Get company news and press releases for a given stock symbol.\n\nArgs:\nsymbol (str): The stock symbol.\n\nReturns:\npd.DataFrame: DataFrame containing company news and press releases.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}}} +{"type": "function", "function": {"name": "get_company_profile", "description": "Get company profile and overview for a given stock symbol.\n\nArgs:\nsymbol (str): The stock symbol.\n\nReturns:\ndict: Dictionary containing company profile and overview.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}}} +{"type": "function", "function": {"name": "get_stock_fundamentals", "description": "Get fundamental data for a given stock symbol using yfinance API.\n\nArgs:\n symbol (str): The stock symbol.\n\nReturns:\n dict: A dictionary containing fundamental data.\n Keys:\n - 'symbol': The stock symbol.\n - 'company_name': The long name of the company.\n - 'sector': The sector to which the company belongs.\n - 'industry': The industry to which the company belongs.\n - 'market_cap': The market capitalization of the company.\n - 'pe_ratio': The forward price-to-earnings ratio.\n - 'pb_ratio': The price-to-book ratio.\n - 'dividend_yield': The dividend yield.\n - 'eps': The trailing earnings per share.\n - 'beta': The beta value of the stock.\n - '52_week_high': The 52-week high price of the stock.\n - '52_week_low': The 52-week low price of the stock.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}}} +{"type": "function", "function": {"name": "get_financial_statements", "description": "Get financial statements for a given stock symbol.\n\nArgs:\nsymbol (str): The stock symbol.\n\nReturns:\ndict: Dictionary containing financial statements (income statement, balance sheet, cash flow statement).", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}}} +{"type": "function", "function": {"name": "get_key_financial_ratios", "description": "Get key financial ratios for a given stock symbol.\n\nArgs:\nsymbol (str): The stock symbol.\n\nReturns:\ndict: Dictionary containing key financial ratios.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}}} +{"type": "function", "function": {"name": "get_analyst_recommendations", "description": "Get analyst recommendations for a given stock symbol.\n\nArgs:\nsymbol (str): The stock symbol.\n\nReturns:\npd.DataFrame: DataFrame containing analyst recommendations.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}}} +{"type": "function", "function": {"name": "get_dividend_data", "description": "Get dividend data for a given stock symbol.\n\nArgs:\nsymbol (str): The stock symbol.\n\nReturns:\npd.DataFrame: DataFrame containing dividend data.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}}} +{"type": "function", "function": {"name": "get_technical_indicators", "description": "Get technical indicators for a given stock symbol.\n\nArgs:\nsymbol (str): The stock symbol.\n\nReturns:\npd.DataFrame: DataFrame containing technical indicators.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}}} diff --git a/tests/unit/steps/generators/test_data.py b/tests/unit/steps/generators/test_data.py index 9817837e20..c35b9db86d 100644 --- a/tests/unit/steps/generators/test_data.py +++ b/tests/unit/steps/generators/test_data.py @@ -17,7 +17,7 @@ from pydantic import ValidationError -class TestLoadDataFromDictsTask: +class TestLoadDataFromDicts: data = [{"instruction": "test"}] * 10 def test_init(self) -> None: diff --git a/tests/unit/steps/generators/test_huggingface.py b/tests/unit/steps/generators/test_huggingface.py index 34b44f4fc5..e72a70acb2 100644 --- a/tests/unit/steps/generators/test_huggingface.py +++ b/tests/unit/steps/generators/test_huggingface.py @@ -13,19 +13,27 @@ # limitations under the License. import os +import tempfile +from pathlib import Path from typing import Generator, Union import pytest from datasets import Dataset, IterableDataset +from distilabel.distiset import Distiset from distilabel.pipeline import Pipeline -from distilabel.steps.generators.huggingface import LoadHubDataset +from distilabel.steps.generators.huggingface import ( + LoadDataFromDisk, + LoadDataFromFileSystem, + LoadDataFromHub, + LoadHubDataset, +) DISTILABEL_RUN_SLOW_TESTS = os.getenv("DISTILABEL_RUN_SLOW_TESTS", False) @pytest.fixture(scope="module") def dataset_loader() -> Generator[Union[Dataset, IterableDataset], None, None]: - load_hub_dataset = LoadHubDataset( + load_hub_dataset = LoadDataFromHub( name="load_dataset", repo_id="distilabel-internal-testing/instruction-dataset-mini", split="test", @@ -39,12 +47,12 @@ def dataset_loader() -> Generator[Union[Dataset, IterableDataset], None, None]: not DISTILABEL_RUN_SLOW_TESTS, reason="These tests depend on internet connection, are slow and depend mainly on HF API, we don't need to test them often.", ) -class TestLoadHubDataset: +class TestLoadDataFromHub: @pytest.mark.parametrize( "streaming, ds_type", [(True, IterableDataset), (False, Dataset)] ) def test_runtime_parameters(self, streaming: bool, ds_type) -> None: - load_hub_dataset = LoadHubDataset( + load_hub_dataset = LoadDataFromHub( name="load_dataset", repo_id="distilabel-internal-testing/instruction-dataset-mini", split="test", @@ -60,6 +68,131 @@ def test_runtime_parameters(self, streaming: bool, ds_type) -> None: assert isinstance(generator_step_output[1], bool) assert len(generator_step_output[0]) == 2 - def test_dataset_outputs(self, dataset_loader: LoadHubDataset) -> None: + def test_dataset_outputs(self, dataset_loader: LoadDataFromHub) -> None: # TODO: This test can be run with/without internet connection, we should emulate it here with a mock. assert dataset_loader.outputs == ["prompt", "completion", "meta"] + + +class TestLoadDataFromFileSystem: + @pytest.mark.parametrize("filetype", ["json", None]) + @pytest.mark.parametrize("streaming", [True, False]) + def test_read_from_jsonl(self, streaming: bool, filetype: Union[str, None]) -> None: + loader = LoadDataFromFileSystem( + filetype=filetype, + data_files=str(Path(__file__).parent / "sample_functions.jsonl"), + streaming=streaming, + ) + loader.load() + generator_step_output = next(loader.process()) + assert isinstance(generator_step_output, tuple) + assert isinstance(generator_step_output[1], bool) + assert len(generator_step_output[0]) == 11 + + @pytest.mark.parametrize("filetype", ["json", None]) + def test_read_from_jsonl_with_folder(self, filetype: Union[str, None]) -> None: + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + filename = "sample_functions.jsonl" + sample_file = Path(__file__).parent / filename + for i in range(3): + Path(tmpdir).mkdir(parents=True, exist_ok=True) + (Path(tmpdir) / f"sample_functions_{i}.jsonl").write_text( + sample_file.read_text(), encoding="utf-8" + ) + + loader = LoadDataFromFileSystem( + filetype=filetype, + data_files=tmpdir, + ) + loader.load() + generator_step_output = next(loader.process()) + assert isinstance(generator_step_output, tuple) + assert isinstance(generator_step_output[1], bool) + assert len(generator_step_output[0]) == 33 + + @pytest.mark.parametrize("filetype", ["json", None]) + def test_read_from_jsonl_with_nested_folder( + self, filetype: Union[str, None] + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + filename = "sample_functions.jsonl" + sample_file = Path(__file__).parent / filename + for folder in ["train", "validation"]: + (Path(tmpdir) / folder).mkdir(parents=True, exist_ok=True) + (Path(tmpdir) / folder / filename).write_text( + sample_file.read_text(), encoding="utf-8" + ) + + loader = LoadDataFromFileSystem( + filetype=filetype, + data_files=tmpdir, + ) + loader.load() + generator_step_output = next(loader.process()) + assert isinstance(generator_step_output, tuple) + assert isinstance(generator_step_output[1], bool) + assert len(generator_step_output[0]) == 22 + + @pytest.mark.parametrize("load", [True, False]) + def test_outputs(self, load: bool) -> None: + loader = LoadDataFromFileSystem( + filetype="json", + data_files=str(Path(__file__).parent / "sample_functions.jsonl"), + ) + if load: + loader.load() + assert loader.outputs == ["type", "function"] + else: + with pytest.raises(ValueError): + loader.outputs # noqa: B018 + + +class TestLoadDataFromDisk: + def test_load_dataset_from_disk(self) -> None: + dataset = Dataset.from_dict({"a": [1, 2, 3]}) + with tempfile.TemporaryDirectory() as tmpdir: + dataset_path = str(Path(tmpdir) / "dataset_path") + dataset.save_to_disk(dataset_path) + + loader = LoadDataFromDisk(dataset_path=dataset_path) + loader.load() + generator_step_output = next(loader.process()) + assert isinstance(generator_step_output, tuple) + assert isinstance(generator_step_output[1], bool) + assert len(generator_step_output[0]) == 3 + + def test_load_distiset_from_disk(self) -> None: + distiset = Distiset( + { + "leaf_step_1": Dataset.from_dict({"a": [1, 2, 3]}), + "leaf_step_2": Dataset.from_dict( + {"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]} + ), + } + ) + with tempfile.TemporaryDirectory() as tmpdir: + dataset_path = str(Path(tmpdir) / "dataset_path") + distiset.save_to_disk(dataset_path) + + loader = LoadDataFromDisk( + dataset_path=dataset_path, is_distiset=True, config="leaf_step_1" + ) + loader.load() + generator_step_output = next(loader.process()) + assert isinstance(generator_step_output, tuple) + assert isinstance(generator_step_output[1], bool) + assert len(generator_step_output[0]) == 3 + + +def test_LoadHubDataset_deprecation_warning(): + with pytest.deprecated_call(): + LoadHubDataset( + repo_id="distilabel-internal-testing/instruction-dataset-mini", + split="test", + batch_size=2, + ) + import distilabel + from packaging.version import Version + + assert Version(distilabel.__version__) <= Version("1.3.0") diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py index 9c3ff44d57..07eb3e5b63 100644 --- a/tests/unit/test_imports.py +++ b/tests/unit/test_imports.py @@ -51,6 +51,8 @@ def test_imports() -> None: GeneratorStepOutput, KeepColumns, LoadDataFromDicts, + LoadDataFromHub, + LoadDataFromDisk, LoadHubDataset, PushToHub, Step, From e5320a32cae8d7625cb9100e8c1a4678a8330e0c Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 10 Jun 2024 13:03:02 +0200 Subject: [PATCH 20/40] Add citation in README to simplify citing from academia (#712) --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 4e071df69d..c2e28df170 100644 --- a/README.md +++ b/README.md @@ -153,3 +153,15 @@ If you build something cool with `distilabel` consider adding one of these badge To directly contribute with `distilabel`, check our [good first issues](https://github.com/argilla-io/distilabel/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) or [open a new one](https://github.com/argilla-io/distilabel/issues/new/choose). +## Citation + +```bibtex +@misc{distilabel-argilla-2024, + author = {Álvaro Bartolomé Del Canto and Gabriel Martín Blázquez and Agustín Piqueres Lajarín and Daniel Vila Suero}, + title = {Distilabel: An AI Feedback (AIF) framework for building datasets with and for LLMs}, + year = {2024}, + publisher = {GitHub}, + journal = {GitHub repository}, + howpublished = {\url{https://github.com/argilla-io/distilabel}} +} +``` From f7eef99ed0d8b4df6968d8294d0a39712bb3edc6 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 10 Jun 2024 15:48:09 +0200 Subject: [PATCH 21/40] Move navigation to top bar (#708) * Move navigation to top tabs instead of left side and include links to socials * Change site name to Distilabel Docs * Update fonts to use argilla ones --- docs/index.md | 2 +- docs/stylesheets/extra.css | 5 +- docs/stylesheets/fonts/FontAwesome.otf | Bin 0 -> 134808 bytes .../stylesheets/fonts/fontawesome-webfont.eot | Bin 0 -> 165742 bytes .../stylesheets/fonts/fontawesome-webfont.svg | 2671 +++++++++++++++++ .../stylesheets/fonts/fontawesome-webfont.ttf | Bin 0 -> 165548 bytes .../fonts/fontawesome-webfont.woff | Bin 0 -> 98024 bytes .../fonts/fontawesome-webfont.woff2 | Bin 0 -> 77160 bytes mkdocs.yml | 144 +- 9 files changed, 2753 insertions(+), 69 deletions(-) create mode 100644 docs/stylesheets/fonts/FontAwesome.otf create mode 100644 docs/stylesheets/fonts/fontawesome-webfont.eot create mode 100644 docs/stylesheets/fonts/fontawesome-webfont.svg create mode 100644 docs/stylesheets/fonts/fontawesome-webfont.ttf create mode 100644 docs/stylesheets/fonts/fontawesome-webfont.woff create mode 100644 docs/stylesheets/fonts/fontawesome-webfont.woff2 diff --git a/docs/index.md b/docs/index.md index 7547e1a19c..8a109bbaf3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- description: Distilabel is an AI Feedback (AIF) framework for building datasets with and for LLMs. hide: - - toc + - navigation --- diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 9e14bf3c36..1d487bd5a9 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,7 +1,10 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..600&display=swap'); + :root { --md-primary-fg-color: #84b0c1; --md-primary-fg-color--light: #84b0c1; --md-primary-fg-color--dark: #84b0c1; + --md-text-font: "Inter"; } [data-md-color-scheme="default"] { --md-primary-fg-color: #000000; @@ -16,4 +19,4 @@ .md-sidebar__scrollwrap:focus-within, .md-sidebar__scrollwrap:hover { scrollbar-color: var(--md-default-fg-color--lighter) #0000; - } \ No newline at end of file +} diff --git a/docs/stylesheets/fonts/FontAwesome.otf b/docs/stylesheets/fonts/FontAwesome.otf new file mode 100644 index 0000000000000000000000000000000000000000..401ec0f36e4f73b8efa40bd6f604fe80d286db70 GIT binary patch literal 134808 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 literal 0 HcmV?d00001 diff --git a/docs/stylesheets/fonts/fontawesome-webfont.eot b/docs/stylesheets/fonts/fontawesome-webfont.eot new file mode 100644 index 0000000000000000000000000000000000000000..e9f60ca953f93e35eab4108bd414bc02ddcf3928 GIT binary patch literal 165742 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 G*yFsbY~cLNXLP73aA74Mq6M9f^&YV`isWW zU@CY~qxP|&bnWBDi{LM9r0!uDR`&3$@xh)p^>voF;SAaZi_ozepkmLV+&hGKrp0jy9{6cAs)nGCitl6Cw2c%Z0GVz1C zH-$3>en`tRh)Z(8))4y=esC5oyjkopd;K_uLM(K16Uoowyo4@9gTv5u=A_uBd0McB zG~8g=+O1_GWtp;w*7oD;g7xT0>D9KH`rx%cs^JH~P_@+@N5^&vZtAIXZ@TH+Rb$iX zv8(8dKV^46(Z&yFGFn4hNolFPVozn;+&27G?m@2LsJe7YgGEHj?!M`nn`S-w=q$Y4 zB>(63Fnnw_J_&IJT0ztZtSecc!QccI&<3XK0KsV4VV(j@25^A-xlh_$hgq6}Ke~GZ zhiQV3X|Mlv6UKb8uXL$*D>r^GD8;;u+Pi;zrDxZzjvWE#@cNGO`q~o7B+DH$I?5#T zf_t7@)B41BzjIgI68Bcci{s-$P8pU>=kLG8SB$x;c&X=_mE3UN@*eF+YgP|eXQVn) z)pd&9U^7r1QaaX{+Wb-9S8_jQZC19~W) z*_+RuH*MPD=B_m7we#2A@YwQv$kH2gA%qk7H)?k!jWbzcHWK497Ke<$ggzW+IYI2A zFQ_A$Ae4bxFvl4XPu2-7cn1vW-EWQ6?|>Qm*6uI!JNaRLXZFc5@3r48t0~)bwpU*5 z-KNE}N45AiuXh{&18l_quuV$6w|?c-PtzqcPhY)q{d+Hc_@OkartG`dddteZXK&Je zGpYJ-+PmEUR`sOnx42*X$6KT~@9ze#J>YvvaN24jI}4QG3M;w<>~!2i@r)9lI!6N1 z0GN((xJjHUB^|#9vJgy=07qv}Kw>zE+6qQns-L}JIqLFtY3pDu_$~YrZOO$WEpF>3 zXTu#w7J9w+@)x-6oW(5`w;GI8gk@*+!5ew8iD$g=DR*n@|2*R`zxe7azdr7~Z;$%< zSH@*lQ9U(Hx^%Fb|1?Smv({(NaZW+DGsnNWwX(DFUG8)(b6Rn>MzUxlZhNbVe>`mS zl&aJjk3F~9{lT-}y>e~pI}kOf@0^%Vdj&m(iK4LTf6kmF!_0HQ$`f-eBnmdTsf$_3 zR`hz2EjKIKWL6z@jj1}us>ZmY)iQInPifzSiOFN92j9$pX*CuV8SPrD#b%Qa97~TI zS6)?BPUgFnkqG8{{HUwd)%ZsvurI~=Jr8YSkhUA!RANJ;o|D->9S9QB5DxTybH&PGFtc0Z>dLwr|Ah}aX`XwTtE&UssYSEILtNijh)8)WWjMm$uT;+p1|=L z><4lEg%APBLn+FRr&2tGd)7icqrVXFE;+3j`3p~mvsiDMU>yK$19$B@8$Dy4GClfzo4)s_o2NuM3t-WhCrXE>LQ z_CQtR*!a0mhnw#I2S=WxT_H@^Saif`)uhLNJC zq4{bSCwYBd!4>6KGH5y~WZc@7_X~RqtaSN(`jfT!KhgGR)3iN50ecR$!|?Vq8|xa+ zY#*+B=>j4;wypclu7?wd+y06`GlVf2vBXzuPA;JgpfkIa1gXG88sZ*aS`(w z_9`LL4@aT0p!4H7sWP`mwUZRKCu@UWdNi-yebkfmNN+*QU+N*lf6BAJ$FNs^SLmDz z^algGcLq`f>-uKOd_Ws4y^1_2ucQaL>xyaQjy!eVD6OQi>km;_zvHS=ZpZZrw4)}Z zPz(rC?a`hZiQV9o^s>b?f-~ljm1*4IE<3plqCV}_shIiuQl=uKB4vUx2T$RCFr0{u z1v660Y3?>kX@{19i6;*CA}pJsFpo{nculW61+66XAOBZD< z{H|h`mJS5C2;ymL##}U*MC%fL0R97OSQ@lUXQ-j?i{z{=l-!$64H{LlTLo{Ln<|OV zBWq*5LP`KJl74fC{GzzP_Z;;;6i--QpZUrtHC@+RBlt+=_3TyV4gk=4b{TBJAx!GehYbTby(&-R337 zQ%g2)Uc&K|x|eL0yR*VCXDBqZ89C(obOFYYht(k`^q0OaQ*Y{)@7xE~KQ7XN)hGlZ zl5$1<#s!tyf%>mbIG(9WR`R*{Qc_h(ZGT^8>7lXOw^g1iIE2EdRaR^3nx_UUDy#W6 zy!q(v^QLL*42nxBK!$WVOv)I9Z4InlKtv#qJOzoZTxx86<5tQ*v528nxJ^sm+_tRp zT7oVNE7-NgcoqA#NPr*AT|8xEa)x&K#QaWEb{M34!cH-0Ro63!ec@APIJoOuP&|13 z9CFAVMAe@*(L6g{3h&p2m!K zEG?(A$c(3trJ5LHQ@(h3@`CB*ep}GDYSOwpgT=cZU;F&F6(b=V*TLLD z*fq(p>yRHTG1ttB*(Q8xLAl4cZdp^?6=QjcG;_V(q>MY0FOru|-SE}@^WElQTpCQZ zAMJy_$l;GISf1ZmbTzkD(^S!#q?(lDIA?SIrj2H$hs*|^{b|Kp!zXPTcjcCcfA+KN zdlV!rFo2RY@10$^a_d*-?j7HJC;KhfoB%@;*{;(hx_iP`#qI(?qa{b zH|YEvx~cE^RQ4J}dS>z%gK-XYm&uvZcgoyLClEhS(`FJ^zV!Vl&2c{U4N9z_|1($J znob`V2~>KDKA&dTi9YwyS#e-5dYkH?3rN(#;$}@K&5Yu}2s&MGF*w{xhbAzS@z(qi z&k99O!34}xTQ`?X!RRgjc)80Qud0{3UN4(nS5uZ1#K=^l&$CdhVr%4<67S=#uNP z$hnqV471K$Gy&){4ElZt?A?0NLoW2o_3R)!o~sw#>7&;Vq954STsM(+32Z#w^MksO zsrqpE@Js9$)|uQzKbXiMwttapenf8iB|j(wIa2-@GqE@(2P#M09Rvvhdu!sE0Mx&cK&$EtK}}WywYEC~MF5r3cUj%d$|lLwY4>`) z_D++uNojUl@4Cz8YF3nvwp>JWtwGtSG`nnfeNp(_RYv`S2?qhgb_(1$KD6ymTRgnD zx^~3GBD2+4vB9{=V_iMG*kQTX;ycG^`f{n+VxR4Ah!t~JQ6Z?Q;ws}Jw|#YE0jR0S z+36oq6_8xno^4J?Y02d!iad3xPm+8~r^*Vvr4A<|$^#UEbKvJ9YHF=Ch2jF`4!QS# zl8We8%)x>ejzT^IH%ymE#EBe2~-$}ZXtz&vZ_NgVk4kc zOv-dk(6ie2e{lAqYwn9Q$weL#^Nh?MpPUK z#Cb)4d96*6`>t7Zwsz#_qbv6CnswLS9Jt|b`8Mqz?`?H1tT99K#4#d+VwAy}#eC74 z;%UFxaNB!Zw`R9){Pncrny4>k;D}TV2BU0ua-+Fsp>wmcX#SGkn`h0O`pN*`jUj8q zIlnc7x6NRbR)=wP1g`-}2unC>O6ow=s{=NV6pfEo3=tY8 z=*$TKFk8Wv0K8B_**m*Q>+VW*1&gD#{#GSc(h#YQL?*<(ZUx~>L^RyAG3}j0&Q|mJtT7ec|Y7cr~ z+A`Wz!Sqz9bk0u-kftk^q{FPl4N+T(>4(fl@jEEVfNE$b*XSE)(t-A>4>`O^cXfrj zd_nrA-@@u?czM(o3OVDok%p3(((12`76;LwysK$;diTl$BdV)!p5Gj=swpb=j2N>b zqJ1D5E#zO9e(vJ6+rGuy<(PS-B6=gHvFat&)qr%j7T`vT1ju zIvHwGCk5)id{uDi@-e?0J*(-W-RGZs)uhSeqv7TA&h|CUx(R0ysoiQC8XnxL&RXI3 zO`H`8Pe&^ePw*`{rIJhzUg@MuhUL`IONG^*V?R0h5@BRDFgEF45b0jSrg0r{<4X)nw^c)uQ_Ai_p>ic!=K$pmnyqYb=`6fUo40ru#Gh= zMRJxOD(1n?Mjz_|IWyJK5^fh3*n>eI0MmEKq%=-oIdGd4F-LT>RL)Bp5FWxb4aNLNXB^o?YBSXQ`SwN zI*N~(CQW~P$HpzwrMG4IZKI>TVI4nQ$a-#)zV}LE(xgQ5MG@L#e!e@ ziNtg{Ph&qpX9FLaMlqMh>3)Nu%sAO#1NEsbe=#4Vqx0Y;<~+mV!xwj%}Z=xZn= zSqjxSH4T~v>Xd*=2wmHPN?@+9!}aQz-9(UIITZ==EB9}pgY1H4xu^-WdOFSK!ocZc zd-qhN$eZcN#Q^0>8J%)XI$4W(IW6R810*ucIM7Q#`twI|?$LYR1kr>3#{B{Z4X(xm&Cb21d^F9MKiD=wk_r+a=nyK!s^$zdXglCdshbfKBqa5aMwN#LmSNj6+DPhH4K-GxRl;#@=IJc zm{h}JsmQFrHCioWCBGzjr5p9L4$t4`c5#Cz(NJ#+R7q-)Tx2)6>#WZDhLGJD964iJ zJXu`snOYJYy=`<+b*HDiI9XPo8XK$TF86)Ub5=NC@VN#f$~GDsjk01g$;wDY!KqOh zC$x={(PT7CH7c?ZPH{RNz}Tel$>M0p;je4|O2|%Yq8@sCb7gRhgR4a*qf+WGD>E8~ z`wb<@^QX)i-7&*Z>U6qXMt_B2M#tzmqZTA1PNgzcvs|(|-E z4t*ZT-`kgepLl0g1>H!{(h8b`Ko=fR+|!L_Iji>5-Qf34-}z%X8+*Qwe^XrIS4Re$ zWUblH=yEfj!IgeIQ>m}+`V(4u?6c;s&Ym_6+pt|V`IQ1!oAC@R1XC3tL4BQ7`!TnU zWaoqG=nhI@e7dV7)8VzO8ivuC!q{hcxO7fo#2I=<`rktP0OfAO-CQE!ZT@}e7lw;{c) z@2l7RV$@&S5H@{=Bj~^Kp5At=Jq=Y92rXP@{-D4j>U=-a^gM2s-nIZA;u=fbm2BP=Zca5W81_cA>Tr z)x+r@{pu_la2Q(wm`Zqyd@GhNDNT&4oNHb_>w4{jIU}m&iXykMxvi;WL8;y7t}cp& z9CEpR)WlI1qmOq!zg4QTmzv#eP3>NLd7V-+YKmuyLFP533rd>WnvL$F3b}g39PYk; z)^hXQ%5jO(B}-TMio7@t<(V?7M5!ycd)u4Z+~!hym9+KwPVO^Wkhi^Dc7$R@)o$oh z^mRbgQ@5EvalJa}V4Bi3cs^w5pYtbXXz5W|e%+z-K;8M%Lf~BlZRvNI7=)cG6lbjg z?)l8iOw!mU`uaKN@UL4>d#edM9^-ePb(VICy6Cg-H^Ew$n_s801w`A83W!_Z{D+1G z(<9A>WB@>)D%cxw7c?Xv7N}6gg?&TkLX|0@k&VL)YMI~SsE^dzj2^3BKL7SM$!0Lt zj;ytKWw|(58n6_NNH$JVRh!W*wewMr7)H2jOCruuJAIIfPMFpf6j=hL!D3nVT9Dpo zut}|VoG<%v&w;HrQtz<%%T&X##*z5{D!!egoRN}R_Xxuy+E3dhx6!7mlNyuqsKR-P zlP#8EKGt{Ij~8kXY?&*%q)PkPG;rziWPd>HefyPwV49!>f&Q_@Fn{8Cyz{HCXuo+( zJMu<#{Tl}^-dh%nM0IrDa@V zMHgAog4`tk;DNK-c{HwRhx%Fn%ir3mex!XeZQ4QY)vQ_iZ(j4-GcO?@6Z-Y*f?u7_ zmf!}WRoGkI#BO9;5CFvMobtV@Qm?#eNKbbX!O@xEVhnm z6LFnWu=E}6kB82ZEf!g}n5&IuivccTHk-_5cazDAe+O!_j+dQ~aUBy~PM34Eq0X-LOl zjunFnO<4Nq|BL`!xwvyj&g9Q0(A_*xLT~l{^nM&kGzB7+^hP^L&bD7iVdXe3wobJXVX~o*tX$ zI5xthE?gAl!4+v~+ASbN2nYIqNn_#3>!fi2k=g*Hg_%caA#plNQR+RtHTiW>(*OFG*-nzu~6DMCrX>xzP`3sj}D!||8 zf3dk-w(NCUMu^C%k|t?sa>9gU_Ms-R2Hhm~4jNfPPyH!3Zy zV0QFf=MWK%>|(eV$pB5qOkC)uou{oIJwb_i4epV{W95%N)`+uOrLx7fNtD^czsq4B znAWb+Zsk|YX}a?b+sS-!*t2w1JUqU6Ol`&Jrqa5=4eeLWzr1DX1fWW`6MYf+8SOW< z+EMJ|fp${RJ7q9G7J+`pLof$#kBJP^i@%wNnG3fnK?&k>3IUVo3dbs9Nt)x_q|wIB zlBAi#1Xv-<+nr<13SBfkdzI?dJ|3~?-e>MzG(yRsA}I_oEd{HEGZ&7H|Km9mEbL6r z{Ubhh;h6_QXN_?>r(eWJ@CM1-yn6Y#am!aXXW!EfCpu}=btdYT?EJ>j+jeuc%;P2g z5*J%*$9La$^cy>u0DqjO#J%*IdaaPnAX#A6rRQ+sAHhY@o32==Ct3IF&sM14!2`FD zA))>ZKsccTyp$U0)vjABEY_N5lh(@e+Gj>sYOTgf?=82K)zw-?JX2d$x}n2Y0v%SjDtBXDxV2TyyxQmN?2%8zkKkKF*!AA$P$1#qrF%fUu~URt`tp3C_(>^tkcbHhO0Hh0A zpTVQR{DjsD=y-Bsl#nuTVKRxYbjpSJg|K+SEP+^Y*z3S9p(_-s9^YP5Zc?Vz*o(Qx z?f03co`dGfW}0T>UdEZaW>s0XVEzlw@s&bc+B-9;^^AGsx$AE~!1-7?tn9z|p4}_? zRsM&sjg1>#Rb#6jFBRKMeZ>I_4<%=&rF3yqUD&Lik@7<@2*(0rC)UqPj`Gfe8L&{S zhGtB67KhF{GnLZCF}gN0IrIPU_9lQ)mFNEOyl0tx-!qeCCX<;7*??>lNC*Q7`xe43 z2$7wD3MhiII4W*v6;Y775v{FSYqhp+|6)6BZR@Rdz4}#KZR4%=+E%T%_gX8-9KPT4 zo|$Aa1ohtUet#uro3p&@^FHhEX`OcGjq==$UeAQ~<6AZzZ|l75nn<#}+mo0rqWv5$ z1N<|1yMgX+Qmz?53v|%P=^&74bwqfH?xIC`L()W{|G`j^>kbs7q<$hb6fL@S za#nHyi$$TJ7*i!6estChR}QriMs#yy!@Po#AYdeWL~* zUR%)FT#4Q~O-N!O&it}b8zFOmbe=egH*Ka<9jT?dFCMAcagAo<>tKrW%w?P_A_gd& zXwHTn>a>WEWRzimu7EJ*$3~Jfv|@bLg}6iH4mgJB!o60eP#_N!xYrQoMf4&rGLau~D9ila zYGD*3*MNN?v*n6op+dQM!Kkr@qH1|^ zh7skG&aC;+$C$OSR2!ke>7|B6JDpjV%$Jo5hI14PGyx1I=Diw7>h@vzL?PLTzC;`; z?}nkmP%J6$BG!9mxz?+Np zIHbVy&<#H&Ekz1(ksSJ_NDQ+XHyg-!YcW8YvE5v*jFQ->F;|Q-IB@Mw6YP~v=jY$~9n@~8MVO{1g z@g=-I$aXs1BH&>hK(~|d>Y9n*;xRm&07=pLuqVYV-bwyCUIKgMdLSrovEs2f3{b z<++d|UX&}*7)y8){Ntc{RL*udOS8r%JV4EZ64fUF85n7%NAWejYbLV}NB|lS>SnYN z?PFpysSR*OodDcNK;OVKsSbKS^g;|bSdogA=};1?3rYq|Nc_tR!b2ln>=bNTL59uS zZjF^Y1RoS7qF^>LEqt<#Mu0ZjpiUNLtsc5%t*8}5lW4OWwFXfqGn-q~H)5}2mSRZ^ zKpfQxOe+KC(M5V`tz1zQ)@pTTQ2?NgStmwpvPCi&U9wd)m<^I-w&{(`Vb?Q*4ApV5 z(G}DMfgox!S_C+OTa5UkEbB#G$SC<8vLrDPPT_Uq5N~7`%Js5Ut3!o!f@HJm?b;(N zbbv90V6J7=E&)E`b|}N4n`VOOuvo$IEMx`%EkX8mpug0yY80enF3?M57gI zQ((b(;dv_v7PDKFgL|6)q^sb%Gp_aU)wp^uX96>jGEsOmBhyuDZ8}+y{bG?UqGqyDfYMtJ{6@xXI>fVC9g+uG zbQzl4fY>P6VAkv8GEpapl2>quqSIoui)Mr95Nuw@voGBux%Mq zYqG!&A9RXvoI%gZRwI->g2SYPB1tbg0U9UkC70cRFPTKU0L{E!2e?|as;p-wNwA;> zm}yKfYURNzE545Jz^T+srPZUGX{3qx0H&3ol`)Eow3xXj!2lx+DkB=}EoF`(n^)2W z_26hljpwvSdw}akJQN9;WAQnnHTN=3Ko19hR`Qqt#60*^1acxN84Oi8W-4nXd^@w0 zVpMzKqWw_(cHwQ`*uQ>F4F;Ncc?}XU{q867ZF>zihsu1j_i%f38%41S53RkO-5Bq< z<^ffy6fQNDn;z=lDz2OXjU+MMr0ziZ)HseHI3+}-N8v$8UWEK_n5pL6VPUS@YH^ z-F?^bJ%5Vt}@l0B2B$XfpF!7J0KUW$rc!~hPD3+Ms%)ia=pl{0nuS0_) zMk9rt16uqE&;%{gtVGqhUs{u$%()O~zzC_11`vYVVXfdfEU}YwTDn~JYTSiTDRNih z4#ap?$m%48h4*c`rhEH7?VLTW9aCi~b>z~)W0xM$c|y(8H%u~4?Yic=Yr3WyCvBMC z9P;P}Ra`!CY1TVd3~%qgX48EO<*6O5d**2Osm_lAM&ZKw?7XUKU$o?gjCIcqH|%NJ zuxtIAj>_t$YW%D0ShIfD2DzU5%qnHsRN0vm^B3-wcim7D^;K7~Uj8EuKZ;X3tlbVD z(=eh%wxAVAWPvDL3Mmg=TPKpMGzTdG=aT&qTw(TFBIg<;`kFOrB)&>#;&>KE1kb>+ z2B2dhdAN+pj}^ZH_t#P}WOC_RDs4ppbD0<}eknMnviR2G%#`AniYwzKw-y(_5*$-_ zmw5S-TNmxQbkR$TmM>p=*`CF(EG{@lszbazB$k;2MYhTooy&w{`02hJ3>+yIKEOe7 z@JMkSHwDW^-jsRwlSM}sEqQs-p1n(#FUOllp3=O)Tup&?1<^)a@`nk7JGz35N>n$} zBOy~(>fI9qX^_jCE*5|=cn@Q((|dZ4jk)4MmOAk+0xA#wuDRF-%lTtBwIA!9Gr9Ct z$c`7mj%LBTedqC%Rm_T=dk5?Lu6Ta&XaF9q!a$AUtk$ z*e$72Su7q{Rad`o)%w|Sbyv5rzAip{{VH|GtUY1tf`Dk1!6*HuN9YH|>@$Gpvq}N6 zCzbi<_XLxmE|LLdr@JCzPlDyUYO2J>kDK?krp5CY@11*7)8aCVVb&~zrEGE2O>>tojkD`+_dDb1*Ao``HQpP(giSRL)4OKuTMcNVOb@(m7M?noGc?geUJ;8t6u0>WYa5RLDJ>(^Zu~>-DTzEbb z=Pw6=C#Q(ao#It|Sa^jEBWtV8YNL5Ce+KO1 zHqBg6?QNQUAP0QbaOG=Lqb?5ZLlZP3JdqXFBbSG?_!QPegco`UzEDBCfy7n?l|5O(2uWh*{9fh*}OFkZGv)4J9g^Su_Z-y zktO~$6KAdO?4HIhm;a)+gVRbF%BNDw_qH-YUp3>pUiriPU-DaPao4J;%WF%Dllm58 z#~3FQnvO5O$UIv}o~Up(EN-l>@f8Ipwl+*yG^2h|U81N>`H9+~R;Nq6WZk+k_l_|; zqH`}-wki9Eekf?yVOxp~wx$i7mS&wyRfA;|YZ$pD0iFQM7=^Of;Mb5{*g%Q+MV}ZZ z4uCY|_@8q>JQ{}h=B5NG!svf6mRKr5#bVli@?ZR%doi+~75m0rb2XFdcTK&}XtK)Y z#n$?!<(KX3?3gc;rSMQ3)+>e{<=;f)h)dXgJA+DdJ5q_(=fbyjlD zyxOq~%LPEFsh*KmXEIW|_M9hDm%Gdrv97&s&LCvUqb)02CoZ4W(b4X%EB2q(#G5YM z&@wJkH_qwtRocyZt7Y4`(pa=cD4!kEPl#4{yum=*q|U{&O2DV&=)yXRws%3})r>`7 zty6tM=kuW2FpR*(!{^GYty*Jp1woSmG%(Qs4H^#!;!Q>OdkH@{*K(vzM1v#qO$_R{ z7+Jto9d&*4xTs#V1lt-9mM`tTxU{8|32n(X!6M-UNsS#R?m__F|Gn3X9 z&{djT%C$c`e{S8Bi4#KMy0LTS?(Vvq%{y6Caq7xk-@t{Re0DV4heM^6gkrEpL-{{% z)|>$4EU3Gq;JmPH{E@zsRX+#@>gc;qk2i2FwVHuCI??#%xdiMweM zWaT78*EG!|+OV634wd0UaR@TenRhksaP%AUUdHC0VcZ2nT> z|Lq#TX5O&2h!GYviFiX{IRHYEViDCLf^Wf)se&K4oOU>MQK$_!7!L(|E5Bx`dn|^Z z8D!P9pUu^~tYLFpB<~24WRqgt9Jadj5ce6JRV}}8O%6hRA!!0JH5LHs91WhgWWLJ- z!KL(|#^$p^amdJ5g8rZ$Ggy6?%`B;J_Kppf<0XMKcmmW9@>-TJn~gIShXI5aI(xEx zlSd-_6cOeEGR2J$MBqWpK*2%7D7_wEFG0(EP;?Sr1EpZsk|pld3%9nq47KjwNtga; z^X`AUY0HzBudMExSE>hYgVxdT>O;3bbp6&zv#t6lVjtU=7OitgFDbdK>r_jozEYb*t7qdj?MRk%pu)4==CR^bNgHOU-j*emraW7T2WR%b?1^<K?p<`lIUQwM$W=cui|bx}?bTOb6E1v3`QcM^BdcQe z=PpkFc*njs2H)6MH*NX+$l&D3bkD1=@_CF6^b#6m7%YZwDoKJobt%*>6l7EZ=V>@G zzzY{zEr!q?#B%Vk9VD%4E~MxbJ)hcn+q^0Z=@qNy9XNJiUX{8Ns(OzNq-fqrsbhbE ziWT!T7SLhKQavnveOJ`2^uK@O;eGSx?>nsSlq%#_#sdo9iphZ#Jwo|{FhMbfSrS>R zQiwFss8KQy?9j`|&<*8j64q^OVgV#e63^ksE_l^9($wb9f`EyHv4&?kqn<@TAOMm< ze1YGL4dcENbcWZd&n7h~Atmwe(#RoslRpeyDguGF}j}$MRo9?SM8!=4Q2wU($EzceOopeaHDv$UhoQfY3;W=e^g5xM87H z;I{8*GeL)G;HH8ITBt8$#)NOPnG>ql&Qh*h zWt>ty34rm;*F33uigBg#?eg{u7R{5>Q`U$R2j3@_Lkx_M{bOC#*zx1XR_*c*B-IGq(GV|B@o{8hJ3p1*lD@AJn%&$i*n1|9(=hKoMs|KsjeFu0HwhG-gj z6NR02xQ2KllvU2l&Q+ddYuKj6LihSj-&!x-tUR@F>EtCIlkybUel`o1t{IyqKm3Y# z^I%x~1FN64cI~X$=bbnBPUd;Rxn=jXhSG-2Z`jT3lX2q?hsL#({W072*)OlJJQjT){R0dcw$MIV@Im_3E)riYBiU=q`Y_6ca&e9uVeb_jW)Y(*6X`BKYM85 z!b8t)Ui*XT*XL>UuiVO9x8B8yUlNM}WBcAqm)&yESfoE>5R7X!w(jnYSbl8TpaivJ~v3;LD^f$vOykiS%0kDp1GRq zVCg_iC;5ATIf&(~gt_DK_8Vo2`%JbUh z9jfe_*S6Eje-d8cyItyiX=UK|B_;1L?UVG9n?6x~K;xR|0vZ5x!At8OJYq-&B}jT5 z#x}{P70vb-p^szS5EvI&o&q#3;_jrm%4X&6S8u*@Sv#ZVm@V<@Hf3s4l;7vm>@w-r|)yZS%w?(I1*QeIrsG=I+5nepzsGxrc~ z!pSc|SCA)uB~*o*q}1leH+COyX<6)cl^Ly@AOH2^A6)<8mq0BH{PW9E7WVFW74(6f z)`kEd2^SPxr15s^#3*QkxXWqEyk{wqj1GtNbEQ|(J1tK6 zUnIYs&2$CihuMv=&x^lu`v>+G339PrtlYp%HorK*>MU~Tjmr477+hGhviLYl@>d-K zU!uTPY~kv}%w^h&xW}uU?TFq&;?(Rl#6glkWN>Gw4B#URl`pWSWHsaPj-^{T?+Rl%;){@`StD{A2dwJ|V96v& z$16bph~Zles|b2KXKVo$Gy2J6qqP8xDY~bRh4}rn$()b-mt@e#Fwd)MdNQq8Y*-I^ zKqOSY68uyOQhX&e!epDI){mhNNM=IwXQLY2+&brLfPWf!2x1u(hS5ey?BxMlyyvL* z=no!g*pcWU2>q^rYg;4Lqki3-zG)X;d+6E=r*#^~7*m$_EGg_eQ=4jA+oZ8YMYWd6 zb?&a!UGBQcmfE7Cu~J)W?WPsCJoTfeZdoCs5nPtKdb}+(w{hma1+}#c_RZX|z*J-U z`YpG79lHe^?%Xkc?nU**&Cy^m+F0WA*VWfFHrCYF`F$mgbgj9#{-U|#cig$|;T=<^ z?0A^d|2~dA8{jc0T&>LodGPkA2Ce<%xn1wIlX?a%!@Eq4Md6Y$Pjh8C)#tL9&B{-Z zDl*AaMfM==qY6ZMs*j2-_o&#DtOvEgKO^o#a!G8V!FLJa99SgR=R+3-1WD>6kPt4T zQEnn&KOhDe*4&&kDJBfJWl@4anq%Se(e27Iv}pbO#r>3wvWJpUt}zNZYx9klkhS?P zCbrI418eh@4+uTT5z<4YR!}Wu!0bb{)|g-CHs~wgPLx_;gZ}Pe*r4aOmyr#+pp0lb zHFY6iYKHu9A$fn1?OWE+XV41w8uJSK1!e3*OLwh>v1U`ou!Z{BA27G z@n6d|J;N3qwe4uQiV3KTDcpf57p!m?0p3so1Ax@X#2IiaA}2>9&SUXL^1&>Xh8#Oo zQ?C?L-8M|oiJLpU6Q{%GGh;&0K{owhQSY%3!h1qcSn>U|R_L;f`cCNUO-efJ#sSbh zkg5Hb9y)Ys=YeAvt+X|EzTjRz37BGClh(UmXfNBmxvV{Ttan9870vRhk`;uSF?`m! zyWBXXtg*^vTY1s31F*aP^xb!Xf`+yrz9*G!3+V51{2PK^bPhMbp(nxq$mtS*2*~V% z(N&JbY2FYBI?V#24?IeNyZFFOpZ~&zB|@M?sbh`bnlV9zkG}tHdLK zx+5aQXm)byO7#8XHFtDn$5~LO*5aqH%?m z$2wT6nTmGDI)?$JimeWHNO7Kra|S#r4ugug1UgoGf)+&L03keV@p1OHE$p^lBA zt*GJGLDNniq=XZ4I+Mb*82pqbfoQ@+p_JGdB0aQaeTB!Lr#Z$97FjWL@MMe@Z^D+s z&IK)jih;Wbb%1MocDc@#$)|IKVWN*g2&aNVGFMmdoaL`cE`T^;1?Tcf@^i>q-czu= zA7p!sX62V=__ATa&S(g9I0rd{)J6Sdr^qB}JA4(U(1Y-`7)a4D)MA`g7I!Mwm6+KC z^C_nUK7sX}(ukntS*u>(uyyY=UeDi#4Mlus`)o8@(xaLmYhKp;LGw3oP&Rni)G|cQ z7Ur#P!U!VO1g(pNoJAP;`R9fA(}??`-wW?AJpaG_{Fi;Nu)eT^;QuU%IRlFc*+_>_ zx`&U5+e^|ih7FuRhmOU(m+aK71UlNUGH`jW!KA(Xf;sb)=69M;|L@O||H&xL zl74Wt!{fDxvzf&5M8E`Lo>IUfK@P&dqXA1j9Ysfw#32a=jPn2f=>Dps?=)zh0y=nF zlN*J67GXr@2Az6He%|WXWJyrTG^F6<|JoS+k`Xm{tCR{6!43_i__z|&s!LT*4`;a3 zwB^UO!_$ZGtWdT77?_S^7Dqv~y|xiDP)-YnK8%pxr7p+Lxp?4~wPvULd zUmZLLn47GQg>WUt!yAzB$G%F{zYS~B=am%aex&q3x^I|U4B;Xp?}AZk z^YIrlk>Jo6{xrIjl;V~Ot%d0#DhpmMHo+{Xi^Rz)*c5L{kRh`PE-|>;1QQ0h^lDfo zd@>|=U5Y91Dt-M)<#*Gl`Fr}3$-Z}Nfx!+IeZ!v7G% ztcDQl>kp+vdVk8V$G)HSg>V(Daj1A4`JRB+&HA5cq3-~n7Y2oBATKb2YG`uA6X8S{ zY?6>Vt(nsVyAxRF6YnNNtUn~CLrIFaIITfuxMVt=e)j}2Or%oj&|p93A5+|pOZ*pd z#pmb`Sv&G65piAWD5e2SoNSIcgY-cWl#06J$28$_X(YT)8umd{pHg7Zo=kQW0->a_ z7yr))>upwE8ZMWr(itk!ke5-mNGO~-u?owjq}8&~H}EaBRQUYJk_kzaMJ-j~1H#0S z1rxw$&lCSsY5*5Eh9p`{{~@y^&(mjM(r6cji;VSvEmZ0dZ}u7v>WxNaH@lu48ujuc z{04p_HtH?AmEG!dXI$pv!-8`CYpz_XJ(2siAQuczyy!!@pi$wT{)yp>!Xhe@`nl`z z1^zAe8p<`=WnrFL1*!@PPZ=huBJ={PS>a{s$9bBsNe$AX5$!cHKZH|luaOs}hA*pi zw$Rj=>@_5!LqS+x4X9Y`l2I@7_L`@81m(I&E!VL96$Z9khIpPCg?Db=MU?BT)g7f3 z1oR}eOn#rEov2`=TqatC@g-cu`;n}|1~nUG-Vnn;qJfhg6hp5T(E`dSLj-kY;GX6Q zi-z9$l?TDudYiv<9p*t?+4_WO=CNA5llp|}o}F1=q4CAqvoxnl z-+26xjr)Osgn&kH{tC8-tSujYAX&ByDk<0rhH0A)eE8>_MbIX>Z9mf=3Xu{d5DSGe z{bXd;!bUBGMEs02AatuZk6h5A3ny8K=vdpjVylr_0=J@48tARLevxvQQ6xQRF2uMT zDdlo6=qryT!$n?JVgWh91v4nu1G=%?-N5?j)BLSd2l{{#%0EAV&&xf1Dr{4qxZQ5= zL(D1c=mH9)qTh-=!wPQK;G!Plb9%5!QL&)AKmk+G}epRD9NQD(&9O0C6ZElh(DA_jLN=MkxobFd(kGnzu)+M~#d1*vxjpI7N&Q;y&0Q(nt9Ov@ z0UAx~93%#q(<@Bk9CzjhzLPRMRY32Y!M4>0SFb)OeWL#Q0u->@`-CeGuA;1us}BAQ zc@mIQK>2shoeQcVJ#!PiaLyd@Kj_ibnQy2+9_9fE%1-skgH%88v00xH6V6~l&y7;< z3z*+Y;rwAP`&tJ>jA`DJcZ`7&@iupQ%b%(G56`bmS<#9BG;0CU_T(luy zt=;C3Nlc<}xz{ z@bcSeLnyAw`PUGAL>*F~12pf(YnG!XZdkkO7$`Hc?ByN%$Z$rECfLDLP%2`Mw2Lkn z%iuczcuO)T(Vwa}C$&16nxS+qnzVRQ5p9I84;?;p=#nva%=pfXYl&x;$;i_ zP|dt~6wqbsm-{)G2ROAL$rK4<&wrWS4F}$7>VLjZ~K@NB#Cl zO&Qzj{Xrj9Q?1IwthH&{H`*sEN1LX>TEL$T9bDBnzAi-V%H>rqOSs{8i9DPnOQEm? zKnSNAa;HMY+M##OP3;`0pT=G%gsg(SQ~>24N?A+(Cl^G2rTi+Y_Xmo`>Wi*@@Y*8% zxO%^0U>2&c=s7QU*VIcq8^q`sm^J3$P#9i9SGJWj|-YQ|Bbro{q^IrwHjL#@aw6r zO5(p)w}zsz_FT2}`msf*s$lq^*3AS90U;2;%8zQ$AmjS~uU@58ERcbWhv?f>K#BeL zYN8qi*%SY*!e{wB?9^3;*7vWVA<6l3`r<8_4JXqkECB$U^#wWOuf$1XFNlXZ{n58dU(CAELUC!&Oi-&kb(YyL&bkw zFG94K{HSTIT!grnt(x7Mt9azgH#FZz%{*?b|DaQ#z(AfKI!4Z}p<~>Ge#1Se1*{80 z*9-3X((C!(%0GrhVCY#e9J%8rDwB&WM#Ib#hh$(WdygIeQucm3{$#|=Kl+eJTk1Z-(L@12&%MZxw-kLv=48+WES(PWIT1Ks z0C<=YX2Yy?Fc%$1$a>sE6N@S(ydbyNTznjed+MRp# zqQd(Tx2JkitUck{ZkFv%h>+T$y361us*p`!x@ITML#@u!?BZJ-!@DqEXFzk1cNoI{ zJl=+S{D?*ZKK1{XW)YK5yzt`pzw`QU#6SP_sM{sCSn6GMftpB-*B5YYd}6E1T{V8s zBM)6)8@_GeJO87$68vfVhG%-%V?Wnl^6Z65%hMOv_5&oUSnJohv?fUse?PIwpgrjj zbkDBTKUc**{+~4@My+3;_M*cli^%=z;`psm^74d} zCj*Zab%E6QT+owC_c5m2HMR6aD{F5vvrm4M^bRUw2oc1;q9jPZaA_vxsFaP~U?%O27@cleW3dOF$d>Vq0Zl}ZBVHjH ztf_?4md<5`q8EHId=*llqXPIzIAX%~1B?b5_S~HV>kar}&i$g+Smv7ZlTat1QzXxJ z$_Fac3X5RMSd@80O63eVgMA|`7viFSV3ZmRpY_8pOoLm0i@%=q@I7J=7Vq5YX9ffA z{>R`WG+DU(#C;6O|HMaLg9l zl)V7Zh_060KjCS9biA=f=azMILnJ&h}h zly@(WRadr83lyzrB*7h*#Kz%c#TEcwRZLH44Gb)Vv~oEAv$QE>6AfHr(F(C#@+ zLJlGHE;Y1|WL2(ysP_V;dWc_?Nl(dVTAaYOpjag5{{*~1y#T?AsgabJdOGqoA-oeB zE0oxN_!V3X&c0eE1?A93*;A)ACcg=udm8GzJ~h))e_kxCET|AT%Htl--e2VXnV<@TsN3YA17M0e6&-Kk=YQOE2LMDBtsJQIke# z@?QDP5g#LZ(1S@bh&gBDacz8F` zRpD-jIg8-ap`Ym@6rNlM3=JFCvr)2b9N_9ODp{J#8`v;h=Es?IOxlxNiKM<#Q9_2M;_jSYUH}t zqe$Y&x^->4;JRt+*3Xu{ylQW~6s%=u)@ z9}!qmL7OlT#T4rTQru(OPi>~6!BlKwMiZNC$FYcG5yvTlmyw#v=M)cWYQ~gfFJVt> zq~`S7oR)6J2?icV&xW6Z&I8CNu=}8Y!-3V5*oU(pJV!{pyvacr8HA5P0nDoEQ%(JY zi_HlS4K2djpeQwr8f|LDf-$pdJEIqbnAcQ(`R2Mwiz8zq+ZHaqq%>Mu7wuYe%n&tL zfGjDLMa5%lx}tTse#w%qZMbXkq~r%<8NgEgk(yfXgz;U~-7DFX3+bnQ@#AqBY=^OF zLbS7X)|dq=R(4l+ji2DHt%>*r30Rp-(iA+JEy;u?keU%+qc(@`QA$BS9Orf!N}fVd zAL_Iua?ljh5MAJ^c}*yLOiMzDF9{(p(30MIi+m$<`Ua+XOL>c2D0t=$9GupiRQ`FA z{BOl%>K)}7|3O^Dzk_}@em{Rc@>6mR)GzU+fJP3!_lP56}Ebt+|2<0=uUVxPy z3)N6@44izF$8~7*yh5H)fjBg#!VE4emB7mt}4}d2r)5g#{ZnU8q)|NhnorPaQnz>S+LontCn2s+La0 zh$jQ|3fkihRKrX7xJMtz8qh?orW`edrfqDgrtxfxOwvIr^UxInxzk2wXb_tKnHl(z^v|lS3R^;C5-qU z@k^Q^e256y0(|hy8uo+8d0&n6hRC-))pyDz3Z=lgVFfaOs{79aG081CD(x1Z!z{a6rfg{`f{nt;>Z~S~76JTgmet|iqonNy9qSRCrj5SG zE*k8okuHXMA1b|YZ0qc>KB6<%`;DPFQ>HnqYN&4EGLuv20mv@Zt>Scu^WHjG$A{{M zn0_!1B4y#@2tE)shK{KGiRKDSUb&Ams?2};;|q5pJXA^P3}#c(A}>+?UHMSdS`A5u zx!-7KdwaT0vc*icx+RrkWvS1Vqu=l9QLeTd`z1pXyttbcEn$YF%gs^<``o$khc~%U z9?(+A$FHjL21BG2Kpc=@FYF5APed6YZ)jh=UwQm-OL4H}p<%olMV739mlk7y|VeJq6h({N-N`F)AkKU*9A zZncuEumPCb0)>TTg$*!DALN=JPBdym6qG@%J)>S~Clne0KH`mlb{f%P!tPP}AjxA# z93;`Q1V$D?)kIu!LsQfhjw9EQ9F=y_B1`piC?(juo)nIC0- zDn9&Z<}dFxHQlKEWj$Lbgq~n;oLYO|eW)MPm|++FFVI|Qe8Ff4uCPwVdtGoTV=nn! z9Mg!5}_H(v@l9y2_n5lmXZ?=E&S(lJU6Imo&ZWZIn@mAKqMS=Au89C=0ru@=+;YS z)498q9ZI9JWB0j$+}686F?+mvy={HRr$^I7WzrL;!!dIDMD^t8ryc8UdcBwRSe?@Q zeCZwRQ~JDm!Eo-)4?J-5xd4^sKe}D^^(*(gg=;zY{*Cfo)5#lh`mXYC@C%ts-TPOr zx4Ya5jAH>O zc|Naas2cQjC5qX ztN*_ zp0iX-C5(oALou489mBshd<ac}LWi(CgsaDL(eO*GXYH2uLp{vr@SV&-2TX_wJ$c zu;DVWH;0OocbL`LWcxFSsKaT)I-4jmq{X-c2t|aJQkL}QXiTVMz=F`J*S(Tc{UO0! zi%CAn@koN|GR(ehQJ(p;)$Op{@wSOMEh&o|_Qx>8!DwP- z`FJ}oaQjgCpV#o@Nx!OH&py^S(Mo<6#&dsVsr*A}PIAih}WFPR&w zCRp$^BQjucQVv0ZvdTb~5Y%*mLkorYIJsDrg^}#t?y#MKoS(VfIorvSE~hJ+Nkv_H z1NyT0bd&Z4`Byk{k++vY9$qbIp;T4E&6tF`tlp*!>j)C5KxYI&p)K>A@*LYD^nxH$ z?vczftYFCQBHl2#E4np$pk;es%l>Foya6Zs>Eu9EYEz!e5Y{R^h4l>CRPYp*(qm5H z=D~}jc&KkX?%Ns_4@L11PWDH)q8*0URaN#UIU9C%a`k~+cScW=kFDx3OHQ<-c(1A| zhLPT?d~EY|Lya>!Q^W8jeqE%Xq@>T#)`R;Q;n0=BC`ofPQDBM+{rFksZ55a(iGAa) zU*eU+_dJAYMzc*kC0`CJJP^FOO9?7Xpo<{uSO7rZNrA__;wfikngXyqdcC>NU}wp6 zrPBc|2Xff6WKjHOlr*OB8%+b_HySNtDX$lf;WU+r55_k%G}>I?y}14c>;mc66GV=~ zB>p6tL*)LIuB-?uX}lCp$PRoG3NBNh#Q-2Qmv!*o*&zk*WvQ}QR7jc9RyUZv;eI1q z1myA@D>js9##>)#Y7`z3u*P$CtoC0yo8w|Q6F271w2yF)%8KD0_2xTV;x+lRX_)S7 zLESy7mmECL$tj(~EAaM1nhN5QP)RT+`Em;B3)pSP8(VtVYgUKyj>BSg0P|KE5JF0S zre930DlR@=+*Q0v=*uq{`_A#ko)-3hEcA%gLXTvULWp5*D*ZywDm-z#xOi1heo6D& zsfhffDTW$dtI)HAE!7yiAVDOsdl1 z^kJ2l>S9UXuCtekeIpWyAb)r;s3gmj-+uKnaX)3%EDkWLFD+A&-j7eww|&#xTfkW^^2cYa9_rm4Q zin3x4(yLf3=0BYT{IwK{%rJaGAcrfB}x_x6~ z?NgR#`|L{eSv%T*Hvmwtyp-4g+;<#Yu-bvpE@#a&$atCK%V}j(r9`g}0;71P)B2$A z^>07GDy&Am=Vx|<@=_YGAKMS!>s6Le->|zU{Oc`LG~#QV)<2JRJPc{DYNOS8_y_LC zl{@TCrW62$lakMd)^-st?P%lI2t z)Hp`>W4-6c4x>S@{PH(^%>AB~t9w+1&30NhSzJq;*3A}|Fx76iJC$XzW&Y(3cE8JR zb!47(SvFgpOI(&s!0&j{;v!y#gh|u^kVZJ9B^rTLKq!cWhf6jz7>B3{VIyUy6St8` zt}7v#!kob_%sj7rhkZ`%r086h2XZFre!9|+So+}e;-=^KDM@y(a^Sx%DRgARg`+6@ zF2u-VGLQ-ZWzz#K(++!YiRJ=~3|GVj`!3)x5$zUkh)3uGfML}Os*EV|5hF(UJ{A{; zN;^ys#azEYS4VvUT}QTW$g@cuN;(_~!om}CfZ=y>M0q>J?!6&0ot>C}-$GouFs%Hh zTmXOk#{D|~3BT@JuRegi$szQ;LUnyKd=u@?UxB<`_Ui-kIc(E;I{yK`ZY?|iTsd&P z-Ds3oUP!mxQvQ9=j3s~$dYyr~$?Q9b+{-|eMivJd_6zn%Diy*g%^dgph0WMnjlyQm zYvbd%&X(IOX1{WrZT72MGXRGk%-(<@szG$F^a0wjK{JzM4tXi@39NXYNK<*-69LR< zHA_JJax@?fIF6fq^$B30HaB2{+{uk~5)kSg_1^k+EuCO#z)8DSy4iVj*ToiH!~Bac z@4lm}>JH~j*Yjl;)*~sL(K7eK*OTEpx-0KkaM|Wbua?%#Xj@*tK(C(|>l{C&ZhWb0 zMo~pu{jBOKI=QucYE5gb!YQVnoLhYCh8f$YkM&BY2iPFc51wjZM;I&Xyq~eb&xB70 zb!DyRW$vzMsVFjQ1?9U8snP5KICcCp+z|F5YaW9djR7^>S60XQbPOU4qinn+8ToxO zNmqH=nTD{Wfv@awt2Of=f=NR|5D_7WgKt``%4VxKRM|4nPih20e86-edqM8Km6$g( zF)F>V8F&FIKjPI0*Fu5JJohBIjc8gc^_8vam+bbN) z^b&a)S?@-wcXYVkV5Z!+PTi!3PaWYx6x{?3=UUM zy8MhLFoOTujq!`V*3tMSxoiS#=D?7Pp0%n(Q89qC3)`8F5QUBrh37*5=v^&^@-+(> z0htu_oq#P)lq8+7G(S15;V0Pkj8^Mm@ObujJiy12bM!;%^Wpm2hU;Hg%d@u!H?ron zhpV7{3eP3fX1D@MX!O<)`U>hiqBVv!FrlFe?i{Tt*v_Hf&)NWd%*!uj=XwWu1V=%m zC=E2Y%d?O9C>(f5K@*3!6y2GKU?CtUfo5X3XhJ~Qjcg?3QbPGiIU@?a)bx-J>E7bj!{QCXu3mQVoR({~yqt$+}u$pqisO>>~0Lk}B@ByTU1@@rY z>u~r$XBHw_V;CUK2l9wfE-|f+u$d`;80<3WWT;92N!SjR2{H~6qAwgjz)%Q~BE5t{ z5sXHIfmk23I8e_Z=spyPNqq^MSm$uq;)aRIt1IR@rrxz|-rh(cR#D{NJiasR3>XYL zQ?c6>sGBu5Y=Z}>%ZU`B67$U8nWmTEokDOZfCCqnPOb^fozyaELUjAIxk6bm033#B zK)9kPDhNB1%fimKXjQzX&F%7()mOHa`eSoz%C&yCm5&2z3k}+W{3v)^aQ~O=ST2;{ zqh1e}hLNfmPB0wKxK4n)$lD{=B-9?QB4!5iAyd1#&(;uI5^TqO<*$<7Dnfn947Tvt zS#<%IyV#^N7y{04=lIS3qKa4`vUlFHyQVtkR$QH&Xo%Y!jyh4ywM6DmD$Evdk4Gmh zpTE=U_G_b+^J4zew#xc4kIUUw6R(Q4Im646I|U(HBwPXSFjgH1mI-sGZI4bs!_5s5 z3VlxJW8l7`)tX5d8S9bLfPC=@;-9uH}`2fVh;~5}+A$u3Um=pMOMiBA#5(f+jB~MSC zn)!Lx?D_0_9r0+`pq+|DG;S}OtTT^^ggZJy6=Tf00YNken;J_z?vjl`&(-CAEmN*Y zCIyenIJNpZr0o0Xx|%6Qw;Ryo*9)=h0Xy!_Sk9T#&@^8c(nn0QS=duDz9H!G1RKVe zc%JC!;BeL*S`*&RKFe1V{`u~DM2I|G-q7&DbY%s5VEO^&mde^;UG{pRiU8kB^nWzuB+3UUR4BQ7)%rO`tFm8O&c}Ju*E2W7p9T9;I7yo!5lX z(M02^IocHA0|sI3XLKxj9>WcSSUt~xtJ8+~5J5C2jfxN-A*?|}r&Io+23KzE5u-v> z$p^6hGe@ZSLfq%|`r@qnoO1>zZdIP&vYv%jtSCiNV75YUt{d0P9x(tvw|d2j+HuYB z@9tg+vR3!~V7#LD=YyVw>~Aj&yNQK8!ugN z9UCp~oxz?gj&*j#ii=|%ov~uJU}aN%okhQriOygttN7OrFRS%-*41?$TfI8-OZKsH zO_fIsv2DtwH7}(~ORJa!MK2%;=)9#Q0e- z_BW5)m|^T*v&rE5TV+7}mC2O(gmsyWM(^LM{K_LvffdF7!z*rZDzod#Dcu7mwar$` z*4sUU=djGz-40u=a6w4CiClcL>lMlWR2F#kgGfL)E^!$C{h|!XpPfWluYi?|c7qNc3!frpzTKbdDdEx|9tNx80$qoyY*K46?85f0sW& z!7aa2ZZbRGWXiX!R!fDr&>YFc1tlDTfX&`!!oS+D8#!ILKE()Z+kfC_7D`;pT=h~J zBhY)eOM-}%pyjLp^|L}=3dbtO3hGJ%;x`FW2IZS?*ETc@zhv(z#m_v*Cd`@z?SI%G zDz$1|ag-7Xu5}ewtF<)b4}(GsDA&ELygY7vMMZRq|I9nAAvVB{pUSXJ24sg9wMM(o zrY%~PNZvB0^154YNvyzv?6VoQqUfS5)sk!s6`k=rvd$y_Iq}U&@DFME5PHT1kJKP} zEE^;b^Tc&c&>7%g!ecN)VEqyZlqJhD3)xb|seD(iW8I2Rd5A4z ze^$P$IK@fI%gP_wWaYhW%I|O^7V&L8tQdZqg7Tj9rt(MS6=qfbuKb7c6ILP~P=2EP zosEO=Vggafln`{`kuTQ?GZ?HQo+QOOT z9l{$Ong7}-Y~1)3dncttGLMU)9@dYzj8x6t-@Ho*98n&*MR;;==JZ~1Z|3qI;fhoD zo;ZPVIc$SdeJ>VhHsNXxx8JS}#q7!uNUUwQid_t{L=-8{Fsd9E_Udc(|1mz31cb(?I^6JaRZ zOzye$B}*=ydBfR%5-yO9@4d2IXr z(+>fwmj~Z*h2;hVYeof&)GC0`+b19}sRuI!+(055HHC{*^C?{$8X}1Po$Hc}qp<{*!Dk8*^uyoeAHZJU8U%?shoMt&Xib zYl<(OwlbyH9~UkQMhyC~<8{XJKyk#ND=F6NBZJPshK^b8abrb?-d)}l>3Pm>xa~G= zd5ie;1B$=2vDk4S7Tj(w853+Y)IY!XJ2L~drKL7goinzKq9^I6`gfQW4iB zl2x2%Fos>-71gXdzIe8N`N3XMNYqZh`AK(2yynh_YGNH8OI>;CFJ22*)VG*q+r7%> z`^<8{Humn%zh7QzyVl^S-u|WnM2=W>gQWLXXqjH?v~2l46QA&xl}Y1RW&YR{?x?Qw zy0NsUFij`?*r{2|!NL28 zsjd^jAOi;(BavJnJkV5@q6Njrx_pnV*!;-$`QZm=?(7`rmYGiaFE&qk+!E>-H~;02 zBJE6QS+!@+L?QH>z_N2MTvjXVl;wk&Q>BefNa&bv=T|ex#<8>^A^`R?a_9izLs%{U zRyz#ZBUff=dwWf5MPreXAx*?dJ(G)?HgsNDz3k3))2?Or<+tCQr@YKpImX9s`YD@k ztXaBwY0)>8)e|o6og%Pt(%Ag!lmACj$e`|sn$To(P86!}giq}j+a3JN9kL(9`Y z{Ef9%UIYG44HLEL>^n)PM^>{TZ54Di;NP@qDndc2gsadLfSJs%0vZVKL>I%adq*nDoUyd%E&iq!a(OQ%d)xUk{) z(OY-yczEWP&E>UgH_q6-y0LLVWXd7s-ICJD&CSscan9_=7?KCFDf{<77Yc>TaU%cy zy(5Q9OUuirR3tkZR`1yN3+b{+bLLELcAB(Dw{0CG+Tm`l`qF8*ueg}y4qyR}!j*y$ z0Mxzk?aWg8)20S@k!zRW%qtMWj59&|43(l zRJX}G;SP2*@$+4~exA6>qSKlWR#hD|Yju{)(cDwjt*ux`iSPOxO`=Czlrud(#EbK_y0L1SShwjawriLP+%D;20XRBpcdlLLkoHhta{ z^Z{xF;tp98FCrCAgdqm6q(YM3jowOiLFwCZj(R6>PGxJRo2b$0UM!pZ&2S<>8&R`n zUrgV^M@nVkc9Q|AcjZ-*&4_qD$p(`w8qDrlhMGW8GnNH=QI#WB9u9gff}qu! zbQZCAL9^FW=p|LAIrKz`K!ZhG)m9I;zuz}q$8H2&*a%a$KunOLo)9!W|Th6I$ zoiwXyoGBg(hea#1+5+~Vw1K&p){Ik|XtHRPZl(uZm)?Z-H6oK4I$TihaQbaUL3@d@ zTvsiRyTI+9eBZ^Df>e81UA(Ofz7Xx*r4?S!lybd@%#`(wOq^QeLacmJF0J$!MEwC9 z1W4TksMIEu*=ouJ(PUsHE^jHTs*r3}vyWK=vfgKd1B`>24GzQqOWS*Z$5EYa!+WM| z@4c_KuXm)KB}*=Hmz!{J;EH=$7dkdzzy@rv=rM+bVv4~K1p*-uz`UjeUW!S8 z03o3UjIAAi_nDP!;gG<4{nzg@J9DO=Iprz$b3a-so`jY9I1>j66mTJ=@l)$fIt8a- zfa8&};F79ws#SG91uJvZ7d3mNzp6COmD?@8dbisIw|K)Gbrxs4M4>B)vAXKw0(-Mu zFK2j#tW2*P9+68698FNSO)Il33nn{_;Vc!KV{kIS-w>VoX*u#mvr4!&8GV8y#^Wl3 zoNyfBTrAIg#z^Iij%YMePQ$|jqGkzq@_DtxX0-zLY~)PsF1^gC@L183@s-?J4nk@) zXxVCm$~IA@FA9egYEEek1ls&&p4I4bq;|DcrEAt26jFy=nx$o>d1Vbz!&7DL0fk*} z_0V+QbIY5}SCuV&u6up1g?L;!`r&}3Di6xhT1ghHCIw(Tse_keCZxa!8>CMEC@gPmB+B{eEN#oA z1IAc_fg+2Kz<3QQEg&oBsg)HQoGB8eXNjW;IHZ6pDjz~C$4PQ#GK{|bx=oh`b&q|v zz1ET?{889VCXFt+_VV?SFlU^%X2a!uS)_n{=YRe%F?-2%{a;~HXGR@9(J^Ypfr8_`djf#7FG;gj{on>7Lh|!^&$cLg14JiQ18@Y;(tRcsrUG z3+;eso*#O7N`aS=bwnIyon$&@w6X#g2swm6!^;6&2#s}x&kI=yAv+`PiDpH|v|Rwd z7_Chj>zYZtg~AX`Lo5c=K`Me|#9587gAgM8 zsU=O3_6aq+x~*BG8%oC%=ahI#O20kOcJY!%vgm{TTjzJST_v1)a*2NQzy{&z26?Mw zYz=Djv%|PD17Ve!3((nH1d+{kg36>_HLwOjNdpL5V*u z=6|HfKUmY*pv6QRmWYl&qh+8mnc_e+Q7Mrs2td3+mLH7y0U=4O)brQ;?-hu4YAon2 zXoRmw@qPYZJ*BY<5Wu$0BdK|9;HDCKwmrUW+v5bdkX$l;yD&#*1abG51&xgbAU1Ux zb!6{$;b3k>%ws31MT>-#o$a9~Y|A_=ctwsQ&Yq%!2ZUWXT|}Yx++VnbQD=kChukQm zE0T><5$KBlSO>8v$U24N;?uB6nt}y+0ebqEicfM>D5AgY)k3dW-V1sV^3vJoNQr&a zBJpEfLz9H)gYk>jT>&+=S#6;qV-(Ai>2UrO#wOI-Lp9YQd+mhm0yu=YN#_hOpOLq$ z?L9sxnRNOI zjpoF3Dd1?Nq=(lT)F)18^w>*EGJDnP%wFMT?A2>doKTD3JjFkScnu?3s3c6sH9D+G z#SsvhI>TaCS~25#c}SF$Da8i`4r2pcKmRPRctm*N(ELB1MmX8lt1(|jrVAGx-$zr- zu6ULhZ_G0o{S&6_I(gly3$lG$*{67$@<;matPy_w=2j3Nu7BpmZ`Qp`-1}}Mwm)r@ zGTGU_k*}<{?&PjgqfZ+{pU&8%Gd}HH`ZdI%3S+VV-*Eir`nb8|5H<~F?$92LJtrl! zJ4>--?h<1JiKIVCi$pIhx$7(s2YNCi$vWLD?SXxuk)pxS>T{t0Bc@1f1{fD%mj=B; z;XosWnIF(9N?{074C0VzbMT{43=jkn=!aQWX%Cn@nvTK|UT%DjHzyls7Ntt(v{h?$ zkDA?f&?g&Ss5(v`==gmmFs|OmcH9TPRnvXPokB}G^#oBq!5}5`!PT!K7QtkCme*%z zAwPG2$`y@jw66f98#n)Tc`w2!NhEV(<}$+DjO3yxop;e=xQ%bQsx2+kN)znAayW6$Ci4qlA^oC@uqVxC@94?~JFB#t zbTC$N#^8$9-OHxg9m?S1`8#T)ET_vMMzxja^>TBWPVXttjkz_9)TmJM3<5VCH5#Md z8h^YiZgy#93B@mf%WUiBbrG+F z4;Z|sM-ba&`ZK+bYeOii|R4-PiVHNXH+FB6*2!InG{fP0yA<503J#ROk-<} z*re(pQVIiHP7%pk8i5N!42ldDFHjEc5*Nj#@f}fyYvLvaXu%m3ow*%!j)9RDtFd{^ zN;wiMdSnK#*86b&UzRKyQ&{-w!X-1HBlZfXcfBwCuU64Z$gcNcD~PmT{W~Eod@OwX z`qnE_2gv01hI~${)k&pSyit&!&+uBMx^ims%5e^pJlBQ?Gf%3w=Wx8!UPH!DER8Bk z%AIm|sIKnbiS8n`&%OTZ{y>XP>+}bPWx4ihTs+9vd|F;LeQr-EaCpYFsV>jMH9gn0 zXl?)4mHFA(eATx3bxo@uUA%&DsRI|cC$G_}(F&OA+WHk5ElBf>RSTFI)7Mwv?s$g! z9u4kp&*n9wdeSRgPGgCy>rnHsxKZk>D3m%u!f{r%SPlz`iRO!^Gz3wo@Q~UKASs|p znM26XjDgaCXie_?gU|l{;N{N*g3kzh(|>vxFm*2e@SoBTkC-2kxccf7e68T> z7tWjYCb2(3hP{!_5k7fy7TMoVKJvaHpnJl8NM(n0kkb%NNVF^!RizS`MlkbYEY>ox zo`BJov6a(xp04vSIK>Ni=>41)8V-i1I?O*>+L5Jnm0y=NY5M$G(?`|l4ai} zb05i_8yY@+(##2C{mY-fWO=68P?#bXkXFdHkh)j>+6ek`gLtm^RV`%%XTz7+D3Oz z8rxE?({WRsGFyGT%E#D7Ztkk}8qs~&YcG}AstY1av4oRYfPwxyTz3>nZWiOKLHqq)>>1s5FqT!cnZjT$io>v){#=BbB;qt1GGS*1GmWAB z&%t19AH`Ow2g1hGk^bj?K|B~zMNog{pv-Ih4;cdn{JA;*EpNa;bUhgw+xPG312QtX zbQ)xGi=-T*fK3#~AfXu(mi224wJiu1$y#_nBhY* z?N1NAx0fjPJxp@yww1qs5r~VnzUy3`LjI(8{dQJmaFo_hZya`>On5()3JPHE%*d3Y z{4VAjBJkF+(2p_2V93OblQHR1l^OFE#d9IPn|^6L{ve`*S1S+xZA@Ndyo$Rrm>bn( zdAC+Ca4mL~b*L&!bTzu>o}2&j&dH(vBX;YbrE=jLQ%~hP2g?8Wq*^x3-eYendnob0 ziHBgAc9G5fXZ*ve+;EJJ~ zrU!<`Y~@l<3P*n1t2Mp}7=}V)`*iTvs6`=Jt#jIt(Fbxm8m|M=kARQ|rmvt0%^yj> zxl-OAVHRI-ODd@`$*MX#s}Qb~Ox*V~NX`Y*J_Dt(3m;`Vur!6dL3z6sh6)Q<^GFj-iI~arAz&Pyw!emlrWp$-_ zp}bNZYnAnfmWI4V*A)qGL~@D{tON0#93{ueQ3{piG=7I=baJ47K*L2e0PUk^v(nN_Hq_^KsVXqabL;TRA*y^fdwtP8U||3%%{Y4=vh##I+~ z>Jq{W3Hi91!VX>HMvtX-Od@aJf_+YFO;;lC=6GfYfL`VD@$}&MZ5C_I_?o<%7u;d* z?jGlQl| zhSFC)I0?YGN!x?8q>fL7>&Q?L2@6Vzz_an0jg2!4pDI-6C@W%YGFFku?(d6L)P@Tm zj>Nq(RG+Q@?h7HSFnTd&t>j9uqcNq`_YX%#E1Fe(MvxfwdXto>Yv)%Qey0j zk+MS&10M;|?h;B^q@2af*$l)Kh9@n~*|<94%MXPs-}ob$_SRd%rzHLvdtW&H&9$p< zC6+(Y6s0Ni9qCCj|PMBy5(bAJooxH476d1n0HDI&v_AL9~=?{dP|bgwBak5^Q=lfjY7T})HDR;6N|8AhHZu`6`CCI7&a z)qZ;IOB1!)=&Y)X4JU9L+Ftk%#5q(#{Ir)LzB<#hLZw+Y8Jtv@0N+XrnmT|LI?BDrrNiJgMIV>QbpV^ul?g6 zS8sh^IPw10qTy4!!kD(tj1x5OH6R%&dL!^bvZ(b0`Z~3*m53liw3!k(9jMw@VogwD zn@H3IxCMnJpo$<*fgcZRqPqtR4puvWt?OVfJUdEYbg*)*dVQVn&pJKgw53IB*Az>Q z!m+aUc)XqbHr`%_wNov#Lt7uNf1VbG%bo9c9%e)~n_b2)z zS*F+3)#>z7X>qaiHCzmBsXI)sS=LqD66%%`SAMuG-X1S0<}JeWvhHw8aj;6~^6Y%! zg`HUrUF8#JMwUzm#~4G$Q(8|MTd)rG6coo((N;y9Ev+Y7O<~bMO{+(&Ct6{&qEI=J zXabW2{5n5fRj6f34-Jpl(5VMf5_?diiGLo~Xm~xJ^KuTa7leYkg8XDY>B{`R2?&O7 z*-hmKNxqNzU5YGE8n~L9mU#1WYqFgDmj~|oQtI%L(xD3xn0z=?h&`(>c`^FbpfQ6l zKqMbK14|KK5aJ(X0}tWj13;BpA_Lbv8qkkmk~6zk_O5hCTzgh@jalI`n_T3w-Snrs zX60=w$e43%>C9nQ-KeEYMhPF8T`u#QbzRGsjV72(-KO&Q*KIPp+@|$T_xjNYUb^pG z13Mj~ZTR31CYuv-sfG-`;y^)vdyJ51#tr zexk0e628upRT7j{d<|gw%BhSYB(<#F5K+H9`;|;8(G;YFn9Dfnt zV8AqTc76Dt(w~#z>&cBTz4THSV@dy=3>O}w1vfEf>}eIiD!HEfxIddYjD5?5t8h#! zbC`Jl1UAb4uG_or$P}Jg9n!z3T`P$1kwmYf6)whn3|Z6D{v^d;Ln4l5#faO%%*MIh zhqHFXb6xJ7xbUxm6=u`@8_gzLV&aBlrHvc!eqdvJ)8oeywHsO6&>Cc#Q{9LyHjpu? zDfBm8Ow>=YBdcae)7!IOHZcpZ8R~xwtK`Iw>sKksKCO_wgt=p@dd{M$C~Rst#Wl%mQ`*2euFzN+Y!(PRk?B*lRc{ckhUVvz~+7*JzTDEd29}5?fTlJ z@I%r0ZRA!qSXo*DLV{5ZZeduDRGF_f9rG!(*|h`+B*M&K3tLv7H@sqDqSl+J*N6Ar zcjWr>82G~Yu*{?OI>J`Jvp%~6Z9=K{wOcinwHC%1pSI~nGv{1t)$45RLakM!1VV^t zvJ7FXL1$%Sdgr6P#i0Oew(E_iyf$Z+o<)#{FX?u~VvI`n25*t;q!8d4Fr4Rl{muf{ zScM|rO-KisF~bsy+VTyRrVgDVKH<*ia#@8^VJerY`o}qQedPree7=eesUIj3j>1Ku zQ^6LR%V=cGN;A+e=?!Dm(qiE1>6J4&t`XzQKY;@+mrO%eB?*8S8EXjIi3lG@8-ag> zT1PUyOoY^do`PyPu*(Cd0QMT30+cUpM-e#YgN0dcPkh5s;qSsx;p5j+(dw=dU4TaTxMo8oD!HI zMyJ&oq@0=*TJ!VWW5ph9nGFq{NkVGd>IfSs$X@gE9m3y!yLiPPh`V?4 z-5ZvTNP3j=usLRTPad;3;u-1E*oO^Ywdo*6GqAV}$Pix4lHHOu7!P!Ca7F1Spvpla z0tMS91Kq8)q@HDMkg0(C^szET?+_Rva0t4-t(@ix!WmI&PEX)iFtD)+AN8mJybq8! zWo3#2)(BQMHd@cr5t}%0a0R`4ybbq_*Dq}wzh?3!A478$3;qO;D{EIera!rS}GJvcS^Py>|TYrTPiKZcyK#3eS&(>4A)q-m!fF zy(9j5n+{LZ;lb982@3=WJ6tv}rlQ`prcllYx1v z{)$s4m`Bp>+*@-Wp8e;!`NxC;rdBw4OL=VTt}6eyQD4=|m2%GQ=i2UTopJSeoiD5; z*Y}^)rVC^mklrKS2kLJD14XwQR2VO?hz~P+_&76f+O z1UD9EkQx{%tJepaAP{f>-C3BDO1@-_TUy4DVsc!kvFX&TP3J^69sAWIy7Fe=B)K z@;)T7(+G|90VGg=rX8Fy`$I0GF`k2|g{5HO{XcE9Khr*buKk?5pSCAFoY?+EyW{`I z>;GTd=ef^w?lzyK2BA|Dx+HxW`k%AxKmTbh^-B*tdmMuXJ0va8f4cJ76T~&zjFYqh z{vQ@nIPiWD?OakUh2v*V6~6wt)d$ZUFogH$XID>ATA~b}40HBDfA+Ng|HH9EE(TeI z0iH?E_3=IMBO?Agve@K>o2wGOR z(3=6+y(7HS|GWsTO9?3vT310r^Z@sVAJP*(%3$j<_LLOtT{`HWrHE%7gPw?~mg+r_ z9jRUd_&&s(0kH>Z)Jix2Tg7}aFfs)LG-*tD$kEtG!c;RF5T_uYsUwqWJ2uo{*}1+( zxMy5v$F>%6K`viKjE@EC8*`h#sBcWSKf3hpqhxsPq)5&BPP*JcW_ONj+15c9T&!l% z$QAqA=yGrR*yvSD_O*{*z2xS?XM|5z6x4cD-II4sIQHvR$3`xyY2Uj7%eH+h=C2;z zzHiB@(d{=cfo(5|n65sINi;ST@)?Ywbk<3jGOvm^W%`!S$Y(-G))Zp$XDlDT`<~t7 z*)OkoHr)Rr?N)3&{OmQUZ*IQ%8+DNhOg!rz&$iI-kjfA8{@#bcMJTGBUj z_iYgVXF>Nf=|__Z(9+4@JW5QLzIU0yyJT(2-G`oP>%96+chjaR4|iqVwRXh%aaGQN zZ-_4__CGJ|KY4hQRx!`dIsPwd0}_psc=!Sa*}EXAng@P(j2M2DLs!h8(kW9DTVg{b zCyPoM>Ipk0>>!&i?7eDHw0&IX{kN|^@9>iw7-jQtvX@-HC3VLw7r#_@xvH&rnM&YV z79vRhcR%)m3D@-hW5u#ta>|xgj><6zPe0Z@U3lQFW%IK-hAGY4AGmkxC3pNb5F;0? zt7s(3PQ0I}Yl)nWGWcJjkOR)3B`9(;K;?O=1Hi~aHCV*|4!%Qq!Ym2W2(tjx1p^O_ z%O(=pN~8r>y>Qi4FQj+un(uPW?`-h-Zs@RdnX^{4&S#H4v}yB04{hG`&~D*hM}!gT zr?;R)*DA-ba+@6&|HK#D*WtGz@tjzwsk8`KFrG#+`- z5LQc-7OHrJ={KbBC}Zi{(|$)$)6f=07#CmzZ!hm%wyamsuk5Or?kFp$S>v#m)^=IV zU2K2GGjgf|bYX8Tqj_c!X9oMHg(OF^ZJinzx&v$*9lLN@M`iJsNIF$**kVT zzjKEKY~!aVNWTE)Sp%zVKJ?@fltBt^XFv?`wV*&*UC@|W(7P7Utcr;!uwM}7prNrQ zS_7aG2}e!PdA&T%4k|+cTm&TvHk_cqHNG5Dy_Id&F~U^zeU(h72rwh_4qaP+UXhRG zo~eppC$ejr2eTG{K)#HpqEE z@fK$SNBuA-QrH+ZL!f0;6VxAV9ySVLAjgqrY5Ml9?1{;YU6Gb3>+eS9g^QHrKFh_1O$xC6bxt*_Sv@CAs7DRfH_Dn#k5n z1@u25ZbBZ&f{t=rd_M^!E6RV3_YxHlOox8-$OQcqXO@^B0ind_8d&nj0plnk%8*0o zbA*&cC~-ziWY#k}QCj$vDdK#V?85RRvI_`p!;Xj}7<5E-7=Yp?*PdCVz&Vc- zBEtFNV#ruyk>moGM6oafY*=FK5rueA$6$E^r8Ev_ury07HK8;l+7k!M0VKfTb!14a z1UJw7JK>_6a$HtEYx|PF90WGN-4pzW@W&f>7X=+M@479-_Nra$2riCo5+1z&PrWu@ zwom1`=-2y6{ydAxll#&+ejw74Wm*wX0Ymg2Yg0Ya3B0 z3wwPz@^EvlI(y1F&LBceBMs4aEuh% z;i*4`b&}7$ntt3ToaYt3@RCBN)l2q!iNTA$XTbj}6%uZxM2i`gX0)#XW`7)Fd z(F7vK2uy{5NYnCC0Q}GH$gCqE92{t+NJ(NsY%e{|ge`00+^x(m(Z+~SCYJ7|b0Byx z=twZQh1fi+NmeZGV@z>OIkYt(hcp_nDAmydiH+U?#veV=C>5X)A{vF2fa)r&NkQ3(-heM@gEEYzonr^c(YK_IBQTJe5D^-}y z3aOTC5#G00lrlYIG%|Xba=OW+l4A|qa@9dd-XTCLuy zCu%j(TXnB%jZPzxO4Wc6z-|u6`rNxN?Ek06=pNtm4DlM`l^5Q1$5)I>snsge|N2U) zDLclr>*WY%)l1V)lD`wBOr?-%$l}x{g|1v9?Fz%iV9^;;I{r3#nAUQ)exEvgl${dFuG0rse z4kn2ce!=PJJ1fz5F2R_DQ4^DxIBX7xGd7vQPxC1g3bv*$TsYXo=848Dv!H!b{R0k+ zOmGOb^8(^VZLl=vpqfEDhItpSjRhnNEuuhe804@&635@D88L=96vkhecM-U11vsLN zKjMa^>m&eO0C%NedfQIcDAmFr)MOToHA_pt<5gN+b*&dc+(gK7AjFs;wbyawo z)%KMgMOu#AE}Gcr-6?5w%-t+p>QR$Q^+_W_;bNrsq=Xsc^va5@P_94{AM@L*g_ANh z;grtUynKa@Va6}LbW_*fl9~K+`NeyXdnQt`imwg+Pg;F)6_T!}(@*rxML`pvv&Wj+TU*o7~HYmz= zLDV=~8vogvUeI#K{*;Ub@iXDs)c!kKgx9)f@eBig0U~9tUVb&hBlenM_*vb*pxW5f zqVyv2k=d!2+t~o3J(=qfrr2(FT4)|&K1;#))9)*MAj5N-$s<4$p6zd$dKml5>Vbv= z1mPK|rrux#`v&PYo2d+_D5wp%5eh+E2);uT`?Hk*Dmcf8dAyRxOLIt4!7l0`!REea znuJf==W%L;pAb%}TG%1H*Zkzuzn~gETe$F6nMuw`IXGZ%UAT}Kh;z}R{W25B;yUX6 zsFN>+k7zp(u|(o{lX?FNDuMozUMkiA6ifKGp`^g|NSPghL!c82rS<&zcg`ZM(=O}C zX&TjDU(_XBJ(cjQ*Od7x>U_WK1@G3`Qe9)#xJ--EuM;~Eg8r__KHX2fQx4+Xf6+T( z2#UiS#8LGM;dVd!3S6pR(npOSqkES^oc;yRO^`yWkDijk@k@IlwwxL72kkOJFoh+M zhr0{U4A2dLH=coC%g=w8ASGD`Op#&@Fq&c*G=Zic(>gOCMl-1taDwzdTk~JXz!Z`P zF*_E?uX*npxn)*rlr?Zf%=N}0{lJ+&1ctHSLr$Jq1FAM0?{lTKg_1t$Uv zBW3hkVWJzD?=tPL64_~||H7|DLBCXPLZ(Zq2vHpf-fn=p^iVp{3vE`t$hs0m5v7o& zB{%^(_s@P=0wIUyj=T%$S&)q7E2qvD{9vt#Y?xrD`Pr#Z%t9=POLj4>7Og_~o+yw^^Ow9b@)&2% zCAb1oXQun;`x9k1QKIet+xJhvb};1^zF8fO9mQB{qrP*5BO-jo4@vvOI%1#Lya7{&d48vLyz?3}H+{eE)=e&kL-c~re%iXYG_KKc~F5+@dTDxx4 zfmJ(iJ9_BBr>bO*rs@Wxuc{=T{GZ$Em}j4}T`GKit24jI5MO@P2jI=T;FY(9J;E2y z^&I%ea1uM*_pf7p`!^F#9nG3IW@7iODUZK7;L{g!&L@zi zI6P=@hVEwI!;n$XpEH^GVA04J!mWR1rU(xT5C86WY$?{h5gzO$dQ4tlUO`5t@8n+k zo$xTxr0--)1N|>q@+|!?1p;g-R!{&-&IM%N`=Kpc`rjeD4!wWzBab{X?R_#2^pjs~ zAx!8H*(KbVn|?3bmVQs8VFI>n2KkAY03`YMC^;O(gVPt`*Fc7ym}!$#6~k1Q%Rttl z*blLyZ6fX-ehw+k&R9aFO?sHP&&!K2(FnC(X1)n_WwL6?mt6Mw-JFg+)rwHwdp^Hl zs``!#XLODr(TDCL_S?zHKmBUMW%Km)>ZZ;_XJLt7cAX>?j-E zUYR?pp|P!NN&UKenErx4th?h=qWs&P7d&1b&0TR@)lElk6+XXRY8Sp-w{w=cP212^ z9&gTR?&@mJxoY*=o#!o1HkMWn%M|ROuPTnk1O9i)y-A~L5-2|>Xdsk@S1GY20KzCs zM5V|hi)A1xGiH^Gxn+5fz#z@MnR(&gq5n*uu>IiEUH5c7ed?>H-R`HmnMSf9Q}6=G zq>5!{Ki%E^G*Ih5ffUwahnt>CuW(Ss6~VgVm|vPs&W=udbu%CQjA{6 ziC_{jfE}X|4TFc?Ps2B;>6ZrM>A+I~7!h5e3>AoY7lYjkIA}ek)?%;RW*oqlo8*6f z7Qy1NWQCt^8(uQM6OinvTjv6uV0M0vRx>|3(rhAt=-%4vkFuO~l-oToughfe1t8UHkOQTpF4kRD`LB6e|+5u(v^{W#I~k}o*RR`YMNxRWGzrXH)680 zL_$$O(C`mR9q5H*5q-i2YcZ@=G>TCM3kHxtwsIED45bvhV?z@}Y=#UVAKEPGUMx#+ z0bB+H<-lRl@(`GGv0KDm;)Db}MLdf(1%R5*1j9h#rol01f@LTSo?UoUxMg9LC$HhU zcMJ{bzl^oIDre5D^qRVYyu50maLdt(2E#koHRP@PRIB~O*L1kDyQpkxSy6Z8;U?cF zTJ5L)#>3T+$iKURM5jC!ODfChttojbXmuSf?XzWrL{5`p*N{$coiWI znoB+ueveq0-+y??B_EO+#IDqQ_|Q*ukhzW0SMCiImsI{LZ-SaJxNFM%hsaHb{1p}M z*-OtCJ_+3W3W)916Y_plS;9;ioiib4^wiGVnv7p5m0uZ~ZtI*X7ESB8t=agcQu(E^ z`L+%w(#WVLre)fq znR7$!ot>e`T_Yrdo%hfB1z%-qT$6QEyc|2p%~>48|#zg`tjqsOT!yIp5+rt=IdBPbKK5`=jJyB z^+%eLTHa^Rlj|-RWkDrEHt255c-whUEDS7^_m$^s+>R19y? z`@uwlI)&{73vrf%Mpr_D<*3|fDWyLOL+SvlRUAD1mB`<6=uLiGtMn> z{$s}8dCR?fs%xq@Y*x2od`NH+X)?Lu>NK^gr8Bbl=(>0Sk@*c;% z$1&4d=hbzWc;ukYlUgD@(!WX%>MFJ4C)TFF99da4dQ^3lb@u!@?9|$>Yc3%#y`Wa+ zW^aDTCXYmY$S&y3A6qFLbyO~Dzq5wR9)G@@vmY39#o@yKr}8H==S>gzr=<5ze&F}f zSWVBQYBB?C9#3_Y2eUUk#R=DL?XyKz=DJY_3EOv;R3MzL6eK4un;VCI7+OfxSnX`R^TYKhc{kv_@ax7yJ|`TKC_x6 zj4anVF&a`>3>K9h)-b-h%{(?C2Q)nS&-jWlNu6AqlxN@96>MHLuEFe6Rhu~^t1Mch z;W@dnEgNPhkU_p}@|&yl);jeSB)6t9VJWW~*)nT%6+gB~Tc##FPnQ32aqe=RIm_aM zk>;jh=5Rp{XP2I5w3>Jru}D7n2c6~NSk%K?ruP)(t~$t> zPm4U^e#ppeB8M#PqjcC4N2|fra^|Ot2@d8!yhP&y3fQPD5u&Ujlv$3VS8P-w4S{=J zEMb~UvU3|7bF*1TY0Qb>% zWIM|$IRmr#?H7?vp15z{{%N}Y!q+E0e13Sx*Tnnvjve2i{ZPBWY4i z_f3B#ykYcc6(*|?3$tuc3O<7u-#s~(jAmyDfwOmiQ#fo9@BaJWX|tndw$E}>%jfn# zdl|F2|E~kjkeL_D#4&-&ANX<^UAB};h69}+?Ew^0s1(s^4nq%wN%7-Sc41nWF^Gts zVNl^pK$!U9zI%li&IgMBGNn#0YkO_={3kCTGv@Lq=g&OUav4oWEdUi5i+Z;%BBpEi zA@VSNauB?CT!iAWZsB>#&2`Oor9*zXf>F+xkJFFhDy@x|BLOzW64K1vTjnfT_wo&y zENw~f7xci0@}qatLFSW4vb2m|l*2(D@}p?7twMiBvKB?~xd+KL=Qs{|3B>N92MLe< zn{TiVJ1}O0U1!^&eVy0B{Pg*)$B zvno3r67>k$Uns6^Fz*OO5H|rCC80KIiY^@LaUv))!AeSh*>m@uvrV%W(KMB$N9bkx zD5!6M*R8j|_xN$CB%O8qY#|HO>EHoO^7!%oUTP*CEFluGIbfTSq+m2orMMsM5rADi zOBpwCm^cPz#)2^Fx5P@bhoBBA&mKl{%%fpCuV$efV?r(EUkyv*5(%b$Hp>mUmWfXNs11uDEuozE5 zR|)R=%UMtGbm+g-bC-kp+AUH8=NYe{FOd@o&!* zdZ-eIIguCrrV_I<@2wrT2i16TGjJlO|I$$s0Hk zS9X1&pi6~V@`QNp-ho>gjl%}-k0;9DRK>dGfXm01hn0@?Gv}Cq2!Qr71d>OhHa?t? z$^c7171WpRQ!j3h z32zLGMu(A{7+M0T{;BGNu_?m`Rgc+}W(}bhhTD+4?g$+nGG90|Q3CmJ&Ndy<=;-yI z_J`>%KMo51+>t-O-ybjIIg#U`j)R@S%OQZ_M>nV2nOU8}_4{Zu!D7fNll;lz^waJL z!$e%n>7U&FAI>7Fv>F6B~0i|3=)Q5JAE;XFJO2j3kToIaVB2zXbyQnZE z(dgOLT@lxoEv`uV|8NSqT%(-NkU2_?p{!#>XH_^{)j0wVg^6eHIu4h_h3V%OeI#Pr zr7Ug~y#w@wsI8ru005!^HVDDenc9payEPyOfNEis&uDY}nKb~coxp5i;Qm2oXFh?d zhEbYsVkG~SUDp2=r8+_aE|C2Wu5o>7>`(X6nE;661-5jO>Fb9lO)N+P6fUum#PQ>_ z&cvlS#-p8zIw0g+*uOEpa8ZH@Dq@615NL3*5Wmv@4Tps#yL)dJst*ghA0`Vo6yDyu z8<^*X?O|c*XXKj5LasWp0LW(?Q@BAqX-BeEcff)W*J&hkBZdB{HiUf^%J4OnQziArTgI@?1AXGOO^WKk$=5m16h z$|*KrKs&Y=66IEQ!R7}y;~)8MQ}^V}n49`Rv!v6aIQ=Sum@x zbQx)ZrIQH1US3j|6^C5*)H#l)X!!;?=F{vJM!j8VCeV@68m(2)vKr%Z~PMQw{(FsuMxco}qr z6XO~q*v4c;U0kpq(+|PoDc%-gxSk_bi#8@K;ac=yl3AHC zbIpcH%!HsTcbZNaG^T&|eAKM$(8)p1YAuYBIR_i1CWGx=il3r+YN#J4C4RfJ8R3GE zTPyG#@%2P0j}8n}+8g?x%CHF5rMwOZ3>Zr3;Ew}dNIm&9DO@_mOW-db@*hGToZM3Q zzg0ZqK~hUc{{ZAHK|>N!ry&5c67f8&4fx~5-~J@q*Po=L1(!V4=l4apw@-;!RW6yr zsW}pj>v z0P9qg`B6D%j_ummwQ)Yvv3cv}5v*~Ka^&Y9e?C&VM{-)FzVwqD#vj}~yNWUFRst|Z zQe@3`*5l$4TiD%~%0*$``2fDD3jo`oj339Rs}& zqnj86MGcdHK2dc}96-?60JOsp1xRZYN+7H>us~3+yNF1KQ2K?@I#CGZIU+olVECxx zl*P^}g2s@7k8HbW-fx!9joVcOF~y^9EExUXvMai~XB(NZL?yfhEdD2azK59**j%(| z8M|)W8ll#$I&9A(4;Rg& zWJgx1I#GI+zzPovY&Z;g1cdlyTv$vCWGV%9p(#j{a^MSKz^9@jG#Qz-6rmLq_(DY+ z*oVSU;n>mytVpHjwqn_%mut(AAd6L>+*+kd3g0rwj;XuN;9NEQlHU+MeAoQDm>Y(T zUcV1S%|(%#=!6!lt$oSXo0%(%^NI_=u}k_=4c6~|9ej<~-2{8`39&iJu|#r`oeGfD zC)NOmpcyq)XrJ7&+9NQ`mh>iOtKPM0`rP5Rkj0zjS6v+-Yi2KOb_6U|KXJ(SmZuN( zSlijBPl*@f#kOfbQ#UkPA{WsHNoe|$FcQoIK6{;HpX4#gA0!`1en8$k2kI25u*f82 zExZEX8WogD&H?2x!Wh9*kBoapaD*8d)D>*%G+HVc0BSD?XGS#>56Yrgi`z;QtOdN1 z)x=U7Ehz<<2=-^hVU)&8L!#+Ntnd(Gs5q)1id*FaYXMsziXoN`vKW4gOX5^-w-(zh zR*TF{VDJt~k*pVxGflx7H{UzVDI>k00ROHuummRZcA9Ua;~ zeg1M=R4RJC;z3-7z5-k^i2)08g6@mbJC&Zj3$9|N*TqgeBz+a}y64{XM<)#I9DE>I zAc#gM`sHX|Zd{A9yTdXD6I+zl6L7tQvUWzm=4PaBocH9VW5!&1Wd4n*ZPRDmzG>=| z&6}r8owjwx^lhmd=O3Z_o}70hGe>5Su^x_>N_iw&;^ho75rGs%`~z?(OHNs>CZpAA zG?6=N_!e@B74nVAc+wWK*+Q34%p?qIqRkzkN_rNGP9A{|J4>ha*>zs8-|O*v@A7yI zPMT=Mt$VOgYjfDlY7oYF3pIA1!>n=mJ^rn7jmA_|wzX%kH&n%=z z%%6uN`rl$%q#@FnbsCLOiOf|<{fb)9@Ocrt!)UTk%<^Sc93cnY_Fyl43f!LFoq}$$ zjxBCH_Sx-b{Uswpp%L_dbCcd2tBaZK0V%^Nbt=2oZuZkvgVtt1)Q8Mk>&nh{)t2mx z`Ld!WtIn^^isJl^Am`?AqTa3{_K00=*IzMssda<9uV`M^YR<07Hlscmu}0`ah|feh zzVY?218?%t(4j!&i^zC6Oo$TH+0zg%(?`aEVO^jzBK!e()Wr$i7y zsX{nL7IJJ2jE`r!6y`EfL>lZ>qAwYpj`of??RBC<2AoK0hKE2nC@+M?O!TG%29Nl_ ze^M$UujuXK|K>F$l_3wJ&T8Eu>6b~9x&DW-vq#OC(Vk!9ZD=6L?1abSvUu!)?8>~F zP(fI3a$AdRIeD$6Nn#CW7uVMpA6va*#p=h%C8HN~)K#3q|Y|^eR zR~AK>-_x5el#>a^j|=xGD!MD$D}{%y)Q>DI6CS#V37t|`j2v0PeTyX($KekcnBy4a zXx2gxbpvG;fi^k{zOR=hf58aOgZMK99L!80X-dI$MF(SyYhhd5Rz`>4l5pmSWPbQk z#4ZQpvS8E_j0R<(@--Ps0aG$-Iav2mhR`6tErHW4fGLXuWDxnO2S+DNj5cwshxnhs z0PK%@nexFxL(qb|M>8WdoqNSC*%=*I+<|e@Z$ay#|7Btf5-y0AMkfl9!IQ31!a-2} z0FZ#O7{^k?wCJJ}%iwij#X_Vn6!#52CiD=JX}~xQqCVOqrX%XZx0ZVeFim3P#y+Ik zIJ*yF zd2w=HzqN6C<@D{2OB^jLdoEZwzLU8@WpLZ0_H4zb(PNPXgd5%U%K5^(Z@qQHb=UE) zW!lyfN5b*8X_=YvAg!IvmdqZna8x+{8hGT8_ zR)wlYT{m^zcIU;85nC>*m*wbuptyB~JX6m*f7Wt#!s7JBqec}c%12)CR*ipH%u`Fg z_S8fc7Ybj!hCekmL!_C)(|& zY%zr*;3?1dTV@fR7nUb%`@L~RP-j)jW&$wgNw36RD{xolfbbR3rB_ahCl0_=c zav)S9Zttv)n}qpNrRf4WY*^?0h450PKeo87y2Wl*EA(K&Qz-ZC)+=~s`F3upT%#mQ zD+W%{to-*=h#u*r?j>54(1Y}eCSnR&aXTA%|3_0XwXqD0=St`-CBPd^#5lefabH(R z_Gac`OsG`)<%4uFFz*gXoRA!W1u)5q~4m((-dPA8D<{IR3#ij*}=vm()!ss_8(ruR9F%d*4&kGb~_jH*ie$LHKKHPc(_WG2bX zg!DF<1V}Oo5K1V45Qx;!JA__D7&;0lMG!$SE24;s;@U-w?%I`AS6p>1aaUd4RoB;D zT}U#Q@8`LbgrK29ZNvq?a;IcW*mv@~9S511Xthz~oXu+4 zFp$p6jrK_U*x$o~PTU5sSQT_gXMIY>}9Qzx0p<#K&)cJ){SPDfezTqimnj+mM zoIrj5vx-x_$>tH3^EgE9TtV_2qTGct357-r#1Pucf4|Q>5Y{|Ec>yy-9(-saeD)}0 z8Bs~-6G@Mg%&;Iprx4jMu;>ZX)N?!1%3AVNTIn}h6~74f%t=)pEme~m=`I$iHV#i` zq4eR#Y8Eh9nzSf8E zj^v9#kVD9>L69yyLSoSxFyj&NKv#yS+-1|_e$EF)ST}g->eAPxubJu9l)71?N=z$E zn+EMX{n(BDcWRU?mD-M;?kDg9|A~(ZJGY=dgGd_TKV* zUPiS_qv11u$&00@AEE)04PyFH2U23766Kg{;f_L%E%x4as~g|yh#;nrk2f{(%4+j6%Dy|XN}UTnw*;`7TrGS zSEo1sY0KE{J}9a*;tFI4;8uxo?!?{=Re3;q|Dekg{?pTlY3T(#LG8@;Epi?|IX@p% zFekW+^VgKkziUdLo=e?B&MKi5{E%@x+ejxll`_ zMX5L={cGaKvvJ{DTKQVQ9VuQ7$k)opW`8oNEhJyt5-pEX0!=l^7|k+;RCMXup#~(+ ze}@8odR%~fk&*mPIih+_w)F6pDXZ5#GJ#vyr{hWgwmK$A-~Zv-vrBuc`j?a&dl}*? z;Y6=gOsuYGi0rs_{1fZLqq%;??LQ2i?-+Pq`sc(uURxm+_*1-96Z@o5ASBU-XuD*0 zqv^>A)#y4jq`|Erc$GR5B3Y^1$XP1oGqi2BlMiMTI~I}lG&5gyha?&Beq;pe{EJF7 z^3;KzciE=+(;b!Kq9VK2m*~n&jZJqrlG18(vTM^^cBel!HPe;os~s0TnIi9GcV3g7 zQ=69LaHP{UKfOghiw6ScgYqIo|6oLER}3l%)L0W!60N>*+|TZW$*7Z<5S!pIn5=Q} ziAiyBQ0O>tAW=RlZ?RBI^lV~$^z4r=jE_rjw7}fcB89qsO}uGXT}>bTzwzKT&}8-|qV_y-mZug_yK4wtYYKG8WOznTvzQ06iXEq-ZAZAM>rvNOBSoNAMK z;hpe4&d?=fi_`LG7!Tv|MsD$s5!}%%dUe-;eI-tCjt$oDv($L1l=b*`f z!p#u-YLC+XVAoV3&lE1;ME`^*77zY4H7#8uaQSJ)P&-&B`n8?`g|%xr)0F8+=>-X_ zuFsTeXQ_X{h;ZGEN9Xdw#8V5NoM_Ya%~*2H(t~%-Zd#V3PIdH33ziJcn0Ih?PcJX_ z>HSq&y*H85>$tRBqcLq@u{O!Jv{q$mY)DcY6MMyry{mWU?w`4GP=3?n)7kt-7cWeR zT~Isd)bcqe=B>0(?mfP=zdvCI_gPPmFuC8$HeSMxO@>uKaYg3cG*aw)DD@3&xaG_O zSO>5;Ih+Z-1ki3w2zUCiMpwM-6)UY;kZ&H+3MA0?N@wCOolH=NOn$fU&=qfF zQm1=tmnZC=D+(jie{%7_G(gdpv9NX%Di?+a7(3R9J?r<+1$76lu_$2+EXp3CZ1tx)>pbH-6&lgQC%tBZt*^OlOamX;Y zWXAQaWCe$f`PcOy$y*AKjp@eEc!Gti-R;R|qzh;E{Jp;7W)|K&YyWSV`b@0U;Vd%f zpwXVZaq}4_KNnA$a(~5CDKq}g4-mMz1ew1cgH;}GnMJ-tsR?eY@*FASACOl^GAv3p z)OTPGhS|T%o@^zU9|GcnCIeqgcEQIkh>iz7kCYgr%N2~)sfa>?<&(n2oK{DteOQQE zgp&q|sm_kM&Qx)b=yM4^m+vo$wn*5Pm}uj|Hg+EwgChzo!f~@Sr;&MX3`;nznd4-- z9`;`@hJ~F;Nlq#3%E{ptrY9z*Cq~9cj)wy^HGyz+$&GJX#9kP_qHo_7!=>Ic<#}N{ z=9CMV7jg(&fMRse73eEM8ut^!Puqk7C5I7!c+09$2U5b6Bl{G-KMu&==nDGixVjJ7 zqAcWfu5e1f56GVLkBvRH8B7Eo4-3X zn=LI!+hpGKf%Ln(e~{))dz#K}#y-nG@jcr=?Mzw$_vh-u!s@~?V@4OGrWM?D;sNRH z(_P!M9{3-&Iklj^{%+}aA8umW_X^VFJ(mCBCh3Rw3Mj5Z2dAy?F&EOeO+f!&E@O)G zP76RCQ{-6b98?WXVFgZDR8y3^oSd4BS2V9+H)_&C+AxYnLDP_;!X*R?a08@WnT5vO zW5;3O%OLcOW+gOA5GDk9;-QDCE(Z#eY8Gk>hqD}E!MK_yCvlF(mEXtlPb^t}+*c~? zbn)Jln2c2E_1n#EW8c*^c~;wqS({S~PPg7yT9srgJQ~;M;*mceJ_tFWM0$CtHzp>t z|Ja66NhVdS$tWcDFLQ^k@$$m;8nuTTSv=|L(?xDNE{gY}D{g z&mnd^r&qu75#E8LZZ8|*GfXu7O||NbI8LSFw@j6;fiY?F z2dN$3r`@$P-Vi(7T{|^YEFI}pvFFZ{_b@IqZ>S|dpc7pwMTu4*wpguciSdruob3aW zm%3sA*mRCl83KcE8=2w>#mqLxqCYtpEHH$f} zmJ15bbo7xgUV83trX)|T#|MT!`n#9P)G-#WqCzn0)qP)l^NknF)CPm- zaaRI~K-2dH{?#`0aQX+n0EDa&d_fZM%4Cm6$h#2WAuM{pnsx5bNQZxz*@h;g;ocb< zf?PFVkvezyRynt1bCdL~ya9pzjcuQ9Vc{*GZjbWB8&(yNE(EHunOyNqplaRr#`ZTFw{LG0@*1~uk1nC7&_ZepR2CIg z2HG5s&*|9b-Rl*H0+p2kX{O!&a7HC}dl7mPn1}vkIOnbpgHPq) z_et;X`;rBvGtwaG4E!@^At~n zEV=|`@*uL>(@EDb5rVqO%i--v*E5Nz$i2JTf^$q9v)s8}k)8Jas(RwQBa zL)qqWdhtwn3HVj1K^~gJpw+{Q#X?9pP6zLS;|aVUR1PSwaFf#RShtxrSr8iY{ z+BKZlZx&UBfS=0c&}(>~U&94>YpRv0Dvbj7G8fw$*(j;_MMmhfbW?expq7IJfog@zuC+)hx%PnE!D8%j+SHi zCzR!FO#dCn-@9R$$ZfDE3({>GjSZ^@)M{sn#b&d4V%0Hhgph30XxMZy*@kPNXAxMM zkN&PLUPCJY^rqB#3u?!J}DhkzR1Qur{-A8OD~z)M=Qnt zBjzCG)$1W?cOom6?h%Z*`m|DHtEyP#T^~MuTFnPwo;T@FGrdlF`3UR%)kkXS!jPA_ znAT4+fp_{WD>UwsKK(F@ZExq$5O%Z|`~(FlAIYVD_*nY9<9g{cmhk64SF<_Dh+#wv z+%^i5DD_nt|DQ1L6tYpZTMLPA-95e?g^z9G0JiYhrjCDZdQ5oZ!BCErm=mhZ<{LIW z!)CTsZ9aQ;bK1k~9>Oq}Y&rd+^kx(2&2_L)P-gF5=;4BbM<=1+NaQ!C9SE7sqVPs{ zL_&%yR=~g6!6P}Pl(N$HI%|Am6q`PApmc5I`9%}Uo48`>*iz)on3iskK9E8yXYs## z_SCk+3)qm??6sBR+|^Q&^z1cb-(XW-zoBy6;>feowS&g7ja={czHB;YTQOnQDybZa z?`;K@qn)p_nuP~9KhQ}Vkmu`PvhOcZa&prI(?LH_aceO=)r$+=3{xGkEAnxk1YKuw z5aG#mNX`!BEOx499Nx6Xdf-6o z^Y^Zuv--htuiSUvcfsG^eDI?Oo0qJ8bNQRc?|Vg9)vhibfAh`bON9&T=gw`vtF)4j z4BxeDcn6=El{$ZZ3co|R<#1I;U17n@d0?W6k3NpMdA!U;Qv?=djbG9`|Kj;5j|%$I z6KO@JEig2G;Id7$x#WfPsmnHlwy}_K{A%0c_OI@0PrK`@b#t`8T0C=jHp_T=f5$$< zw)>8AAKG0mdnA<}03atUBVW^!-A_xYPTrm?Zy&(&uDiba>aJzaBYbZ0ulhaq*L@xP zt4ch71kLrM4a#L%LI7>2JZ*${lLQ13%GH*QZ0`Yh?Un(xdjS0ThQWWg9x*8sL7iv8 zk983um{!7@bv>-C*8^vCk77TtFpewEV?>bZhg^^~P?_2(dd>OcAD~5@J${susOJx^ z0=V<%e{{ak9{iaroB=wEK>wfo5CbDqf0{5D!p)1Zfhi-k+n)|5qiALTI2{Ial%%{? zDmpGi)Z%SzFLC?1V{I>uL^`ABzY60VV={g&c|F@WVvcdnD*RS=t~)B1FxygQU&?IQ zxV+u|xOXYi3|@Ks+u=*Qp6m5Swr_a+@eLavdrW%I-?x8Xf76tBKDpoIq+m&Euy#bS zSGqlAuo2vNn#N^_cf=$G10JZQc1x$&s7n55$5iQkG5zJ2rFWJty}8H#n^JN;hLoHX z`sqD6DJeOg+(|hpIrN*Di;(s=(|+_%x^KkND-SIlk#@y1@%+@sHbzU!u1o8s0V1|N zzpx@h>&QyZ$yG5O@(u&TtT!|AI$p^k&lb)1Jo?^JjK5uwbxiORzfy(;hx?P@JUQB^ zSY|XP-`;xkXe%!rZN2^WR@PdPec|2gii&LZKvszRE|kR{$gW`9>D*Deuxas8p``6h zRz*dY*q@fa`W2RVBk`f>pkMD{Jr2|hxoTyBC`To83q)1Oqd_b{yfC)Fh_5RWNLu;1Ip0#Av!Ma1gdE@r!@79a%M76=*cZT%+ z`YoSqV+rS0ojT%QLgJtGOF{1dM|zxT+S z!3nE2Z&@`V_}HySo~$VolB{+^Y@lKOvUj$=&P-!>+g+-XuAkmG;=TH&U%;jH|SFgI`+P`8dF_u3_ zmvq3r+u`L-zZO-SnBt5&0YNaQ<9+;H)y0*Tc&Uy*Fwymos|=p&j!Syv;3=-ezC2iIM8-Uz6ITRz89wPj@`WoqSFDhFiqO zNv%>FyM~2fsp|+?dRsa|Ca4F(7LO42@QTPR?$(YDUI+tnGTiYO?pAq&g=b0%ORl*? zVY3MebFPI0egUGPVf*iMJ}6_?z`$wF4R@e)UBp_M*)Lt zRET+5@AxupZ;)ZJXV-q ztVTvqFvKiI`9`p?vLQeN6&?@an2e3(YA871UDHi(_#kw^keTR5XFzTV>ws<~y6aFC zs$4u5YHXy22sbhX$7#n@Pf;bRrc{psUJCx{@Sl$n^*Xpe>(g?qTD>ktr`K9@()3OX zKsm%1o-Tny?;U$rcN|!~SCf=8GBEBP2lw1t<^gH$EZ6+L^Ici)v;pR~o>L{fGpgd6 z3=<*>LKGqu3UdVlr?zsO70@jf4UaT+9(BChrb5Q>xYQINB%~stUX03ygB}68Dow|+ z)i>O*x@^hy3#Y_?5DLY>U!*jne0PSoyxg0yyF8<`Bz@$FPdw|JZ=!h=S}?dc2vdH6a#b?oX$O#h8f&HB~XrkD{U1~xAACR|bs=vIRd9U6P>BO#gY z58pa1D~VGqt^de{7#d$}#AB;oVojJqCx5+k)9#yIx$ySV2c6OjsWyvwUv3r@@M0Kh z@hf%i?4Prq**;XI`?Pt{iv#D?e!4Ni-=!H($X*C~n^2JC2xq&TuEaS@kc0qp&V3aL z@$W_2_bf_wCqtqm#XB_jSE}2i{D%U5D6QaeN6<{@fp3DFd{LoMgJ%%T3I;*tf{B9< z%D@_EHCU)f%)8R#gfvmalyIH1q!_;T_3x#&?_a;RYT2rR@mYeH9N)XKG#$}Mc~dt& z^Y$|vr{?j@m|oi0J3d(yvf>A>T2>{6k=i~Asesn22{0(d8|7SA6*J0`lgnmQLW||r33e72nPH0u+Vy8msqDTzhd(siII)*BiaTYC zPq0gQhxdGNA#-pjEiE)S^8)d39CYSku|tlnfi_5?A_rwcm4{z)RF?=7N0+wFoWr0n z#TOPVX=E$HPY6rzz1K>5Kj;#n4vcOd_{WAA-HuPToMaiNpsGw zuP%>XO*gG$>*U9@g)i5INQtb=5W<*u%c8M!fCW{k;P(BqO&IXO!Uk75P#n+?kPY+} znUbiKU4`b$_nbzf$|Y%(UmM+gPkQh4p5qk=bRA$2G&aD{t;`tGu~6mJR&yZe}0Uc-oX;o4ax2Tw8+abbF_%jM^aDALO~F3YgTeIm?5y ztG$5&f%g7|`cW5wJ_SSo0cgHJSEU36MbCGAjdfS6-~NAWj4?6yt1CWeP+Zz-utc_9 zu9k>?g|CC9#jy3#(U-4YL3ASX;n!HE(@<57%s1_gJ-?Rxt>oC!d4wMF-_(u19n_fJ zki(rLq>G3}hm8}ot`n)a*nMRqh`-zj_{i&uW@zHId0M8K19!R*Rh)1KEQT#}$8??; zS9+A~J^Ej^5_N-@j|LWLnL10Ipk3O8w(jw9=1uB6F|B0Xx}UTn>3%>nloDdrOQ6%Q zfpw8AGY$^v-hbNfJwHQ4sE1(IbRgZj381okfy|I#x&%#Ozz@R1;2~~;*A#U*q)V1! zHvHp&{Q0AF20ZYU{ps5~OngYql?4Y6o0%Cn7l2S#qp&EFnli(eFl|BddSqWdUG*}>I!WtblG7ZD5 z*mK~)0x1tD_<<0k;w)!g7_u;>D1bnWc0+SP67|ai)Wwun^t7QBj%4Y($KH~T^;`bN zzFM{BhCgjv@yBcA{?p^jOMOxv-76nNfa@La<9|o^qvJd?yc+m$8yb>tK?C9dLJ0yN z3XMHS+Goj0cdo~T4&@KJzk&mBTz5^A9munB|didgX&N!xjvh~Tmr(W(Hl?rr0 z#ABp&84c;7g;OPu{(fnxX9;mO2tr)($uRlxCZsU@3Pz#f(WQYp2Mg@h_d- z5O~*^BunpREq9l8bay=|bT?rj$b5=yck2U*;mSEP3Xw!o9SyA>vuE(K$K=n>qvv;O zG&vwbJBMF6pANq-di=ig|9)P5XQwtE576uyapn9v{J!Y%`_9Yl`qO!qyClf-Y^j{j z(E&_n4uEYi>spF~fo=vRAj`U4j-Oplp_jV_7xi&5apCuv|CIF3$t|Dk&=F;6rf=Fj zAzFx6ATYiXttSX&Wr}{b;}fFyyll0;9DUG) z<8p1!2O3B+4nHpc52T1?xdBm7slTo!l0*sbC$W@`k7LD>=Jn zR@DNa$-fV{r);hE3F&?Ljhlb2jLi3hR-28B+e4SD#38E~9uYn9L@PB#E9Rk7ETg-9 zq6eRdzNO>qpUkWBw;}ydl!xr%&uGF#9FU9aDy+;d%0EQ33|ICfEi?&G3jgOz) zFf3H!-6tWkNHn#6Iu zan!s8s1C{3m)4-|wnCmLC&Us3j8`Z&SSBhYsuPT+BXfXN0P`zX2s0c0fKuG;5Qpha z6?9m-V90Q*NQPcZG5=cpJtAi|EzB+5GIjURL5v?5o2ZOcS&eFS!2mI(f63$+t+8qS zmnWuAKk=o6)v6KS9R*ou&R15gdPVy3*590zCU2j=>J_e_K_hBCnf^d|_THv>W7XsP zIe5L@wq0c(tW~K8hXQ#jX+-Bkuv-7>@h^wX7H85!q;t}judJH1mF<7%_qXE79fJ}Bf5jy^ZiQZ)3N zf*V!`W-OmRxnH`u4FAlHLn+A&^}(>}Uvm8l6@+fsRX^&92osReGUO%dP$3U71PV}E zK2nFt7z-+qT)&cW?d6I(+;kdn#ps=v>-oqZ_r%4s4?iVNgF>p60twx_14*) zS5){A8*<2IO-xFR_jcDe^6}3<}_O5Q|AsXT#4L(ySAtzr_v_aV|D}gwKbR9VGwm9aK+asZPABUsxY{yvv z*J0a1XAgvK{{-7%G%)5goRn>$4%y2EfqWhnG{kUY4|x2ZKq2YKk=!s87HDhxu{Erpq?rG%QXz#}!Yv&wJgpc&)_4V`D|!!o+vs~}u1Q7x z3It-3!PCf}ssgGOkmR&NOJ@Qk8czc8{p}B*H<=vmtqzmv{KM_w%f6M9IN`~l^-pc- z2yc8`e8rfaZhS?2d?O#;@>E-koU@6&K`>AB4~=@oyXCR{bMNm;z(nuw&T{&*W%*My zXK5$`tDL;aLXnoADONPqD|?QL73sM{Wdvt&=?2iD75M%XV^5ejXdVzyP=2Sxr zmm~<|+vg#1=a<@Cr?AYHXuPE0XLTH9TCTeNPjSim5BSgcj%NmPYdB+~Qu+>BCX@^9 zj4?@gT!>QWiLVatyB}eyBa76PNb17LsP|i}V)P}Y`cC8?j>akHD*D5+-ocd20`FNb z=zL!`kd0)MfJ3>G{hB?;-h%-~;^0sy5>gteU7(sk7V~H(X1`Avl($KA@+qU&V6MeA z49F>+;5z>3tP31eh+3+04!T|kcxOlSiGtTaX^#<)0C+XHW<-~Oe^XeP{jLG0a&Ev<36z*n$Lg|I&(VWrEFU=#2jo9Du>`K zPD67Pl>^7bF27lcdgCSPR3-95qs&S`(a;eR_#J#PAq)CY8md-tkP0H-1+ItU*OaPM zl*uUol^Z+qJ*oBrFI7ubjNFg-Lw)2&i2z%tRw0jG6rX*h_F3Wr92=E@N)@Sm);PE} z)g?F_rTVcc*+aJFrRTOS(T|C4=5Q~wUa1Kw#lE6Mv1tS{2)9oA$J&HN*R2@IeW$jn z*!Xa9UV|etGV)vJ*nD8>a-vnOj58#tG`hqjm)@C}8gH@bRDlNMPc;tbQhbS`KF7dw z+Fn|t(b=DsFHUsZ)utiN-hjA4TIq!Ryn^&Kxn(o=TyM)L@|4E_3o9_SZ+#jQRltg2 zd~fGq3uem1MSTax0`@#Z1NB6fUQG0*a3c&FbxcD*t70}wd}^Z8;E7MrY1N5(r}VvM zluJlRw7G|;#_9XH^detUXdL1)Wa#V;lk4JH*C>t0nwXHD)L$Q$>NOSy1}7Av)Wao1g6+*LehE>mffHY95VQTk2|n3lIWL8;WGY?Th0dX*Y2 zfO!`OJjZ)CGv{6RG5cW;fM(29#`uy#XzEp3PN`AFAh)blm|H5uxJ*E4{BoSPM+ zHfwq(v60A);qSG&K}_9PTsTJW6n^vk)ZPA*v!lclu+oy%I!*|-_fsiC!Mb!F&{ zHvkdSEW{d+%*JTUFldrFQ_O3>et~Ng8&+lb2AFy6n8MpNJPzM$;`U9!_$vbdV#askxc zE05z3*EuZ7I<3Z$l%&xbY=$ItOd>v+aWJPH5b$M|d(2*KoJB-t0-&4dlN{rDYnk;&aHqm8Q^A7;_Xu9{>B&)C@V@q$n z+h7RIFd4OM=~}-3*8J)2xFm~UO}chRvZ42u45iUDz0zE{c9DR#yk;Kn_wBM;RBGF% zz8tsd__F24k1t;)`Opy)R$x%+_(A=i6dD@P?6%RPL?ic7pOtZHrNwk}61UN*-}OQ; z|G8WBcEC3g#*m7Q%fOIS>+?l5fSvFVrm>l=I>4=&ODi<$9KAj%4b2kSY%mR6p^FL3 zD-P6hT;C5WN*0$DZJ&a~2>|Z0I(2$oUB8sq?e=~7sScjEC-x1q+~O*qhYcHw{u67n z2*~4bc2b|6#q$C&x|P)?Lq3X+#Ms0$^wR(+8T_u1Jf@M)`wGtt=0dx|E+Y_0Qk9E2 zSf%Bt#D6w!pE6~8Wa*Ucjg8wQ<4WgkyZ$%OF0#^hcl`dADcO9+!1-&3JuxF`^2Ek! zU(AR@(&-b@2Om7WacTelp4?2j3AfWy%~kQ;w?-pW2>WmrWpjbCMTx*ZM`xxYLUg1Ur*5EYYXMjx z*hMhU7YgJ>1BFdU5+?v!RS;S9D9Vy2YcEkCZ~N_4aG@i^O%lDU)fB1;r1my1A$`FTbMMpuU(@|ICPy?%-!#(6 z#)+FYO^j~sJ$J6-MtDsSCreATEc!@i>=Yn-Wh)bSH3qzip5CZ1@C9UUibU=%**EsQ&7?sWlHESQ&cHTK}bD|V2`6XBwv)BmjjjHN(+u4VlkgFk?L^BcmCtpha?@Ph| zN8bkm(j`&27P_QFyd4Zvst2wI(Nviv^g@+{P&H!qg#~i@kBu*DZLz20@^sHgFInSb zV$#!NViGLuYozv&(r~y2r`d0DPBdqTtr=#~s-Sl$cyRLYaaAz4oq)B>HV>9=ztRJ@ zQ8#cT0)^%xdD~fxGki#DfsP^+3Q6BKA8`-Dt!SZ zlERb=IC__W^PT_Na0hZdU`aV2Xe)vi!w3s=G|K1(R7y*2s8OH|NrH{)hzj9NKshYn zNzt=bSJn-ohn+QKJ!=U~q!$u)S5+x{FtSqo8;WiXm#IGH7MHTSl6!L+tTlg^5C3-L2$kF}sK336IXvY@)pY|Z7h)zmTIz7~DRZw~%IeSUEh@9z^rajEAGZs8vFbeUdjnShe=^c$F zgGS*XWJ#C*c%VT}X;~B1Za-x!cjPOV~^4 ziH{>)dxxUy)l6|giz|-s=n%}EUcxuyTq7<*CU+`Y30_Sfvl9 zt8Pzrs~BLRUkOnJuoaQp$%zjXqzG&S6Ixl3^jh!1eVU9& zuH{)=q*70Pa;jQY*c5~O^vd+w#$}DQ=}O_o;sGMB?w1p+;vshr=8LbuA0iz}SjM^~ ztb=&Orj}C=FhH${=v%+Jm=XiYNEry&a0^ThBfXyf z>(lt(D>9@PdsBK&`VLQcZ{_XGaO8+IbjSC1HQph;^W?qKA5YG>=PO=$MRnvpr|9O@ zz*~wxnuUKHnMR)Xm*;62(=Td603V?YTlMWwmRj{fNN){Ks%n?H0RgN7#$4CAW|>i- zgN<}q=V4*k<%=h=@@84zN)N+h=vpM%rar1rhp{4G)&M+K>JcRdT?}dI&}1rfuTK4M zO4N(S1AiY16^@#t%Q2&ogR-n57P|CnQHu+7!N7=yGFTvx8bUhhKA>y??NnR@ncx-d z5ko~f*GNoHTZ_#4G^SS=Bs*=gzuBj*ooZ))qn$`aRc>xouCROJjr%t5yK!RmlIgPr z%TS9jd-{^3L(nA5DD>NJhJV3nZuM9q7E;Ww@L>NER{D*cy?}8$CSa#syv>m zWrKA)-+c5*mB*uc^3gYU>aKdUr;allIwu7Kx`4yd9o?G z(6uLqk#lCz+_};ssr_=5Atmm?h}gr#%f}*plh!}<-R8~TJ+wYalh>dA`$nR_MEft7onoo}H(#f-?1*zj(cxMDOJ4*+@NU;S2t! z-{9Os4|N!Jy_}Kp@~$iU)4=~_iBqraPfC@Cut5Hc&UF1e?##UF(XIaTO8lfF74F$n zNImL`?_h*=dobwXk4Q=o4#_!czsI0fAd?iX zC@_o9#dnddy+pL-V29`iXdqPPkfAXtkqjNQ(vmKLWf+%`TXy%RpThV+J86L%RRp#X zoy1s_v=%@m47R+Ohj8Q$<>ge#i&R$ZM_w6-#oGB=`DlUPpux$?0#QA>vb3tt?34ue z^qu+z%BI>#c=UYfwV}JF=|ts@$wfJXgfPG%Cg$}+WMrM|K3cctrb_SnD@g2(>y^eH zPV4mp9d=)rUa97)a>8p0hlwm)kW!qlx@r0kg{9Ka*xcHt<)c~p;F+z{cCpDD?E`46 zQTr&Aji3|xKw?*rVpx`wv5tfKmYRtghgt^B0+~aO5+U)l>&ou7K>Qf;Z17Q*%uo0d zB%Y8upW`Ps9>@to48Lba+qh(Q0B`SI1KdIXk1j!&HcNvu^WAxIYa>je34d`$pGf@^`4QTY`tL|f8FiIz;0siMG!tc|X;FCr^q9f6u`FK39z5-I2W zGH22JQG;1sW-(L*uWe7Gb}ua&kmHkH3Gd1eh_2-Wd|KE7&54_8=N>Ts{lMJF^oAYw zdMEedz#)d9C#On#NLyQQNr8>cdUd?r>nI3mnhinTd_i3kNUt)y6hfHK+!rb`XLcy8 z^|}FB+--rHb)J0b-JJ63oHyR6&QgyIWDGKcVs`dDSsqN2@$t};Fbq3+!ZPOVW>)AU z&<8;!Bt^NC!dKgaF-b;YxeH>%$|KqdyGQ3{v9P{uVH($WMN_SW zgf7ybA|KT@-LsP2nGqQ^eV@9rsaDxCG4dOKsG|}AS0=NzFqsc^v|w93D4Pq9PcIQe zTHtjKsG5YaoNv;zvREXjU>Ma(MM-|gKW=|XIsywr?dhAEYTYaE32&P=VwStM>0%3; zc4R%TFY?8^Q*&&|J~vV`8nSwqq#KPbN#03S?s%W-s6Hp*d0Bxak4f3rumBjWpjkdY z1wG3Pvd0klNdQw!YdN5n?}Q{le7-W3C-3xBOn=d_YwfX#218sw#xg>hWYVVsUPC;L zT~RuS+c3n7eC*X>tF1Hi;xg6RiRMjX>o(fzX4y8@U9-h7VU_AyZP1aIk{>tcKxu&_ z_OH+Pm1*u=zeiK%%M0_L7<+4As{|gLom7>o3zR zi$B0uTvAM~VS7povmNZi1lPpv+WPskMoM?G`$o=MI#zqb#Mo3xp~^J5bh?}8lsEaL z&4tQvo-Z4-1J|>d>|>L@GHebsbv*~h!tpRocdm`z9s2pG!KNv1xM5b z8oA!V5#hu0KHvt}$EvnXdT-eRX?JL3lnl9*@3`Xn+9jA>v4Ji5SG9x^M0-XT5z#LuC5g1AjLkm|MFk(F{VBU>~sj zNl(x)WMHtM7PP7A0f*NfuhwtYR^{MuvnJGDslG5Xv*HC%rJB%7hN^VvZ4G(oz5%=`mjy18Z9Idcz;ACk402(i>I z4i2WdjvcPZXQOQKIaS+Crc6ts^bu{Rxmcsc2CVE^j@ZbG0gH0Jf^olQMKv5~pdTHCG*8;MB7-JsBf`?)9kAvn&##OnR=MDl*tWXA0yo6sz zxLzq($%%cS5Cm`)MIjJG5yNCn9)|oi@Y;FDqTdFuoj>TUKy``JTLr@~rqSxR##mU+ z(`x%Fo90Y5v&3xEYc<2MzR{-nK&$2T!iO5$F1>|sU9Puuye;3HWzjD;SghKP3cXHi zj^Tz%V-bvbZ{(pEvsP>1pN%nFBNt*5RH+&SeVM6Bs8A=4r3R7By`ymm1QHHes~AO< z>*D80ff5Y@0gVSzLUbN5mp?Ck`=jScHSi*T_}d$A{FV*vGNbgYcQ$B^oau_eN)K(2--ihb z97gvLas)}S<?ck0Bl{6I@z&V}9WabcIzcen5?o&E(5a0>yaP-o zozbKY=#9K7D=;ei=HEWY$KXMuRq-4eO8EtXMw zfzu-|kQD_dY{c!Ib_BR|)x7X?AA6;)T(sC!Qj7 zsa4e?x@Dgdg+_3y{2CV2@cy7v1Lsi{<64Q>MH;#06ODr;H*0-X`j~6xnj?+aXRVU^ zS>|b!!dxpUR_TO%868fhi#ji(+dgSzVd~?uyejLB$dAPj(up@Y;fv!8`ZZ$E9|U48 zBKxoGy4>r?L-1uoOQZB9bEc17FZJfL*b7o`WC3vED050*rjO-^UZs+cB1+BK@C+`Y z8^gGzioJka{|AqI29Lvy4S>-5X{RJz^#{<`rJ-%Cuq#BfYz_dD(|83cLe7F+y|T-y z3aoeHTMLSz&_nmc7Uc_&4XzGcBX1!(oSixC(c9@>)F*#KD=7 zHjq3zAes}YPlIBKd_p{O@^fwn9BG1ZTMr5wgTsTt;T`_P&5QA0*s!>E#FE9$9RrRn zU3Tow&yNWkk1bnz3_BekOaJrCb#Jd-`}TFu@b^j*;tZtaZ{Iq8?EZ7yNa;IdK}AXh zwoYK{v&uCK4@nmeZ~3A&ca*N)UHj#h!_tLA3pM3gY{7nZ+n-w54O~L>^+Ar_UOb83 zxp*;?%g`df_!#^A*s;%#N$G4IGp;?~c7Cm(TeNWep|_VWee>WXcs}DWJ_BAW2!-nl zZ+Y@I>B6l|(@L&&toBY@d@EDm_T()%K7DZ$`pir?;2pv|tHHN`zp%m$?`kX%k|mP? za?XKA5aldafi0F1k>M001GOU0F?k*3AmthPA-Mqa2NFUKM0{UqyYvIo0=Y*k9e8}x zrpGt2EWMyl&-O2UX)x2dTrtUGlKZ_ReV;rAo5@T!=+!0u>~vhBP0I^;L|fIMrqc0u zd3~NxUK+O?8K%$RNk5!=Yp{8H>LsxT)FJ6+G)LqtOZ3HoNIFBE%H1< zE>)G1l4M~<#V(e}-Nh0A%b9#`gygz^qCUQT;^v7HH?u-*TAyUCZ|%kv2?@!4(zK5B zeswn$-k9%jXdGpZXO;}ZQsZzuQ?zSzzx07;rGK71i-bUHdP1GTa}Q6N82P~#E5@l~ z)6*=LI5F0i-6tzxD7rDP^8rhTMjv^$$Pmct1FyB1v-C9fMMr4mJ@>5STd>5JC4N4v zd|V8}kB@x#WC2n}V+4RVq(DeDmpO8cjPEH6-O8lOaoazWo_*j!>DkY>PY7|(=BBcn zy#w+g`#&u`otl$BAdT(!h~e>-k&6#XEuU}O_BjhZ$f-gT+TZmMz+(OYkMs&F_6*1` zOp(@-PKTi^2SEd7QJ)hLSp-uBq8Jf;kqSgGkKF()Jq0qWLG6j&77*=G2QIi}`H(?8 z007oP90IAg7V`$`rVB^@7QAHOV%aRdD$i%jwCy6oil9oBb} ze8)J}x1ZfJ-@ULRw*O=nI=|0azQl80|Cx$CVHnsap1sD{j`GNNo>|;u`H@Ro;BfLR zZ+oR+=@`+cF5nV-r}pXCJ-v(_&hWEO0|U4MmdoYjRR6vIJNtwAoGMMpSUy)?AXR&i z`k24y%QwKElgkozwTEh=e638QwXo?d0av@X2gM`F6Cuv5T=3ddXbL1vfNQWy)_;)S zaEhN2%n^+v+9k_NMpAGD36>WUQ!WNyki6b8bAuJ8)F;pYK-_|KZ*x>&V467c@aW0R zT*1ijk9gwZeJKUt4JK)pZ{0DOmyW4cZQePFyJ0q;7$@la4Eb=A34DW+nFbAc@qQL- z)nkxwi;pG`(CWngh6S7_LD0w9Y{ObN8#z6$GY+hH?E!y`&b#Q=a{6N zN8J7J$o|GToYy7jlhXN`Pc|C?BY@Wq>UZvb<}k%5tuZl8hg`T$tkN$i(da`pA8m}` zs0#W)f018~Vq7i|x8W*NmP|8P=iKU0q!2m|Bg>lChtE}2b2oi1{gdr) z(9Mua+D@NtJFQf3Yqoyl*WA6Aow)seX?|qRO*bb=WuA*{{Rd1JJRm(IeHf|RV&E2S zVihZtxZ`vijVr`aLXY&aY)x=0fC&o08i-!Ri_;i_M<`J^mD8_;F|eF$2Z*Z2Jm`0^ za##n^uh3smc0plva0Vvu+oaE=0rPuXst?Z6>6Yj-zFt003L;_x`E0@@3UE#g1_BKN z3@gEV19lb(NCgH!a~fL3Ky>B&G;EOG`26wb4ohFnthq)IuBn;HY=@sazFK3F>&GE^%L86W$bF3xPI@#`Ky@v z=5JX4(~lBw%2sw7qdEnX#WQ9wEY`kV~?+5Xugcq6Z@qbhxwP>8nsJQe{Xm)*G&5Y`~qv!8k{px_ii!V$W zv-FlVkL65d7r1xDcW>JL2X1Uh-rnaYj=ue$Tk4iE)zap^_psSNj6iw|3!BWA#|NiY zEj#%rd$4Y5b?!ZjwzaPvGqG;aM_XU#hTM4eEUFlte^g=2KSn~={;@|`)T(LkG6r^Q z-2&K>XD6IdDXjX7FhGLpz)T4!HNj&O+cm!dqG2$kVCnb!N%+1RecHlxQ|9S@w z!AmJbmtlch`4-uNN#$~2Ui>S{PuE^nRjIJHCD|x;D#;HY0mTb$(2I zRYL!>$Bw-;+}A6lkI^}E^WD=QpthBB*NCfSeMzyd0#g)Kb%*h^E`_6ao)Q-wDGEGr|*4vly)8^c~?~OP2_AX8|njjPUbhCF48aR92 zz|g|YjSp=dyldx+FYOG(a%$xNwI|!n`~sJ&<2*}Wo3mie>UU~KX6Gbpbh>!GMm2Xv z_~tDe5-cEn`i=M8dGLCja&dVmRMFJ5ch;ChwK|dU;|8pqIkmW?B#06Vyw%H%l1r>D zs}fC|(V)^+R+*A4VpXNtl`v$*!Z{;rCrqdvHQS>~Fq;ym^=Eb5_QqM~_U?Pbq$?;? z^Stt=Su?5!)(&crru7@V^})$6?Ap0AkisGTxmt7@xf4d`LMbU@v^8f!?Z`Pz>opP&nU^)=EmtwLTRWs^_e8tTs}dcNkG3}MjAG6F#<;oAT~La7Py=kUbw~=dogF= zk6>!R?E_ZLz-MrnDde~Z!t4Vql z(daPh%QxKm@rsq-JbZk5ids-=^wuK!!%a9$=mQrZ8XzaOWm@MM6teH${P-|f8 zfd8*@Zb8mkX>)?tXVCvSeYn-CGx%0+-@R#ec}c@{t9DK+u&0bw+WQvuwMg%0jazqm z=JY$JRK`UbtE&c&b{YE2UQpRrsZ6q(f+PFomycgQv6sdOggjw+{)1!E-!je1uj^&d zTC;C;s5Cr)iK5A3InI=)RK>7+lB)_bbh=jWFq=*1=rcB5nOAqy_|ZEj4(^qx;nr8W z1DwM(YB>C537(sJ|+!H_AXVCJJHXb@sXt6LfNtIPb%1p9ZbU)Irl#?Mx z6N7^g60wY~F2QKoMIj?SwuNvT94%UjcDBk_^w<;?LyIo^uQU?*ZR}h|ku{=TsXeya zEEIakg?{`b`Jq>|j}bB{wGnx+b(%M2>kDQA2FIme#QyBz*VA45C}v@_Y0*|f7>*$= zR5LDw+)xS;RRvgDcQf#c%i9djOjl{OaM4iKjGLnuM&1$>EkCKVL9YMst2Y#hK$!m( zoqfU&&PDDM-pe3s6vurzlAe&!NEAngqW`mY7)ufOXU;@p%%6Tb8g<^af98y)!~Nei z%`FJbzslp}fPZ?t)cXIey=;)9(t#QRtXO#U6KE2eiW*2>{NFW@=#&)5IwQ44Tjm26 zZL0Rh|E^iMzLEl<%kF4<<7x6^BfbBN#voZb%JU|5(h(B=z^!zyFhzHF|wFm&D|vAM^8g7eqt!jo!d*7tt6EN z-tEP>_@g{Wc`42!s)FjSkf)nCf*;0M=v3cdrlwF~Q-3HVmtN(YTJ5gH^tKlHy`gAS zsvkvRi7q0ERk?*Y~*0% zpw?hDW0%7&H=CR7Zja?c?Tt{jw?xRvssDZBeh77ebca8FZsFLHv6-T-Z;WVtM*qlOdHA`-l z8Y|YS627=%xBY}#$tf&Wy;=z*9jg+|dRxe*hJw+Gx!tBlWB&9Ae@UUWwt-3K88$@l z?DXA99&$q-qR15^_;PZH?bHExWmM@}L!&KAM(an#~5!gihJ+=mfgm_V7GDdeYo}Vf0lzJb?@D4xxYjU z@EV=bA$knn_`JM+{&A6;PBH(z_folKI^Lt)IW%|u7{OHN)Hags1bP`TPe2O?)G}D+ zG{E~oAnmFU>8S(0Vjm>)auK>PctA4L%f+r*voEFD(vdfB+Bh~LHs|2AnWY2DUSreV ze3Ol&3Rl;>AhqRJipE%h7ZFq&!>RJ@y<%OuBad7*8F7#FsByIREWG2Z>ziI3QqVYl zWW{`+QoZ9VX8B6maSDy0exRR04LT#31S8l&b--DYGbsHUraZ9m>-%QRxbJKEJ8A@l z_%HN8CA`%2M5Td2ZDw&uBY`ys@e3woc}d$qF7-!FOYib4Bd1xqaFn*W5z>2f6fMaV zqb{{5?-xUI9J-Q0;m`YcXv$Q65-5Vj4yT3Mkv4JAB07}!Yo)W&uRptSYF5Lbddq@g zu_tnFtDn5gndJyp7S5WX)~_iItzvcUeA`#j6lo+=HM1(F96Hs0OZp9J&4wM)Cu1)D z>R0tU;@R~&HGSi#9#sK(kte@m~gm za=r8h-AnyCs(S`w0bj8C&ii4faRyjLFq+#4(I0o)6VD>%5N2!S9TzNsgO0FD|(zW^%wCkPf)x*s0X2LHS!YHx9LF z^@CZk5O{!84i_Ay3wHFG=NN? zx=)vNGr92N8wqO<*?OV|8N`ptMi`KD@@4SChU^rfpX;9%s z71kh+VDS{59tlUCd@6#4pa+BZfimy?A>Z%XcVTz^o);Hx`f}(W7D~6j@+;~6x7V$E zoB4iqo-LL_+#}0iDF5csE=&2NNOp1jy4(GY+uhkQ+Uy?|t-4|Ng}n=3+*7}L{&n}X ztb1E}AJhYnc!#T&nj;b{_Fd+6>H9CGWz7shBqizS+ivhFt@wt7)zXPa5cDv=8KD?v zAUZQ~U*ymPer($#j|;ck_C>y86Qr1qd)Rb<>TbNH%?lmlQg=RALW16?A z>@=F7uPMaEvi%gq(q2&P;&AWfd+;noWBots-UB?2>gpTcduL{QlXkVMu2oz0w%T14 z+p?PFZp*z}bycit6*r0n#x`K8u^pO?3B83-LJh<~0)&JTLJK6s7*a?=38`Rf{Qb_% z$d(Psn|$x{J^$x#YiI7OB27?qt;@uqGejpF5p{d=MAqr#Fzo z?`}uB*XQ%5JEEZL?tI;0b69aK116lB$mtxvY7i#=08co^1YX{Nz5*jdCAX%rRGdvp z$_5ZJ9SV*l=%tNup#*+LI{2$tXbJOxvjwhIS(SbYm>+mlx+V*J3=vB-(VAW(+9w|| z8chc0iQ6*^olz;?6kk*`c#p~sP(EUhZuV8?7ba#!yS$0{1+ntAo=aDf(9X(BJzcQ{ z`H5avbXH!P-Crlb$6gpEfKsaKCXEZ|9-~wio z|G~t^U@y+by1(J@gz)|^FfLh;NvOoRL<>d-!fV7;1n-cHT)?{~f>;W$p;hfptB&!) zW!m0_jAsBV>Tp`&1wT^D=FIXdEUFCWsVHJQDO7;IuRdgO8ggQ-)|5oEciZdd>^c_i zZS>?+=`)SFx(+{>avNN3Q#-#hVig#l`5EGo!7+>Cr7r zx67O3b;aAFdwZj8@$psB?2#!=F$G1jiGsNzdFHHheztAz*2D$g>U_`K{cr3aSa8LQ zpWSucN1n$%lArrs+>=}Hzbe%hH9fwI@viu)3|ssa^>XYBX}0L9_*~A0}Nt$Vj3PmAMLZh(kbpaUoX5thz%5kMGrcDrx!qhctbY6 z(sNm%sAzoQoDjym1aGoY`sMi#Z{Pm#`5zD8kh=HdzQ@jKh3R5bV!@IPi}MqV-o)Ol z?BN5^1>yDUW+ysEuIS9kS+nbfZChTvV6{IvFPtC6^{)6}Mq#4cu`)BWzAe}6uRnjq zyz|!0E>3fqxoy?xl#t9>$Kv>c ze1D)I&1NWDJ#@+X1y}88sR%CK&|O+MJ1@y>j`oLFgq<$NsupC%`oqOjlHw}D)nyIg z**Gj9_*Lm9RexP~_UQrff-tKUDQ3)aMdwRVN~dkWk!W~!r@6y$WoJH(ou%5%nu!rK znJJ`&*-3f5>giV1Kc7U)sq!{BZ-O@cDQ$S2uZlSf!3knc5BWI3_KCPoM4}P;IpdiZ zovG8#4zcX7_U`>keg{|fDYZwL`zohO2})--{P=hFeswC>0+pZj_0K>XPt&jD(eP_M z2|S>x^P}g)>d7UrBmb_izScjd$4rw)`d7VEruN1uV2DjsWa2fC zo2fUS1e1YS4TPa4!Z&^Jfewg4(^-ze{=Ep4(rnVR13VEPpHOxn3x6cW0XDr*2#QD% zv!#+^9@iDl zG7dXPu9QXM)47l51nHU?#}4CL@dw=s_1^4*Oh*phrN>Kgna9sxcTvQ3+3Gt~dG$M1 zU*?Kjw9Yc401;##{f>ee0`=hdhQg^+3;6*APaNeCsXiQ^F6O|Lc3fID!ssNqS?Q|N z;TXi{i0Skqho_0}%I)m&l>?M$V5K~h-I!la;c~!#DsaiKK_>{XGY=10=>i>o!Q}={ zoXC`0sz97`f{OH0A%YTxkK{TXqWO%|Goe%wa-|TJApE*ot`_8S1I%SsvoeR-ES5|0 z^5csPu}7U|ldwQW=mQ*9A@pOqAtjqxO<^S^o4LpkcT|0UDn#X&h#iHa^M4+VJ*l(W z?MGwf$FRIPS^2~r4@YB}`i{+_ck+u9cdM1=fT-)iIM z!+raO%l7X((ZXJ10sMb${GjgSI*2O#02$aI5avIvOfCMLT<4ft#7SVdK5`vi^JT9sjd@DX z1^Jy`Hp)hO!8Lec{3Cqh#JZvKk#eA4q&vkq(l|;wr(Ut<=OXSGota=O$`oWRYHx7J z(KT;g*EoLo6X$)PS|q%{cKoQz2MDx@KIJ~%tiAaurJE-x$>+%_69x>AxTC)si}%O7 zqb1y))S}S=l1?}|Q$H>}j+t(TyrLIAzu*rBQfOta90(K^Y%gGpN+|5@5@Ju> z2%{ho_6px8KQjLL^K#&MV?Zj77;unrqY$e+8ilG8Ccep*7sG-lO!_tBH}ZDx_)ht! zF?qJ}OND>n$*aJH%5OW0IYFl`=p}3f(wU+|o&~b2EI?NGa2Sl;1GrNl-_n$wS_b+G z{YBiiXf}5EurQ-*&+adq*~)+JyFkuXY#WTVt&+zd+xAMOYo4p}m2Hp7}X9wAD z*}>2Gk)z{ptj*x8X>N043uEUUJ@Vvj9orAS-@THtmEG?j+}?59ljKkyD-Xem>C|{m z?6X|p{^w~r-_VmF&t|kQJ@o_j%Y#dK0}+^5dp$%Pu(DJMf0I^XLV8>{0na#J$oH^i zB$hkgEM!@YK6%&cugkl9Myu5*zGK9e?QwYn-}5V6jxDb`o?W$kd6oE1)pEXZY)p4@ z`*xYEAL!KZiCZbhN!>m7U``s3XQK>p{ec4q+^4gVB}rP3v1tVCr_icIqS^Fck0W(R z>p-lM&P^$XvqFhy`K*WsCqN$qznC!e#D%f0@;$GmWvnu1WmQF1hVo5fe&fjSHFK|n z`;buL{GZB;=WSdvrLu5t7N*fNEcEfEi<2e0&Bp4wV>q7m`cq2^QT^T@Y-KK&jJ_E8hqf+-`xG-=A}!$aLSm( zW8tO)AENO-@f~DMgX~Up;_C{TLGFaS`WRyYGzDav02P<@7c0tk2^;+7stiST=o7TYoY!Yg|)iz zteU9K-fgeQADva9T>K3?DWYNOfxn4YM14F9{fkv+VjtzA$!W+^IbgV#0qpgVQBjQj zQU5zwCS+TQ1>lCLr?RU6PXPf?J<_@LQocAXM=#`82KLjuC9IEC*Iw#de7dc_8s3lvS;ec{O=7#* zyU)0B`#U#Y64`b2D{C(uN?`dbZcdhJS0=sbHAKt5i7BcJ{NBy(>Y`%4dV1QPk-cB- z`~JQ?EBmf~8DB+v#tC|#By?9}UYt76RtaeaqX3X(QxCh9BW{=rQ0!We3<>QBNr+bw zGT}Zr!%F79DyU`B`gV%G6$UjI#fQnVQu4Gszc0zFM8zbOrX+>(R|Lzml1fcZi?P=% z8n%6S!F!*|CqB8SqvM`Wn5f*@)n^mMjVMelmK_T;Rwly*OH0f`2Q>_W(x z182D4#S{OPeRTp!_b77?n?ynJQO@YNfow2h>XGCRq&U+3S#TW-$e{;6^N?szh<#^l z?b@+5?6RqKcKK?^ga`)9Hgxbl@2#{Z~h(BIaQ@v(Qb0~}L2nm_eWFh50i1D(2-ou2Ik>+r4 zP4D=#%w>Pa?vj61W{#Hs7UQz?d>oL8{9drd-uF=@@(9aD<7bgqhz|1aZ}c?%Al^aV7m)?$YO znIZ|y9TJxFV*w_{4J-k|OBgJBV2?q_pQKR1v#0lvy94afhMB~|=)bZ$xPY^WNra4` zd%)P!dq9mN3Jf46296b!2yD1fjuM4!xPf=agR(HfUS@`OeQcUdZuXT-1Yxv{UPSU5c?MK6^2{UzlI(?P>t4ri5w{D*da|pTIgmV@wv|=fNseH+=qH22wy9jj(oy zGjj&*C}o7y)eK~X^M%nSo580U-lTB&S10Df|I({Ot)Ko&`oJuS(KCRud2;~jd5^gHdM4ME6yqmwv?$}RH#jwV~F>Z zEY%c4CLZYy1CLh{Y3Ff0IEsqUfJ=5Nq~51D;1RWJa=4IZFpgt4Hj37@l~L zRbg{0f|YdO- z{><*kjyi0ydw#YrYX8=hg#klKL(w@`WltBS;_Rh!3q!-58S%mcr&7eH7bL~0X+&d2 z+2mBw|E4NtPh{y-7q8~9i9I(|o@z|VN()`6-MJFWqSND}QleP0uw zr(p6IGH_?e#SZD+VHtG5>pV!cfas$M0=uWUUG&&RUF35FK}>%5Bgx3hPRl6u9@s!I zeA5RGe^N?%M$o(FhVf^QjXz~gv)*a7>Z@`2IDTgB1#4clrST&gxbM}#pM6N~?dUFr|q~~c%f~`fdMZP#pPJ<_@esS8$-VJ*jJ*zxc{nTh?;*Jw% zsOf=9h0L4uF6`0AflkF)83}?I^ymjt^YQ>12ni5h7GxE@QF@Vhzvvt~we*5YRXPn+ z7Jw~R73m@{3YYreyV2mKWI!4G_fVShW@UBvMrF(>5)-X%Gj~=yUHl7&QSWK2PPyYT zhu)lI^se9WVDs*qvQ~usx3bj2LLUxz8$)>>$pCo<_Tg7E&UvaIrVuyHlZ41E%RMQs zZQ`r3NhuC*rTmXe@|P?qf;@rMJfDT;uNl9?U}J*Qw9e?t*pss6fos>_adBv@yDpJ= zvjVgHsoB%lZEDUnae@8qSnsiCFL#;bYg^@SX9yKlHp349Lk#Ea+aX^!4L;&_qjyLY z7Jsx0M#&l=kg-1iX@0Irvuhh6ZmD2d7*;GfV*%25AW<8#Yo7 zM%wQRo;CpUl3)?^mz29pdv>7*DN(o#1`ekC65gLyvNzi@OJC#zGxD%0t0L@YqFkL* z0n5`_?1}Mz%jT7mz^kI^0jB+v5^qo_JTv_>>7O*5XT< zlW+ysGheiDn?rOITgx`^oV}sy_tSDqGyfQ8PfML23ys*XVq!AW=eqxVu_Goeb3xQI z5o2;Jlt{~SvdV>~=zZB0cNb2T+kAOqxvxAM@`k>tIaxtgEmh~F7ffAmo}QUez?(B! zq3t~HqE!D&=Vfv~{2oXwWkHiHU1ZQArIGz(OQT7z#vXtXu*Lh zNw7+fr4VU$;|RXmO@;9TSW{6lni!#G=Gd)`=dsz(dKj4wnI7j)oa}DH7CD? zD2vN{Zna!*sLT=m`Kie^r2_o>th`uuuEl!kk#&M)sYzZ@T&B zo8G?WAA3`(suTZy=iQ%ta`&qFwv5)fN90%9ndH0t&e!i>Gb8QrxA|Mgrks=?pSxvy zrfdDxap5VMOXKsCoy#h__w`Mi5ABFaeEfJ_4!FJbpn8EBvj7qk#3|-BTuoTzUAuS7LTxpIY;^$AI-Wkr(@P~uWLq4c4kz2O>nb6I46|* z`PbHj34Yi@MQ%>{CK_tmI^&x`+|e-8vPinV#M+~1)t47m2#TZC15=G|ifk2bV2@2^ zhlwXWbsb5DtfH(;w>8@$8l|X=UCUmW7X?`qYqmKi9d8WPyF8b0qr+(}wWn9-&&k7;+(w6wJ?3birdl`x|+Bn)*X{%^*Hpd zOOqr|p-0MfnUd3!@n>{rOCEOoY(5y%Ilvd(h&}Eaj6aYvfh!HAGWCg808%E#0YNbq zM|8r3J`?o^NtO}nQ9&I&M%qf07bG!7!&X}3t~V<2F|u%An8;%CvaJdn>|Fl* z{Ah4cKuftncqnjiDL2}kwo+SqjS2@f>9(NF;V`mGneL3q03fihtRbms4G5+O7i0hk z{PX?uxHC=#0*jr1pooCLtO9|_l_z)v%UN@Q5pP(rbxl~$E~(@XfII^t;8hIVZZMZ5 zW&b4TiI#-$Rv}~xf}tRWIa-G)AbHEGL=e>`-HgH7kjEpKOTCVUnnq($mwb=>>$N{G zTHtidd~C_ic~5}mHd*xgXC1z=V|!)Y#fx_}=31Hl(vOd@z8_1jicmv&(B8rQr88TC zwdZcG)$0n^Hq6c~(no(%m^9s=uTOc=esAb}XR^VNFxQu9OY!5x-6G$SWQbkGSz=*Y z6!?4kGS&|-LncRB!R*2Z#QDwVTvfAp^PE)mOhvJu+5nn)J?uY|Y#W&T!0(fOX<20k zSS>mIBd$Jh`=lSxBi!Ge@e6XuR??gyl#mhaQslCsi$I62%0znvQ3_Q4C%yiY4_w)AJynX_(SpIo&5*5 zuJg_7z=a^?c*2NfST3Ty zz>Dfnxxv(EbQW#MfJD_4gfzpdeL5n#uusA2qbxPb8wDd{K1!rtFG6~qwzPC?tlX$q zDS#zAi;`p0M_W5(5y!HGy^2DuQyXY0=OFh8(<=?~2ust-)6&W>%$b^haXOXYX&Kj+P>7RPj5xFva7d9tqzzkXkGd18re@WLx*MI|?dk0md8 zaPL5yO>U@et)AXKosZ7_R_pw$%8J)?gjQuh_*I;{jCt#(R?45Q5vSy71(czXqVm zr~>{W*Xs7^bnq95Nhd+b*g%>|I9Ds=XpaNl7$9mbK)DJnAfIGt22BE}FF>f}bV>9+R zYUiLRxWa%uP0bQ>ah)|(A*NZf>WdiUZ1~}Lzr8*&=uNbgms_JU;zKDlP7IeqOX(CG znyKuaPHzJs{0+hYRI(Qx=wTTc8{!p!ys!&Ej^K0q!5knV1}Rw#R0#&CH+%(^2aB;P zrlDcmZT(VHabsm;V6DFYwrvd!F;zy(_)nQ(u|oc06b)U*PRr^q**)(hghsoz=xf9KeN1C;PJI6N2f z$gI9<$wKo8m@G_z9t|(c0LQ}>g^$fFq*Rm|XxyL)&`jd7VF!W!LMG}lSZ$J?%`yt+ zygSYpvvL>C$z&{Z&VqcuwB?R0G&a+iU|Ii$G(UevEMu`V@?jjBms#SUUp-@u{Fcy| z+d$C`xsAfxKdubf4Wu@xnE9X%&N+uY4;NbV=Tez-=ND$=9Xqx%hYytEi_

    5q!RY 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 + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/stylesheets/fonts/fontawesome-webfont.ttf b/docs/stylesheets/fonts/fontawesome-webfont.ttf new file mode 100644 index 0000000000000000000000000000000000000000..35acda2fa1196aad98c2adf4378a7611dd713aa3 GIT binary patch literal 165548 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 z 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 d0mRO}xo^G_%I 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 SdSQM28?+M|D(2r_;{O0|uQ74} literal 0 HcmV?d00001 diff --git a/docs/stylesheets/fonts/fontawesome-webfont.woff2 b/docs/stylesheets/fonts/fontawesome-webfont.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4d13fc60404b91e398a37200c4a77b645cfd9586 GIT binary patch literal 77160 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 literal 0 HcmV?d00001 diff --git a/mkdocs.yml b/mkdocs.yml index 6bf897561b..872e0823b8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ # Project information -site_name: distilabel +site_name: Distilabel Docs site_url: https://argilla-io.github.io/distilabel site_author: Argilla, Inc. site_description: Distilabel is an AI Feedback (AIF) framework for building datasets with and for LLMs. @@ -11,6 +11,15 @@ repo_url: https://github.com/argilla-io/distilabel extra: version: provider: mike + social: + - icon: fontawesome/brands/linkedin + link: https://www.linkedin.com/company/argilla-io + - icon: fontawesome/brands/x-twitter + link: https://twitter.com/argilla_io + - icon: fontawesome/brands/youtube + link: https://www.youtube.com/channel/UCAIz8TmvQQrLqbD7sd-5S2A + - icon: fontawesome/brands/slack + link: https://join.slack.com/t/rubrixworkspace/shared_invite/zt-20wllqq29-Z11~kp2SeFYjJ0qevJRiPg extra_css: - stylesheets/extra.css @@ -29,6 +38,7 @@ theme: features: - navigation.sections # Sections are included in the navigation on the left. # - toc.integrate # # Table of contents is integrated on the left; does not appear separately on the right. + - navigation.tabs - header.autohide # header disappears as you scroll - content.code.copy - content.code.annotate @@ -118,74 +128,74 @@ plugins: add_after_page: Learn nav: - - Introduction: "index.md" + - Distilabel: "index.md" - Getting started: - - Installation: "sections/installation.md" - - How-to-Guide: "sections/how_to_guide.md" + - Installation: "sections/installation.md" + - How-to-Guide: "sections/how_to_guide.md" - Learn: - - "sections/learn/index.md" - - Tutorial: - - "sections/learn/tutorial/index.md" - - Step: - - "sections/learn/tutorial/step/index.md" - - GeneratorStep: "sections/learn/tutorial/step/generator_step.md" - - GlobalStep: "sections/learn/tutorial/step/global_step.md" - - Task: - - "sections/learn/tutorial/task/index.md" - - GeneratorTask: "sections/learn/tutorial/task/generator_task.md" - - LLM: "sections/learn/tutorial/llm/index.md" - - Pipeline: "sections/learn/tutorial/pipeline/index.md" - - CLI: "sections/learn/tutorial/cli/index.md" - - Advanced: - - "sections/learn/advanced/index.md" - - Argilla: "sections/learn/advanced/argilla.md" - - Caching: "sections/learn/advanced/caching.md" - - Distiset: "sections/learn/advanced/distiset.md" - - Structured Generation: "sections/learn/advanced/structured_generation.md" - - Using the file system to pass batch data: "sections/learn/advanced/fs_to_pass_data.md" + - "sections/learn/index.md" + - Tutorial: + - "sections/learn/tutorial/index.md" + - Step: + - "sections/learn/tutorial/step/index.md" + - GeneratorStep: "sections/learn/tutorial/step/generator_step.md" + - GlobalStep: "sections/learn/tutorial/step/global_step.md" + - Task: + - "sections/learn/tutorial/task/index.md" + - GeneratorTask: "sections/learn/tutorial/task/generator_task.md" + - LLM: "sections/learn/tutorial/llm/index.md" + - Pipeline: "sections/learn/tutorial/pipeline/index.md" + - CLI: "sections/learn/tutorial/cli/index.md" + - Advanced: + - "sections/learn/advanced/index.md" + - Argilla: "sections/learn/advanced/argilla.md" + - Caching: "sections/learn/advanced/caching.md" + - Distiset: "sections/learn/advanced/distiset.md" + - Structured Generation: "sections/learn/advanced/structured_generation.md" + - Using the file system to pass batch data: "sections/learn/advanced/fs_to_pass_data.md" - Pipeline Samples: - - "sections/pipeline_samples/index.md" - - Examples: "sections/pipeline_samples/examples/index.md" - - Papers: - - "sections/pipeline_samples/papers/index.md" - - DEITA: "sections/pipeline_samples/papers/deita.md" - - Instruction Backtranslation: "sections/pipeline_samples/papers/instruction_backtranslation.md" - - Prometheus 2: "sections/pipeline_samples/papers/prometheus.md" - - UltraFeedback: "sections/pipeline_samples/papers/ultrafeedback.md" + - "sections/pipeline_samples/index.md" + - Examples: "sections/pipeline_samples/examples/index.md" + - Papers: + - "sections/pipeline_samples/papers/index.md" + - DEITA: "sections/pipeline_samples/papers/deita.md" + - Instruction Backtranslation: "sections/pipeline_samples/papers/instruction_backtranslation.md" + - Prometheus 2: "sections/pipeline_samples/papers/prometheus.md" + - UltraFeedback: "sections/pipeline_samples/papers/ultrafeedback.md" - FAQ: "sections/faq.md" - API Reference: - - Pipeline: - - "api/pipeline/index.md" - - Routing Batch Function: "api/pipeline/routing_batch_function.md" - - Typing: "api/pipeline/typing.md" - - Utils: "api/pipeline/utils.md" - - Step: - - "api/step/index.md" - - GeneratorStep: "api/step/generator_step.md" - - GlobalStep: "api/step/global_step.md" - - "@step": "api/step/decorator.md" - - Step Gallery: - - Argilla: "api/step_gallery/argilla.md" - - Columns: "api/step_gallery/columns.md" - - Extra: "api/step_gallery/extra.md" - - Task: - - "api/task/index.md" - - GeneratorTask: "api/task/generator_task.md" - - Task Gallery: "api/task_gallery/index.md" - - LLM: - - "api/llm/index.md" - - LLM Gallery: - - Anthropic: "api/llm/anthropic.md" - - Anyscale: "api/llm/anyscale.md" - - Azure (via OpenAI): "api/llm/azure.md" - - Groq: "api/llm/groq.md" - - Hugging Face: "api/llm/huggingface.md" - - LiteLLM: "api/llm/litellm.md" - - llama.cpp: "api/llm/llamacpp.md" - - Mistral: "api/llm/mistral.md" - - Ollama: "api/llm/ollama.md" - - OpenAI: "api/llm/openai.md" - - Together AI: "api/llm/together.md" - - Google Vertex AI: "api/llm/vertexai.md" - - vLLM: "api/llm/vllm.md" - - CLI: "api/cli.md" + - Pipeline: + - "api/pipeline/index.md" + - Routing Batch Function: "api/pipeline/routing_batch_function.md" + - Typing: "api/pipeline/typing.md" + - Utils: "api/pipeline/utils.md" + - Step: + - "api/step/index.md" + - GeneratorStep: "api/step/generator_step.md" + - GlobalStep: "api/step/global_step.md" + - "@step": "api/step/decorator.md" + - Step Gallery: + - Argilla: "api/step_gallery/argilla.md" + - Columns: "api/step_gallery/columns.md" + - Extra: "api/step_gallery/extra.md" + - Task: + - "api/task/index.md" + - GeneratorTask: "api/task/generator_task.md" + - Task Gallery: "api/task_gallery/index.md" + - LLM: + - "api/llm/index.md" + - LLM Gallery: + - Anthropic: "api/llm/anthropic.md" + - Anyscale: "api/llm/anyscale.md" + - Azure (via OpenAI): "api/llm/azure.md" + - Groq: "api/llm/groq.md" + - Hugging Face: "api/llm/huggingface.md" + - LiteLLM: "api/llm/litellm.md" + - llama.cpp: "api/llm/llamacpp.md" + - Mistral: "api/llm/mistral.md" + - Ollama: "api/llm/ollama.md" + - OpenAI: "api/llm/openai.md" + - Together AI: "api/llm/together.md" + - Google Vertex AI: "api/llm/vertexai.md" + - vLLM: "api/llm/vllm.md" + - CLI: "api/cli.md" From 893cfa3af809984ba1cb1bbb0b20560ccb102355 Mon Sep 17 00:00:00 2001 From: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com> Date: Mon, 10 Jun 2024 17:05:35 +0200 Subject: [PATCH 22/40] Add `set -e` to `install_dependencies.sh` (#713) `set -e` will exit on every non-zero status code --- scripts/install_dependencies.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/install_dependencies.sh b/scripts/install_dependencies.sh index 9344ac472c..4da6ad9dd4 100755 --- a/scripts/install_dependencies.sh +++ b/scripts/install_dependencies.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -e + python_version=$(python -c "import sys; print(sys.version_info[:2])") python -m pip install uv From 23b3b41b4e26b1cc099f93df37887ab63c689b1a Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 10 Jun 2024 17:07:15 +0200 Subject: [PATCH 23/40] Add context to guide the generate sentence pair task if informed (#706) * Add context to guide the generate sentence pair task if informed * Include example of how to add context to generate sentence pairs * Invert order of anchor/context in prompt template --- .../steps/tasks/sentence_transformers.py | 47 +++++++- .../templates/generate-sentence-pair.jinja2 | 7 ++ .../steps/tasks/test_sentence_transformers.py | 104 ++++++++++++++++-- 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/src/distilabel/steps/tasks/sentence_transformers.py b/src/distilabel/steps/tasks/sentence_transformers.py index 12e39eb08e..b1ad50f5e1 100644 --- a/src/distilabel/steps/tasks/sentence_transformers.py +++ b/src/distilabel/steps/tasks/sentence_transformers.py @@ -43,31 +43,36 @@ } POSITIVE_SYSTEM_PROMPT: str = ( - "Your task is to generate a positive sentence given an anchor sentence. The positive" + "Your task is to generate a positive sentence given an anchor sentence.{context} The positive" " sentence has to {action_sentence} the anchor sentence. You must output only one new" " section: `## Positive`." ) POSITIVE_NEGATIVE_SYSTEM_PROMPT: str = ( - "Your task is to generate a positive and a negative sentence given an anchor sentence." + "Your task is to generate a positive and a negative sentence given an anchor sentence.{context}" " The positive sentence has to {action_sentence} the anchor sentence, while the negative" " sentence can use similar words but must not be related to the anchor sentence. You" " must output only two new sections: `## Positive` and `## Negative`." ) +CONTEXT_INTRO: Final[str] = " Take into account the context given." + class GenerateSentencePair(Task): """Generate a positive and negative (optionally) sentences given an anchor sentence. `GenerateSentencePair` is a pre-defined task that given an anchor sentence generates a positive sentence related to the anchor and optionally a negative sentence unrelated - to the anchor. This task is useful to generate training datasets for training embeddings + to the anchor. Optionally, you can give a context to guide the LLM towards more specific + behavior. This task is useful to generate training datasets for training embeddings models. Attributes: triplet: a flag to indicate if the task should generate a triplet of sentences (anchor, positive, negative). Defaults to `False`. action: the action to perform to generate the positive sentence. + context: the context to use for the generation. Can be helpful to guide the LLM + towards more specific context. Not used by default. Input columns: - anchor (`str`): The anchor sentence to generate the positive and negative sentences. @@ -165,10 +170,33 @@ class GenerateSentencePair(Task): result = generate_sentence_pair.process([{"anchor": "What Game of Thrones villain would be the most likely to give you mercy?"}]) ``` + + Generating queries with context (**applies to every action**): + + ```python + from distilabel.steps.tasks import GenerateSentencePair + from distilabel.llms import InferenceEndpointsLLM + + generate_sentence_pair = GenerateSentencePair( + triplet=True, # `False` to generate only positive + action="query", + context="Argilla is an open-source data curation platform for LLMs.", + llm=InferenceEndpointsLLM( + model_id="meta-llama/Meta-Llama-3-70B-Instruct", + tokenizer_id="meta-llama/Meta-Llama-3-70B-Instruct", + ), + input_batch_size=10, + ) + + generate_sentence_pair.load() + + result = generate_sentence_pair.process([{"anchor": "I want to generate queries for my LLM."}]) + ``` """ triplet: bool = False action: GenerationAction + context: str = "" def load(self) -> None: """Loads the Jinja2 template.""" @@ -203,11 +231,20 @@ def format_input(self, input: Dict[str, Any]) -> "ChatType": action_sentence = GENERATION_ACTION_SENTENCES[self.action] system_prompt = ( POSITIVE_NEGATIVE_SYSTEM_PROMPT if self.triplet else POSITIVE_SYSTEM_PROMPT - ).format(action_sentence=action_sentence) + ).format( + action_sentence=action_sentence, + context=CONTEXT_INTRO if self.context else "", + ) return [ {"role": "system", "content": system_prompt}, - {"role": "user", "content": self._template.render(anchor=input["anchor"])}, + { + "role": "user", + "content": self._template.render( + anchor=input["anchor"], + context=self.context if self.context else None, + ), + }, ] @property diff --git a/src/distilabel/steps/tasks/templates/generate-sentence-pair.jinja2 b/src/distilabel/steps/tasks/templates/generate-sentence-pair.jinja2 index 82594f18a8..cac188e101 100644 --- a/src/distilabel/steps/tasks/templates/generate-sentence-pair.jinja2 +++ b/src/distilabel/steps/tasks/templates/generate-sentence-pair.jinja2 @@ -1,3 +1,10 @@ +{% if context is not none -%} +## Context + +{{ context }} + +{% endif -%} + ## Anchor {{ anchor }} diff --git a/tests/unit/steps/tasks/test_sentence_transformers.py b/tests/unit/steps/tasks/test_sentence_transformers.py index 3e50e7e3f1..e63b14bc83 100644 --- a/tests/unit/steps/tasks/test_sentence_transformers.py +++ b/tests/unit/steps/tasks/test_sentence_transformers.py @@ -16,6 +16,7 @@ import pytest from distilabel.steps.tasks.sentence_transformers import ( + CONTEXT_INTRO, POSITIVE_NEGATIVE_SYSTEM_PROMPT, POSITIVE_SYSTEM_PROMPT, GenerateSentencePair, @@ -32,50 +33,56 @@ class TestGenerateSentencePair: ( "paraphrase", True, - POSITIVE_NEGATIVE_SYSTEM_PROMPT.format(action_sentence="paraphrase"), + POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( + action_sentence="paraphrase", context="" + ), ), ( "paraphrase", False, - POSITIVE_SYSTEM_PROMPT.format(action_sentence="paraphrase"), + POSITIVE_SYSTEM_PROMPT.format(action_sentence="paraphrase", context=""), ), ( "semantically-similar", True, POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( - action_sentence="be semantically similar to" + action_sentence="be semantically similar to", context="" ), ), ( "semantically-similar", False, POSITIVE_SYSTEM_PROMPT.format( - action_sentence="be semantically similar to" + action_sentence="be semantically similar to", context="" ), ), ( "query", True, POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( - action_sentence="be a query for" + action_sentence="be a query for", context="" ), ), ( "query", False, - POSITIVE_SYSTEM_PROMPT.format(action_sentence="be a query for"), + POSITIVE_SYSTEM_PROMPT.format( + action_sentence="be a query for", context="" + ), ), ( "answer", True, POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( - action_sentence="be an answer for" + action_sentence="be an answer for", context="" ), ), ( "answer", False, - POSITIVE_SYSTEM_PROMPT.format(action_sentence="be an answer for"), + POSITIVE_SYSTEM_PROMPT.format( + action_sentence="be an answer for", context="" + ), ), ], ) @@ -84,10 +91,89 @@ def test_format_input( ) -> None: task = GenerateSentencePair(llm=DummyLLM(), action=action, triplet=triplet) task.load() + content = "## Anchor\n\nThis is a unit test\n" + assert task.format_input({"anchor": "This is a unit test"}) == [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": content}, + ] + @pytest.mark.parametrize( + "action,triplet,system_prompt", + [ + ( + "paraphrase", + True, + POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( + action_sentence="paraphrase", context=CONTEXT_INTRO + ), + ), + ( + "paraphrase", + False, + POSITIVE_SYSTEM_PROMPT.format( + action_sentence="paraphrase", context=CONTEXT_INTRO + ), + ), + ( + "semantically-similar", + True, + POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( + action_sentence="be semantically similar to", context=CONTEXT_INTRO + ), + ), + ( + "semantically-similar", + False, + POSITIVE_SYSTEM_PROMPT.format( + action_sentence="be semantically similar to", context=CONTEXT_INTRO + ), + ), + ( + "query", + True, + POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( + action_sentence="be a query for", context=CONTEXT_INTRO + ), + ), + ( + "query", + False, + POSITIVE_SYSTEM_PROMPT.format( + action_sentence="be a query for", context=CONTEXT_INTRO + ), + ), + ( + "answer", + True, + POSITIVE_NEGATIVE_SYSTEM_PROMPT.format( + action_sentence="be an answer for", context=CONTEXT_INTRO + ), + ), + ( + "answer", + False, + POSITIVE_SYSTEM_PROMPT.format( + action_sentence="be an answer for", context=CONTEXT_INTRO + ), + ), + ], + ) + def test_format_input_with_context( + self, action: GenerationAction, triplet: bool, system_prompt: str + ) -> None: + context = "This is your context." + task = GenerateSentencePair( + llm=DummyLLM(), + action=action, + triplet=triplet, + context=context, + ) + task.load() + content = f"## Context\n\n{context}\n\n## Anchor\n\nThis is a unit test\n" + # content = f"## Anchor\n\nThis is a unit test\n## Context\n\n{context}" assert task.format_input({"anchor": "This is a unit test"}) == [ {"role": "system", "content": system_prompt}, - {"role": "user", "content": "## Anchor\n\nThis is a unit test\n"}, + {"role": "user", "content": content}, ] @pytest.mark.parametrize( From 1d53ee8b29b32a3dc9d9538126fa3658a5804ad8 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 11 Jun 2024 12:12:02 +0200 Subject: [PATCH 24/40] Add examples to the LLMs to be shown in the components gallery (#714) * Add example for TransformersLLM * Add examples in the LLMs docstrings * Fix typo from code review --- src/distilabel/llms/anthropic.py | 44 +++++++++++++ src/distilabel/llms/anyscale.py | 18 ++++++ src/distilabel/llms/azure.py | 63 +++++++++++++++++++ src/distilabel/llms/cohere.py | 40 ++++++++++++ src/distilabel/llms/groq.py | 40 ++++++++++++ .../llms/huggingface/transformers.py | 15 +++++ src/distilabel/llms/litellm.py | 40 ++++++++++++ src/distilabel/llms/llamacpp.py | 52 +++++++++++++++ src/distilabel/llms/mistral.py | 40 ++++++++++++ src/distilabel/llms/openai.py | 63 +++++++++++++++++++ src/distilabel/llms/together.py | 18 ++++++ src/distilabel/llms/vllm.py | 41 ++++++++++++ 12 files changed, 474 insertions(+) diff --git a/src/distilabel/llms/anthropic.py b/src/distilabel/llms/anthropic.py index af0fdbc76e..c4c8547366 100644 --- a/src/distilabel/llms/anthropic.py +++ b/src/distilabel/llms/anthropic.py @@ -73,6 +73,50 @@ class AnthropicLLM(AsyncLLM): - `timeout`: the maximum time in seconds to wait for a response. Defaults to `600.0`. - `max_retries`: the maximum number of times to retry the request before failing. Defaults to `6`. + + Examples: + + Generate text: + + ```python + from distilabel.llms import AnthropicLLM + + llm = AnthropicLLM(model="claude-3-opus-20240229", api_key="api.key") + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) + ``` + + Generate structured data: + + ```python + from pydantic import BaseModel + from distilabel.llms import AnthropicLLM + + class User(BaseModel): + name: str + last_name: str + id: int + + llm = AnthropicLLM( + model="claude-3-opus-20240229", + api_key="api.key", + structured_output={"schema": User} + ) + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) + ``` """ model: str diff --git a/src/distilabel/llms/anyscale.py b/src/distilabel/llms/anyscale.py index d7eff02043..e1e3fc583d 100644 --- a/src/distilabel/llms/anyscale.py +++ b/src/distilabel/llms/anyscale.py @@ -38,6 +38,24 @@ class AnyscaleLLM(OpenAILLM): `None` if not set. _api_key_env_var: the name of the environment variable to use for the API key. It is meant to be used internally. + + Examples: + + Generate text: + + ```python + from distilabel.llms import AnyscaleLLM + + llm = AnyscaleLLM(model="google/gemma-7b-it", api_key="api.key") + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) + ``` """ base_url: Optional[RuntimeParameter[str]] = Field( diff --git a/src/distilabel/llms/azure.py b/src/distilabel/llms/azure.py index 3fa1f7cde4..2f5dad112a 100644 --- a/src/distilabel/llms/azure.py +++ b/src/distilabel/llms/azure.py @@ -46,6 +46,69 @@ class AzureOpenAILLM(OpenAILLM): Icon: `:simple-microsoftazure:` + + Examples: + + Generate text: + + ```python + from distilabel.llms import AzureOpenAILLM + + llm = AzureOpenAILLM(model="gpt-4-turbo", api_key="api.key") + + llm.load() + + # Synchrounous request + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) + ``` + + Generate text from a custom endpoint following the OpenAI API: + + ```python + from distilabel.llms import AzureOpenAILLM + + llm = AzureOpenAILLM( + model="prometheus-eval/prometheus-7b-v2.0", + base_url=r"http://localhost:8080/v1" + ) + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) + ``` + + Generate structured data: + + ```python + from pydantic import BaseModel + from distilabel.llms import AzureOpenAILLM + + class User(BaseModel): + name: str + last_name: str + id: int + + llm = AzureOpenAILLM( + model="gpt-4-turbo", + api_key="api.key", + structured_output={"schema": User} + ) + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) + ``` """ base_url: Optional[RuntimeParameter[str]] = Field( diff --git a/src/distilabel/llms/cohere.py b/src/distilabel/llms/cohere.py index c4a9c361c5..58ba53895a 100644 --- a/src/distilabel/llms/cohere.py +++ b/src/distilabel/llms/cohere.py @@ -70,6 +70,46 @@ class CohereLLM(AsyncLLM): to `120`. - `client_name`: the name of the client to use for the API requests. Defaults to `"distilabel"`. + + Examples: + + Generate text: + + ```python + from distilabel.llms import CohereLLM + + llm = CohereLLM(model="CohereForAI/c4ai-command-r-plus") + + llm.load() + + # Call the model + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + Generate structured data: + + ```python + from pydantic import BaseModel + from distilabel.llms import CohereLLM + + class User(BaseModel): + name: str + last_name: str + id: int + + llm = CohereLLM( + model="CohereForAI/c4ai-command-r-plus", + api_key="api.key", + structured_output={"schema": User} + ) + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) + ``` """ model: str diff --git a/src/distilabel/llms/groq.py b/src/distilabel/llms/groq.py index 75fb8d5b32..d8ca4c7c93 100644 --- a/src/distilabel/llms/groq.py +++ b/src/distilabel/llms/groq.py @@ -61,6 +61,46 @@ class GroqLLM(AsyncLLM): failing. Defaults to `2`. - `timeout`: the maximum time in seconds to wait for a response from the API. Defaults to `120`. + + Examples: + + Generate text: + + ```python + from distilabel.llms import GroqLLM + + llm = GroqLLM(model="llama3-70b-8192") + + llm.load() + + # Call the model + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + Generate structured data: + + ```python + from pydantic import BaseModel + from distilabel.llms import GroqLLM + + class User(BaseModel): + name: str + last_name: str + id: int + + llm = GroqLLM( + model="llama3-70b-8192", + api_key="api.key", + structured_output={"schema": User} + ) + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) + ``` """ model: str diff --git a/src/distilabel/llms/huggingface/transformers.py b/src/distilabel/llms/huggingface/transformers.py index 1f654b7a7a..1b73eaa469 100644 --- a/src/distilabel/llms/huggingface/transformers.py +++ b/src/distilabel/llms/huggingface/transformers.py @@ -65,6 +65,21 @@ class TransformersLLM(LLM, CudaDevicePlacementMixin): Icon: `:hugging:` + + Examples: + + Generate text: + + ```python + from distilabel.llms import TransformersLLM + + llm = TransformersLLM(model="microsoft/Phi-3-mini-4k-instruct") + + llm.load() + + # Call the model + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + ``` """ model: str diff --git a/src/distilabel/llms/litellm.py b/src/distilabel/llms/litellm.py index c664133012..99087e7da0 100644 --- a/src/distilabel/llms/litellm.py +++ b/src/distilabel/llms/litellm.py @@ -39,6 +39,46 @@ class LiteLLM(AsyncLLM): Runtime parameters: - `verbose`: whether to log the LiteLLM client's logs. Defaults to `False`. + + Examples: + + Generate text: + + ```python + from distilabel.llms import LiteLLM + + llm = LiteLLM(model="gpt-3.5-turbo") + + llm.load() + + # Call the model + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + Generate structured data: + + ```python + from pydantic import BaseModel + from distilabel.llms import LiteLLM + + class User(BaseModel): + name: str + last_name: str + id: int + + llm = LiteLLM( + model="gpt-3.5-turbo", + api_key="api.key", + structured_output={"schema": User} + ) + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) + ``` """ model: str diff --git a/src/distilabel/llms/llamacpp.py b/src/distilabel/llms/llamacpp.py index 94548baa69..aed911f043 100644 --- a/src/distilabel/llms/llamacpp.py +++ b/src/distilabel/llms/llamacpp.py @@ -59,6 +59,58 @@ class LlamaCppLLM(LLM): References: - [`llama.cpp`](https://github.com/ggerganov/llama.cpp) - [`llama-cpp-python`](https://github.com/abetlen/llama-cpp-python) + + Examples: + + Generate text: + + ```python + from pathlib import Path + from distilabel.llms import LlamaCppLLM + + # You can follow along this example downloading the following model running the following + # command in the terminal, that will download the model to the `Downloads` folder: + # curl -L -o ~/Downloads/openhermes-2.5-mistral-7b.Q4_K_M.gguf https://huggingface.co/TheBloke/OpenHermes-2.5-Mistral-7B-GGUF/resolve/main/openhermes-2.5-mistral-7b.Q4_K_M.gguf + + model_path = "Downloads/openhermes-2.5-mistral-7b.Q4_K_M.gguf" + + llm = LlamaCppLLM( + model_path=str(Path.home() / model_path), + n_gpu_layers=-1, # To use the GPU if available + n_ctx=1024, # Set the context size + ) + + llm.load() + + # Call the model + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + ``` + + Generate structured data: + + ```python + from pathlib import Path + from distilabel.llms import LlamaCppLLM + + model_path = "Downloads/openhermes-2.5-mistral-7b.Q4_K_M.gguf" + + class User(BaseModel): + name: str + last_name: str + id: int + + llm = LlamaCppLLM( + model_path=str(Path.home() / model_path), # type: ignore + n_gpu_layers=-1, + n_ctx=1024, + structured_output={"format": "json", "schema": Character}, + ) + + llm.load() + + # Call the model + output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) + ``` """ model_path: RuntimeParameter[FilePath] = Field( diff --git a/src/distilabel/llms/mistral.py b/src/distilabel/llms/mistral.py index 8eafae87f7..99d2d3350b 100644 --- a/src/distilabel/llms/mistral.py +++ b/src/distilabel/llms/mistral.py @@ -60,6 +60,46 @@ class MistralLLM(AsyncLLM): - `timeout`: the maximum time in seconds to wait for a response. Defaults to `120`. - `max_concurrent_requests`: the maximum number of concurrent requests to send. Defaults to `64`. + + Examples: + + Generate text: + + ```python + from distilabel.llms import MistralLLM + + llm = MistralLLM(model="open-mixtral-8x22b") + + llm.load() + + # Call the model + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + Generate structured data: + + ```python + from pydantic import BaseModel + from distilabel.llms import MistralLLM + + class User(BaseModel): + name: str + last_name: str + id: int + + llm = MistralLLM( + model="open-mixtral-8x22b", + api_key="api.key", + structured_output={"schema": User} + ) + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) + ``` """ model: str diff --git a/src/distilabel/llms/openai.py b/src/distilabel/llms/openai.py index a659ae5499..7c93390275 100644 --- a/src/distilabel/llms/openai.py +++ b/src/distilabel/llms/openai.py @@ -60,6 +60,69 @@ class OpenAILLM(AsyncLLM): Icon: `:simple-openai:` + + Examples: + + Generate text: + + ```python + from distilabel.llms import OpenAILLM + + llm = OpenAILLM(model="gpt-4-turbo", api_key="api.key") + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) + ``` + + Generate text from a custom endpoint following the OpenAI API: + + ```python + from distilabel.llms import OpenAILLM + + llm = OpenAILLM( + model="prometheus-eval/prometheus-7b-v2.0", + base_url=r"http://localhost:8080/v1" + ) + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) + ``` + + Generate structured data: + + ```python + from pydantic import BaseModel + from distilabel.llms import OpenAILLM + + class User(BaseModel): + name: str + last_name: str + id: int + + llm = OpenAILLM( + model="gpt-4-turbo", + api_key="api.key", + structured_output={"schema": User} + ) + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) + ``` """ model: str diff --git a/src/distilabel/llms/together.py b/src/distilabel/llms/together.py index 28b9d23571..638243a03e 100644 --- a/src/distilabel/llms/together.py +++ b/src/distilabel/llms/together.py @@ -37,6 +37,24 @@ class TogetherLLM(OpenAILLM): used, or `None` if not set. _api_key_env_var: the name of the environment variable to use for the API key. It is meant to be used internally. + + Examples: + + Generate text: + + ```python + from distilabel.llms import AnyscaleLLM + + llm = TogetherLLM(model="mistralai/Mixtral-8x7B-Instruct-v0.1", api_key="api.key") + + llm.load() + + # Synchronous request + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + + # Asynchronous request + output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) + ``` """ base_url: Optional[RuntimeParameter[str]] = Field( diff --git a/src/distilabel/llms/vllm.py b/src/distilabel/llms/vllm.py index 373fb241df..de8ea44fd7 100644 --- a/src/distilabel/llms/vllm.py +++ b/src/distilabel/llms/vllm.py @@ -72,6 +72,47 @@ class vLLM(LLM, CudaDevicePlacementMixin): Runtime parameters: - `extra_kwargs`: additional dictionary of keyword arguments that will be passed to the `LLM` class of `vllm` library. + + Examples: + + Generate text: + + ```python + from distilabel.llms import vLLM + + # You can pass a custom chat_template to the model + llm = vLLM( + model="prometheus-eval/prometheus-7b-v2.0", + chat_template="[INST] {{ messages[0]['content'] }}\\n{{ messages[1]['content'] }}[/INST]", + ) + + llm.load() + + # Call the model + output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) + ``` + + Generate structured data: + + ```python + from pathlib import Path + from distilabel.llms import vLLM + + class User(BaseModel): + name: str + last_name: str + id: int + + llm = vLLM( + model="prometheus-eval/prometheus-7b-v2.0" + structured_output={"format": "json", "schema": Character}, + ) + + llm.load() + + # Call the model + output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) + ``` """ model: str From a0d7e93caef299dd452920f0f8e177e6e4bb9c7e Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 11 Jun 2024 17:22:22 +0200 Subject: [PATCH 25/40] Gather HF_TOKEN internally when calling `Distiset.push_to_hub` if token is None. (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a way to automatically gather the HF_TOKEN when calling distiset.push_to_hub and mode constant value to distilabel.utils module * Update src/distilabel/distiset.py Co-authored-by: Gabriel Martín Blázquez * Refactor function to obtain huggingface token and move it to it's module --------- Co-authored-by: Gabriel Martín Blázquez --- src/distilabel/distiset.py | 7 +++ .../llms/huggingface/inference_endpoints.py | 18 ++----- src/distilabel/utils/huggingface.py | 53 +++++++++++++++++++ 3 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 src/distilabel/utils/huggingface.py diff --git a/src/distilabel/distiset.py b/src/distilabel/distiset.py index 3538bccd37..f5e8cc7b3a 100644 --- a/src/distilabel/distiset.py +++ b/src/distilabel/distiset.py @@ -33,6 +33,7 @@ size_categories_parser, ) from distilabel.utils.files import list_files_in_dir +from distilabel.utils.huggingface import get_hf_token DISTISET_CONFIG_FOLDER: Final[str] = "distiset_configs" PIPELINE_CONFIG_FILENAME: Final[str] = "pipeline.yaml" @@ -81,7 +82,13 @@ def push_to_hub( Whether to generate a dataset card or not. Defaults to True. **kwargs: Additional keyword arguments to pass to the `push_to_hub` method of the `datasets.Dataset` object. + + Raises: + ValueError: If no token is provided and couldn't be retrieved automatically. """ + if token is None: + token = get_hf_token(self.__class__.__name__, "token") + for name, dataset in self.items(): dataset.push_to_hub( repo_id=repo_id, diff --git a/src/distilabel/llms/huggingface/inference_endpoints.py b/src/distilabel/llms/huggingface/inference_endpoints.py index 015c022b1b..73b13ee4d0 100644 --- a/src/distilabel/llms/huggingface/inference_endpoints.py +++ b/src/distilabel/llms/huggingface/inference_endpoints.py @@ -16,7 +16,6 @@ import os import random import warnings -from pathlib import Path from typing import TYPE_CHECKING, Any, List, Optional, Union from pydantic import ( @@ -33,6 +32,10 @@ from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter from distilabel.steps.tasks.typing import FormattedInput, Grammar, StandardInput +from distilabel.utils.huggingface import ( + _INFERENCE_ENDPOINTS_API_KEY_ENV_VAR_NAME, + get_hf_token, +) from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -41,9 +44,6 @@ from transformers import PreTrainedTokenizer -_INFERENCE_ENDPOINTS_API_KEY_ENV_VAR_NAME = "HF_TOKEN" - - class InferenceEndpointsLLM(AsyncLLM): """InferenceEndpoints LLM implementation running the async API client. @@ -207,7 +207,6 @@ def load(self) -> None: # noqa: C901 from huggingface_hub import ( AsyncInferenceClient, InferenceClient, - constants, get_inference_endpoint, ) except ImportError as ie: @@ -217,14 +216,7 @@ def load(self) -> None: # noqa: C901 ) from ie if self.api_key is None: - if not Path(constants.HF_TOKEN_PATH).exists(): - raise ValueError( - f"To use `{self.__class__.__name__}` an API key must be provided via" - " `api_key` attribute or runtime parameter, set the environment variable" - f" `{self._api_key_env_var}` or use the `huggingface-hub` CLI to login" - " with `huggingface-cli login`." - ) - self.api_key = SecretStr(open(constants.HF_TOKEN_PATH).read().strip()) + self.api_key = SecretStr(get_hf_token(self.__class__.__name__, "api_key")) if self.model_id is not None: client = InferenceClient() diff --git a/src/distilabel/utils/huggingface.py b/src/distilabel/utils/huggingface.py new file mode 100644 index 0000000000..7a637a831c --- /dev/null +++ b/src/distilabel/utils/huggingface.py @@ -0,0 +1,53 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 os +from pathlib import Path +from typing import Final + +from huggingface_hub import constants + +_INFERENCE_ENDPOINTS_API_KEY_ENV_VAR_NAME: Final[str] = "HF_TOKEN" + + +def get_hf_token(cls_name: str, token_arg: str) -> str: + """Get the token for the hugging face API. + + Tries to extract it from the environment variable, if it is not found + it tries to read it from the file using 'huggingface_hub', + and if not possible raises a ValueError. + + Args: + cls_name: Name of the class/function that requires the token. + token_arg: Argument name to use in the error message, normally + is "token" or "api_key". + + Raises: + ValueError: If the token is not found in the file. + + Returns: + The token for the hugging face API. + """ + token = os.getenv(_INFERENCE_ENDPOINTS_API_KEY_ENV_VAR_NAME) + if token is None: + if not Path(constants.HF_TOKEN_PATH).exists(): + raise ValueError( + f"To use `{cls_name}` an API key must be provided via" + f" `{token_arg}`, set the environment variable" + f" `{_INFERENCE_ENDPOINTS_API_KEY_ENV_VAR_NAME}` or use the `huggingface-hub` CLI to login" + " with `huggingface-cli login`." + ) + with open(constants.HF_TOKEN_PATH) as f: + token = f.read().strip() + return token From 0e8c75242d91fe6e1f482c18e1d08a7639bbed82 Mon Sep 17 00:00:00 2001 From: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com> Date: Wed, 12 Jun 2024 10:09:39 +0200 Subject: [PATCH 26/40] Implement "Improving Text Embeddings with LLMs" (#683) * Set `input` as optional in `format_output` * Implement "Improving Text Embeddings with LLMs" (WIP) * Implement "Improving Text Embeddings with LLMs" (WIP) * Add `model_name` at the end of each batch * Move `text_embeddings.py` to `improving_text_embeddings.py` * Fix `re.sub` to also capture `\t` and `\r` * Add `MonolingualTripletGenerator` and `BitextRetrievalGenerator` * Move all `templates` from `str` to `jinja2` files * Update class naming and imports * Add some docstrings and fix `jinja2` file paths * Fix `prompt` accross tasks * Add missing docstrings * Fix `process` method in `EmbeddingTaskGenerator` * Add unit tests for `...Generator` tasks * Add remaining unit tests * Remove duplicated imports in `distilabel.steps.tasks` * Add examples in docstrings and add notes --- src/distilabel/steps/tasks/__init__.py | 16 + src/distilabel/steps/tasks/base.py | 19 +- .../steps/tasks/improving_text_embeddings.py | 941 ++++++++++++++++++ .../bitext-retrieval.jinja2 | 13 + .../brainstorming/text-classification.jinja2 | 6 + .../brainstorming/text-matching-long.jinja2 | 7 + .../brainstorming/text-matching-short.jinja2 | 8 + .../brainstorming/text-retrieval.jinja2 | 11 + .../long-text-matching.jinja2 | 12 + .../monolingual-triplet.jinja2 | 10 + .../short-text-matching.jinja2 | 12 + .../text-classification.jinja2 | 15 + .../text-retrieval.jinja2 | 17 + .../tasks/test_improving_text_embeddings.py | 406 ++++++++ tests/unit/test_imports.py | 7 + 15 files changed, 1494 insertions(+), 6 deletions(-) create mode 100644 src/distilabel/steps/tasks/improving_text_embeddings.py create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/bitext-retrieval.jinja2 create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-classification.jinja2 create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-matching-long.jinja2 create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-matching-short.jinja2 create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-retrieval.jinja2 create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/long-text-matching.jinja2 create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/monolingual-triplet.jinja2 create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/short-text-matching.jinja2 create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/text-classification.jinja2 create mode 100644 src/distilabel/steps/tasks/templates/improving_text_embeddings/text-retrieval.jinja2 create mode 100644 tests/unit/steps/tasks/test_improving_text_embeddings.py diff --git a/src/distilabel/steps/tasks/__init__.py b/src/distilabel/steps/tasks/__init__.py index 72e2216e71..b2456d7824 100644 --- a/src/distilabel/steps/tasks/__init__.py +++ b/src/distilabel/steps/tasks/__init__.py @@ -23,6 +23,15 @@ from distilabel.steps.tasks.evol_quality.base import EvolQuality from distilabel.steps.tasks.generate_embeddings import GenerateEmbeddings from distilabel.steps.tasks.genstruct import Genstruct +from distilabel.steps.tasks.improving_text_embeddings import ( + BitextRetrievalGenerator, + EmbeddingTaskGenerator, + GenerateLongTextMatchingData, + GenerateShortTextMatchingData, + GenerateTextClassificationData, + GenerateTextRetrievalData, + MonolingualTripletGenerator, +) from distilabel.steps.tasks.instruction_backtranslation import ( InstructionBacktranslation, ) @@ -47,6 +56,13 @@ "EvolQuality", "GenerateEmbeddings", "Genstruct", + "BitextRetrievalGenerator", + "EmbeddingTaskGenerator", + "GenerateLongTextMatchingData", + "GenerateShortTextMatchingData", + "GenerateTextClassificationData", + "GenerateTextRetrievalData", + "MonolingualTripletGenerator", "InstructionBacktranslation", "PairRM", "PrometheusEval", diff --git a/src/distilabel/steps/tasks/base.py b/src/distilabel/steps/tasks/base.py index 2c19d8c8a4..fda1e1e248 100644 --- a/src/distilabel/steps/tasks/base.py +++ b/src/distilabel/steps/tasks/base.py @@ -70,7 +70,9 @@ def load(self) -> None: @abstractmethod def format_output( - self, output: Union[str, None], input: Dict[str, Any] + self, + output: Union[str, None], + input: Union[Dict[str, Any], None] = None, ) -> Dict[str, Any]: """Abstract method to format the outputs of the task. It needs to receive an output as a string, and generates a Python dictionary with the outputs of the task. In @@ -80,7 +82,9 @@ def format_output( pass def _format_outputs( - self, outputs: "GenerateOutput", inputs: List[Dict[str, Any]] + self, + outputs: "GenerateOutput", + inputs: Union[List[Dict[str, Any]], None] = None, ) -> List[Dict[str, Any]]: """Formats the outputs of the task using the `format_output` method. If the output is `None` (i.e. the LLM failed to generate a response), then the outputs will be @@ -93,8 +97,11 @@ def _format_outputs( Returns: A list containing a dictionary with the outputs of the task for each input. """ + if inputs is None: + inputs = [None] # type: ignore + formatted_outputs = [] - for output, input in zip(outputs, inputs * len(outputs)): + for output, input in zip(outputs, inputs * len(outputs)): # type: ignore try: formatted_output = self.format_output(output, input) formatted_output = self._maybe_add_raw_output( @@ -109,7 +116,7 @@ def _format_outputs( return formatted_outputs def _output_on_failure( - self, output: Union[str, None], input: Dict[str, Any] + self, output: Union[str, None], input: Union[Dict[str, Any], None] = None ) -> Dict[str, Any]: """In case of failure to format the output, this method will return a dictionary including a new field `distilabel_meta` with the raw output of the LLM. @@ -189,14 +196,14 @@ def process(self, inputs: StepInput) -> "StepOutput": # type: ignore if self.group_generations: combined = combine_dicts(*formatted_outputs) task_outputs.append( - {**input, "model_name": self.llm.model_name, **combined} + {**input, **combined, "model_name": self.llm.model_name} ) continue # Create a row per generation for formatted_output in formatted_outputs: task_outputs.append( - {**input, "model_name": self.llm.model_name, **formatted_output} + {**input, **formatted_output, "model_name": self.llm.model_name} ) yield task_outputs diff --git a/src/distilabel/steps/tasks/improving_text_embeddings.py b/src/distilabel/steps/tasks/improving_text_embeddings.py new file mode 100644 index 0000000000..0e91354274 --- /dev/null +++ b/src/distilabel/steps/tasks/improving_text_embeddings.py @@ -0,0 +1,941 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 random +import re +import sys +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Literal, Optional, Union + +if sys.version_info < (3, 9): + import importlib_resources +else: + import importlib.resources as importlib_resources + +from jinja2 import Template +from pydantic import Field, PrivateAttr +from typing_extensions import override + +from distilabel.steps.tasks.base import GeneratorTask, Task +from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.typing import GeneratorStepOutput + + +# BASE CLASSES +class _JSONFormatter(ABC): + """Abstract class that sets the `outputs` property and `format_output` method, assuming + that the output is a JSON string with the keys specified in the `keys` property. So on, + this class is intended to be used whenever we get a JSON string as the `LLM` output with + a set of `keys` we know are there. + + Note: + At the moment this abstract class is only intended to be used for the tasks defined + below based on the output generated by those. Also note that this is not a replacement + for neither the `StructuredGeneration` task nor for the `structured_output` argument + of an `LLM` subclass. + """ + + @property + @abstractmethod + def keys(self) -> List[str]: + """Contains the `keys` that will be parsed from the `LLM` output into a Python dict.""" + ... + + @property + def outputs(self) -> List[str]: + """Contains the output columns produced by the `process` method of the task. In this + case, it consists of the `keys` (i.e. the JSON keys) and the `model_name`. + """ + return self.keys + ["model_name"] + + def format_output( + self, output: Union[str, None], input: Union[Dict[str, Any], None] = None + ) -> Dict[str, Any]: + """Method to parse the JSON output into a Python dictionary based on the `keys` property. + + Args: + output: The JSON string output produced by the `LLM`. + input: The input dictionary that was used to generate the output. + + Returns: + A Python dictionary with the parsed output based on the `keys` property. + """ + if output is None: + return {key: None for key in self.keys} + + def escape_backslashes_in_values(s): + # Regular expression to match the key-value pairs in the dictionary + pattern = re.compile(r'(".*?":\s*")(.*?)(",?)', re.DOTALL) + + def replace_backslashes(match): + return ( + match.group(1) + + re.sub( + r"(? None: + """Loads the Jinja2 template and sets the random seed.""" + super().load() + + random.seed(self.seed) + + _path = str( + importlib_resources.files("distilabel") + / "steps" + / "tasks" + / "templates" + / "improving_text_embeddings" + / f"{self._template_name}.jinja2" # type: ignore + ) + + self._template = Template(open(_path).read()) + + @property + def inputs(self) -> List[str]: + """Contains the input columns expected by the `process` method of the task. In this + case, it consists of the `task`; ideally produced in a previous task which should be + preferrably `EmbeddingTaskGenerator` (as per the original implementation).""" + return ["task"] + + +class _EmbeddingDataGenerator(_JSONFormatter, GeneratorTask, ABC): + """Base class for the subtasks related to embedding data generation as presented in the + paper "Improving Text Embeddings with Large Language Models" that generate data without + an input i.e. `GeneratorStep` or `GeneratorTask`. This class includes a pre-defined `load` + method to load a Jinja2 template based on the `_template_name` private attribute (to be set + in each of the subclasses), assuming that the `prompt` property only expects the `task`, while + keeping the `format_input` as an abstract method to be implemented in the subclasses. + + Attributes: + seed: The random seed to be set in case there's any sampling within the `format_input` method. + _template: The Jinja2 template to be rendered within the `format_input` method with the + provided arguments. + _template_name: The name of the Jinja2 template file within the + `distilabel/steps/tasks/templates/improving_text_embeddings` directory. + """ + + seed: int = 42 + + _template: Union[Template, None] = PrivateAttr(...) + _template_name: str = PrivateAttr(...) + + def load(self) -> None: + """Loads the Jinja2 template and sets the random seed.""" + super().load() + + random.seed(self.seed) + + _path = str( + importlib_resources.files("distilabel") + / "steps" + / "tasks" + / "templates" + / "improving_text_embeddings" + / f"{self._template_name}.jinja2" # type: ignore + ) + + self._template = Template(open(_path).read()) + + @property + @abstractmethod + def prompt(self) -> ChatType: + """The prompt to be used for the generation step, ideally rendering the `_template`.""" + ... + + @override + def process(self, offset: int = 0) -> GeneratorStepOutput: # type: ignore + """Method to run the `LLM` generation with the `prompt`, as well as formatting the + outputs accordingly for the task i.e. via the `_JSONFormatter` inheritance. So on, the + `LLM` ideally will be prompted to produce JSON content and then the `format_output` + method will parse it into a Python dictionary based on the `keys` property. + + Args: + offset: The offset to start the generation from. Defaults to 0. + + Yields: + The output rows and a boolean indicating if it's the last batch or not. + """ + formatted_inputs = [self.prompt] + outputs = self.llm.generate( + inputs=formatted_inputs, + num_generations=self.num_generations, + **self.llm.generation_kwargs, # type: ignore + ) + + task_outputs = [] + for input_outputs in outputs: + formatted_outputs = self._format_outputs(input_outputs) # type: ignore + for formatted_output in formatted_outputs: + task_outputs.append( + { + **formatted_output, + "model_name": self.llm.model_name, + } + ) + yield task_outputs, True + + +# IMPLEMENTED TASKS +class EmbeddingTaskGenerator(GeneratorTask): + """Generate task descriptions for embedding-related tasks using an `LLM`. + + `EmbeddingTaskGenerator` is a `GeneratorTask` that doesn't receieve any input besides the + provided attributes that generates task descriptions for embedding-related tasks using a + pre-defined prompt based on the `category` attribute. The `category` attribute should be + one of the following: + + - `text-retrieval`: Generate task descriptions for text retrieval tasks. + - `text-matching-short`: Generate task descriptions for short text matching tasks. + - `text-matching-long`: Generate task descriptions for long text matching tasks. + - `text-classification`: Generate task descriptions for text classification tasks. + + Attributes: + category: The category of the task to be generated, which can either be `text-retrieval`, + `text-matching-short`, `text-matching-long`, or `text-classification`. + flatten_tasks: Whether to flatten the tasks i.e. since a list of tasks is generated by the + `LLM`, this attribute indicates whether to flatten the list or not. Defaults to `False`, + meaning that running this task with `num_generations=1` will return a `distilabel.Distiset` + with one row only containing a list with around 20 tasks; otherwise, if set to `True`, it + will return a `distilabel.Distiset` with around 20 rows, each containing one task. + + References: + - [Improving Text Embeddings with Large Language Models](https://arxiv.org/abs/2401.00368) + + Examples: + + Generate embedding tasks for text retrieval: + + ```python + from distilabel.pipeline import Pipeline + from distilabel.steps.tasks import EmbeddingTaskGenerator + + with Pipeline("my-pipeline") as pipeline: + task = EmbeddingTaskGenerator( + category="text-retrieval", + flatten_tasks=True, + llm=..., # LLM instance + ) + + ... + + task >> ... + ``` + """ + + category: Literal[ + "text-retrieval", + "text-matching-short", + "text-matching-long", + "text-classification", + ] + flatten_tasks: bool = False + + _template: Union[Template, None] = PrivateAttr(...) + + def load(self) -> None: + """Loads the Jinja2 template.""" + super().load() + + _path = str( + importlib_resources.files("distilabel") + / "steps" + / "tasks" + / "templates" + / "improving_text_embeddings" + / "brainstorming" + / f"{self.category}.jinja2" + ) + + self._template = Template(open(_path).read()) + + @property + def prompt(self) -> ChatType: # type: ignore + """The prompt to be used in the `process` method, rendering the `_template` with the + provided args / attributes. + """ + return [{"role": "user", "content": self._template.render().strip()}] # type: ignore + + @override + def process(self, offset: int = 0) -> GeneratorStepOutput: # type: ignore + """Method to run the `LLM` generation with the `prompt`, as well as formatting the + outputs accordingly for the task i.e. via the `_JSONFormatter` inheritance. So on, the + `LLM` ideally will be prompted to produce JSON content and then the `format_output` + method will parse it into a Python dictionary based on the `keys` property. + + Args: + offset: The offset to start the generation from. Defaults to 0. + + Yields: + The output rows and a boolean indicating if it's the last batch or not. + """ + formatted_inputs = [self.prompt] + outputs = self.llm.generate( + inputs=formatted_inputs, + num_generations=self.num_generations, + **self.llm.generation_kwargs, # type: ignore + ) + + task_outputs = [] + for input_outputs in outputs: + formatted_outputs = self._format_outputs(input_outputs) # type: ignore + for formatted_output in formatted_outputs: + if isinstance(formatted_output["tasks"], list) and self.flatten_tasks: + tasks = formatted_output.pop("tasks") + task_outputs.extend( + [ + { + "task": task, + **formatted_output, + "model_name": self.llm.model_name, + } + for task in tasks + ] + ) + else: + if self.flatten_tasks: + formatted_output["task"] = formatted_output.pop("tasks") + task_outputs.append( + {**formatted_output, "model_name": self.llm.model_name} + ) + yield task_outputs, True + + @property + def outputs(self) -> List[str]: + """Contains the output columns produced by the `process` method of the task. In this + case, it consists of the `tasks` or `task` (depending on the `flatten_tasks` attribute) + and the `model_name`. + """ + return ["tasks" if not self.flatten_tasks else "task", "model_name"] + + def format_output( + self, output: Union[str, None], input: Union[Dict[str, Any], None] = None + ) -> Dict[str, Any]: + """Method to parse the JSON output into a Python dictionary based on the `keys` property. + + Args: + output: The JSON string output produced by the `LLM`. + input: The input dictionary that was used to generate the output. + + Returns: + A Python dictionary with the parsed output based on the `keys` property. + """ + try: + if output is not None: + output = eval(output) + except Exception: + pass + return {"tasks": output} + + +class GenerateTextRetrievalData(_EmbeddingDataGeneration): + """Generate text retrieval data with an `LLM` to later on train an embedding model. + + `GenerateTextRetrievalData` is a `Task` that generates text retrieval data with an + `LLM` to later on train an embedding model. The task is based on the paper "Improving + Text Embeddings with Large Language Models" and the data is generated based on the + provided attributes, or randomly sampled if not provided. + + Note: + Ideally this task should be used with `EmbeddingTaskGenerator` with `flatten_tasks=True` + with the `category="text-retrieval"`; so that the `LLM` generates a list of tasks that + are flattened so that each row contains a single task for the text-retrieval category. + + Attributes: + language: The language of the data to be generated, which can be any of the languages + retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf. + query_type: The type of query to be generated, which can be `extremely long-tail`, `long-tail`, + or `common`. Defaults to `None`, meaning that it will be randomly sampled. + query_length: The length of the query to be generated, which can be `less than 5 words`, `5 to 15 words`, + or `at least 10 words`. Defaults to `None`, meaning that it will be randomly sampled. + difficulty: The difficulty of the query to be generated, which can be `high school`, `college`, or `PhD`. + Defaults to `None`, meaning that it will be randomly sampled. + clarity: The clarity of the query to be generated, which can be `clear`, `understandable with some effort`, + or `ambiguous`. Defaults to `None`, meaning that it will be randomly sampled. + num_words: The number of words in the query to be generated, which can be `50`, `100`, `200`, `300`, `400`, or `500`. + Defaults to `None`, meaning that it will be randomly sampled. + seed: The random seed to be set in case there's any sampling within the `format_input` method. + + References: + - [Improving Text Embeddings with Large Language Models](https://arxiv.org/abs/2401.00368) + + Examples: + + Generate synthetic text retrieval data for training embedding models: + + ```python + from distilabel.pipeline import Pipeline + from distilabel.steps.tasks import EmbeddingTaskGenerator, GenerateTextRetrievalData + + with Pipeline("my-pipeline") as pipeline: + task = EmbeddingTaskGenerator( + category="text-retrieval", + flatten_tasks=True, + llm=..., # LLM instance + ) + + generate = GenerateTextRetrievalData( + language="English", + query_type="common", + query_length="5 to 15 words", + difficulty="high school", + clarity="clear", + num_words=100, + llm=..., # LLM instance + ) + + task >> generate + ``` + """ + + language: str = Field( + default="English", + description="The languages are retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf", + ) + + query_type: Optional[Literal["extremely long-tail", "long-tail", "common"]] = None + query_length: Optional[ + Literal["less than 5 words", "5 to 15 words", "at least 10 words"] + ] = None + difficulty: Optional[Literal["high school", "college", "PhD"]] = None + clarity: Optional[ + Literal["clear", "understandable with some effort", "ambiguous"] + ] = None + num_words: Optional[Literal[50, 100, 200, 300, 400, 500]] = None + + _template_name: str = PrivateAttr(default="text-retrieval") + + def format_input(self, input: Dict[str, Any]) -> ChatType: + """Method to format the input based on the `task` and the provided attributes, or just + randomly sampling those if not provided. This method will render the `_template` with + the provided arguments and return an OpenAI formatted chat i.e. a `ChatType`, assuming that + there's only one turn, being from the user with the content being the rendered `_template`. + + Args: + input: The input dictionary containing the `task` to be used in the `_template`. + + Returns: + A list with a single chat containing the user's message with the rendered `_template`. + """ + return [ + { + "role": "user", + "content": self._template.render( # type: ignore + task=input["task"], + language=self.language, + query_type=self.query_type + or random.choice(["extremely long-tail", "long-tail", "common"]), + query_length=self.query_length + or random.choice( + ["less than 5 words", "5 to 15 words", "at least 10 words"] + ), + difficulty=self.difficulty + or random.choice(["high school", "college", "PhD"]), + clarity=self.clarity + or random.choice( + ["clear", "understandable with some effort", "ambiguous"] + ), + num_words=self.num_words + or random.choice([50, 100, 200, 300, 400, 500]), + ).strip(), + } + ] + + @property + def keys(self) -> List[str]: + """Contains the `keys` that will be parsed from the `LLM` output into a Python dict.""" + return [ + "user_query", + "positive_document", + "hard_negative_document", + ] + + +class GenerateShortTextMatchingData(_EmbeddingDataGeneration): + """Generate short text matching data with an `LLM` to later on train an embedding model. + + `GenerateShortTextMatchingData` is a `Task` that generates short text matching data with an + `LLM` to later on train an embedding model. The task is based on the paper "Improving + Text Embeddings with Large Language Models" and the data is generated based on the + provided attributes, or randomly sampled if not provided. + + Note: + Ideally this task should be used with `EmbeddingTaskGenerator` with `flatten_tasks=True` + with the `category="text-matching-short"`; so that the `LLM` generates a list of tasks that + are flattened so that each row contains a single task for the text-matching-short category. + + Attributes: + language: The language of the data to be generated, which can be any of the languages + retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf. + seed: The random seed to be set in case there's any sampling within the `format_input` method. + Note that in this task the `seed` has no effect since there are no sampling params. + + References: + - [Improving Text Embeddings with Large Language Models](https://arxiv.org/abs/2401.00368) + + Examples: + + Generate synthetic short text matching data for training embedding models: + + ```python + from distilabel.pipeline import Pipeline + from distilabel.steps.tasks import EmbeddingTaskGenerator, GenerateShortTextMatchingData + + with Pipeline("my-pipeline") as pipeline: + task = EmbeddingTaskGenerator( + category="text-matching-short", + flatten_tasks=True, + llm=..., # LLM instance + ) + + generate = GenerateShortTextMatchingData( + language="English", + llm=..., # LLM instance + ) + + task >> generate + ``` + """ + + language: str = Field( + default="English", + description="The languages are retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf", + ) + + _template_name: str = PrivateAttr(default="short-text-matching") + + def format_input(self, input: Dict[str, Any]) -> ChatType: + """Method to format the input based on the `task` and the provided attributes, or just + randomly sampling those if not provided. This method will render the `_template` with + the provided arguments and return an OpenAI formatted chat i.e. a `ChatType`, assuming that + there's only one turn, being from the user with the content being the rendered `_template`. + + Args: + input: The input dictionary containing the `task` to be used in the `_template`. + + Returns: + A list with a single chat containing the user's message with the rendered `_template`. + """ + return [ + { + "role": "user", + "content": self._template.render( # type: ignore + task=input["task"], + language=self.language, + ).strip(), + } + ] + + @property + def keys(self) -> List[str]: + """Contains the `keys` that will be parsed from the `LLM` output into a Python dict.""" + return ["input", "positive_document"] + + +class GenerateLongTextMatchingData(_EmbeddingDataGeneration): + """Generate long text matching data with an `LLM` to later on train an embedding model. + + `GenerateLongTextMatchingData` is a `Task` that generates long text matching data with an + `LLM` to later on train an embedding model. The task is based on the paper "Improving + Text Embeddings with Large Language Models" and the data is generated based on the + provided attributes, or randomly sampled if not provided. + + Note: + Ideally this task should be used with `EmbeddingTaskGenerator` with `flatten_tasks=True` + with the `category="text-matching-long"`; so that the `LLM` generates a list of tasks that + are flattened so that each row contains a single task for the text-matching-long category. + + Attributes: + language: The language of the data to be generated, which can be any of the languages + retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf. + seed: The random seed to be set in case there's any sampling within the `format_input` method. + Note that in this task the `seed` has no effect since there are no sampling params. + + References: + - [Improving Text Embeddings with Large Language Models](https://arxiv.org/abs/2401.00368) + + Examples: + + Generate synthetic long text matching data for training embedding models: + + ```python + from distilabel.pipeline import Pipeline + from distilabel.steps.tasks import EmbeddingTaskGenerator, GenerateLongTextMatchingData + + with Pipeline("my-pipeline") as pipeline: + task = EmbeddingTaskGenerator( + category="text-matching-long", + flatten_tasks=True, + llm=..., # LLM instance + ) + + generate = GenerateLongTextMatchingData( + language="English", + llm=..., # LLM instance + ) + + task >> generate + ``` + """ + + language: str = Field( + default="English", + description="The languages are retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf", + ) + + _template_name: str = PrivateAttr(default="long-text-matching") + + def format_input(self, input: Dict[str, Any]) -> ChatType: + """Method to format the input based on the `task` and the provided attributes, or just + randomly sampling those if not provided. This method will render the `_template` with + the provided arguments and return an OpenAI formatted chat i.e. a `ChatType`, assuming that + there's only one turn, being from the user with the content being the rendered `_template`. + + Args: + input: The input dictionary containing the `task` to be used in the `_template`. + + Returns: + A list with a single chat containing the user's message with the rendered `_template`. + """ + return [ + { + "role": "user", + "content": self._template.render( # type: ignore + task=input["task"], + language=self.language, + ).strip(), + } + ] + + @property + def keys(self) -> List[str]: + """Contains the `keys` that will be parsed from the `LLM` output into a Python dict.""" + return ["input", "positive_document"] + + +class GenerateTextClassificationData(_EmbeddingDataGeneration): + """Generate text classification data with an `LLM` to later on train an embedding model. + + `GenerateTextClassificationData` is a `Task` that generates text classification data with an + `LLM` to later on train an embedding model. The task is based on the paper "Improving + Text Embeddings with Large Language Models" and the data is generated based on the + provided attributes, or randomly sampled if not provided. + + Note: + Ideally this task should be used with `EmbeddingTaskGenerator` with `flatten_tasks=True` + with the `category="text-classification"`; so that the `LLM` generates a list of tasks that + are flattened so that each row contains a single task for the text-classification category. + + Attributes: + language: The language of the data to be generated, which can be any of the languages + retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf. + difficulty: The difficulty of the query to be generated, which can be `high school`, `college`, or `PhD`. + Defaults to `None`, meaning that it will be randomly sampled. + clarity: The clarity of the query to be generated, which can be `clear`, `understandable with some effort`, + or `ambiguous`. Defaults to `None`, meaning that it will be randomly sampled. + seed: The random seed to be set in case there's any sampling within the `format_input` method. + + References: + - [Improving Text Embeddings with Large Language Models](https://arxiv.org/abs/2401.00368) + + Examples: + + Generate synthetic text classification data for training embedding models: + + ```python + from distilabel.pipeline import Pipeline + from distilabel.steps.tasks import EmbeddingTaskGenerator, GenerateTextClassificationData + + with Pipeline("my-pipeline") as pipeline: + task = EmbeddingTaskGenerator( + category="text-classification", + flatten_tasks=True, + llm=..., # LLM instance + ) + + generate = GenerateTextClassificationData( + language="English", + difficulty="high school", + clarity="clear", + llm=..., # LLM instance + ) + + task >> generate + ``` + """ + + language: str = Field( + default="English", + description="The languages are retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf", + ) + + difficulty: Optional[Literal["high school", "college", "PhD"]] = None + clarity: Optional[ + Literal["clear", "understandable with some effort", "ambiguous"] + ] = None + + _template_name: str = PrivateAttr(default="text-classification") + + def format_input(self, input: Dict[str, Any]) -> ChatType: + """Method to format the input based on the `task` and the provided attributes, or just + randomly sampling those if not provided. This method will render the `_template` with + the provided arguments and return an OpenAI formatted chat i.e. a `ChatType`, assuming that + there's only one turn, being from the user with the content being the rendered `_template`. + + Args: + input: The input dictionary containing the `task` to be used in the `_template`. + + Returns: + A list with a single chat containing the user's message with the rendered `_template`. + """ + return [ + { + "role": "user", + "content": self._template.render( # type: ignore + task=input["task"], + language=self.language, + difficulty=self.difficulty + or random.choice(["high school", "college", "PhD"]), + clarity=self.clarity + or random.choice( + ["clear", "understandable with some effort", "ambiguous"] + ), + ).strip(), + } + ] + + @property + def keys(self) -> List[str]: + """Contains the `keys` that will be parsed from the `LLM` output into a Python dict.""" + return ["input_text", "label", "misleading_label"] + + +class MonolingualTripletGenerator(_EmbeddingDataGenerator): + """Generate monolingual triplets with an `LLM` to later on train an embedding model. + + `MonolingualTripletGenerator` is a `GeneratorTask` that generates monolingual triplets with an + `LLM` to later on train an embedding model. The task is based on the paper "Improving + Text Embeddings with Large Language Models" and the data is generated based on the + provided attributes, or randomly sampled if not provided. + + Attributes: + language: The language of the data to be generated, which can be any of the languages + retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf. + unit: The unit of the data to be generated, which can be `sentence`, `phrase`, or `passage`. + Defaults to `None`, meaning that it will be randomly sampled. + difficulty: The difficulty of the query to be generated, which can be `elementary school`, `high school`, or `college`. + Defaults to `None`, meaning that it will be randomly sampled. + high_score: The high score of the query to be generated, which can be `4`, `4.5`, or `5`. + Defaults to `None`, meaning that it will be randomly sampled. + low_score: The low score of the query to be generated, which can be `2.5`, `3`, or `3.5`. + Defaults to `None`, meaning that it will be randomly sampled. + seed: The random seed to be set in case there's any sampling within the `format_input` method. + + Examples: + + Generate monolingual triplets for training embedding models: + + ```python + from distilabel.pipeline import Pipeline + from distilabel.steps.tasks import MonolingualTripletGenerator + + with Pipeline("my-pipeline") as pipeline: + task = MonolingualTripletGenerator( + language="English", + unit="sentence", + difficulty="elementary school", + high_score="4", + low_score="2.5", + llm=..., + ) + + ... + + task >> ... + ``` + """ + + language: str = Field( + default="English", + description="The languages are retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf", + ) + + unit: Optional[Literal["sentence", "phrase", "passage"]] = None + difficulty: Optional[Literal["elementary school", "high school", "college"]] = None + high_score: Optional[Literal["4", "4.5", "5"]] = None + low_score: Optional[Literal["2.5", "3", "3.5"]] = None + + _template_name: str = PrivateAttr(default="monolingual-triplet") + + @property + def prompt(self) -> ChatType: + """Contains the `prompt` to be used in the `process` method, rendering the `_template`; and + formatted as an OpenAI formatted chat i.e. a `ChatType`, assuming that there's only one turn, + being from the user with the content being the rendered `_template`. + """ + return [ + { + "role": "user", + "content": self._template.render( # type: ignore + language=self.language, + unit=self.unit or random.choice(["sentence", "phrase", "passage"]), + difficulty=self.difficulty + or random.choice(["elementary school", "high school", "college"]), + high_score=self.high_score or random.choice(["4", "4.5", "5"]), + low_score=self.low_score or random.choice(["2.5", "3", "3.5"]), + ).strip(), + } + ] # type: ignore + + @property + def keys(self) -> List[str]: + """Contains the `keys` that will be parsed from the `LLM` output into a Python dict.""" + return ["S1", "S2", "S3"] + + +class BitextRetrievalGenerator(_EmbeddingDataGenerator): + """Generate bitext retrieval data with an `LLM` to later on train an embedding model. + + `BitextRetrievalGenerator` is a `GeneratorTask` that generates bitext retrieval data with an + `LLM` to later on train an embedding model. The task is based on the paper "Improving + Text Embeddings with Large Language Models" and the data is generated based on the + provided attributes, or randomly sampled if not provided. + + Attributes: + source_language: The source language of the data to be generated, which can be any of the languages + retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf. + target_language: The target language of the data to be generated, which can be any of the languages + retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf. + unit: The unit of the data to be generated, which can be `sentence`, `phrase`, or `passage`. + Defaults to `None`, meaning that it will be randomly sampled. + difficulty: The difficulty of the query to be generated, which can be `elementary school`, `high school`, or `college`. + Defaults to `None`, meaning that it will be randomly sampled. + high_score: The high score of the query to be generated, which can be `4`, `4.5`, or `5`. + Defaults to `None`, meaning that it will be randomly sampled. + low_score: The low score of the query to be generated, which can be `2.5`, `3`, or `3.5`. + Defaults to `None`, meaning that it will be randomly sampled. + seed: The random seed to be set in case there's any sampling within the `format_input` method. + + Examples: + + Generate bitext retrieval data for training embedding models: + + ```python + from distilabel.pipeline import Pipeline + from distilabel.steps.tasks import BitextRetrievalGenerator + + with Pipeline("my-pipeline") as pipeline: + task = BitextRetrievalGenerator( + source_language="English", + target_language="Spanish", + unit="sentence", + difficulty="elementary school", + high_score="4", + low_score="2.5", + llm=..., + ) + + ... + + task >> ... + ``` + """ + + source_language: str = Field( + default="English", + description="The languages are retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf", + ) + target_language: str = Field( + default=..., + description="The languages are retrieved from the list of XLM-R in the Appendix A of https://aclanthology.org/2020.acl-main.747.pdf", + ) + + unit: Optional[Literal["sentence", "phrase", "passage"]] = None + difficulty: Optional[Literal["elementary school", "high school", "college"]] = None + high_score: Optional[Literal["4", "4.5", "5"]] = None + low_score: Optional[Literal["2.5", "3", "3.5"]] = None + + _template_name: str = PrivateAttr(default="bitext-retrieval") + + @property + def prompt(self) -> ChatType: + """Contains the `prompt` to be used in the `process` method, rendering the `_template`; and + formatted as an OpenAI formatted chat i.e. a `ChatType`, assuming that there's only one turn, + being from the user with the content being the rendered `_template`. + """ + return [ + { + "role": "user", + "content": self._template.render( # type: ignore + source_language=self.source_language, + target_language=self.target_language, + unit=self.unit or random.choice(["sentence", "phrase", "passage"]), + difficulty=self.difficulty + or random.choice(["elementary school", "high school", "college"]), + high_score=self.high_score or random.choice(["4", "4.5", "5"]), + low_score=self.low_score or random.choice(["2.5", "3", "3.5"]), + ).strip(), + } + ] # type: ignore + + @property + def keys(self) -> List[str]: + """Contains the `keys` that will be parsed from the `LLM` output into a Python dict.""" + return ["S1", "S2", "S3"] diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/bitext-retrieval.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/bitext-retrieval.jinja2 new file mode 100644 index 0000000000..1cf238015f --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/bitext-retrieval.jinja2 @@ -0,0 +1,13 @@ +Write a {{ unit }} triple with one {{ unit }} in {{ source_language }} and two {{ unit }}s in {{ target_language }} with varying translation qualities in JSON format. + +The triple is denotes as ("S1", "S2", "S3"). The translation quality score ranges from 1 to 5, with higher scores are better. + +Please adhere to the following guidelines: + - The values of "S1" is a string in {{ source_language }}, the value of "S2" and "S3" are strings in {{ target_language }}. + - There should be some word overlaps between "S2" and "S3". + - The translation quality score of "S2" with respect to "S1" should be {{ high_score }}. + - The translation quality score of "S3" with respect to "S1" should be {{ low_score }}. + - "S3" should be grammatical and fluent, but contain some keyword or number translation errors, or miss some information, or contain some redundant information. + - "S1" requires {{ difficulty }} level education to understand and should be diverse in terms of topic and length. + +Your output must always be a JSON object only with three keys "S1", "S2" and "S3", do not explain yourself or output anything else. Be creative! diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-classification.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-classification.jinja2 new file mode 100644 index 0000000000..3501b9332d --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-classification.jinja2 @@ -0,0 +1,6 @@ +Brainstorm a list of potentially useful text classification tasks. + +Please adhere to the following guidelines: + - Tasks should cover a diverse range of domains and task types. + +Your output must always be a python list of strings only, with about 20 elements, and each element corresponds to a distinct text classification task in one sentence. Do not explain yourself or output anything else. Be creative! diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-matching-long.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-matching-long.jinja2 new file mode 100644 index 0000000000..0090ef2af4 --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-matching-long.jinja2 @@ -0,0 +1,7 @@ +Brainstorm a list of text matching tasks where the queries are long documents. + +Here are a few examples: + - Given a document that supports a debatable argument, find another document that contains opposite arguments. + - Provided a lengthy business proposal, retrieve competitive business strategies in the same industry. + +Your output must always be a python list of strings only, with about 20 elements, and each element corresponds to a distinct task in one sentence. Do not explain yourself or output anything else. Be creative! diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-matching-short.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-matching-short.jinja2 new file mode 100644 index 0000000000..cf42fddae5 --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-matching-short.jinja2 @@ -0,0 +1,8 @@ +Brainstorm a list of text matching tasks where both the queries and the groundtruth documents are very short (one or two sentences, even a short phrase). + +Here are a few examples: + - Given a scientific paper title, retrieve the title of papers that cite the given paper. + - Match a word with its definition. + - Provided a notable person's name, identify their occupation or achievement. + +Your output must always be a python list of strings only, with about 20 elements, and each element corresponds to a distinct task in one sentence. Do not explain yourself or output anything else. Be creative! diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-retrieval.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-retrieval.jinja2 new file mode 100644 index 0000000000..464ed0e763 --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/brainstorming/text-retrieval.jinja2 @@ -0,0 +1,11 @@ +Brainstorm a list of potentially useful text retrieval tasks. + +Here are a few examples for your reference: + - Provided a scientific claim as query, retrieve documents that help verify or refute the claim. + - Search for documents that answers a FAQ-style query on children's nutrition. + +Please adhere to the following guidelines: + - Specify what the query is, and what the desired documents are. + - Each retrieval task should cover a wide range of queries, and should not be too specific. + +Your output should always be a python list of strings only, with about 20 elements, and each element corresponds to a distinct retrieval task in one sentence. Do not explain yourself or output anything else. Be creative! diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/long-text-matching.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/long-text-matching.jinja2 new file mode 100644 index 0000000000..cd8bf1922a --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/long-text-matching.jinja2 @@ -0,0 +1,12 @@ +You have been assigned a text matching task: {{ task }} + +Your mission is to write one example for this task in JSON format. The JSON object must contain the following keys: + - "input": a string, a random input specified by the task. + - "positive_document": a string, a relevant document for the "input" according to the task. + +Please adhere to the following guidelines: + - The values of all fields should be in {{ language }}. + - Both the "input" and "positive_document" should be long documents (at least 300 words), avoid substantial word overlaps, otherwise the task would be too easy. + - The "input" and "positive_document" should be independent of each other. + +Your output must always be a JSON object only, do not explain yourself or output anything else. Be creative! diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/monolingual-triplet.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/monolingual-triplet.jinja2 new file mode 100644 index 0000000000..585d618620 --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/monolingual-triplet.jinja2 @@ -0,0 +1,10 @@ +Write a {{ unit }} triple with varying semantic similarity scores in JSON format. The semantic similarity score ranges from 1 to 5, with 1 denotes least similar and 5 denotes most similar. + +Please adhere to the following guidelines: + - The keys in JSON are "S1", "S2", and "S3", the values are all strings in {{ language }}, do not add any other keys. + - There should be some word overlaps between all three {{ unit }}s. + - The similarity score between S1 and S2 should be {{ high_score }}. + - The similarity score between S1 and S3 should be {{ low_score }}. + - The {{ unit }}s require {{ difficulty }} level education to understand and should be diverse in terms of topic and length. + +Your output must always be a JSON object only with three keys "S1", "S2" and "S3", do not explain yourself or output anything else. Be creative! diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/short-text-matching.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/short-text-matching.jinja2 new file mode 100644 index 0000000000..90b08f9e57 --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/short-text-matching.jinja2 @@ -0,0 +1,12 @@ +You have been assigned a text matching task: {{ task }} + +Your mission is to write one example for this task in JSON format. The JSON object must contain the following keys: + - "input": a string, a random input specified by the task. + - "positive_document": a string, a relevant document for the "input" according to the task. + +Please adhere to the following guidelines: + - The values of all fields should be in {{ language }}. + - Both the "input" and "positive_document" should be very short (a sentence or a phrase), avoid substantial word overlaps, otherwise the task would be too easy. + - The "input" and "positive_document" should be independent of each other. + +Your output must always be a JSON object only, do not explain yourself or output anything else. Be creative! diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/text-classification.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/text-classification.jinja2 new file mode 100644 index 0000000000..74a184bc56 --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/text-classification.jinja2 @@ -0,0 +1,15 @@ +You have been assigned a text classification task: {{ task }} + +Your mission is to write one text classification example for this task in JSON format. The JSON object must contain the following keys: + - "input_text": a string, the input text specified by the classification task. + - "label": a string, the correct label of the input text. + - "misleading_label": a string, an incorrect label that is related to the task. + +Please adhere to the following guidelines: + - The "input_text" should be diverse in expression. + - The "misleading_label" must be a valid label for the given task, but not as appropriate as the "label" for the "input_text". + - The values for all fields should be in {{ language }}. + - Avoid including the values of the "label" and "misleading_label" fields in the "input_text", that would make the task too easy. + - The "input_text" is {{ clarity }} and requires {{ difficulty }} level education to comprehend. + +Your output must always be a JSON object only, do not explain yourself or output anything else. Be creative! diff --git a/src/distilabel/steps/tasks/templates/improving_text_embeddings/text-retrieval.jinja2 b/src/distilabel/steps/tasks/templates/improving_text_embeddings/text-retrieval.jinja2 new file mode 100644 index 0000000000..c76ac8a698 --- /dev/null +++ b/src/distilabel/steps/tasks/templates/improving_text_embeddings/text-retrieval.jinja2 @@ -0,0 +1,17 @@ +You have been assigned a retrieval task: {{ task }} + +Your mission is to write one text retrieval example for this task in JSON format. The JSON object must contain the following keys: + - "user_query": a string, a random user search query specified by the retrieval task. + - "positive_document": a string, a relevant document for the user query. + - "hard_negative_document": a string, a hard negative document that only appears relevant to the query. + +Please adhere to the following guidelines: + - The "user_query" should be {{ query_type }}, {{ query_length }}, {{ clarity }}, and diverse in topic. + - All documents must be created independent of the query. Avoid copying the query verbatim. It's acceptable if some parts of the "positive_document" are not topically related to the query. + - All documents should be at least {{ num_words}} words long. + - The "hard_negative_document" contains some useful information, but it should be less useful or comprehensive compared to the "positive_document". + - Both the query and documents should be in {{ language }}. + - Do not provide any explanation in any document on why it is relevant or not relevant to the query. + - Both the query and documents require {{ difficulty }} level education to understand. + +Your output must always be a JSON object only, do not explain yourself or output anything else. Be creative! diff --git a/tests/unit/steps/tasks/test_improving_text_embeddings.py b/tests/unit/steps/tasks/test_improving_text_embeddings.py new file mode 100644 index 0000000000..8ab9b2fd51 --- /dev/null +++ b/tests/unit/steps/tasks/test_improving_text_embeddings.py @@ -0,0 +1,406 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 json +from typing import Any, List + +import pytest +from distilabel.llms import LLM +from distilabel.llms.typing import GenerateOutput +from distilabel.pipeline.local import Pipeline +from distilabel.steps.tasks.improving_text_embeddings import ( + BitextRetrievalGenerator, + EmbeddingTaskGenerator, + GenerateLongTextMatchingData, + GenerateShortTextMatchingData, + GenerateTextClassificationData, + GenerateTextRetrievalData, + MonolingualTripletGenerator, +) +from distilabel.steps.tasks.typing import ChatType + + +class MockLLM(LLM): + output: str + + def load(self) -> None: + pass + + @property + def model_name(self) -> str: + return "test" + + def generate( # type: ignore + self, inputs: List[ChatType], num_generations: int = 1 + ) -> List[GenerateOutput]: + return [[self.output] for _ in range(num_generations)] + + +class TestEmbeddingTaskGenerator: + @pytest.mark.parametrize( + "category", + [ + "text-retrieval", + "text-matching-short", + "text-matching-long", + "text-classification", + ], + ) + @pytest.mark.parametrize("flatten_tasks", [True, False]) + def test_process(self, category: str, flatten_tasks: bool) -> None: + task = EmbeddingTaskGenerator( + name="embedding_task_generator", + category=category, # type: ignore + flatten_tasks=flatten_tasks, + add_raw_output=False, + llm=MockLLM(output="[ 'A', 'B', 'C' ]"), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + + assert task.outputs == ["tasks" if not flatten_tasks else "task", "model_name"] + + result = ( + ([{"tasks": ["A", "B", "C"], "model_name": "test"}], True) + if not flatten_tasks + else ( + [ + {"task": "A", "model_name": "test"}, + {"task": "B", "model_name": "test"}, + {"task": "C", "model_name": "test"}, + ], + True, + ) + ) + assert next(task.process()) == result + + +class TestBitextRetrievalGenerator: + @pytest.mark.parametrize( + "task_kwargs", + [ + { + "source_language": "English", + "target_language": "French", + "unit": "sentence", + "difficulty": "elementary school", + "high_score": "4", + "low_score": "2.5", + } + ], + ) + def test_prompt(self, task_kwargs: Any) -> None: + task = BitextRetrievalGenerator( + name="bitext_retrieval_generator", + **task_kwargs, + add_raw_output=False, + llm=MockLLM(output=json.dumps({"S1": "A", "S2": "B", "S3": "C"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + + assert all( + task.prompt[-1]["content"].__contains__(v) for _, v in task_kwargs.items() + ) + + def test_process(self) -> None: + task = BitextRetrievalGenerator( + name="bitext_retrieval_generator", + source_language="English", + target_language="French", + add_raw_output=False, + llm=MockLLM(output=json.dumps({"S1": "A", "S2": "B", "S3": "C"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + + assert task.outputs == ["S1", "S2", "S3", "model_name"] + + assert next(task.process()) == ( + [{"S1": "A", "S2": "B", "S3": "C", "model_name": "test"}], + True, + ) + + def test_reproducibility(self) -> None: + unique_prompts = set() + for _ in range(10): + task = BitextRetrievalGenerator( + name="bitext_retrieval_generator", + source_language="English", + target_language="French", + add_raw_output=False, + seed=42, + llm=MockLLM(output=json.dumps({"S1": "A", "S2": "B", "S3": "C"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + + unique_prompts.add(task.prompt[-1]["content"]) + + assert len(unique_prompts) == 1 + + +class TestMonolingualTripletGenerator: + @pytest.mark.parametrize( + "task_kwargs", + [ + { + "language": "English", + "unit": "sentence", + "difficulty": "elementary school", + "high_score": "4", + "low_score": "2.5", + } + ], + ) + def test_prompt(self, task_kwargs: Any) -> None: + task = MonolingualTripletGenerator( + name="monolingual_triplet_generator", + **task_kwargs, + add_raw_output=False, + llm=MockLLM(output=json.dumps({"S1": "A", "S2": "B", "S3": "C"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + assert all( + task.prompt[-1]["content"].__contains__(v) for _, v in task_kwargs.items() + ) + + def test_process(self) -> None: + task = MonolingualTripletGenerator( + name="monolingual_triplet_generator", + language="English", + add_raw_output=False, + llm=MockLLM(output=json.dumps({"S1": "A", "S2": "B", "S3": "C"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + assert task.outputs == ["S1", "S2", "S3", "model_name"] + assert next(task.process()) == ( + [{"S1": "A", "S2": "B", "S3": "C", "model_name": "test"}], + True, + ) + + def test_reproducibility(self) -> None: + unique_prompts = set() + for _ in range(10): + task = MonolingualTripletGenerator( + name="monolingual_triplet_generator", + language="English", + add_raw_output=False, + seed=42, + llm=MockLLM(output=json.dumps({"S1": "A", "S2": "B", "S3": "C"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + unique_prompts.add(task.prompt[-1]["content"]) + assert len(unique_prompts) == 1 + + +class TestGenerateLongTextMatchingData: + def test_format_input(self) -> None: + task = GenerateLongTextMatchingData( + name="generate_long_text_matching_data", + language="English", + add_raw_output=False, + llm=MockLLM(output=json.dumps({"input": "A", "positive_document": "B"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + + assert task.format_input({"task": "A"})[-1]["content"].startswith( + "You have been assigned a text matching task: A" + ) + + def test_process(self) -> None: + task = GenerateLongTextMatchingData( + name="generate_long_text_matching_data", + language="English", + add_raw_output=False, + llm=MockLLM(output=json.dumps({"input": "A", "positive_document": "B"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + + assert task.outputs == ["input", "positive_document", "model_name"] + + assert next(task.process(inputs=[{"task": "A"}])) == [ + {"task": "A", "input": "A", "positive_document": "B", "model_name": "test"} + ] + + +class TestGenerateShortTextMatchingData: + def test_format_input(self) -> None: + task = GenerateShortTextMatchingData( + name="generate_short_text_matching_data", + language="English", + add_raw_output=False, + llm=MockLLM(output=json.dumps({"input": "A", "positive_document": "B"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + assert task.format_input({"task": "A"})[-1]["content"].startswith( + "You have been assigned a text matching task: A" + ) + + def test_process(self) -> None: + task = GenerateShortTextMatchingData( + name="generate_short_text_matching_data", + language="English", + add_raw_output=False, + llm=MockLLM(output=json.dumps({"input": "A", "positive_document": "B"})), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + assert task.outputs == ["input", "positive_document", "model_name"] + assert next(task.process(inputs=[{"task": "A"}])) == [ + {"task": "A", "input": "A", "positive_document": "B", "model_name": "test"} + ] + + def test_reproducibility(self) -> None: + unique_prompts = set() + for _ in range(10): + task = GenerateShortTextMatchingData( + name="generate_short_text_matching_data", + language="English", + add_raw_output=False, + seed=42, + llm=MockLLM( + output=json.dumps({"input": "A", "positive_document": "B"}) + ), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + unique_prompts.add(task.format_input({"task": "A"})[-1]["content"]) + + assert len(unique_prompts) == 1 + + +class TestGenerateTextClassificationData: + def test_format_input(self) -> None: + task = GenerateTextClassificationData( + name="generate_text_classification_data", + language="English", + add_raw_output=False, + llm=MockLLM( + output=json.dumps( + {"input_text": "A", "label": "B", "misleading_label": "C"} + ) + ), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + assert task.format_input({"task": "A"})[-1]["content"].startswith( + "You have been assigned a text classification task: A" + ) + + def test_process(self) -> None: + task = GenerateTextClassificationData( + name="generate_text_classification_data", + language="English", + add_raw_output=False, + llm=MockLLM( + output=json.dumps( + {"input_text": "A", "label": "B", "misleading_label": "C"} + ) + ), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + assert task.outputs == ["input_text", "label", "misleading_label", "model_name"] + assert next(task.process(inputs=[{"task": "A"}])) == [ + { + "task": "A", + "input_text": "A", + "label": "B", + "misleading_label": "C", + "model_name": "test", + } + ] + + def test_reproducibility(self) -> None: + unique_prompts = set() + for _ in range(10): + task = GenerateTextClassificationData( + name="generate_text_classification_data", + language="English", + add_raw_output=False, + seed=42, + llm=MockLLM( + output=json.dumps( + {"input_text": "A", "label": "B", "misleading_label": "C"} + ) + ), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + unique_prompts.add(task.format_input({"task": "A"})[-1]["content"]) + + assert len(unique_prompts) == 1 + + +class TestGenerateTextRetrievalData: + def test_format_input(self) -> None: + task = GenerateTextRetrievalData( + name="generate_text_retrieval_data", + language="English", + add_raw_output=False, + llm=MockLLM( + output=json.dumps( + { + "user_query": "A", + "positive_document": "B", + "hard_negative_document": "C", + } + ) + ), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + assert task.format_input({"task": "A"})[-1]["content"].startswith( + "You have been assigned a retrieval task: A" + ) + + def test_process(self) -> None: + task = GenerateTextRetrievalData( + name="generate_text_retrieval_data", + language="English", + add_raw_output=False, + llm=MockLLM( + output=json.dumps( + { + "user_query": "A", + "positive_document": "B", + "hard_negative_document": "C", + } + ) + ), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + assert task.outputs == [ + "user_query", + "positive_document", + "hard_negative_document", + "model_name", + ] + assert next(task.process(inputs=[{"task": "A"}])) == [ + { + "task": "A", + "user_query": "A", + "positive_document": "B", + "hard_negative_document": "C", + "model_name": "test", + } + ] diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py index 07eb3e5b63..e20e186c8e 100644 --- a/tests/unit/test_imports.py +++ b/tests/unit/test_imports.py @@ -74,6 +74,13 @@ def test_imports() -> None: EvolInstructGenerator, GenerateEmbeddings, Genstruct, + BitextRetrievalGenerator, + EmbeddingTaskGenerator, + GenerateLongTextMatchingData, + GenerateShortTextMatchingData, + GenerateTextClassificationData, + GenerateTextRetrievalData, + MonolingualTripletGenerator, InstructionBacktranslation, PairRM, PrometheusEval, From d32d6647858ba4152674d8757e4948a68334753c Mon Sep 17 00:00:00 2001 From: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com> Date: Wed, 12 Jun 2024 12:34:17 +0200 Subject: [PATCH 27/40] Add `ArenaHard` benchmark and `ArenaHardResults` step (#670) * Fix `ChatGeneration.format_input` exception * Bump `datasets` to 2.16.0 or higher To be able to efficiently use the cache via `load_dataset` whenever there's no connection * Add `benchmarks/arena_hard.py` (WIP) * Update `_get_hf_dataset_info` error message * Add `ArenaHard` and `ArenaHardResults` docstrings * Catch `ImportError` on `ArenaHardResults.load` * Add `ArenaHard` and `ArenaHardEval` imports * Add `arena-hard` extras * Install `arena-hard` extra in `test.yml` * Update `arena-hard` extra dependencies * Fix circular import in `arena_hard.py` * Apply suggestions from code review * Add missing examples in docstrings & fix type-hints * Add some future TODOs * Update `install_dependencies.sh` to install `arena-hard` extra * Add `ArenaHard` and `ArenaHardResults` unit tests --- pyproject.toml | 7 +- scripts/install_dependencies.sh | 2 +- .../steps/generators/huggingface.py | 2 +- src/distilabel/steps/tasks/__init__.py | 3 + .../steps/tasks/benchmarks/__init__.py | 13 + .../steps/tasks/benchmarks/arena_hard.py | 320 ++++++++++++++++++ src/distilabel/steps/tasks/text_generation.py | 2 +- tests/unit/steps/tasks/benchmarks/__init__.py | 13 + .../steps/tasks/benchmarks/test_arena_hard.py | 172 ++++++++++ tests/unit/test_imports.py | 2 + 10 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 src/distilabel/steps/tasks/benchmarks/__init__.py create mode 100644 src/distilabel/steps/tasks/benchmarks/arena_hard.py create mode 100644 tests/unit/steps/tasks/benchmarks/__init__.py create mode 100644 tests/unit/steps/tasks/benchmarks/test_arena_hard.py diff --git a/pyproject.toml b/pyproject.toml index 80fe4714ef..1e29af9d94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,9 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ - "datasets >= 2.14.0", + # Bump `datasets` to support `load_dataset` from cache + # Ref https://github.com/huggingface/datasets/releases/tag/2.16.0 + "datasets >= 2.16.0", "httpx >= 0.25.2", "importlib-resources >= 6.1.1; python_version < '3.9'", "Jinja2 >= 3.1.2", @@ -83,6 +85,9 @@ outlines = ["outlines >= 0.0.40"] vertexai = ["google-cloud-aiplatform >= 1.38.0"] vllm = ["vllm >= 0.4.0", "outlines == 0.0.34", "filelock >= 3.13.4"] +# Other optional dependencies +arena-hard = ["pandas", "numpy", "scikit-learn"] + [project.urls] Documentation = "https://distilabel.argilla.io/" Issues = "https://github.com/argilla/distilabel/issues" diff --git a/scripts/install_dependencies.sh b/scripts/install_dependencies.sh index 4da6ad9dd4..3372bd60fd 100755 --- a/scripts/install_dependencies.sh +++ b/scripts/install_dependencies.sh @@ -6,7 +6,7 @@ python_version=$(python -c "import sys; print(sys.version_info[:2])") python -m pip install uv -uv pip install --system -e ".[dev,tests,anthropic,argilla,cohere,groq,hf-inference-endpoints,hf-transformers,litellm,llama-cpp,ollama,openai,outlines,vertexai]" +uv pip install --system -e ".[dev,tests,anthropic,arena-hard,argilla,cohere,groq,hf-inference-endpoints,hf-transformers,litellm,llama-cpp,ollama,openai,outlines,vertexai]" if [ "${python_version}" != "(3, 8)" ]; then uv pip install --system -e .[mistralai,instructor] fi diff --git a/src/distilabel/steps/generators/huggingface.py b/src/distilabel/steps/generators/huggingface.py index 96bbbb4882..f07084f0b5 100644 --- a/src/distilabel/steps/generators/huggingface.py +++ b/src/distilabel/steps/generators/huggingface.py @@ -225,7 +225,7 @@ def _dataset_info(self) -> Dict[str, DatasetInfo]: class LoadHubDataset(LoadDataFromHub): def __init__(self, **data: Any) -> None: warnings.warn( - "`LoadHubDataset` is deprecated and will be removed in version 1.3.0, use `LoadFromHub` instead.", + "`LoadHubDataset` is deprecated and will be removed in version 1.3.0, use `LoadDataFromHub` instead.", DeprecationWarning, stacklevel=2, ) diff --git a/src/distilabel/steps/tasks/__init__.py b/src/distilabel/steps/tasks/__init__.py index b2456d7824..d1bccf08f2 100644 --- a/src/distilabel/steps/tasks/__init__.py +++ b/src/distilabel/steps/tasks/__init__.py @@ -13,6 +13,7 @@ # limitations under the License. from distilabel.steps.tasks.base import GeneratorTask, Task +from distilabel.steps.tasks.benchmarks.arena_hard import ArenaHard, ArenaHardResults from distilabel.steps.tasks.complexity_scorer import ComplexityScorer from distilabel.steps.tasks.evol_instruct.base import EvolInstruct from distilabel.steps.tasks.evol_instruct.evol_complexity.base import EvolComplexity @@ -46,6 +47,8 @@ from distilabel.steps.tasks.ultrafeedback import UltraFeedback __all__ = [ + "ArenaHard", + "ArenaHardResults", "GeneratorTask", "Task", "ComplexityScorer", diff --git a/src/distilabel/steps/tasks/benchmarks/__init__.py b/src/distilabel/steps/tasks/benchmarks/__init__.py new file mode 100644 index 0000000000..2598794f29 --- /dev/null +++ b/src/distilabel/steps/tasks/benchmarks/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. diff --git a/src/distilabel/steps/tasks/benchmarks/arena_hard.py b/src/distilabel/steps/tasks/benchmarks/arena_hard.py new file mode 100644 index 0000000000..26fa695e0b --- /dev/null +++ b/src/distilabel/steps/tasks/benchmarks/arena_hard.py @@ -0,0 +1,320 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 re +from typing import Any, Dict, List, Optional, Union + +from typing_extensions import override + +from distilabel.steps import GlobalStep, StepInput +from distilabel.steps.tasks.base import Task +from distilabel.steps.tasks.typing import ChatType +from distilabel.steps.typing import StepOutput + + +class ArenaHard(Task): + """This `Task` is based on the "From Live Data to High-Quality Benchmarks: The + Arena-Hard Pipeline" paper that presents Arena Hard, which is a benchmark for + instruction-tuned LLMs that contains 500 challenging user queries. GPT-4 is used + as the judge to compare the model responses against a baseline model, which defaults + to `gpt-4-0314`. + + Note: + Arena-Hard-Auto has the highest correlation and separability to Chatbot Arena + among popular open-ended LLM benchmarks. + + Input columns: + - instruction (`str`): The instruction to evaluate the responses. + - generations (`List[str]`): The responses generated by two, and only two, LLMs. + + Output columns: + - evaluation (`str`): The evaluation of the responses generated by the LLMs. + - score (`str`): The score extracted from the evaluation. + - model_name (`str`): The model name used to generate the evaluation. + + Categories: + - benchmark + + References: + - [From Live Data to High-Quality Benchmarks: The Arena-Hard Pipeline](https://lmsys.org/blog/2024-04-19-arena-hard/) + - [`arena-hard-auto`](https://github.com/lm-sys/arena-hard-auto/tree/main) + + Examples: + + Evaluate two assistant responses for a given instruction using Arean Hard prompts: + + ```python + from distilabel.pipeline import Pipeline + from distilabel.steps import CombineColumns, LoadDataFromDicts + from distilabel.steps.tasks import ArenaHard, TextGeneration + + with Pipeline() as pipeline: + load_data = LoadDataFromDicts( + data=[{"instruction": "What is the capital of France?"}], + ) + + text_generation_a = TextGeneration( + llm=..., # LLM instance + output_mappings={"model_name": "generation_model"}, + ) + + text_generation_b = TextGeneration( + llm=..., # LLM instance + output_mappings={"model_name": "generation_model"}, + ) + + combine = CombineColumns( + columns=["generation", "generation_model"], + output_columns=["generations", "generation_models"], + ) + + arena_hard = ArenaHard( + llm=..., # LLM instance + ) + + load_data >> [text_generation_a, text_generation_b] >> combine >> arena_hard + ``` + """ + + @property + def inputs(self) -> List[str]: + """The inputs required by this task are the `instruction` and the `generations`, + which are the responses generated by two, and only two, LLMs.""" + return ["instruction", "generations"] + + def format_input(self, input: Dict[str, Any]) -> ChatType: + """This method formats the input data as a `ChatType` using the prompt defined + by the Arena Hard benchmark, which consists on a `system_prompt` plus a template + for the user first message that contains the `instruction` and both `generations`. + """ + return [ + { + "role": "system", + "content": "Please act as an impartial judge and evaluate the quality of the responses provided by two AI assistants to the user prompt displayed below. You will be given assistant A's answer and assistant B's answer. Your job is to evaluate which assistant's answer is better.\n\nBegin your evaluation by generating your own answer to the prompt. You must provide your answers before judging any answers.\n\nWhen evaluating the assistants' answers, compare both assistants' answers with your answer. You must identify and correct any mistakes or inaccurate information.\n\nThen consider if the assistant's answers are helpful, relevant, and concise. Helpful means the answer correctly responds to the prompt or follows the instructions. Note when user prompt has any ambiguity or more than one interpretation, it is more helpful and appropriate to ask for clarifications or more information from the user than providing an answer based on assumptions. Relevant means all parts of the response closely connect or are appropriate to what is being asked. Concise means the response is clear and not verbose or excessive.\n\nThen consider the creativity and novelty of the assistant's answers when needed. Finally, identify any missing important information in the assistants' answers that would be beneficial to include when responding to the user prompt.\n\nAfter providing your explanation, you must output only one of the following choices as your final verdict with a label:\n\n1. Assistant A is significantly better: [[A>>B]]\n2. Assistant A is slightly better: [[A>B]]\n3. Tie, relatively the same: [[A=B]]\n4. Assistant B is slightly better: [[B>A]]\n5. Assistant B is significantly better: [[B>>A]]\n\nExample output: \"My final verdict is tie: [[A=B]]\".", + }, + { + "role": "user", + "content": f"<|User Prompt|>\n{input['instruction']}\n\n<|The Start of Assistant A's Answer|>\n{input['generations'][0]}\n<|The End of Assistant A's Answer|>\n\n<|The Start of Assistant B's Answer|>\n{input['generations'][1]}\n<|The End of Assistant B's Answer|>", + }, + ] + + @property + def outputs(self) -> List[str]: + """The outputs generated by this task are the `evaluation`, the `score` and + the `model_name` (which is automatically injected within the `process` method + of the parent task).""" + return ["evaluation", "score", "model_name"] + + def format_output( + self, + output: Union[str, None], + input: Union[Dict[str, Any], None] = None, + ) -> Dict[str, Any]: + """This method formats the output generated by the LLM as a Python dictionary + containing the `evaluation` which is the raw output generated by the LLM (consisting + of the judge LLM alternate generation for the given instruction, plus an explanation + on the evaluation of the given responses; plus the `score` extracted from the output. + + Args: + output: the raw output of the LLM. + input: the input to the task. Is provided in case it needs to be used to enrich + the output if needed. + + Returns: + A dict with the keys `evaluation` with the raw output which contains the LLM + evaluation and the extracted `score` if possible. + """ + if output is None: + return {"evaluation": None, "score": None} + pattern = re.compile(r"\[\[([AB<>=]+)\]\]") + match = pattern.search(output) + if match is None: + return {"evaluation": output, "score": None} + return {"evaluation": output, "score": match.group(1)} + + +class ArenaHardResults(GlobalStep): + """This `Step` is based on the "From Live Data to High-Quality Benchmarks: The + Arena-Hard Pipeline" paper that presents Arena Hard, which is a benchmark for + instruction-tuned LLMs that contains 500 challenging user queries. This step is + a `GlobalStep` that should run right after the `ArenaHard` task to calculate the + ELO scores for the evaluated models. + + Note: + Arena-Hard-Auto has the highest correlation and separability to Chatbot Arena + among popular open-ended LLM benchmarks. + + References: + - [From Live Data to High-Quality Benchmarks: The Arena-Hard Pipeline](https://lmsys.org/blog/2024-04-19-arena-hard/) + - [`arena-hard-auto`](https://github.com/lm-sys/arena-hard-auto/tree/main) + + Examples: + + Rate the ELO scores for two assistant responses for a given an evaluation / comparison between both using Arean Hard prompts: + + ```python + from distilabel.pipeline import Pipeline + from distilabel.steps import CombineColumns, LoadDataFromDicts + from distilabel.steps.tasks import ArenaHard, TextGeneration + + with Pipeline() as pipeline: + load_data = LoadDataFromDicts( + data=[{"instruction": "What is the capital of France?"}], + ) + + text_generation_a = TextGeneration( + llm=..., # LLM instance + output_mappings={"model_name": "generation_model"}, + ) + + text_generation_b = TextGeneration( + llm=..., # LLM instance + output_mappings={"model_name": "generation_model"}, + ) + + combine = CombineColumns( + columns=["generation", "generation_model"], + output_columns=["generations", "generation_models"], + ) + + arena_hard = ArenaHard( + llm=..., # LLM instance + ) + + arena_hard_results = ArenaHardResults( + custom_model_column="generation_models", + custom_weights={"A>B": 1, "A>>B": 3, "B>A": 1, "B>>A": 3}, + ) + + load_data >> [text_generation_a, text_generation_b] >> combine >> arena_hard >> arena_hard_results + ``` + + """ + + custom_model_column: Optional[str] = None + custom_weights: Dict[str, int] = {"A>B": 1, "A>>B": 3, "B>A": 1, "B>>A": 3} + + def load(self) -> None: + """Ensures that the required dependencies are installed.""" + super().load() + + try: + import numpy as np # noqa: F401 + import pandas as pd # noqa: F401 + from sklearn.linear_model import LogisticRegression # noqa: F401 + except ImportError as e: + raise ImportError( + "In order to run `ArenaHardResults`, the `arena-hard` extra dependencies" + " must be installed i.e. `numpy`, `pandas`, and `scikit-learn`.\n" + "Please install the dependencies by running `pip install distilabel[arena-hard]`." + ) from e + + # TODO: the `evaluation` is not really required as an input, so it could be removed, since + # only `score` is used / required + @property + def inputs(self) -> List[str]: + """The inputs required by this step are the `evaluation` and the `score` generated + by the `ArenaHard` task. Since this step does use the identifiers `model_a` and `model_b`, + optionally one can set `custom_model_column` to use the model names if existing within + the input data, ideally this value should be `model_name` if connected from the `ArenaHard` + step.""" + columns = ["evaluation", "score"] + if self.custom_model_column: + columns.append(self.custom_model_column) + return columns + + @override + def process(self, inputs: StepInput) -> StepOutput: # type: ignore + """This method processes the inputs generated by the `ArenaHard` task to calculate the + win rates for each of the models to evaluate. Since this step inherits from the `GlobalStep`, + it will wait for all the input batches to be processed, and then the output will be yielded in + case there's a follow up step, since this step won't modify the received inputs. + + Args: + inputs: A list of Python dictionaries with the inputs of the task. + + Yields: + A list of Python dictionaries with the outputs of the task. + + References: + - https://github.com/lm-sys/arena-hard-auto/blob/main/show_result.py + """ + import numpy as np + import pandas as pd + from sklearn.linear_model import LogisticRegression + + models = ["A", "B"] + if self.custom_model_column: + models = inputs[0][self.custom_model_column] + + # TODO: the battles are only calculated for the first game, even though the official + # implementation also covers the possibility of a second game (not within the released + # dataset yet) + battles = pd.DataFrame() + for input in inputs: + output = { + # TODO: "question_id": input["question_id"], + "model_a": models[0], + "model_b": models[1], + } + if input["score"] in ["A>B", "A>>B"]: + output["winner"] = models[0] + rows = [output] * self.custom_weights[input["score"]] + elif input["score"] in ["B>A", "B>>A"]: + output["winner"] = models[1] + rows = [output] * self.custom_weights[input["score"]] + elif input["score"] == "A=B": + output["winner"] = "tie" + rows = [output] + else: + continue + + battles = pd.concat([battles, pd.DataFrame(rows)]) + + models = pd.concat([battles["model_a"], battles["model_b"]]).unique() + models = pd.Series(np.arange(len(models)), index=models) + + battles = pd.concat([battles, battles], ignore_index=True) + p = len(models.index) + n = battles.shape[0] + + X = np.zeros([n, p]) + X[np.arange(n), models[battles["model_a"]]] = +np.log(10) + X[np.arange(n), models[battles["model_b"]]] = -np.log(10) + + Y = np.zeros(n) + Y[battles["winner"] == "model_a"] = 1.0 + + tie_idx = battles["winner"] == "tie" + tie_idx[len(tie_idx) // 2 :] = False + Y[tie_idx] = 1.0 + + lr = LogisticRegression(fit_intercept=False, penalty=None, tol=1e-8) # type: ignore + lr.fit(X, Y) + + # The ELO scores are calculated assuming that the reference is `gpt-4-0314` + # with an starting ELO of 1000, so that the evaluated models are compared with + # `gtp-4-0314` only if it's available within the models + elo_scores = 400 * lr.coef_[0] + 1000 + # TODO: we could parametrize the reference / anchor model, but left as is to be faithful to the + # original implementation + if "gpt-4-0314" in models.index: + elo_scores += 1000 - elo_scores[models["gpt-4-0314"]] + + output = pd.Series(elo_scores, index=models.index).sort_values(ascending=False) + self._logger.info(f"Arena Hard ELO: {output}") + + # Here only so that if follow up steps are connected the inputs are preserved, + # since this step doesn't modify nor generate new inputs + yield inputs diff --git a/src/distilabel/steps/tasks/text_generation.py b/src/distilabel/steps/tasks/text_generation.py index 28c207c287..3a3166b447 100644 --- a/src/distilabel/steps/tasks/text_generation.py +++ b/src/distilabel/steps/tasks/text_generation.py @@ -132,7 +132,7 @@ def format_input(self, input: Dict[str, Any]) -> ChatType: if not is_openai_format(input["messages"]): raise ValueError( - "Input `instruction` must be a string or an OpenAI chat-like format. " + "Input `messages` must be an OpenAI chat-like format conversation. " f"Got: {input['messages']}. Please check: 'https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models'." ) diff --git a/tests/unit/steps/tasks/benchmarks/__init__.py b/tests/unit/steps/tasks/benchmarks/__init__.py new file mode 100644 index 0000000000..2598794f29 --- /dev/null +++ b/tests/unit/steps/tasks/benchmarks/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. diff --git a/tests/unit/steps/tasks/benchmarks/test_arena_hard.py b/tests/unit/steps/tasks/benchmarks/test_arena_hard.py new file mode 100644 index 0000000000..50258db668 --- /dev/null +++ b/tests/unit/steps/tasks/benchmarks/test_arena_hard.py @@ -0,0 +1,172 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 logging +from typing import Any, Dict, List, Union + +import pytest +from _pytest.logging import LogCaptureFixture +from distilabel.pipeline.local import Pipeline +from distilabel.steps.tasks.benchmarks.arena_hard import ArenaHard, ArenaHardResults + +from tests.unit.steps.tasks.utils import DummyLLM + + +class TestArenaHard: + def test_format_input(self) -> None: + task = ArenaHard( + name="arena_hard", + llm=DummyLLM(), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + + result = task.format_input( + input={ + "instruction": "INSTRUCTION", + "generations": ["GENERATION_A", "GENERATION_B"], + } + ) + + assert result[-1] == { + "role": "user", + "content": "<|User Prompt|>\nINSTRUCTION\n\n<|The Start of Assistant A's Answer|>\nGENERATION_A\n<|The End of Assistant A's Answer|>\n\n<|The Start of Assistant B's Answer|>\nGENERATION_B\n<|The End of Assistant B's Answer|>", + } + + @pytest.mark.parametrize( + "output, expected", + [ + ( + "My own answer to the prompt would be:\nANSWER\nMy final veredict is: [[A>>B]]\n", + { + "evaluation": "My own answer to the prompt would be:\nANSWER\nMy final veredict is: [[A>>B]]\n", + "score": "A>>B", + }, + ), + ( + "My own answer to the prompt would be:\nANSWER\nMy final veredict is: TIE\n", + { + "evaluation": "My own answer to the prompt would be:\nANSWER\nMy final veredict is: TIE\n", + "score": None, + }, + ), + ( + None, + {"evaluation": None, "score": None}, + ), + ], + ) + def test_format_output( + self, output: Union[str, None], expected: Dict[str, Any] + ) -> None: + task = ArenaHard( + name="arena_hard", + llm=DummyLLM(), + pipeline=Pipeline(name="unit-test-pipeline"), + ) + task.load() + + assert ( + task.format_output( + output=output, + input={ + "instruction": "INSTRUCTION", + "generations": ["GENERATION_A", "GENERATION_B"], + }, + ) + == expected + ) + + +class TestArenaHardResults: + @pytest.mark.parametrize( + "custom_model_column, inputs", + [ + ("model_name", ["evaluation", "score", "model_name"]), + (None, ["evaluation", "score"]), + ], + ) + def test_inputs( + self, custom_model_column: Union[str, None], inputs: List[str] + ) -> None: + step = ArenaHardResults( + name="arena_hard_results", + custom_model_column=custom_model_column, + pipeline=Pipeline(name="unit-test-pipeline"), + ) + assert step.inputs == inputs + + def test_process(self, caplog: LogCaptureFixture) -> None: + step = ArenaHardResults( + name="arena_hard_results", + custom_model_column="model_names", + pipeline=Pipeline(name="unit-test-pipeline"), + ) + step.load() + + with caplog.at_level(logging.INFO): + next( + step.process( + [ + { + "evaluation": "...", + "score": "A>>B", + "model_names": ["gpt-4-0314", "other-model"], + }, + { + "evaluation": "...", + "score": "A=B", + "model_names": ["gpt-4-0314", "other-model"], + }, + { + "evaluation": "...", + "score": "B>>A", + "model_names": ["gpt-4-0314", "other-model"], + }, + ] + ) + ) + assert ( + "Arena Hard ELO: other-model 1445.577347\ngpt-4-0314 1000.000000\ndtype: float64\n" + in caplog.text + ) + + def test_process_errors(self) -> None: + step = ArenaHardResults( + name="arena_hard_results", + custom_model_column="model_names", + pipeline=Pipeline(name="unit-test-pipeline"), + ) + step.load() + + with pytest.raises( + ValueError, + match="This solver needs samples of at least 2 classes in the data, but the data contains only one class: 0.0", + ): + next( + step.process( + [ + { + "evaluation": "...", + "score": "A>>B", + "model_names": ["gpt-4-0314", "other-model"], + }, + { + "evaluation": "...", + "score": "B>>A", + "model_names": ["gpt-4-0314", "other-model"], + }, + ] + ) + ) diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py index e20e186c8e..94feb041ed 100644 --- a/tests/unit/test_imports.py +++ b/tests/unit/test_imports.py @@ -63,6 +63,8 @@ def test_imports() -> None: ) from distilabel.steps.tasks import ( + ArenaHard, + ArenaHardResults, Task, GeneratorTask, ChatItem, From 2ea3f43cd4f1c73e249d6fe7db626a70e2f7d238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Wed, 12 Jun 2024 16:39:03 +0200 Subject: [PATCH 28/40] Refactor `Pipeline` and `BasePipeline` classes (#704) * Move classes to different files * Add `_send_to_step` abstractmethod * Move `_request_initial_batches` method to `BasePipeline` * Move `_notify_step_to_stop` method to `BasePipeline` * Move `_handle_batch_on_stop` method to `BasePipeline` * Move `LAST_BATCH_FLAG_SENT` constant * Move `_request_more_batches_if_needed` method to `BasePipeline` * Move `_register_batch` method to `BasePipeline` * Move `_get_successors` method to `BasePipeline` * Move `_get_step_from_batch` method to `BasePipeline` * Move `_manage_batch_flow` method to `BasePipeline` * Add `_get_from_step` abstract method * Add `_add_batches_back_to_batch_manager` method * Add `_consume_output_queue` method * Add `_create_step_input_queue` method * Add `_run_step` abstract method * Move `_handle_keyboard_interrupt` method * Add `_load_queue` * Add `_init_steps_load_status` method * Move `_all_steps_loaded` method * Move `_check_step_not_loaded_or_finished` method * Move `_handle_stop` method * Move `_run_output_queue_loop` method * Remove unused variables * Fix unit tests * Remove shared dict info and update `CudaDevicePlacementMixin` * Add `unload` method * Add `portalocker` dependency * Add missing unload * Add `_OLD_IMPORT_MODULE_ATTR` dict * Fix `override` import * Remove log message * Add missing call to `unload` --- pyproject.toml | 1 + src/distilabel/llms/base.py | 4 + .../llms/huggingface/transformers.py | 5 + src/distilabel/llms/mixins.py | 88 +- src/distilabel/llms/vllm.py | 5 + src/distilabel/pipeline/base.py | 1564 +++----- src/distilabel/pipeline/batch.py | 233 ++ src/distilabel/pipeline/batch_manager.py | 896 +++++ src/distilabel/pipeline/constants.py | 1 + src/distilabel/pipeline/local.py | 621 +--- .../pipeline/routing_batch_function.py | 2 +- src/distilabel/pipeline/typing.py | 10 +- src/distilabel/pipeline/write_buffer.py | 168 + src/distilabel/steps/base.py | 6 + src/distilabel/steps/tasks/base.py | 9 +- src/distilabel/utils/serialization.py | 31 +- tests/unit/llms/test_mixins.py | 131 +- tests/unit/pipeline/test_base.py | 3142 +++-------------- tests/unit/pipeline/test_batch.py | 172 + tests/unit/pipeline/test_batch_manager.py | 2214 ++++++++++++ tests/unit/pipeline/test_local.py | 33 +- .../pipeline/test_routing_batch_function.py | 2 +- tests/unit/pipeline/test_write_buffer.py | 150 + tests/unit/pipeline/utils.py | 2 +- 24 files changed, 5134 insertions(+), 4356 deletions(-) create mode 100644 src/distilabel/pipeline/batch.py create mode 100644 src/distilabel/pipeline/batch_manager.py create mode 100644 src/distilabel/pipeline/write_buffer.py create mode 100644 tests/unit/pipeline/test_batch.py create mode 100644 tests/unit/pipeline/test_batch_manager.py create mode 100644 tests/unit/pipeline/test_write_buffer.py diff --git a/pyproject.toml b/pyproject.toml index 1e29af9d94..df771ae20c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "tblib >= 3.0.0", "orjson >= 3.10.0", "universal_pathlib >= 0.2.2", + "portalocker >= 2.8.2", ] dynamic = ["version"] diff --git a/src/distilabel/llms/base.py b/src/distilabel/llms/base.py index e94e06e8ec..55250da791 100644 --- a/src/distilabel/llms/base.py +++ b/src/distilabel/llms/base.py @@ -85,6 +85,10 @@ def load(self) -> None: """Method to be called to initialize the `LLM`, its logger and optionally the structured output generator.""" self._logger = logging.getLogger(f"distilabel.llm.{self.model_name}") + def unload(self) -> None: + """Method to be called to unload the `LLM` and release any resources.""" + pass + @property @abstractmethod def model_name(self) -> str: diff --git a/src/distilabel/llms/huggingface/transformers.py b/src/distilabel/llms/huggingface/transformers.py index 1b73eaa469..dc428f4283 100644 --- a/src/distilabel/llms/huggingface/transformers.py +++ b/src/distilabel/llms/huggingface/transformers.py @@ -140,6 +140,11 @@ def load(self) -> None: super().load() + def unload(self) -> None: + """Unloads the `vLLM` model.""" + CudaDevicePlacementMixin.unload(self) + super().unload() + @property def model_name(self) -> str: """Returns the model name used for the LLM.""" diff --git a/src/distilabel/llms/mixins.py b/src/distilabel/llms/mixins.py index 9146a0ef4f..1d2e8b35a0 100644 --- a/src/distilabel/llms/mixins.py +++ b/src/distilabel/llms/mixins.py @@ -12,14 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import os -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Union +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Dict, Generator, List, Literal, Union +import portalocker from pydantic import BaseModel, Field, PrivateAttr -if TYPE_CHECKING: - from multiprocessing.managers import DictProxy - from multiprocessing.synchronize import Lock +_CUDA_DEVICE_PLACEMENT_MIXIN_FILE = ( + Path(tempfile.gettempdir()) / "distilabel_cuda_device_placement_mixin.json" +) class CudaDevicePlacementMixin(BaseModel): @@ -44,11 +49,7 @@ class CudaDevicePlacementMixin(BaseModel): cuda_devices: Union[List[int], Literal["auto"]] = Field(default="auto") _llm_identifier: Union[str, None] = PrivateAttr(default=None) - _device_llm_placement_map: Union["DictProxy[str, Any]", None] = PrivateAttr( - default=None - ) - _device_llm_placement_lock: Union["Lock", None] = PrivateAttr(default=None) - _available_cuda_devices: Union[List[int], None] = PrivateAttr(default=None) + _available_cuda_devices: List[int] = PrivateAttr(default_factory=list) _can_check_cuda_devices: bool = PrivateAttr(default=False) def load(self) -> None: @@ -77,29 +78,40 @@ def load(self) -> None: self._assign_cuda_devices() - def set_device_placement_info( - self, - llm_identifier: str, - device_llm_placement_map: "DictProxy[str, Any]", - device_llm_placement_lock: "Lock", - ) -> None: - """Sets the value of `_device_llm_placement_map` to be used to assign CUDA devices - to the LLM. + def unload(self) -> None: + """Unloads the LLM and removes the CUDA devices assigned to it from the device + placement information provided in `_device_llm_placement_map`.""" + with self._device_llm_placement_map() as device_map: + if self._llm_identifier in device_map: + self._logger.debug( + f"Removing '{self._llm_identifier}' from the CUDA device map file" + f" '{_CUDA_DEVICE_PLACEMENT_MIXIN_FILE}'." + ) + del device_map[self._llm_identifier] - Args: - llm_identifier: the identifier of the LLM to be used as key in the device - placement information. - device_llm_placement_map: a dictionary with the device placement information for - each LLM. It should have two keys. The first key is "lock" and its value is - a lock object to be used to synchronize the access to the device placement - information. The second key is "value" and its value is a dictionary with the - device placement information for each LLM. - device_llm_placement_lock: a lock object to be used to synchronize the access to - `_device_llm_placement_map`. + @contextmanager + def _device_llm_placement_map(self) -> Generator[Dict[str, List[int]], None, None]: + """Reads the content of the device placement file of the node with a lock, yields + the content, and writes the content back to the file after the context manager is + closed. If the file doesn't exist, an empty dictionary will be yielded. + + Yields: + The content of the device placement file. """ - self._llm_identifier = llm_identifier - self._device_llm_placement_map = device_llm_placement_map - self._device_llm_placement_lock = device_llm_placement_lock + _CUDA_DEVICE_PLACEMENT_MIXIN_FILE.touch() + with portalocker.Lock( + _CUDA_DEVICE_PLACEMENT_MIXIN_FILE, + "r+", + flags=portalocker.LockFlags.EXCLUSIVE, + ) as f: + try: + content = json.load(f) + except json.JSONDecodeError: + content = {} + yield content + f.seek(0) + f.truncate() + f.write(json.dumps(content)) def _assign_cuda_devices(self) -> None: """Assigns CUDA devices to the LLM based on the device placement information provided @@ -109,16 +121,14 @@ def _assign_cuda_devices(self) -> None: checked if the devices are available to be used by the LLM. If not, a warning will be logged.""" - if self._device_llm_placement_map is not None: - with self._device_llm_placement_lock: # type: ignore - if self.cuda_devices == "auto": - self.cuda_devices = [ - self._get_cuda_device(self._device_llm_placement_map) - ] - else: - self._check_cuda_devices(self._device_llm_placement_map) + # Take the lock and read the device placement information for each LLM. + with self._device_llm_placement_map() as device_map: + if self.cuda_devices == "auto": + self.cuda_devices = [self._get_cuda_device(device_map)] + else: + self._check_cuda_devices(device_map) - self._device_llm_placement_map[self._llm_identifier] = self.cuda_devices # type: ignore + device_map[self._llm_identifier] = self.cuda_devices # type: ignore # `_device_llm_placement_map` was not provided and user didn't set the `cuda_devices` # attribute. In this case, the `cuda_devices` attribute will be set to an empty list. diff --git a/src/distilabel/llms/vllm.py b/src/distilabel/llms/vllm.py index de8ea44fd7..4a9ce88f75 100644 --- a/src/distilabel/llms/vllm.py +++ b/src/distilabel/llms/vllm.py @@ -189,6 +189,11 @@ def load(self) -> None: self.structured_output ) + def unload(self) -> None: + """Unloads the `vLLM` model.""" + CudaDevicePlacementMixin.unload(self) + super().unload() + @property def model_name(self) -> str: """Returns the model name used for the LLM.""" diff --git a/src/distilabel/pipeline/base.py b/src/distilabel/pipeline/base.py index 88eb127ebb..cb3b0625a7 100644 --- a/src/distilabel/pipeline/base.py +++ b/src/distilabel/pipeline/base.py @@ -12,57 +12,58 @@ # See the License for the specific language governing permissions and # limitations under the License. -import copy import hashlib import logging import os -from collections import defaultdict -from dataclasses import dataclass, field +import signal +import threading +import time +from abc import ABC, abstractmethod from pathlib import Path from typing import ( TYPE_CHECKING, Any, + Callable, Dict, - Iterable, List, Optional, - Set, Tuple, TypedDict, Union, ) import fsspec -import pyarrow as pa -import pyarrow.parquet as pq from typing_extensions import Self from upath import UPath from distilabel import __version__ from distilabel.distiset import create_distiset from distilabel.pipeline._dag import DAG +from distilabel.pipeline.batch import _Batch +from distilabel.pipeline.batch_manager import _BatchManager from distilabel.pipeline.constants import ( + CONVERGENCE_STEP_ATTR_NAME, + INPUT_QUEUE_ATTR_NAME, + LAST_BATCH_SENT_FLAG, RECEIVES_ROUTED_BATCHES_ATTR_NAME, ROUTING_BATCH_FUNCTION_ATTR_NAME, STEP_ATTR_NAME, ) -from distilabel.utils.dicts import flatten_dict -from distilabel.utils.files import list_files_in_dir +from distilabel.pipeline.write_buffer import _WriteBuffer from distilabel.utils.logging import setup_logging, stop_logging from distilabel.utils.serialization import ( TYPE_INFO_KEY, - _check_is_dir, _Serializable, - read_json, ) if TYPE_CHECKING: from os import PathLike + from queue import Queue from distilabel.distiset import Distiset from distilabel.pipeline.routing_batch_function import RoutingBatchFunction - from distilabel.steps.base import _Step - from distilabel.utils.serialization import StrOrPath + from distilabel.pipeline.typing import StepLoadStatus + from distilabel.steps.base import Step, _Step BASE_CACHE_DIR = Path.home() / ".cache" / "distilabel" / "pipelines" @@ -114,7 +115,11 @@ def get_pipeline(cls) -> Union["BasePipeline", None]: return cls._context_global_pipeline -class BasePipeline(_Serializable): +_STEP_LOAD_FAILED_CODE = -666 +_STEP_NOT_LOADED_CODE = -999 + + +class BasePipeline(ABC, _Serializable): """Base class for a `distilabel` pipeline. Attributes: @@ -142,8 +147,14 @@ class BasePipeline(_Serializable): Defaults to `False`. _dry_run: A flag to indicate if the pipeline is running in dry run mode. Defaults to `False`. + output_queue: A queue to store the output of the steps while running the pipeline. + load_queue: A queue used by each `Step` to notify the main process it has finished + loading or it the step has been unloaded. """ + _output_queue: "Queue[Any]" + _load_queue: "Queue[Union[StepLoadStatus, None]]" + def __init__( self, name: str, @@ -182,10 +193,18 @@ def __init__( "filename": self._cache_location["log_file"] } + self._steps_load_status: Dict[str, int] = {} + self._steps_load_status_lock = threading.Lock() + + self._stop_called = False + self._stop_called_lock = threading.Lock() + self._stop_calls = 0 + self._fs: Optional[fsspec.AbstractFileSystem] = None self._storage_base_path: Optional[str] = None self._use_fs_to_pass_data: bool = False - self._dry_run: bool = False + + self._dry_run = False def __enter__(self) -> Self: """Set the global pipeline instance when entering a pipeline context.""" @@ -310,6 +329,8 @@ def run( } ) + self._init_steps_load_status() + # Validate the pipeline DAG to check that all the steps are chainable, there are # no missing runtime parameters, batch sizes are correct, etc. self.dag.validate() @@ -390,6 +411,12 @@ def get_runtime_parameters_info(self) -> Dict[str, List[Dict[str, Any]]]: runtime_parameters[step_name] = step.get_runtime_parameters_info() return runtime_parameters + def _init_steps_load_status(self) -> None: + """Initialize the `_steps_load_status` dictionary assigning 0 to every step of + the pipeline.""" + for step_name in self.dag: + self._steps_load_status[step_name] = _STEP_NOT_LOADED_CODE + def _setup_fsspec( self, storage_parameters: Optional[Dict[str, Any]] = None ) -> None: @@ -451,6 +478,14 @@ def _add_edge(self, from_step: str, to_step: str) -> None: value=routing_batch_function is not None, ) + def _is_convergence_step(self, step_name: str) -> None: + """Checks if a step is a convergence step. + + Args: + step_name: The name of the step. + """ + return self.dag.get_step(step_name).get(CONVERGENCE_STEP_ATTR_NAME) + def _add_routing_batch_function( self, step_name: str, routing_batch_function: "RoutingBatchFunction" ) -> None: @@ -576,1244 +611,521 @@ def _setup_write_buffer(self) -> None: self._logger.info(f"📝 Pipeline data will be written to '{buffer_data_path}'") self._write_buffer = _WriteBuffer(buffer_data_path, self.dag.leaf_steps) - def _send_batch_to_step(self, batch: "_Batch") -> None: - """Sends a batch to the input queue of a step, writing the data of the batch - to the filesystem and setting `batch.data_path` with the path where the data - was written (if requiered i.e. the step is a global step or `use_fs_to_pass_data`) + def _run_output_queue_loop_in_thread(self) -> threading.Thread: + """Runs the output queue loop in a separate thread to receive the output batches + from the steps. This is done to avoid the signal handler to block the loop, which + would prevent the pipeline from stopping correctly.""" + thread = threading.Thread(target=self._output_queue_loop) + thread.start() + return thread + + def _output_queue_loop(self) -> None: + """Loop to receive the output batches from the steps and manage the flow of the + batches through the pipeline.""" + while self._batch_manager.can_generate() and not self._stop_called: # type: ignore + self._logger.debug("Waiting for output batch from step...") + if (batch := self._output_queue.get()) is None: + self._logger.debug("Received `None` from output queue. Breaking loop.") + break - This method should be extended by the specific pipeline implementation, adding - the logic to send the batch to the step. - - Args: - batch: The batch to send. - """ - self._logger.debug( - f"Setting batch {batch.seq_no} as last batch sent to '{batch.step_name}': {batch}" - ) - self._batch_manager.set_last_batch_sent(batch) # type: ignore - - step: "_Step" = self.dag.get_step(batch.step_name)[STEP_ATTR_NAME] - if not step.is_generator and (step.is_global or self._use_fs_to_pass_data): - base_path = UPath(self._storage_base_path) / step.name # type: ignore self._logger.debug( - f"Writing {batch.seq_no} batch for '{batch.step_name}' step to filesystem: {base_path}" + f"Received batch with seq_no {batch.seq_no} from step '{batch.step_name}'" + f" from output queue: {batch}" ) - batch.write_batch_data_to_fs(self._fs, base_path) # type: ignore - - self._logger.debug( - f"Sending batch {batch.seq_no} to step '{batch.step_name}': {batch}" - ) - - -@dataclass -class _Batch(_Serializable): - """Dataclass to represent a batch of data to be processed by a `_Step`. - - Attributes: - seq_no: The sequence number of the batch. - step_name: The name of the step that will process the batch. - last_batch: A flag to indicate if the batch is the last one. - data: The data to be processed. - data_hash: The hash of the data. Defaults to `None`. - data_path: The path where the data of the batch is stored. Defaults to `None`. - accumulated: A flag to indicate if the batch is accumulated. - created_from: A dictionary containing the `seq_no` of the batches of the steps that - were used to create this batch. - size: The size of the batch. - """ - - seq_no: int - step_name: str - last_batch: bool - data: List[List[Dict[str, Any]]] = field(default_factory=list, repr=False) - data_hash: Optional[str] = None - data_path: Optional[str] = None - accumulated: bool = False - created_from: Dict[str, List[Tuple[int, int]]] = field(default_factory=dict) - batch_routed_to: List[str] = field(default_factory=list) - size: int = 0 - _fs: Optional[fsspec.AbstractFileSystem] = None - - def next_batch(self) -> "_Batch": - """Create a new `_Batch` instance with the next batch of data. - Args: - data: The data to be processed. - - Returns: - A `_Batch` instance. - """ - return _Batch( - seq_no=self.seq_no + 1, step_name=self.step_name, last_batch=self.last_batch - ) - - def set_data(self, data: List[List[Dict[str, Any]]]) -> None: - """Sets the data of the batch and updates the size of the batch. - - Args: - data: The data of the batch. - """ - self.data = data - self.size = len(data[0]) - self._update_data_hash() - - def get_data(self, num_rows: Union[int, None] = None) -> List[Dict[str, Any]]: - """Takes `num_rows` from the data of the batch and returns it. This method will - also remove the data from the batch and update the hash of the data. - - Args: - num_rows: The number of rows to take from the data. If `None`, then all the - data will be taken. Defaults to `None`. - - Returns: - A list with the data taken from the batch. - """ - - if self.data == [] and self.data_path is not None: - pass - - if num_rows is None: - data = self.data[0] - self.data = [] - else: - data = self.data[0][:num_rows] - self.data[0] = self.data[0][num_rows:] - - self._update_data_hash() - return data - - def _update_data_hash(self) -> None: - """Updates the hash of the data of the batch.""" - self.data_hash = hashlib.sha1(str(self.data).encode()).hexdigest() + if batch.data_path: + self._logger.debug( + f"Reading {batch.seq_no} batch data from '{batch.step_name}': '{batch.data_path}'" + ) + batch.read_batch_data_from_fs() - @classmethod - def accumulate(cls, step_name: str, batches: List[List["_Batch"]]) -> "_Batch": - """Creates a `_Batch` instance using the data from the list of batches that - were received from another steps. The batches will be accumulated in a single - list of data. + if batch.step_name in self.dag.leaf_steps: + self._write_buffer.add_batch(batch) # type: ignore - Args: - step_name: The name of the step that will process the batch. - batches: a list containing the list of batches received from the predecessors. + # If `_stop_called` was set to `True` while waiting for the output queue, then + # we need to handle the stop of the pipeline and break the loop to avoid + # propagating the batches through the pipeline and making the stop process + # slower. + if self._stop_called: + self._handle_batch_on_stop(batch) + break - Returns: - A `_Batch` instance. - """ + self._manage_batch_flow(batch) - data = [] - for step_batches in batches: - accumulated_data = [row for batch in step_batches for row in batch.data[0]] - data.append(accumulated_data) - return cls( - seq_no=0, step_name=step_name, last_batch=True, data=data, accumulated=True - ) + if self._stop_called: + self._handle_stop() - def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: - """Dumps the content of the `_Batch` to a dictionary, using the `dataclass` helper function. + self._cache() - Args: - obj: Unused, just kept to match the signature of the parent method. - kwargs: Additional arguments that are kept to match the signature of the parent method. + def _run_load_queue_loop_in_thread(self) -> threading.Thread: + """Runs a background thread that reads from the `load_queue` to update the status + of the number of workers loaded for each step. Returns: - A `dict` containing the internal representation of the `_Batch`. - """ - - include_batch_data = kwargs.get("include_batch_data", True) - - dump = { - "seq_no": self.seq_no, - "step_name": self.step_name, - "last_batch": self.last_batch, - "data_hash": self.data_hash, - "accumulated": self.accumulated, - "created_from": self.created_from, - "batch_routed_to": self.batch_routed_to, - "size": self.size, - } - - if include_batch_data: - dump["data"] = self.data + The thread that was started. + """ + thread = threading.Thread(target=self._run_load_queue_loop) + thread.start() + return thread + + def _run_load_queue_loop(self) -> None: + """Runs a loop that reads from the `load_queue` to update the status of the number + of workers loaded for each step.""" + while True: + if (load_info := self._load_queue.get()) is None: + self._logger.debug("Received `None` from load queue. Breaking loop.") + break + + with self._steps_load_status_lock: + step_name, status = load_info["name"], load_info["status"] + if status == "loaded": + if self._steps_load_status[step_name] == _STEP_NOT_LOADED_CODE: + self._steps_load_status[step_name] = 1 + else: + self._steps_load_status[step_name] += 1 + elif status == "unloaded": + self._steps_load_status[step_name] -= 1 + else: + # load failed + self._steps_load_status[step_name] = _STEP_LOAD_FAILED_CODE - return dump + self._logger.debug( + f"Step '{step_name}' loaded workers: {self._steps_load_status[step_name]}" + ) - def copy(self) -> "_Batch": - """Creates a copy of the `_Batch` instance. + def _all_steps_loaded(self) -> bool: + """Waits for all the steps to load. Returns: - A copy of the `_Batch` instance. - """ - return copy.deepcopy(self) - - def write_batch_data_to_fs( - self, - fs: Optional[fsspec.AbstractFileSystem] = None, - base_path: Optional[UPath] = None, - ) -> None: - """Writes the content of the batch to the filesystem. - - Args - fs: The `fsspec` filesystem to be used to write the data. If not provided, the - one set in the `_fs` attribute will be used. Defaults to `None`. - base_path: The base path where the data of the batch will be stored. If not - provided, the one set in the `data_path` attribute will be used. Defaults - to `None`. - - Raises: - ValueError: If `fs` is not provided and the `_fs` attribute is not set. - """ - - if not fs and not self._fs: - raise ValueError( - "The `fs` parameter must be provided if the `_fs` attribute is not set." - ) - - if fs: - self._fs = fs - - if not base_path and not self.data_path: - raise ValueError( - "The `base_path` parameter must be provided if the `data_path` attribute" - " is not set." - ) - - seq_no_dir = ( - base_path / f"seq_no_{self.seq_no}" if base_path else UPath(self.data_path) - ) - seq_no_dir._fs_cached = self._fs # type: ignore - seq_no_dir.mkdir(parents=True, exist_ok=True) - - for i, data in enumerate(self.data): - table = pa.Table.from_pylist(data) - with self._fs.open(seq_no_dir / f"data_index_{i}.parquet", "wb") as f: # type: ignore - pq.write_table(table, f) + `True` if all the steps have been loaded correctly, `False` otherwise. + """ + + self._logger.info("⏳ Waiting for all the steps to load...") + previous_message = None + while not self._stop_called: + with self._steps_load_status_lock: + self._logger.debug(f"Steps loaded: {self._steps_load_status}") + + if any( + num_workers_loaded == _STEP_LOAD_FAILED_CODE + for num_workers_loaded in self._steps_load_status.values() + ): + self._logger.error("❌ Failed to load all the steps") + return False + + num_steps_loaded = 0 + workers_message = "" + for step_name, num_workers_loaded in self._steps_load_status.items(): + # TODO: update condition once we allow more than one worker per step + if num_workers_loaded == 1: + num_steps_loaded += 1 + workers_message += ( + f"\n * '{step_name}' workers: {max(0, num_workers_loaded)}" + ) - self.data = [] - self.data_path = str(seq_no_dir) + message = f"⏳ Steps loaded: {num_steps_loaded}/{len(self.dag)}{workers_message}" + if num_steps_loaded > 0 and message != previous_message: + self._logger.info(message) + previous_message = message - def read_batch_data_from_fs(self) -> None: - """Reads the content of the batch from the filesystem.""" - if not self.data_path: - raise ValueError( - "`data_path` attribute must be set to read the data from the filesystem." - " Use `write_batch_data_to_fs` method to set the `data_path` attribute." - ) + if num_steps_loaded == len(self.dag): + self._logger.info("✅ All the steps have been loaded!") + return True - if not self._fs: - raise ValueError( - "`_fs` attribute must be set to read the data from the filesystem." - " Use `write_batch_data_to_fs` method to set the `_fs` attribute." - ) + time.sleep(2.5) - for file in self._fs.ls(self.data_path): - with self._fs.open(file, "rb") as f: - table = pq.read_table(f) - self.data.append(table.to_pylist()) + return not self._stop_called - self._fs.rm(self.data_path, recursive=True) + def _handle_stop(self) -> None: + """Handles the stop of the pipeline execution, which will stop the steps from + processing more batches and wait for the output queue to be empty, to not lose + any data that was already processed by the steps before the stop was called.""" + self._logger.debug("Handling stop of the pipeline execution...") + self._add_batches_back_to_batch_manager() -@dataclass -class _BatchManagerStep(_Serializable): - """A class that will accumulate data for a step from the predecessors and create - batches for the step to process when there is enough data. + # Wait for the input queue to be empty, which means that all the steps finished + # processing the batches that were sent before the stop flag. + for step_name in self.dag: + self._wait_step_input_queue_empty(step_name) - Attributes: - step_name: The name of the step that will process the data. - accumulate: A flag to indicate if the data should be accumulated and create a - batch with all the data received from the predecessors instead of creating - batches with the `input_batch_size`. - input_batch_size: The size of the batch to be created for the step to process. - If `None`, then `accumulate` must be `True`. Defaults to `None`. - data: A dictionary with the predecessor step name as the key and a list of - dictionaries (rows) as the value. - built_batches: A list with the batches that were built and sent to the step queue, - but the step was stopped before processing the batch, so the batch doesn't get - lost. Defaults to an empty list. - seq_no: The sequence number of the next batch to be created. It will be - incremented for each batch created. - last_batch_received: A list with the names of the steps that sent the last - batch of data. - convergence_step: A flag to indicate if the step is a convergence step. An - `Step` is a convergence step if all its predecessors are receiving routed - batches. Defaults to `False`. - convergence_step_batches_consumed: A dictionary in which the key is the `seq_no` - of the batch created by step A, that was used by step B and C and obtained from - the `created_from` of the batches created by them. It's used to know if all - the batches from B and C steps created from batches of A have been consumed - by D, in order to not mess up the order of the batches. Only used if `convergence_step=True`. - Defaults to an empty dictionary. - next_expected_created_from_batch_seq_no: The next expected sequence number of the - batch from step A used by steps B and C and obtained from the `created_from` - of the batches created by them. It's used to avoid messing up the order of the - batches. Only used if `convergence_step=True`. Defaults to `0`. - """ + self._consume_output_queue() - step_name: str - accumulate: bool - input_batch_size: Union[int, None] = None - data: Dict[str, List[_Batch]] = field(default_factory=dict) - built_batches: List[_Batch] = field(default_factory=list) - seq_no: int = 0 - last_batch_received: List[str] = field(default_factory=list) - convergence_step: bool = False - convergence_step_batches_consumed: Dict[str, Dict[str, int]] = field( - default_factory=dict - ) - next_expected_created_from_batch_seq_no: int = 0 - - def add_batch(self, batch: _Batch, prepend: bool = False) -> None: - """Add a batch of data from `batch.step_name` to the step. It will accumulate the - data and keep track of the last batch received from the predecessors. + def _wait_step_input_queue_empty(self, step_name: str) -> Union["Queue[Any]", None]: + """Waits for the input queue of a step to be empty. Args: - batch: The output batch of an step to be processed by the step. - prepend: If `True`, the content of the batch will be added to the `built_batches` - list. This is done so if a `_Batch` was already built and send to the step - queue, and the step is stopped before processing the batch, the batch doesn't - get lost. Defaults to `False`. - """ - from_step = batch.step_name - - if prepend: - self.built_batches.append(batch) - else: - self.data[from_step].append(batch) - - if batch.last_batch: - self.last_batch_received.append(from_step) - - def get_batch(self) -> Union[_Batch, None]: - """Create a new batch of data for the step to process. It will return `None` if - there is not enough data to create a batch. + step_name: The name of the step. Returns: - A `_Batch` instance if there is enough data to create a batch. Otherwise, - `None`. + The input queue of the step if it's not loaded or finished, `None` otherwise. """ - if not self._ready_to_create_batch(): + if self._check_step_not_loaded_or_finished(step_name): return None - # If there are batches in the `built_batches` list, then return the first one - # and remove it from the list. - if self.built_batches: - return self.built_batches.pop(0) - - # `_last_batch` must be called before `_get_data`, as `_get_data` will update the - # list of data which is used to determine if the batch to be created is the last one. - # TODO: remove `_last_batch` method and integrate logic in `_get_data` - last_batch = self._last_batch() - data, created_from, batch_routed_to = self._get_data() - - return _Batch( - seq_no=self._get_seq_no(), - step_name=self.step_name, - last_batch=last_batch, - data=data, - accumulated=self.accumulate, - created_from=created_from, - batch_routed_to=batch_routed_to, - ) - - def empty_buffers(self) -> List[str]: - """Checks if the input buffer for the step is empty. + if input_queue := self.dag.get_step(step_name).get(INPUT_QUEUE_ATTR_NAME): + while input_queue.qsize() != 0: + pass + return input_queue - Returns: - The name of the previous steps for which the input buffer for this step is - empty. - """ - if self.accumulate: - return [ - previous_step - for previous_step in self.data.keys() - if previous_step not in self.last_batch_received - ] - - return [ - previous_step - for previous_step, batches in self.data.items() - if previous_step not in self.last_batch_received - and sum(len(batch.data[0]) for batch in batches) < self.input_batch_size # type: ignore - ] - - @classmethod - def from_step( - cls, step: "_Step", predecessors: Iterable[str], convergence_step: bool = False - ) -> "_BatchManagerStep": - """Creates a `_BatchManagerStep` instance from a `_Step` instance and its - predecessors. + def _check_step_not_loaded_or_finished(self, step_name: str) -> bool: + """Checks if a step is not loaded or already finished. Args: - step: The `_Step` instance. - predecessors: The names of the predecessors of the step. - convergence_step: A flag to indicate if the step is a convergence step. An - `Step` is a convergence step if all its predecessors are receiving routed - batches. Defaults to `False`. - - Returns: - A `_BatchManagerStep` instance. - """ - return cls( - step_name=step.name, # type: ignore - accumulate=step.is_global, - input_batch_size=getattr(step, "input_batch_size", None), - data={predecessor: [] for predecessor in predecessors}, - convergence_step=convergence_step, - ) - - def _get_seq_no(self) -> int: - """Gets the sequence number for the next batch to be created and increments it. - - Returns: - The sequence number for the next batch to be created. - """ - seq_no = self.seq_no - self.seq_no += 1 - return seq_no - - def _get_data( - self, - ) -> Tuple[List[List[Dict[str, Any]]], Dict[str, List[Tuple[int, int]]], List[str]]: - """Gets the data needed to create a batch for the step to process. If the step is - accumulating data, then it will return a list with all the data received from the - predecessors. Otherwise, it will return a list of data with the `input_batch_size` - for each predecessor. In addition, it will remove the data used to create the - batch from the step's data. - - Returns: - A tuple containing the list of data needed to create a batch for the step to - process, a dictionary with the sequence numbers of the batches that were used - to create the batch and the list of steps to which the batch was routed to if - the step is a normal step. - """ - if self.accumulate: - # Steps accumulating cannot receive routed batches - return self._get_data_for_accumulate() + ([],) - - if self.convergence_step: - # Convergence steps will receive routed batches, but we need to clean the - # `batch_routed_to` list - return self._get_data_for_convergence_step() + ([],) - - return self._get_data_normal() - - def _get_data_for_accumulate( - self, - ) -> Tuple[List[List[Dict[str, Any]]], Dict[str, List[Tuple[int, int]]]]: - """Gets the data needed to create a batch for the step to process when the step - is accumulating data. It will return a list with all the data received from the - predecessors. In addition, it will remove the data used to create the batch from - the step's data. - - Returns: - A tuple containing the list of data needed to create a batch for the step to - process and a dictionary with the sequence numbers of the batches that were - used to create the batch. - """ - data = [] - batches_used = {} - for step_name, batches in self.data.items(): - batches_used[step_name] = [] - for batch in batches: - batches_used[step_name].append((batch.seq_no, batch.size)) - data.append([row for batch in batches for row in batch.get_data()]) - # Reset the data buffer - self.data = {step_name: [] for step_name in self.data} - return data, batches_used - - def _get_data_for_convergence_step( - self, - ) -> Tuple[List[List[Dict[str, Any]]], Dict[str, List[Tuple[int, int]]]]: - """Gets the data needed to create a batch for the step to process when the step is - a convergence step. - - Returns: - A tuple containing the list of data needed to create a batch for the step to - process and a dictionary with the sequence numbers of the batches that were - used to create the batch. - """ - grouped_batches = self._group_batches_by_created_from() - seq_no, batches = grouped_batches[0] - str_seq_no = str(seq_no) - - remaining_rows_per_step: Dict[str, int] = { - step_name: self.input_batch_size - for step_name in self.data # type: ignore - } - batches_used = defaultdict(list) - data = defaultdict(list) - for batch, batch_size in batches: - remaining_rows = remaining_rows_per_step[batch.step_name] - selected_data = batch.get_data(remaining_rows) - data[batch.step_name].extend(selected_data) - - # If A -> [B, C] -> D, then in D (this step) we keep track of the remaining - # rows from the batches of A that B and C used to create the `batches`. - batch_size = self.convergence_step_batches_consumed.setdefault( - str_seq_no, {} - ).get(batch.step_name, batch_size) - remaining_rows_in_batch = batch_size - len(selected_data) - self.convergence_step_batches_consumed[str_seq_no].update( - {batch.step_name: remaining_rows_in_batch} - ) - - # Update the remaining rows - num_rows = len(selected_data) - remaining_rows_per_step[batch.step_name] -= num_rows # type: ignore - - # Keep track of the batches used to create the batch - batches_used[batch.step_name].append((batch.seq_no, batch.size)) - - # If the batch was entirely consumed, then remove it from the buffer - if len(batch.data[0]) == 0: - self.data[batch.step_name].remove(batch) - continue - - # If all the batches grouped by the `seq_no` in `created_from` were consumed, then - # we can update the `next_expected_created_from_batch_seq_no` to the next one - # to avoid skipping batches. - no_remaining_rows = all( - count == 0 - for count in self.convergence_step_batches_consumed[str_seq_no].values() - ) - if no_remaining_rows: - self.next_expected_created_from_batch_seq_no += 1 - - return list(data.values()), dict(batches_used) - - def _get_data_normal( - self, - ) -> Tuple[List[List[Dict[str, Any]]], Dict[str, List[Tuple[int, int]]], List[str]]: - """Gets the data needed to create a batch for the step to process when the step is - not accumulating data. It will return a list of data with the `input_batch_size` - for each predecessor. In addition, it will remove the data used to create the batch - from the step's data. + step_name: The name of the step. Returns: - A tuple containing the list of data needed to create a batch for the step to - process, a dictionary with the sequence numbers of the batches that were used - to create the batch and the list of steps to which the batch was routed to if - the step is a convergence step. + `True` if the step is not loaded or already finished, `False` otherwise. """ - data = [] - batches_used = defaultdict(list) - batch_routed_to = [] - for step_name in self.data: - # For each step batches buffer, we will create a batch with the `input_batch_size` - # using the data from the buffer. We will remove the consumed batches (no data - # left) and update the batch data with the remaining data. - step_data = [] - idx_drop_batches = [] - remaining_rows: int = self.input_batch_size # type: ignore - for idx, batch in enumerate(self.data[step_name]): - if remaining_rows == 0: - break - - # Get `remaining_rows` or the remaining rows in the batch and add it to - # the step data that will be used to create the batch - selected_data = batch.get_data(remaining_rows) - step_data.extend(selected_data) - batch_routed_to = batch.batch_routed_to - - # Update the remaining rows - num_rows = len(selected_data) - remaining_rows -= num_rows - - # Keep track of the batches used to create the batch - batches_used[step_name].append((batch.seq_no, batch.size)) - - # If the batch was entirely consumed, then remove it from the buffer - if len(batch.data[0]) == 0: - idx_drop_batches.append(idx) - continue - - # Remove the batches that were entirely consumed - idx_drop_batches.reverse() - for idx in idx_drop_batches: - self.data[step_name].pop(idx) - - data.append(step_data) - - return data, dict(batches_used), batch_routed_to + with self._steps_load_status_lock: + num_workers = self._steps_load_status[step_name] - def _ready_to_create_batch(self) -> bool: - """Checks if there is enough data to create a batch for the step. + # The step has finished (workers = 0) or it has failed to load + if num_workers in [0, _STEP_LOAD_FAILED_CODE]: + return True - Returns: - `True` if there is enough data to create a batch for the step. Otherwise, - `False`. - """ - if self.accumulate: - return self._ready_to_create_batch_accumulate() + return False - if self.convergence_step: - return self._ready_to_create_batch_convergence_step() + @property + @abstractmethod + def QueueClass(self) -> Callable: + """The class of the queue to use in the pipeline.""" + pass - return self._ready_to_create_batch_normal() + def _create_step_input_queue(self, step_name: str) -> "Queue[Any]": + """Creates an input queue for a step. - def _ready_to_create_batch_accumulate(self) -> bool: - """Checks if there is enough data for an step accumulating data. It will return - `True` if the last batch was received from all the predecessors. + Args: + step_name: The name of the step. Returns: - `True` if ready to create a batch, `False` otherwise. + The input queue created. """ - return all( - step in self.last_batch_received - and sum(len(batch.data[0]) for batch in batches) >= 0 - for step, batches in self.data.items() - ) + input_queue = self.QueueClass() + self.dag.set_step_attr(step_name, INPUT_QUEUE_ATTR_NAME, input_queue) + return input_queue - def _ready_to_create_batch_convergence_step(self) -> bool: - """Checks if there is enough data for creating a batch for an step in which output - batches that were generated by steps that received routed batches are received. - It will return `True`, if all the output batches that were generated from a routed - batch have been received. + @abstractmethod + def _run_step(self, step: "_Step", input_queue: "Queue[Any]") -> None: + """Runs the `Step` instance. - Returns: - `True` if ready to create a batch, `False` otherwise. + Args: + step: The `Step` instance to run. + input_queue: The input queue where the step will receive the batches. """ - grouped_batches = self._group_batches_by_created_from() - if not grouped_batches: - return False - seq_no, batches = grouped_batches[0] - - # If the `seq_no` from the `created_from` field is not the expected one, then - # we cannot create a batch yet or the order will be messed up - if seq_no != self.next_expected_created_from_batch_seq_no: - return False - - # Not all output batches to which the input batch was routed to haven't been - # received - batch_routed_to = batches[0][0].batch_routed_to - batches_received_from = {batch.step_name for batch, _ in batches} - if any(step_name not in batches_received_from for step_name in batch_routed_to): - return False - - # There are output batches to which the input batch was routed to from all - # the steps. Check if there is enough data for creating a batch with `input_batch_size` - rows_per_step = defaultdict(lambda: 0) - for batch, _ in batches: - num_rows = len(batch.data[0]) - rows_per_step[batch.step_name] += num_rows - - # If there aren't at least `input_batch_size` rows from each step, then there - # isn't enough data to create a batch - if not all( - num_rows >= self.input_batch_size or step_name in self.last_batch_received # type: ignore - for step_name, num_rows in rows_per_step.items() - ): - return False - - return True - - def _ready_to_create_batch_normal(self) -> bool: - """Checks if there is enough data for creating a batch for a normal step. It will - be `True` it there are at least `input_batch_size` rows from each predecessor step. + pass - Returns: - `True` if ready to create a batch, `False` otherwise. + def _run_steps(self) -> None: + """Runs the `Step`s of the pipeline, creating first an input queue for each step + that will be used to send the batches. """ - for step_name, batches in self.data.items(): - num_rows = sum(len(batch.data[0]) for batch in batches) - - # If there are now rows but the last batch was already received, then there - # are no more batch to be created - if num_rows == 0 and step_name in self.last_batch_received: - return False - - # If there are not enough rows and the last batch was not received yet, then - # there is not enough data yet to creata a batch - if ( - self.input_batch_size - and num_rows < self.input_batch_size - and step_name not in self.last_batch_received - ): - return False + for step_name in self.dag: + step: "Step" = self.dag.get_step(step_name)[STEP_ATTR_NAME] + input_queue = self._create_step_input_queue(step_name=step_name) - return True + # Set `pipeline` to `None` as in some Python environments the pipeline is not + # picklable and it will raise an error when trying to send the step to the process. + # `TypeError: cannot pickle 'code' object` + step.pipeline = None - def _last_batch(self) -> bool: - """Checks if the batch to be created is the last one i.e. if the last batch was - received from all the predecessors. + self._logger.debug(f"Running 1 instance of step '{step.name}'...") + self._run_step(step=step, input_queue=input_queue) - Returns: - `True` if the batch to be created is the last one. Otherwise, `False`. - """ - if self.accumulate: - return self._last_batch_accumulate() + def _add_batches_back_to_batch_manager(self) -> None: + """Add the `Batch`es that were sent to a `Step` back to the `_BatchManager`. This + method should be used when the pipeline has been stopped prematurely.""" + for step_name in self.dag: + node = self.dag.get_step(step_name) + step: "_Step" = node[STEP_ATTR_NAME] + if step.is_generator: + continue + if input_queue := node.get(INPUT_QUEUE_ATTR_NAME): + while not input_queue.empty(): + batch = input_queue.get() + if batch is None: + continue + self._batch_manager.add_batch( # type: ignore + to_step=step_name, batch=batch, prepend=True + ) + self._logger.debug( + f"Adding batch back to the batch manager: {batch}" + ) + input_queue.put(None) + + def _consume_output_queue(self) -> None: + """Consumes the `Batch`es from the output queue until it's empty. This method should + be used when the pipeline has been stopped prematurely to consume and to not lose + the `Batch`es that were processed by the leaf `Step`s before stopping the pipeline.""" + while not self._output_queue.empty(): + batch = self._output_queue.get() + if batch is None: + continue - if self.convergence_step: - return self._last_batch_convergence_step() + if batch.step_name in self.dag.leaf_steps: + self._write_buffer.add_batch(batch) # type: ignore - return self._last_batch_normal() + self._handle_batch_on_stop(batch) - def _last_batch_accumulate(self) -> bool: - """Checks if the batch to be created is the last one for an step accumulating data. - `True` if the last batch was received from all the predecessors. + def _manage_batch_flow(self, batch: "_Batch") -> None: + """Checks if the step that generated the batch has more data in its buffer to + generate a new batch. If there's data, then a new batch is sent to the step. If + the step has no data in its buffer, then the predecessors generator steps are + requested to send a new batch. - Returns: - `True` if the batch to be created is the last one. Otherwise, `False`. + Args: + batch: The batch that was processed. """ - return all(step in self.last_batch_received for step in self.data.keys()) + assert self._batch_manager, "Batch manager is not set" - def _last_batch_convergence_step(self) -> bool: - """Checks if the batch to be created is the last one for a convergence step. `True` - if the last batch of all the steps (`batch_routed_to`) in the last routed batch - have been received. + # Make sure to send the `LAST_BATCH_SENT_FLAG` to the predecessors of the convergence + # step if the batch is the last one, so they stop their processing loop even if + # they haven't received the last batch because of the routing function. + if self._is_convergence_step(batch.step_name) and batch.last_batch: + for step_name in self.dag.get_step_predecessors(batch.step_name): + self._send_last_batch_flag_to_step(step_name) - Returns: - `True` if the batch to be created is the last one. Otherwise, `False`. - """ - grouped_batches = self._group_batches_by_created_from() - if not grouped_batches: - return False - _, batches = grouped_batches[0] + route_to, routed = self._get_successors(batch) - for batch, _ in batches: - if not batch.last_batch: - return False + # Keep track of the steps that the batch was routed to + if routed: + batch.batch_routed_to = route_to - if len(batch.data[0]) > self.input_batch_size: # type: ignore - return False + self._register_batch(batch) - return True + step = self._get_step_from_batch(batch) - def _last_batch_normal(self) -> bool: - """Checks if the batch to be created is the last one for a normal step. `True` if - there is no more data to be received from the predecessors. + # Add the batch to the successors input buffers + for successor in route_to: + # Copy batch to avoid modifying the same reference in the batch manager + batch_to_add = batch.copy() if len(route_to) > 1 else batch - Returns: - `True` if the batch to be created is the last one. Otherwise, `False`. - """ - for step_name, batches in self.data.items(): - if step_name not in self.last_batch_received: - return False - - num_rows = sum(len(batch.data[0]) for batch in batches) + self._batch_manager.add_batch(successor, batch_to_add) + # Check if the step is a generator and if there are successors that need data + # from this step. This usually happens when the generator `batch_size` is smaller + # than the `input_batch_size` of the successor steps. if ( - self.input_batch_size - and num_rows > self.input_batch_size - and step_name in self.last_batch_received + step.is_generator + and step.name in self._batch_manager.step_empty_buffers(successor) ): - return False - - return True - - def _group_batches_by_created_from( - self, - ) -> List[Tuple[int, List[Tuple["_Batch", int]]]]: - """Group the batches by the first key of `created_from` field. This method is - meant to be used only with a `convergence_step`. - - Returns: - A list of the batches grouped by the `seq_no` of the first step name in `created_from`. - The list is sorted by the `seq_no`. - """ - grouped_batches: Dict[int, List[Tuple["_Batch", int]]] = defaultdict(list) - for batches in self.data.values(): - for batch in batches: - first_key = next(iter(batch.created_from)) - batch_seq_no, batch_size = batch.created_from[first_key][0] - grouped_batches[batch_seq_no].append((batch, batch_size)) - return sorted((seq_no, batches) for seq_no, batches in grouped_batches.items()) - - def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: - """Dumps the content of the `_BatchManagerStep` to a dictionary, using the `dataclass` helper function. - - Args: - obj: Unused, just kept to match the signature of the parent method. - kwargs: Additional arguments that are kept to match the signature of the parent method. - - Returns: - Internal representation of the `_BatchManagerStep`. - """ - return { - "step_name": self.step_name, - "accumulate": self.accumulate, - "input_batch_size": self.input_batch_size, - "data": { - step_name: [batch.dump(**kwargs) for batch in batches] - for step_name, batches in self.data.items() - }, - "built_batches": [batch.dump(**kwargs) for batch in self.built_batches], - "seq_no": self.seq_no, - "last_batch_received": self.last_batch_received, - "convergence_step": self.convergence_step, - "convergence_step_batches_consumed": self.convergence_step_batches_consumed, - "next_expected_created_from_batch_seq_no": self.next_expected_created_from_batch_seq_no, - } - - -LAST_BATCH_SENT_FLAG = "last_batch_sent" - - -class _BatchManager(_Serializable): - """Class to manage the batches received from the steps. It keeps track of the - received batches and returns new batches for the steps to process based on their - input batch size and the batches received from the predecessors. - - Attributes: - steps: A dictionary with the step name as the key and a `_BatchManagerStep` - instance as the value. - last_batch_received: A dictionary with the step name as the key and a flag to - indicate whether we received the last batch from the step. - """ - - def __init__( - self, - steps: Dict[str, _BatchManagerStep], - last_batch_received: Dict[str, Union[_Batch, None]], - last_batch_sent: Dict[str, Union[_Batch, None]], - last_batch_flag_sent_to: List[str], - ) -> None: - """Initialize the `_BatchManager` instance. - - Args: - steps: A dictionary with the step name as the key and a dictionary with the - predecessor step name as the key and a list of batches as the value. - last_batch_received: A dictionary with the step name as the key and a the last - `_Batch` received from the step. - last_batch_sent: A dictionary with the step name as the key and a the last - `_Batch` sent to the step. - last_batch_flag_sent_to: A list with the names of the steps to which `LAST_BATCH_SENT_FLAG` - was sent. - """ - - self._steps = steps - self._last_batch_received = last_batch_received - self._last_batch_sent = last_batch_sent - self._last_batch_flag_sent_to = last_batch_flag_sent_to - - def can_generate(self) -> bool: - """Checks if there are still batches to be processed by the steps. - - Returns: - `True` if there are still batches to be processed by the steps. Otherwise, - `False`. - """ - - for step_name, batch in self._last_batch_received.items(): - if step_name not in self._last_batch_flag_sent_to: - if not batch: - return True - - if not batch.last_batch: - return True - - if not self.get_last_batch_sent(step_name): - return True - - return False - - def register_batch(self, batch: _Batch) -> None: - """Method to register a batch received from a step. It will keep track of the - sequence number and the last batch received from the step in the internal maps. + last_batch_sent = self._batch_manager.get_last_batch_sent(step.name) + self._send_batch_to_step(last_batch_sent.next_batch()) # type: ignore + + # If successor step has enough data in its buffer to create a new batch, then + # send the batch to the step. + if new_batch := self._batch_manager.get_batch(successor): + self._send_batch_to_step(new_batch) + + if not step.is_generator: + # Step ("this", the one from which the batch was received) has enough data on its + # buffers to create a new batch + if new_batch := self._batch_manager.get_batch(step.name): # type: ignore + self._send_batch_to_step(new_batch) + else: + self._request_more_batches_if_needed(step) - Args: - batch: _Batch from which we will register the sequence number and the last batch received. - """ - self._last_batch_received[batch.step_name] = batch + self._cache() - def get_last_batch(self, step_name: str) -> Union[_Batch, None]: - """Gets the last batch received from a step. + def _send_to_step(self, step_name: str, to_send: Any) -> None: + """Sends something to the input queue of a step. Args: step_name: The name of the step. - - Returns: - The last batch received from the step or `None` if no batch was received. - """ - return self._last_batch_received.get(step_name) - - def add_batch(self, to_step: str, batch: _Batch, prepend: bool = False) -> None: - """Add an output batch from `batch.step_name` to `to_step`. - - Args: - to_step: The name of the step that will process the batch. - batch: The output batch of an step to be processed by `to_step`. - prepend: If `True`, the content of the batch will be added at the start of - the buffer. - - Raises: - ValueError: If `to_step` is not found in the batch manager. + to_send: The object to send. """ - if to_step not in self._steps: - raise ValueError(f"Step '{to_step}' not found in the batch manager.") + input_queue = self.dag.get_step(step_name)[INPUT_QUEUE_ATTR_NAME] + input_queue.put(to_send) - step = self._steps[to_step] - step.add_batch(batch, prepend) + def _send_batch_to_step(self, batch: "_Batch") -> None: + """Sends a batch to the input queue of a step, writing the data of the batch + to the filesystem and setting `batch.data_path` with the path where the data + was written (if requiered i.e. the step is a global step or `use_fs_to_pass_data`) - def get_batch(self, step_name: str) -> Union[_Batch, None]: - """Get the next batch to be processed by the step. + This method should be extended by the specific pipeline implementation, adding + the logic to send the batch to the step. Args: - step_name: The name of the step that will process the batch. - - Returns: - A `_Batch` instance if there is a batch to be processed by the step. Otherwise, - `None`. + batch: The batch to send. """ - if step_name not in self._steps: - raise ValueError(f"Step '{step_name}' not found in the batch manager.") - - return self._steps[step_name].get_batch() + self._logger.debug( + f"Setting batch {batch.seq_no} as last batch sent to '{batch.step_name}': {batch}" + ) + self._batch_manager.set_last_batch_sent(batch) # type: ignore - def step_empty_buffers(self, step_name: str) -> List[str]: - """Checks if the input buffer for a step is empty. + step: "_Step" = self.dag.get_step(batch.step_name)[STEP_ATTR_NAME] + if not step.is_generator and (step.is_global or self._use_fs_to_pass_data): + base_path = UPath(self._storage_base_path) / step.name # type: ignore + self._logger.debug( + f"Writing {batch.seq_no} batch for '{batch.step_name}' step to filesystem: {base_path}" + ) + batch.write_batch_data_to_fs(self._fs, base_path) # type: ignore - Args: - step_name: The name of the step. + self._logger.debug( + f"Sending batch {batch.seq_no} to step '{batch.step_name}': {batch}" + ) - Returns: - The name of the previous steps for which the input buffer for this step is - empty. - """ - return self._steps[step_name].empty_buffers() + self._send_to_step(batch.step_name, batch) - def set_last_batch_sent(self, batch: "_Batch") -> None: - """Set the last batch sent to a step. + def _register_batch(self, batch: "_Batch") -> None: + """Registers a batch in the batch manager. Args: - batch: The last batch sent to a step. + batch: The batch to register. """ - self._last_batch_sent[batch.step_name] = batch + self._batch_manager.register_batch(batch) # type: ignore + self._logger.debug( + f"Batch {batch.seq_no} from step '{batch.step_name}' registered in batch" + " manager" + ) - def get_last_batch_sent(self, step_name: str) -> Union["_Batch", None]: - """Get the last batch sent to a step. + def _send_last_batch_flag_to_step(self, step_name: str) -> None: + """Sends the `LAST_BATCH_SENT_FLAG` to a step to stop processing batches. Args: step_name: The name of the step. - - Returns: - The last batch sent to a step or `None` if no batch was sent. """ - return self._last_batch_sent.get(step_name, None) + batch = self._batch_manager.get_last_batch_sent(step_name) # type: ignore + if batch and batch.last_batch: + return - def set_last_batch_flag_sent_to(self, step_name: str) -> None: - """Set the flag to indicate that the last batch was sent to a step. + self._logger.debug( + f"Sending `LAST_BATCH_SENT_FLAG` to '{step_name}' step to stop processing" + " batches..." + ) - Args: - step_name: The name of the step. - """ - self._last_batch_flag_sent_to.append(step_name) + self._send_to_step(step_name, LAST_BATCH_SENT_FLAG) + self._batch_manager.set_last_batch_flag_sent_to(step_name) # type: ignore - @classmethod - def from_dag(cls, dag: "DAG") -> "_BatchManager": - """Create a `_BatchManager` instance from a `DAG` instance. + def _request_initial_batches(self) -> None: + """Requests the initial batches to the generator steps.""" + assert self._batch_manager, "Batch manager is not set" - Args: - dag: The `DAG` instance. + for step in self._batch_manager._steps.values(): + if batch := step.get_batch(): + self._logger.debug( + f"Sending initial batch to '{step.step_name}' step: {batch}" + ) + self._send_batch_to_step(batch) - Returns: - A `_BatchManager` instance. - """ - steps = {} - last_batch_received = {} - last_batch_sent = {} - for step_name in dag: - step: "_Step" = dag.get_step(step_name)[STEP_ATTR_NAME] - last_batch_received[step.name] = None - last_batch_sent[step.name] = None - if step.is_generator: - continue - predecessors = list(dag.get_step_predecessors(step_name)) - convergence_step = all( - dag.get_step(predecessor).get(RECEIVES_ROUTED_BATCHES_ATTR_NAME, False) - for predecessor in predecessors - ) - batch_manager_step = _BatchManagerStep.from_step( - step=step, - predecessors=predecessors, - convergence_step=convergence_step, + for step_name in self.dag.root_steps: + seq_no = 0 + if last_batch := self._batch_manager.get_last_batch(step_name): + seq_no = last_batch.seq_no + 1 + batch = _Batch(seq_no=seq_no, step_name=step_name, last_batch=self._dry_run) + self._logger.debug( + f"Requesting initial batch to '{step_name}' generator step: {batch}" ) - steps[step_name] = batch_manager_step - return cls(steps, last_batch_received, last_batch_sent, []) - - def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: - """Dumps the content of the `_BatchManager` to a dictionary. - - Args: - obj (Any): Unused, just kept to match the signature of the parent method. - kwargs (Any): Additional arguments that are kept to match the signature of the parent method. - - Returns: - Dict[str, Any]: Internal representation of the `_BatchManager`. - """ - return { - "steps": {name: step.dump(**kwargs) for name, step in self._steps.items()}, - "last_batch_received": { - step_name: batch.dump(**kwargs) if batch is not None else None - for step_name, batch in self._last_batch_received.items() - }, - "last_batch_sent": { - step_name: batch.dump(**kwargs) if batch is not None else None - for step_name, batch in self._last_batch_sent.items() - }, - "last_batch_flag_sent_to": self._last_batch_flag_sent_to, - } + self._send_batch_to_step(batch) - def cache(self, path: "StrOrPath") -> None: - """Cache the `_BatchManager` to a file. + def _request_more_batches_if_needed(self, step: "Step") -> None: + """Request more batches to the predecessors steps of `step` if needed. Args: - path: The path to the file where the `_BatchManager` will be cached. If `None`, - then the `_BatchManager` will be cached in the default cache folder. - """ - - def save_batch( - batches_dir: Path, batch_dump: Dict[str, Any], batch_list: List[_Batch] - ) -> Path: - seq_no = batch_dump["seq_no"] - data_hash = batch_dump["data_hash"] - batch_file = batches_dir / f"batch_{seq_no}_{data_hash}.json" - - # Save the batch if it doesn't exist - if not batch_file.exists(): - # Get the data of the batch before saving it - batch = next(batch for batch in batch_list if batch.seq_no == seq_no) - batch_dump["data"] = batch.data - self.save(path=batch_file, format="json", dump=batch_dump) - - return batch_file - - def remove_files(keep_files: List[str], dir: Path) -> None: - files = list_files_in_dir(dir, key=None) - remove = set(files) - {Path(file) for file in keep_files} - for file in remove: - file.unlink() - - path = Path(path) - - # Do not include `_Batch` data so `dump` is fast - dump = self.dump(include_batch_data=False) - batch_manager_step_files = {} - - # Do this to avoid modifying the dictionary while iterating over it - batch_manager_steps = set(dump["steps"].keys()) - for step_name in batch_manager_steps: - step_dump = dump["steps"].pop(step_name) - - # Create a directory for each batch manager step to store their batches - batch_manager_step_dir = path.parent / "batch_manager_steps" / step_name - batch_manager_step_dir.mkdir(parents=True, exist_ok=True) - - # Store each built `_Batch` in a separate file - built_batches_dir = batch_manager_step_dir / "built_batches" - built_batches_dir.mkdir(parents=True, exist_ok=True) - step_dump["built_batches"] = [ - str( - save_batch( - batches_dir=built_batches_dir, - batch_dump=batch_dump, - batch_list=self._steps[step_name].built_batches, - ) - ) - for batch_dump in step_dump["built_batches"] - ] - # Remove built `_Batch`es that were consumed from cache - remove_files(step_dump["built_batches"], built_batches_dir) - - # Store each `_BatchManagerStep` `_Batch`es in a separate file - for buffered_step_name in step_dump["data"]: - step_batches_dir = batch_manager_step_dir / buffered_step_name - step_batches_dir.mkdir(parents=True, exist_ok=True) - - # Store each `_Batch` in a separate file - step_dump["data"][buffered_step_name] = [ - str( - save_batch( - batches_dir=step_batches_dir, - batch_dump=batch_dump, - batch_list=self._steps[step_name].data[buffered_step_name], - ) - ) - for batch_dump in step_dump["data"][buffered_step_name] - ] + step: The step of which it has to be checked if more batches are needed from + its predecessors. + """ + empty_buffers = self._batch_manager.step_empty_buffers(step.name) # type: ignore + for previous_step_name in empty_buffers: + # Only more batches can be requested to the `GeneratorStep`s as they are the + # only kind of steps that lazily generate batches. + if previous_step_name not in self.dag.root_steps: + continue - # Remove `_Batch`es that were consumed from cache - remove_files(step_dump["data"][buffered_step_name], step_batches_dir) + # Get the last batch that the previous step sent to generate the next batch + # (next `seq_no`). + last_batch = self._batch_manager.get_last_batch_sent(previous_step_name) # type: ignore + if last_batch is None: + continue - # Store the `_BatchManagerStep` info - batch_manager_step_file = str( - path.parent / f"batch_manager_steps/{step_name}/batch_manager_step.json" + self._logger.debug( + f"Step '{step.name}' input buffer for step '{previous_step_name}' is" + " empty. Requesting new batch..." ) - self.save(path=batch_manager_step_file, format="json", dump=step_dump) + self._send_batch_to_step(last_batch.next_batch()) - # Store the path to the `_BatchManagerStep` file - batch_manager_step_files[step_name] = batch_manager_step_file - - dump["steps"] = batch_manager_step_files - self.save(path=path, format="json", dump=dump) - - @classmethod - def load_from_cache(cls, path: "StrOrPath") -> "_BatchManager": - """Loads the `_BatchManager` from a cache file. + def _handle_batch_on_stop(self, batch: "_Batch") -> None: + """Handles a batch that was received from the output queue when the pipeline was + stopped. It will add and register the batch in the batch manager. Args: - path: The path to the cache file. + batch: The batch to handle. """ - _check_is_dir(path) - content = read_json(path) - - # Read each `_BatchManagerStep` from file - steps = {} - for step_name, step_file in content["steps"].items(): - steps[step_name] = read_json(step_file) - - # Read each `_Batch` from file - steps[step_name]["built_batches"] = [ - read_json(batch) for batch in steps[step_name]["built_batches"] - ] - - for buffered_step_name, batch_files in steps[step_name]["data"].items(): - steps[step_name]["data"][buffered_step_name] = [ - read_json(batch_file) for batch_file in batch_files - ] - - content["steps"] = steps - return cls.from_dict(content) + assert self._batch_manager, "Batch manager is not set" + self._batch_manager.register_batch(batch) + step: "Step" = self.dag.get_step(batch.step_name)[STEP_ATTR_NAME] + for successor in self.dag.get_step_successors(step.name): # type: ignore + self._batch_manager.add_batch(successor, batch) -class _WriteBuffer: - """Class in charge of sending the batched contents to a buffer and writing - those to files under a given folder. + def _get_step_from_batch(self, batch: "_Batch") -> "Step": + """Gets the `Step` instance from a batch. - As batches are received, they are added to the buffer and once each buffer - is full, the content is written to a parquet file. - """ - - def __init__(self, path: "PathLike", leaf_steps: Set[str]) -> None: - """ Args: - path: Folder where the files will be written, the idea - is for this path to be in the cache folder under /data. - leaf_steps: Leaf steps from either the DAG of the Pipeline. + batch: The batch to get the step from. - Raises: - ValueError: If the path is not a directory. + Returns: + The `Step` instance. """ - self._path = Path(path) - if not self._path.exists(): - self._path.mkdir(parents=True, exist_ok=True) - for step in leaf_steps: - (self._path / step).mkdir(parents=True, exist_ok=True) - - if not self._path.is_dir(): - raise ValueError(f"The path should be a directory, not a file: {path}") + return self.dag.get_step(batch.step_name)[STEP_ATTR_NAME] - self._buffers: Dict[str, List[Dict[str, Any]]] = { - step: [] for step in leaf_steps - } - # TODO: make this configurable - self._buffers_dump_batch_size: Dict[str, int] = { - step: 50 for step in leaf_steps - } - self._buffer_last_schema = {} - self._buffers_last_file: Dict[str, int] = {step: 1 for step in leaf_steps} - self._logger = logging.getLogger("distilabel.write_buffer") + def _notify_steps_to_stop(self) -> None: + """Notifies the steps to stop their infinite running loop by sending `None` to + their input queues.""" + for step_name in self.dag: + self._send_to_step(step_name, None) - def _get_filename(self, step_name: str) -> Path: - """Creates the filename for the step. + def _get_successors(self, batch: "_Batch") -> Tuple[List[str], bool]: + """Gets the successors and the successors to which the batch has to be routed. Args: - step_name: Name of the step to which the data belongs to. - - Returns: - Filename for the step. - """ - return self._path / f"{step_name}.parquet" - - def is_full(self, step_name: str) -> bool: - """Checks the buffers that are full so that those can be written to the file. + batch: The batch to which the successors will be determined. Returns: - Whether the buffer is full. - """ - return len(self._buffers[step_name]) >= self._buffers_dump_batch_size[step_name] - - def add_batch(self, batch: "_Batch") -> None: - """Adds a batch to the buffer and writes the buffer to the file if it's full. - - Args: - batch: batch to add to the buffer. - """ - step_name = batch.step_name - data = batch.data[0] - self._buffers[step_name].extend(data) - self._logger.debug( - f"Added batch to write buffer for step '{step_name}' with {len(data)} rows." - ) - if self.is_full(step_name): - self._logger.debug( - f"Buffer for step '{step_name}' is full (rows: {len(self._buffers[step_name])}," - f" full: {self._buffers_dump_batch_size[step_name]}), writing to file..." + The successors to route the batch to and whether the batch was routed using + a routing function. + """ + node = self.dag.get_step(batch.step_name) + step: "Step" = node[STEP_ATTR_NAME] + successors = list(self.dag.get_step_successors(step.name)) # type: ignore + route_to = successors + + # Check if the step has a routing function to send the batch to specific steps + if routing_batch_function := node.get(ROUTING_BATCH_FUNCTION_ATTR_NAME): + route_to = routing_batch_function(batch, successors) + successors_str = ", ".join(f"'{successor}'" for successor in route_to) + self._logger.info( + f"🚏 Using '{step.name}' routing function to send batch {batch.seq_no} to steps: {successors_str}" ) - self._write(step_name) - def _write(self, step_name: str) -> None: - """Writes the content to the file and cleans the buffer. + return route_to, route_to != successors - Args: - step_name (str): Name of the step to which the data pertains. - """ - step_parquet_dir = Path(self._path, step_name) - if not step_parquet_dir.exists(): - self._logger.debug( - f"Creating directory for step '{step_name}' parquet files..." - ) - step_parquet_dir.mkdir() + @abstractmethod + def _stop(self) -> None: + """Stops the pipeline in a controlled way.""" + pass - try: - table = pa.Table.from_pylist(self._buffers[step_name]) - except pa.lib.ArrowInvalid as pae: - if ( - repr(pae) - != "ArrowInvalid('cannot mix struct and non-struct, non-null values')" - ): - raise pae - flattened_buffers = [flatten_dict(buf) for buf in self._buffers[step_name]] - table = pa.Table.from_pylist(flattened_buffers) + def _stop_load_queue_loop(self) -> None: + """Stops the `_load_queue` loop sending a `None`.""" + self._logger.debug("Sending `None` to the load queue to notify stop...") + self._load_queue.put(None) - last_schema = self._buffer_last_schema.get(step_name) - if last_schema is None: - self._buffer_last_schema[step_name] = table.schema - else: - if not last_schema.equals(table.schema): - new_schema = pa.unify_schemas([last_schema, table.schema]) - self._buffer_last_schema[step_name] = new_schema - table = table.cast(new_schema) + def _stop_output_queue_loop(self) -> None: + """Stops the `_output_queue` loop sending a `None`.""" + self._logger.debug("Sending `None` to the output queue to notify stop...") + self._output_queue.put(None) - next_file_number = self._buffers_last_file[step_name] - self._buffers_last_file[step_name] = next_file_number + 1 + def _handle_keyboard_interrupt(self) -> Any: + """Handles KeyboardInterrupt signal sent during the Pipeline.run method. - parquet_file = step_parquet_dir / f"{str(next_file_number).zfill(5)}.parquet" - pq.write_table(table, parquet_file) - self._logger.debug(f"Written to file '{parquet_file}'") + It will try to call self._stop (if the pipeline didn't started yet, it won't + have any effect), and if the pool is already started, will close it before exiting + the program. - self._clean_buffer(step_name) + Returns: + The original `signal.SIGINT` handler. + """ - def _clean_buffer(self, step_name: str) -> None: - """Cleans the buffer by setting it's content to `None`. + def signal_handler(signumber: int, frame: Any) -> None: + self._stop() - Args: - step_name: The name of the buffer to clean. - """ - self._buffers[step_name] = [] - - def close(self) -> None: - """Closes the buffer by writing the remaining content to the file.""" - for step_name in self._buffers: - if self._buffers[step_name]: - self._write(step_name) - - # We need to read the parquet files and write them again to ensure the schema - # is correct. Otherwise, the first parquets won't have the last schema and - # then we will have issues when reading them. - for file in list_files_in_dir(self._path / step_name): - if step_name in self._buffer_last_schema: - table = pq.read_table( - file, schema=self._buffer_last_schema[step_name] - ) - pq.write_table(table, file) + return signal.signal(signal.SIGINT, signal_handler) diff --git a/src/distilabel/pipeline/batch.py b/src/distilabel/pipeline/batch.py new file mode 100644 index 0000000000..d8ad4312ae --- /dev/null +++ b/src/distilabel/pipeline/batch.py @@ -0,0 +1,233 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 copy +import hashlib +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple, Union + +import fsspec +import pyarrow as pa +import pyarrow.parquet as pq +from upath import UPath + +from distilabel.utils.serialization import _Serializable + + +@dataclass +class _Batch(_Serializable): + """Dataclass to represent a batch of data to be processed by a `_Step`. + + Attributes: + seq_no: The sequence number of the batch. + step_name: The name of the step that will process the batch. + last_batch: A flag to indicate if the batch is the last one. + data: The data to be processed. + data_hash: The hash of the data. Defaults to `None`. + data_path: The path where the data of the batch is stored. Defaults to `None`. + accumulated: A flag to indicate if the batch is accumulated. + created_from: A dictionary containing the `seq_no` of the batches of the steps that + were used to create this batch. + size: The size of the batch. + """ + + seq_no: int + step_name: str + last_batch: bool + data: List[List[Dict[str, Any]]] = field(default_factory=list, repr=False) + data_hash: Optional[str] = None + data_path: Optional[str] = None + accumulated: bool = False + created_from: Dict[str, List[Tuple[int, int]]] = field(default_factory=dict) + batch_routed_to: List[str] = field(default_factory=list) + size: int = 0 + _fs: Optional[fsspec.AbstractFileSystem] = None + + def next_batch(self) -> "_Batch": + """Create a new `_Batch` instance with the next batch of data. + + Args: + data: The data to be processed. + + Returns: + A `_Batch` instance. + """ + return _Batch( + seq_no=self.seq_no + 1, step_name=self.step_name, last_batch=self.last_batch + ) + + def set_data(self, data: List[List[Dict[str, Any]]]) -> None: + """Sets the data of the batch and updates the size of the batch. + + Args: + data: The data of the batch. + """ + self.data = data + self.size = len(data[0]) + self._update_data_hash() + + def get_data(self, num_rows: Union[int, None] = None) -> List[Dict[str, Any]]: + """Takes `num_rows` from the data of the batch and returns it. This method will + also remove the data from the batch and update the hash of the data. + + Args: + num_rows: The number of rows to take from the data. If `None`, then all the + data will be taken. Defaults to `None`. + + Returns: + A list with the data taken from the batch. + """ + + if self.data == [] and self.data_path is not None: + pass + + if num_rows is None: + data = self.data[0] + self.data = [] + else: + data = self.data[0][:num_rows] + self.data[0] = self.data[0][num_rows:] + + self._update_data_hash() + return data + + def _update_data_hash(self) -> None: + """Updates the hash of the data of the batch.""" + self.data_hash = hashlib.sha1(str(self.data).encode()).hexdigest() + + @classmethod + def accumulate(cls, step_name: str, batches: List[List["_Batch"]]) -> "_Batch": + """Creates a `_Batch` instance using the data from the list of batches that + were received from another steps. The batches will be accumulated in a single + list of data. + + Args: + step_name: The name of the step that will process the batch. + batches: a list containing the list of batches received from the predecessors. + + Returns: + A `_Batch` instance. + """ + + data = [] + for step_batches in batches: + accumulated_data = [row for batch in step_batches for row in batch.data[0]] + data.append(accumulated_data) + return cls( + seq_no=0, step_name=step_name, last_batch=True, data=data, accumulated=True + ) + + def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: + """Dumps the content of the `_Batch` to a dictionary, using the `dataclass` helper function. + + Args: + obj: Unused, just kept to match the signature of the parent method. + kwargs: Additional arguments that are kept to match the signature of the parent method. + + Returns: + A `dict` containing the internal representation of the `_Batch`. + """ + + include_batch_data = kwargs.get("include_batch_data", True) + + dump = { + "seq_no": self.seq_no, + "step_name": self.step_name, + "last_batch": self.last_batch, + "data_hash": self.data_hash, + "accumulated": self.accumulated, + "created_from": self.created_from, + "batch_routed_to": self.batch_routed_to, + "size": self.size, + } + + if include_batch_data: + dump["data"] = self.data + + return dump + + def copy(self) -> "_Batch": + """Creates a copy of the `_Batch` instance. + + Returns: + A copy of the `_Batch` instance. + """ + return copy.deepcopy(self) + + def write_batch_data_to_fs( + self, + fs: Optional[fsspec.AbstractFileSystem] = None, + base_path: Optional[UPath] = None, + ) -> None: + """Writes the content of the batch to the filesystem. + + Args + fs: The `fsspec` filesystem to be used to write the data. If not provided, the + one set in the `_fs` attribute will be used. Defaults to `None`. + base_path: The base path where the data of the batch will be stored. If not + provided, the one set in the `data_path` attribute will be used. Defaults + to `None`. + + Raises: + ValueError: If `fs` is not provided and the `_fs` attribute is not set. + """ + + if not fs and not self._fs: + raise ValueError( + "The `fs` parameter must be provided if the `_fs` attribute is not set." + ) + + if fs: + self._fs = fs + + if not base_path and not self.data_path: + raise ValueError( + "The `base_path` parameter must be provided if the `data_path` attribute" + " is not set." + ) + + seq_no_dir = ( + base_path / f"seq_no_{self.seq_no}" if base_path else UPath(self.data_path) + ) + seq_no_dir._fs_cached = self._fs # type: ignore + seq_no_dir.mkdir(parents=True, exist_ok=True) + + for i, data in enumerate(self.data): + table = pa.Table.from_pylist(data) + with self._fs.open(seq_no_dir / f"data_index_{i}.parquet", "wb") as f: # type: ignore + pq.write_table(table, f) + + self.data = [] + self.data_path = str(seq_no_dir) + + def read_batch_data_from_fs(self) -> None: + """Reads the content of the batch from the filesystem.""" + if not self.data_path: + raise ValueError( + "`data_path` attribute must be set to read the data from the filesystem." + " Use `write_batch_data_to_fs` method to set the `data_path` attribute." + ) + + if not self._fs: + raise ValueError( + "`_fs` attribute must be set to read the data from the filesystem." + " Use `write_batch_data_to_fs` method to set the `_fs` attribute." + ) + + for file in self._fs.ls(self.data_path): + with self._fs.open(file, "rb") as f: + table = pq.read_table(f) + self.data.append(table.to_pylist()) + + self._fs.rm(self.data_path, recursive=True) diff --git a/src/distilabel/pipeline/batch_manager.py b/src/distilabel/pipeline/batch_manager.py new file mode 100644 index 0000000000..cc14f0dd21 --- /dev/null +++ b/src/distilabel/pipeline/batch_manager.py @@ -0,0 +1,896 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Tuple, Union + +from distilabel.pipeline._dag import DAG +from distilabel.pipeline.batch import _Batch +from distilabel.pipeline.constants import ( + RECEIVES_ROUTED_BATCHES_ATTR_NAME, + STEP_ATTR_NAME, +) +from distilabel.steps.base import _Step +from distilabel.utils.files import list_files_in_dir +from distilabel.utils.serialization import ( + StrOrPath, + _check_is_dir, + _Serializable, + read_json, +) + +if TYPE_CHECKING: + from distilabel.utils.serialization import StrOrPath + + +@dataclass +class _BatchManagerStep(_Serializable): + """A class that will accumulate data for a step from the predecessors and create + batches for the step to process when there is enough data. + + Attributes: + step_name: The name of the step that will process the data. + accumulate: A flag to indicate if the data should be accumulated and create a + batch with all the data received from the predecessors instead of creating + batches with the `input_batch_size`. + input_batch_size: The size of the batch to be created for the step to process. + If `None`, then `accumulate` must be `True`. Defaults to `None`. + data: A dictionary with the predecessor step name as the key and a list of + dictionaries (rows) as the value. + built_batches: A list with the batches that were built and sent to the step queue, + but the step was stopped before processing the batch, so the batch doesn't get + lost. Defaults to an empty list. + seq_no: The sequence number of the next batch to be created. It will be + incremented for each batch created. + last_batch_received: A list with the names of the steps that sent the last + batch of data. + convergence_step: A flag to indicate if the step is a convergence step. An + `Step` is a convergence step if all its predecessors are receiving routed + batches. Defaults to `False`. + convergence_step_batches_consumed: A dictionary in which the key is the `seq_no` + of the batch created by step A, that was used by step B and C and obtained from + the `created_from` of the batches created by them. It's used to know if all + the batches from B and C steps created from batches of A have been consumed + by D, in order to not mess up the order of the batches. Only used if `convergence_step=True`. + Defaults to an empty dictionary. + next_expected_created_from_batch_seq_no: The next expected sequence number of the + batch from step A used by steps B and C and obtained from the `created_from` + of the batches created by them. It's used to avoid messing up the order of the + batches. Only used if `convergence_step=True`. Defaults to `0`. + """ + + step_name: str + accumulate: bool + input_batch_size: Union[int, None] = None + data: Dict[str, List[_Batch]] = field(default_factory=dict) + built_batches: List[_Batch] = field(default_factory=list) + seq_no: int = 0 + last_batch_received: List[str] = field(default_factory=list) + convergence_step: bool = False + convergence_step_batches_consumed: Dict[str, Dict[str, int]] = field( + default_factory=dict + ) + next_expected_created_from_batch_seq_no: int = 0 + + def add_batch(self, batch: _Batch, prepend: bool = False) -> None: + """Add a batch of data from `batch.step_name` to the step. It will accumulate the + data and keep track of the last batch received from the predecessors. + + Args: + batch: The output batch of an step to be processed by the step. + prepend: If `True`, the content of the batch will be added to the `built_batches` + list. This is done so if a `_Batch` was already built and send to the step + queue, and the step is stopped before processing the batch, the batch doesn't + get lost. Defaults to `False`. + """ + from_step = batch.step_name + + if prepend: + self.built_batches.append(batch) + else: + self.data[from_step].append(batch) + + if batch.last_batch: + self.last_batch_received.append(from_step) + + def get_batch(self) -> Union[_Batch, None]: + """Create a new batch of data for the step to process. It will return `None` if + there is not enough data to create a batch. + + Returns: + A `_Batch` instance if there is enough data to create a batch. Otherwise, + `None`. + """ + if not self._ready_to_create_batch(): + return None + + # If there are batches in the `built_batches` list, then return the first one + # and remove it from the list. + if self.built_batches: + return self.built_batches.pop(0) + + # `_last_batch` must be called before `_get_data`, as `_get_data` will update the + # list of data which is used to determine if the batch to be created is the last one. + # TODO: remove `_last_batch` method and integrate logic in `_get_data` + last_batch = self._last_batch() + data, created_from, batch_routed_to = self._get_data() + + return _Batch( + seq_no=self._get_seq_no(), + step_name=self.step_name, + last_batch=last_batch, + data=data, + accumulated=self.accumulate, + created_from=created_from, + batch_routed_to=batch_routed_to, + ) + + def empty_buffers(self) -> List[str]: + """Checks if the input buffer for the step is empty. + + Returns: + The name of the previous steps for which the input buffer for this step is + empty. + """ + if self.accumulate: + return [ + previous_step + for previous_step in self.data.keys() + if previous_step not in self.last_batch_received + ] + + return [ + previous_step + for previous_step, batches in self.data.items() + if previous_step not in self.last_batch_received + and sum(len(batch.data[0]) for batch in batches) < self.input_batch_size # type: ignore + ] + + @classmethod + def from_step( + cls, step: "_Step", predecessors: Iterable[str], convergence_step: bool = False + ) -> "_BatchManagerStep": + """Creates a `_BatchManagerStep` instance from a `_Step` instance and its + predecessors. + + Args: + step: The `_Step` instance. + predecessors: The names of the predecessors of the step. + convergence_step: A flag to indicate if the step is a convergence step. An + `Step` is a convergence step if all its predecessors are receiving routed + batches. Defaults to `False`. + + Returns: + A `_BatchManagerStep` instance. + """ + return cls( + step_name=step.name, # type: ignore + accumulate=step.is_global, + input_batch_size=getattr(step, "input_batch_size", None), + data={predecessor: [] for predecessor in predecessors}, + convergence_step=convergence_step, + ) + + def _get_seq_no(self) -> int: + """Gets the sequence number for the next batch to be created and increments it. + + Returns: + The sequence number for the next batch to be created. + """ + seq_no = self.seq_no + self.seq_no += 1 + return seq_no + + def _get_data( + self, + ) -> Tuple[List[List[Dict[str, Any]]], Dict[str, List[Tuple[int, int]]], List[str]]: + """Gets the data needed to create a batch for the step to process. If the step is + accumulating data, then it will return a list with all the data received from the + predecessors. Otherwise, it will return a list of data with the `input_batch_size` + for each predecessor. In addition, it will remove the data used to create the + batch from the step's data. + + Returns: + A tuple containing the list of data needed to create a batch for the step to + process, a dictionary with the sequence numbers of the batches that were used + to create the batch and the list of steps to which the batch was routed to if + the step is a normal step. + """ + if self.accumulate: + # Steps accumulating cannot receive routed batches + return self._get_data_for_accumulate() + ([],) + + if self.convergence_step: + # Convergence steps will receive routed batches, but we need to clean the + # `batch_routed_to` list + return self._get_data_for_convergence_step() + ([],) + + return self._get_data_normal() + + def _get_data_for_accumulate( + self, + ) -> Tuple[List[List[Dict[str, Any]]], Dict[str, List[Tuple[int, int]]]]: + """Gets the data needed to create a batch for the step to process when the step + is accumulating data. It will return a list with all the data received from the + predecessors. In addition, it will remove the data used to create the batch from + the step's data. + + Returns: + A tuple containing the list of data needed to create a batch for the step to + process and a dictionary with the sequence numbers of the batches that were + used to create the batch. + """ + data = [] + batches_used = {} + for step_name, batches in self.data.items(): + batches_used[step_name] = [] + for batch in batches: + batches_used[step_name].append((batch.seq_no, batch.size)) + data.append([row for batch in batches for row in batch.get_data()]) + # Reset the data buffer + self.data = {step_name: [] for step_name in self.data} + return data, batches_used + + def _get_data_for_convergence_step( + self, + ) -> Tuple[List[List[Dict[str, Any]]], Dict[str, List[Tuple[int, int]]]]: + """Gets the data needed to create a batch for the step to process when the step is + a convergence step. + + Returns: + A tuple containing the list of data needed to create a batch for the step to + process and a dictionary with the sequence numbers of the batches that were + used to create the batch. + """ + grouped_batches = self._group_batches_by_created_from() + seq_no, batches = grouped_batches[0] + str_seq_no = str(seq_no) + + remaining_rows_per_step: Dict[str, int] = { + step_name: self.input_batch_size + for step_name in self.data # type: ignore + } + batches_used = defaultdict(list) + data = defaultdict(list) + for batch, batch_size in batches: + remaining_rows = remaining_rows_per_step[batch.step_name] + selected_data = batch.get_data(remaining_rows) + data[batch.step_name].extend(selected_data) + + # If A -> [B, C] -> D, then in D (this step) we keep track of the remaining + # rows from the batches of A that B and C used to create the `batches`. + batch_size = self.convergence_step_batches_consumed.setdefault( + str_seq_no, {} + ).get(batch.step_name, batch_size) + remaining_rows_in_batch = batch_size - len(selected_data) + self.convergence_step_batches_consumed[str_seq_no].update( + {batch.step_name: remaining_rows_in_batch} + ) + + # Update the remaining rows + num_rows = len(selected_data) + remaining_rows_per_step[batch.step_name] -= num_rows # type: ignore + + # Keep track of the batches used to create the batch + batches_used[batch.step_name].append((batch.seq_no, batch.size)) + + # If the batch was entirely consumed, then remove it from the buffer + if len(batch.data[0]) == 0: + self.data[batch.step_name].remove(batch) + continue + + # If all the batches grouped by the `seq_no` in `created_from` were consumed, then + # we can update the `next_expected_created_from_batch_seq_no` to the next one + # to avoid skipping batches. + no_remaining_rows = all( + count == 0 + for count in self.convergence_step_batches_consumed[str_seq_no].values() + ) + if no_remaining_rows: + self.next_expected_created_from_batch_seq_no += 1 + + return list(data.values()), dict(batches_used) + + def _get_data_normal( + self, + ) -> Tuple[List[List[Dict[str, Any]]], Dict[str, List[Tuple[int, int]]], List[str]]: + """Gets the data needed to create a batch for the step to process when the step is + not accumulating data. It will return a list of data with the `input_batch_size` + for each predecessor. In addition, it will remove the data used to create the batch + from the step's data. + + Returns: + A tuple containing the list of data needed to create a batch for the step to + process, a dictionary with the sequence numbers of the batches that were used + to create the batch and the list of steps to which the batch was routed to if + the step is a convergence step. + """ + data = [] + batches_used = defaultdict(list) + batch_routed_to = [] + for step_name in self.data: + # For each step batches buffer, we will create a batch with the `input_batch_size` + # using the data from the buffer. We will remove the consumed batches (no data + # left) and update the batch data with the remaining data. + step_data = [] + idx_drop_batches = [] + remaining_rows: int = self.input_batch_size # type: ignore + for idx, batch in enumerate(self.data[step_name]): + if remaining_rows == 0: + break + + # Get `remaining_rows` or the remaining rows in the batch and add it to + # the step data that will be used to create the batch + selected_data = batch.get_data(remaining_rows) + step_data.extend(selected_data) + batch_routed_to = batch.batch_routed_to + + # Update the remaining rows + num_rows = len(selected_data) + remaining_rows -= num_rows + + # Keep track of the batches used to create the batch + batches_used[step_name].append((batch.seq_no, batch.size)) + + # If the batch was entirely consumed, then remove it from the buffer + if len(batch.data[0]) == 0: + idx_drop_batches.append(idx) + continue + + # Remove the batches that were entirely consumed + idx_drop_batches.reverse() + for idx in idx_drop_batches: + self.data[step_name].pop(idx) + + data.append(step_data) + + return data, dict(batches_used), batch_routed_to + + def _ready_to_create_batch(self) -> bool: + """Checks if there is enough data to create a batch for the step. + + Returns: + `True` if there is enough data to create a batch for the step. Otherwise, + `False`. + """ + if self.accumulate: + return self._ready_to_create_batch_accumulate() + + if self.convergence_step: + return self._ready_to_create_batch_convergence_step() + + return self._ready_to_create_batch_normal() + + def _ready_to_create_batch_accumulate(self) -> bool: + """Checks if there is enough data for an step accumulating data. It will return + `True` if the last batch was received from all the predecessors. + + Returns: + `True` if ready to create a batch, `False` otherwise. + """ + return all( + step in self.last_batch_received + and sum(len(batch.data[0]) for batch in batches) >= 0 + for step, batches in self.data.items() + ) + + def _ready_to_create_batch_convergence_step(self) -> bool: + """Checks if there is enough data for creating a batch for an step in which output + batches that were generated by steps that received routed batches are received. + It will return `True`, if all the output batches that were generated from a routed + batch have been received. + + Returns: + `True` if ready to create a batch, `False` otherwise. + """ + grouped_batches = self._group_batches_by_created_from() + if not grouped_batches: + return False + seq_no, batches = grouped_batches[0] + + # If the `seq_no` from the `created_from` field is not the expected one, then + # we cannot create a batch yet or the order will be messed up + if seq_no != self.next_expected_created_from_batch_seq_no: + return False + + # Not all output batches to which the input batch was routed to haven't been + # received + batch_routed_to = batches[0][0].batch_routed_to + batches_received_from = {batch.step_name for batch, _ in batches} + if any(step_name not in batches_received_from for step_name in batch_routed_to): + return False + + # There are output batches to which the input batch was routed to from all + # the steps. Check if there is enough data for creating a batch with `input_batch_size` + rows_per_step = defaultdict(lambda: 0) + for batch, _ in batches: + num_rows = len(batch.data[0]) + rows_per_step[batch.step_name] += num_rows + + # If there aren't at least `input_batch_size` rows from each step, then there + # isn't enough data to create a batch + if not all( + num_rows >= self.input_batch_size or step_name in self.last_batch_received # type: ignore + for step_name, num_rows in rows_per_step.items() + ): + return False + + return True + + def _ready_to_create_batch_normal(self) -> bool: + """Checks if there is enough data for creating a batch for a normal step. It will + be `True` it there are at least `input_batch_size` rows from each predecessor step. + + Returns: + `True` if ready to create a batch, `False` otherwise. + """ + for step_name, batches in self.data.items(): + num_rows = sum(len(batch.data[0]) for batch in batches) + + # If there are now rows but the last batch was already received, then there + # are no more batch to be created + if num_rows == 0 and step_name in self.last_batch_received: + return False + + # If there are not enough rows and the last batch was not received yet, then + # there is not enough data yet to creata a batch + if ( + self.input_batch_size + and num_rows < self.input_batch_size + and step_name not in self.last_batch_received + ): + return False + + return True + + def _last_batch(self) -> bool: + """Checks if the batch to be created is the last one i.e. if the last batch was + received from all the predecessors. + + Returns: + `True` if the batch to be created is the last one. Otherwise, `False`. + """ + if self.accumulate: + return self._last_batch_accumulate() + + if self.convergence_step: + return self._last_batch_convergence_step() + + return self._last_batch_normal() + + def _last_batch_accumulate(self) -> bool: + """Checks if the batch to be created is the last one for an step accumulating data. + `True` if the last batch was received from all the predecessors. + + Returns: + `True` if the batch to be created is the last one. Otherwise, `False`. + """ + return all(step in self.last_batch_received for step in self.data.keys()) + + def _last_batch_convergence_step(self) -> bool: + """Checks if the batch to be created is the last one for a convergence step. `True` + if the last batch of all the steps (`batch_routed_to`) in the last routed batch + have been received. + + Returns: + `True` if the batch to be created is the last one. Otherwise, `False`. + """ + grouped_batches = self._group_batches_by_created_from() + if not grouped_batches: + return False + _, batches = grouped_batches[0] + + for batch, _ in batches: + if not batch.last_batch: + return False + + if len(batch.data[0]) > self.input_batch_size: # type: ignore + return False + + return True + + def _last_batch_normal(self) -> bool: + """Checks if the batch to be created is the last one for a normal step. `True` if + there is no more data to be received from the predecessors. + + Returns: + `True` if the batch to be created is the last one. Otherwise, `False`. + """ + for step_name, batches in self.data.items(): + if step_name not in self.last_batch_received: + return False + + num_rows = sum(len(batch.data[0]) for batch in batches) + + if ( + self.input_batch_size + and num_rows > self.input_batch_size + and step_name in self.last_batch_received + ): + return False + + return True + + def _group_batches_by_created_from( + self, + ) -> List[Tuple[int, List[Tuple["_Batch", int]]]]: + """Group the batches by the first key of `created_from` field. This method is + meant to be used only with a `convergence_step`. + + Returns: + A list of the batches grouped by the `seq_no` of the first step name in `created_from`. + The list is sorted by the `seq_no`. + """ + grouped_batches: Dict[int, List[Tuple["_Batch", int]]] = defaultdict(list) + for batches in self.data.values(): + for batch in batches: + first_key = next(iter(batch.created_from)) + batch_seq_no, batch_size = batch.created_from[first_key][0] + grouped_batches[batch_seq_no].append((batch, batch_size)) + return sorted((seq_no, batches) for seq_no, batches in grouped_batches.items()) + + def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: + """Dumps the content of the `_BatchManagerStep` to a dictionary, using the `dataclass` helper function. + + Args: + obj: Unused, just kept to match the signature of the parent method. + kwargs: Additional arguments that are kept to match the signature of the parent method. + + Returns: + Internal representation of the `_BatchManagerStep`. + """ + return { + "step_name": self.step_name, + "accumulate": self.accumulate, + "input_batch_size": self.input_batch_size, + "data": { + step_name: [batch.dump(**kwargs) for batch in batches] + for step_name, batches in self.data.items() + }, + "built_batches": [batch.dump(**kwargs) for batch in self.built_batches], + "seq_no": self.seq_no, + "last_batch_received": self.last_batch_received, + "convergence_step": self.convergence_step, + "convergence_step_batches_consumed": self.convergence_step_batches_consumed, + "next_expected_created_from_batch_seq_no": self.next_expected_created_from_batch_seq_no, + } + + +class _BatchManager(_Serializable): + """Class to manage the batches received from the steps. It keeps track of the + received batches and returns new batches for the steps to process based on their + input batch size and the batches received from the predecessors. + + Attributes: + steps: A dictionary with the step name as the key and a `_BatchManagerStep` + instance as the value. + last_batch_received: A dictionary with the step name as the key and a flag to + indicate whether we received the last batch from the step. + """ + + def __init__( + self, + steps: Dict[str, _BatchManagerStep], + last_batch_received: Dict[str, Union[_Batch, None]], + last_batch_sent: Dict[str, Union[_Batch, None]], + last_batch_flag_sent_to: List[str], + ) -> None: + """Initialize the `_BatchManager` instance. + + Args: + steps: A dictionary with the step name as the key and a dictionary with the + predecessor step name as the key and a list of batches as the value. + last_batch_received: A dictionary with the step name as the key and a the last + `_Batch` received from the step. + last_batch_sent: A dictionary with the step name as the key and a the last + `_Batch` sent to the step. + last_batch_flag_sent_to: A list with the names of the steps to which `LAST_BATCH_SENT_FLAG` + was sent. + """ + + self._steps = steps + self._last_batch_received = last_batch_received + self._last_batch_sent = last_batch_sent + self._last_batch_flag_sent_to = last_batch_flag_sent_to + + def can_generate(self) -> bool: + """Checks if there are still batches to be processed by the steps. + + Returns: + `True` if there are still batches to be processed by the steps. Otherwise, + `False`. + """ + + for step_name, batch in self._last_batch_received.items(): + if step_name not in self._last_batch_flag_sent_to: + if not batch: + return True + + if not batch.last_batch: + return True + + if not self.get_last_batch_sent(step_name): + return True + + return False + + def register_batch(self, batch: _Batch) -> None: + """Method to register a batch received from a step. It will keep track of the + sequence number and the last batch received from the step in the internal maps. + + Args: + batch: _Batch from which we will register the sequence number and the last batch received. + """ + self._last_batch_received[batch.step_name] = batch + + def get_last_batch(self, step_name: str) -> Union[_Batch, None]: + """Gets the last batch received from a step. + + Args: + step_name: The name of the step. + + Returns: + The last batch received from the step or `None` if no batch was received. + """ + return self._last_batch_received.get(step_name) + + def add_batch(self, to_step: str, batch: _Batch, prepend: bool = False) -> None: + """Add an output batch from `batch.step_name` to `to_step`. + + Args: + to_step: The name of the step that will process the batch. + batch: The output batch of an step to be processed by `to_step`. + prepend: If `True`, the content of the batch will be added at the start of + the buffer. + + Raises: + ValueError: If `to_step` is not found in the batch manager. + """ + if to_step not in self._steps: + raise ValueError(f"Step '{to_step}' not found in the batch manager.") + + step = self._steps[to_step] + step.add_batch(batch, prepend) + + def get_batch(self, step_name: str) -> Union[_Batch, None]: + """Get the next batch to be processed by the step. + + Args: + step_name: The name of the step that will process the batch. + + Returns: + A `_Batch` instance if there is a batch to be processed by the step. Otherwise, + `None`. + """ + if step_name not in self._steps: + raise ValueError(f"Step '{step_name}' not found in the batch manager.") + + return self._steps[step_name].get_batch() + + def step_empty_buffers(self, step_name: str) -> List[str]: + """Checks if the input buffer for a step is empty. + + Args: + step_name: The name of the step. + + Returns: + The name of the previous steps for which the input buffer for this step is + empty. + """ + return self._steps[step_name].empty_buffers() + + def set_last_batch_sent(self, batch: "_Batch") -> None: + """Set the last batch sent to a step. + + Args: + batch: The last batch sent to a step. + """ + self._last_batch_sent[batch.step_name] = batch + + def get_last_batch_sent(self, step_name: str) -> Union["_Batch", None]: + """Get the last batch sent to a step. + + Args: + step_name: The name of the step. + + Returns: + The last batch sent to a step or `None` if no batch was sent. + """ + return self._last_batch_sent.get(step_name, None) + + def set_last_batch_flag_sent_to(self, step_name: str) -> None: + """Set the flag to indicate that the last batch was sent to a step. + + Args: + step_name: The name of the step. + """ + self._last_batch_flag_sent_to.append(step_name) + + @classmethod + def from_dag(cls, dag: "DAG") -> "_BatchManager": + """Create a `_BatchManager` instance from a `DAG` instance. + + Args: + dag: The `DAG` instance. + + Returns: + A `_BatchManager` instance. + """ + steps = {} + last_batch_received = {} + last_batch_sent = {} + for step_name in dag: + step: "_Step" = dag.get_step(step_name)[STEP_ATTR_NAME] + last_batch_received[step.name] = None + last_batch_sent[step.name] = None + if step.is_generator: + continue + predecessors = list(dag.get_step_predecessors(step_name)) + convergence_step = all( + dag.get_step(predecessor).get(RECEIVES_ROUTED_BATCHES_ATTR_NAME, False) + for predecessor in predecessors + ) + batch_manager_step = _BatchManagerStep.from_step( + step=step, + predecessors=predecessors, + convergence_step=convergence_step, + ) + steps[step_name] = batch_manager_step + return cls(steps, last_batch_received, last_batch_sent, []) + + def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: + """Dumps the content of the `_BatchManager` to a dictionary. + + Args: + obj (Any): Unused, just kept to match the signature of the parent method. + kwargs (Any): Additional arguments that are kept to match the signature of the parent method. + + Returns: + Dict[str, Any]: Internal representation of the `_BatchManager`. + """ + return { + "steps": {name: step.dump(**kwargs) for name, step in self._steps.items()}, + "last_batch_received": { + step_name: batch.dump(**kwargs) if batch is not None else None + for step_name, batch in self._last_batch_received.items() + }, + "last_batch_sent": { + step_name: batch.dump(**kwargs) if batch is not None else None + for step_name, batch in self._last_batch_sent.items() + }, + "last_batch_flag_sent_to": self._last_batch_flag_sent_to, + } + + def cache(self, path: "StrOrPath") -> None: + """Cache the `_BatchManager` to a file. + + Args: + path: The path to the file where the `_BatchManager` will be cached. If `None`, + then the `_BatchManager` will be cached in the default cache folder. + """ + + def save_batch( + batches_dir: Path, batch_dump: Dict[str, Any], batch_list: List[_Batch] + ) -> Path: + seq_no = batch_dump["seq_no"] + data_hash = batch_dump["data_hash"] + batch_file = batches_dir / f"batch_{seq_no}_{data_hash}.json" + + # Save the batch if it doesn't exist + if not batch_file.exists(): + # Get the data of the batch before saving it + batch = next(batch for batch in batch_list if batch.seq_no == seq_no) + batch_dump["data"] = batch.data + self.save(path=batch_file, format="json", dump=batch_dump) + + return batch_file + + def remove_files(keep_files: List[str], dir: Path) -> None: + files = list_files_in_dir(dir, key=None) + remove = set(files) - {Path(file) for file in keep_files} + for file in remove: + file.unlink() + + path = Path(path) + + # Do not include `_Batch` data so `dump` is fast + dump = self.dump(include_batch_data=False) + batch_manager_step_files = {} + + # Do this to avoid modifying the dictionary while iterating over it + batch_manager_steps = set(dump["steps"].keys()) + for step_name in batch_manager_steps: + step_dump = dump["steps"].pop(step_name) + + # Create a directory for each batch manager step to store their batches + batch_manager_step_dir = path.parent / "batch_manager_steps" / step_name + batch_manager_step_dir.mkdir(parents=True, exist_ok=True) + + # Store each built `_Batch` in a separate file + built_batches_dir = batch_manager_step_dir / "built_batches" + built_batches_dir.mkdir(parents=True, exist_ok=True) + step_dump["built_batches"] = [ + str( + save_batch( + batches_dir=built_batches_dir, + batch_dump=batch_dump, + batch_list=self._steps[step_name].built_batches, + ) + ) + for batch_dump in step_dump["built_batches"] + ] + # Remove built `_Batch`es that were consumed from cache + remove_files(step_dump["built_batches"], built_batches_dir) + + # Store each `_BatchManagerStep` `_Batch`es in a separate file + for buffered_step_name in step_dump["data"]: + step_batches_dir = batch_manager_step_dir / buffered_step_name + step_batches_dir.mkdir(parents=True, exist_ok=True) + + # Store each `_Batch` in a separate file + step_dump["data"][buffered_step_name] = [ + str( + save_batch( + batches_dir=step_batches_dir, + batch_dump=batch_dump, + batch_list=self._steps[step_name].data[buffered_step_name], + ) + ) + for batch_dump in step_dump["data"][buffered_step_name] + ] + + # Remove `_Batch`es that were consumed from cache + remove_files(step_dump["data"][buffered_step_name], step_batches_dir) + + # Store the `_BatchManagerStep` info + batch_manager_step_file = str( + path.parent / f"batch_manager_steps/{step_name}/batch_manager_step.json" + ) + self.save(path=batch_manager_step_file, format="json", dump=step_dump) + + # Store the path to the `_BatchManagerStep` file + batch_manager_step_files[step_name] = batch_manager_step_file + + dump["steps"] = batch_manager_step_files + self.save(path=path, format="json", dump=dump) + + @classmethod + def load_from_cache(cls, path: "StrOrPath") -> "_BatchManager": + """Loads the `_BatchManager` from a cache file. + + Args: + path: The path to the cache file. + """ + _check_is_dir(path) + content = read_json(path) + + # Read each `_BatchManagerStep` from file + steps = {} + for step_name, step_file in content["steps"].items(): + steps[step_name] = read_json(step_file) + + # Read each `_Batch` from file + steps[step_name]["built_batches"] = [ + read_json(batch) for batch in steps[step_name]["built_batches"] + ] + + for buffered_step_name, batch_files in steps[step_name]["data"].items(): + steps[step_name]["data"][buffered_step_name] = [ + read_json(batch_file) for batch_file in batch_files + ] + + content["steps"] = steps + return cls.from_dict(content) diff --git a/src/distilabel/pipeline/constants.py b/src/distilabel/pipeline/constants.py index 450ef0ed6d..3d400e4a1b 100644 --- a/src/distilabel/pipeline/constants.py +++ b/src/distilabel/pipeline/constants.py @@ -19,3 +19,4 @@ RECEIVES_ROUTED_BATCHES_ATTR_NAME: Final[str] = "receives_routed_batches" ROUTING_BATCH_FUNCTION_ATTR_NAME: Final[str] = "routing_batch_function" CONVERGENCE_STEP_ATTR_NAME: Final[str] = "convergence_step" +LAST_BATCH_SENT_FLAG: Final[str] = "last_batch_sent" diff --git a/src/distilabel/pipeline/local.py b/src/distilabel/pipeline/local.py index 36f41e16b9..51986c031d 100644 --- a/src/distilabel/pipeline/local.py +++ b/src/distilabel/pipeline/local.py @@ -15,53 +15,30 @@ import multiprocessing as mp import signal import sys -import threading -import time import traceback -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union, cast import tblib from distilabel.distiset import create_distiset from distilabel.llms.mixins import CudaDevicePlacementMixin from distilabel.pipeline.base import ( - LAST_BATCH_SENT_FLAG, BasePipeline, - _Batch, ) +from distilabel.pipeline.batch import _Batch from distilabel.pipeline.constants import ( - CONVERGENCE_STEP_ATTR_NAME, - INPUT_QUEUE_ATTR_NAME, - ROUTING_BATCH_FUNCTION_ATTR_NAME, - STEP_ATTR_NAME, + LAST_BATCH_SENT_FLAG, ) -from distilabel.steps.base import Step +from distilabel.steps.tasks.base import Task from distilabel.utils.logging import setup_logging, stop_logging if TYPE_CHECKING: - from multiprocessing.managers import DictProxy, SyncManager - from multiprocessing.pool import Pool from queue import Queue from distilabel.distiset import Distiset - from distilabel.steps.base import GeneratorStep, _Step - - -_STEPS_LOADED_KEY = "steps_loaded" -_STEPS_LOADED_LOCK_KEY = "steps_loaded_lock" -_STEPS_LOADED_ERROR_CODE = -1 -_CUDA_LLM_DEVICE_PLACEMENT_KEY = "cuda_llm_device_placement" -_CUDA_LLM_DEVICE_PLACEMENT_LOCK_KEY = "cuda_llm_device_placement_lock" - -_STOP_CALLED = False -_STOP_CALLED_LOCK = threading.Lock() -_STOP_CALLS = 0 + from distilabel.pipeline.typing import StepLoadStatus + from distilabel.steps.base import GeneratorStep, Step, _Step -_STEPS_LOADED = set() -_STEPS_LOADED_LOCK = threading.Lock() - -_STEPS_FINISHED = set() -_STEPS_FINISHED_LOCK = threading.Lock() _SUBPROCESS_EXCEPTION: Union[Exception, None] = None @@ -129,17 +106,24 @@ def run( initializer=_init_worker, initargs=(log_queue,), ) as pool: - self.output_queue: "Queue[Any]" = manager.Queue() - self.shared_info = self._create_shared_info_dict(manager) - self._handle_keyboard_interrupt(manager=manager, pool=pool) + self._manager = manager + self._pool = pool + self._output_queue = self.QueueClass() + self._load_queue = self.QueueClass() + self._handle_keyboard_interrupt() # Run the steps using the pool of processes - self._run_steps_in_loop(pool, manager, self.output_queue, self.shared_info) + self._run_steps() + + # Run the loop for receiving the load status of each step + self._load_steps_thread = self._run_load_queue_loop_in_thread() # Wait for all the steps to be loaded correctly if not self._all_steps_loaded(): self._write_buffer.close() # type: ignore self._batch_manager = None + self._stop_load_queue_loop() + self._load_steps_thread.join() stop_logging() raise RuntimeError( "Failed to load all the steps. Could not run pipeline." @@ -150,15 +134,20 @@ def run( self._request_initial_batches() # Start a loop to receive the output batches from the steps - self._run_output_queue_loop_in_thread() + self._output_queue_thread = self._run_output_queue_loop_in_thread() + self._output_queue_thread.join() # Send `None` to steps `input_queue`s just in case some step is still waiting self._notify_steps_to_stop() + # Stop the load queue loop + self._stop_load_queue_loop() + # `Pool.__exit__` has already called `terminate`, `join` the pool to make sure # all the processes have finished - pool.join() - manager.join() + self._load_steps_thread.join() + self._pool.join() + self._manager.join() self._write_buffer.close() # type: ignore distiset = create_distiset( @@ -170,421 +159,35 @@ def run( stop_logging() return distiset - def _run_output_queue_loop_in_thread(self) -> None: - """Runs the output queue loop in a separate thread to receive the output batches - from the steps. This is done to avoid the signal handler to block the loop, which - would prevent the pipeline from stopping correctly.""" - thread = threading.Thread(target=self._output_queue_loop) - thread.start() - thread.join() - - def _notify_steps_to_stop(self) -> None: - """Notifies the steps to stop their infinite running loop by sending `None` to - their input queues.""" - for step_name in self.dag: - if input_queue := self.dag.get_step(step_name).get(INPUT_QUEUE_ATTR_NAME): - input_queue.put(None) - - def _output_queue_loop(self) -> None: - """Loop to receive the output batches from the steps and manage the flow of the - batches through the pipeline.""" - while self._batch_manager.can_generate() and not _STOP_CALLED: # type: ignore - self._logger.debug("Waiting for output batch from step...") - if (batch := self.output_queue.get()) is None: - self._logger.debug("Received `None` from output queue. Breaking loop.") - break - - self._logger.debug( - f"Received batch with seq_no {batch.seq_no} from step '{batch.step_name}'" - f" from output queue: {batch}" - ) - - if batch.data_path: - self._logger.debug( - f"Reading {batch.seq_no} batch data from '{batch.step_name}': '{batch.data_path}'" - ) - batch.read_batch_data_from_fs() - - if batch.step_name in self.dag.leaf_steps: - self._write_buffer.add_batch(batch) # type: ignore - - # If `_STOP_CALLED` was set to `True` while waiting for the output queue, then - # we need to handle the stop of the pipeline and break the loop to avoid - # propagating the batches through the pipeline and making the stop process - # slower. - if _STOP_CALLED: - self._handle_batch_on_stop(batch) - break - - self._manage_batch_flow(batch) - - if _STOP_CALLED: - self._handle_stop() - - def _manage_batch_flow(self, batch: "_Batch") -> None: - """Checks if the step that generated the batch has more data in its buffer to - generate a new batch. If there's data, then a new batch is sent to the step. If - the step has no data in its buffer, then the predecessors generator steps are - requested to send a new batch. - - Args: - batch: The batch that was processed. - """ - assert self._batch_manager, "Batch manager is not set" - - # Make sure to send the `LAST_BATCH_SENT_FLAG` to the predecessors of the convergence - # step if the batch is the last one, so they stop their processing loop even if - # they haven't received the last batch because of the routing function. - if self._is_convergence_step(batch.step_name) and batch.last_batch: - for step_name in self.dag.get_step_predecessors(batch.step_name): - self._send_last_batch_flag_to_step(step_name) - - route_to, routed = self._get_successors(batch) - - # Keep track of the steps that the batch was routed to - if routed: - batch.batch_routed_to = route_to - - self._register_batch(batch) - - step = self._get_step_from_batch(batch) - - # Add the batch to the successors input buffers - for successor in route_to: - # Copy batch to avoid modifying the same reference in the batch manager - batch_to_add = batch.copy() if len(route_to) > 1 else batch - - self._batch_manager.add_batch(successor, batch_to_add) - - # Check if the step is a generator and if there are successors that need data - # from this step. This usually happens when the generator `batch_size` is smaller - # than the `input_batch_size` of the successor steps. - if ( - step.is_generator - and step.name in self._batch_manager.step_empty_buffers(successor) - ): - last_batch_sent = self._batch_manager.get_last_batch_sent(step.name) - self._send_batch_to_step(last_batch_sent.next_batch()) # type: ignore - - # If successor step has enough data in its buffer to create a new batch, then - # send the batch to the step. - if new_batch := self._batch_manager.get_batch(successor): - self._send_batch_to_step(new_batch) - - if not step.is_generator: - # Step ("this", the one from which the batch was received) has enough data on its - # buffers to create a new batch - if new_batch := self._batch_manager.get_batch(step.name): # type: ignore - self._send_batch_to_step(new_batch) - else: - self._request_more_batches_if_needed(step) - - self._cache() - - def _register_batch(self, batch: "_Batch") -> None: - """Registers a batch in the batch manager. - - Args: - batch: The batch to register. - """ - self._batch_manager.register_batch(batch) # type: ignore - self._logger.debug( - f"Batch {batch.seq_no} from step '{batch.step_name}' registered in batch" - " manager" - ) - - def _get_successors(self, batch: "_Batch") -> Tuple[List[str], bool]: - """Gets the successors and the successors to which the batch has to be routed. - - Args: - batch: The batch to which the successors will be determined. - - Returns: - The successors to route the batch to and whether the batch was routed using - a routing function. - """ - node = self.dag.get_step(batch.step_name) - step: "Step" = node[STEP_ATTR_NAME] - successors = list(self.dag.get_step_successors(step.name)) # type: ignore - route_to = successors - - # Check if the step has a routing function to send the batch to specific steps - if routing_batch_function := node.get(ROUTING_BATCH_FUNCTION_ATTR_NAME): - route_to = routing_batch_function(batch, successors) - successors_str = ", ".join(f"'{successor}'" for successor in route_to) - self._logger.info( - f"🚏 Using '{step.name}' routing function to send batch {batch.seq_no} to steps: {successors_str}" - ) - - return route_to, route_to != successors - - def _get_step_from_batch(self, batch: "_Batch") -> "Step": - """Gets the `Step` instance from a batch. - - Args: - batch: The batch to get the step from. - - Returns: - The `Step` instance. - """ - return self.dag.get_step(batch.step_name)[STEP_ATTR_NAME] - - def _request_more_batches_if_needed(self, step: "Step") -> None: - """Request more batches to the predecessors steps of `step` if needed. - - Args: - step: The step of which it has to be checked if more batches are needed from - its predecessors. - """ - empty_buffers = self._batch_manager.step_empty_buffers(step.name) # type: ignore - for previous_step_name in empty_buffers: - if previous_step_name not in self.dag.root_steps: - continue - - last_batch = self._batch_manager.get_last_batch_sent(previous_step_name) # type: ignore - if last_batch is None: - continue - - self._logger.debug( - f"Step '{step.name}' input buffer for step '{previous_step_name}' is" - " empty. Requesting new batch..." - ) - self._send_batch_to_step(last_batch.next_batch()) - - def _handle_stop(self) -> None: - """Handles the stop of the pipeline execution, which will stop the steps from - processing more batches and wait for the output queue to be empty, to not lose - any data that was already processed by the steps before the stop was called.""" - self._logger.debug("Handling stop of the pipeline execution...") - - # Add the remaining batches in the input queues back to the batch manager - for step_name in self.dag: - node = self.dag.get_step(step_name) - step: "_Step" = node[STEP_ATTR_NAME] - if step.is_generator: - continue - if input_queue := node.get(INPUT_QUEUE_ATTR_NAME): - while not input_queue.empty(): - batch = input_queue.get() - if batch is None: - continue - self._batch_manager.add_batch( # type: ignore - to_step=step_name, batch=batch, prepend=True - ) - self._logger.debug( - f"Adding batch back to the batch manager: {batch}" - ) - input_queue.put(None) - - # Wait for the input queue to be empty, which means that all the steps finished - # processing the batches that were sent before the stop flag. - for step_name in self.dag: - self._wait_step_input_queue_empty(step_name) - - # Consume the output queue until it's empty to not lose any data that was already - # processed by the steps before stop was called. - while not self.output_queue.empty(): - batch = self.output_queue.get() - if batch is None: - continue - - if batch.step_name in self.dag.leaf_steps: - self._write_buffer.add_batch(batch) # type: ignore - - self._handle_batch_on_stop(batch) - - self._cache() - - def _handle_batch_on_stop(self, batch: "_Batch") -> None: - """Handles a batch that was received from the output queue when the pipeline was - stopped. It will add and register the batch in the batch manager. - - Args: - batch: The batch to handle. - """ - self._batch_manager.register_batch(batch) # type: ignore - step: "Step" = self.dag.get_step(batch.step_name)[STEP_ATTR_NAME] - for successor in self.dag.get_step_successors(step.name): # type: ignore - self._batch_manager.add_batch(successor, batch) # type: ignore - - def _wait_step_input_queue_empty(self, step_name: str) -> Union["Queue[Any]", None]: - """Waits for the input queue of a step to be empty. - - Args: - step_name: The name of the step. - - Returns: - The input queue of the step if it's not loaded or finished, `None` otherwise. - """ - if self._check_step_not_loaded_or_finished(step_name): - return None - - if input_queue := self.dag.get_step(step_name).get(INPUT_QUEUE_ATTR_NAME): - while input_queue.qsize() != 0: - pass - return input_queue - - def _create_shared_info_dict(self, manager: "SyncManager") -> "DictProxy[str, Any]": - """Creates the shared information dictionary to be used by the processes. - - Args: - manager: The manager to create the shared information. - - Returns: - The shared information dictionary. - """ - # TODO: not very important, but we could use a different lock for each matter - return manager.dict( - **{ - _STEPS_LOADED_KEY: manager.list(), - _STEPS_LOADED_LOCK_KEY: manager.Lock(), - _CUDA_LLM_DEVICE_PLACEMENT_KEY: manager.dict(**{}), - _CUDA_LLM_DEVICE_PLACEMENT_LOCK_KEY: manager.Lock(), - } - ) - - def _all_steps_loaded(self) -> bool: - """Waits for all the steps to load. + @property + def QueueClass(self) -> Callable: + """The callable used to create the input and output queues. Returns: - `True` if all the steps have been loaded correctly, `False` otherwise. + The callable to create a `Queue`. """ + assert self._manager, "Manager is not initialized" + return self._manager.Queue - def _update_all_steps_loaded(steps_loaded: List[str]) -> None: - with _STEPS_LOADED_LOCK: - _STEPS_LOADED.update(steps_loaded) - - self._logger.info("⏳ Waiting for all the steps to load...") - previous_message = None - while not _STOP_CALLED: - with self.shared_info[_STEPS_LOADED_LOCK_KEY]: - steps_loaded = self.shared_info[_STEPS_LOADED_KEY] - num_steps_loaded = ( - len(steps_loaded) - if steps_loaded != [_STEPS_LOADED_ERROR_CODE] - else 0 - ) - self._logger.debug(f"Steps loaded: {steps_loaded}") - - message = f"⏳ Steps loaded: {num_steps_loaded}/{len(self.dag)}" - if num_steps_loaded > 0 and message != previous_message: - self._logger.info(message) - previous_message = message - - if num_steps_loaded == len(self.dag): - self._logger.info("✅ All the steps have been loaded!") - _update_all_steps_loaded(steps_loaded) - return True - - if steps_loaded == [_STEPS_LOADED_ERROR_CODE]: - self._logger.error("❌ Failed to load all the steps") - _update_all_steps_loaded(steps_loaded) - return False - - time.sleep(2.5) - - return not _STOP_CALLED - - def _request_initial_batches(self) -> None: - """Requests the initial batches to the generator steps.""" - assert self._batch_manager, "Batch manager is not set" - - for step in self._batch_manager._steps.values(): - if batch := step.get_batch(): - self._logger.debug( - f"Sending initial batch to '{step.step_name}' step: {batch}" - ) - self._send_batch_to_step(batch) - - for step_name in self.dag.root_steps: - seq_no = 0 - if last_batch := self._batch_manager.get_last_batch(step_name): - seq_no = last_batch.seq_no + 1 - batch = _Batch(seq_no=seq_no, step_name=step_name, last_batch=self._dry_run) - self._logger.debug( - f"Requesting initial batch to '{step_name}' generator step: {batch}" - ) - self._send_batch_to_step(batch) - - def _send_batch_to_step(self, batch: "_Batch") -> None: - """Sends a batch to the input queue of a step. - - Args: - batch: The batch to send. - """ - super()._send_batch_to_step(batch) - input_queue = self.dag.get_step(batch.step_name)[INPUT_QUEUE_ATTR_NAME] - input_queue.put(batch) - - def _is_convergence_step(self, step_name: str) -> None: - """Checks if a step is a convergence step. - - Args: - step_name: The name of the step. - """ - return self.dag.get_step(step_name).get(CONVERGENCE_STEP_ATTR_NAME) - - def _send_last_batch_flag_to_step(self, step_name: str) -> None: - """Sends the `LAST_BATCH_SENT_FLAG` to a step to stop processing batches. + def _run_step(self, step: "_Step", input_queue: "Queue[Any]") -> None: + """Runs the `Step` wrapped in a `_ProcessWrapper` in a separate process of the + `Pool`. Args: - step_name: The name of the step. + step: The step to run. + input_queue: The input queue to send the data to the step. """ - batch = self._batch_manager.get_last_batch_sent(step_name) # type: ignore - if batch and batch.last_batch: - return - - self._logger.debug( - f"Sending `LAST_BATCH_SENT_FLAG` to '{step_name}' step to stop processing" - " batches..." + assert self._pool, "Pool is not initialized" + + process_wrapper = _ProcessWrapper( + step=step, + input_queue=input_queue, + output_queue=self._output_queue, + load_queue=self._load_queue, + dry_run=self._dry_run, ) - input_queue = self.dag.get_step(step_name)[INPUT_QUEUE_ATTR_NAME] - input_queue.put(LAST_BATCH_SENT_FLAG) - self._batch_manager.set_last_batch_flag_sent_to(step_name) # type: ignore - def _run_steps_in_loop( - self, - pool: "Pool", - manager: "SyncManager", - output_queue: "Queue[_Batch]", - shared_info: "DictProxy[str, Any]", - ) -> None: - """Using the `pool`, runs the steps in the DAG in an infinite loop waiting for - input batches and sending the output batches to the `output_queue`. - - Each `Step` is wrapped in a `_ProcessWrapper`, which will handle the lifecycle of - the `Step` and the communication with the `input_queue` and `output_queue`. The - `_ProcessWrapper.run` method is the target function of the process. - - Args: - pool: The pool of processes. - manager: The manager to create the queues. - output_queue: The queue to send the output batches. - shared_info: The shared information between the processes. - """ - for step_name in self.dag: - step: "Step" = self.dag.get_step(step_name)[STEP_ATTR_NAME] - input_queue = manager.Queue() - self.dag.set_step_attr(step.name, INPUT_QUEUE_ATTR_NAME, input_queue) # type: ignore - - # Set `pipeline` to `None` as in some Python environments the pipeline is not - # picklable and it will raise an error when trying to send the step to the process. - # `TypeError: cannot pickle 'code' object` - step.pipeline = None - - process_wrapper = _ProcessWrapper( - step=step, - input_queue=input_queue, - output_queue=output_queue, - shared_info=shared_info, - dry_run=self._dry_run, - ) - - pool.apply_async( - process_wrapper.run, - callback=self._finished_callback, - error_callback=self._error_callback, - ) # type: ignore + self._pool.apply_async(process_wrapper.run, error_callback=self._error_callback) def _error_callback(self, e: BaseException) -> None: """Error callback that will be called when an error occurs in a `Step` process. @@ -603,8 +206,6 @@ def _error_callback(self, e: BaseException) -> None: if e.is_load_error: self._logger.error(f"❌ Failed to load step '{e.step.name}': {e.message}") - with self.shared_info[_STEPS_LOADED_LOCK_KEY]: - self.shared_info[_STEPS_LOADED_KEY] = [_STEPS_LOADED_ERROR_CODE] _SUBPROCESS_EXCEPTION = e.subprocess_exception _SUBPROCESS_EXCEPTION.__traceback__ = tblib.Traceback.from_string( # type: ignore e.formatted_traceback @@ -630,94 +231,51 @@ def _error_callback(self, e: BaseException) -> None: # Global step with successors failed self._logger.error(f"An error occurred in global step '{step_name}'") self._logger.error(f"Subprocess traceback:\n\n{e.formatted_traceback}") - self._cache() - self._stop() - - def _finished_callback(self, step_name: str) -> None: - """Callback that will be called when a `Step` process finishes. - - Args: - step_name: The name of the step that finished. - """ - with _STEPS_FINISHED_LOCK: - _STEPS_FINISHED.add(step_name) - - def _check_step_not_loaded_or_finished(self, step_name: str) -> bool: - """Checks if a step is not loaded or already finished. - - Args: - step_name: The name of the step. - - Returns: - `True` if the step is not loaded or already finished, `False` otherwise. - """ - with _STEPS_LOADED_LOCK: - if step_name not in _STEPS_LOADED: - return True - with _STEPS_FINISHED_LOCK: - if step_name in _STEPS_FINISHED: - return True - - return False + self._stop() - def _stop( - self, manager: Optional["SyncManager"] = None, pool: Optional["Pool"] = None - ) -> None: + def _stop(self) -> None: """Stops the pipeline execution. It will first send `None` to the input queues of all the steps and then wait until the output queue is empty i.e. all the steps finished processing the batches that were sent before the stop flag. Then it will send `None` to the output queue to notify the pipeline to stop.""" - global _STOP_CALLED - - with _STOP_CALLED_LOCK: - if _STOP_CALLED: - global _STOP_CALLS - _STOP_CALLS += 1 - if _STOP_CALLS == 1: + with self._stop_called_lock: + if self._stop_called: + self._stop_calls += 1 + if self._stop_calls == 1: self._logger.warning( "🛑 Press again to force the pipeline to stop." ) - elif _STOP_CALLS > 1: + elif self._stop_calls > 1: self._logger.warning("🛑 Forcing pipeline interruption.") - if pool: - pool.terminate() - pool.join() + if self._pool: + self._pool.terminate() + self._pool.join() + self._pool = None - if manager: - manager.shutdown() - manager.join() + if self._manager: + self._manager.shutdown() + self._manager.join() + self._manager = None stop_logging() sys.exit(1) return - _STOP_CALLED = True + self._stop_called = True - self._logger.debug(f"Steps loaded before calling `stop`: {_STEPS_LOADED}") + self._logger.debug( + f"Steps loaded before calling `stop`: {self._steps_load_status}" + ) self._logger.info( "🛑 Stopping pipeline. Waiting for steps to finish processing batches..." ) - self._logger.debug("Sending `None` to the output queue to notify stop...") - self.output_queue.put(None) - - def _handle_keyboard_interrupt( - self, manager: Optional["SyncManager"] = None, pool: Optional["Pool"] = None - ) -> None: - """Handles KeyboardInterrupt signal sent during the Pipeline.run method. - - It will try to call self._stop (if the pipeline didn't started yet, it won't - have any effect), and if the pool is already started, will close it before exiting - the program. - """ - - def signal_handler(signumber: int, frame: Any) -> None: - self._stop(manager=manager, pool=pool) - signal.signal(signal.SIGINT, signal_handler) + self._stop_load_queue_loop() + self._stop_output_queue_loop() class _ProcessWrapperException(Exception): @@ -781,15 +339,16 @@ class _ProcessWrapper: step: The step to run. input_queue: The queue to receive the input data. output_queue: The queue to send the output data. - shared_info: The shared information between the processes. + load_queue: The queue used to notify the main process that the step has been loaded, + has been unloaded or has failed to load. """ def __init__( self, - step: "Step", + step: "_Step", input_queue: "Queue[_Batch]", output_queue: "Queue[_Batch]", - shared_info: "DictProxy[str, Any]", + load_queue: "Queue[Union[StepLoadStatus, None]]", dry_run: bool = False, ) -> None: """Initializes the `_ProcessWrapper`. @@ -798,29 +357,22 @@ def __init__( step: The step to run. input_queue: The queue to receive the input data. output_queue: The queue to send the output data. - shared_info: The shared information between the processes. + load_queue: The queue used to notify the main process that the step has been + loaded, has been unloaded or has failed to load. dry_run: Flag to ensure we are forcing to run the last batch. """ self.step = step self.input_queue = input_queue self.output_queue = output_queue - self.shared_info = shared_info + self.load_queue = load_queue self._dry_run = dry_run - # If step is a task, and it's using a `CUDALLM`, then set the CUDA device map - # and the lock for that map. - if hasattr(self.step, "llm") and isinstance( - self.step.llm, CudaDevicePlacementMixin + if ( + isinstance(self.step, Task) + and hasattr(self.step, "llm") + and isinstance(self.step.llm, CudaDevicePlacementMixin) ): - self.step.llm.set_device_placement_info( - llm_identifier=self.step.name, - device_llm_placement_map=self.shared_info[ - _CUDA_LLM_DEVICE_PLACEMENT_KEY - ], - device_llm_placement_lock=self.shared_info[ - _CUDA_LLM_DEVICE_PLACEMENT_LOCK_KEY - ], - ) + self.step.llm._llm_identifier = self.step.name def run(self) -> str: """The target function executed by the process. This function will also handle @@ -836,6 +388,8 @@ def run(self) -> str: self.step.load() self.step._logger.debug(f"Step '{self.step.name}' loaded!") except Exception as e: + self.step.unload() + self._notify_load_failed() raise _ProcessWrapperException.create_load_error( str(e), self.step, e ) from e @@ -853,14 +407,25 @@ def run(self) -> str: except Exception: pass + self.step.unload() + + self._notify_unload() + self.step._logger.info(f"🏁 Finished running step '{self.step.name}'") return self.step.name # type: ignore def _notify_load(self) -> None: """Notifies that the step has finished executing its `load` function successfully.""" - with self.shared_info[_STEPS_LOADED_LOCK_KEY]: - self.shared_info[_STEPS_LOADED_KEY].append(self.step.name) + self.load_queue.put({"name": self.step.name, "status": "loaded"}) # type: ignore + + def _notify_unload(self) -> None: + """Notifies that the step has been unloaded.""" + self.load_queue.put({"name": self.step.name, "status": "unloaded"}) # type: ignore + + def _notify_load_failed(self) -> None: + """Notifies that the step failed to load.""" + self.load_queue.put({"name": self.step.name, "status": "load_failed"}) # type: ignore def _generator_step_process_loop(self) -> None: """Runs the process loop for a generator step. It will call the `process` method diff --git a/src/distilabel/pipeline/routing_batch_function.py b/src/distilabel/pipeline/routing_batch_function.py index c66a2d82b0..9d074d9fb6 100644 --- a/src/distilabel/pipeline/routing_batch_function.py +++ b/src/distilabel/pipeline/routing_batch_function.py @@ -26,7 +26,7 @@ ) if TYPE_CHECKING: - from distilabel.pipeline.base import _Batch + from distilabel.pipeline.batch import _Batch from distilabel.pipeline.typing import DownstreamConnectableSteps from distilabel.steps.base import _Step diff --git a/src/distilabel/pipeline/typing.py b/src/distilabel/pipeline/typing.py index 2ebb9b4643..ebb4c68155 100644 --- a/src/distilabel/pipeline/typing.py +++ b/src/distilabel/pipeline/typing.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, TypeVar, Union +from typing import TYPE_CHECKING, Literal, TypedDict, TypeVar, Union if TYPE_CHECKING: from distilabel.steps.base import GeneratorStep, GlobalStep, Step @@ -32,3 +32,11 @@ covariant=True, ) """Type for the `Step` types that can be connected as downstream steps.""" + + +class StepLoadStatus(TypedDict): + """Dict containing information about if one step was loaded/unloaded or if it's load + failed""" + + name: str + status: Literal["loaded", "unloaded", "load_failed"] diff --git a/src/distilabel/pipeline/write_buffer.py b/src/distilabel/pipeline/write_buffer.py new file mode 100644 index 0000000000..a71ffdd9b2 --- /dev/null +++ b/src/distilabel/pipeline/write_buffer.py @@ -0,0 +1,168 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 logging +from os import PathLike +from pathlib import Path +from typing import Any, Dict, List, Set + +import pyarrow as pa +import pyarrow.parquet as pq + +from distilabel.pipeline.batch import _Batch +from distilabel.utils.dicts import flatten_dict +from distilabel.utils.files import list_files_in_dir + + +class _WriteBuffer: + """Class in charge of sending the batched contents to a buffer and writing + those to files under a given folder. + + As batches are received, they are added to the buffer and once each buffer + is full, the content is written to a parquet file. + """ + + def __init__(self, path: "PathLike", leaf_steps: Set[str]) -> None: + """ + Args: + path: Folder where the files will be written, the idea + is for this path to be in the cache folder under /data. + leaf_steps: Leaf steps from either the DAG of the Pipeline. + + Raises: + ValueError: If the path is not a directory. + """ + self._path = Path(path) + if not self._path.exists(): + self._path.mkdir(parents=True, exist_ok=True) + for step in leaf_steps: + (self._path / step).mkdir(parents=True, exist_ok=True) + + if not self._path.is_dir(): + raise ValueError(f"The path should be a directory, not a file: {path}") + + self._buffers: Dict[str, List[Dict[str, Any]]] = { + step: [] for step in leaf_steps + } + # TODO: make this configurable + self._buffers_dump_batch_size: Dict[str, int] = { + step: 50 for step in leaf_steps + } + self._buffer_last_schema = {} + self._buffers_last_file: Dict[str, int] = {step: 1 for step in leaf_steps} + self._logger = logging.getLogger("distilabel.write_buffer") + + def _get_filename(self, step_name: str) -> Path: + """Creates the filename for the step. + + Args: + step_name: Name of the step to which the data belongs to. + + Returns: + Filename for the step. + """ + return self._path / f"{step_name}.parquet" + + def is_full(self, step_name: str) -> bool: + """Checks the buffers that are full so that those can be written to the file. + + Returns: + Whether the buffer is full. + """ + return len(self._buffers[step_name]) >= self._buffers_dump_batch_size[step_name] + + def add_batch(self, batch: "_Batch") -> None: + """Adds a batch to the buffer and writes the buffer to the file if it's full. + + Args: + batch: batch to add to the buffer. + """ + step_name = batch.step_name + data = batch.data[0] + self._buffers[step_name].extend(data) + self._logger.debug( + f"Added batch to write buffer for step '{step_name}' with {len(data)} rows." + ) + if self.is_full(step_name): + self._logger.debug( + f"Buffer for step '{step_name}' is full (rows: {len(self._buffers[step_name])}," + f" full: {self._buffers_dump_batch_size[step_name]}), writing to file..." + ) + self._write(step_name) + + def _write(self, step_name: str) -> None: + """Writes the content to the file and cleans the buffer. + + Args: + step_name (str): Name of the step to which the data pertains. + """ + step_parquet_dir = Path(self._path, step_name) + if not step_parquet_dir.exists(): + self._logger.debug( + f"Creating directory for step '{step_name}' parquet files..." + ) + step_parquet_dir.mkdir() + + try: + table = pa.Table.from_pylist(self._buffers[step_name]) + except pa.lib.ArrowInvalid as pae: + if ( + repr(pae) + != "ArrowInvalid('cannot mix struct and non-struct, non-null values')" + ): + raise pae + flattened_buffers = [flatten_dict(buf) for buf in self._buffers[step_name]] + table = pa.Table.from_pylist(flattened_buffers) + + last_schema = self._buffer_last_schema.get(step_name) + if last_schema is None: + self._buffer_last_schema[step_name] = table.schema + else: + if not last_schema.equals(table.schema): + new_schema = pa.unify_schemas([last_schema, table.schema]) + self._buffer_last_schema[step_name] = new_schema + table = table.cast(new_schema) + + next_file_number = self._buffers_last_file[step_name] + self._buffers_last_file[step_name] = next_file_number + 1 + + parquet_file = step_parquet_dir / f"{str(next_file_number).zfill(5)}.parquet" + pq.write_table(table, parquet_file) + self._logger.debug(f"Written to file '{parquet_file}'") + + self._clean_buffer(step_name) + + def _clean_buffer(self, step_name: str) -> None: + """Cleans the buffer by setting it's content to `None`. + + Args: + step_name: The name of the buffer to clean. + """ + self._buffers[step_name] = [] + + def close(self) -> None: + """Closes the buffer by writing the remaining content to the file.""" + for step_name in self._buffers: + if self._buffers[step_name]: + self._write(step_name) + + # We need to read the parquet files and write them again to ensure the schema + # is correct. Otherwise, the first parquets won't have the last schema and + # then we will have issues when reading them. + for file in list_files_in_dir(self._path / step_name): + if step_name in self._buffer_last_schema: + table = pq.read_table( + file, schema=self._buffer_last_schema[step_name] + ) + pq.write_table(table, file) diff --git a/src/distilabel/steps/base.py b/src/distilabel/steps/base.py index 9aab121815..d35cdb8b5d 100644 --- a/src/distilabel/steps/base.py +++ b/src/distilabel/steps/base.py @@ -303,6 +303,12 @@ def load(self) -> None: """ self._logger = logging.getLogger(f"distilabel.step.{self.name}") + def unload(self) -> None: + """Method to perform any cleanup logic after the `process` method is called. For + example, to close a connection to a database, etc. + """ + self._logger.debug("Executing step unload logic.") + @property def is_generator(self) -> bool: """Whether the step is a generator step or not. diff --git a/src/distilabel/steps/tasks/base.py b/src/distilabel/steps/tasks/base.py index fda1e1e248..a73ccbfbdb 100644 --- a/src/distilabel/steps/tasks/base.py +++ b/src/distilabel/steps/tasks/base.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Union from pydantic import Field +from typing_extensions import override from distilabel.llms.base import LLM from distilabel.mixins.runtime_parameters import RuntimeParameter @@ -64,10 +65,16 @@ class _Task(_Step, ABC): ) def load(self) -> None: - """Loads the LLM via the `LLM.load()` method (done for safer serialization).""" + """Loads the LLM via the `LLM.load()` method.""" super().load() self.llm.load() + @override + def unload(self) -> None: + """Unloads the LLM.""" + self._logger.debug("Executing task unload logic.") + self.llm.unload() + @abstractmethod def format_output( self, diff --git a/src/distilabel/utils/serialization.py b/src/distilabel/utils/serialization.py index 7862a8b9c5..714f52fbb0 100644 --- a/src/distilabel/utils/serialization.py +++ b/src/distilabel/utils/serialization.py @@ -25,7 +25,18 @@ from enum import EnumType from pathlib import Path -from typing import Any, Dict, List, Literal, Optional, Type, TypeVar, Union, get_args +from typing import ( + Any, + Dict, + List, + Literal, + Optional, + Tuple, + Type, + TypeVar, + Union, + get_args, +) import yaml from pydantic import BaseModel @@ -41,12 +52,30 @@ SaveFormats = Literal["json", "yaml"] +# Mapping to handle import paths that could have been serialized from previous versions +_OLD_IMPORT_MODULE_ATTR: Dict[Tuple[str, str], Tuple[str, str]] = { + ("distilabel.pipeline.base", "_Batch"): ("distilabel.pipeline.batch", "_Batch"), + ("distilabel.pipeline.base", "_BatchManager"): ( + "distilabel.pipeline.batch_manager", + "_BatchManager", + ), + ("distilabel.pipeline.base", "_BatchManagerStep"): ( + "distilabel.pipeline.batch_manager", + "_BatchManagerStep", + ), +} + + def _get_module_attr(module: str, name: str) -> Type: """Gets a class given the module and the name of the class. Returns: The type of the class. """ + + if (module, name) in _OLD_IMPORT_MODULE_ATTR: + module, name = _OLD_IMPORT_MODULE_ATTR[(module, name)] + mod = importlib.import_module(module) return getattr(mod, name) diff --git a/tests/unit/llms/test_mixins.py b/tests/unit/llms/test_mixins.py index feb8b00e01..c0c7b10671 100644 --- a/tests/unit/llms/test_mixins.py +++ b/tests/unit/llms/test_mixins.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import multiprocessing as mp import os import sys from typing import TYPE_CHECKING, Any, Generator, List, Union @@ -43,6 +42,10 @@ def load(self) -> None: super().load() CudaDevicePlacementMixin.load(self) + def unload(self) -> None: + super().unload() + CudaDevicePlacementMixin.unload(self) + @property def model_name(self) -> str: return "test" @@ -63,13 +66,7 @@ def test_set_cuda_visible_devices(self) -> None: assert os.environ["CUDA_VISIBLE_DEVICES"] == "0,1" - def test_cuda_visible_devices_not_cuda_devices(self) -> None: - llm = DummyCudaLLM() - llm._llm_identifier = "unit-test" - - llm.load() - - assert os.getenv("CUDA_VISIBLE_DEVICES") is None + llm.unload() def test_set_cuda_visible_devices_unvalid_devices(self) -> None: llm = DummyCudaLLM(cuda_devices=[5, 6]) @@ -80,84 +77,54 @@ def test_set_cuda_visible_devices_unvalid_devices(self) -> None: ): llm.load() - def test_set_device_placement_info(self) -> None: - llm = DummyCudaLLM(cuda_devices="auto") + llm.unload() + + def test_set_cuda_visible_devices_auto(self) -> None: + llm1 = DummyCudaLLM() + llm1._llm_identifier = "unit-test-1" + llm1.load() - with mp.Manager() as manager: - llm.set_device_placement_info( - llm_identifier="unit-test", - device_llm_placement_map=manager.dict(), - device_llm_placement_lock=manager.Lock(), # type: ignore - ) + assert os.environ["CUDA_VISIBLE_DEVICES"] == "0" - assert llm._llm_identifier == "unit-test" - assert llm._device_llm_placement_map is not None + llm2 = DummyCudaLLM() + llm2._llm_identifier = "unit-test-2" + llm2.load() - def test_set_cuda_visible_devices_auto(self) -> None: - with mp.Manager() as manager: - device_llm_placement_map = manager.dict() - lock = manager.Lock() - - llm1 = DummyCudaLLM() - llm1.set_device_placement_info( - llm_identifier="unit-test-1", - device_llm_placement_map=device_llm_placement_map, - device_llm_placement_lock=lock, # type: ignore - ) - llm1.load() - - assert os.environ["CUDA_VISIBLE_DEVICES"] == "0" - - llm2 = DummyCudaLLM() - llm2.set_device_placement_info( - llm_identifier="unit-test-2", - device_llm_placement_map=device_llm_placement_map, - device_llm_placement_lock=lock, # type: ignore - ) - llm2.load() - - assert os.environ["CUDA_VISIBLE_DEVICES"] == "1" + assert os.environ["CUDA_VISIBLE_DEVICES"] == "1" + + llm1.unload() + llm2.unload() def test_set_cuda_visible_devices_auto_not_enough_devices(self) -> None: - with mp.Manager() as manager: - device_llm_placement_map = manager.dict() - lock = manager.Lock() - - with pytest.raises( - RuntimeError, match="Couldn't find an available CUDA device" - ): - # 4 devices are available, but 5 LLMs are going to be loaded - for i in range(5): - llm = DummyCudaLLM() - llm.set_device_placement_info( - llm_identifier=f"unit-test-{i}", - device_llm_placement_map=device_llm_placement_map, - device_llm_placement_lock=lock, # type: ignore - ) - llm.load() + llms = [] + for i in range(5): + llm = DummyCudaLLM() + llm._llm_identifier = f"unit-test-{i}" + llms.append(llm) + + with pytest.raises( + RuntimeError, match="Couldn't find an available CUDA device" + ): + # 4 devices are available, but 5 LLMs are going to be loaded + for llm in llms: + llm.load() + + for llm in llms: + llm.unload() def test_check_cuda_devices(self, caplog) -> None: - with mp.Manager() as manager: - device_llm_placement_map = manager.dict() - lock = manager.Lock() - - llm1 = DummyCudaLLM(cuda_devices=[1]) - llm1.set_device_placement_info( - llm_identifier="unit-test-1", - device_llm_placement_map=device_llm_placement_map, - device_llm_placement_lock=lock, # type: ignore - ) - llm1.load() - - llm2 = DummyCudaLLM(cuda_devices=[1]) - llm2.set_device_placement_info( - llm_identifier="unit-test-2", - device_llm_placement_map=device_llm_placement_map, - device_llm_placement_lock=lock, # type: ignore - ) - llm2.load() - - assert ( - "LLM with identifier 'unit-test-1' is also going to use CUDA device '1'" - in caplog.text - ) + llm1 = DummyCudaLLM(cuda_devices=[1]) + llm1._llm_identifier = "unit-test-1" + llm1.load() + + llm2 = DummyCudaLLM(cuda_devices=[1]) + llm2._llm_identifier = "unit-test-2" + llm2.load() + + assert ( + "LLM with identifier 'unit-test-1' is also going to use CUDA device '1'" + in caplog.text + ) + + llm1.unload() + llm2.unload() diff --git a/tests/unit/pipeline/test_base.py b/tests/unit/pipeline/test_base.py index 4c26db132d..c18a30e143 100644 --- a/tests/unit/pipeline/test_base.py +++ b/tests/unit/pipeline/test_base.py @@ -12,26 +12,31 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging import os import tempfile from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional +from queue import Queue +from typing import Any, Callable, Dict, List, Optional from unittest import mock import pytest -from distilabel.distiset import Distiset, create_distiset from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.pipeline._dag import DAG from distilabel.pipeline.base import ( + _STEP_LOAD_FAILED_CODE, + _STEP_NOT_LOADED_CODE, BasePipeline, - _Batch, - _BatchManager, - _BatchManagerStep, _GlobalPipelineManager, - _WriteBuffer, ) -from distilabel.pipeline.local import Pipeline -from distilabel.steps.base import GlobalStep, Step, StepInput +from distilabel.pipeline.batch import _Batch +from distilabel.pipeline.batch_manager import _BatchManager +from distilabel.pipeline.constants import INPUT_QUEUE_ATTR_NAME, LAST_BATCH_SENT_FLAG +from distilabel.pipeline.routing_batch_function import ( + routing_batch_function, + sample_n_steps, +) +from distilabel.pipeline.write_buffer import _WriteBuffer +from distilabel.steps.base import Step, StepInput, _Step from distilabel.steps.typing import StepOutput from distilabel.utils.serialization import TYPE_INFO_KEY from fsspec.implementations.local import LocalFileSystem @@ -43,11 +48,19 @@ DummyGlobalStep, DummyStep1, DummyStep2, - batch_gen, ) -if TYPE_CHECKING: - from distilabel.steps.base import GeneratorStep + +class DummyPipeline(BasePipeline): + @property + def QueueClass(self) -> Callable: + return Queue + + def _run_step(self, step: "_Step", input_queue: "Queue[Any]") -> None: + pass + + def _stop(self) -> None: + pass class TestGlobalPipelineManager: @@ -55,7 +68,7 @@ def teardown_method(self) -> None: _GlobalPipelineManager.set_pipeline(None) def test_set_pipeline(self) -> None: - pipeline = BasePipeline(name="unit-test-pipeline") + pipeline = DummyPipeline(name="unit-test-pipeline") _GlobalPipelineManager.set_pipeline(pipeline) assert _GlobalPipelineManager.get_pipeline() == pipeline @@ -64,7 +77,7 @@ def test_set_pipeline_none(self) -> None: assert _GlobalPipelineManager.get_pipeline() is None def test_get_pipeline(self) -> None: - pipeline = BasePipeline(name="unit-test-pipeline") + pipeline = DummyPipeline(name="unit-test-pipeline") _GlobalPipelineManager.set_pipeline(pipeline) assert _GlobalPipelineManager.get_pipeline() == pipeline @@ -73,7 +86,7 @@ class TestBasePipeline: def test_context_manager(self) -> None: assert _GlobalPipelineManager.get_pipeline() is None - with BasePipeline(name="unit-test-pipeline") as pipeline: + with DummyPipeline(name="unit-test-pipeline") as pipeline: assert pipeline is not None assert _GlobalPipelineManager.get_pipeline() == pipeline @@ -81,7 +94,7 @@ def test_context_manager(self) -> None: @pytest.mark.parametrize("use_cache", [False, True]) def test_load_batch_manager(self, use_cache: bool) -> None: - pipeline = BasePipeline(name="unit-test-pipeline") + pipeline = DummyPipeline(name="unit-test-pipeline") pipeline._load_batch_manager(use_cache=True) pipeline._cache() @@ -102,19 +115,19 @@ def test_load_batch_manager(self, use_cache: bool) -> None: mock_from_dag.assert_called_once_with(pipeline.dag) def test_setup_write_buffer(self) -> None: - pipeline = BasePipeline(name="unit-test-pipeline") + pipeline = DummyPipeline(name="unit-test-pipeline") pipeline._setup_write_buffer() assert isinstance(pipeline._write_buffer, _WriteBuffer) def test_set_logging_parameters(self) -> None: - pipeline = BasePipeline(name="unit-test-pipeline") + pipeline = DummyPipeline(name="unit-test-pipeline") pipeline._set_logging_parameters({"unit-test": "yes"}) assert pipeline._logging_parameters == {"unit-test": "yes"} def test_setup_fsspec(self) -> None: - pipeline = BasePipeline(name="unit-test-pipeline") + pipeline = DummyPipeline(name="unit-test-pipeline") with mock.patch("fsspec.filesystem") as mock_filesystem: pipeline._setup_fsspec({"path": "gcs://my-bucket", "extra": "stuff"}) @@ -122,7 +135,7 @@ def test_setup_fsspec(self) -> None: mock_filesystem.assert_called_once_with("gcs", **{"extra": "stuff"}) def test_setup_fsspec_default(self) -> None: - pipeline = BasePipeline(name="unit-test-pipeline") + pipeline = DummyPipeline(name="unit-test-pipeline") pipeline._setup_fsspec() assert isinstance(pipeline._fs, LocalFileSystem) @@ -132,13 +145,267 @@ def test_setup_fsspec_default(self) -> None: ) def test_setup_fsspec_raises_value_error(self) -> None: - pipeline = BasePipeline(name="unit-test-pipeline") + pipeline = DummyPipeline(name="unit-test-pipeline") with pytest.raises(ValueError, match="The 'path' key must be present"): pipeline._setup_fsspec({"key": "random"}) + def test_init_steps_load_status(self) -> None: + with DummyPipeline(name="dummy") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + step2 = DummyStep1() + step3 = DummyStep2() + + generator >> [step, step2] >> step3 + + pipeline._init_steps_load_status() + assert pipeline._steps_load_status == { + generator.name: _STEP_NOT_LOADED_CODE, + step.name: _STEP_NOT_LOADED_CODE, + step2.name: _STEP_NOT_LOADED_CODE, + step3.name: _STEP_NOT_LOADED_CODE, + } + + def test_run_load_queue_loop(self) -> None: + pipeline = DummyPipeline(name="unit-test-pipeline") + + pipeline._load_queue = Queue() + pipeline._steps_load_status = {"dummy": 0} + pipeline._load_queue.put({"name": "dummy", "status": "loaded"}) + + thread = pipeline._run_load_queue_loop_in_thread() + pipeline._load_queue.put(None) + thread.join() + + assert pipeline._steps_load_status["dummy"] == 1 + + def test_run_load_queue_loop_receiving_none(self) -> None: + pipeline = DummyPipeline(name="unit-test-pipeline") + + pipeline._load_queue = Queue() + pipeline._load_queue.put(None) + + thread = pipeline._run_load_queue_loop_in_thread() + thread.join() + + assert not thread.is_alive() + + def test_all_steps_loaded(self, caplog) -> None: + with DummyPipeline(name="dummy") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + step2 = DummyStep1() + step3 = DummyStep2() + + generator >> [step, step2] >> step3 + + pipeline._steps_load_status = { # type: ignore + generator.name: 1, + step.name: 1, + step2.name: 1, + step3.name: 1, + } + caplog.set_level(logging.INFO) + + assert pipeline._all_steps_loaded() is True + assert "All the steps have been loaded!" in caplog.text + + def test_all_steps_loaded_with_failing_step(self, caplog) -> None: + with DummyPipeline(name="dummy") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + step2 = DummyStep1() + step3 = DummyStep2() + + generator >> [step, step2] >> step3 + + pipeline._init_steps_load_status() + pipeline._steps_load_status[generator.name] = _STEP_LOAD_FAILED_CODE # type: ignore + caplog.set_level(logging.INFO) + + assert pipeline._all_steps_loaded() is False + assert "Failed to load all the steps" in caplog.text + + def test_all_steps_loaded_stop_aclled(self) -> None: + with DummyPipeline(name="dummy") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + step2 = DummyStep1() + step3 = DummyStep2() + + generator >> [step, step2] >> step3 + + pipeline._init_steps_load_status() + pipeline._stop_called = True + + assert pipeline._all_steps_loaded() is False + + def test_handle_stop(self) -> None: + with DummyPipeline(name="dummy") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + step2 = DummyStep1() + step3 = DummyStep2() + + generator >> [step, step2] >> step3 + + pipeline._add_batches_back_to_batch_manager = mock.MagicMock() + pipeline._wait_step_input_queue_empty = mock.MagicMock() + pipeline._consume_output_queue = mock.MagicMock() + + pipeline._handle_stop() + + pipeline._add_batches_back_to_batch_manager.assert_called_once() + pipeline._wait_step_input_queue_empty.assert_has_calls( + [ + mock.call(generator.name), + mock.call(step.name), + mock.call(step2.name), + mock.call(step3.name), + ], + any_order=True, + ) + pipeline._consume_output_queue.assert_called_once() + + @pytest.mark.parametrize( + "num_workers,expected", [(0, True), (_STEP_LOAD_FAILED_CODE, True), (1, False)] + ) + def test_check_step_not_loaded_or_finished( + self, num_workers: int, expected: bool + ) -> None: + pipeline = DummyPipeline(name="unit-test-pipeline") + pipeline._steps_load_status = {"dummy": num_workers} + + assert pipeline._check_step_not_loaded_or_finished("dummy") is expected + + def test_is_convergence_step(self) -> None: + sample_two_steps = sample_n_steps(2) + + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + step2 = DummyStep1() + step3 = DummyStep2() + + generator >> sample_two_steps >> [step, step2] >> step3 + + pipeline.dag.validate() + + assert not pipeline._is_convergence_step(generator.name) # type: ignore + assert not pipeline._is_convergence_step(step.name) # type: ignore + assert not pipeline._is_convergence_step(step2.name) # type: ignore + assert pipeline._is_convergence_step(step3.name) # type: ignore + + def test_create_step_input_queue(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + + generator >> step + + generator_name: str = generator.name # type: ignore + input_queue = pipeline._create_step_input_queue(generator_name) + assert isinstance(input_queue, Queue) + assert isinstance( + pipeline.dag.get_step(generator_name)[INPUT_QUEUE_ATTR_NAME], Queue + ) + + def test_run_steps(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + + generator >> step + + pipeline._create_step_input_queue = mock.MagicMock() + pipeline._run_step = mock.MagicMock() + pipeline._run_steps() + + pipeline._create_step_input_queue.assert_has_calls( + [ + mock.call(step_name=step.name), + mock.call(step_name=generator.name), + ], + any_order=True, + ) + + pipeline._run_step.assert_has_calls( + [ + mock.call(step=mock.ANY, input_queue=mock.ANY), + mock.call(step=mock.ANY, input_queue=mock.ANY), + ] + ) + + def test_add_batches_back_to_batch_manager(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + + generator >> step + + generator_name: str = generator.name # type: ignore + step_name: str = step.name # type: ignore + + pipeline._batch_manager = _BatchManager.from_dag(pipeline.dag) + generator_queue = Queue() + pipeline.dag.set_step_attr( + generator_name, INPUT_QUEUE_ATTR_NAME, generator_queue + ) + step_queue = Queue() + pipeline.dag.set_step_attr(step_name, INPUT_QUEUE_ATTR_NAME, step_queue) + + generator_queue.put( + _Batch(seq_no=0, step_name=generator_name, last_batch=False) + ) + generator_queue.put( + _Batch(seq_no=1, step_name=generator_name, last_batch=False) + ) + + step_batch_0 = _Batch(seq_no=0, step_name=step_name, last_batch=False) + step_batch_1 = _Batch(seq_no=0, step_name=step_name, last_batch=False) + step_queue.put(step_batch_0) + step_queue.put(step_batch_1) + + pipeline._add_batches_back_to_batch_manager() + + assert pipeline._batch_manager._steps[step_name].built_batches == [ + step_batch_0, + step_batch_1, + ] + + def test_consume_output_queue(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + + generator >> step + + pipeline._output_queue = Queue() + pipeline._write_buffer = mock.MagicMock() + pipeline._handle_batch_on_stop = mock.MagicMock() + + generator_name: str = generator.name # type: ignore + step_name: str = step.name # type: ignore + + generator_batch = _Batch(seq_no=0, step_name=generator_name, last_batch=False) + step_batch = _Batch(seq_no=0, step_name=step_name, last_batch=False) + + pipeline._output_queue.put(generator_batch) + pipeline._output_queue.put(step_batch) + + pipeline._consume_output_queue() + + pipeline._write_buffer.add_batch.assert_called_once_with(step_batch) + pipeline._handle_batch_on_stop.assert_has_calls( + [ + mock.call(generator_batch), + mock.call(step_batch), + ] + ) + def test_send_batch_to_step(self) -> None: - with BasePipeline(name="unit-test-pipeline") as pipeline: + with DummyPipeline(name="unit-test-pipeline") as pipeline: generator = DummyGeneratorStep() step = DummyStep1() global_step = DummyGlobalStep() @@ -146,6 +413,7 @@ def test_send_batch_to_step(self) -> None: generator >> [step, global_step] pipeline._batch_manager = mock.MagicMock() + pipeline._send_to_step = mock.MagicMock() pipeline._setup_fsspec() with mock.patch( @@ -159,6 +427,8 @@ def test_send_batch_to_step(self) -> None: _Batch(seq_no=0, step_name=step.name, last_batch=False) # type: ignore ) + # `write_batch_data_to_fs` shouldn't have been called because last batch sent with + # `_send_batch_to_step` is from a non-global step. mock_write.assert_not_called() with mock.patch( @@ -168,6 +438,8 @@ def test_send_batch_to_step(self) -> None: _Batch(seq_no=0, step_name=global_step.name, last_batch=False) # type: ignore ) + # `write_batch_data_to_fs` should have been called because last batch sent with + # `_send_batch_to_step` is from a global step. mock_write.assert_called_once_with( pipeline._fs, UPath(pipeline._storage_base_path) / global_step.name, @@ -182,6 +454,8 @@ def test_send_batch_to_step(self) -> None: _Batch(seq_no=0, step_name=generator.name, last_batch=False) # type: ignore ) + # `write_batch_data_to_fs` shouldn't have been called because generator receives + # empty batches, so there's no data to write. mock_write.assert_not_called() with mock.patch( @@ -207,6 +481,229 @@ def test_send_batch_to_step(self) -> None: ] ) + def test_register_batch(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + + generator >> step + + pipeline._batch_manager = mock.MagicMock() + batch = _Batch(seq_no=0, step_name=generator.name, last_batch=False) # type: ignore + pipeline._register_batch(batch) + + pipeline._batch_manager.register_batch.assert_called_once_with(batch) + + def test_send_last_batch_flag_to_step(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + + generator >> step + + step_name: str = step.name # type: ignore + + pipeline._batch_manager = _BatchManager( + steps={}, + last_batch_received={step_name: None}, + last_batch_sent={step_name: None}, + last_batch_flag_sent_to=[], + ) + + with mock.patch.object(pipeline, "_send_to_step") as mock_sent_to_step: + pipeline._send_last_batch_flag_to_step(step_name) + + mock_sent_to_step.assert_called_once_with(step_name, LAST_BATCH_SENT_FLAG) + + pipeline._batch_manager._last_batch_sent[step_name] = _Batch( + seq_no=0, + step_name=step_name, + last_batch=True, + ) + with mock.patch.object(pipeline, "_send_to_step") as mock_sent_to_step: + pipeline._send_last_batch_flag_to_step(step_name) + + mock_sent_to_step.assert_not_called() + + def test_request_initial_batches(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1(input_batch_size=5) + + generator >> step + + generator2 = DummyGeneratorStep() + step2 = DummyStep1(input_batch_size=5) + + generator2 >> step2 + + pipeline._batch_manager = _BatchManager.from_dag(pipeline.dag) + + # Simulate there were batches from the cache for the steps + batch_0 = _Batch( + seq_no=0, + step_name=generator.name, # type: ignore + last_batch=False, + data=[[{"a": i} for i in range(5)]], + ) + pipeline._batch_manager._steps[step.name].data[generator.name] = [ # type: ignore + batch_0 + ] + + batch_1 = _Batch( + seq_no=0, + step_name=generator2.name, # type: ignore + last_batch=False, + data=[[{"b": i} for i in range(5)]], + ) # type: ignore + pipeline._batch_manager._steps[step2.name].data[generator2.name] = [ # type: ignore + batch_1 + ] + + with mock.patch.object( + pipeline, "_send_batch_to_step" + ) as mock_send_batch_to_step: + pipeline._request_initial_batches() + + mock_send_batch_to_step.assert_has_calls( + [ + mock.call(mock.ANY), + mock.call(mock.ANY), + mock.call(_Batch(seq_no=0, step_name=generator.name, last_batch=False)), # type: ignore + mock.call( + _Batch(seq_no=0, step_name=generator2.name, last_batch=False) # type: ignore + ), + ], + any_order=True, + ) + + def test_request_more_batches_if_needed(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + + generator >> step + + generator_name: str = generator.name # type: ignore + + pipeline._batch_manager = _BatchManager.from_dag(pipeline.dag) + + batch = _Batch(seq_no=0, step_name=generator_name, last_batch=False) + pipeline._batch_manager._last_batch_sent[generator_name] = batch + + with mock.patch.object( + pipeline, "_send_batch_to_step" + ) as mock_send_batch_to_step: + pipeline._request_more_batches_if_needed(step) + + mock_send_batch_to_step.assert_called_once_with(batch.next_batch()) + + def test_handle_batch_on_stop(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1(input_batch_size=5) + step2 = DummyStep1(input_batch_size=5) + step3 = DummyStep1(input_batch_size=5) + + generator >> [step, step2, step3] + + batch_manager_mock = mock.MagicMock() + pipeline._batch_manager = batch_manager_mock + + batch = _Batch(seq_no=0, step_name=generator.name, last_batch=False) # type: ignore + pipeline._handle_batch_on_stop(batch) + + batch_manager_mock.register_batch.assert_called_once_with(batch) + batch_manager_mock.add_batch.assert_has_calls( + [ + mock.call(step.name, batch), + mock.call(step2.name, batch), + mock.call(step3.name, batch), + ] + ) + + def test_get_step_from_batch(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + + generator >> step + + batch = _Batch(seq_no=0, step_name=generator.name, last_batch=False) # type: ignore + assert pipeline._get_step_from_batch(batch) == generator + + batch = _Batch(seq_no=0, step_name=step.name, last_batch=False) # type: ignore + assert pipeline._get_step_from_batch(batch) == step + + def test_notify_steps_to_stop(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1(input_batch_size=5) + + generator >> step + + with mock.patch.object(pipeline, "_send_to_step") as mock_send_to_step: + pipeline._notify_steps_to_stop() + + mock_send_to_step.assert_has_calls( + [ + mock.call(generator.name, None), + mock.call(step.name, None), + ] + ) + + def test_get_successors(self) -> None: + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1() + step2 = DummyStep1() + step3 = DummyStep2() + + generator >> [step, step2] >> step3 + + assert pipeline._get_successors( + _Batch(seq_no=0, step_name=generator.name, last_batch=False) # type: ignore + ) == ([step.name, step2.name], False) + assert pipeline._get_successors( + _Batch(seq_no=0, step_name=step.name, last_batch=False) # type: ignore + ) == ([step3.name], False) + assert pipeline._get_successors( + _Batch(seq_no=0, step_name=step2.name, last_batch=False) # type: ignore + ) == ([step3.name], False) + assert pipeline._get_successors( + _Batch(seq_no=0, step_name=step3.name, last_batch=False) # type: ignore + ) == ([], False) + + def test_get_successors_with_routing_batch_function(self) -> None: + @routing_batch_function() + def fixed_routing_batch_function(steps: List[str]) -> List[str]: + return ["step_2", "step_3"] + + with DummyPipeline(name="unit-test-pipeline") as pipeline: + generator = DummyGeneratorStep() + step = DummyStep1(name="step_1") + step2 = DummyStep1(name="step_2") + step3 = DummyStep1(name="step_3") + step4 = DummyStep2(name="step_4") + + generator >> fixed_routing_batch_function >> [step, step2, step3] >> step4 + + assert pipeline._get_successors( + _Batch(seq_no=0, step_name=generator.name, last_batch=False) # type: ignore + ) == (["step_2", "step_3"], True) + assert pipeline._get_successors( + _Batch(seq_no=0, step_name=step.name, last_batch=False) # type: ignore + ) == ([step4.name], False) + assert pipeline._get_successors( + _Batch(seq_no=0, step_name=step2.name, last_batch=False) # type: ignore + ) == ([step4.name], False) + assert pipeline._get_successors( + _Batch(seq_no=0, step_name=step3.name, last_batch=False) # type: ignore + ) == ([step4.name], False) + assert pipeline._get_successors( + _Batch(seq_no=0, step_name=step4.name, last_batch=False) # type: ignore + ) == ([], False) + def test_get_runtime_parameters_info(self) -> None: class DummyStep1(Step): runtime_param1: RuntimeParameter[str] = Field( @@ -230,7 +727,7 @@ class DummyStep2(Step): def process(self, inputs: StepInput) -> None: pass - with BasePipeline(name="unit-test-pipeline") as pipeline: + with DummyPipeline(name="unit-test-pipeline") as pipeline: DummyStep1(name="dummy_step_1") DummyStep2(name="dummy_step_2") @@ -331,7 +828,7 @@ class DummyStep2(Step): def process(self, inputs: StepInput) -> StepOutput: # type: ignore yield [{}] - with BasePipeline(name="unit-test-pipeline") as pipeline: + with DummyPipeline(name="unit-test-pipeline") as pipeline: gen_step = DummyGeneratorStep(name="dummy_generator_step") step1 = DummyStep1(name="dummy_step_1") step2 = DummyStep2(name="dummy_step_2") @@ -348,7 +845,7 @@ def process(self, inputs: StepInput) -> StepOutput: # type: ignore def test_cache_dir_env_variable(self) -> None: with mock.patch.dict(os.environ, clear=True): os.environ["DISTILABEL_CACHE_DIR"] = "/tmp/unit-test" - pipeline = BasePipeline(name="unit-test-pipeline") + pipeline = DummyPipeline(name="unit-test-pipeline") assert pipeline._cache_dir == Path("/tmp/unit-test") @pytest.mark.parametrize( @@ -371,7 +868,7 @@ def test_cache_dir_env_variable(self) -> None: ) def test_step_names_inferred(self, in_pipeline: bool, names: List[str]) -> None: if in_pipeline: - with BasePipeline(name="unit-test-pipeline"): + with DummyPipeline(name="unit-test-pipeline"): gen_step = DummyGeneratorStep() step1_0 = DummyStep1() step2 = DummyStep2() @@ -391,2430 +888,84 @@ def test_step_names_inferred(self, in_pipeline: bool, names: List[str]) -> None: def test_infer_step_names_big_pipeline(self) -> None: # Tests that the name of the steps are inferred correctly when the pipeline is big (say 50 steps). - with BasePipeline(name="unit-test-pipeline") as pipe: + with DummyPipeline(name="unit-test-pipeline") as pipe: gen_step = DummyGeneratorStep() for _ in range(50): gen_step.connect(DummyStep1()) assert list(pipe.dag.G)[-1] == "dummy_step1_49" -class TestBatch: - def test_get_data(self) -> None: - batch = _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[ - [ - {"a": 0}, - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - {"a": 6}, - ] - ], +class TestPipelineSerialization: + def test_base_pipeline_dump(self): + pipeline = DummyPipeline(name="unit-test-pipeline") + dump = pipeline.dump() + assert len(dump.keys()) == 2 + assert "pipeline" in dump + assert "distilabel" in dump + assert TYPE_INFO_KEY in dump["pipeline"] + assert ( + dump["pipeline"][TYPE_INFO_KEY]["module"] == "tests.unit.pipeline.test_base" ) + assert dump["pipeline"][TYPE_INFO_KEY]["name"] == "DummyPipeline" - batch.set_data( - [ - [ - {"a": 0}, - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - {"a": 6}, - ] - ] - ) + def test_base_pipeline_from_dict(self): + pipeline = DummyPipeline(name="unit-test-pipeline") + pipe = DummyPipeline.from_dict(pipeline.dump()) + assert isinstance(pipe, DummyPipeline) - old_hash = batch.data_hash + def test_pipeline_dump(self): + from distilabel.pipeline.local import Pipeline - data = batch.get_data(5) - assert data == [{"a": 0}, {"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}] - assert batch.data == [[{"a": 5}, {"a": 6}]] - assert batch.data_hash != old_hash + pipeline = Pipeline(name="unit-test-pipeline") + dump = pipeline.dump() + assert len(dump.keys()) == 2 + assert "pipeline" in dump + assert "distilabel" in dump + assert TYPE_INFO_KEY in dump["pipeline"] + assert dump["pipeline"][TYPE_INFO_KEY]["module"] == "distilabel.pipeline.local" + assert dump["pipeline"][TYPE_INFO_KEY]["name"] == "Pipeline" - def test_set_data(self) -> None: - batch = _Batch(seq_no=0, step_name="step1", last_batch=False) - data = [[{"i": i} for i in range(5000)]] - batch.set_data(data) + @pytest.mark.parametrize( + "format, name, loader", + [ + ("yaml", "pipe.yaml", DummyPipeline.from_yaml), + ("json", "pipe.json", DummyPipeline.from_json), + ("invalid", "pipe.invalid", None), + ], + ) + def test_pipeline_to_from_file_format( + self, + format: str, + name: str, + loader: Callable, + ) -> None: + pipeline = DummyPipeline(name="unit-test-pipeline") - assert batch.data == data - assert batch.size == 5000 + with tempfile.TemporaryDirectory() as tmpdirname: + filename = Path(tmpdirname) / name + if format == "invalid": + with pytest.raises(ValueError): + pipeline.save(filename, format=format) + else: + pipeline.save(filename, format=format) + assert filename.exists() + pipe_from_file = loader(filename) + assert isinstance(pipe_from_file, DummyPipeline) - def test_next_batch(self) -> None: - batch = _Batch(seq_no=0, step_name="step1", last_batch=False) - next_batch = batch.next_batch() + def test_base_pipeline_signature(self): + pipeline = DummyPipeline(name="unit-test-pipeline") + # Doesn't matter if it's exactly this or not, the test should fail if we change the + # way this is created. + signature = pipeline._create_signature() + assert signature == "da39a3ee5e6b4b0d3255bfef95601890afd80709" - assert next_batch == _Batch(seq_no=1, step_name="step1", last_batch=False) + # Maybe not the best place for this test, but does the work for now + from distilabel.pipeline.local import Pipeline + from distilabel.pipeline.routing_batch_function import sample_n_steps - def test_accumulate(self) -> None: - batches = [ - [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}]], - ), - _Batch( - seq_no=1, - step_name="step1", - last_batch=True, - data=[[{"a": 4}, {"a": 5}, {"a": 6}]], - ), - ], - [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}]], - ), - _Batch( - seq_no=1, - step_name="step2", - last_batch=True, - data=[[{"b": 4}, {"b": 5}, {"b": 6}]], - ), - ], - ] + from tests.unit.pipeline.utils import DummyGeneratorStep, DummyStep1, DummyStep2 - batch = _Batch.accumulate("step3", batches) - - assert batch.seq_no == 0 - assert batch.step_name == "step3" - assert batch.last_batch is True - assert batch.data == [ - [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}], - [{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}, {"b": 6}], - ] - - def test_dump(self) -> None: - batch = _Batch(seq_no=0, step_name="step1", last_batch=False) - assert batch.dump() == { - "seq_no": 0, - "size": 0, - "step_name": "step1", - "last_batch": False, - "data": [], - "data_hash": None, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": {"module": "distilabel.pipeline.base", "name": "_Batch"}, - } - - batch = _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}]], - data_hash="hash", - accumulated=False, - created_from={"step0": [(0, 5), (1, 5)]}, - batch_routed_to=["step2", "step3"], - ) - assert batch.dump() == { - "seq_no": 0, - "size": 0, - "step_name": "step1", - "last_batch": False, - "data": [[{"a": 1}, {"a": 2}, {"a": 3}]], - "data_hash": "hash", - "accumulated": False, - "created_from": {"step0": [(0, 5), (1, 5)]}, - "batch_routed_to": ["step2", "step3"], - "type_info": {"module": "distilabel.pipeline.base", "name": "_Batch"}, - } - - def test_from_dict(self) -> None: - batch = _Batch.from_dict( - { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [[{"a": 1}, {"a": 2}, {"a": 3}]], - "accumulated": False, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ) - - assert isinstance(batch, _Batch) - assert batch.seq_no == 0 - assert batch.step_name == "step1" - assert batch.last_batch is False - assert batch.data == [[{"a": 1}, {"a": 2}, {"a": 3}]] - assert batch.accumulated is False - - -class TestBatchManagerStep: - def test_add_batch(self) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step2", accumulate=False, input_batch_size=10, data={"step1": []} - ) - - batch = _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}]], - ) - - batch_manager_step.add_batch(batch) - - assert batch_manager_step.data["step1"] == [batch] - assert batch_manager_step.last_batch_received == [] - - def test_add_batch_with_prepend(self) -> None: - batch_1 = _Batch( - seq_no=1, - step_name="step1", - last_batch=False, - data=[[{"a": 6}, {"a": 7}, {"a": 8}, {"a": 9}, {"a": 10}]], - ) - batch_manager_step = _BatchManagerStep( - step_name="step2", - accumulate=False, - input_batch_size=10, - data={"step1": [batch_1]}, - ) - - batch_0 = _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - batch_manager_step.add_batch(batch_0, prepend=True) - - assert batch_manager_step.built_batches == [batch_0] - assert batch_manager_step.data["step1"] == [batch_1] - assert batch_manager_step.last_batch_received == [] - - def test_add_batch_last_batch(self) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step2", accumulate=False, input_batch_size=10, data={"step1": []} - ) - - batch = _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}]], - ) - - batch_manager_step.add_batch(batch) - - assert batch_manager_step.data["step1"] == [batch] - assert batch_manager_step.last_batch_received == ["step1"] - - def test_get_batch(self) -> None: - previously_built_batch = _Batch( - seq_no=0, - step_name="step3", - last_batch=False, - data=[ - [ - {"a": -1}, - {"a": 0}, - ], - [ - {"b": -1}, - {"b": 0}, - ], - ], - ) - - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=False, - input_batch_size=2, - seq_no=1, - data={ - "step1": [ - _Batch( - seq_no=1, - step_name="step1", - last_batch=False, - data=[ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - ] - ], - size=5, - ) - ], - "step2": [ - _Batch( - seq_no=1, - step_name="step2", - last_batch=False, - data=[ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - ] - ], - size=5, - ) - ], - }, - built_batches=[previously_built_batch], - ) - - batch = batch_manager_step.get_batch() - - assert batch == previously_built_batch - - batch = batch_manager_step.get_batch() - - assert batch == _Batch( - step_name="step3", - seq_no=1, - last_batch=False, - data=[ - [ - {"a": 1}, - {"a": 2}, - ], - [ - {"b": 1}, - {"b": 2}, - ], - ], - created_from={"step1": [(1, 5)], "step2": [(1, 5)]}, - ) - - batch = batch_manager_step.get_batch() - - assert batch == _Batch( - step_name="step3", - seq_no=2, - last_batch=False, - data=[ - [ - {"a": 3}, - {"a": 4}, - ], - [ - {"b": 3}, - {"b": 4}, - ], - ], - created_from={"step1": [(1, 5)], "step2": [(1, 5)]}, - ) - - def test_get_batches_accumulate(self) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=True, - data={ - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - ] - ], - size=5, - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=True, - data=[ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - ] - ], - size=6, - ) - ], - }, - last_batch_received=["step1", "step2"], - ) - - batch = batch_manager_step.get_batch() - - assert batch == _Batch( - step_name="step3", - seq_no=0, - last_batch=True, - accumulated=True, - data=[ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - ], - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - ], - ], - created_from={"step1": [(0, 5)], "step2": [(0, 6)]}, - ) - - def test_get_batches_not_enough_data(self) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=False, - input_batch_size=2, - data={ - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[ - [ - {"a": 1}, - ] - ], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[ - [ - {"b": 1}, - {"b": 2}, - ] - ], - ) - ], - }, - ) - - assert batch_manager_step.get_batch() is None - - def test_from_step(self, dummy_step_1: "Step") -> None: - batch_manager_step = _BatchManagerStep.from_step( - step=dummy_step_1, predecessors=["step1", "step2"] - ) - - assert batch_manager_step.step_name == "dummy_step_1" - assert batch_manager_step.accumulate is False - assert batch_manager_step.input_batch_size == 50 - assert batch_manager_step.data == {"step1": [], "step2": []} - assert batch_manager_step.seq_no == 0 - assert batch_manager_step.last_batch_received == [] - - def test_from_step_with_global_step(self, dummy_global_step: "GlobalStep") -> None: - batch_manager_step = _BatchManagerStep.from_step( - step=dummy_global_step, predecessors=["step1", "step2"] - ) - - assert batch_manager_step.step_name == "dummy_global_step" - assert batch_manager_step.accumulate is True - assert batch_manager_step.input_batch_size == 50 - assert batch_manager_step.data == {"step1": [], "step2": []} - assert batch_manager_step.seq_no == 0 - assert batch_manager_step.last_batch_received == [] - - def test_get_seq_no(self) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step2", accumulate=False, input_batch_size=5, data={"step1": []} - ) - - seq_no = batch_manager_step._get_seq_no() - - assert seq_no == 0 - assert batch_manager_step.seq_no == 1 - - def test_get_data(self) -> None: - batch_step_1 = _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}]], - size=6, - batch_routed_to=["step1", "step2"], - ) - batch_step_2 = _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - size=7, - batch_routed_to=["step1", "step2"], - ) - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=False, - input_batch_size=5, - data={ - "step1": [batch_step_1], - "step2": [batch_step_2], - }, - ) - - data, created_from, routed_to = batch_manager_step._get_data() - assert data == [ - [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}], - [{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}], - ] - assert created_from == {"step1": [(0, 6)], "step2": [(0, 7)]} - assert routed_to == ["step1", "step2"] - - assert batch_manager_step.data == { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 6}]], - data_hash=batch_step_1.data_hash, - size=6, - batch_routed_to=["step1", "step2"], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"b": 6}, {"b": 7}]], - data_hash=batch_step_2.data_hash, - size=7, - batch_routed_to=["step1", "step2"], - ) - ], - } - - def test_get_data_accumulate(self) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=True, - data={ - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[ - [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}] - ], - size=6, - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - size=7, - ) - ], - }, - ) - - data, created_from, routed_to = batch_manager_step._get_data() - - assert data == [ - [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}], - [{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}, {"b": 6}, {"b": 7}], - ] - assert created_from == {"step1": [(0, 6)], "step2": [(0, 7)]} - assert routed_to == [] - - assert batch_manager_step.data == {"step1": [], "step2": []} - - def test_get_data_convergence_step(self) -> None: - batch_a_0 = _Batch( - seq_no=0, - step_name="A", - last_batch=False, - data=[ - [ - {"generation": "Hello, I'm A 0"}, - {"generation": "Hello, I'm A 0"}, - {"generation": "Hello, I'm A 0"}, - ] - ], - size=3, - created_from={"Z": [(0, 3)]}, - ) - - batch_a_1 = _Batch( - seq_no=1, - step_name="A", - last_batch=False, - data=[ - [ - {"generation": "Hello, I'm A 1"}, - {"generation": "Hello, I'm A 1"}, - {"generation": "Hello, I'm A 1"}, - ] - ], - size=3, - created_from={"Z": [(1, 3)]}, - ) - - batch_b_0 = _Batch( - seq_no=0, - step_name="B", - last_batch=False, - data=[ - [ - {"generation": "Hello, I'm B 0"}, - {"generation": "Hello, I'm B 0"}, - {"generation": "Hello, I'm B 0"}, - ] - ], - size=3, - created_from={"Z": [(0, 3)]}, - ) - - batch_c_0 = _Batch( - seq_no=0, - step_name="C", - last_batch=False, - data=[ - [ - {"generation": "Hello, I'm C 0"}, - {"generation": "Hello, I'm C 0"}, - {"generation": "Hello, I'm C 0"}, - ] - ], - size=3, - created_from={"Z": [(1, 3)]}, - ) - - batch_manager_step = _BatchManagerStep( - step_name="D", - input_batch_size=3, - convergence_step=True, - accumulate=False, - data={"A": [batch_a_0, batch_a_1], "B": [batch_b_0], "C": [batch_c_0]}, - ) - - data, created_from, routed_to = batch_manager_step._get_data() - - assert data == [ - [ - {"generation": "Hello, I'm A 0"}, - {"generation": "Hello, I'm A 0"}, - {"generation": "Hello, I'm A 0"}, - ], - [ - {"generation": "Hello, I'm B 0"}, - {"generation": "Hello, I'm B 0"}, - {"generation": "Hello, I'm B 0"}, - ], - ] - assert created_from == {"A": [(0, 3)], "B": [(0, 3)]} - assert routed_to == [] - assert batch_manager_step.next_expected_created_from_batch_seq_no == 1 - - data, created_from, routed_to = batch_manager_step._get_data() - - assert data == [ - [ - {"generation": "Hello, I'm A 1"}, - {"generation": "Hello, I'm A 1"}, - {"generation": "Hello, I'm A 1"}, - ], - [ - {"generation": "Hello, I'm C 0"}, - {"generation": "Hello, I'm C 0"}, - {"generation": "Hello, I'm C 0"}, - ], - ] - assert created_from == {"A": [(1, 3)], "C": [(0, 3)]} - assert routed_to == [] - assert batch_manager_step.next_expected_created_from_batch_seq_no == 2 - - @pytest.mark.parametrize( - "data, last_batch_received, expected", - [ - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - ] - }, - [], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}]], - ) - ], - }, - [], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - {"a": 6}, - ] - ], - ) - ] - }, - ["step1"], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - ] - }, - ["step1"], - True, - ), - ], - ) - def test_last_batch( - self, - data: Dict[str, List[_Batch]], - last_batch_received: List[str], - expected: bool, - ) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step2", - accumulate=False, - input_batch_size=5, - data=data, - last_batch_received=last_batch_received, - ) - - assert batch_manager_step._last_batch() is expected - - @pytest.mark.parametrize( - "data, last_batch_received, expected", - [ - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - ) - ], - }, - [], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - ) - ], - }, - ["step1"], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - ) - ], - }, - ["step1", "step2"], - True, - ), - ], - ) - def test_last_batch_accumulate( - self, - data: Dict[str, List[_Batch]], - last_batch_received: List[str], - expected: bool, - ) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=True, - data=data, - last_batch_received=last_batch_received, - ) - - assert batch_manager_step._last_batch() is expected - - @pytest.mark.parametrize( - "data, last_batch_received, expected", - [ - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - created_from={"step0": [(0, 5)]}, - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - created_from={"step0": [(0, 5)]}, - ) - ], - }, - [], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - created_from={"step0": [(0, 5)]}, - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - created_from={"step0": [(0, 5)]}, - ) - ], - }, - [], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}]], - created_from={"step0": [(0, 3)]}, - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"b": 1}, {"b": 2}, {"b": 3}]], - created_from={"step0": [(0, 3)]}, - ) - ], - }, - [], - True, - ), - ], - ) - def test_last_batch_convergence_step( - self, - data: Dict[str, List[_Batch]], - last_batch_received: List[str], - expected: bool, - ) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=False, - data=data, - last_batch_received=last_batch_received, - input_batch_size=3, - convergence_step=True, - ) - - assert batch_manager_step._last_batch() is expected - - @pytest.mark.parametrize( - "data, last_batch_received, expected", - [ - ( - { - "step1": [], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - ) - ], - }, - [], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - ) - ], - }, - [], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=True, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - ) - ], - }, - ["step1", "step2"], - True, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=True, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}]], - ) - ], - }, - ["step1", "step2"], - True, - ), - ], - ) - def test_ready_to_create_batch( - self, - data: Dict[str, List[_Batch]], - last_batch_received: List[str], - expected: bool, - ) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step2", - accumulate=False, - input_batch_size=5, - data=data, - last_batch_received=last_batch_received, - ) - - assert batch_manager_step._ready_to_create_batch() is expected - - @pytest.mark.parametrize( - "data, last_batch_received, expected", - [ - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=True, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - ) - ], - }, - ["step1", "step2"], - True, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - ) - ], - }, - ["step1"], - False, - ), - ], - ) - def test_ready_to_create_batch_accumulate( - self, - data: Dict[str, List[_Batch]], - last_batch_received: List[str], - expected: bool, - ) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=True, - data=data, - last_batch_received=last_batch_received, - ) - - assert batch_manager_step._ready_to_create_batch() is expected - - def test_dump(self) -> None: - batch_step_1 = _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}]], - data_hash="hash0", - size=6, - ) - batch_step_2 = _Batch( - seq_no=0, - step_name="step2", - last_batch=True, - data=[ - [{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}, {"b": 6}, {"b": 7}] - ], - data_hash="hash1", - size=7, - ) - batch_step_3 = _Batch( - seq_no=0, - step_name="step3", - last_batch=True, - data=[[{"c": 1}, {"c": 2}, {"c": 3}, {"c": 4}, {"c": 5}]], - data_hash="hash2", - size=5, - ) - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=True, - data={ - "step1": [batch_step_1], - "step2": [batch_step_2], - }, - built_batches=[batch_step_3], - ) - assert batch_manager_step.dump() == { - "step_name": "step3", - "accumulate": True, - "convergence_step": False, - "convergence_step_batches_consumed": {}, - "input_batch_size": None, - "data": { - "step1": [ - { - "seq_no": 0, - "step_name": "step1", - "last_batch": True, - "data": [ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - {"a": 6}, - ] - ], - "data_hash": "hash0", - "size": 6, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - "step2": [ - { - "seq_no": 0, - "step_name": "step2", - "last_batch": True, - "data": [ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - "data_hash": "hash1", - "size": 7, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - }, - "built_batches": [ - { - "seq_no": 0, - "step_name": "step3", - "last_batch": True, - "data": [[{"c": 1}, {"c": 2}, {"c": 3}, {"c": 4}, {"c": 5}]], - "data_hash": "hash2", - "size": 5, - "accumulated": False, - "batch_routed_to": [], - "created_from": {}, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - "seq_no": 0, - "last_batch_received": [], - "next_expected_created_from_batch_seq_no": 0, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", - }, - } - - @pytest.mark.parametrize( - "data, last_batch_received, expected", - [ - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - batch_routed_to=["step1", "step2"], - created_from={"step0": [(0, 5)]}, - ) - ], - "step2": [], - }, - [], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - batch_routed_to=["step1", "step2"], - created_from={"step0": [(0, 5)]}, - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - batch_routed_to=["step1", "step2"], - created_from={"step0": [(0, 5)]}, - ) - ], - }, - [], - True, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], - batch_routed_to=["step1", "step2"], - created_from={"step0": [(0, 4)]}, - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - batch_routed_to=["step1", "step2"], - created_from={"step0": [(0, 5)]}, - ) - ], - }, - [], - False, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=True, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], - batch_routed_to=["step1", "step2"], - created_from={"step0": [(0, 4)]}, - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=True, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}]], - batch_routed_to=["step1", "step2"], - created_from={"step0": [(0, 4)]}, - ) - ], - }, - ["step1", "step2"], - True, - ), - ( - { - "step1": [ - _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], - batch_routed_to=["step1", "step2"], - created_from={"step0": [(0, 4)]}, - ) - ], - "step2": [ - _Batch( - seq_no=0, - step_name="step2", - last_batch=False, - data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], - batch_routed_to=["step1", "step2"], - created_from={"step0": [(0, 5)]}, - ) - ], - }, - [], - False, - ), - ], - ) - def test_ready_to_create_batch_convergence_step( - self, - data: Dict[str, List[_Batch]], - last_batch_received: List[str], - expected: bool, - ) -> None: - batch_manager_step = _BatchManagerStep( - step_name="step3", - accumulate=False, - input_batch_size=5, - data=data, - last_batch_received=last_batch_received, - convergence_step=True, - ) - - assert batch_manager_step._ready_to_create_batch() is expected - - def test_from_dict(self) -> None: - batch_manager_step = _BatchManagerStep.from_dict( - { - "step_name": "step3", - "accumulate": True, - "convergence_step": False, - "convergence_step_batches_consumed": {0: {"Z": 1234}}, - "input_batch_size": None, - "data": { - "step1": [ - { - "seq_no": 0, - "step_name": "step1", - "last_batch": True, - "data": [ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - {"a": 6}, - ] - ], - "size": 6, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - } - ], - "step2": [ - { - "seq_no": 0, - "step_name": "step2", - "last_batch": True, - "data": [ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - "size": 7, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - } - ], - }, - "seq_no": 0, - "last_batch_received": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", - }, - } - ) - - assert isinstance(batch_manager_step, _BatchManagerStep) - assert batch_manager_step.step_name == "step3" - assert batch_manager_step.accumulate is True - assert batch_manager_step.convergence_step is False - assert batch_manager_step.convergence_step_batches_consumed == {0: {"Z": 1234}} - assert batch_manager_step.input_batch_size is None - assert batch_manager_step.seq_no == 0 - assert batch_manager_step.last_batch_received == [] - - -class TestBatchManager: - def test_add_batch(self) -> None: - batch_manager = _BatchManager( - steps={ - "step3": _BatchManagerStep( - step_name="step3", - accumulate=False, - input_batch_size=5, - data={"step1": [], "step2": []}, - ) - }, - last_batch_received={"step3": None}, - last_batch_sent={"step3": None}, - last_batch_flag_sent_to=[], - ) - - batch_from_step_1 = _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - batch_manager.add_batch(to_step="step3", batch=batch_from_step_1) - - assert batch_manager._steps["step3"].data == { - "step1": [batch_from_step_1], - "step2": [], - } - - def test_add_batch_with_prepend(self) -> None: - batch_1 = _Batch( - seq_no=1, - step_name="step1", - last_batch=False, - data=[[{"a": 6}, {"a": 7}, {"a": 8}, {"a": 9}, {"a": 10}]], - ) - batch_manager = _BatchManager( - steps={ - "step3": _BatchManagerStep( - step_name="step3", - accumulate=False, - input_batch_size=5, - data={ - "step1": [batch_1], - "step2": [], - }, - ) - }, - last_batch_received={"step3": None}, - last_batch_sent={"step3": None}, - last_batch_flag_sent_to=[], - ) - batch_0 = _Batch( - seq_no=0, - step_name="step1", - last_batch=False, - data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], - ) - batch_manager.add_batch(to_step="step3", batch=batch_0, prepend=True) - assert batch_manager._steps["step3"].built_batches == [batch_0] - assert batch_manager._steps["step3"].data == { - "step1": [batch_1], - "step2": [], - } - - def test_from_dag( - self, - dummy_generator_step: "GeneratorStep", - dummy_step_1: "Step", - dummy_step_2: "Step", - dummy_global_step: "GlobalStep", - ) -> None: - dag = DAG() - dag.add_step(dummy_generator_step) - dag.add_step(dummy_step_1) - dag.add_step(dummy_step_2) - dag.add_step(dummy_global_step) - dag.add_edge("dummy_generator_step", "dummy_step_1") - dag.add_edge("dummy_generator_step", "dummy_global_step") - dag.add_edge("dummy_step_1", "dummy_step_2") - - batch_manager = _BatchManager.from_dag(dag) - - assert batch_manager._steps == { - "dummy_step_1": _BatchManagerStep( - step_name="dummy_step_1", - accumulate=False, - input_batch_size=50, - data={"dummy_generator_step": []}, - ), - "dummy_global_step": _BatchManagerStep( - step_name="dummy_global_step", - accumulate=True, - input_batch_size=50, - data={"dummy_generator_step": []}, - ), - "dummy_step_2": _BatchManagerStep( - step_name="dummy_step_2", - accumulate=False, - input_batch_size=50, - data={"dummy_step_1": []}, - ), - } - - def test_can_generate(self) -> None: - batch_manager = _BatchManager( - steps={}, - last_batch_received={ - "step_1": _Batch(seq_no=0, step_name="step_1", last_batch=False), - "step_2": _Batch(seq_no=0, step_name="step_2", last_batch=False), - "step_3": _Batch(seq_no=0, step_name="step_3", last_batch=False), - }, - last_batch_sent={"step_1": None, "step_2": None, "step_3": None}, - last_batch_flag_sent_to=[], - ) - - assert batch_manager.can_generate() - - batch_1 = _Batch(seq_no=0, step_name="step_1", last_batch=True) - batch_2 = _Batch(seq_no=0, step_name="step_2", last_batch=True) - batch_3 = _Batch(seq_no=0, step_name="step_3", last_batch=True) - - batch_manager = _BatchManager( - steps={}, - last_batch_received={ - "step_1": batch_1, - "step_2": batch_2, - "step_3": batch_3, - }, - last_batch_sent={"step_1": batch_1, "step_2": batch_2, "step_3": batch_3}, - last_batch_flag_sent_to=[], - ) - - assert not batch_manager.can_generate() - - def test_dump(self) -> None: - built_batch = _Batch( - seq_no=0, - last_batch=False, - step_name="step3", - data=[[]], - data_hash="hash", - ) - - batch_manager = _BatchManager( - steps={ - "step3": _BatchManagerStep( - step_name="step3", - accumulate=False, - input_batch_size=5, - data={"step1": [], "step2": []}, - built_batches=[built_batch], - seq_no=1, - ) - }, - last_batch_received={ - "step3": _Batch( - seq_no=0, - step_name="step3", - last_batch=False, - ) - }, - last_batch_sent={ - "step3": _Batch( - seq_no=1, - step_name="step3", - last_batch=False, - ) - }, - last_batch_flag_sent_to=["step99"], - ) - assert batch_manager.dump() == { - "steps": { - "step3": { - "step_name": "step3", - "accumulate": False, - "convergence_step": False, - "convergence_step_batches_consumed": {}, - "input_batch_size": 5, - "data": {"step1": [], "step2": []}, - "built_batches": [ - { - "seq_no": 0, - "step_name": "step3", - "last_batch": False, - "data": [[]], - "data_hash": "hash", - "size": 0, - "accumulated": False, - "batch_routed_to": [], - "created_from": {}, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - "seq_no": 1, - "last_batch_received": [], - "next_expected_created_from_batch_seq_no": 0, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", - }, - }, - }, - "last_batch_received": { - "step3": { - "seq_no": 0, - "step_name": "step3", - "batch_routed_to": [], - "created_from": {}, - "last_batch": False, - "data": [], - "data_hash": None, - "size": 0, - "accumulated": False, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - }, - "last_batch_sent": { - "step3": { - "seq_no": 1, - "step_name": "step3", - "batch_routed_to": [], - "created_from": {}, - "last_batch": False, - "data": [], - "data_hash": None, - "size": 0, - "accumulated": False, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - }, - "last_batch_flag_sent_to": ["step99"], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManager", - }, - } - - def test_from_dict(self) -> None: - batch_manager = _BatchManager.from_dict( - { - "steps": { - "step1": { - "step_name": "step1", - "accumulate": True, - "convergence_step": False, - "convergence_step_batches_consumed": {0: {"Z": 1234}}, - "input_batch_size": None, - "data": { - "step2": [ - { - "seq_no": 0, - "step_name": "step2", - "last_batch": True, - "data": [ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - "size": 7, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - }, - "seq_no": 0, - "last_batch_received": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", - }, - }, - "step2": { - "step_name": "step2", - "accumulate": False, - "convergence_step": False, - "convergence_step_batches_consumed": {0: {"Z": 1234}}, - "input_batch_size": 50, - "data": { - "step2": [ - { - "seq_no": 0, - "step_name": "step2", - "last_batch": True, - "data": [ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - "size": 7, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - }, - "seq_no": 0, - "last_batch_received": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", - }, - }, - }, - "last_batch_received": { - "step1": { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - "step2": { - "seq_no": 0, - "step_name": "step2", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - }, - "last_batch_sent": { - "step1": { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - "step2": { - "seq_no": 0, - "step_name": "step2", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - }, - "last_batch_flag_sent_to": ["step3"], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManager", - }, - } - ) - - assert isinstance(batch_manager, _BatchManager) - - assert len(batch_manager._steps) == 2 - for step in batch_manager._steps.values(): - assert isinstance(step, _BatchManagerStep) - - assert len(batch_manager._last_batch_received) == 2 - for step in batch_manager._last_batch_received.values(): - assert isinstance(step, _Batch) - - assert len(batch_manager._last_batch_sent) == 2 - for step in batch_manager._last_batch_sent.values(): - assert isinstance(step, _Batch) - - assert batch_manager._last_batch_flag_sent_to == ["step3"] - - def test_cache(self) -> None: - batch_manager = _BatchManager.from_dict( - { - "steps": { - "step1": { - "step_name": "step1", - "accumulate": True, - "convergence_step": False, - "convergence_step_batches_consumed": {"0": {"Z": 1234}}, - "input_batch_size": None, - "data": { - "step2": [ - { - "seq_no": 0, - "step_name": "step2", - "last_batch": True, - "data": [ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - "data_hash": "1234", - "size": 7, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - }, - "built_batches": [ - { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - ] - ], - "data_hash": "1234", - "size": 5, - "accumulated": False, - "batch_routed_to": [], - "created_from": {}, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - "seq_no": 0, - "last_batch_received": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", - }, - }, - "step2": { - "step_name": "step2", - "accumulate": False, - "convergence_step": False, - "convergence_step_batches_consumed": {"0": {"Z": 1234}}, - "input_batch_size": 50, - "data": { - "step2": [ - { - "seq_no": 0, - "step_name": "step2", - "last_batch": True, - "data": [ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - "data_hash": "1234", - "size": 7, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - }, - "built_batches": [ - { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - ] - ], - "data_hash": "1234", - "size": 5, - "accumulated": False, - "batch_routed_to": [], - "created_from": {}, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - "seq_no": 0, - "last_batch_received": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", - }, - }, - }, - "last_batch_received": { - "step1": { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - "step2": { - "seq_no": 0, - "step_name": "step2", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - }, - "last_batch_sent": { - "step1": { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - "step2": { - "seq_no": 0, - "step_name": "step2", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - }, - "last_batch_flag_sent_to": ["step3"], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManager", - }, - } - ) - - with tempfile.TemporaryDirectory() as tmp_dir: - batch_manager_path = Path(tmp_dir) / "batch_manager.json" - batch_manager.cache(batch_manager_path) - - assert batch_manager_path.exists() and batch_manager_path.is_file() - - for step_name, step in batch_manager._steps.items(): - batch_manager_step_dir = ( - Path(tmp_dir) / "batch_manager_steps" / step_name - ) - assert ( - batch_manager_step_dir.exists() and batch_manager_step_dir.is_dir() - ) - - batch_manager_step_path = ( - batch_manager_step_dir / "batch_manager_step.json" - ) - assert ( - batch_manager_step_path.exists() - and batch_manager_step_path.is_file() - ) - - built_batches_dir = batch_manager_step_dir / "built_batches" - assert built_batches_dir.exists() - - for batch in step.built_batches: - batch_path = ( - built_batches_dir - / f"batch_{batch.seq_no}_{batch.data_hash}.json" - ) - assert batch_path.exists() and batch_path.is_file() - - for buffered_step_name in step.data: - buffered_step_dir = batch_manager_step_dir / buffered_step_name - assert buffered_step_dir.exists() and buffered_step_dir.is_dir() - - for batch in step.data[buffered_step_name]: - batch_path = ( - buffered_step_dir - / f"batch_{batch.seq_no}_{batch.data_hash}.json" - ) - assert batch_path.exists() and batch_path.is_file() - - def test_load_from_cache(self) -> None: - batch_manager = _BatchManager.from_dict( - { - "steps": { - "step1": { - "step_name": "step1", - "accumulate": True, - "convergence_step": False, - "convergence_step_batches_consumed": {"0": {"Z": 1234}}, - "input_batch_size": None, - "data": { - "step2": [ - { - "seq_no": 0, - "step_name": "step2", - "last_batch": True, - "data": [ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - "data_hash": "1234", - "size": 7, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - }, - "built_batches": [ - { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - ] - ], - "data_hash": "1234", - "size": 5, - "accumulated": False, - "batch_routed_to": [], - "created_from": {}, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - "seq_no": 0, - "last_batch_received": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", - }, - }, - "step2": { - "step_name": "step2", - "accumulate": False, - "convergence_step": False, - "convergence_step_batches_consumed": {"0": {"Z": 1234}}, - "input_batch_size": 50, - "data": { - "step2": [ - { - "seq_no": 0, - "step_name": "step2", - "last_batch": True, - "data": [ - [ - {"b": 1}, - {"b": 2}, - {"b": 3}, - {"b": 4}, - {"b": 5}, - {"b": 6}, - {"b": 7}, - ] - ], - "data_hash": "1234", - "size": 7, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - }, - "built_batches": [ - { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [ - [ - {"a": 1}, - {"a": 2}, - {"a": 3}, - {"a": 4}, - {"a": 5}, - ] - ], - "data_hash": "1234", - "size": 5, - "accumulated": False, - "batch_routed_to": [], - "created_from": {}, - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - } - ], - "seq_no": 0, - "last_batch_received": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManagerStep", - }, - }, - }, - "last_batch_received": { - "step1": { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - "step2": { - "seq_no": 0, - "step_name": "step2", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - }, - "last_batch_sent": { - "step1": { - "seq_no": 0, - "step_name": "step1", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - "step2": { - "seq_no": 0, - "step_name": "step2", - "last_batch": False, - "data": [], - "size": 0, - "accumulated": False, - "created_from": {}, - "batch_routed_to": [], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_Batch", - }, - }, - }, - "last_batch_flag_sent_to": ["step3"], - "type_info": { - "module": "distilabel.pipeline.base", - "name": "_BatchManager", - }, - } - ) - - with tempfile.TemporaryDirectory() as tmp_dir: - batch_manager_path = Path(tmp_dir) / "batch_manager.json" - batch_manager.cache(batch_manager_path) - loaded_batch_manager = _BatchManager.load_from_cache(batch_manager_path) - - assert batch_manager.dump() == loaded_batch_manager.dump() - - -class TestPipelineSerialization: - def test_base_pipeline_dump(self): - pipeline = BasePipeline(name="unit-test-pipeline") - dump = pipeline.dump() - assert len(dump.keys()) == 2 - assert "pipeline" in dump - assert "distilabel" in dump - assert TYPE_INFO_KEY in dump["pipeline"] - assert dump["pipeline"][TYPE_INFO_KEY]["module"] == "distilabel.pipeline.base" - assert dump["pipeline"][TYPE_INFO_KEY]["name"] == "BasePipeline" - - def test_base_pipeline_from_dict(self): - pipeline = BasePipeline(name="unit-test-pipeline") - pipe = BasePipeline.from_dict(pipeline.dump()) - assert isinstance(pipe, BasePipeline) - - def test_pipeline_dump(self): - from distilabel.pipeline.local import Pipeline - - pipeline = Pipeline(name="unit-test-pipeline") - dump = pipeline.dump() - assert len(dump.keys()) == 2 - assert "pipeline" in dump - assert "distilabel" in dump - assert TYPE_INFO_KEY in dump["pipeline"] - assert dump["pipeline"][TYPE_INFO_KEY]["module"] == "distilabel.pipeline.local" - assert dump["pipeline"][TYPE_INFO_KEY]["name"] == "Pipeline" - - @pytest.mark.parametrize( - "format, name, loader", - [ - ("yaml", "pipe.yaml", BasePipeline.from_yaml), - ("json", "pipe.json", BasePipeline.from_json), - ("invalid", "pipe.invalid", None), - ], - ) - def test_pipeline_to_from_file_format( - self, - format: str, - name: str, - loader: Callable, - ) -> None: - pipeline = BasePipeline(name="unit-test-pipeline") - - with tempfile.TemporaryDirectory() as tmpdirname: - filename = Path(tmpdirname) / name - if format == "invalid": - with pytest.raises(ValueError): - pipeline.save(filename, format=format) - else: - pipeline.save(filename, format=format) - assert filename.exists() - pipe_from_file = loader(filename) - assert isinstance(pipe_from_file, BasePipeline) - - def test_base_pipeline_signature(self): - pipeline = BasePipeline(name="unit-test-pipeline") - # Doesn't matter if it's exactly this or not, the test should fail if we change the - # way this is created. - signature = pipeline._create_signature() - assert signature == "da39a3ee5e6b4b0d3255bfef95601890afd80709" - - # Maybe not the best place for this test, but does the work for now - from distilabel.pipeline.local import Pipeline - from distilabel.pipeline.routing_batch_function import sample_n_steps - - from tests.unit.pipeline.utils import DummyGeneratorStep, DummyStep1, DummyStep2 - - sample_two_steps = sample_n_steps(2) + sample_two_steps = sample_n_steps(2) with Pipeline(name="unit-test-pipeline") as pipeline: dummy_generator = DummyGeneratorStep() @@ -2951,126 +1102,3 @@ def test_binary_operators(self) -> None: signature_2 = pipeline_2._create_signature() assert signature_1 == signature_2 - - -class TestWriteBuffer: - def test_create(self) -> None: - with tempfile.TemporaryDirectory() as tmpdirname: - folder = Path(tmpdirname) / "data" - with Pipeline(name="unit-test-pipeline") as pipeline: - dummy_generator_1 = DummyGeneratorStep(name="dummy_generator_step_1") - dummy_generator_2 = DummyGeneratorStep(name="dummy_generator_step_2") - dummy_step_1 = DummyStep1(name="dummy_step_1") - dummy_step_2 = DummyStep2(name="dummy_step_2") - dummy_step_3 = DummyStep2(name="dummy_step_3") - - dummy_generator_1.connect(dummy_step_1) - dummy_generator_2.connect(dummy_step_2) - dummy_step_1.connect(dummy_step_2) - dummy_step_1.connect(dummy_step_3) - - write_buffer = _WriteBuffer(path=folder, leaf_steps=pipeline.dag.leaf_steps) - - assert write_buffer._buffers == {"dummy_step_2": [], "dummy_step_3": []} - assert write_buffer._buffers_dump_batch_size == { - "dummy_step_2": 50, - "dummy_step_3": 50, - } - assert write_buffer._buffer_last_schema == {} - assert write_buffer._buffers_last_file == { - "dummy_step_2": 1, - "dummy_step_3": 1, - } - - def test_write_buffer_one_leaf_step_and_create_dataset(self) -> None: - with tempfile.TemporaryDirectory() as tmpdirname: - folder = Path(tmpdirname) / "data" - with Pipeline(name="unit-test-pipeline") as pipeline: - dummy_generator = DummyGeneratorStep(name="dummy_generator_step") - dummy_step_1 = DummyStep1(name="dummy_step_1") - dummy_step_2 = DummyStep2(name="dummy_step_2") - - dummy_generator.connect(dummy_step_1) - dummy_step_1.connect(dummy_step_2) - - write_buffer = _WriteBuffer(path=folder, leaf_steps=pipeline.dag.leaf_steps) - - # Add one batch with 5 rows, shouldn't write anything 5 < 50 - batch = batch_gen(dummy_step_2.name) - write_buffer.add_batch(batch) - - # Add 45 more rows, should write now - for _ in range(9): - batch = batch_gen(dummy_step_2.name) - write_buffer.add_batch(batch) - - assert Path(folder, "dummy_step_2", "00001.parquet").exists() - - # Add 50 more rows, we should have a new file - for _ in range(10): - batch = batch_gen(dummy_step_2.name) - write_buffer.add_batch(batch) - - assert Path(folder, "dummy_step_2", "00002.parquet").exists() - - # Add more rows and close the write buffer, we should have a new file - for _ in range(5): - batch = batch_gen(dummy_step_2.name) - write_buffer.add_batch(batch) - - write_buffer.close() - - assert Path(folder, "dummy_step_2", "00003.parquet").exists() - - ds = create_distiset(write_buffer._path) - assert isinstance(ds, Distiset) - assert len(ds.keys()) == 1 - assert len(ds["default"]["train"]) == 125 - - def test_write_buffer_multiple_leaf_steps_and_create_dataset(self): - with tempfile.TemporaryDirectory() as tmpdirname: - folder = Path(tmpdirname) / "data" - with Pipeline(name="unit-test-pipeline") as pipeline: - dummy_generator_1 = DummyGeneratorStep(name="dummy_generator_step_1") - dummy_generator_2 = DummyGeneratorStep(name="dummy_generator_step_2") - dummy_step_1 = DummyStep1(name="dummy_step_1") - dummy_step_2 = DummyStep2(name="dummy_step_2") - dummy_step_3 = DummyStep2(name="dummy_step_3") - - dummy_generator_1.connect(dummy_step_1) - dummy_generator_2.connect(dummy_step_2) - dummy_step_1.connect(dummy_step_2) - dummy_step_1.connect(dummy_step_3) - - write_buffer = _WriteBuffer(path=folder, leaf_steps=pipeline.dag.leaf_steps) - - for _ in range(10): - batch = batch_gen(dummy_step_2.name) - write_buffer.add_batch(batch) - - assert Path(folder, "dummy_step_2", "00001.parquet").exists() - - for _ in range(10): - batch = batch_gen(dummy_step_3.name) - write_buffer.add_batch(batch) - - assert Path(folder, "dummy_step_3", "00001.parquet").exists() - - for _ in range(5): - batch = batch_gen(dummy_step_2.name) - write_buffer.add_batch(batch) - - for _ in range(5): - batch = batch_gen(dummy_step_3.name) - write_buffer.add_batch(batch) - - write_buffer.close() - - assert Path(folder, "dummy_step_2", "00002.parquet").exists() - assert Path(folder, "dummy_step_3", "00002.parquet").exists() - - ds = create_distiset(write_buffer._path) - assert isinstance(ds, Distiset) - assert len(ds.keys()) == 2 - assert len(ds["dummy_step_2"]["train"]) == 75 - assert len(ds["dummy_step_3"]["train"]) == 75 diff --git a/tests/unit/pipeline/test_batch.py b/tests/unit/pipeline/test_batch.py new file mode 100644 index 0000000000..ed246e491f --- /dev/null +++ b/tests/unit/pipeline/test_batch.py @@ -0,0 +1,172 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from distilabel.pipeline.batch import _Batch + + +class TestBatch: + def test_get_data(self) -> None: + batch = _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[ + [ + {"a": 0}, + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + {"a": 6}, + ] + ], + ) + + batch.set_data( + [ + [ + {"a": 0}, + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + {"a": 6}, + ] + ] + ) + + old_hash = batch.data_hash + + data = batch.get_data(5) + assert data == [{"a": 0}, {"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}] + assert batch.data == [[{"a": 5}, {"a": 6}]] + assert batch.data_hash != old_hash + + def test_set_data(self) -> None: + batch = _Batch(seq_no=0, step_name="step1", last_batch=False) + data = [[{"i": i} for i in range(5000)]] + batch.set_data(data) + + assert batch.data == data + assert batch.size == 5000 + + def test_next_batch(self) -> None: + batch = _Batch(seq_no=0, step_name="step1", last_batch=False) + next_batch = batch.next_batch() + + assert next_batch == _Batch(seq_no=1, step_name="step1", last_batch=False) + + def test_accumulate(self) -> None: + batches = [ + [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}]], + ), + _Batch( + seq_no=1, + step_name="step1", + last_batch=True, + data=[[{"a": 4}, {"a": 5}, {"a": 6}]], + ), + ], + [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}]], + ), + _Batch( + seq_no=1, + step_name="step2", + last_batch=True, + data=[[{"b": 4}, {"b": 5}, {"b": 6}]], + ), + ], + ] + + batch = _Batch.accumulate("step3", batches) + + assert batch.seq_no == 0 + assert batch.step_name == "step3" + assert batch.last_batch is True + assert batch.data == [ + [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}], + [{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}, {"b": 6}], + ] + + def test_dump(self) -> None: + batch = _Batch(seq_no=0, step_name="step1", last_batch=False) + assert batch.dump() == { + "seq_no": 0, + "size": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "data_hash": None, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": {"module": "distilabel.pipeline.batch", "name": "_Batch"}, + } + + batch = _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}]], + data_hash="hash", + accumulated=False, + created_from={"step0": [(0, 5), (1, 5)]}, + batch_routed_to=["step2", "step3"], + ) + assert batch.dump() == { + "seq_no": 0, + "size": 0, + "step_name": "step1", + "last_batch": False, + "data": [[{"a": 1}, {"a": 2}, {"a": 3}]], + "data_hash": "hash", + "accumulated": False, + "created_from": {"step0": [(0, 5), (1, 5)]}, + "batch_routed_to": ["step2", "step3"], + "type_info": {"module": "distilabel.pipeline.batch", "name": "_Batch"}, + } + + def test_from_dict(self) -> None: + batch = _Batch.from_dict( + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [[{"a": 1}, {"a": 2}, {"a": 3}]], + "accumulated": False, + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ) + + assert isinstance(batch, _Batch) + assert batch.seq_no == 0 + assert batch.step_name == "step1" + assert batch.last_batch is False + assert batch.data == [[{"a": 1}, {"a": 2}, {"a": 3}]] + assert batch.accumulated is False diff --git a/tests/unit/pipeline/test_batch_manager.py b/tests/unit/pipeline/test_batch_manager.py new file mode 100644 index 0000000000..7b1cb1a8a6 --- /dev/null +++ b/tests/unit/pipeline/test_batch_manager.py @@ -0,0 +1,2214 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 tempfile +from pathlib import Path +from typing import Dict, List + +import pytest +from distilabel.pipeline._dag import DAG +from distilabel.pipeline.batch import _Batch +from distilabel.pipeline.batch_manager import _BatchManager, _BatchManagerStep +from distilabel.steps.base import GeneratorStep, GlobalStep, Step + + +class TestBatchManagerStep: + def test_add_batch(self) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step2", accumulate=False, input_batch_size=10, data={"step1": []} + ) + + batch = _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}]], + ) + + batch_manager_step.add_batch(batch) + + assert batch_manager_step.data["step1"] == [batch] + assert batch_manager_step.last_batch_received == [] + + def test_add_batch_with_prepend(self) -> None: + batch_1 = _Batch( + seq_no=1, + step_name="step1", + last_batch=False, + data=[[{"a": 6}, {"a": 7}, {"a": 8}, {"a": 9}, {"a": 10}]], + ) + batch_manager_step = _BatchManagerStep( + step_name="step2", + accumulate=False, + input_batch_size=10, + data={"step1": [batch_1]}, + ) + + batch_0 = _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + batch_manager_step.add_batch(batch_0, prepend=True) + + assert batch_manager_step.built_batches == [batch_0] + assert batch_manager_step.data["step1"] == [batch_1] + assert batch_manager_step.last_batch_received == [] + + def test_add_batch_last_batch(self) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step2", accumulate=False, input_batch_size=10, data={"step1": []} + ) + + batch = _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}]], + ) + + batch_manager_step.add_batch(batch) + + assert batch_manager_step.data["step1"] == [batch] + assert batch_manager_step.last_batch_received == ["step1"] + + def test_get_batch(self) -> None: + previously_built_batch = _Batch( + seq_no=0, + step_name="step3", + last_batch=False, + data=[ + [ + {"a": -1}, + {"a": 0}, + ], + [ + {"b": -1}, + {"b": 0}, + ], + ], + ) + + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=False, + input_batch_size=2, + seq_no=1, + data={ + "step1": [ + _Batch( + seq_no=1, + step_name="step1", + last_batch=False, + data=[ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + size=5, + ) + ], + "step2": [ + _Batch( + seq_no=1, + step_name="step2", + last_batch=False, + data=[ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + ] + ], + size=5, + ) + ], + }, + built_batches=[previously_built_batch], + ) + + batch = batch_manager_step.get_batch() + + assert batch == previously_built_batch + + batch = batch_manager_step.get_batch() + + assert batch == _Batch( + step_name="step3", + seq_no=1, + last_batch=False, + data=[ + [ + {"a": 1}, + {"a": 2}, + ], + [ + {"b": 1}, + {"b": 2}, + ], + ], + created_from={"step1": [(1, 5)], "step2": [(1, 5)]}, + ) + + batch = batch_manager_step.get_batch() + + assert batch == _Batch( + step_name="step3", + seq_no=2, + last_batch=False, + data=[ + [ + {"a": 3}, + {"a": 4}, + ], + [ + {"b": 3}, + {"b": 4}, + ], + ], + created_from={"step1": [(1, 5)], "step2": [(1, 5)]}, + ) + + def test_get_batches_accumulate(self) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=True, + data={ + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + size=5, + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=True, + data=[ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + ] + ], + size=6, + ) + ], + }, + last_batch_received=["step1", "step2"], + ) + + batch = batch_manager_step.get_batch() + + assert batch == _Batch( + step_name="step3", + seq_no=0, + last_batch=True, + accumulated=True, + data=[ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ], + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + ], + ], + created_from={"step1": [(0, 5)], "step2": [(0, 6)]}, + ) + + def test_get_batches_not_enough_data(self) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=False, + input_batch_size=2, + data={ + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[ + [ + {"a": 1}, + ] + ], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[ + [ + {"b": 1}, + {"b": 2}, + ] + ], + ) + ], + }, + ) + + assert batch_manager_step.get_batch() is None + + def test_from_step(self, dummy_step_1: "Step") -> None: + batch_manager_step = _BatchManagerStep.from_step( + step=dummy_step_1, predecessors=["step1", "step2"] + ) + + assert batch_manager_step.step_name == "dummy_step_1" + assert batch_manager_step.accumulate is False + assert batch_manager_step.input_batch_size == 50 + assert batch_manager_step.data == {"step1": [], "step2": []} + assert batch_manager_step.seq_no == 0 + assert batch_manager_step.last_batch_received == [] + + def test_from_step_with_global_step(self, dummy_global_step: "GlobalStep") -> None: + batch_manager_step = _BatchManagerStep.from_step( + step=dummy_global_step, predecessors=["step1", "step2"] + ) + + assert batch_manager_step.step_name == "dummy_global_step" + assert batch_manager_step.accumulate is True + assert batch_manager_step.input_batch_size == 50 + assert batch_manager_step.data == {"step1": [], "step2": []} + assert batch_manager_step.seq_no == 0 + assert batch_manager_step.last_batch_received == [] + + def test_get_seq_no(self) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step2", accumulate=False, input_batch_size=5, data={"step1": []} + ) + + seq_no = batch_manager_step._get_seq_no() + + assert seq_no == 0 + assert batch_manager_step.seq_no == 1 + + def test_get_data(self) -> None: + batch_step_1 = _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}]], + size=6, + batch_routed_to=["step1", "step2"], + ) + batch_step_2 = _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + size=7, + batch_routed_to=["step1", "step2"], + ) + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=False, + input_batch_size=5, + data={ + "step1": [batch_step_1], + "step2": [batch_step_2], + }, + ) + + data, created_from, routed_to = batch_manager_step._get_data() + assert data == [ + [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}], + [{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}], + ] + assert created_from == {"step1": [(0, 6)], "step2": [(0, 7)]} + assert routed_to == ["step1", "step2"] + + assert batch_manager_step.data == { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 6}]], + data_hash=batch_step_1.data_hash, + size=6, + batch_routed_to=["step1", "step2"], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"b": 6}, {"b": 7}]], + data_hash=batch_step_2.data_hash, + size=7, + batch_routed_to=["step1", "step2"], + ) + ], + } + + def test_get_data_accumulate(self) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=True, + data={ + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[ + [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}] + ], + size=6, + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + size=7, + ) + ], + }, + ) + + data, created_from, routed_to = batch_manager_step._get_data() + + assert data == [ + [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}], + [{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}, {"b": 6}, {"b": 7}], + ] + assert created_from == {"step1": [(0, 6)], "step2": [(0, 7)]} + assert routed_to == [] + + assert batch_manager_step.data == {"step1": [], "step2": []} + + def test_get_data_convergence_step(self) -> None: + batch_a_0 = _Batch( + seq_no=0, + step_name="A", + last_batch=False, + data=[ + [ + {"generation": "Hello, I'm A 0"}, + {"generation": "Hello, I'm A 0"}, + {"generation": "Hello, I'm A 0"}, + ] + ], + size=3, + created_from={"Z": [(0, 3)]}, + ) + + batch_a_1 = _Batch( + seq_no=1, + step_name="A", + last_batch=False, + data=[ + [ + {"generation": "Hello, I'm A 1"}, + {"generation": "Hello, I'm A 1"}, + {"generation": "Hello, I'm A 1"}, + ] + ], + size=3, + created_from={"Z": [(1, 3)]}, + ) + + batch_b_0 = _Batch( + seq_no=0, + step_name="B", + last_batch=False, + data=[ + [ + {"generation": "Hello, I'm B 0"}, + {"generation": "Hello, I'm B 0"}, + {"generation": "Hello, I'm B 0"}, + ] + ], + size=3, + created_from={"Z": [(0, 3)]}, + ) + + batch_c_0 = _Batch( + seq_no=0, + step_name="C", + last_batch=False, + data=[ + [ + {"generation": "Hello, I'm C 0"}, + {"generation": "Hello, I'm C 0"}, + {"generation": "Hello, I'm C 0"}, + ] + ], + size=3, + created_from={"Z": [(1, 3)]}, + ) + + batch_manager_step = _BatchManagerStep( + step_name="D", + input_batch_size=3, + convergence_step=True, + accumulate=False, + data={"A": [batch_a_0, batch_a_1], "B": [batch_b_0], "C": [batch_c_0]}, + ) + + data, created_from, routed_to = batch_manager_step._get_data() + + assert data == [ + [ + {"generation": "Hello, I'm A 0"}, + {"generation": "Hello, I'm A 0"}, + {"generation": "Hello, I'm A 0"}, + ], + [ + {"generation": "Hello, I'm B 0"}, + {"generation": "Hello, I'm B 0"}, + {"generation": "Hello, I'm B 0"}, + ], + ] + assert created_from == {"A": [(0, 3)], "B": [(0, 3)]} + assert routed_to == [] + assert batch_manager_step.next_expected_created_from_batch_seq_no == 1 + + data, created_from, routed_to = batch_manager_step._get_data() + + assert data == [ + [ + {"generation": "Hello, I'm A 1"}, + {"generation": "Hello, I'm A 1"}, + {"generation": "Hello, I'm A 1"}, + ], + [ + {"generation": "Hello, I'm C 0"}, + {"generation": "Hello, I'm C 0"}, + {"generation": "Hello, I'm C 0"}, + ], + ] + assert created_from == {"A": [(1, 3)], "C": [(0, 3)]} + assert routed_to == [] + assert batch_manager_step.next_expected_created_from_batch_seq_no == 2 + + @pytest.mark.parametrize( + "data, last_batch_received, expected", + [ + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + ] + }, + [], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}]], + ) + ], + }, + [], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + {"a": 6}, + ] + ], + ) + ] + }, + ["step1"], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + ] + }, + ["step1"], + True, + ), + ], + ) + def test_last_batch( + self, + data: Dict[str, List[_Batch]], + last_batch_received: List[str], + expected: bool, + ) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step2", + accumulate=False, + input_batch_size=5, + data=data, + last_batch_received=last_batch_received, + ) + + assert batch_manager_step._last_batch() is expected + + @pytest.mark.parametrize( + "data, last_batch_received, expected", + [ + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + ) + ], + }, + [], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + ) + ], + }, + ["step1"], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + ) + ], + }, + ["step1", "step2"], + True, + ), + ], + ) + def test_last_batch_accumulate( + self, + data: Dict[str, List[_Batch]], + last_batch_received: List[str], + expected: bool, + ) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=True, + data=data, + last_batch_received=last_batch_received, + ) + + assert batch_manager_step._last_batch() is expected + + @pytest.mark.parametrize( + "data, last_batch_received, expected", + [ + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + created_from={"step0": [(0, 5)]}, + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + created_from={"step0": [(0, 5)]}, + ) + ], + }, + [], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + created_from={"step0": [(0, 5)]}, + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + created_from={"step0": [(0, 5)]}, + ) + ], + }, + [], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}]], + created_from={"step0": [(0, 3)]}, + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"b": 1}, {"b": 2}, {"b": 3}]], + created_from={"step0": [(0, 3)]}, + ) + ], + }, + [], + True, + ), + ], + ) + def test_last_batch_convergence_step( + self, + data: Dict[str, List[_Batch]], + last_batch_received: List[str], + expected: bool, + ) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=False, + data=data, + last_batch_received=last_batch_received, + input_batch_size=3, + convergence_step=True, + ) + + assert batch_manager_step._last_batch() is expected + + @pytest.mark.parametrize( + "data, last_batch_received, expected", + [ + ( + { + "step1": [], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + ) + ], + }, + [], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + ) + ], + }, + [], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=True, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + ) + ], + }, + ["step1", "step2"], + True, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=True, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}]], + ) + ], + }, + ["step1", "step2"], + True, + ), + ], + ) + def test_ready_to_create_batch( + self, + data: Dict[str, List[_Batch]], + last_batch_received: List[str], + expected: bool, + ) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step2", + accumulate=False, + input_batch_size=5, + data=data, + last_batch_received=last_batch_received, + ) + + assert batch_manager_step._ready_to_create_batch() is expected + + @pytest.mark.parametrize( + "data, last_batch_received, expected", + [ + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=True, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + ) + ], + }, + ["step1", "step2"], + True, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + ) + ], + }, + ["step1"], + False, + ), + ], + ) + def test_ready_to_create_batch_accumulate( + self, + data: Dict[str, List[_Batch]], + last_batch_received: List[str], + expected: bool, + ) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=True, + data=data, + last_batch_received=last_batch_received, + ) + + assert batch_manager_step._ready_to_create_batch() is expected + + def test_dump(self) -> None: + batch_step_1 = _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}]], + data_hash="hash0", + size=6, + ) + batch_step_2 = _Batch( + seq_no=0, + step_name="step2", + last_batch=True, + data=[ + [{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}, {"b": 6}, {"b": 7}] + ], + data_hash="hash1", + size=7, + ) + batch_step_3 = _Batch( + seq_no=0, + step_name="step3", + last_batch=True, + data=[[{"c": 1}, {"c": 2}, {"c": 3}, {"c": 4}, {"c": 5}]], + data_hash="hash2", + size=5, + ) + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=True, + data={ + "step1": [batch_step_1], + "step2": [batch_step_2], + }, + built_batches=[batch_step_3], + ) + assert batch_manager_step.dump() == { + "step_name": "step3", + "accumulate": True, + "convergence_step": False, + "convergence_step_batches_consumed": {}, + "input_batch_size": None, + "data": { + "step1": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": True, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + {"a": 6}, + ] + ], + "data_hash": "hash0", + "size": 6, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "data_hash": "hash1", + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step3", + "last_batch": True, + "data": [[{"c": 1}, {"c": 2}, {"c": 3}, {"c": 4}, {"c": 5}]], + "data_hash": "hash2", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + "seq_no": 0, + "last_batch_received": [], + "next_expected_created_from_batch_seq_no": 0, + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManagerStep", + }, + } + + @pytest.mark.parametrize( + "data, last_batch_received, expected", + [ + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + batch_routed_to=["step1", "step2"], + created_from={"step0": [(0, 5)]}, + ) + ], + "step2": [], + }, + [], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + batch_routed_to=["step1", "step2"], + created_from={"step0": [(0, 5)]}, + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + batch_routed_to=["step1", "step2"], + created_from={"step0": [(0, 5)]}, + ) + ], + }, + [], + True, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], + batch_routed_to=["step1", "step2"], + created_from={"step0": [(0, 4)]}, + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + batch_routed_to=["step1", "step2"], + created_from={"step0": [(0, 5)]}, + ) + ], + }, + [], + False, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=True, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], + batch_routed_to=["step1", "step2"], + created_from={"step0": [(0, 4)]}, + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=True, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}]], + batch_routed_to=["step1", "step2"], + created_from={"step0": [(0, 4)]}, + ) + ], + }, + ["step1", "step2"], + True, + ), + ( + { + "step1": [ + _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}]], + batch_routed_to=["step1", "step2"], + created_from={"step0": [(0, 4)]}, + ) + ], + "step2": [ + _Batch( + seq_no=0, + step_name="step2", + last_batch=False, + data=[[{"b": 1}, {"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]], + batch_routed_to=["step1", "step2"], + created_from={"step0": [(0, 5)]}, + ) + ], + }, + [], + False, + ), + ], + ) + def test_ready_to_create_batch_convergence_step( + self, + data: Dict[str, List[_Batch]], + last_batch_received: List[str], + expected: bool, + ) -> None: + batch_manager_step = _BatchManagerStep( + step_name="step3", + accumulate=False, + input_batch_size=5, + data=data, + last_batch_received=last_batch_received, + convergence_step=True, + ) + + assert batch_manager_step._ready_to_create_batch() is expected + + def test_from_dict(self) -> None: + batch_manager_step = _BatchManagerStep.from_dict( + { + "step_name": "step3", + "accumulate": True, + "convergence_step": False, + "convergence_step_batches_consumed": {0: {"Z": 1234}}, + "input_batch_size": None, + "data": { + "step1": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": True, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + {"a": 6}, + ] + ], + "size": 6, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + } + ], + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + } + ], + }, + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManagerStep", + }, + } + ) + + assert isinstance(batch_manager_step, _BatchManagerStep) + assert batch_manager_step.step_name == "step3" + assert batch_manager_step.accumulate is True + assert batch_manager_step.convergence_step is False + assert batch_manager_step.convergence_step_batches_consumed == {0: {"Z": 1234}} + assert batch_manager_step.input_batch_size is None + assert batch_manager_step.seq_no == 0 + assert batch_manager_step.last_batch_received == [] + + +class TestBatchManager: + def test_add_batch(self) -> None: + batch_manager = _BatchManager( + steps={ + "step3": _BatchManagerStep( + step_name="step3", + accumulate=False, + input_batch_size=5, + data={"step1": [], "step2": []}, + ) + }, + last_batch_received={"step3": None}, + last_batch_sent={"step3": None}, + last_batch_flag_sent_to=[], + ) + + batch_from_step_1 = _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + batch_manager.add_batch(to_step="step3", batch=batch_from_step_1) + + assert batch_manager._steps["step3"].data == { + "step1": [batch_from_step_1], + "step2": [], + } + + def test_add_batch_with_prepend(self) -> None: + batch_1 = _Batch( + seq_no=1, + step_name="step1", + last_batch=False, + data=[[{"a": 6}, {"a": 7}, {"a": 8}, {"a": 9}, {"a": 10}]], + ) + batch_manager = _BatchManager( + steps={ + "step3": _BatchManagerStep( + step_name="step3", + accumulate=False, + input_batch_size=5, + data={ + "step1": [batch_1], + "step2": [], + }, + ) + }, + last_batch_received={"step3": None}, + last_batch_sent={"step3": None}, + last_batch_flag_sent_to=[], + ) + batch_0 = _Batch( + seq_no=0, + step_name="step1", + last_batch=False, + data=[[{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}]], + ) + batch_manager.add_batch(to_step="step3", batch=batch_0, prepend=True) + assert batch_manager._steps["step3"].built_batches == [batch_0] + assert batch_manager._steps["step3"].data == { + "step1": [batch_1], + "step2": [], + } + + def test_from_dag( + self, + dummy_generator_step: "GeneratorStep", + dummy_step_1: "Step", + dummy_step_2: "Step", + dummy_global_step: "GlobalStep", + ) -> None: + dag = DAG() + dag.add_step(dummy_generator_step) + dag.add_step(dummy_step_1) + dag.add_step(dummy_step_2) + dag.add_step(dummy_global_step) + dag.add_edge("dummy_generator_step", "dummy_step_1") + dag.add_edge("dummy_generator_step", "dummy_global_step") + dag.add_edge("dummy_step_1", "dummy_step_2") + + batch_manager = _BatchManager.from_dag(dag) + + assert batch_manager._steps == { + "dummy_step_1": _BatchManagerStep( + step_name="dummy_step_1", + accumulate=False, + input_batch_size=50, + data={"dummy_generator_step": []}, + ), + "dummy_global_step": _BatchManagerStep( + step_name="dummy_global_step", + accumulate=True, + input_batch_size=50, + data={"dummy_generator_step": []}, + ), + "dummy_step_2": _BatchManagerStep( + step_name="dummy_step_2", + accumulate=False, + input_batch_size=50, + data={"dummy_step_1": []}, + ), + } + + def test_can_generate(self) -> None: + batch_manager = _BatchManager( + steps={}, + last_batch_received={ + "step_1": _Batch(seq_no=0, step_name="step_1", last_batch=False), + "step_2": _Batch(seq_no=0, step_name="step_2", last_batch=False), + "step_3": _Batch(seq_no=0, step_name="step_3", last_batch=False), + }, + last_batch_sent={"step_1": None, "step_2": None, "step_3": None}, + last_batch_flag_sent_to=[], + ) + + assert batch_manager.can_generate() + + batch_1 = _Batch(seq_no=0, step_name="step_1", last_batch=True) + batch_2 = _Batch(seq_no=0, step_name="step_2", last_batch=True) + batch_3 = _Batch(seq_no=0, step_name="step_3", last_batch=True) + + batch_manager = _BatchManager( + steps={}, + last_batch_received={ + "step_1": batch_1, + "step_2": batch_2, + "step_3": batch_3, + }, + last_batch_sent={"step_1": batch_1, "step_2": batch_2, "step_3": batch_3}, + last_batch_flag_sent_to=[], + ) + + assert not batch_manager.can_generate() + + def test_dump(self) -> None: + built_batch = _Batch( + seq_no=0, + last_batch=False, + step_name="step3", + data=[[]], + data_hash="hash", + ) + + batch_manager = _BatchManager( + steps={ + "step3": _BatchManagerStep( + step_name="step3", + accumulate=False, + input_batch_size=5, + data={"step1": [], "step2": []}, + built_batches=[built_batch], + seq_no=1, + ) + }, + last_batch_received={ + "step3": _Batch( + seq_no=0, + step_name="step3", + last_batch=False, + ) + }, + last_batch_sent={ + "step3": _Batch( + seq_no=1, + step_name="step3", + last_batch=False, + ) + }, + last_batch_flag_sent_to=["step99"], + ) + assert batch_manager.dump() == { + "steps": { + "step3": { + "step_name": "step3", + "accumulate": False, + "convergence_step": False, + "convergence_step_batches_consumed": {}, + "input_batch_size": 5, + "data": {"step1": [], "step2": []}, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step3", + "last_batch": False, + "data": [[]], + "data_hash": "hash", + "size": 0, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + "seq_no": 1, + "last_batch_received": [], + "next_expected_created_from_batch_seq_no": 0, + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManagerStep", + }, + }, + }, + "last_batch_received": { + "step3": { + "seq_no": 0, + "step_name": "step3", + "batch_routed_to": [], + "created_from": {}, + "last_batch": False, + "data": [], + "data_hash": None, + "size": 0, + "accumulated": False, + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + }, + "last_batch_sent": { + "step3": { + "seq_no": 1, + "step_name": "step3", + "batch_routed_to": [], + "created_from": {}, + "last_batch": False, + "data": [], + "data_hash": None, + "size": 0, + "accumulated": False, + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + }, + "last_batch_flag_sent_to": ["step99"], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManager", + }, + } + + def test_from_dict(self) -> None: + batch_manager = _BatchManager.from_dict( + { + "steps": { + "step1": { + "step_name": "step1", + "accumulate": True, + "convergence_step": False, + "convergence_step_batches_consumed": {0: {"Z": 1234}}, + "input_batch_size": None, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + } + ], + }, + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManagerStep", + }, + }, + "step2": { + "step_name": "step2", + "accumulate": False, + "convergence_step": False, + "convergence_step_batches_consumed": {0: {"Z": 1234}}, + "input_batch_size": 50, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + } + ], + }, + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManagerStep", + }, + }, + }, + "last_batch_received": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + }, + }, + "last_batch_sent": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + }, + }, + "last_batch_flag_sent_to": ["step3"], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManager", + }, + } + ) + + assert isinstance(batch_manager, _BatchManager) + + assert len(batch_manager._steps) == 2 + for step in batch_manager._steps.values(): + assert isinstance(step, _BatchManagerStep) + + assert len(batch_manager._last_batch_received) == 2 + for step in batch_manager._last_batch_received.values(): + assert isinstance(step, _Batch) + + assert len(batch_manager._last_batch_sent) == 2 + for step in batch_manager._last_batch_sent.values(): + assert isinstance(step, _Batch) + + assert batch_manager._last_batch_flag_sent_to == ["step3"] + + def test_cache(self) -> None: + batch_manager = _BatchManager.from_dict( + { + "steps": { + "step1": { + "step_name": "step1", + "accumulate": True, + "convergence_step": False, + "convergence_step_batches_consumed": {"0": {"Z": 1234}}, + "input_batch_size": None, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "data_hash": "1234", + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + } + ], + }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + "data_hash": "1234", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManagerStep", + }, + }, + "step2": { + "step_name": "step2", + "accumulate": False, + "convergence_step": False, + "convergence_step_batches_consumed": {"0": {"Z": 1234}}, + "input_batch_size": 50, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "data_hash": "1234", + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + } + ], + }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + "data_hash": "1234", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManagerStep", + }, + }, + }, + "last_batch_received": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + }, + }, + "last_batch_sent": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_Batch", + }, + }, + }, + "last_batch_flag_sent_to": ["step3"], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManager", + }, + } + ) + + with tempfile.TemporaryDirectory() as tmp_dir: + batch_manager_path = Path(tmp_dir) / "batch_manager.json" + batch_manager.cache(batch_manager_path) + + assert batch_manager_path.exists() and batch_manager_path.is_file() + + for step_name, step in batch_manager._steps.items(): + batch_manager_step_dir = ( + Path(tmp_dir) / "batch_manager_steps" / step_name + ) + assert ( + batch_manager_step_dir.exists() and batch_manager_step_dir.is_dir() + ) + + batch_manager_step_path = ( + batch_manager_step_dir / "batch_manager_step.json" + ) + assert ( + batch_manager_step_path.exists() + and batch_manager_step_path.is_file() + ) + + built_batches_dir = batch_manager_step_dir / "built_batches" + assert built_batches_dir.exists() + + for batch in step.built_batches: + batch_path = ( + built_batches_dir + / f"batch_{batch.seq_no}_{batch.data_hash}.json" + ) + assert batch_path.exists() and batch_path.is_file() + + for buffered_step_name in step.data: + buffered_step_dir = batch_manager_step_dir / buffered_step_name + assert buffered_step_dir.exists() and buffered_step_dir.is_dir() + + for batch in step.data[buffered_step_name]: + batch_path = ( + buffered_step_dir + / f"batch_{batch.seq_no}_{batch.data_hash}.json" + ) + assert batch_path.exists() and batch_path.is_file() + + def test_load_from_cache(self) -> None: + batch_manager = _BatchManager.from_dict( + { + "steps": { + "step1": { + "step_name": "step1", + "accumulate": True, + "convergence_step": False, + "convergence_step_batches_consumed": {"0": {"Z": 1234}}, + "input_batch_size": None, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "data_hash": "1234", + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + "data_hash": "1234", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManagerStep", + }, + }, + "step2": { + "step_name": "step2", + "accumulate": False, + "convergence_step": False, + "convergence_step_batches_consumed": {"0": {"Z": 1234}}, + "input_batch_size": 50, + "data": { + "step2": [ + { + "seq_no": 0, + "step_name": "step2", + "last_batch": True, + "data": [ + [ + {"b": 1}, + {"b": 2}, + {"b": 3}, + {"b": 4}, + {"b": 5}, + {"b": 6}, + {"b": 7}, + ] + ], + "data_hash": "1234", + "size": 7, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + }, + "built_batches": [ + { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [ + [ + {"a": 1}, + {"a": 2}, + {"a": 3}, + {"a": 4}, + {"a": 5}, + ] + ], + "data_hash": "1234", + "size": 5, + "accumulated": False, + "batch_routed_to": [], + "created_from": {}, + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + } + ], + "seq_no": 0, + "last_batch_received": [], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManagerStep", + }, + }, + }, + "last_batch_received": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + }, + }, + "last_batch_sent": { + "step1": { + "seq_no": 0, + "step_name": "step1", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + }, + "step2": { + "seq_no": 0, + "step_name": "step2", + "last_batch": False, + "data": [], + "size": 0, + "accumulated": False, + "created_from": {}, + "batch_routed_to": [], + "type_info": { + "module": "distilabel.pipeline.batch", + "name": "_Batch", + }, + }, + }, + "last_batch_flag_sent_to": ["step3"], + "type_info": { + "module": "distilabel.pipeline.batch_manager", + "name": "_BatchManager", + }, + } + ) + + with tempfile.TemporaryDirectory() as tmp_dir: + batch_manager_path = Path(tmp_dir) / "batch_manager.json" + batch_manager.cache(batch_manager_path) + loaded_batch_manager = _BatchManager.load_from_cache(batch_manager_path) + + assert batch_manager.dump() == loaded_batch_manager.dump() diff --git a/tests/unit/pipeline/test_local.py b/tests/unit/pipeline/test_local.py index 511f8f5040..4797f8e66d 100644 --- a/tests/unit/pipeline/test_local.py +++ b/tests/unit/pipeline/test_local.py @@ -15,7 +15,8 @@ from typing import TYPE_CHECKING from unittest import mock -from distilabel.pipeline.base import _Batch, _BatchManager +from distilabel.pipeline.batch import _Batch +from distilabel.pipeline.batch_manager import _BatchManager from distilabel.pipeline.local import Pipeline from .utils import DummyGeneratorStep, DummyStep1, DummyStep2 @@ -63,11 +64,6 @@ def test_send_batch_to_step(self, dummy_generator_step: "GeneratorStep") -> None @mock.patch("distilabel.pipeline.local._ProcessWrapper") def test_create_processes(self, process_wrapper_mock: mock.MagicMock) -> None: - pool = mock.MagicMock() - manager = mock.MagicMock() - queue = mock.MagicMock() - shared_info = mock.MagicMock() - with Pipeline(name="unit-test-pipeline") as pipeline: dummy_generator = DummyGeneratorStep(name="dummy_generator_step") dummy_step_1 = DummyStep1(name="dummy_step_1") @@ -76,51 +72,52 @@ def test_create_processes(self, process_wrapper_mock: mock.MagicMock) -> None: dummy_generator.connect(dummy_step_1) dummy_step_1.connect(dummy_step_2) - pipeline._run_steps_in_loop(pool, manager, queue, shared_info) + pipeline._pool = mock.MagicMock() + pipeline._manager = mock.MagicMock() + pipeline._output_queue = mock.MagicMock() + pipeline._load_queue = mock.MagicMock() + pipeline._run_steps() - assert manager.Queue.call_count == 3 + assert pipeline._manager.Queue.call_count == 3 process_wrapper_mock.assert_has_calls( [ mock.call( step=dummy_generator, input_queue=mock.ANY, - output_queue=queue, - shared_info=shared_info, + output_queue=pipeline._output_queue, + load_queue=pipeline._load_queue, dry_run=False, ), mock.call( step=dummy_step_1, input_queue=mock.ANY, - output_queue=queue, - shared_info=shared_info, + output_queue=pipeline._output_queue, + load_queue=pipeline._load_queue, dry_run=False, ), mock.call( step=dummy_step_2, input_queue=mock.ANY, - output_queue=queue, - shared_info=shared_info, + output_queue=pipeline._output_queue, + load_queue=pipeline._load_queue, dry_run=False, ), ], ) - pool.apply_async.assert_has_calls( + pipeline._pool.apply_async.assert_has_calls( [ mock.call( process_wrapper_mock.return_value.run, - callback=pipeline._finished_callback, error_callback=pipeline._error_callback, ), mock.call( process_wrapper_mock.return_value.run, - callback=pipeline._finished_callback, error_callback=pipeline._error_callback, ), mock.call( process_wrapper_mock.return_value.run, - callback=pipeline._finished_callback, error_callback=pipeline._error_callback, ), ] diff --git a/tests/unit/pipeline/test_routing_batch_function.py b/tests/unit/pipeline/test_routing_batch_function.py index 5e3f208c5b..6cc3090eb7 100644 --- a/tests/unit/pipeline/test_routing_batch_function.py +++ b/tests/unit/pipeline/test_routing_batch_function.py @@ -14,7 +14,7 @@ from typing import List -from distilabel.pipeline.base import _Batch +from distilabel.pipeline.batch import _Batch from distilabel.pipeline.local import Pipeline from distilabel.pipeline.routing_batch_function import ( RoutingBatchFunction, diff --git a/tests/unit/pipeline/test_write_buffer.py b/tests/unit/pipeline/test_write_buffer.py new file mode 100644 index 0000000000..a7ae64c91e --- /dev/null +++ b/tests/unit/pipeline/test_write_buffer.py @@ -0,0 +1,150 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 tempfile +from pathlib import Path + +from distilabel.distiset import Distiset, create_distiset +from distilabel.pipeline.local import Pipeline +from distilabel.pipeline.write_buffer import _WriteBuffer + +from tests.unit.pipeline.utils import ( + DummyGeneratorStep, + DummyStep1, + DummyStep2, + batch_gen, +) + + +class TestWriteBuffer: + def test_create(self) -> None: + with tempfile.TemporaryDirectory() as tmpdirname: + folder = Path(tmpdirname) / "data" + with Pipeline(name="unit-test-pipeline") as pipeline: + dummy_generator_1 = DummyGeneratorStep(name="dummy_generator_step_1") + dummy_generator_2 = DummyGeneratorStep(name="dummy_generator_step_2") + dummy_step_1 = DummyStep1(name="dummy_step_1") + dummy_step_2 = DummyStep2(name="dummy_step_2") + dummy_step_3 = DummyStep2(name="dummy_step_3") + + dummy_generator_1.connect(dummy_step_1) + dummy_generator_2.connect(dummy_step_2) + dummy_step_1.connect(dummy_step_2) + dummy_step_1.connect(dummy_step_3) + + write_buffer = _WriteBuffer(path=folder, leaf_steps=pipeline.dag.leaf_steps) + + assert write_buffer._buffers == {"dummy_step_2": [], "dummy_step_3": []} + assert write_buffer._buffers_dump_batch_size == { + "dummy_step_2": 50, + "dummy_step_3": 50, + } + assert write_buffer._buffer_last_schema == {} + assert write_buffer._buffers_last_file == { + "dummy_step_2": 1, + "dummy_step_3": 1, + } + + def test_write_buffer_one_leaf_step_and_create_dataset(self) -> None: + with tempfile.TemporaryDirectory() as tmpdirname: + folder = Path(tmpdirname) / "data" + with Pipeline(name="unit-test-pipeline") as pipeline: + dummy_generator = DummyGeneratorStep(name="dummy_generator_step") + dummy_step_1 = DummyStep1(name="dummy_step_1") + dummy_step_2 = DummyStep2(name="dummy_step_2") + + dummy_generator.connect(dummy_step_1) + dummy_step_1.connect(dummy_step_2) + + write_buffer = _WriteBuffer(path=folder, leaf_steps=pipeline.dag.leaf_steps) + + # Add one batch with 5 rows, shouldn't write anything 5 < 50 + batch = batch_gen(dummy_step_2.name) # type: ignore + write_buffer.add_batch(batch) + + # Add 45 more rows, should write now + for _ in range(9): + batch = batch_gen(dummy_step_2.name) # type: ignore + write_buffer.add_batch(batch) + + assert Path(folder, "dummy_step_2", "00001.parquet").exists() + + # Add 50 more rows, we should have a new file + for _ in range(10): + batch = batch_gen(dummy_step_2.name) # type: ignore + write_buffer.add_batch(batch) + + assert Path(folder, "dummy_step_2", "00002.parquet").exists() + + # Add more rows and close the write buffer, we should have a new file + for _ in range(5): + batch = batch_gen(dummy_step_2.name) # type: ignore + write_buffer.add_batch(batch) + + write_buffer.close() + + assert Path(folder, "dummy_step_2", "00003.parquet").exists() + + ds = create_distiset(write_buffer._path) + assert isinstance(ds, Distiset) + assert len(ds.keys()) == 1 + assert len(ds["default"]["train"]) == 125 + + def test_write_buffer_multiple_leaf_steps_and_create_dataset(self) -> None: + with tempfile.TemporaryDirectory() as tmpdirname: + folder = Path(tmpdirname) / "data" + with Pipeline(name="unit-test-pipeline") as pipeline: + dummy_generator_1 = DummyGeneratorStep(name="dummy_generator_step_1") + dummy_generator_2 = DummyGeneratorStep(name="dummy_generator_step_2") + dummy_step_1 = DummyStep1(name="dummy_step_1") + dummy_step_2 = DummyStep2(name="dummy_step_2") + dummy_step_3 = DummyStep2(name="dummy_step_3") + + dummy_generator_1.connect(dummy_step_1) + dummy_generator_2.connect(dummy_step_2) + dummy_step_1.connect(dummy_step_2) + dummy_step_1.connect(dummy_step_3) + + write_buffer = _WriteBuffer(path=folder, leaf_steps=pipeline.dag.leaf_steps) + + for _ in range(10): + batch = batch_gen(dummy_step_2.name) # type: ignore + write_buffer.add_batch(batch) + + assert Path(folder, "dummy_step_2", "00001.parquet").exists() + + for _ in range(10): + batch = batch_gen(dummy_step_3.name) # type: ignore + write_buffer.add_batch(batch) + + assert Path(folder, "dummy_step_3", "00001.parquet").exists() + + for _ in range(5): + batch = batch_gen(dummy_step_2.name) # type: ignore + write_buffer.add_batch(batch) + + for _ in range(5): + batch = batch_gen(dummy_step_3.name) # type: ignore + write_buffer.add_batch(batch) + + write_buffer.close() + + assert Path(folder, "dummy_step_2", "00002.parquet").exists() + assert Path(folder, "dummy_step_3", "00002.parquet").exists() + + ds = create_distiset(write_buffer._path) + assert isinstance(ds, Distiset) + assert len(ds.keys()) == 2 + assert len(ds["dummy_step_2"]["train"]) == 75 + assert len(ds["dummy_step_3"]["train"]) == 75 diff --git a/tests/unit/pipeline/utils.py b/tests/unit/pipeline/utils.py index 8d02340114..7f771271d0 100644 --- a/tests/unit/pipeline/utils.py +++ b/tests/unit/pipeline/utils.py @@ -14,7 +14,7 @@ from typing import List -from distilabel.pipeline.base import _Batch +from distilabel.pipeline.batch import _Batch from distilabel.steps.base import GeneratorStep, GlobalStep, Step, StepInput from distilabel.steps.typing import GeneratorStepOutput, StepOutput From 3822c7377732d6bfff16cba01bedb77d86809f09 Mon Sep 17 00:00:00 2001 From: Agus Date: Wed, 12 Jun 2024 17:30:48 +0200 Subject: [PATCH 29/40] Fix AzureOpenAILLM load method setting the correct path to mock the internal class (#725) --- src/distilabel/llms/azure.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/distilabel/llms/azure.py b/src/distilabel/llms/azure.py index 2f5dad112a..23166744d1 100644 --- a/src/distilabel/llms/azure.py +++ b/src/distilabel/llms/azure.py @@ -134,7 +134,9 @@ def load(self) -> None: """Loads the `AsyncAzureOpenAI` client to benefit from async requests.""" # This is a workaround to avoid the `OpenAILLM` calling the _prepare_structured_output # in the load method before we have the proper client. - with patch("OpenAILLM._prepare_structured_output", lambda x: x): + with patch( + "distilabel.llms.openai.OpenAILLM._prepare_structured_output", lambda x: x + ): super().load() try: From ae6d7fa9ea8f52205210574731752c939f236b0e Mon Sep 17 00:00:00 2001 From: Agus Date: Wed, 12 Jun 2024 18:14:13 +0200 Subject: [PATCH 30/40] Components examples steps (#715) * Update highlight colors to match the alembics elixir * Add examples for the combine step * Add examples of the steps for the components gallery --- docs/stylesheets/extra.css | 14 +- src/distilabel/steps/argilla/preference.py | 56 ++++++++ .../steps/argilla/text_generation.py | 30 +++++ src/distilabel/steps/combine.py | 45 +++++++ src/distilabel/steps/deita.py | 36 ++++++ src/distilabel/steps/expand.py | 25 ++++ .../steps/formatting/conversation.py | 24 ++++ src/distilabel/steps/formatting/dpo.py | 75 +++++++++++ src/distilabel/steps/formatting/sft.py | 65 ++++++++++ src/distilabel/steps/generators/data.py | 18 +++ .../steps/generators/huggingface.py | 121 +++++++++++++++++- src/distilabel/steps/globals/huggingface.py | 24 ++++ src/distilabel/steps/keep.py | 21 +++ 13 files changed, 546 insertions(+), 8 deletions(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 1d487bd5a9..538a4b4776 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,20 +1,20 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..600&display=swap'); :root { - --md-primary-fg-color: #84b0c1; - --md-primary-fg-color--light: #84b0c1; - --md-primary-fg-color--dark: #84b0c1; + --md-primary-fg-color: #f2a8ff; + --md-primary-fg-color--light: #f2a8ff; + --md-primary-fg-color--dark: #f2a8ff; --md-text-font: "Inter"; } [data-md-color-scheme="default"] { --md-primary-fg-color: #000000; - --md-typeset-a-color: #279bc8; - --md-accent-fg-color: #0ba5e1; + --md-typeset-a-color: #9c50c2; + --md-accent-fg-color: #c57fed; } [data-md-color-scheme="slate"] { --md-primary-fg-color: #000000; - --md-typeset-a-color: #66bada; - --md-accent-fg-color: #6cd0f7; + --md-typeset-a-color: #ca77d8; + --md-accent-fg-color: #f2a8ff; } .md-sidebar__scrollwrap:focus-within, .md-sidebar__scrollwrap:hover { diff --git a/src/distilabel/steps/argilla/preference.py b/src/distilabel/steps/argilla/preference.py index 7a1a5f7a15..fa674010b3 100644 --- a/src/distilabel/steps/argilla/preference.py +++ b/src/distilabel/steps/argilla/preference.py @@ -73,6 +73,62 @@ class PreferenceToArgilla(Argilla): generated ratings won't be pushed to Argilla. - rationales (`List[str]`, optional): The rationales for the ratings. If not provided, the generated rationales won't be pushed to Argilla. + + Examples: + + Push a preference dataset to an Argilla instance: + + ```python + from distilabel.steps import PreferenceToArgilla + + to_argilla = PreferenceToArgilla( + num_generations=2, + api_url="https://dibt-demo-argilla-space.hf.space/", + api_key="api.key", + dataset_name="argilla_dataset", + dataset_workspace="my_workspace", + ) + to_argilla.load() + + result = next( + to_argilla.process( + [ + { + "instruction": "instruction", + "generations": ["first_generation", "second_generation"], + } + ], + ) + ) + # >>> result + # [{'instruction': 'instruction', 'generations': ['first_generation', 'second_generation']}] + ``` + + It can also include ratings and rationales: + + ```python + result = next( + to_argilla.process( + [ + { + "instruction": "instruction", + "generations": ["first_generation", "second_generation"], + "ratings": ["4", "5"], + "rationales": ["rationale for 4", "rationale for 5"], + } + ], + ) + ) + # >>> result + # [ + # { + # 'instruction': 'instruction', + # 'generations': ['first_generation', 'second_generation'], + # 'ratings': ['4', '5'], + # 'rationales': ['rationale for 4', 'rationale for 5'] + # } + # ] + ``` """ num_generations: int diff --git a/src/distilabel/steps/argilla/text_generation.py b/src/distilabel/steps/argilla/text_generation.py index 1e259b7e0e..7e60f3b278 100644 --- a/src/distilabel/steps/argilla/text_generation.py +++ b/src/distilabel/steps/argilla/text_generation.py @@ -58,6 +58,36 @@ class TextGenerationToArgilla(Argilla): Input columns: - instruction (`str`): The instruction that was used to generate the completion. - generation (`str` or `List[str]`): The completions that were generated based on the input instruction. + + Examples: + + Push a text generation dataset to an Argilla instance: + + ```python + from distilabel.steps import PreferenceToArgilla + + to_argilla = TextGenerationToArgilla( + num_generations=2, + api_url="https://dibt-demo-argilla-space.hf.space/", + api_key="api.key", + dataset_name="argilla_dataset", + dataset_workspace="my_workspace", + ) + to_argilla.load() + + result = next( + to_argilla.process( + [ + { + "instruction": "instruction", + "generation": "generation", + } + ], + ) + ) + # >>> result + # [{'instruction': 'instruction', 'generation': 'generation'}] + ``` """ _id: str = PrivateAttr(default="id") diff --git a/src/distilabel/steps/combine.py b/src/distilabel/steps/combine.py index cc77d4091e..f0c5b49647 100644 --- a/src/distilabel/steps/combine.py +++ b/src/distilabel/steps/combine.py @@ -41,6 +41,51 @@ class CombineColumns(Step): Output columns: - dynamic (determined by `columns` and `output_columns` attributes): The columns that were merged. + + Examples: + + Combine columns of a dataset: + + ```python + from distilabel.steps import CombineColumns + + combine_columns = CombineColumns( + name="combine_columns", + columns=["generation", "model_name"], + ) + combine_columns.load() + + result = next( + combine_columns.process( + [{"generation": "AI generated text"}, {"model_name": "my_model"}], + [{"generation": "Other generated text", "model_name": "my_model"}] + ) + ) + # >>> result + # [{'merged_generation': ['AI generated text', 'Other generated text'], 'merged_model_name': ['my_model']}] + ``` + + Specify the name of the output columns: + + ```python + from distilabel.steps import CombineColumns + + combine_columns = CombineColumns( + name="combine_columns", + columns=["generation", "model_name"], + output_columns=["generations", "generation_models"] + ) + combine_columns.load() + + result = next( + combine_columns.process( + [{"generation": "AI generated text"}, {"model_name": "my_model"}], + [{"generation": "Other generated text", "model_name": "my_model"}] + ) + ) + # >>> result + #[{'generations': ['AI generated text', 'Other generated text'], 'generation_models': ['my_model']}] + ``` """ columns: List[str] diff --git a/src/distilabel/steps/deita.py b/src/distilabel/steps/deita.py index e1c258f87a..6b08abcbf7 100644 --- a/src/distilabel/steps/deita.py +++ b/src/distilabel/steps/deita.py @@ -64,6 +64,42 @@ class DeitaFiltering(GlobalStep): References: - [`What Makes Good Data for Alignment? A Comprehensive Study of Automatic Data Selection in Instruction Tuning`](https://arxiv.org/abs/2312.15685) + + Examples: + + Filter the dataset based on the DEITA score and the cosine distance between the embeddings: + + ```python + from distilabel.steps import DeitaFiltering + + deita_filtering = DeitaFiltering(data_budget=1) + + deita_filtering.load() + + result = next( + deita_filtering.process( + [ + { + "evol_instruction_score": 0.5, + "evol_response_score": 0.5, + "embedding": [-8.12729941, -5.24642847, -6.34003029], + }, + { + "evol_instruction_score": 0.6, + "evol_response_score": 0.6, + "embedding": [2.99329242, 0.7800932, 0.7799726], + }, + { + "evol_instruction_score": 0.7, + "evol_response_score": 0.7, + "embedding": [10.29041806, 14.33088073, 13.00557506], + }, + ], + ) + ) + # >>> result + # [{'evol_instruction_score': 0.5, 'evol_response_score': 0.5, 'embedding': [-8.12729941, -5.24642847, -6.34003029], 'deita_score': 0.25, 'deita_score_computed_with': ['evol_instruction_score', 'evol_response_score'], 'nearest_neighbor_distance': 1.9042812683723933}] + ``` """ data_budget: RuntimeParameter[int] = Field( diff --git a/src/distilabel/steps/expand.py b/src/distilabel/steps/expand.py index 3286d3c305..7312e1a4fd 100644 --- a/src/distilabel/steps/expand.py +++ b/src/distilabel/steps/expand.py @@ -41,6 +41,31 @@ class ExpandColumns(Step): Output columns: - dynamic (determined by `columns` attribute): The expanded columns. + + Examples: + + Expand the selected columns into multiple rows: + + ```python + from distilabel.steps import ExpandColumns + + expand_columns = ExpandColumns( + columns=["generation"], + ) + expand_columns.load() + + result = next( + expand_columns.process( + [ + { + "instruction": "instruction 1", + "generation": ["generation 1", "generation 2"]} + ], + ) + ) + # >>> result + # [{'instruction': 'instruction 1', 'generation': 'generation 1'}, {'instruction': 'instruction 1', 'generation': 'generation 2'}] + ``` """ columns: Union[Dict[str, str], List[str]] diff --git a/src/distilabel/steps/formatting/conversation.py b/src/distilabel/steps/formatting/conversation.py index 43d62de527..22cc582c54 100644 --- a/src/distilabel/steps/formatting/conversation.py +++ b/src/distilabel/steps/formatting/conversation.py @@ -34,6 +34,30 @@ class ConversationTemplate(Step): - format - chat - template + + Examples: + + Create a conversation from an instruction and a response: + + ```python + from distilabel.steps import ConversationTemplate + + conv_template = ConversationTemplate() + conv_template.load() + + result = next( + conv_template.process( + [ + { + "instruction": "Hello", + "response": "Hi", + } + ], + ) + ) + # >>> result + # [{'instruction': 'Hello', 'response': 'Hi', 'conversation': [{'role': 'user', 'content': 'Hello'}, {'role': 'assistant', 'content': 'Hi'}]}] + ``` """ @property diff --git a/src/distilabel/steps/formatting/dpo.py b/src/distilabel/steps/formatting/dpo.py index 72c4b1e440..9402436ee9 100644 --- a/src/distilabel/steps/formatting/dpo.py +++ b/src/distilabel/steps/formatting/dpo.py @@ -63,6 +63,43 @@ class FormatTextGenerationDPO(Step): - preference - instruction - generations + + Examples: + + Format your dataset for DPO fine tuning: + + ```python + from distilabel.steps import FormatTextGenerationDPO + + format_dpo = FormatTextGenerationDPO() + format_dpo.load() + + # NOTE: Both "system_prompt" and "generation_models" can be added optionally. + result = next( + format_dpo.process( + [ + { + "instruction": "What's 2+2?", + "generations": ["4", "5", "6"], + "ratings": [1, 0, -1], + } + ] + ) + ) + # >>> result + # [ + # { 'instruction': "What's 2+2?", + # 'generations': ['4', '5', '6'], + # 'ratings': [1, 0, -1], + # 'prompt': "What's 2+2?", + # 'prompt_id': '7762ecf17ad41479767061a8f4a7bfa3b63d371672af5180872f9b82b4cd4e29', + # 'chosen': [{'role': 'user', 'content': "What's 2+2?"}, {'role': 'assistant', 'content': '4'}], + # 'chosen_rating': 1, + # 'rejected': [{'role': 'user', 'content': "What's 2+2?"}, {'role': 'assistant', 'content': '6'}], + # 'rejected_rating': -1 + # } + # ] + ``` """ @property @@ -194,6 +231,44 @@ class FormatChatGenerationDPO(Step): - preference - messages - generations + + Examples: + + Format your dataset for DPO fine tuning: + + ```python + from distilabel.steps import FormatChatGenerationDPO + + format_dpo = FormatChatGenerationDPO() + format_dpo.load() + + # NOTE: "generation_models" can be added optionally. + result = next( + format_dpo.process( + [ + { + "messages": [{"role": "user", "content": "What's 2+2?"}], + "generations": ["4", "5", "6"], + "ratings": [1, 0, -1], + } + ] + ) + ) + # >>> result + # [ + # { + # 'messages': [{'role': 'user', 'content': "What's 2+2?"}], + # 'generations': ['4', '5', '6'], + # 'ratings': [1, 0, -1], + # 'prompt': "What's 2+2?", + # 'prompt_id': '7762ecf17ad41479767061a8f4a7bfa3b63d371672af5180872f9b82b4cd4e29', + # 'chosen': [{'role': 'user', 'content': "What's 2+2?"}, {'role': 'assistant', 'content': '4'}], + # 'chosen_rating': 1, + # 'rejected': [{'role': 'user', 'content': "What's 2+2?"}, {'role': 'assistant', 'content': '6'}], + # 'rejected_rating': -1 + # } + # ] + ``` """ @property diff --git a/src/distilabel/steps/formatting/sft.py b/src/distilabel/steps/formatting/sft.py index 0c6f51e2a9..ec93aadf79 100644 --- a/src/distilabel/steps/formatting/sft.py +++ b/src/distilabel/steps/formatting/sft.py @@ -48,6 +48,39 @@ class FormatTextGenerationSFT(Step): - text-generation - instruction - generation + + Examples: + + Format your dataset for SFT fine tuning: + + ```python + from distilabel.steps import FormatTextGenerationSFT + + format_sft = FormatTextGenerationSFT() + format_sft.load() + + # NOTE: "system_prompt" can be added optionally. + result = next( + format_sft.process( + [ + { + "instruction": "What's 2+2?", + "generation": "4" + } + ] + ) + ) + # >>> result + # [ + # { + # 'instruction': 'What's 2+2?', + # 'generation': '4', + # 'prompt': 'What's 2+2?', + # 'prompt_id': '7762ecf17ad41479767061a8f4a7bfa3b63d371672af5180872f9b82b4cd4e29', + # 'messages': [{'role': 'user', 'content': "What's 2+2?"}, {'role': 'assistant', 'content': '4'}] + # } + # ] + ``` """ @property @@ -133,6 +166,38 @@ class FormatChatGenerationSFT(Step): - chat-generation - instruction - generation + + Examples: + + Format your dataset for Supervised Fine Tuning (SFT): + + ```python + from distilabel.steps import FormatChatGenerationSFT + + format_sft = FormatChatGenerationSFT() + format_sft.load() + + # NOTE: "system_prompt" can be added optionally. + result = next( + format_sft.process( + [ + { + "messages": [{"role": "user", "content": "What's 2+2?"}], + "generation": "4" + } + ] + ) + ) + # >>> result + # [ + # { + # 'messages': [{'role': 'user', 'content': "What's 2+2?"}, {'role': 'assistant', 'content': '4'}], + # 'generation': '4', + # 'prompt': 'What's 2+2?', + # 'prompt_id': '7762ecf17ad41479767061a8f4a7bfa3b63d371672af5180872f9b82b4cd4e29', + # } + # ] + ``` """ @property diff --git a/src/distilabel/steps/generators/data.py b/src/distilabel/steps/generators/data.py index e51791006d..fbf29ec7fe 100644 --- a/src/distilabel/steps/generators/data.py +++ b/src/distilabel/steps/generators/data.py @@ -40,6 +40,24 @@ class LoadDataFromDicts(GeneratorStep): Categories: - load + + Examples: + + Load data from a list of dictionaries: + + ```python + from distilabel.steps import LoadDataFromDicts + + loader = LoadDataFromDicts( + data=[{"instruction": "What are 2+2?"}] * 5, + batch_size=2 + ) + loader.load() + + result = next(loader.process()) + # >>> result + # ([{'instruction': 'What are 2+2?'}, {'instruction': 'What are 2+2?'}], False) + ``` """ data: List[Dict[str, Any]] diff --git a/src/distilabel/steps/generators/huggingface.py b/src/distilabel/steps/generators/huggingface.py index f07084f0b5..04f8b7c312 100644 --- a/src/distilabel/steps/generators/huggingface.py +++ b/src/distilabel/steps/generators/huggingface.py @@ -78,6 +78,26 @@ class LoadDataFromHub(GeneratorStep): Categories: - load + + Examples: + + Load data from a dataset in Hugging Face Hub: + + ```python + from distilabel.steps import LoadDataFromHub + + loader = LoadDataFromHub( + repo_id="distilabel-internal-testing/instruction-dataset-mini", + split="test", + batch_size=2 + ) + loader.load() + + # Just like we saw with LoadDataFromDicts, the `process` method will yield batches. + result = next(loader.process()) + # >>> result + # ([{'prompt': 'Arianna has 12...', False) + ``` """ repo_id: RuntimeParameter[str] = Field( @@ -264,6 +284,53 @@ class LoadDataFromFileSystem(LoadDataFromHub): Categories: - load + + Examples: + + Load data from a Hugging Face dataset in your file system: + + ```python + from distilabel.steps import LoadDataFromFileSystem + + loader = LoadDataFromFileSystem(data_files="path/to/dataset.jsonl") + loader.load() + + # Just like we saw with LoadDataFromDicts, the `process` method will yield batches. + result = next(loader.process()) + # >>> result + # ([{'type': 'function', 'function':...', False) + ``` + + Specify a filetype if the file extension is not expected: + + ```python + from distilabel.steps import LoadDataFromFileSystem + + loader = LoadDataFromFileSystem(filetype="csv", data_files="path/to/dataset.txtr") + loader.load() + + # Just like we saw with LoadDataFromDicts, the `process` method will yield batches. + result = next(loader.process()) + # >>> result + # ([{'type': 'function', 'function':...', False) + ``` + + Load data from a file in your cloud provider: + + ```python + from distilabel.steps import LoadDataFromFileSystem + + loader = LoadDataFromFileSystem( + data_files="gcs://path/to/dataset", + storage_options={"project": "experiments-0001"} + ) + loader.load() + + # Just like we saw with LoadDataFromDicts, the `process` method will yield batches. + result = next(loader.process()) + # >>> result + # ([{'type': 'function', 'function':...', False) + ``` """ data_files: RuntimeParameter[Union[str, Path]] = Field( @@ -393,11 +460,63 @@ class LoadDataFromDisk(LoadDataFromHub): Categories: - load + + Examples: + + Load data from a Hugging Face Dataset: + + ```python + from distilabel.steps import LoadDataFromDisk + + loader = LoadDataFromDisk(dataset_path="path/to/dataset") + loader.load() + + # Just like we saw with LoadDataFromDicts, the `process` method will yield batches. + result = next(loader.process()) + # >>> result + # ([{'type': 'function', 'function':...', False) + ``` + + Load data from a distilabel Distiset: + + ```python + from distilabel.steps import LoadDataFromDisk + + # Specify the configuration to load. + loader = LoadDataFromDisk( + dataset_path="path/to/dataset", + is_distiset=True, + config="leaf_step_1" + ) + loader.load() + + # Just like we saw with LoadDataFromDicts, the `process` method will yield batches. + result = next(loader.process()) + # >>> result + # ([{'a': 1}, {'a': 2}, {'a': 3}], True) + ``` + + Load data from a Hugging Face Dataset or Distiset in your cloud provider: + + ```python + from distilabel.steps import LoadDataFromDisk + + loader = LoadDataFromDisk( + dataset_path="gcs://path/to/dataset", + storage_options={"project": "experiments-0001"} + ) + loader.load() + + # Just like we saw with LoadDataFromDicts, the `process` method will yield batches. + result = next(loader.process()) + # >>> result + # ([{'type': 'function', 'function':...', False) + ``` """ dataset_path: RuntimeParameter[Union[str, Path]] = Field( default=None, - description="_summary_", + description="Path to the dataset or distiset.", ) config: RuntimeParameter[str] = Field( default=None, diff --git a/src/distilabel/steps/globals/huggingface.py b/src/distilabel/steps/globals/huggingface.py index c5b95a3b7d..28ef3932bd 100644 --- a/src/distilabel/steps/globals/huggingface.py +++ b/src/distilabel/steps/globals/huggingface.py @@ -56,6 +56,30 @@ class PushToHub(GlobalStep): - save - dataset - huggingface + + Examples: + + Push batches of your dataset to the Hugging Face Hub repository: + + ```python + from distilabel.steps import PushToHub + + push = PushToHub(repo_id="path_to/repo") + push.load() + + result = next( + push.process( + [ + { + "instruction": "instruction ", + "generation": "generation" + } + ], + ) + ) + # >>> result + # [{'instruction': 'instruction ', 'generation': 'generation'}] + ``` """ repo_id: RuntimeParameter[str] = Field( diff --git a/src/distilabel/steps/keep.py b/src/distilabel/steps/keep.py index 1f6f9bab88..58380660fa 100644 --- a/src/distilabel/steps/keep.py +++ b/src/distilabel/steps/keep.py @@ -43,6 +43,27 @@ class KeepColumns(Step): Output columns: - dynamic (determined by `columns` attribute): The columns that were kept. + + Examples: + + Select the columns to keep: + + ```python + from distilabel.steps import KeepColumns + + keep_columns = KeepColumns( + columns=["instruction", "generation"], + ) + keep_columns.load() + + result = next( + keep_columns.process( + [{"instruction": "What's the brightest color?", "generation": "white", "model_name": "my_model"}], + ) + ) + # >>> result + # [{'instruction': "What's the brightest color?", 'generation': 'white'}] + ``` """ columns: List[str] From ce8dde8c4c481190694649b81e056256e6a34194 Mon Sep 17 00:00:00 2001 From: Agus Date: Wed, 12 Jun 2024 18:14:39 +0200 Subject: [PATCH 31/40] Add basic examples for tasks to show in the components gallery (#724) --- .../steps/tasks/complexity_scorer.py | 26 +++ .../steps/tasks/evol_instruct/base.py | 80 +++++++++ .../evol_instruct/evol_complexity/base.py | 25 ++- .../evol_complexity/generator.py | 23 +++ .../steps/tasks/evol_instruct/generator.py | 23 +++ .../steps/tasks/evol_quality/base.py | 36 ++++ .../steps/tasks/generate_embeddings.py | 27 +++ src/distilabel/steps/tasks/genstruct.py | 36 ++++ src/distilabel/steps/tasks/pair_rm.py | 31 ++++ src/distilabel/steps/tasks/prometheus_eval.py | 161 +++++++++++++++++- src/distilabel/steps/tasks/quality_scorer.py | 37 ++++ src/distilabel/steps/tasks/self_instruct.py | 28 +++ .../steps/tasks/structured_generation.py | 85 ++++++++- src/distilabel/steps/tasks/text_generation.py | 65 ++++++- src/distilabel/steps/tasks/ultrafeedback.py | 39 +++++ 15 files changed, 718 insertions(+), 4 deletions(-) diff --git a/src/distilabel/steps/tasks/complexity_scorer.py b/src/distilabel/steps/tasks/complexity_scorer.py index 758aaf05d6..b20909383a 100644 --- a/src/distilabel/steps/tasks/complexity_scorer.py +++ b/src/distilabel/steps/tasks/complexity_scorer.py @@ -59,6 +59,32 @@ class ComplexityScorer(Task): References: - [`What Makes Good Data for Alignment? A Comprehensive Study of Automatic Data Selection in Instruction Tuning`](https://arxiv.org/abs/2312.15685) + + Examples: + + Evaluate the complexity of your instructions: + + ```python + from distilabel.steps.tasks import ComplexityScorer + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + scorer = ComplexityScorer( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ) + ) + + scorer.load() + + result = next( + scorer.process( + [{"instructions": ["plain instruction", "highly complex instruction"]}] + ) + ) + # result + # [{'instructions': ['plain instruction', 'highly complex instruction'], 'model_name': 'test', 'scores': [1, 5], 'distilabel_metadata': {'raw_output_complexity_scorer_0': 'output'}}] + ``` """ _template: Union[Template, None] = PrivateAttr(...) diff --git a/src/distilabel/steps/tasks/evol_instruct/base.py b/src/distilabel/steps/tasks/evol_instruct/base.py index b7c4e2f65a..e0071bf5d9 100644 --- a/src/distilabel/steps/tasks/evol_instruct/base.py +++ b/src/distilabel/steps/tasks/evol_instruct/base.py @@ -69,6 +69,86 @@ class EvolInstruct(Task): References: - [WizardLM: Empowering Large Language Models to Follow Complex Instructions](https://arxiv.org/abs/2304.12244) - [GitHub: h2oai/h2o-wizardlm](https://github.com/h2oai/h2o-wizardlm) + + Examples: + + Evolve an instruction using an LLM: + + ```python + from distilabel.steps.tasks import EvolInstruct + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + evol_instruct = EvolInstruct( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ), + num_evolutions=2, + ) + + evol_instruct.load() + + result = next(evol_instruct.process([{"instruction": "common instruction"}])) + # result + # [{'instruction': 'common instruction', 'evolved_instruction': 'evolved instruction', 'model_name': 'model_name'}] + ``` + + Keep the iterations of the evolutions: + + ```python + from distilabel.steps.tasks import EvolInstruct + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + evol_instruct = EvolInstruct( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ), + num_evolutions=2, + store_evolutions=True, + ) + + evol_instruct.load() + + result = next(evol_instruct.process([{"instruction": "common instruction"}])) + # result + # [ + # { + # 'instruction': 'common instruction', + # 'evolved_instructions': ['initial evolution', 'final evolution'], + # 'model_name': 'model_name' + # } + # ] + ``` + + Generate answers for the instructions in a single step: + + ```python + from distilabel.steps.tasks import EvolInstruct + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + evol_instruct = EvolInstruct( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ), + num_evolutions=2, + generate_answers=True, + ) + + evol_instruct.load() + + result = next(evol_instruct.process([{"instruction": "common instruction"}])) + # result + # [ + # { + # 'instruction': 'common instruction', + # 'evolved_instruction': 'evolved instruction', + # 'answer': 'answer to the instruction', + # 'model_name': 'model_name' + # } + # ] + ``` """ num_evolutions: int diff --git a/src/distilabel/steps/tasks/evol_instruct/evol_complexity/base.py b/src/distilabel/steps/tasks/evol_instruct/evol_complexity/base.py index 75161eb70c..c07f49d621 100644 --- a/src/distilabel/steps/tasks/evol_instruct/evol_complexity/base.py +++ b/src/distilabel/steps/tasks/evol_instruct/evol_complexity/base.py @@ -24,7 +24,7 @@ class EvolComplexity(EvolInstruct): """Evolve instructions to make them more complex using an `LLM`. `EvolComplexity` is a task that evolves instructions to make them more complex, - and it is based in the EvolInstruct task, but using slight different prompts, but the + and it is based in the EvolInstruct task, using slight different prompts, but the exact same evolutionary approach. Attributes: @@ -61,6 +61,29 @@ class EvolComplexity(EvolInstruct): References: - [What Makes Good Data for Alignment? A Comprehensive Study of Automatic Data Selection in Instruction Tuning](https://arxiv.org/abs/2312.15685) - [WizardLM: Empowering Large Language Models to Follow Complex Instructions](https://arxiv.org/abs/2304.12244) + + Examples: + + Evolve an instruction using an LLM: + + ```python + from distilabel.steps.tasks import EvolComplexity + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + evol_complexity = EvolComplexity( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ), + num_evolutions=2, + ) + + evol_complexity.load() + + result = next(evol_complexity.process([{"instruction": "common instruction"}])) + # result + # [{'instruction': 'common instruction', 'evolved_instruction': 'evolved instruction', 'model_name': 'model_name'}] + ``` """ mutation_templates: Dict[str, str] = MUTATION_TEMPLATES diff --git a/src/distilabel/steps/tasks/evol_instruct/evol_complexity/generator.py b/src/distilabel/steps/tasks/evol_instruct/evol_complexity/generator.py index 8fc1f35db8..c4cd051190 100644 --- a/src/distilabel/steps/tasks/evol_instruct/evol_complexity/generator.py +++ b/src/distilabel/steps/tasks/evol_instruct/evol_complexity/generator.py @@ -59,6 +59,29 @@ class EvolComplexityGenerator(EvolInstructGenerator): References: - [What Makes Good Data for Alignment? A Comprehensive Study of Automatic Data Selection in Instruction Tuning](https://arxiv.org/abs/2312.15685) - [WizardLM: Empowering Large Language Models to Follow Complex Instructions](https://arxiv.org/abs/2304.12244) + + Examples: + + Generate evolved instructions without initial instructions: + + ```python + from distilabel.steps.tasks import EvolComplexityGenerator + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + evol_complexity_generator = EvolComplexityGenerator( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ), + num_instructions=2, + ) + + evol_complexity_generator.load() + + result = next(scorer.process()) + # result + # [{'instruction': 'generated instruction', 'model_name': 'test'}] + ``` """ mutation_templates: Dict[str, str] = GENERATION_MUTATION_TEMPLATES diff --git a/src/distilabel/steps/tasks/evol_instruct/generator.py b/src/distilabel/steps/tasks/evol_instruct/generator.py index 8d4adc38b5..bc8c5d2eff 100644 --- a/src/distilabel/steps/tasks/evol_instruct/generator.py +++ b/src/distilabel/steps/tasks/evol_instruct/generator.py @@ -75,6 +75,29 @@ class EvolInstructGenerator(GeneratorTask): References: - [WizardLM: Empowering Large Language Models to Follow Complex Instructions](https://arxiv.org/abs/2304.12244) - [GitHub: h2oai/h2o-wizardlm](https://github.com/h2oai/h2o-wizardlm) + + Examples: + + Generate evolved instructions without initial instructions: + + ```python + from distilabel.steps.tasks import EvolInstructGenerator + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + evol_instruct_generator = EvolInstructGenerator( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ), + num_instructions=2, + ) + + evol_instruct_generator.load() + + result = next(scorer.process()) + # result + # [{'instruction': 'generated instruction', 'model_name': 'test'}] + ``` """ num_instructions: int diff --git a/src/distilabel/steps/tasks/evol_quality/base.py b/src/distilabel/steps/tasks/evol_quality/base.py index 0b50e568cc..b245a879ff 100644 --- a/src/distilabel/steps/tasks/evol_quality/base.py +++ b/src/distilabel/steps/tasks/evol_quality/base.py @@ -65,6 +65,42 @@ class EvolQuality(Task): References: - [`What Makes Good Data for Alignment? A Comprehensive Study of Automatic Data Selection in Instruction Tuning`](https://arxiv.org/abs/2312.15685) + + Examples: + + Evolve the quality of the responses given a prompt: + + ```python + from distilabel.steps.tasks import EvolQuality + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + evol_quality = EvolQuality( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ), + num_evolutions=2, + ) + + evol_quality.load() + + result = next( + evol_quality.process( + [ + {"instruction": "common instruction", "response": "a response"}, + ] + ) + ) + # result + # [ + # { + # 'instruction': 'common instruction', + # 'response': 'a response', + # 'evolved_response': 'evolved response', + # 'model_name': '"mistralai/Mistral-7B-Instruct-v0.2"' + # } + # ] + ``` """ num_evolutions: int diff --git a/src/distilabel/steps/tasks/generate_embeddings.py b/src/distilabel/steps/tasks/generate_embeddings.py index 39c17f016e..1b0df634c6 100644 --- a/src/distilabel/steps/tasks/generate_embeddings.py +++ b/src/distilabel/steps/tasks/generate_embeddings.py @@ -47,6 +47,33 @@ class GenerateEmbeddings(Step): References: - [What Makes Good Data for Alignment? A Comprehensive Study of Automatic Data Selection in Instruction Tuning](https://arxiv.org/abs/2312.15685) + + Examples: + + Rank LLM candidates: + + ```python + from distilabel.steps.tasks import GenerateEmbeddings + from distilabel.llms.huggingface import TransformersLLM + + # Consider this as a placeholder for your actual LLM. + embedder = GenerateEmbeddings( + llm=TransformersLLM( + model="TaylorAI/bge-micro-v2", + model_kwargs={"is_decoder": True}, + cuda_devices=[], + ) + ) + embedder.load() + + result = next( + embedder.process( + [ + {"text": "Hello, how are you?"}, + ] + ) + ) + ``` """ llm: LLM diff --git a/src/distilabel/steps/tasks/genstruct.py b/src/distilabel/steps/tasks/genstruct.py index 550e1220d5..1e80fcb429 100644 --- a/src/distilabel/steps/tasks/genstruct.py +++ b/src/distilabel/steps/tasks/genstruct.py @@ -67,6 +67,42 @@ class Genstruct(Task): References: - [Genstruct 7B by Nous Research](https://huggingface.co/NousResearch/Genstruct-7B) - [Ada-Instruct: Adapting Instruction Generators for Complex Reasoning](https://arxiv.org/abs/2310.04484) + + Examples: + + Generate instructions from raw documents using the title and content: + + ```python + from distilabel.steps.tasks import Genstruct + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + genstruct = Genstruct( + llm=InferenceEndpointsLLM( + model_id="NousResearch/Genstruct-7B", + ), + ) + + genstruct.load() + + result = next( + genstruct.process( + [ + {"title": "common instruction", "content": "content of the document"}, + ] + ) + ) + # result + # [ + # { + # 'title': 'An instruction', + # 'content': 'content of the document', + # 'model_name': 'test', + # 'user': 'An instruction', + # 'assistant': 'content of the document', + # } + # ] + ``` """ _template: Union[Template, None] = PrivateAttr(...) diff --git a/src/distilabel/steps/tasks/pair_rm.py b/src/distilabel/steps/tasks/pair_rm.py index 3c1ecc7a56..be23a38699 100644 --- a/src/distilabel/steps/tasks/pair_rm.py +++ b/src/distilabel/steps/tasks/pair_rm.py @@ -49,6 +49,37 @@ class PairRM(Step): Note: This step differs to other tasks as there is a single implementation of this model currently, and we will use a specific `LLM`. + + Examples: + + Rank LLM candidates: + + ```python + from distilabel.steps.tasks import PairRM + + # Consider this as a placeholder for your actual LLM. + pair_rm = PairRM() + + pair_rm.load() + + result = next( + scorer.process( + [ + {"input": "Hello, how are you?", "candidates": ["fine", "good", "bad"]}, + ] + ) + ) + # result + # [ + # { + # 'input': 'Hello, how are you?', + # 'candidates': ['fine', 'good', 'bad'], + # 'ranks': [2, 1, 3], + # 'ranked_candidates': ['good', 'fine', 'bad'], + # 'model_name': 'llm-blender/PairRM', + # } + # ] + ``` """ model: str = "llm-blender/PairRM" diff --git a/src/distilabel/steps/tasks/prometheus_eval.py b/src/distilabel/steps/tasks/prometheus_eval.py index 0edde308df..4b03c3932f 100644 --- a/src/distilabel/steps/tasks/prometheus_eval.py +++ b/src/distilabel/steps/tasks/prometheus_eval.py @@ -135,6 +135,165 @@ class PrometheusEval(Task): References: - [Prometheus 2: An Open Source Language Model Specialized in Evaluating Other Language Models](https://arxiv.org/abs/2405.01535) - [prometheus-eval: Evaluate your LLM's response with Prometheus 💯](https://github.com/prometheus-eval/prometheus-eval) + + Examples: + + Critique and evaluate LLM generation quality using Prometheus 2.0: + + ```python + from distilabel.steps.tasks import PrometheusEval + from distilabel.llms import vLLM + + # Consider this as a placeholder for your actual LLM. + prometheus = PrometheusEval( + llm=vLLM( + model="prometheus-eval/prometheus-7b-v2.0", + chat_template="[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]", + ), + mode="absolute", + rubric="factual-validity" + ) + + prometheus.load() + + result = next( + prometheus.process( + [ + {"instruction": "make something", "generation": "something done"}, + ] + ) + ) + # result + # [ + # { + # 'instruction': 'make something', + # 'generation': 'something done', + # 'model_name': 'prometheus-eval/prometheus-7b-v2.0', + # 'feedback': 'the feedback', + # 'result': 6, + # } + # ] + ``` + + Critique for relative evaluation: + + ```python + from distilabel.steps.tasks import PrometheusEval + from distilabel.llms import vLLM + + # Consider this as a placeholder for your actual LLM. + prometheus = PrometheusEval( + llm=vLLM( + model="prometheus-eval/prometheus-7b-v2.0", + chat_template="[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]", + ), + mode="relative", + rubric="honesty" + ) + + prometheus.load() + + result = next( + prometheus.process( + [ + {"instruction": "make something", "generations": ["something done", "other thing"]}, + ] + ) + ) + # result + # [ + # { + # 'instruction': 'make something', + # 'generations': ['something done', 'other thing'], + # 'model_name': 'prometheus-eval/prometheus-7b-v2.0', + # 'feedback': 'the feedback', + # 'result': 'something done', + # } + # ] + ``` + + Critique with a custom rubric: + + ```python + from distilabel.steps.tasks import PrometheusEval + from distilabel.llms import vLLM + + # Consider this as a placeholder for your actual LLM. + prometheus = PrometheusEval( + llm=vLLM( + model="prometheus-eval/prometheus-7b-v2.0", + chat_template="[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]", + ), + mode="absolute", + rubric="custom", + rubrics={ + "custom": "[A]\nScore 1: A\nScore 2: B\nScore 3: C\nScore 4: D\nScore 5: E" + } + ) + + prometheus.load() + + result = next( + prometheus.process( + [ + {"instruction": "make something", "generation": "something done"}, + ] + ) + ) + # result + # [ + # { + # 'instruction': 'make something', + # 'generation': 'something done', + # 'model_name': 'prometheus-eval/prometheus-7b-v2.0', + # 'feedback': 'the feedback', + # 'result': 6, + # } + # ] + ``` + + Critique using a reference answer: + + ```python + from distilabel.steps.tasks import PrometheusEval + from distilabel.llms import vLLM + + # Consider this as a placeholder for your actual LLM. + prometheus = PrometheusEval( + llm=vLLM( + model="prometheus-eval/prometheus-7b-v2.0", + chat_template="[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]", + ), + mode="absolute", + rubric="helpfulness", + reference=True, + ) + + prometheus.load() + + result = next( + prometheus.process( + [ + { + "instruction": "make something", + "generation": "something done", + "reference": "this is a reference answer", + }, + ] + ) + ) + # result + # [ + # { + # 'instruction': 'make something', + # 'generation': 'something done', + # 'reference': 'this is a reference answer', + # 'model_name': 'prometheus-eval/prometheus-7b-v2.0', + # 'feedback': 'the feedback', + # 'result': 6, + # } + # ] + ``` """ mode: Literal["absolute", "relative"] @@ -202,7 +361,7 @@ def inputs(self) -> List[str]: if self.reference: return ["instruction", "generation", "reference"] return ["instruction", "generation"] - else: # self.mode == "relative" + else: if self.reference: return ["instruction", "generations", "reference"] return ["instruction", "generations"] diff --git a/src/distilabel/steps/tasks/quality_scorer.py b/src/distilabel/steps/tasks/quality_scorer.py index f805c91e38..a93c2a399a 100644 --- a/src/distilabel/steps/tasks/quality_scorer.py +++ b/src/distilabel/steps/tasks/quality_scorer.py @@ -59,6 +59,43 @@ class QualityScorer(Task): References: - [`What Makes Good Data for Alignment? A Comprehensive Study of Automatic Data Selection in Instruction Tuning`](https://arxiv.org/abs/2312.15685) + + Examples: + + Evaluate the quality of your instructions: + + ```python + from distilabel.steps.tasks import QualityScorer + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + scorer = QualityScorer( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ) + ) + + scorer.load() + + result = next( + scorer.process( + [ + { + "instruction": "instruction", + "responses": ["good response", "weird response", "bad response"] + } + ] + ) + ) + # result + [ + { + 'instructions': 'instruction', + 'model_name': 'test', + 'scores': [5, 3, 1], + } + ] + ``` """ _template: Union[Template, None] = PrivateAttr(...) diff --git a/src/distilabel/steps/tasks/self_instruct.py b/src/distilabel/steps/tasks/self_instruct.py index 6bb673b8cf..34d3ffee06 100644 --- a/src/distilabel/steps/tasks/self_instruct.py +++ b/src/distilabel/steps/tasks/self_instruct.py @@ -60,6 +60,34 @@ class SelfInstruct(Task): Reference: - [`Self-Instruct: Aligning Language Models with Self-Generated Instructions`](https://arxiv.org/abs/2212.10560) + + Examples: + + Generate instructions based on a given input: + + ```python + from distilabel.steps.tasks import SelfInstruct + from distilabel.llms.huggingface import InferenceEndpointsLLM + + self_instruct = SelfInstruct( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ), + num_instructions=5, # This is the default value + ) + + self_instruct.load() + + result = next(self_instruct.process([{"input": "instruction"}])) + # result + # [ + # { + # 'input': 'instruction', + # 'model_name': 'mistralai/Mistral-7B-Instruct-v0.2', + # 'instructions': ["instruction 1", "instruction 2", "instruction 3", "instruction 4", "instruction 5"], + # } + # ] + ``` """ num_instructions: int = 5 diff --git a/src/distilabel/steps/tasks/structured_generation.py b/src/distilabel/steps/tasks/structured_generation.py index ca43f9beba..6171c0bc83 100644 --- a/src/distilabel/steps/tasks/structured_generation.py +++ b/src/distilabel/steps/tasks/structured_generation.py @@ -47,10 +47,93 @@ class StructuredGeneration(Task): - structured-generation Examples: + + Generate structured output from a JSON schema: + ```python from distilabel.steps.tasks import StructuredGeneration + from distilabel.llms import InferenceEndpointsLLM + + structured_gen = StructuredGeneration( + llm=InferenceEndpointsLLM( + model_id="meta-llama/Meta-Llama-3-70B-Instruct", + tokenizer_id="meta-llama/Meta-Llama-3-70B-Instruct", + ), + ) + + structured_gen.load() + + result = next( + structured_gen.process( + [ + { + "instruction": "Create an RPG character", + "structured_output": { + "type": "json", + "value": { + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "role": { + "title": "Role", + "type": "string" + }, + "weapon": { + "title": "Weapon", + "type": "string" + } + }, + "required": [ + "name", + "description", + "role", + "weapon" + ], + "title": "Character", + "type": "object" + } + }, + } + ] + ) + ) + ``` - task = StructuredGeneration(llm=LLM(...)) + Generate structured output from a regex pattern: + + ```python + from distilabel.steps.tasks import StructuredGeneration + from distilabel.llms import InferenceEndpointsLLM + + structured_gen = StructuredGeneration( + llm=InferenceEndpointsLLM( + model_id="meta-llama/Meta-Llama-3-70B-Instruct", + tokenizer_id="meta-llama/Meta-Llama-3-70B-Instruct", + ), + ) + + structured_gen.load() + + result = next( + structured_gen.process( + [ + { + "instruction": "What's the weather like today in Seattle in Celsius degrees?", + "structured_output": { + "type": "regex", + "value": r"(\\d{1,2})°C" + }, + + } + ] + ) + ) ``` """ diff --git a/src/distilabel/steps/tasks/text_generation.py b/src/distilabel/steps/tasks/text_generation.py index 3a3166b447..3f5b17a8fb 100644 --- a/src/distilabel/steps/tasks/text_generation.py +++ b/src/distilabel/steps/tasks/text_generation.py @@ -43,10 +43,35 @@ class TextGeneration(Task): - text-generation Examples: + + Generate text from an instruction: + ```python from distilabel.steps.tasks import TextGeneration + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + text_gen = TextGeneration( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ) + ) - task = TextGeneration(llm=LLM(...)) + text_gen.load() + + result = next( + text_gen.process( + [{"instruction": "your instruction"}] + ) + ) + # result + # [ + # { + # 'instruction': 'your instruction', + # 'model_name': 'mistralai/Mistral-7B-Instruct-v0.2', + # 'generation': 'generation', + # } + # ] ``` """ @@ -119,6 +144,44 @@ class ChatGeneration(Task): Icon: `:material-chat:` + + Examples: + + Generate text from a conversation in OpenAI chat format: + + ```python + from distilabel.steps.tasks import ChatGeneration + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + chat = ChatGeneration( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ) + ) + + chat.load() + + result = next( + chat.process( + [ + { + "messages": [ + {"role": "user", "content": "How much is 2+2?"}, + ] + } + ] + ) + ) + # result + # [ + # { + # 'messages': [{'role': 'user', 'content': 'How much is 2+2?'}], + # 'model_name': 'mistralai/Mistral-7B-Instruct-v0.2', + # 'generation': '4', + # } + # ] + ``` """ @property diff --git a/src/distilabel/steps/tasks/ultrafeedback.py b/src/distilabel/steps/tasks/ultrafeedback.py index 9b200ddafd..c6cd95482c 100644 --- a/src/distilabel/steps/tasks/ultrafeedback.py +++ b/src/distilabel/steps/tasks/ultrafeedback.py @@ -60,6 +60,45 @@ class UltraFeedback(Task): References: - [`UltraFeedback: Boosting Language Models with High-quality Feedback`](https://arxiv.org/abs/2310.01377) - [`UltraFeedback - GitHub Repository`](https://github.com/OpenBMB/UltraFeedback) + + Examples: + + Rate generations from different LLMs based on the selected aspect: + + ```python + from distilabel.steps.tasks import UltraFeedback + from distilabel.llms.huggingface import InferenceEndpointsLLM + + # Consider this as a placeholder for your actual LLM. + ultrafeedback = UltraFeedback( + llm=InferenceEndpointsLLM( + model_id="mistralai/Mistral-7B-Instruct-v0.2", + ) + ) + + ultrafeedback.load() + + result = next( + chat.process( + [ + { + "instruction": "How much is 2+2?", + "generations": ["4", "and a car"], + } + ] + ) + ) + # result + # [ + # { + # 'instruction': 'How much is 2+2?', + # 'generations': ['4', 'and a car'], + # 'ratings': [1, 2], + # 'rationales': ['explanation for 4', 'explanation for and a car'], + # 'model_name': 'mistralai/Mistral-7B-Instruct-v0.2', + # } + # ] + ``` """ aspect: Literal[ From 2f245c6dd3c9c814824bbb1d008b89d4b33e564d Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 13 Jun 2024 12:26:31 +0200 Subject: [PATCH 32/40] =?UTF-8?q?[FEATURE]=C2=A0Refactor=20of=20structured?= =?UTF-8?q?=20generation=20and=20use=20schemas=20defined=20in=20a=20datase?= =?UTF-8?q?t=20(#688)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix linting issue from `develop` branch * Add `grammar` arg in `agenerate` (WIP) * Run `codespell` in `src/` and `docs/` * Add support for `StructuredGeneration` (WIP) - Now the `generate` method in the `LLM` can receive either a chat or a tuple with the chat and the grammar for that chat - `grammar` is an arg at `LLM` level - The `grammar` can be specified per row via the `StructuredGeneration`, while when specifying a global `grammar` then the `grammar` arg within the `LLM` can be used via the `TextGeneration` task instead * Add `flatten_dict` to avoid `pyarrow` issues with nested dicts * Handle `pyarrow.lib.ArrowInvalid` when nested unaligned dicts * Update grammar argument to structured_output for consistency * Update tests to check the structured outputs on serialization * Add tests for the structured generation class * Update typing and testing according to instructor or outlines structured output parameters * Fix passing grammar via structured outputs * Remove debug log * Add tests for the new minibatches of the structured generation * Update outlines based structured generation * Update tests with new keyword for structured generation * Update api based llms to run with structured generation * Fix tests after refactor * Fix test after refactor * Fix vllm batch sorting mechanism * Fix error on vllm with sorting bathces --------- Co-authored-by: Alvaro Bartolome --- src/distilabel/llms/anthropic.py | 36 +++- src/distilabel/llms/base.py | 13 +- src/distilabel/llms/cohere.py | 35 +++- src/distilabel/llms/groq.py | 33 ++- .../llms/huggingface/inference_endpoints.py | 38 +++- .../llms/huggingface/transformers.py | 14 +- src/distilabel/llms/litellm.py | 29 ++- src/distilabel/llms/llamacpp.py | 26 ++- src/distilabel/llms/mistral.py | 33 ++- src/distilabel/llms/ollama.py | 8 +- src/distilabel/llms/openai.py | 29 ++- src/distilabel/llms/vllm.py | 189 +++++++++++++++--- .../steps/tasks/structured_generation.py | 18 +- .../tasks/structured_outputs/instructor.py | 22 +- .../tasks/structured_outputs/outlines.py | 23 +-- src/distilabel/steps/tasks/typing.py | 42 +++- .../huggingface/test_inference_endpoints.py | 11 +- tests/unit/llms/test_llamacpp.py | 63 +++++- tests/unit/llms/test_vertexai.py | 1 - tests/unit/llms/test_vllm.py | 170 ++++++++++++++++ .../steps/tasks/evol_instruct/test_base.py | 1 - .../tasks/evol_instruct/test_generator.py | 1 - .../steps/tasks/evol_quality/test_base.py | 1 - .../tasks/structured_outputs/test_outlines.py | 17 +- tests/unit/steps/tasks/test_base.py | 1 - .../steps/tasks/test_structured_generation.py | 16 +- 26 files changed, 695 insertions(+), 175 deletions(-) create mode 100644 tests/unit/llms/test_vllm.py diff --git a/src/distilabel/llms/anthropic.py b/src/distilabel/llms/anthropic.py index c4c8547366..e3aadd0668 100644 --- a/src/distilabel/llms/anthropic.py +++ b/src/distilabel/llms/anthropic.py @@ -33,7 +33,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import StandardInput +from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -60,7 +60,8 @@ class AnthropicLLM(AsyncLLM): http_client: if provided, an alternative HTTP client to use for calling Anthropic API. Defaults to `None`. structured_output: a dictionary containing the structured output configuration configuration - using `instructor`. Defaults to None. + using `instructor`. You can take a look at the dictionary structure in + `InstructorStructuredOutputType` from `distilabel.steps.tasks.structured_outputs.instructor`. _api_key_env_var: the name of the environment variable to use for the API key. It is meant to be used internally. _aclient: the `AsyncAnthropic` client to use for the Anthropic API. It is meant @@ -140,6 +141,12 @@ class User(BaseModel): " failing.", ) http_client: Optional[AsyncClient] = Field(default=None, exclude=True) + structured_output: Optional[RuntimeParameter[InstructorStructuredOutputType]] = ( + Field( + default=None, + description="The structured output format to use across all the generations.", + ) + ) _api_key_env_var: str = PrivateAttr(default=_ANTHROPIC_API_KEY_ENV_VAR_NAME) _aclient: Optional["AsyncAnthropic"] = PrivateAttr(...) @@ -207,7 +214,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: StandardInput, + input: FormattedInput, max_tokens: int = 128, stop_sequences: Union[List[str], None] = None, temperature: float = 1.0, @@ -229,6 +236,19 @@ async def agenerate( # type: ignore """ from anthropic._types import NOT_GIVEN + structured_output = None + if isinstance(input, tuple): + input, structured_output = input + result = self._prepare_structured_output( + structured_output=structured_output, + client=self._aclient, + framework="anthropic", + ) + self._aclient = result.get("client") + + if structured_output is None and self.structured_output is not None: + structured_output = self.structured_output + kwargs = { "messages": input, # type: ignore "model": self.model, @@ -245,13 +265,13 @@ async def agenerate( # type: ignore "top_k": NOT_GIVEN if top_k is None else top_k, } - if self.structured_output: - kwargs = self._prepare_kwargs(kwargs, self.structured_output) + if structured_output: + kwargs = self._prepare_kwargs(kwargs, structured_output) generations = [] completion = await self._aclient.messages.create(**kwargs) # type: ignore - if self.structured_output: + if structured_output: generations.append(completion.model_dump_json()) return generations @@ -267,7 +287,7 @@ async def agenerate( # type: ignore @override def generate( self, - inputs: List["StandardInput"], + inputs: List["FormattedInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -276,7 +296,7 @@ def generate( """ async def agenerate( - inputs: List["StandardInput"], **kwargs: Any + inputs: List["FormattedInput"], **kwargs: Any ) -> "GenerateOutput": """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/base.py b/src/distilabel/llms/base.py index 55250da791..50f5d46fc0 100644 --- a/src/distilabel/llms/base.py +++ b/src/distilabel/llms/base.py @@ -14,6 +14,7 @@ import asyncio import inspect +import json import logging import sys from abc import ABC, abstractmethod @@ -58,8 +59,6 @@ class LLM(RuntimeParametersMixin, BaseModel, _Serializable, ABC): Attributes: generation_kwargs: the kwargs to be propagated to either `generate` or `agenerate` methods within each `LLM`. - structured_output: a dictionary containing the structured output configuration or if more - fine-grained control is needed, an instance of `OutlinesStructuredOutput`. Defaults to None. _logger: the logger to be used for the `LLM`. It will be initialized when the `load` method is called. """ @@ -77,7 +76,6 @@ class LLM(RuntimeParametersMixin, BaseModel, _Serializable, ABC): description="The kwargs to be propagated to either `generate` or `agenerate`" " methods within each `LLM`.", ) - structured_output: Optional[Any] = None _logger: Union[logging.Logger, None] = PrivateAttr(...) @@ -351,7 +349,7 @@ def _prepare_structured_output( raise ValueError( f"The `structured_output` argument must contain a schema: {structured_output}" ) - if issubclass(schema, BaseModel): + if inspect.isclass(schema) and issubclass(schema, BaseModel): # We want a json schema for the serialization, but instructor wants a pydantic BaseModel. structured_output["schema"] = schema.model_json_schema() result["structured_output"] = structured_output @@ -376,11 +374,16 @@ def _prepare_kwargs( # We can deal with json schema or BaseModel, but we need to convert it to a BaseModel # for the Instructor client. schema = structured_output.get("schema") - if not issubclass(schema, BaseModel): + # We can assume if it's a class it must be a pydantic model. + if not inspect.isclass(schema): from distilabel.steps.tasks.structured_outputs.utils import ( json_schema_to_model, ) + if isinstance(schema, str): + # In case it was saved in the dataset as a string. + schema = json.loads(schema) + try: schema = json_schema_to_model(schema) except Exception as e: diff --git a/src/distilabel/llms/cohere.py b/src/distilabel/llms/cohere.py index 58ba53895a..a3fc1619a6 100644 --- a/src/distilabel/llms/cohere.py +++ b/src/distilabel/llms/cohere.py @@ -30,7 +30,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import StandardInput +from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -131,6 +131,12 @@ class User(BaseModel): default="distilabel", description="The name of the client to use for the API requests.", ) + structured_output: Optional[RuntimeParameter[InstructorStructuredOutputType]] = ( + Field( + default=None, + description="The structured output format to use across all the generations.", + ) + ) _ChatMessage: Type["ChatMessage"] = PrivateAttr(...) _aclient: "AsyncClient" = PrivateAttr(...) @@ -172,7 +178,7 @@ def load(self) -> None: self.structured_output = structured_output def _format_chat_to_cohere( - self, input: "StandardInput" + self, input: "FormattedInput" ) -> Tuple[Union[str, None], List["ChatMessage"], str]: """Formats the chat input to the Cohere Chat API conversational format. @@ -209,7 +215,7 @@ def _format_chat_to_cohere( @validate_call async def agenerate( # type: ignore self, - input: StandardInput, + input: FormattedInput, temperature: Optional[float] = None, max_tokens: Optional[int] = None, k: Optional[int] = None, @@ -244,6 +250,19 @@ async def agenerate( # type: ignore Returns: The generated response from the Cohere API model. """ + structured_output = None + if isinstance(input, tuple): + input, structured_output = input + result = self._prepare_structured_output( + structured_output=structured_output, + client=self._aclient, + framework="cohere", + ) + self._aclient = result.get("client") + + if structured_output is None and self.structured_output is not None: + structured_output = self.structured_output + system, chat_history, message = self._format_chat_to_cohere(input) kwargs = { @@ -261,12 +280,12 @@ async def agenerate( # type: ignore "presence_penalty": presence_penalty, "raw_prompting": raw_prompting, } - if self.structured_output: - kwargs = self._prepare_kwargs(kwargs, self.structured_output) + if structured_output: + kwargs = self._prepare_kwargs(kwargs, structured_output) response = await self._aclient.chat(**kwargs) # type: ignore - if self.structured_output: + if structured_output: return response.model_dump_json() if (text := response.text) == "": @@ -281,7 +300,7 @@ async def agenerate( # type: ignore @override def generate( self, - inputs: List["StandardInput"], + inputs: List["FormattedInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -289,7 +308,7 @@ def generate( synchronously awaiting for the response of each input sent to `agenerate`.""" async def agenerate( - inputs: List["StandardInput"], **kwargs: Any + inputs: List["FormattedInput"], **kwargs: Any ) -> "GenerateOutput": """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/groq.py b/src/distilabel/llms/groq.py index d8ca4c7c93..fc09207e58 100644 --- a/src/distilabel/llms/groq.py +++ b/src/distilabel/llms/groq.py @@ -22,7 +22,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.steps.base import RuntimeParameter -from distilabel.steps.tasks.typing import StandardInput +from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -124,6 +124,12 @@ class User(BaseModel): default=120, description="The maximum time in seconds to wait for a response from the API.", ) + structured_output: Optional[RuntimeParameter[InstructorStructuredOutputType]] = ( + Field( + default=None, + description="The structured output format to use across all the generations.", + ) + ) _api_key_env_var: str = PrivateAttr(_GROQ_API_KEY_ENV_VAR_NAME) _aclient: Optional["AsyncGroq"] = PrivateAttr(...) @@ -171,7 +177,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: StandardInput, + input: FormattedInput, seed: Optional[int] = None, max_new_tokens: int = 128, temperature: float = 1.0, @@ -196,6 +202,19 @@ async def agenerate( # type: ignore References: - https://console.groq.com/docs/text-chat """ + structured_output = None + if isinstance(input, tuple): + input, structured_output = input + result = self._prepare_structured_output( + structured_output=structured_output, + client=self._aclient, + framework="groq", + ) + self._aclient = result.get("client") + + if structured_output is None and self.structured_output is not None: + structured_output = self.structured_output + kwargs = { "messages": input, # type: ignore "model": self.model, @@ -206,12 +225,12 @@ async def agenerate( # type: ignore "stream": False, "stop": stop, } - if self.structured_output: - kwargs = self._prepare_kwargs(kwargs, self.structured_output) + if structured_output: + kwargs = self._prepare_kwargs(kwargs, structured_output) generations = [] completion = await self._aclient.chat.completions.create(**kwargs) # type: ignore - if self.structured_output: + if structured_output: generations.append(completion.model_dump_json()) return generations @@ -228,7 +247,7 @@ async def agenerate( # type: ignore @override def generate( self, - inputs: List["StandardInput"], + inputs: List["FormattedInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -237,7 +256,7 @@ def generate( """ async def agenerate( - inputs: List["StandardInput"], **kwargs: Any + inputs: List["FormattedInput"], **kwargs: Any ) -> "GenerateOutput": """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/huggingface/inference_endpoints.py b/src/distilabel/llms/huggingface/inference_endpoints.py index 73b13ee4d0..db3ef8da85 100644 --- a/src/distilabel/llms/huggingface/inference_endpoints.py +++ b/src/distilabel/llms/huggingface/inference_endpoints.py @@ -31,7 +31,11 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import FormattedInput, Grammar, StandardInput +from distilabel.steps.tasks.typing import ( + FormattedInput, + StandardInput, + StructuredOutputType, +) from distilabel.utils.huggingface import ( _INFERENCE_ENDPOINTS_API_KEY_ENV_VAR_NAME, get_hf_token, @@ -149,9 +153,9 @@ class InferenceEndpointsLLM(AsyncLLM): model_display_name: Optional[str] = None use_openai_client: bool = False - grammar: Optional[RuntimeParameter[Grammar]] = Field( + structured_output: Optional[RuntimeParameter[StructuredOutputType]] = Field( default=None, - description="The grammar to use across all the generations.", + description="The structured output format to use across all the generations.", ) _model_name: Optional[str] = PrivateAttr(default=None) @@ -382,9 +386,29 @@ async def agenerate( # type: ignore ) stop_sequences = stop_sequences[:4] - grammar = None + structured_output = None if isinstance(input, tuple): - input, grammar = input + input, structured_output = input + structured_output = { + "type": structured_output["format"], + "value": structured_output["schema"], + } + + # NOTE: `self.structured_output` applies to all the generations, while `structured_output` i.e. the + # value included within the tuple provided as `input` to this method, is intended to be different per + # each input, so those should not be used together. Meaning that it should be either provided at attribute + # level i.e. self, or via a column within each input i.e. row. + if structured_output is None and self.structured_output is not None: + try: + structured_output = { + "type": self.structured_output["format"], + "value": self.structured_output["schema"], + } + except KeyError as e: + raise ValueError( + "To use the structured output you have to inform the `format` and `schema` in " + "the `structured_output` attribute." + ) from e if self.use_openai_client: return await self._openai_agenerate( @@ -420,9 +444,7 @@ async def agenerate( # type: ignore stop_sequences=stop_sequences, return_full_text=return_full_text, watermark=watermark, - # NOTE: `self.grammar` applies to all the generations, while `grammar` is intended - # to be different per each input, and those are not intended to be used together - grammar=grammar or self.grammar, # type: ignore + grammar=structured_output, # type: ignore # NOTE: here to ensure that the cache is not used and a different response is # generated every time seed=seed or random.randint(0, 2147483647), diff --git a/src/distilabel/llms/huggingface/transformers.py b/src/distilabel/llms/huggingface/transformers.py index dc428f4283..6e7736d006 100644 --- a/src/distilabel/llms/huggingface/transformers.py +++ b/src/distilabel/llms/huggingface/transformers.py @@ -15,13 +15,14 @@ import os from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union -from pydantic import PrivateAttr, validate_call +from pydantic import Field, PrivateAttr, validate_call from distilabel.llms.base import LLM from distilabel.llms.chat_templates import CHATML_TEMPLATE from distilabel.llms.mixins import CudaDevicePlacementMixin from distilabel.llms.typing import GenerateOutput -from distilabel.steps.tasks.typing import StandardInput +from distilabel.mixins.runtime_parameters import RuntimeParameter +from distilabel.steps.tasks.typing import OutlinesStructuredOutputType, StandardInput if TYPE_CHECKING: from transformers import Pipeline @@ -29,7 +30,6 @@ from transformers.tokenization_utils import PreTrainedTokenizer from distilabel.llms.typing import HiddenState - from distilabel.steps.tasks.structured_outputs.outlines import StructuredOutputType class TransformersLLM(LLM, CudaDevicePlacementMixin): @@ -62,6 +62,8 @@ class TransformersLLM(LLM, CudaDevicePlacementMixin): token: the Hugging Face Hub token that will be used to authenticate to the Hugging Face Hub. If not provided, the `HF_TOKEN` environment or `huggingface_hub` package local configuration will be used. Defaults to `None`. + structured_output: a dictionary containing the structured output configuration or if more + fine-grained control is needed, an instance of `OutlinesStructuredOutput`. Defaults to None. Icon: `:hugging:` @@ -93,6 +95,10 @@ class TransformersLLM(LLM, CudaDevicePlacementMixin): device: Optional[Union[str, int]] = None device_map: Optional[Union[str, Dict[str, Any]]] = None token: Optional[str] = None + structured_output: Optional[RuntimeParameter[OutlinesStructuredOutputType]] = Field( + default=None, + description="The structured output format to use across all the generations.", + ) _pipeline: Optional["Pipeline"] = PrivateAttr(...) _prefix_allowed_tokens_fn: Union[Callable, None] = PrivateAttr(default=None) @@ -244,7 +250,7 @@ def get_last_hidden_states( ] def _prepare_structured_output( - self, structured_output: Optional["StructuredOutputType"] = None + self, structured_output: Optional[OutlinesStructuredOutputType] = None ) -> Union[Callable, None]: """Creates the appropriate function to filter tokens to generate structured outputs. diff --git a/src/distilabel/llms/litellm.py b/src/distilabel/llms/litellm.py index 99087e7da0..068508abdb 100644 --- a/src/distilabel/llms/litellm.py +++ b/src/distilabel/llms/litellm.py @@ -20,7 +20,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import StandardInput +from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType if TYPE_CHECKING: from litellm import Choices @@ -85,6 +85,12 @@ class User(BaseModel): verbose: RuntimeParameter[bool] = Field( default=False, description="Whether to log the LiteLLM client's logs." ) + structured_output: Optional[RuntimeParameter[InstructorStructuredOutputType]] = ( + Field( + default=None, + description="The structured output format to use across all the generations.", + ) + ) _aclient: Optional[Callable] = PrivateAttr(...) @@ -128,9 +134,9 @@ def model_name(self) -> str: return self.model @validate_call - async def agenerate( # type: ignore + async def agenerate( # type: ignore # noqa: C901 self, - input: StandardInput, + input: FormattedInput, num_generations: int = 1, functions: Optional[List] = None, function_call: Optional[str] = None, @@ -194,6 +200,19 @@ async def agenerate( # type: ignore """ import litellm + structured_output = None + if isinstance(input, tuple): + input, structured_output = input + result = self._prepare_structured_output( + structured_output=structured_output, + client=self._aclient, + framework="litellm", + ) + self._aclient = result.get("client") + + if structured_output is None and self.structured_output is not None: + structured_output = self.structured_output + kwargs = { "model": self.model, "messages": input, @@ -218,8 +237,8 @@ async def agenerate( # type: ignore "force_timeout": force_timeout, "custom_llm_provider": custom_llm_provider, } - if self.structured_output: - kwargs = self._prepare_kwargs(kwargs, self.structured_output) + if structured_output: + kwargs = self._prepare_kwargs(kwargs, structured_output) async def _call_aclient_until_n_choices() -> List["Choices"]: choices = [] diff --git a/src/distilabel/llms/llamacpp.py b/src/distilabel/llms/llamacpp.py index aed911f043..f66eb214b0 100644 --- a/src/distilabel/llms/llamacpp.py +++ b/src/distilabel/llms/llamacpp.py @@ -19,13 +19,11 @@ from distilabel.llms.base import LLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import StandardInput +from distilabel.steps.tasks.typing import FormattedInput, OutlinesStructuredOutputType if TYPE_CHECKING: from llama_cpp import CreateChatCompletionResponse, Llama, LogitsProcessorList - from distilabel.steps.tasks.structured_outputs.outlines import StructuredOutputType - class LlamaCppLLM(LLM): """llama.cpp LLM implementation running the Python bindings for the C++ code. @@ -128,7 +126,6 @@ class User(BaseModel): n_ctx: int = 512 n_batch: int = 512 seed: int = 4294967295 - verbose: RuntimeParameter[bool] = Field( default=False, description="Whether to print verbose output from llama.cpp library.", @@ -139,6 +136,10 @@ class User(BaseModel): " `Llama` class of `llama_cpp` library. See all the supported arguments at: " "https://llama-cpp-python.readthedocs.io/en/latest/api-reference/#llama_cpp.Llama.__init__", ) + structured_output: Optional[RuntimeParameter[OutlinesStructuredOutputType]] = Field( + default=None, + description="The structured output format to use across all the generations.", + ) _logits_processor: Optional["LogitsProcessorList"] = PrivateAttr(default=None) _model: Optional["Llama"] = PrivateAttr(...) @@ -180,7 +181,7 @@ def model_name(self) -> str: @validate_call def generate( # type: ignore self, - inputs: List[StandardInput], + inputs: List[FormattedInput], num_generations: int = 1, max_new_tokens: int = 128, frequency_penalty: float = 0.0, @@ -210,18 +211,23 @@ def generate( # type: ignore Returns: A list of lists of strings containing the generated responses for each input. """ - + structured_output = None batch_outputs = [] for input in inputs: + if isinstance(input, tuple): + input, structured_output = input + elif self.structured_output: + structured_output = self.structured_output + outputs = [] for _ in range(num_generations): # NOTE(plaguss): There seems to be a bug in how the logits processor # is used. Basically it consumes the FSM internally, and it isn't reinitialized # after each generation, so subsequent calls yield nothing. This is a workaround # until is fixed in the `llama_cpp` or `outlines` libraries. - if self.structured_output: + if structured_output: self._logits_processor = self._prepare_structured_output( - self.structured_output + structured_output ) chat_completions: "CreateChatCompletionResponse" = ( self._model.create_chat_completion( # type: ignore @@ -240,7 +246,7 @@ def generate( # type: ignore return batch_outputs def _prepare_structured_output( - self, structured_output: Optional["StructuredOutputType"] = None + self, structured_output: Optional[OutlinesStructuredOutputType] = None ) -> Union["LogitsProcessorList", None]: """Creates the appropriate function to filter tokens to generate structured outputs. @@ -255,6 +261,6 @@ def _prepare_structured_output( ) result = prepare_guided_output(structured_output, "llamacpp", self._model) - if schema := result.get("schema"): + if (schema := result.get("schema")) and self.structured_output: self.structured_output["schema"] = schema return result["processor"] diff --git a/src/distilabel/llms/mistral.py b/src/distilabel/llms/mistral.py index 99d2d3350b..72eced4aa6 100644 --- a/src/distilabel/llms/mistral.py +++ b/src/distilabel/llms/mistral.py @@ -22,7 +22,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import StandardInput +from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType from distilabel.utils.itertools import grouper if TYPE_CHECKING: @@ -120,6 +120,12 @@ class User(BaseModel): max_concurrent_requests: RuntimeParameter[int] = Field( default=64, description="The maximum number of concurrent requests to send." ) + structured_output: Optional[RuntimeParameter[InstructorStructuredOutputType]] = ( + Field( + default=None, + description="The structured output format to use across all the generations.", + ) + ) _api_key_env_var: str = PrivateAttr(_MISTRALAI_API_KEY_ENV_VAR_NAME) _aclient: Optional["MistralAsyncClient"] = PrivateAttr(...) @@ -169,7 +175,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: StandardInput, + input: FormattedInput, max_new_tokens: Optional[int] = None, temperature: Optional[float] = None, top_p: Optional[float] = None, @@ -187,6 +193,19 @@ async def agenerate( # type: ignore Returns: A list of lists of strings containing the generated responses for each input. """ + structured_output = None + if isinstance(input, tuple): + input, structured_output = input + result = self._prepare_structured_output( + structured_output=structured_output, + client=self._aclient, + framework="mistral", + ) + self._aclient = result.get("client") + + if structured_output is None and self.structured_output is not None: + structured_output = self.structured_output + kwargs = { "messages": input, # type: ignore "model": self.model, @@ -195,15 +214,15 @@ async def agenerate( # type: ignore "top_p": top_p, } generations = [] - if self.structured_output: - kwargs = self._prepare_kwargs(kwargs, self.structured_output) + if structured_output: + kwargs = self._prepare_kwargs(kwargs, structured_output) # TODO: This should work just with the _aclient.chat method, but it's not working. # We need to check instructor and see if we can create a PR. completion = await self._aclient.chat.completions.create(**kwargs) else: completion = await self._aclient.chat(**kwargs) - if self.structured_output: + if structured_output: generations.append(completion.model_dump_json()) return generations @@ -220,7 +239,7 @@ async def agenerate( # type: ignore @override def generate( self, - inputs: List["StandardInput"], + inputs: List["FormattedInput"], num_generations: int = 1, **kwargs: Any, ) -> List["GenerateOutput"]: @@ -229,7 +248,7 @@ def generate( """ async def agenerate( - inputs: List["StandardInput"], **kwargs: Any + inputs: List["FormattedInput"], **kwargs: Any ) -> "GenerateOutput": """Internal function to parallelize the asynchronous generation of responses.""" tasks = [ diff --git a/src/distilabel/llms/ollama.py b/src/distilabel/llms/ollama.py index 491e273279..8da29c2d95 100644 --- a/src/distilabel/llms/ollama.py +++ b/src/distilabel/llms/ollama.py @@ -19,7 +19,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import StandardInput +from distilabel.steps.tasks.typing import InstructorStructuredOutputType, StandardInput if TYPE_CHECKING: from ollama import AsyncClient @@ -88,6 +88,12 @@ class OllamaLLM(AsyncLLM): default=120, description="The timeout for the Ollama API." ) follow_redirects: bool = True + structured_output: Optional[RuntimeParameter[InstructorStructuredOutputType]] = ( + Field( + default=None, + description="The structured output format to use across all the generations.", + ) + ) _aclient: Optional["AsyncClient"] = PrivateAttr(...) diff --git a/src/distilabel/llms/openai.py b/src/distilabel/llms/openai.py index 7c93390275..3285f3bbfa 100644 --- a/src/distilabel/llms/openai.py +++ b/src/distilabel/llms/openai.py @@ -20,7 +20,7 @@ from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import StandardInput +from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType if TYPE_CHECKING: from openai import AsyncOpenAI @@ -145,6 +145,12 @@ class User(BaseModel): default=120, description="The maximum time in seconds to wait for a response from the API.", ) + structured_output: Optional[RuntimeParameter[InstructorStructuredOutputType]] = ( + Field( + default=None, + description="The structured output format to use across all the generations.", + ) + ) _api_key_env_var: str = PrivateAttr(_OPENAI_API_KEY_ENV_VAR_NAME) _aclient: Optional["AsyncOpenAI"] = PrivateAttr(...) @@ -192,7 +198,7 @@ def model_name(self) -> str: @validate_call async def agenerate( # type: ignore self, - input: StandardInput, + input: FormattedInput, num_generations: int = 1, max_new_tokens: int = 128, frequency_penalty: float = 0.0, @@ -236,6 +242,19 @@ async def agenerate( # type: ignore f"Invalid response format '{response_format}'. Must be either 'text' or 'json'." ) + structured_output = None + if isinstance(input, tuple): + input, structured_output = input + result = self._prepare_structured_output( + structured_output=structured_output, + client=self._aclient, + framework="openai", + ) + self._aclient = result.get("client") + + if structured_output is None and self.structured_output is not None: + structured_output = self.structured_output + kwargs = { "messages": input, # type: ignore "model": self.model, @@ -249,13 +268,13 @@ async def agenerate( # type: ignore "timeout": 50, "response_format": {"type": response_format}, } - if self.structured_output: - kwargs = self._prepare_kwargs(kwargs, self.structured_output) + if structured_output: + kwargs = self._prepare_kwargs(kwargs, structured_output) generations = [] completion = await self._aclient.chat.completions.create(**kwargs) - if self.structured_output: + if structured_output: generations.append(completion.model_dump_json()) return generations diff --git a/src/distilabel/llms/vllm.py b/src/distilabel/llms/vllm.py index 4a9ce88f75..ded098b14b 100644 --- a/src/distilabel/llms/vllm.py +++ b/src/distilabel/llms/vllm.py @@ -12,8 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Union - +import json +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Literal, + Optional, + Tuple, + Union, +) + +import numpy as np from pydantic import Field, PrivateAttr, validate_call from distilabel.llms.base import LLM @@ -21,14 +33,12 @@ from distilabel.llms.mixins import CudaDevicePlacementMixin from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import StandardInput +from distilabel.steps.tasks.typing import FormattedInput, OutlinesStructuredOutputType if TYPE_CHECKING: from transformers import PreTrainedTokenizer from vllm import LLM as _vLLM - from distilabel.steps.tasks.structured_outputs.outlines import StructuredOutputType - SamplingParams = None @@ -135,6 +145,10 @@ class User(BaseModel): " `vLLM` class of `vllm` library. See all the supported arguments at: " "https://github.com/vllm-project/vllm/blob/main/vllm/entrypoints/llm.py", ) + structured_output: Optional[RuntimeParameter[OutlinesStructuredOutputType]] = Field( + default=None, + description="The structured output format to use across all the generations.", + ) _model: Optional["_vLLM"] = PrivateAttr(...) _tokenizer: Optional["PreTrainedTokenizer"] = PrivateAttr(...) @@ -199,7 +213,7 @@ def model_name(self) -> str: """Returns the model name used for the LLM.""" return self.model - def prepare_input(self, input: "StandardInput") -> str: + def prepare_input(self, input: "FormattedInput") -> str: """Prepares the input by applying the chat template to the input, which is formatted as an OpenAI conversation, and adding the generation prompt. """ @@ -209,10 +223,52 @@ def prepare_input(self, input: "StandardInput") -> str: add_generation_prompt=True, # type: ignore ) + def _prepare_batches( + self, inputs: List[FormattedInput] + ) -> Tuple[List[List[FormattedInput]], List[int]]: + """Prepares the inputs by grouping them by the structured output. + + When we generate structured outputs with schemas obtained from a dataset, we need to + prepare the data to try to send batches of inputs instead of single inputs to the model + to take advante of the engine. So we group the inputs by the structured output to be + passed in the `generate` method. + + Args: + inputs: The batch of inputs passed to the generate method. As we expect to be generating + structured outputs, each element will be a tuple containing the instruction and the + structured output. + + Returns: + The prepared batches (sub-batches let's say) to be passed to the `generate` method. + Each new tuple will contain instead of the single instruction, a list of instructions + """ + instruction_order = {} + batches = {} + for i, (instruction, structured_output) in enumerate(inputs): + instruction = self.prepare_input(instruction) + instruction_order[instruction] = i + structured_output = json.dumps(structured_output) + if structured_output not in batches: + batches[structured_output] = [instruction] + else: + batches[structured_output].append(instruction) + + # Flatten the instructions in prepared_data + flat_instructions = [ + instruction for _, group in batches.items() for instruction in group + ] + # Generate the list of indices based on the original order + sorted_indices = [ + instruction_order[instruction] for instruction in flat_instructions + ] + return [ + (batch, json.loads(schema)) for schema, batch in batches.items() + ], sorted_indices + @validate_call def generate( # type: ignore self, - inputs: List[StandardInput], + inputs: List[FormattedInput], num_generations: int = 1, max_new_tokens: int = 128, frequency_penalty: float = 0.0, @@ -244,36 +300,62 @@ def generate( # type: ignore Returns: A list of lists of strings containing the generated responses for each input. """ - prepared_inputs = [self.prepare_input(input) for input in inputs] if extra_sampling_params is None: extra_sampling_params = {} + structured_output = None + needs_sorting = False + + if isinstance(inputs[0], tuple): + prepared_batches, sorted_indices = self._prepare_batches(inputs) + needs_sorting = True + else: + # Simulate a batch without the structured output content + prepared_batches = [([self.prepare_input(input) for input in inputs], None)] + + # In case we have a single structured output for the dataset, we can + logits_processors = None + if self._logits_processor: + logits_processors = [self._logits_processor] + + batched_outputs = [] + + for prepared_inputs, structured_output in prepared_batches: + if structured_output: + logits_processors = [self._prepare_structured_output(structured_output)] + + sampling_params = SamplingParams( # type: ignore + n=num_generations, + presence_penalty=presence_penalty, + frequency_penalty=frequency_penalty, + temperature=temperature, + top_p=top_p, + top_k=top_k, + max_tokens=max_new_tokens, + logits_processors=logits_processors, + **extra_sampling_params, + ) - sampling_params = SamplingParams( # type: ignore - n=num_generations, - presence_penalty=presence_penalty, - frequency_penalty=frequency_penalty, - temperature=temperature, - top_p=top_p, - top_k=top_k, - max_tokens=max_new_tokens, - logits_processors=( - [self._logits_processor] if self._logits_processor else None - ), - **extra_sampling_params, - ) + batch_outputs = self._model.generate( # type: ignore + prepared_inputs, + sampling_params, + use_tqdm=False, # type: ignore + ) - batch_outputs = self._model.generate( # type: ignore - prepared_inputs, - sampling_params, - use_tqdm=False, # type: ignore - ) - return [ - [output.text for output in outputs.outputs] for outputs in batch_outputs - ] + batched_outputs += [ + [output.text for output in outputs.outputs] for outputs in batch_outputs + ] + + # If logits_processor is set, we need to sort the outputs back to the original order + # (would be needed only if we have multiple structured outputs in the dataset) + if needs_sorting: + batched_outputs = _sort_batches( + batched_outputs, sorted_indices, num_generations=num_generations + ) + return batched_outputs def _prepare_structured_output( - self, structured_output: Optional["StructuredOutputType"] = None + self, structured_output: Optional[OutlinesStructuredOutputType] = None ) -> Union[Callable, None]: """Creates the appropriate function to filter tokens to generate structured outputs. @@ -288,6 +370,51 @@ def _prepare_structured_output( ) result = prepare_guided_output(structured_output, "vllm", self._model) - if schema := result.get("schema"): + if (schema := result.get("schema")) and self.structured_output: self.structured_output["schema"] = schema return result["processor"] + + +def _sort_batches( + batches: List[List[FormattedInput]], indices: List[int], num_generations: int = 1 +) -> List[str]: + """Helper function to sort back the mini-batches generated by the model. + + It must take into account the number of `num_generations` to repeat the indices + accordingly. + + Args: + batches: The mini-batches generated by the model. + indices: The indices that would sort the mini-batches back to the original order. + num_generations: The number of generations requested to vLLM. Defaults to 1. + + Returns: + Sorted batched_outputs. + """ + batch_sizes = [len(batch) for batch in batches] + flattened_batches = np.array([b for batch in batches for b in batch]) + sorted_batches = np.take_along_axis( + flattened_batches, + np.argsort(np.repeat(indices, num_generations)), + axis=0, + ).tolist() + sorted_batches = _batchify(sorted_batches, batch_sizes) + return sorted_batches + + +def _batchify(sorted_batches: List[str], batch_sizes: List[int]) -> List[List[str]]: + """Helper function to regenerate the sorted batches from the flattened sorted ones. + + Args: + sorted_batches: Output obtained from the `_sort_batches` function. + batch_sizes: The batch sizes to be used to split the sorted batches. + + Returns: + Batched sorted batches in the original shape. + """ + batches = [] + idx = 0 + for bs in batch_sizes: + batches.append(sorted_batches[idx : idx + bs]) + idx += bs + return batches diff --git a/src/distilabel/steps/tasks/structured_generation.py b/src/distilabel/steps/tasks/structured_generation.py index 6171c0bc83..240cd44698 100644 --- a/src/distilabel/steps/tasks/structured_generation.py +++ b/src/distilabel/steps/tasks/structured_generation.py @@ -22,9 +22,9 @@ class StructuredGeneration(Task): """Generate structured content for a given `instruction` using an `LLM`. - `StructuredGeneration` is a pre-defined task that defines the `instruction` and the `grammar` + `StructuredGeneration` is a pre-defined task that defines the `instruction` and the `structured_output` as the inputs, and `generation` as the output. This task is used to generate structured content based on - the input instruction and following the schema provided within the `grammar` column per each + the input instruction and following the schema provided within the `structured_output` column per each `instruction`. The `model_name` also returned as part of the output in order to enhance it. Attributes: @@ -34,9 +34,9 @@ class StructuredGeneration(Task): Input columns: - instruction (`str`): The instruction to generate structured content from. - - grammar (`Dict[str, Any]`): The grammar to generate structured content from. It should be a - Python dictionary with the keys `type` and `value`, where `type` should be one of `json` or - `regex`, and the `value` should be either the JSON schema or the regex pattern, respectively. + - structured_output (`Dict[str, Any]`): The structured_output to generate structured content from. It should be a + Python dictionary with the keys `format` and `schema`, where `format` should be one of `json` or + `regex`, and the `schema` should be either the JSON schema or the regex pattern, respectively. Output columns: - generation (`str`): The generated text matching the provided schema, if possible. @@ -141,10 +141,10 @@ class StructuredGeneration(Task): @property def inputs(self) -> List[str]: - """The input for the task are the `instruction` and the `grammar`. + """The input for the task are the `instruction` and the `structured_output`. Optionally, if the `use_system_prompt` flag is set to True, then the `system_prompt` will be used too.""" - columns = ["instruction", "grammar"] + columns = ["instruction", "structured_output"] if self.use_system_prompt: columns = ["system_prompt"] + columns return columns @@ -170,7 +170,7 @@ def format_input(self, input: Dict[str, Any]) -> StructuredInput: stacklevel=2, ) - return (messages, input.get("grammar", None)) # type: ignore + return (messages, input.get("structured_output", None)) # type: ignore @property def outputs(self) -> List[str]: @@ -182,6 +182,6 @@ def format_output( ) -> Dict[str, Any]: """The output is formatted as a dictionary with the `generation`. The `model_name` will be automatically included within the `process` method of `Task`. Note that even - if the `grammar` is defined to produce a JSON schema, this method will return the raw + if the `structured_output` is defined to produce a JSON schema, this method will return the raw output i.e. a string without any parsing.""" return {"generation": output} diff --git a/src/distilabel/steps/tasks/structured_outputs/instructor.py b/src/distilabel/steps/tasks/structured_outputs/instructor.py index e9ec1ea431..7249457d17 100644 --- a/src/distilabel/steps/tasks/structured_outputs/instructor.py +++ b/src/distilabel/steps/tasks/structured_outputs/instructor.py @@ -19,15 +19,11 @@ Literal, Optional, Tuple, - Type, TypeAlias, - TypedDict, Union, get_args, ) -from pydantic import BaseModel - if TYPE_CHECKING: import instructor from anthropic import AsyncAnthropic @@ -53,16 +49,16 @@ """Available clients that can be wrapped with `instructor`. """ -class InstructorStructuredOutputType(TypedDict): - """TypedDict to represent the structured output configuration from `instructor`.""" +# class InstructorStructuredOutputType(TypedDict): +# """TypedDict to represent the structured output configuration from `instructor`.""" - schema: Type[BaseModel] - """The schema to use for the structured output, a `pydantic.BaseModel` class. """ - mode: Optional["instructor.Mode"] - """Generation mode. Take a look at `instructor.Mode` for more information, if not informed it will - be determined automatically. """ - max_retries: int - """Number of times to reask the model in case of error, if not set will default to the model's default. """ +# schema: Type[BaseModel] +# """The schema to use for the structured output, a `pydantic.BaseModel` class. """ +# mode: Optional["instructor.Mode"] +# """Generation mode. Take a look at `instructor.Mode` for more information, if not informed it will +# be determined automatically. """ +# max_retries: int +# """Number of times to reask the model in case of error, if not set will default to the model's default. """ def _client_patcher(framework: InstructorFrameworks) -> Tuple[Callable, str]: diff --git a/src/distilabel/steps/tasks/structured_outputs/outlines.py b/src/distilabel/steps/tasks/structured_outputs/outlines.py index 7bc53623de..d726b5e4f5 100644 --- a/src/distilabel/steps/tasks/structured_outputs/outlines.py +++ b/src/distilabel/steps/tasks/structured_outputs/outlines.py @@ -19,12 +19,9 @@ Any, Callable, Dict, - List, Literal, - Optional, Tuple, Type, - TypedDict, Union, get_args, ) @@ -32,30 +29,12 @@ from pydantic import BaseModel from distilabel.steps.tasks.structured_outputs.utils import schema_as_dict +from distilabel.steps.tasks.typing import StructuredOutputType Frameworks = Literal["transformers", "llamacpp", "vllm"] """Available frameworks for the structured output configuration. """ -class StructuredOutputType(TypedDict): - """TypedDict to represent the structured output configuration from outlines.""" - - format: Literal["json", "regex"] - """One of "json" or "regex".""" - schema: Union[str, Type[BaseModel]] - """The schema to use for the structured output. If "json", it - can be a pydantic.BaseModel class, or the schema as a string, - as obtained from `model_to_schema(BaseModel)`, if "regex", it - should be a regex pattern as a string. - """ - whitespace_pattern: Optional[Union[str, List[str]]] - """If "json" corresponds to a string or a list of - strings with a pattern (doesn't impact string literals). - For example, to allow only a single space or newline with - `whitespace_pattern=r"[\n ]?"` - """ - - def model_to_schema(schema: Type[BaseModel]) -> Dict[str, Any]: """Helper function to return a string representation of the schema from a `pydantic.BaseModel` class.""" return json.dumps(schema.model_json_schema()) diff --git a/src/distilabel/steps/tasks/typing.py b/src/distilabel/steps/tasks/typing.py index 71e068cab1..4f92cdc057 100644 --- a/src/distilabel/steps/tasks/typing.py +++ b/src/distilabel/steps/tasks/typing.py @@ -12,8 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Dict, List, Literal, Tuple, Union +from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union +from pydantic import BaseModel from typing_extensions import TypedDict @@ -26,14 +27,45 @@ class ChatItem(TypedDict): """ChatType is a type alias for a `list` of `dict`s following the OpenAI conversational format.""" -class Grammar(TypedDict): - type: Literal["json", "regex"] - value: Union[str, Dict[str, Any]] +class OutlinesStructuredOutputType(TypedDict, total=False): + """TypedDict to represent the structured output configuration from `outlines`.""" + format: Literal["json", "regex"] + """One of "json" or "regex".""" + schema: Union[str, Type[BaseModel], Dict[str, Any]] + """The schema to use for the structured output. If "json", it + can be a pydantic.BaseModel class, or the schema as a string, + as obtained from `model_to_schema(BaseModel)`, if "regex", it + should be a regex pattern as a string. + """ + whitespace_pattern: Optional[Union[str, List[str]]] = None + """If "json" corresponds to a string or a list of + strings with a pattern (doesn't impact string literals). + For example, to allow only a single space or newline with + `whitespace_pattern=r"[\n ]?"` + """ + + +class InstructorStructuredOutputType(TypedDict, total=False): + """TypedDict to represent the structured output configuration from `instructor`.""" + + schema: Union[Type[BaseModel], Dict[str, Any]] + """The schema to use for the structured output, a `pydantic.BaseModel` class. """ + mode: Optional[str] + """Generation mode. Take a look at `instructor.Mode` for more information, if not informed it will + be determined automatically. """ + max_retries: int + """Number of times to reask the model in case of error, if not set will default to the model's default. """ + + +StructuredOutputType = Union[ + OutlinesStructuredOutputType, InstructorStructuredOutputType +] +"""StructuredOutputType is an alias for the union of `OutlinesStructuredOutputType` and `InstructorStructuredOutputType`.""" StandardInput = ChatType """StandardInput is an alias for ChatType that defines the default / standard input produced by `format_input`.""" -StructuredInput = Tuple[StandardInput, Union[Grammar, None]] +StructuredInput = Tuple[StandardInput, Union[StructuredOutputType, None]] """StructuredInput defines a type produced by `format_input` when using either `StructuredGeneration` or a subclass of it.""" FormattedInput = Union[StandardInput, StructuredInput] """FormattedInput is an alias for the union of `StandardInput` and `StructuredInput` as generated by `format_input` and expected by the `LLM`s.""" diff --git a/tests/unit/llms/huggingface/test_inference_endpoints.py b/tests/unit/llms/huggingface/test_inference_endpoints.py index c6ce40ff94..87a890a38c 100644 --- a/tests/unit/llms/huggingface/test_inference_endpoints.py +++ b/tests/unit/llms/huggingface/test_inference_endpoints.py @@ -193,12 +193,12 @@ async def test_generate_via_openai_client( ) == [(" Aenean hendrerit aliquam velit. ...",)] @pytest.mark.asyncio - async def test_agenerate_with_grammar( + async def test_agenerate_with_structured_output( self, mock_inference_client: MagicMock, _: MagicMock ) -> None: llm = InferenceEndpointsLLM( model_id="distilabel-internal-testing/tiny-random-mistral", - grammar={"type": "regex", "value": r"\b[A-Z][a-z]*\b"}, + structured_output={"format": "regex", "schema": r"\b[A-Z][a-z]*\b"}, ) llm._aclient = mock_inference_client @@ -237,7 +237,9 @@ async def test_agenerate_with_grammar( mock_inference_client.text_generation.assert_called_with(**kwargs) def test_serialization( - self, mock_inference_client: MagicMock, mock_openai_client: MagicMock + self, + mock_inference_client: MagicMock, + mock_openai_client: MagicMock, ) -> None: llm = InferenceEndpointsLLM( model_id="distilabel-internal-testing/tiny-random-mistral", @@ -250,10 +252,9 @@ def test_serialization( "base_url": None, "tokenizer_id": None, "generation_kwargs": {}, - "grammar": None, + "structured_output": None, "model_display_name": None, "use_openai_client": False, - "structured_output": None, "type_info": { "module": "distilabel.llms.huggingface.inference_endpoints", "name": "InferenceEndpointsLLM", diff --git a/tests/unit/llms/test_llamacpp.py b/tests/unit/llms/test_llamacpp.py index c69b460ce5..b226d99292 100644 --- a/tests/unit/llms/test_llamacpp.py +++ b/tests/unit/llms/test_llamacpp.py @@ -14,11 +14,13 @@ import os import urllib.request -from typing import Generator +from typing import Any, Dict, Generator import pytest from distilabel.llms.llamacpp import LlamaCppLLM +from .utils import DummyUserDetail + @pytest.fixture(scope="module") def llm() -> Generator[LlamaCppLLM, None, None]: @@ -54,3 +56,62 @@ def test_generate(self, llm: LlamaCppLLM) -> None: assert len(responses) == 2 assert len(responses[0]) == 3 + + @pytest.mark.parametrize( + "structured_output, dump", + [ + ( + None, + { + "chat_format": None, + "extra_kwargs": {}, + "n_batch": 512, + "n_ctx": 512, + "n_gpu_layers": 0, + "seed": 4294967295, + "generation_kwargs": {}, + "structured_output": None, + "type_info": { + "module": "distilabel.llms.llamacpp", + "name": "LlamaCppLLM", + }, + "verbose": False, + }, + ), + ( + { + "schema": DummyUserDetail.model_json_schema(), + "format": "json", + }, + { + "chat_format": None, + "extra_kwargs": {}, + "n_batch": 512, + "n_ctx": 512, + "n_gpu_layers": 0, + "seed": 4294967295, + "generation_kwargs": {}, + "structured_output": { + "schema": DummyUserDetail.model_json_schema(), + "format": "json", + }, + "type_info": { + "module": "distilabel.llms.llamacpp", + "name": "LlamaCppLLM", + }, + "verbose": False, + }, + ), + ], + ) + def test_serialization( + self, structured_output: Dict[str, Any], dump: Dict[str, Any] + ) -> None: + llm = LlamaCppLLM( + model_path="tinyllama.gguf", + n_gpu_layers=0, + structured_output=structured_output, + ) + + assert llm.dump() == dump + assert isinstance(LlamaCppLLM.from_dict(dump), LlamaCppLLM) diff --git a/tests/unit/llms/test_vertexai.py b/tests/unit/llms/test_vertexai.py index b15262e26c..5d3f8d1217 100644 --- a/tests/unit/llms/test_vertexai.py +++ b/tests/unit/llms/test_vertexai.py @@ -115,7 +115,6 @@ def test_serialization(self, _: MagicMock) -> None: _dump = { "model": "gemini-1.0-pro", "generation_kwargs": {}, - "structured_output": None, "type_info": { "module": "distilabel.llms.vertexai", "name": "VertexAILLM", diff --git a/tests/unit/llms/test_vllm.py b/tests/unit/llms/test_vllm.py new file mode 100644 index 0000000000..4c847aad8e --- /dev/null +++ b/tests/unit/llms/test_vllm.py @@ -0,0 +1,170 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from typing import List + +import numpy as np +import pytest +from distilabel.llms import vLLM +from distilabel.llms.vllm import _sort_batches +from pydantic import BaseModel + + +class Character(BaseModel): + name: str + description: str + role: str + weapon: str + + +class Animal(BaseModel): + name: str + species: str + habitat: str + diet: str + + +SAMPLE_DATA = [ + [ + { + "instruction": "Generate a character from a RPG game.", + "structured_output": { + "format": "json", + "schema": Character.model_json_schema(), + }, + }, + { + "instruction": "Generate an animal from a zoo.", + "structured_output": { + "format": "json", + "schema": Animal.model_json_schema(), + }, + }, + { + "instruction": "Repeated character", + "structured_output": { + "format": "json", + "schema": Character.model_json_schema(), + }, + }, + { + "instruction": "What's the weather like today in Seattle in Celsius degrees?", + "structured_output": { + "format": "regex", + "schema": "(\\d{1,2})°C", + }, + }, + { + "instruction": "Other character", + "structured_output": { + "format": "json", + "schema": Character.model_json_schema(), + }, + }, + { + "instruction": "repeated regex", + "structured_output": { + "format": "regex", + "schema": "(\\d{1,2})°C", + }, + }, + ] +] + + +# Just a mock to avoid loading the model +class DummyTokenizer: + def __init__(self) -> None: + pass + + def apply_chat_template(self, input, **kwargs): + return input + + +class TestvLLM: + @pytest.mark.parametrize( + "num_generations, expected_sorted_batches", + [ + ( + 1, + [ + "Generate a character from a RPG game.", + "Generate an animal from a zoo.", + "Repeated character", + "What's the weather like today in Seattle in Celsius degrees?", + "Other character", + "repeated regex", + ], + ), + ( + 3, + np.repeat( + [ + "Generate a character from a RPG game.", + "Generate an animal from a zoo.", + "Repeated character", + "What's the weather like today in Seattle in Celsius degrees?", + "Other character", + "repeated regex", + ], + 3, + ).tolist(), + ), + ], + ) + def test_prepare_batches_and_sort_back( + self, num_generations: int, expected_sorted_batches: List[str] + ): + formatted_inputs = [ + (item["instruction"], item["structured_output"]) + for row in SAMPLE_DATA + for item in row + ] + llm = vLLM(model="dummy") + llm._tokenizer = DummyTokenizer() + batches, indices = llm._prepare_batches(formatted_inputs) + # NOTE: We have to simulate calling self._model.generate(n=num_generations) and then sorting the results + num_generations_batches = [] + for batch in batches: + num_generations_batches.append( + (np.repeat(batch[0], num_generations).tolist(), batch[1]) + ) + batches = num_generations_batches + # Recreate as the output from batched_outputs += [[output.text for output in outputs.outputs] for outputs in batch_outputs] + batches = [batch for batch, _ in batches] + sorted_batches = _sort_batches( + batches, indices, num_generations=num_generations + ) + + assert sorted_batches == [ + np.repeat( + [ + "Generate a character from a RPG game.", + "Generate an animal from a zoo.", + "Repeated character", + ], + num_generations, + ).tolist(), + np.repeat( + ["What's the weather like today in Seattle in Celsius degrees?"], + num_generations, + ).tolist(), + np.repeat( + [ + "Other character", + "repeated regex", + ], + num_generations, + ).tolist(), + ] diff --git a/tests/unit/steps/tasks/evol_instruct/test_base.py b/tests/unit/steps/tasks/evol_instruct/test_base.py index cea6fd75ca..9b679f6dfb 100644 --- a/tests/unit/steps/tasks/evol_instruct/test_base.py +++ b/tests/unit/steps/tasks/evol_instruct/test_base.py @@ -127,7 +127,6 @@ def test_serialization(self, dummy_llm: LLM) -> None: "input_batch_size": task.input_batch_size, "llm": { "generation_kwargs": {}, - "structured_output": None, "type_info": { "module": task.llm.__module__, "name": task.llm.__class__.__name__, diff --git a/tests/unit/steps/tasks/evol_instruct/test_generator.py b/tests/unit/steps/tasks/evol_instruct/test_generator.py index bdc09c9162..13cf6e2783 100644 --- a/tests/unit/steps/tasks/evol_instruct/test_generator.py +++ b/tests/unit/steps/tasks/evol_instruct/test_generator.py @@ -117,7 +117,6 @@ def test_serialization(self, dummy_llm: LLM) -> None: "name": "task", "llm": { "generation_kwargs": {}, - "structured_output": None, "type_info": { "module": task.llm.__class__.__module__, "name": task.llm.__class__.__name__, diff --git a/tests/unit/steps/tasks/evol_quality/test_base.py b/tests/unit/steps/tasks/evol_quality/test_base.py index c5c7ecbce4..fffacafa06 100644 --- a/tests/unit/steps/tasks/evol_quality/test_base.py +++ b/tests/unit/steps/tasks/evol_quality/test_base.py @@ -98,7 +98,6 @@ def test_serialization(self, dummy_llm: LLM) -> None: "input_batch_size": task.input_batch_size, "llm": { "generation_kwargs": {}, - "structured_output": None, "type_info": { "module": task.llm.__module__, "name": task.llm.__class__.__name__, diff --git a/tests/unit/steps/tasks/structured_outputs/test_outlines.py b/tests/unit/steps/tasks/structured_outputs/test_outlines.py index 549155076b..e174f53716 100644 --- a/tests/unit/steps/tasks/structured_outputs/test_outlines.py +++ b/tests/unit/steps/tasks/structured_outputs/test_outlines.py @@ -17,9 +17,10 @@ import pytest from distilabel.llms.huggingface.transformers import TransformersLLM from distilabel.steps.tasks.structured_outputs.outlines import ( - StructuredOutputType, + # StructuredOutputType, model_to_schema, ) +from distilabel.steps.tasks.typing import OutlinesStructuredOutputType from pydantic import BaseModel @@ -88,10 +89,6 @@ class DummyUserTest(BaseModel): class TestOutlinesIntegration: - # @pytest.mark.skipif( - # not DISTILABEL_RUN_SLOW_TESTS, - # reason="Slow tests, run locally when needed.", - # ) @pytest.mark.parametrize( "format, schema, prompt", [ @@ -99,7 +96,7 @@ class TestOutlinesIntegration: "json", DummyUserTest, "Create a user profile with the fields name, last_name and id", - ), # + ), ( "json", model_to_schema(DummyUserTest), @@ -117,7 +114,9 @@ def test_generation( ) -> None: llm = TransformersLLM( model="openaccess-ai-collective/tiny-mistral", - structured_output=StructuredOutputType(format=format, schema=schema), + structured_output=OutlinesStructuredOutputType( + format=format, schema=schema + ), ) llm.load() @@ -154,7 +153,9 @@ def test_serialization( ) -> None: llm = TransformersLLM( model="openaccess-ai-collective/tiny-mistral", - structured_output=StructuredOutputType(format=format, schema=schema), + structured_output=OutlinesStructuredOutputType( + format=format, schema=schema + ), ) llm.load() assert llm.dump() == dump diff --git a/tests/unit/steps/tasks/test_base.py b/tests/unit/steps/tasks/test_base.py index 4a9f566c6a..e24dd0fefb 100644 --- a/tests/unit/steps/tasks/test_base.py +++ b/tests/unit/steps/tasks/test_base.py @@ -202,7 +202,6 @@ def test_serialization(self) -> None: "input_batch_size": 50, "llm": { "generation_kwargs": {}, - "structured_output": None, "type_info": { "module": "tests.unit.steps.tasks.utils", "name": "DummyLLM", diff --git a/tests/unit/steps/tasks/test_structured_generation.py b/tests/unit/steps/tasks/test_structured_generation.py index febc7f698f..e2c230ef7e 100644 --- a/tests/unit/steps/tasks/test_structured_generation.py +++ b/tests/unit/steps/tasks/test_structured_generation.py @@ -52,11 +52,11 @@ def test_format_input(self) -> None: { "instruction": "test", "system_prompt": "test", - "grammar": {"type": "regex", "value": r"[a-zA-Z]+"}, + "structured_output": {"format": "regex", "schema": r"[a-zA-Z]+"}, } ) == ( [{"role": "user", "content": "test"}], - {"type": "regex", "value": r"[a-zA-Z]+"}, + {"format": "regex", "schema": r"[a-zA-Z]+"}, ) # 2. Not including the `grammar` field within the input @@ -92,9 +92,9 @@ def test_process(self) -> None: [ { "instruction": "test", - "grammar": { - "type": "json", - "value": { + "structured_output": { + "format": "json", + "schema": { "properties": { "test": {"title": "Test", "type": "string"} }, @@ -109,9 +109,9 @@ def test_process(self) -> None: ) == [ { "instruction": "test", - "grammar": { - "type": "json", - "value": { + "structured_output": { + "format": "json", + "schema": { "properties": {"test": {"title": "Test", "type": "string"}}, "required": ["test"], "title": "Test", From ee573fb7f101953ab936cf0f54f99812f258ad93 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Thu, 13 Jun 2024 13:07:45 +0200 Subject: [PATCH 33/40] Update docs document phrasing and funnel (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update mkdocs version * Update align documentation with argilla SDK 2.0 * Updated naming of basices Moved CLI to advanced * Delete unneeded index pages * Update naming * Update navigation and content edit * Update naming of How to guides * Add popular issue and community page * Update GITHUB_ACCESS_TOKEN to GH_ACCESS_TOKEN due to protected naming * Update scoped reqs for token * Add GH_ACCESS_TOKEN to workflow * Delete literate nav * Update jinja templates to hide unrendered navigation * Update navigation orderin for API reference * Update docs/sections/how_to_guides/advanced/structured_generation.md Co-authored-by: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com> * docs: prose in guides (#721) * docs: make argilla prose talk about argilla * docs: simplify prose in generator and global steps * Update LLM page * Update LLM docs * Update Pipeline docs * Avoid using "function" * Update Step documentation * Update docs/sections/how_to_guides/basic/step/index.md Co-authored-by: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com> * Update docs/sections/how_to_guides/basic/step/index.md Co-authored-by: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com> * Update `Task` page * Update definiton of `GeneratorTask` * Update Step documentation * Update advanced documentation --------- Co-authored-by: davidberenstein1957 Co-authored-by: Agus Co-authored-by: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com> * Make `GH_ACCESS_TOKEN` optional * Add `pandas>=2.0` to `docs` * Fix typo * Update default signature GeneratorStep * Update missing `mkdocs_autorefs` within API reference * Update API page * Update CHATML_TEMPLATE formatting to avoid autodoc issues * Add reference to token scopes required --------- Co-authored-by: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com> Co-authored-by: burtenshaw Co-authored-by: Agus Co-authored-by: Gabriel Martín Blázquez --- .github/workflows/docs.yml | 4 + docs/api/cli.md | 2 +- docs/api/distiset.md | 6 + docs/api/llm/cohere.md | 3 + docs/api/llm/index.md | 2 +- docs/api/pipeline/index.md | 2 +- docs/api/step/decorator.md | 2 +- docs/api/step/generator_step.md | 2 +- docs/api/step/global_step.md | 2 +- docs/api/step/index.md | 2 +- docs/api/step_gallery/columns.md | 2 +- docs/api/step_gallery/extra.md | 1 + docs/api/step_gallery/hugging_face.md | 7 + docs/api/task/generator_task.md | 2 +- docs/api/task/index.md | 2 +- docs/api/task/typing.md | 11 ++ docs/api/task_gallery/index.md | 1 + .../steps/argilla/preference.png | Bin .../steps/argilla/text_generation.png | Bin docs/index.md | 14 +- docs/scripts/gen_popular_issues.py | 180 +++++++++++++++++ docs/sections/community/index.md | 44 +++++ docs/sections/{ => getting_started}/faq.md | 8 +- .../{ => getting_started}/installation.md | 6 + .../quickstart.md} | 16 +- .../advanced/argilla.md | 20 +- .../advanced/caching.md | 6 +- .../advanced}/cli/index.md | 0 .../advanced/distiset.md | 68 ++++--- .../advanced/fs_to_pass_data.md | 0 .../advanced/structured_generation.md | 14 +- .../sections/how_to_guides/basic/llm/index.md | 145 ++++++++++++++ .../basic}/pipeline/index.md | 186 ++++++++---------- .../basic/step/generator_step.md | 110 +++++++++++ .../how_to_guides/basic/step/global_step.md | 67 +++++++ .../how_to_guides/basic/step/index.md | 132 +++++++++++++ .../basic}/task/generator_task.md | 26 +-- .../how_to_guides/basic/task/index.md | 76 +++++++ docs/sections/learn/advanced/index.md | 3 - docs/sections/learn/index.md | 3 - docs/sections/learn/tutorial/index.md | 5 - docs/sections/learn/tutorial/llm/index.md | 164 --------------- .../learn/tutorial/step/generator_step.md | 117 ----------- .../learn/tutorial/step/global_step.md | 70 ------- docs/sections/learn/tutorial/step/index.md | 142 ------------- docs/sections/learn/tutorial/task/index.md | 83 -------- .../pipeline_samples/examples/index.md | 2 +- docs/sections/pipeline_samples/index.md | 3 - .../sections/pipeline_samples/papers/deita.md | 4 +- .../papers/instruction_backtranslation.md | 6 +- .../pipeline_samples/papers/prometheus.md | 6 +- .../pipeline_samples/papers/ultrafeedback.md | 6 +- mkdocs.yml | 81 ++++---- pyproject.toml | 3 +- src/distilabel/llms/chat_templates.py | 2 +- src/distilabel/llms/vllm.py | 2 +- src/distilabel/steps/tasks/prometheus_eval.py | 8 +- .../utils/mkdocs/components_gallery.py | 22 ++- .../components-gallery/components-list.jinja2 | 4 +- .../templates/components-gallery/index.md | 4 +- .../components-gallery/llm-detail.jinja2 | 6 +- .../components-gallery/step-detail.jinja2 | 5 +- 62 files changed, 1063 insertions(+), 859 deletions(-) create mode 100644 docs/api/distiset.md create mode 100644 docs/api/llm/cohere.md create mode 100644 docs/api/step_gallery/hugging_face.md create mode 100644 docs/api/task/typing.md rename docs/assets/images/sections/{learn => how_to_guides}/steps/argilla/preference.png (100%) rename docs/assets/images/sections/{learn => how_to_guides}/steps/argilla/text_generation.png (100%) create mode 100644 docs/scripts/gen_popular_issues.py create mode 100644 docs/sections/community/index.md rename docs/sections/{ => getting_started}/faq.md (92%) rename docs/sections/{ => getting_started}/installation.md (97%) rename docs/sections/{how_to_guide.md => getting_started/quickstart.md} (79%) rename docs/sections/{learn => how_to_guides}/advanced/argilla.md (70%) rename docs/sections/{learn => how_to_guides}/advanced/caching.md (98%) rename docs/sections/{learn/tutorial => how_to_guides/advanced}/cli/index.md (100%) rename docs/sections/{learn => how_to_guides}/advanced/distiset.md (66%) rename docs/sections/{learn => how_to_guides}/advanced/fs_to_pass_data.md (100%) rename docs/sections/{learn => how_to_guides}/advanced/structured_generation.md (85%) create mode 100644 docs/sections/how_to_guides/basic/llm/index.md rename docs/sections/{learn/tutorial => how_to_guides/basic}/pipeline/index.md (50%) create mode 100644 docs/sections/how_to_guides/basic/step/generator_step.md create mode 100644 docs/sections/how_to_guides/basic/step/global_step.md create mode 100644 docs/sections/how_to_guides/basic/step/index.md rename docs/sections/{learn/tutorial => how_to_guides/basic}/task/generator_task.md (55%) create mode 100644 docs/sections/how_to_guides/basic/task/index.md delete mode 100644 docs/sections/learn/advanced/index.md delete mode 100644 docs/sections/learn/index.md delete mode 100644 docs/sections/learn/tutorial/index.md delete mode 100644 docs/sections/learn/tutorial/llm/index.md delete mode 100644 docs/sections/learn/tutorial/step/generator_step.md delete mode 100644 docs/sections/learn/tutorial/step/global_step.md delete mode 100644 docs/sections/learn/tutorial/step/index.md delete mode 100644 docs/sections/learn/tutorial/task/index.md delete mode 100644 docs/sections/pipeline_samples/index.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 46e55a7e0c..c5abc04ca1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -45,6 +45,10 @@ jobs: - run: mike deploy dev --push if: github.ref == 'refs/heads/develop' + env: + GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} - run: mike deploy ${{ github.ref_name }} latest --update-aliases --push if: startsWith(github.ref, 'refs/tags/') + env: + GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} diff --git a/docs/api/cli.md b/docs/api/cli.md index acdc2bef06..4757cb1470 100644 --- a/docs/api/cli.md +++ b/docs/api/cli.md @@ -1,6 +1,6 @@ # Command Line Interface (CLI) -This section contains the API reference for the CLI. For more information on how to use the CLI, see [Tutorial - CLI](../sections/learn/tutorial/cli/index.md). +This section contains the API reference for the CLI. For more information on how to use the CLI, see [Tutorial - CLI](../sections/how_to_guides/advanced/cli/index.md). ## Utility functions for the `distilabel pipeline` sub-commands diff --git a/docs/api/distiset.md b/docs/api/distiset.md new file mode 100644 index 0000000000..71b57c43ca --- /dev/null +++ b/docs/api/distiset.md @@ -0,0 +1,6 @@ +# Distiset + +This section contains the API reference for the Distiset. For more information on how to use the CLI, see [Tutorial - CLI](../sections/how_to_guides/advanced/distiset.md). + +:::distilabel.distiset.Distiset +:::distilabel.distiset.create_distiset diff --git a/docs/api/llm/cohere.md b/docs/api/llm/cohere.md new file mode 100644 index 0000000000..c7064b7a75 --- /dev/null +++ b/docs/api/llm/cohere.md @@ -0,0 +1,3 @@ +# CohereLLM + +::: distilabel.llms.cohere diff --git a/docs/api/llm/index.md b/docs/api/llm/index.md index 27628e034a..fe58a65384 100644 --- a/docs/api/llm/index.md +++ b/docs/api/llm/index.md @@ -2,6 +2,6 @@ This section contains the API reference for the `distilabel` LLMs, both for the [`LLM`][distilabel.llms.LLM] synchronous implementation, and for the [`AsyncLLM`][distilabel.llms.AsyncLLM] asynchronous one. -For more information and examples on how to use existing LLMs or create custom ones, please refer to [Tutorial - LLM](../../sections/learn/tutorial/llm/index.md). +For more information and examples on how to use existing LLMs or create custom ones, please refer to [Tutorial - LLM](../../sections/how_to_guides/basic/llm/index.md). ::: distilabel.llms.base diff --git a/docs/api/pipeline/index.md b/docs/api/pipeline/index.md index f727ecea81..40c40426a0 100644 --- a/docs/api/pipeline/index.md +++ b/docs/api/pipeline/index.md @@ -1,6 +1,6 @@ # Pipeline -This section contains the API reference for the `distilabel` pipelines. For an example on how to use the pipelines, see the [Tutorial - Pipeline](../../sections/learn/tutorial/pipeline/index.md). +This section contains the API reference for the `distilabel` pipelines. For an example on how to use the pipelines, see the [Tutorial - Pipeline](../../sections/how_to_guides/basic/pipeline/index.md). ::: distilabel.pipeline.base ::: distilabel.pipeline.local diff --git a/docs/api/step/decorator.md b/docs/api/step/decorator.md index 98e6c6eefe..73844910e5 100644 --- a/docs/api/step/decorator.md +++ b/docs/api/step/decorator.md @@ -2,6 +2,6 @@ This section contains the reference for the `@step` decorator, used to create new [`Step`][distilabel.steps.Step] subclasses without having to manually define the class. -For more information check the [Tutorial - Step](../../sections/learn/tutorial/step/index.md) page. +For more information check the [Tutorial - Step](../../sections/how_to_guides/basic/step/index.md) page. ::: distilabel.steps.decorator diff --git a/docs/api/step/generator_step.md b/docs/api/step/generator_step.md index 5055b51545..949202eefd 100644 --- a/docs/api/step/generator_step.md +++ b/docs/api/step/generator_step.md @@ -2,6 +2,6 @@ This section contains the API reference for the [`GeneratorStep`][distilabel.steps.base.GeneratorStep] class. -For more information and examples on how to use existing generator steps or create custom ones, please refer to [Tutorial - Step - GeneratorStep](../../sections/learn/tutorial/step/generator_step.md). +For more information and examples on how to use existing generator steps or create custom ones, please refer to [Tutorial - Step - GeneratorStep](../../sections/how_to_guides/basic/step/generator_step.md). ::: distilabel.steps.base.GeneratorStep diff --git a/docs/api/step/global_step.md b/docs/api/step/global_step.md index e2a4d9f8e1..c2fba5ac38 100644 --- a/docs/api/step/global_step.md +++ b/docs/api/step/global_step.md @@ -2,6 +2,6 @@ This section contains the API reference for the [`GlobalStep`][distilabel.steps.base.GlobalStep] class. -For more information and examples on how to use existing global steps or create custom ones, please refer to [Tutorial - Step - GlobalStep](../../sections/learn/tutorial/step/global_step.md). +For more information and examples on how to use existing global steps or create custom ones, please refer to [Tutorial - Step - GlobalStep](../../sections/how_to_guides/basic/step/global_step.md). ::: distilabel.steps.base.GlobalStep diff --git a/docs/api/step/index.md b/docs/api/step/index.md index ac5de3bdab..cc49224bb3 100644 --- a/docs/api/step/index.md +++ b/docs/api/step/index.md @@ -2,7 +2,7 @@ This section contains the API reference for the `distilabel` step, both for the [`_Step`][distilabel.steps.base._Step] base class and the [`Step`][distilabel.steps.Step] class. -For more information and examples on how to use existing steps or create custom ones, please refer to [Tutorial - Step](../../sections/learn/tutorial/step/index.md). +For more information and examples on how to use existing steps or create custom ones, please refer to [Tutorial - Step](../../sections/how_to_guides/basic/step/index.md). ::: distilabel.steps.base options: diff --git a/docs/api/step_gallery/columns.md b/docs/api/step_gallery/columns.md index 2d30080f09..80fa7adfee 100644 --- a/docs/api/step_gallery/columns.md +++ b/docs/api/step_gallery/columns.md @@ -1,6 +1,6 @@ # Columns -This section contains the existing steps intended to be used for commong column operations to apply to the batches. +This section contains the existing steps intended to be used for common column operations to apply to the batches. ::: distilabel.steps.combine ::: distilabel.steps.expand diff --git a/docs/api/step_gallery/extra.md b/docs/api/step_gallery/extra.md index 4eecb44e5d..e310e45d4b 100644 --- a/docs/api/step_gallery/extra.md +++ b/docs/api/step_gallery/extra.md @@ -1,5 +1,6 @@ # Extra +::: distilabel.steps.generators.data ::: distilabel.steps.deita ::: distilabel.steps.formatting ::: distilabel.steps.typing diff --git a/docs/api/step_gallery/hugging_face.md b/docs/api/step_gallery/hugging_face.md new file mode 100644 index 0000000000..42fb85e795 --- /dev/null +++ b/docs/api/step_gallery/hugging_face.md @@ -0,0 +1,7 @@ +# Hugging Face + +This section contains the existing steps integrated with `Hugging Face` so as to easily push the generated datasets to Hugging Face. + +::: distilabel.steps.LoadDataFromDisk +::: distilabel.steps.LoadDataFromFileSystem +::: distilabel.steps.LoadDataFromHub diff --git a/docs/api/task/generator_task.md b/docs/api/task/generator_task.md index 309aba2a16..31748034df 100644 --- a/docs/api/task/generator_task.md +++ b/docs/api/task/generator_task.md @@ -2,6 +2,6 @@ This section contains the API reference for the `distilabel` generator tasks. -For more information on how the [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask] works and see some examples, check the [Tutorial - Task - GeneratorTask](../../sections/learn/tutorial/task/generator_task.md) page. +For more information on how the [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask] works and see some examples, check the [Tutorial - Task - GeneratorTask](../../sections/how_to_guides/basic/task/generator_task.md) page. ::: distilabel.steps.tasks.base.GeneratorTask diff --git a/docs/api/task/index.md b/docs/api/task/index.md index f280ab02bd..ee32b63aae 100644 --- a/docs/api/task/index.md +++ b/docs/api/task/index.md @@ -2,7 +2,7 @@ This section contains the API reference for the `distilabel` tasks. -For more information on how the [`Task`][distilabel.steps.tasks.Task] works and see some examples, check the [Tutorial - Task](../../sections/learn/tutorial/task/index.md) page. +For more information on how the [`Task`][distilabel.steps.tasks.Task] works and see some examples, check the [Tutorial - Task](../../sections/how_to_guides/basic/task/index.md) page. ::: distilabel.steps.tasks.base options: diff --git a/docs/api/task/typing.md b/docs/api/task/typing.md new file mode 100644 index 0000000000..ee88398e86 --- /dev/null +++ b/docs/api/task/typing.md @@ -0,0 +1,11 @@ +# Task Typing + +This section contains typing classes implemented in distilabel. + +::: distilabel.steps.tasks.typing.ChatType + options: + members: + - _ChatType + - ChatType +::: distilabel.steps.tasks.structured_outputs.outlines.StructuredOutputType +::: distilabel.steps.tasks.structured_outputs.instructor.InstructorStructuredOutputType \ No newline at end of file diff --git a/docs/api/task_gallery/index.md b/docs/api/task_gallery/index.md index 96d38ac958..4cf90c479d 100644 --- a/docs/api/task_gallery/index.md +++ b/docs/api/task_gallery/index.md @@ -9,3 +9,4 @@ This section contains the existing [`Task`][distilabel.steps.tasks.Task] subclas - "!_Task" - "!GeneratorTask" - "!ChatType" + - "!typing" \ No newline at end of file diff --git a/docs/assets/images/sections/learn/steps/argilla/preference.png b/docs/assets/images/sections/how_to_guides/steps/argilla/preference.png similarity index 100% rename from docs/assets/images/sections/learn/steps/argilla/preference.png rename to docs/assets/images/sections/how_to_guides/steps/argilla/preference.png diff --git a/docs/assets/images/sections/learn/steps/argilla/text_generation.png b/docs/assets/images/sections/how_to_guides/steps/argilla/text_generation.png similarity index 100% rename from docs/assets/images/sections/learn/steps/argilla/text_generation.png rename to docs/assets/images/sections/how_to_guides/steps/argilla/text_generation.png diff --git a/docs/index.md b/docs/index.md index 8a109bbaf3..2d7f6c0895 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,16 +58,6 @@ Compute is expensive and output quality is important. We help you **focus on dat Synthesize and judge data with **latest research papers** while ensuring **flexibility, scalability and fault tolerance**. So you can focus on improving your data and training your models. -## 🏘️ Community - -We are an open-source community-driven project and we love to hear from you. Here are some ways to get involved: - -- [Community Meetup](https://lu.ma/embed-checkout/evt-IQtRiSuXZCIW6FB): listen in or present during one of our bi-weekly events. - -- [Slack](https://join.slack.com/t/rubrixworkspace/shared_invite/zt-whigkyjn-a3IUJLD7gDbTZ0rKlvcJ5g): get direct support from the community. - -- [Roadmap](https://github.com/orgs/argilla-io/projects/10/views/1): plans change but we love to discuss those with our community so feel encouraged to participate. - ## What do people build with Distilabel? Distilabel is a tool that can be used to **synthesize data and provide AI feedback**. Our community uses Distilabel to create amazing [datasets](https://huggingface.co/datasets?other=distilabel) and [models](https://huggingface.co/models?other=distilabel), and **we love contributions to open-source** ourselves too. @@ -113,14 +103,14 @@ Then run: ```python from distilabel.llms import OpenAILLM from distilabel.pipeline import Pipeline -from distilabel.steps import LoadHubDataset +from distilabel.steps import LoadDataFromHub from distilabel.steps.tasks import TextGeneration with Pipeline( name="simple-text-generation-pipeline", description="A simple text generation pipeline", ) as pipeline: - load_dataset = LoadHubDataset(output_mappings={"prompt": "instruction"}) + load_dataset = LoadDataFromHub(output_mappings={"prompt": "instruction"}) generate_with_openai = TextGeneration(llm=OpenAILLM(model="gpt-3.5-turbo")) diff --git a/docs/scripts/gen_popular_issues.py b/docs/scripts/gen_popular_issues.py new file mode 100644 index 0000000000..aff095214e --- /dev/null +++ b/docs/scripts/gen_popular_issues.py @@ -0,0 +1,180 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 os +from datetime import datetime +from typing import List, Union + +import pandas as pd +import requests +import mkdocs_gen_files + + +REPOSITORY = "argilla-io/distilabel" +DATA_PATH = "sections/community/popular_issues.md" + +GITHUB_ACCESS_TOKEN = os.getenv( + "GH_ACCESS_TOKEN" +) # public_repo and read:org scopes are required + + +def fetch_issues_from_github_repository( + repository: str, auth_token: Union[str, None] = None +) -> pd.DataFrame: + if auth_token is None: + return pd.DataFrame( + { + "Issue": [], + "State": [], + "Created at": [], + "Closed at": [], + "Last update": [], + "Labels": [], + "Milestone": [], + "Reactions": [], + "Comments": [], + "URL": [], + "Repository": [], + "Author": [], + } + ) + + headers = { + "Authorization": f"token {auth_token}", + "Accept": "application/vnd.github.v3+json", + } + issues_data = [] + + print(f"Fetching issues from '{repository}'...") + with requests.Session() as session: + session.headers.update(headers) + + owner, repo_name = repository.split("/") + issues_url = ( + f"https://api.github.com/repos/{owner}/{repo_name}/issues?state=all" + ) + + while issues_url: + response = session.get(issues_url) + issues = response.json() + + for issue in issues: + issues_data.append( + { + "Issue": f"{issue['number']} - {issue['title']}", + "State": issue["state"], + "Created at": issue["created_at"], + "Closed at": issue.get("closed_at", None), + "Last update": issue["updated_at"], + "Labels": [label["name"] for label in issue["labels"]], + "Milestone": (issue.get("milestone") or {}).get("title"), + "Reactions": issue["reactions"]["total_count"], + "Comments": issue["comments"], + "URL": issue["html_url"], + "Repository": repo_name, + "Author": issue["user"]["login"], + } + ) + + issues_url = response.links.get("next", {}).get("url", None) + + return pd.DataFrame(issues_data) + + +def get_org_members(auth_token: Union[str, None] = None) -> List[str]: + if auth_token is None: + return [] + + headers = { + "Authorization": f"token {auth_token}", + "Accept": "application/vnd.github.v3+json", + } + members_list = [] + + members_url = "https://api.github.com/orgs/argilla-io/members" + + while members_url: + response = requests.get(members_url, headers=headers) + members = response.json() + + for member in members: + members_list.append(member["login"]) + + members_list.extend(["pre-commit-ci[bot]"]) + + members_url = response.links.get("next", {}).get("url", None) + + return members_list + + +with mkdocs_gen_files.open(DATA_PATH, "w") as f: + df = fetch_issues_from_github_repository(REPOSITORY, GITHUB_ACCESS_TOKEN) + + open_issues = df.loc[df["State"] == "open"] + engagement_df = ( + open_issues[["URL", "Issue", "Repository", "Reactions", "Comments"]] + .sort_values(by=["Reactions", "Comments"], ascending=False) + .head(10) + .reset_index() + ) + + members = get_org_members(GITHUB_ACCESS_TOKEN) + community_issues = df.loc[~df["Author"].isin(members)] + community_issues_df = ( + community_issues[ + ["URL", "Issue", "Repository", "Created at", "Author", "State"] + ] + .sort_values(by=["Created at"], ascending=False) + .head(10) + .reset_index() + ) + + planned_issues = df.loc[df["Milestone"].notna()] + planned_issues_df = ( + planned_issues[ + ["URL", "Issue", "Repository", "Created at", "Milestone", "State"] + ] + .sort_values(by=["Milestone"], ascending=False) + .head(10) + .reset_index() + ) + + f.write('=== "Most engaging open issues"\n\n') + f.write(" | Rank | Issue | Reactions | Comments |\n") + f.write(" |------|-------|:---------:|:--------:|\n") + for ix, row in engagement_df.iterrows(): + f.write( + f" | {ix+1} | [{row['Issue']}]({row['URL']}) | 👍 {row['Reactions']} | 💬 {row['Comments']} |\n" + ) + + f.write('\n=== "Latest issues open by the community"\n\n') + f.write(" | Rank | Issue | Author |\n") + f.write(" |------|-------|:------:|\n") + for ix, row in community_issues_df.iterrows(): + state = "🟢" if row["State"] == "open" else "🟣" + f.write( + f" | {ix+1} | {state} [{row['Issue']}]({row['URL']}) | by **{row['Author']}** |\n" + ) + + f.write('\n=== "Planned issues for upcoming releases"\n\n') + f.write(" | Rank | Issue | Milestone |\n") + f.write(" |------|-------|:------:|\n") + for ix, row in planned_issues_df.iterrows(): + state = "🟢" if row["State"] == "open" else "🟣" + f.write( + f" | {ix+1} | {state} [{row['Issue']}]({row['URL']}) | **{row['Milestone']}** |\n" + ) + + today = datetime.today().date() + f.write(f"\nLast update: {today}\n") diff --git a/docs/sections/community/index.md b/docs/sections/community/index.md new file mode 100644 index 0000000000..e7bef08111 --- /dev/null +++ b/docs/sections/community/index.md @@ -0,0 +1,44 @@ +--- +hide: + - toc + - footer +--- + +We are an open-source community-driven project not only focused on building a great product but also on building a great community, where you can get support, share your experiences, and contribute to the project! We would love to hear from you and help you get started with distilabel. + +

    \ No newline at end of file diff --git a/docs/sections/faq.md b/docs/sections/getting_started/faq.md similarity index 92% rename from docs/sections/faq.md rename to docs/sections/getting_started/faq.md index 58e7c13a07..d70b961897 100644 --- a/docs/sections/faq.md +++ b/docs/sections/getting_started/faq.md @@ -1,3 +1,9 @@ +--- +description: Distilabel is an AI Feedback (AIF) framework for building datasets with and for LLMs. +hide: + - toc +--- + # Frequent Asked Questions (FAQ) ??? faq "How can I rename the columns in a batch?" @@ -30,6 +36,6 @@ All the data will be stored in `.cache/distilabel`, but the only data that will persist at the end of the `Pipeline.run` execution is the one from the leaf step/s, so bear that in mind. - For more information on the caching mechanism in `distilabel`, you can check the [Learn - Advanced - Caching](./learn/advanced/caching.md) section. + For more information on the caching mechanism in `distilabel`, you can check the [Learn - Advanced - Caching](../how_to_guides/advanced/caching.md) section. Also note that when running a [`Step`][distilabel.steps.base.Step] or a [`Task`][distilabel.steps.tasks.Task] standalone, the cache mechanism won't be used, so if you want to use that, you should use the `Pipeline` context manager. diff --git a/docs/sections/installation.md b/docs/sections/getting_started/installation.md similarity index 97% rename from docs/sections/installation.md rename to docs/sections/getting_started/installation.md index 3244622762..07e473795a 100644 --- a/docs/sections/installation.md +++ b/docs/sections/getting_started/installation.md @@ -1,3 +1,9 @@ +--- +description: Distilabel is an AI Feedback (AIF) framework for building datasets with and for LLMs. +hide: + - toc +--- + # Installation !!! NOTE diff --git a/docs/sections/how_to_guide.md b/docs/sections/getting_started/quickstart.md similarity index 79% rename from docs/sections/how_to_guide.md rename to docs/sections/getting_started/quickstart.md index f12b18a63a..f982ae319f 100644 --- a/docs/sections/how_to_guide.md +++ b/docs/sections/getting_started/quickstart.md @@ -1,20 +1,26 @@ -# How to Guide +--- +description: Distilabel is an AI Feedback (AIF) framework for building datasets with and for LLMs. +hide: + - toc +--- + +# Quickstart To start off, `distilabel` is a framework for building pipelines for generating synthetic data using LLMs, that defines a [`Pipeline`][distilabel.pipeline.Pipeline] which orchestrates the execution of the [`Step`][distilabel.steps.base.Step] subclasses, and those will be connected as nodes in a Direct Acyclic Graph (DAG). -This being said, in this guide we will walk you through the process of creating a simple pipeline that uses the [`OpenAILLM`][distilabel.llms.OpenAILLM] class to generate text.å The [`Pipeline`][distilabel.pipeline.Pipeline] will load a dataset that contains a column named `prompt` from the Hugging Face Hub via the step [`LoadHubDataset`][distilabel.steps.LoadHubDataset] and then use the [`OpenAILLM`][distilabel.llms.OpenAILLM] class to generate text based on the dataset using the [`TextGeneration`][distilabel.steps.tasks.TextGeneration] task. +That being said, in this guide we will walk you through the process of creating a simple pipeline that uses the [`OpenAILLM`][distilabel.llms.OpenAILLM] class to generate text. The [`Pipeline`][distilabel.pipeline.Pipeline] will load a dataset that contains a column named `prompt` from the Hugging Face Hub via the step [`LoadDataFromHub`][distilabel.steps.LoadDataFromHub] and then use the [`OpenAILLM`][distilabel.llms.OpenAILLM] class to generate text based on the dataset using the [`TextGeneration`][distilabel.steps.tasks.TextGeneration] task. ```python from distilabel.llms import OpenAILLM from distilabel.pipeline import Pipeline -from distilabel.steps import LoadHubDataset +from distilabel.steps import LoadDataFromHub from distilabel.steps.tasks import TextGeneration with Pipeline( # (1) name="simple-text-generation-pipeline", description="A simple text generation pipeline", ) as pipeline: # (2) - load_dataset = LoadHubDataset( # (3) + load_dataset = LoadDataFromHub( # (3) name="load_dataset", output_mappings={"prompt": "instruction"}, ) @@ -50,7 +56,7 @@ if __name__ == "__main__": 2. We are using the [`Pipeline`][distilabel.pipeline.Pipeline] context manager, meaning that every [`Step`][distilabel.steps.base.Step] subclass that is defined within the context manager will be added to the pipeline automatically. -3. We define a [`LoadHubDataset`][distilabel.steps.LoadHubDataset] step named `load_dataset` that will load a dataset from the Hugging Face Hub, as provided via runtime parameters in the `pipeline.run` method below, but it can also be defined within the class instance via the arg `repo_id=...`. This step will basically produce output batches with the rows from the dataset, and the column `prompt` will be mapped to the `instruction` field. +3. We define a [`LoadDataFromHub`][distilabel.steps.LoadDataFromHub] step named `load_dataset` that will load a dataset from the Hugging Face Hub, as provided via runtime parameters in the `pipeline.run` method below, but it can also be defined within the class instance via the arg `repo_id=...`. This step will basically produce output batches with the rows from the dataset, and the column `prompt` will be mapped to the `instruction` field. 4. We define a [`TextGeneration`][distilabel.steps.tasks.TextGeneration] task named `text_generation` that will generate text based on the `instruction` field from the dataset. This task will use the [`OpenAILLM`][distilabel.llms.OpenAILLM] class with the model `gpt-3.5-turbo`. diff --git a/docs/sections/learn/advanced/argilla.md b/docs/sections/how_to_guides/advanced/argilla.md similarity index 70% rename from docs/sections/learn/advanced/argilla.md rename to docs/sections/how_to_guides/advanced/argilla.md index bea24b74b7..43537eb14b 100644 --- a/docs/sections/learn/advanced/argilla.md +++ b/docs/sections/how_to_guides/advanced/argilla.md @@ -1,20 +1,17 @@ -# Argilla +# Export data to Argilla -As an additional step, besides being able to restore the dataset from the [`Pipeline`][distilabel.pipeline.Pipeline] output as a [`Distiset`][distilabel.distiset.Distiset] (which is a `datasets.DatasetDict` with multiple configurations depending on the leaf nodes of the [`Pipeline`][distilabel.pipeline.Pipeline]), one can also include a [`Step`][distilabel.steps.Step] within the [`Pipeline`][distilabel.pipeline.Pipeline] to easily export the datasets to Argilla with a pre-defined configuration, suiting the annotation purposes. +Being able to export the generated synthetic datasets to Argilla, is a core feature within `distilabel`. We believe in the potential of synthetic data, but without removing the impact a human annotator or group of annotators can bring. So on, the Argilla integration makes it straightforward to push a dataset to Argilla while the [`Pipeline`][distilabel.pipeline.Pipeline] is running, to be able to follow along the generation process in Argilla's UI, as well as annotating the records on the fly. One can include a [`Step`][distilabel.steps.Step] within the [`Pipeline`][distilabel.pipeline.Pipeline] to easily export the datasets to Argilla with a pre-defined configuration, suiting the annotation purposes. -Being able to export the generated synthetic datasets to Argilla, was one of the core features we wanted to have integrated within `distilabel` because we believe in the potential of synthetic data, but without removing the impact a human annotator or group of annotators can bring. So on, the Argilla integration makes it straightforward to push a dataset to Argilla while the [`Pipeline`][distilabel.pipeline.Pipeline] is running, to be able to follow along the generation process in Argilla's UI, as well as annotating the records on the fly. - -Before using any of the steps about to be described below, you should first have an Argilla instance up and running, so that you can successfully upload the data to Argilla. In order to deploy Argilla, the easiest and most straight forward way is to deploy it via the [Argilla Template in Hugging Face Spaces](https://docs.argilla.io/en/latest/getting_started/installation/deployments/huggingface-spaces.html) as simply as following the steps there, or just via the following button: +Before using any of the steps about to be described below, you should first have an Argilla instance up and running, so that you can successfully upload the data to Argilla. In order to deploy Argilla, the easiest and most straightforward way is to deploy it via the [Argilla Template in Hugging Face Spaces](https://huggingface.co/docs/hub/en/spaces-sdks-docker-argilla) as simply as following the steps there, or just via the following button: -Additionally, Argilla offer multiple deployment options listed in the [Argilla Documentation - Installation](https://docs.argilla.io/en/latest/getting_started/installation/deployments/deployments.html) page. ### Text Generation -For text generation scenarios, i.e. when the [`Pipeline`][distilabel.pipeline.Pipeline] contains a [`TextGeneration`][distilabel.steps.tasks.TextGeneration] step, we have designed the task [`TextGenerationToArgilla`][distilabel.steps.TextGenerationToArgilla], which will seamlessly push the generated data to Argilla, and allow the annotator to review the records. +For text generation scenarios, i.e. when the [`Pipeline`][distilabel.pipeline.Pipeline] contains a single [`TextGeneration`][distilabel.steps.tasks.TextGeneration] step, we have designed the task [`TextGenerationToArgilla`][distilabel.steps.TextGenerationToArgilla], which will seamlessly push the generated data to Argilla, and allow the annotator to review the records. The dataset will be pushed with the following configuration: @@ -58,7 +55,7 @@ with Pipeline(name="my-pipeline") as pipeline: pipeline.run() ``` -![Text Generation to Argilla](../../../assets/images/sections/learn/steps/argilla/text_generation.png) +![Text Generation to Argilla](../../../assets/images/sections/how_to_guides/steps/argilla/text_generation.png) ### Preference @@ -74,7 +71,7 @@ The dataset will be pushed with the following configuration: The [`PreferenceToArgilla`][distilabel.steps.PreferenceToArgilla] step will only work if the [`Pipeline`][distilabel.pipeline.Pipeline] contains multiple [`TextGeneration`][distilabel.steps.tasks.TextGeneration] steps, or if the columns `instruction` and `generations` are available within the batch data. Otherwise, the variable `input_mappings` will need to be set so that either both or one of `instruction` and `generations` are mapped to one of the existing columns in the batch data. !!! NOTE - Additionally, if the [`Pipeline`][distilabel.pipeline.Pipeline] contains an [`UltraFeedback`][distilabel.steps.tasks.UltraFeedback] step, the `ratings` and `rationales` will also be available, so if that's the case, those will be automatically injected as suggestions to the existing dataset so that the annotator only needs to review those, instead of fulfilling those by themselves. + Additionally, if the [`Pipeline`][distilabel.pipeline.Pipeline] contains an [`UltraFeedback`][distilabel.steps.tasks.UltraFeedback] step, the `ratings` and `rationales` will also be available and be automatically injected as suggestions to the existing dataset. ```python from distilabel.llms import OpenAILLM @@ -112,7 +109,4 @@ with Pipeline(name="my-pipeline") as pipeline: pipeline.run() ``` -![Preference to Argilla](../../../assets/images/sections/learn/steps/argilla/preference.png) - -!!! NOTE - If you are willing to also add the suggestions, feel free to check ["UltraFeedback: Boosting Language Models with High-quality Feedback"](../../pipeline_samples/papers/ultrafeedback.md) where the [`UltraFeedback`][distilabel.steps.tasks.UltraFeedback] task is used to generate both ratings and rationales for each of the generations of a given instruction. +![Preference to Argilla](../../../assets/images/sections/how_to_guides/steps/argilla/preference.png) diff --git a/docs/sections/learn/advanced/caching.md b/docs/sections/how_to_guides/advanced/caching.md similarity index 98% rename from docs/sections/learn/advanced/caching.md rename to docs/sections/how_to_guides/advanced/caching.md index 16137f9a72..1fc9414940 100644 --- a/docs/sections/learn/advanced/caching.md +++ b/docs/sections/how_to_guides/advanced/caching.md @@ -1,6 +1,6 @@ -# Caching +# Cache and recover pipeline executions -Distilabel `Pipelines` automatically save all the intermediate steps to to avoid losing any data in case of error. +Distilabel `Pipelines` automatically save all the intermediate steps to avoid losing any data in case of error. ## Cache directory @@ -131,5 +131,5 @@ ds !!! Note Internally, the function will try to inject the `pipeline_path` variable if it's not passed via argument, assuming it's in the parent directory of the current one, called `pipeline.yaml`. If the file doesn't exist, it won't raise any error, but take into account that if the `Distiset` is pushed to the Hugging Face Hub, the `pipeline.yaml` won't be generated. The same happens with the `pipeline.log` file, it can be passed via `log_filename_path`, but it will try to locate it automatically. - + Lastly, there is the option of including the `distilabel_metadata` column in the final dataset. This column can contain custom metadata generated automatically by the pipeline, like the raw output from an `LLM` without formatting in case of failure, and we can decide whether to include it using the `enable_metadata` argument. diff --git a/docs/sections/learn/tutorial/cli/index.md b/docs/sections/how_to_guides/advanced/cli/index.md similarity index 100% rename from docs/sections/learn/tutorial/cli/index.md rename to docs/sections/how_to_guides/advanced/cli/index.md diff --git a/docs/sections/learn/advanced/distiset.md b/docs/sections/how_to_guides/advanced/distiset.md similarity index 66% rename from docs/sections/learn/advanced/distiset.md rename to docs/sections/how_to_guides/advanced/distiset.md index 199b607fa5..0893599c43 100644 --- a/docs/sections/learn/advanced/distiset.md +++ b/docs/sections/how_to_guides/advanced/distiset.md @@ -1,10 +1,10 @@ -# Distiset +# Using the Distiset dataset object -A [`Pipeline`][distilabel.pipeline.Pipeline] in `distilabel` returns a special type of Hugging Face [`datasets.DatasetDict`](https://huggingface.co/docs/datasets/main/en/package_reference/main_classes#datasets.DatasetDict) which is called [`Distiset`][distilabel.distiset.Distiset], as a combination of `distilabel` and dataset. This object is a wrapper around [`datasets.Dataset`](https://huggingface.co/docs/datasets/main/en/package_reference/main_classes#datasets.Dataset) which comes with some extra functionality to easily deal with the dataset pieces that a [`Pipeline`][distilabel.pipeline.Pipeline] can generate. +A [`Pipeline`][distilabel.pipeline.Pipeline] in `distilabel` returns a special type of Hugging Face [`datasets.DatasetDict`](https://huggingface.co/docs/datasets/main/en/package_reference/main_classes#datasets.DatasetDict) which is called [`Distiset`][distilabel.distiset.Distiset]. -The [`Distiset`][distilabel.distiset.Distiset] is a dictionary-like object that contains the different configurations generated by the [`Pipeline`][distilabel.pipeline.Pipeline], where each configuration corresponds to each leaf step in the DAG built by the [`Pipeline`][distilabel.pipeline.Pipeline]. Each configuration corresponds to a different subset of the dataset, which is a concept taken from 🤗 `datasets` that lets you upload different configurations of the same dataset within the same repository and can contain different columns i.e. different configurations, which can be seamlessly pushed to the Hugging Face Hub straight away. +The [`Distiset`][distilabel.distiset.Distiset] is a dictionary-like object that contains the different configurations generated by the [`Pipeline`][distilabel.pipeline.Pipeline], where each configuration corresponds to each leaf step in the DAG built by the [`Pipeline`][distilabel.pipeline.Pipeline]. Each configuration corresponds to a different subset of the dataset. This is a concept taken from 🤗 `datasets` that lets you upload different configurations of the same dataset within the same repository and can contain different columns i.e. different configurations, which can be seamlessly pushed to the Hugging Face Hub. -Below you can find an example on how to create a [`Distiset`][distilabel.distiset.Distiset] object, similarly as a [`datasets.DatasetDict`](https://huggingface.co/docs/datasets/main/en/package_reference/main_classes#datasets.DatasetDict), which is not required in `distilabel` since that's internally handled by the [`Pipeline`][distilabel.pipeline.Pipeline] as part of the output of the `run` method: +Below you can find an example of how to create a [`Distiset`][distilabel.distiset.Distiset] object that resembles a [`datasets.DatasetDict`](https://huggingface.co/docs/datasets/main/en/package_reference/main_classes#datasets.DatasetDict): ```python from datasets import Dataset @@ -29,7 +29,7 @@ We can interact with the different pieces generated by the [`Pipeline`][distilab ### Train/Test split -Which easily does the train/test split partition of the dataset for the different configurations or subsets. +Create a train/test split partition of the dataset for the different configurations or subsets. ```python >>> distiset.train_test_split(train_size=0.9) @@ -59,7 +59,7 @@ Distiset({ ### Push to Hugging Face Hub -Pushes the [`Distiset`][distilabel.distiset.Distiset] to a Hugging Face repository, where each one of the subsets will correspond to a different configuration: +Push the [`Distiset`][distilabel.distiset.Distiset] to a Hugging Face repository, where each one of the subsets will correspond to a different configuration: ```python distiset.push_to_hub( @@ -72,39 +72,43 @@ distiset.push_to_hub( ### Save and load from disk -Saves the [`Distiset`][distilabel.distiset.Distiset] to disk, and optionally (will be done by default) saves the dataset card, the pipeline config file and logs: +Take into account that these methods work as `datasets.load_from_disk` and `datasets.Dataset.save_to_disk` so the arguments are directly passed to those methods. This means you can also make use of `storage_options` argument to save your [`Distiset`][distilabel.distiset.Distiset] in your cloud provider, including the distilabel artifacts (`pipeline.yaml`, `pipeline.log` and the `README.md` with the dataset card). You can read more in `datasets` documentation [here](https://huggingface.co/docs/datasets/filesystems#saving-serialized-datasets). -```python -distiset.save_to_disk( - "my-dataset", - save_card=True, - save_pipeline_config=True, - save_pipeline_log=True -) -``` +=== "Save to disk" -And load a [`Distiset`][distilabel.distiset.Distiset] that was saved using [`Distiset.save_to_disk`][distilabel.distiset.Distiset.save_to_disk] just the same way: + Save the [`Distiset`][distilabel.distiset.Distiset] to disk, and optionally (will be done by default) saves the dataset card, the pipeline config file and logs: -```python -from distilabel.distiset import Distiset + ```python + distiset.save_to_disk( + "my-dataset", + save_card=True, + save_pipeline_config=True, + save_pipeline_log=True + ) + ``` -distiset = Distiset.load_from_disk("my-dataset") -``` +=== "Load from disk (local)" -or from your cloud provider if that's where it was stored: + Load a [`Distiset`][distilabel.distiset.Distiset] that was saved using [`Distiset.save_to_disk`][distilabel.distiset.Distiset.save_to_disk] just the same way: -```python -distiset = Distiset.load_from_disk( - "s3://path/to/my_dataset", # gcs:// or any filesystem tolerated by fsspec - storage_options={ - "key": os.environ["S3_ACCESS_KEY"], - "secret": os.environ["S3_SECRET_KEY"], - ... - } -) -``` + ```python + distiset = Distiset.load_from_disk("my-dataset") + ``` -Take into account that these methods work as `datasets.load_from_disk` and `datasets.Dataset.save_to_disk` so the arguments are directly passed to those methods. This means you can also make use of `storage_options` argument to save your [`Distiset`][distilabel.distiset.Distiset] in your cloud provider, including the distilabel artifacts (`pipeline.yaml`, `pipeline.log` and the `README.md` with the dataset card). You can read more in `datasets` documentation [here](https://huggingface.co/docs/datasets/filesystems#saving-serialized-datasets). +=== "Load from disk (cloud)" + + Load a [`Distiset`][distilabel.distiset.Distiset] from a remote location, like S3, GCS. You can pass the `storage_options` argument to authenticate with the cloud provider: + + ```python + distiset = Distiset.load_from_disk( + "s3://path/to/my_dataset", # gcs:// or any filesystem tolerated by fsspec + storage_options={ + "key": os.environ["S3_ACCESS_KEY"], + "secret": os.environ["S3_SECRET_KEY"], + ... + } + ) + ``` Take a look at the remaining arguments at [`Distiset.save_to_disk`][distilabel.distiset.Distiset.save_to_disk] and [`Distiset.load_from_disk`][distilabel.distiset.Distiset.load_from_disk]. diff --git a/docs/sections/learn/advanced/fs_to_pass_data.md b/docs/sections/how_to_guides/advanced/fs_to_pass_data.md similarity index 100% rename from docs/sections/learn/advanced/fs_to_pass_data.md rename to docs/sections/how_to_guides/advanced/fs_to_pass_data.md diff --git a/docs/sections/learn/advanced/structured_generation.md b/docs/sections/how_to_guides/advanced/structured_generation.md similarity index 85% rename from docs/sections/learn/advanced/structured_generation.md rename to docs/sections/how_to_guides/advanced/structured_generation.md index 579427434d..17e64430a7 100644 --- a/docs/sections/learn/advanced/structured_generation.md +++ b/docs/sections/how_to_guides/advanced/structured_generation.md @@ -1,4 +1,4 @@ -# Structured Generation +# Structured data generation `Distilabel` has integrations with relevant libraries to generate structured text i.e. to guide the [`LLM`][distilabel.llms.LLM] towards the generation of structured outputs following a JSON schema, a regex, etc. @@ -111,7 +111,7 @@ These were some simple examples, but one can see the options this opens. !!! Tip A full pipeline example can be seen in the following script: - [`examples/structured_generation_with_outlines.py`](../../pipeline_samples/examples/index.md#llama-cpp-with-outlines) + [`examples/structured_generation_with_outlines.py`](../../pipeline_samples/examples/#llama-cpp-with-outlines) [^1]: You can check the variable type by importing it from: @@ -129,9 +129,7 @@ These were some simple examples, but one can see the options this opens. ## Instructor -When working with model providers behind an API, there's no direct way of accesing the internal logit processor as `outlines` does, but thanks to [`instructor`](https://python.useinstructor.com/) we can generate structured output from LLM providers. We have integrated `instructor` to deal with the [`AsyncLLM`][distilabel.llms.AsyncLLM], so you can work with the following LLMs: [`OpenAILLM`][distilabel.llms.OpenAILLM], [`AzureOpenAILLM`][distilabel.llms.AzureOpenAILLM], [`CohereLLM`][distilabel.llms.CohereLLM], [`GroqLLM`][distilabel.llms.GroqLLM], [`LiteLLM`][distilabel.llms.LiteLLM] and [`MistralLLM`][distilabel.llms.MistralLLM]. - -`instructor` works with `pydantic.BaseModel` objects internally but in `distilabel` the examples generated would result in the string representation of them, from which the `BaseModel` object can be regenerated. +When working with model providers behind an API, there's no direct way of accessing the internal logit processor like `outlines` does, but thanks to [`instructor`](https://python.useinstructor.com/) we can generate structured output from LLM providers based on `pydantic.BaseModel` objects. We have integrated `instructor` to deal with the [`AsyncLLM`][distilabel.llms.AsyncLLM], so you can work with the following LLMs: [`OpenAILLM`][distilabel.llms.OpenAILLM], [`AzureOpenAILLM`][distilabel.llms.AzureOpenAILLM], [`CohereLLM`][distilabel.llms.CohereLLM], [`GroqLLM`][distilabel.llms.GroqLLM], [`LiteLLM`][distilabel.llms.LiteLLM] and [`MistralLLM`][distilabel.llms.MistralLLM]. !!! Note For `instructor` integration to work you may need to install the corresponding dependencies: @@ -170,7 +168,7 @@ llm = MistralLLM( llm.load() ``` -And we are ready to pass our instruction as usual: +And we are ready to pass our instructions as usual: ```python import json @@ -198,9 +196,9 @@ We get back a Python dictionary (formatted as a string) that we can parse using OpenAI offers a [JSON Mode](https://platform.openai.com/docs/guides/text-generation/json-mode) to deal with structured output via their API, let's see how to make use of them. The JSON mode instructs the model to always return a JSON object following the instruction required. !!! WARNING - Bear in mind, that in order for this to work, you must instruct the model in some way to generate JSON, either in the `system message` or in the instruction, as can be seen in the [API reference](https://platform.openai.com/docs/guides/text-generation/json-mode). + Bear in mind, for this to work, you must instruct the model in some way to generate JSON, either in the `system message` or in the instruction, as can be seen in the [API reference](https://platform.openai.com/docs/guides/text-generation/json-mode). -Contrary to what we have via `outlines`, JSON mode will not guarantee the output matches any specific schema, only that it is valid and parses without errors. More information can be found the OpenAI documentation. +Contrary to what we have via `outlines`, JSON mode will not guarantee the output matches any specific schema, only that it is valid and parses without errors. More information can be found in the OpenAI documentation. Other than the reference to generating JSON, to ensure the model generates parseable JSON we can pass the argument `response_format="json"`[^3]: diff --git a/docs/sections/how_to_guides/basic/llm/index.md b/docs/sections/how_to_guides/basic/llm/index.md new file mode 100644 index 0000000000..944e3f4fce --- /dev/null +++ b/docs/sections/how_to_guides/basic/llm/index.md @@ -0,0 +1,145 @@ +# Define LLMs as local models or remote APIs + +## Working with LLMs + +LLM subclasses are designed to be used within a [Task][distilabel.steps.tasks.Task], but they can also be used standalone. + +```python +from distilabel.llms import OpenAILLM + +llm = OpenAILLM(model="gpt-4") +llm.load() + +llm.generate( + inputs=[ + [{"role": "user", "content": "What's the capital of Spain?"}], + ], +) +# "The capital of Spain is Madrid." +``` + +!!! NOTE + Always call the `LLM.load` or `Task.load` method when using LLMs standalone or as part of a `Task`. If using a `Pipeline`, this is done automatically in `Pipeline.run()`. + +### Within a Task + +Pass the LLM as an argument to the [`Task`][distilabel.steps.tasks.Task], and the task will handle the rest. + +```python +from distilabel.llms import OpenAILLM +from distilabel.steps.tasks import TextGeneration + +llm = OpenAILLM(model="gpt-4") +task = TextGeneration(name="text_generation", llm=llm) + +task.load() + +next(task.process(inputs=[{"instruction": "What's the capital of Spain?"}])) +# [{'instruction': "What's the capital of Spain?", "generation": "The capital of Spain is Madrid."}] +``` + +### Runtime Parameters + +LLMs can have runtime parameters, such as `generation_kwargs`, provided via the `Pipeline.run()` method using the `params` argument. + +!!! NOTE + Runtime parameters can differ between LLM subclasses, caused by the different functionalities offered by the LLM providers. + +```python +from distilabel.pipeline import Pipeline +from distilabel.llms import OpenAILLM +from distilabel.steps import LoadDataFromDicts +from distilabel.steps.tasks import TextGeneration + +with Pipeline(name="text-generation-pipeline") as pipeline: + load_dataset = LoadDataFromDicts( + name="load_dataset", + data=[{"instruction": "Write a short story about a dragon that saves a princess from a tower."}], + ) + + text_generation = TextGeneration( + name="text_generation", + llm=OpenAILLM(model="gpt-4"), + ) + + load_dataset >> text_generation + +if __name__ == "__main__": + pipeline.run( + parameters={ + text_generation.name: {"llm": {"generation_kwargs": {"temperature": 0.3}}}, + }, + ) +``` + +## Creating custom LLMs + +To create custom LLMs, subclass either [`LLM`][distilabel.llms.LLM] for synchronous or [`AsyncLLM`][distilabel.llms.AsyncLLM] for asynchronous LLMs. Implement the following methods: + +* `model_name`: A property containing the model's name. + +* `generate`: A method that takes a list of prompts and returns generated texts. + +* `agenerate`: A method that takes a single prompt and returns generated texts. This method is used within the `generate` method of the `AsyncLLM` class. +* +* (optional) `get_last_hidden_state`: is a method that will take a list of prompts and return a list of hidden states. This method is optional and will be used by some tasks such as the [`GenerateEmbeddings`][distilabel.steps.tasks.GenerateEmbeddings] task. + + +=== "Custom LLM" + + ```python + from typing import Any + + from pydantic import validate_call + + from distilabel.llms import LLM + from distilabel.llms.typing import GenerateOutput, HiddenState + from distilabel.steps.tasks.typing import ChatType + + class CustomLLM(LLM): + @property + def model_name(self) -> str: + return "my-model" + + @validate_call + def generate(self, inputs: List[ChatType], num_generations: int = 1, **kwargs: Any) -> List[GenerateOutput]: + for _ in range(num_generations): + ... + + def get_last_hidden_state(self, inputs: List[ChatType]) -> List[HiddenState]: + ... + ``` + +=== "Custom AsyncLLM" + + ```python + from typing import Any + + from pydantic import validate_call + + from distilabel.llms import AsyncLLM + from distilabel.llms.typing import GenerateOutput, HiddenState + from distilabel.steps.tasks.typing import ChatType + + class CustomAsyncLLM(AsyncLLM): + @property + def model_name(self) -> str: + return "my-model" + + @validate_call + async def agenerate(self, input: ChatType, num_generations: int = 1, **kwargs: Any) -> GenerateOutput: + for _ in range(num_generations): + ... + + def get_last_hidden_state(self, inputs: List[ChatType]) -> List[HiddenState]: + ... + ``` + +`generate` and `agenerate` keyword arguments (but `input` and `num_generations`) are considered as `RuntimeParameter`s, so a value can be passed to them via the `parameters` argument of the `Pipeline.run` method. + +!!! NOTE + To have the arguments of the `generate` and `agenerate` coerced to the expected types, the `validate_call` decorator is used, which will automatically coerce the arguments to the expected types, and raise an error if the types are not correct. This is specially useful when providing a value for an argument of `generate` or `agenerate` from the CLI, since the CLI will always provide the arguments as strings. + +## Available LLMs + +[Our LLM gallery](/distilabel/components-gallery/llms/) shows a list of the available LLMs that can be used within the `distilabel` library. \ No newline at end of file diff --git a/docs/sections/learn/tutorial/pipeline/index.md b/docs/sections/how_to_guides/basic/pipeline/index.md similarity index 50% rename from docs/sections/learn/tutorial/pipeline/index.md rename to docs/sections/how_to_guides/basic/pipeline/index.md index cd4720d5cb..3f14cee4c7 100644 --- a/docs/sections/learn/tutorial/pipeline/index.md +++ b/docs/sections/how_to_guides/basic/pipeline/index.md @@ -1,48 +1,47 @@ -# Pipeline - -The [`Pipeline`][distilabel.pipeline.Pipeline] is the central point in `distilabel`, the way to organize the steps to create your datasets. Up to this point we've seen how we can define different [`Step`][distilabel.steps.Step] and [`Task`][distilabel.steps.tasks.Task] subclasses in [Tutorial - Step](../step/index.md) and [Tutorial - Task](../task/index.md), respectively; which together with an [`LLM`][distilabel.llms.LLM] are the building blocks of our datasets, in this section we will take a look at how all these blocks are organized inside a [`Pipeline`][distilabel.pipeline.Pipeline]. - -!!! Note - Currently `distilabel` implements a *local* version of a [`Pipeline`][distilabel.pipeline.Pipeline], and will assume that's the only definition, but this can be extended in the future to include remote execution of the [`Pipeline`][distilabel.pipeline.Pipeline]. +# Execute Steps and Tasks in a Pipeline ## How to create a pipeline -The most common way a [`Pipeline`][distilabel.pipeline.Pipeline] should be created is by making use of the context manager, we just need to give our [`Pipeline`][distilabel.pipeline.Pipeline] a **name**, and optionally a **description**, and that's it[^1]: +[`Pipeline`][distilabel.pipeline.Pipeline] organise the Steps and Tasks in a sequence, where the output of one step is the input of the next one. +A [`Pipeline`][distilabel.pipeline.Pipeline] should be created by making use of the context manager along with passing a **name**, and optionally a **description**. ```python from distilabel.pipeline import Pipeline -with Pipeline("pipe-name", description="My first pipe") as pipeline: # (1) +with Pipeline("pipe-name", description="My first pipe") as pipeline: ... - ``` -1. Use the context manager to create a [`Pipeline`][distilabel.pipeline.Pipeline] with a name and an optional description. +### Connecting steps with the `Step.connect` method + +Now, we can define the steps of our [`Pipeline`][distilabel.pipeline.Pipeline]. + +!!! NOTE + Steps without predecessors (i.e. root steps), need to be [`GeneratorStep`][distilabel.steps.GeneratorStep]s such as [`LoadDataFromDicts`][distilabel.steps.LoadDataFromDicts] or [`LoadDataFromHub`][distilabel.steps.LoadDataFromHub]. After this, other steps can be defined. -This way, we ensure all the steps we define there are connected with each other under the same [`Pipeline`][distilabel.pipeline.Pipeline]. The next step is to define the steps of our [`Pipeline`][distilabel.pipeline.Pipeline]. It's mandatory that the root steps of the pipeline i.e. the ones that doesn't have any predecessors, are [`GeneratorStep`][distilabel.steps.GeneratorStep]s such as [`LoadDataFromDicts`][distilabel.steps.LoadDataFromDicts] or [`LoadHubDataset`][distilabel.steps.LoadHubDataset]. ```python from distilabel.pipeline import Pipeline -from distilabel.steps import LoadHubDataset +from distilabel.steps import LoadDataFromHub with Pipeline("pipe-name", description="My first pipe") as pipeline: - load_dataset = LoadHubDataset(name="load_dataset") # (1) + load_dataset = LoadDataFromHub(name="load_dataset") ... - ``` -1. Define the first step of the pipeline, in this case `LoadHubDataset`, a `GeneratorStep` used to load a dataset from the Hugging Face Hub. +Next, we will use `prompt` column from the dataset obtained through `LoadDataFromHub` and use several `LLM`s to execute a `TextGeneration` task. We will also use the `Task.connect()` method to connect the steps, so the output of one step is the input of the next one. -Once we have a source of data, we can create another [`Step`][distilabel.steps.Step]s that will consume and process the data generated by the `GeneratorStep`s. Let's assume that the dataset we're going to load from the Hub contains a `prompt` column and that we want to generate texts based on this prompt. We also want to use several `LLM`s for this task. To do so, we will create several `TextGeneration` tasks, each with a different `LLM`. +!!! NOTE + The order of the execution of the steps will be determined by the connections of the steps. In this case, the `TextGeneration` tasks will be executed after the `LoadDataFromHub` step. ```python from distilabel.llms import MistralLLM, OpenAILLM, VertexAILLM from distilabel.pipeline import Pipeline -from distilabel.steps import LoadHubDataset +from distilabel.steps import LoadDataFromHub from distilabel.steps.tasks import TextGeneration with Pipeline("pipe-name", description="My first pipe") as pipeline: - load_dataset = LoadHubDataset(name="load_dataset") + load_dataset = LoadDataFromHub(name="load_dataset") for llm in ( OpenAILLM(model="gpt-4-0125-preview"), @@ -55,22 +54,19 @@ with Pipeline("pipe-name", description="My first pipe") as pipeline: ... ``` -1. Create a `TextGeneration` task for each `LLM` we want to use. -2. Connect the `TextGeneration` task with the `LoadHubDataset` step, so the output data from the dataset is passed to the task. +For each row of the dataset, the `TextGeneration` task will generate a text based on the `instruction` column and the `LLM` model, and store the result (a single string) in a new column called `generation`. Because we need to have the `response`s in the same column, we will add `CombineColumns` to combine them all in the same column as a list of strings. !!! NOTE - The order of the execution of the steps will be determined by the connections of the steps. In this case, the `TextGeneration` tasks will be executed after the `LoadHubDataset` step. - -For each row of the dataset, the `TextGeneration` task will generate a text based on the `instruction` column and the `LLM` model, and store the result (a single string) in a new column called `generation`. As we would like to have all the `response`s in the same column, we will add an extra step to combine them all in the same column, so the value of this column is a list of strings or responses. + In this case, the `CombineColumns` tasks will be executed after all `TextGeneration` steps. ```python from distilabel.llms import MistralLLM, OpenAILLM, VertexAILLM from distilabel.pipeline import Pipeline -from distilabel.steps import CombineColumns, LoadHubDataset +from distilabel.steps import CombineColumns, LoadDataFromHub from distilabel.steps.tasks import TextGeneration with Pipeline("pipe-name", description="My first pipe") as pipeline: - load_dataset = LoadHubDataset(name="load_dataset") + load_dataset = LoadDataFromHub(name="load_dataset") combine_generations = CombineColumns( # (1) name="combine_generations", @@ -85,33 +81,25 @@ with Pipeline("pipe-name", description="My first pipe") as pipeline: ): task = TextGeneration(name=f"text_generation_with_{llm.model_name}", llm=llm) load_dataset.connect(task) - task.connect(combine_generations) # (2) + task.connect(combine_generations) ``` -1. Create a `CombineColumns` step to combine all the `generation` columns into a single column called `generations` and the `model_name` columns into a single column called `model_names`. -2. Connect the `TextGeneration` task with the `CombineColumns` step, so the output data from the task is passed to the step that will combine all the `generation` columns. - -As the [`CombineColumns`][distilabel.steps.CombineColumns] is the last step or it's a leaf step of the pipeline because it doesn't have any successors, that means that the outputs of this step will be included in the returned [`Distiset`][distilabel.distiset.Distiset] (more information about it in [Advanced - Distiset](../../advanced/distiset.md)). - -!!! NOTE - One pipeline can have several leaf steps, which means that the outputs of all the leaf steps will be included in the returned `Distiset`, which will contain several subsets, one for each leaf step. - -### Connecting steps +### Connecting steps with the `>>` operator -In the previous example we saw how to create a `Pipeline` and connect different steps using the `Step.connect` method: `step1.connect(step2)`, but there's an alternative way by making use of the `>>` operator, let's see how using the previous `Pipeline` as an example: +Besides the `Step.connect` method: `step1.connect(step2)`, there's an alternative way by making use of the `>>` operator. We can connect steps in a more readable way, and it's also possible to connect multiple steps at once. === "Step per step" - Each call to `step1.connect(step2)` has been exchanged by `step1 >> step2`: + Each call to `step1.connect(step2)` has been exchanged by `step1 >> step2` within the loop. ```python from distilabel.llms import MistralLLM, OpenAILLM, VertexAILLM from distilabel.pipeline import Pipeline - from distilabel.steps import CombineColumns, LoadHubDataset + from distilabel.steps import CombineColumns, LoadDataFromHub from distilabel.steps.tasks import TextGeneration with Pipeline("pipe-name", description="My first pipe") as pipeline: - load_dataset = LoadHubDataset(name="load_dataset") + load_dataset = LoadDataFromHub(name="load_dataset") combine_generations = CombineColumns( name="combine_generations", @@ -125,24 +113,21 @@ In the previous example we saw how to create a `Pipeline` and connect different VertexAILLM(model="gemini-1.5-pro"), ): task = TextGeneration(name=f"text_generation_with_{llm.model_name}", llm=llm) - load_dataset >> task >> combine_generations # (1) + load_dataset >> task >> combine_generations ``` - 1. Here `load_dataset >> task >> combine_generations` was exchanged with `load_dataset.connect(task).connect(combine_generations)`. - - === "Multiple steps at once" - All the calls to connections from the `load_dataset` step to the different `task` objects are done in a single call: + Each task is first appended to a list, and then all the calls to connections are done in a single call. ```python from distilabel.llms import MistralLLM, OpenAILLM, VertexAILLM from distilabel.pipeline import Pipeline - from distilabel.steps import CombineColumns, LoadHubDataset + from distilabel.steps import CombineColumns, LoadDataFromHub from distilabel.steps.tasks import TextGeneration with Pipeline("pipe-name", description="My first pipe") as pipeline: - load_dataset = LoadHubDataset(name="load_dataset") + load_dataset = LoadDataFromHub(name="load_dataset") combine_generations = CombineColumns( name="combine_generations", @@ -160,21 +145,20 @@ In the previous example we saw how to create a `Pipeline` and connect different TextGeneration(name=f"text_generation_with_{llm.model_name}", llm=llm) ) - load_dataset >> tasks >> combine_generations # (1) + load_dataset >> tasks >> combine_generations ``` - 1. Notice how `tasks` is a list of different `Tasks`. In a single call to the operator we are connecting `load_dataset` with all the `tasks`, and all of those again to `combine_generations`. - - ### Routing batches to specific downstream steps -In some pipelines, it's likely that you will need to have a list of downstream steps receiving batches from the same upstream step, but you would like to route the batches to specific downstream steps based on some condition. To do so, you can use a `routing_batch_function`, which is a simple function that receives a list of the downstream steps to which a batch can be routed, and returns a list containing the names of steps to which the batch should be routed. Let's update the example above to route the batches loaded by the `LoadHubDataset` step to just 2 of the `TextGeneration` tasks. First, we will create our custom [`routing_batch_function`][distilabel.pipeline.routing_batch_function.routing_batch_function], and then we will update the pipeline to use it: +In some pipelines, you may want to send batches from a single upstream step to specific downstream steps based on certain conditions. To achieve this, you can use a `routing_batch_function`. This function takes a list of downstream steps and returns a list of step names to which each batch should be routed. + +Let's update the example above to route the batches loaded by the `LoadDataFromHub` step to just 2 of the `TextGeneration` tasks. First, we will create our custom [`routing_batch_function`][distilabel.pipeline.routing_batch_function.routing_batch_function], and then we will update the pipeline to use it: ```python import random from distilabel.llms import MistralLLM, OpenAILLM, VertexAILLM from distilabel.pipeline import Pipeline, routing_batch_function -from distilabel.steps import CombineColumns, LoadHubDataset +from distilabel.steps import CombineColumns, LoadDataFromHub from distilabel.steps.tasks import TextGeneration @routing_batch_function @@ -182,7 +166,7 @@ def sample_two_steps(steps: list[str]) -> list[str]: return random.sample(steps, 2) with Pipeline("pipe-name", description="My first pipe") as pipeline: - load_dataset = LoadHubDataset( + load_dataset = LoadDataFromHub( name="load_dataset", output_mappings={"prompt": "instruction"}, ) @@ -206,7 +190,7 @@ with Pipeline("pipe-name", description="My first pipe") as pipeline: load_dataset >> sample_two_steps >> tasks >> combine_generations ``` -As it can be seen, the `routing_batch_function` can be used with the `>>` operator to route the batches to specific downstream steps. In this case, each batch yielded by the `load_dataset` step will be routed to just 2 of the `TextGeneration` tasks, and then all the outputs of the tasks will be combined in the `CombineColumns` step so each row of the final dataset will contain generations of 2 `LLM`s at most. The `routing_batch_function` that we just built is a common one, so `distilabel` comes with an auxiliary function that can be used to achieve the same behavior: + The `routing_batch_function` that we just built is a common one, so `distilabel` comes with a builtin function that can be used to achieve the same behavior: ```python from distilable.pipeline import sample_n_steps @@ -218,7 +202,7 @@ sample_two_steps = sample_n_steps(2) ### Pipeline.dry_run -Before running the `Pipeline` we may want to check all the components behave as expected. We can do a `dry_run` for this case: +Before running the `Pipeline` we can check if the pipeline is valid using the `Pipeline.dry_run()` method. It takes the same parameters as the `run` method which we will discuss in the following section, plus the `batch_size` we want the dry run to use (by default set to 1). ```python with Pipeline("pipe-name", description="My first pipe") as pipeline: @@ -228,11 +212,9 @@ if __name__ == "__main__": distiset = pipeline.dry_run(parameters=..., batch_size=1) ``` -It takes the same parameters as the `run` method we will see in the following section, plus the `batch_size` we want the dry run to use (by default set to 1). In this case, the `Pipeline` would select a single example from our generator steps and pass through all the steps. Assuming the `dry_run` runs successfully, we are ready to run our pipeline. - ### Pipeline.run -Once we have created the pipeline, we can run it. To do so, we need to call the `run` method of the `Pipeline`, and specify the runtime parameters for each step: +After testing, we can now execute the full `Pipeline` using the `Pipeline.run()` method. ```python with Pipeline("pipe-name", description="My first pipe") as pipeline: @@ -273,27 +255,25 @@ if __name__ == "__main__": ) ``` -But if we run it, we will see that the `run` method will fail: +But if we run the pipeline above, we will see that the `run` method will fail: ``` ValueError: Step 'text_generation_with_gpt-4-0125-preview' requires inputs ['instruction'], but only the inputs=['prompt', 'completion', 'meta'] are available, which means that the inputs=['instruction'] are missing or not available when the step gets to be executed in the pipeline. Please make sure previous steps to 'text_generation_with_gpt-4-0125-preview' are generating the required inputs. ``` -This is because, before actually running the pipeline, the pipeline is validated to verify that everything is correct and all the steps in the pipeline are chainable i.e. each step has the necessary inputs to be executed. In this case, the `TextGeneration` task requires the `instruction` column, but the `LoadHubDataset` step generates the `prompt` column. To solve this, we can use the `output_mappings` argument that every `Step` has, to map or rename the output columns of a step to the required input columns of another step: +This is because, before actually running the pipeline, we must ensure each step has the necessary input columns to be executed. In this case, the `TextGeneration` task requires the `instruction` column, but the `LoadDataFromHub` step generates the `prompt` column. To solve this, we can use the `output_mappings` or `input_mapping` arguments of individual `Step`s, to map columns from one step to another. ```python with Pipeline("pipe-name", description="My first pipe") as pipeline: - load_dataset = LoadHubDataset( + load_dataset = LoadDataFromHub( name="load_dataset", - output_mappings={"prompt": "instruction"}, # (1) + output_mappings={"prompt": "instruction"} ) ... ``` -1. Use the `output_mappings` argument to map the `prompt` column generated by the `LoadHubDataset` step to the `instruction` column required by the `TextGeneration` task. - If we execute the pipeline again, it will run successfully and we will have a `Distiset` with the outputs of all the leaf steps of the pipeline which we can push to the Hugging Face Hub. ```python @@ -304,37 +284,42 @@ if __name__ == "__main__": ### Stopping the pipeline -In case you want to stop the pipeline while it's running using the `Ctrl+c` (`Cmd+c` in macos), we automatically catch the signal and try to finish whatever steps are currently running. If it got hang by some reason, repeating the command 2 times it will force the pipeline close. +In case you want to stop the pipeline while it's running using the `Ctrl+C` (`Cmd+C` in MacOS), and the outputs will be stored in the cache. Repeating the command 2 times will force the pipeline to close. !!! Note - When pushing sending the signal to kill the process, you can expect to see the following log messages: - ![Pipeline ctrl+c](../../../../assets/images/sections/pipeline/pipeline-ctrlc.png) ## Cache -If we try to execute the pipeline again, the pipeline won't execute as it will load the dataset from the cache, and the outputs of the pipeline will be the same as the previous run. If for some reason, we decide to stop the pipeline execution in the middle of the process pressing CTRL + C, the pipeline will stop and the state of the pipeline and the outputs will be stored in the cache, so we can resume the pipeline execution from the point where it was stopped. +If for some reason, the pipeline execution stops (for example by pressing `Ctrl+C`), the state of the pipeline and the outputs will be stored in the cache, so we can resume the pipeline execution from the point where it was stopped. -If we want to force the pipeline to run again, then we can use the `use_cache` argument of the `run` method and set it to `False`: +If we want to force the pipeline to run again without can, then we can use the `use_cache` argument of the `Pipeline.run()` method: ```python if __name__ == "__main__": distiset = pipeline.run(parameters={...}, use_cache=False) ``` +!!! NOTE + For more information on caching, we refer the reader to the [caching](../../advanced/caching.md) section. + ## Adjusting the batch size for each step -It's very likely that in some pipelines the batch size of the steps (the number of dictionaries that will receive every `Step.process` method when called) will need to be adjusted in order to avoid memory issues or a more efficient processing. To do so, we can use the `input_batch_size` argument of the `run` method: +Memory issues can arise when processing large datasets or when using large models. To avoid this, we can use the `input_batch_size` argument of individual tasks. `TextGeneration` task will receive 5 dictionaries, while the `LoadDataFromHub` step will send 10 dictionaries per batch: ```python from distilabel.llms import MistralLLM, OpenAILLM, VertexAILLM from distilabel.pipeline import Pipeline -from distilabel.steps import CombineColumns, LoadHubDataset +from distilabel.steps import CombineColumns, LoadDataFromHub from distilabel.steps.tasks import TextGeneration with Pipeline("pipe-name", description="My first pipe") as pipeline: - ... + load_dataset = LoadDataFromHub( + name="load_dataset", + output_mappings={"prompt": "instruction"}, + batch_size=10 + ) for llm in ( OpenAILLM(model="gpt-4-0125-preview"), @@ -344,61 +329,55 @@ with Pipeline("pipe-name", description="My first pipe") as pipeline: task = TextGeneration( name=f"text_generation_with_{llm.model_name}", llm=llm, - input_batch_size=5, # (1) + input_batch_size=5, ) ... ``` -1. Use the `input_batch_size` argument to set the batch size of the `TextGeneration` task to 5. - -When we run the pipeline, the `TextGeneration` task will receive 5 dictionaries in every call to the `process` method. In addition, we can also adjust the batch size of the generated batches by the `GeneratorStep`s using the `batch_size` argument: - -```python -with Pipeline("pipe-name", description="My first pipe") as pipeline: - load_dataset = LoadHubDataset( - name="load_dataset", - output_mappings={"prompt": "instruction"}, - batch_size=10 # (1) - ) - - ... -``` - -1. Use the `batch_size` argument to set the batch size of the `LoadHubDataset` step to 10. - -By default, both arguments have a value of `50`. - ## Serializing the pipeline Sharing a pipeline with others is very easy, as we can serialize the pipeline object using the `save` method. We can save the pipeline in different formats, such as `yaml` or `json`: -```python -if __name__ == "__main__": - pipeline.save("pipeline.yaml", format="yaml") -``` +=== "yaml" + ```python + if __name__ == "__main__": + pipeline.save("pipeline.yaml", format="yaml") + ``` + +=== "json" + ```python + if __name__ == "__main__": + pipeline.save("pipeline.json", format="json") + ``` To load the pipeline, we can use the `from_yaml` or `from_json` methods: -```python -pipeline = Pipeline.from_yaml("pipeline.yaml") -``` +=== "yaml" + ```python + pipeline = Pipeline.from_yaml("pipeline.yaml") + ``` + +=== "json" + ```python + pipeline = Pipeline.from_json("pipeline.json") + ``` -Serializing the pipeline is very useful when we want to share the pipeline with others, or when we want to store the pipeline for future use. It can even be hosted online, so the pipeline can be executed directly using the [CLI](../cli/index.md) knowing the URL of the pipeline. +Serializing the pipeline is very useful when we want to share the pipeline with others, or when we want to store the pipeline for future use. It can even be hosted online, so the pipeline can be executed directly using the [CLI](../../advanced/cli/index.md). ## Fully working example -To sump up, here is the full code of the pipeline we have created in this section. Note that you will need to change the name of the Hugging Face repository where the resulting will be pushed, set `OPENAI_API_KEY` environment variable, set `MISTRAL_API_KEY` and have `gcloud` installed and configured: +To sum up, here is the full code of the pipeline we have created in this section. Note that you will need to change the name of the Hugging Face repository where the resulting will be pushed, set `OPENAI_API_KEY` environment variable, set `MISTRAL_API_KEY` and have `gcloud` installed and configured: ??? Code ```python from distilabel.llms import MistralLLM, OpenAILLM, VertexAILLM from distilabel.pipeline import Pipeline - from distilabel.steps import CombineColumns, LoadHubDataset + from distilabel.steps import CombineColumns, LoadDataFromHub from distilabel.steps.tasks import TextGeneration with Pipeline("pipe-name", description="My first pipe") as pipeline: - load_dataset = LoadHubDataset( + load_dataset = LoadDataFromHub( name="load_dataset", output_mappings={"prompt": "instruction"}, ) @@ -456,4 +435,3 @@ To sump up, here is the full code of the pipeline we have created in this sectio ) ``` -[^1]: We also have the *cache_dir* argument to pass, for more information on this parameter, we refer the reader to the [caching](../../advanced/caching.md) section. diff --git a/docs/sections/how_to_guides/basic/step/generator_step.md b/docs/sections/how_to_guides/basic/step/generator_step.md new file mode 100644 index 0000000000..c5b665a82e --- /dev/null +++ b/docs/sections/how_to_guides/basic/step/generator_step.md @@ -0,0 +1,110 @@ +# GeneratorStep + +The [`GeneratorStep`][distilabel.steps.GeneratorStep] is a subclass of [`Step`][distilabel.steps.Step] that is intended to be used as the first step within a [`Pipeline`][distilabel.pipeline.Pipeline], because it doesn't require input and generates data that can be used by other steps. Alternatively, it can also be used as a standalone. + +```python +from typing import List +from typing_extensions import override + +from distilabel.steps import GeneratorStep +from distilabel.steps.typing import GeneratorStepOutput + +class MyGeneratorStep(GeneratorStep): + instructions: List[str] + + @override + def process(self, offset: int = 0) -> GeneratorStepOutput: + if offset: + self.instructions = self.instructions[offset:] + + while self.instructions: + batch = [ + { + "instruction": instruction + } for instruction in self.instructions[: self.batch_size] + ] + self.instructions = self.instructions[self.batch_size :] + yield ( + batch, + True if len(self.instructions) == 0 else False, + ) + + @property + def outputs(self) -> List[str]: + return ["instruction"] +``` + +Then we can use it as follows: + +```python +step = MyGeneratorStep( + name="my-generator-step", + instructions=["Tell me a joke.", "Tell me a story."], + batch_size=1, +) +step.load() + +next(step.process(offset=0)) +# ([{'instruction': 'Tell me a joke.'}], False) +next(step.process(offset=1)) +# ([{'instruction': 'Tell me a story.'}], True) +``` + +!!! NOTE + The `Step.load()` always needs to be executed when being used as a standalone. Within a pipeline, this will be done automatically during pipeline execution. + +## Defining custom GeneratorSteps + +We can define a custom generator step by creating a new subclass of the [`GeneratorStep`][distilabel.steps.GeneratorStep] and defining the following: + +- `outputs`: is a property that returns a list of strings with the names of the output fields. + +- `process`: is a method that yields output data and a boolean flag indicating whether that's the last batch to be generated. + +!!! NOTE + The default signature for the `process` method is `process(self, offset: int = 0) -> GeneratorStepOutput`. The argument `offset` should be respected, no more arguments can be provided, and the type-hints and return type-hints should be respected too because it should be able to receive any number of inputs by default i.e. more than one [`Step`][distilabel.steps.Step] at a time could be connected to the current one. + +!!! WARNING + For the custom [`Step`][distilabel.steps.Step] subclasses to work properly with `distilabel` and with the validation and serialization performed by default over each [`Step`][distilabel.steps.Step] in the [`Pipeline`][distilabel.pipeline.Pipeline], the type-hint for both [`StepInput`][distilabel.steps.StepInput] and [`StepOutput`][distilabel.steps.typing.StepOutput] should be used and not surrounded with double-quotes or imported under `typing.TYPE_CHECKING`, otherwise, the validation and/or serialization will fail. + +=== "Inherit from `GeneratorStep`" + + We can inherit from the `GeneratorStep` class and define the `outputs`, and `process` methods as follows: + + + ```python + from typing import List + from typing_extensions import override + + from distilabel.steps import GeneratorStep + from distilabel.steps.typing import GeneratorStepOutput + + class MyGeneratorStep(GeneratorStep): + instructions: List[str] + + @override + def process(self, offset: int = 0) -> GeneratorStepOutput: + ... + + @property + def outputs(self) -> List[str]: + ... + ``` + +=== "Using the `@step` decorator" + + The `@step` decorator will take care of the boilerplate code, and will allow to define the `outputs`, and `process` methods in a more straightforward way. One downside is that it won't let you access the `self` attributes if any, neither set those, so if you need to access or set any attribute, you should go with the first approach of defining the custom [`GeneratorStep`][distilabel.steps.GeneratorStep] subclass. + + ```python + from distilabel.steps import step + from distilabel.steps.typing import GeneratorStepOutput + + @step(outputs=[...], step_type="generator") + def CustomGeneratorStep(offset: int = 0) -> GeneratorStepOutput: + yield ( + ..., + True if offset == 10 else False, + ) + + step = CustomGeneratorStep(name="my-step") + ``` \ No newline at end of file diff --git a/docs/sections/how_to_guides/basic/step/global_step.md b/docs/sections/how_to_guides/basic/step/global_step.md new file mode 100644 index 0000000000..c9044a87d2 --- /dev/null +++ b/docs/sections/how_to_guides/basic/step/global_step.md @@ -0,0 +1,67 @@ +# GlobalStep + +The [`GlobalStep`][distilabel.steps.GlobalStep] is a subclass of [`Step`][distilabel.steps.Step] that is used to define a step that requires the previous steps to be completed to run, since it will wait until all the input batches are received before running. This step is useful when you need to run a step that requires all the input data to be processed before running. Alternatively, it can also be used as a standalone. + +## Defining custom GlobalSteps + +We can define a custom step by creating a new subclass of the [`GlobalStep`][distilabel.steps.GlobalStep] and defining the following: + +- `inputs`: is a property that returns a list of strings with the names of the required input fields. + +- `outputs`: is a property that returns a list of strings with the names of the output fields. + +- `process`: is a method that receives the input data and returns the output data, and it should be a generator, meaning that it should `yield` the output data. + +!!! NOTE + The default signature for the `process` method is `process(self, *inputs: StepInput) -> StepOutput`. The argument `inputs` should be respected, no more arguments can be provided, and the type-hints and return type-hints should be respected too because it should be able to receive any number of inputs by default i.e. more than one [`Step`][distilabel.steps.Step] at a time could be connected to the current one. + +!!! WARNING + For the custom [`GlobalStep`][distilabel.steps.GlobalStep] subclasses to work properly with `distilabel` and with the validation and serialization performed by default over each [`Step`][distilabel.steps.Step] in the [`Pipeline`][distilabel.pipeline.Pipeline], the type-hint for both [`StepInput`][distilabel.steps.StepInput] and [`StepOutput`][distilabel.steps.typing.StepOutput] should be used and not surrounded with double-quotes or imported under `typing.TYPE_CHECKING`, otherwise, the validation and/or serialization will fail. + +=== "Inherit from `GlobalStep`" + + We can inherit from the `GlobalStep` class and define the `inputs`, `outputs`, and `process` methods as follows: + + ```python + from distilabel.steps import GlobalStep, StepInput + from distilabel.steps.typing import StepOutput + + class CustomStep(Step): + @property + def inputs(self) -> List[str]: + ... + + @property + def outputs(self) -> List[str]: + ... + + def process(self, *inputs: StepInput) -> StepOutput: + for input in inputs: + for item in input: + ... + yield item + + # When overridden (ideally under the `typing_extensions.override` decorator) + # @typing_extensions.override + # def process(self, inputs: StepInput) -> StepOutput: + # for input in inputs: + # ... + # yield inputs + ``` + +=== "Using the `@step` decorator" + + The `@step` decorator will take care of the boilerplate code, and will allow to define the `inputs`, `outputs`, and `process` methods in a more straightforward way. One downside is that it won't let you access the `self` attributes if any, neither set those, so if you need to access or set any attribute, you should go with the first approach of defining the custom [`GlobalStep`][distilabel.steps.GlobalStep] subclass. + + ```python + from distilabel.steps import StepInput, step + from distilabel.steps.typing import StepOutput + + @step(inputs=[...], outputs=[...], step_type="global") + def CustomStep(inputs: StepInput) -> StepOutput: + for input in inputs: + ... + yield inputs + + step = CustomStep(name="my-step") + ``` \ No newline at end of file diff --git a/docs/sections/how_to_guides/basic/step/index.md b/docs/sections/how_to_guides/basic/step/index.md new file mode 100644 index 0000000000..e3b19e5334 --- /dev/null +++ b/docs/sections/how_to_guides/basic/step/index.md @@ -0,0 +1,132 @@ +# Define Steps for your Pipeline + +## Working with Steps + +The [`Step`][distilabel.steps.Step] is intended to be used within the scope of a [`Pipeline`][distilabel.pipeline.Pipeline], which will orchestrate the different steps defined but can also be used standalone. + +Assuming that we have a [`Step`][distilabel.steps.Step] already defined as it follows: + +```python +class MyStep(Step): + @property + def inputs(self) -> List[str]: + return ["input_field"] + + @property + def outputs(self) -> List[str]: + return ["output_field"] + + def process(self, inputs: StepInput) -> "StepOutput": + for input in inputs: + input["output_field"] = input["input_field"] + yield inputs +``` + +Then we can use it as follows: + +```python +step = MyStep(name="my-step") +step.load() + +next(step.process([{"input_field": "value"}])) +# [{'input_field': 'value', 'output_field': 'value'}] +``` +!!! NOTE + The `Step.load()` always needs to be executed when being used as a standalone. Within a pipeline, this will be done automatically during pipeline execution. + +### Arguments + +- `input_mappings`, is a dictionary that maps keys from the input dictionaries to the keys expected by the step. For example, if `input_mappings={"instruction": "prompt"}`, means that the input key `prompt` will be used as the key `instruction` for current step. + +- `output_mappings`, is a dictionary that can be used to map the outputs of the step to other names. For example, if `output_mappings={"conversation": "prompt"}`, means that output key `conversation` will be renamed to `prompt` for the next step. + +- `input_batch_size` (by default set to 50), is independent for every step and will determine how many input dictionaries will process at once. + +### Runtime parameters + +`Step`s can also have `RuntimeParameter`, which are parameters that can only used after the pipeline initialisation when calling the `Pipeline.run`. + +```python +from distilabel.mixins.runtime_parameters import RuntimeParameter + +class Step(...): + input_batch_size: RuntimeParameter[PositiveInt] = Field( + default=DEFAULT_INPUT_BATCH_SIZE, + description="The number of rows that will contain the batches processed by the" + " step.", + ) +``` + +## Types of Steps + +There are two special types of [`Step`][distilabel.steps.Step] in `distilabel`: + +* [`GeneratorStep`][distilabel.steps.GeneratorStep]: is a step that only generates data, and it doesn't need any input data from previous steps and normally is the first node in a [`Pipeline`][distilabel.pipeline.Pipeline]. More information: [Components -> Step - GeneratorStep](./generator_step.md). + +* [`GlobalStep`][distilabel.steps.GlobalStep]: is a step with the standard interface i.e. receives inputs and generates outputs, but it processes all the data at once, and often is the final step in the [`Pipeline`][distilabel.pipeline.Pipeline]. The fact that a [`GlobalStep`][distilabel.steps.GlobalStep] requires the previous steps to finish before being able to start. More information: [Components - Step - GlobalStep](global_step.md). + +* [`Task`][distilabel.steps.tasks.Task], is essentially the same as a default [`Step`][distilabel.steps.Step], but it relies on an [`LLM`][distilabel.llms.LLM] as an attribute, and the `process` method will be in charge of calling that LLM. More information: [Components - Task](../task/index.md). + +## Defining custom Steps + +We can define a custom step by creating a new subclass of the [`Step`][distilabel.steps.Step] and defining the following: + +- `inputs`: is a property that returns a list of strings with the names of the required input fields. + +- `outputs`: is a property that returns a list of strings with the names of the output fields. + +- `process`: is a method that receives the input data and returns the output data, and it should be a generator, meaning that it should `yield` the output data. + +!!! NOTE + The default signature for the `process` method is `process(self, *inputs: StepInput) -> StepOutput`. The argument `inputs` should be respected, no more arguments can be provided, and the type-hints and return type-hints should be respected too because it should be able to receive any number of inputs by default i.e. more than one [`Step`][distilabel.steps.Step] at a time could be connected to the current one. + +!!! WARNING + For the custom [`Step`][distilabel.steps.Step] subclasses to work properly with `distilabel` and with the validation and serialization performed by default over each [`Step`][distilabel.steps.Step] in the [`Pipeline`][distilabel.pipeline.Pipeline], the type-hint for both [`StepInput`][distilabel.steps.StepInput] and [`StepOutput`][distilabel.steps.typing.StepOutput] should be used and not surrounded with double-quotes or imported under `typing.TYPE_CHECKING`, otherwise, the validation and/or serialization will fail. + +=== "Inherit from `Step`" + + We can inherit from the `Step` class and define the `inputs`, `outputs`, and `process` methods as follows: + + ```python + from distilabel.steps import Step, StepInput + from distilabel.steps.typing import StepOutput + + class CustomStep(Step): + @property + def inputs(self) -> List[str]: + ... + + @property + def outputs(self) -> List[str]: + ... + + def process(self, *inputs: StepInput) -> StepOutput: + for input in inputs: + ... + yield item + + # When overridden (ideally under the `typing_extensions.override` decorator) + # @typing_extensions.override + # def process(self, inputs: StepInput) -> StepOutput: + # for input in inputs: + # ... + # yield inputs + ``` + +=== "Using the `@step` decorator" + + The `@step` decorator will take care of the boilerplate code, and will allow to define the `inputs`, `outputs`, and `process` methods in a more straightforward way. One downside is that it won't let you access the `self` attributes if any, neither set those, so if you need to access or set any attribute, you should go with the first approach of defining the custom [`Step`][distilabel.steps.Step] subclass. + + + ```python + from distilabel.steps import StepInput, step + from distilabel.steps.typing import StepOutput + + @step(inputs=[...], outputs=[...]) + def CustomStep(inputs: StepInput) -> StepOutput: + for input in inputs: + ... + yield inputs + + step = CustomStep(name="my-step") + ``` \ No newline at end of file diff --git a/docs/sections/learn/tutorial/task/generator_task.md b/docs/sections/how_to_guides/basic/task/generator_task.md similarity index 55% rename from docs/sections/learn/tutorial/task/generator_task.md rename to docs/sections/how_to_guides/basic/task/generator_task.md index c9943154af..040af877d9 100644 --- a/docs/sections/learn/tutorial/task/generator_task.md +++ b/docs/sections/how_to_guides/basic/task/generator_task.md @@ -1,15 +1,11 @@ # GeneratorTask -The [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask] is a custom implementation of a [`Task`][distilabel.steps.tasks.Task], but based on [`GeneratorStep`][distilabel.steps.GeneratorStep]; which means that will essentially be similar to the standard [`Task`][distilabel.steps.tasks.Task], but without the need of providing any input data, as the data will be generated as part of the [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask] execution. - -!!! WARNING - This task is still experimental and may be subject to changes in the future, since apparently it's not the most efficient way to generate data, but it's a good way to generate data on the fly without the need of providing any input data. - ## Working with GeneratorTasks -The subclasses of [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask] are intended to be used within the scope of a [`Pipeline`][distilabel.pipeline.Pipeline], which will orchestrate the different tasks defined; but nonetheless, they can be used standalone if needed too. +The [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask] is a custom implementation of a [`Task`][distilabel.steps.tasks.Task] based on the [`GeneratorStep`][distilabel.steps.GeneratorStep]. As with a [`Task`][distilabel.steps.tasks.Task], it is normally used within a [`Pipeline`][distilabel.pipeline.Pipeline] but can also be used standalone. -These tasks will basically expect no input data, but generate data as part of the `process` method of the parent class. Say you have a [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask] that generates text from a pre-defined instruction: +!!! WARNING + This task is still experimental and may be subject to changes in the future. ```python from typing import Any, Dict, List, Union @@ -48,7 +44,7 @@ class MyCustomTask(GeneratorTask): return {"output_field": output} ``` -To then use it as: +We can then use it as follows: ```python task = MyCustomTask( @@ -66,21 +62,17 @@ next(task.process()) Most of the times you would need to override the default `process` method, as it's suited for the standard [`Task`][distilabel.steps.tasks.Task] and not for the [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask]. But within the context of the `process` function you can freely use the `llm` to generate data in any way. !!! NOTE - The `load` method needs to be called ALWAYS if using the tasks as standalone, otherwise, if the [`Pipeline`][distilabel.pipeline.Pipeline] context manager is used, there's no need to call that method, since it will be automatically called on `Pipeline.run`; but in any other case the method `load` needs to be called from the parent class e.g. a [`GeneratorTask`][distilabel.steps.tasks.Task] with an [`LLM`][distilabel.llms.LLM] will need to call `GeneratorTask.load` to load both the task and the LLM. + The `Step.load()` always needs to be executed when being used as a standalone. Within a pipeline, this will be done automatically during pipeline execution. ## Defining custom GeneratorTasks -In order to define custom tasks, we need to inherit from the [`Task`][distilabel.steps.tasks.Task] class and implement the `format_input` and `format_output` methods, as well as setting the properties `inputs` and `outputs`, as for [`Step`][distilabel.steps.Step] subclasses. - -So on, the following will need to be defined: - -- `process`: is a method that generates the data based on the [`LLM`][distilabel.llms.LLM] and the `instruction` provided within the class instance, and returns a dictionary with the output data formatted as needed i.e. with the values for the columns in `outputs`. Note that the `inputs` argument is not allowed in this function since this is not a [`Task`][distilabel.steps.tasks.Task] but a [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask], so no input data is expected; so the signature only expects the `offset` argument, which is used to keep track of the current iteration in the generator. +We can define a custom generator task by creating a new subclass of the [`GeneratorTask`][distilabel.steps.tasks.Task] and defining the following: -- `outputs`: is a property that returns a list of strings with the names of the output fields. Note that since all the [`Task`][distilabel.steps.tasks.Task] subclasses are designed to work with a single [`LLM`][distilabel.llms.LLM], this property should always include `model_name` as one of the outputs, since that's automatically injected from the LLM. +- `process`: is a method that generates the data based on the [`LLM`][distilabel.llms.LLM] and the `instruction` provided within the class instance, and returns a dictionary with the output data formatted as needed i.e. with the values for the columns in `outputs`. Note that the `inputs` argument is not allowed in this function since this is a [`GeneratorTask`][distilabel.steps.tasks.GeneratorTask]. The signature only expects the `offset` argument, which is used to keep track of the current iteration in the generator. -- `format_output`: is a method that receives the output from the [`LLM`][distilabel.llms.LLM] and optionally also the input data (which may be useful to build the output in some scenarios), and returns a dictionary with the output data formatted as needed i.e. with the values for the columns in `outputs`. Note that there's no need to include the `model_name` in the output, since that's automatically injected from the LLM in the `process` method of the [`Task`][distilabel.steps.tasks.Task]. +- `outputs`: is a property that returns a list of strings with the names of the output fields, this property should always include `model_name` as one of the outputs since that's automatically injected from the LLM. -Once those methods have been implemented, the task can be used as any other task, and it will be able to generate text based on the input data. +- `format_output`: is a method that receives the output from the [`LLM`][distilabel.llms.LLM] and optionally also the input data (which may be useful to build the output in some scenarios), and returns a dictionary with the output data formatted as needed i.e. with the values for the columns in `outputs`. Note that there's no need to include the `model_name` in the output. ```python from typing import Any, Dict, List, Union diff --git a/docs/sections/how_to_guides/basic/task/index.md b/docs/sections/how_to_guides/basic/task/index.md new file mode 100644 index 0000000000..a184357af7 --- /dev/null +++ b/docs/sections/how_to_guides/basic/task/index.md @@ -0,0 +1,76 @@ +# Define Tasks as Steps that rely on LLMs + +## Working with Tasks + +The [`Task`][distilabel.steps.tasks.Task] is a special kind of [`Step`][distilabel.steps.Step] that includes the [`LLM`][distilabel.llms.LLM] as a mandatory argument. As with a [`Step`][distilabel.steps.Step], it is normally used within a [`Pipeline`][distilabel.pipeline.Pipeline] but can also be used standalone. + +For example, the most basic task is the [`TextGeneration`][distilabel.steps.tasks.TextGeneration] task, which generates text based on a given instruction. + +```python +from distilabel.steps.tasks import TextGeneration + +task = TextGeneration( + name="text-generation", + llm=OpenAILLM(model="gpt-4"), +) +task.load() + +next(task.process([{"instruction": "What's the capital of Spain?"}])) +# [ +# { +# "instruction": "What's the capital of Spain?", +# "generation": "The capital of Spain is Madrid.", +# "model_name": "gpt-4", +# "distilabel_metadata": { +# "raw_output_text-generation": "The capital of Spain is Madrid" +# } +# } +# ] +``` + +!!! NOTE + The `Step.load()` always needs to be executed when being used as a standalone. Within a pipeline, this will be done automatically during pipeline execution. + +As shown above, the [`TextGeneration`][distilabel.steps.tasks.TextGeneration] task adds a `generation` based on the `instruction`. Additionally, it provides some metadata about the LLM call through `distilabel_metadata`. This can be disabled by setting the `add_raw_output` attribute to `False` when creating the task. + +## Defining custom Tasks + +We can define a custom step by creating a new subclass of the [`Task`][distilabel.steps.tasks.Task] and defining the following: + +- `inputs`: is a property that returns a list of strings with the names of the required input fields. + +- `format_input`: is a method that receives a dictionary with the input data and returns a [`ChatType`][distilabel.steps.tasks.ChatType] following [the chat-completion OpenAI message formatting](https://platform.openai.com/docs/guides/text-generation). + +- `outputs`: is a property that returns a list of strings with the names of the output fields, this property should always include `model_name` as one of the outputs since that's automatically injected from the LLM. + +- `format_output`: is a method that receives the output from the [`LLM`][distilabel.llms.LLM] and optionally also the input data (which may be useful to build the output in some scenarios), and returns a dictionary with the output data formatted as needed i.e. with the values for the columns in `outputs`. Note that there's no need to include the `model_name` in the output. + +```python +from typing import Any, Dict, List, Union + +from distilabel.steps.tasks.base import Task +from distilabel.steps.tasks.typing import ChatType + + +class MyCustomTask(Task): + @property + def inputs(self) -> List[str]: + return ["input_field"] + + def format_input(self, input: Dict[str, Any]) -> ChatType: + return [ + { + "role": "user", + "content": input["input_field"], + }, + ] + + @property + def outputs(self) -> List[str]: + return ["output_field", "model_name"] + + def format_output( + self, output: Union[str, None], input: Dict[str, Any] + ) -> Dict[str, Any]: + return {"output_field": output} +``` diff --git a/docs/sections/learn/advanced/index.md b/docs/sections/learn/advanced/index.md deleted file mode 100644 index eff42bbe24..0000000000 --- a/docs/sections/learn/advanced/index.md +++ /dev/null @@ -1,3 +0,0 @@ -# Advanced - -This subsection will cover the advanced components of `distilabel` which are either internal specifications on how `distilabel` works or components used to create more complex and robust pipelines. diff --git a/docs/sections/learn/index.md b/docs/sections/learn/index.md deleted file mode 100644 index 92e11b95fd..0000000000 --- a/docs/sections/learn/index.md +++ /dev/null @@ -1,3 +0,0 @@ -# Learn - -Here is a step by step guide to all the components of `distilabel` in a Tutorial form and a special section for more advanced topics. diff --git a/docs/sections/learn/tutorial/index.md b/docs/sections/learn/tutorial/index.md deleted file mode 100644 index 78bfd2629e..0000000000 --- a/docs/sections/learn/tutorial/index.md +++ /dev/null @@ -1,5 +0,0 @@ -# Tutorial - -`Distilabel` builds a [`Pipeline`][distilabel.pipeline.Pipeline] with steps that can be thought of as nodes in a graph, as the [`Pipeline`][distilabel.pipeline.Pipeline] will orchestrate the execution of the [`Step`][distilabel.steps.base.Step] subclasses, and those will be connected as nodes in a Direct Acyclic Graph (DAG). - -This guide can be considered a tutorial, which will guide you through the different components of `distilabel`. diff --git a/docs/sections/learn/tutorial/llm/index.md b/docs/sections/learn/tutorial/llm/index.md deleted file mode 100644 index 68a1a9b0d9..0000000000 --- a/docs/sections/learn/tutorial/llm/index.md +++ /dev/null @@ -1,164 +0,0 @@ -# LLM - -The LLMs are implemented as subclasses of either [`LLM`][distilabel.llms.LLM] or [`AsyncLLM`][distilabel.llms.AsyncLLM], and are only in charge of running the text generation for a given prompt or conversation. The LLMs are intended to be used together with the [`Task`][distilabel.steps.tasks.Task] and any of its subclasses, via the `llm` argument, this means that any of the implemented LLMs can be easily plugged seamlessly into any task. - -## Working with LLMs - -The subclasses of both [`LLM`][distilabel.llms.LLM] or [`AsyncLLM`][distilabel.llms.AsyncLLM] are intended to be used within the scope of a [`Task`][distilabel.steps.tasks.Task], since those are seamlessly integrated within the different tasks; but nonetheless, they can be used standalone if needed. - -```python -from distilabel.llms import OpenAILLM - -llm = OpenAILLM(model="gpt-4") -llm.load() - -llm.generate( - inputs=[ - [ - {"role": "user", "content": "What's the capital of Spain?"}, - ], - ], -) -# "The capital of Spain is Madrid." -``` - -!!! NOTE - The `load` method needs to be called ALWAYS if using the LLMs as standalone or as part of a task, otherwise, if the `Pipeline` context manager is used, there's no need to call that method, since it will be automatically called on `Pipeline.run`; but in any other case the method `load` needs to be called from the parent class e.g. a `Task` with an `LLM` will need to call `Task.load` to load both the task and the LLM. - -### Within a Task - -Now, in order to use the LLM within a [`Task`][distilabel.steps.tasks.Task], we need to pass it as an argument to the task, and the task will take care of the rest. - -```python -from distilabel.llms import OpenAILLM -from distilabel.steps.tasks import TextGeneration - - -llm = OpenAILLM(model="gpt-4") -task = TextGeneration(name="text_generation", llm=llm) - -task.load() - -next(task.process(inputs=[{"instruction": "What's the capital of Spain?"}])) -# [{'instruction': "What's the capital of Spain?", "generation": "The capital of Spain is Madrid."}] -``` - -### Runtime Parameters - -Additionally, besides the runtime parameters that can / need to be provided to the [`Task`][distilabel.steps.tasks], the LLMs can also define their own runtime parameters such as the `generation_kwargs`, and those need to be provided within the `Pipeline.run` method via the argument `params`. - -!!! NOTE - Each LLM subclass may have its own runtime parameters and those can differ between the different implementations, as those are not aligned, since the LLM engines offer different functionalities. - -```python -from distilabel.pipeline import Pipeline -from distilabel.llms import OpenAILLM -from distilabel.steps import LoadDataFromDicts -from distilabel.steps.tasks import TextGeneration - - -with Pipeline(name="text-generation-pipeline") as pipeline: - load_dataset = LoadDataFromDicts( - name="load_dataset", - data=[ - { - "instruction": "Write a short story about a dragon that saves a princess from a tower.", - }, - ], - ) - - text_generation = TextGeneration( - name="text_generation", - llm=OpenAILLM(model="gpt-4"), - ) - - load_dataset >> text_generation - -if __name__ == "__main__": - pipeline.run( - parameters={ - text_generation.name: {"llm": {"generation_kwargs": {"temperature": 0.3}}}, - }, - ) -``` - -## Defining custom LLMs - -In order to define custom LLMs, one must subclass either [`LLM`][distilabel.llms.LLM] or [`AsyncLLM`][distilabel.llms.AsyncLLM], to define a synchronous or asynchronous LLM, respectively. - -One can either extend any of the existing LLMs to override the default behaviour if needed, but also to define a new one from scratch, that could be potentially contributed to the `distilabel` codebase. - -In order to define a new LLM, one must define the following methods: - -* `model_name`: is a property that contains the name of the model to be used, which means that it needs to be retrieved from the LLM using the LLM-specific approach i.e. for [`TransformersLLM`][distilabel.llms.TransformersLLM] the `model_name` will be the `model_name_or_path` provided as an argument, or in [`OpenAILLM`][distilabel.llms.OpenAILLM] the `model_name` will be the `model` provided as an argument. - -* `generate`: is a method that will take a list of prompts and return a list of generated texts. This method will be called by the [`Task`][distilabel.steps.tasks.Task] to generate the texts, so it's the most important method to define. This method will be implemented in the subclass of the [`LLM`][distilabel.llms.LLM] i.e. the synchronous LLM. - -* `agenerate`: is a method that will take a single prompt and return a list of generated texts, since the rest of the behaviour will be controlled by the `generate` method that cannot be overwritten when subclassing [`AsyncLLM`][distilabel.llms.AsyncLLM]. This method will be called by the [`Task`][distilabel.steps.tasks.Task] to generate the texts, so it's the most important method to define. This method will be implemented in the subclass of the [`AsyncLLM`][distilabel.llms.AsyncLLM] i.e. the asynchronous LLM. - -* (optional) `get_last_hidden_state`: is a method that will take a list of prompts and return a list of hidden states. This method is optional and will be used by some tasks such as the [`GenerateEmbeddings`][distilabel.steps.tasks.GenerateEmbeddings] task. - -Once those methods have been implemented, then the custom LLM will be ready to be integrated within either any of the existing or a new task. - -```python -from typing import Any - -from pydantic import validate_call - -from distilabel.llms import AsyncLLM, LLM -from distilabel.llms.typing import GenerateOutput, HiddenState -from distilabel.steps.tasks.typing import ChatType - - -class CustomLLM(LLM): - @property - def model_name(self) -> str: - return "my-model" - - @validate_call - def generate(self, inputs: List[ChatType], num_generations: int = 1, **kwargs: Any) -> List[GenerateOutput]: - for _ in range(num_generations): - ... - - def get_last_hidden_state(self, inputs: List[ChatType]) -> List[HiddenState]: - ... - - -class CustomAsyncLLM(AsyncLLM): - @property - def model_name(self) -> str: - return "my-model" - - @validate_call - async def agenerate(self, input: ChatType, num_generations: int = 1, **kwargs: Any) -> GenerateOutput: - for _ in range(num_generations): - ... - - def get_last_hidden_state(self, inputs: List[ChatType]) -> List[HiddenState]: - ... -``` - -`generate` and `agenerate` keyword arguments (but `input` and `num_generations`) are considered as `RuntimeParameter`s, so a value can be passed to them via the `parameters` argument of the `Pipeline.run` method. - -!!! NOTE - To have the arguments of the `generate` and `agenerate` coerced to the expected types, the `validate_call` decorator is used, which will automatically coerce the arguments to the expected types, and raise an error if the types are not correct. This is specially useful when providing a value for an argument of `generate` or `agenerate` from the CLI, since the CLI will always provide the arguments as strings. - -## Available LLMs - -Here's a list with the available LLMs that can be used within the `distilabel` library: - -* [AnthropicLLM][distilabel.llms.AnthropicLLM] -* [AnyscaleLLM][distilabel.llms.AnyscaleLLM] -* [AzureOpenAILLM][distilabel.llms.AzureOpenAILLM] -* [CohereLLM][distilabel.llms.CohereLLM] -* [GroqLLM][distilabel.llms.GroqLLM] -* [InferenceEndpointsLLM][distilabel.llms.huggingface.InferenceEndpointsLLM] -* [LiteLLM][distilabel.llms.LiteLLM] -* [LlamaCppLLM][distilabel.llms.LlamaCppLLM] -* [MistralLLM][distilabel.llms.MistralLLM] -* [OllamaLLM][distilabel.llms.OllamaLLM] -* [OpenAILLM][distilabel.llms.OpenAILLM] -* [TogetherLLM][distilabel.llms.TogetherLLM] -* [TransformersLLM][distilabel.llms.huggingface.TransformersLLM] -* [VertexAILLM][distilabel.llms.VertexAILLM] -* [vLLM][distilabel.llms.vLLM] diff --git a/docs/sections/learn/tutorial/step/generator_step.md b/docs/sections/learn/tutorial/step/generator_step.md deleted file mode 100644 index a5599174ab..0000000000 --- a/docs/sections/learn/tutorial/step/generator_step.md +++ /dev/null @@ -1,117 +0,0 @@ -# GeneratorStep - -The [`GeneratorStep`][distilabel.steps.GeneratorStep] is a subclass of [`Step`][distilabel.steps.Step] that only produces outputs, but doesn't receive any input. The [`GeneratorStep`][distilabel.steps.GeneratorStep] is intended to be used the first step within a [`Pipeline`][distilabel.pipeline.Pipeline], since it doesn't require any input to run and will generate data that can be potentially used by the follow up steps. - -## Working with GeneratorSteps - -The [`GeneratorStep`][distilabel.steps.GeneratorStep] is intended to be used within the scope of a [`Pipeline`][distilabel.pipeline.Pipeline] before any other [`Step`][distilabel.steps.Step]. Alternatively, in can also be used as a standalone [`Step`][distilabel.steps.Step] i.e. not within the context of a [`Pipeline`][distilabel.pipeline.Pipeline]. - -For example, the following code snippet shows how to use the [`GeneratorStep`][distilabel.steps.GeneratorStep] as a standalone [`Step`][distilabel.steps.Step], to generate data out of a provided list of strings. - -```python -from typing import List -from typing_extensions import override - -from distilabel.steps import GeneratorStep -from distilabel.steps.typing import GeneratorStepOutput - -class MyGeneratorStep(GeneratorStep): - instructions: List[str] - - @override - def process(self, offset: int = 0) -> GeneratorStepOutput: - if offset: - self.instructions = self.instructions[offset:] - - while self.instructions: - batch = [ - { - "instruction": instruction - } for instruction in self.instructions[: self.batch_size] - ] - self.instructions = self.instructions[self.batch_size :] - yield ( - batch, - True if len(self.instructions) == 0 else False, - ) - - @property - def outputs(self) -> List[str]: - return ["instruction"] -``` - -Then we can use / instantiate it as follows: - -```python -step = MyGeneratorStep( - name="my-generator-step", - instructions=["Tell me a joke.", "Tell me a story."], - batch_size=1, -) -step.load() - -next(step.process(offset=0)) -# ([{'instruction': 'Tell me a joke.'}], False) -next(step.process(offset=1)) -# ([{'instruction': 'Tell me a story.'}], True) -``` - -!!! NOTE - The `load` method needs to be called ALWAYS if using the steps and any [`Step`][distilabel.steps.Step] subclass as standalone, unless the [`Pipeline`][distilabel.pipeline.Pipeline] context manager is used, meaning that there will be no need to call the `load` method, since it will be automatically called on `Pipeline.run`; but in any other case the method `load` needs to be called from the parent class. - -Anyway, most of the times we'll end up using pre-defined steps in `distilabel`, so that there's no need to create custom steps, but anyway, we'll cover that later in this page. - -## Defining custom GeneratorSteps - -In order to define a custom [`GeneratorStep`][distilabel.steps.GeneratorStep], we need to subclass it, and set the `outputs` property, and define the `process` method. In this case, the `process` method signature differs from the `process` method signature of the [`Step`][distilabel.steps.Step], since it won't receive any `inputs` but generate those, so the only argument of `process` is `offset` which is automatically handled by the [`Pipeline`][distilabel.pipeline.Pipeline] shifting it until all the batches are generated. - -So on, the following will need to be defined: - -- `outputs`: is a property that returns a list of strings with the names of the output fields. - -- `process`: is a method that yields output data and a boolean flag indicating whether that's the last batch to be generated. It's important to override the default signature of the [`Step.process`][distilabel.steps.Step] method `def process(self, *inputs: StepInput) -> StepOutput`, to be set to `def process(self, offset: int = 0) -> GeneratorStepOutput` instead, since that's the one that will be used by the [`Pipeline`][distilabel.pipeline.Pipeline] to orchestrate the steps, meaning that the argument `offset` should be respected, no more arguments can be provided, and the type-hints and return type-hints should be respected too. - -!!! NOTE - The default signature for the `process` method is `process(self, *inputs: StepInput) -> StepOutput`, but since in this case we're defining a [`GeneratorStep`][distilabel.steps.GeneratorStep], we will need to override that (ideally under the `typing_extensions.override` decorator) with `process(self, offset: int = 0) -> GeneratorStepOutput`, so that the `process` method only receives the `offset` argument, and the return type-hints should be respected too. The `offset` argument is automatically handled by the [`Pipeline`][distilabel.pipeline.Pipeline] shifting it until all the batches are generated, and there's no need to default it to 0, since it will be set to 0 by default anyway. - -!!! WARNING - For the custom [`GeneratorStep`][distilabel.steps.GeneratorStep] subclasses to work properly with `distilabel` and with the validation and serialization performed by default over each [`Step`][distilabel.steps.Step] in the [`Pipeline`][distilabel.pipeline.Pipeline], the type-hint for [`GeneratorStepOutput`][distilabel.steps.typing.GeneratorStepOutput] should be used and not surrounded with double-quotes or imported under `typing.TYPE_CHECKING`, otherwise, the validation and/or serialization will fail. - -```python -from typing import List -from typing_extensions import override - -from distilabel.steps import GeneratorStep -from distilabel.steps.typing import GeneratorStepOutput - -class MyGeneratorStep(GeneratorStep): - instructions: List[str] - - @override - def process(self, offset: int = 0) -> GeneratorStepOutput: - ... - - @property - def outputs(self) -> List[str]: - ... -``` - -Alternatively, a simpler and more suitable way of defining custom [`GeneratorStep`][distilabel.steps.GeneratorStep] subclasses is via the `@step` decorator with the `step_type="generator"`, which will take care of the boilerplate code, and will allow to define the `outputs`, and `process` methods in a more straightforward way. - -```python -from distilabel.steps import step -from distilabel.steps.typing import GeneratorStepOutput - -@step(outputs=[...], step_type="generator") -def CustomGeneratorStep(offset: int = 0) -> GeneratorStepOutput: - yield ( - ..., - True if offset == 10 else False, - ) - -step = CustomGeneratorStep(name="my-step") -``` - -!!! WARNING - One downside of the `@step` decorator is that it won't let you access the `self` attributes if any, neither set those, so if you need to access or set any attribute, you should go with the first approach of defining the custom [`GeneratorStep`][distilabel.steps.GeneratorStep] subclass. - diff --git a/docs/sections/learn/tutorial/step/global_step.md b/docs/sections/learn/tutorial/step/global_step.md deleted file mode 100644 index 1b08884f44..0000000000 --- a/docs/sections/learn/tutorial/step/global_step.md +++ /dev/null @@ -1,70 +0,0 @@ -# GlobalStep - -The [`GlobalStep`][distilabel.steps.GlobalStep] is a subclass of [`Step`][distilabel.steps.Step] that is used to define a step that requires the previous steps to be completed to run, since it will wait until all the input batches are received before running. This step is useful when you need to run a step that requires all the input data to be processed before running. - -## Working with GlobalSteps - -The [`GlobalStep`][distilabel.steps.GlobalStep] is intended to be used within the scope of a [`Pipeline`][distilabel.pipeline.Pipeline] and after some previous steps have been defined. Alternatively, it can also be used as a standalone [`Step`][distilabel.steps.Step] if needed, but then using [`Step`][distilabel.steps.Step] instead would be more appropriate. - -## Defining custom GlobalSteps - -In order to define custom steps, we need to create a new subclass of the [`GlobalStep`][distilabel.steps.GlobalStep] class, and set both the `inputs` and `outputs` property, as well as the `process` method. - -So on, the following will need to be defined: - -- `inputs`: is a property that returns a list of strings with the names of the required input fields. - -- `outputs`: is a property that returns a list of strings with the names of the output fields. - -- `process`: is a method that receives the input data and returns the output data, and it should be a generator, meaning that it should `yield` the output data. It's important to preserve the default signature within the method `def process(self, *inputs: StepInput) -> StepOutput`, since that's the one that will be used by the [`Pipeline`][distilabel.pipeline.Pipeline] to orchestrate the steps, meaning that the argument `inputs` should be respected, no more arguments can be provided, and the type-hints and return type-hints should be respected too. - -!!! NOTE - The default signature for the `process` method is `process(self, *inputs: StepInput) -> StepOutput`, meaning that it should be able to receive any number of inputs by default i.e. more than one [`Step`][distilabel.steps.Step] at a time could be connected to the current one. Anyway, when defining custom steps, that can be overridden with `process(self, inputs: StepInput) -> StepOutput`, so that the `process` method only receives the outputs from one previous [`Step`][distilabel.steps.Step] connected to it. - -!!! WARNING - For the custom [`GlobalStep`][distilabel.steps.GlobalStep] subclasses to work properly with `distilabel` and with the validation and serialization performed by default over each [`Step`][distilabel.steps.Step] in the [`Pipeline`][distilabel.pipeline.Pipeline], the type-hint for both [`StepInput`][distilabel.steps.StepInput] and [`StepOutput`][distilabel.steps.typing.StepOutput] should be used and not surrounded with double-quotes or imported under `typing.TYPE_CHECKING`, otherwise, the validation and/or serialization will fail. - -```python -from distilabel.steps import GlobalStep, StepInput -from distilabel.steps.typing import StepOutput - -class CustomStep(Step): - @property - def inputs(self) -> List[str]: - ... - - @property - def outputs(self) -> List[str]: - ... - - def process(self, *inputs: StepInput) -> StepOutput: - for input in inputs: - for item in input: - ... - yield item - - # When overridden (ideally under the `typing_extensions.override` decorator) - # @typing_extensions.override - # def process(self, inputs: StepInput) -> StepOutput: - # for input in inputs: - # ... - # yield inputs -``` - -Alternatively, a simpler and more suitable way of defining custom [`GlobalStep`][distilabel.steps.GlobalStep] subclasses is via the `@step` decorator with the `step_type="global"`, which will take care of the boilerplate code, and will allow to define the `inputs`, `outputs`, and `process` methods in a more straightforward way. - -```python -from distilabel.steps import StepInput, step -from distilabel.steps.typing import StepOutput - -@step(inputs=[...], outputs=[...], step_type="global") -def CustomStep(inputs: StepInput) -> StepOutput: - for input in inputs: - ... - yield inputs - -step = CustomStep(name="my-step") -``` - -!!! WARNING - One downside of the `@step` decorator is that it won't let you access the `self` attributes if any, neither set those, so if you need to access or set any attribute, you should go with the first approach of defining the custom [`GlobalStep`][distilabel.steps.GlobalStep] subclass. diff --git a/docs/sections/learn/tutorial/step/index.md b/docs/sections/learn/tutorial/step/index.md deleted file mode 100644 index df9d1bbc74..0000000000 --- a/docs/sections/learn/tutorial/step/index.md +++ /dev/null @@ -1,142 +0,0 @@ -# Step - -The [`Step`][distilabel.steps.Step] is an abstract class which defines the interface for the building blocks to be defined within the context of a [`Pipeline`][distilabel.pipeline.Pipeline], a [`Step`][distilabel.steps.Step] can be seen as a node within a Direct Acyclic Graph (DAG) which execution is orchestrated by the [`Pipeline`][distilabel.pipeline.Pipeline]. - -## Working with Steps - -The [`Step`][distilabel.steps.Step] is intended to be used within the scope of a [`Pipeline`][distilabel.pipeline.Pipeline], which will orchestrate the different steps defined; but nonetheless, they can be used standalone if needed too. - -Assuming that we have a [`Step`][distilabel.steps.Step] already defined as it follows: - -```python -class MyStep(Step): - @property - def inputs(self) -> List[str]: - return ["input_field"] - - @property - def outputs(self) -> List[str]: - return ["output_field"] - - def process(self, inputs: StepInput) -> "StepOutput": - for input in inputs: - input["output_field"] = input["input_field"] - yield inputs -``` - -Then we can use / instantiate it as follows: - -```python -step = MyStep(name="my-step") -step.load() - -next(step.process([{"input_field": "value"}])) -# [{'input_field': 'value', 'output_field': 'value'}] -``` -!!! NOTE - The `load` method needs to be called ALWAYS if using the steps and any [`Step`][distilabel.steps.Step] subclass as standalone, unless the [`Pipeline`][distilabel.pipeline.Pipeline] context manager is used, meaning that there will be no need to call the `load` method, since it will be automatically called on `Pipeline.run`; but in any other case the method `load` needs to be called from the parent class. - -Anyway, most of the times we'll end up using pre-defined steps in `distilabel`, so that there's no need to create custom steps, but anyway, we'll cover that later in this page. - -Let's see now a set of arguments that can be used to map fields across steps, or to set the batch size specific for the step: - -- `input_mappings`, which is a dictionary that can be useful to map keys from the input dictionaries to the keys expected by the step. For example, if `input_mappings={"instruction": "prompt"}`, that means that the key prompt from the input dictionaries will be used as the key instruction for the step. - -- `output_mappings`, which is a dictionary that can be used to map the outputs of the step to other names. For example, if `output_mappings={"conversation": "prompt"}`, that means that the key conversation generated by the step will be renamed to prompt and the output dictionaries of this step will contain a key called prompt instead of conversation. - -- `input_batch_size` (by default set to 50), which is independent for every step and will determine how many input dictionaries will process at once. If won't matter that much in this step, but as we will see later, other types of steps will come with an LLM, so having this flexibility will be really useful. - -### Runtime parameters - -Finally, let's introduce at a special type of argument that we will find when dealing with the `Steps`, the `Runtime parameters`. For example, the `input_batch_size` is of type `RuntimeParameter`: - -```python -from distilabel.mixins.runtime_parameters import RuntimeParameter - -class Step(...): - input_batch_size: RuntimeParameter[PositiveInt] = Field( - default=DEFAULT_INPUT_BATCH_SIZE, - description="The number of rows that will contain the batches processed by the" - " step.", - ) -``` - -We can interact with these types of arguments when we call the `Pipeline.run` method as we will see in the `Pipeline` section. These types of arguments can be really useful to insert info to the steps after the pipeline has been defined. - -## Types of Steps - -Besides the default [`Step`][distilabel.steps.Step] already described, in `distilabel` we find the following abstract subclasses on top of the [`Step`][distilabel.steps.Step]. - -* [`GeneratorStep`][distilabel.steps.GeneratorStep]: is a step that only produces / generates data, and it doesn't need any input data from previous steps, is in most of the cases a parent node of the graph i.e. the first [`Step`][distilabel.steps.Step] in the [`Pipeline`][distilabel.pipeline.Pipeline]. - - More information about it at [Components -> Step - GeneratorStep](./generator_step.md). - -* [`GlobalStep`][distilabel.steps.GlobalStep]: is a step with the standard interface i.e. receives inputs and generates outputs, but it processes all the data at once, is in most of the cases a leaf node of the graph i.e. the last [`Step`][distilabel.steps.Step] in the [`Pipeline`][distilabel.pipeline.Pipeline]. The fact that a [`GlobalStep`][distilabel.steps.GlobalStep] requires the outputs from the previous steps, means that the previous steps needs to finish for this step to start, and the connected outputs steps, if any, will need to wait until this step is done. - - More information about it at [Components - Step - GlobalStep](global_step.md). - -Additionally, `distilabel` also defines another type of [`Step`][distilabel.steps.Step], which is the [`Task`][distilabel.steps.tasks.Task], which is essentially the same, besides the fact that the task will expect an [`LLM`][distilabel.llms.LLM] as an attribute, and the `process` method will be in charge of calling that LLM. So one could say that the [`Task`][distilabel.steps.tasks.Task] is a [`Step`][distilabel.steps.Step] to work with an [`LLM`][distilabel.llms.LLM]. - -More information about it at [Components - Task](../task/index.md). - -## Defining custom Steps - -In order to define custom steps, we need to create a new subclass of the [`Step`][distilabel.steps.Step] class, and set both the `inputs` and `outputs` property, as well as the `process` method. - -So on, the following will need to be defined: - -- `inputs`: is a property that returns a list of strings with the names of the required input fields. - -- `outputs`: is a property that returns a list of strings with the names of the output fields. - -- `process`: is a method that receives the input data and returns the output data, and it should be a generator, meaning that it should `yield` the output data. It's important to preserve the default signature within the method `def process(self, *inputs: StepInput) -> StepOutput`, since that's the one that will be used by the [`Pipeline`][distilabel.pipeline.Pipeline] to orchestrate the steps, meaning that the argument `inputs` should be respected, no more arguments can be provided, and the type-hints and return type-hints should be respected too. - -!!! NOTE - The default signature for the `process` method is `process(self, *inputs: StepInput) -> StepOutput`, meaning that it should be able to receive any number of inputs by default i.e. more than one [`Step`][distilabel.steps.Step] at a time could be connected to the current one. Anyway, when defining custom steps, that can be overridden with `process(self, inputs: StepInput) -> StepOutput`, so that the `process` method only receives the outputs from one previous [`Step`][distilabel.steps.Step] connected to it. - -!!! WARNING - For the custom [`Step`][distilabel.steps.Step] subclasses to work properly with `distilabel` and with the validation and serialization performed by default over each [`Step`][distilabel.steps.Step] in the [`Pipeline`][distilabel.pipeline.Pipeline], the type-hint for both [`StepInput`][distilabel.steps.StepInput] and [`StepOutput`][distilabel.steps.typing.StepOutput] should be used and not surrounded with double-quotes or imported under `typing.TYPE_CHECKING`, otherwise, the validation and/or serialization will fail. - -```python -from distilabel.steps import Step, StepInput -from distilabel.steps.typing import StepOutput - -class CustomStep(Step): - @property - def inputs(self) -> List[str]: - ... - - @property - def outputs(self) -> List[str]: - ... - - def process(self, *inputs: StepInput) -> StepOutput: - for input in inputs: - ... - yield item - - # When overridden (ideally under the `typing_extensions.override` decorator) - # @typing_extensions.override - # def process(self, inputs: StepInput) -> StepOutput: - # for input in inputs: - # ... - # yield inputs -``` - -Alternatively, a simpler and more suitable way of defining custom [`Step`][distilabel.steps.Step] subclasses is via the `@step` decorator, which will take care of the boilerplate code, and will allow to define the `inputs`, `outputs`, and `process` methods in a more straightforward way. - -```python -from distilabel.steps import StepInput, step -from distilabel.steps.typing import StepOutput - -@step(inputs=[...], outputs=[...]) -def CustomStep(inputs: StepInput) - StepOutput: - for input in inputs: - ... - yield inputs - -step = CustomStep(name="my-step") -``` - -!!! WARNING - One downside of the `@step` decorator is that it won't let you access the `self` attributes if any, neither set those, so if you need to access or set any attribute, you should go with the first approach of defining the custom [`Step`][distilabel.steps.Step] subclass. diff --git a/docs/sections/learn/tutorial/task/index.md b/docs/sections/learn/tutorial/task/index.md deleted file mode 100644 index 2e322895e7..0000000000 --- a/docs/sections/learn/tutorial/task/index.md +++ /dev/null @@ -1,83 +0,0 @@ -# Task - -The [`Task`][distilabel.steps.tasks.Task] is an implementation on top of [`Step`][distilabel.steps.Step] that includes the [`LLM`][distilabel.llms.LLM] as a mandatory argument, so that the [`Task`][distilabel.steps.tasks.Task] defines both the input and output format via the `format_input` and `format_output` abstract methods, respectively; and calls the [`LLM`][distilabel.llms.LLM] to generate the text. We can see the [`Task`][distilabel.steps.tasks.Task] as an [`LLM`][distilabel.llms.LLM] powered [`Step`][distilabel.steps.Step]. - -## Working with Tasks - -The subclasses of [`Task`][distilabel.steps.tasks.Task] are intended to be used within the scope of a [`Pipeline`][distilabel.pipeline.Pipeline], which will orchestrate the different tasks defined; but nonetheless, they can be used standalone if needed too. - -For example, the most basic task is the [`TextGeneration`][distilabel.steps.tasks.TextGeneration] task, which generates text based on a given instruction, and it can be used standalone as well as within a [`Pipeline`][distilabel.pipeline.Pipeline]. - -```python -```python -from distilabel.steps.tasks import TextGeneration - -task = TextGeneration( - name="text-generation", - llm=OpenAILLM(model="gpt-4"), -) -task.load() - -next(task.process([{"instruction": "What's the capital of Spain?"}])) -# [ -# { -# "instruction": "What's the capital of Spain?", -# "generation": "The capital of Spain is Madrid.", -# "model_name": "gpt-4", -# "distilabel_metadata": { -# "raw_output_text-generation": "The capital of Spain is Madrid" -# } -# } -# ] -``` - -!!! NOTE - The `load` method needs to be called ALWAYS if using the tasks as standalone, otherwise, if the [`Pipeline`][distilabel.pipeline.Pipeline] context manager is used, there's no need to call that method, since it will be automatically called on `Pipeline.run`; but in any other case the method `load` needs to be called from the parent class e.g. a [`Task`][distilabel.steps.tasks.Task] with an [`LLM`][distilabel.llms.LLM] will need to call `Task.load` to load both the task and the LLM. - -As we can see in the comment of the code snippet above, the task has enriched the input dictionaries adding the `generation`, the `model_name` that was used to generate, and finally the `distilabel_metadata` dictionary that contains the raw output (without post-processing) from the LLM. In this case, the `TextGeneration` task does no post-processing, so the `generation` and the raw output is the same, but some other tasks do post-processing, which in some situations it can fail. That's why is useful to have the raw output available in the `distilabel_metadata` dictionary. If this default behaviour is not desired, then all the `Task`s has a `add_raw_output` attribute that we can set to `False` when creating the instance of the task or at run time. - -## Defining custom Tasks - -In order to define custom tasks, we need to inherit from the [`Task`][distilabel.steps.tasks.Task] class and implement the `format_input` and `format_output` methods, as well as setting the properties `inputs` and `outputs`, as for [`Step`][distilabel.steps.Step] subclasses. - -So on, the following will need to be defined: - -- `inputs`: is a property that returns a list of strings with the names of the required input fields. - -- `format_input`: is a method that receives a dictionary with the input data and returns a [`ChatType`][distilabel.steps.tasks.ChatType], which is basically a list of dictionaries with the input data formatted for the [`LLM`][distilabel.llms.LLM] following [the chat-completion OpenAI formatting](https://platform.openai.com/docs/guides/text-generation). It's important to note that the [`ChatType`][distilabel.steps.tasks.ChatType] is a list of dictionaries, where each dictionary represents a turn in the conversation, and it must contain the keys `role` and `content`, and this is done like this since the [`LLM`][distilabel.llms.LLM] subclasses will format that according to the LLM used, since it's the most standard formatting. - -- `outputs`: is a property that returns a list of strings with the names of the output fields. Note that since all the [`Task`][distilabel.steps.tasks.Task] subclasses are designed to work with a single [`LLM`][distilabel.llms.LLM], this property should always include `model_name` as one of the outputs, since that's automatically injected from the LLM. - -- `format_output`: is a method that receives the output from the [`LLM`][distilabel.llms.LLM] and optionally also the input data (which may be useful to build the output in some scenarios), and returns a dictionary with the output data formatted as needed i.e. with the values for the columns in `outputs`. Note that there's no need to include the `model_name` in the output, since that's automatically injected from the LLM in the `process` method of the [`Task`][distilabel.steps.tasks.Task]. - -Once those methods have been implemented, the task can be used as any other task, and it will be able to generate text based on the input data. - -```python -from typing import Any, Dict, List, Union - -from distilabel.steps.tasks.base import Task -from distilabel.steps.tasks.typing import ChatType - - -class MyCustomTask(Task): - @property - def inputs(self) -> List[str]: - return ["input_field"] - - def format_input(self, input: Dict[str, Any]) -> ChatType: - return [ - { - "role": "user", - "content": input["input_field"], - }, - ] - - @property - def outputs(self) -> List[str]: - return ["output_field", "model_name"] - - def format_output( - self, output: Union[str, None], input: Dict[str, Any] - ) -> Dict[str, Any]: - return {"output_field": output} -``` diff --git a/docs/sections/pipeline_samples/examples/index.md b/docs/sections/pipeline_samples/examples/index.md index aa74004357..19b2136278 100644 --- a/docs/sections/pipeline_samples/examples/index.md +++ b/docs/sections/pipeline_samples/examples/index.md @@ -44,7 +44,7 @@ Answer instructions with knowledge graphs defined as `pydantic.BaseModel` object ``` ??? "Visualizing the graphs" - + Want to see how to visualize the graphs? You can test it using the following script. Generate some samples on your own and take a look: !!! NOTE diff --git a/docs/sections/pipeline_samples/index.md b/docs/sections/pipeline_samples/index.md deleted file mode 100644 index e2fa065098..0000000000 --- a/docs/sections/pipeline_samples/index.md +++ /dev/null @@ -1,3 +0,0 @@ -# Pipeline Samples - -Take a look at this section to see some [`Examples`](./examples/index.md) of pipelines ready to run or go visit the [`Papers`](./papers/index.md) section for more structured implementations of pipelines seen in the literature. \ No newline at end of file diff --git a/docs/sections/pipeline_samples/papers/deita.md b/docs/sections/pipeline_samples/papers/deita.md index f4d3014464..5c9036d756 100644 --- a/docs/sections/pipeline_samples/papers/deita.md +++ b/docs/sections/pipeline_samples/papers/deita.md @@ -38,7 +38,7 @@ Import distilabel: ```python from distilabel.llms import TransformersLLM, OpenAILLM from distilabel.pipeline import Pipeline -from distilabel.steps import ConversationTemplate, DeitaFiltering, ExpandColumns, LoadHubDataset +from distilabel.steps import ConversationTemplate, DeitaFiltering, ExpandColumns, LoadDataFromHub from distilabel.steps.tasks import ComplexityScorer, EvolInstruct, EvolQuality, GenerateEmbeddings, QualityScorer ``` @@ -47,7 +47,7 @@ Define the distilabel Pipeline and load the dataset from the Hugging Face Hub. ```python pipeline = Pipeline(name="DEITA") -load_data = LoadHubDataset( +load_data = LoadDataFromHub( name="load_data", batch_size=100, output_mappings={"prompt": "instruction"}, pipeline=pipeline ) ``` diff --git a/docs/sections/pipeline_samples/papers/instruction_backtranslation.md b/docs/sections/pipeline_samples/papers/instruction_backtranslation.md index c5c1968712..cd05c9aafe 100644 --- a/docs/sections/pipeline_samples/papers/instruction_backtranslation.md +++ b/docs/sections/pipeline_samples/papers/instruction_backtranslation.md @@ -30,7 +30,7 @@ And since we will be using [`InferenceEndpointsLLM`][distilabel.llms.InferenceEn #### Building blocks -- [`LoadHubDataset`][distilabel.steps.LoadHubDataset]: Generator Step to load a dataset from the Hugging Face Hub. +- [`LoadDataFromHub`][distilabel.steps.LoadDataFromHub]: Generator Step to load a dataset from the Hugging Face Hub. - [`TextGeneration`][distilabel.steps.tasks.TextGeneration]: Task to generate responses for a given instruction using an LLM. - [`InferenceEndpointsLLM`][distilabel.llms.InferenceEndpointsLLM]: LLM that runs a model from an Inference Endpoint in the Hugging Face Hub. - [`InstructionBacktranslation`][distilabel.steps.tasks.InstructionBacktranslation]: Task that generates a score and a reason for a response for a given instruction using the Self Alignment with Instruction Backtranslation prompt. @@ -43,12 +43,12 @@ As mentioned before, we will put the previously mentioned building blocks togeth ```python from distilabel.llms import InferenceEndpointsLLM, OpenAILLM from distilabel.pipeline import Pipeline -from distilabel.steps import LoadHubDataset +from distilabel.steps import LoadDataFromHub from distilabel.steps.tasks import InstructionBacktranslation, TextGeneration with Pipeline(name="self-alignment-with-instruction-backtranslation") as pipeline: - load_hub_dataset = LoadHubDataset( + load_hub_dataset = LoadDataFromHub( name="load_dataset", output_mappings={"prompt": "instruction"}, ) diff --git a/docs/sections/pipeline_samples/papers/prometheus.md b/docs/sections/pipeline_samples/papers/prometheus.md index ca148d00ac..7f7b1d19d5 100644 --- a/docs/sections/pipeline_samples/papers/prometheus.md +++ b/docs/sections/pipeline_samples/papers/prometheus.md @@ -44,7 +44,7 @@ pip install flash-attn --no-build-isolation #### Building blocks -- [`LoadHubDataset`][distilabel.steps.LoadHubDataset]: [`GeneratorStep`][distilabel.steps.GeneratorStep] to load a dataset from the Hugging Face Hub. +- [`LoadDataFromHub`][distilabel.steps.LoadDataFromHub]: [`GeneratorStep`][distilabel.steps.GeneratorStep] to load a dataset from the Hugging Face Hub. - [`PrometheusEval`][distilabel.steps.tasks.PrometheusEval]: [`Task`][distilabel.steps.tasks.Task] that assesses the quality of a response for a given instruction using any of the Prometheus 2 models. - [`vLLM`][distilabel.llms.vLLM]: [`LLM`][distilabel.llms.LLM] that loads a model from the Hugging Face Hub via [vllm-project/vllm](https://github.com/vllm-project/vllm). @@ -61,12 +61,12 @@ As mentioned before, we will put the previously mentioned building blocks togeth ```python from distilabel.llms import vLLM from distilabel.pipeline import Pipeline -from distilabel.steps import KeepColumns, LoadHubDataset +from distilabel.steps import KeepColumns, LoadDataFromHub from distilabel.steps.tasks import PrometheusEval if __name__ == "__main__": with Pipeline(name="prometheus") as pipeline: - load_dataset = LoadHubDataset( + load_dataset = LoadDataFromHub( name="load_dataset", repo_id="HuggingFaceH4/instruction-dataset", split="test", diff --git a/docs/sections/pipeline_samples/papers/ultrafeedback.md b/docs/sections/pipeline_samples/papers/ultrafeedback.md index b63702ef67..df36e0345b 100644 --- a/docs/sections/pipeline_samples/papers/ultrafeedback.md +++ b/docs/sections/pipeline_samples/papers/ultrafeedback.md @@ -24,7 +24,7 @@ And since we will be using `vllm` we will need to use a VM with at least 6 NVIDI #### Building blocks -- [`LoadHubDataset`][distilabel.steps.LoadHubDataset]: Generator Step to load a dataset from the Hugging Face Hub. +- [`LoadDataFromHub`][distilabel.steps.LoadDataFromHub]: Generator Step to load a dataset from the Hugging Face Hub. - [`sample_n_steps`][distilabel.pipeline.sample_n_steps]: Function to create a `routing_batch_function` that samples `n` downstream steps for each batch generated by the upstream step. This is the key to replicate the LLM pooling mechanism described in the paper. - [`TextGeneration`][distilabel.steps.tasks.TextGeneration]: Task to generate responses for a given instruction using an LLM. - [`vLLM`][distilabel.llms.vLLM]: LLM that loads a model from the Hugging Face Hub using `vllm`. @@ -44,7 +44,7 @@ from distilabel.pipeline import Pipeline, sample_n_steps from distilabel.steps import ( CombineColumns, KeepColumns, - LoadHubDataset, + LoadDataFromHub, PreferenceToArgilla, ) from distilabel.steps.tasks import TextGeneration, UltraFeedback @@ -53,7 +53,7 @@ sample_three_llms = sample_n_steps(n=3) with Pipeline(name="ultrafeedback-pipeline") as pipeline: - load_hub_dataset = LoadHubDataset( + load_hub_dataset = LoadDataFromHub( name="load_dataset", output_mappings={"prompt": "instruction"}, batch_size=2, diff --git a/mkdocs.yml b/mkdocs.yml index 872e0823b8..4edbb2fed1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,13 +36,23 @@ theme: icon: repo: fontawesome/brands/github-alt features: - - navigation.sections # Sections are included in the navigation on the left. - # - toc.integrate # # Table of contents is integrated on the left; does not appear separately on the right. + - navigation.instant + - navigation.sections - navigation.tabs + - navigation.footer + - navigation.top + - navigation.tracking + - navigation.path - header.autohide # header disappears as you scroll - content.code.copy - content.code.annotate - content.tabs.link + - content.action.edit + - toc.follow + - search.suggest + - search.highlight + - search.share + palette: - media: "(prefers-color-scheme)" primary: white @@ -103,9 +113,7 @@ plugins: - autorefs # Cross-links to headings - gen-files: scripts: - - docs/scripts/gen_ref_pages.py - - literate-nav: - nav_file: SUMMARY.md + - docs/scripts/gen_popular_issues.py - section-index - mkdocstrings: handlers: @@ -125,36 +133,33 @@ plugins: heading_level: 4 - social - distilabel/components-gallery: - add_after_page: Learn + add_after_page: How-to guides nav: - Distilabel: "index.md" - Getting started: - - Installation: "sections/installation.md" - - How-to-Guide: "sections/how_to_guide.md" - - Learn: - - "sections/learn/index.md" - - Tutorial: - - "sections/learn/tutorial/index.md" - - Step: - - "sections/learn/tutorial/step/index.md" - - GeneratorStep: "sections/learn/tutorial/step/generator_step.md" - - GlobalStep: "sections/learn/tutorial/step/global_step.md" - - Task: - - "sections/learn/tutorial/task/index.md" - - GeneratorTask: "sections/learn/tutorial/task/generator_task.md" - - LLM: "sections/learn/tutorial/llm/index.md" - - Pipeline: "sections/learn/tutorial/pipeline/index.md" - - CLI: "sections/learn/tutorial/cli/index.md" + - Installation: "sections/getting_started/installation.md" + - Quickstart: "sections/getting_started/quickstart.md" + - FAQ: "sections/getting_started/faq.md" + - How-to guides: + - Basic: + - Define Steps for your Pipeline: + - "sections/how_to_guides/basic/step/index.md" + - GeneratorStep: "sections/how_to_guides/basic/step/generator_step.md" + - GlobalStep: "sections/how_to_guides/basic/step/global_step.md" + - Define Tasks as Steps that rely on LLMs: + - "sections/how_to_guides/basic/task/index.md" + - GeneratorTask: "sections/how_to_guides/basic/task/generator_task.md" + - Define LLMs as local models or remote APIs: "sections/how_to_guides/basic/llm/index.md" + - Execute Steps and Tasks in a Pipeline: "sections/how_to_guides/basic/pipeline/index.md" - Advanced: - - "sections/learn/advanced/index.md" - - Argilla: "sections/learn/advanced/argilla.md" - - Caching: "sections/learn/advanced/caching.md" - - Distiset: "sections/learn/advanced/distiset.md" - - Structured Generation: "sections/learn/advanced/structured_generation.md" - - Using the file system to pass batch data: "sections/learn/advanced/fs_to_pass_data.md" + - Using the Distiset dataset object: "sections/how_to_guides/advanced/distiset.md" + - Export data to Argilla: "sections/how_to_guides/advanced/argilla.md" + - Using a file system to pass data of batches between steps: "sections/how_to_guides/advanced/fs_to_pass_data.md" + - Using CLI to explore and re-run existing Pipelines: "sections/how_to_guides/advanced/cli/index.md" + - Cache and recover pipeline executions: "sections/how_to_guides/advanced/caching.md" + - Structured data generation: "sections/how_to_guides/advanced/structured_generation.md" - Pipeline Samples: - - "sections/pipeline_samples/index.md" - Examples: "sections/pipeline_samples/examples/index.md" - Papers: - "sections/pipeline_samples/papers/index.md" @@ -162,13 +167,7 @@ nav: - Instruction Backtranslation: "sections/pipeline_samples/papers/instruction_backtranslation.md" - Prometheus 2: "sections/pipeline_samples/papers/prometheus.md" - UltraFeedback: "sections/pipeline_samples/papers/ultrafeedback.md" - - FAQ: "sections/faq.md" - API Reference: - - Pipeline: - - "api/pipeline/index.md" - - Routing Batch Function: "api/pipeline/routing_batch_function.md" - - Typing: "api/pipeline/typing.md" - - Utils: "api/pipeline/utils.md" - Step: - "api/step/index.md" - GeneratorStep: "api/step/generator_step.md" @@ -176,18 +175,21 @@ nav: - "@step": "api/step/decorator.md" - Step Gallery: - Argilla: "api/step_gallery/argilla.md" + - Hugging Face: "api/step_gallery/hugging_face.md" - Columns: "api/step_gallery/columns.md" - Extra: "api/step_gallery/extra.md" - Task: - "api/task/index.md" - GeneratorTask: "api/task/generator_task.md" - Task Gallery: "api/task_gallery/index.md" + - Typing: "api/task/typing.md" - LLM: - "api/llm/index.md" - LLM Gallery: - Anthropic: "api/llm/anthropic.md" - Anyscale: "api/llm/anyscale.md" - Azure (via OpenAI): "api/llm/azure.md" + - Cohere: "api/llm/cohere.md" - Groq: "api/llm/groq.md" - Hugging Face: "api/llm/huggingface.md" - LiteLLM: "api/llm/litellm.md" @@ -198,4 +200,13 @@ nav: - Together AI: "api/llm/together.md" - Google Vertex AI: "api/llm/vertexai.md" - vLLM: "api/llm/vllm.md" + - Pipeline: + - "api/pipeline/index.md" + - Routing Batch Function: "api/pipeline/routing_batch_function.md" + - Typing: "api/pipeline/typing.md" + - Utils: "api/pipeline/utils.md" + - Distiset: "api/distiset.md" - CLI: "api/cli.md" + - Community: + - sections/community/index.md + - Issue dashboard: sections/community/popular_issues.md diff --git a/pyproject.toml b/pyproject.toml index df771ae20c..50c858462c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ distilabel = "distilabel.cli.app:app" [project.optional-dependencies] dev = ["ruff == 0.4.5", "pre-commit >= 3.5.0"] docs = [ - "mkdocs-material >= 9.5.0", + "mkdocs-material >=9.5.17", "mkdocstrings[python] >= 0.24.0", "mkdocs-literate-nav >= 0.6.1", "mkdocs-section-index >= 0.3.8", @@ -60,6 +60,7 @@ docs = [ "Pillow >= 9.5.0", "CairoSVG >= 2.7.1", "mknotebooks >= 0.8.0", + "pandas >= 2.0", ] tests = [ "pytest >= 7.4.0", diff --git a/src/distilabel/llms/chat_templates.py b/src/distilabel/llms/chat_templates.py index 47b96b33b0..7edba0132c 100644 --- a/src/distilabel/llms/chat_templates.py +++ b/src/distilabel/llms/chat_templates.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -CHATML_TEMPLATE = "{% for message in messages %}{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}" +CHATML_TEMPLATE = "{% for message in messages %}{{'<|im_start|>' + message['role'] + '\n' + message[\"content\"] + '<|im_end|>' + '\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}" diff --git a/src/distilabel/llms/vllm.py b/src/distilabel/llms/vllm.py index ded098b14b..ee124c7dfa 100644 --- a/src/distilabel/llms/vllm.py +++ b/src/distilabel/llms/vllm.py @@ -93,7 +93,7 @@ class vLLM(LLM, CudaDevicePlacementMixin): # You can pass a custom chat_template to the model llm = vLLM( model="prometheus-eval/prometheus-7b-v2.0", - chat_template="[INST] {{ messages[0]['content'] }}\\n{{ messages[1]['content'] }}[/INST]", + chat_template="[INST] {{ messages[0]\"content\" }}\\n{{ messages[1]\"content\" }}[/INST]", ) llm.load() diff --git a/src/distilabel/steps/tasks/prometheus_eval.py b/src/distilabel/steps/tasks/prometheus_eval.py index 4b03c3932f..294f9f4d0e 100644 --- a/src/distilabel/steps/tasks/prometheus_eval.py +++ b/src/distilabel/steps/tasks/prometheus_eval.py @@ -148,7 +148,7 @@ class PrometheusEval(Task): prometheus = PrometheusEval( llm=vLLM( model="prometheus-eval/prometheus-7b-v2.0", - chat_template="[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]", + chat_template="[INST] {{ messages[0]\"content\" }}\n{{ messages[1]\"content\" }}[/INST]", ), mode="absolute", rubric="factual-validity" @@ -185,7 +185,7 @@ class PrometheusEval(Task): prometheus = PrometheusEval( llm=vLLM( model="prometheus-eval/prometheus-7b-v2.0", - chat_template="[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]", + chat_template="[INST] {{ messages[0]\"content\" }}\n{{ messages[1]\"content\" }}[/INST]", ), mode="relative", rubric="honesty" @@ -222,7 +222,7 @@ class PrometheusEval(Task): prometheus = PrometheusEval( llm=vLLM( model="prometheus-eval/prometheus-7b-v2.0", - chat_template="[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]", + chat_template="[INST] {{ messages[0]\"content\" }}\n{{ messages[1]\"content\" }}[/INST]", ), mode="absolute", rubric="custom", @@ -262,7 +262,7 @@ class PrometheusEval(Task): prometheus = PrometheusEval( llm=vLLM( model="prometheus-eval/prometheus-7b-v2.0", - chat_template="[INST] {{ messages[0]['content'] }}\n{{ messages[1]['content'] }}[/INST]", + chat_template="[INST] {{ messages[0]\"content\" }}\n{{ messages[1]\"content\" }}[/INST]", ), mode="absolute", rubric="helpfulness", diff --git a/src/distilabel/utils/mkdocs/components_gallery.py b/src/distilabel/utils/mkdocs/components_gallery.py index a5a5ba40e8..ae43c586af 100644 --- a/src/distilabel/utils/mkdocs/components_gallery.py +++ b/src/distilabel/utils/mkdocs/components_gallery.py @@ -21,7 +21,6 @@ from mkdocs.config.config_options import Type from mkdocs.plugins import BasePlugin from mkdocs.structure.files import File -from mkdocs.structure.pages import Page from mkdocs_section_index import SectionPage from distilabel.utils.export_components_info import export_components_info @@ -360,11 +359,26 @@ def on_nav( steps_file = files.get_file_from_path(self.file_paths["steps"][0]) tasks_file = files.get_file_from_path(self.file_paths["tasks"][0]) llms_file = files.get_file_from_path(self.file_paths["llms"][0]) + steps_files = [ + files.get_file_from_path(path) for path in self.file_paths["steps"][0:] + ] + tasks_files = [ + files.get_file_from_path(path) for path in self.file_paths["tasks"][0:] + ] + llms_files = [ + files.get_file_from_path(path) for path in self.file_paths["llms"][0:] + ] # Create subsections - steps_page = Page("Steps", file=steps_file, config=config) # type: ignore - tasks_page = Page("Tasks", file=tasks_file, config=config) # type: ignore - llms_page = Page("LLMs", file=llms_file, config=config) # type: ignore + steps_page = SectionPage( + "Steps", file=steps_file, config=config, children=steps_files + ) # type: ignore + tasks_page = SectionPage( + "Tasks", file=tasks_file, config=config, children=tasks_files + ) # type: ignore + llms_page = SectionPage( + "LLMs", file=llms_file, config=config, children=llms_files + ) # type: ignore # Create the gallery section page = SectionPage( diff --git a/src/distilabel/utils/mkdocs/templates/components-gallery/components-list.jinja2 b/src/distilabel/utils/mkdocs/templates/components-gallery/components-list.jinja2 index 3c465761c5..319c69164b 100644 --- a/src/distilabel/utils/mkdocs/templates/components-gallery/components-list.jinja2 +++ b/src/distilabel/utils/mkdocs/templates/components-gallery/components-list.jinja2 @@ -1,8 +1,8 @@ --- -hide: +hide: - toc + - navigation --- - # {{ title }} {{ description }} diff --git a/src/distilabel/utils/mkdocs/templates/components-gallery/index.md b/src/distilabel/utils/mkdocs/templates/components-gallery/index.md index 5c7af73180..eb2914b6a6 100644 --- a/src/distilabel/utils/mkdocs/templates/components-gallery/index.md +++ b/src/distilabel/utils/mkdocs/templates/components-gallery/index.md @@ -1,8 +1,8 @@ --- -hide: +hide: + - navigation - toc --- - # Components Gallery
    diff --git a/src/distilabel/utils/mkdocs/templates/components-gallery/llm-detail.jinja2 b/src/distilabel/utils/mkdocs/templates/components-gallery/llm-detail.jinja2 index 212bbe8601..020008cd24 100644 --- a/src/distilabel/utils/mkdocs/templates/components-gallery/llm-detail.jinja2 +++ b/src/distilabel/utils/mkdocs/templates/components-gallery/llm-detail.jinja2 @@ -1,4 +1,8 @@ -# {{ llm.name }} +--- +hide: + - navigation +--- +{{ llm.name }} {% if llm.docstring.short_description %} {{ llm.docstring.short_description }} diff --git a/src/distilabel/utils/mkdocs/templates/components-gallery/step-detail.jinja2 b/src/distilabel/utils/mkdocs/templates/components-gallery/step-detail.jinja2 index 4be5fdc1ab..43a7d552b7 100644 --- a/src/distilabel/utils/mkdocs/templates/components-gallery/step-detail.jinja2 +++ b/src/distilabel/utils/mkdocs/templates/components-gallery/step-detail.jinja2 @@ -1,5 +1,8 @@ +--- +hide: + - navigation +--- # {{ step.name }} - {% if step.docstring.short_description %} {{ step.docstring.short_description }} {% endif %} From 9d63f4a624df303dd8e8db655dcfcb6f5cd18eaf Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Thu, 13 Jun 2024 13:21:59 +0200 Subject: [PATCH 34/40] Update typing API reference (#729) --- docs/api/task/typing.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/docs/api/task/typing.md b/docs/api/task/typing.md index ee88398e86..818ad070b6 100644 --- a/docs/api/task/typing.md +++ b/docs/api/task/typing.md @@ -1,11 +1,3 @@ # Task Typing -This section contains typing classes implemented in distilabel. - -::: distilabel.steps.tasks.typing.ChatType - options: - members: - - _ChatType - - ChatType -::: distilabel.steps.tasks.structured_outputs.outlines.StructuredOutputType -::: distilabel.steps.tasks.structured_outputs.instructor.InstructorStructuredOutputType \ No newline at end of file +::: distilabel.steps.tasks.typing \ No newline at end of file From 806fd571a8bef4b07726c85e6063adc10b695325 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Thu, 13 Jun 2024 17:26:05 +0200 Subject: [PATCH 35/40] docs: 730 docs add an index to the guide overview (#731) * Add index page to how-to guides * Apply suggestions from code review Co-authored-by: burtenshaw --------- Co-authored-by: burtenshaw --- docs/index.md | 89 +----------------- docs/sections/community/index.md | 18 +++- .../sections/how_to_guides/basic/llm/index.md | 2 +- .../how_to_guides/basic/task/index.md | 2 +- docs/sections/how_to_guides/index.md | 93 +++++++++++++++++++ mkdocs.yml | 5 +- 6 files changed, 116 insertions(+), 93 deletions(-) create mode 100644 docs/sections/how_to_guides/index.md diff --git a/docs/index.md b/docs/index.md index 2d7f6c0895..de4bc67be8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,8 +40,6 @@ Distilabel is the **framework for synthetic data and AI feedback for AI engineer If you just want to get started, we recommend you check the [documentation](http://distilabel.argilla.io/). Curious, and want to know more? Keep reading! - - ## Why use Distilabel? Whether you are working on **a predictive model** that computes semantic similarity or the next **generative model** that is going to beat the LLM benchmarks. Our framework ensures that the **hard data work pays off**. Distilabel is the missing piece that helps you **synthesize data** and provide **AI feedback**. @@ -64,89 +62,4 @@ Distilabel is a tool that can be used to **synthesize data and provide AI feedba - The [1M OpenHermesPreference](https://huggingface.co/datasets/argilla/OpenHermesPreferences) is a dataset of ~1 million AI preferences derived from teknium/OpenHermes-2.5. It shows how we can use Distilabel to **synthesize data on an immense scale**. - Our [distilabeled Intel Orca DPO dataset](https://huggingface.co/datasets/argilla/distilabel-intel-orca-dpo-pairs) and the [improved OpenHermes model](https://huggingface.co/argilla/distilabeled-OpenHermes-2.5-Mistral-7B),, show how we **improve model performance by filtering out 50%** of the original dataset through **AI feedback**. -- The [haiku DPO data](https://github.com/davanstrien/haiku-dpo) outlines how anyone can create a **dataset for a specific task** and **the latest research papers** to improve the quality of the dataset. - -## 👨🏽‍💻 Installation - -```sh -pip install distilabel --upgrade -``` - -Requires Python 3.8+ - -In addition, the following extras are available: - -- `anthropic`: for using models available in [Anthropic API](https://www.anthropic.com/api) via the `AnthropicLLM` integration. -- `cohere`: for using models available in [Cohere](https://cohere.ai/) via the `CohereLLM` integration. -- `argilla`: for exporting the generated datasets to [Argilla](https://argilla.io/). -- `groq`: for using models available in [Groq](https://groq.com/) using [`groq`](https://github.com/groq/groq-python) Python client via the `GroqLLM` integration. -- `hf-inference-endpoints`: for using the [Hugging Face Inference Endpoints](https://huggingface.co/inference-endpoints) via the `InferenceEndpointsLLM` integration. -- `hf-transformers`: for using models available in [transformers](https://github.com/huggingface/transformers) package via the `TransformersLLM` integration. -- `litellm`: for using [`LiteLLM`](https://github.com/BerriAI/litellm) to call any LLM using OpenAI format via the `LiteLLM` integration. -- `llama-cpp`: for using [llama-cpp-python](https://github.com/abetlen/llama-cpp-python) Python bindings for `llama.cpp` via the `LlamaCppLLM` integration. -- `mistralai`: for using models available in [Mistral AI API](https://mistral.ai/news/la-plateforme/) via the `MistralAILLM` integration. -- `ollama`: for using [Ollama](https://ollama.com/) and their available models via `OllamaLLM` integration. -- `openai`: for using [OpenAI API](https://openai.com/blog/openai-api) models via the `OpenAILLM` integration, or the rest of the integrations based on OpenAI and relying on its client as `AnyscaleLLM`, `AzureOpenAILLM`, and `TogetherLLM`. -- `vertexai`: for using [Google Vertex AI](https://cloud.google.com/vertex-ai) proprietary models via the `VertexAILLM` integration. -- `vllm`: for using [vllm](https://github.com/vllm-project/vllm) serving engine via the `vLLM` integration. - -### Example - -To run the following example you must install `distilabel` with both `openai` extra: - -```sh -pip install "distilabel[openai]" --upgrade -``` - -Then run: - -```python -from distilabel.llms import OpenAILLM -from distilabel.pipeline import Pipeline -from distilabel.steps import LoadDataFromHub -from distilabel.steps.tasks import TextGeneration - -with Pipeline( - name="simple-text-generation-pipeline", - description="A simple text generation pipeline", -) as pipeline: - load_dataset = LoadDataFromHub(output_mappings={"prompt": "instruction"}) - - generate_with_openai = TextGeneration(llm=OpenAILLM(model="gpt-3.5-turbo")) - - load_dataset.connect(generate_with_openai) - -if __name__ == "__main__": - distiset = pipeline.run( - parameters={ - load_dataset.name: { - "repo_id": "distilabel-internal-testing/instruction-dataset-mini", - "split": "test", - }, - generate_with_openai.name: { - "llm": { - "generation_kwargs": { - "temperature": 0.7, - "max_new_tokens": 512, - } - } - }, - }, - ) -``` - -## Badges - -If you build something cool with `distilabel` consider adding one of these badges to your dataset or model card. - - [Built with Distilabel](https://github.com/argilla-io/distilabel) - -[Built with Distilabel](https://github.com/argilla-io/distilabel) - - [Built with Distilabel](https://github.com/argilla-io/distilabel) - -[Built with Distilabel](https://github.com/argilla-io/distilabel) - -## Contribute - -To directly contribute with `distilabel`, check our [good first issues](https://github.com/argilla-io/distilabel/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) or [open a new one](https://github.com/argilla-io/distilabel/issues/new/choose). +- The [haiku DPO data](https://github.com/davanstrien/haiku-dpo) outlines how anyone can create a **dataset for a specific task** and **the latest research papers** to improve the quality of the dataset. \ No newline at end of file diff --git a/docs/sections/community/index.md b/docs/sections/community/index.md index e7bef08111..ed7f6cdd42 100644 --- a/docs/sections/community/index.md +++ b/docs/sections/community/index.md @@ -41,4 +41,20 @@ We are an open-source community-driven project not only focused on building a gr [:octicons-arrow-right-24: Roadmap ↗](https://github.com/orgs/argilla-io/projects/15) -
    \ No newline at end of file + + +## Badges + +If you build something cool with `distilabel` consider adding one of these badges to your dataset or model card. + + [Built with Distilabel](https://github.com/argilla-io/distilabel) + +[Built with Distilabel](https://github.com/argilla-io/distilabel) + + [Built with Distilabel](https://github.com/argilla-io/distilabel) + +[Built with Distilabel](https://github.com/argilla-io/distilabel) + +## Contribute + +To directly contribute with `distilabel`, check our [good first issues](https://github.com/argilla-io/distilabel/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) or [open a new one](https://github.com/argilla-io/distilabel/issues/new/choose). \ No newline at end of file diff --git a/docs/sections/how_to_guides/basic/llm/index.md b/docs/sections/how_to_guides/basic/llm/index.md index 944e3f4fce..4bd5f9de2b 100644 --- a/docs/sections/how_to_guides/basic/llm/index.md +++ b/docs/sections/how_to_guides/basic/llm/index.md @@ -1,4 +1,4 @@ -# Define LLMs as local models or remote APIs +# Define LLMs as local or remote models ## Working with LLMs diff --git a/docs/sections/how_to_guides/basic/task/index.md b/docs/sections/how_to_guides/basic/task/index.md index a184357af7..54c04483dc 100644 --- a/docs/sections/how_to_guides/basic/task/index.md +++ b/docs/sections/how_to_guides/basic/task/index.md @@ -1,4 +1,4 @@ -# Define Tasks as Steps that rely on LLMs +# Define Tasks that rely on LLMs ## Working with Tasks diff --git a/docs/sections/how_to_guides/index.md b/docs/sections/how_to_guides/index.md new file mode 100644 index 0000000000..3d6cb3e82d --- /dev/null +++ b/docs/sections/how_to_guides/index.md @@ -0,0 +1,93 @@ +# How-to guides + +Welcome to the how-to guides section! Here you will find a collection of guides that will help you get started with Distilabel. We have divided the guides into two categories: basic and advanced. The basic guides will help you get started with the core concepts of Distilabel, while the advanced guides will help you explore more advanced features. + +## Basic + +
    + +- __Define Steps for your Pipeline__ + + --- + + Steps are the building blocks of your pipeline. They can be used to generate data, evaluate models, manipulate data, or any other general task. + + [:octicons-arrow-right-24: Define Steps](basic/step/index.md) + +- __Define Tasks that rely on LLMs__ + + --- + + Tasks are a specific type of step that rely on Language Models (LLMs) to generate data. + + [:octicons-arrow-right-24: Define Tasks](basic/task/index.md) + +- __Define LLMs as local or remote models__ + + --- + + LLMs are the core of your tasks. They are used to integrate with local models or remote APIs. + + [:octicons-arrow-right-24: Define LLMs](basic/llm/index.md) + +- __Execute Steps and Tasks in a Pipeline__ + + --- + + Pipeline is where you put all your steps and tasks together to create a workflow. + + [:octicons-arrow-right-24: Execute Pipeline](basic/pipeline/index.md) + +
    + +## Advanced + +
    +- __Using the Distiset dataset object__ + + --- + + Distiset is a dataset object based on the datasets library that can be used to store and manipulate data. + + [:octicons-arrow-right-24: Distiset](advanced/distiset.md) + +- __Export data to Argilla__ + + --- + + Argilla is a platform that can be used to store, search, and apply feedback to datasets. + [:octicons-arrow-right-24: Argilla](advanced/argilla.md) + +- __Using a file system to pass data of batches between steps__ + + --- + + File system can be used to pass data between steps in a pipeline. + + [:octicons-arrow-right-24: File System](advanced/fs_to_pass_data.md) + +- __Using CLI to explore and re-run existing Pipelines__ + + --- + + CLI can be used to explore and re-run existing pipelines through the command line. + + [:octicons-arrow-right-24: CLI](advanced/cli/index.md) + +- __Cache and recover pipeline executions__ + + --- + + Caching can be used to recover pipeline executions to avoid loosing data and precious LLM calls. + + [:octicons-arrow-right-24: Caching](advanced/caching.md) + +- __Structured data generation__ + + --- + + Structured data generation can be used to generate data with a specific structure like JSON, function calls, etc. + + [:octicons-arrow-right-24: Structured Generation](advanced/structured_generation.md) + +
    \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 4edbb2fed1..0bdfd56629 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -142,15 +142,16 @@ nav: - Quickstart: "sections/getting_started/quickstart.md" - FAQ: "sections/getting_started/faq.md" - How-to guides: + - "sections/how_to_guides/index.md" - Basic: - Define Steps for your Pipeline: - "sections/how_to_guides/basic/step/index.md" - GeneratorStep: "sections/how_to_guides/basic/step/generator_step.md" - GlobalStep: "sections/how_to_guides/basic/step/global_step.md" - - Define Tasks as Steps that rely on LLMs: + - Define Tasks that rely on LLMs: - "sections/how_to_guides/basic/task/index.md" - GeneratorTask: "sections/how_to_guides/basic/task/generator_task.md" - - Define LLMs as local models or remote APIs: "sections/how_to_guides/basic/llm/index.md" + - Define LLMs as local or remote models: "sections/how_to_guides/basic/llm/index.md" - Execute Steps and Tasks in a Pipeline: "sections/how_to_guides/basic/pipeline/index.md" - Advanced: - Using the Distiset dataset object: "sections/how_to_guides/advanced/distiset.md" From 9d6a15258f1418fb3981911a61a8505fad162e03 Mon Sep 17 00:00:00 2001 From: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com> Date: Fri, 14 Jun 2024 09:08:13 +0200 Subject: [PATCH 36/40] Update `README.md` Remove emojis and connect steps via rshift instead --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c2e28df170..786b6ad523 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Compute is expensive and output quality is important. We help you **focus on dat Synthesize and judge data with **latest research papers** while ensuring **flexibility, scalability and fault tolerance**. So you can focus on improving your data and training your models. -## 🏘️ Community +## Community We are an open-source community-driven project and we love to hear from you. Here are some ways to get involved: @@ -68,7 +68,7 @@ Distilabel is a tool that can be used to **synthesize data and provide AI feedba - Our [distilabeled Intel Orca DPO dataset](https://huggingface.co/datasets/argilla/distilabel-intel-orca-dpo-pairs) and the [improved OpenHermes model](https://huggingface.co/argilla/distilabeled-OpenHermes-2.5-Mistral-7B),, show how we **improve model performance by filtering out 50%** of the original dataset through **AI feedback**. - The [haiku DPO data](https://github.com/davanstrien/haiku-dpo) outlines how anyone can create a **dataset for a specific task** and **the latest research papers** to improve the quality of the dataset. -## 👨🏽‍💻 Installation +## Installation ```sh pip install distilabel --upgrade @@ -116,7 +116,7 @@ with Pipeline( generate_with_openai = TextGeneration(llm=OpenAILLM(model="gpt-3.5-turbo")) - load_dataset.connect(generate_with_openai) + load_dataset >> generate_with_openai if __name__ == "__main__": distiset = pipeline.run( From d736dd725089c89445585152c134ed1acf863df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Tue, 18 Jun 2024 12:00:50 +0200 Subject: [PATCH 37/40] Add `MixtureOfAgentsLLM` (#735) * Update `RuntimeParametersMixin` to handle `list`s * Check if `generation_kwargs` is present * Add `get_generation_kwargs` method * Add `_num_generation_param_supported` attribute to avoid code duplication * Refactor `OllamaLLM` and `VertexAILLM` * Add `MixtureOfAgents` llm * Add docstrings * Fix unit tests * Update docstrings * Fix missing # * Update Arena Hard tasks docstrings * Fix cross-reference * Update unit tests * Rename to `MixtureOfAgentsLLM` * Update `_extra_serializable_fields` to work with `List[_Serializable]` attributes * Remove `from_dict` method for `Step` * Update to render list * Update handling `List[RuntimeParametersMixin]` attributes * Fix unit tests * Remove test code * Add `MixtureOfAgentsLLM` docstring example * Add alias for runtime parameters names --- .../advanced/structured_generation.md | 2 +- src/distilabel/cli/pipeline/utils.py | 31 +- src/distilabel/llms/__init__.py | 2 + src/distilabel/llms/anthropic.py | 41 +-- src/distilabel/llms/anyscale.py | 4 - src/distilabel/llms/azure.py | 4 - src/distilabel/llms/base.py | 117 +++++--- src/distilabel/llms/cohere.py | 62 ++-- src/distilabel/llms/groq.py | 44 +-- .../llms/huggingface/inference_endpoints.py | 52 +--- src/distilabel/llms/litellm.py | 6 +- src/distilabel/llms/mistral.py | 56 +--- src/distilabel/llms/moa.py | 275 ++++++++++++++++++ src/distilabel/llms/ollama.py | 26 +- src/distilabel/llms/openai.py | 16 +- src/distilabel/llms/together.py | 4 - src/distilabel/llms/vertexai.py | 80 +++-- src/distilabel/mixins/runtime_parameters.py | 43 ++- src/distilabel/pipeline/base.py | 4 +- src/distilabel/pipeline/typing.py | 9 +- src/distilabel/steps/base.py | 48 +-- src/distilabel/steps/tasks/base.py | 11 +- .../steps/tasks/benchmarks/arena_hard.py | 12 +- .../tasks/structured_outputs/instructor.py | 12 - src/distilabel/steps/tasks/text_generation.py | 2 +- src/distilabel/utils/itertools.py | 8 +- .../components-gallery/llm-detail.jinja2 | 2 +- src/distilabel/utils/serialization.py | 42 ++- .../{steps/tasks/utils.py => conftest.py} | 26 +- tests/unit/llms/test_cohere.py | 2 +- tests/unit/llms/test_moa.py | 61 ++++ tests/unit/mixins/test_runtime_parameters.py | 73 ++++- .../steps/tasks/benchmarks/test_arena_hard.py | 2 +- tests/unit/steps/tasks/conftest.py | 55 ---- tests/unit/steps/tasks/test_base.py | 17 +- .../steps/tasks/test_complexity_scorer.py | 2 +- tests/unit/steps/tasks/test_genstruct.py | 2 +- .../unit/steps/tasks/test_prometheus_eval.py | 2 +- tests/unit/steps/tasks/test_quality_scorer.py | 2 +- tests/unit/steps/tasks/test_self_instruct.py | 2 +- .../steps/tasks/test_sentence_transformers.py | 2 +- .../unit/steps/tasks/test_text_generation.py | 2 +- tests/unit/steps/test_base.py | 4 +- tests/unit/utils/test_serialization.py | 37 +++ 44 files changed, 799 insertions(+), 507 deletions(-) create mode 100644 src/distilabel/llms/moa.py rename tests/unit/{steps/tasks/utils.py => conftest.py} (56%) create mode 100644 tests/unit/llms/test_moa.py delete mode 100644 tests/unit/steps/tasks/conftest.py create mode 100644 tests/unit/utils/test_serialization.py diff --git a/docs/sections/how_to_guides/advanced/structured_generation.md b/docs/sections/how_to_guides/advanced/structured_generation.md index 17e64430a7..ee7565272c 100644 --- a/docs/sections/how_to_guides/advanced/structured_generation.md +++ b/docs/sections/how_to_guides/advanced/structured_generation.md @@ -139,7 +139,7 @@ When working with model providers behind an API, there's no direct way of access ``` !!! Note - Take a look at [`InstructorStructuredOutputType`][distilabel.steps.tasks.structured_outputs.instructor.InstructorStructuredOutputType] to see the expected format + Take a look at [`InstructorStructuredOutputType`][distilabel.steps.tasks.typing.InstructorStructuredOutputType] to see the expected format of the `structured_output` dict variable. The following is the same example you can see with `outlines`'s `JSON` section for comparison purposes. diff --git a/src/distilabel/cli/pipeline/utils.py b/src/distilabel/cli/pipeline/utils.py index 59d006b523..869e0e6db1 100644 --- a/src/distilabel/cli/pipeline/utils.py +++ b/src/distilabel/cli/pipeline/utils.py @@ -120,7 +120,8 @@ def get_pipeline(config: str) -> "BasePipeline": FileNotFoundError: If the configuration file does not exist. """ if valid_http_url(config): - return Pipeline.from_dict(get_config_from_url(config)) + data = get_config_from_url(config) + return Pipeline.from_dict(data) if Path(config).is_file(): return Pipeline.from_file(config) @@ -200,9 +201,15 @@ def _build_steps_panel(pipeline: "BasePipeline") -> "Panel": from rich.table import Table def _add_rows( - table: Table, runtime_params: List[Dict[str, Any]], prefix: str = "" + table: Table, + runtime_params: List[Dict[str, Any]], + prefix: str = "", ) -> None: for param in runtime_params: + if isinstance(param, str): + _add_rows(table, runtime_params[param], f"{prefix}{param}.") + continue + # nested (for example `LLM` in `Task`) if "runtime_parameters_info" in param: _add_rows( @@ -210,22 +217,22 @@ def _add_rows( runtime_params=param["runtime_parameters_info"], prefix=f"{prefix}{param['name']}.", ) - continue - # `LLM` special case - if "keys" in param: + elif "keys" in param: _add_rows( table=table, runtime_params=param["keys"], prefix=f"{prefix}{param['name']}.", ) - continue - - optional = param.get("optional", "") - if optional != "": - optional = "Yes" if optional else "No" + return + else: + optional = param.get("optional", "") + if optional != "": + optional = "Yes" if optional else "No" - table.add_row(prefix + param["name"], param.get("description"), optional) + table.add_row( + prefix + param["name"], param.get("description"), optional + ) steps = [] for step_name, runtime_params in pipeline.get_runtime_parameters_info().items(): @@ -239,7 +246,7 @@ def _add_rows( expand=True, ) - table.add_column("Runtime parameter", style="dim", width=50) + table.add_column("Runtime parameter", style="dim", width=60) table.add_column("Description", width=100) table.add_column("Optional", justify="right") _add_rows(table, runtime_params) diff --git a/src/distilabel/llms/__init__.py b/src/distilabel/llms/__init__.py index 73009795a2..3e50ddefaa 100644 --- a/src/distilabel/llms/__init__.py +++ b/src/distilabel/llms/__init__.py @@ -23,6 +23,7 @@ from distilabel.llms.llamacpp import LlamaCppLLM from distilabel.llms.mistral import MistralLLM from distilabel.llms.mixins import CudaDevicePlacementMixin +from distilabel.llms.moa import MixtureOfAgentsLLM from distilabel.llms.ollama import OllamaLLM from distilabel.llms.openai import OpenAILLM from distilabel.llms.together import TogetherLLM @@ -43,6 +44,7 @@ "LlamaCppLLM", "MistralLLM", "CudaDevicePlacementMixin", + "MixtureOfAgentsLLM", "OllamaLLM", "OpenAILLM", "TogetherLLM", diff --git a/src/distilabel/llms/anthropic.py b/src/distilabel/llms/anthropic.py index e3aadd0668..843b14b21f 100644 --- a/src/distilabel/llms/anthropic.py +++ b/src/distilabel/llms/anthropic.py @@ -12,11 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio import os from typing import ( TYPE_CHECKING, - Any, List, Literal, Optional, @@ -28,13 +26,14 @@ from httpx import AsyncClient from pydantic import Field, PrivateAttr, SecretStr, validate_call -from typing_extensions import override from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType -from distilabel.utils.itertools import grouper +from distilabel.steps.tasks.typing import ( + FormattedInput, + InstructorStructuredOutputType, +) if TYPE_CHECKING: from anthropic import AsyncAnthropic @@ -112,11 +111,7 @@ class User(BaseModel): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) ``` """ @@ -148,6 +143,8 @@ class User(BaseModel): ) ) + _num_generations_param_supported = False + _api_key_env_var: str = PrivateAttr(default=_ANTHROPIC_API_KEY_ENV_VAR_NAME) _aclient: Optional["AsyncAnthropic"] = PrivateAttr(...) @@ -282,29 +279,3 @@ async def agenerate( # type: ignore ) generations.append(content) return generations - - # TODO: remove this function once Anthropic client allows `n` parameter - @override - def generate( - self, - inputs: List["FormattedInput"], - num_generations: int = 1, - **kwargs: Any, - ) -> List["GenerateOutput"]: - """Method to generate a list of responses asynchronously, returning the output - synchronously awaiting for the response of each input sent to `agenerate`. - """ - - async def agenerate( - inputs: List["FormattedInput"], **kwargs: Any - ) -> "GenerateOutput": - """Internal function to parallelize the asynchronous generation of responses.""" - tasks = [ - asyncio.create_task(self.agenerate(input=input, **kwargs)) - for input in inputs - for _ in range(num_generations) - ] - return [outputs[0] for outputs in await asyncio.gather(*tasks)] - - outputs = self.event_loop.run_until_complete(agenerate(inputs, **kwargs)) - return list(grouper(outputs, n=num_generations, incomplete="ignore")) diff --git a/src/distilabel/llms/anyscale.py b/src/distilabel/llms/anyscale.py index e1e3fc583d..54b777b8a8 100644 --- a/src/distilabel/llms/anyscale.py +++ b/src/distilabel/llms/anyscale.py @@ -50,11 +50,7 @@ class AnyscaleLLM(OpenAILLM): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) ``` """ diff --git a/src/distilabel/llms/azure.py b/src/distilabel/llms/azure.py index 23166744d1..ebcb5ef9ea 100644 --- a/src/distilabel/llms/azure.py +++ b/src/distilabel/llms/azure.py @@ -103,11 +103,7 @@ class User(BaseModel): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) ``` """ diff --git a/src/distilabel/llms/base.py b/src/distilabel/llms/base.py index 50f5d46fc0..2a64e77847 100644 --- a/src/distilabel/llms/base.py +++ b/src/distilabel/llms/base.py @@ -28,17 +28,22 @@ RuntimeParametersMixin, ) from distilabel.utils.docstring import parse_google_docstring +from distilabel.utils.itertools import grouper from distilabel.utils.notebook import in_notebook from distilabel.utils.serialization import _Serializable if TYPE_CHECKING: from distilabel.llms.typing import GenerateOutput, HiddenState - from distilabel.mixins.runtime_parameters import RuntimeParametersNames - from distilabel.steps.tasks.structured_outputs.instructor import ( - InstructorStructuredOutputType, + from distilabel.mixins.runtime_parameters import ( + RuntimeParameterInfo, + RuntimeParametersNames, ) from distilabel.steps.tasks.structured_outputs.outlines import StructuredOutputType - from distilabel.steps.tasks.typing import FormattedInput, StandardInput + from distilabel.steps.tasks.typing import ( + FormattedInput, + InstructorStructuredOutputType, + StandardInput, + ) from distilabel.utils.docstring import Docstring if in_notebook(): @@ -93,6 +98,15 @@ def model_name(self) -> str: """Returns the model name used for the LLM.""" pass + def get_generation_kwargs(self) -> Dict[str, Any]: + """Returns the generation kwargs to be used for the generation. This method can + be overridden to provide a more complex logic for the generation kwargs. + + Returns: + The kwargs to be used for the generation. + """ + return self.generation_kwargs # type: ignore + @abstractmethod def generate( self, @@ -151,7 +165,7 @@ def runtime_parameters_names(self) -> "RuntimeParametersNames": return runtime_parameters - def get_runtime_parameters_info(self) -> List[Dict[str, Any]]: + def get_runtime_parameters_info(self) -> List["RuntimeParameterInfo"]: """Gets the information of the runtime parameters of the `LLM` such as the name and the description. This function is meant to include the information of the runtime parameters in the serialized data of the `LLM`. @@ -162,21 +176,27 @@ def get_runtime_parameters_info(self) -> List[Dict[str, Any]]: runtime_parameters_info = super().get_runtime_parameters_info() generation_kwargs_info = next( - runtime_parameter_info - for runtime_parameter_info in runtime_parameters_info - if runtime_parameter_info["name"] == "generation_kwargs" + ( + runtime_parameter_info + for runtime_parameter_info in runtime_parameters_info + if runtime_parameter_info["name"] == "generation_kwargs" + ), + None, ) - generate_docstring_args = self.generate_parsed_docstring["args"] + # If `generation_kwargs` attribute is present, we need to include the `generate` + # method arguments as the information for this attribute. + if generation_kwargs_info: + generate_docstring_args = self.generate_parsed_docstring["args"] - generation_kwargs_info["keys"] = [] - for key, value in generation_kwargs_info["optional"].items(): - info = {"name": key, "optional": value} - if description := generate_docstring_args.get(key): - info["description"] = description - generation_kwargs_info["keys"].append(info) + generation_kwargs_info["keys"] = [] + for key, value in generation_kwargs_info["optional"].items(): + info = {"name": key, "optional": value} + if description := generate_docstring_args.get(key): + info["description"] = description + generation_kwargs_info["keys"].append(info) - generation_kwargs_info.pop("optional") + generation_kwargs_info.pop("optional") return runtime_parameters_info @@ -234,6 +254,7 @@ class AsyncLLM(LLM): _event_loop: the event loop to be used for the asynchronous generation of responses. """ + _num_generations_param_supported = True _event_loop: "asyncio.AbstractEventLoop" = PrivateAttr(default=None) _new_event_loop: bool = PrivateAttr(default=False) @@ -278,20 +299,20 @@ async def agenerate( """ pass - def generate( - self, - inputs: List["FormattedInput"], - num_generations: int = 1, - **kwargs: Any, + async def _agenerate( + self, inputs: List["FormattedInput"], num_generations: int = 1, **kwargs: Any ) -> List["GenerateOutput"]: - """Method to generate a list of responses asynchronously, returning the output - synchronously awaiting for the response of each input sent to `agenerate`. - """ + """Internal function to concurrently generate responses for a list of inputs. - async def agenerate( - inputs: List["FormattedInput"], **kwargs: Any - ) -> List[List[Union[str, None]]]: - """Internal function to parallelize the asynchronous generation of responses.""" + Args: + inputs: the list of inputs to generate responses for. + num_generations: the number of generations to generate per input. + **kwargs: the additional kwargs to be used for the generation. + + Returns: + A list containing the generations for each input. + """ + if self._num_generations_param_supported: tasks = [ asyncio.create_task( self.agenerate( @@ -302,7 +323,34 @@ async def agenerate( ] return await asyncio.gather(*tasks) - return self.event_loop.run_until_complete(agenerate(inputs, **kwargs)) + tasks = [ + asyncio.create_task(self.agenerate(input=input, **kwargs)) + for input in inputs + for _ in range(num_generations) + ] + outputs = [outputs[0] for outputs in await asyncio.gather(*tasks)] + return list(grouper(outputs, n=num_generations, incomplete="ignore")) + + def generate( + self, + inputs: List["FormattedInput"], + num_generations: int = 1, + **kwargs: Any, + ) -> List["GenerateOutput"]: + """Method to generate a list of responses asynchronously, returning the output + synchronously awaiting for the response of each input sent to `agenerate`. + + Args: + inputs: the list of inputs to generate responses for. + num_generations: the number of generations to generate per input. + **kwargs: the additional kwargs to be used for the generation. + + Returns: + A list containing the generations for each input. + """ + return self.event_loop.run_until_complete( + self._agenerate(inputs=inputs, num_generations=num_generations, **kwargs) + ) def __del__(self) -> None: """Closes the event loop when the object is deleted.""" @@ -315,7 +363,7 @@ def __del__(self) -> None: self._event_loop.close() @staticmethod - def _prepare_structured_output( + def _prepare_structured_output( # type: ignore structured_output: "InstructorStructuredOutputType", client: Any = None, framework: Optional[str] = None, @@ -340,7 +388,7 @@ def _prepare_structured_output( client = prepare_instructor( client, mode=structured_output.get("mode"), - framework=framework, + framework=framework, # type: ignore ) result["client"] = client @@ -351,7 +399,7 @@ def _prepare_structured_output( ) if inspect.isclass(schema) and issubclass(schema, BaseModel): # We want a json schema for the serialization, but instructor wants a pydantic BaseModel. - structured_output["schema"] = schema.model_json_schema() + structured_output["schema"] = schema.model_json_schema() # type: ignore result["structured_output"] = structured_output return result @@ -373,9 +421,8 @@ def _prepare_kwargs( """ # We can deal with json schema or BaseModel, but we need to convert it to a BaseModel # for the Instructor client. - schema = structured_output.get("schema") - # We can assume if it's a class it must be a pydantic model. - if not inspect.isclass(schema): + schema = structured_output.get("schema", {}) + if not issubclass(schema, BaseModel): from distilabel.steps.tasks.structured_outputs.utils import ( json_schema_to_model, ) diff --git a/src/distilabel/llms/cohere.py b/src/distilabel/llms/cohere.py index a3fc1619a6..a1295a9ba8 100644 --- a/src/distilabel/llms/cohere.py +++ b/src/distilabel/llms/cohere.py @@ -12,11 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio import os from typing import ( TYPE_CHECKING, - Any, List, Optional, Sequence, @@ -26,18 +24,18 @@ ) from pydantic import Field, PrivateAttr, SecretStr, validate_call -from typing_extensions import override from distilabel.llms.base import AsyncLLM +from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType -from distilabel.utils.itertools import grouper +from distilabel.steps.tasks.typing import ( + FormattedInput, + InstructorStructuredOutputType, +) if TYPE_CHECKING: from cohere import AsyncClient, ChatMessage - from distilabel.llms.typing import GenerateOutput - _COHERE_API_KEY_ENV_VAR_NAME = "COHERE_API_KEY" @@ -104,11 +102,7 @@ class User(BaseModel): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) ``` """ @@ -138,6 +132,8 @@ class User(BaseModel): ) ) + _num_generations_param_supported = False + _ChatMessage: Type["ChatMessage"] = PrivateAttr(...) _aclient: "AsyncClient" = PrivateAttr(...) @@ -173,7 +169,7 @@ def load(self) -> None: client=self._aclient, framework="cohere", ) - self._aclient = result.get("client") + self._aclient = result.get("client") # type: ignore if structured_output := result.get("structured_output"): self.structured_output = structured_output @@ -204,7 +200,7 @@ def _format_chat_to_cohere( "An assistant message but be preceded by a user message." ) chat_history.append(self._ChatMessage(role="USER", message=message)) # type: ignore - chat_history.append(self._ChatMessage(role="CHATBOT", message=content)) + chat_history.append(self._ChatMessage(role="CHATBOT", message=content)) # type: ignore message = None if message is None: @@ -225,7 +221,7 @@ async def agenerate( # type: ignore frequency_penalty: Optional[float] = None, presence_penalty: Optional[float] = None, raw_prompting: Optional[bool] = None, - ) -> Union[str, None]: + ) -> GenerateOutput: """Generates a response from the LLM given an input. Args: @@ -254,11 +250,11 @@ async def agenerate( # type: ignore if isinstance(input, tuple): input, structured_output = input result = self._prepare_structured_output( - structured_output=structured_output, + structured_output=structured_output, # type: ignore client=self._aclient, framework="cohere", ) - self._aclient = result.get("client") + self._aclient = result.get("client") # type: ignore if structured_output is None and self.structured_output is not None: structured_output = self.structured_output @@ -281,42 +277,18 @@ async def agenerate( # type: ignore "raw_prompting": raw_prompting, } if structured_output: - kwargs = self._prepare_kwargs(kwargs, structured_output) + kwargs = self._prepare_kwargs(kwargs, structured_output) # type: ignore response = await self._aclient.chat(**kwargs) # type: ignore if structured_output: - return response.model_dump_json() + return [response.model_dump_json()] if (text := response.text) == "": - self._logger.warning( + self._logger.warning( # type: ignore f"Received no response using Cohere client (model: '{self.model}')." f" Finish reason was: {response.finish_reason}" ) - return None - - return text + return [None] - @override - def generate( - self, - inputs: List["FormattedInput"], - num_generations: int = 1, - **kwargs: Any, - ) -> List["GenerateOutput"]: - """Method to generate a list of responses asynchronously, returning the output - synchronously awaiting for the response of each input sent to `agenerate`.""" - - async def agenerate( - inputs: List["FormattedInput"], **kwargs: Any - ) -> "GenerateOutput": - """Internal function to parallelize the asynchronous generation of responses.""" - tasks = [ - asyncio.create_task(self.agenerate(input=input, **kwargs)) - for input in inputs - for _ in range(num_generations) - ] - return await asyncio.gather(*tasks) - - outputs = self.event_loop.run_until_complete(agenerate(inputs, **kwargs)) - return list(grouper(outputs, n=num_generations, incomplete="ignore")) + return [text] diff --git a/src/distilabel/llms/groq.py b/src/distilabel/llms/groq.py index fc09207e58..3a362951ec 100644 --- a/src/distilabel/llms/groq.py +++ b/src/distilabel/llms/groq.py @@ -12,18 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio import os -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Optional from pydantic import Field, PrivateAttr, SecretStr, validate_call -from typing_extensions import override from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.steps.base import RuntimeParameter -from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType -from distilabel.utils.itertools import grouper +from distilabel.steps.tasks.typing import ( + FormattedInput, + InstructorStructuredOutputType, +) if TYPE_CHECKING: from groq import AsyncGroq @@ -95,11 +95,7 @@ class User(BaseModel): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) ``` """ @@ -131,6 +127,8 @@ class User(BaseModel): ) ) + _num_generations_param_supported = False + _api_key_env_var: str = PrivateAttr(_GROQ_API_KEY_ENV_VAR_NAME) _aclient: Optional["AsyncGroq"] = PrivateAttr(...) @@ -165,7 +163,7 @@ def load(self) -> None: client=self._aclient, framework="groq", ) - self._aclient = result.get("client") + self._aclient = result.get("client") # type: ignore if structured_output := result.get("structured_output"): self.structured_output = structured_output @@ -242,29 +240,3 @@ async def agenerate( # type: ignore ) generations.append(content) return generations - - # TODO: remove this function once Groq client allows `n` parameter - @override - def generate( - self, - inputs: List["FormattedInput"], - num_generations: int = 1, - **kwargs: Any, - ) -> List["GenerateOutput"]: - """Method to generate a list of responses asynchronously, returning the output - synchronously awaiting for the response of each input sent to `agenerate`. - """ - - async def agenerate( - inputs: List["FormattedInput"], **kwargs: Any - ) -> "GenerateOutput": - """Internal function to parallelize the asynchronous generation of responses.""" - tasks = [ - asyncio.create_task(self.agenerate(input=input, **kwargs)) - for input in inputs - for _ in range(num_generations) - ] - return [outputs[0] for outputs in await asyncio.gather(*tasks)] - - outputs = self.event_loop.run_until_complete(agenerate(inputs, **kwargs)) - return list(grouper(outputs, n=num_generations, incomplete="ignore")) diff --git a/src/distilabel/llms/huggingface/inference_endpoints.py b/src/distilabel/llms/huggingface/inference_endpoints.py index db3ef8da85..6d4d3d1a5e 100644 --- a/src/distilabel/llms/huggingface/inference_endpoints.py +++ b/src/distilabel/llms/huggingface/inference_endpoints.py @@ -12,11 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio import os import random import warnings -from typing import TYPE_CHECKING, Any, List, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union from pydantic import ( Field, @@ -40,7 +39,6 @@ _INFERENCE_ENDPOINTS_API_KEY_ENV_VAR_NAME, get_hf_token, ) -from distilabel.utils.itertools import grouper if TYPE_CHECKING: from huggingface_hub import AsyncInferenceClient @@ -83,11 +81,7 @@ class InferenceEndpointsLLM(AsyncLLM): llm.load() - # Synchrounous request output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) ``` Dedicated Inference Endpoints: @@ -103,11 +97,7 @@ class InferenceEndpointsLLM(AsyncLLM): llm.load() - # Synchrounous request output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) ``` Dedicated Inference Endpoints or TGI: @@ -122,11 +112,7 @@ class InferenceEndpointsLLM(AsyncLLM): llm.load() - # Synchrounous request output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) ``` """ @@ -158,6 +144,8 @@ class InferenceEndpointsLLM(AsyncLLM): description="The structured output format to use across all the generations.", ) + _num_generations_param_supported = False + _model_name: Optional[str] = PrivateAttr(default=None) _tokenizer: Optional["PreTrainedTokenizer"] = PrivateAttr(default=None) _api_key_env_var: str = PrivateAttr(_INFERENCE_ENDPOINTS_API_KEY_ENV_VAR_NAME) @@ -325,11 +313,10 @@ async def _openai_agenerate( ) return [completion.choices[0].message.content] - # TODO: add `num_generations` parameter once either TGI or `AsyncInferenceClient` allows `n` parameter @validate_call async def agenerate( # type: ignore self, - input: "FormattedInput", + input: FormattedInput, max_new_tokens: int = 128, frequency_penalty: float = 0.0, presence_penalty: float = 0.0, @@ -343,7 +330,7 @@ async def agenerate( # type: ignore return_full_text: bool = False, seed: Optional[int] = None, watermark: bool = False, - ) -> "GenerateOutput": + ) -> GenerateOutput: """Generates completions for the given input using the OpenAI async client. Args: @@ -431,6 +418,7 @@ async def agenerate( # type: ignore # TODO: should we apply a default chat template here instead? e.g. ChatML prompt = "\n".join([message["content"] for message in input]) + completion = None try: completion = await self._aclient.text_generation( # type: ignore prompt=prompt, # type: ignore @@ -449,36 +437,10 @@ async def agenerate( # type: ignore # generated every time seed=seed or random.randint(0, 2147483647), ) - return [completion] except Exception as e: self._logger.warning( # type: ignore f"⚠️ Received no response using Inference Client (model: '{self.model_name}')." f" Finish reason was: {e}" ) - return [None] - - # TODO: remove this function once `AsyncInferenceClient` allows `n` parameter - @override - def generate( - self, - inputs: List["FormattedInput"], - num_generations: int = 1, - **kwargs: Any, - ) -> List["GenerateOutput"]: - """Method to generate a list of responses asynchronously, returning the output - synchronously awaiting for the response of each input sent to `agenerate`. - """ - async def agenerate( - inputs: List["FormattedInput"], **kwargs: Any - ) -> "GenerateOutput": - """Internal function to parallelize the asynchronous generation of responses.""" - tasks = [ - asyncio.create_task(self.agenerate(input=input, **kwargs)) - for input in inputs - for _ in range(num_generations) - ] - return [outputs[0] for outputs in await asyncio.gather(*tasks)] - - outputs = self.event_loop.run_until_complete(agenerate(inputs, **kwargs)) - return list(grouper(outputs, n=num_generations, incomplete="ignore")) + return [completion] diff --git a/src/distilabel/llms/litellm.py b/src/distilabel/llms/litellm.py index 068508abdb..71a73365bb 100644 --- a/src/distilabel/llms/litellm.py +++ b/src/distilabel/llms/litellm.py @@ -73,11 +73,7 @@ class User(BaseModel): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) ``` """ @@ -268,7 +264,7 @@ async def _call_aclient_until_n_choices() -> List["Choices"]: for choice in choices: if (content := choice.message.content) is None: - self._logger.warning( + self._logger.warning( # type: ignore f"Received no response using LiteLLM client (model: '{self.model}')." f" Finish reason was: {choice.finish_reason}" ) diff --git a/src/distilabel/llms/mistral.py b/src/distilabel/llms/mistral.py index 72eced4aa6..ed1c3af7d5 100644 --- a/src/distilabel/llms/mistral.py +++ b/src/distilabel/llms/mistral.py @@ -12,18 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio import os -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Optional from pydantic import Field, PrivateAttr, SecretStr, validate_call -from typing_extensions import override from distilabel.llms.base import AsyncLLM from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter -from distilabel.steps.tasks.typing import FormattedInput, InstructorStructuredOutputType -from distilabel.utils.itertools import grouper +from distilabel.steps.tasks.typing import ( + FormattedInput, + InstructorStructuredOutputType, +) if TYPE_CHECKING: from mistralai.async_client import MistralAsyncClient @@ -94,11 +94,7 @@ class User(BaseModel): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) ``` """ @@ -127,6 +123,8 @@ class User(BaseModel): ) ) + _num_generations_param_supported = False + _api_key_env_var: str = PrivateAttr(_MISTRALAI_API_KEY_ENV_VAR_NAME) _aclient: Optional["MistralAsyncClient"] = PrivateAttr(...) @@ -151,9 +149,9 @@ def load(self) -> None: self._aclient = MistralAsyncClient( api_key=self.api_key.get_secret_value(), endpoint=self.endpoint, - max_retries=self.max_retries, - timeout=self.timeout, - max_concurrent_requests=self.max_concurrent_requests, + max_retries=self.max_retries, # type: ignore + timeout=self.timeout, # type: ignore + max_concurrent_requests=self.max_concurrent_requests, # type: ignore ) if self.structured_output: @@ -162,7 +160,7 @@ def load(self) -> None: client=self._aclient, framework="mistral", ) - self._aclient = result.get("client") + self._aclient = result.get("client") # type: ignore if structured_output := result.get("structured_output"): self.structured_output = structured_output @@ -218,9 +216,9 @@ async def agenerate( # type: ignore kwargs = self._prepare_kwargs(kwargs, structured_output) # TODO: This should work just with the _aclient.chat method, but it's not working. # We need to check instructor and see if we can create a PR. - completion = await self._aclient.chat.completions.create(**kwargs) + completion = await self._aclient.chat.completions.create(**kwargs) # type: ignore else: - completion = await self._aclient.chat(**kwargs) + completion = await self._aclient.chat(**kwargs) # type: ignore if structured_output: generations.append(completion.model_dump_json()) @@ -228,35 +226,9 @@ async def agenerate( # type: ignore for choice in completion.choices: if (content := choice.message.content) is None: - self._logger.warning( + self._logger.warning( # type: ignore f"Received no response using MistralAI client (model: '{self.model}')." f" Finish reason was: {choice.finish_reason}" ) generations.append(content) return generations - - # TODO: remove this function once Mistral client allows `n` parameter - @override - def generate( - self, - inputs: List["FormattedInput"], - num_generations: int = 1, - **kwargs: Any, - ) -> List["GenerateOutput"]: - """Method to generate a list of responses asynchronously, returning the output - synchronously awaiting for the response of each input sent to `agenerate`. - """ - - async def agenerate( - inputs: List["FormattedInput"], **kwargs: Any - ) -> "GenerateOutput": - """Internal function to parallelize the asynchronous generation of responses.""" - tasks = [ - asyncio.create_task(self.agenerate(input=input, **kwargs)) - for input in inputs - for _ in range(num_generations) - ] - return [outputs[0] for outputs in await asyncio.gather(*tasks)] - - outputs = self.event_loop.run_until_complete(agenerate(inputs, **kwargs)) - return list(grouper(outputs, n=num_generations, incomplete="ignore")) diff --git a/src/distilabel/llms/moa.py b/src/distilabel/llms/moa.py new file mode 100644 index 0000000000..d139da87e3 --- /dev/null +++ b/src/distilabel/llms/moa.py @@ -0,0 +1,275 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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 asyncio +import itertools +from typing import TYPE_CHECKING, Any, Dict, List, Union, cast + +from pydantic import Field + +from distilabel.llms.base import LLM, AsyncLLM +from distilabel.steps.tasks.typing import StandardInput + +if TYPE_CHECKING: + from distilabel.llms.typing import GenerateOutput + from distilabel.mixins.runtime_parameters import RuntimeParametersNames + from distilabel.steps.tasks.typing import FormattedInput + +# Mixture-of-Agents system prompt from the paper with the addition instructing the LLM +# to not mention that it used responses from previous models to avoid having texts like +# "Based on the previous responses..." in the completion. +MOA_SYSTEM_PROMPT = ( + "You have been provided with a set of responses from various open-source models to the" + " latest user query. Your task is to synthesize these responses into a single, high-quality" + " response. It is crucial to critically evaluate the information provided in these responses," + " recognizing that some of it may be biased or incorrect. Your response should not simply" + " replicate the given answers but should offer a refined, accurate, and comprehensive" + " reply to the instruction. Ensure your response is well-structured, coherent, and adheres" + " to the highest standards of accuracy and reliability. Do not mention that you have used" + " the responses from previous models." + "\nResponses from models:" +) + + +class MixtureOfAgentsLLM(AsyncLLM): + """`Mixture-of-Agents` implementation. + + An `LLM` class that leverages `LLM`s collective strenghts to generate a response, + as described in the "Mixture-of-Agents Enhances Large Language model Capabilities" + paper. There is a list of `LLM`s proposing/generating outputs that `LLM`s from the next + round/layer can use as auxiliary information. Finally, there is an `LLM` that aggregates + the outputs to generate the final response. + + Attributes: + aggregator_llm: The `LLM` that aggregates the outputs of the proposer `LLM`s. + proposers_llms: The list of `LLM`s that propose outputs to be aggregated. + rounds: The number of layers or rounds that the `proposers_llms` will generate + outputs. Defaults to `1`. + + References: + - [Mixture-of-Agents Enhances Large Language Model Capabilities](https://arxiv.org/abs/2406.04692) + + Examples: + + Generate text: + + ```python + from distilabel.llms import MixtureOfAgentsLLM, InferenceEndpointsLLM + + llm = MixtureOfAgentsLLM( + aggregator_llm=InferenceEndpointsLLM( + model_id="meta-llama/Meta-Llama-3-70B-Instruct", + tokenizer_id="meta-llama/Meta-Llama-3-70B-Instruct", + ), + proposers_llms=[ + InferenceEndpointsLLM( + model_id="meta-llama/Meta-Llama-3-70B-Instruct", + tokenizer_id="meta-llama/Meta-Llama-3-70B-Instruct", + ), + InferenceEndpointsLLM( + model_id="NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO", + tokenizer_id="NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO", + ), + InferenceEndpointsLLM( + model_id="HuggingFaceH4/zephyr-orpo-141b-A35b-v0.1", + tokenizer_id="HuggingFaceH4/zephyr-orpo-141b-A35b-v0.1", + ), + ], + rounds=2, + ) + + llm.load() + + output = llm.generate( + inputs=[ + [ + { + "role": "user", + "content": "My favorite witty review of The Rings of Power series is this: Input:", + } + ] + ] + ) + ``` + """ + + aggregator_llm: LLM + proposers_llms: List[AsyncLLM] = Field(default_factory=list) + rounds: int = 1 + + @property + def runtime_parameters_names(self) -> "RuntimeParametersNames": + """Returns the runtime parameters of the `LLM`, which are a combination of the + `RuntimeParameter`s of the `LLM`, the `aggregator_llm` and the `proposers_llms`. + + Returns: + The runtime parameters of the `LLM`. + """ + runtime_parameters_names = super().runtime_parameters_names + del runtime_parameters_names["generation_kwargs"] + return runtime_parameters_names + + def load(self) -> None: + """Loads all the `LLM`s in the `MixtureOfAgents`.""" + super().load() + + for llm in self.proposers_llms: + self._logger.debug(f"Loading proposer LLM in MoA: {llm}") # type: ignore + llm.load() + + self._logger.debug(f"Loading aggregator LLM in MoA: {self.aggregator_llm}") # type: ignore + self.aggregator_llm.load() + + @property + def model_name(self) -> str: + """Returns the aggregated model name.""" + return f"moa-{self.aggregator_llm.model_name}-{'-'.join([llm.model_name for llm in self.proposers_llms])}" + + def get_generation_kwargs(self) -> Dict[str, Any]: + """Returns the generation kwargs of the `MixtureOfAgents` as a dictionary. + + Returns: + The generation kwargs of the `MixtureOfAgents`. + """ + return { + "aggregator_llm": self.aggregator_llm.get_generation_kwargs(), + "proposers_llms": [ + llm.get_generation_kwargs() for llm in self.proposers_llms + ], + } + + # `abstractmethod`, had to be implemented but not used + async def agenerate( + self, input: "FormattedInput", num_generations: int = 1, **kwargs: Any + ) -> List[Union[str, None]]: + raise NotImplementedError( + "`agenerate` method is not implemented for `MixtureOfAgents`" + ) + + def _build_moa_system_prompt(self, prev_outputs: List[str]) -> str: + """Builds the Mixture-of-Agents system prompt. + + Args: + prev_outputs: The list of previous outputs to use as references. + + Returns: + The Mixture-of-Agents system prompt. + """ + moa_system_prompt = MOA_SYSTEM_PROMPT + for i, prev_output in enumerate(prev_outputs): + if prev_output is not None: + moa_system_prompt += f"\n{i + 1}. {prev_output}" + return moa_system_prompt + + def _inject_moa_system_prompt( + self, input: "StandardInput", prev_outputs: List[str] + ) -> "StandardInput": + """Injects the Mixture-of-Agents system prompt into the input. + + Args: + input: The input to inject the system prompt into. + prev_outputs: The list of previous outputs to use as references. + + Returns: + The input with the Mixture-of-Agents system prompt injected. + """ + if len(prev_outputs) == 0: + return input + + moa_system_prompt = self._build_moa_system_prompt(prev_outputs) + + system = next((item for item in input if item["role"] == "system"), None) + if system: + original_system_prompt = system["content"] + system["content"] = f"{moa_system_prompt}\n\n{original_system_prompt}" + else: + input.insert(0, {"role": "system", "content": moa_system_prompt}) + + return input + + async def _agenerate( + self, + inputs: List["FormattedInput"], + num_generations: int = 1, + **kwargs: Any, + ) -> List["GenerateOutput"]: + """Internal function to concurrently generate responses for a list of inputs. + + Args: + inputs: the list of inputs to generate responses for. + num_generations: the number of generations to generate per input. + **kwargs: the additional kwargs to be used for the generation. + + Returns: + A list containing the generations for each input. + """ + aggregator_llm_kwargs: Dict[str, Any] = kwargs.get("aggregator_llm", {}) + proposers_llms_kwargs: List[Dict[str, Any]] = kwargs.get( + "proposers_llms", [{}] * len(self.proposers_llms) + ) + + prev_outputs = [] + for round in range(self.rounds): + self._logger.debug(f"Generating round {round + 1}/{self.rounds} in MoA") # type: ignore + + # Generate `num_generations` with each proposer LLM for each input + tasks = [ + asyncio.create_task( + llm._agenerate( + inputs=[ + self._inject_moa_system_prompt( + cast("StandardInput", input), prev_input_outputs + ) + for input, prev_input_outputs in itertools.zip_longest( + inputs, prev_outputs, fillvalue=[] + ) + ], + num_generations=1, + **generation_kwargs, + ) + ) + for llm, generation_kwargs in zip( + self.proposers_llms, proposers_llms_kwargs + ) + ] + + # Group generations per input + outputs: List[List["GenerateOutput"]] = await asyncio.gather(*tasks) + prev_outputs = [ + list(itertools.chain(*input_outputs)) for input_outputs in zip(*outputs) + ] + + self._logger.debug("Aggregating outputs in MoA") # type: ignore + if isinstance(self.aggregator_llm, AsyncLLM): + return await self.aggregator_llm._agenerate( + inputs=[ + self._inject_moa_system_prompt( + cast("StandardInput", input), prev_input_outputs + ) + for input, prev_input_outputs in zip(inputs, prev_outputs) + ], + num_generations=num_generations, + **aggregator_llm_kwargs, + ) + + return self.aggregator_llm.generate( + inputs=[ + self._inject_moa_system_prompt( + cast("StandardInput", input), prev_input_outputs + ) + for input, prev_input_outputs in zip(inputs, prev_outputs) + ], + num_generations=num_generations, + **aggregator_llm_kwargs, + ) diff --git a/src/distilabel/llms/ollama.py b/src/distilabel/llms/ollama.py index 8da29c2d95..bd664b30db 100644 --- a/src/distilabel/llms/ollama.py +++ b/src/distilabel/llms/ollama.py @@ -12,12 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, List, Literal, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Sequence, Union from pydantic import Field, PrivateAttr, validate_call from typing_extensions import TypedDict from distilabel.llms.base import AsyncLLM +from distilabel.llms.typing import GenerateOutput from distilabel.mixins.runtime_parameters import RuntimeParameter from distilabel.steps.tasks.typing import InstructorStructuredOutputType, StandardInput @@ -95,6 +96,8 @@ class OllamaLLM(AsyncLLM): ) ) + _num_generations_param_supported = False + _aclient: Optional["AsyncClient"] = PrivateAttr(...) def load(self) -> None: @@ -124,18 +127,16 @@ def model_name(self) -> str: async def agenerate( # type: ignore self, input: StandardInput, - num_generations: int = 1, format: Literal["", "json"] = "", # TODO: include relevant options from `Options` in `agenerate` method. options: Union[Options, None] = None, keep_alive: Union[bool, None] = None, - ) -> List[str]: + ) -> GenerateOutput: """ Generates a response asynchronously, using the [Ollama Async API definition](https://github.com/ollama/ollama-python). Args: input: the input to use for the generation. - num_generations: the number of generations to produce. Defaults to `1`. format: the format to use for the generation. Defaults to `""`. options: the options to use for the generation. Defaults to `None`. keep_alive: whether to keep the connection alive. Defaults to `None`. @@ -143,10 +144,9 @@ async def agenerate( # type: ignore Returns: A list of strings as completion for the given input. """ - generations = [] - # TODO: remove this for-loop and override the `generate` method - for _ in range(num_generations): - completion = await self._aclient.chat( # type: ignore + text = None + try: + completion: Dict[str, Any] = await self._aclient.chat( # type: ignore model=self.model, messages=input, # type: ignore stream=False, @@ -154,7 +154,11 @@ async def agenerate( # type: ignore options=options, keep_alive=keep_alive, ) - # TODO: improve error handling - generations.append(completion["message"]["content"]) + text = completion["message"]["content"] + except Exception as e: + self._logger.warning( # type: ignore + f"⚠️ Received no response using Ollama client (model: '{self.model_name}')." + f" Finish reason was: {e}" + ) - return generations + return [text] diff --git a/src/distilabel/llms/openai.py b/src/distilabel/llms/openai.py index 3285f3bbfa..42555b3649 100644 --- a/src/distilabel/llms/openai.py +++ b/src/distilabel/llms/openai.py @@ -72,11 +72,7 @@ class OpenAILLM(AsyncLLM): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) ``` Generate text from a custom endpoint following the OpenAI API: @@ -91,11 +87,7 @@ class OpenAILLM(AsyncLLM): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) ``` Generate structured data: @@ -117,11 +109,7 @@ class User(BaseModel): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Create a user profile for the following marathon"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Create a user profile for the following marathon"}]) ``` """ @@ -186,7 +174,7 @@ def load(self) -> None: client=self._aclient, framework="openai", ) - self._aclient = result.get("client") + self._aclient = result.get("client") # type: ignore if structured_output := result.get("structured_output"): self.structured_output = structured_output @@ -272,7 +260,7 @@ async def agenerate( # type: ignore kwargs = self._prepare_kwargs(kwargs, structured_output) generations = [] - completion = await self._aclient.chat.completions.create(**kwargs) + completion = await self._aclient.chat.completions.create(**kwargs) # type: ignore if structured_output: generations.append(completion.model_dump_json()) diff --git a/src/distilabel/llms/together.py b/src/distilabel/llms/together.py index 638243a03e..aa63ae1ad5 100644 --- a/src/distilabel/llms/together.py +++ b/src/distilabel/llms/together.py @@ -49,11 +49,7 @@ class TogetherLLM(OpenAILLM): llm.load() - # Synchronous request output = llm.generate(inputs=[[{"role": "user", "content": "Hello world!"}]]) - - # Asynchronous request - output = await llm.agenerate(input=[{"role": "user", "content": "Hello world!"}]) ``` """ diff --git a/src/distilabel/llms/vertexai.py b/src/distilabel/llms/vertexai.py index 34cc9484f8..f89a7b0912 100644 --- a/src/distilabel/llms/vertexai.py +++ b/src/distilabel/llms/vertexai.py @@ -21,19 +21,7 @@ from distilabel.steps.tasks.typing import StandardInput if TYPE_CHECKING: - from vertexai.generative_models import Content, GenerativeModel - - -def _is_gemini_model(model: str) -> bool: - """Returns `True` if the model is a model from the Vertex AI Gemini API. - - Args: - model (str): the model name to be checked. - - Returns: - bool: `True` if the model is a model from the Vertex AI Gemini API. - """ - return "gemini" in model + from vertexai.generative_models import Content, GenerationResponse, GenerativeModel class VertexAILLM(AsyncLLM): @@ -59,6 +47,8 @@ class VertexAILLM(AsyncLLM): model: str + _num_generations_param_supported = False + _aclient: Optional["GenerativeModel"] = PrivateAttr(...) def load(self) -> None: @@ -115,7 +105,6 @@ def _chattype_to_content(self, input: "StandardInput") -> List["Content"]: async def agenerate( # type: ignore self, input: StandardInput, - num_generations: int = 1, temperature: Optional[float] = None, top_p: Optional[float] = None, top_k: Optional[int] = None, @@ -128,8 +117,6 @@ async def agenerate( # type: ignore Args: input: a single input in chat format to generate responses for. - num_generations: the number of generations to create per input. Defaults to - `1`. temperature: Controls the randomness of predictions. Range: [0.0, 1.0]. Defaults to `None`. top_p: If specified, nucleus sampling will be used. Range: (0.0, 1.0]. Defaults to `None`. top_k: If specified, top-k sampling will be used. Defaults to `None`. @@ -143,33 +130,40 @@ async def agenerate( # type: ignore """ from vertexai.generative_models import GenerationConfig - contents = self._chattype_to_content(input) - generations = [] - # TODO: remove this for-loop and override `generate` - for _ in range(num_generations): - content = await self._aclient.generate_content_async( # type: ignore - contents=contents, - generation_config=GenerationConfig( - candidate_count=1, # only one candidate allowed per call - temperature=temperature, - top_k=top_k, - top_p=top_p, - max_output_tokens=max_output_tokens, - stop_sequences=stop_sequences, - ), - safety_settings=safety_settings, - tools=tools, - stream=False, + content: "GenerationResponse" = await self._aclient.generate_content_async( # type: ignore + contents=self._chattype_to_content(input), + generation_config=GenerationConfig( + candidate_count=1, # only one candidate allowed per call + temperature=temperature, + top_k=top_k, + top_p=top_p, + max_output_tokens=max_output_tokens, + stop_sequences=stop_sequences, + ), + safety_settings=safety_settings, # type: ignore + tools=tools, # type: ignore + stream=False, + ) + + text = None + try: + text = content.candidates[0].text + except ValueError: + self._logger.warning( # type: ignore + f"Received no response using VertexAI client (model: '{self.model}')." + f" Finish reason was: '{content.candidates[0].finish_reason}'." ) - text = None - try: - text = content.candidates[0].text - except ValueError: - self._logger.warning( - f"Received no response using VertexAI client (model: '{self.model}')." - f" Finish reason was: '{content.candidates[0].finish_reason}'." - ) - generations.append(text) + return [text] - return generations + +def _is_gemini_model(model: str) -> bool: + """Returns `True` if the model is a model from the Vertex AI Gemini API. + + Args: + model (str): the model name to be checked. + + Returns: + bool: `True` if the model is a model from the Vertex AI Gemini API. + """ + return "gemini" in model diff --git a/src/distilabel/mixins/runtime_parameters.py b/src/distilabel/mixins/runtime_parameters.py index 9a6fec512b..a7dd848f17 100644 --- a/src/distilabel/mixins/runtime_parameters.py +++ b/src/distilabel/mixins/runtime_parameters.py @@ -31,6 +31,10 @@ """Used to mark the attributes of a `Step` as a runtime parameter.""" RuntimeParametersNames = Dict[str, Union[bool, "RuntimeParametersNames"]] +"""Alias for the names of the runtime parameters of a `Step`.""" + +RuntimeParameterInfo = Dict[str, Any] +"""Alias for the information of the runtime parameters of a `Step`.""" class RuntimeParametersMixin(BaseModel): @@ -45,7 +49,7 @@ class RuntimeParametersMixin(BaseModel): _runtime_parameters: Dict[str, Any] = PrivateAttr(default_factory=dict) @property - def runtime_parameters_names(self) -> RuntimeParametersNames: + def runtime_parameters_names(self) -> "RuntimeParametersNames": """Returns a dictionary containing the name of the runtime parameters of the class as keys and whether the parameter is required or not as values. @@ -57,18 +61,27 @@ def runtime_parameters_names(self) -> RuntimeParametersNames: runtime_parameters = {} for name, field_info in self.model_fields.items(): # type: ignore + # `field: RuntimeParameter[Any]` or `field: Optional[RuntimeParameter[Any]]` is_runtime_param, is_optional = _is_runtime_parameter(field_info) if is_runtime_param: runtime_parameters[name] = is_optional continue attr = getattr(self, name) + + # `field: RuntimeParametersMixin` if isinstance(attr, RuntimeParametersMixin): runtime_parameters[name] = attr.runtime_parameters_names + # `field: List[RuntiemParametersMixin]` + if isinstance(attr, list) and isinstance(attr[0], RuntimeParametersMixin): + runtime_parameters[name] = { + str(i): item.runtime_parameters_names for i, item in enumerate(attr) + } + return runtime_parameters - def get_runtime_parameters_info(self) -> List[Dict[str, Any]]: + def get_runtime_parameters_info(self) -> List["RuntimeParameterInfo"]: """Gets the information of the runtime parameters of the class such as the name and the description. This function is meant to include the information of the runtime parameters in the serialized data of the class. @@ -82,6 +95,8 @@ def get_runtime_parameters_info(self) -> List[Dict[str, Any]]: continue attr = getattr(self, name) + + # Get runtime parameters info for `RuntimeParametersMixin` field if isinstance(attr, RuntimeParametersMixin): runtime_parameters_info.append( { @@ -91,6 +106,19 @@ def get_runtime_parameters_info(self) -> List[Dict[str, Any]]: ) continue + # Get runtime parameters info for `List[RuntimeParametersMixin]` field + if isinstance(attr, list) and isinstance(attr[0], RuntimeParametersMixin): + runtime_parameters_info.append( + { + "name": name, + "runtime_parameters_info": { + str(i): item.get_runtime_parameters_info() + for i, item in enumerate(attr) + }, + } + ) + continue + info = {"name": name, "optional": self.runtime_parameters_names[name]} if field_info.description is not None: info["description"] = field_info.description @@ -125,17 +153,28 @@ def set_runtime_parameters(self, runtime_parameters: Dict[str, Any]) -> None: continue attr = getattr(self, name) + + # Set runtime parameters for `RuntimeParametersMixin` field if isinstance(attr, RuntimeParametersMixin): attr.set_runtime_parameters(value) self._runtime_parameters[name] = value continue + # Set runtime parameters for `List[RuntimeParametersMixin]` field + if isinstance(attr, list) and isinstance(attr[0], RuntimeParametersMixin): + for i, item in enumerate(attr): + item_value = value.get(str(i), {}) + item.set_runtime_parameters(item_value) + self._runtime_parameters[name] = value + continue + # Handle settings values for `_SecretField` field_info = self.model_fields[name] inner_type = _extract_runtime_parameter_inner_type(field_info.annotation) if inspect.isclass(inner_type) and issubclass(inner_type, _SecretField): value = inner_type(value) + # Set the value of the runtime parameter setattr(self, name, value) self._runtime_parameters[name] = value diff --git a/src/distilabel/pipeline/base.py b/src/distilabel/pipeline/base.py index cb3b0625a7..455ee7ab60 100644 --- a/src/distilabel/pipeline/base.py +++ b/src/distilabel/pipeline/base.py @@ -62,7 +62,7 @@ from distilabel.distiset import Distiset from distilabel.pipeline.routing_batch_function import RoutingBatchFunction - from distilabel.pipeline.typing import StepLoadStatus + from distilabel.pipeline.typing import PipelineRuntimeParametersInfo, StepLoadStatus from distilabel.steps.base import Step, _Step @@ -398,7 +398,7 @@ def dry_run( self._dry_run = False return distiset - def get_runtime_parameters_info(self) -> Dict[str, List[Dict[str, Any]]]: + def get_runtime_parameters_info(self) -> "PipelineRuntimeParametersInfo": """Get the runtime parameters for the steps in the pipeline. Returns: diff --git a/src/distilabel/pipeline/typing.py b/src/distilabel/pipeline/typing.py index ebb4c68155..e73d20e8ab 100644 --- a/src/distilabel/pipeline/typing.py +++ b/src/distilabel/pipeline/typing.py @@ -12,9 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Literal, TypedDict, TypeVar, Union +from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, TypeVar, Union if TYPE_CHECKING: + from distilabel.mixins.runtime_parameters import RuntimeParameterInfo from distilabel.steps.base import GeneratorStep, GlobalStep, Step DownstreamConnectable = Union["Step", "GlobalStep"] @@ -40,3 +41,9 @@ class StepLoadStatus(TypedDict): name: str status: Literal["loaded", "unloaded", "load_failed"] + + +PipelineRuntimeParametersInfo = Dict[ + str, Union[List["RuntimeParameterInfo"], Dict[str, "RuntimeParameterInfo"]] +] +"""Alias for the information of the runtime parameters of a `Pipeline`.""" diff --git a/src/distilabel/steps/base.py b/src/distilabel/steps/base.py index d35cdb8b5d..5db81a5666 100644 --- a/src/distilabel/steps/base.py +++ b/src/distilabel/steps/base.py @@ -16,7 +16,6 @@ import logging import re from abc import ABC, abstractmethod -from enum import Enum from functools import cached_property from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, overload @@ -27,7 +26,7 @@ RuntimeParameter, RuntimeParametersMixin, ) -from distilabel.utils.serialization import TYPE_INFO_KEY, _Serializable +from distilabel.utils.serialization import _Serializable from distilabel.utils.typing_ import is_parameter_annotated_with if TYPE_CHECKING: @@ -453,51 +452,6 @@ def get_outputs(self) -> List[str]: """ return [self.output_mappings.get(output, output) for output in self.outputs] - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "_Step": - """Create a Step from a dict containing the serialized data. - - Needs the information from the step and the Pipeline it belongs to. - - Note: - It's intended for internal use. - - Args: - data: dictionary containing the serialized data from a `Step` and the - `Pipeline` it belongs to. - - Returns: - A `Step` instance. - """ - # Remove the "type_info" to avoid errors on instantiation - _data = data.copy() - if TYPE_INFO_KEY in _data.keys(): - _data.pop(TYPE_INFO_KEY) - - # Before passing the data to instantiate the general step, we have to instantiate - # some of the internal objects. For the moment we only take into account the LLM, - # we should take care if we update any of the objects. - if llm := _data.get("llm"): - from distilabel.utils.serialization import _get_module_attr - - nested_cls = _get_module_attr(**llm.pop(TYPE_INFO_KEY)) - # Load the LLM and update the _data inplace - nested_cls = nested_cls(**llm) - _data.update({"llm": nested_cls}) - - # Enums need a specific restoring process - for k, v in _data.items(): - if isinstance(v, dict) and "_type" in v and v["_type"] == "enum": - _data[k] = Enum(v["_name"], v["_values"], type=eval(v["_enum_type"])) - - # Skip `runtime_parameters_info` since extras are not allowed - _data.pop("runtime_parameters_info", None) - - # Every step needs the pipeline, and the remaining arguments are general - step = cls(**_data) - - return step - def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: dump = super()._model_dump(obj, **kwargs) dump["runtime_parameters_info"] = self.get_runtime_parameters_info() diff --git a/src/distilabel/steps/tasks/base.py b/src/distilabel/steps/tasks/base.py index a73ccbfbdb..06a6fecd06 100644 --- a/src/distilabel/steps/tasks/base.py +++ b/src/distilabel/steps/tasks/base.py @@ -112,7 +112,9 @@ def _format_outputs( try: formatted_output = self.format_output(output, input) formatted_output = self._maybe_add_raw_output( - formatted_output, output, add_raw_output=self.add_raw_output + formatted_output, + output, + add_raw_output=self.add_raw_output, # type: ignore ) formatted_outputs.append(formatted_output) except Exception as e: @@ -132,7 +134,9 @@ def _output_on_failure( outputs = {output: None for output in self.outputs} outputs["model_name"] = self.llm.model_name # type: ignore outputs = self._maybe_add_raw_output( - outputs, output, add_raw_output=self.add_raw_output + outputs, + output, + add_raw_output=self.add_raw_output, # type: ignore ) return outputs @@ -190,10 +194,11 @@ def process(self, inputs: StepInput) -> "StepOutput": # type: ignore """ formatted_inputs = self._format_inputs(inputs) + outputs = self.llm.generate( inputs=formatted_inputs, num_generations=self.num_generations, # type: ignore - **self.llm.generation_kwargs, # type: ignore + **self.llm.get_generation_kwargs(), # type: ignore ) task_outputs = [] diff --git a/src/distilabel/steps/tasks/benchmarks/arena_hard.py b/src/distilabel/steps/tasks/benchmarks/arena_hard.py index 26fa695e0b..78cd7fa175 100644 --- a/src/distilabel/steps/tasks/benchmarks/arena_hard.py +++ b/src/distilabel/steps/tasks/benchmarks/arena_hard.py @@ -24,7 +24,9 @@ class ArenaHard(Task): - """This `Task` is based on the "From Live Data to High-Quality Benchmarks: The + """Evaluates two assistant responses using an LLM as judge. + + This `Task` is based on the "From Live Data to High-Quality Benchmarks: The Arena-Hard Pipeline" paper that presents Arena Hard, which is a benchmark for instruction-tuned LLMs that contains 500 challenging user queries. GPT-4 is used as the judge to compare the model responses against a baseline model, which defaults @@ -145,7 +147,9 @@ def format_output( class ArenaHardResults(GlobalStep): - """This `Step` is based on the "From Live Data to High-Quality Benchmarks: The + """Process Arena Hard results to calculate the ELO scores. + + This `Step` is based on the "From Live Data to High-Quality Benchmarks: The Arena-Hard Pipeline" paper that presents Arena Hard, which is a benchmark for instruction-tuned LLMs that contains 500 challenging user queries. This step is a `GlobalStep` that should run right after the `ArenaHard` task to calculate the @@ -155,6 +159,10 @@ class ArenaHardResults(GlobalStep): Arena-Hard-Auto has the highest correlation and separability to Chatbot Arena among popular open-ended LLM benchmarks. + Input columns: + - evaluation (`str`): The evaluation of the responses generated by the LLMs. + - score (`str`): The score extracted from the evaluation. + References: - [From Live Data to High-Quality Benchmarks: The Arena-Hard Pipeline](https://lmsys.org/blog/2024-04-19-arena-hard/) - [`arena-hard-auto`](https://github.com/lm-sys/arena-hard-auto/tree/main) diff --git a/src/distilabel/steps/tasks/structured_outputs/instructor.py b/src/distilabel/steps/tasks/structured_outputs/instructor.py index 7249457d17..94ab1097e5 100644 --- a/src/distilabel/steps/tasks/structured_outputs/instructor.py +++ b/src/distilabel/steps/tasks/structured_outputs/instructor.py @@ -49,18 +49,6 @@ """Available clients that can be wrapped with `instructor`. """ -# class InstructorStructuredOutputType(TypedDict): -# """TypedDict to represent the structured output configuration from `instructor`.""" - -# schema: Type[BaseModel] -# """The schema to use for the structured output, a `pydantic.BaseModel` class. """ -# mode: Optional["instructor.Mode"] -# """Generation mode. Take a look at `instructor.Mode` for more information, if not informed it will -# be determined automatically. """ -# max_retries: int -# """Number of times to reask the model in case of error, if not set will default to the model's default. """ - - def _client_patcher(framework: InstructorFrameworks) -> Tuple[Callable, str]: """Helper function to return the appropriate instructor client for the given framework. diff --git a/src/distilabel/steps/tasks/text_generation.py b/src/distilabel/steps/tasks/text_generation.py index 3f5b17a8fb..f5c4659651 100644 --- a/src/distilabel/steps/tasks/text_generation.py +++ b/src/distilabel/steps/tasks/text_generation.py @@ -117,7 +117,7 @@ def outputs(self) -> List[str]: return ["generation", "model_name"] def format_output( - self, output: Union[str, None], input: Dict[str, Any] + self, output: Union[str, None], input: Union[Dict[str, Any], None] = None ) -> Dict[str, Any]: """The output is formatted as a dictionary with the `generation`. The `model_name` will be automatically included within the `process` method of `Task`.""" diff --git a/src/distilabel/utils/itertools.py b/src/distilabel/utils/itertools.py index 9428389188..88ce86cc4e 100644 --- a/src/distilabel/utils/itertools.py +++ b/src/distilabel/utils/itertools.py @@ -13,18 +13,20 @@ # limitations under the License. from itertools import zip_longest -from typing import Any, Iterable, Literal +from typing import Any, Iterable, List, Literal, TypeVar + +T = TypeVar("T") # Copy pasted from https://docs.python.org/3/library/itertools.html#itertools-recipes # Just added the type hints and use `if`s instead of `match` def grouper( - iterable: Iterable[Any], + iterable: Iterable[T], n: int, *, incomplete: Literal["fill", "strict", "ignore"] = "fill", fillvalue: Any = None, -) -> Iterable[Any]: +) -> Iterable[List[T]]: "Collect data into non-overlapping fixed-length chunks or blocks." # grouper('ABCDEFG', 3, fillvalue='x') --> ABC DEF Gxx # grouper('ABCDEFG', 3, incomplete='strict') --> ABC DEF ValueError diff --git a/src/distilabel/utils/mkdocs/templates/components-gallery/llm-detail.jinja2 b/src/distilabel/utils/mkdocs/templates/components-gallery/llm-detail.jinja2 index 020008cd24..5d2b72dd90 100644 --- a/src/distilabel/utils/mkdocs/templates/components-gallery/llm-detail.jinja2 +++ b/src/distilabel/utils/mkdocs/templates/components-gallery/llm-detail.jinja2 @@ -2,7 +2,7 @@ hide: - navigation --- -{{ llm.name }} +# {{ llm.name }} {% if llm.docstring.short_description %} {{ llm.docstring.short_description }} diff --git a/src/distilabel/utils/serialization.py b/src/distilabel/utils/serialization.py index 714f52fbb0..8f32afc2eb 100644 --- a/src/distilabel/utils/serialization.py +++ b/src/distilabel/utils/serialization.py @@ -106,8 +106,16 @@ def load_with_type_info(class_: Any) -> Any: return class_ type_info = class_.pop(TYPE_INFO_KEY) + cls = _get_module_attr(type_info["module"], type_info["name"]) + if issubclass(cls, BaseModel): + # `pop` keys from the dictionary that are not in the model fields + field_names = cls.model_fields + keys_to_drop = [k for k in class_.keys() if k not in field_names] + for k in keys_to_drop: + class_.pop(k) + instance = cls(**class_) return instance @@ -196,10 +204,15 @@ def _model_dump(self, obj: Any, **kwargs: Any) -> Dict[str, Any]: "_name": getattr(obj, k).__name__, "_values": {x.name: x.value for x in v}, # type: ignore } + elif isinstance(v, list): + dump[k] = {str(i): list_v for i, list_v in enumerate(v)} + # Grab the fields that need extra care (LLMs from inside tasks) to_update = _extra_serializable_fields(obj) + # Update those in the dumped dict - [dump.update(field) for field in to_update] + for field in to_update: + dump.update(field) return dump @@ -340,12 +353,19 @@ def _check_is_dir(path: StrOrPath) -> None: def _extra_serializable_fields(obj: BaseModel) -> List[Dict[str, Dict[str, Any]]]: - # This function is here to loop over objects that contains nested _Serializable objects. - # Cannot work recursively due to the mix between models that inherit from BaseModel and - # those that don't, so we loop over the classes and update those that are _Serializable. - # Extra introspection to dump nested objects. - # Mainly for the LLMs inside a Task for the moment. - # This way we ensure the "type_info" is inserted in those objects. + """Gets the information of the nested `_Serializable` attributes within another `_Serializable` + instance. + + It's mainly used to get the information of the `LLM` objects inside a `Task` object, + as they are nested and need to be serialized (`type_info`). + + Args: + obj: the object to extract the information from. + + Returns: + A list of dictionaries containing the information of the nested `_Serializable` + attributes. + """ from distilabel.pipeline.base import BasePipeline to_update = [] @@ -353,6 +373,12 @@ def _extra_serializable_fields(obj: BaseModel) -> List[Dict[str, Dict[str, Any]] field = getattr(obj, k) # Have to remove the Pipeline as it will be inside the Step objects but is really # in a higher level hierarchy. - if isinstance(field, _Serializable) and (not isinstance(field, BasePipeline)): + if isinstance(field, BasePipeline): + continue + + if isinstance(field, _Serializable): to_update.append({k: getattr(obj, k).dump()}) + elif isinstance(field, list) and field and isinstance(field[0], _Serializable): + to_update.append({k: {str(i): x.dump() for i, x in enumerate(field)}}) + return to_update diff --git a/tests/unit/steps/tasks/utils.py b/tests/unit/conftest.py similarity index 56% rename from tests/unit/steps/tasks/utils.py rename to tests/unit/conftest.py index 989fb3ad5b..bbe6ca1ed4 100644 --- a/tests/unit/steps/tasks/utils.py +++ b/tests/unit/conftest.py @@ -12,13 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, List, Union +from typing import TYPE_CHECKING -from distilabel.llms.base import LLM -from distilabel.steps.tasks.typing import ChatType +import pytest +from distilabel.llms.base import AsyncLLM +if TYPE_CHECKING: + from distilabel.llms.typing import GenerateOutput + from distilabel.steps.tasks.typing import FormattedInput -class DummyLLM(LLM): + +# Defined here too, so that the serde still works +class DummyLLM(AsyncLLM): def load(self) -> None: pass @@ -26,7 +31,12 @@ def load(self) -> None: def model_name(self) -> str: return "test" - def generate( - self, inputs: List["ChatType"], num_generations: int = 1, **kwargs: Any - ) -> List[List[Union[str, None]]]: - return [["output" for _ in range(num_generations)] for _ in inputs] + async def agenerate( + self, input: "FormattedInput", num_generations: int = 1 + ) -> "GenerateOutput": + return ["output" for _ in range(num_generations)] + + +@pytest.fixture +def dummy_llm() -> AsyncLLM: + return DummyLLM() diff --git a/tests/unit/llms/test_cohere.py b/tests/unit/llms/test_cohere.py index 3cba9611d8..a16d904e11 100644 --- a/tests/unit/llms/test_cohere.py +++ b/tests/unit/llms/test_cohere.py @@ -98,7 +98,7 @@ async def test_agenerate_structured( }, ] ) - assert generation == sample_user.model_dump_json() + assert generation == [sample_user.model_dump_json()] @pytest.mark.asyncio async def test_generate(self, mock_async_client: mock.MagicMock) -> None: diff --git a/tests/unit/llms/test_moa.py b/tests/unit/llms/test_moa.py new file mode 100644 index 0000000000..b3a92eded1 --- /dev/null +++ b/tests/unit/llms/test_moa.py @@ -0,0 +1,61 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from distilabel.llms.moa import MOA_SYSTEM_PROMPT, MixtureOfAgentsLLM + +from tests.unit.conftest import DummyLLM + + +class TestMixtureOfAgents: + def test_model_name(self) -> None: + llm = MixtureOfAgentsLLM( + aggregator_llm=DummyLLM(), + proposers_llms=[DummyLLM(), DummyLLM(), DummyLLM()], + ) + + assert llm.model_name == "moa-test-test-test-test" + + def test_build_moa_system_prompt(self) -> None: + llm = MixtureOfAgentsLLM( + aggregator_llm=DummyLLM(), + proposers_llms=[DummyLLM(), DummyLLM(), DummyLLM()], + ) + + system_prompt = llm._build_moa_system_prompt( + prev_outputs=["output1", "output2", "output3"] + ) + + assert ( + system_prompt == f"{MOA_SYSTEM_PROMPT}\n1. output1\n2. output2\n3. output3" + ) + + def test_inject_moa_system_prompt(self) -> None: + llm = MixtureOfAgentsLLM( + aggregator_llm=DummyLLM(), + proposers_llms=[DummyLLM(), DummyLLM(), DummyLLM()], + ) + + results = llm._inject_moa_system_prompt( + input=[ + {"role": "system", "content": "I'm a system prompt."}, + ], + prev_outputs=["output1", "output2", "output3"], + ) + + assert results == [ + { + "role": "system", + "content": f"{MOA_SYSTEM_PROMPT}\n1. output1\n2. output2\n3. output3\n\nI'm a system prompt.", + } + ] diff --git a/tests/unit/mixins/test_runtime_parameters.py b/tests/unit/mixins/test_runtime_parameters.py index 82cac59b7e..8e8d7766d0 100644 --- a/tests/unit/mixins/test_runtime_parameters.py +++ b/tests/unit/mixins/test_runtime_parameters.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Optional +from typing import List, Optional from distilabel.mixins.runtime_parameters import ( RuntimeParameter, @@ -32,6 +32,7 @@ class DummyNestedClass(RuntimeParametersMixin): class DummyClass(RuntimeParametersMixin): nested_class: DummyNestedClass + mixins_list: List[DummyNestedClass] runtime_param1: RuntimeParameter[SecretStr] = Field( default=None, description="Runtime param 1" @@ -43,7 +44,10 @@ class DummyClass(RuntimeParametersMixin): class TestRuntimeParametersMixin: def test_runtime_parameters_names(self) -> None: - dummy = DummyClass(nested_class=DummyNestedClass()) + dummy = DummyClass( + nested_class=DummyNestedClass(), + mixins_list=[DummyNestedClass(), DummyNestedClass(), DummyNestedClass()], + ) assert dummy.runtime_parameters_names == { "runtime_param1": False, @@ -52,10 +56,27 @@ def test_runtime_parameters_names(self) -> None: "runtime_param1": False, "runtime_param2": True, }, + "mixins_list": { + "0": { + "runtime_param1": False, + "runtime_param2": True, + }, + "1": { + "runtime_param1": False, + "runtime_param2": True, + }, + "2": { + "runtime_param1": False, + "runtime_param2": True, + }, + }, } def test_get_runtime_parameters_info(self) -> None: - dummy = DummyClass(nested_class=DummyNestedClass()) + dummy = DummyClass( + nested_class=DummyNestedClass(), + mixins_list=[DummyNestedClass(), DummyNestedClass(), DummyNestedClass()], + ) assert dummy.get_runtime_parameters_info() == [ { @@ -73,6 +94,47 @@ def test_get_runtime_parameters_info(self) -> None: }, ], }, + { + "name": "mixins_list", + "runtime_parameters_info": { + "0": [ + { + "name": "runtime_param1", + "description": "Runtime param 1", + "optional": False, + }, + { + "name": "runtime_param2", + "description": "Runtime param 2", + "optional": True, + }, + ], + "1": [ + { + "name": "runtime_param1", + "description": "Runtime param 1", + "optional": False, + }, + { + "name": "runtime_param2", + "description": "Runtime param 2", + "optional": True, + }, + ], + "2": [ + { + "name": "runtime_param1", + "description": "Runtime param 1", + "optional": False, + }, + { + "name": "runtime_param2", + "description": "Runtime param 2", + "optional": True, + }, + ], + }, + }, { "name": "runtime_param1", "description": "Runtime param 1", @@ -86,7 +148,10 @@ def test_get_runtime_parameters_info(self) -> None: ] def test_set_runtime_parameters(self) -> None: - dummy = DummyClass(nested_class=DummyNestedClass()) + dummy = DummyClass( + nested_class=DummyNestedClass(), + mixins_list=[DummyNestedClass(), DummyNestedClass(), DummyNestedClass()], + ) dummy.set_runtime_parameters( { diff --git a/tests/unit/steps/tasks/benchmarks/test_arena_hard.py b/tests/unit/steps/tasks/benchmarks/test_arena_hard.py index 50258db668..40666e4402 100644 --- a/tests/unit/steps/tasks/benchmarks/test_arena_hard.py +++ b/tests/unit/steps/tasks/benchmarks/test_arena_hard.py @@ -20,7 +20,7 @@ from distilabel.pipeline.local import Pipeline from distilabel.steps.tasks.benchmarks.arena_hard import ArenaHard, ArenaHardResults -from tests.unit.steps.tasks.utils import DummyLLM +from tests.unit.conftest import DummyLLM class TestArenaHard: diff --git a/tests/unit/steps/tasks/conftest.py b/tests/unit/steps/tasks/conftest.py deleted file mode 100644 index da1493c9ce..0000000000 --- a/tests/unit/steps/tasks/conftest.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2023-present, Argilla, Inc. -# -# 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. - -from typing import TYPE_CHECKING, List - -import pytest -from distilabel.llms.base import LLM - -if TYPE_CHECKING: - from distilabel.llms.typing import GenerateOutput - from distilabel.steps.tasks.typing import ChatType - - -@pytest.fixture -def dummy_llm() -> LLM: - class DummyLLM(LLM): - def load(self) -> None: - pass - - @property - def model_name(self) -> str: - return "test" - - def generate( # type: ignore - self, inputs: List["ChatType"], num_generations: int = 1 - ) -> List["GenerateOutput"]: - return [["output"] for _ in inputs] - - return DummyLLM() - - -# Defined here too, so that the serde still works -class DummyLLM(LLM): - def load(self) -> None: - pass - - @property - def model_name(self) -> str: - return "test" - - def generate( # type: ignore - self, inputs: List["ChatType"], num_generations: int = 1 - ) -> List["GenerateOutput"]: - return [["output"] for _ in inputs] diff --git a/tests/unit/steps/tasks/test_base.py b/tests/unit/steps/tasks/test_base.py index e24dd0fefb..c5ff87e1e0 100644 --- a/tests/unit/steps/tasks/test_base.py +++ b/tests/unit/steps/tasks/test_base.py @@ -22,7 +22,7 @@ from distilabel.steps.tasks.base import Task from pydantic import ValidationError -from tests.unit.steps.tasks.utils import DummyLLM +from tests.unit.conftest import DummyLLM if TYPE_CHECKING: from distilabel.steps.tasks.typing import ChatType @@ -156,7 +156,7 @@ def test_process_with_runtime_parameters(self) -> None: assert task.llm.runtime_parameters_names == { "runtime_parameter": False, "runtime_parameter_optional": True, - "generation_kwargs": {"kwargs": False}, + "generation_kwargs": {}, } # 2. Runtime parameters in init @@ -171,7 +171,7 @@ def test_process_with_runtime_parameters(self) -> None: assert task.llm.runtime_parameters_names == { "runtime_parameter": False, "runtime_parameter_optional": True, - "generation_kwargs": {"kwargs": False}, + "generation_kwargs": {}, } # 3. Runtime parameters in init superseded by runtime parameters @@ -187,7 +187,7 @@ def test_process_with_runtime_parameters(self) -> None: assert task.llm.runtime_parameters_names == { "runtime_parameter": False, "runtime_parameter_optional": True, - "generation_kwargs": {"kwargs": False}, + "generation_kwargs": {}, } def test_serialization(self) -> None: @@ -203,7 +203,7 @@ def test_serialization(self) -> None: "llm": { "generation_kwargs": {}, "type_info": { - "module": "tests.unit.steps.tasks.utils", + "module": "tests.unit.conftest", "name": "DummyLLM", }, }, @@ -221,12 +221,7 @@ def test_serialization(self) -> None: { "description": "The kwargs to be propagated to either `generate` or " "`agenerate` methods within each `LLM`.", - "keys": [ - { - "name": "kwargs", - "optional": False, - }, - ], + "keys": [], "name": "generation_kwargs", }, ], diff --git a/tests/unit/steps/tasks/test_complexity_scorer.py b/tests/unit/steps/tasks/test_complexity_scorer.py index a47a16445d..ec0575d745 100644 --- a/tests/unit/steps/tasks/test_complexity_scorer.py +++ b/tests/unit/steps/tasks/test_complexity_scorer.py @@ -18,7 +18,7 @@ from distilabel.pipeline.local import Pipeline from distilabel.steps.tasks.complexity_scorer import ComplexityScorer -from tests.unit.steps.tasks.utils import DummyLLM +from tests.unit.conftest import DummyLLM class TestComplexityScorer: diff --git a/tests/unit/steps/tasks/test_genstruct.py b/tests/unit/steps/tasks/test_genstruct.py index 8ecc9d2d58..12878b9f26 100644 --- a/tests/unit/steps/tasks/test_genstruct.py +++ b/tests/unit/steps/tasks/test_genstruct.py @@ -18,7 +18,7 @@ from distilabel.pipeline.local import Pipeline from distilabel.steps.tasks.genstruct import Genstruct -from tests.unit.steps.tasks.utils import DummyLLM +from tests.unit.conftest import DummyLLM class TestGenstruct: diff --git a/tests/unit/steps/tasks/test_prometheus_eval.py b/tests/unit/steps/tasks/test_prometheus_eval.py index e5a4ad8590..31a437fdab 100644 --- a/tests/unit/steps/tasks/test_prometheus_eval.py +++ b/tests/unit/steps/tasks/test_prometheus_eval.py @@ -27,7 +27,7 @@ from jinja2 import Template from pydantic import ValidationError -from tests.unit.steps.tasks.utils import DummyLLM +from tests.unit.conftest import DummyLLM def load_template(template: str) -> Template: diff --git a/tests/unit/steps/tasks/test_quality_scorer.py b/tests/unit/steps/tasks/test_quality_scorer.py index 0a3db8261b..608631e9a2 100644 --- a/tests/unit/steps/tasks/test_quality_scorer.py +++ b/tests/unit/steps/tasks/test_quality_scorer.py @@ -18,7 +18,7 @@ from distilabel.pipeline.local import Pipeline from distilabel.steps.tasks.quality_scorer import QualityScorer -from tests.unit.steps.tasks.utils import DummyLLM +from tests.unit.conftest import DummyLLM class TestQualityScorer: diff --git a/tests/unit/steps/tasks/test_self_instruct.py b/tests/unit/steps/tasks/test_self_instruct.py index 8525b88e6b..e3378e7e93 100644 --- a/tests/unit/steps/tasks/test_self_instruct.py +++ b/tests/unit/steps/tasks/test_self_instruct.py @@ -15,7 +15,7 @@ from distilabel.pipeline.local import Pipeline from distilabel.steps.tasks.self_instruct import SelfInstruct -from tests.unit.steps.tasks.utils import DummyLLM +from tests.unit.conftest import DummyLLM class TestSelfInstruct: diff --git a/tests/unit/steps/tasks/test_sentence_transformers.py b/tests/unit/steps/tasks/test_sentence_transformers.py index e63b14bc83..2f81240755 100644 --- a/tests/unit/steps/tasks/test_sentence_transformers.py +++ b/tests/unit/steps/tasks/test_sentence_transformers.py @@ -23,7 +23,7 @@ GenerationAction, ) -from tests.unit.steps.tasks.utils import DummyLLM +from tests.unit.conftest import DummyLLM class TestGenerateSentencePair: diff --git a/tests/unit/steps/tasks/test_text_generation.py b/tests/unit/steps/tasks/test_text_generation.py index 545cf6a7b8..c98adb00e5 100644 --- a/tests/unit/steps/tasks/test_text_generation.py +++ b/tests/unit/steps/tasks/test_text_generation.py @@ -16,7 +16,7 @@ from distilabel.pipeline.local import Pipeline from distilabel.steps.tasks.text_generation import ChatGeneration, TextGeneration -from tests.unit.steps.tasks.utils import DummyLLM +from tests.unit.conftest import DummyLLM class TestTextGeneration: diff --git a/tests/unit/steps/test_base.py b/tests/unit/steps/test_base.py index 543e5a43d2..6b469bf324 100644 --- a/tests/unit/steps/test_base.py +++ b/tests/unit/steps/test_base.py @@ -310,7 +310,7 @@ def test_step_from_dict(self) -> None: **{ "name": "dummy", TYPE_INFO_KEY: { - "module": "tests.unit.pipeline.step.test_base", + "module": "tests.unit.steps.test_base", "name": "DummyStep", }, } @@ -327,7 +327,7 @@ def test_step_from_dict_without_pipeline_context( **{ "name": "dummy", TYPE_INFO_KEY: { - "module": "tests.pipeline.step.test_base", + "module": "tests.unit.steps.test_base", "name": "DummyStep", }, } diff --git a/tests/unit/utils/test_serialization.py b/tests/unit/utils/test_serialization.py new file mode 100644 index 0000000000..153e2a8692 --- /dev/null +++ b/tests/unit/utils/test_serialization.py @@ -0,0 +1,37 @@ +# Copyright 2023-present, Argilla, Inc. +# +# 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. + +from distilabel.utils.serialization import _extra_serializable_fields, _Serializable +from pydantic import BaseModel + + +def test_extra_serializable_fields() -> None: + class DummyAttribute(BaseModel, _Serializable): + pass + + class Dummy(BaseModel, _Serializable): + attr: DummyAttribute + + dummy = Dummy(attr=DummyAttribute()) + + assert _extra_serializable_fields(dummy) == [ + { + "attr": { + "type_info": { + "module": "tests.unit.utils.test_serialization", + "name": "DummyAttribute", + } + } + } + ] From 356a4a33652479834d67c70ec0982743e2a1cc79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Tue, 18 Jun 2024 12:05:44 +0200 Subject: [PATCH 38/40] Document `num_generations` and `group_generations` attributes (#739) * Add warning about having to install specific `fsspec` implementation * Remove unused stuff * Add documentation for `num_generations` and `group_generations` attributes * Remove unused image * Update docs/sections/how_to_guides/basic/task/index.md Co-authored-by: Agus --------- Co-authored-by: Agus --- .../sections/pipeline/pipeline-ctrlc.png | Bin 336223 -> 0 bytes .../how_to_guides/advanced/fs_to_pass_data.md | 14 +- .../how_to_guides/basic/pipeline/index.md | 12 +- .../how_to_guides/basic/task/index.md | 89 ++++++++++- mkdocs.yml | 139 +++++++++--------- 5 files changed, 168 insertions(+), 86 deletions(-) delete mode 100644 docs/assets/images/sections/pipeline/pipeline-ctrlc.png diff --git a/docs/assets/images/sections/pipeline/pipeline-ctrlc.png b/docs/assets/images/sections/pipeline/pipeline-ctrlc.png deleted file mode 100644 index 33b5b171aee6a731e02dfefd73093e54f6de16fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 336223 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Ⓢ4*mCTxTX?PKkW2Jd zQ7|6Z)1sqK$BV#xRj3PV%0x(BT)XftWwTgs_-h$iSfW?iK$?kk+;x~>lBt|Ze--(d zL9Te(JQ`e>5l6sAS7n-06)`glD0zVq;s>SFoy0{=xzmDA>>j0unNw=UMfsen^8(UslzF$&#u_iSWa)`L{;+&I zjuju1#KEnedaYD5%X=O!a!o3wj4X`|FdI33lZl*r-{3}$fykw{ zm=0VaUphR&%*X+B^WhR?5?wD37v)kVb^<*S8N)~~C^}y<47`F2Dpm@EJ&ITZ0#z0? za-hiBo-)tD#KDEvxlSG+@;nmx&*33}|QiKDVk+1oXDj-QJ5qG~i@zsf^BPoIQW@j@a90fzk z^gaAd{JB>o<+p2dxHn1KKVi zyne)GF{MpbOq`5YtOlG*EmOgu=XOvXr_ra)&(ZzNUwb-`#5Kn}knG55H?HI>joUui z_|!+;(GHQeQYgj)9l9&EZmVys7`I#2A5qO@aoJw+P=pEkXQx|{Dn4>~ntKl(5KF9I z5DSH!*3GqqHi@DqR!jA9TC|0$l)^Fto6>RA8`$t6^9()Tch!pOnfLRDAwl#~<)-es zG;NRBYB*9iYBVhpgL{47NE4}OR%RQXZJ#*|))AgnjZf~WRF5EM#D*$wY1sCE9YDYsY*%fF>u$B zcIcs`K61=4-iU0VgKC1f{K>829BN4jSK?R2 zNfz(_H<~T_i0An}UDdZ5DG7M8-4*CMU>5qbf%Vn|D4Voi;g<%5`_w#+ilC zz#0o`+^k7)AFubV$9n^zE)03@cI0xX!jI#ZJ1C_A@p4N4@5IZ1U!aRK{2 zR@f8m6)qaSY$!UM3>s91WS1w4wWk%7Kr^eqm`sACN;68+WtIi8EaM+?JK$z#1sRB) z6=u7i(zbI~e{68;K;6Lie0+5Y1-~byzTtpX&&M~jVt&6@2eEyXR(sp%7)k<|yyp@W^LgO{cZq_9t zTimzz-BHU8*^n5K3V%5~QL#Mc-AN#OxOlqew{Zg~N;drMta6ILqq{}jz@hR~p_giv zoWZZy3U>Q}%!51+p|pIxULO^hf+EB?>c+O2*gRh0W_C2@sVp|$UwZWSnJ&0(d*luh z(+=LNYeks#?-NjDBd55&3%-*-)H#ei?DZF6`u<4+5_gH#zFb_Jo9jZRd-jyk%qN$@ z`&v^=z2*&)W1

    TZPw~uD5nM1;#5A?BCPX=O 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�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 Date: Tue, 18 Jun 2024 14:00:09 +0200 Subject: [PATCH 39/40] Add `examples/arena_hard.py` and remove from `distilabel` core (#741) * Remove `arena-hard` extras * Remove `ArenaHard` and `ArenaHardResults` * Add `examples/arena_hard.py` * Add `arena_hard.py` example to `docs` * Remove files included due to merge conflict --- .../pipeline_samples/examples/index.md | 16 ++ .../benchmarks => examples}/arena_hard.py | 134 +++++++++++++- pyproject.toml | 3 - scripts/install_dependencies.sh | 2 +- src/distilabel/steps/tasks/__init__.py | 3 - .../steps/tasks/benchmarks/__init__.py | 13 -- tests/unit/steps/tasks/benchmarks/__init__.py | 13 -- .../steps/tasks/benchmarks/test_arena_hard.py | 172 ------------------ tests/unit/test_imports.py | 2 - 9 files changed, 149 insertions(+), 209 deletions(-) rename {src/distilabel/steps/tasks/benchmarks => examples}/arena_hard.py (77%) delete mode 100644 src/distilabel/steps/tasks/benchmarks/__init__.py delete mode 100644 tests/unit/steps/tasks/benchmarks/__init__.py delete mode 100644 tests/unit/steps/tasks/benchmarks/test_arena_hard.py diff --git a/docs/sections/pipeline_samples/examples/index.md b/docs/sections/pipeline_samples/examples/index.md index 19b2136278..ffcadb3199 100644 --- a/docs/sections/pipeline_samples/examples/index.md +++ b/docs/sections/pipeline_samples/examples/index.md @@ -60,3 +60,19 @@ Answer instructions with knowledge graphs defined as `pydantic.BaseModel` object ``` ![Knowledge graph figure](../../../assets/images/sections/examples/knowledge-graph-example.png) + + +### [Benchmarking with `distilabel`: Arena Hard](#benchmarking-with-distilabel-arena-hard) + +Benchmark LLMs with `distilabel`: reproducing the Arena Hard benchmark. + +??? Example "See example" + + The script below first defines both the `ArenaHard` and the `ArenaHardResults` tasks, so as to generate responses for a given collection of prompts/questions with up to two LLMs, and then calculate the results as per the original implementation, respectively. Additionally, the second part of the example builds a `Pipeline` to run the generation on top of the prompts with `InferenceEndpointsLLM` while streaming the rest of the generations from a pre-computed set of GPT-4 generations, and then evaluate one against the other with `OpenAILLM` generating an alternate response, a comparison between the responses, and a result as A>>B, A>B, B>A, B>>A, or tie. + + To run this example you will first need to install the Arena Hard optional dependencies, being `pandas`, `scikit-learn`, and `numpy`. + + ```python title="arena_hard.py" + --8<-- "examples/arena_hard.py" + ``` + diff --git a/src/distilabel/steps/tasks/benchmarks/arena_hard.py b/examples/arena_hard.py similarity index 77% rename from src/distilabel/steps/tasks/benchmarks/arena_hard.py rename to examples/arena_hard.py index 78cd7fa175..81bec55ace 100644 --- a/src/distilabel/steps/tasks/benchmarks/arena_hard.py +++ b/examples/arena_hard.py @@ -15,12 +15,11 @@ import re from typing import Any, Dict, List, Optional, Union -from typing_extensions import override - from distilabel.steps import GlobalStep, StepInput from distilabel.steps.tasks.base import Task from distilabel.steps.tasks.typing import ChatType from distilabel.steps.typing import StepOutput +from typing_extensions import override class ArenaHard(Task): @@ -326,3 +325,134 @@ def process(self, inputs: StepInput) -> StepOutput: # type: ignore # Here only so that if follow up steps are connected the inputs are preserved, # since this step doesn't modify nor generate new inputs yield inputs + + +if __name__ == "__main__": + import json + + from distilabel.llms import InferenceEndpointsLLM, OpenAILLM + from distilabel.pipeline import Pipeline + from distilabel.steps import ( + CombineColumns, + KeepColumns, + LoadHubDataset, + StepInput, + step, + ) + from distilabel.steps.tasks import TextGeneration + from distilabel.steps.typing import StepOutput + + @step(inputs=["turns"], outputs=["system_prompt", "instruction"]) + def PrepareForTextGeneration(*inputs: StepInput) -> StepOutput: + for input in inputs: + for item in input: + item["system_prompt"] = "You are a helpful assistant." + item["instruction"] = item["turns"][0]["content"] + yield input + + @step( + inputs=["question_id"], + outputs=["generation", "generation_model"], + step_type="global", + ) + def LoadReference(*inputs: StepInput) -> StepOutput: + # File downloaded from https://raw.githubusercontent.com/lm-sys/arena-hard-auto/e0a8ea1df42c1df76451a6cd04b14e31ff992b87/data/arena-hard-v0.1/model_answer/gpt-4-0314.jsonl + lines = open("gpt-4-0314.jsonl", mode="r").readlines() + for input in inputs: + for item in input: + for line in lines: + data = json.loads(line) + if data["question_id"] == item["question_id"]: + item["generation"] = data["choices"][0]["turns"][0]["content"] + item["generation_model"] = data["model_id"] + break + yield input + + with Pipeline(name="arena-hard-v0.1") as pipeline: + load_dataset = LoadHubDataset( + name="load_dataset", + repo_id="alvarobartt/lmsys-arena-hard-v0.1", + split="test", + num_examples=5, + ) + + load_reference = LoadReference(name="load_reference") + + prepare = PrepareForTextGeneration(name="prepare") + + text_generation_cohere = TextGeneration( + name="text_generation_cohere", + llm=InferenceEndpointsLLM( + model_id="CohereForAI/c4ai-command-r-plus", + tokenizer_id="CohereForAI/c4ai-command-r-plus", + ), + use_system_prompt=True, + input_batch_size=10, + output_mappings={"model_name": "generation_model"}, + ) + + combine_columns = CombineColumns( + name="combine_columns", + columns=["generation", "generation_model"], + output_columns=["generations", "generation_models"], + ) + + arena_hard = ArenaHard( + name="arena_hard", + llm=OpenAILLM(model="gpt-4-1106-preview"), + output_mappings={"model_name": "evaluation_model"}, + ) + + keep_columns = KeepColumns( + name="keep_columns", + columns=[ + "question_id", + "category", + "cluster", + "system_prompt", + "instruction", + "generations", + "generation_models", + "evaluation", + "score", + "evaluation_model", + ], + ) + + win_rates = ArenaHardResults( + name="win_rates", custom_model_column="generation_models" + ) + + load_dataset >> load_reference # type: ignore + load_dataset >> prepare >> text_generation_cohere # type: ignore + ( # type: ignore + [load_reference, text_generation_cohere] + >> combine_columns + >> arena_hard + >> keep_columns + >> win_rates + ) + + distiset = pipeline.run( + parameters={ # type: ignore + text_generation_cohere.name: { + "llm": { + "generation_kwargs": { + "temperature": 0.7, + "max_new_tokens": 4096, + "stop_sequences": ["", "<|END_OF_TURN_TOKEN|>"], + } + } + }, + arena_hard.name: { + "llm": { + "generation_kwargs": { + "temperature": 0.0, + "max_new_tokens": 4096, + } + } + }, + }, + ) + if distiset is not None: + distiset.push_to_hub("arena-hard-results") diff --git a/pyproject.toml b/pyproject.toml index 50c858462c..b94387ff51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,9 +87,6 @@ outlines = ["outlines >= 0.0.40"] vertexai = ["google-cloud-aiplatform >= 1.38.0"] vllm = ["vllm >= 0.4.0", "outlines == 0.0.34", "filelock >= 3.13.4"] -# Other optional dependencies -arena-hard = ["pandas", "numpy", "scikit-learn"] - [project.urls] Documentation = "https://distilabel.argilla.io/" Issues = "https://github.com/argilla/distilabel/issues" diff --git a/scripts/install_dependencies.sh b/scripts/install_dependencies.sh index 3372bd60fd..4da6ad9dd4 100755 --- a/scripts/install_dependencies.sh +++ b/scripts/install_dependencies.sh @@ -6,7 +6,7 @@ python_version=$(python -c "import sys; print(sys.version_info[:2])") python -m pip install uv -uv pip install --system -e ".[dev,tests,anthropic,arena-hard,argilla,cohere,groq,hf-inference-endpoints,hf-transformers,litellm,llama-cpp,ollama,openai,outlines,vertexai]" +uv pip install --system -e ".[dev,tests,anthropic,argilla,cohere,groq,hf-inference-endpoints,hf-transformers,litellm,llama-cpp,ollama,openai,outlines,vertexai]" if [ "${python_version}" != "(3, 8)" ]; then uv pip install --system -e .[mistralai,instructor] fi diff --git a/src/distilabel/steps/tasks/__init__.py b/src/distilabel/steps/tasks/__init__.py index d1bccf08f2..b2456d7824 100644 --- a/src/distilabel/steps/tasks/__init__.py +++ b/src/distilabel/steps/tasks/__init__.py @@ -13,7 +13,6 @@ # limitations under the License. from distilabel.steps.tasks.base import GeneratorTask, Task -from distilabel.steps.tasks.benchmarks.arena_hard import ArenaHard, ArenaHardResults from distilabel.steps.tasks.complexity_scorer import ComplexityScorer from distilabel.steps.tasks.evol_instruct.base import EvolInstruct from distilabel.steps.tasks.evol_instruct.evol_complexity.base import EvolComplexity @@ -47,8 +46,6 @@ from distilabel.steps.tasks.ultrafeedback import UltraFeedback __all__ = [ - "ArenaHard", - "ArenaHardResults", "GeneratorTask", "Task", "ComplexityScorer", diff --git a/src/distilabel/steps/tasks/benchmarks/__init__.py b/src/distilabel/steps/tasks/benchmarks/__init__.py deleted file mode 100644 index 2598794f29..0000000000 --- a/src/distilabel/steps/tasks/benchmarks/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2023-present, Argilla, Inc. -# -# 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. diff --git a/tests/unit/steps/tasks/benchmarks/__init__.py b/tests/unit/steps/tasks/benchmarks/__init__.py deleted file mode 100644 index 2598794f29..0000000000 --- a/tests/unit/steps/tasks/benchmarks/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2023-present, Argilla, Inc. -# -# 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. diff --git a/tests/unit/steps/tasks/benchmarks/test_arena_hard.py b/tests/unit/steps/tasks/benchmarks/test_arena_hard.py deleted file mode 100644 index 40666e4402..0000000000 --- a/tests/unit/steps/tasks/benchmarks/test_arena_hard.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright 2023-present, Argilla, Inc. -# -# 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 logging -from typing import Any, Dict, List, Union - -import pytest -from _pytest.logging import LogCaptureFixture -from distilabel.pipeline.local import Pipeline -from distilabel.steps.tasks.benchmarks.arena_hard import ArenaHard, ArenaHardResults - -from tests.unit.conftest import DummyLLM - - -class TestArenaHard: - def test_format_input(self) -> None: - task = ArenaHard( - name="arena_hard", - llm=DummyLLM(), - pipeline=Pipeline(name="unit-test-pipeline"), - ) - task.load() - - result = task.format_input( - input={ - "instruction": "INSTRUCTION", - "generations": ["GENERATION_A", "GENERATION_B"], - } - ) - - assert result[-1] == { - "role": "user", - "content": "<|User Prompt|>\nINSTRUCTION\n\n<|The Start of Assistant A's Answer|>\nGENERATION_A\n<|The End of Assistant A's Answer|>\n\n<|The Start of Assistant B's Answer|>\nGENERATION_B\n<|The End of Assistant B's Answer|>", - } - - @pytest.mark.parametrize( - "output, expected", - [ - ( - "My own answer to the prompt would be:\nANSWER\nMy final veredict is: [[A>>B]]\n", - { - "evaluation": "My own answer to the prompt would be:\nANSWER\nMy final veredict is: [[A>>B]]\n", - "score": "A>>B", - }, - ), - ( - "My own answer to the prompt would be:\nANSWER\nMy final veredict is: TIE\n", - { - "evaluation": "My own answer to the prompt would be:\nANSWER\nMy final veredict is: TIE\n", - "score": None, - }, - ), - ( - None, - {"evaluation": None, "score": None}, - ), - ], - ) - def test_format_output( - self, output: Union[str, None], expected: Dict[str, Any] - ) -> None: - task = ArenaHard( - name="arena_hard", - llm=DummyLLM(), - pipeline=Pipeline(name="unit-test-pipeline"), - ) - task.load() - - assert ( - task.format_output( - output=output, - input={ - "instruction": "INSTRUCTION", - "generations": ["GENERATION_A", "GENERATION_B"], - }, - ) - == expected - ) - - -class TestArenaHardResults: - @pytest.mark.parametrize( - "custom_model_column, inputs", - [ - ("model_name", ["evaluation", "score", "model_name"]), - (None, ["evaluation", "score"]), - ], - ) - def test_inputs( - self, custom_model_column: Union[str, None], inputs: List[str] - ) -> None: - step = ArenaHardResults( - name="arena_hard_results", - custom_model_column=custom_model_column, - pipeline=Pipeline(name="unit-test-pipeline"), - ) - assert step.inputs == inputs - - def test_process(self, caplog: LogCaptureFixture) -> None: - step = ArenaHardResults( - name="arena_hard_results", - custom_model_column="model_names", - pipeline=Pipeline(name="unit-test-pipeline"), - ) - step.load() - - with caplog.at_level(logging.INFO): - next( - step.process( - [ - { - "evaluation": "...", - "score": "A>>B", - "model_names": ["gpt-4-0314", "other-model"], - }, - { - "evaluation": "...", - "score": "A=B", - "model_names": ["gpt-4-0314", "other-model"], - }, - { - "evaluation": "...", - "score": "B>>A", - "model_names": ["gpt-4-0314", "other-model"], - }, - ] - ) - ) - assert ( - "Arena Hard ELO: other-model 1445.577347\ngpt-4-0314 1000.000000\ndtype: float64\n" - in caplog.text - ) - - def test_process_errors(self) -> None: - step = ArenaHardResults( - name="arena_hard_results", - custom_model_column="model_names", - pipeline=Pipeline(name="unit-test-pipeline"), - ) - step.load() - - with pytest.raises( - ValueError, - match="This solver needs samples of at least 2 classes in the data, but the data contains only one class: 0.0", - ): - next( - step.process( - [ - { - "evaluation": "...", - "score": "A>>B", - "model_names": ["gpt-4-0314", "other-model"], - }, - { - "evaluation": "...", - "score": "B>>A", - "model_names": ["gpt-4-0314", "other-model"], - }, - ] - ) - ) diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py index 94feb041ed..e20e186c8e 100644 --- a/tests/unit/test_imports.py +++ b/tests/unit/test_imports.py @@ -63,8 +63,6 @@ def test_imports() -> None: ) from distilabel.steps.tasks import ( - ArenaHard, - ArenaHardResults, Task, GeneratorTask, ChatItem, From 2430e62b4eb7c1a21b14dd8c075868c1aa8c10a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Mart=C3=ADn=20Bl=C3=A1zquez?= Date: Tue, 18 Jun 2024 14:21:49 +0200 Subject: [PATCH 40/40] Add serving LLM docs (#742) --- docs/sections/getting_started/faq.md | 3 + .../how_to_guides/advanced/argilla.md | 3 +- .../advanced/serving_an_llm_for_reuse.md | 92 +++++++++++++++++++ mkdocs.yml | 1 + 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 docs/sections/how_to_guides/advanced/serving_an_llm_for_reuse.md diff --git a/docs/sections/getting_started/faq.md b/docs/sections/getting_started/faq.md index d70b961897..27768a3c6f 100644 --- a/docs/sections/getting_started/faq.md +++ b/docs/sections/getting_started/faq.md @@ -39,3 +39,6 @@ hide: For more information on the caching mechanism in `distilabel`, you can check the [Learn - Advanced - Caching](../how_to_guides/advanced/caching.md) section. Also note that when running a [`Step`][distilabel.steps.base.Step] or a [`Task`][distilabel.steps.tasks.Task] standalone, the cache mechanism won't be used, so if you want to use that, you should use the `Pipeline` context manager. + +??? faq "How can I use the same `LLM` across several tasks without having to load it several times?" + You can serve the LLM using a solution like TGI or vLLM, and then connect to it using an `AsyncLLM` client like `InferenceEndpointsLLM` or `OpenAILLM`. Please refer to [Serving LLMs guide](../how_to_guides/advanced/serving_an_llm_for_reuse.md) for more information. diff --git a/docs/sections/how_to_guides/advanced/argilla.md b/docs/sections/how_to_guides/advanced/argilla.md index 43537eb14b..2d8c047960 100644 --- a/docs/sections/how_to_guides/advanced/argilla.md +++ b/docs/sections/how_to_guides/advanced/argilla.md @@ -106,7 +106,8 @@ with Pipeline(name="my-pipeline") as pipeline: load_dataset >> text_generation >> to_argilla -pipeline.run() +if __name__ == "__main__": + pipeline.run() ``` ![Preference to Argilla](../../../assets/images/sections/how_to_guides/steps/argilla/preference.png) diff --git a/docs/sections/how_to_guides/advanced/serving_an_llm_for_reuse.md b/docs/sections/how_to_guides/advanced/serving_an_llm_for_reuse.md new file mode 100644 index 0000000000..eedba82e3d --- /dev/null +++ b/docs/sections/how_to_guides/advanced/serving_an_llm_for_reuse.md @@ -0,0 +1,92 @@ +# Serving an `LLM` for sharing it between several `Task`s + +It's very common to want to use the same `LLM` for several `Task`s in a pipeline. To avoid loading the `LLM` as many times as the number of `Task`s and avoid wasting resources, it's recommended to serve the model using solutions like [`text-generation-inference`](https://huggingface.co/docs/text-generation-inference/quicktour#launching-tgi) or [`vLLM`](https://docs.vllm.ai/en/stable/serving/deploying_with_docker.html), and then use an `AsyncLLM` compatible client like `InferenceEndpointsLLM` or `OpenAILLM` to communicate with the server respectively. + +## Serving `meta-llama/Meta-Llama-3-8B-Instruct` using `text-generation-inference` + +```bash +model=meta-llama/Meta-Llama-3-8B-Instruct +volume=$PWD/data # share a volume with the Docker container to avoid downloading weights every run + +docker run --gpus all --shm-size 1g -p 8080:80 -v $volume:/data \ + -e HUGGING_FACE_HUB_TOKEN= \ + ghcr.io/huggingface/text-generation-inference:2.0.4 \ + --model-id $model +``` + +!!! NOTE + + The bash command above has been copy-pasted from the official docs [text-generation-inference](https://huggingface.co/docs/text-generation-inference/quicktour#launching-tgi). Please refer to the official docs for more information. + +And then we can use `InferenceEndpointsLLM` with `base_url=http://localhost:8080` (pointing to our `TGI` local deployment): + +```python +from distilabel.llms import InferenceEndpointsLLM +from distilabel.pipeline import Pipeline +from distilabel.steps import LoadDataFromDicts +from distilabel.steps.tasks import TextGeneration, UltraFeedback + +with Pipeline(name="serving-llm") as pipeline: + load_data = LoadDataFromDicts( + data=[{"instruction": "Write a poem about the sun and moon."}] + ) + + # `base_url` points to the address of the `TGI` serving the LLM + llm = InferenceEndpointsLLM(base_url="http://192.168.1.138:8080") + + text_generation = TextGeneration( + llm=llm, + num_generations=3, + group_generations=True, + output_mappings={"generation": "generations"}, + ) + + ultrafeedback = UltraFeedback(aspect="overall-rating", llm=llm) + + load_data >> text_generation >> ultrafeedback +``` + + +## Serving `meta-llama/Meta-Llama-3-8B-Instruct` using `vLLM` + +```bash +docker run --gpus all \ + -v ~/.cache/huggingface:/root/.cache/huggingface \ + --env "HUGGING_FACE_HUB_TOKEN=" \ + -p 8000:8000 \ + --ipc=host \ + vllm/vllm-openai:latest \ + --model meta-llama/Meta-Llama-3-8B-Instruct +``` + +!!! NOTE + + The bash command above has been copy-pasted from the official docs [vLLM](https://docs.vllm.ai/en/stable/serving/deploying_with_docker.html). Please refer to the official docs for more information. + +And then we can use `OpenAILLM` with `base_url=http://localhost:8000` (pointing to our `vLLM` local deployment): + +```python +from distilabel.llms import OpenAILLM +from distilabel.pipeline import Pipeline +from distilabel.steps import LoadDataFromDicts +from distilabel.steps.tasks import TextGeneration, UltraFeedback + +with Pipeline(name="serving-llm") as pipeline: + load_data = LoadDataFromDicts( + data=[{"instruction": "Write a poem about the sun and moon."}] + ) + + # `base_url` points to the address of the `vLLM` serving the LLM + llm = OpenAILLM(base_url="http://192.168.1.138:8000", model="") + + text_generation = TextGeneration( + llm=llm, + num_generations=3, + group_generations=True, + output_mappings={"generation": "generations"}, + ) + + ultrafeedback = UltraFeedback(aspect="overall-rating", llm=llm) + + load_data >> text_generation >> ultrafeedback +``` diff --git a/mkdocs.yml b/mkdocs.yml index 66406c9d2e..da01807064 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -161,6 +161,7 @@ nav: - Using CLI to explore and re-run existing Pipelines: "sections/how_to_guides/advanced/cli/index.md" - Cache and recover pipeline executions: "sections/how_to_guides/advanced/caching.md" - Structured data generation: "sections/how_to_guides/advanced/structured_generation.md" + - Serving an LLM for sharing it between several tasks: "sections/how_to_guides/advanced/serving_an_llm_for_reuse.md" - Pipeline Samples: - Examples: "sections/pipeline_samples/examples/index.md" - Papers: