This is an automated email from the ASF dual-hosted git repository.

shunping pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 5c153878385 [Python] Handle NaN in DataFrame drop_duplicates, 
duplicated and nunique (#40571)
5c153878385 is described below

commit 5c153878385e27ad1a67580d99d07da9a38ef94f
Author: Anish Mehta <[email protected]>
AuthorDate: Fri Oct 23 22:33:10 2026 +0530

    [Python] Handle NaN in DataFrame drop_duplicates, duplicated and nunique 
(#40571)
    
    Co-authored-by: Shunping Huang <[email protected]>
---
 CHANGES.md                                       |  1 +
 sdks/python/apache_beam/dataframe/frames.py      | 51 ++++++++++++++++++++----
 sdks/python/apache_beam/dataframe/frames_test.py | 19 +++++++++
 3 files changed, 63 insertions(+), 8 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 9d3f6fbac24..33e66880343 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -135,6 +135,7 @@
 * (Python) Setting `max_batch_weight` on a RunInference `ModelHandler` no 
longer fails with a `TypeError`: `BatchElements` now supports 
`max_batch_weight`, which limits the total `element_size_fn` of a batch in 
addition to its number of elements 
([#40644](https://github.com/apache/beam/issues/40644)).
 * (Python) `BatchElements` now honors `element_size_fn` when 
`max_batch_duration_secs` is set, and no longer emits an empty batch before an 
element that exceeds the batch size on its own 
([#40644](https://github.com/apache/beam/issues/40644)).
 * (Python) `KeyedModelHandler` and `MaybeKeyedModelHandler` now apply the 
`element_size_fn` and `batch_length_fn` of the wrapped `ModelHandler` to 
examples, instead of to `(key, example)` pairs 
([#40644](https://github.com/apache/beam/issues/40644)).
+* (Python) DataFrame API: `drop_duplicates`, `duplicated` and `nunique` no 
longer fail on rows with NaN values or on categorical columns with unused 
categories, and `nunique(dropna=False)` now counts NaN 
([#40570](https://github.com/apache/beam/issues/40570)).
 * (Java) BigQuery Storage Write API `TableRow` conversion now honors 
`allowMissingRequiredFields` when building protocol-buffer messages, so callers 
that pass `true` with required descriptors get partial messages instead of a 
conversion-time `UninitializedMessageException` 
([#40189](https://github.com/apache/beam/issues/40189)).
 * Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)).
 
diff --git a/sdks/python/apache_beam/dataframe/frames.py 
b/sdks/python/apache_beam/dataframe/frames.py
index 4b62fed72f0..f99df35afe8 100644
--- a/sdks/python/apache_beam/dataframe/frames.py
+++ b/sdks/python/apache_beam/dataframe/frames.py
@@ -1589,7 +1589,9 @@ class DeferredSeries(DeferredDataFrameOrSeries):
   @frame_base.with_docs_from(pd.Series)
   @frame_base.args_to_kwargs(pd.Series)
   @frame_base.populate_defaults(pd.Series)
-  def nunique(self, **kwargs):
+  def nunique(self, dropna, **kwargs):
+    if dropna:
+      self = self.dropna()  # pylint: disable=self-cls-assignment
     return self.drop_duplicates(keep="any").size
 
   @frame_base.with_docs_from(pd.Series)
@@ -2843,11 +2845,12 @@ class DeferredDataFrame(DeferredDataFrameOrSeries):
           "keep=\"any\" are supported.",
           reason="order-sensitive")
 
-    by = subset or list(self.columns)
-
-    return self.groupby(by).apply(
-        lambda df: pd.DataFrame(df.duplicated(keep=keep, subset=subset),
-                                columns=[None]))[None].droplevel(by)
+    return self._apply_with_duplicates_colocated(
+        'duplicated',
+        # set_axis keeps the index when df is empty (e.g. for the proxy),
+        # pandas returns a default index in that case.
+        lambda df: df.duplicated(keep=keep, subset=subset).set_axis(df.index),
+        subset)
 
   @frame_base.with_docs_from(pd.DataFrame)
   @frame_base.args_to_kwargs(pd.DataFrame)
@@ -2876,10 +2879,42 @@ class DeferredDataFrame(DeferredDataFrameOrSeries):
           "the data.",
           reason="order-sensitive")
 
+    return self._apply_with_duplicates_colocated(
+        'drop_duplicates',
+        lambda df: df.drop_duplicates(keep=keep, subset=subset),
+        subset)
+
+  def _apply_with_duplicates_colocated(self, name, func, subset):
+    """Applies ``func`` to partitions in which all duplicate rows (considering
+    only the columns in ``subset``) are colocated.
+
+    Rows are colocated by a hash of their values instead of with groupby,
+    which drops rows with NaN values and fails on unobserved categories."""
     by = subset or list(self.columns)
+    hash_level = '__apache_beam_duplicates_hash'
+
+    def add_hash_level(df):
+      key = df[by].copy()
+      for col in key.columns:
+        if key[col].dtype.kind in 'fc':
+          # -0.0 and 0.0 hash differently but are duplicates in pandas
+          key[col] = key[col] + 0
+      hashes = pd.util.hash_pandas_object(key, index=False)
+      return df.set_index(
+          pd.Index(hashes.to_numpy(), name=hash_level), append=True)
+
+    with_hash = expressions.ComputedExpression(
+        'add_hash_level',
+        add_hash_level, [self._expr],
+        requires_partition_by=partitionings.Arbitrary(),
+        preserves_partition_by=partitionings.Singleton())
 
-    return self.groupby(by).apply(
-        lambda df: df.drop_duplicates(keep=keep, subset=subset)).droplevel(by)
+    return frame_base.DeferredFrame.wrap(
+        expressions.ComputedExpression(
+            name,
+            lambda df: func(df.droplevel(hash_level)), [with_hash],
+            requires_partition_by=partitionings.Index([hash_level]),
+            preserves_partition_by=partitionings.Singleton()))
 
   @frame_base.with_docs_from(pd.DataFrame)
   @frame_base.args_to_kwargs(pd.DataFrame)
diff --git a/sdks/python/apache_beam/dataframe/frames_test.py 
b/sdks/python/apache_beam/dataframe/frames_test.py
index 00400045200..fbe3ef36d80 100644
--- a/sdks/python/apache_beam/dataframe/frames_test.py
+++ b/sdks/python/apache_beam/dataframe/frames_test.py
@@ -1199,6 +1199,25 @@ class DeferredFrameTest(_AbstractFrameTest):
         lambda df: df.drop_duplicates(subset=['brand', 'style'], keep=False),
         df)
 
+  def test_duplicates_with_nan(self):
+    df = pd.DataFrame({
+        'a': [1.0, 1.0, np.nan, np.nan, 2.0, 3.0, 0.0, -0.0],
+        'b': ['x', 'x', 'y', 'y', None, 'z', 'w', 'w'],
+    })
+
+    self._run_test(lambda df: df.drop_duplicates(keep=False), df)
+    self._run_test(lambda df: df.duplicated(keep=False), df)
+    self._run_test(lambda df: df.drop_duplicates(subset=['b'], keep=False), df)
+
+    s = pd.Series([1.0, 1.0, np.nan, 2.0], name='a')
+    self._run_test(lambda s: s.drop_duplicates(keep=False), s)
+    self._run_test(lambda s: s.nunique(), s)
+    self._run_test(lambda s: s.nunique(dropna=False), s)
+
+  def test_nunique_categorical_with_unused_categories(self):
+    s = pd.Series(['a', 'b', 'a'], dtype=pd.CategoricalDtype(['a', 'b', 'c']))
+    self._run_test(lambda s: s.nunique(), s)
+
   @parameterized.expand([
       (
           lambda base: base.from_dict({

Reply via email to