ホルモン治療薬が身体のリズムに与える影響:女性たちの半年間のデータから見えたこと

こんにちは、データサイエンスチームの瀬川です。

テックドクターでは、女性社員のみで構成された「Ladynamic」プロジェクトを通して、女性の視点に立った課題提起とデータ解析を目指しています。同プロジェクトでは女性の健康に関する様々なデータ分析を行っており、これまでのブログ記事でもいくつかの事例をご紹介しました。

techblog.technology-doctor.com

techblog.technology-doctor.com

そんなLadynamicプロジェクトから、今回は基礎体温についてのお話です。

基礎体温は排卵の前には低く、排卵の後には高くなるというように、2つの段階に分かれます(このような性質を「二相性」と呼びます)。また、この変化には月経周期と連動した周期性があります。この連動を利用して月経周期の把握ができるため、基礎体温を毎日記録されている方もいらっしゃるかと思います。

基礎体温のニ相性。引用元:病気が見えるvol.9 婦人科・乳腺外科(第4版)(メディックメディア)

スマートウォッチ等の一般的なウェアラブルデバイスで基礎体温を記録することは、現時点ではできません(※)。代わりにウェアラブルデバイスから得られる別のデータを使用して、この月経周期と連動した周期性と同様の傾向は確認できないでしょうか?

※皮膚温であればFitbit等で記録が可能ですが、その違いについては後ほど触れます。また一般的なスマートウォッチ等ではない、基礎体温測定用の専用のウェアラブル機器は存在します。

本稿では、この疑問を解明するため、月経周期とウェアラブルデバイスデータで得られた心拍数・脈拍数との関連を探っていきます。また、ホルモン製剤を服用している人と服用していない人とのデータから、服薬がその関連にどういった影響を与えるかも調べていきたいと思います。

イメージイラスト

ウェアラブルデータが月経周期と関連するかどうか

まずはウェアラブルデバイスのデータが月経周期と連動した周期性(二相性)を示すかどうかを検証しました。

【対象データ】

女性ホルモン製剤を服用していない女性社員(Aさん)の約半年間のデータを可視化しました。

使用ウェアラブルデバイス:Fitbit
指標:安静時心拍数、起床直前30分(基礎体温の計測タイミングに合わせて)の脈拍数、皮膚温

【結果】

グラフはそれぞれの指標の1日ごとのデータを点で表し、7日移動平均線を表示しています。月経開始日は縦の赤線で示しました。

グラフ

分析の結果、安静時心拍数と起床直前30分の脈拍数については、月経開始日に向けて上昇し、その後低下するという傾向が見られました。この二つの値については、月経周期と連動した周期性があると言えそうです。

一方、皮膚温では心拍数・脈拍数データほどの明確な周期性は確認できませんでした。皮膚温も基礎体温と同様に周期性を示すかと思われましたが、Fitbitを装着した手の位置(布団の中か外か)や室温といった外部環境の影響を受けやすく、月経周期の影響が反映されにくい結果になったと考えられます。

ホルモン剤を服用することによって周期性が変化するのかどうか

次に、ウェアラブルデバイスの心拍数データと月経周期との関係が、女性ホルモン製剤を服用している方々でどのように異なるかを検証しました。

【対象データ】

2名の女性社員から得られた約半年間のデータが対象です。
先ほどの検証で月経周期と特に明確な関連性が見られた、安静時心拍数に注目して分析を進めました。

  • Aさん: 何も服薬していない方
  • Bさん: ジエノゲストを服用している方

【ジエノゲストの作用】

可視化したデータを見る前に、ジエノゲストが体にどのような作用をもたらすのかを簡単に説明します。

  • ジエノゲスト:

子宮内膜症や子宮腺筋症に伴う痛みの治療に使用され、毎日服用します。
女性ホルモンの一種であるプロゲステロン受容体に対して似た働きをし、卵巣機能抑制および子宮内膜細胞の増殖抑制によりプロスタグランジン産生を抑制することから、月経困難症に対する有効性を示すと考えられます。また、LHサージを抑制し、排卵抑制作用を示すと考えられます(1)。排卵が抑えられ月経が来なくなります。

ジエノゲストの服用は、女性の身体の周期性にどのように影響を与えるのでしょうか。

【結果】

2人の安静時心拍数の経過を可視化しました。

グラフ

グラフからは、以下の特徴が見られました。

服用なし(Aさん):

先ほど見たとおり、安静時心拍数が月経開始日に向けて上昇し、その後低下するという周期性が見られます。女性ホルモンの変動が心拍数にも影響を与えている可能性があります。

ジエノゲスト服用者(Bさん):

Aさんよりグラフの変動幅が少なく、明確な周期性は見られませんでした。
ジエノゲストは毎日服用することから服用による女性ホルモン量の変動が少なくなります。これにより、安静時心拍数の周期性が、服用なしのAさんよりもみられないのではないかと考えています。

まとめ

今回の調査から、以下のようなことがわかりました。

  • 安静時心拍数と起床直前30分の脈拍数については、月経開始日に向けて上昇し、その後低下するという傾向が見られ、月経周期と連動した周期性があると言えそうです。
  • ジエノゲストを服用している方の心拍数に周期性が見られなかったことは、薬が女性ホルモンを通じて身体の周期性にも影響を与えているという可能性を示唆しているかもしれません。

ジエノゲストなど女性ホルモン剤の服用者に関する基礎体温や体調のデータはまだ多くありません。そんな中、ウェアラブルデバイスのデータは、毎朝手動で計測しなければいけない基礎体温と違って継続的に女性の体調を記録できるという点から非常に意義があると考えられます。今回の可視化から得られた「女性ホルモンが生体へ影響を与える可能性がある」という結果も、今後の女性の体調管理に役立つかもしれません。

今回の調査は少数のデータに基づいたものであり、今後医学的観点からの考察をより深める必要があります。今後も、より多くのデータを集め、女性の健康に関する課題を解き明かし、よりパーソナライズされた健康管理や治療法の開発に貢献していきたいと考えています。

DDDにCQRSをどう組み込むか~バックエンドアーキテクチャ設計時の考え方

はじめに

こんにちは。テックドクターでバックエンドエンジニアをしている筧と申します。

新規プロダクトのバックエンドで、DDD (Domain-Driven Design) と CQRS (Command Query Responsibility Segregation) を組み合わせたアーキテクチャを採用しました。

DDDの本や記事は、Eric Evans著『Domain-Driven Design』や『実践ドメイン駆動設計』など様々あります。CQRSについてもMartin Fowler氏のCQRSの解説記事などがあります。しかし、DDDにCQRSをどう組み込んでいったかという話はあまり見かけません。

この点について以前より情報収集や試行錯誤を重ねていましたが、今回のプロダクトでようやく納得のいく形で実装ができました。この記事ではそのポイントをご紹介します。特にCQRSを具体的に実装していくApplication層を中心に、他の層とのデータのやりとりや責務分担について詳しく説明したいと思います。

この記事の想定読者とゴール

この記事は、以下のような方を想定しています:

  • DDDの基本(Entity、Value Object、Repository等の概念)は理解している
  • 実際のコードでCQRSをどう導入すればよいかわからない

記事を読み終わったときに、Application層のCommand/QueryHandlerの実装や、他の層とのデータのやりとりを具体的にイメージできるようになることがゴールです。

CQRSとは何か

まず、この記事でのCQRSの定義を明確にしておきます。

