0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

A*(エースター)を手を動かして学ぶ|Pythonで学ぶ経路生成②

0
Posted at

はじめに

前回のダイクストラ法は、グリッド上の最短経路を確実に引けました。ただし1つ、もやっとする結果が残りました。最短経路はたった23セルなのに、それを見つけるためにマップのほぼ全部(369セル)を調べていたことです。

今回のA*(エースター)は、まったく同じマップ・まったく同じ最短コストのまま、この探索を 369 → 113 セル(約7割減) まで減らします。

astar_compare.gif

やることは、ダイクストラにたった1つの関数を足すだけです。その関数の名前がヒューリスティック。この記事のゴールは、「なぜ見積もりを足すだけで、最短を保ったまま探索が速くなるのか」 を、自分のコードで再現できるようになることです。

このシリーズについて

本記事は、Pythonで経路生成(パスプランニング:ロボットやゲームのキャラが障害物をよけて目的地へ進む道すじを計算すること)を基礎から学ぶ全4回シリーズの第2回です。

前回のダイクストラが、あとの全手法が比較される 基準 でした。A*はその上に立つので、まだ①を読んでいない方は先に①からがおすすめです。

想定読者と環境

  • 想定読者:Pythonが少し書ける方。前回①を読んで「$g$(実コスト)」「緩和」「優先度キュー」がなんとなく分かっていると一番スムーズです(未読でも本文で軽く復習します)
  • 環境は前回と同じ。NumPyMatplotlib だけ、優先度キューの heapq は標準ライブラリなので追加インストール不要です
ライブラリ 用途 インストール
NumPy マップ(格子)を配列で持つ pip install numpy
Matplotlib 経路と探索の様子を可視化 pip install matplotlib

マップも前回とまったく同じものを使います(20×20・中央に壁と1マスの隙間・ぬかるみ帯)。同じ土俵で比べるためです。

1. 前回の宿題:369の無駄はどこから来たか

ダイクストラは「スタートに近い順」にノードを確定していきました。この戦略には、大きな盲点が1つあります。

ゴールが右上にあっても、ダイクストラは左下にも同じ熱心さで探索を広げる。

「近い順」しか見ていないので、探索は水たまりのように全方向へ均等に広がります。ゴールと反対方向にも、律儀にマスを塗りつぶす。これが369マスの正体です。

素直な不満はこうです。「ゴールはあっちにあるんだから、そっち側を重点的に探せばいいのに」。A*は、この不満をそのまま形にします。

2. A*のアイデア:f = g + h

ダイクストラは、優先度キューから $g$(スタートからの実コスト)が小さい順 にノードを取り出していました。A*は、取り出す順番を決める値を変えます。

f(v) = g(v) + h(v)
  • $g(v)$:スタートから $v$ までの実際にかかったコスト(前回の主役。もう分かっている確定的な値)
  • $h(v)$:$v$ からゴールまでの推定コスト(今回の新顔。まだ行っていないので「見積もり」)
  • $f(v)$:その $v$ を通る道の、スタートからゴールまでの総コストの見積もり

ダイクストラが「ここまでどれだけ来たか($g$)」だけで選んでいたのに対し、A*は「この先ゴールまでを含めた通し($f$)」で選ぶ。違いはこれだけです。

前回の「探検隊」の比喩を延長しましょう。ダイクストラの隊員は、背後(スタートからの経過時間)だけを見て進む先を決めていました。A*の隊員は、それに加えて顔を上げてゴールの方角を見る。「ゴールに近づく一歩」を優先するので、隊列は反対方向に散らばらず、ゴールへ細く伸びていくのです。

3. ヒューリスティック h の設計:「過大評価しない」が最短保証の条件

ここが今回いちばん大事なところです。「見積もり $h$ を足すだけ」と言いましたが、$h$ の作り方をまちがえると、A*は最短経路を見失います。安全な $h$ の条件は、たった1つ。

許容性(admissibility):$h$ は、本当のゴールまでのコストを、決して超えて(過大評価して)はいけない。

理由は直感的です。$h$ が本当の残りコストより大きいと、A*は「あっちは遠そうだ」とまだ短い可能性のある道を早々に見捨ててしまう。逆に $h$ が本当のコスト以下(=控えめな見積もり)である限り、A*は最短を見落としません。

$h = 0$(=まったく見積もらない)は、いつでも許容的です。このとき $f = g$ となり、A*はダイクストラそのものに戻ります。つまりダイクストラはA*の特別な場合($h=0$)。これは章末で実際に確かめます。

今回の h:オクタイル距離

本記事のマップは8近傍(斜め移動あり・斜めのコストは $\sqrt{2}$)なので、それに合った見積もりを使います。オクタイル距離(octile distance:斜めに進める前提での最短の格子距離)です。ゴールまでの縦の差を $dr$、横の差を $dc$ とすると、

