麻雀の打牌選択アルゴリズム(1)

麻雀AIを作る場合、まずは和了に向かう手作りができるようにする必要があるのだが、これは比較的簡単。向聴数計算ルーチンさえあれば向聴数が下がるように手を進めていくことでいずれ聴牌し、立直すれば役を考慮しなくても和了ることができる。

向聴数が下がるのは自摸および鳴きをした場合のみであるので、鳴きなしとすると、打牌時に向聴数を上げない(手戻りさせない)ように考慮すればよい。

電脳麻将 では打牌選択はMajiang.Playerクラスのメソッドselect_dapai()で行っているが、ver.0.3.2 での実際の打牌選択を行う部分のコードは以下の通り。

    var n_xiangting = Majiang.Util.xiangting(this._shoupai);
                                                   // 現在の向聴数を求める
    var dapai, max = 0;
    for (var p of this.get_dapai()) {    // 打牌可能な牌について以下を行う
        var new_shoupai = this._shoupai.clone();  // 手牌のコピーを作る
        new_shoupai.dapai(p);                     // 打牌してみる
        if (Majiang.Util.xiangting(new_shoupai) > n_xiangting) continue;
                                              // 向聴数が上がる打牌は選択しない
        var n_tingpai = Majiang.Util.tingpai(new_shoupai).length;
                                              // 向聴数が下がる牌の種類数を得る
        if (n_tingpai >= max) {     // 種類数が一番多い牌を選択する
            max = n_tingpai;
            dapai = p;
        }
    }

Majiang.Util.tingpai()は手牌(Majiang.Shoupaiインスタンス)を入力とし、向聴数が下がる牌の一覧を返す関数。この返り値の要素数(つまり向聴数を下げる牌の種類数)が一番多い牌を打牌するよう選択している。種類数が同数の場合は後から候補となったものを優先しているので、結果として手牌の右側(字牌側)から牌を切るようになる*1

まずは現在の実力を測るために、4人全員をAIにして1000半荘打たせてみた。

対戦数 1,000総局数 9,576
1位率 .254和了 .216
2位率 .240放銃率 .167
3位率 .259立直率 .424
4位率 .247副露率 .000
平均順位 2.50平均打点 6,779

4人とも同一のアルゴリズムなので、論理上は1位率〜4位率はいずれも 0.25、平均順位は 2.5位 となるはずだが、順位の配分には若干のバラツキがでた。立直率は40%を超えているので、自分の手だけを見て聴牌を目指せば40%以上は聴牌できるということのようだ。

和了役についても統計を取ってみた(出現率の低い役は省略)。

和了出現回数出現率 和了出現回数出現率
ドラ 942 45.44% 翻牌 126 6.08%
赤ドラ 905 43.66% 平和 428 20.65%
裏ドラ 917 44.24% 断幺九 184 8.88%
立直 2,051 98.94% 一盃口 115 5.55%
ダブル立直 9 0.43% 三色同順 21 1.01%
一発 620 29.91% 一気通貫 12 0.58%
門前清自摸 541 26.10% 七対子 139 6.71%

ドラの出現率は使用したドラの枚数で求めている。ver.0.3.2では打牌の際にドラをまったく考慮していないが、それでも40%以上(つまり0.4枚以上)はドラが乗るということらしい。天鳳での各役の出現率は オンライン対戦麻雀 天鳳 / ランキング で参照できる*2が、それと比較すると翻牌と断幺九の出現率が低い(天鳳では翻牌は40%超え、断幺九は22%超え)。現在は鳴いていないので当然なのだが、鳴きを実装しないと手作り上相当の制約があることが分かる。逆に七対子率が高い(天鳳では2.6%)が、七対子は2向聴までは手が進みやすいので無理に七対子を狙ってしまう傾向にあるように見えた。

現状の打牌選択アルゴリズムは待ち受けの種類数だけを見ているので、双碰待ち(2種4枚)と両面待ち(2種8枚)の評価が同じになってしまう。そこで、第一段の改良として待ちの論理上の枚数で評価するようにしてみた。*3

    var n_xiangting = Majiang.Util.xiangting(this._shoupai);
                                                   // 現在の向聴数を求める
    var dapai, max = 0;
    for (var p of this.get_dapai()) {    // 打牌可能な牌について以下を行う
        var new_shoupai = this._shoupai.clone();  // 手牌のコピーを作る
        new_shoupai.dapai(p);                     // 打牌してみる
        if (Majiang.Util.xiangting(new_shoupai) > n_xiangting) continue;
                                              // 向聴数が上がる打牌は選択しない
        var x = 0;                            // 評価値の初期値は 0
        for (var tp of Majiang.Util.tingpai(new_shoupai)) {
                                              // 向聴数が下がる牌について
            x += 4 - this._shoupai._bingpai[tp[0]][tp[1]];
                                              // 論理上の待ち枚数を加算する
        }
        if (x >= max) {                       // 枚数が一番多い牌を選択する
            max = x;
            dapai = p;
        }
    }

まず打ち筋の変化を見てみる。例えば以下の局面の場合、
二萬二萬二萬七萬九萬二筒二筒四筒四筒五筒七筒八筒九筒八萬
アルゴリズムは打五筒で双碰待ち立直、新アルゴリズムは打四筒で両面待ち立直となった。

それでは実際に新旧のアルゴリズムを対戦させてどのくらい順位に影響があるか調べてみる。旧アルゴリズム3名に対して新アルゴリズム1名という対戦である。

対戦数 1,000総局数 9,757
1位率 .259和了 .228
2位率 .246放銃率 .163
3位率 .248立直率 .438
4位率 .247副露率 .000
平均順位 2.48平均打点 6,672

和了出現回数出現率 和了出現回数出現率
ドラ 986 44.39% 翻牌 156 7.02%
赤ドラ 935 42.10% 平和 543 24.45%
裏ドラ 952 42.86% 断幺九 204 9.19%
立直 2,212 99.59% 一盃口 109 4.91%
ダブル立直 4 0.18% 三色同順 27 1.22%
一発 649 29.22% 一気通貫 15 0.68%
門前清自摸 510 22.96% 七対子 160 7.20%

立直率が 42.4% → 43.8%、和了率が 21.6% → 22.8%、放銃率が 16.7% → 16.3% となり、若干強くなったようにも思えるが、平均順位は 2.50位 → 2.48位 にとどまっており、ほぼ誤差の範囲である。待ちの牌種重視でも枚数重視でも成績には大して影響ないらしい。

では待ちの枚数を論理値ではなく、河や副露牌も考慮した枚数としたらどうなるか?ということで次回に続く。