ファイルの入出力#

Polars はファイルの読み書きが速く、CSV・Excel・JSON・Parquet・IPC といった形式をひととおり扱えます。この章では、実際によく使う読み込みオプションと、書き出しのしかたを順に見ていきます。

import polars as pl
from helper.jupyter import row, capture_except

CSV ファイル#

CSV は形式が緩いぶん、ヘッダーの有無、型の推定、余分な空白など、読み込み前に決めておくことが多くあります。ここでは代表的なオプションを、実際のデータで確認します。

読み込み#

ヘッダー#

CSV にはヘッダーがあるものとないものがあり、ヘッダーが2行にまたがることもあります。次のデータを例に、ヘッダーの扱い方を確認します。

%%writefile data/csv_header.csv
A,B
a,b
0,1
2,3
4,5
Overwriting data/csv_header.csv
  • df1 — 既定では1行目をヘッダーとして読み込みます。

  • df2 — has_header=False にすると1行目もデータになります。列名は new_columns で指定します。

  • df3 — skip_rows で先頭の N 行を読み飛ばします。

  • df4 — skip_rows_after_header でヘッダーの次の N 行を読み飛ばします。

  • df5 — 先頭2行をヘッダーなしで読み、列ごとに連結して列名を作ります。2行のヘッダーをまとめたいときに使います。

fn = 'data/csv_header.csv'
df1 = pl.read_csv(fn)
df2 = pl.read_csv(fn, has_header=False, new_columns=['x', 'y'])
df3 = pl.read_csv(fn, skip_rows=1)
df4 = pl.read_csv(fn, skip_rows_after_header=1)

df_header = pl.read_csv(fn, n_rows=2, has_header=False)
columns = df_header.select(pl.all().str.join('-')).row(0)
df5 = pl.read_csv(fn, has_header=False, skip_rows=2, new_columns=columns)
row(df1, df2, df3, df4, df5)
shape: (4, 2)
AB
strstr
"a""b"
"0""1"
"2""3"
"4""5"
shape: (5, 2)
xy
strstr
"A""B"
"a""b"
"0""1"
"2""3"
"4""5"
shape: (3, 2)
ab
i64i64
01
23
45
shape: (3, 2)
AB
i64i64
01
23
45
shape: (3, 2)
A-aB-b
i64i64
01
23
45

infer_schema=True(既定)のとき、Polars は先頭の infer_schema_length 行だけを見て各列の型を決めます。その範囲の外に違う型の値があるとエラーになります。次のデータで確認します。

%%writefile data/csv_different_type.csv
A,B
0,1
2,3
4,5
a,5.5
10,20
Overwriting data/csv_different_type.csv

infer_schema_length の既定値は100です。次のコードでは2行に絞るため、4行目の a でエラーになります。

%%capture_except
df = pl.read_csv('data/csv_different_type.csv', infer_schema_length=2)
ComputeError: could not parse `a` as dtype `i64` at column 'A' (column number 1)

The current offset in the file is 15 bytes.

You might want to try:
- increasing `infer_schema_length` (e.g. `infer_schema_length=10000`),
- increasing `infer_schema_files` (e.g. `infer_schema_files=50`),
- specifying correct dtype with the `schema_overrides` argument
- setting `ignore_errors` to `True`,
- adding `a` to the `null_values` list.

Original error: ```invalid primitive value found during CSV parsing```

エラーメッセージには解決方法がいくつか示されます。次のコードはそれらを順に試したものです。

  • df1 — infer_schema_length を増やし、A を str、B を f64 として読む。

  • df2 — infer_schema_length=None ですべての行から型を推定し、null_values=['a'] で a を NULL 扱いにして A を i64 にする。

  • df3 — ignore_errors=True で、推定した型に合わない値を NULL にする。

  • df4 — schema_overrides で型を明示し、ignore_errors=True で外れた値を捨てる。型を決め打ちできるぶん、メモリも節約できます。