CQRSは、データを変更する操作(Command)と、データを読み取る操作(Query)を分離するパターンです。通常のCRUDではデータモデルが読み書き共通ですが、CQRSでは以下のように分けます:

  • Command側(書き込み): ビジネスルールの検証を重視。Entityを経由してデータを更新
  • Query側(読み込み): パフォーマンスと利便性を重視。最適化されたクエリで直接DTOにマッピング

この分離により、それぞれの操作に最適な実装を選択できるようになります。

全体像:レイヤー構成の概略と責務の割り当て

今回のプロダクトは一種のダッシュボードシステムで、データの集計・可視化を行うほか、組織やユーザー情報等の登録も行います。
レイヤー構成は以下の5層としました。

層名 説明
Presentation層 ← API エンドポイント(薄い層)
UseCase層 ← ビジネスフロー + 認可制御
Application層 ← Command/Query Handler(純粋なCRUD)
Domain層 ← Entity、Value Object、Repository Interface
Infrastructure層 ← Repository実装、Query Service

各層の責務を簡単に整理します。

Presentation層はHTTPリクエスト/レスポンス変換のみを担当します。ビジネスロジックは持たせません。

UseCase層は認可チェックや、複数のCommand/Queryを組み合わせたビジネスフローを扱います。

Application層は純粋なCRUD操作のみで、認可処理は持ちません。

Domain層はビジネスルールの中核を担い、他の層に依存しません。

Infrastructure層はデータベースや外部サービスとの実際のやりとりを担当します。

Core層もありますが、DIコンテナや共通設定を担う補助的な層なので本稿では詳しく扱いません。

特に重要なのはUseCase層とApplication層の境界です。Application層のCommand/QueryHandlerは認可処理を持たず、純粋にドメインロジックに集中します。一方、認可や監査ログといった横断的な関心事はUseCase層で扱います。

この設計にした理由は、別プロダクトでUseCase層とその下のService層(今回のApplication層に相当)を分けて成功した経験があったからです。UseCase層にCQRSのCommand/Queryを直接配置すると、認可処理とデータ操作のロジックが混在してしまうため、Application層として分離しました。

Application層におけるCQRS実装

Application層では、Command(書き込み)とQuery(読み込み)を明確に分離しています。

Commandの実装例:組織を作成する

例として、組織を作成するCommandHandlerを見てみましょう。以下は実際のプロダクトのコードを簡略化したサンプルです。

class CreateOrganizationCommand(BaseModel):
    """組織作成コマンド"""
    organization_id: OrganizationId
    name: OrganizationName
    # ... その他のフィールド

class CreateOrganizationCommandResult(BaseModel):
    """組織作成結果"""
    organization: Organization
    created: bool

class CreateOrganizationCommandHandler(
    ICommandHandler[CreateOrganizationCommand, CreateOrganizationCommandResult]
):
    """組織作成コマンドハンドラー"""

    def __init__(self, organization_repository: IOrganizationRepository) -> None:
        self._organization_repository = organization_repository

    async def handle(
        self, command: CreateOrganizationCommand
    ) -> CreateOrganizationCommandResult:
        # 1. 既存チェック
        if await self._organization_repository.exists(command.organization_id):
            raise EntityAlreadyExistsException(...)

        # 2. ドメインモデルでビジネスルール検証
        organization = Organization.create(
            organization_id=command.organization_id,
            name=command.name,
            # ...
        )

        # 3. 永続化
        await self._organization_repository.save(organization)

        return CreateOrganizationCommandResult(
            organization=organization,
            created=True,
        )

このCommandHandlerで最も重要なのは、認可処理を一切持たない点です。「この組織を作成できる権限があるか?」といったチェックはUseCase層の仕事で、Application層は純粋に「組織の作成」というビジネスロジックに集中しています。

具体的な処理の流れは、入力としてCommand(必要なデータのみ)を受け取り、出力としてCommandResult(処理結果)を返します。内部では、ドメインモデルのOrganization.create()を使ってビジネスルール検証を行い(例えば、OrganizationNameというValue Objectで組織名の長さや形式をチェックしています)、最後にRepositoryで永続化します。

こうすることでHandlerがシンプルになり、テストも書きやすくなります。認可を気にする必要がなく、Infrastructure層のDBセッションにも依存しないため、モックのRepositoryを渡すだけで単体テストができました。

Queryの実装例:組織を取得する

次に、組織を取得するQueryHandlerです。

class GetOrganizationQuery(BaseModel):
    """組織取得クエリ"""
    organization_id: OrganizationId

class GetOrganizationQueryResult(BaseModel):
    """組織取得結果"""
    organization: OrganizationDTO | None

class GetOrganizationQueryHandler(
    IQueryHandler[GetOrganizationQuery, GetOrganizationQueryResult]
):
    """組織取得クエリハンドラー"""

    def __init__(self, organization_repository: IOrganizationRepository) -> None:
        self._organization_repository = organization_repository

    async def handle(self, query: GetOrganizationQuery) -> GetOrganizationQueryResult:
        organization = await self._organization_repository.find_by_id(
            query.organization_id
        )

        if organization is None:
            return GetOrganizationQueryResult(organization=None)

        # Entity → DTOへの変換
        organization_dto = OrganizationDTO(
            organization_id=str(organization.organization_id.value),
            name=str(organization.name.value),
            created_at=organization.created_at,
            # ...
        )

        return GetOrganizationQueryResult(organization=organization_dto)

QueryHandlerで重要なのは、Entity→DTOの変換です。入力としてQuery(検索条件)を受け取り、出力としてQueryResult(DTO形式のデータ)を返します。責務はデータ取得とDTO変換のみで、Command側のようなビジネスルール検証は行いません。

DTOに変換する理由は、Presentation層で使いやすい形にするためです。Entityはビジネスロジックを持つ重いオブジェクトですが、DTOは単なるデータ転送用の軽いオブジェクトです。この変換をApplication層で行うことで、Presentation層はシンプルに保てます。

データモデルの使い分け

実装していて最も悩んだのが、「どのデータモデルをどこで使うか」でした。

当初、各データモデルの役割は ”なんとなく” 決まっていたものの、具体的なルールがありませんでした。例えば、DTOの使い回しや、概念の分離ができていなかったりしました。

最終的に、各層の境界を明確にするため、以下のようにデータモデルを整理しました:

データモデル 役割 配置場所 命名の由来
Command/Query リクエストデータ(入力) Application層 CQRSの概念からそのまま
CommandResult
/QueryResult
レスポンスデータ(出力) Application層 Commandの結果、Queryの結果という明確な名前
Projection Infrastructure
→Application層のデータ
Infrastructure層 CQRSの文献で使われている用語(後述)
DTO Application
→Presentation層のデータ
Application層 特に他の名前が思いつかなかった

Projectionという名前について補足します。当初はXXResultという名前も検討しましたが、CommandResult/QueryResultと名前が被ってしまうこと、そしてRepositoryの結果とQuery Serviceの結果の両方があり「どちらの名前を取るか」という論争が起きそうだったため不採用にしました。

CQRSの文献を調べたところ、読み取り側のデータモデルとして「Projection」という用語が使われていること(参考1参考2)がわかり、この名前を採用しました。

これらを明確に分けることで、各層の関心事が混ざらないようにできています。特に名前付けには苦労しましたが、役割が明確になってからはコードの見通しが格段に良くなりました。

UseCase+Infrastructure:認可とデータアクセスをどう接続したか

Application層のCommand/QueryHandlerは純粋なCRUD操作だけを扱うので、認可処理はUseCase層で行います。

