Recruit Data Blog

  • はてなブックマーク

目次

はじめに

株式会社リクルートでアナリティクスエンジニアをしている岩井と申します。 主に次の2つの業務をメインに行なっています。

  • SaaS領域全体における横断的なデータマネジメントの推進
  • SaaS決済領域(SaaS事業において『Airペイ』などの決済サービスを扱う領域)でのデータ利活用の促進

本記事ではSaaS領域のデータマネジメント活動の一環のデータ品質向上のための取り組みを紹介します。
もともとデータに対するテストはデータマート開発時しか行われていませんでしたが、運用中のデータに対するバリデーションテストを積極的に実装したり、テスト駆動でデータマートを開発したりとテスト文化も定着してきました。

本記事は、データ推進室 Advent Calendar 2024 15日目の記事です

データ品質向上のための取り組み

データ環境

データ品質向上の取り組みの紹介に入る前に、SaaS領域におけるデータ環境について紹介します。
簡単にデータを取り巻く環境としては以下の通りです。

  • DWH
    • Google BigQuery
  • ETL
    • 内製のETLフレームワーク
  • データマート
    • One Big Table (大福帳)

SaaS領域は『Airレジ』や『Airペイ』など10を超えるプロダクトが存在しており、それぞれのプロダクトのデータマートを提供しています。データマートの形式はほぼ全てのプロダクトではOne Big Tableを採用しており、数テーブルである程度の分析ができる状態です。
データマートの作成は内製のETLフレームワークで行っており、SQLファイルを登録すると日次で実行されデータマートが作成されます。

課題

昨今のデータ環境においてはデータ品質を担保するためにテストを実施するのがますます重要視されるようになっています。 テストがないとデータの異常に偶発的にしか気付けず、知らぬ間に不備のあるデータで分析が行われるかもしれません。実際に筆者がコードを読む中でバグに気づいたり、問い合わせからバグが見つかるといったりすることが散見されました。また、昔から存在するデータマートにおいては、作成者が離任しているケースも多くかつドキュメントも残されていないので、途中から開発に入るメンバーは秘伝のタレに継ぎ足しでデータマートを開発していくことになります。作成者の意図が分からず、とりあえず現状維持で放置されているロジックがSQLの中に存在しており、それがバグの温床となりかねません。
また、リクルート全体で見ると、開発工程にテストを取り入れて品質を向上させた事例がいくつか挙がっており、私が所属するチームでもこういった事例をヒアリングしつつ、取り入れていきたいと考えていました。
ref. 当社データブログ https://blog.recruit.co.jp/data/articles/data_test_with_dbt/

以上のことから

  • データマートのバグに気づけない(バグがあるかも分からない)状態である
  • 既存のロジックが正しい状態か分からない(正しくビジネスロジックを反映しているか分からない)

という2つの課題を解決するためにデータテストを実装することとしました。

データテスト環境構築

データテストのためにdbtとelementaryを導入しました。

dbt

dbtの詳細は 公式サイト をご確認ください。 dbt自体はアプリケーション開発の手法を用いたデータトランスフォームのサービスで、一般的にはETL(ELT)のTransformの部分を担うために導入されることが多いかと思います。 しかし現状、内製のETLフレームワークによりデータマートを作成しているため今回はデータテストの実装のためだけにdbtを利用します。
またdbtにはdbt Coreとdbt Cloudの2種類ありますが、テストだけであればCLIで十分なためdbt Coreを使用します。

elmentary

elementaryの詳細は 公式サイト をご確認ください。
elementaryはdbtネイティブのdata observability サービスで、主に以下2つの目的で採用しました。

  • testがfailした際のアラート
  • テスト実行結果のモニタリング

またelementaryにもOSS版とCloud版がありますがこちらもCLIで十分なためOSS版を採用しました。

■elementaryのSlack通知
dbt testを実行したのちにelementaryがpost-hookで実行され、テスト結果をBigQueryに書き込みます(elementaryを導入するとelementary用のデータセットとテーブルが自動で作成されます)。そこからfailしたテストがSlackに通知されます。

■elementaryのレポート画面
上述した通りelementaryはテストに限らず、さまざまデータを取得しBigQueryに保存します。その内容をもとにモニタリング用の静的ファイルを出力できます。このファイルをGithub Pagesにホスティングしてモニタリングを行なっています。

