Skip to content

Commit 4253fab

Browse files
feat(bigframes): support offset-based column access via iloc (#17367)
🦕 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent a5a717d commit 4253fab

7 files changed

Lines changed: 973 additions & 134 deletions

File tree

packages/bigframes/bigframes/core/indexers.py

Lines changed: 220 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,25 @@
1414

1515
from __future__ import annotations
1616

17+
import numbers
1718
import typing
1819
import warnings
19-
from typing import Tuple, Union
20+
from typing import Any, Sequence, Tuple, Union, cast
2021

2122
import bigframes_vendored.constants as constants
2223
import bigframes_vendored.ibis.common.exceptions as ibis_exceptions
24+
import numpy as np
2325
import pandas as pd
26+
import pyarrow as pa
27+
import pyarrow.types # type: ignore
2428

2529
import bigframes.core.blocks
2630
import bigframes.core.col
2731
import bigframes.core.expression as ex
2832
import bigframes.core.guid as guid
2933
import bigframes.core.indexes as indexes
3034
import bigframes.core.scalar
35+
import bigframes.core.validations as validations
3136
import bigframes.core.window_spec as windows
3237
import bigframes.dataframe
3338
import bigframes.dtypes
@@ -45,6 +50,9 @@
4550
]
4651

4752

53+
_DATAFRAME_ILOC_ERROR = "Only DataFrame.iloc[:, col_indexer] = value is supported."
54+
55+
4856
class LocSeriesIndexer:
4957
def __init__(self, series: bigframes.series.Series):
5058
self._series = series
@@ -102,6 +110,9 @@ def __getitem__(
102110
103111
Other key types are not yet supported.
104112
"""
113+
if not _is_noop_slice(key):
114+
validations.enforce_ordered(self._series, "iloc")
115+
105116
return _iloc_getitem_series_or_dataframe(self._series, key)
106117

107118

@@ -110,9 +121,9 @@ def __init__(self, series: bigframes.series.Series):
110121
self._series = series
111122

112123
def __getitem__(self, key: int) -> bigframes.core.scalar.Scalar:
113-
if not isinstance(key, int):
124+
if not _is_integer_scalar(key):
114125
raise ValueError("Series iAt based indexing can only have integer indexers")
115-
return self._series.iloc[key]
126+
return self._series.iloc[_to_python_int(key)]
116127

117128

118129
class AtSeriesIndexer:
@@ -186,14 +197,7 @@ def __setitem__(
186197
key: Tuple[slice, str],
187198
value: bigframes.dataframe.SingleItemValue,
188199
):
189-
if (
190-
isinstance(key, tuple)
191-
and len(key) == 2
192-
and isinstance(key[0], slice)
193-
and (key[0].start is None or key[0].start == 0)
194-
and (key[0].step is None or key[0].step == 1)
195-
and key[0].stop is None
196-
):
200+
if isinstance(key, tuple) and len(key) == 2 and _is_noop_slice(key[0]):
197201
# TODO(swast): Support setting multiple columns with key[1] as a list
198202
# of labels and value as a DataFrame.
199203
df = self._dataframe.assign(**{key[1]: value})
@@ -244,8 +248,41 @@ def __getitem__(self, key) -> Union[bigframes.dataframe.DataFrame, pd.Series]:
244248
245249
Other key types are not yet supported.
246250
"""
251+
requires_ordering = True
252+
if isinstance(key, tuple):
253+
if len(key) > 0:
254+
row_indexer = key[0]
255+
if _is_noop_slice(row_indexer):
256+
requires_ordering = False
257+
elif _is_noop_slice(key):
258+
requires_ordering = False
259+
260+
if requires_ordering:
261+
validations.enforce_ordered(self._dataframe, "iloc")
262+
247263
return _iloc_getitem_series_or_dataframe(self._dataframe, key)
248264

265+
def __setitem__(
266+
self,
267+
key: Tuple[
268+
slice, Union[int, typing.Sequence[int], slice, typing.Sequence[bool]]
269+
],
270+
value: Union[
271+
bigframes.dataframe.SingleItemValue, bigframes.dataframe.DataFrame
272+
],
273+
):
274+
if not (isinstance(key, tuple) and len(key) == 2):
275+
raise NotImplementedError(_DATAFRAME_ILOC_ERROR)
276+
277+
row_indexer, col_indexer = key
278+
279+
if not _is_noop_slice(row_indexer):
280+
raise NotImplementedError(_DATAFRAME_ILOC_ERROR)
281+
282+
col_offsets = _iloc_col_indexer_to_offsets(self._dataframe, col_indexer)
283+
df = self._dataframe._assign_multi_items_by_offsets(col_offsets, value)
284+
self._dataframe._set_block(df._get_block())
285+
249286

250287
class IatDataFrameIndexer:
251288
def __init__(self, dataframe: bigframes.dataframe.DataFrame):
@@ -254,19 +291,21 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
254291
def __getitem__(self, key: tuple) -> bigframes.core.scalar.Scalar:
255292
error_message = "DataFrame.iat should be indexed by a tuple of exactly 2 ints"
256293
# we raise TypeError or ValueError under the same conditions that pandas does
257-
if isinstance(key, int):
294+
if _is_integer_scalar(key):
258295
raise TypeError(error_message)
259296
if not isinstance(key, tuple):
260297
raise ValueError(error_message)
261-
key_values_are_ints = [isinstance(key_value, int) for key_value in key]
298+
key_values_are_ints = [_is_integer_scalar(key_value) for key_value in key]
262299
if not all(key_values_are_ints):
263300
raise ValueError(error_message)
264301
if len(key) != 2:
265302
raise TypeError(error_message)
303+
row_idx = _to_python_int(key[0])
304+
col_idx = _to_python_int(key[1])
266305
block: bigframes.core.blocks.Block = self._dataframe._block
267-
column_block = block.select_columns([block.value_columns[key[1]]])
306+
column_block = block.select_columns([block.value_columns[col_idx]])
268307
column = bigframes.series.Series(column_block)
269-
return column.iloc[key[0]]
308+
return column.iloc[row_idx]
270309

271310

272311
class AtDataFrameIndexer:
@@ -283,6 +322,16 @@ def __getitem__(
283322
return self._dataframe.loc[key]
284323

285324

325+
def _is_noop_slice(key: Any) -> bool:
326+
"""Return True if key is a slice selecting all elements in the original order."""
327+
return (
328+
isinstance(key, slice)
329+
and (key.start is None or key.start == 0)
330+
and (key.step is None or key.step == 1)
331+
and key.stop is None
332+
)
333+
334+
286335
@typing.overload
287336
def _loc_getitem_series_or_dataframe(
288337
series_or_dataframe: bigframes.series.Series, key
@@ -304,12 +353,14 @@ def _loc_getitem_series_or_dataframe(
304353
pd.Series,
305354
bigframes.core.scalar.Scalar,
306355
]:
356+
if _is_noop_slice(key):
357+
return series_or_dataframe.copy()
358+
307359
if isinstance(key, slice):
308-
if (key.start is None) and (key.stop is None) and (key.step is None):
309-
return series_or_dataframe.copy()
310360
raise NotImplementedError(
311361
f"loc does not yet support indexing with a slice. {constants.FEEDBACK_LINK}"
312362
)
363+
313364
if isinstance(key, bigframes.core.col.Expression):
314365
label_to_col_ref = {
315366
label: ex.deref(id)
@@ -426,6 +477,119 @@ def _struct_accessor_check_and_warn(
426477
warnings.warn(msg, stacklevel=7, category=bfe.BadIndexerKeyWarning)
427478

428479

480+
def _to_python_int(value: Any) -> int:
481+
if isinstance(value, pa.Scalar):
482+
return int(value.as_py())
483+
return int(value)
484+
485+
486+
def _iloc_clip_to_offset(index: Any, length: int, name: str) -> int:
487+
"""Support negative values for offsets."""
488+
if not _is_integer_scalar(index):
489+
raise TypeError(f"got unexpected {type(index)} for {name}")
490+
offset = _to_python_int(index)
491+
if offset < 0:
492+
offset += length
493+
494+
if offset < 0 or offset >= length:
495+
raise IndexError(f"{name} {index} is out-of-bounds")
496+
497+
return offset
498+
499+
500+
def _is_integer_scalar(value: Any) -> bool:
501+
return not (
502+
isinstance(value, bool)
503+
or isinstance(value, np.bool_)
504+
or (isinstance(value, pa.Scalar) and pyarrow.types.is_boolean(value.type))
505+
) and (
506+
isinstance(value, numbers.Integral)
507+
or (isinstance(value, pa.Scalar) and pyarrow.types.is_integer(value.type))
508+
)
509+
510+
511+
def _is_boolean_scalar(value: Any) -> bool:
512+
return (
513+
isinstance(value, bool)
514+
or isinstance(value, np.bool_)
515+
or (isinstance(value, pa.Scalar) and pyarrow.types.is_boolean(value.type))
516+
)
517+
518+
519+
def _truth_val(value: Any) -> bool:
520+
if value is None or value is pd.NA or pd.isna(value):
521+
return False
522+
if isinstance(value, pa.Scalar):
523+
return bool(value.as_py()) if value.is_valid else False
524+
return bool(value)
525+
526+
527+
def _is_boolean_indexer(indexer: Any) -> bool:
528+
if hasattr(indexer, "dtype") and pd.api.types.is_bool_dtype(indexer.dtype):
529+
return True
530+
if (
531+
hasattr(indexer, "type")
532+
and isinstance(indexer.type, pa.DataType)
533+
and pyarrow.types.is_boolean(indexer.type)
534+
):
535+
return True
536+
if pd.api.types.is_list_like(indexer):
537+
lst = (
538+
list(indexer)
539+
if not isinstance(indexer, (bigframes.series.Series, indexes.Index))
540+
else list(indexer.to_pandas())
541+
)
542+
if len(lst) > 0 and all(
543+
_is_boolean_scalar(x) or (x is None) or (x is pd.NA) or pd.isna(x)
544+
for x in lst
545+
):
546+
return any(_is_boolean_scalar(x) for x in lst)
547+
return False
548+
549+
550+
def _iloc_col_indexer_to_offsets(
551+
df: bigframes.dataframe.DataFrame, col_indexer: Any
552+
) -> Sequence[int]:
553+
"""Convert col_indexer from one of the many pandas-compatible formats to a list of offsets."""
554+
n_cols = len(df.columns)
555+
556+
if _is_integer_scalar(col_indexer):
557+
col_offset = _to_python_int(col_indexer)
558+
return [
559+
_iloc_clip_to_offset(
560+
col_offset, n_cols, "single positional iloc column indexer"
561+
)
562+
]
563+
564+
elif isinstance(col_indexer, slice):
565+
return list(range(*col_indexer.indices(n_cols)))
566+
567+
elif _is_boolean_indexer(col_indexer):
568+
col_indexer_list = list(col_indexer)
569+
if len(col_indexer_list) != n_cols:
570+
raise ValueError(
571+
f"Boolean iloc column indexer has wrong length: {len(col_indexer_list)} instead of {n_cols}"
572+
)
573+
return [i for i, val in enumerate(col_indexer_list) if _truth_val(val)]
574+
575+
elif pd.api.types.is_list_like(col_indexer):
576+
col_indexer_list = list(col_indexer)
577+
return [
578+
_iloc_clip_to_offset(idx, n_cols, "iloc column indexer")
579+
for idx in col_indexer_list
580+
]
581+
582+
raise TypeError(f"got unexpected {type(col_indexer)} for iloc column indexer")
583+
584+
585+
def _iloc_df_from_column_offsets(
586+
df: bigframes.dataframe.DataFrame, key: Sequence[int]
587+
) -> bigframes.dataframe.DataFrame:
588+
block = df._block
589+
selected_ids = tuple(block.value_columns[offset] for offset in key)
590+
return bigframes.dataframe.DataFrame(block.select_columns(selected_ids))
591+
592+
429593
@typing.overload
430594
def _iloc_getitem_series_or_dataframe(
431595
series_or_dataframe: bigframes.series.Series, key
@@ -447,9 +611,10 @@ def _iloc_getitem_series_or_dataframe(
447611
bigframes.core.scalar.Scalar,
448612
pd.Series,
449613
]:
450-
if isinstance(key, int):
451-
stop_key = key + 1 if key != -1 else None
452-
internal_slice_result = series_or_dataframe._slice(key, stop_key, 1)
614+
if _is_integer_scalar(key):
615+
key_int = _to_python_int(key)
616+
stop_key = key_int + 1 if key_int != -1 else None
617+
internal_slice_result = series_or_dataframe._slice(key_int, stop_key, 1)
453618
result_pd_df = internal_slice_result.to_pandas()
454619
if result_pd_df.empty:
455620
raise IndexError("single positional indexer is out-of-bounds")
@@ -470,21 +635,49 @@ def _iloc_getitem_series_or_dataframe(
470635

471636
# len(key) == 2
472637
df = typing.cast(bigframes.dataframe.DataFrame, series_or_dataframe)
473-
if isinstance(key[1], int):
638+
if _is_integer_scalar(key[0]) and _is_integer_scalar(key[1]):
474639
return df.iat[key]
475-
elif isinstance(key[1], list):
476-
columns = df.columns[key[1]]
477-
return _iloc_getitem_series_or_dataframe(df[columns], key[0])
478-
raise NotImplementedError(
479-
f"iloc does not yet support indexing with {key}. {constants.FEEDBACK_LINK}"
480-
)
640+
641+
row_indexer, column_indexer = key
642+
column_offsets = _iloc_col_indexer_to_offsets(df, column_indexer)
643+
df_subset = _iloc_df_from_column_offsets(df, column_offsets)
644+
645+
if _is_integer_scalar(column_indexer):
646+
selected_columns = cast(
647+
Union[bigframes.dataframe.DataFrame, bigframes.series.Series],
648+
df_subset[df_subset.columns[0]],
649+
)
650+
else:
651+
selected_columns = df_subset
652+
653+
return _iloc_getitem_series_or_dataframe(selected_columns, row_indexer)
481654
elif pd.api.types.is_list_like(key):
482655
if len(key) == 0:
483656
return typing.cast(
484657
Union[bigframes.dataframe.DataFrame, bigframes.series.Series],
485658
series_or_dataframe.iloc[0:0],
486659
)
487660

661+
if _is_boolean_indexer(key):
662+
key_list = (
663+
list(key)
664+
if not isinstance(key, (bigframes.series.Series, indexes.Index))
665+
else list(key.to_pandas())
666+
)
667+
n_rows = len(series_or_dataframe)
668+
if len(key_list) != n_rows:
669+
raise IndexError(
670+
f"Boolean index has wrong length: {len(key_list)} instead of {n_rows}"
671+
)
672+
key = [i for i, val in enumerate(key_list) if _truth_val(val)]
673+
if len(key) == 0:
674+
return typing.cast(
675+
Union[bigframes.dataframe.DataFrame, bigframes.series.Series],
676+
series_or_dataframe.iloc[0:0],
677+
)
678+
else:
679+
key = [_to_python_int(k) for k in list(key)]
680+
488681
# Check if both positive index and negative index are necessary
489682
if isinstance(key, (bigframes.series.Series, indexes.Index)):
490683
# Avoid data download

0 commit comments

Comments
 (0)