UseCaseでの認可統合:Command実行前に権限チェック

UseCase層では、Application層のHandlerをラップして認可処理を追加します。実装例を見てみましょう。

class CreateOrganizationUseCase:
    """組織作成UseCase(認可付き)"""

    def __init__(
        self,
        permission_checker: PermissionChecker,
        create_organization_handler: CreateOrganizationCommandHandler,
    ):
        self._permission_checker = permission_checker
        self._create_organization_handler = create_organization_handler

    async def execute(
        self,
        request: CreateOrganizationRequest,
        user_claims: JWTClaims
    ) -> Organization:
        # 1. 認可チェック(UseCase層の責務)
        await self._permission_checker.verify_role(user_claims, required_roles=["org-admin"])

        # 2. RequestからCommandへの変換
        command = CreateOrganizationCommand(
            organization_id=OrganizationId.generate(),
            name=OrganizationName(request.name),
            # ...
        )

        # 3. Application層のHandlerを実行
        result = await self._create_organization_handler.handle(command)

        return result.organization

このように、認可チェックとCommand実行を分離することで、いくつかのメリットがあります。

まず、Application層はビジネスロジックに集中できます。「組織を作成する」というドメインロジックに認可処理が混ざらないので、コードが読みやすくなります。

次に、認可ロジックを一箇所に集約できます。権限チェックの方法を変更したいとき、UseCase層だけ修正すれば済みます。

そして何より、テストが書きやすくなります。Application層はビジネスロジックの単体テストに集中でき、UseCase層は認可のテストとして分離できました。

認可処理の設計

認可処理の実装について、今回のプロダクトでは細かいロール設定をバックエンド側で担当することにしました。これにより、より柔軟な権限管理を実現しています。

Query Serviceでのデータ取得最適化

読み取り側において、複雑なクエリはInfrastructure層のQuery Serviceで最適化します。

Query Serviceとは、最適化されたクエリを実行するための読み取り専用のサービスです。Repositoryパターンとは異なり、複数のテーブルをJOINして1回のクエリで必要なデータを取得することに特化しています。

今回のプロダクトは、ダッシュボードシステムであり、複雑なデータの集計・可視化を行います。Repositoryパターンだけだと大量のクエリが発生し、パフォーマンスが低下する可能性があるため、CQRSを採用し、読み取り側ではQuery Serviceを使って1回のクエリで効率的にデータを取得しています。

具体例を見てみましょう(実際のプロダクトのコードを簡略化したものです):

class OrganizationQueryService:
    """組織クエリサービス(Infrastructure層)"""

    async def get_organization_with_stats(
        self, organization_id: str
    ) -> OrganizationProjection | None:
        # 最適化されたJOINクエリで一度に取得
        query = select(
            OrganizationModel.id,
            OrganizationModel.name,
            func.count(MemberModel.id).label('member_count'),
            func.count(ItemModel.id).label('item_count'),
        ).select_from(
            OrganizationModel
        ).outerjoin(
            MemberModel
        ).outerjoin(
            ItemModel
        ).where(
            OrganizationModel.id == organization_id
        ).group_by(OrganizationModel.id)

        result = await self._session.execute(query)
        row = result.first()

        if not row:
            return None

        # Projectionとして返す(DTO変換はQueryHandlerで)
        return OrganizationProjection(
            id=row.id,
            name=row.name,
            member_count=row.member_count or 0,
            item_count=row.item_count or 0,
        )

ここでのポイントは、Query ServiceがProjectionを返す点です。データフローは次のようになります:

QueryService(Projection) → QueryHandler(DTO) → UseCase → API(Response)

ProjectionはInfrastructure層からApplication層へのデータモデル、DTOはApplication層からPresentation層へのデータモデルと、役割が分かれています。

この設計にしたのは、Infrastructure層にドメイン知識を漏らさないためです。もしQuery ServiceがDTOを直接返すと、Infrastructure層がPresentation層の都合(どんな形式でデータを返すか)を知る必要があり、依存方向が逆転してしまいます。Projectionという中間データモデルを挟むことで、各層の責務を明確に保てています。

Query ServiceとRepositoryの使い分け

具体的な判断基準として、複数回JOINが必要かどうかを見ています。

単純な取得であればRepositoryを使いますが、組織に紐づく複数の関連データを同時に取得する場合など、複数のテーブルをJOINする必要がある場合はQuery Serviceを使います。これにより、クエリ実行回数を抑え、パフォーマンスを向上させています。

Presentation層とCore層の扱い

Presentation層:UseCaseに委譲するだけの薄い層

APIエンドポイントは本当に薄く、UseCaseに処理を委譲するだけです。

@router.post("", response_model=CreateOrganizationResponse, status_code=201)
@require_roles(["org-admin"])
async def create_organization(
    request: CreateOrganizationRequest,
    create_organization_use_case: Annotated[
        CreateOrganizationUseCase, Depends(get_create_organization_use_case)
    ],
    user_claims: Annotated[JWTClaims, Depends(get_current_user)],
) -> CreateOrganizationResponse:
    """組織作成エンドポイント"""
    try:
        # UseCaseに処理を委譲
        organization = await create_organization_use_case.execute(request, user_claims)

        # レスポンスに変換して返すだけ
        return CreateOrganizationResponse(
            organization_id=str(organization.organization_id.value),
            name=str(organization.name.value),
            # ...
        )
    except ValidationException:
        # エラーハンドリング(詳細は省略)
        raise

エンドポイントではビジネスロジックを持たず、HTTPの世界とアプリケーションの世界を繋ぐ責務だけに徹しています。実際のコードを見ても、UseCaseの実行結果をResponseに変換し、例外をHTTPステータスコードに変換しているだけです。

この設計にしてよかったのは、エンドポイントのテストがシンプルになったことです。HTTPリクエストの形式が正しいかだけをテストすればよく、ビジネスロジックのテストはUseCase層で完結します。

Core層

Core層はDI ContainerやJWT検証といった共通機能を提供していますが、アーキテクチャの中心ではないので本稿では詳しく触れません。重要なのは、各層の責務を明確に分けることです。

まとめ:実際に導入してみて感じたメリット

DDD+CQRSを実際のプロダクトに導入してみて、以下のメリットを感じました。

まず、認可ロジックの明確化です。UseCase層に認可処理を集約することで、「誰が何にアクセスできるか」が一目でわかるようになりました。

次に、テストの容易性です。Application層のHandlerがシンプルなので、単体テストが書きやすくなりました。認可処理と分離されているため、ビジネスロジックのテストに集中できます。また、Infrastructure層に依存しないため、DBセッションを用意する必要もありません。

そして、保守性の向上です。各層の責務が明確なので、変更の影響範囲が予測しやすく、コードの見通しが良くなりました。新しいメンバーがジョインしたときも、「この機能はどこを見ればいい?」という質問に明確に答えられるようになっています。

特にApplication層とUseCase層を分けたことで、ビジネスロジックと認可処理が混ざらず、それぞれに集中できるようになりました。当初は「層が多すぎて複雑では?」と心配したのですが、実装を進めるうちに明確なルールが整備され、責務が明確な分、むしろシンプルに感じています。

DDDもCQRSも学習コストは高いですが、実際に手を動かして設計してみると、その価値が実感できます。この記事が、これからDDD+CQRSを導入しようとしている方の参考になれば幸いです。

似顔絵
書いた人:筧

UI生成ツールv0でゼロイチ開発が変わった話~AI製プロトタイプの「捨てやすさ」が仮説検証を加速する

