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

tvalentyn 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 0be6fca73c6 fix(dataframe): handle pandas dimensionality reduction in 
.xs() for single-item matches (#39851)
0be6fca73c6 is described below

commit 0be6fca73c6d4e6a9bb4f18f9bbf6046e90cc7a9
Author: Manvith Panyam <[email protected]>
AuthorDate: Fri Sep 4 23:13:17 2026 +0530

    fix(dataframe): handle pandas dimensionality reduction in .xs() for 
single-item matches (#39851)
    
    * fix(dataframe): handle pandas dimensionality reduction in .xs() for 
single-item matches
    
    Beam's .xs() implementation assumed static output shape (DataFrame/Series)
    across partitions, but pandas reduces dimensionality (DataFrame->Series,
    Series->scalar) when a key matches exactly one row and all index levels
    are selected. This caused TypeError/shape-mismatch failures during
    cross-partition concat.
    
    Fixes the key_size >= nlevels path to route matching partitions through
    a singleton unwrap stage that mirrors pandas' actual runtime behavior,
    while documenting the inherent proxy-time ambiguity for duplicate-match
    cases (proxy assumes single-match dimensionality; runtime produces
    whichever type pandas actually returns).
    
    Fixes #28559
    
    Signed-off-by: ManvithPanyam 
<[email protected]>
    
    * fix(dataframe): use reindex() for xs() proxy construction to support 
extension dtypes
    
    Addresses review feedback: dtype.type() crashes on Categorical and
    timezone-aware datetime dtypes since they aren't callable as zero-arg
    scalar constructors. Switched the DataFrame branch to build the dummy
    proxy via reindex() + xs(), letting pandas handle type construction
    internally. Kept dtype.type() as the primary path for the Series
    (single-match scalar) branch since reindex().iloc[0] alone silently
    upcasts plain numeric types (e.g. int64 -> float64) by introducing NaN;
    falls back to reindex().iloc[0] only on TypeError for extension types.
    Also handles proxy indexes with duplicate labels, which reindex()
    otherwise rejects.
    
    Added regression tests for Categorical, tz-aware datetime, and nullable
    Int64 columns.
    
    Signed-off-by: ManvithPanyam 
<[email protected]>
    
    * fix(dataframe): simplify xs() proxy construction and fix non-empty 
duplicate-proxy crash
    
    Further simplifies the reindex()-based proxy generation from the
    previous commit: unifies dummy_index construction (was duplicated
    per-branch), and hoists the is_unique/drop_duplicates() dedup check to
    run once instead of twice.
    
    The dedup check is retained, not removed — traced that pandas can cache
    an IndexEngine on an index once inspected (e.g. via .is_unique, .loc,
    .get_loc), and that cache can survive slicing to iloc[:0], leaving
    is_unique stale as False on an otherwise-empty result. This path is
    reachable in practice (e.g. the existing test harness calls .xs() on
    the full arg before slicing to an empty proxy), so removing the check
    entirely would reintroduce a reindex() failure in that case.
    
    Also fixes a real bug found while testing: a non-empty user-supplied
    proxy with duplicate index labels (via
    to_dataframe(pcoll, proxy=df_with_dup_index)) crashed the old code with
    'ValueError: Length mismatch', since drop_duplicates() shrinks the
    index but not the frame before reassignment. Pre-slicing to iloc[:0]
    before the dedup check resolves this.
    
    Added regression test test_dataframe_xs_non_empty_duplicate_proxy
    covering both DataFrame and Series non-empty duplicate-proxy cases.
    
    Signed-off-by: ManvithPanyam 
<[email protected]>
    
    ---------
    
    Signed-off-by: ManvithPanyam 
<[email protected]>
---
 sdks/python/apache_beam/dataframe/frames.py      | 90 +++++++++++++++++++-----
 sdks/python/apache_beam/dataframe/frames_test.py | 72 ++++++++++++++++++-
 2 files changed, 143 insertions(+), 19 deletions(-)

diff --git a/sdks/python/apache_beam/dataframe/frames.py 
b/sdks/python/apache_beam/dataframe/frames.py
index 310791d2b58..452f519c58d 100644
--- a/sdks/python/apache_beam/dataframe/frames.py
+++ b/sdks/python/apache_beam/dataframe/frames.py
@@ -1091,27 +1091,81 @@ class 
DeferredDataFrameOrSeries(frame_base.DeferredFrame):
       reindexed = self.reorder_levels(
           level + [i for i in range(self.index.nlevels) if i not in level])
 
-    def xs_partitioned(frame, key):
-      if not len(key):
-        # key is not in this partition, return empty dataframe
-        result = frame.iloc[:0]
-        if key_size < frame.index.nlevels:
+    if key_size < reindexed.index.nlevels:
+
+      def xs_partitioned(frame, key):
+        if not len(key):
+          # key is not in this partition, return empty dataframe/series
+          result = frame.iloc[:0]
           return result.droplevel(list(range(key_size)))
-        else:
-          return result
+        return frame.xs(key.item(), **kwargs)
 
-      # key should be in this partition, call xs. Will raise KeyError if not
-      # present.
-      return frame.xs(key.item())
+      return frame_base.DeferredFrame.wrap(
+          expressions.ComputedExpression(
+              'xs',
+              xs_partitioned, [reindexed._expr, key_expr],
+              requires_partition_by=partitionings.Index(list(range(key_size))),
+              preserves_partition_by=partitionings.Singleton()))
+    else:
+      # When all index levels are matched (key_size >= nlevels), pandas .xs()
+      # return type is data-dependent:
+      #   - Single match: reduces dimensionality (DataFrame -> Series, Series 
-> scalar)
+      #   - Duplicate matches: preserves container type (DataFrame -> 
DataFrame, Series -> Series)
+      # Because proxy schemas are 0-row templates evaluated at graph 
construction time
+      # without knowledge of dataset contents or key frequencies, the proxy 
always assumes
+      # a single match (dimensionality-reduced type). At runtime, the 
Singleton unwrap stage
+      # correctly produces whichever type pandas returns. Tests with 
multi-matching keys
+      # therefore specify check_proxy=False.
+      def xs_partitioned_wrapped(frame, key):
+        if not len(key):
+          return pd.Series([], dtype=object)
+        k = key.item()
+        try:
+          res = frame.xs(k, **kwargs)
+          return pd.Series([res], dtype=object)
+        except KeyError:
+          return pd.Series([], dtype=object)
+
+      intermediate = expressions.ComputedExpression(
+          'xs_partitioned_wrapped',
+          xs_partitioned_wrapped, [reindexed._expr, key_expr],
+          proxy=pd.Series([], dtype=object),
+          requires_partition_by=partitionings.Index(list(range(key_size))),
+          preserves_partition_by=partitionings.Singleton())
 
