データ型#

from helper.python import print_subclasses
from helper.jupyter import row
import numpy as np
import polars as pl

Polarsは、データの効率的な管理と操作を可能にするため、多様なデータ型をサポートしています。この章では、Polarsで利用可能なデータ型を詳細に紹介し、各データ型の特性と用途について説明します。数値型、時系列型、ネスト型など、さまざまなデータ型がどのように構造化され、データ処理にどのように役立つかを理解することで、Polarsの強力なデータ処理機能を最大限に活用できるようになります。

データ型の継承木#

Polarsのデータ型は、非常に多様で柔軟性があります。次の継承木の構造と各データ型の詳細について説明します。この継承木は、各データ型がどのように構造化されているかを示し、各データ型の特性や用途を理解するのに役立ちます。NumericTypeやTemporalTypeのような主要なデータ型は、さらに細かいデータ型に分かれており、それぞれが特定のデータ処理ニーズに応じて最適化されています。NestedTypeは複雑なデータ構造をサポートし、BooleanやStringなどの基本的なデータ型も広範なデータ処理を可能にします。

print_subclasses(pl.DataType)
└──DataType
   ├──NumericType
   │  ├──IntegerType
   │  │  ├──SignedIntegerType
   │  │  │  ├──Int8
   │  │  │  ├──Int16
   │  │  │  ├──Int32
   │  │  │  └──Int64
   │  │  └──UnsignedIntegerType
   │  │     ├──UInt8
   │  │     ├──UInt16
   │  │     ├──UInt32
   │  │     └──UInt64
   │  ├──FloatType
   │  │  ├──Float32
   │  │  └──Float64
   │  └──Decimal
   ├──TemporalType
   │  ├──Date
   │  ├──Time
   │  ├──Datetime
   │  └──Duration
   ├──NestedType
   │  ├──List
   │  ├──Array
   │  └──Struct
   ├──Boolean
   ├──String
   ├──Binary
   ├──Categorical
   ├──Enum
   ├──Object
   ├──Null
   └──Unknown

数値型

  • NumericType: 数値型の基底クラス。整数型や浮動小数点型などの数値データを扱います。

    • IntegerType: 整数データを表すクラスの基底クラス。

      • SignedIntegerType: 符号付き整数データ(Int8、Int16、Int32、Int64)

      • UnsignedIntegerType: 符号なし整数データ(UInt8、UInt16、UInt32、UInt64)

    • FloatType: 浮動小数点データを表すクラスの基底クラス(Float32、Float64)

    • Decimal: 固定小数点数データを扱います。高精度の数値計算に使用されます。

時系列型

  • TemporalType: 時間に関連するデータ型の基底クラス。

    • Date: 日付データを扱います(例: 2024-07-18)。

    • Time: 時刻データを扱います(例: 12:34:56)。

    • Datetime: 日時データを扱います(例: 2024-07-18 12:34:56)。

    • Duration: 時間の期間を扱います(例: 5分、2時間)。

ネスト型

  • NestedType: ネスト構造を持つデータ型の基底クラス。

    • List: 要素のリストを扱います。リスト内の要素は同じデータ型でなければなりません。

    • Array: 固定長の配列を扱います。

    • Struct: 構造体データを扱います。異なるデータ型を持つ複数のフィールドを持つことができます。

その他のデータ型

  • Boolean: 真偽値データを扱います(TrueまたはFalse)。

  • String: 文字列データを扱います。

  • Binary: バイナリデータを扱います。

  • Categorical: カテゴリカルデータを扱います。効率的にカテゴリ値を管理します。

  • Enum: 列挙型データを扱います。

  • Object: 任意のPythonオブジェクトを扱います。特定のデータ型に適合しないデータを格納するために使用されます。

  • Null: NULL値を扱います。欠損値を示すために使用されます。

  • Unknown: 未知のデータ型を扱います。

データ型変換#

Cast#

Polarsは、データ型の変換を簡単に行うための便利な関数を提供しています。特に、cast()shrink_dtype() の2つの関数は、データ型のキャストや縮小に使用されます。以下に、それぞれの関数について詳しく説明します。

df = pl.DataFrame(
    {
        "a": [3, 3, 3, 4],
        "b": [4, 12, 6, 7],
        "g": ['B', 'A', 'A', 'C']
    }
)
df
shape: (4, 3)
abg
i64i64str
34"B"
312"A"
36"A"
47"C"

