🤖

BigQuery と Python Notebook で支える Datathon Japan 2024

2024/12/06に公開

2024年11月29日から12月1日にかけて、東京科学大学で4回目のデータソンジャパンがおこなれました。メディキューはデータベース、データ切り出し/分析基盤の提供を行いました。この背景については木下により下の Note を御覧ください。

https://note.com/mph_for_doctors/n/nac68c567a692?sub_rt=share_pb

当日様々なトラブルも有りましたが100人が参加するイベントのインフラ運営ができたことは一つの成果かなと思うので、この背景をまとめようと思います。

前提

データソンには医療者とデータサイエンティストの両者が集まる

もちろんそれ以外の背景を持つ参加者もいますが、多くの参加者は医療者かデータサイエンティストです。100人の参加者を10チームに分けて、テーマに合わせてモデル設計/データ切り出し/学習/評価を3日間で一気に行います。様々な背景の参加者に対応できる柔軟な基盤が必要です。

シャーディングしやすいデータである

メディキューが保管/管理するデータは匿名加工情報です。臨床研究上重要でないデータができる限り削ぎ落とされているので、任意の2つの患者のリンクする情報はありません。つまりデータのクリーニングは極論患者ごとに並列に処理してから結合するようなシャーディングができるということです。この性質を活かすと結合する症例を(例えば乱数で降ったIDがn倍数など)に絞ると、ただデータサイズが小さいだけの同じ性質を持ったデータセットを作ることができます。

設計 BigQuery + Python Notebook

様々な背景の参加者が3日以内にデータの切り出しと機械学習を行う環境を準備するため、基本的には「すべてクラウドで完了できる」という環境を作っておくことが不可欠でした。また、3日間という日程の中で複数のシステムに慣れてもらうことは体験が良くないと考えたので1つのシステムで完結できることも重要だと考えました。

この結果データは BigQuery、機械学習は Python Notebook をメインとすることになりました[1]

Terraform で project 作成自動化

何らかの問題が生じてもチーム内に閉じるようにする、権限設定を簡単にしたい、というニーズがあったため、チームごとに一つの Google Cloud project を発行することにしました。各チームのための project を手作業で作るのは大変かつミスのもとなので自動で全チームの project を正しい設定で作成できるようにすることが必要です。
そこで下のような interface の terraform module を作りました。

variable "org_id" {
  type = string
}
variable "billing_account" {
  type    = string
  default = null
}
variable "team_name" {
  type = string
}

# 運営として技術サポートする用のアカウント一覧
variable "owners" {
  type = list(string)
}

# チームメンバーのアカウント一覧
variable "team_members" {
  type = list(string)
}

resource "google_project" "project" {
  name            = "datathon-japan-2024-${var.team_name}"
  project_id      = "datathon-japan-2024-${var.team_name}"
  billing_account = var.billing_account
  org_id          = var.org_id
}

この module を下のように呼び出すことでチームを発行しました。

module "datathon-japan-2024-team01" {
  source          = "./datathon-japan-2024"
  team_name       = "team01"
  org_id          = var.org_id
  billing_account = data.google_billing_account.main.billing_account
  owners = [
    "user:owner@example.com",
  ]
  team_members = [
    "user:user1@example.com",
    "user:user2@example.com",
  ]
}

これが10チーム分できたら下のようにまとめて好きな権限を付与できるようにします。

locals {
  datathon_related_data_readers = concat(
    module.datathon-japan-2024-team00.team_members,
    module.datathon-japan-2024-team01.team_members,
    module.datathon-japan-2024-team02.team_members,
    module.datathon-japan-2024-team03.team_members,
    module.datathon-japan-2024-team04.team_members,
    module.datathon-japan-2024-team05.team_members,
    module.datathon-japan-2024-team06.team_members,
    module.datathon-japan-2024-team07.team_members,
    module.datathon-japan-2024-team08.team_members,
    module.datathon-japan-2024-team09.team_members,
    module.datathon-japan-2024-team10.team_members,
    module.datathon-japan-2024-team99.team_members,
  )
}

このアカウント一覧に後述する「データ管理プロジェクト」の露出するべきテーブルの閲覧権限を付与することで閲覧権限を確保できます。

データは共通でview で閲覧

今回の設計の概要は下のようになります。チームごとに自分たちで選んだタイミングでデータサイズを増やせるようにいくつかのデータセットを用意していました。参加者に「データセットの切り替え」[2] の方法を正しく実行してもらうのは不可能だと考えたので運営側で view を張り替えるという方法で対応しました。

この方法にしておけば「中間テーブルは view のみで行う」という制約だけ守れば view のポインタを張り替えるだけで参加者は一切何も変更することなく気にすることなくデータセットを変更できます。これによってデータセット変更依頼を受けて最短1分くらいで変更ができました。このときの処理費用がゼロなので気軽に発行できることも良いポイントです。