-    return frame_base.DeferredFrame.wrap(
-        expressions.ComputedExpression(
-            'xs',
-            xs_partitioned,
-            [reindexed._expr, key_expr],
-            requires_partition_by=partitionings.Index(list(range(key_size))),
-            # Drops index levels, so partitioning is not preserved
-            preserves_partition_by=partitionings.Singleton()))
+      proxy_frame = reindexed._expr.proxy().iloc[:0]
+      if not proxy_frame.index.is_unique:
+        proxy_frame.index = proxy_frame.index.drop_duplicates()
+      k_val = key_series.iloc[0]
+      dummy_index = (
+          pd.MultiIndex.from_tuples([k_val], names=proxy_frame.index.names) if
+          isinstance(k_val, tuple) else pd.Index([k_val],
+                                                 name=proxy_frame.index.name))
+
+      if isinstance(proxy_frame, pd.DataFrame):
+        dummy_obj = proxy_frame.reindex(dummy_index)
+        xs_proxy = dummy_obj.xs(k_val, **kwargs)
+        if isinstance(xs_proxy, (pd.DataFrame, pd.Series)):
+          xs_proxy = xs_proxy.iloc[:0]
+      else:
+        try:
+          xs_proxy = proxy_frame.dtype.type()
+        except TypeError:
+          xs_proxy = proxy_frame.reindex(dummy_index).iloc[0]
+
+      def unwrap_xs(ser):
+        if ser.empty:
+          raise KeyError(k_val)
+        return ser.iloc[0]
+
+      with expressions.allow_non_parallel_operations(True):
+        return frame_base.DeferredFrame.wrap(
+            expressions.ComputedExpression(
+                'xs',
+                unwrap_xs, [intermediate],
+                proxy=xs_proxy,
+                requires_partition_by=partitionings.Singleton(),
+                preserves_partition_by=partitionings.Singleton()))
 
   @property
   def dtype(self):