fn = 'data/csv_different_type.csv'
df1 = pl.read_csv(fn, infer_schema_length=1000)
df2 = pl.read_csv(fn, infer_schema_length=None, null_values=['a'])
df3 = pl.read_csv(fn, infer_schema_length=2, ignore_errors=True)
df4 = pl.read_csv(fn, schema_overrides={'A':pl.Int16, 'B':pl.Float32}, ignore_errors=True)
row(df1, df2, df3, df4)
shape: (5, 2)
AB
strf64
"0"1.0
"2"3.0
"4"5.0
"a"5.5
"10"20.0
shape: (5, 2)
AB
i64f64
01.0
23.0
45.0
null5.5
1020.0
shape: (5, 2)
AB
i64i64
01
23
45
nullnull
1020
shape: (5, 2)
AB
i16f32
01.0
23.0
45.0
null5.5
1020.0

列の値の末尾に空白があると、Polars の標準エンジンはそれをそのまま残し、列を str と判定します。次のデータで確認します。

%%writefile data/csv_trailing_space.csv
str,int,float
日本語 ,4 ,5.67 
abc ,5 ,1.23 
Overwriting data/csv_trailing_space.csv

このデータは、標準エンジンと use_pyarrow=True で結果が変わります。

  • df1 — 標準エンジンでは、すべての列が str になります。末尾に空白があるため数値と判定できないからです。

  • df2 — use_pyarrow=True にすると、int と float の列が数値として解釈されます。PyArrow のパーサーが空白を自動で落とします。

どちらを選ぶかはデータの状態で決めます。書式が整っていれば標準エンジン、崩れていれば PyArrow のほうが寛容です。

fn = 'data/csv_trailing_space.csv'
df1 = pl.read_csv(fn)
df2 = pl.read_csv(fn, use_pyarrow=True)
row(df1, df2)
shape: (2, 3)
strintfloat
strstrstr
"日本語 ""4 ""5.67 "
"abc ""5 ""1.23 "
shape: (2, 3)
strintfloat
stri64f64
"日本語 "45.67
"abc "51.23

標準エンジンで読んだ文字列を数値にしたいときは、自前で変換します。空白を落としてから、整数 → 小数 → 元の文字列の順に試す関数を用意します。

from polars.exceptions import InvalidOperationError


def try_cast_to_number(s: pl.Series) -> pl.Series:
    """空白を落として、Int64 → Float64 → 元の文字列 の順に変換を試す。"""
    stripped = s.str.strip_chars()
    for dtype in (pl.Int64, pl.Float64):
        try:
            return stripped.cast(dtype)
        except InvalidOperationError:
            continue
    return stripped


# 列ごとに型が変わるので、列単位で適用してから 1 つのフレームにまとめる
df_converted = pl.DataFrame({name: try_cast_to_number(df1[name]) for name in df1.columns})
row(df1, df_converted)
shape: (2, 3)
strintfloat
strstrstr
"日本語 ""4 ""5.67 "
"abc ""5 ""1.23 "
shape: (2, 3)
strintfloat
stri64f64
"日本語"45.67
"abc"51.23

複数のファイルを読み込み#

ストック価格の CSV をまとめて読み込む例です。まず data/stock にデータをダウンロードします。

from helper.utils import download_folder_from_github, print_folder_structure
download_folder_from_github("https://github.com/jeroenjanssens/python-polars-the-definitive-guide/tree/main/data/stock", "data/stock")
data/stock exists, please delete and try again.

ダウンロードしたフォルダは、銘柄ごとのサブフォルダに年ごとの CSV が入った構造です。

print_folder_structure("data/stock", max_file_count=3)
|-- asml
    |-- 1999.csv
    |-- 2000.csv
    |-- 2001.csv
    |-- ...
|-- nvda
    |-- 1999.csv
    |-- 2000.csv
    |-- 2001.csv
    |-- ...
|-- tsm
    |-- 1999.csv
    |-- 2000.csv
    |-- 2001.csv
    |-- ...

すべての CSV をまとめて読むには、パスにワイルドカードを使います。

