0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DuckDB のマクロが便利だったので、テーブルデータ加工について Pandas とユースケース別に比較してみた

0
Posted at

はじめに

以前、DuckDB に関する記事を書きました。

テーブルデータの加工に SQL を使うこと自体は以前からやっていたのですが、最近新たに、DuckDB(および一部の SQL 方言)でマクロが使えることを知りました。
マクロを使うと、複雑な加工ロジックを名前付きの関数として定義でき、SQL の可読性と再利用性が一段上がります。

使い込んでいくうちに、「この加工は Pandas で書くより SQL の方が圧倒的に読みやすいな」と感じる場面が増えてきました。
特に、文書データの前処理のように加工ステップが多段階にわたる場合、Pandas ではメソッドチェーンが長くなり、後に自分で見返しても何をしているかわからない、ということがよくあります。
マクロを使用することである程度複雑な処理にも対応できるようになりました。

一方で、コンペや機械学習の文脈では Pandas が主流です。これは、scikit-learn をはじめとする ML エコシステムとの親和性という、構造的な理由があるからだと思いました。

そこで本記事では、テーブルデータの加工における Pandas と SQL(DuckDB)の使い分けを、具体的なユースケース別に整理してみようと思います。

SQL と Pandas、それぞれの得意領域

具体的なコード例に入る前に、両者がどんな場面で力を発揮するのかを整理しておきます。
正直、正解はないので、私の所感による主張です。

SQL(DuckDB 含む)が優れる場面

集計・結合・フィルタリングといった「関係代数的な操作」が中心のタスクでは、SQL の方が圧倒的に読みやすく書きやすいです。
特に CTE(WITH 句)を使うと、加工ステップが名前付きの中間テーブルとして宣言的に並ぶため、「何をしているか」が上から順に読めます。

Pandas で同じことをすると、df.merge(...).groupby(...).agg(...).reset_index().rename(...) のようなメソッドチェーンになり、各ステップの意図が埋もれがちです。

加工ロジックを再利用・共有する場面でも SQL が有利です。マクロや VIEW を使えば、チーム内で「この加工はこう定義する」という共通言語になります。SQL は DB エンジニア・アナリスト・ML エンジニアの共通言語(多分)でもあるため、引き継ぎコストが低いのも利点です。

データサイズが中〜大(メモリに収まるか微妙なライン)の場合も、DuckDB のようなカラムナエンジンは Pandas より速いことが多いです。

Pandas が優れる場面

行ごとの複雑な処理、特に Python のライブラリや自作関数を適用したい場面では Pandas の方が自然です。

例えば文書データなら、spaCy でのトークナイズ、Transformers での埋め込み生成といった処理は、apply や map で柔軟に書けます。
SQL でもユーザー定義関数(UDF)は書けますが、Python エコシステムとの接続は Pandas の方がスムーズです。

探索的データ分析(EDA)のフェーズでも Pandas が便利です。df.describe()df.info()df.value_counts()、可視化ライブラリとの連携など、「データをまず眺める」ための道具が揃っています。

機械学習のパイプラインに組み込む場合も、scikit-learn・PyTorch・TensorFlow などが DataFrame または ndarray を前提としているので、最終的には Pandas(あるいは NumPy)に変換することが多いです。

DuckDB のマクロ

マクロは、SQL の中で再利用可能な処理を 名前付きの関数として定義する仕組み です。Python でいう関数定義に近く、一度定義しておけば、以降のクエリから何度でも呼び出せます。
CREATE MACRO で定義し、通常の関数と同じように SELECT 句や WHERE 句から呼び出せます。

基本的な使い方

最もシンプルな例として、文字列の前後の空白を削除して小文字化するマクロを定義してみます。

sqlCREATE OR REPLACE MACRO normalize_text(txt) AS
    LOWER(TRIM(txt));

定義したマクロは、通常の関数と同じように呼び出せます。

sqlSELECT normalize_text(title) AS title_normalized
FROM df;

CREATE OR REPLACE を付けておくと、すでに同名のマクロが存在しても上書きできるので、開発中は便利です。

複数の処理をまとめる

マクロの真価が発揮されるのは、複数の処理をひとまとめにしたい場面です。例えば、HTML タグの除去・空白の正規化・小文字化をまとめたマクロは、こう書けます。