h = \sqrt{2}\cdot \min(dr, dc) + 1 \cdot \bigl(\max(dr, dc) - \min(dr, dc)\bigr)

「斜めで詰められるだけ詰めて($\sqrt{2}$)、残りをまっすぐ進む($1$)」と見積もる、という意味です。

なぜ地形(ぬかるみ×3)を無視してよいのか。 $h$ は「まっすぐ自由に進めたら」の距離なので、実際のコスト(壁を迂回する・ぬかるみで3倍かかる)は必ずこの見積もり以上になります。つまりオクタイル距離は決して過大評価しない=許容的。だから最短保証が保たれます。実際、スタート地点では $h=24.04$ に対し本当のコストは $26.97$。ちゃんと下回っています。

4. 実装する:ダイクストラとの差分は「キューに h を足す」だけ

処理の骨格は前回と同じ。違うのは、キューに入れる優先度を $g$ から $f=g+h$ に変える1点だけです。

図:A*の処理フロー。前回のダイクストラの図と見比べてください。オレンジの「$f=g+h$」以外は全部同じです。

ファイル構成も前回を流用します。constant.pygrid_map.pyplot.py は前回①とまったく同じなので、まだ手元にない方は①から持ってきてください。新しく書くのは heuristic()astar.py、そして図を出す main.py だけです。

ファイル 前回から 役割
constant.py 流用(①と同一) 定数(セルの種類・地形の重み・移動コスト)
grid_map.py 流用(①と同一) 占有格子地図とサンプルマップ
plot.py 流用(①と同一) 可視化の下地
astar.py 新規 A* 本体+ヒューリスティック
main.py 差し替え 解いて比較図を出す

4-1. ヒューリスティック h

まず、今回の主役 heuristic() です。オクタイル距離をそのままコードにします。

# astar.py
import heapq    # 優先度キュー(取り出すたびに最小の要素が出る)

from constant import CONNECTIVITY, COST_STRAIGHT, COST_DIAGONAL
from grid_map import GridMap
from dijkstra import reconstruct_path    # 経路復元は①と共通


def heuristic(cell: tuple, goal: tuple) -> float:
    """
    ゴールまでの推定コスト h(オクタイル距離)を返す

    8近傍(斜め移動あり・斜めコスト√2)に整合したヒューリスティック。
    地形の重み(ぬかるみ×3)は考えず「まっすぐ進めたら」の距離を返すので,
    実際のコストを超えない(=許容的:最短保証をこわさない)。

    パラメータ
        cell: 現在セル (row, col)
        goal: ゴールセル (row, col)

    戻り値
        h: ゴールまでの推定コスト(下限の見積もり)
    """
    dr = abs(cell[0] - goal[0])
    dc = abs(cell[1] - goal[1])
    # 斜めで詰められるぶんは斜め(√2),残りは直線(1)で進むと見積もる
    diagonal = min(dr, dc)
    straight = max(dr, dc) - diagonal
    return COST_DIAGONAL * diagonal + COST_STRAIGHT * straight

4-2. A* 本体

ダイクストラのコードと並べて見比べてください。キューの要素を (コスト, セル) から (f=g+h, g, セル) に変え、push する優先度に heuristic() を足す。変わったのは実質そこだけです。

def astar(grid_map: GridMap, connectivity: CONNECTIVITY = CONNECTIVITY.EIGHT) -> tuple:
    """
    A* 法でスタートからゴールまでの最短経路(コスト最小の経路)を求める

    Dijkstra との違いは,キューの優先度を g(実コスト)ではなく
    f = g + h(実コスト+ゴールまでの推定)にする1点だけ。

    パラメータ
        grid_map: 占有格子地図(スタート・ゴール設定済み)
        connectivity: 近傍の種類(4近傍 / 8近傍)

    戻り値
        path: 最短経路(セルのリスト。到達できなければ空リスト)
        explored: 確定した順に並べた探索済みセルのリスト(可視化・比較用)
        cost: ゴールまでの最短コスト(到達できなければ無限大)
    """
    start = grid_map.start
    goal  = grid_map.goal

    # g 値(始点からの実コスト最小値)と,経路復元用の親ノード
    dist      = {start: 0.0}
    came_from = {}

    visited  = set()
    explored = []

    # 優先度キュー。要素は (f=g+h, g, セル)。取り出し順は f の小さい順
    queue = [(heuristic(start, goal), 0.0, start)]

    while queue:
        # いま未確定の中で f(=g+h)が最小のノードを取り出す
        f, cost, current = heapq.heappop(queue)
        if current in visited:
            continue
        visited.add(current)
        explored.append(current)

        if current == goal:
            break

        # 近傍ノードを緩和(relax)する
        for n_cell, move_cost in grid_map.neighbors(current, connectivity):
            new_cost = cost + move_cost
            if n_cell not in dist or new_cost < dist[n_cell]:
                dist[n_cell]      = new_cost
                came_from[n_cell] = current
                priority = new_cost + heuristic(n_cell, goal)
                heapq.heappush(queue, (priority, new_cost, n_cell))

    path = reconstruct_path(came_from, start, goal)
    cost = dist.get(goal, float("inf"))

    return path, explored, cost

