改訂新版 前処理大全4章 with r-polars

R
Polars
Author

statditto

Published

May 28, 2024

はじめに

「改訂新版 前処理大全」が発売されました。写経するだけでは面白くない&Rが載っていないのは寂しいので、RのPolarsパッケージ1を触っていきたいと思います。「RのPolarsパッケージってなにそれ?」という方は先にえいつぴさんの紹介記事(Polars R パッケージについて)を読むと概要が分かって良さそうです。

r-polars

前処理大全4章を進めていきます。書籍と公式ドキュメントを参照しながら進めていきます。

パッケージのインストール

まずはPolarsをインストールしましょう。Rustで書かれたパッケージはCRANに載せるのが難しい2という背景があるらしいので、R-universeを利用することが推奨されているそうです3

# Sys.setenv(NOT_CRAN = "true")
# install.packages("polars", repos = "https://rpolars.r-universe.dev")

インストール出来たらパッケージを読み込みましょう。今回は実行時間を測定したい箇所があるので、tictocパッケージ4も利用します。

library(polars)
library(tictoc)

SeriesDataFrame

SeriesはRのvectorDataFramedata.franeに相当するものになります。

x <- pl$Series(name = 'col1', 1:3)
Warning in pl$Series(name = "col1", 1:3): `pl$Series()` will handle unnamed arguments differently as of 0.17.0:
- until 0.17.0, the first argument corresponds to the values and the second argument to the name of the Series.
- as of 0.17.0, the first argument will correspond to the name and the second argument to the values.
Use named arguments in `pl$Series()` or replace `pl$Series(<values>, <name>)` by `as_polars_series(<values>, <name>)` to silence this warning.
x
polars Series: shape: (3,)
Series: 'col1' [i32]
[
    1
    2
    3
]
y <- pl$DataFrame(
  'col1' = 1:3,
  'col2' = c(10.0, 20.0, 30.0),
  'col3' = c('a', 'b', 'c')
)
y
shape: (3, 3)
┌──────┬──────┬──────┐
│ col1 ┆ col2 ┆ col3 │
│ ---  ┆ ---  ┆ ---  │
│ i32  ┆ f64  ┆ str  │
╞══════╪══════╪══════╡
│ 1    ┆ 10.0 ┆ a    │
│ 2    ┆ 20.0 ┆ b    │
│ 3    ┆ 30.0 ┆ c    │
└──────┴──────┴──────┘

欠損はnullとして表示されます。

x_missing <- pl$Series(name = 'col1', c(1, NA, 3))
Warning in pl$Series(name = "col1", c(1, NA, 3)): `pl$Series()` will handle unnamed arguments differently as of 0.17.0:
- until 0.17.0, the first argument corresponds to the values and the second argument to the name of the Series.
- as of 0.17.0, the first argument will correspond to the name and the second argument to the values.
Use named arguments in `pl$Series()` or replace `pl$Series(<values>, <name>)` by `as_polars_series(<values>, <name>)` to silence this warning.
x_missing
polars Series: shape: (3,)
Series: 'col1' [f64]
[
    1.0
    null
    3.0
]

Expression

polarsではデータの変換にExpressionを利用するようです。Expressionをselect()に渡すことで処理結果を得ることが出来ます。

df <- pl$DataFrame(
  'col1' = 1:3,
  'col2' = c(10.0, 20.0, 30.0),
  'col3' = c('a', 'b', 'c')
)

# col1の2乗とcol2の和の計算を行うExpressionを定義
expr <- pl$col("col1")$pow(2) + pl$col("col2")

df$select(expr)
shape: (3, 1)
┌──────┐
│ col1 │
│ ---  │
│ f64  │
╞══════╡
│ 11.0 │
│ 24.0 │
│ 39.0 │
└──────┘
df$with_columns(col4=expr)
shape: (3, 4)
┌──────┬──────┬──────┬──────┐
│ col1 ┆ col2 ┆ col3 ┆ col4 │
│ ---  ┆ ---  ┆ ---  ┆ ---  │
│ i32  ┆ f64  ┆ str  ┆ f64  │
╞══════╪══════╪══════╪══════╡
│ 1    ┆ 10.0 ┆ a    ┆ 11.0 │
│ 2    ┆ 20.0 ┆ b    ┆ 24.0 │
│ 3    ┆ 30.0 ┆ c    ┆ 39.0 │
└──────┴──────┴──────┴──────┘

遅延実行

Polarsは関数を実行するたびに処理が走るeagerモードと、結果が必要になったタイミングで初めて実行されるlazyモードがあります。lazyモードは一連の処理を最適化してくれるそうです。

まずはeagerモードでデータを読み込む例です。

tic()

path <- '../../data/reservation.parquet'

df <- pl$read_parquet(path)

df$filter(pl$col("reserved_at")$dt$year() >= 2016)$
  filter(pl$col("people_num") == 1)$
  select(pl$col("reservation_id", "total_price"))