こんにちは。テックドクターでプロダクトマネージャーをしている田向です。

テックドクターでは各種AIツールを積極的に導入し、プロダクト開発のプロセス改善に取り組んでいます。

中でもUIデザイン生成ツールv0の導入は、単に個々のプロトタイプの実装を効率化するだけでなく、プロトタイプ開発全体を大きく加速させてくれました。

本記事では、v0の概要から具体的な導入効果までをご紹介します。

v0とは

v0は、Vercelが開発したAIツールです。自然言語のプロンプトや画像、Figmaファイルなどをもとに、WebやモバイルアプリのUIを簡単に作成できます。

v0.app

v0を選んだ理由

他にもUIを生成できるAIツールはありますが、今回は下記の理由からv0を選びました。

非エンジニアでも使える手軽さ

v0の大きな魅力は、自然言語のプロンプトでUIを生成できる点です。

大まかな指示でも一定の品質のものが作成できますが、プロンプトで要件を細かく指定したり参考画像を提示することで、さらに質が高く、意図に近いプロトタイプを仕上げることができます。

簡易的な指示で作成したサンプル。所要時間は1分程度!プロンプトは「プロジェクト管理のSaaSのダッシュボード画面を作成してください」

shadcn/ui との親和性

現在開発中のプロダクトでは、UIコンポーネントライブラリにshadcn/uiを採用しています。
v0はshadcn/uiを使ってUIを生成するため、プロトタイプとして作成したコードを本番の実装にも活かすことができ、開発のリードタイム短縮に繋がります。

※shadcn/uiとは

shadcn/uiは、Radix UIとTailwind CSSを組み合わせて構築された、カスタマイズ性に優れたコンポーネントライブラリです。
従来のライブラリと異なり、必要なコンポーネントのコードを直接自分のプロジェクトにインストールする方式のため、デザインの微調整や機能追加を自由に行えるのが特徴です。

v0の導入効果

v0を導入することで、様々なメリットがありました。

プロトタイプ開発の高速化とコスト削減

v0の導入前、プロトタイプを開発するには、①エンジニアやデザイナーに要件を伝え、②仕様書を作成し、③開発する……という工程が必要でした。関わる人数も多くなりますし、開発期間もアイデアから検証までに数週間かかることが当たり前でした。

v0導入後は、プロダクトマネージャーが自然言語の指示により自分でプロトタイプを作成できるようになりました。プロトタイプ作成にくわえフィードバックを受け改善するサイクルまでが1人で完結するようになったことで、プロトタイプ開発のコストが大幅に削減されました。

認識のズレと開発の手戻り防止

従来、仕様の共有はドキュメントや静的なワイヤーフレームで行っていましたが、静的なドキュメントだけでは関係者がそれぞれ頭の中で挙動を想像しながら議論することになり、認識のズレが生まれがちです。
その結果、実装後に「イメージと違う…」という手戻りが発生することも少なくありませんでした。

v0導入後はv0で作成したプロトタイプを中心に議論することで、認識のズレに起因する開発の手戻りを未然に防げるようになりました。

「捨てやすさ」が仮説検証を加速する

プロトタイプの目的は、あくまでアイデアの仮説検証です。

仮説検証の結果、もしユーザーにとってそのアイデアに価値がないと分かれば、すぐに捨てて次のアイデアを考えるのが正しいアプローチです。

しかし、エンジニアが時間をかけて作ったプロトタイプは、「せっかく作ってもらったのだから…」というサンクコストバイアスに陥りやすく、客観的な判断を鈍らせる原因になり得ます。

その点、v0で作成したプロトタイプはひとりで短時間で作成したものです。検証結果が思わしくないときは、何の心理的な負い目も感じることなく、躊躇なく捨てることができます。

このことが、プロトタイプ開発における仮説検証のサイクル全体を大きく加速させてくれました。

今後の展望

現在はshadcn/uiのレジストリからコンポーネントを配布し、v0のプロジェクトに適用する検証を進めています。
本番環境と同等のコンポーネントを使うことで、質の高いプロトタイプをより短時間で作成できるようになることを目指しています。

ui.shadcn.com

今回はAIツール導入の一例として、v0の導入効果についてご紹介しました。参考になれば幸いです。

似顔絵
書いた人:田向

SaaS型翻訳サービスWeglotでReact SPAを多言語化した

はじめに

こんにちは。エンジニアリングマネージャの星野です。今回はReactを用いたSPAの他言語化についての事例を紹介します。

テックドクターで開発している臨床研究支援システム「SelfBase」において、海外案件への対応をきっかけに管理画面の多言語化が必要となりました。

フロントエンドはReactを用いたSPA(Single Page Application)で構築されており、自前で実装する場合は react-i18next などを利用したi18n(国際化)対応を行うことになります。そのためには数ヶ月単位の少なくない開発工数が見込まれました。

そこで、「自前での実装」という選択肢だけでなく、「SaaS型の翻訳サービス」の導入を本格的に検討することにしました。


フロントエンド技術スタックと検討ポイント

多言語化を検討するにあたり、技術スタックとSaaS選定のポイントについて説明します。

フロントエンド技術スタック

フロントエンドの主要な技術スタックは以下の通りです。

  • UI: React
  • 言語: TypeScript
  • ルーティング: React Router
  • スタイリング: MUI + Emotion

SaaS選定における検討のポイント

SaaSを選定するにあたっては、開発工数をかけずに多言語化を実現するため、いくつかの点を重視しました。

  1. SPAに対応していること。
  2. 翻訳管理が容易であること。たとえば、翻訳された文言の一元管理機能ができる、ユーザーデータのような翻訳不要な要素を柔軟に除外できる、など。
  3. 予算内で導入できるコストであるか。
  4. SOC2やGDPRなどのセキュリティ基準を満たしているか。
  5. 国内外での十分な導入実績があるか。

SaaSによる多言語化の比較検討

上記のポイントに基づき、いくつかのSaaSを比較検討しました。

サービス名 SPA対応 価格帯 特徴
shutto翻訳 実績不明 安価 国内サービス。SPAでの利用実績が確認できず、今回の要件には合致しませんでした。
WOVN.io 対応可能 高額(要問合せ) 日本国内での大手企業による採用実績が豊富。ただし、コストが比較的高額でした。アプリの翻訳機能もあり、予算があるなら利用したかった。
ConveyThis 対応可能 安価 比較的安価で導入しやすそう。ただ、後述するWeglotと比較すると実績やシェアの面で見劣りしていました。
Weglot 対応可能 手頃 SPA対応が可能。料金も手頃で、セキュリティ基準も満たしていました。調べた範囲ではPV数ベースの集計において高いシェアを持っているようでした。

検討結果

比較検討の結果、Weglotを導入することにしました。理由は下記です。

  • ReactなどのSPAプロジェクトへの導入がヘルプに記載されており、実績があると判断できました。
  • 実際にテストで動作させてみても、問題なく対応していることが確認できました。
  • 翻訳結果を管理するダッシュボードがあり、非エンジニアでも直感的に翻訳の修正や管理が可能でした。
  • AIを利用した翻訳が利用でき、翻訳の修正工数を減らせると期待しました。
  • SOC2およびGDPRに準拠しており、エンタープライズレベルのセキュリティ要件を満たしています。
  • Google検索やWappalyzerなどの情報から、ある程度シェアがあると判断。国外では大手での導入実績もあるようでした。
  • 必要な機能を備えつつ、料金が手頃で、予算感に最もマッチしていました。

