7
4

Polars 1.0.0 の破壊的変更の紹介(抜粋)

Posted at

はじめに

Python Polars は2024-07-01に、待望の v1.0.0 のメジャーアップデートが公開された。本記事では、恒例の破壊的変更について紹介していきたい。なお、筆者が重要と思うものをpickupしているのでバイアスがあることに注意してもらいたい。気になる方は公式ドキュメントを参照してほしい。

参考: 前回の v0.20系の破壊的変更の記事

Disclaimer

この記事の内容は、私個人の意見や見解であり、私が所属する組織の公式な立場、方針、意見を反映するものではありません。この記事の内容について、組織はいかなる責任も負いません。

(一口メモ)Polarsの破壊的変更の追い方

  • 基本は release page を見る
    • 💥 Breaking changes の項目がそれ
    • 2024-07-04 時点で Python Polars 1.0.0 があるのでそれを見る
  • Milestones に、変更に関するissues, pull requestがあるのでそれを眺める
  • v1.0.0 に関しては、Official v1 docsに公式まとめがあるので、以下はその中から重要と思われるものを抜粋して説明する

執筆時点(2024-07-05)での実行環境

before

>>> sys.version
'3.11.9 (main, Apr 26 2024, 16:03:42) [Clang 15.0.0 (clang-1500.3.9.4)]'
>>> pl.__version__
'0.20.31'

after

>>> sys.version
'3.11.9 (main, Apr 26 2024, 16:03:42) [Clang 15.0.0 (clang-1500.3.9.4)]'
>>> pl.__version__
'1.0.0'

それではいってみましょう

Series constructorでのtime zoneの取り扱い変更 (danger!!!)

今回の変更で最も影響があるものを挙げろと言われればこれ。公式docsの例は timezoneを Europe/Amsterdam としているが、日本人に理解しやすいように Asia/Tokyo での挙動を以下で示している。公式docsと順番は変えてあえて一番目に持ってきています。

before

>>> from datetime import datetime
>>> pl.Series([datetime(2020, 1, 1)], dtype=pl.Datetime('us', 'Asia/Tokyo'))
shape: (1,)
Series: '' [datetime[μs, Asia/Tokyo]]
[
        2020-01-01 00:00:00 JST
]

after


>>> pl.Series([datetime(2020, 1, 1)], dtype=pl.Datetime('us', 'Asia/Tokyo'))
shape: (1,)
Series: '' [datetime[μs, Asia/Tokyo]]
[
        2020-01-01 09:00:00 JST
]

(続き)

before-afterで変更はないが、aware な datetime を渡しても、pl.Series側でdtypeを指定しないとUTC表示で扱われる

before

>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>> pl.Series([datetime(2020, 1, 1, tzinfo=ZoneInfo("Asia/Tokyo"))])
shape: (1,)
Series: '' [datetime[μs, UTC]]
[
	2019-12-31 15:00:00 UTC
]

after



>>> pl.Series([datetime(2020, 1, 1, tzinfo=ZoneInfo("Asia/Tokyo"))])
shape: (1,)
Series: '' [datetime[μs, UTC]]
[
	2019-12-31 15:00:00 UTC
]

(続き)

元データ(datetime(2020, 1, 1))での tzinfo=ZoneInfo("Asia/Tokyo") の有無(naive/aware) と
dtype=pl.Datetime("us", "Asia/Tokyo") の有無の結果

code
pl.Series([datetime(2020, 1, 1)])
pl.Series([datetime(2020, 1, 1)], dtype=pl.Datetime("us", "Asia/Tokyo"))
pl.Series([datetime(2020, 1, 1, tzinfo=ZoneInfo("Asia/Tokyo"))])
pl.Series([datetime(2020, 1, 1, tzinfo=ZoneInfo("Asia/Tokyo"))], dtype=pl.Datetime("us", "Asia/Tokyo"))

before

input dtype指定 結果
naive Series: '' [datetime[μs]]
[2020-01-01 00:00:00]
naive Series: '' [datetime[μs, Asia/Tokyo]]
[2020-01-01 00:00:00 JST]
aware Series: '' [datetime[μs, UTC]]
[2019-12-31 15:00:00 UTC]
aware ValueError: time-zone-aware
datetimes are converted to UTC

after

input dtype指定 結果 変更
naive Series: '' [datetime[μs]]
[2020-01-01 00:00:00]
naive Series: '' [datetime[μs, Asia/Tokyo]]
[2020-01-01 09:00:00 JST]
aware Series: '' [datetime[μs, UTC]]
[2019-12-31 15:00:00 UTC]
aware Series: '' [datetime[μs, Asia/Tokyo]]
[2020-01-01 00:00:00 JST]

Series constructorの型の厳格化

実行時の不確実性を減らすために厳格になることは望ましいと個人的に感じる。Hintが表示されるのは厳しさの中に優しさがあるのでは。

before

>>> s = pl.Series([1, 2, 3.5])
shape: (3,)
Series: '' [f64]
[
        1.0
        2.0
        3.5
]
>>> s = pl.Series([1, 2, 3.5], strict=False)
shape: (3,)
Series: '' [i64]
[
        1
        2
        null
]
>>> s = pl.Series([1, 2, 3.5], strict=False, dtype=pl.Int8)
Series: '' [i8]
[
        1
        2
        null
]

after

>>> s = pl.Series([1, 2, 3.5])
Traceback (most recent call last):
...
TypeError: unexpected value while building Series of type Int64;
 found value of type Float64: 3.5