cast()は、指定されたデータ型に列をキャストするために使用されます。この関数は、データの表現形式を変える際に非常に便利です。例えば、整数型の列を浮動小数点型に変換したり、文字列型の列を日付型に変換したりすることができます。

df.with_columns(
    pl.col.a.cast(pl.UInt8),
    pl.col.b.cast(pl.String)
)
shape: (4, 3)
abg
u8strstr
3"4""B"
3"12""A"
3"6""A"
4"7""C"

shrink_dtype()は、データ型を必要最小限のサイズに縮小するために使用されます。例えば、Int64 型の列が実際には Int32 で十分な場合、この関数を使用してデータ型を自動的に縮小することができます。これにより、メモリ使用量を削減し、効率的なデータ管理が可能になります。

df.select(pl.all().shrink_dtype())
shape: (4, 3)
abg
i8i8str
34"B"
312"A"
36"A"
47"C"

データ型再定義#

castではなく、NumPyの.view()のように、データをそのままにして、別の型に切り替えたい場合は、次のプログラムのように、.to_arrow()で一旦PyArrowの配列に変換し、その配列の.view()メソッドでデータ型を変更します。最後にその結果をSeriesに変換します。

Expr.reinterpret()Series.reinterpret()もがありますが、これは整数int64とuint64間の切り替えしかできません。

データ型を変更する際に、データそのものは変更せずに型だけを切り替えたい場合があります。NumPyの .view() メソッドのように、データの内容をそのままにして別の型に変更するには、以下の方法を使用します。

  1. to_arrow() を使用してPyArrowの配列に変換

  2. PyArrowの配列の .view() メソッドでデータ型を変更

  3. 変更後の結果を Series に変換

次の例では、UInt64のデータをFloat64として再定義します。

df_wrong = pl.DataFrame({"nums": [4837362400224322580,None,4837362400224322584]}, {"nums":pl.UInt64})
df_fixed = df_wrong.select(pl.col("nums").map_batches(lambda s:pl.Series(s.to_arrow().view("f8"))))
row(df_wrong, df_fixed)
shape: (3, 1)
nums
u64
4837362400224322580
null
4837362400224322584
shape: (3, 1)
nums
f64
2.5000e15
null
2.5000e15

Polarsには Expr.reinterpret() および Series.reinterpret() というメソッドもありますが、これらは主に int64uint64 の間で型を切り替えるためのもので、他のデータ型への変更には使用できません。

次のコードはarrow配列のバッファでbytesデータから整数に変換します。

import polars as pl
import numpy as np

df = pl.DataFrame({
    'binary_column': [b'\x00\x00\x00\x01', b'\x00\x00\x00\x02', b'\x00\x00\x00\x03', b'\x00\x00@A']
})

df.select(pl.col('binary_column').map_batches(lambda s:pl.Series(np.frombuffer(s.to_arrow().buffers()[2], dtype=">i4").astype("<i4"))))
shape: (4, 1)
binary_column
i32
1
2
3
16449

文字列#

Polarsは文字列データの処理にも強力な機能を備えています。文字列処理関連の演算式関数はネーミングスペース.str の下にあります。

文字列の長さ#

  • str.len_chars(): 文字列のキャラクター数(文字数)を測定します。これは、文字列内に含まれる実際の文字の数を返します。たとえば、マルチバイト文字(日本語のような2バイト文字)も1文字としてカウントされます。

  • str.len_bytes(): 文字列のバイト数を測定します。これは、各文字がエンコードされる際に占めるバイト数の合計を返します。マルチバイト文字は複数バイトとしてカウントされます。

df = pl.DataFrame({
    "lib": ["pandas", "numpy", "polars", "図形"],
    "ver": ["2.0", "1.1.4", "1.2.1", "4.0"]
})

df.select(
    "lib",
    len_char=pl.col("lib").str.len_chars(),
    len_byte=pl.col("lib").str.len_bytes()
)
shape: (4, 3)
liblen_charlen_byte
stru32u32
"pandas"66
"numpy"55
"polars"66
"図形"26

文字列の部分一致検索#

str.contains()str.starts_with()str.ends_with()などの関数で、特定の部分文字列が含まれるか、指定された文字列で始まるか、終わるかをチェックできます。