また、データに不整合が見つかったときに大元を直せば provisioning し直さなくても全部治るという良さもあります。実際にいくつかフィードバックをうけて開催期間中にデータを修正しました。

source と model を生成

view を作れば良いとはいえ実際当日に用意したテーブルは1チームあたり60枚程度あるのでこれらを自動で反映する方法が必要です。メディキューで管理するテーブルはすべて dbt なのでメタデータはすべてコードで取得できます。

python スクリプトを用意しておき、dbt の source (データ管理プロジェクトを指定) と model (select * で恒等的に露出するだけ) を自動生成してそれを流し込めば完成です。あとは下のようなコマンドで view が展開できます。

$ rye run dbt --target team01 run

せっかくなので model を source に変換するコードもおいておきます。社内用の dbt project の必要なモデルだけを source として参照できるようにしました。

# path: 露出させたいテーブル一覧を格納した directory
# schema: テーブル一覧を格納する dataset 名
def sourcify(path, schema):
    files = os.listdir(path)
    files = [f for f in files if f.endswith(".yaml") or f.endswith(".yml")]

    tables = []
    for file in files:
        filepath = os.path.join(path, file)
        with open(filepath) as f:
            models = yaml.safe_load(f)
        for key in ["models", "seeds"]:
            if key not in models:
                continue

            for model in models[key]:
                name = model["name"].removeprefix(f"{schema}_")
                columns = model["columns"]

                source_columns = []
                for c in columns:
                    column = {
                        "name": c["name"],
                        "type": c.get("type", c.get("data_type")),
                    }
                    if column["type"] is None:
                        raise ValueError(
                            f"Missing type for column {column['name']} in {name}"
                        )

                    source_columns.append(column)
                    if "description" in c:
                        column["description"] = c["description"]
                tables.append({"name": name, "columns": source_columns})

    source = {
        "name": f"{schema}_latest",
        "schema": f"{schema}_latest",
        "database": "TODO", # ここを switch させる
        "tables": tables,
    }

    header = (
        f"# DO NOT EDIT: This file is generated by {sys.argv[0]}\n\n# prettier-ignore\n"
    )
    body = yaml.dump(
        {"sources": [source]},
        allow_unicode=True,
        sort_keys=False,
        default_flow_style=False,
    )

    with open(f"models/__generated__/{schema}.yml", "w") as stream:
        stream.write(header)
        stream.write(body)

これを下のように呼び出せば source が完成します。

sourcify("../another-project/models/one-icu", "one_icu")

最後に下のように source から select * するだけの view を生成すれば完成です。

for root, dirs, files in os.walk("models"):
    for file in files:
        if not file.endswith(".yml"):
            continue

        filepath = os.path.join(root, file)
        with open(filepath) as stream:
            sources = yaml.safe_load(stream)
            for source in sources["sources"]:
                for table in source["tables"]:
                    sql = f"""
                        {{{{
                            config(
                                materialized='view',
                                schema='{source["schema"].removeprefix("one_icu_")}',
                                alias='{table["name"]}'
                            )
                        }}}}
                        select * from {{{{ source('{source["name"]}', '{table["name"]}') }}}}
                    """
                    with open(
                        f"{root}/{source["name"]}_{table['name']}.sql",
                        "w",
                    ) as f:
                        f.write(sql)

ツラミポイント

最後にこの仕組みで難しかった反省点を残しておきます。

Notebook の provisioning は手作業しかない

探した限り BigQuery の Notebook は機械的なアップロードや編集ができませんでした。 terraform や bq コマンドにも操作する方法がなかったためおそらくできないのだと思います。
この結果サンプルの Notebook を用意していましたがこれは全 project に手作業で配ることになり少し大変でした。

Notebook の Runtime が同時に5つまでしか起動できない

default では Notebook の runtime の disk 容量が500GBに制限されています。default の設定では一つの runtime で 100GB消費するため同時に5つしか起動できないということになります。上限を上げる申請はできますが、即座に反映されるわけではないため複数人で解析していくときには少し工夫が必要でしょう。

まとめ

非常に簡単でしたができる限り手作業を排除して基盤を構築したことが読み取れると思います。メディキューでは手作業でやってもなんとかなる作業をはじめからコードを用いて自動化することでスケールできるようにしています。実際今回も社内の検証環境の provisioning がすべて terraform 化されていたことでコピペベースでデータソン用の基盤が作れました。今後もデータサイズに対して線形以下の労力でデータをマネジメントしていける基盤を作っていこうと思います。一緒に作ってみたい方は是非一度お声がけください。

脚注
  1. https://zenn.dev/medicu/articles/4d8234762c0696 でも示している通り BigQuery がそもそも弊社のベースにあるという点ももちろん大きいです。 ↩︎

  2. dbt を使って SQL をテンプレート化したり、様々に書いた view の from を書き換えて回ったりと言うのはイベントの中では現実的ではないと判断しました ↩︎

MeDiCU

Discussion