Overview of User Defined Functions with cuDF#
import numpy as np
import cudf
from cudf.datasets import randomdata
Like many tabular data processing APIs, cuDF provides a range of composable, DataFrame style operators. While out of the box functions are flexible and useful, it is sometimes necessary to write custom code, or user-defined functions (UDFs), that can be applied to rows, columns, and other groupings of the cells making up the DataFrame.
In conjunction with the broader GPU PyData ecosystem, cuDF provides interfaces to run UDFs on a variety of data structures. Currently, we can only execute UDFs on numeric, boolean, datetime, and timedelta typed data with partial support for strings in some APIs. This guide covers writing and executing UDFs on the following data structures:
Series
DataFrame
Rolling Windows Series
Groupby DataFrames
CuPy NDArrays
Numba DeviceNDArrays
It also demonstrates cuDF’s default null handling behavior, and how to write UDFs that can interact with null values.
Series UDFs#
You can execute UDFs on Series in two ways:
Writing a standard python function and using
cudf.Series.apply
Writing a Numba kernel and using Numba’s
forall
syntax
Using apply
or is simpler, but writing a Numba kernel offers the flexibility to build more complex functions (we’ll be writing only simple kernels in this guide).
cudf.Series.apply
#
cuDF provides a similar API to pandas.Series.apply
for applying scalar UDFs to series objects. Here is a very basic example.
# Create a cuDF series
sr = cudf.Series([1, 2, 3])
UDFs destined for cudf.Series.apply
might look something like this:
# define a scalar function
def f(x):
return x + 1
cudf.Series.apply
is called like pd.Series.apply
and returns a new Series
object:
sr.apply(f)
0 2
1 3
2 4
dtype: int64
Functions with Additional Scalar Arguments#
In addition, cudf.Series.apply
supports args=
just like pandas, allowing you to write UDFs that accept an arbitrary number of scalar arguments. Here is an example of such a function and it’s API call in both pandas and cuDF:
def g(x, const):
return x + const
# cuDF apply
sr.apply(g, args=(42,))
0 43
1 44
2 45
dtype: int64
As a final note, **kwargs
is not yet supported.
Nullable Data#
The null value NA
an propagates through unary and binary operations. Thus, NA + 1
, abs(NA)
, and NA == NA
all return NA
. To make this concrete, let’s look at the same example from above, this time using nullable data:
# Create a cuDF series with nulls
sr = cudf.Series([1, cudf.NA, 3])
sr
0 1
1 <NA>
2 3
dtype: int64
# redefine the same function from above
def f(x):
return x + 1
# cuDF result
sr.apply(f)
0 2
1 <NA>
2 4
dtype: int64
Often however you want explicit null handling behavior inside the function. cuDF exposes this capability the same way as pandas, by interacting directly with the NA
singleton object. Here’s an example of a function with explicit null handling:
def f_null_sensitive(x):
# do something if the input is null
if x is cudf.NA:
return 42
else:
return x + 1
# cuDF result
sr.apply(f_null_sensitive)
0 2
1 42
2 4
dtype: int64
In addition, cudf.NA
can be returned from a function directly or conditionally. This capability should allow you to implement custom null handling in a wide variety of cases.
String data#
Experimental support for a subset of string functionality is available for apply
. The following string operations are currently supported:
str.count
str.startswith
str.endswith
str.find
str.rfind
str.isalnum
str.isdecimal
str.isdigit
str.islower
str.isupper
str.isalpha
str.istitle
str.isspace
==
,!=
,>=
,<=
,>
,<
(between two strings)len
(e.g.len(some_string))
in
(e.g,'abc' in some_string
)strip
lstrip
rstrip
upper
lower
+
(string concatenation)replace
sr = cudf.Series(["", "abc", "some_example"])
def f(st):
if len(st) > 0:
if st.startswith("a"):
return 1
elif "example" in st:
return 2
else:
return -1
else:
return 42
result = sr.apply(f)
print(result)
0 42
1 1
2 2
dtype: int64
String UDF Memory Considerations#
UDFs that create intermediate strings as part of the computation may require memory tuning. An API is provided for convenience to accomplish this:
from cudf.core.udf.utils import set_malloc_heap_size
set_malloc_heap_size(int(2e9))
Lower level control with custom numba
kernels#
In addition to the Series.apply() method for performing custom operations, you can also pass Series objects directly into CUDA kernels written with Numba. Note that this section requires basic CUDA knowledge. Refer to numba’s CUDA documentation for details.
The easiest way to write a Numba kernel is to use cuda.grid(1)
to manage thread indices, and then leverage Numba’s forall
method to configure the kernel for us. Below, define a basic multiplication kernel as an example and use @cuda.jit
to compile it.
df = randomdata(nrows=5, dtypes={"a": int, "b": int, "c": int}, seed=12)
from numba import cuda
@cuda.jit
def multiply(in_col, out_col, multiplier):
i = cuda.grid(1)
if i < in_col.size: # boundary guard
out_col[i] = in_col[i] * multiplier
This kernel will take an input array, multiply it by a configurable value (supplied at runtime), and store the result in an output array. Notice that we wrapped our logic in an if
statement. Because we can launch more threads than the size of our array, we need to make sure that we don’t use threads with an index that would be out of bounds. Leaving this out can result in undefined behavior.
To execute our kernel, must pre-allocate an output array and leverage the forall
method mentioned above. First, we create a Series of all 0.0
in our DataFrame, since we want float64
output. Next, we run the kernel with forall
. forall
requires us to specify our desired number of tasks, so we’ll supply in the length of our Series (which we store in size
). The cuda_array_interface is what allows us to directly call our Numba kernel on our Series.
size = len(df["a"])
df["e"] = 0.0
multiply.forall(size)(df["a"], df["e"], 10.0)
After calling our kernel, our DataFrame is now populated with the result.
df.head()
a | b | c | e | |
---|---|---|---|---|
0 | 963 | 1005 | 997 | 9630.0 |
1 | 977 | 1026 | 980 | 9770.0 |
2 | 1048 | 1026 | 1019 | 10480.0 |
3 | 1078 | 960 | 985 | 10780.0 |
4 | 979 | 982 | 1011 | 9790.0 |
This API allows a you to theoretically write arbitrary kernel logic, potentially accessing and using elements of the series at arbitrary indices and use them on cuDF data structures. Advanced developers with some CUDA experience can often use this capability to implement iterative transformations, or spot treat problem areas of a data pipeline with a custom kernel that does the same job faster.
DataFrame UDFs#
Like cudf.Series
, there are multiple ways of using UDFs on dataframes, which essentially amount to UDFs that expect multiple columns as input:
cudf.DataFrame.apply
, which functions likepd.DataFrame.apply
and expects a row udfcudf.DataFrame.apply_rows
, which is a thin wrapper around numba and expects a numba kernelcudf.DataFrame.apply_chunks
, which is similar tocudf.DataFrame.apply_rows
but offers lower level control.
cudf.DataFrame.apply
#
cudf.DataFrame.apply
is the main entrypoint for UDFs that expect multiple columns as input and produce a single output column. Functions intended to be consumed by this API are written in terms of a “row” argument. The “row” is considered to be like a dictionary and contains all of the column values at a certain iloc
in a DataFrame
. The function can access these values by key within the function, the keys being the column names corresponding to the desired value. Below is an example function that would be used to add column A
and column B
together inside a UDF.
def f(row):
return row["A"] + row["B"]
Let’s create some very basic toy data containing at least one null.
df = cudf.DataFrame({"A": [1, 2, 3], "B": [4, cudf.NA, 6]})
df
A | B | |
---|---|---|
0 | 1 | 4 |
1 | 2 | <NA> |
2 | 3 | 6 |
Finally call the function as you would in pandas - by using a lambda function to map the UDF onto “rows” of the DataFrame:
df.apply(f, axis=1)
0 5
1 <NA>
2 9
dtype: int64
The same function should produce the same result as pandas:
df.to_pandas(nullable=True).apply(f, axis=1)
0 5
1 <NA>
2 9
dtype: object
Notice that Pandas returns object
dtype - see notes on this in the caveats section.
Like cudf.Series.apply
, these functions support generalized null handling. Here’s a function that conditionally returns a different value if a certain input is null:
def f(row):
x = row["a"]
if x is cudf.NA:
return 0
else:
return x + 1
df = cudf.DataFrame({"a": [1, cudf.NA, 3]})
df
a | |
---|---|
0 | 1 |
1 | <NA> |
2 | 3 |
df.apply(f, axis=1)
0 2
1 0
2 4
dtype: int64
cudf.NA
can also be directly returned from a function resulting in data that has the the correct nulls in the end, just as if it were run in Pandas. For the following data, the last row fulfills the condition that 1 + 3 > 3
and returns NA
for that row:
def f(row):
x = row["a"]
y = row["b"]
if x + y > 3:
return cudf.NA
else:
return x + y
df = cudf.DataFrame({"a": [1, 2, 3], "b": [2, 1, 1]})
df
a | b | |
---|---|---|
0 | 1 | 2 |
1 | 2 | 1 |
2 | 3 | 1 |
df.apply(f, axis=1)
0 3
1 3
2 <NA>
dtype: int64
Mixed types are allowed, but will return the common type, rather than object as in Pandas. Here’s a null aware op between an int and a float column:
def f(row):
return row["a"] + row["b"]
df = cudf.DataFrame({"a": [1, 2, 3], "b": [0.5, cudf.NA, 3.14]})
df
a | b | |
---|---|---|
0 | 1 | 0.5 |
1 | 2 | <NA> |
2 | 3 | 3.14 |
df.apply(f, axis=1)
0 1.5
1 <NA>
2 6.14
dtype: float64
Functions may also return scalar values, however the result will be promoted to a safe type regardless of the data. This means even if you have a function like:
def f(x):
if x > 1000:
return 1.5
else:
return 2
And your data is:
[1,2,3,4,5]
You will get floats in the final data even though a float is never returned. This is because Numba ultimately needs to produce one function that can handle any data, which means if there’s any possibility a float could result, you must always assume it will happen. Here’s an example of a function that returns a scalar in some cases:
def f(row):
x = row["a"]
if x > 3:
return x
else:
return 1.5
df = cudf.DataFrame({"a": [1, 3, 5]})
df
a | |
---|---|
0 | 1 |
1 | 3 |
2 | 5 |
df.apply(f, axis=1)
0 1.5
1 1.5
2 5.0
dtype: float64
Any number of columns and many arithmetic operators are supported, allowing for complex UDFs:
def f(row):
return row["a"] + (row["b"] - (row["c"] / row["d"])) % row["e"]
df = cudf.DataFrame(
{
"a": [1, 2, 3],
"b": [4, 5, 6],
"c": [cudf.NA, 4, 4],
"d": [8, 7, 8],
"e": [7, 1, 6],
}
)
df
a | b | c | d | e | |
---|---|---|---|---|---|
0 | 1 | 4 | <NA> | 8 | 7 |
1 | 2 | 5 | 4 | 7 | 1 |
2 | 3 | 6 | 4 | 8 | 6 |
df.apply(f, axis=1)
0 <NA>
1 2.428571429
2 8.5
dtype: float64
String Data#
String data may be used inside DataFrame.apply
UDFs, subject to the same constraints as those for Series.apply
. See the section on string handling for Series
UDFs above for details. Below is a simple example extending the row UDF logic from above in the case of a string column:
str_df = cudf.DataFrame(
{"str_col": ["abc", "ABC", "Example"], "scale": [1, 2, 3]}
)
str_df
str_col | scale | |
---|---|---|
0 | abc | 1 |
1 | ABC | 2 |
2 | Example | 3 |
def f(row):
st = row["str_col"]
scale = row["scale"]
if len(st) > 5:
return len(st) + scale
else:
return len(st)
result = str_df.apply(f, axis=1)
print(result)
0 3
1 3
2 10
dtype: int64
Numba kernels for DataFrames#
We could apply a UDF on a DataFrame like we did above with forall
. We’d need to write a kernel that expects multiple inputs, and pass multiple Series as arguments when we execute our kernel. Because this is fairly common and can be difficult to manage, cuDF provides two APIs to streamline this: apply_rows
and apply_chunks
. Below, we walk through an example of using apply_rows
. apply_chunks
works in a similar way, but also offers more control over low-level kernel behavior.
Now that we have two numeric columns in our DataFrame, let’s write a kernel that uses both of them.
def conditional_add(x, y, out):
for i, (a, e) in enumerate(zip(x, y)):
if a > 0:
out[i] = a + e
else:
out[i] = a
Notice that we need to enumerate
through our zipped
function arguments (which either match or are mapped to our input column names). We can pass this kernel to apply_rows
. We’ll need to specify a few arguments:
incols
A list of names of input columns that match the function arguments. Or, a dictionary mapping input column names to their corresponding function arguments such as
{'col1': 'arg1'}
.
outcols
A dictionary defining our output column names and their data types. These names must match our function arguments.
kwargs (optional)
We can optionally pass keyword arguments as a dictionary. Since we don’t need any, we pass an empty one.
While it looks like our function is looping sequentially through our columns, it actually executes in parallel in multiple threads on the GPU. This parallelism is the heart of GPU-accelerated computing. With that background, we’re ready to use our UDF.
df = df.apply_rows(
conditional_add,
incols={"a": "x", "e": "y"},
outcols={"out": np.float64},
kwargs={},
)
df.head()
a | b | c | d | e | out | |
---|---|---|---|---|---|---|
0 | 1 | 4 | <NA> | 8 | 7 | 8.0 |
1 | 2 | 5 | 4 | 7 | 1 | 3.0 |
2 | 3 | 6 | 4 | 8 | 6 | 9.0 |
As expected, we see our conditional addition worked. At this point, we’ve successfully executed UDFs on the core data structures of cuDF.
Null Handling in apply_rows
and apply_chunks
#
By default, DataFrame methods for applying UDFs like apply_rows
will handle nulls pessimistically (all rows with a null value will be removed from the output if they are used in the kernel). Exploring how not handling not pessimistically can lead to undefined behavior is outside the scope of this guide. Suffice it to say, pessimistic null handling is the safe and consistent approach. You can see an example below.
def gpu_add(a, b, out):
for i, (x, y) in enumerate(zip(a, b)):
out[i] = x + y
df = randomdata(nrows=5, dtypes={"a": int, "b": int, "c": int}, seed=12)
df.loc[2, "a"] = None
df.loc[3, "b"] = None
df.loc[1, "c"] = None
df.head()
a | b | c | |
---|---|---|---|
0 | 963 | 1005 | 997 |
1 | 977 | 1026 | <NA> |
2 | <NA> | 1026 | 1019 |
3 | 1078 | <NA> | 985 |
4 | 979 | 982 | 1011 |
In the dataframe above, there are three null values. Each column has a null in a different row. When we use our UDF with apply_rows
, our output should have two nulls due to pessimistic null handling (because we’re not using column c
, the null value there does not matter to us).
df = df.apply_rows(
gpu_add, incols=["a", "b"], outcols={"out": np.float64}, kwargs={}
)
df.head()
a | b | c | out | |
---|---|---|---|---|
0 | 963 | 1005 | 997 | 1968.0 |
1 | 977 | 1026 | <NA> | 2003.0 |
2 | <NA> | 1026 | 1019 | <NA> |
3 | 1078 | <NA> | 985 | <NA> |
4 | 979 | 982 | 1011 | 1961.0 |
As expected, we end up with two nulls in our output. The null values from the columns we used propagated to our output, but the null from the column we ignored did not.
Rolling Window UDFs#
For time-series data, we may need to operate on a small “window” of our column at a time, processing each portion independently. We could slide (“roll”) this window over the entire column to answer questions like “What is the 3-day moving average of a stock price over the past year?”
We can apply more complex functions to rolling windows to rolling
Series and DataFrames using apply
. This example is adapted from cuDF’s API documentation. First, we’ll create an example Series and then create a rolling
object from the Series.
ser = cudf.Series([16, 25, 36, 49, 64, 81], dtype="float64")
ser
0 16.0
1 25.0
2 36.0
3 49.0
4 64.0
5 81.0
dtype: float64
rolling = ser.rolling(window=3, min_periods=3, center=False)
rolling
Rolling [window=3,min_periods=3,center=False]
Next, we’ll define a function to use on our rolling windows. We created this one to highlight how you can include things like loops, mathematical functions, and conditionals. Rolling window UDFs do not yet support null values.
import math
def example_func(window):
b = 0
for a in window:
b = max(b, math.sqrt(a))
if b == 8:
return 100
return b
We can execute the function by passing it to apply
. With window=3
, min_periods=3
, and center=False
, our first two values are null
.
rolling.apply(example_func)
0 <NA>
1 <NA>
2 6.0
3 7.0
4 100.0
5 9.0
dtype: float64
We can apply this function to every column in a DataFrame, too.
df2 = cudf.DataFrame()
df2["a"] = np.arange(55, 65, dtype="float64")
df2["b"] = np.arange(55, 65, dtype="float64")
df2.head()
a | b | |
---|---|---|
0 | 55.0 | 55.0 |
1 | 56.0 | 56.0 |
2 | 57.0 | 57.0 |
3 | 58.0 | 58.0 |
4 | 59.0 | 59.0 |
rolling = df2.rolling(window=3, min_periods=3, center=False)
rolling.apply(example_func)
a | b | |
---|---|---|
0 | <NA> | <NA> |
1 | <NA> | <NA> |
2 | 7.549834435 | 7.549834435 |
3 | 7.615773106 | 7.615773106 |
4 | 7.681145748 | 7.681145748 |
5 | 7.745966692 | 7.745966692 |
6 | 7.810249676 | 7.810249676 |
7 | 7.874007874 | 7.874007874 |
8 | 7.937253933 | 7.937253933 |
9 | 100.0 | 100.0 |
GroupBy DataFrame UDFs#
We can also apply UDFs to grouped DataFrames using apply_grouped
. This example is also drawn and adapted from the RAPIDS API documentation.
First, we’ll group our DataFrame based on column b
, which is either True or False.
df = randomdata(
nrows=10, dtypes={"a": float, "b": bool, "c": str, "e": float}, seed=12
)
df.head()
a | b | c | e | |
---|---|---|---|---|
0 | -0.691674 | True | Dan | -0.958380 |
1 | 0.480099 | False | Bob | -0.729580 |
2 | -0.473370 | True | Xavier | -0.767454 |
3 | 0.067479 | True | Alice | -0.380205 |
4 | -0.970850 | False | Sarah | 0.342905 |
grouped = df.groupby(["b"])
Next we’ll define a function to apply to each group independently. In this case, we’ll take the rolling average of column e
, and call that new column rolling_avg_e
.
def rolling_avg(e, rolling_avg_e):
win_size = 3
for i in range(cuda.threadIdx.x, len(e), cuda.blockDim.x):
if i < win_size - 1:
# If there is not enough data to fill the window,
# take the average to be NaN
rolling_avg_e[i] = np.nan
else:
total = 0
for j in range(i - win_size + 1, i + 1):
total += e[j]
rolling_avg_e[i] = total / win_size
We can execute this with a very similar API to apply_rows
. This time, though, it’s going to execute independently for each group.
results = grouped.apply_grouped(
rolling_avg, incols=["e"], outcols=dict(rolling_avg_e=np.float64)
)
results
a | b | c | e | rolling_avg_e | |
---|---|---|---|---|---|
1 | 0.480099 | False | Bob | -0.729580 | NaN |
4 | -0.970850 | False | Sarah | 0.342905 | NaN |
6 | 0.801430 | False | Sarah | 0.632337 | 0.081887 |
7 | -0.933157 | False | Quinn | -0.420826 | 0.184805 |
0 | -0.691674 | True | Dan | -0.958380 | NaN |
2 | -0.473370 | True | Xavier | -0.767454 | NaN |
3 | 0.067479 | True | Alice | -0.380205 | -0.702013 |
5 | 0.837494 | True | Wendy | -0.057540 | -0.401733 |
8 | 0.913899 | True | Ursula | 0.466252 | 0.009502 |
9 | -0.725581 | True | George | 0.405245 | 0.271319 |
Notice how, with a window size of three in the kernel, the first two values in each group for our output column are null.
Numba Kernels on CuPy Arrays#
We can also execute Numba kernels on CuPy NDArrays, again thanks to the __cuda_array_interface__
. We can even run the same UDF on the Series and the CuPy array. First, we define a Series and then create a CuPy array from that Series.
import cupy as cp
s = cudf.Series([1.0, 2, 3, 4, 10])
arr = cp.asarray(s)
arr
array([ 1., 2., 3., 4., 10.])
Next, we define a UDF and execute it on our Series. We need to allocate a Series of the same size for our output, which we’ll call out
.
@cuda.jit
def multiply_by_5(x, out):
i = cuda.grid(1)
if i < x.size:
out[i] = x[i] * 5
out = cudf.Series(cp.zeros(len(s), dtype="int32"))
multiply_by_5.forall(s.shape[0])(s, out)
out
0 5
1 10
2 15
3 20
4 50
dtype: int32
Finally, we execute the same function on our array. We allocate an empty array out
to store our results.
out = cp.empty_like(arr)
multiply_by_5.forall(arr.size)(arr, out)
out
array([ 5., 10., 15., 20., 50.])
Caveats#
UDFs are currently only supported for numeric nondecimal scalar types (full support) and strings in
Series.apply
andDataFrame.apply
(partial support, subject to the caveats outlined above). Attempting to use this API with unsupported types will raise aTypeError
.We do not yet fully support all arithmetic operators. Certain ops like bitwise operations are not currently implemented, but planned in future releases. If an operator is needed, a github issue should be raised so that it can be properly prioritized and implemented.
Summary#
This guide has covered a lot of content. At this point, you should hopefully feel comfortable writing UDFs (with or without null values) that operate on
Series
DataFrame
Rolling Windows
GroupBy DataFrames
CuPy NDArrays
Numba DeviceNDArrays
Generalized NA UDFs
String UDFs
For more information please see the cuDF, Numba.cuda, and CuPy documentation.