df.filter(pl.col.lib.str.starts_with("p"))
shape: (2, 2)
libver
strstr
"pandas""2.0"
"polars""1.2.1"
df.filter(pl.col.ver.str.ends_with("0"))
shape: (2, 2)
libver
strstr
"pandas""2.0"
"図形""4.0"
df.filter(pl.col.lib.str.contains('p'))
shape: (3, 2)
libver
strstr
"pandas""2.0"
"numpy""1.1.4"
"polars""1.2.1"

.contains()には正規表現が使えます。次のコードは二つの点があるバージョンの行を取得します。

df.filter(pl.col.ver.str.contains("^\d+\.\d+\.\d+$"))
shape: (2, 2)
libver
strstr
"numpy""1.1.4"
"polars""1.2.1"

.str.contains_any() は特定の文字列やパターンのリストのいずれかが含まれているかを判定するために使用されます。このメソッドは、複数のキーワードが含まれているかどうかを一度にチェックしたい場合に非常に便利です。

df2 = pl.DataFrame({
    "text": ["hello world", "data process library", "data science", "machine learning", "plot library"]
})

df2.filter(
    pl.col("text").str.contains_any(["science", "machine"])
)
shape: (2, 1)
text
str
"data science"
"machine learning"

.str.contains_any() の引数には、演算式も使用できます。以下のプログラムは、text 列に含まれる単語の中で、出現頻度が上位2つの単語を含む行を抽出します。

expr = pl.col.text.str.split(' ').explode().value_counts(sort=True).head(2).struct.field('text')
row(df2.select(expr), df2.filter(pl.col.text.str.contains_any(expr)))
shape: (2, 1)
text
str
"data"
"library"
shape: (3, 1)
text
str
"data process library"
"data science"
"plot library"

文字列の置換#

.str.replace(), .str.replace_all()で特定の部分文字列を置換します。

  • str.replace(): は最初に見つかったN個の一致のみを置換します。

  • str.replace_all(): はすべての一致を置換します。

df2.select(pl.col.text.str.replace_all('data', 'DATA'))
shape: (5, 1)
text
str
"hello world"
"DATA process library"
"DATA science"
"machine learning"
"plot library"

文字列の分割#

文字列を特定の区切り文字で分割できます。

df2.with_columns([
    pl.col("text").str.split(" ").alias("split")
])
shape: (5, 2)
textsplit
strlist[str]
"hello world"["hello", "world"]
"data process library"["data", "process", "library"]
"data science"["data", "science"]
"machine learning"["machine", "learning"]
"plot library"["plot", "library"]
df2.with_columns([
    pl.col("text").str.split_exact(" ", 1).alias("split_exact")
])
shape: (5, 2)
textsplit_exact
strstruct[2]
"hello world"{"hello","world"}
"data process library"{"data","process"}
"data science"{"data","science"}
"machine learning"{"machine","learning"}
"plot library"{"plot","library"}

文字列のトリミング#

文字列の前後の空白や特定の文字を除去します。

df3 = pl.DataFrame({
    "text": [" hello ", " world", "polars ", "is ", " awesome"]
})

df3.with_columns([
    pl.col("text").str.strip_chars(" ").alias("stripped"),
    pl.col("text").str.strip_prefix(" ").alias("no_prefix"),
    pl.col("text").str.strip_suffix(" ").alias("no_suffix")
    
])
shape: (5, 4)
textstrippedno_prefixno_suffix
strstrstrstr
" hello ""hello""hello "" hello"
" world""world""world"" world"
"polars ""polars""polars ""polars"
"is ""is""is ""is"
" awesome""awesome""awesome"" awesome"
  • str.strip_chars(): 両端の空白を削除。

  • str.strip_prefix(): 左端の空白を削除。

  • str.strip_suffix(): 右端の空白を削除。

文字列のパディング#

文字列の左右、または片方に特定の文字で埋めてパディングを行います。

df2.with_columns([
    pl.col("text").str.pad_start(20, "-").alias("padded")
])
shape: (5, 2)
textpadded
strstr
"hello world""---------hello world"
"data process library""data process library"
"data science""--------data science"
"machine learning""----machine learning"
"plot library""--------plot library"

文字列の抽出#