df_stock = pl.read_csv('data/stock/**/*.csv', try_parse_dates=True)
df_stock
shape: (18_476, 8)
symboldateopenhighlowcloseadj closevolume
strdatef64f64f64f64f64i64
"ASML"1999-01-0411.76562512.2812511.76562512.1406257.5225231801867
"ASML"1999-01-0511.85937514.2511.7187513.968758.6552578241600
"ASML"1999-01-0614.2517.60156314.20312516.87510.45601816400267
"ASML"1999-01-0714.74218817.812514.5312516.85156310.44149517722133
"ASML"1999-01-0816.07812516.28906315.02343815.7968759.78799510696000
……………………
"TSM"2023-06-26102.019997103.040001100.089996100.11000199.1259548560000
"TSM"2023-06-27101.150002102.790001100.019997102.080002101.0765919732000
"TSM"2023-06-28100.5101.879997100.220001100.91999899.9279868160900
"TSM"2023-06-29101.339996101.519997100.019997100.63999999.6507427383900
"TSM"2023-06-30101.400002101.889999100.410004100.91999899.92798611701700

このデータには銘柄名の列があるため、ファイル名を列にする必要はありません。どのファイルから来た行かを知りたいときは include_file_paths で列名を指定します。

Polars 2.0 では read_csv() もこの引数を受け付けますが、ここでは遅延評価の scan_csv() を使います。LazyFrame が返るため、中身を見るときは collect() を呼びます。

df_stock = pl.scan_csv('data/stock/**/*.csv', try_parse_dates=True, include_file_paths="path")
df_stock

NAIVE QUERY PLAN

run LazyFrame.show_graph() to see the optimized version

polars_query p1 Csv SCAN [data/stock/asml/1999.csv, ... 74 other sources] π */9;
df_stock.collect()
shape: (18_476, 9)
symboldateopenhighlowcloseadj closevolumepath
strdatef64f64f64f64f64i64str
"ASML"1999-01-0411.76562512.2812511.76562512.1406257.5225231801867"data/stock/asml/1999.csv"
"ASML"1999-01-0511.85937514.2511.7187513.968758.6552578241600"data/stock/asml/1999.csv"
"ASML"1999-01-0614.2517.60156314.20312516.87510.45601816400267"data/stock/asml/1999.csv"
"ASML"1999-01-0714.74218817.812514.5312516.85156310.44149517722133"data/stock/asml/1999.csv"
"ASML"1999-01-0816.07812516.28906315.02343815.7968759.78799510696000"data/stock/asml/1999.csv"
………………………
"TSM"2023-06-26102.019997103.040001100.089996100.11000199.1259548560000"data/stock/tsm/2023.csv"
"TSM"2023-06-27101.150002102.790001100.019997102.080002101.0765919732000"data/stock/tsm/2023.csv"
"TSM"2023-06-28100.5101.879997100.220001100.91999899.9279868160900"data/stock/tsm/2023.csv"
"TSM"2023-06-29101.339996101.519997100.019997100.63999999.6507427383900"data/stock/tsm/2023.csv"
"TSM"2023-06-30101.400002101.889999100.410004100.91999899.92798611701700"data/stock/tsm/2023.csv"

CSV でないテキストでも、1行を 1列として読み込めば、Polars の文字列操作で解析できます。次の variables_dump.txt は、1行に複数の「変数 = 値」が並んだダンプです。

%%writefile data/variables_dump.txt
A = 1, B = 2, C = 3.23
A = 11, B = 22, C = 33.23
B = 30, C = 20.0
D = 10, A = 3
Overwriting data/variables_dump.txt

読み込むときの separator にヌル文字 "\x00" を指定すると、行が分割されず、各行がまるごと 1つの値になります。そのあと次の操作で表に組み直します。

  • .str.split(",") — 行を , で分割する

  • .explode("s") — 分割したリストを行に展開する

  • .str.extract_groups(...) — 正規表現で変数名(name)と値(value)を取り出す

  • .pivot(...) — 変数名を列にし、1行を 1サンプルとして並べ直す

  • .cast(pl.Float64) — 値を浮動小数点にする

最後に、小数部分がすべて 0の列を見つけて Int64 に戻します。

