機械学習実験を加速させる dbt による特徴量管理の実践

こんにちは、AI テクノロジーグループ データサイエンティストの髙橋です。業務では企画/分析/機械学習モデル作成/プロダクション向けの実装/効果検証を一貫して行っています。この記事は Enigmo Advent Calendar 2025 の 19 日目の記事です。

本記事では、 dbt を利用した機械学習モデルの特徴量管理について紹介します。この特徴量管理を活用することで、機械学習を利用したプロジェクトで多くの実験を効率的に実施でき、利益増加というビジネス成果に繋げることができました。

www.getdbt.com

※文中に記載するディレクトリやファイル名、 SQL コード、コマンドなどは全てイメージです。

特徴量管理の目的・成果

dbt を利用した特徴量管理を導入した目的は、ある機械学習プロジェクトにて効率的に数多くの特徴量を試す必要があったためです。まず、そのプロジェクトと得られた成果について説明します。
弊社が運営している CtoC ECサービス BUYMA では、MA (Marketing Automation) ツールを通じてクーポンなどのインセンティブをメールで配布しています。これまで、様々な会員セグメントを定義し、各セグメントに対してのインセンティブ配布ルールの運用を行っており、そのルールのチューニングで改善を図っていました。このアプローチではルールをもとに一律配布しているため、機械学習による最適化余地があると考えました。具体的には、インセンティブがなくても購入する会員にもコストをかけてしまったり、インセンティブがあれば購入する会員に配布できていなかったりなどの最適化の余地があるのではないかと考えました。そこで、機械学習を活用してデータに基づくより効果的なインセンティブ配布を実現することを目指しました。

BUYMA はリリースから 20年を超えるサービスであり、様々な MA シナリオ(会員セグメントと配布ルールの組み合わせを以降 MA シナリオと呼びます)が運用されています。出来るだけ多くの MA シナリオに対して機械学習による最適化を適用したく、そのためには様々な特徴量を効率良く試せるようにすべきと考えました。

そこで、今回紹介する dbt を利用した特徴量管理を導入しました。その結果、約1年間でおよそ8個の MA シナリオに機械学習による配布最適化を試すことができ、シナリオによってばらつきはあるものの約20%の利益増加が実現できました。

数多くの特徴量を試す上での課題

まず前提として、特徴量は BigQuery で作成する方針としました。理由は、既に BUYMA のデータは BigQuery に保存されていたことと、Python 実行環境(ノートブックなど)への特徴量作成のもとになる行動データのダウンロードに非常に時間がかかったためです。時間がかかる理由は、BUYMA は会員数・商品数が非常に多く、それに伴いユーザーの行動ログのデータ量も非常に多いためです。具体的には、会員数1185万人、商品数590万品であり*1、利用する行動ログのレコード数は数千万件になることもあります。

この前提のもと、BigQuery で数多くの特徴量を効率的に試すには以下の課題がありました。

  • 特徴量の数が多くなると SQL が肥大化して可読性が低下する。
    • 例えば、弊社で過去別の機能で作成した特徴量 SQL ファイルは2000行を超えており読むのが大変でした。
  • 特徴量作成のロジックが複雑になると可読性が低下する
    • 例えば、過去 n 日間の閲覧数という特徴量を複数 n に対して記述すると、SQL が長くなり可読性が低下します。
  • 別の実験で特徴量を再利用しづらい
    • 再利用する場合は、その特徴量部分を毎回コピペする必要があるためです。

dbt による課題解決

そこで、 dbt を利用して特徴量管理を行うことで、これら課題の解決を図りました

まず、 dbt について簡単に説明します。ただし、dbt は多くの機能があるため今回の課題解決に関連する機能に絞って説明します。全体を詳しく知りたい方は公式ドキュメントを参照ください。 今回役立ったのは以下の機能です。

  • SQL ファイルの分割
  • for などのロジックの記述

SQL ファイルの分割について、dbt を利用することで CTE (Common Table Expression) を別のファイルに分けることができます。例として、以下のような CTE を使った SQL を考えます。

-- main.sql
WITH users AS (
    SELECT
        id AS user_id,
        first_name,
        last_name
    FROM `project.dataset.users`
)