Polarsにおける文字列操作関数のいくつか(str.extract, str.extract_all, str.extract_groups, str.extract_many)は、正規表現を使ったパターンマッチングによって文字列データから特定の部分を抽出する際に便利です。これらの関数は、使い方や返される結果が異なりますので、順番に説明します。

str.extract(pattern, group_index) は、正規表現に基づいてマッチしたグループを返します。2つの引数は:

  • pattern: 正規表現パターンです。文字列から特定の部分を抽出するために、正規表現を使ってパターンを指定します。このパターンには()キャプチャグループを示します。この括弧内に含まれる部分が、マッチした場合に抽出される内容です。複数のキャプチャグループを作ることも可能で、その場合、グループの番号(1, 2, 3…)が割り当てられます。

  • group_index: キャプチャグループのインデックスです。正規表現パターンの中でどのキャプチャグループを抽出するかを指定します。

次の例のpattern引数には二つのキャプチャグループがあり、group_indexで抽出するグループのインデックスを指定し、アイテムAとBの数値を抽出します。

df = pl.DataFrame({
    "text": ["Item A: 50, Item B: 30", "Item A: 70, Item B: 45"]
})

pattern = r'Item A: (\d+), Item B: (\d+)'

df.select(
    pl.col("text").str.extract(pattern, 1).alias("A_value"),
    pl.col("text").str.extract(pattern, 2).alias("B_value"),
)
shape: (2, 2)
A_valueB_value
strstr
"50""30"
"70""45"

str.extract_groups() は、正規表現内のすべてのキャプチャグループを抽出し、一つの構造体列として返します。フィールド名指定する場合は、(?P<A_value>\d+)のような名前付きキャプチャグループを使用します。

pattern = r'Item A: (?P<A_value>\d+), Item B: (?P<B_value>\d+)'

df.select(
    pl.col("text").str.extract_groups(pattern).alias('values')
).unnest('values')
shape: (2, 2)
A_valueB_value
strstr
"50""30"
"70""45"

str.extract_all() は、正規表現に基づいてマッチしたすべての部分をリストとして返します。

df.select(
    pl.col("text").str.extract_all(r'\d+').alias('values')
)
shape: (2, 1)
values
list[str]
["50", "30"]
["70", "45"]

str.extract_many()は、Aho-Corasickアルゴリズムを使用して、文字列内から指定されたパターンに一致するすべての部分を抽出するための関数です。このメソッドは、正規表現ではなく、文字列リテラルのみに基づいてマッチングを行います。このメソッドは、文字列検索に対して非常に効率的であり、大量のデータセットでもパフォーマンスを発揮します。引数は以下のようです。

  • patterns: 検索する文字列パターンのリストを指定します。["apple", "banana", "orange"] のように、探したい特定の単語や文字列をリスト形式で与えます。

  • ascii_case_insensitive: 大文字・小文字を区別しないマッチングを有効にするかどうかを指定します。

  • overlapping: オーバーラップするマッチを許可するかどうかを指定します。True にすると、同じ文字列内で複数のパターンが重複している場合、それぞれを独立したマッチとして扱います。

df = pl.DataFrame({
    "text": ["I like apples and bananas", "I prefer oranges and pineapples"]
})

# "apple"と"banana"を検索
df.select(
    pl.col("text").str.extract_many(["apple", "banana"]).alias("matches")
)
shape: (2, 1)
matches
list[str]
["apple", "banana"]
["apple"]

EnumとCategorical#

Enum型(列挙型)は、特定の固定された値の集合から選択されるデータ型です。これは、データの一貫性を確保し、誤った値が入力されるのを防ぐのに役立ちます。Enum型を使用することで、プログラム内で定義された値以外の入力を防ぐことができ、データの整合性を維持することができます。

Categorical型(カテゴリカル型)は、有限個のカテゴリーを持つデータ型で、データの重複を避け、メモリ効率を向上させるために使用されます。Categorical型は、各カテゴリに整数のインデックスを割り当てることで、メモリ使用量を削減します。

Enumは事前に作成され、要素とその順番が固定されています。一方、Categoricalは事前作成の必要がなく、要素の順番はデータ内での出現順になります。

score = pl.Enum(['A', 'B', 'C', 'D'])
df2 = df.select(
    pl.col.g.cast(score).alias('g_enum'),
    pl.col.g.cast(pl.Categorical).alias('g_cat')
)
df2
shape: (4, 2)
g_enumg_cat
enumcat
"B""B"
"A""A"
"A""A"
"C""C"

