Timee Product Team Blog

タイミー開発者ブログ

dbtCloudから作成したPullRequestにコンパイル済みSQLをコメントする仕組みを作成した話

こんにちは☀️
タイミーでアナリストとアナリティクスエンジニアしてますokodoonです

今回の記事はdbt CloudでPull Requestを作るときに、レビュー負荷が高くなってしまっていた問題を解消できるように、コンパイル済みのSQLをPR上にコメントするような仕組みを作成したことについての紹介です。

もし同じような課題感を抱えている方がいらっしゃれば、参考にしていただければ幸いです

課題感

弊社のデータ基盤ではDWH層DataMart層は「分析用に加工されたデータを扱う層」として定義しています。
各種ドメインに依存した集計や変換のロジックが含まれるため、この層のモデリングに関しては基盤開発側のレビューのみでなくアナリスト観点でのレビューも必須となります。

ですが、分析ドメイン観点でのレビューが必要な場合に、純粋なSQLではないdbtモデルがアナリストレビューの障壁になることが多いです。
またアナリスト以外であっても「このmacroがどのようにコンパイルされるか」を把握するのは少し面倒だったりします

今回選択した解決策

そこでdbtモデルをコンパイルしたSQLファイルをPullRequest上にコメントするような仕組みを考えました。

この実装によって先ほど挙げた課題感が以下図のように解決されることを期待して開発しました

背景/前提

1. 開発環境について

弊社ではdbtを活用したデータ基盤の開発を行っており、

dbtモデル開発をdbt Cloud上の統合環境にて実施するような流れになっています。

2. CIジョブについて

CI用のdbt CloudのJobはRUN ON PULLREQUEST で実行されて、以下のようなコマンドが実行されています。

dbt build --select state:modified+

mainブランチとの差分があるdbtモデルがBigQuery上にビルドされている状態です(本来CIで走るdbtコマンドをレイヤーごとに分割していますが、ここでは簡略化しています)

3. CI環境のスキーマ名とcustom_schemas.yml戦略

dbt Cloudで設定できるCI Jobではtarget schema名の命名dbt_cloud_pr_<job_id>_<pr_id>となっており、連携レポジトリのPR番号に対して動的に作成されます。

(DBT_JOB_SCHEMA_OVERRIDEでこの命名規則の上書きもできますが、PR単位でCIテーブルが作成されて欲しいので、デフォルトの命名規則にしたがっています。)

参考:https://docs.getdbt.com/docs/deploy/continuous-integration#how-ci-works

また、弊社では開発環境とCI環境のスキーマ名が {{ターゲットスキーマ名}}_{{カスタムスキーマ名}}になるように custom_schemas.sql で定義してあります。

そのため、CI環境のテーブルは

dbt_cloud_pr_<job_id>_<pr_id>_{{カスタムスキーマ名}}

命名規則で作成されたデータセット名以下に作成されている状態です。

実装概要

作成したYAMLはこちら(クリックで展開)

name: DBT Compile and Comment on PR
    
on:
  pull_request:
    types: [opened, synchronize, reopened]
    
jobs:
  dbt_compile:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
      id-token: write
    
    env:
      DBT_ENVIRONMENT: dev
    steps:
      - name: Check out repository code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
          ref: ${{ github.event.pull_request.head.ref }}
    
      - name: Fetch base ref
        run: git fetch origin ${{ github.event.pull_request.base.ref }}
    
      - name: Set Up Auth
        uses: "google-github-actions/auth@v1"
        with:
          token_format: "access_token"
          workload_identity_provider: "hogehogehoge"
          service_account: "hogehogehoge@hogehoge.iam.gserviceaccount.com"
    
      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v1
    
      - name: Set up Python
        uses: actions/setup-python@v3
        with:
          python-version: 3.11
    
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install dbt-core dbt-bigquery
    
      - name: Generate profiles.yml
        run: |
          chmod +x ci/compile_sql_comment/generate_profile.sh
          ci/compile_sql_comment/generate_profile.sh ${{ github.event.pull_request.number }}
    
      - name: Compile DBT
        id: compile
        run: |
          dbt deps
          dbt compile --profiles-dir . --target dev --profile timee-dwh
          compiled_sqls=""
          files=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} ${{ github.event.pull_request.head.ref }} | grep '\.sql$' || true)
          if [ -n "$files" ]; then
            for file in $files; do
              compiled_file_path=$(find target/compiled -name $(basename $file))
              echo "Compiled file path: $compiled_file_path"
              if [ -n "$compiled_file_path" ]; then
                compiled_sql=$(cat "$compiled_file_path")
                compiled_sqls="${compiled_sqls}<details><summary>${file}</summary>\n\n\`\`\`\n${compiled_sql}\n\`\`\`\n\n</details>"
              fi
            done
          fi
          printf "%b" "$compiled_sqls" > compiled_sqls.txt
    
      - name: Comment on PR
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs');
            const output = fs.readFileSync('compiled_sqls.txt', 'utf8');
            const issue_number = context.payload.pull_request.number;
            const owner = context.repo.owner;
            const repo = context.repo.repo;
    
            async function processComments() {
              const comments = await github.rest.issues.listComments({
                owner: owner,
                repo: repo,
                issue_number: issue_number,
              });
    
              const dbtComment = comments.data.find(comment => comment.body.startsWith('DBT Compile Result'));
              const body = `DBT Compile Result:\n${output}`;
    
              if (dbtComment) {
                await github.rest.issues.updateComment({
                  owner: owner,
                  repo: repo,
                  comment_id: dbtComment.id,
                  body: body
                });
              } else {
                await github.rest.issues.createComment({
                  owner: owner,
                  repo: repo,
                  issue_number: issue_number,
                  body: body
                });
              }
            }

            processComments();

actionsの流れを説明するとこうなります。

  • PRブランチにチェックアウトして、PRブランチとmainの差分を確認するためにfetch
  • workload_identity_providerを用いたBigQueryへの認証
  • 必要パッケージのインストールと使用するパッケージの宣言
  • PRの情報をもとにprofiles.ymlの動的生成
  • コンパイル処理の実施
    • dbt compileの実行
    • mainとの差分があるファイルだけを抽出
    • 差分ファイルのcompile結果を文字列化
  • PR上にcompile結果がなければdbt compile結果を新規コメント。既にcompile結果がコメントされていたらコメントをupdate

各ステップの説明

説明が必要そうなステップの説明をしていきます

PRの情報をもとにprofiles.ymlの動的生成

上で述べた通り、CI環境のテーブルはdbt_cloud_pr_<job_id>_<pr_id>_{{カスタムスキーマ名}}命名規則で作成されたデータセットに配置されています。

dbt compileの出力結果のデータセット名がdbt_cloud_pr_<job_id>_<pr_id>_{{カスタムスキーマ名}}になるように、デフォルトスキーマ名を動的に宣言するためのprofiles.ymlを作成するシャルスクリプトを作成しております