実装と運用の工夫

Weglotの導入はスムーズでしたが、実際のプロダクトで運用するためにはいくつかの工夫が必要でした。

導入方法

  • Weglotでのプロジェクト作成

Weglotでアカウントを作成し、翻訳元(日本語)と翻訳先の言語(英語など)を設定します。

  • インテグレーションの方式の選択

サブドメイン方式とサブディレクトリ方式があります
SEOなどの観点からサブディレクトリ方式が推奨されていますが、今回はサブドメイン方式を選択しました。
理由としては、既存サイト全体がWeglot経由で配信されようになるため、影響範囲を抑える目的でサブドメイン方式を選択しました。

  • DNSレコードの設定

WeglotからCNAMEに登録すべきURLが提供されるので、利用しているDNSプロバイダー(Amazon Route 53、Google Cloud DNSなど)から、指定したサブドメインをCNAMEレコードとして、追加します。
この設定により、例えば、en.example.comへのアクセスがWeglotのサーバーに向けられ、翻訳されたコンテンツが配信されるようになります。

  • JavaScriptスニペットの埋め込み

発行されたAPIキーを含むJavaScriptスニペットをWebサイトに埋め込みます。
これは主に、翻訳単語の抽出や言語切り替えボタン(言語スイッチャー)の表示、言語によるサイトの自動切り替えなどに利用します。

  • SPA特有の設定

デフォルトでは動的に描画されるコンテンツは翻訳されません。
翻訳対象に含めるため、Weglotのプロジェクト設定にある Dynamic Element にて、 body セレクタを指定します。

キャプチャ
動的要素の翻訳設定画面

実装上の注意点

Weglotは手軽に導入できる反面、自由度は低いと感じました。
そのため運用にあたっては、以下のような工夫が必要でした。

翻訳対象の除外設定:

Weglotはデフォルトでページ上の全テキストを翻訳しようとします。翻訳が不要な箇所については、翻訳対象から除外する設定を行う必要がありました。

  • ユーザー名などのユーザが入力した内容

本来翻訳の対象としたくない部分ですし、翻訳の管理上もユーザが新しい単語を入力するために訳語の設定が必要となってしまうのは好ましくありません。
また、翻訳語数ベースの課金モデルにおいてコストを抑える意図もあります。

対策としては、翻訳したくない要素をラップするコンポーネントをReactで作成、特定のCSSセレクタを持つ要素を翻訳対象から除外する機能と組み合わせ、自動翻訳を抑止しました。

export const NoTranslate = forwardRef<HTMLElement, BoxProps>(
  ({ component = 'span', ...boxProps }, ref) => (
    <Box
      ref={ref}
      className="no-translate"
      translate="no"
      component={component}
      {...boxProps}
    >
      {boxProps.children}
    </Box>
  ),
)

NoTranslate.displayName = 'NoTranslate'

NoTranslateコンポーネント実装イメージ
 

キャプチャ
Weglotの自動翻訳抑止機能
  • 日付や数値のローカライズ

日付や数値は翻訳ではなく、各言語の文化に合わせたフォーマットが必要です。これらは翻訳対象から除外し、Intl.DateTimeFormatIntl.NumberFormatといったブラウザ標準APIで対応しました。

動的テキストの制約(語順と単数/複数形):

例えば {count}日間以上デバイスのデータがアップロードされてない場合 のような動的に数値を埋め込むテキストを翻訳する場合、{count} 部分は翻訳から除外するため、数字を除いた「日間以上デバイスのデータがアップロードされてない場合」というテキストをもとに翻訳が行われます。

これにより、2つの大きな問題が生じます。

1つ目は語順の問題です。
翻訳後のテキストは、翻訳されなかった {count} の後ろに単純に連結されます。しかし、多くの言語では日本語と語順が異なります。例えば、英語で「For more than {count} days...」のような {count} の前に前置詞が来るケースがありますし、他にも文法上、数字が文中に入ってしまう場合もあります。こういった文章は自然に作ることができません。

2つ目は単数/複数形の切り替えです。`{count}`の値に応じて `1 day` / `2 days` のように単語の形を変化させることができません。

今回はこれらの制約を許容し、妥協案として {count} day(s) without device data uploaded のような、英語表記で対応しました。

1つ目の問題は翻訳元の文に変数プレースホルダーを挿入する「variables」機能を利用することで解決はできそうでしたが、今回はそこまで対応することができませんでした。

文脈に応じた翻訳の難しさ

Weglotでは、基本的に一つの単語は一つの訳語に対応します。例えば「日」という単語を day(s) と翻訳した場合、別のページで「日曜日」の文脈で使われていても day(s) と訳されてしまう可能性があります。これを防ぐには、元の日本語の単語を文脈に応じてより具体的に(例:「日付」「日曜日」など)使い分けるといった、原文側での工夫が必要になります。

Weglotよりもさらに厳密な表現や文脈に合わせた翻訳が求められる場合は、やはり react-i18next のようなi18nライブラリによる本格的な対応が必要と感じました。

キャプチャ
該当部分の翻訳前
キャプチャ
該当部分の翻訳後

翻訳の質と翻訳管理について

自動翻訳の品質は、まずまずといった程度でしたが、AI機能を使うとかなり不自然な内容は減りました。

長文では、手動での言い換えが必要になる場合も多少ありましたが、短いフレーズや単語レベルの翻訳は正確でした。全体としては十分に実用的な品質といえます。

また、専門用語や製品名などについては、翻訳するかどうかも含めて辞書登録できる点が便利でした。

キャプチャ
会社名や、製品名を登録した例

さらに、翻訳結果にレビュー済みかどうかが分かる仕組みが用意されており、今後画面修正などが発生した際にも、追加翻訳をスムーズに行えるようになっています。

キャプチャ
フィルタ機能で、自動翻訳対象のテキストを検索している様子。(すべて翻訳が登録されているので結果は0件)

i18nライブラリのように翻訳者が翻訳ファイルを直接編集するわけではないため、翻訳者と開発者の役割分担が明確になり、運用がスムーズになる点もメリットと感じています。

キャプチャ
コラボレーションするための画面。日本語と英語訳が表示される

導入後の感想

Weglotの導入を決定したことで、当初3ヶ月以上かかると想定していた多言語化対応が、1ヶ月程度で完了する見込みとなりました!
これにより、SaaS導入は期待通りにエンジニアの工数を大幅に削減でき、非常に助かりました。

課題点としては、初回アクセス時は一瞬だけ日本語が表示されてから英語に切り替わるという動作になっています。
この点は、当初から理解していて実用上は問題ないと判断していましたが、不自然さは感じてしまいます。

今後について

今回は、まずはいったん多言語サイトをサービス提供することが重要だと考えて、SaaSという選択肢を選びました。
ですが、一瞬日本語で表示されてしまう点や翻訳の制限などから、どこかでi18nライブラリを用いた本格的な多言語化基盤の構築も必要だと感じています。

その際には、今回見送った react-i18next によるi18n基盤の構築を再度検討し、翻訳ファイルの管理に特化したSaaS(Lokalise, Transifexなど)へ移行することも検討したいと考えています。

まずは、Weglotで翻訳の知見を溜めつつ、翻訳精度の向上や用語の統一を行うことでグローバルに通用するサービスの第一歩目としていきます。


似顔絵
書いた人:星野

ストレスが続くと肌が荒れる?:ウェアラブルデータで解き明かす皮脂と自律神経活動の関係