to_physical()を使用すると、内部で保存されている番号を取得できます。次の結果では、g_enum の番号は score で定義された番号であり、g_catの番号は元の列の値が出現する順番に基づいています。

df2.select(pl.all().to_physical())
shape: (4, 2)
g_enumg_cat
u32u32
10
01
01
22

次のコードは、Categorical型の列で異なるコードが使用される場合の違いを示しています。異なるコードが使用される二つのCategorical列同士で計算を行うと、処理が遅くなる可能性があります。

df3 = pl.DataFrame(dict(
    c1 = pl.Series(["Polar", "Panda", "Brown", "Brown", "Polar"], dtype=pl.Categorical),
    c2 = pl.Series(["Panda", "Brown", "Brown", "Polar", "Polar"], dtype=pl.Categorical),
))

df3.with_columns(pl.all().to_physical().name.suffix('_code'))
shape: (5, 4)
c1c2c1_codec2_code
catcatu32u32
"Polar""Panda"00
"Panda""Brown"11
"Brown""Brown"21
"Brown""Polar"22
"Polar""Polar"02

次に、pl.StringCache()を使用して、同じコードが使われるCategorical列を作成します。

with pl.StringCache() as s:
    df4 = pl.DataFrame(dict(
        c1 = pl.Series(["Polar", "Panda", "Brown", "Brown", "Polar"], dtype=pl.Categorical),
        c2 = pl.Series(["Panda", "Brown", "Brown", "Polar", "Polar"], dtype=pl.Categorical),
    ))
    
df4.with_columns(pl.all().to_physical().name.suffix('_code'))
shape: (5, 4)
c1c2c1_codec2_code
catcatu32u32
"Polar""Panda"01
"Panda""Brown"12
"Brown""Brown"22
"Brown""Polar"20
"Polar""Polar"00

DateTime#

ListとArray#

一つの要素に複数の値を保存する場合には、List 列または Array 列を使用します。Array 列では、各要素の長さが一定でなければなりませんが、List 列では要素の長さが異なっても問題ありません。Array 列と List 列の要素を処理するための式関数は、それぞれ array および list ネームスペース内にあります。list列のメモリレイアウト 各個リストを処理する式

次のコードは、itemsという名前の列を持つDataFrameを作成しています。この列はリストを含んでおり、各行にはリストが含まれています。listネームスペース内のmax()mean()len()を使って、items列の各リストの最大値、平均値と要素数を計算し、新しい列として追加します。

df = pl.DataFrame(dict(items=[[1, 2], [3, 4], [5, 6, 7]]))
df.with_columns(
    max=pl.col("items").list.max(),
    mean=pl.col("items").list.mean(),
    len=pl.col("items").list.len()
)
shape: (3, 4)
itemsmaxmeanlen
list[i64]i64f64u32
[1, 2]21.52
[3, 4]43.52
[5, 6, 7]76.03

list.get(index)で指定されたindexの要素を取り出します。次のコードはitems列の各個リストの最初の要素と最後の要素の足し算を計算します。

df.with_columns(
    first_plus_last = 
        pl.col('items').list.get(0) + 
        pl.col('items').list.get(-1)
)
shape: (3, 2)
itemsfirst_plus_last
list[i64]i64
[1, 2]3
[3, 4]7
[5, 6, 7]12

concat_list#

pl.concat_list()関数は、指定された列や式の結果をリストとして結合するために使用されます。この関数を使用すると、複数の列の値をリストとして1つの列にまとめることができます。

df = pl.DataFrame(
    {
        "a": [3, 3, 3, 4],
        "b": [4, 12, 6, 7],
        "g": ['B', 'A', 'A', 'C']
    }
)

df.with_columns(
    pl.concat_list(
        ['a', 'b', pl.col('a') + pl.col('b')]
    )
    .alias('ab')
)
shape: (4, 4)
abgab
i64i64strlist[i64]
34"B"[3, 4, 7]
312"A"[3, 12, 15]
36"A"[3, 6, 9]
47"C"[4, 7, 11]