sqlCREATE OR REPLACE MACRO clean_text(txt) AS
    LOWER(
        regexp_replace(
            regexp_replace(txt, '<[^>]+>', '', 'g'),
            '\s+', ' ', 'g'
        )
    );

このマクロを使えば、クエリ側はシンプルに書けます。

sqlSELECT clean_text(body) AS body_cleaned
FROM df;

regexp_replace のネストが消え、「何をしているか」が一目でわかる形になりました。マクロの定義部分だけを見れば処理の中身が確認でき、クエリ本体は意図がストレートに読めます。これが、SQL の可読性を一段引き上げてくれます。

引数を複数取るマクロ

マクロは複数の引数を取ることもできます。例えば、特定のカテゴリに属する記事だけを抽出する条件をマクロ化するなら、こう書けます。

sqlCREATE OR REPLACE MACRO is_target_category(cat, target) AS
    cat = target;

SELECT *
FROM df
WHERE is_target_category(category, 'sports-watch');

この程度なら直接書いた方が早いと感じるかもしれませんが、条件が複雑になるほどマクロ化のメリットが効いてきます。

テーブルマクロ

スカラーマクロが「値を返す関数」だとすれば、テーブルマクロは「テーブルを返す関数」です。CREATE MACRO ... AS TABLE で定義します。

sqlCREATE OR REPLACE MACRO articles_by_category(target_cat) AS TABLE
    SELECT category, title, clean_text(body) AS body_cleaned
    FROM df
    WHERE category = target_cat;

呼び出すときは、テーブルのように FROM 句で使います。

sqlSELECT * FROM articles_by_category('sports-watch');

これは VIEW に似ていますが、引数を取れる点が異なります。「カテゴリを指定すると、そのカテゴリの記事をクリーニングして返す」という処理を、再利用可能な部品として定義できるわけです。

マクロの利点

マクロを使うことで、SQL に以下の利点がもたらされます。
可読性の向上: 複雑な式をネストせずに、名前付きの処理として並べられる。
再利用性: 同じ処理を複数のクエリで使う場合、マクロにまとめておけば一箇所で管理できる。
チームでの共通化: DB に定義が残るため、チームメンバーが同じ前処理を共有できる。Python 関数のように「どのファイルに定義されているか」を探す必要がない。

ユースケース別比較

ここからは、具体的なユースケースで Pandas と SQL(DuckDB)を比較していきます。データは livedoor ニュースコーパスのような、category(カテゴリ名)、title(タイトル)、body(本文)といったカラムを持つ DataFrame df があるものとします。

ケース1:集計・グルーピング

やりたいこと:カテゴリごとの記事数と、本文の平均文字数を集計する。

Pandas で書く場合

result = (
    df.assign(body_length=df["body"].str.len())
      .groupby("category")
      .agg(
          article_count=("title", "count"),
          avg_body_length=("body_length", "mean"),
      )
      .reset_index()
      .sort_values("article_count", ascending=False)
)

SQL(DuckDB)で書く場合

SELECT
    category,
    COUNT(*) AS article_count,
    AVG(LENGTH(body)) AS avg_body_length
FROM df
GROUP BY category
ORDER BY article_count DESC;

比較

集計処理は SQL が本来得意とする領域で、宣言的に「何を集計したいか」がそのまま書けます。Pandas の場合、assign で中間カラムを作り、groupbyaggreset_indexsort_values とメソッドチェーンが続き、コードを目で追う必要があります。一方 SQL は、SELECT の中に集計式を直接書けるため、新しい中間状態を作らずに済むのが利点です。

ただし Pandas にも、結果がそのまま DataFrame として後続の処理(可視化や ML パイプライン)に渡せるという利点があります。集計結果を即座にグラフ化したい場合などは Pandas の方がスムーズです。

ケース2:複雑な文字列処理(正規表現含む)

やりたいこと:本文から HTML タグを除去し、連続する空白を1つにまとめ、小文字化する。

Pandas で書く場合

import re

def clean_text(text: str) -> str:
    text = re.sub(r"<[^>]+>", "", text)   # HTMLタグを除去
    text = re.sub(r"\s+", " ", text)      # 連続空白を1つに
    return text.lower()                   # 小文字化

df["body_cleaned"] = df["body"].apply(clean_text)

SQL(DuckDB)で書く場合(マクロを使用)

