第13回 DuckDBとPolars¶
科目:ビッグデータ応用Ⅱ〔特徴量エンジニアリング〕 担当:小野原彩香 対象:3年生(後期)
学習目標¶
この回を終えると、以下のことができるようになります。
- DuckDBがどのような場面で適切かを説明できる
- Polarsの遅延評価とArrow互換性のメリットを理解する
- DuckDBの地理空間拡張(spatial)でST_Contains/ST_Distanceなどを使える
- DuckDB→Polarsのシームレスな連携パターンを実装できる
- PandasよりもPolars/DuckDBが速い理由を説明できる
1. DuckDBの特徴と使い所¶
1.1 DuckDBとは¶
DuckDBはインプロセスOLAP(Online Analytical Processing)データベースです。
- インプロセス:サーバーを別途起動せず、Pythonプロセスの中で動作する
- OLAP:大量データの集計・分析に特化(OLTPのトランザクション処理とは対照的)
- 列指向ストレージ:集計クエリで必要な列だけ読み込むため高速
イメージとしては「SQLが使えるPandas」ですが、大規模データでは圧倒的に高速です。
1.2 DuckDBが得意なこと¶
| 用途 | 具体例 |
|---|---|
| 大規模CSV/Parquetの直接読み込み | ファイルをデータベースにインポートせず、そのままSQLで集計 |
| 複雑なSQLでの集計 | GROUP BY, WINDOW関数, サブクエリを組み合わせた分析 |
| 地理空間クエリ | ST_Distance, ST_Contains等の空間フィルタリング |
| 複数ファイルの結合 | 複数のParquetファイルをJOINで統合 |
| メモリ効率 | RAMに収まらないデータも、一時ファイルを使って処理できる |
1.3 インストールと基本操作¶
import duckdb
# インメモリ接続(セッション内のみ有効)
conn = duckdb.connect()
# ファイルベース接続(永続化)
conn_persistent = duckdb.connect('data/analysis.duckdb')
# 基本クエリ
result = conn.execute("""
SELECT
floor(longitude / 0.01) AS grid_x,
floor(latitude / 0.01) AS grid_y,
COUNT(*) AS query_count,
AVG(is_error::FLOAT) AS error_rate
FROM read_parquet('data/llm_geo_logs.parquet')
GROUP BY grid_x, grid_y
ORDER BY query_count DESC
LIMIT 10
""").fetchdf() # pandas DataFrameとして取得
print(result)
2. Polarsの特徴と使い所¶
2.1 Polarsとは¶
PolarsはRustで書かれた高速なDataFrameライブラリです。pandasと似たAPIを持ちますが、設計思想が異なります。
2.2 Pandasとの比較¶
| 比較軸 | Pandas | Polars |
|---|---|---|
| 内部実装 | Python/NumPy(行指向) | Rust(列指向、Arrow) |
| 遅延評価 | なし(即時実行) | あり(lazy()で遅延実行) |
| 並列処理 | GILにより限定的 | マルチコア並列をデフォルトで活用 |
| NULL処理 | NaN(浮動小数点)で代替 | 本物のnull値(Arrow仕様) |
| 適したデータ規模 | 〜数GB | 数GB〜数百GB |
| メモリ効率 | 中程度 | 高い(ゼロコピー変換) |
2.3 遅延評価(Lazy Evaluation)¶
import polars as pl
# 即時実行(Eager)
df_eager = pl.read_parquet('data/llm_geo_logs.parquet')
result_eager = df_eager.filter(pl.col('is_error') == True).group_by('grid_id').agg(pl.len())
# 遅延評価(Lazy)- クエリプランを最適化してから実行
result_lazy = (
pl.scan_parquet('data/llm_geo_logs.parquet') # scan_: 遅延読み込み
.filter(pl.col('is_error') == True)
.group_by('grid_id')
.agg(pl.len().alias('error_count'))
.sort('error_count', descending=True)
.collect() # ここで初めて実行される
)
# クエリプランの確認
lazy_frame = (
pl.scan_parquet('data/llm_geo_logs.parquet')
.filter(pl.col('is_error') == True)
.group_by('grid_id')
.agg(pl.len())
)
print(lazy_frame.explain(optimized=True)) # 最適化後のクエリプランを表示
遅延評価のメリット: - Polarsがクエリ全体を最適化してから実行する(不要な列を読み込まない、フィルタを早期適用する) - 大規模データでメモリ効率が大幅に向上する
2.4 Apache Arrowとの互換性¶
PolarsはApache Arrowフォーマットを内部で使用します。
# Polars → PyArrow(Arrow形式)
import pyarrow as pa
df_pl = pl.read_parquet('data/llm_geo_logs.parquet')
arrow_table = df_pl.to_arrow()
# Arrow → DuckDB(ゼロコピーで高速)
import duckdb
conn = duckdb.connect()
conn.register('logs_arrow', arrow_table)
result = conn.execute("SELECT COUNT(*) FROM logs_arrow").fetchone()
print(f"レコード数: {result[0]:,}")
3. 地理空間拡張(DuckDB spatial)¶
3.1 spatialのインストールと有効化¶
import duckdb
conn = duckdb.connect()
# 地理空間拡張のインストール(初回のみ)
conn.execute("INSTALL spatial;")
conn.execute("LOAD spatial;")
print("spatial拡張が有効になりました")
3.2 主要な空間SQL関数¶
# === ST_Point:点ジオメトリの作成 ===
conn.execute("""
SELECT ST_Point(141.35, 43.06) AS point_geom
""").show()
# === ST_AsText:ジオメトリをWKT文字列に変換 ===
result = conn.execute("""
SELECT
longitude,
latitude,
ST_AsText(ST_Point(longitude, latitude)) AS wkt
FROM read_parquet('data/llm_geo_logs.parquet')
LIMIT 5
""").pl()
print(result)
# === ST_Distance:2点間の距離(度単位)===
# 距離をメートルに変換するには、度×約111000を使う(緯度方向の近似)
conn.execute("""
SELECT
grid_id,
ST_Distance(
ST_Point(longitude, latitude),
ST_Point(141.3544, 43.0618) -- 札幌駅
) * 111000.0 AS dist_from_sapporo_m
FROM read_parquet('data/llm_geo_logs.parquet')
LIMIT 10
""").show()
# === ST_Contains:ポリゴン内に点が含まれるか判定 ===
# 例:札幌市中央区のバウンディングボックス内のクエリを抽出
result_central = conn.execute("""
SELECT COUNT(*) AS count_in_central
FROM read_parquet('data/llm_geo_logs.parquet')
WHERE ST_Contains(
ST_GeomFromText('POLYGON((
141.31 43.03,
141.42 43.03,
141.42 43.10,
141.31 43.10,
141.31 43.03
))'),
ST_Point(longitude, latitude)
)
""").fetchone()
print(f"中央区内のクエリ数: {result_central[0]:,}")
# === ST_GeomFromGeoJSON:GeoJSONからジオメトリ作成 ===
# 例:区境界ポリゴンを読み込んでフィルタリング
conn.execute("""
SELECT
a.ward_name,
COUNT(*) AS query_count,
AVG(b.is_error::FLOAT) AS error_rate
FROM read_parquet('data/ward_boundaries.parquet') a
JOIN read_parquet('data/llm_geo_logs.parquet') b
ON ST_Contains(
ST_GeomFromWKB(a.geometry_wkb),
ST_Point(b.longitude, b.latitude)
)
GROUP BY a.ward_name
ORDER BY query_count DESC
""").show()
3.3 最近傍探索¶
# 各クエリポイントから最寄り駅までの距離を計算
# (クロスジョイン + 最小値でシミュレート)
result_nearest = conn.execute("""
WITH logs AS (
SELECT
grid_id,
AVG(longitude) AS lon,
AVG(latitude) AS lat
FROM read_parquet('data/llm_geo_logs.parquet')
GROUP BY grid_id
),
stations AS (
SELECT station_name, longitude AS s_lon, latitude AS s_lat
FROM read_parquet('data/train_stations.parquet')
),
distances AS (
SELECT
l.grid_id,
s.station_name,
ST_Distance(
ST_Point(l.lon, l.lat),
ST_Point(s.s_lon, s.s_lat)
) * 111000.0 AS dist_m,
ROW_NUMBER() OVER (
PARTITION BY l.grid_id
ORDER BY ST_Distance(
ST_Point(l.lon, l.lat),
ST_Point(s.s_lon, s.s_lat)
)
) AS rank
FROM logs l CROSS JOIN stations s
)
SELECT grid_id, station_name, dist_m
FROM distances
WHERE rank = 1
ORDER BY dist_m DESC
LIMIT 20
""").pl()
print("最寄り駅が遠いグリッドセルTOP20:")
print(result_nearest)
4. DuckDB + Polars の連携パターン¶
4.1 DuckDB → Polars(fetchdf() vs .pl())¶
import duckdb
import polars as pl
conn = duckdb.connect()
conn.execute("LOAD spatial;")
# パターン1:.pl()でPolarsとして取得(推奨)
df_polars = conn.execute("""
SELECT
date_trunc('day', timestamp) AS day_bin,
grid_id,
COUNT(*) AS query_count
FROM read_parquet('data/llm_geo_logs.parquet')
GROUP BY ALL
ORDER BY day_bin, grid_id
""").pl()
print(type(df_polars)) # polars.DataFrame
print(df_polars.head())
# パターン2:.fetchdf()でPandasとして取得
df_pandas = conn.execute("SELECT ...").fetchdf()
# パターン3:Arrowテーブルとして取得してPolarsに変換
df_arrow = conn.execute("SELECT ...").arrow()
df_from_arrow = pl.from_arrow(df_arrow) # ゼロコピー変換
4.2 Polars → DuckDB(PolarsのDataFrameをSQLで操作)¶
# Polarsで前処理したDataFrameをDuckDBに渡す
df_preprocessed = (
pl.scan_parquet('data/llm_geo_logs.parquet')
.with_columns([
pl.col('timestamp').dt.truncate('1h').alias('hour_bin'),
(pl.col('longitude') / 0.01).floor().cast(pl.Int32).alias('grid_x'),
(pl.col('latitude') / 0.01).floor().cast(pl.Int32).alias('grid_y'),
])
.collect()
)
# DuckDBにPolars DataFrameを登録
conn.register('preprocessed', df_preprocessed)
# SQLで集計
result = conn.execute("""
SELECT
grid_x, grid_y,
AVG(is_error::FLOAT) AS error_rate,
STDDEV(response_time_ms) AS response_time_std
FROM preprocessed
GROUP BY grid_x, grid_y
HAVING COUNT(*) >= 10 -- サンプル数が少ないグリッドを除外
""").pl()
print(result.head())
4.3 推奨される連携パターン¶
5. LLMログとの統合処理¶
5.1 完全なデータパイプライン¶
import duckdb
import polars as pl
import pandas as pd
from sklearn.preprocessing import StandardScaler
import time
conn = duckdb.connect()
conn.execute("LOAD spatial;")
# ==============================
# ステップ1: DuckDBで大規模集計
# ==============================
t0 = time.perf_counter()
raw_features = conn.execute("""
WITH log_data AS (
SELECT
floor(longitude / 0.01)::INT AS grid_x,
floor(latitude / 0.01)::INT AS grid_y,
timestamp,
response_time_ms,
is_error,
EXTRACT(hour FROM timestamp) AS hour_of_day,
EXTRACT(dow FROM timestamp) AS day_of_week -- 0=日曜
FROM read_parquet('data/llm_geo_logs.parquet')
WHERE timestamp >= '2024-01-01'
AND timestamp < '2024-04-01'
)
SELECT
grid_x,
grid_y,
COUNT(*) AS total_queries,
SUM(is_error::INT) AS total_errors,
AVG(is_error::FLOAT) AS error_rate,
AVG(response_time_ms) AS mean_response_ms,
PERCENTILE_CONT(0.95) WITHIN GROUP
(ORDER BY response_time_ms) AS p95_response_ms,
STDDEV(response_time_ms) AS std_response_ms,
-- 時間帯別の割合
AVG(CASE WHEN hour_of_day BETWEEN 9 AND 18
THEN 1 ELSE 0 END::FLOAT) AS daytime_ratio,
AVG(CASE WHEN day_of_week IN (0, 6)
THEN 1 ELSE 0 END::FLOAT) AS weekend_ratio
FROM log_data
GROUP BY grid_x, grid_y
HAVING COUNT(*) >= 20 -- データが少なすぎるグリッドを除外
ORDER BY grid_x, grid_y
""").pl()
t1 = time.perf_counter()
print(f"DuckDB集計: {t1-t0:.2f}秒")
print(f"集計結果: {len(raw_features):,}グリッドセル")
# ==============================
# ステップ2: Polarsで特徴量エンジニアリング
# ==============================
features = (
raw_features
.with_columns([
# 対数変換(スキュー補正)
pl.col('total_queries').log(base=10).alias('log_query_count'),
pl.col('mean_response_ms').log(base=10).alias('log_mean_response_ms'),
# エラー数を人口密度で補正
(pl.col('total_errors') / pl.col('total_queries')).alias('error_per_query'),
# グリッドID(文字列)
(pl.col('grid_x').cast(pl.Utf8) + '_' +
pl.col('grid_y').cast(pl.Utf8)).alias('grid_id'),
])
.drop_nulls()
)
t2 = time.perf_counter()
print(f"Polars特徴量エンジニアリング: {t2-t1:.2f}秒")
# ==============================
# ステップ3: Parquetに永続化
# ==============================
features.write_parquet('data/spatial_features_integrated.parquet',
compression='zstd')
t3 = time.perf_counter()
print(f"Parquet保存: {t3-t2:.2f}秒")
print(f"合計処理時間: {t3-t0:.2f}秒")
6. ハンズオン手順¶
6.1 環境のセットアップ¶
# requirements.txtからインストール
pip install -r requirements.txt
# 確認
python -c "
import duckdb, polars, pyarrow
print(f'DuckDB: {duckdb.__version__}')
print(f'Polars: {polars.__version__}')
print(f'PyArrow: {pyarrow.__version__}')
"
# DuckDB spatialの動作確認
python -c "
import duckdb
conn = duckdb.connect()
conn.execute('INSTALL spatial; LOAD spatial;')
print(conn.execute(\"SELECT ST_AsText(ST_Point(141.35, 43.06))\").fetchone()[0])
"
6.2 Makefile への組み込み¶
プロジェクトの再現性を高めるために、Makefileでパイプラインを定義します。
# Makefile
.PHONY: all clean data features
# デフォルトターゲット
all: data features
# データの前処理(DuckDB)
data/cube_daily.parquet: data/llm_geo_logs.parquet
python src/01_build_cube.py
# 特徴量エンジニアリング(DuckDB + Polars)
data/spatial_features_integrated.parquet: data/cube_daily.parquet
python src/02_feature_engineering.py
features: data/spatial_features_integrated.parquet
# クリーンアップ
clean:
rm -f data/cube_daily.parquet
rm -f data/spatial_features_integrated.parquet
rm -f data/analysis.duckdb
rm -f output/*.png
実行:
6.3 ベンチマーク比較¶
import pandas as pd
import polars as pl
import duckdb
import time
DATA_PATH = 'data/llm_geo_logs.parquet'
# --- Pandas ---
t0 = time.perf_counter()
df_pd = pd.read_parquet(DATA_PATH)
result_pd = (
df_pd
.assign(hour_bin=df_pd['timestamp'].dt.floor('h'))
.groupby(['hour_bin', 'grid_id'])
.agg(
query_count=('is_error', 'count'),
error_rate=('is_error', 'mean'),
mean_response_ms=('response_time_ms', 'mean')
)
.reset_index()
)
t_pd = time.perf_counter() - t0
# --- Polars ---
t0 = time.perf_counter()
result_pl = (
pl.scan_parquet(DATA_PATH)
.with_columns(pl.col('timestamp').dt.truncate('1h').alias('hour_bin'))
.group_by(['hour_bin', 'grid_id'])
.agg([
pl.len().alias('query_count'),
pl.col('is_error').mean().alias('error_rate'),
pl.col('response_time_ms').mean().alias('mean_response_ms'),
])
.collect()
)
t_pl = time.perf_counter() - t0
# --- DuckDB ---
conn = duckdb.connect()
t0 = time.perf_counter()
result_dk = conn.execute(f"""
SELECT
date_trunc('hour', timestamp) AS hour_bin,
grid_id,
COUNT(*) AS query_count,
AVG(is_error::FLOAT) AS error_rate,
AVG(response_time_ms) AS mean_response_ms
FROM read_parquet('{DATA_PATH}')
GROUP BY hour_bin, grid_id
""").pl()
t_dk = time.perf_counter() - t0
print("=== ベンチマーク結果(時間帯×グリッド集計)===")
print(f"Pandas: {t_pd:.2f}秒")
print(f"Polars: {t_pl:.2f}秒 ({t_pd/t_pl:.1f}x faster)")
print(f"DuckDB: {t_dk:.2f}秒 ({t_pd/t_dk:.1f}x faster)")
print(f"\n結果行数 - Pandas:{len(result_pd):,}, Polars:{len(result_pl):,}, DuckDB:{len(result_dk):,}")
7. 演習課題¶
課題1(基本)¶
- DuckDBで
data/llm_geo_logs.parquetから「月別」×「グリッドセル別」のクエリ数とエラー率を集計する(SQLを書くこと) - 結果を
.pl()でPolarsとして取得し、エラー率が最高の月を特定する
課題2(応用)¶
- DuckDBの
ST_Distanceを使って、各グリッドセルから「札幌駅(141.3544, 43.0618)」までの距離を計算する - 距離帯(0-1km, 1-5km, 5-20km, 20km以上)別のエラー率をPolarsで集計し、棒グラフを描く
課題3(発展)¶
- Pandas, Polars, DuckDBのベンチマークを自分のデータで実行し、結果を比較する
Makefileを作成して、以下の処理をmake all一発で再現できるようにする:data/spatial_features_integrated.parquetの生成output/cluster_map.pngの生成(第11回のk-meansクラスタリング結果)README.mdに再現手順を記述する(第14回の最終プロジェクトに向けた準備)
提出形式:Jupyterノートブック+Makefile、ファイル名:13_duckdb_polars_[学籍番号].ipynb
まとめ¶
| 項目 | ポイント |
|---|---|
| DuckDB | インプロセスOLAP、Parquetを直接SQLで処理、空間拡張でGIS機能も使える |
| Polars | Rust製・列指向・遅延評価・マルチコア並列、大規模データで高速 |
| 連携の基本 | DuckDB→.pl()でPolars DataFrame取得、Polars→conn.register()でSQL利用 |
| 空間SQL | ST_Contains, ST_Distance, ST_AsText, ST_GeomFromTextを組み合わせる |
| Parquet永続化 | write_parquet(compression='zstd')で中間結果を保存し、Makefileで管理 |
| 速度感 | PandasのGROUP BY集計に対してPolarsは3〜10倍、DuckDBはさらに速いケースも |
| 次回への接続 | 第14回では本日構築したパイプラインを再現リポジトリとしてまとめ、発表する |
次回予告: 第14回(最終回)は、全講義の成果物を統合した再現リポジトリを発表します。リポジトリ要件を事前に確認しておいてください。
作成:小野原彩香 / ビッグデータ応用Ⅱ〔特徴量エンジニアリング〕