diff --git a/dag_example_module.png b/dag_example_module.png
new file mode 100644
index 000000000..5351fb719
Binary files /dev/null and b/dag_example_module.png differ
diff --git a/docs/reference/decorators/pipe.rst b/docs/reference/decorators/pipe.rst
index 18d5bbad8..a6cef8751 100644
--- a/docs/reference/decorators/pipe.rst
+++ b/docs/reference/decorators/pipe.rst
@@ -1,8 +1,24 @@
=======================
-pipe
+pipe family
=======================
-We have a family of decorators that can help with transforming the input and output of a node in the DAG. For a hands on example have a look at https://github.com/DAGWorks-Inc/hamilton/tree/main/examples/scikit-learn/species_distribution_modeling
+We have a family of decorators that represent a chained set of transformations. This specifically solves the "node redefinition"
+problem, and is meant to represent a pipeline of chaining/redefinitions. This is similar (and can happily be
+used in conjunction with) ``pipe`` in pandas. In Pyspark this is akin to the common operation of redefining a dataframe
+with new columns.
+
+For some examples have a look at: https://github.com/DAGWorks-Inc/hamilton/tree/main/examples/scikit-learn/species_distribution_modeling
+
+While it is generally reasonable to contain constructs within a node's function,
+you should consider the pipe family for any of the following reasons:
+
+1. You want the transformations to display as nodes in the DAG, with the possibility of storing or visualizing
+the result.
+
+2. You want to pull in functions from an external repository, and build the DAG a little more procedurally.
+
+3. You want to use the same function multiple times, but with different parameters -- while ``@does`` / ``@parameterize`` can
+do this, this presents an easier way to do this, especially in a chain.
--------------
@@ -24,3 +40,8 @@ pipe_output
----------------
.. autoclass:: hamilton.function_modifiers.macros.pipe_output
:special-members: __init__
+
+mutate
+----------------
+.. autoclass:: hamilton.function_modifiers.macros.mutate
+ :special-members: __init__
diff --git a/examples/mutate/abstract functionality blueprint/DAG.png b/examples/mutate/abstract functionality blueprint/DAG.png
new file mode 100644
index 000000000..8ace57862
Binary files /dev/null and b/examples/mutate/abstract functionality blueprint/DAG.png differ
diff --git a/examples/mutate/abstract functionality blueprint/README b/examples/mutate/abstract functionality blueprint/README
new file mode 100644
index 000000000..342fc6033
--- /dev/null
+++ b/examples/mutate/abstract functionality blueprint/README
@@ -0,0 +1,31 @@
+# Mutate
+
+We give some application suggestions for mutating the outputs of functions in a distributed manner with `@mutate.`
+When you scroll through the notebook we build examples from most straight forward applications to more complex logic that showcases the amount of flexibility you get with this decorator.
+
+Mutate gives the ability to apply the same transformation to the each output of multiple functions in the DAG. It can be particularly useful in the following scenarios:
+
+1. Loading data and applying pre-cleaning step.
+2. Feature engineering via joining, filtering, sorting, applying adjustment factors on a per column basis etc.
+3. Experimenting with different transformations across nodes by selectively turning transformations on / off.
+
+
+and effectively replaces:
+1. Having to have unique names and then changing wiring if you want to add/remove/replace something.
+2. Enabling more verb like names on functions.
+3. Potentially simpler "reuse" of transform functions across DAG paths...
+
+# Modules
+The same modules can be viewed and executed in `notebook.ipynb`.
+
+We have six modules:
+1. procedural.py: basic example without using Hamilton
+2. pipe_output.py: how the above would be implemented using `pipe_output` from Hamilton
+3. mutate.py: how the above would be implemented using `mutate`
+4. pipe_output_on_output.py: functionality that allows to apply `pipe_output` to user selected nodes (comes in handy with `extract_columns`/`extract_fields`)
+5. mutate_on_output.py: same as above but implemented using `mutate`
+6. mutate_twice_the_same: how you would apply the same transformation on a node twice usign `mutate`
+
+that demonstrate the same behavior achieved either without Hamilton, using `pipe_output` or `mutate` and that should give you some idea of potential application
+
+![image info](./DAG.png)
diff --git a/examples/mutate/abstract functionality blueprint/mutate.py b/examples/mutate/abstract functionality blueprint/mutate.py
new file mode 100644
index 000000000..cbf85de83
--- /dev/null
+++ b/examples/mutate/abstract functionality blueprint/mutate.py
@@ -0,0 +1,78 @@
+from typing import Any, List
+
+import pandas as pd
+
+from hamilton.function_modifiers import mutate, source, value
+
+
+def data_1() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict({"col_1": [3, 2, pd.NA, 0], "col_2": ["a", "b", pd.NA, "d"]})
+ return df
+
+
+def data_2() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict(
+ {"col_1": ["a", "b", pd.NA, "d", "e"], "col_2": [150, 155, 145, 200, 5000]}
+ )
+ return df
+
+
+def data_3() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict({"col_1": [150, 155, 145, 200, 5000], "col_2": [10, 23, 32, 50, 0]})
+ return df
+
+
+# data1 and data2
+@mutate(data_1, data_2)
+def _filter(some_data: pd.DataFrame) -> pd.DataFrame:
+ """Remove NAN values.
+
+ Decorated with mutate this will be applied to both data_1 and data_2.
+ """
+ return some_data.dropna()
+
+
+# data 2
+# this is for value
+@mutate(data_2, missing_row=value(["c", 145]))
+def _add_missing_value(some_data: pd.DataFrame, missing_row: List[Any]) -> pd.DataFrame:
+ """Add row to dataframe.
+
+ The functions decorated with mutate can be viewed as steps in pipe_output in the order they
+ are implemented. This means that data_2 had a row removed with NAN and here we add back a row
+ by hand that replaces that row.
+ """
+ some_data.loc[-1] = missing_row
+ return some_data
+
+
+# data 2
+# this is for source
+@mutate(data_2, other_data=source("data_3"))
+def _join(some_data: pd.DataFrame, other_data: pd.DataFrame) -> pd.DataFrame:
+ """Join two dataframes.
+
+ We can use results from other nodes in the DAG by using the `source` functionality. Here we join
+ data_2 table with another table - data_3 - that is the output of another node.
+ """
+ return some_data.set_index("col_2").join(other_data.set_index("col_1"))
+
+
+# data1 and data2
+@mutate(data_1, data_2)
+def _sort(some_data: pd.DataFrame) -> pd.DataFrame:
+ """Sort dataframes by first column.
+
+ This is the last step of our pipeline(s) and gets again applied to data_1 and data_2. We did some
+ light pre-processing on data_1 by removing NANs and sorting and more elaborate pre-processing on
+ data_2 where we added values and joined another table.
+ """
+ columns = some_data.columns
+ return some_data.sort_values(by=columns[0])
+
+
+def feat_A(data_1: pd.DataFrame, data_2: pd.DataFrame) -> pd.DataFrame:
+ """Combining two raw dataframes to create a feature."""
+ return (
+ data_1.set_index("col_2").join(data_2.reset_index(names=["col_3"]).set_index("col_1"))
+ ).reset_index(names=["col_0"])
diff --git a/examples/mutate/abstract functionality blueprint/mutate_on_output.py b/examples/mutate/abstract functionality blueprint/mutate_on_output.py
new file mode 100644
index 000000000..ef0ca0f71
--- /dev/null
+++ b/examples/mutate/abstract functionality blueprint/mutate_on_output.py
@@ -0,0 +1,124 @@
+from typing import Any, Dict, List
+
+import pandas as pd
+
+from hamilton.function_modifiers import (
+ apply_to,
+ extract_columns,
+ extract_fields,
+ mutate,
+ source,
+ value,
+)
+
+
+def data_1() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict({"col_1": [3, 2, pd.NA, 0], "col_2": ["a", "b", pd.NA, "d"]})
+ return df
+
+
+def data_2() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict(
+ {"col_1": ["a", "b", pd.NA, "d", "e"], "col_2": [150, 155, 145, 200, 5000]}
+ )
+ return df
+
+
+def data_3() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict({"col_1": [150, 155, 145, 200, 5000], "col_2": [10, 23, 32, 50, 0]})
+ return df
+
+
+@extract_fields({"field_1": pd.Series, "field_2": pd.Series})
+def feat_A(data_1: pd.DataFrame, data_2: pd.DataFrame) -> Dict[str, pd.Series]:
+ df = (
+ data_1.set_index("col_2").join(data_2.reset_index(names=["col_3"]).set_index("col_1"))
+ ).reset_index(names=["col_0"])
+ return {"field_1": df.iloc[:, 1], "field_2": df.iloc[:, 2]}
+
+
+@extract_columns("col_2", "col_3")
+def feat_B(data_1: pd.DataFrame, data_2: pd.DataFrame) -> pd.DataFrame:
+ return (
+ data_1.set_index("col_2").join(data_2.reset_index(names=["col_3"]).set_index("col_1"))
+ ).reset_index(names=["col_0"])
+
+
+def feat_C(field_1: pd.Series, col_3: pd.Series) -> pd.DataFrame:
+ return pd.concat([field_1, col_3], axis=1)
+
+
+def feat_D(field_2: pd.Series, col_2: pd.Series) -> pd.DataFrame:
+ return pd.concat([field_2, col_2], axis=1)
+
+
+# data1 and data2
+@mutate(apply_to(data_1).when_in(a=[1, 2, 3]), apply_to(data_2).when_not_in(a=[1, 2, 3]))
+def _filter(some_data: pd.DataFrame) -> pd.DataFrame:
+ """Remove NAN values.
+
+ Mutate accepts a `config.*` family conditional where we can choose when the transform will be applied
+ onto the target function.
+ """
+ return some_data.dropna()
+
+
+# data 2
+# this is for value
+@mutate(apply_to(data_2), missing_row=value(["c", 145]))
+def _add_missing_value(some_data: pd.DataFrame, missing_row: List[Any]) -> pd.DataFrame:
+ """Add row to dataframe.
+
+ The functions decorated with mutate can be viewed as steps in pipe_output in the order they
+ are implemented. This means that data_2 had a row removed with NAN and here we add back a row
+ by hand that replaces that row.
+ """
+ some_data.loc[-1] = missing_row
+ return some_data
+
+
+# data 2
+# this is for source
+@mutate(
+ apply_to(data_2).named(name="", namespace="some_random_namespace"), other_data=source("data_3")
+)
+def join(some_data: pd.DataFrame, other_data: pd.DataFrame) -> pd.DataFrame:
+ """Join two dataframes.
+
+ We can use results from other nodes in the DAG by using the `source` functionality. Here we join
+ data_2 table with another table - data_3 - that is the output of another node.
+
+ In addition, mutate also support adding custom names to the nodes.
+ """
+ return some_data.set_index("col_2").join(other_data.set_index("col_1"))
+
+
+# data1 and data2
+@mutate(apply_to(data_1), apply_to(data_2))
+def sort(some_data: pd.DataFrame) -> pd.DataFrame:
+ """Sort dataframes by first column.
+
+ This is the last step of our pipeline(s) and gets again applied to data_1 and data_2. We did some
+ light pre-processing on data_1 by removing NANs and sorting and more elaborate pre-processing on
+ data_2 where we added values and joined another table.
+ """
+ columns = some_data.columns
+ return some_data.sort_values(by=columns[0])
+
+
+# we want to apply some adjustment coefficient to all the columns of feat_B, but only to field_1 of feat_A
+@mutate(
+ apply_to(feat_A, factor=value(100)).on_output("field_1").named("Europe"),
+ apply_to(feat_B, factor=value(10)).named("US"),
+)
+def _adjustment_factor(some_data: pd.Series, factor: float) -> pd.Series:
+ """Adjust the value by some factor.
+
+ You can imagine this step occurring later in time. We first constructed our DAG with features A and
+ B only to realize that something is off. We are now experimenting post-hoc to improve and find the
+ best possible features.
+
+ We first split the features by columns of interest and then adjust them by a regional factor to
+ combine them into improved features we can use further down the pipeline.
+ """
+ return some_data * factor
diff --git a/examples/mutate/abstract functionality blueprint/mutate_twice_the_same.py b/examples/mutate/abstract functionality blueprint/mutate_twice_the_same.py
new file mode 100644
index 000000000..b67fa26a3
--- /dev/null
+++ b/examples/mutate/abstract functionality blueprint/mutate_twice_the_same.py
@@ -0,0 +1,20 @@
+from hamilton.function_modifiers import mutate
+
+
+def data_1() -> int:
+ return 10
+
+
+@mutate(data_1)
+def add_something(user_input: int) -> int:
+ return user_input + 100
+
+
+@mutate(data_1)
+def add_something_more(user_input: int) -> int:
+ return user_input + 1000
+
+
+@mutate(data_1)
+def add_something(user_input: int) -> int: # noqa
+ return user_input + 100
diff --git a/examples/mutate/abstract functionality blueprint/notebook.ipynb b/examples/mutate/abstract functionality blueprint/notebook.ipynb
new file mode 100644
index 000000000..f5d27fca5
--- /dev/null
+++ b/examples/mutate/abstract functionality blueprint/notebook.ipynb
@@ -0,0 +1,2386 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Using `mutate` for pre-processing and feature engineering \n",
+ "\n",
+ "We give some application suggestions for mutating the outputs of functions in a distributed manner with `@mutate.`\n",
+ "When you scroll through the notebook we build examples from most straight forward applications to more complex logic that showcases the amount of flexibility you get with this decorator.\n",
+ "\n",
+ "Mutate gives the ability to apply the same transformation to the each output of multiple functions in the DAG. It can be particularly useful in the following scenarios:\n",
+ "\n",
+ "1. Loading data and applying pre-cleaning step.\n",
+ "2. Feature engineering via joining, filtering, sorting, applying adjustment factors on a per column basis etc.\n",
+ "3. Experimenting with different transformations across nodes by selectively turning transformations on / off.\n",
+ "\n",
+ "\n",
+ "and effectively replaces:\n",
+ "1. Having to have unique names and then changing wiring if you want to add/remove/replace something.\n",
+ "2. Enabling more verb like names on functions.\n",
+ "3. Potentially simpler \"reuse\" of transform functions across DAG paths..."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# from hamilton import registry\n",
+ "# registry.disable_autoload()\n",
+ "\n",
+ "%load_ext hamilton.plugins.jupyter_magic\n",
+ "from hamilton import driver"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Final data 1\n",
+ " col_1 col_2\n",
+ "3 0 d\n",
+ "1 2 b\n",
+ "0 3 a\n",
+ "Final data 2\n",
+ " col_1 col_2\n",
+ "col_2 \n",
+ "150 a 10\n",
+ "155 b 23\n",
+ "145 c 32\n",
+ "200 d 50\n",
+ "5000 e 0\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ " \n",
+ "\n",
+ "cluster__legend \n",
+ " \n",
+ "Legend \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2 \n",
+ " \n",
+ "data_2 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_3 \n",
+ " \n",
+ "data_3 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1 \n",
+ " \n",
+ "data_1 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "function \n",
+ " \n",
+ "function \n",
+ " \n",
+ " \n",
+ " \n"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "%%cell_to_module -m procedural --display\n",
+ "\n",
+ "from typing import Any, List\n",
+ "import pandas as pd\n",
+ "\n",
+ "\n",
+ "def data_1()->pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({'col_1': [3, 2, pd.NA, 0], 'col_2': ['a', 'b', pd.NA, 'd']})\n",
+ " return df\n",
+ "\n",
+ "def data_2()->pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({'col_1': ['a', 'b', pd.NA, 'd', 'e'], 'col_2': [150, 155, 145, 200, 5000]})\n",
+ " return df\n",
+ "\n",
+ "def data_3()->pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({'col_1': [150, 155, 145, 200, 5000], 'col_2': [10,23, 32, 50, 0]})\n",
+ " return df\n",
+ "\n",
+ "# data1 and data2\n",
+ "def _filter(some_data:pd.DataFrame)->pd.DataFrame:\n",
+ " return some_data.dropna()\n",
+ "\n",
+ "# data 2\n",
+ "# this is for value\n",
+ "def _add_missing_value(some_data:pd.DataFrame, missing_row:List[Any])->pd.DataFrame:\n",
+ " some_data.loc[-1] = missing_row\n",
+ " return some_data\n",
+ "\n",
+ "# data 2\n",
+ "# this is for source\n",
+ "def _join(some_data:pd.DataFrame, other_data:pd.DataFrame)->pd.DataFrame:\n",
+ " return some_data.set_index('col_2').join(other_data.set_index('col_1'))\n",
+ "\n",
+ "# data1 and data2\n",
+ "def _sort(some_data:pd.DataFrame)->pd.DataFrame:\n",
+ " columns = some_data.columns\n",
+ " return some_data.sort_values(by=columns[0])\n",
+ "\n",
+ "if __name__ == \"__main__\":\n",
+ " # print(\"Filter data 1\")\n",
+ " # print(_filter(data_1()))\n",
+ " # print(\"Sort data 1\")\n",
+ " print(\"Final data 1\")\n",
+ " print(_sort(_filter(data_1())))\n",
+ " # print(\"Filter data 2\")\n",
+ " # print(_filter(data_2()))\n",
+ " # print(\"Add missing value data 2\")\n",
+ " # print(_add_missing_value(_filter(data_2()),missing_row=['c', 145]))\n",
+ " # print(\"Join data 2 and data 3\")\n",
+ " # print(_join(_add_missing_value(_filter(data_2()),missing_row=['c', 145]),other_data=data_3()))\n",
+ " # print(\"Sort joined dataframe\")\n",
+ " print(\"Final data 2\")\n",
+ " print(_sort(_join(_add_missing_value(_filter(data_2()),missing_row=['c', 145]),other_data=data_3())))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "node_to_execute = [\"data_1\", \"data_2\", \"feat_A\"]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ " \n",
+ "\n",
+ "cluster__legend \n",
+ " \n",
+ "Legend \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_add_missing_value \n",
+ " \n",
+ "data_2.with_add_missing_value \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_join \n",
+ " \n",
+ "data_2.with_join \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_add_missing_value->data_2.with_join \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1_raw \n",
+ " \n",
+ "data_1_raw \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_filter \n",
+ " \n",
+ "data_1.with_filter \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1_raw->data_1.with_filter \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1 \n",
+ " \n",
+ "data_1 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_A \n",
+ " \n",
+ "feat_A \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1->feat_A \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_filter \n",
+ " \n",
+ "data_2.with_filter \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_filter->data_2.with_add_missing_value \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_sort \n",
+ " \n",
+ "data_1.with_sort \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_sort->data_1 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2_raw \n",
+ " \n",
+ "data_2_raw \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2_raw->data_2.with_filter \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_filter->data_1.with_sort \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_sort \n",
+ " \n",
+ "data_2.with_sort \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_join->data_2.with_sort \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2 \n",
+ " \n",
+ "data_2 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_sort->data_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2->feat_A \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_3 \n",
+ " \n",
+ "data_3 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_3->data_2.with_join \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "function \n",
+ " \n",
+ "function \n",
+ " \n",
+ "\n",
+ "\n",
+ "output \n",
+ " \n",
+ "output \n",
+ " \n",
+ " \n",
+ " \n"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_1 \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 0 \n",
+ " d \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 2 \n",
+ " b \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 3 \n",
+ " a \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_1 col_2\n",
+ "3 0 d\n",
+ "1 2 b\n",
+ "0 3 a"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_1 \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 150 \n",
+ " a \n",
+ " 10 \n",
+ " \n",
+ " \n",
+ " 155 \n",
+ " b \n",
+ " 23 \n",
+ " \n",
+ " \n",
+ " 145 \n",
+ " c \n",
+ " 32 \n",
+ " \n",
+ " \n",
+ " 200 \n",
+ " d \n",
+ " 50 \n",
+ " \n",
+ " \n",
+ " 5000 \n",
+ " e \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_1 col_2\n",
+ "col_2 \n",
+ "150 a 10\n",
+ "155 b 23\n",
+ "145 c 32\n",
+ "200 d 50\n",
+ "5000 e 0"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_0 \n",
+ " col_1 \n",
+ " col_3 \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " d \n",
+ " 0 \n",
+ " 200 \n",
+ " 50 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " b \n",
+ " 2 \n",
+ " 155 \n",
+ " 23 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " a \n",
+ " 3 \n",
+ " 150 \n",
+ " 10 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_0 col_1 col_3 col_2\n",
+ "0 d 0 200 50\n",
+ "1 b 2 155 23\n",
+ "2 a 3 150 10"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "%%cell_to_module -m pipe_output --display --execute node_to_execute\n",
+ "\n",
+ "from typing import Any, List\n",
+ "import pandas as pd\n",
+ "from hamilton.function_modifiers import pipe_output, step, source, value\n",
+ "\n",
+ "# data1 and data2\n",
+ "def _filter(some_data:pd.DataFrame)->pd.DataFrame:\n",
+ " return some_data.dropna()\n",
+ "\n",
+ "# data 2\n",
+ "# this is for value\n",
+ "def _add_missing_value(some_data:pd.DataFrame, missing_row:List[Any])->pd.DataFrame:\n",
+ " some_data.loc[-1] = missing_row\n",
+ " return some_data\n",
+ "\n",
+ "# data 2\n",
+ "# this is for source\n",
+ "def _join(some_data:pd.DataFrame, other_data:pd.DataFrame)->pd.DataFrame:\n",
+ " return some_data.set_index('col_2').join(other_data.set_index('col_1'))\n",
+ "\n",
+ "# data1 and data2\n",
+ "def _sort(some_data:pd.DataFrame)->pd.DataFrame:\n",
+ " columns = some_data.columns\n",
+ " return some_data.sort_values(by=columns[0])\n",
+ "\n",
+ "@pipe_output(\n",
+ " step(_filter),\n",
+ " step(_sort),\n",
+ ")\n",
+ "def data_1()->pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({'col_1': [3, 2, pd.NA, 0], 'col_2': ['a', 'b', pd.NA, 'd']})\n",
+ " return df\n",
+ "\n",
+ "@pipe_output(\n",
+ " step(_filter),\n",
+ " step(_add_missing_value,missing_row=value(['c', 145])),\n",
+ " step(_join, other_data=source('data_3')),\n",
+ " step(_sort),\n",
+ ")\n",
+ "def data_2()->pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({'col_1': ['a', 'b', pd.NA, 'd', 'e'], 'col_2': [150, 155, 145, 200, 5000]})\n",
+ " return df\n",
+ "\n",
+ "def data_3()->pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({'col_1': [150, 155, 145, 200, 5000], 'col_2': [10,23, 32, 50, 0]})\n",
+ " return df\n",
+ "\n",
+ "def feat_A(data_1:pd.DataFrame, data_2:pd.DataFrame)->pd.DataFrame:\n",
+ " return (data_1.set_index('col_2').join(data_2.reset_index(names=['col_3']).set_index('col_1'))).reset_index(names=[\"col_0\"])\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ " \n",
+ "\n",
+ "cluster__legend \n",
+ " \n",
+ "Legend \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_add_missing_value \n",
+ " \n",
+ "data_2.with_add_missing_value \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_join \n",
+ " \n",
+ "data_2.with_join \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_add_missing_value->data_2.with_join \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1_raw \n",
+ " \n",
+ "data_1_raw \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_filter \n",
+ " \n",
+ "data_1.with_filter \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1_raw->data_1.with_filter \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1 \n",
+ " \n",
+ "data_1 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_A \n",
+ " \n",
+ "feat_A \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1->feat_A \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_filter \n",
+ " \n",
+ "data_2.with_filter \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_filter->data_2.with_add_missing_value \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_sort \n",
+ " \n",
+ "data_1.with_sort \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_sort->data_1 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2_raw \n",
+ " \n",
+ "data_2_raw \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2_raw->data_2.with_filter \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_filter->data_1.with_sort \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_sort \n",
+ " \n",
+ "data_2.with_sort \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_join->data_2.with_sort \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2 \n",
+ " \n",
+ "data_2 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_sort->data_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2->feat_A \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_3 \n",
+ " \n",
+ "data_3 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_3->data_2.with_join \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "function \n",
+ " \n",
+ "function \n",
+ " \n",
+ "\n",
+ "\n",
+ "output \n",
+ " \n",
+ "output \n",
+ " \n",
+ " \n",
+ " \n"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_1 \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 0 \n",
+ " d \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 2 \n",
+ " b \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 3 \n",
+ " a \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_1 col_2\n",
+ "3 0 d\n",
+ "1 2 b\n",
+ "0 3 a"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_1 \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 150 \n",
+ " a \n",
+ " 10 \n",
+ " \n",
+ " \n",
+ " 155 \n",
+ " b \n",
+ " 23 \n",
+ " \n",
+ " \n",
+ " 145 \n",
+ " c \n",
+ " 32 \n",
+ " \n",
+ " \n",
+ " 200 \n",
+ " d \n",
+ " 50 \n",
+ " \n",
+ " \n",
+ " 5000 \n",
+ " e \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_1 col_2\n",
+ "col_2 \n",
+ "150 a 10\n",
+ "155 b 23\n",
+ "145 c 32\n",
+ "200 d 50\n",
+ "5000 e 0"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_0 \n",
+ " col_1 \n",
+ " col_3 \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " d \n",
+ " 0 \n",
+ " 200 \n",
+ " 50 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " b \n",
+ " 2 \n",
+ " 155 \n",
+ " 23 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " a \n",
+ " 3 \n",
+ " 150 \n",
+ " 10 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_0 col_1 col_3 col_2\n",
+ "0 d 0 200 50\n",
+ "1 b 2 155 23\n",
+ "2 a 3 150 10"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "%%cell_to_module -m mutate --display --execute node_to_execute\n",
+ "\n",
+ "from typing import Any, List\n",
+ "import pandas as pd\n",
+ "from hamilton.function_modifiers import mutate, apply_to, source, value\n",
+ "\n",
+ "def data_1()->pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({'col_1': [3, 2, pd.NA, 0], 'col_2': ['a', 'b', pd.NA, 'd']})\n",
+ " return df\n",
+ "\n",
+ "def data_2()->pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({'col_1': ['a', 'b', pd.NA, 'd', 'e'], 'col_2': [150, 155, 145, 200, 5000]})\n",
+ " return df\n",
+ "\n",
+ "def data_3()->pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({'col_1': [150, 155, 145, 200, 5000], 'col_2': [10,23, 32, 50, 0]})\n",
+ " return df\n",
+ "\n",
+ "# data1 and data2\n",
+ "@mutate(\n",
+ " data_1, data_2\n",
+ " )\n",
+ "def _filter(some_data:pd.DataFrame)->pd.DataFrame:\n",
+ " \"\"\"Remove NAN values.\n",
+ " \n",
+ " Decorated with mutate this will be applied to both data_1 and data_2.\n",
+ " \"\"\"\n",
+ " return some_data.dropna()\n",
+ "\n",
+ "# data 2\n",
+ "# this is for value\n",
+ "@mutate(\n",
+ " data_2, \n",
+ " missing_row=value(['c', 145])\n",
+ " )\n",
+ "def _add_missing_value(some_data:pd.DataFrame, missing_row:List[Any])->pd.DataFrame:\n",
+ " \"\"\"Add row to dataframe.\n",
+ " \n",
+ " The functions decorated with mutate can be viewed as steps in pipe_output in the order they\n",
+ " are implemented. This means that data_2 had a row removed with NAN and here we add back a row\n",
+ " by hand that replaces that row.\n",
+ " \"\"\"\n",
+ " some_data.loc[-1] = missing_row\n",
+ " return some_data\n",
+ "\n",
+ "# data 2\n",
+ "# this is for source\n",
+ "@mutate(\n",
+ " data_2, \n",
+ " other_data=source('data_3')\n",
+ " )\n",
+ "def _join(some_data:pd.DataFrame, other_data:pd.DataFrame)->pd.DataFrame:\n",
+ " \"\"\"Join two dataframes.\n",
+ " \n",
+ " We can use results from other nodes in the DAG by using the `source` functionality. Here we join\n",
+ " data_2 table with another table - data_3 - that is the output of another node.\n",
+ " \"\"\"\n",
+ " return some_data.set_index('col_2').join(other_data.set_index('col_1'))\n",
+ "\n",
+ "# data1 and data2\n",
+ "@mutate(\n",
+ " data_1, data_2\n",
+ ")\n",
+ "def _sort(some_data:pd.DataFrame)->pd.DataFrame:\n",
+ " \"\"\"Sort dataframes by first column.\n",
+ " \n",
+ " This is the last step of our pipeline(s) and gets again applied to data_1 and data_2. We did some \n",
+ " light pre-processing on data_1 by removing NANs and sorting and more elaborate pre-processing on\n",
+ " data_2 where we added values and joined another table.\n",
+ " \"\"\"\n",
+ " columns = some_data.columns\n",
+ " return some_data.sort_values(by=columns[0])\n",
+ "\n",
+ "def feat_A(data_1:pd.DataFrame, data_2:pd.DataFrame)->pd.DataFrame:\n",
+ " \"\"\"Combining two raw dataframes to create a feature.\"\"\"\n",
+ " return (data_1.set_index('col_2').join(data_2.reset_index(names=['col_3']).set_index('col_1'))).reset_index(names=[\"col_0\"])\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# We can run such pipelines also remotely and can track them in the UI"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from hamilton import base\n",
+ "from hamilton_sdk import adapters\n",
+ "from hamilton.plugins.h_ray import RayGraphAdapter,RayTaskExecutor\n",
+ "import ray\n",
+ "\n",
+ "import mutate\n",
+ "\n",
+ "remote_executor = RayTaskExecutor(num_cpus=4)\n",
+ "shutdown = ray.shutdown\n",
+ "\n",
+ "project_id = 3\n",
+ "username = \"jf\""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "tracker_ray = adapters.HamiltonTracker(\n",
+ " project_id=project_id,\n",
+ " username=username,\n",
+ " dag_name=\"mutate ray graph adapter\",\n",
+ " )\n",
+ "ray.init()\n",
+ "rga = RayGraphAdapter(result_builder=base.DictResult(), shutdown_ray_on_completion=True)\n",
+ "dr = driver.Builder().with_modules(mutate).with_adapters(rga, tracker_ray).build()\n",
+ "result = dr.execute(final_vars=[\"data_1\", \"data_2\", \"feat_A\"])\n",
+ "print(result)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "tracker_ray = adapters.HamiltonTracker(\n",
+ " project_id=project_id,\n",
+ " username=username,\n",
+ " dag_name=\"mutate ray task executor\",\n",
+ " )\n",
+ "\n",
+ "dr = (\n",
+ " driver.Builder()\n",
+ " .enable_dynamic_execution(allow_experimental_mode=True)\n",
+ " .with_modules(mutate)\n",
+ " .with_remote_executor(remote_executor)\n",
+ " .with_adapters(tracker_ray)\n",
+ " .build()\n",
+ " )\n",
+ "\n",
+ "print(dr.execute(final_vars=[\"data_1\", \"data_2\", \"feat_A\"]))\n",
+ "if shutdown is not None:\n",
+ " shutdown()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# pipe_output allows for targeting specific nodes\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ " \n",
+ "\n",
+ "cluster__legend \n",
+ " \n",
+ "Legend \n",
+ " \n",
+ "\n",
+ "\n",
+ "foo \n",
+ " \n",
+ "foo \n",
+ "Dict \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1_raw \n",
+ " \n",
+ "field_1_raw \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "foo->field_1_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2_raw \n",
+ " \n",
+ "field_2_raw \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "foo->field_2_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_3_raw \n",
+ " \n",
+ "field_3_raw \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "foo->field_3_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1 \n",
+ " \n",
+ "field_1 \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "a \n",
+ " \n",
+ "a \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "a->foo \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2 \n",
+ " \n",
+ "field_2 \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_3.with_something_else \n",
+ " \n",
+ "field_3.with_something_else \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_3.transform_2 \n",
+ " \n",
+ "field_3.transform_2 \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_3.with_something_else->field_3.transform_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_3 \n",
+ " \n",
+ "field_3 \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1.with_something_else \n",
+ " \n",
+ "field_1.with_something_else \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1_raw->field_1.with_something_else \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_3.transform_2->field_3 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1.transform_1 \n",
+ " \n",
+ "field_1.transform_1 \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1.transform_2 \n",
+ " \n",
+ "field_1.transform_2 \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1.transform_1->field_1.transform_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2.with_something_else \n",
+ " \n",
+ "field_2.with_something_else \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2.with_something_else->field_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1.transform_2->field_1 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2_raw->field_2.with_something_else \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1.with_something_else->field_1.transform_1 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_3_raw->field_3.with_something_else \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "function \n",
+ " \n",
+ "function \n",
+ " \n",
+ " \n",
+ " \n"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "%%cell_to_module -m pipe_output_on_output --display\n",
+ "from typing import Dict\n",
+ "from hamilton.function_modifiers import extract_fields, pipe_input, pipe_output, step\n",
+ "\n",
+ "def _pre_step(something:int)->int:\n",
+ " return something + 10\n",
+ "\n",
+ "def _post_step(something:int)->int:\n",
+ " return something + 100\n",
+ "\n",
+ "def _something_else(something:int)->int:\n",
+ " return something + 1000\n",
+ "\n",
+ "def a()->int:\n",
+ " return 10\n",
+ "\n",
+ "@pipe_output(\n",
+ " step(_something_else), # gets applied to all sink nodes\n",
+ " step(_pre_step).named(name=\"transform_1\").on_output(\"field_1\"), # only applied to field_1\n",
+ " step(_post_step).named(name=\"transform_2\").on_output([\"field_1\", \"field_3\"]), # applied to field_1 and field_3\n",
+ ")\n",
+ "@extract_fields(\n",
+ " {\"field_1\":int, \"field_2\":int, \"field_3\":int}\n",
+ ")\n",
+ "def foo(a:int)->Dict[str,int]:\n",
+ " return {\"field_1\":1, \"field_2\":2, \"field_3\":3}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Similarly mutate allows to specify which nodes it gets applied to\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "node_to_execute = [\"data_1\", \"data_2\", \"feat_C\", \"feat_D\"]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ " \n",
+ "\n",
+ "cluster__legend \n",
+ " \n",
+ "Legend \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2. \n",
+ " \n",
+ "data_2. \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_sort \n",
+ " \n",
+ "data_2.with_sort \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.->data_2.with_sort \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_add_missing_value \n",
+ " \n",
+ "data_2.with_add_missing_value \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_add_missing_value->data_2. \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2 \n",
+ " \n",
+ "field_2 \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_D \n",
+ " \n",
+ "feat_D \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2->feat_D \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1_raw \n",
+ " \n",
+ "data_1_raw \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_sort \n",
+ " \n",
+ "data_1.with_sort \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1_raw->data_1.with_sort \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1_raw \n",
+ " \n",
+ "field_1_raw \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1.Europe \n",
+ " \n",
+ "field_1.Europe \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1_raw->field_1.Europe \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1 \n",
+ " \n",
+ "data_1 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_sort->data_1 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2_raw \n",
+ " \n",
+ "data_2_raw \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_filter \n",
+ " \n",
+ "data_2.with_filter \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2_raw->data_2.with_filter \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2.Europe \n",
+ " \n",
+ "field_2.Europe \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2.Europe->field_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_B \n",
+ " \n",
+ "feat_B \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_3_raw \n",
+ " \n",
+ "col_3_raw \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_B->col_3_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_2_raw \n",
+ " \n",
+ "col_2_raw \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_B->col_2_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_A \n",
+ " \n",
+ "feat_A \n",
+ "Dict \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_A->field_1_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2_raw \n",
+ " \n",
+ "field_2_raw \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_A->field_2_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2 \n",
+ " \n",
+ "data_2 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_sort->data_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "feat_C \n",
+ " \n",
+ "feat_C \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1 \n",
+ " \n",
+ "field_1 \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1.Europe->field_1 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_1->feat_C \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_2 \n",
+ " \n",
+ "col_2 \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_2->feat_D \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_3 \n",
+ " \n",
+ "col_3 \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_3->feat_C \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1->feat_B \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1->feat_A \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_2.US \n",
+ " \n",
+ "col_2.US \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_2.US->col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2.with_filter->data_2.with_add_missing_value \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_3.US \n",
+ " \n",
+ "col_3.US \n",
+ "Series \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_3.US->col_3 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_3_raw->col_3.US \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2->feat_B \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_2->feat_A \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_3 \n",
+ " \n",
+ "data_3 \n",
+ "DataFrame \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_3->data_2. \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "field_2_raw->field_2.Europe \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "col_2_raw->col_2.US \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "function \n",
+ " \n",
+ "function \n",
+ " \n",
+ "\n",
+ "\n",
+ "output \n",
+ " \n",
+ "output \n",
+ " \n",
+ " \n",
+ " \n"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_1 \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 0 \n",
+ " d \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 2 \n",
+ " b \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 3 \n",
+ " a \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " <NA> \n",
+ " <NA> \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_1 col_2\n",
+ "3 0 d\n",
+ "1 2 b\n",
+ "0 3 a\n",
+ "2 "
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_1 \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 150 \n",
+ " a \n",
+ " 10 \n",
+ " \n",
+ " \n",
+ " 155 \n",
+ " b \n",
+ " 23 \n",
+ " \n",
+ " \n",
+ " 145 \n",
+ " c \n",
+ " 32 \n",
+ " \n",
+ " \n",
+ " 200 \n",
+ " d \n",
+ " 50 \n",
+ " \n",
+ " \n",
+ " 5000 \n",
+ " e \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_1 col_2\n",
+ "col_2 \n",
+ "150 a 10\n",
+ "155 b 23\n",
+ "145 c 32\n",
+ "200 d 50\n",
+ "5000 e 0"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_1 \n",
+ " col_3 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 0 \n",
+ " 2000.0 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 200 \n",
+ " 1550.0 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 300 \n",
+ " 1500.0 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " <NA> \n",
+ " NaN \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_1 col_3\n",
+ "0 0 2000.0\n",
+ "1 200 1550.0\n",
+ "2 300 1500.0\n",
+ "3 NaN"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " col_3 \n",
+ " col_2 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 20000.0 \n",
+ " 500.0 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 15500.0 \n",
+ " 230.0 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 15000.0 \n",
+ " 100.0 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " NaN \n",
+ " NaN \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " col_3 col_2\n",
+ "0 20000.0 500.0\n",
+ "1 15500.0 230.0\n",
+ "2 15000.0 100.0\n",
+ "3 NaN NaN"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "%%cell_to_module -m mutate_on_output --display --execute node_to_execute\n",
+ "from typing import Any,Dict, List\n",
+ "import pandas as pd\n",
+ "from hamilton.function_modifiers import extract_fields, extract_columns, apply_to, mutate, value, source\n",
+ "\n",
+ "\n",
+ "def data_1() -> pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({\"col_1\": [3, 2, pd.NA, 0], \"col_2\": [\"a\", \"b\", pd.NA, \"d\"]})\n",
+ " return df\n",
+ "\n",
+ "\n",
+ "def data_2() -> pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict(\n",
+ " {\"col_1\": [\"a\", \"b\", pd.NA, \"d\", \"e\"], \"col_2\": [150, 155, 145, 200, 5000]}\n",
+ " )\n",
+ " return df\n",
+ "\n",
+ "\n",
+ "def data_3() -> pd.DataFrame:\n",
+ " df = pd.DataFrame.from_dict({\"col_1\": [150, 155, 145, 200, 5000], \"col_2\": [10, 23, 32, 50, 0]})\n",
+ " return df\n",
+ "\n",
+ "\n",
+ "@extract_fields(\n",
+ " {'field_1':pd.Series,\n",
+ " 'field_2':pd.Series})\n",
+ "def feat_A(data_1:pd.DataFrame, data_2:pd.DataFrame)->Dict[str, pd.Series]:\n",
+ " df = (data_1.set_index('col_2').join(data_2.reset_index(names=['col_3']).set_index('col_1'))).reset_index(names=[\"col_0\"])\n",
+ " return {\n",
+ " \"field_1\": df.iloc[:, 1],\n",
+ " \"field_2\": df.iloc[:, 2]\n",
+ " }\n",
+ "\n",
+ "\n",
+ "@extract_columns('col_2','col_3')\n",
+ "def feat_B(data_1:pd.DataFrame, data_2:pd.DataFrame)->pd.DataFrame:\n",
+ " return (data_1.set_index('col_2').join(data_2.reset_index(names=['col_3']).set_index('col_1'))).reset_index(names=[\"col_0\"])\n",
+ "\n",
+ "\n",
+ "def feat_C(field_1:pd.Series, col_3:pd.Series)->pd.DataFrame:\n",
+ " return pd.concat([field_1, col_3], axis=1)\n",
+ "\n",
+ "def feat_D(field_2:pd.Series, col_2:pd.Series)->pd.DataFrame:\n",
+ " return pd.concat([field_2, col_2], axis=1)\n",
+ "\n",
+ "# data1 and data2\n",
+ "@mutate(\n",
+ " apply_to(data_1).when_in(a=[1,2,3]),\n",
+ " apply_to(data_2).when_not_in(a=[1,2,3])\n",
+ ")\n",
+ "def _filter(some_data:pd.DataFrame)->pd.DataFrame:\n",
+ " \"\"\"Remove NAN values.\n",
+ " \n",
+ " Mutate accepts a `config.*` family conditional where we can choose when the transform will be applied\n",
+ " onto the target function.\n",
+ " \"\"\"\n",
+ " return some_data.dropna()\n",
+ "\n",
+ "# data 2\n",
+ "# this is for value\n",
+ "@mutate(apply_to(data_2), missing_row=value(['c', 145]))\n",
+ "def _add_missing_value(some_data:pd.DataFrame, missing_row:List[Any])->pd.DataFrame:\n",
+ " \"\"\"Add row to dataframe.\n",
+ " \n",
+ " The functions decorated with mutate can be viewed as steps in pipe_output in the order they\n",
+ " are implemented. This means that data_2 had a row removed with NAN and here we add back a row\n",
+ " by hand that replaces that row.\n",
+ " \"\"\"\n",
+ " some_data.loc[-1] = missing_row\n",
+ " return some_data\n",
+ "\n",
+ "# data 2\n",
+ "# this is for source\n",
+ "@mutate(apply_to(data_2).named(name=\"\",namespace=\"some_random_namespace\"), other_data=source('data_3'))\n",
+ "def join(some_data:pd.DataFrame, other_data:pd.DataFrame)->pd.DataFrame:\n",
+ " \"\"\"Join two dataframes.\n",
+ " \n",
+ " We can use results from other nodes in the DAG by using the `source` functionality. Here we join\n",
+ " data_2 table with another table - data_3 - that is the output of another node.\n",
+ "\n",
+ " In addition, mutate also support adding custom names to the nodes.\n",
+ " \"\"\"\n",
+ " return some_data.set_index('col_2').join(other_data.set_index('col_1'))\n",
+ "\n",
+ "# data1 and data2\n",
+ "@mutate(\n",
+ " apply_to(data_1),\n",
+ " apply_to(data_2)\n",
+ ")\n",
+ "def sort(some_data:pd.DataFrame)->pd.DataFrame:\n",
+ " \"\"\"Sort dataframes by first column.\n",
+ " \n",
+ " This is the last step of our pipeline(s) and gets again applied to data_1 and data_2. We did some \n",
+ " light pre-processing on data_1 by removing NANs and sorting and more elaborate pre-processing on\n",
+ " data_2 where we added values and joined another table.\n",
+ " \"\"\"\n",
+ " columns = some_data.columns\n",
+ " return some_data.sort_values(by=columns[0])\n",
+ "\n",
+ "\n",
+ "# we want to apply some adjustment coefficient to all the columns of feat_B, but only to field_1 of feat_A\n",
+ "@mutate(\n",
+ " apply_to(feat_A, factor=value(100)).on_output('field_1').named(\"Europe\"),\n",
+ " apply_to(feat_B, factor=value(10)).named(\"US\")\n",
+ ")\n",
+ "def _adjustment_factor(some_data:pd.Series, factor:float)->pd.Series:\n",
+ " \"\"\"Adjust the value by some factor.\n",
+ " \n",
+ " You can imagine this step occurring later in time. We first constructed our DAG with features A and\n",
+ " B only to realize that something is off. We are now experimenting post-hoc to improve and find the \n",
+ " best possible features.\n",
+ "\n",
+ " We first split the features by columns of interest and then adjust them by a regional factor to \n",
+ " combine them into improved features we can use further down the pipeline.\n",
+ " \"\"\"\n",
+ " return some_data * factor\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Apply mutate twice by copying the function"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "node_to_execute = [\"data_1\"]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 16,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ " \n",
+ "\n",
+ "cluster__legend \n",
+ " \n",
+ "Legend \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1_raw \n",
+ " \n",
+ "data_1_raw \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_add_something \n",
+ " \n",
+ "data_1.with_add_something \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1_raw->data_1.with_add_something \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_add_something_more \n",
+ " \n",
+ "data_1.with_add_something_more \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_add_something_1 \n",
+ " \n",
+ "data_1.with_add_something_1 \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_add_something_more->data_1.with_add_something_1 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_add_something->data_1.with_add_something_more \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1 \n",
+ " \n",
+ "data_1 \n",
+ "int \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_1.with_add_something_1->data_1 \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "function \n",
+ " \n",
+ "function \n",
+ " \n",
+ "\n",
+ "\n",
+ "output \n",
+ " \n",
+ "output \n",
+ " \n",
+ " \n",
+ " \n"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/plain": [
+ "1210"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "%%cell_to_module -m mutate_twice_the_same --display --execute node_to_execute\n",
+ "\n",
+ "from typing import Any, List\n",
+ "import pandas as pd\n",
+ "from hamilton.function_modifiers import mutate, apply_to, source, value\n",
+ "\n",
+ "\n",
+ "def data_1()->int:\n",
+ " return 10\n",
+ "\n",
+ "\n",
+ "@mutate(data_1)\n",
+ "def add_something(user_input:int)->int:\n",
+ " return user_input + 100\n",
+ "\n",
+ "@mutate(data_1)\n",
+ "def add_something_more(user_input:int)->int:\n",
+ " return user_input + 1000\n",
+ "\n",
+ "@mutate(data_1)\n",
+ "def add_something(user_input:int)->int: # noqa\n",
+ " return user_input + 100\n",
+ "\n",
+ "\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "ham_scikit",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.14"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/examples/mutate/abstract functionality blueprint/pipe_output.py b/examples/mutate/abstract functionality blueprint/pipe_output.py
new file mode 100644
index 000000000..b5cec9c22
--- /dev/null
+++ b/examples/mutate/abstract functionality blueprint/pipe_output.py
@@ -0,0 +1,62 @@
+from typing import Any, List
+
+import pandas as pd
+
+from hamilton.function_modifiers import pipe_output, source, step, value
+
+
+# data1 and data2
+def _filter(some_data: pd.DataFrame) -> pd.DataFrame:
+ return some_data.dropna()
+
+
+# data 2
+# this is for value
+def _add_missing_value(some_data: pd.DataFrame, missing_row: List[Any]) -> pd.DataFrame:
+ some_data.loc[-1] = missing_row
+ return some_data
+
+
+# data 2
+# this is for source
+def _join(some_data: pd.DataFrame, other_data: pd.DataFrame) -> pd.DataFrame:
+ return some_data.set_index("col_2").join(other_data.set_index("col_1"))
+
+
+# data1 and data2
+def _sort(some_data: pd.DataFrame) -> pd.DataFrame:
+ columns = some_data.columns
+ return some_data.sort_values(by=columns[0])
+
+
+@pipe_output(
+ step(_filter),
+ step(_sort),
+)
+def data_1() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict({"col_1": [3, 2, pd.NA, 0], "col_2": ["a", "b", pd.NA, "d"]})
+ return df
+
+
+@pipe_output(
+ step(_filter),
+ step(_add_missing_value, missing_row=value(["c", 145])),
+ step(_join, other_data=source("data_3")),
+ step(_sort),
+)
+def data_2() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict(
+ {"col_1": ["a", "b", pd.NA, "d", "e"], "col_2": [150, 155, 145, 200, 5000]}
+ )
+ return df
+
+
+def data_3() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict({"col_1": [150, 155, 145, 200, 5000], "col_2": [10, 23, 32, 50, 0]})
+ return df
+
+
+def feat_A(data_1: pd.DataFrame, data_2: pd.DataFrame) -> pd.DataFrame:
+ return (
+ data_1.set_index("col_2").join(data_2.reset_index(names=["col_3"]).set_index("col_1"))
+ ).reset_index(names=["col_0"])
diff --git a/examples/mutate/abstract functionality blueprint/pipe_output_on_output.py b/examples/mutate/abstract functionality blueprint/pipe_output_on_output.py
new file mode 100644
index 000000000..198836593
--- /dev/null
+++ b/examples/mutate/abstract functionality blueprint/pipe_output_on_output.py
@@ -0,0 +1,31 @@
+from typing import Dict
+
+from hamilton.function_modifiers import extract_fields, pipe_output, step
+
+
+def _pre_step(something: int) -> int:
+ return something + 10
+
+
+def _post_step(something: int) -> int:
+ return something + 100
+
+
+def _something_else(something: int) -> int:
+ return something + 1000
+
+
+def a() -> int:
+ return 10
+
+
+@pipe_output(
+ step(_something_else), # gets applied to all sink nodes
+ step(_pre_step).named(name="transform_1").on_output("field_1"), # only applied to field_1
+ step(_post_step)
+ .named(name="transform_2")
+ .on_output(["field_1", "field_3"]), # applied to field_1 and field_3
+)
+@extract_fields({"field_1": int, "field_2": int, "field_3": int})
+def foo(a: int) -> Dict[str, int]:
+ return {"field_1": 1, "field_2": 2, "field_3": 3}
diff --git a/examples/mutate/abstract functionality blueprint/procedural.py b/examples/mutate/abstract functionality blueprint/procedural.py
new file mode 100644
index 000000000..f95d5a0a0
--- /dev/null
+++ b/examples/mutate/abstract functionality blueprint/procedural.py
@@ -0,0 +1,67 @@
+from typing import Any, List
+
+import pandas as pd
+
+
+def data_1() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict({"col_1": [3, 2, pd.NA, 0], "col_2": ["a", "b", pd.NA, "d"]})
+ return df
+
+
+def data_2() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict(
+ {"col_1": ["a", "b", pd.NA, "d", "e"], "col_2": [150, 155, 145, 200, 5000]}
+ )
+ return df
+
+
+def data_3() -> pd.DataFrame:
+ df = pd.DataFrame.from_dict({"col_1": [150, 155, 145, 200, 5000], "col_2": [10, 23, 32, 50, 0]})
+ return df
+
+
+# data1 and data2
+def _filter(some_data: pd.DataFrame) -> pd.DataFrame:
+ return some_data.dropna()
+
+
+# data 2
+# this is for value
+def _add_missing_value(some_data: pd.DataFrame, missing_row: List[Any]) -> pd.DataFrame:
+ some_data.loc[-1] = missing_row
+ return some_data
+
+
+# data 2
+# this is for source
+def _join(some_data: pd.DataFrame, other_data: pd.DataFrame) -> pd.DataFrame:
+ return some_data.set_index("col_2").join(other_data.set_index("col_1"))
+
+
+# data1 and data2
+def _sort(some_data: pd.DataFrame) -> pd.DataFrame:
+ columns = some_data.columns
+ return some_data.sort_values(by=columns[0])
+
+
+if __name__ == "__main__":
+ # print("Filter data 1")
+ # print(_filter(data_1()))
+ # print("Sort data 1")
+ print("Final data 1")
+ print(_sort(_filter(data_1())))
+ # print("Filter data 2")
+ # print(_filter(data_2()))
+ # print("Add missing value data 2")
+ # print(_add_missing_value(_filter(data_2()),missing_row=['c', 145]))
+ # print("Join data 2 and data 3")
+ # print(_join(_add_missing_value(_filter(data_2()),missing_row=['c', 145]),other_data=data_3()))
+ # print("Sort joined dataframe")
+ print("Final data 2")
+ print(
+ _sort(
+ _join(
+ _add_missing_value(_filter(data_2()), missing_row=["c", 145]), other_data=data_3()
+ )
+ )
+ )
diff --git a/examples/scikit-learn/species_distribution_modeling/hamilton_notebook.ipynb b/examples/scikit-learn/species_distribution_modeling/hamilton_notebook.ipynb
index 1694f2f82..a627be54c 100644
--- a/examples/scikit-learn/species_distribution_modeling/hamilton_notebook.ipynb
+++ b/examples/scikit-learn/species_distribution_modeling/hamilton_notebook.ipynb
@@ -11,7 +11,7 @@
},
{
"cell_type": "code",
- "execution_count": 3,
+ "execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
@@ -938,6 +938,926 @@
"plt.show()\n"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# New feature we can use `@mutate` for distributed function output transforms"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ " \n",
+ "\n",
+ "cluster__legend \n",
+ " \n",
+ "Legend \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test.with_decision_function \n",
+ " \n",
+ "prediction_test.with_decision_function \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test \n",
+ " \n",
+ "prediction_test \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test.with_decision_function->prediction_test \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_prediction_step \n",
+ " \n",
+ "prediction_train.with_prediction_step \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train \n",
+ " \n",
+ "prediction_train \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_prediction_step->prediction_train \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_OneClassSVM_model \n",
+ " \n",
+ "prediction_train.with_OneClassSVM_model \n",
+ "OneClassSVM \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_decision_function \n",
+ " \n",
+ "prediction_train.with_decision_function \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_OneClassSVM_model->prediction_train.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test.with_OneClassSVM_model \n",
+ " \n",
+ "prediction_test.with_OneClassSVM_model \n",
+ "OneClassSVM \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test.with_OneClassSVM_model->prediction_test.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test_raw \n",
+ " \n",
+ "prediction_test_raw \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test_raw->prediction_test.with_OneClassSVM_model \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train_raw \n",
+ " \n",
+ "prediction_train_raw \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train_raw->prediction_train.with_OneClassSVM_model \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_decision_function->prediction_train.with_prediction_step \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_test.with_decision_function_inputs \n",
+ " \n",
+ "std \n",
+ "ndarray \n",
+ "mean \n",
+ "ndarray \n",
+ "test_cover_std \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_test.with_decision_function_inputs->prediction_test.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_train.with_prediction_step_inputs \n",
+ " \n",
+ "data \n",
+ "Bunch \n",
+ "idx \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_train.with_prediction_step_inputs->prediction_train.with_prediction_step \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_test_raw_inputs \n",
+ " \n",
+ "train_cover_std \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_test_raw_inputs->prediction_test_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_train_raw_inputs \n",
+ " \n",
+ "train_cover_std \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_train_raw_inputs->prediction_train_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_train.with_decision_function_inputs \n",
+ " \n",
+ "coverages_land \n",
+ "ndarray \n",
+ "std \n",
+ "ndarray \n",
+ "mean \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "_prediction_train.with_decision_function_inputs->prediction_train.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "input \n",
+ " \n",
+ "input \n",
+ " \n",
+ "\n",
+ "\n",
+ "function \n",
+ " \n",
+ "function \n",
+ " \n",
+ " \n",
+ " \n"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "%%cell_to_module train_and_predict_using_mutate\n",
+ "import numpy as np\n",
+ "import numpy.typing as npt\n",
+ "from sklearn import svm\n",
+ "from sklearn.utils._bunch import Bunch\n",
+ "\n",
+ "from hamilton.function_modifiers import mutate, apply_to, source, value\n",
+ "\n",
+ "\n",
+ "def prediction_train(train_cover_std: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:\n",
+ " return train_cover_std\n",
+ "\n",
+ "\n",
+ "def prediction_test(train_cover_std: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:\n",
+ " return train_cover_std\n",
+ "\n",
+ "\n",
+ "@mutate(\n",
+ " prediction_train, \n",
+ " prediction_test, \n",
+ " nu=value(0.1), kernel=value(\"rbf\"), gamma=value(0.5)\n",
+ ")\n",
+ "def _OneClassSVM_model(\n",
+ " training_set: npt.NDArray[np.float64], nu: float, kernel: str, gamma: float\n",
+ ") -> svm.OneClassSVM:\n",
+ " clf = svm.OneClassSVM(nu=nu, kernel=kernel, gamma=gamma)\n",
+ " clf.fit(training_set)\n",
+ " return clf\n",
+ "\n",
+ "@mutate(\n",
+ " apply_to(prediction_train,underlying_data=source(\"coverages_land\"),mean=source(\"mean\"),std=source(\"std\")),\n",
+ " apply_to(prediction_test,underlying_data=source(\"test_cover_std\"),mean=source(\"mean\"),std=source(\"std\")),\n",
+ ")\n",
+ "def _decision_function(\n",
+ " model: svm.OneClassSVM,\n",
+ " underlying_data: npt.NDArray[np.float64],\n",
+ " mean: npt.NDArray[np.float64],\n",
+ " std: npt.NDArray[np.float64],\n",
+ ") -> npt.NDArray[np.float64]:\n",
+ " return model.decision_function((underlying_data - mean) / std)\n",
+ "\n",
+ "\n",
+ "@mutate(\n",
+ " prediction_train,\n",
+ " idx=source(\"idx\"), data=source(\"data\")\n",
+ ")\n",
+ "def _prediction_step(\n",
+ " decision: npt.NDArray[np.float64], idx: npt.NDArray[np.float64], data: Bunch\n",
+ ") -> npt.NDArray[np.float64]:\n",
+ " Z = decision.min() * np.ones((data.Ny, data.Nx), dtype=np.float64)\n",
+ " Z[idx[0], idx[1]] = decision\n",
+ " return Z\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "(,) is from module train_and_predict_using_mutate\n",
+ "________________________________________________________________________________\n",
+ "Modeling distribution of species 'bradypus_variegatus_0'\n",
+ "\n",
+ " Area under the ROC curve : 0.868443\n",
+ "________________________________________________________________________________\n",
+ "Modeling distribution of species 'microryzomys_minutus_0'\n",
+ "\n",
+ " Area under the ROC curve : 0.993919\n"
+ ]
+ },
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from hamilton import driver\n",
+ "import grids, load_data, postprocessing_results, preprocessing, train_and_predict, train_and_predict_using_mutate\n",
+ "\n",
+ "dr = (\n",
+ " driver.Builder()\n",
+ " .with_modules(\n",
+ " grids, \n",
+ " load_data, \n",
+ " postprocessing_results, \n",
+ " preprocessing, \n",
+ " train_and_predict, \n",
+ " train_and_predict_using_mutate\n",
+ " )\n",
+ " .allow_module_overrides()\n",
+ " .build()\n",
+ " )\n",
+ "\n",
+ "print(f\"{dr.list_available_variables()[-3].originating_functions} is from module {dr.list_available_variables()[-3].originating_functions[0].__module__}\")\n",
+ "# dr.visualize_execution(inputs={\"chosen_species\": \"aaa\"}, final_vars = [\"plot_species_distribution\"])\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "from run import plot_helper\n",
+ "\n",
+ "species=(\"bradypus_variegatus_0\", \"microryzomys_minutus_0\")\n",
+ "for i, name in enumerate(species):\n",
+ " print(\"_\" * 80)\n",
+ " print(\"Modeling distribution of species '%s'\" % name)\n",
+ " inputs = {\"chosen_species\": name}\n",
+ " final_vars = [\"plot_species_distribution\"]\n",
+ " results = dr.execute(inputs=inputs,final_vars=final_vars)[final_vars[0]]\n",
+ " plot_helper(i=i,**results)\n",
+ " \n",
+ "plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ " \n",
+ "\n",
+ "cluster__legend \n",
+ " \n",
+ "Legend \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_grid_.with_construct_grids \n",
+ " \n",
+ "data_grid_.with_construct_grids \n",
+ "Tuple \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_grid_ \n",
+ " \n",
+ "data_grid_ \n",
+ "Tuple \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_grid_.with_construct_grids->data_grid_ \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test.with_decision_function \n",
+ " \n",
+ "prediction_test.with_decision_function \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test \n",
+ " \n",
+ "prediction_test \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test.with_decision_function->prediction_test \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_decision_function \n",
+ " \n",
+ "prediction_train.with_decision_function \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_prediction_step \n",
+ " \n",
+ "prediction_train.with_prediction_step \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_decision_function->prediction_train.with_prediction_step \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train \n",
+ " \n",
+ "prediction_train \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_prediction_step->prediction_train \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "std \n",
+ " \n",
+ "std \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "std->prediction_test.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "std->prediction_train.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "coverages_land \n",
+ " \n",
+ "coverages_land \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "coverages_land->prediction_train.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "species \n",
+ " \n",
+ "species \n",
+ "Dict \n",
+ " \n",
+ "\n",
+ "\n",
+ "species->std \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "bunch \n",
+ " \n",
+ "bunch \n",
+ "Bunch \n",
+ " \n",
+ "\n",
+ "\n",
+ "species->bunch \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "train_cover_std \n",
+ " \n",
+ "train_cover_std \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "species->train_cover_std \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "test_cover_std \n",
+ " \n",
+ "test_cover_std \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "species->test_cover_std \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "mean \n",
+ " \n",
+ "mean \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "species->mean \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "background_points \n",
+ " \n",
+ "background_points \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_background \n",
+ " \n",
+ "prediction_background \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "background_points->prediction_background \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "plot_species_distribution \n",
+ " \n",
+ "plot_species_distribution \n",
+ "Dict \n",
+ " \n",
+ "\n",
+ "\n",
+ "bunch->plot_species_distribution \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "area_under_curve \n",
+ " \n",
+ "area_under_curve \n",
+ "float \n",
+ " \n",
+ "\n",
+ "\n",
+ "area_under_curve->plot_species_distribution \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "species.with_create_species_bunch \n",
+ " \n",
+ "species.with_create_species_bunch \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_grid_->species.with_create_species_bunch \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "meshgrid \n",
+ " \n",
+ "meshgrid \n",
+ "Tuple \n",
+ " \n",
+ "\n",
+ "\n",
+ "data_grid_->meshgrid \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "levels \n",
+ " \n",
+ "levels \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train->levels \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train->plot_species_distribution \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_background.with_normalize \n",
+ " \n",
+ "prediction_background.with_normalize \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train->prediction_background.with_normalize \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "species.with_standardize_features \n",
+ " \n",
+ "species.with_standardize_features \n",
+ "Tuple \n",
+ " \n",
+ "\n",
+ "\n",
+ "species.with_standardize_features->species \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test->area_under_curve \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_background->area_under_curve \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "land_reference \n",
+ " \n",
+ "land_reference \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "land_reference->plot_species_distribution \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "idx \n",
+ " \n",
+ "idx \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "land_reference->idx \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "land_reference->prediction_background.with_normalize \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "levels->plot_species_distribution \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test_raw \n",
+ " \n",
+ "prediction_test_raw \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "train_cover_std->prediction_test_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train_raw \n",
+ " \n",
+ "prediction_train_raw \n",
+ "ndarray \n",
+ " \n",
+ "\n",
+ "\n",
+ "train_cover_std->prediction_train_raw \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_OneClassSVM_model \n",
+ " \n",
+ "prediction_train.with_OneClassSVM_model \n",
+ "OneClassSVM \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train.with_OneClassSVM_model->prediction_train.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "species.with_create_species_bunch->species.with_standardize_features \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test.with_OneClassSVM_model \n",
+ " \n",
+ "prediction_test.with_OneClassSVM_model \n",
+ "OneClassSVM \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test.with_OneClassSVM_model->prediction_test.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "test_cover_std->prediction_test.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "meshgrid->plot_species_distribution \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_test_raw->prediction_test.with_OneClassSVM_model \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_train_raw->prediction_train.with_OneClassSVM_model \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "idx->prediction_train.with_prediction_step \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "idx->coverages_land \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "prediction_background.with_normalize->prediction_background \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "mean->prediction_test.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "mean->prediction_train.with_decision_function \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data \n",
+ " \n",
+ "data \n",
+ "Bunch \n",
+ " \n",
+ "\n",
+ "\n",
+ "data->data_grid_.with_construct_grids \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data->prediction_train.with_prediction_step \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data->coverages_land \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data->background_points \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data->land_reference \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "data->species.with_create_species_bunch \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "_species.with_create_species_bunch_inputs \n",
+ " \n",
+ "chosen_species \n",
+ "str \n",
+ " \n",
+ "\n",
+ "\n",
+ "_species.with_create_species_bunch_inputs->species.with_create_species_bunch \n",
+ " \n",
+ " \n",
+ " \n",
+ "\n",
+ "\n",
+ "input \n",
+ " \n",
+ "input \n",
+ " \n",
+ "\n",
+ "\n",
+ "function \n",
+ " \n",
+ "function \n",
+ " \n",
+ "\n",
+ "\n",
+ "output \n",
+ " \n",
+ "output \n",
+ " \n",
+ " \n",
+ " \n"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 2,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "dr.visualize_execution(inputs=inputs,final_vars=final_vars)"
+ ]
+ },
{
"cell_type": "code",
"execution_count": null,
diff --git a/examples/scikit-learn/species_distribution_modeling/train_and_predict_using_mutate.py b/examples/scikit-learn/species_distribution_modeling/train_and_predict_using_mutate.py
new file mode 100644
index 000000000..8e823c025
--- /dev/null
+++ b/examples/scikit-learn/species_distribution_modeling/train_and_predict_using_mutate.py
@@ -0,0 +1,55 @@
+import numpy as np
+import numpy.typing as npt
+from sklearn import svm
+from sklearn.utils._bunch import Bunch
+
+from hamilton.function_modifiers import apply_to, mutate, source, value
+
+
+def prediction_train(train_cover_std: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
+ return train_cover_std
+
+
+def prediction_test(train_cover_std: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
+ return train_cover_std
+
+
+@mutate(prediction_train, prediction_test, nu=value(0.1), kernel=value("rbf"), gamma=value(0.5))
+def _OneClassSVM_model(
+ training_set: npt.NDArray[np.float64], nu: float, kernel: str, gamma: float
+) -> svm.OneClassSVM:
+ clf = svm.OneClassSVM(nu=nu, kernel=kernel, gamma=gamma)
+ clf.fit(training_set)
+ return clf
+
+
+@mutate(
+ apply_to(
+ prediction_train,
+ underlying_data=source("coverages_land"),
+ mean=source("mean"),
+ std=source("std"),
+ ),
+ apply_to(
+ prediction_test,
+ underlying_data=source("test_cover_std"),
+ mean=source("mean"),
+ std=source("std"),
+ ),
+)
+def _decision_function(
+ model: svm.OneClassSVM,
+ underlying_data: npt.NDArray[np.float64],
+ mean: npt.NDArray[np.float64],
+ std: npt.NDArray[np.float64],
+) -> npt.NDArray[np.float64]:
+ return model.decision_function((underlying_data - mean) / std)
+
+
+@mutate(prediction_train, idx=source("idx"), data=source("data"))
+def _prediction_step(
+ decision: npt.NDArray[np.float64], idx: npt.NDArray[np.float64], data: Bunch
+) -> npt.NDArray[np.float64]:
+ Z = decision.min() * np.ones((data.Ny, data.Nx), dtype=np.float64)
+ Z[idx[0], idx[1]] = decision
+ return Z
diff --git a/hamilton/function_modifiers/__init__.py b/hamilton/function_modifiers/__init__.py
index 333f103f4..52b7906eb 100644
--- a/hamilton/function_modifiers/__init__.py
+++ b/hamilton/function_modifiers/__init__.py
@@ -63,7 +63,9 @@
pipe = macros.pipe
pipe_input = macros.pipe_input
pipe_output = macros.pipe_output
+mutate = macros.mutate
step = macros.step
+apply_to = macros.apply_to
# resolve transform/model decorator
dynamic_transform = macros.dynamic_transform
diff --git a/hamilton/function_modifiers/base.py b/hamilton/function_modifiers/base.py
index 92a8b763a..131ee0383 100644
--- a/hamilton/function_modifiers/base.py
+++ b/hamilton/function_modifiers/base.py
@@ -221,26 +221,6 @@ def transform_dag(
pass
-# TODO -- delete this/replace with the version that will be added by
-# https://github.com/DAGWorks-Inc/hamilton/pull/249/ as part of the Node class
-def _reassign_inputs(node_: node.Node, input_names: Dict[str, Any]) -> node.Node:
- """Reassigns the input names of a node. Useful for applying
- a node to a separate input if needed. Note that things can get a
- little strange if you have multiple inputs with the same name, so
- be careful about how you use this.
- :param input_names: Input name map to reassign
- :return: A node with the input names reassigned
- """
-
- def new_callable(**kwargs) -> Any:
- reverse_input_names = {v: k for k, v in input_names.items()}
- return node_.callable(**{reverse_input_names.get(k, k): v for k, v in kwargs.items()})
-
- new_input_types = {input_names.get(k, k): v for k, v in node_.input_types.items()}
- out = node_.copy_with(callabl=new_callable, input_types=new_input_types)
- return out
-
-
class NodeInjector(SubDAGModifier, abc.ABC):
"""Injects a value as a source node in the DAG. This is a special case of the SubDAGModifier,
which gets all the upstream (required) nodes from the subdag and gives the decorator a chance
@@ -293,7 +273,7 @@ def transform_dag(
for node_ in nodes:
# if there's an intersection then we want to rename the input
if set(node_.input_types.keys()) & set(rename_map.keys()):
- out.append(_reassign_inputs(node_, rename_map))
+ out.append(node_.reassign_inputs(input_names=rename_map))
else:
out.append(node_)
out.extend(nodes_to_inject)
diff --git a/hamilton/function_modifiers/macros.py b/hamilton/function_modifiers/macros.py
index 00bf05689..b3ddd40d6 100644
--- a/hamilton/function_modifiers/macros.py
+++ b/hamilton/function_modifiers/macros.py
@@ -323,23 +323,38 @@ class Applicable:
def __init__(
self,
- fn: Callable,
+ fn: Union[Callable, str, None],
args: Tuple[Union[Any, SingleDependency], ...],
kwargs: Dict[str, Union[Any, SingleDependency]],
+ target_fn: Union[Callable, str, None] = None,
_resolvers: List[ConfigResolver] = None,
_name: Optional[str] = None,
_namespace: Union[str, None, EllipsisType] = ...,
+ _target: base.TargetType = None,
):
"""Instantiates an Applicable.
- :param fn: Function it takes in
+ We allow fn=None for the use-cases where we want to store the Applicable config (i.e. .when* family, namespace, target, etc.)
+ but do not yet the access to the actual function we are turning into the Applicable. In addition, in case the target nodes come
+ from a function (using extract_columns/extract_fields) we can pass target_fn to have access to its pointer that we can decorate
+ programmatically. See `apply_to` and `mutate` for an example.
+
:param args: Args (*args) to pass to the function
- :param kwargs: Kwargs (**kwargs) to pass to the function
+ :param fn: Function it takes in. Can be None to create an Applicable placeholder with delayed choice of function.
+ :param target_fn: Function the applicable will be applied to
:param _resolvers: Resolvers to use for the function
:param _name: Name of the node to be created
:param _namespace: Namespace of the node to be created -- currently only single-level namespaces are supported
+ :param _target: Selects which target nodes it will be appended onto. By default all.
+ :param kwargs: Kwargs (**kwargs) to pass to the function
"""
+
+ if isinstance(fn, str) or isinstance(target_fn, str):
+ raise TypeError("Strings are not supported currently. Please provide function pointer.")
+
self.fn = fn
+ self.target_fn = target_fn
+
if "_name" in kwargs:
raise ValueError("Cannot pass in _name as a kwarg")
@@ -349,6 +364,7 @@ def __init__(
self.resolvers = _resolvers if _resolvers is not None else []
self.name = _name
self.namespace = _namespace
+ self.target = _target
def _with_resolvers(self, *additional_resolvers: ConfigResolver) -> "Applicable":
"""Helper function for the .when* group"""
@@ -359,6 +375,7 @@ def _with_resolvers(self, *additional_resolvers: ConfigResolver) -> "Applicable"
_namespace=self.namespace,
args=self.args,
kwargs=self.kwargs,
+ target_fn=self.target_fn,
)
def when(self, **key_value_pairs) -> "Applicable":
@@ -411,6 +428,7 @@ def namespaced(self, namespace: NamespaceType) -> "Applicable":
_namespace=namespace,
args=self.args,
kwargs=self.kwargs,
+ target_fn=self.target_fn,
)
def resolves(self, config: Dict[str, Any]) -> bool:
@@ -448,6 +466,27 @@ def named(self, name: str, namespace: NamespaceType = ...) -> "Applicable":
),
args=self.args,
kwargs=self.kwargs,
+ target_fn=self.target_fn,
+ )
+
+ def on_output(self, target: base.TargetType) -> "Applicable":
+ """Add Target on a single function level.
+
+ This determines to which node(s) it will applies. Should match the same naming convention
+ as the NodeTransorfmLifecycle child class (for example NodeTransformer).
+
+ :param target: Which node(s) to apply on top of
+ :return: The Applicable with specified target
+ """
+ return Applicable(
+ fn=self.fn,
+ _resolvers=self.resolvers,
+ _name=self.name,
+ _namespace=self.namespace,
+ _target=target if target is not None else self.target,
+ args=self.args,
+ kwargs=self.kwargs,
+ target_fn=self.target_fn,
)
def get_config_elements(self) -> List[str]:
@@ -585,17 +624,7 @@ def step(
class pipe_input(base.NodeInjector):
- """Decorator to represent a chained set of transformations. This specifically solves the "node redefinition"
- problem, and is meant to represent a pipeline of chaining/redefinitions. This is similar (and can happily be
- used in conjunction with) `pipe` in pandas. In Pyspark this is akin to the common operation of redefining a dataframe
- with new columns. While it is generally reasonable to contain these constructs within a node's function,
- you should consider `pipe` for any of the following reasons:
-
- 1. You want the transformations to display as nodes in the DAG, with the possibility of storing or visualizing
- the result
- 2. You want to pull in functions from an external repository, and build the DAG a little more procedurally
- 3. You want to use the same function multiple times, but with different parameters -- while `@does`/`@parameterize` can
- do this, this presents an easier way to do this, especially in a chain.
+ """Running a series of transformation on the input of the function.
To demonstrate the rules for chaining nodes, we'll be using the following example. This is
using primitives to demonstrate, but as hamilton is just functions of any python objects, this works perfectly with
@@ -662,9 +691,9 @@ def final_result(upstream_int: int) -> int:
Note that functions must have no position-only arguments (this is rare in python, but hamilton does not handle these).
- This basically means that the functions must be defined similarly to `def fn(x, y, z=10)` and not `def fn(x, y, /, z=10)`.
- In fact, all arguments must be named and "kwarg-friendly", meaning that the function can happily be called with `**kwargs`,
- where kwargs are some set of resolved upstream values. So, no `*args` are allowed, and `**kwargs` (variable keyword-only) are not
+ This basically means that the functions must be defined similarly to ``def fn(x, y, z=10)`` and not ``def fn(x, y, /, z=10)``.
+ In fact, all arguments must be named and "kwarg-friendly", meaning that the function can happily be called with ``**kwargs``,
+ where kwargs are some set of resolved upstream values. So, no ``*args`` are allowed, and ``**kwargs`` (variable keyword-only) are not
permitted. Note that this is not a design limitation, rather an implementation detail -- if you feel like you need this, please
reach out.
@@ -672,9 +701,9 @@ def final_result(upstream_int: int) -> int:
One has two ways to tune the shape/implementation of the subsequent nodes:
- 1. `when`/`when_not`/`when_in`/`when_not_in` -- these are used to filter the application of the function. This is valuable to reflect
+ 1. ``when``/``when_not``/``when_in``/``when_not_in`` -- these are used to filter the application of the function. This is valuable to reflect
if/else conditions in the structure of the DAG, pulling it out of functions, rather than buried within the logic itself. It is functionally
- equivalent to `@config.when`.
+ equivalent to ``@config.when``.
For instance, if you want to include a function in the chain only when a config parameter is set to a certain value, you can do:
@@ -687,14 +716,14 @@ def final_result(upstream_int: int) -> int:
def final_result(upstream_int: int) -> int:
return upstream_int
- This will only apply the first function when the config parameter `foo` is set to `bar`, and the second when it is set to `baz`.
+ This will only apply the first function when the config parameter ``foo`` is set to ``bar``, and the second when it is set to ``baz``.
- 2. `named` -- this is used to name the node. This is useful if you want to refer to intermediate results. If this is left out,
+ 2. ``named`` -- this is used to name the node. This is useful if you want to refer to intermediate results. If this is left out,
hamilton will automatically name the functions in a globally unique manner. The names of
- these functions will not necessarily be stable/guaranteed by the API, so if you want to refer to them, you should use `named`.
+ these functions will not necessarily be stable/guaranteed by the API, so if you want to refer to them, you should use ``named``.
The default namespace will always be the name of the decorated function (which will be the last node in the chain).
- `named` takes in two parameters -- required is the `name` -- this will assign the nodes with a single name and *no* global namespace.
+ ``named`` takes in two parameters -- required is the ``name`` -- this will assign the nodes with a single name and *no* global namespace.
For instance:
.. code-block:: python
@@ -706,15 +735,15 @@ def final_result(upstream_int: int) -> int:
def final_result(upstream_int: int) -> int:
return upstream_int
- The above will create two nodes, `a` and `b`. `a` will be the result of `_add_one`, and `b` will be the result of `_add_two`.
- `final_result` will then be called with the output of `b`. Note that, if these are part of a namespaced operation (a subdag, in particular),
+ The above will create two nodes, ``a`` and ``b``. ``a`` will be the result of ``_add_one``, and ``b`` will be the result of ``_add_two``.
+ ``final_result`` will then be called with the output of ``b``. Note that, if these are part of a namespaced operation (a subdag, in particular),
they *will* get the same namespace as the subdag.
- The second parameter is `namespace`. This is used to specify a namespace for the node. This is useful if you want
+ The second parameter is ``namespace``. This is used to specify a namespace for the node. This is useful if you want
to either (a) ensure that the nodes are namespaced but share a common one to avoid name clashes (usual case), or (b)
if you want a custom namespace (unusual case). To indicate a custom namespace, one need simply pass in a string.
- To indicate that a node should share a namespace with the rest of the step(...) operations in a pipe, one can pass in `...` (the ellipsis).
+ To indicate that a node should share a namespace with the rest of the step(...) operations in a pipe, one can pass in ``...`` (the ellipsis).
.. code-block:: python
:name: Namespaced step
@@ -727,7 +756,7 @@ def final_result(upstream_int: int) -> int:
def final_result(upstream_int: int) -> int:
return upstream_int
- Note that if you pass a namespace argument to the `pipe` function, it will set the namespace on each step operation.
+ Note that if you pass a namespace argument to the ``pipe`` function, it will set the namespace on each step operation.
This is useful if you want to ensure that all the nodes in a pipe have a common namespace, but you want to rename them.
.. code-block:: python
@@ -744,7 +773,7 @@ def final_result(upstream_int: int) -> int:
return upstream_int
In all likelihood, you should not be using this, and this is only here in case you want to expose a node for
- consumption/output later. Setting the namespace in individual nodes as well as in `pipe` is not yet supported.
+ consumption/output later. Setting the namespace in individual nodes as well as in ``pipe`` is not yet supported.
"""
def __init__(
@@ -754,12 +783,12 @@ def __init__(
collapse=False,
_chain=False,
):
- """Instantiates a `@pipe` decorator.
+ """Instantiates a ``@pipe_input`` decorator.
:param transforms: step transformations to be applied, in order
- :param namespace: namespace to apply to all nodes in the pipe. This can be "..." (the default), which resolves to the name of the decorated function, None (which means no namespace), or a string (which means that all nodes will be namespaced with that string). Note that you can either use this *or* namespaces inside pipe()...
+ :param namespace: namespace to apply to all nodes in the pipe. This can be "..." (the default), which resolves to the name of the decorated function, None (which means no namespace), or a string (which means that all nodes will be namespaced with that string). Note that you can either use this *or* namespaces inside ``pipe_input()``...
:param collapse: Whether to collapse this into a single node. This is not currently supported.
- :param _chain: Whether to chain the first parameter. This is the only mode that is supported. Furthermore, this is not externally exposed. @flow will make use of this.
+ :param _chain: Whether to chain the first parameter. This is the only mode that is supported. Furthermore, this is not externally exposed. ``@flow`` will make use of this.
"""
self.transforms = transforms
self.collapse = collapse
@@ -883,13 +912,29 @@ def __init__(
# super(flow, self).__init__(*transforms, collapse=collapse, _chain=False)
-class pipe_output(base.SingleNodeNodeTransformer):
+class SingleTargetError(Exception):
+ """We prohibit the target to be raise both globally and locally.
+
+ Decorators that transform the output of a node can be set to transform only
+ a certain output node (useful with extract_columns / extract_fields). Some decorators
+ can group multiple transforms and we can set that certain output node either for all of them
+ or for each individually.
+
+ This is a safeguard, because when you set the global target it creates a subset of those nodes and
+ if the local target is outside of that subset it gets ignore (opposed to the logical assumption that
+ it can override the global one). So we disable that case.
+ """
+
+ pass
+
+
+class pipe_output(base.NodeTransformer):
"""Running a series of transformation on the output of the function.
The decorated function declares the dependency, the body of the function gets executed, and then
- we run a series of transformations on the result of the function specified by `pipe_output`.
+ we run a series of transformations on the result of the function specified by ``pipe_output``.
- If we have nodes **A --> B --> C** in the DAG and decorate `B` with `pipe_output` like
+ If we have nodes **A --> B --> C** in the DAG and decorate **B** with ``pipe_output`` like
.. code-block:: python
:name: Simple @pipe_output example
@@ -901,36 +946,91 @@ class pipe_output(base.SingleNodeNodeTransformer):
def B(...):
return ...
- we obtain the new DAG **A --> B_raw --> B1 --> B2 --> B --> C**, where we can think of the **B_raw --> B1 --> B2 --> B** as a "pipe" that takes the raw output of B, applies to it
- B1, takes the output of B1 applies to it B2 and then gets renamed to B to re-connect to the rest of the DAG.
+ we obtain the new DAG **A --> B_raw --> B1 --> B2 --> B --> C**, where we can think of the **B_raw --> B1 --> B2 --> B** as a "pipe" that takes the raw output of **B**, applies to it
+ **B1**, takes the output of **B1** applies to it **B2** and then gets renamed to **B** to re-connect to the rest of the DAG.
+
+ The rules for chaining nodes are the same as for ``pipe_input``.
+
+ For extra control in case of multiple output nodes, for example after ``extract_field``/ ``extract_columns`` we can also specify the output node that we wish to mutate.
+ The following apply *A* to all fields while *B* only to ``field_1``
+
+ .. code-block:: python
+ :name: Simple @pipe_output example targeting specific nodes
+
+ @extract_columns("col_1", "col_2")
+ def A(...):
+ return ...
+
+ def B(...):
+ return ...
+
- While it is generally reasonable to contain these constructs within a node's function,
- you should consider `pipe_output` for similar reasons as `pipe_input`, namely, for any of the following reasons:
+ @pipe_output(
+ step(A),
+ step(B).on_output("field_1"),
+ )
+ @extract_fields(
+ {"field_1":int, "field_2":int, "field_3":int}
+ )
+ def foo(a:int)->Dict[str,int]:
+ return {"field_1":1, "field_2":2, "field_3":3}
- 1. You want the transformations to display as nodes in the DAG, with the possibility of storing or visualizing
- the result
- 2. You want to pull in functions from an external repository, and build the DAG a little more procedurally
- 3. You want to use the same function multiple times, but with different parameters -- while `@does`/`@parameterize` can
- do this, this presents an easier way to do this, especially in a chain.
+ We can also do this on the global level (but cannot do on both levels at the same time). The following would apply function *A* and function *B* to only ``field_1`` and ``field_2``
- The rules for chaining nodes as the same as for pipe.
+ .. code-block:: python
+ :name: Simple @pipe_output targeting specific nodes local
+
+ @pipe_output(
+ step(A),
+ step(B),
+ on_output = ["field_1","field_2]
+ )
+ @extract_fields(
+ {"field_1":int, "field_2":int, "field_3":int}
+ )
+ def foo(a:int)->Dict[str,int]:
+ return {"field_1":1, "field_2":2, "field_3":3}
"""
+ @classmethod
+ def _validate_single_target_level(cls, target: base.TargetType, transforms: Tuple[Applicable]):
+ """We want to make sure that target gets applied on a single level.
+ Either choose for each step individually what it targets or set it on the global level where
+ all steps will target the same node(s).
+ """
+ if target is not None:
+ for transform in transforms:
+ if transform.target is not None:
+ raise SingleTargetError("Cannot have target set on pipe_output and step level.")
+
def __init__(
self,
*transforms: Applicable,
namespace: NamespaceType = ...,
+ on_output: base.TargetType = None,
collapse=False,
_chain=False,
):
- """Instantiates a `@pipe_output` decorator.
+ """Instantiates a ``@pipe_output`` decorator.
+
+ Warning: if there is a global pipe_output target, the individual ``step(...).target`` would only choose
+ from the subset pre-selected from the global pipe_output target. We have disabled this for now to avoid
+ confusion. Leave global pipe_output target empty if you want to choose between all the nodes on the individual step level.
:param transforms: step transformations to be applied, in order
- :param namespace: namespace to apply to all nodes in the pipe. This can be "..." (the default), which resolves to the name of the decorated function, None (which means no namespace), or a string (which means that all nodes will be namespaced with that string). Note that you can either use this *or* namespaces inside pipe()...
+ :param namespace: namespace to apply to all nodes in the pipe. This can be "..." (the default), which resolves to the name of the decorated function, None (which means no namespace), or a string (which means that all nodes will be namespaced with that string). Note that you can either use this *or* namespaces inside ``pipe_output()``...
+ :param on_output: setting the target node for all steps in the pipe. Leave empty to select all the output nodes.
:param collapse: Whether to collapse this into a single node. This is not currently supported.
- :param _chain: Whether to chain the first parameter. This is the only mode that is supported. Furthermore, this is not externally exposed. @flow will make use of this.
+ :param _chain: Whether to chain the first parameter. This is the only mode that is supported. Furthermore, this is not externally exposed. ``@flow`` will make use of this.
"""
- super(pipe_output, self).__init__()
+ pipe_output._validate_single_target_level(target=on_output, transforms=transforms)
+
+ if on_output == ...:
+ raise ValueError(
+ "Cannot apply Elipsis(...) to on_output. Use None, single string or list of strings."
+ )
+
+ super(pipe_output, self).__init__(target=on_output)
self.transforms = transforms
self.collapse = collapse
self.chain = _chain
@@ -944,6 +1044,27 @@ def __init__(
if self.chain:
raise NotImplementedError("@flow() is not yet supported -- this is ")
+ def _filter_individual_target(self, node_):
+ """Resolves target option on the transform level.
+ Adds option that we can decide for each applicable which output node it will target.
+
+ :param node_: The current output node.
+ :return: The set of transforms that target this node
+ """
+ selected_transforms = []
+ for transform in self.transforms:
+ target = transform.target
+ if isinstance(target, str): # user selects single target via string
+ if node_.name == target:
+ selected_transforms.append(transform)
+ elif isinstance(target, Collection): # user inputs a list of targets
+ if node_.name in target:
+ selected_transforms.append(transform)
+ else: # for target=None (default) we include all sink nodes
+ selected_transforms.append(transform)
+
+ return tuple(selected_transforms)
+
def transform_node(
self, node_: node.Node, config: Dict[str, Any], fn: Callable
) -> Collection[node.Node]:
@@ -954,22 +1075,28 @@ def transform_node(
The last node is an identity to the previous one with the original name `function_name` to
represent an exit point of `pipe_output`.
"""
-
- if len(self.transforms) < 1:
+ transforms = self._filter_individual_target(node_)
+ if len(transforms) < 1:
# in case no functions in pipeline we short-circuit and return the original node
return [node_]
+ if self.namespace is None:
+ _namespace = None
+ elif self.namespace is ...:
+ _namespace = node_.name
+ else:
+ _namespace = self.namespace
+
original_node = node_.copy_with(name=f"{node_.name}_raw")
def __identity(foo: Any) -> Any:
return foo
- transforms = self.transforms + (step(__identity).named(fn.__name__),)
-
+ transforms = transforms + (step(__identity).named(fn.__name__),)
nodes, _ = chain_transforms(
first_arg=original_node.name,
transforms=transforms,
- namespace=self.namespace,
+ namespace=_namespace, # self.namespace,
config=config,
fn=fn,
)
@@ -1055,3 +1182,217 @@ def chain_transforms(
)
first_arg = raw_node.name
return nodes, first_arg
+
+
+def apply_to(fn_: Union[Callable, str], **mutating_fn_kwargs: Union[SingleDependency, Any]):
+ """Creates an applicable placeholder with potential kwargs that will be applied to a node (or a subcomponent of a node).
+ See documentation for ``mutate`` to see how this is used. It de facto allows a postponed ``step``.
+
+ We pass fn=None here as this will be the function we are decorating and need to delay passing it in. The target
+ function is the one we wish to mutate and we store it for later access.
+
+ :param fn: Function the applicable will be applied to
+ :param mutating_fn_kwargs: Kwargs (**kwargs) to pass to the mutator function. Must be validly called as f(**kwargs), and have a 1:1 mapping of kwargs to parameters.
+ :return: an applicable placeholder with the target function
+ """
+ return Applicable(fn=None, args=(), kwargs=mutating_fn_kwargs, target_fn=fn_, _resolvers=[])
+
+
+class NotSameModuleError(Exception):
+ """Limit the use of a decorator on functions from the same module.
+
+ Some decorators have the ability to transform also other functions than the one they are decorating (for example mutate).
+ This ensures that all the functions are located within the same module.
+ """
+
+ def __init__(self, fn: Callable, target_fn: Callable):
+ super().__init__(
+ f"The functions have to be in the same module... "
+ f"The target function {target_fn.__name__} is in module {target_fn.__module__} and "
+ f"the mutator function {fn.__name__} is in module {fn.__module__}./n"
+ "Use power user setting to disable this restriction."
+ )
+
+
+class mutate:
+ """Running a transformation on the outputs of a series of functions.
+
+ This is closely related to ``pipe_output`` as it effectively allows you to run transformations on the output of a node without touching that node.
+ We choose which target functions we wish to mutate by the transformation we are decorating. For now, the target functions, that will be mutated,
+ have to be in the same module (come speak to us if you need this capability over multiple modules).
+
+ We suggest you define them with an prefixed underscore to only have them displayed in the `transform pipeline` of the target node.
+
+ If we wish to apply ``_transform1`` to the output of **A** and **B** and ``_transform2`` only to the output
+ of node **B**, we can do this like
+
+ .. code-block:: python
+ :name: Simple @mutate example
+
+ def A(...):
+ return ...
+
+ def B(...):
+ return ...
+
+ @mutate(A, B)
+ def _transform1(...):
+ return ...
+
+ @mutate(B)
+ def _transform2(...):
+ return ...
+
+ we obtain the new pipe-like subDAGs **A_raw --> _transform1 --> A** and **B_raw --> _transform1 --> _transform2 --> B**,
+ where the behavior is the same as ``pipe_output``.
+
+ While it is generally reasonable to use ``pipe_output``, you should consider ``mutate`` in the following scenarios:
+
+ 1. Loading data and applying pre-cleaning step.
+ 2. Feature engineering via joining, filtering, sorting, etc.
+ 3. Experimenting with different transformations across nodes by selectively turning transformations on / off.
+
+ We assume the first argument of the decorated function to be the output of the function we are targeting.
+ For transformations with multiple arguments you can use key word arguments coupled with ``step`` or ``value``
+ the same as with other ``pipe``-family decorators
+
+ .. code-block:: python
+ :name: Simple @mutate example with multiple arguments
+
+ @mutate(A, B, arg2=step('upstream_node'), arg3=value(some_literal), ...)
+ def _transform1(output_from_target:correct_type, arg2:arg2_type, arg3:arg3_type,...):
+ return ...
+
+ You can also select individual args that will be applied to each target node by adding ``apply_to(...)``
+
+ .. code-block:: python
+ :name: Simple @mutate example with multiple arguments allowing individual actions
+
+ @mutate(
+ apply_to(A, arg2=step('upstream_node_1'), arg3=value(some_literal_1)),
+ apply_to(B, arg2=step('upstream_node_2'), arg3=value(some_literal_2)),
+ )
+ def _transform1(output_from_target:correct_type, arg2:arg2_type, arg3:arg3_type, ...):
+ return ...
+
+ In case of multiple output nodes, for example after ``extract_field`` / ``extract_columns`` we can also specify the output node that we wish to mutate.
+ The following would mutate all columns of *A* individually while in the case of function *B* only ``field_1``
+
+ .. code-block:: python
+ :name: @mutate example targeting specific nodes local
+
+ @extract_columns("col_1", "col_2")
+ def A(...):
+ return ...
+
+ @extract_fields(
+ {"field_1":int, "field_2":int, "field_3":int}
+ )
+ def B(...):
+ return ...
+
+ @mutate(
+ apply_to(A),
+ apply_to(B).on_output("field_1"),
+ )
+
+ def foo(a:int)->Dict[str,int]:
+ return {"field_1":1, "field_2":2, "field_3":3}
+ """
+
+ def __init__(
+ self,
+ *target_functions: Union[Applicable, Callable],
+ collapse: bool = False,
+ _chain: bool = False,
+ **mutating_function_kwargs: Union[SingleDependency, Any],
+ ):
+ """Instantiates a ``mutate`` decorator.
+
+ We assume the first argument of the decorated function to be the output of the function we are targeting.
+
+ :param target_functions: functions we wish to mutate the output of
+ :param collapse: Whether to collapse this into a single node. This is not currently supported.
+ :param _chain: Whether to chain the first parameter. This is the only mode that is supported. Furthermore, this is not externally exposed. ``@flow`` will make use of this.
+ :param \*\*mutating_function_kwargs: other kwargs that the decorated function has. Must be validly called as ``f(**kwargs)``, and have a 1-to-1 mapping of kwargs to parameters. This will be applied for all ``target_functions``, unless ``apply_to`` already has the mutator function kwargs, in which case it takes those.
+ """
+ self.collapse = collapse
+ self.chain = _chain
+ # keeping it here once it gets implemented maybe nice to have options
+ if self.collapse:
+ raise NotImplementedError(
+ "Collapsing functions as one node is not yet implemented for mutate(). Please reach out if you want this feature."
+ )
+ if self.chain:
+ raise NotImplementedError("@flow() is not yet supported -- this is ")
+
+ self.remote_applicables = tuple(
+ [apply_to(fn) if isinstance(fn, Callable) else fn for fn in target_functions]
+ )
+ self.mutating_function_kwargs = mutating_function_kwargs
+
+ # Cross module will require some thought so we are restricting mutate to single module for now
+ self.restrict_to_single_module = True
+
+ def validate_same_module(self, mutating_fn: Callable):
+ """Validates target functions are in the same module as the mutator function.
+
+ :param mutating_fn: Function to validate against
+ :return: Nothing, raises exception if not valid.
+ """
+ local_module = mutating_fn.__module__
+ for remote_applicable in self.remote_applicables:
+ if remote_applicable.target_fn.__module__ != local_module:
+ raise NotSameModuleError(fn=mutating_fn, target_fn=remote_applicable.target_fn)
+
+ def _create_step(self, mutating_fn: Callable, remote_applicable_builder: Applicable):
+ """Adds the correct function for the applicable and resolves kwargs"""
+
+ if not remote_applicable_builder.kwargs:
+ remote_applicable_builder.kwargs = self.mutating_function_kwargs
+
+ remote_applicable_builder.fn = mutating_fn
+
+ return remote_applicable_builder
+
+ def __call__(self, mutating_fn: Callable):
+ """Adds to an existing pipe_output or creates a new pipe_output.
+
+ This is a new type of decorator that builds ``pipe_output`` for multiple nodes in the DAG. It does
+ not fit in the current decorator framework since it does not decorate the node function in the DAG
+ but allows us to "remotely decorate" multiple nodes at once, which needs to happen before the
+ NodeTransformLifecycle gets applied / resolved.
+
+ :param mutating_fn: function that will be used in pipe_output to transform target function
+ :return: mutating_fn, to guarantee function works even when Hamilton driver is not used
+ """
+
+ # TODO: We want to hide such helper function from the DAG by default, since we are manually
+ # adding them to the DAG in a different place
+ # Suggestion: ignore decorator - https://github.com/DAGWorks-Inc/hamilton/issues/1168
+ # if not mutating_fn.__name__.startswith("_"):
+ # mutating_fn.__name__ = "".join(("_", mutating_fn.__name__))
+
+ if self.restrict_to_single_module:
+ self.validate_same_module(mutating_fn=mutating_fn)
+
+ # TODO: If @mutate runs once it's good
+ # If you run that again, it might double-up
+ # In the juptyer notebook/cross-module case we'll want to guard against it.
+ for remote_applicable in self.remote_applicables:
+ new_pipe_step = self._create_step(
+ mutating_fn=mutating_fn, remote_applicable_builder=remote_applicable
+ )
+ found_pipe_output = False
+ if hasattr(remote_applicable.target_fn, base.NodeTransformer.get_lifecycle_name()):
+ for decorator in remote_applicable.target_fn.transform:
+ if isinstance(decorator, pipe_output):
+ decorator.transforms = decorator.transforms + (new_pipe_step,)
+ found_pipe_output = True
+
+ if not found_pipe_output:
+ remote_applicable.target_fn = pipe_output(
+ new_pipe_step, collapse=self.collapse, _chain=self.chain
+ )(remote_applicable.target_fn)
+
+ return mutating_fn
diff --git a/tests/function_modifiers/test_macros.py b/tests/function_modifiers/test_macros.py
index 6695fd24b..bc51bc44a 100644
--- a/tests/function_modifiers/test_macros.py
+++ b/tests/function_modifiers/test_macros.py
@@ -10,13 +10,16 @@
from hamilton.function_modifiers.dependencies import source, value
from hamilton.function_modifiers.macros import (
Applicable,
+ apply_to,
ensure_function_empty,
+ mutate,
pipe_input,
pipe_output,
step,
)
from hamilton.node import DependencyType
+import tests.resources.mutate
import tests.resources.pipe_input
import tests.resources.pipe_output
@@ -237,10 +240,6 @@ def _test_apply_function(foo: int, bar: int, baz: int = 100) -> int:
return foo + bar + baz
-def _test_apply_function_2(foo: int) -> int:
- return foo + 1
-
-
@pytest.mark.parametrize(
"args,kwargs,chain_first_param",
[
@@ -463,6 +462,16 @@ def result_from_downstream_function() -> int:
return 2
+def test_pipe_output_single_target_level_error():
+ with pytest.raises(hamilton.function_modifiers.macros.SingleTargetError):
+ pipe_output(
+ step(_test_apply_function, source("bar_upstream"), baz=value(100)).on_output(
+ "some_node"
+ ),
+ on_output="some_other_node",
+ )
+
+
def test_pipe_output_shortcircuit():
n = node.Node.from_fn(result_from_downstream_function)
decorator = pipe_output()
@@ -531,6 +540,97 @@ def test_pipe_output_inherits_null_namespace():
assert "result_from_downstream_function" in {item.name for item in nodes}
+def test_pipe_output_global_on_output_all():
+ n1 = node.Node.from_fn(result_from_downstream_function, name="node_1")
+ n2 = node.Node.from_fn(result_from_downstream_function, name="node_2")
+
+ decorator = pipe_output(
+ step(_test_apply_function, source("bar_upstream"), baz=value(100)),
+ )
+ nodes = decorator.select_nodes(decorator.target, [n1, n2])
+ assert len(nodes) == 2
+ assert [node_.name for node_ in nodes] == ["node_1", "node_2"]
+
+
+def test_pipe_output_global_on_output_string():
+ n1 = node.Node.from_fn(result_from_downstream_function, name="node_1")
+ n2 = node.Node.from_fn(result_from_downstream_function, name="node_2")
+
+ decorator = pipe_output(
+ step(_test_apply_function, source("bar_upstream"), baz=value(100)), on_output="node_2"
+ )
+ nodes = decorator.select_nodes(decorator.target, [n1, n2])
+ assert len(nodes) == 1
+ assert nodes[0].name == "node_2"
+
+
+def test_pipe_output_global_on_output_list_strings():
+ n1 = node.Node.from_fn(result_from_downstream_function, name="node_1")
+ n2 = node.Node.from_fn(result_from_downstream_function, name="node_2")
+ n3 = node.Node.from_fn(result_from_downstream_function, name="node_3")
+
+ decorator = pipe_output(
+ step(_test_apply_function, source("bar_upstream"), baz=value(100)),
+ on_output=["node_1", "node_2"],
+ )
+ nodes = decorator.select_nodes(decorator.target, [n1, n2, n3])
+ assert len(nodes) == 2
+ assert [node_.name for node_ in nodes] == ["node_1", "node_2"]
+
+
+def test_pipe_output_elipsis_error():
+ with pytest.raises(ValueError):
+ pipe_output(
+ step(_test_apply_function, source("bar_upstream"), baz=value(100)), on_output=...
+ )
+
+
+def test_pipe_output_local_on_output_string():
+ n1 = node.Node.from_fn(result_from_downstream_function, name="node_1")
+ n2 = node.Node.from_fn(result_from_downstream_function, name="node_2")
+
+ decorator = pipe_output(
+ step(_test_apply_function, source("bar_upstream"), baz=value(100))
+ .named("correct_transform")
+ .on_output("node_2"),
+ step(_test_apply_function, source("bar_upstream"), baz=value(100))
+ .named("wrong_transform")
+ .on_output("node_3"),
+ )
+ steps = decorator._filter_individual_target(n1)
+ assert len(steps) == 0
+ steps = decorator._filter_individual_target(n2)
+ assert len(steps) == 1
+ assert steps[0].name == "correct_transform"
+
+
+def test_pipe_output_local_on_output_list_string():
+ n1 = node.Node.from_fn(result_from_downstream_function, name="node_1")
+ n2 = node.Node.from_fn(result_from_downstream_function, name="node_2")
+ n3 = node.Node.from_fn(result_from_downstream_function, name="node_3")
+
+ decorator = pipe_output(
+ step(_test_apply_function, source("bar_upstream"), baz=value(100))
+ .named("correct_transform_list")
+ .on_output(["node_2", "node_3"]),
+ step(_test_apply_function, source("bar_upstream"), baz=value(100))
+ .named("correct_transform_string")
+ .on_output("node_2"),
+ step(_test_apply_function, source("bar_upstream"), baz=value(100))
+ .named("wrong_transform")
+ .on_output("node_5"),
+ )
+ steps = decorator._filter_individual_target(n1)
+ assert len(steps) == 0
+ steps = decorator._filter_individual_target(n2)
+ assert len(steps) == 2
+ assert steps[0].name == "correct_transform_list"
+ assert steps[1].name == "correct_transform_string"
+ steps = decorator._filter_individual_target(n3)
+ assert len(steps) == 1
+ assert steps[0].name == "correct_transform_list"
+
+
def test_pipe_output_end_to_end_simple():
dr = driver.Builder().with_config({"calc_c": True}).build()
@@ -552,7 +652,7 @@ def test_pipe_output_end_to_end_simple():
assert result["downstream_f"] == result["chain_not_using_pipe_output"]
-def test_pipe_output_end_to_end_1():
+def test_pipe_output_end_to_end():
dr = (
driver.Builder()
.with_modules(tests.resources.pipe_output)
@@ -579,14 +679,99 @@ def test_pipe_output_end_to_end_1():
assert result["chain_2_using_pipe_output"] == result["chain_2_not_using_pipe_output"]
-def test_pipe_output_end_to_end_2():
+# Mutate will mark the modules (and leave a mark).
+# Thus calling it a second time (for instance through pmultiple tests) might mess it up slightly...
+# Using fixtures just to be sure.
+
+
+@pytest.fixture(scope="function")
+def _downstream_result_to_mutate():
+ def downstream_result_to_mutate() -> int:
+ return 2
+
+ yield downstream_result_to_mutate
+
+
+@pytest.fixture(scope="function")
+def import_mutate_module():
+ import importlib
+
+ mod = importlib.import_module("tests.resources.mutate")
+ yield mod
+
+
+# This doesn't change so no need to have it as fixture
+def mutator_function(input_1: int, input_2: int) -> int:
+ return input_1 + input_2
+
+
+def test_mutate_convert_callable_to_applicable(_downstream_result_to_mutate):
+ decorator = mutate(_downstream_result_to_mutate)
+
+ assert len(decorator.remote_applicables) == 1
+ remote_applicable = decorator.remote_applicables[0]
+ assert isinstance(remote_applicable, Applicable)
+ assert remote_applicable.fn is None
+ assert remote_applicable.target_fn == _downstream_result_to_mutate
+
+
+def test_mutate_restricted_to_same_module():
+ decorator = mutate(tests.resources.mutate.f_of_interest)
+
+ with pytest.raises(hamilton.function_modifiers.macros.NotSameModuleError):
+ decorator.validate_same_module(mutator_function)
+
+
+def test_mutate_global_kwargs(_downstream_result_to_mutate):
+ decorator = mutate(apply_to(_downstream_result_to_mutate), input_2=17)
+ remote_applicable = decorator.remote_applicables[0]
+
+ pipe_step = decorator._create_step(
+ mutating_fn=mutator_function, remote_applicable_builder=remote_applicable
+ )
+ assert pipe_step.kwargs["input_2"] == 17
+
+
+def test_mutate_local_kwargs_override_global_ones(_downstream_result_to_mutate):
+ decorator = mutate(apply_to(_downstream_result_to_mutate, input_2=13), input_2=17)
+ remote_applicable = decorator.remote_applicables[0]
+
+ pipe_step = decorator._create_step(
+ mutating_fn=mutator_function, remote_applicable_builder=remote_applicable
+ )
+ assert pipe_step.kwargs["input_2"] == 13
+
+
+def test_mutate_end_to_end_simple(import_mutate_module):
+ dr = driver.Builder().with_config({"calc_c": True}).build()
+
dr = (
driver.Builder()
- .with_modules(tests.resources.pipe_output)
+ .with_modules(import_mutate_module)
.with_adapter(base.DefaultAdapter())
.build()
)
+ inputs = {}
+ result = dr.execute(
+ [
+ "downstream_f",
+ "chain_not_using_mutate",
+ ],
+ inputs=inputs,
+ )
+ assert result["downstream_f"] == result["chain_not_using_mutate"]
+
+
+def test_mutate_end_to_end_1(import_mutate_module):
+ dr = (
+ driver.Builder()
+ .with_modules(import_mutate_module)
+ .with_adapter(base.DefaultAdapter())
+ .with_config({"calc_c": True})
+ .build()
+ )
+
inputs = {
"input_1": 10,
"input_2": 20,
@@ -594,12 +779,12 @@ def test_pipe_output_end_to_end_2():
}
result = dr.execute(
[
- "chain_1_using_pipe_output",
- "chain_2_using_pipe_output",
- "chain_1_not_using_pipe_output",
- "chain_2_not_using_pipe_output",
+ "chain_1_using_mutate",
+ "chain_2_using_mutate",
+ "chain_1_not_using_mutate",
+ "chain_2_not_using_mutate",
],
inputs=inputs,
)
- assert result["chain_1_using_pipe_output"] == result["chain_1_not_using_pipe_output"]
- assert result["chain_2_using_pipe_output"] == result["chain_2_not_using_pipe_output"]
+ assert result["chain_1_using_mutate"] == result["chain_1_not_using_mutate"]
+ assert result["chain_2_using_mutate"] == result["chain_2_not_using_mutate"]
diff --git a/tests/resources/mutate.py b/tests/resources/mutate.py
new file mode 100644
index 000000000..0aa212a96
--- /dev/null
+++ b/tests/resources/mutate.py
@@ -0,0 +1,104 @@
+from hamilton.function_modifiers import apply_to, mutate, source, value
+
+
+def upstream() -> str:
+ return "-upstream-"
+
+
+def user_input() -> str:
+ return "-user input-"
+
+
+def f_of_interest(user_input: str) -> str:
+ return user_input + "-raw function-"
+
+
+def downstream_f(f_of_interest: str) -> str:
+ return f_of_interest + "-downstream."
+
+
+@mutate(f_of_interest)
+def _mutate0(x: str) -> str:
+ return x + "-post pipe 0-"
+
+
+@mutate(apply_to(f_of_interest, upstream=source("upstream")).named("random"))
+def _mutate1(x: str, upstream: str) -> str:
+ return x + f"-post pipe 1 with {upstream}-"
+
+
+@mutate(apply_to(f_of_interest, some_val=value(1000)))
+def _mutate2(x: str, some_val: int) -> str:
+ return x + f"-post pipe 2 with value {some_val}-"
+
+
+def chain_not_using_mutate() -> str:
+ t = downstream_f(_mutate2(_mutate1(_mutate0(f_of_interest(user_input())), upstream()), 1000))
+ return t
+
+
+def v() -> int:
+ return 10
+
+
+def chain_1_using_mutate(v: int) -> int:
+ return v * 10
+
+
+def chain_1_not_using_mutate(v: int, input_1: int, input_2: int, calc_c: bool = False) -> int:
+ start = v * 10
+ a = _add_one(start)
+ b = _add_two(a)
+ c = _add_n(b, n=3) if calc_c else b
+ d = _add_n(c, n=input_1)
+ e = _multiply_n(d, n=input_2)
+ return e
+
+
+def chain_2_using_mutate(v: int) -> int:
+ return v + 10
+
+
+def chain_2_not_using_mutate(v: int, input_3: int, calc_c: bool = False) -> int:
+ start = v + 10
+ a = _square(start)
+ b = _multiply_n(a, n=2)
+ return b
+
+
+@mutate(
+ apply_to(chain_2_using_mutate).named("a"),
+)
+def _square(v: int) -> int:
+ return v * v
+
+
+@mutate(
+ apply_to(chain_1_using_mutate).named("a"),
+)
+def _add_one(v: int) -> int:
+ return v + 1
+
+
+@mutate(
+ apply_to(chain_1_using_mutate).named("b"),
+)
+def _add_two(v: int) -> int:
+ return v + 2
+
+
+@mutate(
+ apply_to(chain_1_using_mutate, n=3).named("c").when(calc_c=True),
+ apply_to(chain_1_using_mutate, n=source("input_1")).named("d"),
+)
+def _add_n(v: int, n: int) -> int:
+ return v + n
+
+
+@mutate(
+ apply_to(chain_1_using_mutate).named("e"),
+ apply_to(chain_2_using_mutate, n=value(2)).named("b"),
+ n=source("input_2"),
+)
+def _multiply_n(v: int, *, n: int) -> int:
+ return v * n