#!/bin/bash
set -e

cat << EOF > profiles.yml
timee-ci-dwh:
  target: ci
  outputs:
    dev:
      type: bigquery
      method: oauth
      project: ci_env_project
      schema: dbt_cloud_pr_39703_$1
      execution_project: ci_env_project
      threads: 1
EOF

参考: https://docs.getdbt.com/docs/core/connect-data-platform/bigquery-setup#oauth-via-gcloud

これによりdbt compileの出力結果が、このターゲットスキーマ名を参照したスキーマになります。

コンパイル処理の実施

run: |
          dbt deps
          dbt compile --profiles-dir . --target ci --profile timee-ci-dwh
          compiled_sqls=""
          files=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} ${{ github.event.pull_request.head.ref }} | grep '\.sql$' || true)
          if [ -n "$files" ]; then
            for file in $files; do
              compiled_file_path=$(find target/compiled -name $(basename $file))
              echo "Compiled file path: $compiled_file_path"
              if [ -n "$compiled_file_path" ]; then
                compiled_sql=$(cat "$compiled_file_path")
                compiled_sqls="${compiled_sqls}<details><summary>${file}</summary>\n\n\`\`\`\n${compiled_sql}\n\`\`\`\n\n</details>"
              fi
            done
          fi
          printf "%b" "$compiled_sqls" > compiled_sqls.txt
  • dbt compileをPRブランチで実行
  • git diffで差分があった.sqlファイル名をdbt compileの実行結果であるcompile済みsqlファイルが格納されるtarget/compiled/ 以下でfindを実行してfileパスを取得
  • fileパスのcat結果を折りたたみタグ内に格納して文字列に追加

って流れの処理にしています。

このような処理にすることでSQLコンパイル結果のPRコメントを必要分だけコメントできる形としました。また、折りたたみタグに格納することでPullRequestの可視性を損なわないようにしました。

PR上にコメント

- name: Comment on PR
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs');
            const output = fs.readFileSync('compiled_sqls.txt', 'utf8');
            const issue_number = context.payload.pull_request.number;
            const owner = context.repo.owner;
            const repo = context.repo.repo;

            async function processComments() {
              const comments = await github.rest.issues.listComments({
                owner: owner,
                repo: repo,
                issue_number: issue_number,
              });

              const dbtComment = comments.data.find(comment => comment.body.startsWith('DBT Compile Result'));
              const body = `DBT Compile Result:\n${output}`;

              if (dbtComment) {
                await github.rest.issues.updateComment({
                  owner: owner,
                  repo: repo,
                  comment_id: dbtComment.id,
                  body: body
                });
              } else {
                await github.rest.issues.createComment({
                  owner: owner,
                  repo: repo,
                  issue_number: issue_number,
                  body: body
                });
              }
            }

PR上のコメントの一覧を取得して、既に DBT Compile Result:で始まるコメントがPR上に存在するのであれば、そのコメントのアップデート、存在しないのであれば新しくコメントをする。

という処理をしています。

条件分岐が発生する処理を簡便に記載したかったのでinlineでjavascriptを記載できるgithub-scriptを使用して記述しています

これによってactionが走るたびにコメントが新規でされるのではなく、一つのコメントが上書きされ続けるような処理となり、PullRequestの可視性を損なわないようにしました。

どんなふうに動くかみてみる

こんなモデルをテストで作ってみました

{{ config(
    schema = 'sample_schema'
)}}

SELECT
    'hogehoge' AS name
    , 30 AS amount
UNION ALL
SELECT
    'fugafuga' AS name
    , 50 AS amount
{{ config(
    schema = 'sample_schema'
)}}

SELECT
    SUM(amount) AS sum_amount
FROM {{ ref('sample_model1') }} AS sample_model1

以下のようにPullRequest上に変更内容が折り畳まれた状態でコメントされて、ref関数のコンパイル結果がCI環境のデータセットになっていることを確認できます

次にsample_model1.sqlを以下のように修正して再度pushしてみます

{{ config(
    schema = 'sample_schema'
)}}

SELECT
    'hogehoge' AS name
    , 30 AS amount
UNION ALL
SELECT
    'fugafuga' AS name
    , 50 AS amount
UNION ALL
SELECT
    'blabla' AS name
    , 100 AS amount

以下のように既存のcommentがeditされて、修正後の内容でコンパイルした結果で上書きされていることが確認できます

結果

弊社のデータ基盤はfour keys計測による開発ヘルススコアの計測を行っており、今回の仕組みのリリース前後で開発リードタイムを計測してみましたが、目立った影響は出ていませんでした😢

レビューを円滑にできる環境を整っていないことが課題ではなく、他業務との兼ね合いでDWH開発のレビューに充てることができる時間がそもそも少なそうだったり、レビューに必要なドメインのインプットが足りていないことがボトルネックになっていそうだなという発見にも繋がったので、そこはプラスに捉えています

メンバーの声を聞いていると便利なことは間違いないらしいので、レビューコストの低減による持続的なトイル削減に将来的には繋がっていけばいいなと思ってます🙏

使えそうだな。試してみようかなと思っていただけたら幸いです!

We’re Hiring!

タイミーのデータ統括部では、ともに働くメンバーを募集しています!!

このたび、アナリティクスエンジニアのポジションが公開されたので、是非ともご応募お願いします
その他ポジションも絶賛募集中なのでこちらからご覧ください

H3を使用したLookerでの可視化

こんにちは、タイミーのデータ統括部データサイエンス(以下DS)グループ所属の菊地です。

今回は、タイミーがBIツールとして導入しているLookerでの、H3を使用した可視化をするための取り組みを紹介したいと思います!

H3とは

H3とは、Uber社が開発しているグリッドシステムで、オープンソースとして提供されています。

H3では、位置情報に紐づいたイベントを階層的な六角形の領域にバケット化することができ、バケット化された単位でデータの集計が可能になります。

タイミーでは、サービスを提供する各都市の需給を測定するために,六角形単位で集計したデータを可視化するなど、様々な場面での分析に活用しており、例えば以下のような可視化を行なっております(数値はランダム値です)。

H3についての詳細は、以下のページが参考になるかと思います。

前提条件

LookerのデータソースはGoogle Cloud BigQueryとします。

可視化に必要な各種ファイルの生成にはPythonを用いており、使用しているPythonバージョンと、依存ライブラリのバージョンについては、以下で動作確認を行っています。

  • Pythonバージョン: 3.11.4
  • 依存ライブラリ
geojson==3.0.1
h3==3.7.6
numpy==1.25.2
pandas==2.0.3
shapely==2.0.1
topojson==1.5