diff --git a/sdks/python/apache_beam/dataframe/frames_test.py 
b/sdks/python/apache_beam/dataframe/frames_test.py
index 7a03af6220b..290d13adc40 100644
--- a/sdks/python/apache_beam/dataframe/frames_test.py
+++ b/sdks/python/apache_beam/dataframe/frames_test.py
@@ -331,6 +331,19 @@ class DeferredFrameTest(_AbstractFrameTest):
         lambda df: df.num_legs.xs(('bird', 'walks'), level=[0, 'locomotion']),
         df)
 
+    # Test cases reported in BEAM-28559
+    df_single_index = df.reset_index().set_index('class')
+    self._run_test(
+        lambda df: df.num_legs.xs('mammal'), df_single_index, 
check_proxy=False)
+    self._run_test(lambda df: df.num_legs.xs('bird'), df_single_index)
+
+    # Categorical Series single match
+    s_cat = pd.Series(
+        pd.Categorical(['a', 'b', 'c']),
+        index=['r1', 'r2', 'r3'],
+        name='cat_col')
+    self._run_test(lambda s: s.xs('r1'), s_cat, check_proxy=False)
+
   def test_dataframe_xs(self):
     # Test cases reported in BEAM-13421
     df = pd.DataFrame(
@@ -342,10 +355,67 @@ class DeferredFrameTest(_AbstractFrameTest):
         ]),
         columns=['provider', 'time', 'value'])
 
-    self._run_test(lambda df: df.xs('state'), df.set_index(['provider']))
+    self._run_test(
+        lambda df: df.xs('state'),
+        df.set_index(['provider']),
+        check_proxy=False)
     self._run_test(
         lambda df: df.xs('state'), df.set_index(['provider', 'time']))
 
+    # Test cases reported in BEAM-28559
+    self._run_test(lambda df: df.xs('county'), df.set_index(['provider']))
+    self._run_test(
+        lambda df: df.xs(('state', 'day1')),
+        df.set_index(['provider', 'time']),
+        check_proxy=False)
+
+    df_unique = pd.DataFrame(
+        np.array([
+            ['state', 'day1', 12],
+            ['state', 'day2', 14],
+            ['county', 'day1', 9],
+        ]),
+        columns=['provider', 'time', 'value'])
+    self._run_test(
+        lambda df: df.xs(('state', 'day2')),
+        df_unique.set_index(['provider', 'time']))
+
+    # Categorical and extension dtype tests
+    df_cat = pd.DataFrame({
+        'cat': pd.Categorical(['a', 'b', 'c']), 'val': [1, 2, 3]
+    },
+                          index=['r1', 'r2', 'r3'])
+    self._run_test(lambda df: df.xs('r1'), df_cat)
+
+    df_dt_tz = pd.DataFrame({
+        'dt': pd.Series([
+            pd.Timestamp('2023-01-01', tz='UTC'),
+            pd.Timestamp('2023-01-02', tz='UTC')
+        ],
+                        dtype='datetime64[ns, UTC]'),
+        'val': [1, 2]
+    },
+                            index=['r1', 'r2'])
+    self._run_test(lambda df: df.xs('r1'), df_dt_tz)
+
+    df_null_int = pd.DataFrame({'num': pd.Series([1, 2, None], dtype='Int64')},
+                               index=['r1', 'r2', 'r3'])
+    self._run_test(lambda df: df.xs('r1'), df_null_int)
+
+  def test_dataframe_xs_non_empty_duplicate_proxy(self):
+    df_dups = pd.DataFrame({'a': [1, 2]}, index=['x', 'x'])
+    p = beam.Pipeline()
+    deferred = to_dataframe(p | beam.Create([{'a': 1}]), proxy=df_dups)
+    res = deferred.xs('x')
+    self.assertIsInstance(res, frames.DeferredSeries)
+    self.assertTrue(res._expr.proxy().empty)
+
+    s_dups = pd.Series(pd.Categorical(['a', 'b']), index=['x', 'x'], name='s')
+    deferred_s = to_dataframe(
+        p | 'CreateSeries' >> beam.Create(['a']), proxy=s_dups)
+    res_s = deferred_s.xs('x')
+    self.assertIsInstance(res_s, frame_base.DeferredBase)
+
   def test_set_column(self):
     def new_column(df):
       df['NewCol'] = df['Speed']

Reply via email to