Skip to content
Published on

[深層強化学習] 13. ウェブナビゲーションと強化学習

Authors

概要

ウェブブラウザを操作して情報を探し、フォームを記入し、ボタンをクリックする作業は人間には自然ですが、機械にとっては非常に困難です。ウェブページは動的で多様なレイアウトを持ち、同じ作業でもサイトごとに異なる方法で実行する必要があります。

ウェブナビゲーションに強化学習を適用すると、エージェントがウェブページの視覚情報やDOM構造を観察して適切な行動(クリック、タイピングなど)を学習できます。


ウェブナビゲーションの課題

なぜ難しいのか

ウェブ環境は従来のRL環境と大きく異なります:

  1. 巨大な状態空間:ウェブページのレンダリング結果(ピクセル)は数百万次元
  2. 巨大な行動空間:マウス位置(x, y)+ クリック/ドラッグ + キーボード入力の組み合わせ
  3. 遅延報酬:複数回のクリックと入力の後にようやくタスク完了を確認できる
  4. 部分観測性:スクロールしないと見えない要素、ポップアップ、動的ロードなど
  5. 環境の非決定性:同じページでもロード時間によって異なる状態を見せる

状態表現方式

方式説明長所短所
ピクセルベーススクリーンショットを直接入力に使用汎用的、視覚情報を含む高次元、学習が遅い
DOMベースHTML DOMツリーをパースして使用構造情報が豊富サイトごとに構造が異なる
ハイブリッドピクセル + DOM情報を結合最も豊富な情報複雑な前処理が必要

ブラウザ自動化と強化学習

環境インターフェース

ウェブブラウザをGym互換環境としてラップします:

import numpy as np

