diff --git a/__init__.py b/__init__.py index 732f849..fb125a8 100644 --- a/__init__.py +++ b/__init__.py @@ -66,6 +66,7 @@ from .modules.pandas_wrapper.pandas_mul import PandasMul from .modules.pandas_wrapper.pandas_mul_scalar_float import PandasMulScalarFloat from .modules.pandas_wrapper.pandas_mul_scalar_int import PandasMulScalarInt from .modules.pandas_wrapper.pandas_mul_series import PandasMulSeries +from .modules.pandas_wrapper.pandas_pivot import PandasPivot from .modules.pandas_wrapper.pandas_pow import PandasPow from .modules.pandas_wrapper.pandas_pow_scalar_float import PandasPowScalarFloat from .modules.pandas_wrapper.pandas_pow_scalar_int import PandasPowScalarInt @@ -177,6 +178,7 @@ NODE_CLASS_MAPPINGS: Dict[str, Type[T]] = { "PandasMulScalarFloat": PandasMulScalarFloat, "PandasMulScalarInt": PandasMulScalarInt, "PandasMulSeries": PandasMulSeries, + "PandasPivot": PandasPivot, "PandasPow": PandasPow, "PandasPowScalarFloat": PandasPowScalarFloat, "PandasPowScalarInt": PandasPowScalarInt, @@ -288,6 +290,7 @@ NODE_DISPLAY_NAME_MAPPINGS: Dict[str, str] = { "PandasMulScalarFloat": "Pandas Mul Scalar Float", "PandasMulScalarInt": "Pandas Mul Scalar Int", "PandasMulSeries": "Pandas Mul Series", + "PandasPivot": "Pandas Pivot", "PandasPow": "Pandas Pow", "PandasPowScalarFloat": "Pandas Pow Scalar Float", "PandasPowScalarInt": "Pandas Pow Scalar Int", diff --git a/docs/images/pivot.png b/docs/images/pivot.png new file mode 100644 index 0000000..32106be Binary files /dev/null and b/docs/images/pivot.png differ diff --git a/docs/reference/node_reference.md b/docs/reference/node_reference.md index 9fac6c9..bc9182b 100644 --- a/docs/reference/node_reference.md +++ b/docs/reference/node_reference.md @@ -136,6 +136,7 @@ | [Pandas Horizontal Concat](pandas_horizontal_concat.md) | Horizontally concatenates two pandas DataFrames. | | [Pandas Horizontal Split](pandas_horizontal_split.md) | Horizontally splits a Pandas DataFrame into two pandas DataFrames. | | [Pandas Join](pandas_join.md) | Merges two pandas DataFrames based on a common column. | +| [Pandas Pivot](pandas_pivot.md) | Computes a pivot table from a pandas DataFrame. | | [Pandas Rename](pandas_rename.md) | Renames an index (row label) or a column label. | | [Pandas Series To Data Frame](pandas_series_to_dataframe.md) | Converts a Pandas Series to a DataFrame. | | [Pandas Set Index](pandas_set_index.md) | Create index (row label) from existing column or columns. | diff --git a/docs/reference/pandas_pivot.md b/docs/reference/pandas_pivot.md new file mode 100644 index 0000000..a3a57bd --- /dev/null +++ b/docs/reference/pandas_pivot.md @@ -0,0 +1,100 @@ +# Pandas Pivot +Computes a pivot table from a pandas DataFrame. + +## Note on a pivot table +A pivot table is easier to understand if we start off from the table itself: +In the below DataFrame: + +``` + Column labels + X Y Z +Index +A 1 2 3 +B 4 5 6 +C 7 8 9 +``` + +We have index `["A", "B", "C"]` and column labels `["X", "Y", "Z"]`. +Values in this DataFrame can be accessed by specifying the index and the column label. +For example: + +```python +df.loc["A", "X"] # 1 +df.loc["A", "Y"] # 2 +``` + +Now, let's restructure the table by converting column labels into row values: + +``` +A X 1 +A Y 2 +A Z 3 +B X 4 +B Y 5 +B Z 6 +C X 7 +C Y 8 +C Z 9 +``` + +Let's add a column label to each of these columns: + +``` +R C V +-------- +A X 1 +A Y 2 +A Z 3 +B X 4 +B Y 5 +B Z 6 +C X 7 +C Y 8 +C Z 9 +``` + +Now, the first two columns uniquely identify the value in the third column. + +**Pivoting** is the reverse of this operation. +- You start with the table above and produce the original one. +- In pandas, this means telling it to use: + - R column: `A`, `B`, `C` as the index (row labels). + - C column: `X`, `Y`, `Z` as the column labels. + - V column: The numbers (`1, 2, 3, ...`) as the values for each cell indexed by `[A, B, or C]` and `[X, Y, or Z]`. + +This can be done using: + +```python +df.pivot(index="R", columns="C", values="V") +``` + +The result: + +``` + X Y Z +A 1 2 3 +B 4 5 6 +C 7 8 9 +``` + +Now the data is back to its original, structured form. + +![Pivot](../images/pivot.png) + +## Input +| Name | Data type | +|---|---| +| dataframe | DataFrame | +| index | String | +| columns | String | +| values | String | + +## Output +| Data type | +|---| +| DataFrame | + +
+Category: Transformation + +ComfyUI Data Analysis Node Reference. © 2025 Hide Inada (HowToSD.com). All rights reserved. diff --git a/examples/workflows/pivot.json b/examples/workflows/pivot.json new file mode 100644 index 0000000..e53e72a --- /dev/null +++ b/examples/workflows/pivot.json @@ -0,0 +1 @@ +{"last_node_id":5,"last_link_id":3,"nodes":[{"id":3,"type":"PandasShowDataFrame","pos":[-1654.97607421875,6915.33837890625],"size":[210,191.09849548339844],"flags":{},"order":1,"mode":0,"inputs":[{"name":"dataframe","type":"DATAFRAME","link":1,"localized_name":"dataframe"}],"outputs":[{"name":"STRING","type":"STRING","links":null,"shape":6,"localized_name":"STRING"}],"properties":{"Node name for S&R":"PandasShowDataFrame"},"widgets_values":[" R C V\n0 A X 1\n1 A Y 2\n2 A Z 3\n3 B X 4\n4 B Y 5\n5 B Z 6\n6 C X 7\n7 C Y 8\n8 C Z 9"]},{"id":4,"type":"PandasPivot","pos":[-1655.634765625,7191.73486328125],"size":[210,106],"flags":{},"order":2,"mode":0,"inputs":[{"name":"dataframe","type":"DATAFRAME","link":3,"localized_name":"dataframe"}],"outputs":[{"name":"DATAFRAME","type":"DATAFRAME","links":[2],"slot_index":0,"localized_name":"DATAFRAME"}],"properties":{"Node name for S&R":"PandasPivot"},"widgets_values":["R","C","V"]},{"id":5,"type":"PandasShowDataFrame","pos":[-1402.3653564453125,7191.20849609375],"size":[225.09877014160156,123.6739273071289],"flags":{},"order":3,"mode":0,"inputs":[{"name":"dataframe","type":"DATAFRAME","link":2,"localized_name":"dataframe"}],"outputs":[{"name":"STRING","type":"STRING","links":null,"shape":6,"localized_name":"STRING"}],"properties":{"Node name for S&R":"PandasShowDataFrame"},"widgets_values":["C X Y Z\nR \nA 1 2 3\nB 4 5 6\nC 7 8 9"]},{"id":2,"type":"PandasCreate","pos":[-1912.679931640625,6913.57373046875],"size":[210,188.82322692871094],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[{"name":"DATAFRAME","type":"DATAFRAME","links":[1,3],"slot_index":0,"localized_name":"DATAFRAME"}],"properties":{"Node name for S&R":"PandasCreate"},"widgets_values":["R,C,V\nA,X,1\nA,Y,2\nA,Z,3\nB,X,4\nB,Y,5\nB,Z,6\nC,X,7\nC,Y,8\nC,Z,9"]}],"links":[[1,2,0,3,0,"DATAFRAME"],[2,4,0,5,0,"DATAFRAME"],[3,2,0,4,0,"DATAFRAME"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.4103714517271915,"offset":[4188.132094541789,-5896.532448779008]},"node_versions":{"data-analysis":"8c154c826fa5684b581ad37159b2f85935da19b5"},"ue_links":[]},"version":0.4} \ No newline at end of file diff --git a/modules/pandas_wrapper/pandas_pivot.py b/modules/pandas_wrapper/pandas_pivot.py new file mode 100644 index 0000000..4c54db1 --- /dev/null +++ b/modules/pandas_wrapper/pandas_pivot.py @@ -0,0 +1,140 @@ +from typing import Any, Dict +import pandas as pd +from .utils import column_labels_string_to_list + +class PandasPivot: + """ + Computes a pivot table from a pandas DataFrame. + + ## Note on a pivot table + A pivot table is easier to understand if we start off from the table itself: + In the below DataFrame: + + ``` + Column labels + X Y Z + Index + A 1 2 3 + B 4 5 6 + C 7 8 9 + ``` + + We have index `["A", "B", "C"]` and column labels `["X", "Y", "Z"]`. + Values in this DataFrame can be accessed by specifying the index and the column label. + For example: + + ```python + df.loc["A", "X"] # 1 + df.loc["A", "Y"] # 2 + ``` + + Now, let's restructure the table by converting column labels into row values: + + ``` + A X 1 + A Y 2 + A Z 3 + B X 4 + B Y 5 + B Z 6 + C X 7 + C Y 8 + C Z 9 + ``` + + Let's add a column label to each of these columns: + + ``` + R C V + -------- + A X 1 + A Y 2 + A Z 3 + B X 4 + B Y 5 + B Z 6 + C X 7 + C Y 8 + C Z 9 + ``` + + Now, the first two columns uniquely identify the value in the third column. + + **Pivoting** is the reverse of this operation. + - You start with the table above and produce the original one. + - In pandas, this means telling it to use: + - R column: `A`, `B`, `C` as the index (row labels). + - C column: `X`, `Y`, `Z` as the column labels. + - V column: The numbers (`1, 2, 3, ...`) as the values for each cell indexed by `[A, B, or C]` and `[X, Y, or Z]`. + + This can be done using: + + ```python + df.pivot(index="R", columns="C", values="V") + ``` + + The result: + + ``` + X Y Z + A 1 2 3 + B 4 5 6 + C 7 8 9 + ``` + + Now the data is back to its original, structured form. + + ![Pivot](../images/pivot.png) + category: Transformation + """ + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Any]: + """ + Defines the input types for the `select_columns` function. + + Returns: + Dict[str, Any]: A dictionary specifying required input types. + """ + return { + "required": { + "dataframe": ("DATAFRAME", {}), + "index": ("STRING", {"default": ""}), + "columns": ("STRING", {"default": ""}), + "values": ("STRING", {"default": ""}), + } + } + + RETURN_TYPES: tuple = ("DATAFRAME",) + FUNCTION: str = "f" + CATEGORY: str = "Data Analysis" + + def f(self, dataframe: pd.DataFrame, + index: str, + columns: str, + values: str) -> tuple: + """ + Selects specific columns from a pandas DataFrame. + + Args: + dataframe (DataFrame): The DataFrame. + index (str): A comma-separated string of column names to use as index in the pivot table. + columns (str): A comma-separated string of column names to use as columns in the pivot table. + values (str): A comma-separated string of column names to use as values in the pivot table. + + Returns: + tuple: A tuple containing the DataFrame. + """ + ind = column_labels_string_to_list(dataframe, index) + col = column_labels_string_to_list(dataframe, columns) + val = column_labels_string_to_list(dataframe, values) + + if len(ind) == 1: + ind = ind[0] + if len(col) == 1: + col = col + if len(val) == 1: + val = val[0] + + df2 = dataframe.pivot(index=ind, columns=col, values=val) + return (df2,) diff --git a/pyproject.toml b/pyproject.toml index f387b11..44c966c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "data-analysis" description = "Data analysis custom modules for ComfyUI - Use Pandas & Matplotlib within ComfyUI" -version = "1.1.22" +version = "1.1.23" license = {file = "LICENSE"} [project.urls] diff --git a/tests/pandas_wrapper/pandas_pivot_test.py b/tests/pandas_wrapper/pandas_pivot_test.py new file mode 100644 index 0000000..9cda5d4 --- /dev/null +++ b/tests/pandas_wrapper/pandas_pivot_test.py @@ -0,0 +1,107 @@ +import os +import sys +import unittest +import pandas as pd + +PROJECT_ROOT = os.path.realpath(os.path.join(__file__, "..", "..", "..")) +MODULE_ROOT = os.path.join(PROJECT_ROOT, "modules") +sys.path.append(MODULE_ROOT) + +from pandas_wrapper.pandas_pivot import PandasPivot + +class TestPandasPivot(unittest.TestCase): + def setUp(self): + """Set up test data""" + self.node = PandasPivot() + + self.df = pd.DataFrame({ + "A": ["X", "X", "Y", "Y", "Z", "Z"], + "B": ["M", "N", "M", "N", "M", "N"], + "C": [1, 2, 3, 4, 5, 6], + "D": [10, 20, 30, 40, 50, 60], + "E": [100, 200, 300, 400, 500, 600] + }) + + def test_pivot_single_index_single_column(self): + """Test pivot with a single index and a single column""" + result = self.node.f(self.df, index="A", columns="B", values="C")[0] + expected = self.df.pivot(index="A", columns="B", values="C") + pd.testing.assert_frame_equal(result, expected) + + def test_pivot_multi_index_single_column(self): + """Test pivot with multiple indices and a single column""" + result = self.node.f(self.df, index="A,B", columns="C", values="D")[0] + expected = self.df.pivot(index=["A", "B"], columns="C", values="D") + pd.testing.assert_frame_equal(result, expected) + + def test_pivot_single_index_multi_column(self): + """Test pivot with a single index and multiple columns""" + result = self.node.f(self.df, index="A", columns="B,C", values="D")[0] + expected = self.df.pivot(index="A", columns=["B", "C"], values="D") + pd.testing.assert_frame_equal(result, expected) + + def test_pivot_multi_index_multi_column(self): + """Test pivot with multiple indices and multiple columns""" + result = self.node.f(self.df, index="A,B", columns="C,E", values="D")[0] + expected = self.df.pivot(index=["A", "B"], columns=["C", "E"], values="D") + pd.testing.assert_frame_equal(result, expected) + + def test_pivot_single_index_multi_values(self): + """Test pivot with a single index and multiple value columns""" + result = self.node.f(self.df, index="A", columns="B", values="C,D")[0] + expected = self.df.pivot(index="A", columns="B", values=["C", "D"]) + pd.testing.assert_frame_equal(result, expected) + + def test_pivot_multi_index_multi_values(self): + """Test pivot with multiple indices and multiple value columns""" + result = self.node.f(self.df, index="A,B", columns="C", values="D,E")[0] + expected = self.df.pivot(index=["A", "B"], columns="C", values=["D", "E"]) + pd.testing.assert_frame_equal(result, expected) + + def test_pivot_index_not_in_dataframe(self): + """Test pivot when the given index is not in the DataFrame""" + with self.assertRaises(ValueError): + self.node.f(self.df, index="Z", columns="B", values="C")[0] + + def test_pivot_columns_not_in_dataframe(self): + """Test pivot when the given column is not in the DataFrame""" + with self.assertRaises(ValueError): + self.node.f(self.df, index="A", columns="Z", values="C")[0] + + def test_pivot_values_not_in_dataframe(self): + """Test pivot when the given value column is not in the DataFrame""" + with self.assertRaises(ValueError): + self.node.f(self.df, index="A", columns="B", values="Z")[0] + + def test_pivot_duplicate_index_column_pairs(self): + """Test pivot when index-column pairs are not unique (should raise an error)""" + df_dup = self.df.copy() + df_dup.loc[1, "B"] = "M" # Introduce a duplicate index-column pair + with self.assertRaises(ValueError): + self.node.f(df_dup, index="A", columns="B", values="C")[0] + + def test_pivot_from_long_to_wide(self): + """Test pivot where column labels become row values before pivoting. + This is the test case referenced in the docstring.""" + df_long = pd.DataFrame({ + "R": ["A", "A", "A", "B", "B", "B", "C", "C", "C"], + "C": ["X", "Y", "Z", "X", "Y", "Z", "X", "Y", "Z"], + "V": [1, 2, 3, 4, 5, 6, 7, 8, 9] + }) + + result = self.node.f(df_long, index="R", columns="C", values="V")[0] + expected = pd.DataFrame({ + "X": [1, 4, 7], + "Y": [2, 5, 8], + "Z": [3, 6, 9] + }, index=["A", "B", "C"]) + + # Explicitly set index and column names to match pivot output + expected.index.name = "R" + expected.columns.name = "C" + + pd.testing.assert_frame_equal(result, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/pandas_wrapper/utils_column_labels_string_to_list.py b/tests/pandas_wrapper/utils_column_labels_string_to_list_test.py similarity index 100% rename from tests/pandas_wrapper/utils_column_labels_string_to_list.py rename to tests/pandas_wrapper/utils_column_labels_string_to_list_test.py