Power BIによるSaaS分析6 -DAXの最適化②

前回(下記リンク)はIF構文の最適化について紹介しました。今回はもう少し複雑なDAXについて見ていきたいと思います。なお、SE*1とFE*2という言葉が初めてという方は、前回の記事でこれらを確認できます。

新規顧客メジャーの最適化

新規顧客というメジャーですが、定義は以下の通りとなります。

前月にはなく、今月から新規となる顧客の数

DAX式は以下の通り。

  • 新規顧客数(# New Customers)
# New Customers =   
//CallbackDataIDが発生するが、CustomerIDが少ないため、クエリパフォーマンスは良い  
CALCULATE(
    SUMX(
        Sales,
        var _current_date = Sales[Date]
        var _first_signup_date = 
            CALCULATE( 
                MIN( Sales[Date] )
                ALLEXCEPT( Sales, dCustomer )
                dEventType[EventType] = "Signed-up"
            )
        var _result = INT( _current_date = _first_signup_date )
        return _result
    ),
    Sales[MonthlyPrice] > 0
)

このメジャーに対して、詳細に解説はしていませんでしたが、アルゴリズムは以下の通りです。

  1. CALCULATEでまずはSales[MonthlyPrice]がゼロを超える行だけで絞る
    CALCULATE ( ..., Sales[MonthlyPrice] > 0 )の部分がこれに当たります
    CALCULATEを使った場合、常に外側から条件フィルタが設定されます

  2. ...の中身ですが、SUMXを使って、Salesテーブルの中を1行ずつスキャン処理(加算)を行っていきます。

  3. その際、まずはSales[Date]を_current_dateという変数に格納し、最初にサインアップした(=有料顧客になった)月を取得するため、_first_signup_dateという変数に、初回サインアップ月を格納していきます

  4. _first_signup_dateという変数はまたもやCALCULATEを使っていますので、CALCULATE ( ..., ALLEXCEPT( Sales, dCustomer ), dEventType[EventType] = "Signed-up" )という条件を同時に満たすよう、フィルタ処理を行います。

  5. ALLEXCEPTはSalesテーブルのExpanded版のdCustomerテーブルに対して、dCustomer以外のテーブルのフィルターを無視する。dCustomer内の各列(dCustomer[CustomerID]等)でフィルターが効くようにテーブルの行を取得していき、これと同時に、dEventType[EventType]がSigned-upという条件を満たすものだけを取得。最後に、条件にマッチする範囲において、MIN ( Sales[Date] )という数式を評価し、最初の日付を取得。
    非常に分かりにくい説明になったと思いますので、このDAX式を別途書き換えたものが下図になります。

    f:id:marshal115:20220225141616p:plain

    ご覧の通り、それぞれのCustomerIDには各顧客が一番最初にサインアップした日付だけが取得されています。これをdCustomer以外のテーブルがどのような状態でもフィルターが効かないよう、必ずその日に固定しています

  6. 上記ステップの計算結果を_first_signup_dateに格納し、Salesの各行の日付(_current_date)と比較され(下のコード)、比較結果は_resultという変数に格納される。

    _result = INT( _current_date = _first_signup_date )

    これにより、_first_signup_dateと_current_dateが一致した場合(すなわち、最初にサインアップした日付がSales[Date]と同じであれば)、TRUE、そうでない場合はFALSEというBoolean値が返されます。このBooleanを整数にするため、INT関数が使用され、_resultには1 or 0という結果だけが格納されます。

  7. 最後にreturn _resultというステートメントにより1 or 0が各行の評価結果として計算され、これらをSUMXが集計。条件にマッチした場合(=1の場合)、新規顧客として扱われ、結果的に見たい切り口(dCalendar / dCustomer / dPriceType)別にその都度、集計される。

こちらが新規顧客数(# New Customers)を算出するためのDAXクエリのロジックとなりますが、最初からこのようなDAX式を書ける人は殆どいないと思います。アルゴリズムを考える必要があるのと、DAX式が複雑になるのが原因ですが、このDAXクエリをdCalendar[Date]で実行した場合、以下のようなクエリとパフォーマンスが返ってきます。

f:id:marshal115:20220225144029p:plain

f:id:marshal115:20220225144111p:plain

このクエリで気になるのは以下の通り。

  • クエリは実行結果で、Rowsという列に901という数字が出現。こちらは全てSEの実行結果になりますので、901行(この数字は正確な数字ではなく概算数字で、正確な数字を知りたい場合、DAX StudioのリボンでQuery Planというアイコンを有効にする必要あり)もMaterialize(日本語訳では「具現化」でしょうか)されています。Materialize自体は珍しいことではないですが、年月別の新規顧客というシンプルな結果に対して、バックエンドではこれほどMaterializeをしていたことになります

  • 左側のクエリパフォーマンスは悪いものではなく、SE CPUがゼロのため、同時処理はされておらず、SEに必要な処理時間は全体の約30%、残りをFEが担当

  • SE Queriesは3つ

一見すると、非常に速い(実際、速い)クエリのようにも見えますが、最適化を行うとSEクエリは以下のようになります。

f:id:marshal115:20220225221039p:plain

[# New Customers 2]は結果が同じとなるメジャーで、DAX式は以下の通り。

  • 新規顧客数_最適化(# New Customers 2)
 # New Customers 2 = SUM ( Sales[NewFlag] )

ここで[# New Cutomer]と比較してみると、非常に大きな変化があることが分かります。

  • DAX
    DAX式は非常にシンプルになり、SUMだけで完了

  • SEクエリ
    • SE vs FE
      SEで処理する時間は全体の50%へと上昇

    • SE Queries
      最適化前までは3つのクエリ、かつ、Materializeされていた。最適化後ではスキャンがたった1回のスキャン(しかも、Materialzeをすることなく)で終了

このように、あらゆる部分で、DAXクエリが最適化され、処理速度も改善されたことが分かります。この例ではデータ量が少ないこと、そもそもSUMXを使ったDAX式がそれほど遅いものではなかったことから、どちらのアプローチでも構わないのですが、データ量が大きい場合、可能な限りマルチコア処理が可能なSEに作業させることが重要となるため、後者のアプローチを採用するとパフォーマンスの上昇に繋がります。

ちなみに、[# New Customers 2]というメジャーはSales[NewFlag]という列を合計しているのですが、こちらは計算列となっています。計算列の数式は以下の通り。

NewFlag =
//新規顧客が登場した月のフラグ
//こちらはメジャーではなく、計算列であることに注意
VAR _first_signup_date =
    CALCULATE (
        SELECTEDVALUE ( dCustomer[StartDate] ),
        dEventType[EventType] = "Signed-up"
    )
VAR _result =
    IF ( Sales[Date] = _first_signup_date, 1, BLANK () )
RETURN
    _result

この計算列の結果は下図の通りにですが、合計30名のサインアップ客のサインアップした年月([Date]列)に対して、NewFlag = 1として計算されています。

f:id:marshal115:20220225222807p:plain

計算列のDAX式自体、[# New Customers]で詳しく解説したDAXと殆ど同じで、_first_signup_dateは同じ結果、_resultも同じことを算出しています。唯一異なるところがあるとしたら、IF式の最後にBLANK()という関数が使用されており、もし1でなかった場合、各行を空白扱いとしていることです。

理由は、BLANK()扱いの場合、合計しても0として年月に出現してこないため、[# New Customers]のDAX式のように、CALCULATE ( ..., Sales[MonthlyPrice] > 0 )の影響を予め織り込んでいることにあります。

まとめ

今回紹介した事例をまとめると以下のようになります。

  • SUMXの式はSalesテーブル内の各行をスキャンしていき、サインアップした年月とSales[Date]が一致すれば1、そうでなければ0を返す。結果、SUMXで1を合計すれば、各切り口別に見た新規顧客数が分かるようになる

  • SUMを使った事例は結果こそ同じものの、クエリパフォーマンスが最適化されている。理由は、複雑なDAX式を要しない、また、計算列に予め1を含む列を計算させておくことで、クエリ時(テーブルやチャートなどのビジュアルを作るためにDAXクエリが送られ、それに対する結果が返ること)の反応速度が速くなる

  • 本当に計算列を作る必要があるかどうかを考える。計算列はPower Queryのデータがデータモデルにインポートされてから評価されるため、データ量が大きい場合、データモデルの更新速度に影響を与えてしまう可能性がある

  • 顧客離反率(Cutomer Churn)等のメジャーも同じように計算列を用いることで最適化を図ることが可能

  • DAXで計算列を作る以外に、Power Queryでデータモデルに読み込む前にNewFlag列を追加することも検討する。読み込んだデータに対して計算列をDAXで作るより、読み込む前に列(NewFlag)を追加したほうが、データモデルのサイズが最適化される可能性もある

DAXの最適化はパターンが決まっていることが多く、1つはIF、もう一つは複雑なDAX式を計算列に分解し、簡単なDAX式で対応する、そして最後にCALCULATEのフィルター引数に”ファクトテーブル全体でフィルターを行わない”といったベストプラクティスが存在します。最後のケースについてはまた時間があるときに、別途紹介したいと思います。

次回は下記より

marshal115.hatenablog.com

*1:Storage Engineの略

*2:Formula Engineの略