df = pl.read_csv(
    "data/variables_dump.txt", has_header=False, separator="\x00", new_columns=["s"]
)

df_vars = (
    df.select(pl.col("s").str.split(","))
    .with_row_index()
    .explode("s")  # 1 セル 1 変数にする
    .with_columns(
        pl.col("s").str.extract_groups(r"(?<name>\w)+\s*=\s*(?<value>.+)")
    )
    .unnest("s")  # name / value の 2 列に分ける
    .pivot(on="name", index="index")
    .with_columns(pl.exclude("index").cast(pl.Float64))
)

# 小数部分が常に 0 の列は Int64 に戻す
int_cols = (
    df_vars.select((pl.col(pl.Float64) % 1 == 0).all())
    .transpose(include_header=True)
    .filter("column_0")
    .get_column("column")
    .to_list()
)

df_vars = df_vars.with_columns(pl.col(int_cols).cast(pl.Int64))
row(df, df_vars)
shape: (4, 1)
s
str
"A = 1, B = 2, C = 3.23"
"A = 11, B = 22, C = 33.23"
"B = 30, C = 20.0"
"D = 10, A = 3"
shape: (4, 5)
indexABCD
u32i64i64f64i64
0123.23null
1112233.23null
2null3020.0null
33nullnull10

書き出し#

DataFrame.write_csv() を使うと、データフレームを CSV ファイルとして出力できます。

df1.write_csv('data/csv_output_utf8.csv')

write_csv() では include_header、separator、line_terminator などを指定できますが、文字コードを直接指定する引数はありません。Shift-JIS で書き出したいときは、いったん StringIO に書いてから、その中身を変換して保存します。

import io

buf = io.StringIO()
df1.write_csv(buf)

with open('data/csv_output_shiftjis.csv', 'w', encoding='shift-jis') as f:
    f.write(buf.getvalue())

Excel ファイル#

read_excel() と DataFrame.write_excel() で Excel ファイルの入出力を行います。読み込みには calamine、xlsx2csv、openpyxl の3つのエンジンが使え、既定の calamine がもっとも高速です。書き出しには xlsxwriter を使います。次のコマンドで必要なライブラリをインストールします。

conda install fastexcel xlsxwriter

読み込み#

read_excel() でシートを指定して読み込みます。sheet_id には1から始まるシート番号、またはそのリストを渡します。0 を渡すとすべてのシートを読み、{シート名: DataFrame} の辞書を返します。sheet_names でシート名を指定することもできます。

fn = 'data/xlsx_example.xlsx'
df1, df2 = pl.read_excel(fn, sheet_id=[1, 2]).values()
row(df1, df2)
shape: (3, 2)
AB
i64str
1"aa"
3"bb"
5"cc"
shape: (3, 3)
xyz
f64f64f64
1.01.03.1
1.22.14.3
3.24.35.4
df1, df2, df3 = pl.read_excel(fn, sheet_id=0).values()
row(df1, df2, df3)
shape: (3, 2)
AB
i64str
1"aa"
3"bb"
5"cc"
shape: (3, 3)
xyz
f64f64f64
1.01.03.1
1.22.14.3
3.24.35.4
shape: (4, 2)
AB
strstr
"a""b"
"0""1"
"2""3"
"4""5"

Sheet3 は 2行のヘッダーを持つため、そのまま読むと 2行目がデータ扱いになり、すべての列が文字列になります。read_options でエンジンに渡す設定を変えれば解決できます。

次のコードは、1回目の読み込みで先頭 2行だけをデータとして取り出して列名を組み立て、2回目でヘッダーを読み飛ばしてその列名を適用します。

df_header = pl.read_excel(fn, sheet_id=3, read_options=dict(n_rows=2, header_row=None))
columns = df_header.select(pl.all().str.join('-')).row(0)
df3 = pl.read_excel(fn, sheet_id=3, read_options=dict(skip_rows=2, column_names=columns))
df3
shape: (3, 2)
A-aB-b
i64i64
01
23
45

