2025.01.14

llmを用いて将棋の棋譜解説 その1

まとめ

  • 大規模言語モデルを用いて将棋の棋譜解説を行うソースコード(デモ版)をgithubに公開しています
    • 「①指し手と将棋エンジンの読み筋の差異を解説」「②これから最適手がどういうものかを述べる」という2つの観点で解説を行うことができます
  • 今後の開発でより性能や汎用性を向上させていく見込みです。

はじめに

AI研究開発室のM.S.です。今回は私の趣味である将棋(アマ二段程度の実力です。)とllmを組み合わせて棋譜の解説を試みます。

近年の機械学習や自然言語処理の飛躍的な進歩の中でも、チャット型の大規模言語モデル(LLM)は特に注目を浴びています。文章生成能力が格段に向上したことで、テキストベースのタスク——たとえば対話システムや文書要約などが大きく発展しました。ここでは、そのLLMの技術発展を「将棋」の領域にも活かせないかを模索する取り組みについて紹介します。


本編

棋譜から解説を生成する

初期局面から解説をさせて、性能を確認

まずは、現在の大規模言語モデルがどれほど将棋の解説をする最低限の能力があるのかを確認します。

chatGPTのGUIで、質問をしてみます。

回答

少しおしゃべりというかレトリックの効いた表現が鼻につく気がしますが、概ね悪くない回答をしているように見えます。

解説のスクリプトを実装していく

将棋エンジンから読み筋を取得し、解説文を生成する

将棋解説システムを実装するにあたって、まず最低限の要件は「①指し手と将棋エンジンの読み筋の差異を解説」「②これから最適手がどういうものかを述べる」という二つです。今回はこの2つの機能に絞って開発を進めました。また、そのために以下のステップを取りました。

