500万詰将棋の答えを将棋エンジンで解くことを試みる

さて

自分のサイトにやねうらおうさん提供の500万局の詰将棋局面を任意に表示できるページを前回作成したわけだが、詰め手順まではついていない。 これはもともと提供される局面にも回答は添付されていないのであって、基本的にはじぶんで解くか(それほど難しい問題はそれほど無い)さもなくば将棋エンジン(やねうらおうなど)を自分のパソコンに導入して局面をsfenではりつけて検索させれば回答は導け出せますという使い方が前提になっているわけだ。

自分のサイトでもページからsfenを取得できるので詰将棋が解ける環境を自分のパソコン上に持っていればいいことになる。

ただ前にも書いたが、サイトのページ上で回答もそのまま見ることができるようになれば便利なことは確かである。

これを実現しようとすると最初に考えるのは詰将棋を解くAPIを用意してWeb PageからオンデマンドそのAPIを叩けに行けば良いという方法。 これにはAPIを供給するサーバーが必要になる。  すこし探してみたらClaude.ai関連のMCPサーバーの実装で将棋エンジンとブリッジするサーバー構築をexpressを使った形ですでにGitHubにあげている方がいた。azumausu/shogi-mcp

実際にこれでサーバーを作成し、やねうらおうを導入してみたら、うまく作動することは確認できた。  でもやはり、サーバーを常にスタンバイさせておかなければならない、という課題は残るし、リアルタイムに詰みを返せるような高速のエンジンを動かすためのバーチャルマシンはそれなりのスペックのものが必要になる。  こちらは最低限のリソースとマシンスペックでサイトを運営するのを旨としているので、方針がそぐわない。

よって考え方を切り替え、時間がかかってもよいので退職以来3年間眠っている業務用のノートパソコンでひたすら局面を解析させて500万問の詰め手順を導き出し、これをデータベースのテーブル上に格納してここから答えを引き出す方針で進めることにした。

これを行うためには、ノートパソコンで将棋エンジンを起動し、これに局面を送って、詰みの回答を読み取ることを延々と繰り返すというスクリプトが必要になる。

なので、以下Pythonのお勉強の続きをすることになったわけです。

将棋のEngineと通信する部分のモジュールを以下のような構成で考える。

将棋エンジン(やねうらおう)をサブプロセスで起動する。
このプロセスにstdioを通じて詰将棋を解いてね、というコマンドを送り 答えをいただく。
これを問題の数だけ繰り返す。
すべて終わったらサブプロセスを終了

ちなみにこのエンジンを起動させたあと、コンソールで詰将棋を解くためには、
まず “usi” コマンドを送って、”usiok”が戻ってくるのをまってUSIプロトコルが働くことを確認し、そののち setoption name xxx value yyyというフォーマットでオプション類を設定する。

これが終わったら、以下のプロセスを問題の数だけ繰り返す。
”isready”を送ってエンジンの局面を初期化”readyok”と返ってくるのを待つ。
“position sfen sfen文字列” と送って局面を通達。
“go mate” で詰み検索を開始。
stdoutに返されてくるinfo文字列を読み解きこの中に mate xx (xx は例えば11手詰めならば11)  という文字が含まれていれば、所定手数の詰みを見つけたということなので ”stop”コマンドを送信して検討をストップさせ、文字列の中から詰め手順だけ抜き出して親プロセスに返す。 (親プロセスで画面表示するなりファイルにセーブするなりデータベースに書き込むなりの処理をする)

すべて終了したらサブプロセスを終了しスクリプトも終了

ということだと思うので、この考えをもとに実際に書いてみました

ヘルパーFunction その1。 詰めたよ、という文字列が所定手数になっているかの確認。11手詰めのはずの詰めだと13手とか17手の詰めの解答が最初戻ってくるのでそれを無視するためのもの。 ただし、11手詰めのはずなに9手とか7手で詰みます、といってくることがあることがわかったので所定手数以下の文字列についても正解と判断するようになっている。