dbtコマンド及びedrコマンド(elementary)を日次バッチで実行することで、毎日テストが実行され失敗したらアラートが通知される、またテスト結果をUIから確認できる環境を構築しました。

データテスト実装

背景

データマートの確からしさを確認するためには、作成ロジックを読み解き不具合やデータの矛盾を突き止める、というのが一般的なアプローチになるかと思います。しかし、我々の組織においてはデータマート数は約50とそれほど多くないものの一つ一つのSQLが非常に長くコードリーディングをするのにも時間がかかります。また、既存のSQLが誤っていない可能性もある中で、1からクエリを読みバグを見つけ出す作業を「データマートの品質を高めたいからSQLを読み解いて不具合を探してください」と課題設定するのは、他のタスクがある中で優先して行うにはあまりにも漠然としています。そのため、SQLを読み解く前にデータマートの正しい状態をテストで定義する、というステップを踏みました。これであればコードリーディングをするよりは断然低コストで実装ができるのと、継続的な品質テストができるのでデータの異常にすぐ気づけるという大きな恩恵も受けられます。テスト方針について以降詳細にご説明します。

テスト実装方針

最終的に達成したいことは提供しているデータマートに一切のバグがなく、仮に不具合があっても検知できる状態を作ることです。
とはいえ、テストのためにdbtを導入したということからも分かる通り、これまでdbtを使った開発は行なっておらず、dbtに習熟したメンバーも少ないという状況の中これをいきなり実現することは非現実的です。そこで以下のようなテスト実装計画を立て段階的にテストを進めていくこととしました。

フェーズ テスト概要 目的 実施テスト例
Phase1 クリティカルエラーテスト データとして明らかに異常なデータが入っていないか PKが重複していないか / nullを許容しないカラムにnullが入っていないか
Phase2 整合性テスト / サービスデータ変更検知テスト ビジネスイベント・サービス仕様上起こり得ないデータはないか、サービスデータの変更があった場合追従できるか 前後関係のあるカラムAとカラムBは必ずカラムBのほうが新しいか,参照しているソースのコード値に追加はないか
Phase3 ディメンショナルモデリングによるリファクタリング データマート保守性・開発速度の向上 レイヤーごとに実施するテストを定義

テストにはdbtの標準搭載のテストだけでなく、dbtのパッケージの dbt-utils dbt-expectations にあるものも使用しており、これらで実現できないものに関してはカスタムテストを作成します。 

またテスト実装の指針として、テストはメタデータであり、メタデータがテスト項目になる という標語を掲げました。
テストコードを見るだけでそのカラムの仕様がわかり、メタデータとして公開されている情報の正しさを保証することがテストの目的になるという考えに基づいています。

Phase1 クリティカルエラーテスト

初めに単体カラムに対して以下のテストを実装しました。

  • 重複チェック(unique)
  • nullチェック(not_null)
  • 空文字チェック(not_empty_string)
  • 値確認チェック(at_least_once)

このフェーズでは複雑なテストはせずに、明らかに異常な状態になっていないかを確認する、もしくは異常な状態になった時に検知する目的で進行しました。また前述した通りdbtに習熟したメンバーが少ない中でまずはdbt testに慣れていくという意味合いもありました。とはいえ、テストを行うだけであればyamlを書くだけなのでほとんどのメンバーが短期間でテストに慣れたように感じます。

Phase2 整合性テスト / サービスデータ変更検知テスト

サービスデータ変更検知テスト

サービスデータ変更検知テストは、新しいバリューの追加検知が目的です。
例えば以下のような処理がデータマートのロジックにあったとします。

case
    when ingredient in ('にんじん','たまねぎ') then '野菜'
    when ingredient in ('鶏肉','豚肉') then '肉'
end as ingredient_category

ingredientというカラムに[‘えび’]という値が入ってきたら、このロジックだとnullになってしまいます。しかし、ビジネスロジックとして新しいカテゴリを追加する場合があるはずです。この場合サービスの値の追加に追従するためにdbtのaccepted_valueを実装します。

- name: ingredient
  data_tests:
    - accepted_values:
        values: ['にんじん', 'たまねぎ', '鶏肉', '豚肉'] 