calamine エンジンに渡せる引数は、ExcelReader.load_sheet? で確認できます。

from fastexcel import ExcelReader

ExcelReader.load_sheet?

書き出し#

DataFrame.write_excel() を使うと、指定したファイル名とシート名にデータを書き出せます。複数のデータフレームを同じファイルの別々のシートに書き出す場合は、次のコードのように Workbook オブジェクトを作り、そのオブジェクトに対して .write_excel() を複数回呼びます。

import xlsxwriter

with xlsxwriter.Workbook("data/xlsx_example_output.xlsx") as wb:
    df1.write_excel(wb, "df1")
    df2.write_excel(wb, "df2")
    df3.write_excel(wb, "df3")    

JSON は入れ子をそのまま表せるので、Web API の応答や設定ファイルでよく使われます。read_json() に bytes をそのまま渡せます。

json_data1 = b'''
[
    {"color":"red","value":"#f00"},
    {"color":"green","value":"#0f0"},
    {"color":"blue","value":"#00f"}
]
'''

df = pl.read_json(json_data1)
df
shape: (3, 2)
colorvalue
strstr
"red""#f00"
"green""#0f0"
"blue""#00f"

1行に 1つの JSON オブジェクトを並べた形式が NDJSON です。こちらは read_ndjson() で読みます。

ndjson_data = b'''
{"color":"red","value":"#f00"}
{"color":"green","value":"#0f0"}
{"color":"blue","value":"#00f"}
'''

df = pl.read_ndjson(ndjson_data)
df
shape: (3, 2)
colorvalue
strstr
"red""#f00"
"green""#0f0"
"blue""#00f"

JSON をデータフレームにするときのルールは2つです。

  1. JSON は辞書のリストとして扱われ、各辞書が 1行、キーが列になります。最上位が辞書なら、1要素のリストとして扱います。

  2. 入れ子のリストは List 型、入れ子の辞書は Struct 型になります。

次のデータを読み込むと、id、type、name、ppu、topping の列を持つ3行のデータフレームになります。topping は「構造体のリスト」(List[Struct])です。

json_data2 = b'''
[
	{
		"id": "0001",
		"type": "donut",
		"name": "Cake",
		"ppu": 0.55,
		"topping":
			[
				{ "id": "5001", "type": "None" },
				{ "id": "5002", "type": "Glazed" },
				{ "id": "5005", "type": "Sugar" },
				{ "id": "5007", "type": "Powdered Sugar" },
				{ "id": "5006", "type": "Chocolate with Sprinkles" },
				{ "id": "5003", "type": "Chocolate" },
				{ "id": "5004", "type": "Maple" }
			]
	},
	{
		"id": "0002",
		"type": "donut",
		"name": "Raised",
		"ppu": 0.55,
		"topping":
			[
				{ "id": "5001", "type": "None" },
				{ "id": "5002", "type": "Glazed" },
				{ "id": "5005", "type": "Sugar" },
				{ "id": "5003", "type": "Chocolate" },
				{ "id": "5004", "type": "Maple" }
			]
	},
	{
		"id": "0003",
		"type": "donut",
		"name": "Old Fashioned",
		"ppu": 0.55,
		"topping":
			[
				{ "id": "5001", "type": "None" },
				{ "id": "5002", "type": "Glazed" },
				{ "id": "5003", "type": "Chocolate" },
				{ "id": "5004", "type": "Maple" }
			]
	}
]
'''
df = pl.read_json(json_data2)
df
shape: (3, 5)
idtypenamepputopping
strstrstrf64list[struct[2]]
"0001""donut""Cake"0.55[{"5001","None"}, {"5002","Glazed"}, … {"5004","Maple"}]
"0002""donut""Raised"0.55[{"5001","None"}, {"5002","Glazed"}, … {"5004","Maple"}]
"0003""donut""Old Fashioned"0.55[{"5001","None"}, {"5002","Glazed"}, … {"5004","Maple"}]

topping 列のデータ型は次のとおりです。

print(df.schema['topping'])
List(Struct({'id': String, 'type': String}))

