/note/tech

dbtでレセプトデータをモデリングしてみたら、定石が全然通用しなかった話

要約:

■ 1. dbtの理想と医療データの現実

  • 前提:
    • 医療データ(レセプト)の標準化をdbtで進める中で体験した、理想と現実のギャップの記録
    • 医療系データエンジニアや、複雑なドメインでdbtを使った経験がある読者を想定
    • レセプトデータの構造をある程度知っていることが前提
  • dbtの定番化:
    • データ分析基盤にdbtを使うのが定番になっている
    • かつてはDataform等のライバルもあったが、同系統のツールならdbt一択
    • 根拠はコミュニティの盛り上がりと公式ドキュメントの整備具合
  • 公式ベストプラクティスの前提:
    • stagingでは軽い変換のみ行う
    • 1つのソーステーブルに1つのstagingモデルを対応させる
    • インクリメンタル処理はupdated_atを基準にする
    • モデルの粒度を明確に分離する
  • 処理レイヤーによる整理の評価:
    • データ品質の担保に非常に有効な秀逸な整理
    • 大概のデータはこれに従えばパイプライン構築時の課題を解決できる
    • しかしレセプトのような複雑な構造の医療データでは難しい部分がある
  • 前提とレセプトの現実の対比:
    • 構造が統一されている前提に対し、ファイル種別で構造がまったく違う
    • カラム数が一定の前提に対し、同一ファイル内でも行ごとにカラム数が変わる
    • 仕様書が明確な前提に対し、医療制度の知識がないと仕様書すら読めない
    • データ品質が安定している前提に対し、仕様通りでないことが日常茶飯事
    • シンプルなJOIN設計の前提に対し、識別単位が複雑に絡み合っている
    • updated_atでのインクリメンタル処理の前提に対し、過去月データが後から修正・再送される
  • 最初に理解すべきこと:
    • ベストプラクティスを教科書通りに適用しても、レセプトデータでは通用しない部分が多い
    • シンプルな構造向けのベストプラクティスと、複雑な構造への現実的な対応ではdbtの使い方がまったく違う

■ 2. レセプトデータが厄介な理由

  • CSVの皮を被った何か:
    • CSV形式で提供されるが、実態は通常のCSVとはかけ離れている
  • 医療機関の種類ごとの4ファイル:
    • 医科・DPC・歯科・調剤でそれぞれ構造が異なる
    • 医科とDPCは似ているが、歯科・調剤は別物
  • 行ごとに変わる構造:
    • 同一ファイル内でもデータ識別単位(レコード種別)によってカラム数がまったく違う
    • レコード種別の例はMN、IR、RE、SY、IYなど
  • 仕様書との不一致:
    • データ不備や変則的な構造が存在するのが普通
    • 保険者ごとに微妙に違うこともある
  • 読み手に求められる専門性:
    • 医療知識や制度の理解がないと、仕様書が読み解けない
  • この時点でdbtの想定する世界ではないと気づく

■ 3. stagingでの重いバリデーション

  • dbtの推奨:
    • stagingでは名前変換・型付け程度を行い、ロジックはintermediateに任せる
  • 実際の実装:
    • stagingの段階で相当なバリデーション処理が必要になった
    • ファイル形式のチェック(拡張子がcsvかどうか)
    • 保険者コードの正当性チェック(8桁かどうか)
    • 診療年月の妥当性チェック(年月の形式に合致するか)
    • 削除済みや未完了のファイルの除外
    • 以下、延々とCTEが続く構成になる
  • 判断の理由:
    • レセプトデータは仕様通りでないことが前提
    • 後工程でエラーになるより、早い段階で不正データを除外した方が効率的
  • staging → intermediate → martの責務分離:
    • きれいなデータならこの責務分離が理想
    • 汚いデータではstagingでできるだけ整える方が現実的

■ 4. モデルの粒度設計

  • 正攻法の限界:
    • データ識別ごとにstagingモデルを作るのが理想的な正攻法
    • 現実にはファイル種別と識別単位の組み合わせが100近くになった
    • 100パターンのモデルを作るのは現実的でない
    • 品質・コストの両面で1レコード種別1モデルは現実的でなかった
  • 摘要情報の統合:
    • 対象はSI(診療行為)、IY(医薬品)、TO(特定機材)、CO(コメント)
    • これらは構造が類似しており、相互に依存している
    • 構造が似て連携が強い識別単位を1つのstagingモデルにまとめて処理した
  • ベストプラクティスを破る判断:
    • 設計の複雑さと実行効率のバランスを取るため、あえて定石を破った

■ 5. インクリメンタル処理の問題

  • 通常のインクリメンタル処理:
    • updated_atが既存の最大値より新しいレコードだけを取り込む
  • レセプト特有の事情:
    • 毎月、過去の月のデータが再送・修正されることがある
    • 3月分が6月に再送されるようなケースに、最終更新日時ベースでは対応できない
  • 修正後の構成:
    • invoice_year_monthとreceipt_idをキーにする
    • incremental_strategyにinsert_overwriteを採用する
    • 対象月以降のデータを全件削除・再生成する
  • トレードオフ:
    • 実行時間は少し増える
    • その代わりデータの整合性を確保できる

■ 6. 中間テーブルのmaterialized戦略

  • tableの採用:
    • 中間テーブルはviewやephemeralにするのが理想
    • レセプトデータではそれだとパフォーマンスが悪くなるため、tableにした
  • コストの抑制:
    • 再利用する中間ロジックをmaterialized='table'にした
    • BigQueryの分散処理に乗せることでコストを抑えた
  • Pythonを使わなかった理由:
    • Pythonで複雑な処理を書く選択肢もある
    • 実行環境のリソース管理を考えると非現実的
    • 保険者によるデータサイズのばらつきも考慮すると非現実的

■ 7. 難しい領域でこそ活きる設計思想

  • ドメイン理解の重要性:
    • レセプトのパイプライン構築を通じて、ドメインの特性を理解する大切さを改めて感じた
    • ベストプラクティスは強力だが、複雑なドメインでは形式的に守るのが難しいこともある
  • モデルの責務を明確に分ける:
    • stagingの役割が重くなっても、データ検証という明確な責務を持たせる
    • 無理に軽量化するより、各段階で何をするかを明確にする方が重要
  • 定義を統一し再利用可能にする:
    • マクロやテスト定義で、医療制度特有のバリデーションロジックを標準化する
    • 保険者コード8桁チェックや診療年月の妥当性確認などを共通化する
  • 依存関係の可視化:
    • 複雑な識別単位でも、lineageグラフで上流・下流の関係が一目瞭然になる
    • データ修正時の影響範囲が事前に分かるため、安全にメンテナンスできる
  • テストによる品質担保:
    • 仕様通りでない前提だからこそ、自動テストでデータ品質をチェックする
    • severity: warnにより処理を止めずに問題を可視化できる
  • オレオレ設計への誘惑と結論:
    • このような状況ではベストプラクティスに従わずオレオレ設計で切り抜けたくなる
    • 重要なのは、なぜそのプラクティスが存在するのかを理解すること
    • その上で自分たちのドメインに適応させることが大切