またサービスの値の追加のもう一つのケースとしてコード値の対応表をテーブルとして持っているケースがあります。例えば、下図のように店舗テーブルと都道府県マスタテーブルがあったとします。

その際以下のようなクエリだと店舗テーブルの都道府県コードが都道府県マスタテーブルにない場合都道府県名がnullになります。

select 
    店舗名,
    都道府県名
from
    店舗
    left join 都道府県マスタ using(都道府県コード)

やはりこの場合も対応するコード値がなければアラートを出してデータの是正を促すべきです。参照整合性テストを実装することでサービスの値追加を検知することができます。

- name: 店舗
  data_tests:
    - relationships:
        to: source('都道府県マスタ')
        field: 都道府県コード

テーブル同士の参照整合性を確認するのも有効ですが、テーブルに対してスプレッドシート等で管理しているマスタデータを紐づける、といったような一方はシステム的に更新され、他方は人手で更新する関係性のものには特に有効です。

ソースデータの値の追加に気づかずそのままにしておく、というのはバグとは言えないかもしれませんが、高品質なデータマートとも言えません。これらのテストを実装していないとプロダクト側のアップデート(もしくは不具合)に追従することができず、結果として現状を正しく表さないデータマートとなってしまいます。

整合性テスト

整合性テストは、同一テーブルにおける複数カラム間で矛盾がないかをチェックするテストと位置付けておこないました。幸いにもOne Big Tableでデータマートを提供しているため、1テーブルに多くのカラムを持っており整合性テストをする上でカラム間の比較がしやすい状態でした。そこで、以下のようにカラムを意味ベースで分類し、組み合わせで考えられるテストケースを洗い出しました。

独自定義型 データ型 概要
フラグ型 int / string / bool True/Falseを表す2値を取るもの
カテゴリ型 string 入力規則のある特定の文字列
ステータス型 string カテゴリ型の中でも状態遷移におけるステータスを示し、決まったルールで時系列的に値が変化する値
テキスト型 string 入力規則のない文字列
ID型 int / string ID
数値型 int / float 数値
日時型 date / datetime 日付

量が膨大になるためテストケースに関しては一部をご紹介します。

カラム1 カラム2 概要
フラグ型 フラグ型 フラグ同士の整合性が取れているか アクティブフラグが1であれば解約フラグは0である
フラグ型 ステータス型 フラグとステータスの整合性が取れているか 解約フラグが立っていればステータスは解約である
カテゴリ型 日付型 カテゴリと日付の整合性が取れているか ユーザータイプが一部機能のみ利用可であれば、利用できない機能の利用開始日が入っていない
数値型 数値型 演算関係が成り立つか カテゴリA/B/Cの購買金額の総和が購買額と一致する
日時型 日時型 大小関係が成り立つか 申し込み日より初回決済日が新しい

このテストで強く意識したのはデータ観点ではなくビジネスロジック観点でカラム同士の整合性が取れているかに着眼してテストを行うことです。つまり、ビジネス・サービス仕様上起こり得ないことがデータの世界に反映されていないかを確認するためのテストと言えます(意味合いとしては単体テストに近しいかと思います)。 この工程が今回の一連の取り組みの肝だと考えており、SQLを読み解くという工程を経ずに低コストでSQLもしくはデータの不具合に気づくことができるということが大きな利点です。また、ビジネスロジック観点でのテストということは当然ビジネスロジックを知っていないとできません。いざ実装しようとすると意外と手が止まることがあり、知っているつもりだったビジネスやプロダクトの理解がいかに足りていないかを痛感しました。データ職の人間はどうしてもデータに目が行きがちですが、このフェーズであらためてビジネスのためのデータであることを再認識すると同時に、ビジネス・サービス仕様のキャッチアップを半強制的に行う必要があり組織全体としてドメイン知識が深まったように感じます。

