From 907b69068c063d6de6798d76351bdd64ff6d00ee Mon Sep 17 00:00:00 2001 From: James Arruda Date: Fri, 5 Jul 2024 12:20:23 -0400 Subject: [PATCH] Mega-commit for James Arruda's fixes on #1017 Initial issue description as a test.t Modifying GracefulErrorAdpater for Parallelizable blocks. Added docs to adapter. Updated for simpler passthrough. Added sentinel injection feature and test. Updated gitignore for vscode settings folder. Fix for tag error Updating adapter test for proper tags Fixing types for 3.8 Type fixing for 3.8 Fixing sentinel equality testing to avoid incomparible types. Adding example of paralellism and GracefulErrorAdapter Added decorator for sentinel acceptance as input. Updated tests and example. Added DAG image to example. Typo. Simplifying node callable retrieval. Adding docstring clarification on try_all_parallel. Parametrizing tests. Moving test module to resources. Added docs to decorator. Changed node tag key. --- .gitignore | 3 + .../parallelism/graceful_running/README.md | 34 ++++ examples/parallelism/graceful_running/dag.png | Bin 0 -> 43738 bytes .../parallelism/graceful_running/functions.py | 81 ++++++++++ examples/parallelism/graceful_running/run.py | 83 ++++++++++ hamilton/lifecycle/__init__.py | 1 + hamilton/lifecycle/default.py | 149 +++++++++++++++++- tests/resources/graceful_parallel.py | 63 ++++++++ tests/test_parallel_graceful.py | 117 ++++++++++++++ 9 files changed, 525 insertions(+), 6 deletions(-) create mode 100644 examples/parallelism/graceful_running/README.md create mode 100644 examples/parallelism/graceful_running/dag.png create mode 100644 examples/parallelism/graceful_running/functions.py create mode 100644 examples/parallelism/graceful_running/run.py create mode 100644 tests/resources/graceful_parallel.py create mode 100644 tests/test_parallel_graceful.py diff --git a/.gitignore b/.gitignore index 44ac9feff..03b593021 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ examples/dbt/logs/* # Ignore hamilton-env virtual envs examples/**/hamilton-env + +# vxcode +.vscode diff --git a/examples/parallelism/graceful_running/README.md b/examples/parallelism/graceful_running/README.md new file mode 100644 index 000000000..635f89653 --- /dev/null +++ b/examples/parallelism/graceful_running/README.md @@ -0,0 +1,34 @@ +# Parallelism with GracefulErrorAdapter Example + +## Overview + +This is a simple example of using the `GracefulErrorAdapter` in a parallelism example, where we might expect some component of an analysis to fail, but we'd still like to get as much data back as we can. + +This example divides a large dataframe into smaller frames, and runs the same analysis on each of those frames. It then gathers the results at the end into a single frame. Any errors inside the paralellism block do not halt the total operation of the driver. + +The user can define custom data splitting functions to process in the same sub-dag. In some ways, this is an example of how to do `@subdag` with `Parallelizable`. + +The DAG is shown below, which is a simple set of operations on a custom set of splits of a dataframe. Failures occur on purpose in this example in the `model_fit` portion. + +![image](dag.png) + +## Take home + +This demonstrates these capabilities: + +1. Dynamically generating datasets from a larger one and analyzing them the same way - in parallel +2. Skipping over nodes when a failure occurs and returning sentinel values on failure + +## Running + +You can run the basic analysis in the terminal with: + +```bash +python run.py +``` + +Change the `mode` input to demonstrate multiple methods of running in parallel. + +Add the flag `--no-adapt` to see the failure that occurs when not using the adapter. + +Modify the example to throw an exception in a function passed in to split the data. Change the order of the functions to see the effect on the results. diff --git a/examples/parallelism/graceful_running/dag.png b/examples/parallelism/graceful_running/dag.png new file mode 100644 index 0000000000000000000000000000000000000000..ce37c60b15b31afec789e24a2d592c11ba4fc3cd GIT binary patch literal 43738 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�?Xxc3Z$5 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

}e-P+hsc%O|uBNS)JJmecuL6tLf(RK31~x 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 QZ6xqgmRFO@zx5#CfAW!r pd.DataFrame: + """Make some fake data.""" + n_rows = 200 + views = np.linspace(0, 10_000.0, n_rows) + spend = views * np.sin(np.arange(n_rows)) + _regions = cycle(["Central", "North", "South"]) + regions = [next(_regions) for _ in range(n_rows)] + _methods = cycle(["Internet", "TV"]) + method = [next(_methods) for _ in range(n_rows)] + df = ( + pd.DataFrame() + .assign(Views=views) + .assign(Spend=spend) + .assign(Region=regions) + .assign(Method=method) + ) + return df + + +def split_to_groups( + load_data: pd.DataFrame, funcs: List[Splitter] +) -> Parallelizable[tuple[str, pd.DataFrame]]: + """Split data into interesting groups.""" + for func in funcs: + for grp_name, grp in func(load_data): + yield (grp_name, grp) + + +@extract_fields(dict(data=pd.DataFrame, group_name=str)) +def expander(split_to_groups: tuple[str, pd.DataFrame]) -> dict[str, Any]: + return {"data": split_to_groups[1], "group_name": split_to_groups[0]} + + +def average(data: pd.DataFrame) -> float: + """Average the views.""" + return data.Views.mean() + + +def model_fit(data: pd.DataFrame, group_name: str) -> Tuple[float, float, float]: + """Imagine a model fit that doesn't always work.""" + if "Method:TV" in group_name: + raise Exception("Fake floating point error, e.g.") + xs = data.Spend.values + ys = data.Views.values + res = linregress(xs, ys) + return res.intercept, res.slope, res.rvalue + + +@accept_error_sentinels +def gather_metrics( + group_name: Union[str, None], + average: Union[float, None], + model_fit: Union[Tuple[float, float, float], None], +) -> dict[str, Any]: + answer = { + "Name": group_name, + "Average": average, + "Intercept": model_fit[0] if model_fit else None, + "Spend_Coef": model_fit[1] if model_fit else None, + "Model_Fit": model_fit[2] if model_fit else None, + } + return answer + + +def final_collect(gather_metrics: Collect[dict[str, Any]]) -> pd.DataFrame: + df = pd.DataFrame.from_records(gather_metrics) + return df diff --git a/examples/parallelism/graceful_running/run.py b/examples/parallelism/graceful_running/run.py new file mode 100644 index 000000000..fd19881ce --- /dev/null +++ b/examples/parallelism/graceful_running/run.py @@ -0,0 +1,83 @@ +from typing import Iterable, Tuple + +import click +import functions +import pandas as pd +from dask import distributed + +from hamilton import driver +from hamilton.execution import executors +from hamilton.lifecycle import GracefulErrorAdapter +from hamilton.plugins import h_dask + +# Assume we define some custom methods for splittings + + +def split_on_region(data: pd.DataFrame) -> Iterable[Tuple[str, pd.DataFrame]]: + for idx, grp in data.groupby("Region"): + yield f"Region:{idx}", grp + + +def split_on_attrs(data: pd.DataFrame) -> Iterable[Tuple[str, pd.DataFrame]]: + for (region, method), grp in data.groupby(["Region", "Method"]): + yield f"Region:{region} - Method:{method}", grp + + +def split_on_views(data: pd.DataFrame) -> Iterable[Tuple[str, pd.DataFrame]]: + yield "Low Views", data[data.Views <= 4000.0] + yield "High Views", data[data.Views > 4000.0] + + +@click.command() +@click.option( + "--mode", + type=click.Choice(["local", "multithreading", "dask"]), + help="Where to run remote tasks.", + default="local", +) +@click.option("--no-adapt", is_flag=True, default=False, help="Disable the graceful adapter.") +def main(mode: str, no_adapt: bool): + adapter = GracefulErrorAdapter( + error_to_catch=Exception, + sentinel_value=None, + try_all_parallel=True, + ) + + shutdown = None + if mode == "local": + remote_executor = executors.SynchronousLocalTaskExecutor() + elif mode == "multithreading": + remote_executor = executors.MultiThreadingExecutor(max_tasks=100) + elif mode == "dask": + cluster = distributed.LocalCluster() + client = distributed.Client(cluster) + remote_executor = h_dask.DaskExecutor(client=client) + shutdown = cluster.close + + dr = ( + driver.Builder() + .enable_dynamic_execution(allow_experimental_mode=True) + .with_remote_executor(remote_executor) + .with_modules(functions) + ) + if not no_adapt: + dr = dr.with_adapters(adapter) + dr = dr.build() + + the_funcs = [split_on_region, split_on_attrs, split_on_views] + dr.visualize_execution( + ["final_collect"], "./dag", {}, inputs={"funcs": the_funcs}, show_legend=False + ) + + print( + dr.execute( + final_vars=["final_collect"], + inputs={"funcs": the_funcs}, + )["final_collect"] + ) + if shutdown: + shutdown() + + +if __name__ == "__main__": + main() diff --git a/hamilton/lifecycle/__init__.py b/hamilton/lifecycle/__init__.py index 74f152454..45d3b35cb 100644 --- a/hamilton/lifecycle/__init__.py +++ b/hamilton/lifecycle/__init__.py @@ -17,6 +17,7 @@ PDBDebugger, PrintLn, SlowDownYouMoveTooFast, + accept_error_sentinels, ) PrintLnHook = PrintLn # for backwards compatibility -- this will be removed in 2.0 diff --git a/hamilton/lifecycle/default.py b/hamilton/lifecycle/default.py index 1947702b8..22144ca89 100644 --- a/hamilton/lifecycle/default.py +++ b/hamilton/lifecycle/default.py @@ -11,6 +11,7 @@ from typing import Any, Callable, Dict, List, Optional, Type, Union from hamilton import graph_types, htypes +from hamilton.function_modifiers.metadata import tag from hamilton.graph_types import HamiltonGraph from hamilton.lifecycle import GraphExecutionHook, NodeExecutionHook, NodeExecutionMethod @@ -510,6 +511,49 @@ def run_after_node_execution( SENTINEL_DEFAULT = None # sentinel value -- lazy for now +INJECTION_ALLOWED = "injection is requested" + + +def accept_error_sentinels(func: Callable): + """Tag a function to allow passing in error sentinels. + + For use with ``GracefulErrorAdapter``. The standard adapter behavior is to skip a node + when an error sentinel is one of its inputs. This decorator will cause the node to + run, and place the error sentinel into the appropriate input. + + Take care to ensure your sentinels are easily distinguishable if you do this - see the + note in the GracefulErrorAdapater docstring. + + A use case is any data or computation aggregation step that still wants partial results, + or considers a failure interesting enough to log or notify. + + .. code-block:: python + + SENTINEL = object() + + ... + + @accept_error_sentinels + def results_gathering(result_1: float, result_2: float) -> dict[str, Any]: + answer = {} + for name, res in zip(["result 1", "result 2"], [result_1, result_2]) + answer[name] = res + if res is SENTINEL: + answer[name] = "Node failure: no result" + # You may want side-effects for a failure. + _send_text_that_your_runs_errored() + return answer + + ... + adapter = GracefulErrorAdapter(sentinel_value=SENTINEL) + ... + + + """ + _the_tag = tag( + **{"hamilton.error_sentinel": INJECTION_ALLOWED}, bypass_reserved_namespaces_=True + ) + return _the_tag(func) class GracefulErrorAdapter(NodeExecutionMethod): @@ -518,7 +562,13 @@ class GracefulErrorAdapter(NodeExecutionMethod): required dependencies fail (including optional dependencies). """ - def __init__(self, error_to_catch: Type[Exception], sentinel_value: Any = SENTINEL_DEFAULT): + def __init__( + self, + error_to_catch: Type[Exception], + sentinel_value: Any = SENTINEL_DEFAULT, + try_all_parallel: bool = True, + allow_injection: bool = True, + ): """Initializes the adapter. Allows you to customize the error to catch (which exception your graph will throw to indicate failure), as well as the sentinel value to use in place of a node's result if it fails (this defaults to ``None``). @@ -563,11 +613,70 @@ def never_reached(wont_proceed: int) -> int: Note you can customize the error you want it to fail on and the sentinel value to use in place of a node's result if it fails. + For Parallelizable nodes, this adapter will attempt to iterate over the node outputs. + If an error occurs, the sentinel value is returned and no more iterations over the node + will occur. Meaning if item (3) fails out of 1,2,3,4,5, 4/5 will not run. If you set + ``try_all_parallel`` to be False, it only sends one sentinel value into the parallelize sub-dag. + + Here's an example for parallelizable to demonstrate try_all_parallel: + + .. code-block:: python + + # parallel_module.py + # custom exception + class DoNotProceed(Exception): + pass + + def start_point() -> Parallelizable[int]: + for i in range(5): + if i == 3: + raise DoNotProceed() + yield i + + def inner(start_point: int) -> int: + return start_point + + def gather(inner: Collect[int]) -> list[int]: + return inner + + dr = ( + driver.Builder() + .with_modules(parallel_module) + .with_adapters( + default.GracefulErrorAdapter( + error_to_catch=DoNotProceed, + sentinel_value=None, + try_all_parallel=True, + ) + ) + .build() + ) + dr.execute(["gather"]) # will return {'gather': [0,1,2,None]} + + dr = ( + driver.Builder() + .with_modules(parallel_module) + .with_adapters( + default.GracefulErrorAdapter( + error_to_catch=DoNotProceed, + sentinel_value=None, + try_all_parallel=False, + ) + ) + .build() + ) + dr.execute(["gather"]) # will return {'gather': [None]} + + :param error_to_catch: The error to catch :param sentinel_value: The sentinel value to use in place of a node's result if it fails + :param try_all_parallel: Gather parallelizable outputs until a failure, then add a Sentinel. + :param allow_injection: Flag for considering the ``accept_error_sentinels`` tag. Defaults to True. """ self.error_to_catch = error_to_catch self.sentinel_value = sentinel_value + self.try_all_parallel = try_all_parallel + self.allow_injection = allow_injection def run_to_execute_node( self, @@ -584,10 +693,38 @@ def run_to_execute_node( # and truncate it/provide sentinels for every failure) # TODO -- decide what to do with collect """Executes a node. If the node fails, returns the sentinel value.""" - for key, value in node_kwargs.items(): - if value == self.sentinel_value: # == versus is - return self.sentinel_value # cascade it through + default_return = [self.sentinel_value] if is_expand else self.sentinel_value + _node_tags = future_kwargs["node_tags"] + can_inject = _node_tags.get("hamilton.error_sentinel", "") == INJECTION_ALLOWED + can_inject = can_inject and self.allow_injection + + if not can_inject: + for key, value in node_kwargs.items(): + if type(self.sentinel_value) is type(value): + if self.sentinel_value == value: # == versus is + return default_return + if not is_expand: + try: + return node_callable(**node_kwargs) + except self.error_to_catch: + return self.sentinel_value + + if not self.try_all_parallel: + gen_func = node_callable + else: + # Grab the partial-ized function that is a parallelizable. + gen_func = node_callable.keywords["_callable"] try: - return node_callable(**node_kwargs) + gen = gen_func(**node_kwargs) except self.error_to_catch: - return self.sentinel_value + return [self.sentinel_value] + results: list[Any] = [] + try: + for _res in gen: + results.append(_res) + except self.error_to_catch: + if self.try_all_parallel: + results.append(self.sentinel_value) + else: + results = [self.sentinel_value] + return results diff --git a/tests/resources/graceful_parallel.py b/tests/resources/graceful_parallel.py new file mode 100644 index 000000000..dc9adb625 --- /dev/null +++ b/tests/resources/graceful_parallel.py @@ -0,0 +1,63 @@ +from typing import List + +from hamilton.function_modifiers import config +from hamilton.htypes import Collect, Parallelizable +from hamilton.lifecycle.default import accept_error_sentinels + + +@config.when(test_front="good") +def input_maker__good(n: int) -> int: + return n + + +@config.when(test_front="bad") +def input_maker__bad(n: int) -> int: + raise Exception("Went wrong") + + +@config.when(test_state="middle") +def distro__middle(input_maker: int) -> Parallelizable[int]: + for x in range(input_maker): + if x > 4: + raise Exception("bad") + yield x * 3 + + +@config.when(test_state="early") +def distro__early(input_maker: int) -> Parallelizable[int]: + raise Exception("bad") + for x in range(input_maker): + yield x * 3 + + +@config.when(test_state="pass") +def distro__pass(input_maker: int) -> Parallelizable[int]: + for x in range(input_maker): + yield x * 3 + + +def some_math(distro: int) -> float: + if distro > 15: + raise Exception("No no no") + return distro * 2.0 + + +def other_math(distro: int) -> float: + if distro < 10: + raise Exception("Not allowed") + return distro + 1 + + +@accept_error_sentinels +def gather_math(some_math: float, other_math: float) -> List[float]: + return [some_math, other_math] + + +def distro_end(gather_math: Collect[List[float]]) -> List[float]: + ans = [x for x in gather_math] + return ans + + +def distro_gather(some_math: Collect[float]) -> List[float]: + ans = [x for x in some_math] + return ans diff --git a/tests/test_parallel_graceful.py b/tests/test_parallel_graceful.py new file mode 100644 index 000000000..677cd09bb --- /dev/null +++ b/tests/test_parallel_graceful.py @@ -0,0 +1,117 @@ +import pytest + +from hamilton import driver +from hamilton.execution.executors import SynchronousLocalTaskExecutor +from hamilton.lifecycle.default import GracefulErrorAdapter + +from .resources import graceful_parallel + + +def _make_driver( + test_state: str, + try_parallel=True, + fail_first=False, + allow_injection=True, +): + local_executor = SynchronousLocalTaskExecutor() + dr = ( + driver.Builder() + .enable_dynamic_execution(allow_experimental_mode=True) + .with_modules(graceful_parallel) + .with_remote_executor(local_executor) + .with_adapters( + GracefulErrorAdapter( + error_to_catch=Exception, + sentinel_value=None, + try_all_parallel=try_parallel, + allow_injection=allow_injection, + ) + ) + .with_config( + { + "test_state": test_state, + "test_front": "bad" if fail_first else "good", + }, + ) + .build() + ) + return dr + + +@pytest.mark.parametrize( + "test_state, try_parallel, fail_first, expected_value", + [ + ("early", True, False, [None]), + ("middle", True, False, [0.0, 6.0, 12.0, 18.0, 24.0, None]), + ("middle", False, False, [None]), + ("pass", True, False, [0.0, 6.0, 12.0, 18.0, 24.0, 30.0, None, None, None, None]), + ("pass", False, True, [None]), + ], + ids=[ + "test_early_parallel_fail", + "test_middle_parallel_fail", + "test_middle_parallel_as_single", + "test_inside_block_errors", + "test_pre_parallel_fail", + ], +) +def test_parallel_graceful_simple(test_state, try_parallel, fail_first, expected_value) -> None: + # Fail before the anything is yielded: should get 1 failure and skip all + dr = _make_driver( + test_state, + try_parallel, + fail_first, + ) + ans = dr.execute( + ["distro_gather"], + inputs={"n": 10}, + ) + assert ans["distro_gather"] == expected_value + + +def test_parallel_gather_injection() -> None: + dr = _make_driver( + "pass", + try_parallel=True, + fail_first=False, + ) + ans = dr.execute( + ["distro_end"], + inputs={"n": 10}, + ) + assert ans["distro_end"] == [ + [0.0, None], + [6.0, None], + [12.0, None], + [18.0, None], + [24.0, 13], + [30.0, 16], + [None, 19], + [None, 22], + [None, 25], + [None, 28], + ] + + # show that disallowing injection skips over the gathering step + dr = _make_driver( + "pass", + try_parallel=True, + fail_first=False, + allow_injection=False, + ) + ans = dr.execute( + ["distro_end"], + inputs={"n": 10}, + ) + assert ans["distro_end"] == [ + None, + None, + None, + None, + [24.0, 13], + [30.0, 16], + None, + None, + None, + None, + ]