pl.concat_list()で複数の列を一つのリスト列に変換した後、.list下のメソッドを使って様々な計算を行うことができます。例えば、次のプログラムでは、この方法で次の計算を行います。

  • A, C, E列の行毎の総和。行毎の総和を計算する場合は、pl.sum_horizontal()も使えます。

  • 行毎の中間値

df_numbers = pl.DataFrame(
    np.random.randint(0, 10, size=(6, 5)), schema=list("ABCDE")
)
df_numbers.with_columns(
    pl.concat_list('A', 'C', 'E').list.sum().alias('sum_ACE'),
    pl.sum_horizontal(pl.col('A', 'C', 'E')).alias('sum_ACE2'),
    pl.concat_list('*').list.median().alias('median_all')
)
shape: (6, 8)
ABCDEsum_ACEsum_ACE2median_all
i32i32i32i32i32i32i32f64
1432913133.0
9927314147.0
26460664.0
21175882.0
4872718187.0
2992112122.0

reshape#

Expr.reshape()numpy.reshape()と似ています。1つの列の要素を指定した数で横向きに並べる関数です。以下の例では、reshape()を使ってデータフレームの列の形状を変更しています。

row(
    df_numbers.select(pl.col('*').reshape((-1, 3))),
    df_numbers.select(pl.col('*').reshape((-1, 2))),
)
shape: (2, 5)
ABCDE
array[i32, 3]array[i32, 3]array[i32, 3]array[i32, 3]array[i32, 3]
[1, 9, 2][4, 9, 6][3, 2, 4][2, 7, 6][9, 3, 0]
[2, 4, 2][1, 8, 9][1, 7, 9][7, 2, 2][5, 7, 1]
shape: (3, 5)
ABCDE
array[i32, 2]array[i32, 2]array[i32, 2]array[i32, 2]array[i32, 2]
[1, 9][4, 9][3, 2][2, 7][9, 3]
[2, 2][6, 1][4, 1][6, 7][0, 5]
[4, 2][8, 9][7, 9][2, 2][7, 1]

implode と explode#

Expr.implode()は、式の出力を1つのリスト列に変換します。以下のプログラムは、各列の最初の3行のデータをリストとして保存します。

df = pl.DataFrame(dict(A=[1, 2, 3, 4], B=["X", "Y", "X", "Z"]))
df.select(pl.col('*').head(3).implode())
shape: (1, 2)
AB
list[i64]list[str]
[1, 2, 3]["X", "Y", "X"]

Expr.explode()は、リスト内の要素を縦に展開します。次のプログラムでは、リスト列valueの要素を縦に並べ、group列の各要素をExpr.repeat_by()を使ってvalueの各リストの長さ回繰り返します。

  • pl.col("group").repeat_by(pl.col("value").list.lengths()) は、group列の各要素をvalue列の各リストの長さ回繰り返します。

  • pl.col("value").explode() は、value列のリストを縦に展開します。

結果として、group列の各要素は対応するvalue列の要素に合わせて繰り返されます。

df = pl.DataFrame(dict(group=["B", "A", "A", "C"], value=[[3, 4], [1, 2, 6], [1], [2, 3]]))

df.select(
    pl.col.group.repeat_by(pl.col.value.list.len()).explode(),
    pl.col.value.list.explode()
)
shape: (8, 2)
groupvalue
stri64
"B"3
"B"4
"A"1
"A"2
"A"6
"A"1
"C"2
"C"3

DataFrame.explode()は、Expr.explode()と同じ処理を行いますが、他の列のデータを自動的に繰り返します。次のプログラムでは、リスト列valueの要素を縦に並べ、他の列(group列)のデータを自動的に繰り返します。

df.explode('value')
shape: (8, 2)
groupvalue
stri64
"B"3
"B"4
"A"1
"A"2
"A"6
"A"1
"C"2
"C"3

struct列に変換#

list.to_struct()は、リスト列の各要素を構造化データ(構造体)に変換します。このメソッドでは、リスト内の各値を個別のフィールドとして持つ構造体に変換し、フィールド数は引数n_field_strategyにn基づいて決定されます。'max_width'は、最長のリストの長さに基づいてフィールド数を設定するために使用されます。