また、大変簡略化した例ですが、データソースのBigQueryプロジェクト・データセット・テーブルは、以下の想定とします。

  • プロジェクト名: sample-project
  • データセット名: sample-dataset
  • テーブル
    • sales: 売上データを保持しているテーブル
      • sales_at: 売上の日時
      • amount: 売上
      • place_id: placesテーブルの外部キー
    • places: 位置情報(緯度・経度)を保持しているテーブル
    • placesテーブルとsalesテーブルには1:nのリレーションが存在
erDiagram

places ||--|{ sales: "1:n"

places {
  INTEGER id
  FLOAT latitude
  FLOAT longitude
}

sales {
  INTEGER id
  DATETIME sales_at
  INTEGER amount
    INTEGER place_id
}

Lookerでの可視化を行うための手順

今回は以下の手順に従って、上記saleテーブルの売上をH3六角形にバケット化し、Looker上で可視化します。

  1. 緯度経度情報を保持しているBigQueryテーブルにH3六角形IDを付与し、別テーブルとして保存
  2. TopoJsonファイルの作成
  3. 作成したTopoJsonファイルをLookerに追加
  4. Lookerのmodelファイルにmap_viewフィールドを追加
  5. Lookerのviewファイルにdimensionを追加
  6. Lookerのmodelファイルにexploreを追加
  7. Lookerでの可視化

1. 緯度経度情報を保持しているBigQueryテーブルにH3六角形IDを付与し、別テーブルとして保存

集計の際に使用する「緯度経度情報を保持しているBigQueryテーブル」(ここではplacesテーブル)に対して、H3六角形ID(以下H3 hex idと記載)を付与し、別テーブルとして保存しておきます。ここではh3_placesテーブルとして保存しています。

下記は、placesテーブルをpandas.DataFrameとして読み込み、H3六角形解像度0~15までのH3 hex idを付与し、テーブルとして書き出すコードの例です。

H3六角形解像度は値が大きくなるにつれて、小さな六角形(=解像度が上げる)になり、詳細については下記ドキュメントが参考になるかと思います。

import h3
import pandas as pd


class BigQueryClient:

    def __init__(self):
        ...

    def read_table_as_dataframe(self, table_id: str) -> pd.DataFrame:
        """BigQueryテーブルをpandas.DataFrameとして読み込む処理"""
        ...

    def write_table_from_dataframe(self, df: pd.DataFrame, table_id: str) -> None:
        """pandas.DataFrameをBigQueryテーブルを書き込む処理"""
        ...


def make_h3_hex_ids(df: pd.DataFrame) -> pd.DataFrame:
    _df = df.copy()
    for resolution in range(16):
        # 緯度・経度情報を元に、H3 hex idを付与
        _df[f'h3_hex_id_res_{resolution}'] = df.apply(lambda x: h3.geo_to_h3(x['latitude'], x['longitude'], resolution),
                                                      axis=1)
    return _df


if __name__ == '__main__':
    ...

    # 緯度(latitude)、経度(longitude)を保持しているBigQueryテーブルをDataFrameとして読み込む
    df = BigQueryClient.read_table_as_dataframe('sample-project.sample-dataset.places')

    # H3 hex idを付与する
    h3_df = make_h3_hex_ids(df)
    h3_df.rename(columns=dict(id='place_id'), inplace=True)

    # H3 hex idを付与したDataframeをBigQueryテーブルとして書き込み
    BigQueryClient.write_table_from_dataframe(df=h3_df, 'sample-project.sample-dataset.h3_places')

例として、以下のような緯度経度を保持しているサンプルデータに、H3 hex idを付与した場合、以下のような結果になります。

import numpy as np


np.random.seed(42)

tokyo_latitude = 35.6762
tokyo_longitude = 139.6503

df = pd.DataFrame(
    [[i, np.random.normal(tokyo_latitude, 0.3), np.random.normal(tokyo_longitude, 0.3)] for i in range(1, 11)], 
    columns=['place_id', 'latitude', 'longitude']
)

h3_df = make_h3_hex_ids(df)
h3_df.head(10)

2. TopoJsonファイルの作成

「1. 緯度経度情報を保持しているBigQueryテーブルにH3 hex idを付与する」でH3 hex idを付与したDataFrameを元に、TopoJsonファイルを作成します。

TopoJsonの詳細についてはこちらの「topojsonGitHubリポジトリを参照してください。

下記は、TopoJsonファイルを作成するコード例です。

処理の内容としては、GeoJson形式を経由して、TopoJsonに変換し、ファイルとして出力をしています。

TopoJsonファイルは、H3解像度別に作成しています。

from pathlib import Path

import geojson
import h3
import pandas as pd
from shapely import geometry
import topojson


class H3ToGeojson:

    @staticmethod
    def get_h3_geojson_features(h3_hex_ids: list[str]) -> list[geojson.Feature]:
        polygons = h3.h3_set_to_multi_polygon(h3_hex_ids, geo_json=True)
        features = [geojson.Feature(geometry=geometry.Polygon(polygon[0]), properties=dict(h3_hex_id=h3_hex_id)) for
                    polygon, h3_hex_id in zip(polygons, h3_hex_ids)]
        return features

    def get_h3_geojson_feature_collection_from_dataframe(self, df: pd.DataFrame,
                                                         h3_hex_id_column: str) -> geojson.FeatureCollection:
        assert df.columns.isin([h3_hex_id_column]).any(), f'column `{h3_hex_id_column}` is not exists.'

        unique_h3_hex_ids = df[h3_hex_id_column].unique().tolist()
        geojson_features = self.get_h3_geojson_features(unique_h3_hex_ids)
        feature_collection = geojson.FeatureCollection(geojson_features)
        return feature_collection


class H3ToTopojson:

    def __init__(self):
        self.h3_to_geojson = H3ToGeojson()

    def get_h3_topojson_topology_from_dataframe(self, df: pd.DataFrame, h3_hex_id_column: str) -> topojson.Topology:
        feature_collection = self.h3_to_geojson.get_h3_geojson_feature_collection_from_dataframe(
            df,
            h3_hex_id_column=h3_hex_id_column
        )
        return topojson.Topology(feature_collection, prequantize=False)

    def make_h3_topojson_file_from_dataframe(self, df: pd.DataFrame, h3_hex_id_column: str,
                                             save_file_path: Path) -> None:
        topojson_topology = self.get_h3_topojson_topology_from_dataframe(df=df, h3_hex_id_column=h3_hex_id_column)
        topojson_topology.to_json(save_file_path)


if __name__ == '__main__':
    ...

    h3_to_topojson = H3ToTopojson()
    save_dir = Path('topojson')
    save_dir.mkdir(exist_ok=True)

    for resolution in range(0, 16):
        h3_hex_id_column = f'h3_hex_id_res_{resolution}'
        h3_to_topojson.make_h3_topojson_file_from_dataframe(df=h3_df, h3_hex_id_column=h3_hex_id_column,
                                                            save_file_path=save_dir / f'{h3_hex_id_column}.json')

例として、先ほど作成したサンプルデータに対して、resolution=4を指定してTopoJsonファイルとして書き出す処理は以下のようになります。

h3_to_topojson = H3ToTopojson()
h3_to_topojson.make_h3_topojson_file_from_dataframe(h3_df, resolution=4)

TopoJsonファイルの中身は以下のようになります。

{"type":"Topology","objects":{"data":{"geometries":[{"properties":{"h3_hex_id":"842f5a3ffffffff"},"type":"Polygon","arcs":[[-5,-2,0]],"id":"feature_0"},{"properties":{"h3_hex_id":"842f5bdffffffff"},"type":"Polygon","arcs":[[1,-4,2]],"id":"feature_1"},{"properties":{"h3_hex_id":"842f5abffffffff"},"type":"Polygon","arcs":[[3,4,5]],"id":"feature_2"}],"type":"GeometryCollection"}},"bbox":[139.198358,35.267135,140.126313,36.103519],"arcs":[[[139.44526,35.765969],[139.458427,36.000295],[139.695196,36.103519],[139.918545,35.971536],[139.903854,35.7366]],[[139.44526,35.765969],[139.667342,35.634256]],[[139.653549,35.399825],[139.419179,35.297723],[139.198358,35.429167],[139.21065,35.662982],[139.44526,35.765969]],[[139.653549,35.399825],[139.667342,35.634256]],[[139.667342,35.634256],[139.903854,35.7366]],[[139.903854,35.7366],[140.126313,35.603627],[140.111006,35.368594],[139.874758,35.267135],[139.653549,35.399825]]]}

3. 作成したTopoJsonファイルをLookerに追加

LookerのFileBrowserを開いて、先ほど作成したTopoJsonファイルを追加します。

追加後、適切なフォルダにファイルを移動します。ここではmaps/h3フォルダにTopoJsonファイルを移動します。

├── maps
    └── h3
        ├── h3_hex_id_res_0.topojson
        ├── h3_hex_id_res_1.topojson
        ├── h3_hex_id_res_2.topojson
                ...
        └── h3_hex_id_res_15.topojson

4. Lookerのmodelファイルにmap_viewフィールドを追加

下記のようにmap_layerを設定します。map_layerはH3解像度別に設定しています。

property_keyは「2. TopoJsonファイルの作成」で使用しているH3ToGeojson.get_h3_geojson_featuresメソッド内のgeojson.Featureの引数で設定しているpropertiesのkey名であるh3_hex_idを指定しています。

map_layer: h3_hex_id_res_0 {
  file: "/maps/h3/h3_hex_id_res_0.topojson"
  format: topojson
  property_key: "h3_hex_id"
}

map_layer: h3_hex_id_res_1 {
  file: "/maps/h3/h3_hex_id_res_1.topojson"
  format: topojson
  property_key: "h3_hex_id"
}

map_layer: h3_hex_id_res_2 {
  file: "/maps/h3/h3_hex_id_res_2.topojson"
  format: topojson
  property_key: "h3_hex_id"
}

...

map_layer: h3_hex_id_res_15 {
  file: "/maps/h3/h3_hex_id_res_15.topojson"
  format: topojson
  property_key: "h3_hex_id"
}

5. Lookerのviewファイルにdimensionを追加

map_layer_nameは、「4. Lookerのmodelファイルにmap_viewフィールドを追記」で作成した、map_layer名を指定します。

dimensionはH3解像度別に設定しています。

view: h3_places {
  sql_table_name: `sample-project.sample-dataset.h3_places`
    ;;

  dimension: h3_hex_id_res_0 {
    group_label: "H3"
    group_item_label: "H3解像度0の六角形ID"
    type: string
    sql: ${TABLE}.h3_hex_id_res_0 ;;
    map_layer_name: h3_hex_id_res_0
  }

  dimension: h3_hex_id_res_1 {
    group_label: "H3"
    group_item_label: "H3解像度1の六角形ID"
    type: string
    sql: ${TABLE}.h3_hex_id_res_1 ;;
    map_layer_name: h3_hex_id_res_1
  }

  dimension: h3_hex_id_res_2 {
    group_label: "H3"
    group_item_label: "H3解像度2の六角形ID"
    type: string
    sql: ${TABLE}.h3_hex_id_res_2 ;;
    map_layer_name: h3_hex_id_res_2
  }

  ...

  dimension: h3_hex_id_res_15 {
    group_label: "H3"
    group_item_label: "H3解像度15の六角形ID"
    type: string
    sql: ${TABLE}.h3_hex_id_res_15 ;;
    map_layer_name: h3_hex_id_res_15
  }

}

6 Lookerのmodelファイルにexploreを追加

下記のようにexploreを追加します。

explore: sales {
  label: "sales"
  ...

  join: h3_places {
    view_label: "place"
    type: inner
    sql_on: ${sales.place_id} = ${h3_places.place_id} ;;
    relationship: many_to_one
  }
  
}

7. Lookerでの可視化

作成したexploreでマップでの可視化を行うと、地図上にH3六角形メッシュが表示され、メッシュ毎にバケット化された集計値を色で表現することができます。

下記は東京近郊のデータをH3解像度7のdimensionを使用して可視化した例です(数値はランダム値です)。

今回作成したH3解像度dimensionを変更することで、目的に合わせて六角形メッシュの大きさを変更して可視化を行うことが可能です。

まとめ

今回は、Uber社がオープンソースとして提供しているH3を使用して、Looker上で可視化を行う方法について解説しました。

タイミーでは今回紹介したLookerでの可視化以外にも、機械学習の特徴量作成時に使用するなど、様々な場面でH3を活用しています。

今後も地理情報を活かした分析をする際に活用していきたいと考えています。

We’re Hiring!

タイミーのデータ統括部では、ともに働くメンバーを募集しています!!

現在募集中のポジションはこちらです!

「話を聞きたい」と思われた方は、是非一度カジュアル面談でお話ししましょう!

PoEAAの輪読会を実施しました

はじめに

マッチング領域、ワーキングリレーションチームの@Juju_62qです。 タイミーでは有志のメンバーが集まって1年半ほど前から輪読会を行っています。 現在5冊の書籍を読み終わっています。

ブログのタイトルにもありますが、今回エンタープライズ アプリケーションアーキテクチャパターン(略称PoEAA)の輪読を実施しました。 書籍選定には以下のような狙いを置いていました。

  • 複雑なドメインをソフトウェアで扱うための術を身につける
  • アクティブレコードパターンにより詳しくなり、Railsを上手く扱えるようになる
  • 書籍に出てくるデザインパターンをきっかけとしてタイミーのアーキテクチャについて議論し、実験のきっかけを作る。
続きを読む

droidcon San Francisco 2023レポート 〜セッション紹介編〜

こんにちは、Androidエンジニアのはる(@ haru2036)とシャム(@arus4869)です!

私たちは2023年6月8-9日にアメリカ合衆国サンフランシスコで開催されたdroidcon San Francisco 2023に参加してきました。

前回のイベント報告編に引き続き、実際のセッションを紹介していきたいと思います。

tech.timee.co.jp

特に気になったセッション(haru編)

Mobile Feature Flags and Experiments at Uber

はじめに取り上げるのはUberのMahesh HadaさんとArun Babu A S PさんによるUberでのFeature Flag運用に関するセッションです。

Uberでは大量のFeature FlagやExperimentalなFeatureを管理するために独自のFeature Flag自動生成などの取り組みを行っているそうで、そういったFlagのFetchをするタイミングもサービスの性質上「大きく位置情報が変化したとき」など特殊なものがあるということでした(日本から渡航してきたばかりの私の端末上でもそれをトリガーとしたFlagのFetchが走ってたんだなあ、と謎の実感を持ちながら聞いていました)。

また、そういったFlagによって問題が起きた時にできる限り早くロールバックするためにFCMを用いて緊急ロールバック用メッセージを送信しているという話を聞いた時はなかなか衝撃を受けました。

タイミーでもFirebase Remote Configなどを使ったFeature Flag管理を行っているのですが、それとは桁外れに大規模で即時性の高い管理が行われていてさすがだなと思いました。

上記の緊急ロールバックの話など、世界で展開している大きなサービスならではの手法を知ることができたセッションでした。

Unlocking the Power of Shaders in Android with AGSL and Jetpack Compose

次に取り上げるのはRikin MarfatiaさんのAGSLをJetpack Composeで使うセッションです。

AGSLはAndroid上で利用できるシェーダ言語で、GPUを使用して画面を描画することができます。

私は趣味でUnityなどでシェーダを扱っていたのですが、それとよく似た記法のAGSLを用いることで視覚的にリッチなUIを実現することができるそうでした。

実際にデモとして紹介されていたUIはボタンが電球のように光ったり、写真ギャラリーの切り替え時に色収差を発生させるなどのとても派手な表現でしたが、それをCompose上で簡単に実現できることに感動しました。

AGSLについてはこちら↓

developer.android.com

Reimagining text fields in Compose

最後に取り上げるのはGoogleのZach KlippensteinさんによるJetpack ComposeにおけるTextFieldの成り立ちとこれからについてのセッションです。

多くのプロダクトと同様に、MVPから始めてユーザースタディを繰り返しながら開発していったそうなのですが、これまた多くのプロダクトと同様に現在では技術的負債も貯まってきてしまっているそうです。

そこで、それを解消すべく大きくAPIを変更したBasicTextField v2を開発しているそうです。ユーザースタディ参加者も募集中だよ!とのことです。参加してみたいなあ。

GoogleでのCompose開発の舞台裏と、TextFieldの未来を同時に知ることができる一粒で二度美味しいセッションでした。

speakerdeck.com

特に気になったセッション(syam編)

次にsyamが特に気になったセッションを紹介していきます!

ADAM GREENBERGさんによるセッションを紹介します。

新しいコードベースを理解し、その知識を活用するための具体的な手法と戦略について深く掘り下げたセッションでした。ADAM GREENBERGさんは、自身の経験を基に、コードベースの理解、ドキュメンテーションの作成、そしてその知識の共有という3つの主要なステップを中心にお話ししていました。

以下にセッションで触れた3つの重要なステップについて触れます。

1. コードベースの理解

新しいプロジェクトや既存のコードベースに取り組む開発者にとって、コードベースの理解は非常に重要なステップである。コードベースの理解を促進させるためにはアーキテクチャ図の作成やデバッグツールの使用などが重要であると話していました。

2. ドキュメンテーションの作成

ドキュメンテーションをすることは、学んだこと振り返ったりを他の人々と共有するための重要なステップであると説いています。アーキテクチャ図の作成、重要な機能の説明、特定のコードスニペットの説明など必要に応じてドキュメンテーションする必要があると話していました。

3. 知識の共有

他の開発者が自分の知識を利用し、コードベースをより効果的に利用するための重要なステップです。このステップがチーム全体の生産性と効率を向上させ、個々の開発者が自身の理解を深めるのに役立つと述べました。

このセッションは、新しいコードベースを効果的に理解し、その知識を活用・共有するために大事なポイントをお話ししていました。

ちょうど僕たちのチームもオンボーディングや共通認知をとるためのREADMEを作成していることもあり、当たり前のことかもしれませんが、改めて聞くことができたのでこのセッションは有用でした。

Find your way with GoogleMap() {}

次は、BRIAN GARDNERさんによるセッションを紹介します。

このセッションでは、その可能性を具体的に示すために、マップの表示、マーカーの追加、そしてクラスタリングといった基本的な機能がどのように簡単に実装できるのかを学びました。

特に印象的だったのは、大量のマーカーを効率的に表示するためのクラスタリング機能です。マーカーの数が増えると、地図が混雑し、ユーザーが特定のマーカーを見つけるのが難しくなります。しかし、クラスタリングを使用することで、近接するマーカーを一つのクラスタとして表示でき、地図の見やすさとパフォーマンスを向上させることができるとのことです。

また、このセッションでは、具体的な実装方法を確認するためのソースコードも提供されました。

JetpackComposeのGoogleMapを使用する際にいくつか注意点も述べていました。

クラスタリングについての注意点として、MarkerをClustering内で使用しないようにとの警告がありこれは、IllegalStateExceptionを引き起こす可能性があるそうです。

また、パフォーマンスを向上のためにMapsInitializer.initialize(context, MapsInitializer.Renderer.LATEST)を使用して、最新のレンダラーを利用することを推奨していました。

さらに、Paparazziテストで、特にプレビューモードでの早期リターンを利用して、レイアウトを保持するためのBoxを返すと良いとのことでした。

ANR問題についても触れられていましたが、具体的な解決策は聞き取れなかったので、調べてみようかなと思います。

ちょうどGoogleMap周りをコンポーズ化している時だったので、とても有り難かったです。

セッションスライド

speakerdeck.com

ソースコード

github.com

Animating content changes with Jetpack Compose

最後にKINNERA PRIYA PUTTIさんによるセッションを紹介します!

Jetpack Compose for Desktopを使ってスライドを作っており、随所にアニメーションが動くすごいプレゼンテーションでした!

KINNERA PRIYA PUTTIさんは自身の実践を踏まえ、UIアニメーションをより魅力的にするための各種アニメーションテクニックについて語ってくれました。

以下にセッションで紹介された注目すべきテクニックについて触れていきます。

1. CrossFadeによるコンテンツ間の切り替え

新旧の画面や要素間のトランジションを滑らかにするCrossFadeは、ユーザーがアプリの使用中に感じる違和感を軽減するための非常に重要な要素です。彼は、これがいかに自然なユーザー体験を生むかについて語っていました。

2. animateContentSizeによるコンテンツサイズのアニメーション化

コンテンツのサイズを変更する必要がある場合、animateContentSizeを用いるとその変更がなめらかになります。これは、ユーザーが自由にコンテンツのサイズを調整できるようにするための重要なステップであると彼は指摘していました。

3. 各種UI要素に対するアニメーションの追加

リスト、詳細画面、ナビゲーションドロワー、ボトムシート、ダイアログなどのUI要素にアニメーションを追加することで、ユーザーフレンドリーなUIを実現する方法について具体的に語られました。

このプレゼンテーションは、Jetpack Composeを活用して、ユーザーにとって使いやすく、魅力的なUIアニメーションを創出するための重要なポイントを提供していました。

我々のチームもUI開発におけるアニメーションの活用に取り組んでおり、このような新たな視点やアイデアを得られることは非常に有意義でした。

まとめ

今回はセッションの内容をいくつか抜粋して紹介させて頂きました。

もしより詳しい内容や海外カンファレンスの様子に興味を持っていただけたら、カジュアル面談でお話しすることもできますので是非一度お話ししましょう!

devenable.timee.co.jp

droidcon San Francisco 2023レポート 〜行ってきました編〜

はじめに

こんにちは、Androidエンジニアのはる(@ haru2036)とシャム(@arus4869)です!

私たちは2023年6月8~9日にアメリカ合衆国サンフランシスコで開催されたdroidcon San Francisco 2023に参加してきました。

タイミーではKaigiPassというカンファレンス参加補助制度があり、その海外参加第一号として私たちが参加してきた形になります。

私たちが開発・運営しているタイミーは、ワーカーさんが利用するためにモバイルアプリが必要不可欠なサービスとなっており、その開発に関する知見を広く得るために参加してきました。

テック企業が集まるサンフランシスコでのカンファレンスは内容もそうですが情報の新鮮度といういう意味でも良い刺激になり、とても有意義な経験をすることができました。

会場のようす

会場となったカリフォルニア大学サンフランシスコ校は、医学分野が主になっているそうで、カンファレンスセンターのすぐ隣にリハビリセンターのようなものがあったりしました。そう言った意味ではソフトウェアのカンファレンス会場としては結構異色なのではないでしょうか。

また、国内のカンファレンスと比べてスポンサーの層が結構違うという感想を抱きました。

国内カンファレンスでは多くの場合Androidプラットフォーム上でサービスを展開している事業会社がスポンサーをしているケースが多いのですが、それと比較するとSentry, Bitrise, DataDogなどほとんどの会社が事業会社に対して開発をサポートするサービスを提供している会社だったのが興味深かったです。

実際にブース出展している様々な会社のノベルティをいただきました。 droidcon sanfransiscoTシャツは入場者特典でみんなもらえるみたいです! すごく靴下が多い印象でした。

どこの会社のステッカーも良かったのですが、droidconステッカーが中でも嬉しかったです

また、会場では一日を通して食事や飲み物が提供されていたのですが、全て屋外で提供されており参加者の憩いの場となっていました。私(はる)も朝は顔くらいのサイズがあるクロワッサンをかじりながらコーヒーを飲んでいました(笑)

ランチの時間の屋外では見知らぬ人でも活発に話しかけて盛り上がっているところも多く、私も初めて会う方とお話しすることが多かったです。

実際のセッションの内容については後日投稿するセッション内容編をご覧ください!

tech.timee.co.jp

初めての海外カンファレンスに参加してみて

今回初めて海外カンファレンスに参加してみた私たちでしたが、印象的だったのは先述のスポンサーの違いだけではありません。セッション自体の内容も、全世界でサービスを提供しているUberのような企業による実際の実装の解説や、Androidというプラットフォーム自体を提供しているGoogle自身によるフレームワーク自体についての解説、今後の予定の発表など、一次情報に直接触れている感覚がとても強いイベントでした。

また、日本でも「この中で⚪︎⚪︎使ってる方どのくらいいらっしゃいますかね?」と質問する発表者の方は多いですが、今回のイベントではさらに多くのスピーカーがそう言った質問を投げかけたりしていました。

また、結構な確率でジョークを挟んでくるスピーカーが多く、この辺りの話術は自分が発表する際にも見習いたいな、と感じました。

また、今回のトレンドとして多くセッションがあったのはJetpack Composeに関する話題で、やはり世界中のAndroid開発者が興味を持っているトピックなんだなと感じました。

おまけ

せっかくサンフランシスコに行ったので、近くにあるいくつかのとても行きたかった場所に行ってきました!

Computer History Museum (マウンテンビュー)
興奮のあまり建物の写真を撮り忘れました……

コンピュータに関わる人なら人生で一度はぜひ行ってもらいたいComputer History Museum。念願かなっていくことができました。そろばんや計算尺などの道具から始まり、コンピュータ科学の父と呼ばれているアラン・チューリングが解読したエニグマ暗号機の実機や、GUIを初めて搭載したコンピュータであるXerox Alto、iPhoneのご先祖様と言えなくもないNewton MessagePad, そして2021年に発売されたばかりのAI用超巨大プロセッサ、Cerebras Wafer Scale Engine 2などコンピュータの始まりから現在に至るまでのエポックメイキングなコンピュータたちが多数収蔵されていました。

周りを少し散策するだけでGoogleのロゴが入った建物だらけのエリアに迷い込んでしまうので、これぞシリコンバレー!という感じの場所でした。

TwitterX本社

当時は、この看板が外れるとは思いませんでしたが、Twitterの看板が外れる前に見ることができてよかったです!

まとめ

今回の海外カンファレンスへの参加では、帰国してすぐにコードベースの改善に活かせる情報から、これからのAndroidを取り巻く情報までの幅広い知見たちだけではなく、実際にその場にいることによって得られる肌感覚のようなものや海外の開発者との新しい出会いなど、絶対にその場にいないと得られないものをたくさん持ち帰ってくることができたと思います。

また、タイミーでは一緒にサービスを成長させていく方を募集しています。もし少しでも興味を持っていただけたら、カジュアル面談受け付けておりますので是非一度お話ししましょう! devenable.timee.co.jp

Rails edgeでCIを回し始めました 〜見つけたエラー編〜

こんにちは、マッチング領域でバックエンドエンジニアをしているぽこひで ( @pokohide ) です。

前回はRails edgeでCIを回し始めた話を紹介しました。

tech.timee.co.jp

今回は、実際に弊社でCIをRails edgeで回し始めた事で見つけたエラーの例を紹介していきます。記事公開時点(2023年7月)のバージョンは下記の通りです。

$ ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) +YJIT [aarch64-linux]