入れ子が扱いにくいときは、explode() でリストを行に、struct.unnest() で構造体を列に展開します。

df.explode('topping').select(
    pl.exclude('topping'),
    pl.col('topping').struct.unnest().name.prefix('topping_')
)
shape: (16, 6)
idtypenamepputopping_idtopping_type
strstrstrf64strstr
"0001""donut""Cake"0.55"5001""None"
"0001""donut""Cake"0.55"5002""Glazed"
"0001""donut""Cake"0.55"5005""Sugar"
"0001""donut""Cake"0.55"5007""Powdered Sugar"
"0001""donut""Cake"0.55"5006""Chocolate with Sprinkles"
………………
"0002""donut""Raised"0.55"5004""Maple"
"0003""donut""Old Fashioned"0.55"5001""None"
"0003""donut""Old Fashioned"0.55"5002""Glazed"
"0003""donut""Old Fashioned"0.55"5003""Chocolate"
"0003""donut""Old Fashioned"0.55"5004""Maple"

元のデータフレームを3つの表に分けてから結合し直す方法もあります。本体(ドーナツ)の表、具材(重複を除いた topping)の表、両者の対応を表す表です。

これはリレーショナルモデリングの定石で、データ量が多く、具材の情報を再利用したいときは、平坦化するより容量を抑えられます。

df_topping = (
    df.select(
        pl.col('topping').explode().unique().struct.unnest().name.prefix(f'topping_')
    )
    .sort(by=f'topping_id')
)

df_donut = df.drop('topping')
df_donut_topping = (
    df.select(
        'id', 
        topping_id = pl.col('topping').list.eval(
            pl.element().struct.field('id')
        )
    )
    .explode(f'topping_id')
)

row(df_donut, df_donut_topping, df_topping)
shape: (3, 4)
idtypenameppu
strstrstrf64
"0001""donut""Cake"0.55
"0002""donut""Raised"0.55
"0003""donut""Old Fashioned"0.55
shape: (16, 2)
idtopping_id
strstr
"0001""5001"
"0001""5002"
"0001""5005"
"0001""5007"
"0001""5006"
……
"0002""5004"
"0003""5001"
"0003""5002"
"0003""5003"
"0003""5004"
shape: (7, 2)
topping_idtopping_type
strstr
"5001""None"
"5002""Glazed"
"5003""Chocolate"
"5004""Maple"
"5005""Sugar"
"5006""Chocolate with Sprinkles"
"5007""Powdered Sugar"

分けた3つの表は、join() で1つに戻せます。

df_donut.join(df_donut_topping, on='id').join(df_topping, on='topping_id')
shape: (16, 6)
idtypenamepputopping_idtopping_type
strstrstrf64strstr
"0001""donut""Cake"0.55"5001""None"
"0001""donut""Cake"0.55"5002""Glazed"
"0001""donut""Cake"0.55"5005""Sugar"
"0001""donut""Cake"0.55"5007""Powdered Sugar"
"0001""donut""Cake"0.55"5006""Chocolate with Sprinkles"
………………
"0002""donut""Raised"0.55"5004""Maple"
"0003""donut""Old Fashioned"0.55"5001""None"
"0003""donut""Old Fashioned"0.55"5002""Glazed"
"0003""donut""Old Fashioned"0.55"5003""Chocolate"
"0003""donut""Old Fashioned"0.55"5004""Maple"

もう少し複雑な例として、ニューヨーク市の地区ポリゴン(GeoJSON)を読んでみます。

df = pl.read_json("data/nyc-neighborhoods.geojson")
df
shape: (1, 3)
typecrsfeatures
strstruct[2]list[struct[3]]
"FeatureCollection"{"name",{"urn:ogc:def:crs:OGC:1.3:CRS84"}}[{"Feature",{"Allerton","2","Bronx","http://nyc.pediacities.com/Resource/Neighborhood/Allerton"},{"Polygon",[[[-73.848597, 40.87167], [-73.845823, 40.870239], … [-73.848597, 40.87167]]]}}, {"Feature",{"Alley Pond Park","4","Queens","http://nyc.pediacities.com/Resource/Neighborhood/Alley_Pond_Park"},{"Polygon",[[[-73.743333, 40.738883], [-73.743714, 40.739403], … [-73.743333, 40.738883]]]}}, … {"Feature",{"Harlem","1","Manhattan",null},{"Polygon",[[[-73.934568, 40.828149], [-73.934424, 40.827043], … [-73.934568, 40.828149]]]}}]

