【Unit7 ブログリレー7日目】こんにちは、エンジニアリンググループGMの木田です。 前日の記事は海野尾による プロダクトマネジメント1年目の学び 〜プロダクトを成功に導く「一次情報」の力〜 でした。
さて、今日はアンケートシステムの複雑な分岐制御に潜む構造的な問題を検知するための取り組みを試行錯誤した結果、一定の成果が得られたのでご紹介します。
柔軟な設問設計を実現するアンケートシステム
私たちのチームでは、m3.com会員を対象とした市場調査のためのリサーチプロダクト群を開発・運用しており、アンケートシステムはその中心的な役割を果たすものです。シンプルなアンケートであれば、Google Formなどのツールを使ってアンケートフォームの作成は可能です。しかし、市場調査においては、さらに複雑で柔軟な制御をしたい要件が多く存在するため、独自のシステムを構築しています。「複雑で柔軟な制御」の例を挙げると、具体的には以下のようなものがあります。
- 条件分岐: 複数の設問の回答の組み合わせに応じて次に表示するページを変えたり、一部の設問をスキップしたりする機能
- 割付: 回答者の回答内容(例えば年代や勤務先の都道府県など)に応じてセグメント分けを行い、各セグメントについて計画通りのサンプル数で回答を集めるための機能
- 回答内容の再掲: 回答者が前のページで選択した内容を次のページの質問文で引用したり、バリデーション時に参照したりする機能
これらの機能の存在によって、システムは様々な用途に応じたアンケートフォームを動的に生成するための表現力をもち、アンケートの設計は一種のプログラミング的な側面もあると考えています。
詳しくはチームの紹介資料や、過去のブログ記事 をご覧ください。
複雑なアンケートにはテストが必要
複雑な制御を実現したアンケート設計が可能になる一方で、設計ミスやバグが潜むリスクも増大します。たとえば条件分岐を多用してページ遷移が入り組んだアンケートでは、必ず答えて欲しい設問がスキップされてしまったり、到達不可能なページが存在したりといった問題が設定ミスによって発生する可能性があります。
上記のようなケース以外にも、選択肢の表示設定や配信設定などチェックすべき観点が多く存在します。 設定ミスで回答者に迷惑をかけたり、調査結果の信頼性が損なわれたりしないように、私たちのチームではアンケート配信前にQA担当者がチェックを行う品質保証 (QA) をしています。
これらのQAチェックは主にチェックリストを用いたQA担当者による目視確認や動作プレビューと呼ばれるモードでの手動テストで行われています。ビジネスの成長に伴い、アンケートの実施数や複雑度が増加しており、QA担当者の負荷は年々増大しています。
少しでもQA担当者の負荷を軽減し、品質を向上させるために、今回はアンケートの分岐構造を自動解析するツールの開発に挑戦しました。 アンケート設計がプログラミング的な側面を持つということは、eslintやflake8のような静的コード解析ツールが欲しいだろうということで、設計段階で構造的な不備を自動検出するツールを作れないかと考えました。
アンケートのページ遷移を有向グラフとして捉える
構造解析のアプローチとして、アンケートフローを有向グラフとして捉え、経路問題として扱うことにしました。ノード(頂点)を各ページ(表紙、質問、条件分岐、終了ページなど)、エッジ(辺)をページ間の遷移(条件付き分岐を含む)として表現します。この抽象化により、複雑なアンケート設定からページ間の構造情報のみを抽出し、構造的問題を機械的に検出できます。このアプローチ自体は、静的コード解析などでも用いられる制御フローグラフと類似しており、特に目新しいアイデアではありませんが、割付や条件分岐などアンケートの多様な制御を構造的に解釈するのには丁度良いと考えました。
制約の形式化
まずQA時のチェックリストをもとに、グラフ探索アルゴリズムで検査できそうな制約をリストアップしました。制約は他にも複数ありますが、代表的なものを以下に紹介します。
- 必ず表紙から開始して、終了ページで終わること。
- 表紙から到達不可能なページが存在しないこと。
- 割り付けの判定を迂回するような条件分岐がないこと。
これらの制約事項をそれぞれ今回のデータ構造を念頭に言い換えるとこのようになります。
- 表紙が開始ノード(入力エッジなし)、終了ページが終了ノード(出力エッジなし)であり、表紙から終了ページへの経路が1つ以上存在すること
- 表紙から到達不可能なノードが存在しないこと
- 表紙から終了ページへのすべての経路に割付ノードが含まれること
これらの制約が満たされているかをチェックできれば、構造的な問題を検出できます。
開始ノード・終了ノードについては、有向グラフを構築した際にチェックできます。特定ノード間の経路の存在(到達可能性)は、幅優先探索(BFS)や深さ優先探索(DFS)といったグラフ探索アルゴリズムで検証できます。
実装例の紹介
ここからは、実際に開発したプロトタイプをもとに、説明のために簡略化したコードを交えて上記制約の検証事例を紹介します。構造チェックの実装先としてはアンケートシステム本体のバリデーションロジックとしての組み込みや、GUIを持つアンケート製作者向けセルフチェックツールなども考えましたが、アイデアの検証を優先してまずはPythonで素朴なCLIツールとして実装しました。
下記のようなデータ構造でアンケートのページ間の関連を有向グラフとして表現し、探索アルゴリズムを実装していきます。ゆくゆく別言語への移植含め計画しているので一旦は素朴なデータ構造で実装を進めましたが、PythonであればNetworkXのようなライブラリを利用するのも良いでしょう。
from dataclasses import dataclass, field from typing import Any @dataclass class Node: id: str label: str = "" attributes: dict[str, Any] = field(default_factory=dict) @dataclass class Edge: from_node: Node to_node: Node attributes: dict[str, Any] = field(default_factory=dict) class Graph: def __init__(self): self.nodes: dict[str, Node] = {} self.edges: list[Edge] = [] self.outgoing_edges: dict[str, list[Edge]] = {} self.incoming_edges: dict[str, list[Edge]] = {}
例1: 到達不可能なページが存在しないことの検証
到達不可能なページの検出について説明します。これはプログラムでいうところのデッドコードのようなもので、編集を複数回繰り返す過程でのページ消し忘れや分岐設定ミスで発生しがちです。表紙からDFSで訪問済みノードをマークしていき、到達不可能なノードを検出します。
from collections import deque def find_unreachable_nodes(graph: Graph) -> set[str]: """表紙から到達不可能なノードを検出""" start_id = "cover" # 表紙ノードのID reachable = dfs(graph, start_id) all_nodes = set(graph.nodes.keys()) return all_nodes - reachable def dfs(graph: Graph, node_id: str, visited: set[str] = None) -> set[str]: if visited is None: visited = set() visited.add(node_id) for edge in graph.outgoing_edges.get(node_id, []): if edge.to_node.id not in visited: dfs(graph, edge.to_node.id, visited) return visited
例2:割付のバイパス検知
次に、割付のバイパス検知の例を紹介します。割付ノードを経由せずに表紙から終了ページに到達する経路が存在しないかを検証します。条件分岐などの設定ミスで割付を飛ばしてしまうようなケースが存在すると、"どのセグメントにも属さない回答"という想定外のデータが発生してしまうので、必ずチェックしたい項目です。
問題の解き方としては複数のアプローチが考えられます。
- 表紙から終了ページまでの経路を列挙し、割付ノードが必ず含まれるかをチェック
- 割付ノードに侵入禁止フラグ等を設定し、割付ノードを経由しない経路の有無をチェック
- 割付ノードを除外したグラフで、表紙から終了ページまでの経路が存在するかをチェック
これらのバリエーションのうち、経路を全列挙するよりも反例を1つ見つける方が効率的であるため、今回お示しするリストではBFSで経路を探索する際に割付ノードへの遷移はキューに積まないという方法で実現しています。
def has_allocation_bypass(graph: Graph, allocation_ids: set[str]) -> bool: start_id = "cover" # 表紙ノードのID end_id = "complete" # 終了ノードのID visited = set() queue = deque([start_id]) while queue: node_id = queue.popleft() if node_id in visited: continue visited.add(node_id) if node_id == end_id: return True for edge in graph.outgoing_edges.get(node_id, []): if edge.to_node.id not in allocation_ids and edge.to_node.id not in visited: queue.append(edge.to_node.id) return False
上記の検証例に係る計算量はO(V+E)(V: ノード数、E: エッジ数)で、数十ページのアンケートでも概ね実用的な時間で実行できます。
アンケート定義データをデータベースから取得してグラフに変換する箇所については、詳細をお話しできないこととひたすら泥臭いデータ変換処理になってしまったので割愛します。 余談ですが、変換処理の実装はサンプルデータをいくつかAIに渡してVibe Codingで実装しました。AIに指示するタスクが具体的かつ適切なサイズだったためか、スムーズに実装が進みました。
QA担当者の反応
データベースからアンケートの定義情報を抽出し、上記で説明したグラフ構造に変換して解析するCLIツールを開発しました。 過去のQAで頻出の実装ミスや、実際のトラブル事例をもとに作成した、意図的に不備を含むアンケートデータでトライアルを行ったところ、意図通り検出できました。検証結果をQA担当者に見せたところ、「割付のチェックは見落としがちなので助かる」と反応も良好で、アンケート製作者向けのセルフチェックツールとしての展開を期待する声もいただきました。
まとめ
アンケートの分岐構造を有向グラフとして捉え、経路探索アルゴリズムを用いて構造的な問題を自動検知する試みについて紹介しました。現在はまだ静的解析ツールとしての位置づけですが、将来的にはアンケートシステム本体への組み込みによる設計者のセルフチェック機能や、経路列挙を活用したテストケースの自動生成といった応用も考えています。
AIを組み込んだ機能開発事例が多くなってきた昨今ですが、本稿で紹介した解析ロジック自体は決定的な挙動をするものです。 アンケートの品質保証をする上でも1日目の秦野の記事で紹介したようなAIを用いた手法との組み合わせが有効と考えております。さらなる検証と改善を進め、進展があればまたこの場をお借りして報告できればと思います。
We are hiring!
エムスリーではデータを活用して様々なサービスを開発するエンジニアを募集中です。少しでもご興味をお持ちの方は、以下ページよりカジュアル面談等にお申し込みください!