こんにちは。データサイエンスチームの望月です。

みなさんは、ストレスが続くと肌荒れや吹き出物が増えたり、寝不足のときに肌の調子が崩れたりした経験はありませんか?

「肌は心を映す鏡」と表現されるように、古くから肌と心(自律神経)の状態は密接に関わっていると考えられてきました。
ストレスや睡眠不足が肌にあらわれる──その背景には自律神経の乱れやホルモン分泌の変化が関わっています。

肌荒れのイメージイラスト

とりわけ肌の状態を左右する大きな要因のひとつが「皮脂」です。
皮脂が過剰に分泌されると肌荒れやニキビ、テカリ、化粧崩れの原因となり、逆に不足すると乾燥につながります。
皮脂分泌にはホルモン、自律神経、生活習慣といったさまざまな要因が関わると考えられており、皮膚科学領域でも注目されてきました。

しかし、ウェアラブルデバイスで測定した心拍変動(HRV)や睡眠といった客観的なデータを用いて、皮脂量との関連を統計的に解析した例はほとんどありません。
私たちは、この「肌と心の関係」をより科学的に捉えるため、ウェアラブルデータ(※)と皮脂測定を組み合わせて解析しました。その結果、小規模データながらも皮脂量と自律神経活動に確かな関連が見えてきました。不規則な生活習慣やストレス、ホルモンバランスの変動で皮脂量が増加する可能性が見えてきたのです。

今回は、その詳細をご紹介します。

※ウェアラブルデータ……スマートウォッチを代表とするウェアラブル端末で測定された生体情報等のデータ

研究概要

今回の解析には、女性5名・男性3名の合計8名にご協力いただき、14日間にわたりデータを収集しました。

皮脂量の測定

皮脂の量は、皮膚研究で用いられる皮脂チェッカーを使用し、毎朝の起床時に額と頬の2か所から採取しました。
皮脂チェッカーはシート状の測定器具で、肌に押し当てると皮脂が付着した部分が黒くなります。その写真を参加者にスマートフォンで撮影してもらい収集、画像を白黒に変換して数値化し、皮脂レベルとして解析に利用しています。

皮脂レベル解析の手順

生体データの取得

生体データ(心拍数や睡眠、活動量など)は Fitbit を用いて取得しました。日中の活動データとその夜の睡眠データを、翌朝の皮脂レベルに対応させて関係を調べます。

Fitbitの写真
Fitbitの例(Fitbit Inspire 3。Googleストアより引用)

解析方法

解析を行い、皮脂量と関係する生体データがあるかを調べました。
対象とした生体データは、心拍数、睡眠、そして活動量に関するものです。

まず、心拍数データから心拍変動指標(HRV) を算出し、睡眠中の平均心拍数や、自律神経の活動を評価する複数のHRV指標を解析に取り入れました。

さらに、睡眠指標として、総睡眠時間や各睡眠ステージ(覚醒、レム睡眠、浅い睡眠、深い睡眠)の時間を解析に加えました。

これらのデータと皮脂量との関連を統計的に調べるため、皮脂量を中央値で2群(低皮脂量群と高皮脂量群)に分け、各種パラメータを比較・評価しました。また、皮脂量には性差があることが知られているため、男女を分けてそれぞれ解析を行いました。

解析結果

皮脂量の性差・部位差

皮脂量には性差と部位差が見られました。
全体として、男性は女性より皮脂量が多く、また額は頬に比べて多い傾向が見られました。

グラフ

睡眠中の自律神経活動(HRV)と皮脂量

自律神経活動は「心拍変動(HRV)」から推定することができます。HRVとは、心拍のゆらぎの大きさを表す指標で、リラックス時など副交感神経が優位なときに大きく、緊張時など交感神経が優位なときに小さくなる特徴があります。

解析の結果、皮脂量が多いほど睡眠中に交感神経優位の状態にある可能性が示されました。
具体的には、皮脂の多い群では男女ともに、睡眠中のHRV指標であるRMSSDが低く、交感神経の活動を示すCSIが高く、副交感神経の活動を示すCVIが低い傾向が見られました。

睡眠中RMSSD

グラフ

睡眠中CSI

グラフ

睡眠中CVI

グラフ

睡眠指標と皮脂量

睡眠指標との関係を調べたところ、女性では皮脂の多い群で総睡眠時間とレム睡眠が有意に短く、深い睡眠も短い傾向が見られました。
一方、男性では皮脂の多い群で深い睡眠が有意に短いことが分かりました。
このことから、睡眠の質の低下も皮脂量の増加に関わっている可能性があると考えられます。

睡眠時間

グラフ

レム睡眠時間

グラフ

深い睡眠時間

グラフ

まとめ

こうした肌と心の関係はこれまで皮膚科学や精神皮膚医学の分野では研究されてきましたが、今回のようにウェアラブルデータで皮脂量と自律神経活動のつながりを検証した例はほとんどありません。

解析の結果から、皮脂量が多い群では睡眠中の副交感神経活動(RMSSD、CVI)が低く、交感神経活動(CSI)が高い傾向が見られました。さらに、女性では総睡眠時間・レム睡眠・深い睡眠が短く、男性でも深い睡眠が短いという特徴が明らかになりました。

一般に、交感神経が優位な状態では、腎臓の上にある副腎皮質からコルチゾール(代表的なストレスホルモン)やアドレナリンといったホルモンが分泌されます。その際、男性ホルモンの一種であるアンドロゲンも増加することが知られており、このアンドロゲンは皮脂分泌を促す作用を持っています。
今回の結果は、不規則な生活習慣(睡眠時間や睡眠の質)、ストレス、ホルモンバランスの変動によって交感神経が優位になり、その結果として皮脂量が増加するという可能性を支持しています。

小規模の解析ではありますが、ウェアラブルデバイスによる客観的なデータを用いて、皮脂量と自律神経活動との関連を統計的に示した初めての試みの一つといえるかもしれません。今後の研究により、肌と生活習慣、自律神経の関係がさらに解明されていくことが期待されます。

最後に

将来的には、自律神経や睡眠といったウェアラブルデータから皮脂量の増減を予測することで、スキンケアの方法を変えたり、生活習慣を見直すきっかけをユーザーに提供できる可能性があります。

「自分の肌の変化を先取りしてケアする」そんな未来が、データサイエンスによって実現に近づいています。


書いた人:望月

ウェアラブルデバイス(Fitbit) × Apache Hudiによるデータ収集基盤の設計案

こんにちは。テックドクターのエンジニア、伊藤です。

テックドクターでは、心拍や睡眠などの日常の生体データを取得するためにウェアラブルデバイスを活用しています。これらのデバイスで集めたデータを使って、健康管理やフィットネスの向上、さらには医療分野への応用を目指しています。

いま世の中に出回っているウェアラブルデバイスの例としては、 Apple Watch、Garmin、Fitbit などがあります。これらは(理想的には) 24 時間 365 日データを取得し続けることができます。そのデータは同期している iPhone のストレージや各社のサーバーに保存されますが、活用のためにデータを取得したいときはWeb API などを通じて取得することになります。

その際、日付などの条件を指定してデータを取得する以外に、(仕組みが提供されていれば)Webhook などを活用してリアルタイムに近い形でデータを取得することも可能です。

後者のようなリアルタイム/準リアルタイムのデータ取得は、ユーザーの行動変化に即座に対応したい場合や、継続的なモニタリングが必要な場合に特に有用です。ただし、それに対応したデータ収集基盤の構築も必要となってきます。

