From 57a3e9e812f2d26ae5f6e9a1c4e0425852c27a75 Mon Sep 17 00:00:00 2001 From: Mohak Gupta Date: Sun, 6 Sep 2026 12:37:59 +0530 Subject: [PATCH] Implement Series/DataFrame.first_valid_index and last_valid_index Closes #1480. Uses notna() plus boolean-mask indexing on the frame's own Index, reducing DataFrame's per-cell mask with any(axis=1) first - entirely GPU-resident, no host round-trip. Matches pandas semantics exactly: empty frames, all-null frames, and non-default index labels all fall out of the same boolean-mask-then-length-check path without special-casing. Verified against a real cudf install (26.08.01, pip wheels from pypi.nvidia.com) on a real GPU: reproduced every example from pandas' own first_valid_index/last_valid_index docstrings directly against the installed package, then added 9 pytest cases comparing cudf output to real pandas output across Series and DataFrame, empty/all-null/custom- index cases - 9/9 passed. Confirmed the tests actually exercise the fix by reverting it and re-running: 9/9 failed with AttributeError, then passed again after restoring. Signed-off-by: Mohak Gupta --- python/cudf/cudf/core/indexed_frame.py | 59 +++++++++++++++++++ .../methods/test_first_last_valid_index.py | 42 +++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 python/cudf/cudf/tests/dataframe/methods/test_first_last_valid_index.py diff --git a/python/cudf/cudf/core/indexed_frame.py b/python/cudf/cudf/core/indexed_frame.py index 37b88e55c19a..9c4f18879ab2 100644 --- a/python/cudf/cudf/core/indexed_frame.py +++ b/python/cudf/cudf/core/indexed_frame.py @@ -1458,6 +1458,65 @@ def tail(self, n=5): return self.iloc[-n:] + def _find_valid_index(self, *, how: str) -> Hashable: + valid = self.notna() + if valid.ndim == 2: + valid = valid.any(axis=1) + valid_index = self.index[valid] + if len(valid_index) == 0: + return None + return valid_index[0] if how == "first" else valid_index[-1] + + @_performance_tracking + def first_valid_index(self) -> Hashable: + """ + Return index for first non-NA value or None, if no non-NA value is found. + + Returns + ------- + type of index + Index of first non-missing value, or None if all entries are + missing or the Series/DataFrame is empty. + + See Also + -------- + Series.last_valid_index : Return index for last non-NA value. + DataFrame.last_valid_index : Return index for last non-NA value. + + Examples + -------- + >>> import cudf + >>> s = cudf.Series([None, 3, 4]) + >>> s.first_valid_index() + np.int64(1) + """ + return self._find_valid_index(how="first") + + @_performance_tracking + def last_valid_index(self) -> Hashable: + """ + Return index for last non-NA value or None, if no non-NA value is found. + + Returns + ------- + type of index + Index of last non-missing value, or None if all entries are + missing or the Series/DataFrame is empty. + + See Also + -------- + Series.first_valid_index : Return index for first non-NA value. + DataFrame.first_valid_index : Return index for first non-NA value. + + Examples + -------- + >>> import cudf + >>> s = cudf.Series([None, 3, 4]) + >>> s.last_valid_index() + np.int64(2) + """ + return self._find_valid_index(how="last") + @_performance_tracking def pipe(self, func, *args, **kwargs): """ diff --git a/python/cudf/cudf/tests/dataframe/methods/test_first_last_valid_index.py b/python/cudf/cudf/tests/dataframe/methods/test_first_last_valid_index.py new file mode 100644 index 000000000000..f45a9cbdde6a --- /dev/null +++ b/python/cudf/cudf/tests/dataframe/methods/test_first_last_valid_index.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 + +import pandas as pd +import pytest + +import cudf + + +@pytest.mark.parametrize( + "data,index", + [ + ([None, 3, 4], None), + ([None, None], None), + ([1, 2, 3, 4], None), + ([], None), + ([None, 3, 4], ["x", "y", "z"]), + ], +) +def test_series_first_last_valid_index(data, index): + ps = pd.Series(data, index=index, dtype="float64" if data else "object") + gs = cudf.from_pandas(ps) + + assert gs.first_valid_index() == ps.first_valid_index() + assert gs.last_valid_index() == ps.last_valid_index() + + +@pytest.mark.parametrize( + "data", + [ + {"A": [None, None, 2], "B": [None, 3, 4]}, + {"A": [None, None, None], "B": [None, None, None]}, + {"A": [1, 2, 3], "B": [4, 5, 6]}, + {}, + ], +) +def test_dataframe_first_last_valid_index(data): + pdf = pd.DataFrame(data) + gdf = cudf.from_pandas(pdf) + + assert gdf.first_valid_index() == pdf.first_valid_index() + assert gdf.last_valid_index() == pdf.last_valid_index()