ch03.eager_and_lazy_apis
Chapter 3. Eager and Lazy APIs
Overview of Polars APIs
- Two types of Polars APIs:
- Eager API
- Lazy API
- Each API serves different use cases and has distinct performance characteristics.
Eager API
- Immediate execution model:
- Functions are executed sequentially.
- Data manipulation happens in real time.
- Use cases:
- Ideal for data exploration and iterative tasks.
- Provides immediate feedback after each operation.
- Similar experience to Pandas, making it easier for users familiar with Pandas to transition.
Lazy API
- Deferred execution model:
- Execution of data transformations is postponed until required.
- Performance benefits:
- Allows Polars to optimize queries before execution.
- Particularly useful in large-scale and performance-sensitive scenarios.
Eager API: DataFrame
- Eager API operates on an immediate execution model, executing each function sequentially.
- Ideal for data exploration and iterative analysis, providing direct interaction with data.
- Similar to Pandas, making it intuitive for users transitioning from Pandas.
Example: Taxi Trip Analysis with Eager API
Goal: Analyze taxi trip data to determine the top three vendors by revenue per distance traveled.
%%time
trips = pl.read_parquet("data/taxi/yellow_tripdata_*.parquet")
sum_per_vendor = trips.group_by("VendorID").sum()
income_per_distance_per_vendor = sum_per_vendor.select(
"VendorID",
income_per_distance=pl.col("total_amount") / pl.col("trip_distance")
)
top_three = (
income_per_distance_per_vendor.sort(
by="income_per_distance",
descending=True
)
.head(3)
)
top_three
-
Read Data:
- Loads all taxi trip data from matching Parquet files into a single DataFrame.
-
Group and Sum:
- Groups data by vendor and calculates sums of key columns like total amount and trip distance.
-
Calculate Metrics:
- Computes income per distance for each vendor by dividing the total income by the total distance traveled.
-
Sort and Select:
- Ranks vendors by their income per distance and extracts the top three vendors.
Benefits of Eager API in Analysis
- Tackling problems in smaller steps provides immediate feedback, allowing you to inspect the data after each step.
- This step-by-step approach helps make informed decisions about the next operations, improving analysis accuracy.
LazyFrame
lazy_df = pl.LazyFrame({
"foo": [1, 2, 3, 4, 5],
"bar": [6, 7, 8, 9, 10]
})
1. Common Subplan Elimination
-
Definition: A subplan (or subtree) is a group of steps in the query plan. If multiple operations share the same subplan, the results are cached and reused, avoiding redundant computation.
-
Example:
common_subplan = lazy_df.with_columns(pl.col("foo") * 2) expr1 = common_subplan.filter(pl.col("foo") * 2 > 4) expr2 = common_subplan.filter(pl.col("foo") * 2 < 8) result = pl.concat([expr1, expr2]) -
Before optimization: The file is read twice for each expression.
-
After optimization: The file is read only once, with the shared subplan reused for both expressions.
-
Graph visualization:
- Use
result.show_graph(optimized=False)to view the unoptimized plan. - Use
result.show_graph()to view the optimized plan.
- Use
2. Eager API Internally Using Lazy API
- Eager API often calls the lazy API under the hood:
- Allows the query planner to optimize queries before execution.
- Simplifies the codebase, as eager methods act as thin wrappers around lazy API methods.
3. Schema Error Detection
-
Lazy API detects schema errors before data is processed, allowing fast failure and quick feedback.
-
Example: Treating an integer column as a string raises an immediate
SchemaError.ldf = pl.LazyFrame({ "name": ["Alice", "Bob", "Charlie"], "age": [25, 30, 35] }) erroneous_query = ldf.with_columns( pl.col("age").str.slice(1, 3).alias("sliced_age") ) result_df = erroneous_query.collect() # Output: SchemaError: invalid series dtype: expected `String`, got `i64`- Benefit: Prevents long-running queries from failing late due to schema mismatches, improving programming efficiency with instant feedback.
Key Takeaways
- Common subplan elimination avoids redundant data reading by reusing shared subplans.
- Eager API often leverages lazy API optimizations, ensuring efficient execution.
- Lazy API helps catch schema errors early, preventing costly errors in large, long-running queries.
Performance Differences: Eager API vs. Lazy API
Compared to previous snippets ... Lazy API Query Example:
%%time
trips = pl.scan_parquet("data/taxi/yellow_tripdata_*.parquet")
sum_per_vendor = trips.group_by("VendorID").sum()
income_per_distance_per_vendor = sum_per_vendor.select(
"VendorID",
income_per_distance=pl.col("total_amount") / pl.col("trip_distance")
)
top_three = income_per_distance_per_vendor.sort(
by="income_per_distance",
descending=True
).head(3)
top_three.collect()
- The lazy API is about 10 times faster than the eager API in this scenario.
- Reason: The lazy API defers execution and performs query optimizations, leading to faster results.
LazyFrame and collect() Method
- LazyFrame is only evaluated when you call
**collect()**.- This triggers the conversion of the LazyFrame into a DataFrame.
- However, repeated calls to
collect()will recompute the LazyFrame from scratch, re-running the entire computation.
Example: Repeated Computation
-
Creating a LazyFrame and performing computations:
lf = pl.LazyFrame({"col1": [1, 2, 3], "col2": [4, 5, 6]}) print(lf.collect()) print(lf.with_columns(pl.col("col1") + 1).collect()) # Recomputes the LazyFrame -
Note: The second
collect()recalculates the entire LazyFrame, leading to repeated computation.
Key Takeaways
- Lazy API offers substantial performance benefits over the eager API, especially for complex queries and large datasets.
- Recomputing a LazyFrame via multiple
collect()calls can lead to inefficiencies, as it re-executes the query from scratch.
Functionalities Difference Between LazyFrame and DataFrame
- LazyFrame: Data is not available until it’s collected using the
collect()method. - DataFrame: Data is available immediately, allowing all operations to be performed directly.
Aggregations in LazyFrame vs. DataFrame
| Method | DataFrame | LazyFrame |
|---|---|---|
.max_horizontal() |
✓ | |
.mean_horizontal() |
✓ | |
.min_horizontal() |
✓ | |
.product() |
✓ | |
.sum_horizontal() |
✓ | |
.max() |
✓ | ✓ |
.mean() |
✓ | ✓ |
.median() |
✓ | ✓ |
.min() |
✓ | ✓ |
.null_count() |
✓ | ✓ |
.quantile() |
✓ | ✓ |
.std() |
✓ | ✓ |
.sum() |
✓ | ✓ |
.var() |
✓ | ✓ |
-
Common Aggregations:
- Both LazyFrame and DataFrame support column-wise aggregations, such as:
.max().mean().median().min().sum().std().var().null_count().quantile().product()
- In LazyFrame, these operations are deferred until data is collected.
- Both LazyFrame and DataFrame support column-wise aggregations, such as:
-
Horizontal Aggregations (DataFrame-Only):
- Horizontal aggregations apply operations row-wise across columns (e.g., summing multiple columns for each row).
- Available only in DataFrame, not in LazyFrame.
- Examples of horizontal aggregations:
.max_horizontal().mean_horizontal().min_horizontal().sum_horizontal()
Attribute Differences Between DataFrame and LazyFrame
| Attribute | DataFrame | LazyFrame |
|---|---|---|
.flags |
✓ | |
.height |
✓ | |
.shape |
✓ | |
.columns |
✓ | ✓ |
.dtypes |
✓ | ✓ |
.schema |
✓ | ✓ |
.width |
✓ | ✓ |
- Missing Attributes in LazyFrame:
- LazyFrame does not have
.shape,.height, and.flagsattributes because:- Shape/Height: Number of rows and columns is only known when the data is collected.
- Flags: Used internally for optimizations like indicating if a column is sorted.
- LazyFrame does not have
Computation Methods
-
DataFrame:
- Supports computation methods like:
.fold(): Row-wise reduction by applying a function that reduces two Series to one..hash_rows(): Hashes each row to aUInt64value.
- Supports computation methods like:
-
LazyFrame:
- Does not support row-wise computation methods like
.fold()and.hash_rows().
- Does not support row-wise computation methods like
Descriptive Methods Differences
| Method | DataFrame | LazyFrame |
|---|---|---|
.approx_n_unique() |
✓ | |
.describe() |
✓ | |
.estimated_size() |
✓ | |
.glimpse() |
✓ | |
.is_duplicated() |
✓ | |
.is_empty() |
✓ | |
.is_unique() |
✓ | |
.n_chunks() |
✓ | |
.n_unique() |
✓ | |
.explain() |
✓ | |
.show_graph() |
✓ |
-
LazyFrame:
- Has only two descriptive methods:
.explain(): Showcases the query plan..show_graph(): Visualizes the query graph.
- Has only two descriptive methods:
-
DataFrame:
- Provides a range of descriptive methods to analyze the data:
.describe(): Summarizes statistics about each column..estimated_size(): Estimates the memory size of the DataFrame..is_duplicated(),.is_empty(),.is_unique(): Check for duplicates, emptiness, or uniqueness..n_unique(),.approx_n_unique(): Get the number of unique values..glimpse(): Offers a quick view of the data.
- Provides a range of descriptive methods to analyze the data:
Computation Methods
-
DataFrame:
- Supports computation methods like:
.fold(): Row-wise reduction by applying a function that reduces two Series to one..hash_rows(): Hashes each row to aUInt64value.
- Supports computation methods like:
-
LazyFrame:
- Does not support row-wise computation methods like
.fold()and.hash_rows().
- Does not support row-wise computation methods like
GroupBy Methods Difference
- Key Difference: In DataFrame, you can iterate over groups using
.__iter__(), but this is unavailable in LazyFrame.
| Method | DataFrame | LazyFrame |
|---|---|---|
.__iter__() |
✓ | |
.agg() |
✓ | ✓ |
.all() |
✓ | ✓ |
.apply() |
✓ | ✓ |
.count() |
✓ | ✓ |
.first() |
✓ | ✓ |
.head() |
✓ | ✓ |
.last() |
✓ | ✓ |
.map_groups() |
✓ | ✓ |
.max() |
✓ | ✓ |
.mean() |
✓ | ✓ |
.median() |
✓ | ✓ |
.min() |
✓ | ✓ |
.n_unique() |
✓ | ✓ |
.quantile() |
✓ | ✓ |
.sum() |
✓ | ✓ |
.tail() |
✓ | ✓ |
Exporting Methods Difference
- DataFrame: Can export data to various formats such as Arrow, Numpy, Pandas, dictionaries, and more.
- LazyFrame: Cannot export data since it does not hold data until collected.
Manipulation and Selection Difference
- DataFrame: Offers more methods for manipulation and selection compared to LazyFrame.
| Method | DataFrame | LazyFrame |
|---|---|---|
.drop_in_place() |
✓ | |
.extend() |
✓ | |
.find_idx_by_name() |
✓ | |
.get_column() |
✓ | |
.get_column_index() |
✓ | |
.get_columns() |
✓ | |
.hstack() |
✓ | |
.insert_at_idx() |
✓ | |
.insert_column() |
✓ | |
.item() |
✓ | |
.iter_columns() |
✓ | |
.iter_rows() |
✓ | |
.iter_slices() |
✓ | |
.partition_by() |
✓ | |
.pipe() |
✓ | |
.pivot() |
✓ | |
.rechunk() |
✓ | |
.replace() |
✓ | |
.replace_at_idx() |
✓ | |
.replace_column() |
✓ | |
.row() |
✓ | |
.rows() |
✓ | |
.rows_by_key() |
✓ | |
.sample() |
✓ | |
.shrink_to_fit() |
✓ | |
.to_dummies() |
✓ | |
.to_series() |
✓ | |
.transpose() |
✓ | |
.unstack() |
✓ | |
.upsample() |
✓ | |
.vstack() |
✓ | |
.approx_n_unique() |
✓ | |
.first() |
✓ | |
.inspect() |
✓ | |
.last() |
✓ | |
.with_context() |
✓ | |
.bottom_k() |
✓ | ✓ |
.cast() |
✓ | ✓ |
.clear() |
✓ | ✓ |
.clone() |
✓ | ✓ |
.drop() |
✓ | ✓ |
.drop_nulls() |
✓ | ✓ |
.explode() |
✓ | ✓ |
.fill_nan() |
✓ | ✓ |
.fill_null() |
✓ | ✓ |
.filter() |
✓ | ✓ |
.gather_every() |
✓ | ✓ |
.group_by() |
✓ | ✓ |
.group_by_dynamic() |
✓ | ✓ |
.group_by_rolling() |
✓ | ✓ |
.head() |
✓ | ✓ |
.interpolate() |
✓ | ✓ |
.join() |
✓ | ✓ |
.join_asof() |
✓ | ✓ |
.limit() |
✓ | ✓ |
.melt() |
✓ | ✓ |
.merge_sorted() |
✓ | ✓ |
.rename() |
✓ | ✓ |
.reverse() |
✓ | ✓ |
.rolling() |
✓ | ✓ |
.select() |
✓ | ✓ |
.select_seq() |
✓ | ✓ |
.set_sorted() |
✓ | ✓ |
.shift() |
✓ | ✓ |
.shift_and_fill() |
✓ | ✓ |
.slice() |
✓ | ✓ |
.sort() |
✓ | ✓ |
.tail() |
✓ | ✓ |
.take_every() |
✓ | ✓ |
.top_k() |
✓ | ✓ |
.unique() |
✓ | ✓ |
.unnest() |
✓ | ✓ |
.update() |
✓ | ✓ |
.with_columns() |
✓ | ✓ |
.with_columns_seq() |
✓ | ✓ |
.with_row_count() |
✓ | ✓ |
Miscellaneous Methods Difference
- LazyFrame: Limited support for miscellaneous methods compared to DataFrame.
| Method | DataFrame | LazyFrame |
|---|---|---|
.corr() |
✓ | |
.equals() |
✓ | |
.frame_equal() |
✓ | |
.map_rows() |
✓ | |
.cache() |
✓ | |
.collect() |
✓ | |
.collect_async() |
✓ | |
.fetch() |
✓ | |
.map() |
✓ | |
.map_batches() |
✓ | |
.pipe() |
✓ | |
.profile() |
✓ | |
.lazy() |
✓ | ✓ |
Out-of-Core Computation with Lazy API’s Streaming Mode
- Out-of-core computation: Processes data in chunks, allowing computations on datasets too large to fit in RAM.
- Benefit: Shifts the memory limit from the size of RAM to the size of your hard disk, significantly increasing the capacity for handling large datasets.
How to Use Streaming Mode
-
Trigger streaming mode by passing
streaming=Trueto:.collect(streaming=True)— collects the result into RAM (final result must fit in RAM)..sink_csv(),.sink_ipc(),.sink_parquet()— writes results to disk.
-
How it works:
- Data is read in chunks of rows.
- Chunk size depends on the number of available threads and number of columns in the dataset.
Determining Available Threads
-
Find the number of logical CPU cores using:
pl.thread_pool_size()- Example output:
12threads available.
- Example output:
-
Adjusting thread count:
- Modify thread count using environment variables before importing Polars:
import os os.environ["POLARS_MAX_THREADS"] = "2" import polars as pl
Chunk Size Calculation in Streaming Mode
-
Formula: Chunk size is the smaller of:
100050000 / (n_cols * thread_factor)
-
Thread factor:
- 1 if there are 12 or more threads.
- Greater than 1 if there are fewer threads (larger chunk size for fewer threads).
-
Factors affecting chunk size:
- More threads: Smaller chunk size.
- More columns: Smaller chunk size (each row uses more RAM).
Adjusting Chunk Size Manually
-
If default chunk size causes memory issues, manually set chunk size using:
pl.Config.set_streaming_chunk_size(1000)
Tips and Tricks for Using Polars
1. Converting Between LazyFrame and DataFrame
-
From DataFrame to LazyFrame:
-
Use
.lazy()to convert a DataFrame into a LazyFrame. This does not trigger computation, but sets up the data for lazy execution. -
Example:
df = pl.DataFrame({"col": [1, 2, 3]}) lf = df.lazy()
-
-
From LazyFrame to DataFrame:
-
Use
.collect()to materialize a LazyFrame, triggering the query execution and storing the result in RAM. -
Example:
lf.collect()
-
2. Joining a DataFrame and LazyFrame
-
Error when joining a DataFrame and a LazyFrame directly:
lf = pl.LazyFrame( {"id": [1, 2, 3], "value1": [4, 5, 6]}) df = pl.DataFrame( {"id": [1, 2, 3], "value2": [7, 8, 9]}) lf.join(df, on="id") # Results in a TypeError -
Solution:
- Convert the DataFrame to a LazyFrame using
.lazy()or convert the LazyFrame to a DataFrame with.collect(). - Recommended: Stick with the lazy API for better performance.
lf.join(df.lazy(), on="id") - Convert the DataFrame to a LazyFrame using
3. Caching Intermittent Stages
-
To avoid recomputing heavy operations, cache the LazyFrame in memory by chaining
.collect().lazy().- This collects the result into RAM and returns a LazyFrame that points to the cached data.
-
Pattern for optimizing computations:
lf = pl.LazyFrame( {"col1": [1, 2, 3], "col2": [4, 5, 6]}) lf = lf.collect().lazy() # Cache result in memory print(lf.collect()) # Utilizes the cached result print(lf.with_columns(pl.col("col1") + 1).collect()) -
Benefits: Leverages lazy evaluation while avoiding the need for recomputing resource-intensive stages.