「評価関数」とは、読んで字の通り、評価する関数である。
何を評価するのかと言うと、ゲームの場合、「現在の局面が有利か不利かを数値にして比較が出来るように評価する」ことが目標となる。評価関数がもしも素晴らしい出来で、その局面が勝ちか負けかを正確に返してくるなら、1手先読みして、そこでの局面を評価関数にかけることで、完璧なプレイをするプログラムが作れる。
もちろん、そんなすばらしい評価関数は(簡単なゲームならばともかく)、将棋のような複雑なゲームでは作成できないか、作成が著しく困難である。
ただし、9章で作成する「詰め将棋ルーチン」を呼び出して「詰む」ようなときは話は別で、この場合は勝ちか負けかがはっきり分かる。
一般的に、二人零和完全情報ゲームのプログラムは、「評価関数」と「先読み」からできている。何手、あるいは何十手か先を読んで、相手がどう応じてもいちばん有利になるような手を探してその手を指す、というのが基本的なプログラムの仕組みになる(「先読み」の仕組みについては、4章で解説する)。
ここで、もしも評価関数が局面の評価…有利不利を間違えるようなら、プログラムはその間違った有利不利に基づいて動くことになってしまう。従って、思考ゲームプログラミングでは、評価関数が非常に重要になる。
評価関数を作成するには、局面の有利不利を決める要素…将棋ならば、「駒得か駒損か」「玉の守りの堅さはどうか」「攻めているか」などのいろいろな要素をそれぞれ評価して、足し算で足し合わせるなり、掛け算(は扱いが難しいが)で掛け合わせるなどの方法が考えられる。そして、より局面を正確に評価する関数=より多くの要素を正確に反映した評価関数ほど、性能が良い関数になると考えられる。
しかし、一方で、将棋のようなゲームでは、深く先を読むことが重要になる。あまりに複雑で、計算に時間がかかる評価関数を作ってしまうと、先読みが浅くなって、単純な評価関数で深く読むプログラムよりもかえって弱くなってしまうことすら考えられる。結局、ここでもバランスを取ることが必要になってくる。
それでは、将棋の局面の評価関数を作り始めてみよう。最初は簡単に…そして、徐々に複雑に。
局面の評価は、主に駒の価値で行う。「駒得は裏切らない」という有名な言葉もあるくらいだ。しかし、例えば、「角」と「銀桂」の二枚換えなら交換して銀と桂馬を取れた方が得だとか、実はあんまり簡単に数値化できるものではなくて、微妙な数値になってしまう。しかし、探してみると、駒の価値を表した入門書など意外にあるもので、とりあえず、まずはそれを真似てみることにしよう。後々、微妙な調整が必要になってくると思われるので、歩の価値は100点として、伊藤果 著「実戦で強くなる将棋入門」の真似をしてみると、次の表のようになる。なお、成り駒の価値は、本に記載されていなかったため、金と同じ性能を持つものは金と同じ価値に、馬と竜はそれぞれ200点増しとした。
歩 100 香 600 桂 700 銀 1000 金 1200 角 1800 飛 2000 と 1200 成香1200 成桂1200 成銀1200 馬 2000 竜 2200 玉 ∞
しかし、玉の無限大は扱いに困るので、プログラム上では10000点とすることにしておく。それから、それぞれの駒が持ち駒になった時の価値は、元の価値の5%増しくらいにしておく。同じ価値だとチャンスがあるまで持ち駒に持っていればいいのに、どんどん盤面に打ってしまうという現象が起きる。このようにすることで、意味もなく飛車角を自陣に打ったり、金銀で桂香を拾いにいったり、といった現象を防ぐのに効果がある。
歩 105 香 630 桂 735 銀 1050 金 1260 角 1890 飛 2100
実際には、局面局面により、駒の価値がどんどん変わっていくのが将棋と言うゲームの特徴であるのだが、(そもそも、駒得を競うゲームではなく、相手の玉を先に詰ませるゲームであるから、極端に言えば詰むのだったら詰ませるのに必要でない駒は全部相手の駒でもよい理屈である。)ここでは特に反映しないことにした。しかし、これでは欠点が大きいのがプログラムが強くなってくると段々に判明してくる。後の章では特に金銀などの金駒(と金や成香、成桂、成銀などを含む)の価値に関して修正案を示したい。
これを、Kyokumenのvalue(現在の評価値)とし、KyokumenのMove関数を駒得(駒損)を計算するように書き換えたのが以下である。また、initControlの中で、最初の評価値を計算することにした。
int KomaValue[]={ //空空空空空空空空空空空空空空空空空 歩 香 桂 銀 金 角 飛 王 と 杏 圭 全 金 馬 龍 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,100,600,700,1000,1200,1800,2000,10000,1200,1200,1200,1200,1200,2000,2200, //空 歩 香 桂 銀 金 角 飛 王 と 杏 圭 全 金 馬 龍 壁 0,-100,-600,-700,-1000,-1200,-1800,-2000,-10000,-1200,-1200,-1200,-1200,-1200,-2000,-2200,0 }; // 成り駒は持ち駒になりえないので、先手後手の歩香桂銀金角飛の分だけ定義すればよい。 int HandValue[]={ //空空空空空空空空空空空空空空空空空 歩 香 桂 銀 金 角 飛王と杏圭全金馬龍 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,105,630,735,1050,1260,1890,2100,0,0,0,0,0,0,0,0, //空 歩 香 桂 銀 金 角 飛 0,-105,-630,-735,-1050,-1260,-1890,-2100 };
public: : int value; // 現在の局面の評価値
for(int suji=0x10:suji<=0x90;suji++) { for(int dan=1;dan<=9;dan++) { : value+=KomaValue[ban[suji+dan]]; } } for(koma=0;koma<EHI;koma++) { : value+=HandValue[i]*Motigoma[i]; }
if (from<OU) { : value-=HandValue[te.koma]; value+=KomaValue[te.koma]; } if (promote) { value-=KomaValue[te.koma]; value+=KomaValue[te.koma|PROMOTED]; : } if (capture!=EMPTY) { : value-=KomaValue[te.capture]; value+=HandValue[(te.capture&~PROMOTED&~SELF&~ENEMY)|SorE]; }
Te Sikou::Think(int SorE,Kyokumen k) { Te teBuf[600]; int teNum=k.MakeLegalMoves(SorE,teBuf); int i; #ifdef _DEBUG // デバッグの際には、合法手を一覧表示します。 for(i=0;i<teNum;i++) { teBuf[i].Print(); } printf("\n"); #endif int best=0; int bestVal; if (SorE==SELF) { bestVal=-1000000; } else { bestVal=+1000000; } for (i=0;i<teNum;i++) { Kyokumen kk(k); kk.Move(SorE,teBuf[i]); if (SorE==SELF && bestVal<kk.value) { bestVal=kk.value; best=i; } if (SorE==ENEMY && bestVal>kk.value) { bestVal=kk.value; best=i; } } return teBuf[best]; }
しかし、対戦してみると分かると思うが、このプログラムは相当に弱い。
何が問題なのかというと、1手しか先読みしていないので、相手が金で守っている歩を飛車で取ると「歩を得した」という評価が返ってきて、なんとプラスになってしまうからである。
2手、3手と読みを深くしていけば、ある程度この問題は解決するが、それでも読みの末端の局面で同じようにほとんど飛車損の局面が高く評価されたりしては話にならない。
それに、この関数では、持ち駒を手放すのはマイナスで、決してプラスに評価されないので、絶対に持ち駒を盤に打たない。しかも、この関数では、乱数を使っていないので端っこから順に指して行く。(乱数を用いるのは難しいことではないので、挑戦してみたい読者は挑戦されたし。ヒントは、配列をもう一つ使って、先に乱数で評価する順番をシャッフルしておく、というくらいで十分であろう。)
ここまでの変更は、chapter03-01フォルダに格納した。
次の節では、この二つの問題に対処する一つの方法を示す。
評価関数で評価した時に、図1の局面で、手番が後手だったらどう評価して欲しいだろうか?もちろん、評価して欲しい値は、先手の角損(歩は一枚手にしているが)である。しかし、先の節のように全く何も考えないでプログラムを書くと、ここでは歩を得しているということで+200点が返って来てしまう。
そこで、駒の取り合いに関しては、手番のある側が一番駒得できる駒を取れるものとして評価するのが良く行われている方法である。ただし、実際には、図2のように、同じ箇所に複数の駒が利きあっており、単純に取って駒得で終わるわけにもいかない。
そのため、駒の交換値と呼ばれる手法が良く用いられる。これは、図3のようなアルゴリズムである。
つまり、それぞれの局面で、取った方が得か、取らないでパスした方が得か、を計算するわけである。(この考え方は、後にMinMaxと呼ばれるアルゴリズムの説明でも同じような考え方をするので、「ややこしい」と思われるだろうが、是非ここでマスターしておいて欲しい。)
これに沿って、実際にプログラムを書くと、以下のようになる。とりあえず、交換値の計算ではピンは考慮しないことにしたが、もちろん考慮する方が望ましい。ただ、考慮するとかなり遅くなると思われる。この関数も相当の回数を呼ばれる関数になるので、多少の不正確さには目をつぶって、高速な動作を優先した。
// 事前に設定していなかった、te.captureを書き換える。 int IsCorrectMove(Te &te) { if (ban[te.from]==OU) { if (controlE[te.to]!=0) { return 0; } else { te.capture=ban[te.to]; return 1; } } else if (ban[te.from]==EOU) { if (controlS[te.to]!=0) { return 0; } else { te.capture=ban[te.to]; return 1; } } int d=kyori(te.from,te.to); if (d==0) return 0; int dir=(te.to-te.from)/d; if (pin[te.from]!=0 && dir!=pin[te.from] && dir!=-pin[te.from]) return 0; if (d==1) { te.capture=ban[te.to]; return 1; } // ジャンプなので、途中に邪魔な駒がいないかどうかチェックする for(int i=1,pos=te.from+dir;i<d;i++,pos=pos+dir) { if (ban[pos]!=EMPTY) { return 0; } } te.capture=ban[te.to]; return 1; } int GainMin(Te *AtackS,int NumAtackS, Te *AtackE,int NumAtackE) { int v=value; if (NumAtackE>0) { int k=0; while(!CanMove(AtackE[k]) && k<NumAtackE) { k++; } if (k==0) { } else if (k<NumAtackE) { Te t=AtackE[k]; for(int i=k;i>0;i--) { AtackE[i]=AtackE[i-1]; } AtackE[0]=t; } else { // 他に手がない=取れない。 return v; } AtackE[0].capture=ban[AtackE[0].to]; Move(ENEMY,AtackE[0]); return tmin(v, GainMax(AtackS,NumAtackS,AtackE+1,NumAtackE-1)); } else { return v; } } int GainMax(Te *AtackS,int NumAtackS, Te *AtackE,int NumAtackE) { int v=value; if (NumAtackS>0) { // 邪魔駒の処理 int k=0; while(!CanMove(AtackS[k]) && k<NumAtackS) { k++; } if (k==0) { } else if (k<NumAtackS) { Te t=AtackS[k]; for(int i=k;i>0;i--) { AtackS[i]=AtackS[i-1]; } AtackS[0]=t; } else { // 他に手がない=取れない。 return v; } AtackS[0].capture=ban[AtackS[0].to]; Move(SELF,AtackS[0]); return tmax(v, GainMin(AtackS+1,NumAtackS-1,AtackE,NumAtackE)); } else { return v; } } int EvalGain(int position) const { Te teTop[40]; int ret; int ToPos=position; if ((ban[position]&SELF) && !controlE[position]) { // 取られる心配がない return 0; } if ((ban[position]&ENEMY) && !controlS[position]) { // 取られる心配がない return 0; } // AtackCountを得るように、駒のリストを得る Te *AtackS=teTop; // 一個所への利きは、最大隣接8+桂馬2+飛飛角角香香香香=18だから。 Te *AtackE=teTop+18; int AtackCountE=0; int AtackCountS=0; int pos2; int PromoteS,PromoteE; int b=1; int bj=1<<16; int i; int pos=ToPos; if ((ToPos&0x0f)<=3+Te::HOSEI) { PromoteS=1; } else { PromoteS=0; } if ((ToPos&0x0f)>=7+Te::HOSEI) { PromoteE=1; } else { PromoteE=0; } // 桂馬の利きは別に数える for (i = 0; i < 8; i++) { pos2=pos; if (controlS[pos] & b) { pos2-=Kyokumen::Direct[i]; AtackS[AtackCountS].from=pos2; AtackS[AtackCountS].koma=ban[pos2]; AtackS[AtackCountS].to=pos; if ((PromoteS || (pos2 & 0x0f)<=3+Te::HOSEI) && canPromote[AtackS[AtackCountS].koma]) { AtackS[AtackCountS].promotion=1; } else { AtackS[AtackCountS].promotion=0; } AtackCountS++; } else if (controlE[pos] & b) { pos2-=Kyokumen::Direct[i]; AtackE[AtackCountE].from=pos2; AtackE[AtackCountE].koma=ban[pos2]; AtackE[AtackCountE].to=pos; if ((PromoteE || (pos2 & 0x0f)>=7+Te::HOSEI) && canPromote[AtackE[AtackCountE].koma]) { AtackE[AtackCountE].promotion=1; } else { AtackE[AtackCountE].promotion=0; } AtackCountE++; } if (ban[pos-Kyokumen::Direct[i]]!=OU && ban[pos-Kyokumen::Direct[i]]!=EOU) { while((controlS[pos2] & bj) || (controlE[pos2] & bj)) { pos2-=Kyokumen::Direct[i]; while(ban[pos2]==EMPTY) { pos2-=Kyokumen::Direct[i]; } if (IsEnemy(ban[pos2])) { AtackE[AtackCountE].from=pos2; AtackE[AtackCountE].koma=ban[pos2]; AtackE[AtackCountE].to=pos; if ((PromoteE || (pos2 & 0x0f)>=7+Te::HOSEI) && canPromote[AtackE[AtackCountE].koma]) { AtackE[AtackCountE].promotion=1; } else { AtackE[AtackCountE].promotion=0; } AtackCountE++; } else if (IsSelf(ban[pos2])) { AtackS[AtackCountS].from=pos2; AtackS[AtackCountS].koma=ban[pos2]; AtackS[AtackCountS].to=pos; if ((PromoteS || (pos2 & 0x0f)<=3+Te::HOSEI) && canPromote[AtackS[AtackCountS].koma]) { AtackS[AtackCountS].promotion=1; } else { AtackS[AtackCountS].promotion=0; } AtackCountS++; } } } b<<=1; bj<<=1; } // 桂馬の利き b=1<<8; for(i=8;i<12;i++) { if (controlS[pos] & b) { pos2=pos-Kyokumen::Direct[i]; AtackS[AtackCountS].from=pos2; AtackS[AtackCountS].koma=ban[pos2]; AtackS[AtackCountS].to=pos; if (PromoteS && canPromote[AtackS[AtackCountS].koma]) { AtackS[AtackCountS].promotion=1; } else { AtackS[AtackCountS].promotion=0; } AtackCountS++; } if (controlE[pos] & b) { pos2=pos+Kyokumen::Direct[i]; AtackE[AtackCountE].from=pos2; AtackE[AtackCountE].koma=ban[pos2]; AtackE[AtackCountE].to=pos; if (PromoteE && canPromote[AtackE[AtackCountE].koma]) { AtackE[AtackCountE].promotion=1; } else { AtackE[AtackCountE].promotion=0; } AtackCountE++; } b<<=1; } // 駒の価値でソート。 for (i=0; i < AtackCountS-1; i++) { int max_id = i; int max_val = PieceValue[AtackS[i].koma]+CalcKomaChousei(AtackS[i].koma,AtackS[i].from); for (int j = i+1; j < AtackCountS ; j++) { int v=PieceValue[AtackS[j].koma]+CalcKomaChousei(AtackS[j].koma,AtackS[j].from); if (v < max_val) { max_id = j; max_val= v; } else if (v==max_val) { if (PieceValue[AtackS[j].koma]<PieceValue[AtackS[max_id].koma]) { max_id=j; } } } //最大値との交換 if (i!=max_id) { swap(AtackS[i],AtackS[max_id]); } } // 駒の価値でソート。 for (i=0; i < AtackCountE-1; i++) { int max_id = i; int max_val = PieceValue[AtackE[i].koma]+CalcKomaChousei(AtackE[i].koma,AtackE[i].from); for (int j = i+1; j < AtackCountE ; j++) { int v=PieceValue[AtackE[j].koma]+CalcKomaChousei(AtackE[j].koma,AtackE[j].from); if (v> max_val) { max_id = j; max_val= v; } else if (v==max_val) { if (PieceValue[AtackE[j].koma]>PieceValue[AtackE[max_id].koma]) { max_id=j; } } } //最大値との交換 if (i!=max_id) { swap(AtackE[i],AtackE[max_id]); } } int IsEnemy=(ban[position] & ENEMY); int IsSelf=!IsEnemy && ban[position]!=EMPTY; if (IsEnemy && AtackCountS>0) { int Eval=Evaluate(); Kyokumen now(*this); ret=now.GainMax(AtackS,AtackCountS,AtackE,AtackCountE)-Eval; } else if (IsSelf && AtackCountE>0) { int Eval=Evaluate(); Kyokumenl now(*this); ret=Eval-now.GainMin(AtackS,AtackCountS,AtackE,AtackCountE); } else { ret=0; } return ret; } int Kyokumen::BestEval(int SorE,Kyokumen k) { // SorEの利きのある敵の駒(&SorE==0の駒)について、Evalを呼び出して、 // 一番いい交換値を探す。 best=0; for(int suji=0x10;suji<=0x90;suji+=0x10) { for(int dan=1;dan<=9;dan++) { if ((ban[suji+dan]&SorE)==0) { int value=EvalGain(suji+dan); if (value>best) { best=value; } } } } return best; }
先のプログラムを思考ルーチンに組み込んで、1手(+交換値)だけ先を読むプログラムが書ける。
Te Sikou::Think(int SorE,Kyokumen k) { int bestTe=0; int bestVal=-1000000; for(int i=0;i<teNum;i++) { Kyokumen kk(k); Move(kk,teBuf[i]) value=kk.value-kk.BestEval(SorE==ENEMY?SELF:ENEMY); if (value>bestVal) { bestTe=i; bestVal=value; } } return teBuf[bestTe]; }
第一章の乱数プログラムよりははるかに手ごわくなったはずである。
なんといっても、すぐに取られるようなところに無駄に駒を打ったりはしないし、取り返せるときにはちゃんと駒を取り返す。
とはいえ、まだまだ笑ってしまうくらい弱いのではあるが。相変わらず持ち駒を盤に打てない。実際、少なくとも2手くらい読めないと、盤に駒を打って得をするかどうか判断できない。また、このように交換値を取り入れても、末端局面で王手飛車とか王手角取りとかの好手が相手にあるかどうか判断できない。これは、結局読みを十分に深くしていくしかない。
それに、何かの駒が取られそうになると、色々と別の箇所で暴れたりする現象を見られるはずだ。このような現象は、「水平線効果」とか「地平線効果」と呼ばれる。この言葉には、プログラムからある一定の深さ=水平線の向こうは見えないという意味が込められている。つまり、水平線の向こうには何があるか分からないので、破滅的な手があったら、何か無駄な手を指して、その破滅的な手を水平線の先に送り出してしまうことで、破滅を回避できたと思い込んでしまうのだ。
たいていの場合、破滅を「遅らせる」ことには成功するのだが、それによりなお悪い結果を生み出す。極端な場合には、ただ単に飛車を大人しく取られていれば単なる飛車損だったのに、銀やら金やら持ち駒全部王手で捨ててしまって、持ち駒全部+飛車損なんていうこともありえる。この水平線効果は、将棋プログラムなどの思考プログラムの未解決の問題の一つで、色々な解決案が今も議論されている。(が、完全になくすことが出来たという例はこの本を書いている私自身も知らない。)
実際には、この水平線効果を減らす一つの手法として、静止探索が考えられた。1手読みでは、相手の駒の利きがある歩を角で単に取って、歩を得した!と喜ぶような手を指していたはずだが、静止探索を導入することで、次に角を損するような局面であれば、歩を単に角で取ったりはしないようになったはずである。
いよいよ、次の章からは、先読みを導入して、本格的な将棋プログラムとして仕上げていく。この章のプログラムは、chapter03-02フォルダ以下に格納した。chapter02と同様にコンパイル・実行して確認してみて欲しい。