理論とコードの対応は、ダイクストラの4つの勘どころに 1つ足すだけです。

  • キューの要素を (f, g, セル) の3つ組にして、取り出しは $f=g+h$ の最小(=ゴールを含んだ通しで最有望なノードを選ぶ)
  • 確定の判定に使うのは相変わらず $g$(dist)。$h$ は「並べ替えのヒント」に徹し、確定コストそのものは汚さない

つまずきポイント:なぜタプルを (f, g, セル) の順にするのか。 heapq はタプルを先頭の要素から比較します。先頭を $f$ にすれば「$f$ が小さい順」に取り出せます。$f$ が同点のときは次の $g$ で比較される(=ゴールに近い方=実コストの大きい方が優先されがち)ので、探索が素直にゴールへ伸びます。

4-3. 動かす

main.py で、ダイクストラとA*を同じマップで解いて、左右に並べて比較します。

# main.py
from constant import *
from grid_map import make_sample_map
from dijkstra import dijkstra
from astar import astar
from plot_compare import plot_compare_static, plot_compare_animation


def main() -> None:
    grid_map = make_sample_map()

    d_path, d_explored, d_cost = dijkstra(grid_map, CONNECTIVITY.EIGHT)
    a_path, a_explored, a_cost = astar(grid_map, CONNECTIVITY.EIGHT)

    print(f"Dijkstra: 通過点={len(d_path)}  コスト={d_cost:.2f}  展開ノード={len(d_explored)}")
    print(f"A*      : 通過点={len(a_path)}  コスト={a_cost:.2f}  展開ノード={len(a_explored)}")

    plot_compare_static(grid_map, (d_path, d_explored, d_cost),
                        (a_path, a_explored, a_cost), "astar_compare.png")
    plot_compare_animation(grid_map, (d_path, d_explored),
                           (a_path, a_explored), "astar_compare.gif", frames_n=40)


if __name__ == "__main__":
    main()

比較図を描く plot_compare.py は本筋ではないので折りたたみにします(①の plot.py の下地をそのまま使い、左右2枚を並べているだけです)。

plot_compare.py の全文(クリックで展開)
# plot_compare.py  (比較図。①の plot.py の下地 _draw_base を流用)
import matplotlib
matplotlib.use("Agg")    # 画面を出さずに画像へ保存(ヘッドレス)
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from plot import _draw_base


def _draw_panel(ax, grid_map, path, explored, title):
    """1枚ぶん(地形+探索済み+経路)を描く"""
    _draw_base(ax, grid_map)
    if explored:
        ax.plot([c[1] for c in explored], [c[0] for c in explored],
                "s", color="skyblue", markersize=6, alpha=0.5)
    if path:
        ax.plot([c[1] for c in path], [c[0] for c in path],
                "-", color="tab:orange", linewidth=2.5)
    _draw_base(ax, grid_map)
    ax.set_title(title)


def plot_compare_static(grid_map, dij, ast, filename):
    """Dijkstra と A* の探索範囲・経路を左右に並べた静止画を保存"""
    d_path, d_explored, d_cost = dij
    a_path, a_explored, a_cost = ast
    fig, axes = plt.subplots(1, 2, figsize=(11, 5.6))
    _draw_panel(axes[0], grid_map, d_path, d_explored,
                f"Dijkstra  (explored={len(d_explored)}, cost={d_cost:.2f})")
    _draw_panel(axes[1], grid_map, a_path, a_explored,
                f"A*  (explored={len(a_explored)}, cost={a_cost:.2f})")
    fig.tight_layout()
    fig.savefig(filename, dpi=110, bbox_inches="tight")
    plt.close(fig)


def plot_compare_animation(grid_map, dij, ast, filename, frames_n=40):
    """探索フロントが広がる様子を左右で同時再生するGIFを保存"""
    d_path, d_explored = dij
    a_path, a_explored = ast

    def cut(explored, i):
        # 進捗率 i/frames_n までの探索済みセル
        k = round(len(explored) * i / frames_n)
        return explored[:k]

    fig, axes = plt.subplots(1, 2, figsize=(11, 5.6))

    def update(i):
        for ax in axes:
            ax.clear()
        last = (i == frames_n)
        _draw_panel(axes[0], grid_map, d_path if last else [], cut(d_explored, i),
                    f"Dijkstra  (explored={len(d_explored)})")
        _draw_panel(axes[1], grid_map, a_path if last else [], cut(a_explored, i),
                    f"A*  (explored={len(a_explored)})")

    anim = animation.FuncAnimation(fig, update, frames=frames_n + 1, interval=120)
    anim.save(filename, writer="pillow")
    plt.close(fig)

