diff --git a/__init__.py b/__init__.py index 9495c18..3a8bc5a 100644 --- a/__init__.py +++ b/__init__.py @@ -33,6 +33,7 @@ from .modules.pandas_wrapper.pandas_create_from_multiple_dict import PandasCreat from .modules.pandas_wrapper.pandas_create_from_numpy import PandasCreateFromNumpy from .modules.pandas_wrapper.pandas_create_series_from_dict import PandasCreateSeriesFromDict from .modules.pandas_wrapper.pandas_create_with_index import PandasCreateWithIndex +from .modules.pandas_wrapper.pandas_crosstab import PandasCrosstab from .modules.pandas_wrapper.pandas_cummax import PandasCummax from .modules.pandas_wrapper.pandas_cummin import PandasCummin from .modules.pandas_wrapper.pandas_cumprod import PandasCumprod @@ -111,6 +112,7 @@ from .modules.pandas_wrapper.pandas_to_datetime import PandasToDatetime from .modules.pandas_wrapper.pandas_to_numpy import PandasToNumpy from .modules.pandas_wrapper.pandas_to_string import PandasToString from .modules.pandas_wrapper.pandas_transpose import PandasTranspose +from .modules.pandas_wrapper.pandas_value_counts import PandasValueCounts from .modules.pandas_wrapper.pandas_var import PandasVar from .modules.pandas_wrapper.pandas_vertical_concat import PandasVerticalConcat from .modules.pandas_wrapper.pandas_vertical_split import PandasVerticalSplit @@ -172,6 +174,7 @@ NODE_CLASS_MAPPINGS: Dict[str, Type[T]] = { "PandasCreateFromNumpy": PandasCreateFromNumpy, "PandasCreateSeriesFromDict": PandasCreateSeriesFromDict, "PandasCreateWithIndex": PandasCreateWithIndex, + "PandasCrosstab": PandasCrosstab, "PandasCummax": PandasCummax, "PandasCummin": PandasCummin, "PandasCumprod": PandasCumprod, @@ -250,6 +253,7 @@ NODE_CLASS_MAPPINGS: Dict[str, Type[T]] = { "PandasToNumpy": PandasToNumpy, "PandasToString": PandasToString, "PandasTranspose": PandasTranspose, + "PandasValueCounts": PandasValueCounts, "PandasVar": PandasVar, "PandasVerticalConcat": PandasVerticalConcat, "PandasVerticalSplit": PandasVerticalSplit, @@ -314,6 +318,7 @@ NODE_DISPLAY_NAME_MAPPINGS: Dict[str, str] = { "PandasCreateFromNumpy": "Pandas Create From Numpy", "PandasCreateSeriesFromDict": "Pandas Create Series From Dict", "PandasCreateWithIndex": "Pandas Create With Index", + "PandasCrosstab": "Pandas Crosstab", "PandasCummax": "Pandas Cummax", "PandasCummin": "Pandas Cummin", "PandasCumprod": "Pandas Cumprod", @@ -392,6 +397,7 @@ NODE_DISPLAY_NAME_MAPPINGS: Dict[str, str] = { "PandasToNumpy": "Pandas To Numpy", "PandasToString": "Pandas To String", "PandasTranspose": "Pandas Transpose", + "PandasValueCounts": "Pandas Value Counts", "PandasVar": "Pandas Var", "PandasVerticalConcat": "Pandas Vertical Concat", "PandasVerticalSplit": "Pandas Vertical Split", diff --git a/docs/images/frequency_table.png b/docs/images/frequency_table.png new file mode 100644 index 0000000..64e4380 Binary files /dev/null and b/docs/images/frequency_table.png differ diff --git a/docs/reference/node_reference.md b/docs/reference/node_reference.md index 4812790..0d41364 100644 --- a/docs/reference/node_reference.md +++ b/docs/reference/node_reference.md @@ -26,6 +26,11 @@ | [Pandas Sub Scalar Float](pandas_sub_scalar_float.md) | Subtracts a floating-point number from a Pandas DataFrame. | | [Pandas Sub Scalar Int](pandas_sub_scalar_int.md) | Subtracts an integer from a Pandas DataFrame. | | [Pandas Sub Series](pandas_sub_series.md) | Subtracts a Pandas Series from a Pandas DataFrame. | +## Categorical summary +| Node | Description | +| --- | --- | +| [Pandas Crosstab](pandas_crosstab.md) | Creates a contingency table (crosstab, or cross-tabulation) from two or more columns in a DataFrame. | +| [Pandas Value Counts](pandas_value_counts.md) | Creates a frequency table from one or more columns in a DataFrame. | ## Cumulative calculations | Node | Description | | --- | --- | diff --git a/docs/reference/pandas_crosstab.md b/docs/reference/pandas_crosstab.md new file mode 100644 index 0000000..721bd15 --- /dev/null +++ b/docs/reference/pandas_crosstab.md @@ -0,0 +1,19 @@ +# Pandas Crosstab +Creates a contingency table (crosstab, or cross-tabulation) from two or more columns in a DataFrame. + +## Input +| Name | Data type | +|---|---| +| dataframe | DataFrame | +| index | String | +| column_names | String | + +## Output +| Data type | +|---| +| DataFrame | + +
+Category: Categorical summary + +ComfyUI Data Analysis Node Reference. © 2025 Hide Inada (HowToSD.com). All rights reserved. diff --git a/docs/reference/pandas_value_counts.md b/docs/reference/pandas_value_counts.md new file mode 100644 index 0000000..42cc382 --- /dev/null +++ b/docs/reference/pandas_value_counts.md @@ -0,0 +1,20 @@ +# Pandas Value Counts +Creates a frequency table from one or more columns in a DataFrame. + +![Frequency table](../images/frequency_table.png) + +## Input +| Name | Data type | +|---|---| +| dataframe | DataFrame | +| column_names | String | + +## Output +| Data type | +|---| +| DataFrame | + +
+Category: Categorical summary + +ComfyUI Data Analysis Node Reference. © 2025 Hide Inada (HowToSD.com). All rights reserved. diff --git a/examples/workflows/frequency_table.json b/examples/workflows/frequency_table.json new file mode 100644 index 0000000..57aba0e --- /dev/null +++ b/examples/workflows/frequency_table.json @@ -0,0 +1 @@ +{"last_node_id":6,"last_link_id":4,"nodes":[{"id":2,"type":"PandasCreate","pos":[2785.739990234375,687.7178344726562],"size":[210,155.83331298828125],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[{"name":"DATAFRAME","type":"DATAFRAME","links":[1],"slot_index":0}],"properties":{"Node name for S&R":"PandasCreate"},"widgets_values":["Fruits\napple\napple\napple\nbanana\nbanana\ncoconut"]},{"id":1,"type":"PandasValueCounts","pos":[3036.5732421875,688.5511474609375],"size":[315,58],"flags":{},"order":2,"mode":0,"inputs":[{"name":"dataframe","type":"DATAFRAME","link":1}],"outputs":[{"name":"DATAFRAME","type":"DATAFRAME","links":[2],"slot_index":0}],"properties":{"Node name for S&R":"PandasValueCounts"},"widgets_values":["Fruits"]},{"id":4,"type":"PandasCreate","pos":[2783.60302734375,927.6964111328125],"size":[210,156.66668701171875],"flags":{},"order":1,"mode":0,"inputs":[],"outputs":[{"name":"DATAFRAME","type":"DATAFRAME","links":[3],"slot_index":0}],"properties":{"Node name for S&R":"PandasCreate"},"widgets_values":["Fruits,Color\napple,red\napple,red\napple,yellow\nbanana,yellow\nbanana,green\ncoconut,brown"]},{"id":3,"type":"PandasShowDataFrame","pos":[3390.740234375,689.3844604492188],"size":[210,116.83334350585938],"flags":{},"order":4,"mode":0,"inputs":[{"name":"dataframe","type":"DATAFRAME","link":2}],"outputs":[{"name":"STRING","type":"STRING","links":null,"shape":6}],"properties":{"Node name for S&R":"PandasShowDataFrame"},"widgets_values":[" Fruits count\n0 apple 3\n1 banana 2\n2 coconut 1"]},{"id":5,"type":"PandasValueCounts","pos":[3034.4365234375,930.1962280273438],"size":[315,58],"flags":{},"order":3,"mode":0,"inputs":[{"name":"dataframe","type":"DATAFRAME","link":3}],"outputs":[{"name":"DATAFRAME","type":"DATAFRAME","links":[4],"slot_index":0}],"properties":{"Node name for S&R":"PandasValueCounts"},"widgets_values":["Fruits,Color"]},{"id":6,"type":"PandasShowDataFrame","pos":[3391.93603515625,930.1962890625],"size":[237.0999755859375,134.33334350585938],"flags":{},"order":5,"mode":0,"inputs":[{"name":"dataframe","type":"DATAFRAME","link":4}],"outputs":[{"name":"STRING","type":"STRING","links":null,"shape":6}],"properties":{"Node name for S&R":"PandasShowDataFrame"},"widgets_values":[" Fruits Color count\n0 apple red 2\n1 apple yellow 1\n2 banana green 1\n3 banana yellow 1\n4 coconut brown 1"]}],"links":[[1,2,0,1,0,"DATAFRAME"],[2,1,0,3,0,"DATAFRAME"],[3,4,0,5,0,"DATAFRAME"],[4,5,0,6,0,"DATAFRAME"]],"groups":[],"config":{},"extra":{"ds":{"scale":1,"offset":[-2487.817049646748,-548.6010443750537]},"node_versions":{"data-analysis":"e6beae73ef53e21d561aaf8ca714e350de59ec7d"},"ue_links":[]},"version":0.4} \ No newline at end of file diff --git a/modules/pandas_wrapper/pandas_crosstab.py b/modules/pandas_wrapper/pandas_crosstab.py new file mode 100644 index 0000000..4e96313 --- /dev/null +++ b/modules/pandas_wrapper/pandas_crosstab.py @@ -0,0 +1,57 @@ +from typing import Any, Dict +import pandas as pd +from .utils import column_labels_string_to_list + + +class PandasCrosstab: + """ + Creates a contingency table (crosstab, or cross-tabulation) from two or more columns in a DataFrame. + + category: Categorical summary + """ + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Any]: + """ + Defines the input types for the function. + + Returns: + Dict[str, Any]: A dictionary specifying required input types. + """ + return { + "required": { + "dataframe": ("DATAFRAME", {}), + "index": ("STRING", {"default": ""}), + "column_names": ("STRING", {"default": ""}) + } + } + + RETURN_TYPES: tuple = ("DATAFRAME",) + FUNCTION: str = "f" + CATEGORY: str = "Data Analysis" + + def f(self, dataframe: pd.DataFrame, index: str, column_names: str) -> tuple: + """ + Creates a contingency table (crosstab, or cross-tabulation) from two or more columns in a DataFrame. + + Args: + dataframe (DataFrame): The DataFrame. + index (str): A comma-separated list of index (row labels) containing categorical data. + column_names (str): A comma-separated list of column names containing categorical data. + + Returns: + tuple: A tuple containing the DataFrame. + """ + rows = column_labels_string_to_list(dataframe, index) + cols = column_labels_string_to_list(dataframe, column_names) + + if len(rows) == 1: + rows = dataframe[rows[0]] + else: + rows = [dataframe[e] for e in rows] + if len(cols) == 1: + cols = dataframe[cols[0]] + else: + cols = [dataframe[e] for e in cols] + df2 = pd.crosstab(index=rows, columns=cols) + return (df2,) diff --git a/modules/pandas_wrapper/pandas_value_counts.py b/modules/pandas_wrapper/pandas_value_counts.py new file mode 100644 index 0000000..effc485 --- /dev/null +++ b/modules/pandas_wrapper/pandas_value_counts.py @@ -0,0 +1,80 @@ +from typing import Any, Dict +import pandas as pd +from .utils import column_labels_string_to_list + + +class PandasValueCounts: + """ + Creates a frequency table from one or more columns in a DataFrame. + + ![Frequency table](../images/frequency_table.png) + + category: Categorical summary + """ + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Any]: + """ + Defines the input types for the function. + + Returns: + Dict[str, Any]: A dictionary specifying required input types. + """ + return { + "required": { + "dataframe": ("DATAFRAME", {}), + "column_names": ("STRING", {"default": ""}) + } + } + + RETURN_TYPES: tuple = ("DATAFRAME",) + FUNCTION: str = "f" + CATEGORY: str = "Data Analysis" + + def f(self, dataframe: pd.DataFrame, column_names: str) -> tuple: + """ + Creates a frequency tableConverts one or more columns in a DataFrame. + + Args: + dataframe (DataFrame): The DataFrame. + column_names (str): A comma-separated list of column names containing categorical data. + + Returns: + tuple: A tuple containing the DataFrame with selected columns. + """ + + # Note + # If the original DataFrame contains a single column with categorical variables: + # + # Category + # -------- + # A + # A + # A + # B + # B + # C + # C + # + # and you want to compute the count of each variable and call value_counts(). + # After value_counts(), the column entries are became the index in a generated Series: + # df["Category"].value_counts().to_frame() + # Category + # A 3 + # B 2 + # C 2 + # + # After you call reset_index, A, B, C are converted back to a column, but the column label + # is set to "index". + # Therefore, you need to do the following: + # * Convert a Series to a DataFrame (to_frame) + # * Rename the count column to Count (rename) + # * Convert the original column from index to a column (reset_index) + # * Restore the original column label (rename) + cols = column_labels_string_to_list(dataframe, column_names) + if len(cols) == 1: + col_label = cols[0] + df2 = dataframe[col_label].value_counts().to_frame().rename(columns={col_label:"Count"}).reset_index().rename(columns={"index":col_label}) + else: + df2 = dataframe[cols].value_counts().to_frame().rename(columns={0:"Count"}).reset_index() + return (df2,) diff --git a/pyproject.toml b/pyproject.toml index 5bbb3dd..02e7b42 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.3.3" +version = "1.3.4" license = {file = "LICENSE"} [project.urls] diff --git a/tests/pandas_wrapper/pandas_crosstab_test.py b/tests/pandas_wrapper/pandas_crosstab_test.py new file mode 100644 index 0000000..da5c77a --- /dev/null +++ b/tests/pandas_wrapper/pandas_crosstab_test.py @@ -0,0 +1,59 @@ +import os +import sys +import unittest +import pandas as pd + +PROJECT_ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "..")) +MODULE_ROOT = os.path.join(PROJECT_ROOT, "modules") +sys.path.append(MODULE_ROOT) + +from pandas_wrapper.pandas_crosstab import PandasCrosstab + +class TestPandasCrosstab(unittest.TestCase): + def setUp(self): + """Set up test data""" + self.node = PandasCrosstab() + + 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] + }) + + def test_crosstab_single_index_single_column(self): + """Test crosstab with a single index and a single column""" + result = self.node.f(self.df, index="A", column_names="B")[0] + expected = pd.crosstab(index=self.df["A"], columns=self.df["B"]) + pd.testing.assert_frame_equal(result, expected) + + def test_crosstab_multi_index_single_column(self): + """Test crosstab with multiple indices and a single column""" + result = self.node.f(self.df, index="A,B", column_names="C")[0] + expected = pd.crosstab(index=[self.df["A"], self.df["B"]], columns=self.df["C"]) + pd.testing.assert_frame_equal(result, expected) + + def test_crosstab_single_index_multi_column(self): + """Test crosstab with a single index and multiple columns""" + result = self.node.f(self.df, index="A", column_names="B,C")[0] + expected = pd.crosstab(index=self.df["A"], columns=[self.df["B"], self.df["C"]]) + pd.testing.assert_frame_equal(result, expected) + + def test_crosstab_multi_index_multi_column(self): + """Test crosstab with multiple indices and multiple columns""" + result = self.node.f(self.df, index="A,B", column_names="C,D")[0] + expected = pd.crosstab(index=[self.df["A"], self.df["B"]], columns=[self.df["C"], self.df["D"]]) + pd.testing.assert_frame_equal(result, expected) + + def test_crosstab_index_not_in_dataframe(self): + """Test crosstab when the given index is not in the DataFrame""" + with self.assertRaises(ValueError): + self.node.f(self.df, index="Z", column_names="B")[0] + + def test_crosstab_columns_not_in_dataframe(self): + """Test crosstab when the given column is not in the DataFrame""" + with self.assertRaises(ValueError): + self.node.f(self.df, index="A", column_names="Z")[0] + +if __name__ == "__main__": + unittest.main() diff --git a/tests/pandas_wrapper/pandas_value_counts_test.py b/tests/pandas_wrapper/pandas_value_counts_test.py new file mode 100644 index 0000000..50d5dec --- /dev/null +++ b/tests/pandas_wrapper/pandas_value_counts_test.py @@ -0,0 +1,56 @@ +import os +import sys +import unittest +import pandas as pd + +PROJECT_ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "..")) +MODULE_ROOT = os.path.join(PROJECT_ROOT, "modules") +sys.path.append(MODULE_ROOT) + +from pandas_wrapper.pandas_value_counts import PandasValueCounts + +class TestPandasValueCounts(unittest.TestCase): + def setUp(self): + """Set up test data""" + self.node = PandasValueCounts() + + self.df = pd.DataFrame({ + "Category": ["A", "B", "A", "C", "B", "A", "C"], + "Numbers": [1, 2, 1, 3, 2, 1, 3], + "Letter": ["X", "Y", "X", "Z", "Y", "X", "Z"], + 100: ["M", "N", "M", "O", "N", "M", "O"], # Integer-labeled column + 200: [10, 20, 10, 30, 20, 10, 30] # Another integer-labeled column + }) + + def test_single_string_column_label(self): + """Test value_counts on a single string column label""" + result = self.node.f(self.df, "Category") + expected = self.df["Category"].value_counts().to_frame().rename(columns={"Category": "Count"}).reset_index().rename(columns={"index": "Category"}) + pd.testing.assert_frame_equal(result[0], expected) + + def test_single_integer_column_label(self): + """Test value_counts on a single integer column label""" + result = self.node.f(self.df, "100") + expected = self.df[100].value_counts().to_frame().rename(columns={100: "Count"}).reset_index().rename(columns={"index": 100}) + pd.testing.assert_frame_equal(result[0], expected) + + def test_multiple_string_column_labels(self): + """Test value_counts on multiple string column labels""" + result = self.node.f(self.df, "Category,Letter") + expected = self.df[["Category", "Letter"]].value_counts().to_frame().rename(columns={0:"Count"}).reset_index() + pd.testing.assert_frame_equal(result[0], expected) + + def test_multiple_integer_column_labels(self): + """Test value_counts on multiple integer column labels""" + result = self.node.f(self.df, "100,200") + expected = self.df[[100, 200]].value_counts().to_frame().rename(columns={0:"Count"}).reset_index() + pd.testing.assert_frame_equal(result[0], expected) + + def test_mixed_string_integer_column_labels(self): + """Test value_counts on a mix of string and integer column labels""" + result = self.node.f(self.df, "Category,100") + expected = self.df[["Category", 100]].value_counts().to_frame().rename(columns={0:"Count"}).reset_index() + pd.testing.assert_frame_equal(result[0], expected) + +if __name__ == "__main__": + unittest.main()