Hint: Try setting `strict=False` to allow passing data 
with mixed types.
>>> s = pl.Series([1, 2, 3.5], strict=False)
shape: (3,)
Series: '' [f64]
[
        1.0
        2.0
        3.5
]
>>> s = pl.Series([1, 2, 3.5], strict=False, dtype=pl.Int8)
Series: '' [i8]
[
        1
        2
        3
]

read_scan_parquet Hive パーテションの取り扱い変更

ファイル指定(単一ファイル、glob, list of ファイル)の読み込み時に、Hiveパーテションの自動読み込みはされなくなったので、 同じ挙動のためには hive_partitioning=True の指定が必要。同一コードのままでも実行時エラーなど出ないので注意が必要。

before

>>> pl.read_parquet("dataset/a=1/foo.parquet")
shape: (2, 2)
┌─────┬─────┐
 a    x   
 ---  --- 
 i64  f64 
╞═════╪═════╡
 1    1.0 
 1    2.0 
└─────┴─────┘

after

>>> pl.read_parquet("dataset/a=1/foo.parquet")
shape: (2, 1)
┌─────┐
 x   
 --- 
 f64 
╞═════╡
 1.0 
 2.0 
└─────┘
>>> pl.read_parquet("dataset/a=1/foo.parquet", hive_partitioning=True)
shape: (2, 2)
┌─────┬─────┐
 a    x   
 ---  --- 
 i64  f64 
╞═════╪═════╡
 1    1.0 
 1    2.0 
└─────┴─────┘

replace 機能の分離

replace 実行結果の型が変わるケースでエラーとなる。 replace_strict の新しいmethodに書き換えが必要。

before

>>> s = pl.Series([1, 2, 3])
>>> s.replace(1, "a")
shape: (3,)
Series: '' [str]
[
        "a"
        "2"
        "3"
]

after

>>> s.replace(1, "a")
Traceback (most recent call last):
...
polars.exceptions.InvalidOperationError: conversion from `str` to `i64` failed in column 'literal' for 1 out of 1 values: ["a"]
>>> s.replace_strict(1, "a", default=s)
shape: (3,)
Series: '' [str]
[
        "a"
        "2"
        "3"
]

Expr.clipの null の挙動変更

便利な clip method の範囲指定の None (null value) について、解釈が変更。個人的には変更後の挙動は嬉しい。(結果をnullにするようなユースケースはほぼないので。)

before

>>> df = pl.DataFrame(
        {"a": [0, 1, 2], "min": [1, None, 1]})
>>> df.select(pl.col("a").clip("min"))
shape: (3, 1)
┌──────┐
 a    
 ---  
 i64  
╞══════╡
 1    
 null 
 2    
└──────┘

after



>>> df.select(pl.col("a").clip("min"))     
shape: (3, 1)
┌──────┐
 a    
 ---  
 i64  
╞══════╡
 1    
 1    
 2    
└──────┘

str.to_datetimeのformatでの %.fが nano sec から micro secへ変更

こういう変更が、csv or json等に永続化して別プロセスでそれを読み込む場合、忘れた頃にパースエラーで workflow止まるので要注意。

before

>>> s = pl.Series(["2022-08-31 00:00:00.123456789"])
>>> s.str.to_datetime(format="%Y-%m-%d %H:%M:%S%.f")
shape: (1,)
Series: '' [datetime[ns]]
[
        2022-08-31 00:00:00.123456789
]

after


>>> s.str.to_datetime(format="%Y-%m-%d %H:%M:%S%.f")
shape: (1,)
Series: '' [datetime[us]]
[
        2022-08-31 00:00:00.123456
]

Series.equals がデフォルトではカラム名の名前を見なくなる

エラーにならないので注意。Seriesを比較するユースケースは私はあまりないかも。

before

>>> s1 = pl.Series("foo", [1, 2, 3])
>>> s2 = pl.Series("bar", [1, 2, 3])
>>> s1.equals(s2)
False

after

>>> s1.equals(s2)
True
>>> s1.equals(s2, check_names=True)
False

df.sql()が廃止し pl.sql()に

DataFrameオブジェクトにメソッドが生えるより、pl.sql()のほうが自然に感じる。

before

>>> df1 = pl.DataFrame({"id1": [1, 2]})
>>> df2 = pl.DataFrame({"id2": [3, 4]})
>>> df1.sql("SELECT * FROM df1 CROSS JOIN df2")            
shape: (4, 2)
┌─────┬─────┐
 id1  id2 
 ---  --- 
 i64  i64 
╞═════╪═════╡
 1    3   
 1    4   
 2    3   
 2    4   
└─────┴─────┘

after

>>> df1.sql("SELECT * FROM df1 CROSS JOIN df2")
Traceback (most recent call last):
...
polars.exceptions.SQLInterfaceError: relation 'df1' was
 not found
>>> pl.sql("SELECT * FROM df1 CROSS JOIN df2", eager=True)
shape: (4, 2)
┌─────┬─────┐
 id1  id2 
 ---  --- 
 i64  i64 
╞═════╪═════╡
 1    3   
 1    4   
 2    3   
 2    4   
└─────┴─────┘

以上です。

繰り返しますが、全ての破壊的変更については公式ドキュメントを参照してください。

最後に個人の感想ですが、v1.0のメジャーアップデートになったからといえ今後も破壊的変更はカジュアルにおこなわれるのではと懸念(期待)しています。

(このタイミングでのv1化は、某🦆dbが1.0になった時流に乗ったマーケティング戦略上の都合なのか、polars BV(非公開株式会社)として資金調達し組織化した関係から何かプレッシャーがあったのか、いろいろ考えさせられます。)

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4