shape: (280_847, 2)
┌────────────────┬─────────────┐
│ reservation_id ┆ total_price │
│ ---            ┆ ---         │
│ i64            ┆ i32         │
╞════════════════╪═════════════╡
│ 595174         ┆ 15500       │
│ 595177         ┆ 7900        │
│ 595183         ┆ 5600        │
│ 595189         ┆ 13200       │
│ 595202         ┆ 8500        │
│ …              ┆ …           │
│ 1999972        ┆ 9200        │
│ 1999977        ┆ 15200       │
│ 1999997        ┆ 7100        │
│ 1999999        ┆ 17000       │
│ 2000000        ┆ 7800        │
└────────────────┴─────────────┘
toc()
0.22 sec elapsed

次にlazyモードの例です。lazyモードを利用する際は、データの読み込み時にscan_から始まる関数を利用します。

tic()

path <- '../../data/reservation.parquet'

df <- pl$scan_parquet(path)

query <- df$
  filter(pl$col("reserved_at")$dt$year() >= 2016)$
  filter(pl$col("people_num") == 1)$
  select(pl$col("reservation_id", "total_price"))
# この時点ではまだデータは読み込まれておらず、前処理も走っていない
# ここまでの処理を実行して結果を取得
query$collect()
shape: (280_847, 2)
┌────────────────┬─────────────┐
│ reservation_id ┆ total_price │
│ ---            ┆ ---         │
│ i64            ┆ i32         │
╞════════════════╪═════════════╡
│ 595174         ┆ 15500       │
│ 595177         ┆ 7900        │
│ 595183         ┆ 5600        │
│ 595189         ┆ 13200       │
│ 595202         ┆ 8500        │
│ …              ┆ …           │
│ 1999972        ┆ 9200        │
│ 1999977        ┆ 15200       │
│ 1999997        ┆ 7100        │
│ 1999999        ┆ 17000       │
│ 2000000        ┆ 7800        │
└────────────────┴─────────────┘
toc()
0.1 sec elapsed

僕の環境では30~40%ほど高速化出来ています。最適化ありとなしでは実行計画がどのように変化するのかを確認してみましょう。

query$describe_plan()
 SELECT [col("reservation_id"), col("total_price")] FROM
  FILTER [(col("people_num")) == (1.0)] FROM

  FILTER [(col("reserved_at").dt.year()) >= (2016.0)] FROM

    Parquet SCAN ../../data/reservation.parquet
    PROJECT */11 COLUMNS
query$describe_optimized_plan()
 SELECT [col("reservation_id"), col("total_price")] FROM

    Parquet SCAN ../../data/reservation.parquet
    PROJECT 4/11 COLUMNS
    SELECTION: [([(col("reserved_at").dt.year().cast(Float64)) >= (2016.0)]) & ([(col("people_num").cast(Float64)) == (1.0)])]

最適化なしの実行計画は特にコメントする箇所はないですね。普通に全データを読み込んで、二回フィルターをかけています。最適化ありの実行計画を確認すると、前処理に利用する4列のみを読み込んでいることが分かります。また、読み込みの時点で抽出条件を適用している事も分かります。かしこい。

DataFramelazy()を用いると、データを読み込んだ後からでも、その後の処理をlazyモードで行うことが出来ます。

tic()

path <- '../../data/reservation.parquet'

df <- pl$read_parquet(path)

query <- df$lazy()$
  filter(pl$col("reserved_at")$dt$year() >= 2016)$
  filter(pl$col("people_num") == 1)$
  select(pl$col("reservation_id", "total_price"))

query$collect()
shape: (280_847, 2)
┌────────────────┬─────────────┐
│ reservation_id ┆ total_price │
│ ---            ┆ ---         │
│ i64            ┆ i32         │
╞════════════════╪═════════════╡
│ 595174         ┆ 15500       │
│ 595177         ┆ 7900        │
│ 595183         ┆ 5600        │
│ 595189         ┆ 13200       │
│ 595202         ┆ 8500        │
│ …              ┆ …           │
│ 1999972        ┆ 9200        │
│ 1999977        ┆ 15200       │
│ 1999997        ┆ 7100        │
│ 1999999        ┆ 17000       │
│ 2000000        ┆ 7800        │
└────────────────┴─────────────┘
toc()
0.19 sec elapsed

あんまり速度が変わらないですね。データの読み込みがボトルネックとなるケースもありそうなので、こだわりが無ければscan_から始まる関数を利用してよさそうです。

おわりに

いつかPolars触るぞ!と決意してからだいぶ月日がた経ってしまいましたが、ようやく重い腰を上げることが出来ました。RとPythonでは、一部関数名が異なるケースもあったので注意して利用したいと思います。

また、Polarsは破壊的変更が頻繁に行われるそうなので、バージョンを明記しておきます。

polars_info()
Polars R package version : 0.16.4
Rust Polars crate version: 0.39.2

Thread pool size: 20 

Features:                               
default                    TRUE
full_features              TRUE
disable_limit_max_threads  TRUE
nightly                    TRUE
sql                        TRUE
rpolars_debug_print       FALSE

Code completion: deactivated