この記事では、 Fitbit に対してリアルタイムでデータ収集をする際の基盤設計案を紹介したいと思います。なお、今回は Fitbit を例としますが、似たような構成の API や Webhook 機能を提供しているサービスであれば、同様のアーキテクチャを適用可能だと思います。

Fitbitデータ収集における課題

まずは、今回のデータ取得の課題について整理しておきましょう。ウェアラブルデバイスという特性上発生する課題と、 Fitbit API による課題があります。

課題
  • データ更新の頻度: ユーザーの日次データ(歩数、睡眠時間等)は一日中継続的に更新される
  • 遅延データの到着: デバイス同期のタイミングにより、過去日時のデータが後から送信される場合がある
  • レート制限: Fitbit API には厳格な制限があり、効率的なデータ収集戦略が必要
  • データ一貫性: リアルタイム性とデータ整合性のバランス
  • スケーラビリティ: ユーザー数の増加に対応可能な設計
  • コスト最適化: 不要な API 呼び出しの削減

ここで注目してほしいのが最初の2つ「データ更新の頻度」「遅延データの到着」です。実はこれらの特性にぴったりの強みを持ったプラットフォームがあります。それが Apache Hudi です。

Apache Hudi とは?

Apache Hudi が何であるか、まずは公式ドキュメントから引用します。

What is Apache Hudi

Apache Hudi (pronounced "hoodie") pioneered the concept of "transactional data lakes", which is more popularly known today as the data lakehouse architecture. Today, Hudi has grown into an open data lakehouse platform, with a open table format purpose-built for high performance writes on incremental data pipelines and fast query performance due to comprehensive table optimizations.

引用元:Apache Hudi 公式ドキュメント

全訳すると長くなるのでかいつまんで説明すると、Hudi(フーディ)はデータレイクハウスアーキテクチャを実現するためのオープンソースプロジェクトです。インクリメンタルなデータパイプラインに適していると説明されています。

また、AWSのドキュメントでは、センサーやIoTデバイスからのストリーミングデータに対して、特定のデータ挿入と更新イベントが必要な場合にHudiが適していると述べられています。

Working with streaming data from sensors and other Internet of Things (IoT) devices that require specific data insertion and update events.

引用元:AWS EMR Hudi

Hudiのこれらの強みは、まさに今回の用途、ウェアラブルデータを「準リアルタイム」に取得し「同一レコードの頻繁な更新」をする際にとても有効です。

Hudiは他にもタイムトラベル機能やスキーマ進化のサポートなど、データレイクハウスの運用に必要な機能もいろいろと備えています。

というわけで、今回はマイクロサービスアーキテクチャと Apache Hudi を組み合わせ、リアルタイム通知をマイクロバッチ処理へ変換する仕組みを考えてみることにしました。

※ただし、本記事は実装前の設計案であり、実際の運用環境での検証は今後の課題です。

Fitbit Subscription APIについて

Subscription APIとは

アーキテクチャ全体の説明に入る前に、Fitbit 側のAPIについても説明しておきます。

Fitbit Subscription APIは、ユーザがFitbitアプリを通じてFitbitサーバーにデータを同期したときに、事前に登録したサーバーにWebhookを通じて通知を送信するAPIです。これを利用することで、データの変更を即座に検知し、必要なデータ取得をトリガーできます。

参考:Fitbit Web API Documentation Using Subscriptions

通知データの構造

Subscription APIからの通知は以下のJSONフォーマットで受け取ります。

[
    {
        "collectionType": "foods",
        "date": "2010-03-01",
        "ownerId": "USER_1",
        "ownerType": "user",
        "subscriptionId": "1234"
    }
]


主要フィールドの説明

フィールド 説明
collectionType データの種類 `foods`, `activities`, `sleep`, `heartrate`
date データの日付 `2010-03-01`
ownerId ユーザー識別子 `USER_1`
ownerType オーナータイプ `user` (固定値)
subscriptionId サブスクリプション識別子 `1234`

注目して欲しい点は、通知自体には詳細なデータは含まれず、あくまで「どのユーザーのどのデータタイプが更新されたか」の情報のみであることです。実際のデータ取得にはユーザー・collectionType・日付に基づいて、別途 API 呼び出しが必要です。

Subscription API利用時の制約事項

前述したように、API 呼び出しには制限があります。

レート制限:
150 requests/hour/user

レスポンスタイムアウト:

  • Fitbitは通知送信時に5秒のタイムアウトを設定
  • エンドポイントは迅速な応答(204 No Content)が必要
  • これらが守られない場合、最終的にサブスクリプションを無効化する可能性がある

以上が Fitbit Subscription API の概要です。

アーキテクチャの全体像

全体のシステム構成図

いよいよシステムの全体像です。

大きく以下の2つの部分に分けられます。
①Fitbit Subscriptionからの通知を受け取り永続化する
②ミニバッチでAPI呼び出しを行い、データをApache Hudi形式で永続化する

クラウド基盤としては Google Cloud を使用します。

構成図

各コンポーネントについてまとめます。

①通知処理部分

インフラ (コンポーネント名) 役割 主な責務
Cloud Run Gateway Notification 受信専用 高速応答、Pub/Sub 転送
Cloud Pub/Sub メッセージング基盤 非同期処理
Cloud Run Ingestor 永続化サービス NotificationのGCS への書き込み、メタデータ付与
Cloud Storage (GCS) Notification 永続化 Notification の蓄積

②ミニバッチ部分

インフラ (コンポーネント名) 役割 主な責務
Cloud Scheduler 定期実行 バッチ処理トリガー
Cloud Run Batch バッチ処理サービス GCS からのデータ取得、API 呼び出し
Dataproc + Apache Hudi データレイクハウス 変換、保存
Cloud Storage ウェアラブルデータ永続化 ウェアラブルデータの蓄積

このような構成です。
次の項でそれぞれもう少し詳しくご説明していきます。

アーキテクチャ設計の詳細

Notification 受信層(Cloud Run + Pub/Sub)

構成図

Fitbit Subscription API の制約として、5 秒以内に 204 No Content を返すサービスを実装する必要があります。
そのためここでは通知の受信と Pub/Sub への Publish のみに特化した軽量サービスを Cloud Run で構築します。

処理の流れは以下の通りです。

  1. Fitbit からの Webhook 通知を HTTP POST で受信
  2. 受信した JSON データを最小限の検証のみ実施
  3. タイムスタンプを付与して Pub/Sub メッセージとして即座に Publish
  4. Fitbit に対して 204 No Content レスポンスを返却(5 秒以内)

Notificationデータ 永続化層(Cloud Run + GCS)

構成図

この層では Pub/Sub から受信したメッセージを構造化して GCS に保存します。バッチ処理に適した形でデータを整理し、後続のバッチジョブが効率的に処理できるようにします。

処理の流れは以下の通りです。

  1. Pub/Sub からメッセージを受信
  2. メッセージのパースと検証
  3. バッチ ID と受信タイムスタンプを付与
  4. GCS に日付・時間ベースでパーティショニングして保存


GCS 保存例:

/notifications/
├── year=2025/
│   ├── month=08/
│   │   ├── day=01/
│   │   │   ├── hour=00/
│   │   │   │   └── 01K1GFYJHR7EKM5HN9PPD2AG9V.json
│   │   │   │   ├── 01K1GFYKHKJ3KHKVY0RDGNE2GZ.json
│   │   │   └── hour=01/
│   │   └── day=02/
│   └── month=09/