テストは基本的にdbtのパッケージの dbt-utils dbt-expectations で実現可能なものを活用しますが、どうしても対応できないテストケースもあります。dbt に「Custom generic tests」(https://docs.getdbt.com/best-practices/writing-custom-generic-tests) という、ユーザーが自身のユースケースに合わせてGeneric tests を定義できる機構があります。これを利用し我々のユースケースに合うようなCustom generic tests を作成しました。いくつかご紹介します。

・ 同じ型の2カラムを比較して、一致しないことを期待するテスト
(dbt-expectationsには一致するテストはありますが、逆がなかったので作成)

/*
expect_column_pair_values_to_be_not_equal

[テスト概要]
同じ型の2カラムを比較して、一致しないことを期待するテスト

[使い方]
- expect_column_pair_values_to_be_not_equal:
    column_A: active_flg
    column_B: churn_flg
*/

{% test expect_column_pair_values_to_be_not_equal(model,
                                                    column_A,
                                                    column_B,
                                                    row_condition=None
                                                    ) %}

with grouped_expression as (
    select
        {{ column_A }} != {{ column_B }} as expression
    from 
        {{ model }}
    {% if row_condition %}
    where 
        {{ row_condition }}
    {% endif %}
)

, validation_errors as (
    select
        *
    from
        grouped_expression
    where
        not(expression = true)

)

select *
from validation_errors

{% endtest %}

・ 合計値を持つ1カラムに対して、複数のカラムの合計が一致することを期待するテスト

/*
expect_multicolumn_sum_to_equal_column

[概要]
合計値を持つ1カラムに対して、複数のカラムの合計が一致することを期待するテスト

[使い方]
- expect_multicolumn_sum_to_equal_column:
    column_list: ["sales_a", "sales_b"]
    row_condition: "sales_date is not null"  # (Optional)
*/

{% test expect_multicolumn_sum_to_equal_column(model,
                                                column_name,    
                                                column_list,
                                                row_condition=None
                                                ) %}

{% set expression %}
{% for column in column_list %}
{{ column }}{% if not loop.last %} + {% endif %}
{% endfor %} = {{ column_name }}
{% if row_condition %}
and {{ row_condition }}
{% endif %}
{% endset %}

with grouped_expression as (
    select
        {{ expression }} as expression
    from 
        {{ model }}
)

, validation_errors as (
    select 
        *
    from
        grouped_expression
    where 
        not(expression is true)
)

select 
    *
from
    validation_errors

{% endtest %}

・ 任意のフィルタ条件に対して特定の結果のみ出力されることを期待するテスト
自由度が高いテストであるが故に複雑なテストもできてしまいますが、複雑性をなくすために複数条件指定できないようにしています。

/*
expect_filter_condition_to_be_specified_output

[テスト概要]
任意のフィルタ条件に対して特定の結果のみ出力されることを期待するテスト

[使い方]
●カラムテスト
- name: column_name
 data_tests:
    - expect_filter_condition_to_be_specified_output:
                    filter_condition: " > '2025-01-01'" (絞り込み条件)
                    expect_output: "date is null" (期待するアウトプット)
 
●テーブルテスト
data_tests:
    - expect_filter_condition_to_be_specified_output:
                    filter_condition: "column_name > '2025-1-1'" (絞り込み条件)
                    expect_output: "date is null" (期待するアウトプット)

※ テーブルテストの場合はfilter_conditionにカラム名を明示する

[備考]
下記のようにfilter_conditionの条件値の中に、and や or の予約語がある場合はエラーになる。
ex: filter_condition: " = 'hoge and'"
*/

{% test expect_filter_condition_to_be_specified_output(
                                                        model,
                                                        filter_condition,
                                                        expect_output,
                                                        column_name=None
                                                        ) %}

{% set operators = ['and', 'or'] %}
{% set conditions_list = filter_condition.split() %}
{% set ns = namespace(is_contains_operator = false)%}

{% for word in conditions_list %}
    {% if word|lower in operators %}
        {% set ns.is_contains_operator = true %}
    {% endif %}
{% endfor %}



{% if ns.is_contains_operator == true and column_name is not none %}
    select
        'Multiple conditions in filter_condition are not allowed when column_name is not null.' AS error_reason
{% else %}
    with grouped_expression as (
        select
            {{ expect_output }} as expression
        from 
            {{ model }}
        where
            {% if column_name %}
                {{ column_name }} {{ filter_condition }}
            {% else %}
                {{ filter_condition }}
            {% endif %}
    )
    , validation_errors as (
        select
            *
        from
            grouped_expression
        where
            not(expression is true)
    )

    select *
    from validation_errors
{% endif %}
{% endtest %}

これらのテストを実装することにより、ビジネス・サービス仕様の観点からカラム間の矛盾を見つけ出し、修正をすることができます。

Phase3 ディメンショナルモデリングによるリファクタリング

このフェーズはまさに現在取り組んでいるところで具体的なアウトプットはまだありません。そのためなぜディメンショナルモデリングの推進をしているかという点についてお話しさせていただきます。前述した通り現在のデータマートはOne Big Tableでの提供となっており、かつ1つのクエリでデータマートを作成しているためコードの保守メンテ性が低く、同じような処理を何度も使いまわしており、ロジックの変更があった際に複数の同じような処理に対して修正をする必要が生じ、非効率的かつバグの温床となります。。ディメンショナルモデリングを進めることで処理を適切な粒度に分割でき、レイヤー単位で役割を明確化することができます。また集計処理の定義を一箇所にまとめ、使いまわすことができるので人による集計結果の差異をなくすことが期待できます。以下のようなレイヤーとテスト戦略を構想しています。

レイヤー 役割 実施テスト
source 元データのコピー なし
staging sourceデータのクレンジング クリティカルエラーテスト / サービスデータ変更検知テスト
intermediate dimension/factの複雑な前処理の集約 Unit Test / Row Count Test
dimension ビジネスイベントの分析切り口となるデータの集約 Unit Test / Row Count Test
fact ビジネスイベントの計測対象となるデータの集約 Unit Test / Row Count Test
marts ユーザーインターフェースのOne Big Table 整合性テスト

Unit Testは単純なyamlでは表現できないビジネス仕様をテストする際に実施します。また、Row Count Testはjoin等によりレコード数が意図せず増えていないかといったレコードの件数チェックをするテストです。
Phase1/Phase2においては質より量でテストを実装しておりテスト過多な状態になりつつありましたが、このフェーズからはレイヤーにより必要なテストを定義することでテストを効率的に実装することができると考えています。

ここまで品質の話をしてきたのでその延長線かのようにディメンショナルモデリングについて触れましたが、そもそもディメンショナルモデリングを推進するのは品質向上(リファクタリング)のためだけではありません。多様な分析ニーズに対して迅速に応えられるデータ環境を構築することが真の目的で、そのための手段としてディメンショナルモデリングが最適であると考えています。とはいえ現在提供しているデータマート自体に不具合があっては元も子もないのでディメンショナルモデリングの開発よりもデータテストを優先すべきと判断しました。

振り返り

Phase1/Phase2のテストを実装した結果以下のようになりました。

  • テスト実装マート数: 49
  • 実施テスト数: 3076
  • 検知不具合数: 120

これまで当たり前のように使われていたデータマートですがテストを実施することで不具合が顕在化し改めてテストの重要性を再認識しました。もちろんこの不具合の中にも濃淡はあり、クリティカルなものもあれば、影響軽微なものもあるので120件即座に直さないと意思決定に影響が出るというわけではないですが、それでも我々開発者が想定しなかった不具合が散見されたことには驚きを隠せませんでした。特に作られてから時間が経過しているデータマートは作成経緯や作成者の意図もドキュメントとして残っておらず、改めてテストをすることで気づくデータ・サービスの仕様があり、テスト実装の副次的な効果としてドメイン知識が深まる、ということも体感しました。

一方で以下のようにまだテストでできていないこともあり今後対応を検討していきます。

  • データ鮮度・アノマリーテストの実装
  • テストに対してのアラートレベルの設定

データマートの品質に懸念がある場合は、コードを読み解く前にテストでデータの振る舞いを確認すること、特にビジネスイベントの流れに即しているかをテストで定義することは非常に有用であると感じました。

終わりに

データ品質を高めることは、アナリティクスエンジニアとしては非常に重要なテーマではありますが、それ自体が目的になることはありません。ユーザーの正しい意思決定を支援し続けることこそがアナリティクスエンジニアの使命なので、データ品質に限らずアナリティクス・レディで高品質なデータ環境を作り上げることにこだわっていきます。

shintaro_iwai

アナリティクスエンジニア

shintaro_iwai

リクルート中途入社。データにまつわるエトセトラをしています。