開発環境はMacBook Pro(14インチ, 2024)、CPUはApple M2 Pro, メモリは16GBでローカル環境内でdockerイメージを作成して、その環境内でエンジンを操作します。

  1. 将棋エンジンのインストール(Dockerfileを使った環境構築)
    • 将棋エンジンとしてやねうら王を用います。このおかげで、スムーズに将棋エンジンとの対局が可能になります。
    • 将棋の解析ソフトとして、暫定的にelmoを配置しています。
      # Python 3.9ベースイメージ (x86_64向け)
      FROM --platform=linux/amd64 python:3.9
      
      # 非対話モード
      ENV DEBIAN_FRONTEND=noninteractive
      
      # ここで Python の入出力エンコーディングを UTF-8 に固定
      ENV PYTHONIOENCODING=utf-8
      
      # 必要な依存パッケージをインストール
      RUN apt-get update && apt-get install -y --no-install-recommends \
          build-essential \
          clang \
          lld \
          libopenblas-dev \
          unzip \
          zip \
          p7zip-full \
          git \
          wget \
          ca-certificates \
          && rm -rf /var/lib/apt/lists/*
      
      # 作業ディレクトリを /app に設定
      WORKDIR /app
      
      # Pythonライブラリをまとめてインストール: requirements.txtをコピーしてpip install
      COPY requirements.txt /app/requirements.txt
      RUN pip install --no-cache-dir -r requirements.txt
      
      # やねうら王のリポジトリをクローン
      RUN git clone https://github.com/yaneurao/YaneuraOu.git
      
      WORKDIR /app/YaneuraOu/source
      
      # MakefileのTARGET_CPU=AVX2ブロックを修正
      # '-DUSE_AVX2 -DUSE_BMI2 -mbmi -mbmi2 -mavx2 -march=corei7-avx' -> '-DUSE_AVX2 -march=haswell'
      RUN sed -i '/^else ifeq (\$(TARGET_CPU),AVX2)/,+1 s/-DUSE_AVX2 -DUSE_BMI2 -mbmi -mbmi2 -mavx2 -march=corei7-avx/-DUSE_AVX2 -march=haswell/' Makefile
      
      # downloadsディレクトリをコンテナにコピー(Elmo評価ファイルなど)
      # 例: downloads/eval/nn.bin , engine_name.txt , book.db が含まれる
      COPY downloads /app/downloads
      
      # Elmoの評価関数・定跡を /app/eval や /app/book に配置
      RUN mkdir -p /app/eval && \
          cp -r /app/downloads/eval/* /app/eval/ && \
          cp /app/downloads/engine_name.txt /app/YaneuraOu/source/ && \
          mkdir -p /app/book && \
          if [ -f /app/downloads/book.db ]; then cp /app/downloads/book.db /app/book/; fi && \
          rm -rf /app/downloads
      
      # デバッグ: ファイル一覧を表示
      RUN ls -l /app/eval || true
      
      # やねうら王ビルド: トーナメント版 + NNUE
      RUN make clean YANEURAOU_EDITION=YANEURAOU_ENGINE_NNUE && \
          make -j8 tournament COMPILER=g++ YANEURAOU_EDITION=YANEURAOU_ENGINE_NNUE \
          EXTRA_CPPFLAGS="-DHASH_KEY_BITS=128 -DTT_CLUSTER_SIZE=4 -march=haswell -Ofast -DNDEBUG -D_LINUX -DUNICODE -DNO_EXCEPTIONS -DFOR_TOURNAMENT" \
          ENGINE_NAME="YaneuraOu_tournament_haswell"
      
      # ビルド結果を /app にコピー
      RUN cp YaneuraOu-by-gcc /app/YaneuraOuNNUE_haswell
      
      WORKDIR /app
      
      # ベンチマーク実行 (evaluation file を /app/eval/ に置いている想定)
      RUN printf "bench\nquit" | /app/YaneuraOuNNUE_haswell > benchmark_result.txt
      
      # # PythonスクリプトとKIFファイルをコピー
      # COPY demo_kif.py /app/demo_kif.py
      # COPY example.kif /app/example.kif
      
      # エントリーポイントスクリプトを作成(オプション)
      COPY entrypoint.sh /app/entrypoint.sh
      RUN chmod +x /app/entrypoint.sh
      
      # エントリーポイントの設定(オプション)
      ENTRYPOINT ["/app/entrypoint.sh"]
      
      # コンテナ起動時に bash を立ち上げる (デバッグ用)
      CMD ["/bin/bash"]

      今回は、将棋ソフトの解析性能は一旦置いておいて、エンジンから応答が返ってくることを確認することを最低条件として、開発しています。

  2. .kifファイルの読み取りと解析
    • 将棋の指し手が記録された.kifファイルを読み込み、正規表現などを用いてパースし、各手をUSI形式(例: 7g7f)に変換します。
    • Pythonのライブラリであるcshogiを用いると、読み込んだ指し手をボードに反映しながら対局の進行を再現できます。CUI上で局面をセットしたり、棋譜を一手ずつ進めてエンジンに問い合わせる流れをスクリプトで自動化できます。
    • 現状では、「打」などいくつか重要な.kifファイルの概念に対応できていないですが、あくまでもデモということでガリガリ進めていきます。
      def kif_move_to_usi(kif_move: str, last_to_square: str = None) -> (str, str):
          """
          KIFの指し手をUSI形式に変換する。
          例: '7六歩(77)' -> '7g7f'
          「同」指し手の場合はlast_to_squareを使用。
          """
          # 日本語数字を英数字に変換
          num_map = {'1': '1', '2': '2', '3': '3', '4': '4', '5': '5',
                     '6': '6', '7': '7', '8': '8', '9': '9'}
          rank_num_to_usi = {
              1: 'a', 2: 'b', 3: 'c', 4: 'd', 5: 'e',
              6: 'f', 7: 'g', 8: 'h', 9: 'i'
          }
      
          # 「同」の処理
          if kif_move.startswith("同"):
              if not last_to_square:
                  raise ValueError("「同」指し手が前の指し手の情報を必要とします。")
              return f"*{last_to_square}", last_to_square
      
          # 通常の指し手の処理
          match = re.match(r'^([123456789])([一二三四五六七八九])([歩香桂銀金角飛王玉と馬竜]*)?\((\d)(\d)\)$', kif_move)
          if not match:
              raise ValueError(f"無効な指し手フォーマット: {kif_move}")
          
          # 目的地の筋と段を変換
          to_file = num_map[match.group(1)]  # 筋(例: "7" -> "7")
          to_rank_num = "一二三四五六七八九".index(match.group(2)) + 1  # 段を数値化(例: "六" -> 6)
          to_rank = rank_num_to_usi[to_rank_num]  # 段をUSI形式に変換(例: 6 -> "f")
          
          # 元の位置(from_square)の筋と段
          from_file = match.group(4)
          from_rank = match.group(5)
          from_square = f"{from_file}{rank_num_to_usi[int(from_rank)]}"
          
          # 成りの処理
          piece = match.group(3) or ''
          if "成" in piece:
              usi_move = f"{from_square}{to_file}{to_rank}+"
          else:
              usi_move = f"{from_square}{to_file}{to_rank}"
          
          return usi_move, f"{to_file}{to_rank}"
      
      def parse_kif_from_text(kif_text: str):
          """
          KIFファイルのテキスト内容をリスト化し、指し手を抽出する。
          """
          moves = []
          last_to_square = None
          lines = kif_text.splitlines()
          move_section = False
          for line in lines:
              line = line.strip()
              if line.startswith('手数----指手'):
                  move_section = True
                  continue
              if move_section:
                  if line.startswith(('投了', '持将棋', '千日手')):
                      break
                  move_parts = line.split()
                  if len(move_parts) >= 2:
                      kif_move = move_parts[1]
                      try:
                          usi_move, to_square = kif_move_to_usi(kif_move, last_to_square)
                          moves.append(usi_move)
                          last_to_square = to_square
                      except ValueError as e:
                          print(f"警告: {e}")
          return moves
      
      def parse_kif(file_path: str):
          """
          KIFファイルからテキストを読み込んでparse_kif_from_textに渡す。
          """
          with open(file_path, 'r', encoding='utf-8') as f:
              kif_text = f.read()
          return parse_kif_from_text(kif_text)

       

  3. 将棋エンジンに候補手を問い合わせ、解説を生成
    • 具体的には、局面が変化するたびにエンジンに「goコマンド」を送り、最善手(bestmove)と評価値、さらには読み筋(次にどんな手を指そうとしているか)を取得します。
    • それをもとに、AI(OpenAI GPTなど)に「指された手」と「最善手の違い」や「読み筋の意図」を解説させるテキストを生成します。
      def generate_llm_comment(
          move_number: int,
          predicted_move: str,   # ★新規: 事前にエンジンが推奨していた手
          played_move: str,
          engine_best_move: str,
          next_best_line: str
      ) -> str:
          """
          OpenAIの新しいAPI(v1系)を用いてコメント文を生成する。
          事前推奨手(predicted_move)を加え、指された手(played_move)との比較を促す。
          """
          prompt_text = f"""
      【状況説明】
      - 手数: 第{move_number}手
      - 事前にエンジンが推奨していた手: {predicted_move}
      - 実際に指された手: {played_move}
      - この手の後、エンジン最善手: {engine_best_move}
      - エンジン読み筋: {next_best_line}
      
      【お願い】
      上記情報を踏まえ、将棋の文脈で分かりやすく解説コメントを書いてください。
      1. 「事前の推奨手」と「実際に指された手」を比較し、意味や狙い、違いについて説明する
      2. エンジンが読み上げている次の展開(最善手の読み筋)の内容・意図を解説する
      """
      
          try:
              response = client.chat.completions.create(
                  model="gpt-4o",  # 適宜モデルを変更
                  messages=[
                      {
                          "role": "user",
                          "content": prompt_text,
                      }
                  ],
                  max_tokens=600,
                  temperature=0.7,
              )
              return response.choices[0].message.content.strip()
          except Exception as e:
              return f"LLMコメント生成中にエラーが発生しました: {e}"
      
      def main(kif_path: str, engine_path: str, move_time: int = 2000):
          moves = parse_kif(kif_path)
          if not moves:
              print(f"KIFファイル({kif_path})から指し手が取得できませんでした。")
              return
      
          engine = Engine(engine_path)
          engine.connect()
          engine.isready()
      
          board = cshogi.Board()
          board.reset()
      
          for i, usi_move in enumerate(moves):
              move_number = i + 1
      
              # ===========================================================
              # (1) 事前にエンジンに問い合わせて推奨手を取得
              # ===========================================================
              engine.position(sfen=board.sfen())
              result_before_push = engine.go(btime=move_time, wtime=move_time)
              predicted_best_move = result_before_push[0]
      
              print(f"指し手 {move_number} (KIF): {usi_move}")
              print(f"  [事前評価] エンジン推奨手: {predicted_best_move}")
      
              # ===========================================================
              # (2) 実際の指し手を盤面に適用
              # ===========================================================
              try:
                  board.push_usi(usi_move)
              except ValueError as e:
                  print(f"エラー: 指し手 {usi_move} の適用に失敗しました。 {e}")
                  break
      
              # ===========================================================
              # (3) 手を進めた後の局面を再評価 (事後評価)
              # ===========================================================
              engine.position(sfen=board.sfen())
              result_after_push = engine.go(btime=move_time, wtime=move_time)
              bestmove = result_after_push[0]
      
              # 簡易的な読み筋(もう1手)を取得
              next_board = cshogi.Board(board.sfen())
              try:
                  next_board.push_usi(bestmove)
                  engine.position(sfen=next_board.sfen())
                  next_result = engine.go(btime=move_time, wtime=move_time)
                  next_best_move = next_result[0]
                  next_best_line = f"{bestmove} -> {next_best_move}"
              except ValueError:
                  next_best_line = f"{bestmove}"
      
              print(f"  [事後評価] エンジン最善手: {bestmove}")
      
              # ===========================================================
              # (4) LLMを用いて解説コメントを生成 (事前推奨手を含む)
              # ===========================================================
              llm_comment = generate_llm_comment(
                  move_number=move_number,
                  predicted_move=predicted_best_move,  # ★事前推奨手を渡す
                  played_move=usi_move,
                  engine_best_move=bestmove,
                  next_best_line=next_best_line
              )
      
              print(f"  コメント:\n{llm_comment}\n")
      
          engine.quit()
          print("棋譜の読み込みとエンジンでの簡易評価、およびLLMを用いた解説コメントを完了しました。")
      

       

結果

入力

example.kifファイルを入力します。初期局面からよくある序盤の出だしを5手程度例示します。

# --- example.kif ---

開始日時:2024/12/31 09:00
終了日時:2024/12/31 09:10
手合割:平手
先手:サンプル先手
後手:サンプル後手

手数----指手---------消費時間--
   1 2六歩(27)   ( 0:00/00:00:00)
   2 3四歩(33)   ( 0:00/00:00:00)
   3 7六歩(77)   ( 0:00/00:00:00)
   4 4四歩(43)   ( 0:00/00:00:00)
   5 4八銀(39)   ( 0:00/00:00:00)
投了

出力

2手目までのexample.kifの読み込み結果を抜粋しています。

指し手 1 (KIF): 2g2f
  [事前評価] エンジン推奨手: 7g7f
  [事後評価] エンジン最善手: 8c8d
  コメント:
【解説コメント】

1. **「事前の推奨手」と「実際に指された手」の比較**

事前にエンジンが推奨していた手は「7g7f」で、これは一般的な振り飛車の指し方で、飛車を動かしやすくして積極的な展開を目指す意図があります。振り飛車の初手としては安定しており、後々の駒組みを見据えた合理的な一手です。

一方、実際に指された「2g2f」は、居飛車の囲いを意識した手で、右辺の銀を活用しやすくする狙いがあります。この手は居飛車党の指し手としては自然ですが、振り飛車の展開を考えると初手としてはやや意外性があります。

両者の違いは、初期の駒組みの方向性にあります。「7g7f」は振り飛車、「2g2f」は居飛車の指し方に向かうスタートとなります。したがって、どちらを選ぶかでその後の戦略が大きく変わります。

2. **エンジンが読み上げている次の展開(最善手の読み筋)の内容・意図**

エンジンが読み上げている次の展開は「8c8d -> 7g7f」です。まず「8c8d」は、相手側が左辺の歩を突く手で、相手も自らの飛車を振る準備をしている可能性があります。この手は、相手が振り飛車を指向していることを示唆しており、局面を一層複雑にする可能性を秘めています。

その後の「7g7f」は、こちら側が初手で選ばなかった振り飛車の指し方に戻る手で、後手の動きに柔軟に対応しつつ、再び振り飛車を目指す展開を狙っています。これにより、相手の意図を観察しながら自らの戦略を立て直すことが可能になります。

この読み筋は、初手での選択を修正しつつ、柔軟に相手に対応するための一手であり、局面を落ち着かせてから自分の形を作る意図が込められています。相手の動きに応じて最適な布陣を整える、非常に戦略

指し手 2 (KIF): 3c3d
  [事前評価] エンジン推奨手: 8c8d
  [事後評価] エンジン最善手: 7g7f
  コメント:
【解説コメント】

1. 「事前の推奨手」と「実際に指された手」の比較と違いについて

事前にエンジンが推奨していた手は「8c8d」で、これは角道を開けるための手です。角道を開けることにより、後々の展開で角を使いやすくし、攻めや守りの幅を広げる意図があります。これに対して、実際に指された手は「3c3d」で、こちらは銀を進めるための手です。この手は、中央を厚くする狙いがあり、守備力を強化しつつ、将来的に攻めの起点を作ることも考えられます。要するに、「8c8d」はより早期に角の活用を意識した手であり、「3c3d」は中央を重視した安定感のある手といえます。

2. エンジンが読み上げている次の展開(最善手の読み筋)について

エンジンの最善手として「7g7f」が挙げられています。これは飛車を使いやすくするための手で、飛車先を伸ばして攻撃の基盤を作る意味があります。次にエンジンが考えている手は「8c8d」で、これは先ほど触れた通り、角道を開けるための手です。この展開では、まず飛車先を突いて攻撃の姿勢を見せつつ、角道を開けて駒の活用を図るという意図が考えられます。こうした展開により、バランス良く攻めと守りを整えつつ、相手の出方を伺うことができる布陣を構築する狙いがあります。

 

指し手が「2g2f(先手番の2六歩を表す)」といったSFEN(Shogi Forsyth-Edwards Notation)形式で見づらいですが、このあたりのパースは思ったより実装に時間が掛かりそうだったので省略しました、、。

現段階(Phase1)では間違いが目立ちますね、、。たとえばこの例だと8c8d(後手番の8四歩)は、角道を開けるための手と紹介されていますが、実際は飛車先を通すことが目的といえます。このように、「駒の種類を取り違えている」の他に「実際は攻めでもないのに攻めとして解説される」といった事例も散見されます。

しかし、あくまで「エンジンとLLMを組み合わせ、最低限の解説生成ができるかどうか」を試す段階としては、十分に合格点と言えそうです。


まとめ

今後の発展内容

状況から正確な解説を生成する方法の確立(最重要)

今回の実装では、指された手と最善手の比較、そしてエンジンの読み筋を単純にLLMに投げてコメントを生成するだけでした。そのため、誤解を招く解説や、正確性に欠ける表現も混じることがあります。
最も重要なのは、「いま盤面にある状況をどう捉え、どの情報をもとに解説するか」を明確にし、その情報をLLMへ的確に渡す仕組みを整えることです。具体的には、以下のような要素を取り込んで精度向上を図る必要があります。

  • board から得られる駒配置
  • エンジンが算出した評価値(数値)
  • 最善手(bestmove)と読み筋(複数手先のプラン)
  • multipv(最善手の複数候補の情報の利用)
  • 対局者の意図・戦型の特徴など、メタ情報

LLMへのプロンプトに、これらを適切に組み込む必要があるでしょう。

特にデモは最序盤の局面だったので、まだ文章レベルで学習対象が多かったことが予測されますが、中盤以降はそもそも学習対象が少ないので全く性能が高くないことが予想されます。その際に、適切な情報を与えることや、場合によってはAIエージェントに適切な情報を探索させることが、性能を担保するうえで必須の要件となってくるでしょう。

その他の改善点

入力出力の問題点の解消

現状では.kif形式を想定していますが、実際にはsfencsaなど、さまざまな棋譜フォーマットが存在します。新たに対応するには、各フォーマットに応じたパーサを実装するか、共通ライブラリを活用する必要があります。

言語モデルのコストと可換性

現在デモで使っているモデルは”gpt-4o”でコストが大きいので、より軽量のモデルを用いたいです。また、そのときにモデルを変更したとしても性能が大きく変わらないように、状況を正確に把握するということがあくまでも最重要となってきます。

GUIとの連携

現在対応するGUIがないので、過程が視覚的にわからないという問題があります(開発中はアナログの将棋盤で駒を動かしながら開発していました笑)。今後はオープンソースのGUIを提供しているShogiHomeの活用なども検討したいと思っています。


おわりに

以上のように、LLM技術を将棋の解説に活かす取り組みを簡単に紹介しました。まだまだ開発途上の部分は多くありますが、実際に解説を生成してみると、「なるほど、こういう観点で喋ってくるのか」という気づきも得られて面白いものです。

LLMによる将棋解説がどのように進化し、将棋の学習や観戦体験をどう変えていくのか——今後も注目していきたい分野です。興味を持たれた方はぜひ一度スクリプトを試し、実際にどのような解説が出力されるかを確認してみてください。新しい発見や面白いアイデアが生まれるかもしれません。

なお、本取り組みでは、「cshogi」によるboardの活用、「やねうら王」をはじめとするUSIプロトコル対応の将棋エンジンの恩恵を多大に受けています。さらに、評価関数として有名な「elmo」の技術も参考にすることで、より高精度な読み筋の取得が可能となっています。これらの素晴らしいオープンソースプロジェクトを開発・公開してくださった方々に、改めて深く感謝申し上げます。今後も敬意を払いながら、本ソフトウェアの機能改善・拡張に努めていきたいと思います。

最後に

グループ研究開発本部 AI 研究開発室では、データサイエンティスト/機械学習エンジニアを募集しています。ビッグデータの解析業務など AI 研究開発室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧からご応募をお願いします。皆さんのご応募をお待ちしています。

  • Twitter
  • Facebook
  • はてなブックマークに追加

グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。

 
  • AI研究開発室
  • 大阪研究開発グループ

関連記事