GIFの保存には Pillow を使います(pip install pillow)。

5. 動かして、目で確かめる

5つのファイル(constant.py / grid_map.py / dijkstra.py / astar.py / plot.py)に、今回の plot_compare.pymain.py を加えて同じフォルダに置き、そのフォルダへ移動して実行します。

cd astar
python main.py

ターミナルには、次のように表示されます。

Dijkstra: 通過点=23  コスト=26.97  展開ノード=369
A*      : 通過点=23  コスト=26.97  展開ノード=113

astar_compare.png

  • 展開ノード 369 → 113(約7割減):A*は反対方向をほとんど探索していない。$h$ がゴール方向へ探索を引き寄せた成果です
  • コストは 26.97 で完全一致:速くなっても最短は1ミリも損なわれていない。これがオクタイル距離の許容性の御利益です
  • 通過点はどちらも23:同じ長さの最適解にたどり着いている

左の水色(探索済み)がマップ全面に広がるのに対し、右のA*は水色がゴール方向へ細く伸びているだけ。この見た目の差が、そのまま 369 と 113 の差です。

⚠️ 経路の見た目は、マップやゴールによって前回とわずかに変わることがあります。 同じコスト26.97でも、それを実現する道すじは複数あり得ます(斜めをどこで消化するか等)。A*は「同コストの最適解のうちの1本」を返すので、ダイクストラの経路とセル単位で完全一致するとは限りません。コストが同じなら、どちらも正しい最短経路です。

6. まとめ:h の質が探索効率を決める

A*で、最短を保ったまま探索の無駄を7割減らせることを、同じマップで確かめました。要点です。

  • A*は優先度を $g$ から $f = g + h$ に変えるだけ。$h$ が探索をゴール方向へ引き寄せる
  • $h$ は**許容的(過大評価しない)**でなければならない。そうであれば最短は保証される
  • $h = 0$ なら A* はダイクストラに戻る(ダイクストラは A* の特別な場合
手法 経路コスト 展開ノード数 ゴール方向を意識?
ダイクストラ(①) 26.97 369 しない
A*(今回) 26.97 113 する

A*の賢さは、結局 $h$ の質にかかっています。ゴールへの見積もりが正確なほど探索は細く速くなり、雑なら($h=0$なら)ダイクストラに逆戻り。「よい見積もりをどう作るか」 が、この先の経路計画でもずっと効いてきます。

次回③はポテンシャル法。ここまでの「格子(グリッド)をマスごとに調べる」世界を離れ、連続空間で「ゴールへの引力」と「障害物からの斥力」に沿って進む、別の発想の手法へ入っていきます。

章末:やってみよう

手を動かすと、$h$ の効き方が体に入ります。数値は手元で確かめた実測値です。

  1. $h=0$ にするheuristic() が常に 0.0 を返すようにすると、A*はダイクストラそのものになり、展開ノードは113から369へ戻ります(コストは26.97のまま)。「ダイクストラ=$h=0$のA*」を目で確認できます。
  2. $h$ を盛って、やりすぎの閾値を探すreturnw * (...) に変えて倍率 w を上げてみてください。このマップでは w=2→展開24、w=3→展開23 とさらに速くなりますがコストは26.97のまま(まだ最適)。ところが w=5 にすると展開は19まで減る代わりに、コストが 26.97 → 35.94 に悪化します。過大評価は「速いが最短が壊れる」トレードオフ。壊れ始める倍率を自分で挟み込んでみてください。
  3. 4近傍にするCONNECTIVITY.FOUR にしたら、$h$ はマンハッタン距離(縦+横の格子距離)に替えるのが自然です。近傍とヒューリスティックはセットで整合させる、が勘どころ。
  4. ゴールを動かすmake_sample_map() のゴールを変えると、$h$ の引き寄せの向きが変わり、探索範囲の形も変わります。

終わりに

今回は、ダイクストラに「ゴールの方角の見積もり $h$」を足すだけで、最短を保ったまま探索を速くできることを、手を動かして確かめました。$f = g + h$、そして $h$ は過大評価しない。この2つが分かっていれば大成功です。

経路生成シリーズ全体は、こちらのまとめから辿れます。

ロボット関連の記事はこちらのまとめにもあります。

次回は ③ポテンシャル法。格子を離れ、連続空間を「引力と斥力」で進みます。

役に立ったら いいね・ストック で応援いただけると、次回の励みになります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?