SELECT
    *
FROM
    users

dbt を利用することでこの SQL を2つのファイルに分けることができます。

-- user.sql
SELECT
    id AS user_id,
    first_name,
    last_name
FROM `project.dataset.users`

-- main.sql
SELECT
    *
FROM
    {{ ref("user") }}

ここで、 {{ (ref("user")) }} は user.sql を参照することを意味します。 dbt run コマンドを実行すると、 user.sqlmain.sql のテーブルビューが作成されます。この機能を利用することで、特徴量作成などの SQL を分割して可読性を向上させることが出来ます。具体的には、以下のように SQL ファイルを分割しました。

models
├── datasets
│   └── dataset.sql
└── features
│   ├── features_user_attributes.sql
│   └── features_user_action_log.sql
└── labels
    ├── label_type_1.sql
    └── label_type_2.sql

ここで、 models ディレクトリは dbt でデータ取得のための SQL を配置するディレクトリです。*2

各 datasets ファイルの中身は以下のようにしました。

SELECT
    *
FROM
    {{ var("user_ids_table_name") }}
LEFT JOIN
    {{ ref("features_user_attributes") }}
USING(user_id)
LEFT JOIN
    {{ ref("features_user_action_log") }}
USING(user_id)
LEFT JOIN
    {{ ref("label_type_1") }}
USING(user_id)

ここで、 var は dbt で利用できる変数です。*3 様々な会員セグメントについて実験するために、セグメントごとの会員 ID を別テーブルにあらかじめ保存しておき、変数として切り替えられるようにしました。 features ディレクトリ配下のファイルは意味がある粒度で特徴量を分けて再利用しやすくしました。また、機械学習の目的変数である label も複数パターン試せるようにしました。 こうしたことで、実験が進むごとに特徴量が増加しても、SQL が肥大化して読みにくくなることを防げました。具体的には、1つの SQL ファイルあたり長くとも約100行におさまるようになりました。また、 dataset.sql を見ればどのような特徴量が利用されているかが一目で分かるようになりました。

for などのロジックの記述について、dbt では Jinjaというテンプレートエンジン の記法で for などのロジックを記述することができます。これを利用して、例えば過去 1、3、7日間の閲覧、お気に入り回数の集計は以下のように記述できます。

{%- set agg_actions = ["view", "like"] -%}
{%- set last_n_days = [1, 3, 7] -%}

SELECT
    user_id,
    {% for agg_action in agg_actions %}
        {% for last_n_day in last_n_days %}
            COUNTIF(
                day_from_base_date <= {{ last_n_day }}
                AND action = "{{ agg_action }}"
            ) AS cnt_action_{{ agg_action }}_last_{{ last_n_day }}_days,
        {% endfor %}
    {% endfor %}
FROM
    `project.dataset.user_action_log`
GROUP BY
    user_id

ここで、簡単のために user_action_log テーブルに特徴量作成の基準日から何日前のログかを示す day_from_base_date カラムが存在すると仮定しています。これを通常の SQL で記述すると以下のようになります。

SELECT
    user_id,
    COUNTIF(
        day_from_base_date <= 1
        AND action = "view"
    ) AS cnt_action_view_last_1_days,
    COUNTIF(
        day_from_base_date <= 3
        AND action = "view"
    ) AS cnt_action_view_last_3_days,
    COUNTIF(
        day_from_base_date <= 7
        AND action = "view"
    ) AS cnt_action_view_last_7_days,
    COUNTIF(
        day_from_base_date <= 1
        AND action = "like"
    ) AS cnt_action_like_last_1_days,
    COUNTIF(
        day_from_base_date <= 3
        AND action = "like"
    ) AS cnt_action_like_last_3_days,
    COUNTIF(
        day_from_base_date <= 7
        AND action = "like"
    ) AS cnt_action_like_last_7_days
FROM
    `project.dataset.user_action_log`
GROUP BY
    user_id

比較してみると、 dbt を利用することで SQL が短くなり、かつ変数を定義できるためどの行動を過去何日分集計するかが一目で分かるようになりました。これにより特徴量が複雑になっても可読性が低下することを防げました。また、集計する日数や行動が増えたとしても、変数のリストに要素を追加するだけで対応できるようになりました。