start_engineという関数は将棋エンジンをsubProcessで起動し、option設定を終えたのちにこのプロセスオブジェクトをメインプログラムに返す。`

ちなみにEngineとOptionsについては以下をメインにて定義する。非力なノートパソコンで走らせるのでThreadをデフォルトの4から2にしないとエンジンが固まることがあった。FV_SCALEは水匠の推奨値。定跡ファイルは必要ないのでno_bookを指定

このプロセスオブジェクトででエンジンが動いているので、stdin/stdoutのパイプを使って交信することができるようになる。

Send_command関数でSFENの局面をエンジンに渡し回答を得るのだが、そのためのヘルパー関数を用意する

initialize_engine関数ではstopを送って探索を終了させたのち、isreadyを送って局面値を初期化する。  try_mate_againではisreadyを送らず、go mateを送るので、そのときメモリー上にある局面を改めて探索して詰みを探す。
query_mate関数はsfen文字列をpositionとしてセットし、go mateを送って詰みの探索開始するためのもの

send_commandsはこれらのヘルパーを使いながら詰みを検索。

swipe_printはコンソールで、画面がスクロールしないように同じ行に出力を重ねていくヘルパー関数

で、send_commandを500万回繰り返せばすべての解答が得られるはず

stop_engine関数でエンジンを終了しサブプロセスを終了。proc.communicateはプロセスの終了処理も行ってくれる。

で、下のスクリプトでは上の関数を適宜に呼び出して、mate11.sfenの最初の500行を解いてみている。 その結果をみながら、上のsend_commandの中身は何度か修正している。 

いつまで待っても所定手数の詰みが見つけられない、という可能性もあることがわかったのでタイムアウトを設けることにした。 最初はThreadingでタイムアウトを生じさせ、このときKeyboardInterruptを生成してこれを処理する手法を試してみたがCTL-Cが乗っ取られるのがどうも気に食わない。 async.ioライブラリーも読んでみたがブロックIOとどのように整合させてよいのかがよくわからない。 ので最初はどろくさくWhileループの中で時間をチェックして一定時間たっていたらbreakするという簡単な方法で書いてみた。 ただし、これポーリングで時間を見ているだけなので、ループ中のstdout.readline()がブロックしている限り時間切れにはならない。タイムアウト15秒くらいまでなら、だらだらと文字列が出力されているようなのでとりあえずハングすることはないとたかをくくって書いてみたら問題なく動いている。 これで良しにしようと思っていたが、さらに日を置いて調べてみたら、os.set_blockingという関数を見つけた。Linuxのみに対応かと思ったら、WindowsのpipeにもPython3.12から対応しました、となっていて、これはラッキー。 これをつかうとstdioがブロックされなくなり、readline()で何もないときには空の文字列が返ってくるようになるので、Timeoutも割り込みなどの処理を使うことなく検知できるようになった。 また、エンジン、検討途中でドツボにはまって正解がなかなか出せなくなる、ということも起こるということを経験的に知った。この場合は出力を待つよりも一度ご破算にして解答を聞き直すとすぐに正解にたどり着くような現象が多くみられた。(実に人間のようである)のでタイムアウトをやたら長くするより、リトライと組み合わせたほうが効率が良いと考えリトライも追加。たいてい2回か3回のリトライで正解にたどり着くようだが、中には30回目40回目のリトライで正解、というケースも500問中2問や3問発生し、それでも正解にたどり着かなかった場合はあきらめて、最後に読んだ文字列を取得するようにしてみた。何回か試してみたが、試すごとにエンジンがドツボにはまる問題には、ある程度の一貫性はあるものの、前回30回以上リトライした問題が次のRunでは最初のトライで解答がでてきたり、このあたりのランダム性は不思議だなあと思うのである。この設定による500問11手詰めの探索で、全問に11手の(またはそれ以下)詰め手順が返されてくるのはタタイムアウトを15秒以上、リトライ回数を50回マックスくらいにすればよさそうだ。タイムアウトをもっと長くすれば確実性は増しそうだが、全部の問題を解く時間がどんどん長くなる。プログラムの出力を見ていると、9割以上の問題は2,3秒で指定手数の正解が出てしまうのであるが、つまずいてリトライが30回発生する問題は解くのに10分かかってしまうということである。またリトライ無しにすれば全問にかかる時間は少なくなるが不完全な(13手詰め以上の)解答が増えてくる。とりあえず時間節約のため、全部解かせたあとで、11手詰めになっていない問題をあとから再度検索させる方法も考えたが、2度手間になってしまうので時間がかかっても全問指定手数の解答までたどり着く方向で考えることにしたのだが、 この実験結果から試算すると11手詰めの問題100万個すべてに11手以下の詰みをみつけるのには最低2か月はかかりそうだ。 ちなみに3手詰めのほうはリトライなどはさすがに発生してしないので4日ほどで終わりそうである。

なにせノートブック用2コア4スレッドのスカイレークという非力なCPUの上にRAMも8ギガバイトである。遅いのは致し方がない。改めて500万問という数字の理不尽さをしみじみ感じてしまうと同時にCPUの性能のちがいが顕著に見えてくる将棋エンジンのコンピューターパワーの使いたおし方は凄いなあと思う。

とにかく、mysqlのテーブルに出力された文字列を書き込むパイソンスクリプトも別途作成し、上記のスクリプトを組み合わせて、500万問を3手詰めから順番になめていくメインプログラムを起動させたのが三日前。画面をブランクにし、ひっそりたたずむノートパソコン。 ときおり、画面を開いてはコンソール上に出てくる問題番号を見て進行状況を確認しております。 うんうん、まだ3手詰のあたりは順調だが道は長い。(長すぎる!)

ハノイの塔を解く(Solving Towers of Hanoi)

ハノイの塔というのはパズルだが、リカージョンというアルゴリズムによくなじむのでプログラミングの教科書に出ていることがある。 自分がこれを見たのは”Oh Pascal!”というパスカルの入門書で30年くらい前に発行された学生向けの教科書だ。
パズルの内容はこんな具合。
1.3本の棒が立っている。仮にこれを右からA,B,Cとする。
2.Aには真ん中に穴のあいた円盤が積み上げてある。円盤の直径は底にあるものが一番大きく、上に行くほど小さくなる。
3.一度に一個ずつこのこの円盤を取り出して他の棒に移すことができる。最終的には全部の円盤をCに移したい。
4.ただし、大きな円盤を小さな円盤の上に置くことはできない。

Hanoiの塔というのはこの”金の”円盤を64枚積み上げたものだそうで、このパズルが解けたときには世の中が終焉を迎える、というオトロしい落ちがついている。

これをコンピュータープログラムを使って解くとどうなるか、という話である。 パターンを見つけるために少ない枚数で実際に解くことを試みる。
Disk(円盤)の数が一枚の時には簡単だ。円盤をAからCに移せばよい。

Move(1disk from A to C) = Move a Disk from A to C

では2枚の時にはどうなるか

Move(2disks from A to C) = 
 Move a Disk from A to B
 Move a Disk from A to C
 Move a Disk from B to C

ここで棒Bを使うことになる。 つまり、まず上の円盤を棒AからBに移し、下の円盤をAからCへ、最後にBに移してあった円盤をCに移して完成だ。

Move (2 disks from A to C) = Move (2 disks from A to C using B)

3枚はどうか。 すでに2枚を動かす方法はわかっているのその方法を使ってまずは2枚をBに動かしておき、最後の一枚をAからCに移動、さらに2枚を動かす方法を使ってBからCに移動するという方法で動かせる。

Move(3 disks from A to C using B) =
 Move(2 disks from A to B using C)
 Move a disk from A to C
 Move (2 disks from B to c using A)

4枚の場合も同様に考えられるのでパターンとしては (n-1)枚の円盤をAからBに移す。最後の円盤をAからCに移動し、その後(n-1)枚の円盤をBからCに移す。 ということになる。

Move (n disks from A to C using B) =
 if n>2 Move (n-1 disks from A to B using C)
 Move a disk from A to C
 if n>2 Move (n-1 disks from B to C using A)

このパタ-ンを使って関数を書くと、自分で自分自身を呼びだす関数になる。これがいわゆるRecursion と呼ばれるプログラムテクニックだがCでもPascalでもPHPでもいまどきのプログラムなら軒並みサポートされている。ただし普通のループに比べるとスタックにどんどん自分のコピーを積んでいくためメモリーを消費し、昔の8ビットパソコンでは何せRAMが32キロバイトあれば高級機、計算しているうちにメモリーが足りなくなる、なんてこともあったりして、Loopで記述できるものはRecursionをさける、というのが常識だった。
しかしながらこの記述の簡便さ、短さは感動ものである。今のパソコンはそんなにメモリを気にしなくてもよいので、改めて組んでみよう。
上のパターンは条件判定を何度も呼ぶことになるので少し工夫して

function Move (n, from, to , using) =
 if n=1 "Move a Disk from 'from' to 'to'"
else
 Move (n-1,from, using,to)
 "Move a Disk from 'from' to 'to'"
 Move (n-1,using, to, from)

というようなロジックを使うこととし、
Free Pascalで書いた例

program hanoi;
             {Recursively solve the towers of Hanoi problem.  Moves disks from A to C.
             The code from "Oh Pascal" by Doug Cooper}
var height:integer;
  procedure Move (Height: integer; FromPeg,ToPeg,UsingPeg:char);
  begin
    if Height = 1
    then writeln('Move disk from ', FromPeg,' to ',ToPeg)
    else begin
      Move (Height-1, FromPeg,UsingPeg,ToPeg);
      writeln('Move a disk from ',FromPeg,' to ',ToPeg);
      Move (Height-1,UsingPeg,ToPeg,FromPeg)
    end;
  end;

begin
  writeln('How Many disks are you going to start with?');
  readln(Height);
  Move (Height,'A','C','B');
  readln()
end.

C++で書いた例

#include < iostream >

using namespace std;
void move(int numberOfDisk, char fromPin,char toPin,char usePin);


int main() {
    int numberOfDisk;
    char fromPin = 'A',usePin='B',toPin='C';
    cout << "How many disk do you want to move from pin A to pin C?: " ;
    cin >> numberOfDisk;
    move (numberOfDisk,fromPin,toPin,usePin);

    return 0;
}
void move(int numberOfDisk, char fromPin,char toPin,char usePin) {
    if(numberOfDisk == 1) cout << "Move a Disk from "<< fromPin << " to " << toPin<< endl;
    else {
        move(numberOfDisk-1,fromPin,usePin,toPin);
        cout << "Move a Disk from " << fromPin << " to " << toPin << endl;
        move(numberOfDisk-1,usePin,toPin,fromPin);
    }

}

Pythonで書いた例

'''Towers of Hanoi'''
def move(numberOfDisk, fromPin, toPin, usingPin):
    if (numberOfDisk == 1):
        print ("Move a Disk from ",fromPin," to ",toPin)
    else:
        move(numberOfDisk-1,fromPin,usingPin,toPin)
        print ("Move a Disk from ",fromPin," to ",toPin)
        move(numberOfDisk-1,usingPin,toPin,fromPin)

if __name__ == '__main__':
    numberOfDisk = input("How many Disk would you like to move? ")
    move(int(numberOfDisk),'A','B','C')


Python で円盤の数を4とした場合の実行例


~ $ python3 hanoi.py
How many Disk would you like to move? 4
Move a Disk from  A  to  C
Move a Disk from  A  to  B
Move a Disk from  C  to  B
Move a Disk from  A  to  C
Move a Disk from  B  to  A
Move a Disk from  B  to  C
Move a Disk from  A  to  C
Move a Disk from  A  to  B
Move a Disk from  C  to  B
Move a Disk from  C  to  A
Move a Disk from  B  to  A
Move a Disk from  C  to  B
Move a Disk from  A  to  C
Move a Disk from  A  to  B
Move a Disk from  C  to  B

動かす回数は円盤の数が1の時は1、2の時は3、3の時は7、4の時は上の出力のように15となり、円盤の数がnのときは

2のn乗から1を引いた値

となる。枚数が多ければ多いほど全部のステップの数がどんどん大きくなっていく。
Python version で試してみると、手持ちのややくたびれたノートブックパソコンではステップの出力が終わるのに 円盤の数が23枚で30秒、24枚で1分、25枚で2分かかった。(下の出力結果参照)実行時間がここの調子で倍々で増えていくとすると入力が64枚のときに解法を出力するのに2の38乗の分がかかるということになるが、これを年に直すと、なんと52万年というとんでもない数字になる。 ので上のプログラムに64と入力しようものなら計算が終わらない。 なるほど、これでは世界が滅びるわけだ。

~ $ time echo 23 | python3 hanoi.py >/dev/null
real	0m29.308s
user	0m29.225s
sys	0m0.054s
~ $ time echo 24 | python3 hanoi.py>/dev/null
real	0m58.437s
user	0m58.237s
sys	0m0.122s
~ $ time echo 25 | python3 hanoi.py>/dev/null
real	1m55.068s
user	1m54.540s
sys	0m0.320s

Python、comprehension を使った一括処理

Pythonには” … for … in”という構文を使った、一括でメンバー要素を処理する機能がある。Comprehensionと呼ばれ、リスト、セット、辞書に使える。
リストの使用例

>>> a_list =list(range(10))
>>> a_list
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> a_list = [x**2 for x in a_list]  --List comprehension
>>> a_list
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

If を使い、要素のFilteringをしてから処理することもできる。下はリスト中、偶数だけを処理して新しいリストを作成した例。 

>>> a_list = list(range(10))
>>> a_list = [x**2 for x in a_list if (x%2 ==0)]
>>> a_list
[0, 4, 16, 36, 64]

Python Dictionary の キーと値をスワップして新しい辞書を作る。

>>> week_dict={'Mon':'月','Tue':'火','Wed':'水','Thu':'木','Fri':'金','Sat':'土','Sun':'日'}
>>> youbi_dict={value:key for key,value in week_dict.items()}
>>> youbi_dict
{'木': 'Thu', '土': 'Sat', '月': 'Mon', '火': 'Tue', '水': 'Wed', '日': 'Sun', '金': 'Fri'}
>>> week_dict
{'Mon': '月', 'Fri': '金', 'Tue': '火', 'Sat': '土', 'Sun': '日', 'Wed': '水', 'Thu': '木'}
>>> week_dict['Fri']
'金'
>>> youbi_dict['日']
'Sun'

辞書は順番はキープしないのだ。

Python でテクストファイルを読み書きする。

With と For iterater を使うとファイルを明示的にクローズしないでもよいそうだ。
例題でやっているのはTest.txtというファイルから一行ずつ読み込んでリストを作成し、それをリストオブジェクトのSortedメソドで並び替えをしたのち、test2.txtというファイルに書き戻す、という作業。 なんか簡単にできるし(簡単だけど)、コメントを入れなくても読み解きやすい感じ。このあたりがPythonの強みか。

a_list=[]
with open("test.txt", mode="r",encoding='utf-8') as a_file:
     for a_line in a_file:
         a_list.append(a_line.rstrip())

a_list = sorted(a_list)

with open("test2.txt", mode="w", encoding='utf-8') as a_file:
    for a_line in a_list:
        a_file.write(a_line+'\n')

Pythonの関数定義とパラメーター

Python3 をいじっていく中で、気が付いたことをこのプログにぼちぼち書き留めておこうと思う。

Pythonの関数定義ではパラメーターも戻り値もタイプを指定しない。C++,Java,Pascalなどの知識のある学習者には異常に見えるが、Python は loosely typed language なんである。

>>> def foo(a,b,c):
	return a*100 + b*10 + c

>>> foo(3,2,1)
321

パラメーターに初期値を持たせることができる。 パラメーターを指定しない場合はその初期値が使用される。

>>> def bar(a=1, b=1, c=1):
	return a*100 + b*10 + c

>>> bar()
111
>>> bar(3)
311
>>> bar(3,4)
341
>>> bar(3,4,1)
341

さらにはパラメーターを明示すれば、カッコ内に現れる順序を気にする必要がなくなる。

>>> foo (a=5,c=2,b=1)
512
>>> bar(c=3,b=2)
123
>>> bar(c=9)
119
>>> bar(c=6,b=4,a=2)
246

Ruby と Python

どちらもInterpreter Languageだが、ひとつを選べと言われたらどちらを覚えたほうがよいのだろう。 
日本ではRubyがかなり人気があるようだが、WorldwideのUser BaseではPythonのほうが多い。 多いと言ってもPHPとかに比べるとかなり少ない。

Ruby

# Old "coke" program from 80's Basic programming book in Ruby
answer = Random.rand(1..100)
count,guess = 0,0
puts 'I am thinking about number between 1 and 100. Can you guess?'
while (answer != guess) 
  guess = gets.to_i
  count += 1
  if guess > answer 
     puts "#{guess} is too high"
  elsif guess < answer
     puts "#{guess} is too low"
  else 
    puts "you got it right in #{count} try!"
  end
end

Python

"""Old "coke" program from 80's Basic programming book, in Python"""
import random
answer = random.randrange(1,100)
print ("I am thinking a number between 1 and 100. Hope you can guess")
guess, count = 0,0
while (answer != guess ):
    guess = input ("what number am I thinking? ")
    guess = int(guess)
    count += 1
    if answer < guess:
        print ( guess, " is too high, try again")
    elif answer > guess:
        print ( guess," is too low. Try again")
    else:
        print ("Bingo! you guessed it right in ",count," tries!")

とりあえず調べて見えてきた部分で大昔、Basicで初期に書いた練習用プログラムを移植してみたが、ごらんのとおり、これくらいのサイズのプログラムなら、どちらで書いてもほとんど差がない。言語のパワフルなところを使ってみないとその差がわからない、という感じだが、 ルーズに行くならPythonかな?インデントでブロックを明示している、つまり単なる書式ではなく、言語の一部になっているのが、なんとも割り切っているところ。 これで{}も"End"も必要ない。 個人的には好きである。 どちらの言語も読み解きやすいのはまちがいなく、これをC++ で書こうとすると、

#include <iostream>
#include <cstdlib> //need this to use rand() 
#include <ctime> //need this to use time() 
using namespace std;

int main(){
    int answer, guess=0, count=0;
    srand(time(NULL));
    answer = rand()%100 + 1;
    cout << "I am thinking about the number between 1 and 100. Can you guess?";
    while (guess != answer) {
        ++count;
        cin >> guess;
        if (guess > answer) 
            cout << guess << "is too high";
        else if (guess < answer) 
            cout << guess << "is too low";
        else
            cout <<"You have it right in " << count << "  times!";
        cout << endl;
                    
    }
    return 0;
}

これよりは楽ですよ。