このファイルは最上位が 1つの辞書であるため、結果は1行のデータフレームになります。地理情報はすべて features 列に入っています。

print(df.schema['features'])
List(Struct({'type': String, 'properties': Struct({'neighborhood': String, 'boroughCode': String, 'borough': String, 'X.id': String}), 'geometry': Struct({'type': String, 'coordinates': List(List(List(Float64)))})}))

複雑にネストした構造を読みやすくするため、本書では dtype_pretty_format() 関数を用意しています。この関数を使うと、ネストしたデータ型を見やすい形式で表示できます。

from helper.polars import dtype_pretty_format
print(dtype_pretty_format(df.schema['features']))
List(
  Struct(
    type: String
    properties:
      Struct(
        neighborhood: String
        boroughCode: String
        borough: String
        X.id: String
      )
    geometry:
      Struct(
        type: String
        coordinates:
          List(
            List(
              List(Float64)
            )
          )
      )
  )
)

features は properties と geometry という 2つの構造体を要素に持つリストです。次のコードは、それぞれのフィールドを列に展開し、リストの各要素を 1行にします。

coordinates は三重のリストですが、いちばん外側に要素が 1つしかないので、その階層を落とします。

df_nb = (
    df
    .select(
        pl.col('features').list.explode().struct.unnest()
    )
    .select(
        pl.col('properties', 'geometry').struct.unnest()
    )
    .with_columns(
        pl.col('coordinates').list.get(0)
    )
)

df_nb
shape: (312, 6)
neighborhoodboroughCodeboroughX.idtypecoordinates
strstrstrstrstrlist[list[f64]]
"Allerton""2""Bronx""http://nyc.pediacities.com/Res…"Polygon"[[-73.848597, 40.87167], [-73.845823, 40.870239], … [-73.848597, 40.87167]]
"Alley Pond Park""4""Queens""http://nyc.pediacities.com/Res…"Polygon"[[-73.743333, 40.738883], [-73.743714, 40.739403], … [-73.743333, 40.738883]]
"Arden Heights""5""Staten Island""http://nyc.pediacities.com/Res…"Polygon"[[-74.169827, 40.561078], [-74.169823, 40.561057], … [-74.169827, 40.561078]]
"Arlington""5""Staten Island""http://nyc.pediacities.com/Res…"Polygon"[[-74.159748, 40.641417], [-74.159979, 40.641446], … [-74.159748, 40.641417]]
"Arrochar""5""Staten Island""http://nyc.pediacities.com/Res…"Polygon"[[-74.06078, 40.593188], [-74.060792, 40.593178], … [-74.06078, 40.593188]]
………………
"Woodside""4""Queens""http://nyc.pediacities.com/Res…"Polygon"[[-73.900866, 40.757674], [-73.90014, 40.75615], … [-73.900866, 40.757674]]
"East Williamsburg""3""Brooklyn"null"Polygon"[[-73.9413, 40.7218], [-73.9406, 40.7186], … [-73.9413, 40.7218]]
"Hamilton Heights""1""Manhattan"null"Polygon"[[-73.9505, 40.8349], [-73.9411, 40.8312], … [-73.9505, 40.8349]]
"Williamsburg""3""Brooklyn"null"Polygon"[[-73.957572, 40.725097], [-73.952998, 40.722253], … [-73.957572, 40.725097]]
"Harlem""1""Manhattan"null"Polygon"[[-73.934568, 40.828149], [-73.934424, 40.827043], … [-73.934568, 40.828149]]

holoviews の Polygons で描くには、経度と緯度のリストを別々の列に分けます。list.eval() はリストの各要素に対する式で、pl.element() がその要素、list.get() が入れ子のリストからの取り出しです。

