Skip to content

Commit 4f94be8

Browse files
authored
feat(bigframes): add ai.classify, ai.score, ai.if_ to the df bq accessor (#17569)
Also fixed a bug where a single pandas series input got incorrectly rejected by the AI functions. Internal issue: b/517233441
1 parent f479800 commit 4f94be8

4 files changed

Lines changed: 289 additions & 2 deletions

File tree

packages/bigframes/bigframes/bigquery/_operations/ai.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1178,12 +1178,15 @@ def _separate_context_and_series(
11781178
Input: ("str1", series1, "str2", "str3", series2)
11791179
Output: ["str1", None, "str2", "str3", None], [series1, series2]
11801180
"""
1181-
if not isinstance(prompt, (str, list, tuple, series.Series)):
1181+
if not isinstance(prompt, (str, list, tuple, series.Series, pd.Series)):
11821182
raise ValueError(f"Unsupported prompt type: {type(prompt)}")
11831183

11841184
if isinstance(prompt, str):
11851185
return [None], [series.Series([prompt])]
11861186

1187+
if isinstance(prompt, pd.Series):
1188+
return [None], [bpd.read_pandas(prompt)]
1189+
11871190
if isinstance(prompt, series.Series):
11881191
if prompt.dtype == dtypes.OBJ_REF_DTYPE:
11891192
# Multi-model support

packages/bigframes/bigframes/extensions/core/dataframe_accessor.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,91 @@ def generate_double(
214214
)
215215
return self._to_series(result)
216216

217+
def classify(
218+
self,
219+
input: PROMPT_TYPE,
220+
categories: tuple[str, ...] | list[str],
221+
*,
222+
examples: list[tuple[str, str]]
223+
| list[tuple[str, list[str] | tuple[str, ...]]]
224+
| None = None,
225+
connection_id: str | None = None,
226+
endpoint: str | None = None,
227+
output_mode: Literal["single", "multi"] | None = None,
228+
optimization_mode: Literal["minimize_cost", "maximize_quality"] | None = None,
229+
max_error_ratio: float | None = None,
230+
) -> S:
231+
"""
232+
Classifies a given input into one of the specified categories. It will always return one of the provided categories best fit the prompt input.
233+
234+
This is an accessor for :func:`bigframes.bigquery.ai.classify`. See that
235+
function's documentation for detailed parameter descriptions and examples.
236+
"""
237+
import bigframes.bigquery.ai
238+
239+
result = bigframes.bigquery.ai.classify(
240+
input,
241+
categories,
242+
examples=examples,
243+
connection_id=connection_id,
244+
endpoint=endpoint,
245+
output_mode=output_mode,
246+
optimization_mode=optimization_mode,
247+
max_error_ratio=max_error_ratio,
248+
)
249+
return self._to_series(result)
250+
251+
def if_(
252+
self,
253+
prompt: PROMPT_TYPE,
254+
*,
255+
connection_id: str | None = None,
256+
endpoint: str | None = None,
257+
optimization_mode: Literal["minimize_cost", "maximize_quality"] | None = None,
258+
max_error_ratio: float | None = None,
259+
) -> S:
260+
"""
261+
Evaluates the prompt to True or False. Compared to ``ai.generate_bool()``, this function
262+
provides optimization such that not all rows are evaluated with the LLM.
263+
264+
This is an accessor for :func:`bigframes.bigquery.ai.if_`. See that
265+
function's documentation for detailed parameter descriptions and examples.
266+
"""
267+
import bigframes.bigquery.ai
268+
269+
result = bigframes.bigquery.ai.if_(
270+
prompt,
271+
connection_id=connection_id,
272+
endpoint=endpoint,
273+
optimization_mode=optimization_mode,
274+
max_error_ratio=max_error_ratio,
275+
)
276+
return self._to_series(result)
277+
278+
def score(
279+
self,
280+
prompt: PROMPT_TYPE,
281+
*,
282+
connection_id: str | None = None,
283+
endpoint: str | None = None,
284+
max_error_ratio: float | None = None,
285+
) -> S:
286+
"""
287+
Computes a score based on rubrics described in natural language. It will return a double value.
288+
289+
This is an accessor for :func:`bigframes.bigquery.ai.score`. See that
290+
function's documentation for detailed parameter descriptions and examples.
291+
"""
292+
import bigframes.bigquery.ai
293+
294+
result = bigframes.bigquery.ai.score(
295+
prompt,
296+
connection_id=connection_id,
297+
endpoint=endpoint,
298+
max_error_ratio=max_error_ratio,
299+
)
300+
return self._to_series(result)
301+
217302

218303
class BigQueryDataFrameAccessor(AbstractBigQueryDataFrameAccessor[T, S]):
219304
"""

packages/bigframes/tests/system/small/bigquery/test_ai.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def _create_mock_obj_ref_df(session, uris, name="image", connection=None):
5555
return session.read_gbq(table_id)
5656

5757

58-
def test_ai_function_pandas_input(session):
58+
def test_ai_function_pandas_tuple_input(session):
5959
s1 = pd.Series(["apple", "bear"])
6060
s2 = bpd.Series(["fruit", "tree"], session=session)
6161
prompt = (s1, " is a ", s2)
@@ -74,6 +74,17 @@ def test_ai_function_pandas_input(session):
7474
)
7575

7676

77+
def test_ai_function_pandas_series_input(session):
78+
s = pd.Series(["cat", "lavender"])
79+
80+
result = bbq.ai.classify(
81+
s, categories=["animal", "plant"], endpoint="gemini-2.5-flash"
82+
)
83+
84+
assert len(result) == len(s)
85+
assert result.dtype == dtypes.STRING_DTYPE
86+
87+
7788
def test_ai_function_string_input(session):
7889
with mock.patch(
7990
"bigframes.core.global_session.get_global_session"

packages/bigframes/tests/unit/extensions/core/test_dataframe_accessor.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,3 +335,191 @@ def test_bigframes_ai_generate_double(scalar_types_df: bpd.DataFrame, monkeypatc
335335
}
336336
result_series.to_pandas.assert_not_called()
337337
assert actual_result is result_series
338+
339+
340+
def test_ai_classify(monkeypatch):
341+
mock_classify = mock.MagicMock()
342+
result_series = mock.create_autospec(bpd.Series)
343+
mock_classify.return_value = result_series
344+
expected_result = mock.create_autospec(pd.Series)
345+
result_series.to_pandas.return_value = expected_result
346+
347+
monkeypatch.setattr(bigframes.bigquery.ai, "classify", mock_classify)
348+
349+
input_prompt = mock.create_autospec(pd.Series)
350+
df = pd.DataFrame({"text_input": ["Is this a positive review?"]})
351+
actual_result = df.bigquery.ai.classify(
352+
input_prompt,
353+
categories=["Mammal", "Fish"],
354+
examples=[("Cat", "Mammal")],
355+
connection_id="conn",
356+
endpoint="endpoint",
357+
output_mode="single",
358+
optimization_mode="minimize_cost",
359+
max_error_ratio=0.1,
360+
)
361+
362+
mock_classify.assert_called_once_with(
363+
input_prompt,
364+
["Mammal", "Fish"],
365+
examples=[("Cat", "Mammal")],
366+
connection_id="conn",
367+
endpoint="endpoint",
368+
output_mode="single",
369+
optimization_mode="minimize_cost",
370+
max_error_ratio=0.1,
371+
)
372+
result_series.to_pandas.assert_called_once()
373+
assert actual_result is expected_result
374+
375+
376+
def test_bigframes_ai_classify(scalar_types_df: bpd.DataFrame, monkeypatch):
377+
bf_series = mock.create_autospec(bpd.Series)
378+
result_series = mock.create_autospec(bpd.Series)
379+
380+
mock_classify = mock.MagicMock()
381+
mock_classify.return_value = result_series
382+
383+
monkeypatch.setattr(bigframes.bigquery.ai, "classify", mock_classify)
384+
385+
actual_result = scalar_types_df.bigquery.ai.classify(
386+
bf_series,
387+
categories=["Mammal", "Fish"],
388+
examples=[("Cat", "Mammal")],
389+
connection_id="conn",
390+
endpoint="endpoint",
391+
output_mode="single",
392+
optimization_mode="minimize_cost",
393+
max_error_ratio=0.1,
394+
)
395+
396+
mock_classify.assert_called_once()
397+
args, kwargs = mock_classify.call_args
398+
assert args[0] is bf_series
399+
assert args[1] == ["Mammal", "Fish"]
400+
assert kwargs == {
401+
"examples": [("Cat", "Mammal")],
402+
"connection_id": "conn",
403+
"endpoint": "endpoint",
404+
"output_mode": "single",
405+
"optimization_mode": "minimize_cost",
406+
"max_error_ratio": 0.1,
407+
}
408+
result_series.to_pandas.assert_not_called()
409+
assert actual_result is result_series
410+
411+
412+
def test_ai_if(monkeypatch):
413+
mock_if = mock.MagicMock()
414+
result_series = mock.create_autospec(bpd.Series)
415+
mock_if.return_value = result_series
416+
expected_result = mock.create_autospec(pd.Series)
417+
result_series.to_pandas.return_value = expected_result
418+
419+
monkeypatch.setattr(bigframes.bigquery.ai, "if_", mock_if)
420+
421+
prompt = mock.create_autospec(pd.Series)
422+
df = pd.DataFrame({"text_input": ["Is this a positive review?"]})
423+
actual_result = df.bigquery.ai.if_(
424+
prompt,
425+
connection_id="conn",
426+
endpoint="endpoint",
427+
optimization_mode="minimize_cost",
428+
max_error_ratio=0.1,
429+
)
430+
431+
mock_if.assert_called_once_with(
432+
prompt,
433+
connection_id="conn",
434+
endpoint="endpoint",
435+
optimization_mode="minimize_cost",
436+
max_error_ratio=0.1,
437+
)
438+
result_series.to_pandas.assert_called_once()
439+
assert actual_result is expected_result
440+
441+
442+
def test_bigframes_ai_if(scalar_types_df: bpd.DataFrame, monkeypatch):
443+
bf_series = mock.create_autospec(bpd.Series)
444+
result_series = mock.create_autospec(bpd.Series)
445+
446+
mock_if = mock.MagicMock()
447+
mock_if.return_value = result_series
448+
449+
monkeypatch.setattr(bigframes.bigquery.ai, "if_", mock_if)
450+
451+
actual_result = scalar_types_df.bigquery.ai.if_(
452+
bf_series,
453+
connection_id="conn",
454+
endpoint="endpoint",
455+
optimization_mode="minimize_cost",
456+
max_error_ratio=0.1,
457+
)
458+
459+
mock_if.assert_called_once()
460+
args, kwargs = mock_if.call_args
461+
assert args[0] is bf_series
462+
assert kwargs == {
463+
"connection_id": "conn",
464+
"endpoint": "endpoint",
465+
"optimization_mode": "minimize_cost",
466+
"max_error_ratio": 0.1,
467+
}
468+
result_series.to_pandas.assert_not_called()
469+
assert actual_result is result_series
470+
471+
472+
def test_ai_score(monkeypatch):
473+
mock_score = mock.MagicMock()
474+
result_series = mock.create_autospec(bpd.Series)
475+
mock_score.return_value = result_series
476+
expected_result = mock.create_autospec(pd.Series)
477+
result_series.to_pandas.return_value = expected_result
478+
479+
monkeypatch.setattr(bigframes.bigquery.ai, "score", mock_score)
480+
481+
prompt = mock.create_autospec(pd.Series)
482+
df = pd.DataFrame({"text_input": ["Is this a positive review?"]})
483+
actual_result = df.bigquery.ai.score(
484+
prompt,
485+
connection_id="conn",
486+
endpoint="endpoint",
487+
max_error_ratio=0.1,
488+
)
489+
490+
mock_score.assert_called_once_with(
491+
prompt,
492+
connection_id="conn",
493+
endpoint="endpoint",
494+
max_error_ratio=0.1,
495+
)
496+
result_series.to_pandas.assert_called_once()
497+
assert actual_result is expected_result
498+
499+
500+
def test_bigframes_ai_score(scalar_types_df: bpd.DataFrame, monkeypatch):
501+
bf_series = mock.create_autospec(bpd.Series)
502+
result_series = mock.create_autospec(bpd.Series)
503+
504+
mock_score = mock.MagicMock()
505+
mock_score.return_value = result_series
506+
507+
monkeypatch.setattr(bigframes.bigquery.ai, "score", mock_score)
508+
509+
actual_result = scalar_types_df.bigquery.ai.score(
510+
bf_series,
511+
connection_id="conn",
512+
endpoint="endpoint",
513+
max_error_ratio=0.1,
514+
)
515+
516+
mock_score.assert_called_once()
517+
args, kwargs = mock_score.call_args
518+
assert args[0] is bf_series
519+
assert kwargs == {
520+
"connection_id": "conn",
521+
"endpoint": "endpoint",
522+
"max_error_ratio": 0.1,
523+
}
524+
result_series.to_pandas.assert_not_called()
525+
assert actual_result is result_series

0 commit comments

Comments
 (0)