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.
+
+
+
+## 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.
+
+ 
+
+ 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()