df = pl.DataFrame(dict(values=[[1, 2], [2, 3, 4], [1, 10]]))
df2 = df.select(pl.col.values.list.to_struct(n_field_strategy='max_width'))
row(df, df2)
print(df2.schema)
shape: (3, 1)
values
list[i64]
[1, 2]
[2, 3, 4]
[1, 10]
shape: (3, 1)
values
struct[3]
{1,2,null}
{2,3,4}
{1,10,null}
Schema([('values', Struct({'field_0': Int64, 'field_1': Int64, 'field_2': Int64}))])

リスト列同士の計算#

Polarsでは、リスト列同士、またはリスト列と数値列の間で算術演算を行うことができます。

  • リスト列同士の演算: 各行において、対応するリストの要素ごとに演算が行われます。

  • リスト列と数値列の演算: 数値列の値が各リストの全要素に適用されます。

以下は具体的な例です。このデータフレームでは、x列とy列の要素がリストで、各行のリストの長さは一致しています。また、z列は数値列です。

df = pl.DataFrame(dict(
    x=[[1, 3, 3], [4, 5, 7, 8]], 
    y=[[10, 20, 30], [40, 50, 60, 80]],
    z=[10, 20]    
))

df.with_columns(
    x_plus_y = pl.col('x') + pl.col('y'),
    x_plus_z = pl.col('x') + pl.col('z'),
    
)
shape: (2, 5)
xyzx_plus_yx_plus_z
list[i64]list[i64]i64list[i64]list[i64]
[1, 3, 3][10, 20, 30]10[11, 23, 33][11, 13, 13]
[4, 5, … 8][40, 50, … 80]20[44, 55, … 88][24, 25, … 28]

Polars 1.17.0時点では、リスト列間の演算として算術演算(加算、減算、乗算、除算など)はサポートされていますが、それ以外の演算(例: 比較演算や論理演算)はサポートされていません。そのため、次のコードはエラーになります。

%%capture_except
df.select(
    pl.col('x') * 10 > pl.col('y')
)
InvalidOperationError: cannot perform '>' comparison between series 'x' of dtype: list[i64] and series 'y' of dtype: list[i64]

次の方法を使えば、リスト列の要素同士の計算が可能です。

  1. with_row_index()
    各行に一意のインデックスを追加します。このインデックスは後でグループ化に使用されます。

  2. explode('x', 'y')
    x列とy列のリストを展開して、各リストの要素を縦方向に並べます。これにより、リスト要素の対応が維持されます。

  3. group_by('index')
    行ごとの演算結果を元の形に戻すため、インデックス列を基準にデータをグループ化します。

  4. agg()
    各グループ内で演算を行います。ここでは、x列の要素を10倍し、y列の対応する要素と比較します。

  5. drop('index')
    最後にインデックス列を削除して不要な列を取り除きます。

(
df
.with_row_index()
.explode('x', 'y')
.group_by('index')
.agg(
    pl.col('x') * 10 > pl.col('y')
)
.drop('index')
)
shape: (2, 1)
x
list[bool]
[false, true, false]
[false, false, … false]

本書のhelper.polarsモジュールをインポートすると、list_eval()メソッドがDataFrameクラスに追加されます。このメソッドは、リスト列を含むデータフレームに対して要素ごとの演算を簡潔に実行するためのラッパーとして機能します。

import helper.polars #list_evalメソッドを有効化

df.list_eval(
    (pl.col('x') * 10 > pl.col('y')).alias('flag')
)
shape: (2, 1)
flag
list[bool]
[false, true, false]
[false, false, … false]

構造体(struct)#

polarsの構造体列は、複数のフィールドを持つ複合データ型で、複数の異なる型のデータを1つの列にまとめて格納することができます。構造体列は、データを整理して、より複雑なデータ構造を表現するのに役立ちます。データフレームを作成する際に、Pythonの辞書のリストを構造体列に変換することができます。

df = pl.DataFrame({
    "person": [
        {"name": "John", "age": 30, "car": "ABC"},
        {"name": "Alice", "age": 65, "car": "VOX"},
        {"name": "Tom", "age": 25, "car": "ABC"},
        {"name": "Bob", "age": 45, "car": "KTL"},
        {"name": "Fun", "age": 18, "car": None},
    ]
})

df
shape: (5, 1)
person
struct[3]
{"John",30,"ABC"}
{"Alice",65,"VOX"}
{"Tom",25,"ABC"}
{"Bob",45,"KTL"}
{"Fun",18,null}

フィールドと列の変換#