dbt による特徴量管理時の工夫

より多くの特徴量を素早く試せるように行った工夫があるため、それらも紹介します。ここでは2つ紹介します。

1つ目はデータセット管理表を用意し、実験で利用するデータセットごとに ID を採番し、 dataset_001dataset_002 のようにファイルを作成していく方針としたことです。

データセット管理表のイメージ:

データセットID データセット説明
1 セグメント1に対して特徴量 A を利用したデータセット
2 セグメント2に対して特徴量 A, B を利用したデータセット

作成したファイルのイメージ:

models
└── datasets
    ├── dataset_001.sql
    └── dataset_002.sql

こうすることで、新しいデータセットを簡単に追加できるようにし、かつ過去のデータセットも参照しやすくしました。実際に2025年12月時点ではデータセット ID は 100 を超えていますが、問題なく運用出来ています。

2つ目は dbt で作成したテーブルのビューから Python 実行環境でデータを取得する際は、以下のような SQL で一度 GCS にエクスポートしてダウンロードするようにしたことです。これは、Python で BigQuery SDK を利用してデータ取得するとレコード数が多い場合非常に時間がかかるためです。

-- analyses/export_dataset.sql
{%-
    set bucket_folder = "datasets/" + var("dataset_id")
-%}
{%-
    set table_name = 
        target.database
        + "."
        + target.schema
        + "."
        + "dataset_"
        + var("dataset_id")
-%}

-- BigQuery の export data 構文において _table_suffix を含んでいるとエラーが発生するため
-- CREATE TEMP TABLE 構文を利用。
-- https://stackoverflow.com/a/70033601
CREATE TEMP TABLE temp_dataset AS (
    SELECT
        *
    FROM
        {{ table_name }}
);

EXPORT DATA
OPTIONS (
    uri = "gs://{{ var('bucket_name') }}/{{ bucket_folder }}/*.gz",
    format = "Parquet",
    overwrite = true,
    compression = "GZIP"
)
AS (
    SELECT
        *
    FROM
        temp_dataset
);

ここで、GCS バケット名やフォルダ、エクスポートするデータセット ID を dbt 変数としており、これによりデータセットによってエクスポート先を変更できるようにしました。また、この SQL はテーブルビューを作成する必要がないため、 analyses ディレクトリに配置して、以下のコマンドでコンパイルして実行するようにしました。

dbt compile \
    --select analyses/export_dataset.sql \
    --vars '{bucket_name: "your_bucket_name", bucket_folder: "your_bucket_folder", dataset_id: "001"}' && \
bq query < target/compiled/your_dbt_project_name/analyses/export_dataset.sql

ここで、 dbt compile コマンドは Jinja 記法などを解決して実行可能な SQLコンパイルするコマンドであり、コンパイルされたファイルは target/compiled 配下に保存されます。また、 dbt の analyses ディレクトリとは models ディレクトリとは異なり一時的な分析用 SQL などを配置するのに適したものになります。*4

まとめ

本記事では、dbt を利用した特徴量管理について紹介しました。SQL の肥大化や特徴量の再利用しづらさという課題を、 SQL ファイルの分割や for などのロジック記述により解決しました。また、データセット管理の方法や Python 実行環境でのデータ取得の高速化というより効率的に多くの特徴量を試す方法も紹介しました。これにより、複数の MA シナリオに対して機械学習を利用したインセンティブ配布最適化を試すことができ、利益増加という成果に繋げることが出来ました。

本記事が特徴量管理の参考になれば幸いです。

明日の記事は BUYMA TRAVEL のエンジニアの赤間さんです。お楽しみに!


株式会社エニグモ すべての求人一覧 hrmos.co

*1:2025年10月末時点の数値です。https://enigmo.co.jp/ir/

*2:dbt models について詳細は dbt 公式ドキュメントを参照ください。

*3:dbt の変数について詳細は dbt 公式ドキュメントを参照ください。

*4:dbt analyses について詳細は dbt 公式ドキュメントを参照ください。