先ほど紹介した clean_text マクロをそのまま使えます。

CREATE OR REPLACE MACRO clean_text(txt) AS
    LOWER(
        regexp_replace(
            regexp_replace(txt, '<[^>]+>', '', 'g'),
            '\s+', ' ', 'g'
        )
    );

SELECT *, clean_text(body) AS body_cleaned
FROM df;

比較

マクロを使わずに regexp_replace をネストさせて書くと、SQL は一気に読みにくくなります。これは Pandas で apply を使わず、ラムダ式を直接書いたり一行に詰め込んだりするのと同じ問題です。

しかし、マクロで処理を切り出してしまえば、SQL でも Pandas でもコードの構造はほぼ同じになります。「処理を関数として定義し、それをデータに適用する」という形に揃えることが可能です。

ここで Pandas の優位性として残るのは、Python のライブラリを直接使える点です。例えば spaCy でのトークナイズや Transformers での埋め込み生成などは、Pandas の apply で自然に書けますが、SQL では UDF を別途定義する必要があり、エコシステムとの接続では Pandas に軍配が上がります。

逆に、正規表現や文字列関数の組み合わせで完結する処理なら、マクロを使った SQL の方が再利用性で勝ると思います。同じ前処理を別のテーブルやクエリで使い回したい場合、Python 関数だとファイルを跨いだ import が必要ですが、マクロは DB 側に定義が残るため、どのクエリからも呼び出せます。

ケース3:欠損処理

やりたいこと:本文(body)が欠損している行を除外し、タイトル(title)が欠損している場合は「(タイトルなし)」で埋める。

Pandas で書く場合

df_cleaned = (
    df.dropna(subset=["body"])
      .assign(title=df["title"].fillna("(タイトルなし)"))
)

SQL(DuckDB)で書く場合

SELECT
    COALESCE(title, '(タイトルなし)') AS title,
    category,
    body
FROM df
WHERE body IS NOT NULL;

比較

シンプルな欠損処理なら、SQL でも COALESCEIS NOT NULL で過不足なく書けます。ただし、より複雑な欠損処理になると Pandas の API の洗練度が際立ちます。

例えば、グループごとの中央値で欠損を埋める処理を考えてみます。

Pandas の場合

df["body_length"] = df["body_length"].fillna(
    df.groupby("category")["body_length"].transform("median")
)

SQL の場合

SELECT
    *,
    COALESCE(
        body_length,
        MEDIAN(body_length) OVER (PARTITION BY category)
    ) AS body_length_filled
FROM df;

どちらも書けますが、Pandas の fillna は「欠損処理」という概念に特化したメソッドが揃っており、ffill(前の値で埋める)、bfill(後の値で埋める)、interpolate(補間)など、専用 API が直感的に使えます。SQL でも LAGLEAD を使えば同等のことができますが、書き味としては Pandas の方が自然かと思います。

また、機械学習の前処理という観点では、scikit-learn の SimpleImputerKNNImputer のような欠損処理ツールがあり、学習データで fit して検証データに transform を適用する、という ML のお作法に沿った設計になっています。SQL で同じことをするには、学習データの統計量を別途保存して再利用する手作業が発生するため、ここは Pandas(および scikit-learn)が明確に優位です。

ケース4:多段階の前処理パイプライン

やりたいこと:以下の処理を順に適用する。

  1. 本文が空または NULL の行を除外
  2. 本文をクリーニング(HTML 除去、空白正規化、小文字化)
  3. クリーニング後の本文の文字数を計算
  4. カテゴリごとに、文字数が中央値以上の記事だけを残す

Pandas で書く場合

import re

def clean_text(text: str) -> str:
    text = re.sub(r"<[^>]+>", "", text)
    text = re.sub(r"\s+", " ", text)
    return text.lower()

# ステップ1: 空・NULL除外
df_step1 = df.dropna(subset=["body"])
df_step1 = df_step1[df_step1["body"].str.strip() != ""]

# ステップ2: クリーニング
df_step2 = df_step1.assign(body_cleaned=df_step1["body"].apply(clean_text))

# ステップ3: 文字数計算
df_step3 = df_step2.assign(body_length=df_step2["body_cleaned"].str.len())

# ステップ4: カテゴリごとに中央値以上を抽出
median_length = df_step3.groupby("category")["body_length"].transform("median")
result = df_step3[df_step3["body_length"] >= median_length]