$ rails -v
Rails 7.0.6

ActiveRecord::DangerousAttributeError object_id is defined by Active Record

このエラーに関する参考記事はこちらです。

euglena1215.hatenablog.jp

一部のモデルで object_id というカラム名を定義していたため以下のようなエラーが発生し、そのレコードを生成しているテストが軒並み落ちました。

ActiveRecord::DangerousAttributeError:
  object_id is defined by Active Record. Check to make sure that you don't have an attribute or method with the same name.
# /usr/local/bundle/gems/factory_bot-6.2.1/lib/factory_bot/decorator/new_constructor.rb:9:in `new'
# /usr/local/bundle/gems/factory_bot-6.2.1/lib/factory_bot/decorator.rb:16:in `send'
...

Objectクラスで、オーバーライドされると予期せぬ影響を与えうるメソッド名と同じ名前を利用できなくなったからでした。 object_id に限らず dup, freeze, hash, class, clone なども利用できなくなるのでそういったカラム名を使わないようにする必要があります。

このエラーは object_idobject_identifier とリネームする事で対応しました。

Rails7.0.5.1からの create_association の挙動変化によるエラー

巷で話題のcreate_associationの挙動変更によるエラーです。既存レコードが存在する場合にユニーク制約によりバリデーションエラーが発生するといってテストケースがありましたが、そちらのテストが落ちました。Rails edgeを回し始めた頃はRails v7.0.4だったため、このエラーに遭遇しました。