class WebEnvironment:
    """Gym互換ウェブブラウザ環境"""

    def __init__(self, task_config, screen_width=160, screen_height=210):
        self.screen_width = screen_width
        self.screen_height = screen_height
        self.task = task_config

        # 行動空間:グリッドクリック + キーボード入力
        self.grid_size = 16  # 16x16グリッド
        self.num_click_actions = self.grid_size * self.grid_size
        self.num_type_actions = 128  # ASCII文字
        self.total_actions = self.num_click_actions + self.num_type_actions

    def reset(self):
        """新しいエピソード開始:ウェブページのロード"""
        self._load_page(self.task['url'])
        screenshot = self._get_screenshot()
        return self._preprocess(screenshot)

    def step(self, action):
        """行動実行と結果の返却"""
        if action < self.num_click_actions:
            # クリック行動:グリッド座標に変換
            row = action // self.grid_size
            col = action % self.grid_size
            x = col * (self.screen_width // self.grid_size)
            y = row * (self.screen_height // self.grid_size)
            self._click(x, y)
        else:
            # タイピング行動
            char_idx = action - self.num_click_actions
            self._type_char(chr(char_idx))

        screenshot = self._get_screenshot()
        obs = self._preprocess(screenshot)

        reward = self._compute_reward()
        done = self._check_done()

        return obs, reward, done, {}

    def _preprocess(self, screenshot):
        """スクリーンショットをモデル入力に変換"""
        # リサイズと正規化
        resized = np.array(screenshot.resize((self.screen_width,
                                               self.screen_height)))
        return resized.astype(np.float32) / 255.0

Mini World of Bitsベンチマーク

OpenAIと研究者が開発したMini World of Bits(MiniWoB)は、ウェブナビゲーションRLの標準ベンチマークです。シンプルなHTMLウィジェットで特定のタスクを実行することが目標です。

タスク例

  • click-button:特定テキストが書かれたボタンをクリック
  • click-checkboxes:指定されたチェックボックスを選択
  • enter-text:テキストフィールドに指定された文字列を入力
  • navigate-tree:ツリー構造メニューを探索して特定項目を選択
  • email-inbox:メールリストから特定条件のメールを見つけてタスクを実行

各タスクは160x210ピクセルの小型ウェブページで、エージェントは10秒以内に完了する必要があります。

class MiniWoBTask:
    """MiniWoBタスク定義"""
    def __init__(self, task_name):
        self.task_name = task_name
        self.time_limit = 10.0  # 10秒
        self.reward_range = (-1.0, 1.0)

    def get_reward(self, page_state, time_elapsed):
        """タスク完了に応じた報酬"""
        if self._is_task_complete(page_state):
            # 速いほど高い報酬
            time_bonus = 1.0 - (time_elapsed / self.time_limit)
            return max(time_bonus, 0.1)
        elif time_elapsed >= self.time_limit:
            return -1.0  # タイムアウトペナルティ
        else:
            return 0.0  # 進行中

OpenAI Universe

OpenAI Universeは、さまざまな環境(ゲーム、ウェブブラウザなど)を標準インターフェースで提供するプラットフォームでした。VNC(Virtual Network Computing)を通じてブラウザ画面を観察し、マウス/キーボードイベントを送信します。

VNCベース環境の特徴

  • 実際のブラウザ(Chrome、Firefox)をDockerコンテナで実行
  • エージェントはVNCプロトコルで画面ピクセルを受信
  • 行動はマウス座標とキーボードイベントで送信
  • ネットワーク遅延とレンダリング遅延が存在
class VNCActionSpace:
    """VNCベースの行動空間"""

    def __init__(self, screen_width, screen_height):
        self.screen_width = screen_width
        self.screen_height = screen_height

    def click(self, x, y):
        """マウスクリックイベント生成"""
        return {
            'type': 'pointer',
            'x': int(x),
            'y': int(y),
            'button': 1,  # 左クリック
        }

    def type_text(self, text):
        """キーボード入力イベント生成"""
        events = []
        for char in text:
            events.append({
                'type': 'key',
                'key': char,
                'action': 'press',
            })
        return events

    def scroll(self, x, y, direction='down'):
        """スクロールイベント生成"""
        delta = -3 if direction == 'down' else 3
        return {
            'type': 'scroll',
            'x': int(x),
            'y': int(y),
            'delta': delta,
        }

シンプルクリックアプローチ

最も基本的なウェブナビゲーションエージェントは、画面をグリッドに分割し、どのセルをクリックするかを決定する分類問題に変換します。

グリッド行動空間

class GridActionSpace:
    """画面をNxNグリッドに分割してクリック位置を決定"""

    def __init__(self, screen_w, screen_h, grid_n=16):
        self.screen_w = screen_w
        self.screen_h = screen_h
        self.grid_n = grid_n
        self.cell_w = screen_w / grid_n
        self.cell_h = screen_h / grid_n
        self.n_actions = grid_n * grid_n

    def action_to_coordinate(self, action_idx):
        """行動インデックスを画面座標に変換"""
        row = action_idx // self.grid_n
        col = action_idx % self.grid_n
        # セル中央座標
        x = (col + 0.5) * self.cell_w
        y = (row + 0.5) * self.cell_h
        return int(x), int(y)

    def coordinate_to_action(self, x, y):
        """画面座標を行動インデックスに変換"""
        col = min(int(x / self.cell_w), self.grid_n - 1)
        row = min(int(y / self.cell_h), self.grid_n - 1)
        return row * self.grid_n + col

CNNベースモデル

スクリーンショットを入力として受け取り、グリッドセルのクリック確率を出力するモデル:

import torch
import torch.nn as nn

class WebNavigationModel(nn.Module):
    """CNNベースのウェブナビゲーションエージェント"""

    def __init__(self, grid_size=16):
        super().__init__()
        self.grid_size = grid_size
        n_actions = grid_size * grid_size

        # CNNで画面特徴を抽出
        self.conv = nn.Sequential(
            nn.Conv2d(3, 32, 8, stride=4),
            nn.ReLU(),
            nn.Conv2d(32, 64, 4, stride=2),
            nn.ReLU(),
            nn.Conv2d(64, 64, 3, stride=1),
            nn.ReLU(),
        )

        # 特徴ベクトルサイズの計算
        self._feature_size = self._get_conv_output_size((3, 210, 160))

        # Actor: クリック位置の方策
        self.policy = nn.Sequential(
            nn.Linear(self._feature_size, 512),
            nn.ReLU(),
            nn.Linear(512, n_actions),
        )

        # Critic: 状態価値
        self.value = nn.Sequential(
            nn.Linear(self._feature_size, 512),
            nn.ReLU(),
            nn.Linear(512, 1),
        )

    def _get_conv_output_size(self, shape):
        with torch.no_grad():
            dummy = torch.zeros(1, *shape)
            return self.conv(dummy).view(1, -1).shape[1]

    def forward(self, screen):
        features = self.conv(screen).view(screen.size(0), -1)
        policy_logits = self.policy(features)
        value = self.value(features)
        return policy_logits, value

学習ループ

def train_web_agent(model, env, num_episodes=10000, gamma=0.99):
    """A2Cでウェブナビゲーションエージェントを学習"""
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)

    for episode in range(num_episodes):
        state = env.reset()
        done = False
        episode_reward = 0
        log_probs = []
        values = []
        rewards = []

        while not done:
            state_t = torch.FloatTensor(state).permute(2, 0, 1).unsqueeze(0)
            logits, value = model(state_t)

            probs = torch.softmax(logits, dim=-1)
            dist = torch.distributions.Categorical(probs)
            action = dist.sample()

            next_state, reward, done, info = env.step(action.item())

            log_probs.append(dist.log_prob(action))
            values.append(value.squeeze())
            rewards.append(reward)

            state = next_state
            episode_reward += reward

        # A2C更新
        returns = compute_returns(rewards, gamma)
        returns_t = torch.FloatTensor(returns)
        values_t = torch.stack(values)
        log_probs_t = torch.stack(log_probs)

        advantages = returns_t - values_t.detach()
        policy_loss = -(log_probs_t * advantages).mean()
        value_loss = (returns_t - values_t).pow(2).mean()
        loss = policy_loss + 0.5 * value_loss

        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 40.0)
        optimizer.step()

        if episode % 100 == 0:
            print(f"Episode {episode}: Reward={episode_reward:.2f}")