保存データ形式:

{
  "original_notifications": [...],
  "metadata": {
    "received_at": "2025-08-01T14:00:00Z",
    "batch_id": "01K1GFYJHR7EKM5HN9PPD2AG9V"
  }
}

バッチ処理層(Cloud Run + Dataproc + Apache Hudi)

構成図

この層では蓄積された通知情報をもとに、ミニバッチで API 呼び出しを行います。その結果を Apache Hudi 形式でデータレイクハウスとして保存します。

処理の流れは以下の通りです。

  1. 未処理通知の発見: GCS から新しい通知ファイルを検出
  2. データのグルーピング: ユーザー・日付・データタイプで最適化されたバッチを作成
  3. Fitbit API の呼び出し: レート制限を考慮した効率的なリクエスト実行
  4. Apache Hudi での書き込み: Upsert 操作によるデータレイクハウスへの永続化

データ管理のポイントとしては簡単に以下のようなものがあります。

  • パーティショニング戦略: 日付ベースでのデータ配置最適化
  • 主キー制約: ユーザーID, データタイプ, 日付の組み合わせによる一意性保証
  • 更新時刻管理: 最新データを保持

アーキテクチャ全体の説明は以上です。

考慮事項と今後の課題

ここまでで全体的な設計案を示しましたが、実際の運用に向けてはより詳細な検討が必要になる点もあります。

例えばバッチ処理層では、単なるAPI呼び出しを行うような書き方をしましたが、実際には蓄積された通知をグルーピングして API 呼び出しを行う際にレート制限を超えないようにするための工夫が必要です。

また、繰り返しになりますがFitbit は「データが更新された」という通知のみを送信し、実際のデータは別途 API で取得する仕組みです。しかし、他のウェアラブルデバイスでは、通知と同時に実際の測定データ(心拍数、歩数など)をストリームとして直接送信する場合があります。その場合、このアーキテクチャに沿って単にそのデータを保存するだけだと、障害などでデータを取り逃がした際にデータ損失を起こす可能性があります。別途API での再取得機能を実装したり、データの完全性を担保する仕組みが必要になるでしょう。

以上、参考になれば幸いです。

第22回日本うつ病学会にて、抑うつ気分の予測に関するポスター発表を行いました

こんにちは、データサイエンス部の深見です。

テックドクターのデータサイエンスチームでは、定期的に学会への参加や登壇を行なっています。今回は7/11-12に浜松町で開催された第22回日本うつ病学会総会にてポスター発表を行なってきましたので、その内容を紹介いたします。

私自身はちょうど一年前の日本睡眠学会での登壇以来、久しぶりの学会参加でした。またポスター発表となると本当に久しぶりで、最後は学生時代だったかもしれません。

私以外にもテックドクター社としては、これまでにも日本リウマチ学会や国際医薬経済・アウトカム研究会(ISPOR)など国内外の様々な学会に参加して研究の成果を報告しています。

www.technology-doctor.com

今回の発表内容は、いわゆるケーススタディという形式で、一人の被験者の長期に渡るウェアラブルバイスのデータを解析したものです。

ウェアラブルバイスを使うと、これまでは病院に来院したタイミングでしか分からなかった被験者の状態が、連続的にかつ細かな粒度で把握できます。このことが医療の質を上げるのに役立つと期待されています。

特にスマートフォンウェアラブルバイスから得られる生体データをもとにした指標「デジタルバイオマーカー」は、疾患の有無や病状の変化を客観的に評価することができる可能性を秘めており、その開発は国内でも注目が高まっています。

※デジタルバイオマーカーの詳細については弊社の下記サイトを参照してください。
デジタルバイオマーカー | TechDoctor

研究概要

演題:
双極性障害患者におけるウェアラブルバイスを用いたデジタルバイオマーカーの探索」

被験者:
2010年に診断を受けた双極性障害患者1名(40代・男性)

目的:
双極性障害の症状(特に抑うつ気分)に関連するデジタルバイオマーカーの発見。

対象データ

収集したデータは、ウェアラブルバイスFitbit Charge 6を使った生体データ(測定値)と、eMoodsというアプリを使った主観データ(自己申告の症状記録)です。それぞれどんな項目が対象かは、下記の表を見てください。

収集データの一覧表

期間としては2024年2月から11月まで9ヶ月間のデータを解析対象としました。こちらは現在も収集が続いており、今後も継続して解析を実施する予定です。

解析方法

主観データの中でも、特に「抑うつ気分」に着目、この指標と連動する生体データがないか調べました。

具体的には、

心拍数データをもとに算出した心拍変動指標
例)
・睡眠中の平均心拍数
・自律神経の活動を評価する複数の心拍変動

睡眠指標
例)
・ 睡眠時間
・各睡眠ステージ(覚醒/レム睡眠/浅い睡眠/深い睡眠)の時間や比率

などを使用しました。

これらの指標を1日ごとに算出、大きく変動した値(全期間の平均と標準偏差から、平均値±標準偏差以上、離れているもの)を異常値として抽出しました。

解析結果

副交感神経の活動を評価するRMSSD(Root Mean Square of Successive Differences)という心拍変動指標があります。一般的に、RMSSDの数値が大きくなると副交感神経が優位になる=リラックスした状態、逆に値が小さくなるとリラックスできていない状態と考えることができます。

今回の研究により、睡眠中のRMSSDの値に抑うつ気分との関連が見られることがわかりました。
実際のデータを見てみましょう。

睡眠中のRMSSDの値と抑うつ気分との関連グラフ

特に期間の後半、抑うつ気分の高まり(図中、青棒グラフが2点以上)とともにRMSSDのトレンド成分(図中、赤線グラフ)が下降していました。

また期間中にRMSSDが平均より大きく低下(1.5SD以上)した日が14日ありましたが、そのうち12日にその後1週間以内に抑うつ気分スコアの上昇が確認されました。

これらの結果は、睡眠中の副交感神経活動の低下を見ることでその後の抑うつ気分を予測できる可能性を示しています。もっと研究を進めることで、抑うつ気分を推定するバイオマーカーとして有用と判明するかもしれません。

今回の発表では一人の被験者に対して抑うつ気分との関連を調査しましたが、抑うつ気分以外の主観指標と生体データの関連の解析や、大人数を対象にした検証も実施しています。今後さらに内容を充実させた上で論文を投稿する予定です。

発表を通しての印象

ポスター発表では演題が60以上あり、演題番号が奇数のものが初日に、偶数のものが2日目にそれぞれ30分発表を行う形式でした。

私の発表は2日目の土曜日の14時からと比較的足を運びやすい時間帯だったこともあってか、時間中ひっきりなしに質問があり、5名程度の方に説明をすることができました。それ以外にも足を止めてくださる方、遠目に見ていただける方が何名かいらっしゃいましたのでそれなりに興味を持っていただけたのではないかと思います。
※会場内撮影禁止でしたので残念ながら写真はありません…

またポスター発表の他にも、父親の産後うつに関するセッションや、精神疾患の治療において薬を使った場合と精神療法を用いた場合の比較に関するセッションに参加し、最新の研究に触れることができました。こういった知見を日々の研究に活かしていきたいと思います。

まとめ

いかがでしたでしょうか。テックドクターは、研究成果を今回のような学会発表や論文投稿という形で発表することで、医学の発展への貢献を目指しています。また対外的な発表以外にも特許という形で成果を残すこともあります。今後も学会発表や論文投稿した際には随時発信していきたいと思います。

似顔絵
書いた人:深見