構造体列を処理する関数は、structネームスペースにあります。たとえば、.struct.field()メソッドを使用して、構造体列から複数のフィールドを個別の列として取り出すことができます。

df2 = df.select(
    pl.col('person').struct.field('name', 'age')
)

df2
shape: (5, 2)
nameage
stri64
"John"30
"Alice"65
"Tom"25
"Bob"45
"Fun"18

.struct.field()の逆の操作は、pl.struct()を使用することです。pl.struct()を使うことで、複数の列を構造体列に変換することができます。次のコードでは、nameageの2つの列を構造体としてまとめ、personという名前の構造体列を作成しています。

df2.select(
    pl.struct('name', 'age').alias('person')
)
shape: (5, 1)
person
struct[2]
{"John",30}
{"Alice",65}
{"Tom",25}
{"Bob",45}
{"Fun",18}

フィールドの演算式#

.struct.with_fields()メソッドを使うと、構造体列に対してフィールドの追加や変換ができます。演算式では、pl.col()の代わりにpl.field()を使ってフィールドを指定します。これにより、構造体内の特定のフィールドに対して操作を実行できます。

次の例ではperson列内のnameフィールドの文字列を大文字に変換した新しいname_upperフィールドと、carフィールドのnull値を”Mazda”に置き換えたます。

df2 = df.select(
    pl.col("person").struct.with_fields(
        pl.field("name").str.to_uppercase().alias('name_upper'),
        pl.field("car").fill_null("Mazda")
    )
)
df2
shape: (5, 1)
person
struct[4]
{"John",30,"ABC","JOHN"}
{"Alice",65,"VOX","ALICE"}
{"Tom",25,"ABC","TOM"}
{"Bob",45,"KTL","BOB"}
{"Fun",18,"Mazda","FUN"}

DataFrame.unnest()は構造体列を複数の列に展開することができます。

df2.unnest('person')
shape: (5, 4)
nameagecarname_upper
stri64strstr
"John"30"ABC""JOHN"
"Alice"65"VOX""ALICE"
"Tom"25"ABC""TOM"
"Bob"45"KTL""BOB"
"Fun"18"Mazda""FUN"

出力は構造体の演算式#

一部の演算式の出力は構造体になることがあります。例えば、value_count() は:

df_car_count = df2.select(pl.col("person").struct.field("car").value_counts())
print(df_car_count.schema)
df_car_count
Schema([('car', Struct({'car': String, 'count': UInt32}))])
shape: (4, 1)
car
struct[2]
{"ABC",2}
{"KTL",1}
{"Mazda",1}
{"VOX",1}

複数の列を一緒に処理#

複数の列を一緒に処理するには、構造体を利用すると便利です。たとえば、次のコードでは、AB列の値が同じ行に対して、最初の行だけを残します。

df = pl.DataFrame(
    dict(
        A = [1, 2, 1, 2, 1, 2],
        B = [2, 4, 2, 5, 2, 4],
        C = [1, 2, 3, 4, 5, 6]
    )
)

df.filter(
    pl.struct('A', 'B').is_first_distinct()
)
shape: (3, 3)
ABC
i64i64i64
121
242
254

上のコードは、DataFrame.unique()と同じ結果を得られます。

df.unique(['A', 'B'], maintain_order=True, keep='first')
shape: (3, 3)
ABC
i64i64i64
121
242
254

pl.when().then().otherwise() で同じ条件に基づいて複数の演算式を計算する際は、次のように構造体を使うことでコードを短くできます。

次のコードでは、A列の値に基づいてBC列の値を操作します。

  • A列の値は1の場合は、B列の値に10を加算し、C列の値に100を加算します。

  • A列の値は1ではない場合は、B列から10を減算し、C列から100を減算します。

pl.struct()で複数の演算式を一つの構造体に纏め、最後に.struct.field()で構造体のフィールドを列に戻ります。

df.with_columns(
    pl.when(pl.col('A') == 1)
    .then(
        pl.struct(
            pl.col('B') + 10, 
            pl.col('C') + 100
        )
    )
    .otherwise(
         pl.struct(
             pl.col('B') - 10, 
             pl.col('C') - 100
         )   
    )
    .struct.field('B', 'C')
)
shape: (6, 3)
ABC
i64i64i64
112101
2-6-98
112103
2-5-96
112105
2-6-94