import holoviews as hv
hv.extension('bokeh', inline=False)
ⓘ
df_nb2 = df_nb.select(
    pl.col('neighborhood'),
    pl.col('borough'),
    pl.col('coordinates').list.eval(pl.element().list.get(0)).alias('lng'),
    pl.col('coordinates').list.eval(pl.element().list.get(1)).alias('lat'),
)

hv.Polygons(df_nb2.to_dicts(), kdims=['lng', 'lat'], vdims=['borough']).opts(cmap='tab10', data_aspect=1)

バイナリ形式として、Polars は IPC(Arrow IPC)と Parquet をサポートしています。

  1. IPC — プロセス間で受け渡すためのバイナリ形式です。シリアライズとデシリアライズが速く、大きなデータをそのまま渡せます。

  2. Parquet — 列指向の保存形式です。圧縮効率がよく、読み込み時に必要な列だけを取り出せます。Spark などの分散処理でも標準的に使われます。

DataFrame は read_*() / write_*()、LazyFrame は scan_*() / sink_*() を使います。

  • read_ipc() / read_parquet() — ファイルから DataFrame を読む

  • write_ipc() / write_parquet() — DataFrame をファイルに書く

  • scan_ipc() / scan_parquet() — ファイルを LazyFrame として読む

  • sink_ipc() / sink_parquet() — LazyFrame をチャンク単位で書き出す

sink_*() はデータを一度にメモリへ載せず、チャンクで処理するので、メモリに収まらない規模でも扱えます。次のコードは、複数の CSV を 1つの IPC ファイルに変換します。

df_stock = pl.scan_csv('data/stock/**/*.csv', try_parse_dates=True)
df_stock.sink_ipc('data/stock.arrow')
df_stock = pl.read_ipc('data/stock.arrow')
df_stock
shape: (18_476, 8)
symboldateopenhighlowcloseadj closevolume
strdatef64f64f64f64f64i64
"ASML"1999-01-0411.76562512.2812511.76562512.1406257.5225231801867
"ASML"1999-01-0511.85937514.2511.7187513.968758.6552578241600
"ASML"1999-01-0614.2517.60156314.20312516.87510.45601816400267
"ASML"1999-01-0714.74218817.812514.5312516.85156310.44149517722133
"ASML"1999-01-0816.07812516.28906315.02343815.7968759.78799510696000
……………………
"TSM"2023-06-26102.019997103.040001100.089996100.11000199.1259548560000
"TSM"2023-06-27101.150002102.790001100.019997102.080002101.0765919732000
"TSM"2023-06-28100.5101.879997100.220001100.91999899.9279868160900
"TSM"2023-06-29101.339996101.519997100.019997100.63999999.6507427383900
"TSM"2023-06-30101.400002101.889999100.410004100.91999899.92798611701700

上の関数は 1ファイル 1フレームですが、write_ipc_stream() / read_ipc_stream() を使うと、複数のフレームを順番に 1つのファイルへ書けます。次のコードは 2つのフレームを書き出す例です。

df1 = pl.DataFrame({"a":[1, 2, 3], "b":["xx", "yyy", "zzzz"]})
df2 = pl.DataFrame({"x":[3.0, 4.0, 6.0, 7.0], "y":[1.0, 2.0, 3.0, 4.0], "z":[0.0, 0.1, 0.2, 0.3]})

with open('data/stream_test.arrow', 'wb') as f:
    df1.write_ipc_stream(f)
    df2.write_ipc_stream(f)

読み込むときは、書き出した順に取り出します。

注釈

Polars 2.0 でも、続けて読み込むには use_pyarrow=True が必要です。既定のままでは2つ目以降が正しく読めません。

with open("data/stream_test.arrow", "rb") as f:
    df1_r = pl.read_ipc_stream(f, use_pyarrow=True)
    df2_r = pl.read_ipc_stream(f, use_pyarrow=True)

print(df1.equals(df1_r), df2.equals(df2_r))
True True