メソッドチェーンで一気に書くこともできますが、可読性を考えると上のように段階的に書くのが現実的です。
個人的にですが、各ステップで新しい変数(df_step1, df_step2, …)を作るか、上書きするかで悩みます。

SQL(DuckDB)で書く場合

-- ① 処理の本体はマクロとして別で定義(一度書けば使い回せる)
CREATE OR REPLACE MACRO clean_text(txt) AS
    LOWER(
        regexp_replace(
            regexp_replace(txt, '<[^>]+>', '', 'g'),
            '\s+', ' ', 'g'
        )
    );

-- ② SQL 本体は「何を取り出したいか」だけを宣言的に書く
WITH cleaned AS (
    SELECT
        category,
        title,
        clean_text(body) AS body_cleaned,
        LENGTH(clean_text(body)) AS body_length
    FROM df
    WHERE body IS NOT NULL AND TRIM(body) != ''
)
SELECT *
FROM cleaned
WHERE body_length >= MEDIAN(body_length) OVER (PARTITION BY category);

比較

このケースで注目してほしいのは、マクロによって「処理の定義」と「データの取り出し」が分離されている点です。

clean_text マクロは、HTML 除去・空白正規化・小文字化という詳細をすべて引き受けてくれます。そのおかげで、SQL 本体を見ると clean_text(body) という名前だけが現れ、「何をしているか」が一目でわかります。マクロの中身を確認したい人は定義部分を見れば良く、SQL 本体を読んでいる間は処理の詳細を意識せずに済みます。

これは Python で長い処理を関数として切り出すのと同じ発想ですが、SQL の場合はマクロが DB 側に定義として残るため、別のクエリやチームメンバーから呼び出せるという利点があります。Python 関数のように「どのファイルに定義されているか」を探す必要がなく、clean_text という名前一つで再利用できるのです。

Pandas で同じことを実現するには、clean_text 関数を別モジュールに切り出して import する必要があります。やっていることは似ていますが、SQL の場合は「DB に定義を一元化できる」という点で再利用性が一段上です。

CTE(WITH 句)も、加工ステップを名前付きで段階的に組み立てる仕組みとして有用ですが、本ケースのように1段階の中間状態で済む場合は、無理に多段化する必要はありません。「マクロで処理の本体を切り出す」「CTE で意味のある中間テーブルに名前を付ける」という2つの道具を、必要に応じて使い分けるのが SQL の書き方として自然です。

使い分けの指針

ここまでのユースケースを踏まえて、私なりの使い分けの指針をまとめておきます。

SQL(DuckDB + マクロ)が向く場面

  • 集計・結合・フィルタリングが中心の加工
  • 加工ステップが多段階にわたるパイプライン処理
  • 同じ前処理を複数のクエリで使い回したい場面
  • 可読性重視のスクリプト

Pandas が向く場面

  • 探索的データ分析(EDA)のフェーズ
  • spaCy や Transformers など、Python ライブラリを使った行ごとの処理
  • scikit-learn を使った欠損処理・正規化・特徴量エンジニアリング
  • 機械学習パイプラインの一部としてのデータ加工
  • 結果をそのまま可視化したい場面

そして重要なのは、両者は排他的な関係ではないという点です。DuckDB は Pandas DataFrame を直接クエリできるので、「メインの加工は SQL で書き、最後に DataFrame として受け取って ML パイプラインに渡す」というハイブリッド運用が可能です。前回の記事でも触れたように、これが DuckDB と Pandas の大きな魅力です。

個人的な実感としては、「探索フェーズでは Pandas、定型化したパイプラインは SQL」という使い分けが現実的だと感じています。最初は Pandas でデータを眺めながら加工を試行錯誤し、加工内容が固まってきたら SQL(マクロ + CTE)に書き直して再利用可能な形で残す、という流れです。

おわりに

本記事では、Pandas と SQL(DuckDB)の使い分けを、私なりにユースケース別に整理してきました。

私自身、以前は Pandas オンリーで書いていましたが、マクロを知ったことで SQL の表現力が大きく広がり、「読みやすさ」「再利用性」「引き継ぎやすさ」を重視する場面では SQL を選ぶようになりました。一方で、Python のエコシステムと深く接続したい場面では今でも Pandas を使います。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?