def compute_returns(rewards, gamma):
    returns = []
    R = 0
    for r in reversed(rewards):
        R = r + gamma * R
        returns.insert(0, R)
    return returns

人間のデモンストレーションを活用した学習

純粋なRLだけではウェブナビゲーションの学習は非常に遅くなる可能性があります。**人間のデモンストレーション(human demonstrations)**を活用すると学習を大幅に加速できます。

行動クローニング(Behavioral Cloning)

まず人間の行動を模倣する教師あり学習を行い、その後RLでファインチューニングします:

def pretrain_with_demonstrations(model, demos, num_epochs=50):
    """人間のデモンストレーションデータで事前学習"""
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
    criterion = nn.CrossEntropyLoss()

    for epoch in range(num_epochs):
        total_loss = 0
        correct = 0
        total = 0

        for screen, action in demos:
            screen_t = torch.FloatTensor(screen).permute(2, 0, 1).unsqueeze(0)
            action_t = torch.tensor([action], dtype=torch.long)

            logits, _ = model(screen_t)
            loss = criterion(logits, action_t)

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            total_loss += loss.item()
            predicted = logits.argmax(dim=-1)
            correct += (predicted == action_t).sum().item()
            total += 1

        accuracy = correct / total
        print(f"Epoch {epoch}: Loss={total_loss/len(demos):.4f}, "
              f"Accuracy={accuracy:.2%}")

    return model

デモンストレーション重み付け学習

人間のデモンストレーション経験をreplay bufferに入れて高い優先度を付与し、RL学習中も継続的に参照します:

class DemonstrationReplayBuffer:
    """デモンストレーションデータを含む優先度リプレイバッファ"""

    def __init__(self, capacity, demo_ratio=0.25):
        self.capacity = capacity
        self.demo_ratio = demo_ratio
        self.demo_buffer = []
        self.agent_buffer = []

    def add_demonstration(self, transition):
        self.demo_buffer.append(transition)

    def add_agent_experience(self, transition):
        if len(self.agent_buffer) >= self.capacity:
            self.agent_buffer.pop(0)
        self.agent_buffer.append(transition)

    def sample(self, batch_size):
        """デモンストレーションとエージェント経験を混合してサンプリング"""
        n_demo = int(batch_size * self.demo_ratio)
        n_agent = batch_size - n_demo

        demo_samples = random.sample(
            self.demo_buffer,
            min(n_demo, len(self.demo_buffer))
        )
        agent_samples = random.sample(
            self.agent_buffer,
            min(n_agent, len(self.agent_buffer))
        )

        return demo_samples + agent_samples

実践的な考慮事項と限界

現在の技術的限界

  • MiniWoBベンチマークでも複雑なタスク(email-inbox、social-mediaなど)は人間レベルに達していない
  • 実際のウェブサイトの多様性と複雑性への対応が困難
  • DOM構造の動的変化に脆弱

最新の研究方向

  • 大規模言語モデル(LLM)ベースのウェブエージェント:HTMLをテキストとして入力し行動を生成
  • マルチモーダルモデル:画面画像とテキストを同時に理解するエージェント
  • 階層的RL:高レベル計画(どの要素と対話するか)と低レベル実行(正確な座標クリック)を分離

要点まとめ

  • ウェブナビゲーションは巨大な状態/行動空間と遅延報酬が特徴の難しいRL問題である
  • グリッドベースの行動空間で単純化し、CNN + A2Cで基本エージェントを実装できる
  • 人間のデモンストレーション(行動クローニング)で事前学習するとRL学習が大幅に加速される
  • 最新の研究はLLMとマルチモーダルモデルを活用した汎用ウェブエージェントに向かっている

次の記事では、連続行動空間を扱うDDPGと分布方策勾配を見ていきます。