Rails v7.0.5.1から create_association の挙動が「別々のトランザクションでinsertしてからdeleteする」から「同一トランザクションでdeleteしてからinsertする」に変わりました。この影響により、DBのレコードに依存するバリデーション(例: validates_uniquness_of )が効かなくなりました。この挙動変化に関する内容はこちらをご参考ください。

blog.willnet.in

parser gemを利用してcreate_associationの呼び出し箇所を調査し、影響範囲を一つずつ確認、必要な箇所に条件文を追加するなどして対応しました。

既にリリースされている変更ではありますが、Rails edgeでCIを回し始めたことで早期に問題を発見でき、create_association削除する前に検証を挟む新しいオプションの提案をrails/railsへのPR*1を通して行うなどしました。

NoMethodError: undefined method `reading_role' for ActiveRecord::Base:Class

Rails7.1から ActiveRecord::Base.reading_role がなくなるため、この記述を行っていた箇所のテストが落ちました。

irb(main):002:0> ActiveRecord::Base.reading_role
{"severity":"WARN","message":"DEPRECATION WARNING: ActiveRecord::Base.reading_role is deprecated and will be removed in Rails 7.1.\nUse `ActiveRecord.reading_role` instead.\n (called from xxxxx)"}
=> :reading

irb(main):003:0> ActiveRecord::Base.writing_role
{"severity":"WARN","message":"DEPRECATION WARNING: ActiveRecord::Base.writing_role is deprecated and will be removed in Rails 7.1.\nUse `ActiveRecord.writing_role` instead.\n (called from xxxxx)"}
=> :writing

この問題に関しては元々出ていたWarningの内容に従い、ActiveRecord.reading_role を代わりに使う事で対応しました。

partialsにインスタンス変数をlocalsとして渡す挙動が削除されたことによるエラー

弊社では請求書(PDF)を生成するために ActionController::Base#render_to_string を用いてHTML文字列を取得しています。その処理の中でpartialsにインスタンス変数をlocalsとして渡していましたが、そこでエラーが発生しました。

今までWarningが出ていた内容ではありますが、Rails7.1からインスタンス変数をlocalsで渡せなくなったためです*2

インスタンス変数ではなくローカル変数として渡すことで対応を予定しています。

before_type_castの返り値の型が変わったことによるエラー

# create_table :posts, force: true do |t|
#   t.integer :foo, default: 1, null: false
# end
class Post < ActiveRecord::Base
  enum foo: { x: 1, y: 2 }
end

Integer型でEnumを定義しているカラム foo を持つモデルのレコードに対して record.foo_before_type_cast で参照すると、元々の環境ではInteger型で返っていたものが、Rails edge環境下ではString型で返るようになったため落ちているテストケースを見つけました。

record = Post.new(foo: :x)

# 検証当時の環境(Rails v7.0.6)
record.foo_before_type_cast
=> 1

# Rails edge
record.foo_before_type_cast
=> "1"

今回のエラーを再現するコード*3を用意し、 git bisect*4 を利用して二分探索で挙動が変わったをコミットを調査しました。その後のコミットやPRを追ったところ、rails v7.1からDBの型で値を取得する *_for_database が追加されていることに気づいたため、その実装をバックポートして使うように修正することで対応を行いました。

# frozen_string_literal: true

case Rails::VERSION::STRING
when /^7\.0/
  # 以下の定義を読み込むために何もしない。
when /^7\.1/
  # v7.1 には含まれているため読み込まない。
  return
else
  # v7.2 以降で削除し忘れないように例外を投げる。
  raise('Consider removing this patch')
end

module ActiveRecord
  module AttributeMethods
    module BeforeTypeCast
      # refs: https://github.com/rails/rails/pull/46283
      def read_attribute_for_database(attr_name)
        name = attr_name.to_s
        name = self.class.attribute_aliases[name] || name

        attribute_for_database(name)
      end
    end
  end
end

最後に

今回は、Rails edgeでCIを回すことによって見つけた将来動かなくなるコードの早期発見やその対応、原因についての簡単な解説を行いました。また紹介しきれていないですが、エラーだけでなくRails 7.2からの廃止予定を知らせるWarningもいくつか確認できました。

Rails edge導入当初は112個のテストケースが落ちましたが、徐々に対応を行なっていき落ちるテストケースは22件にまで減りました。こういった活動を継続することでRubyRailsのコミュニティの進化に追随できるので引き続き頑張っていこうと思います。

タイミーでは一緒にサービスを成長させていく方を募集しています。もし少しでも興味を持っていただけたら、カジュアル面談受け付けておりますので是非お話ししましょう!

devenable.timee.co.jp

Vertex AI Pipelinesを効率的に開発するための取り組み

こんにちは、タイミーのデータ統括部データサイエンス(以下DS)グループ所属の小関です。

今回はDSグループがMLパイプライン構築時に活用しているVertex AI Pipelinesを効率的に開発するための取り組みを紹介したいと思います!

Vertex AI Pipelinesとは

Vertex AI Pipelinesとは、Google Cloudが提供しているMLパイプラインをサーバーレスに構築・実行できるサービスです。

Vertex AI Pipelinesを活用することで、下記のようなデータをBigQeuryから取得し、特徴量の作成・データセットの分割後、モデルを学習するようなML パイプラインが比較的容易に構築できます。

Vertex AI Pipelinesで構築したMLパイプラインのサンプル

Vertex AI Pipelinesの活用事例と挙げられた改善点

タイミーのDSグループでは、下記のようなML パイプラインをVetex AI Pipelinesで開発・運用しています。

  • ワーカーに対して、おすすめの募集を出力するパイプライン
  • クライアントの離脱を予測するパイプライン
  • 各種KPIを予測するパイプライン

ML パイプラインを構築していく上で、以下のような改善点が挙げられていました。

  • パイプラインのリポジトリディレクトリ構成や、CI/CDを共通化したい
  • Google Cloud上での処理を共通化し、より使いやすい形で処理を呼び出せるようにしたい
  • 各パイプラインに必ず入れ込む必要があるKubeflow Pipelines*1の記述を共通化したい

Vertex AI Pipelinesを効率的に開発するための取り組み

挙げられた課題に対して、DSグループでは以下の3つの取り組みを行なっています。

1. Vertex AI Pipelines開発用のテンプレートリポジトリの構築

cookiecutterを使用して、パイプライン開発に関わるディレクトリや、CI/CDに用いるyaml, shell scriptを生成してくれるテンプレートを作成しました。パイプラインの開発開始時にこのテンプレートを利用しています。

下記のようにcookiecutterコマンドでプロジェクトを生成し、プロジェクト名・プロジェクトの説明・Pythonのバージョン・作成者を入力することで、開発用のテンプレートが生成されます。

$ cookiecutter [開発用のテンプレートリポジトリのパス]

> project_name [project_name]:
> project_description []:
> python_version [3.10.1]:
> author [timee-dev]:
# Vertex AI Pipelines開発用のテンプレート
.
├── .github
│   ├── PULL_REQUEST_TEMPLATE.md
│   └── workflows
│       ├── CI/CDのyamlファイル
├── .gitignore
├── Makefile
├── README.md
├── pyproject.toml
├── src
│   └── pipeline_template
│       ├── components
│       │   └── component
│       │       ├── パイプラインを構成するコンポーネントのソースコードとDockerfile
│       ├── pipelines
│       │   ├── パイプラインをコンパイル、実行するためのソースコード
│       ├── pyproject.toml
└── tests
    ├── テストコード

2. Google Cloudの処理を集約した社内ライブラリの構築

Google CloudのPythonライブラリをラップして、BigQueryでのクエリ実行・クエリ結果のDataFrame化・テーブルの書き込みや、Cloud StorageにおけるファイルのI/O処理などを行える社内ライブラリを構築しています。こちらの社内ライブラリは、DSグループ全体で保守・運用を行なっており、バージョン管理とデプロイの自動化をした上で、Artifact Registryにプライベートパッケージとして置いて利用しています*2。この社内ライブラリに関しては、Vertex AI Pipelinesに限らずVertex AI WorkbenchやCloud Runなど、他のGoogle Cloudのサービスでの実装でも活用されています。

簡単な利用例として、SQLファイルのクエリを実行して、pd.DataFrameとして取得する処理を紹介します。

# 社内ライブラリからBigQuery関連の処理をまとめているクラスをimport
from [社内ライブラリ名].bigquery import BigQueryClient

# project_idにGoogle Cloudのプロジェクト名、sql_dirにSQL fileを格納しているディレクトリ名を指定
bq_client = BigQueryClient(project_id=PROJECT_ID, sql_dir=SQL_DIR)

# test.sql内でJinjaテンプレートで定義されているパラメーターをquery_paramsで受け取り、クエリの実行結果をpd.DataFrameとして受け取る
test_df = bq_client.read_gbq_by_file(
    file_name='test.sql',
    query_params=dict(loading_start_date=LOADING_START_DATE, loading_end_date=LOADING_END_DATE),
)
-- SQL_DIR/test.sql
DECLARE LOADING_START_DATE DEFAULT '{{ loading_start_date }}';
DECLARE LOADING_END_DATE DEFAULT '{{ loading_end_date }}';

SELECT
  *
FROM
  `project_id.dataset_id.table_id`
WHERE
  event_data BETWEEN LOADING_START_DATE AND LOADING_END_DATE

3. Kubeflow Pipelinesにおいて共通化できる処理を集約した社内ライブラリの構築

コンポーネント間のアーティファクトの受け渡し・Cloud StargeへのI/O処理や、yamlで定義されたコンポーネントの情報を取得してくる処理*3などを行える社内ライブラリを構築しています。こちらもDSグループ全体で保守・運用を行なっており、バージョン管理とデプロイの自動化をした上で、Artifact Registryにプライベートパッケージとして置いて利用しています。

利用例として、学習データを受け取り、それを特徴量とターゲットに分割するコンポーネントにおけるアーティファクトの受け渡し・Cloud StargeへのI/O処理の実装を紹介します。

# pipeline_name/components/component_name/src/main.py
from dataclasses import dataclass

import pandas as pd
from [社内ライブラリ名].artifacts import Artifacts
from [社内ライブラリ名].io import df_to_pickle


@dataclass
class ComponentArguments:

    train_dataset_path: str

@dataclass
class OutputDestinations:

    x_train_path: str
    y_train_path: str


def main(args: ComponentArguments) -> pd.DataFrame:
    train_dataset = pd.read_pickle(args.train_dataset_path)
    x_cols = ['x1', 'x2']
    y_col = ['y']
    X_train, y_train = train_dataset[x_cols], train_dataset[y_col]

    return X_train, y_train


if __name__ == '__main__':
    # アーティファクトのパスを取得
    artifacts = Artifacts.from_args(ComponentArguments, OutputDestinations)

    # インプットとなるアーティファクトのパスを受け取り、main関数を実行
    X_train, y_train = main(artifacts.component_arguments)

    # パイプラインのアーティファクトを管理するCloud Storageのバケットへpickle形式でX_train, y_trainを書き込む
    df_to_pickle(artifacts.output_destinations.x_train_path, X_train)
    df_to_pickle(artifacts.output_destinations.y_train_path, y_train)

取り組みから感じたメリット

上で挙げた取り組みを通じて、グループ全体で感じている主なメリットを紹介していきます。

  • 開発のスピードが上がる。特にパイプラインのテンプレートが用意されている事で、開発の初動が大幅に速くなりました。

  • テンプレートやライブラリを通して、ファイル構成や処理に共通知があるので、メンバー間でのレビューがしやすくなっている。

  • 個別に開発した処理を社内ライブラリに追加していくことで、グループ全体の資産として蓄積している。

  • 属人化されているコードが減っていくので、新規メンバーのキャッチアップがし易くなる。

今回紹介した取り組み以外にも、MLパイプラインのソースコードのモノリポ化などDSグループでは常に社内のMLOps基盤を強固にしていく活動を続けています!

We’re Hiring!

タイミーのデータ統括部では、ともに働くメンバーを募集しています!!

現在募集中のポジションはこちらです!

少しでも気になった方はお気軽にカジュアル面談に申し込みいただけると嬉しいです!

*1:Kubeflow Pipelines SDKで実装したパイプラインをVertex AI Pipelinesで動作させています

*2:Artifact RegistryのプライベートパッケージをPoetryで扱う方法はこちらが参考になります

*3:Kubeflow Pipelinesにおけるパイプラインの定義ファイルで使用します