"""
=============================================================================
第 14 讲 · 代码 3：AlphaZero 的骨架——自我对弈 + PUCT 搜索 + 学习策略与价值
=============================================================================

对应讲义：lectures/lecture14_mcts_alphazero.md 第 5–6 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
MCTS 的两个弱点：
    - 随机走子的评估很粗糙（上一个文件演示 2：估计值收敛得很慢）；
    - 每个着法都要先"试一下"，没有"直觉"告诉它哪些着法值得先看。
AlphaZero 用一个网络同时输出：
    - 策略 p(a | s)："直觉"——哪些着法看起来值得考虑（作为搜索的先验）；
    - 价值 v(s)："判断"——这个局面大概能赢还是会输（代替随机走子）。
然后用**自我对弈**来训练这个网络：
    - 策略的目标 = MCTS 搜索后的访问次数分布（搜索比直觉更强，让直觉去模仿搜索）；
    - 价值的目标 = 这盘棋最终的胜负。
网络越好，搜索越强；搜索越强，训练目标越好——一个不断自我提升的循环。

-----------------------------------------------------------------------------
【本文件的简化】
-----------------------------------------------------------------------------
真正的 AlphaZero 用深度神经网络，能把在一个局面上学到的东西**泛化**到相似的、从没见过的局面。
这里为了只用 numpy、并且能看清每个数字，"网络"是一张**查找表**：每个局面各存一组策略 logits 和一个价值。
所以它**不能泛化**：自我对弈中从没遇到过的局面，它一无所知（演示 2、3 会专门看这一点）。
除此之外，训练循环与 AlphaZero 相同。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    cd code/lecture14
    python 03_alphazero_lite.py      （约 15 秒）
"""

import math

import numpy as np

from mcts import best_move, mcts
from tictactoe import (EMPTY, critical_positions, legal_moves, optimal_moves, play, solve,
                       to_move, winner)


# =============================================================================
# 第 1 部分："网络"（查找表）
# =============================================================================
class TableNet:
    """
    每个局面一组策略 logits（长度 9）和一个价值，存在字典里；没见过的局面用默认值
    （均匀的策略、价值 0）。

    【predict(board)】返回 (合法着法上的先验概率 dict, 价值)，价值从"轮到的一方"的角度。
    【update(board, pi, z)】朝着目标走一小步：
        价值：v ← v + lr_v · (z - v)，lr_v = max(1/n, lr_v_min)，n 是这个局面被训练过的次数
              ——前几次相当于"求平均"，之后保持一个小的学习率，让价值能跟上越来越强的自我对弈。
        策略：logits ← logits + lr_p · (π - softmax(logits))
              ——这是交叉熵 -Σ π log p 对 logits 的梯度下降一步。
    【RL 联系】这两个更新就是 AlphaZero 损失函数 (z - v)² - πᵀ log p 的梯度步。
    【为什么价值用"平均"？】一盘棋的结果 z 是很吵的（自我对弈中会下错、有随机性）。
        固定的大学习率会让价值一直在最近几盘的结果之间跳来跳去；求平均才能把噪声平均掉。
    """

    def __init__(self, lr_v_min=0.05, lr_p=0.3):
        self.logits = {}
        self.value = {}
        self.count = {}
        self.lr_v_min, self.lr_p = lr_v_min, lr_p

    def predict(self, board):
        moves = legal_moves(board)
        lg = self.logits.get(board)
        if lg is None:
            p = np.full(len(moves), 1.0 / len(moves))
        else:
            x = lg[moves]
            p = np.exp(x - x.max())
            p /= p.sum()
        return dict(zip(moves, p)), self.value.get(board, 0.0)

    def update(self, board, pi, z):
        moves = legal_moves(board)
        lg = self.logits.setdefault(board, np.zeros(9))
        x = lg[moves]
        p = np.exp(x - x.max())
        p /= p.sum()
        target = np.array([pi.get(m, 0.0) for m in moves])
        lg[moves] += self.lr_p * (target - p)
        n = self.count.get(board, 0) + 1
        self.count[board] = n
        v = self.value.get(board, 0.0)
        self.value[board] = v + max(1.0 / n, self.lr_v_min) * (z - v)


# =============================================================================
# 第 2 部分：PUCT 搜索（AlphaZero 的 MCTS）
# =============================================================================
class PNode:
    __slots__ = ("board", "children", "N", "W", "P", "expanded")

    def __init__(self, board, prior):
        self.board, self.P = board, prior
        self.children, self.N, self.W, self.expanded = {}, 0, 0.0, False


def puct_search(board, net, n_sims, c_puct, rng, dirichlet=None):
    """
    AlphaZero 式的 MCTS，与 UCT 有三点不同：

    1. 选择用 PUCT 公式：argmax_a  Q(s,a) + c_puct · P(s,a) · sqrt(N(s)) / (1 + N(s,a))
       P(s,a) 是网络给的先验："直觉"认为好的着法，探索奖励更大——不必每个着法都先试一次。
    2. 叶子节点**不做随机走子**，直接用网络的价值 v(s) 作为评估（终局则用真实胜负）。
    3. 展开叶子时，一次性为所有合法着法建立子节点，并记下网络给的先验概率。

    【参数】dirichlet : (α, ε) 或 None。自我对弈时，在根节点的先验上混入 Dirichlet 噪声
        P ← (1-ε)·P + ε·Dir(α)，保证即使网络"很确定"，也会偶尔探索其他着法（第 11–13 讲的探索！）。
    【返回】根节点。
    """
    root = PNode(board, 1.0)
    for sim in range(n_sims):
        node, path = root, [root]
        # 1. 选择
        while node.expanded and node.children:
            sqrt_n = math.sqrt(node.N)
            best, best_score = None, -1e9
            for ch in node.children.values():
                q = ch.W / ch.N if ch.N > 0 else 0.0
                score = q + c_puct * ch.P * sqrt_n / (1 + ch.N)
                if score > best_score:
                    best, best_score = ch, score
            node = best
            path.append(node)
        # 2. 评估（+ 展开）
        w = winner(node.board)
        if w is not None:
            value = 0.0 if w == 0 else -1.0              # 从"轮到的一方"看：刚走的一方赢了 → -1
        else:
            priors, value = net.predict(node.board)
            if node is root and dirichlet is not None:
                alpha, eps = dirichlet
                noise = rng.dirichlet([alpha] * len(priors))
                priors = {m: (1 - eps) * p + eps * n for (m, p), n in zip(priors.items(), noise)}
            for m, p in priors.items():
                node.children[m] = PNode(play(node.board, m), p)
            node.expanded = True
        # 3. 回传：value 是从 node"轮到的一方"看的；每个节点的 W 从"走到它的那一方"看
        for n in reversed(path):
            n.N += 1
            n.W += -value                                # 走到 n 的一方 = n 的对手，所以取负
            value = -value
    return root


def visit_distribution(root, temperature=1.0):
    """根节点下各着法的访问次数分布 π(a) ∝ N(a)^(1/τ)。τ → 0 时就是"选访问最多的"。"""
    moves = list(root.children)
    n = np.array([root.children[m].N for m in moves], dtype=float)
    if temperature == 0:
        pi = (n == n.max()).astype(float)
    else:
        pi = n ** (1.0 / temperature)
    return dict(zip(moves, pi / pi.sum()))


# =============================================================================
# 第 3 部分：自我对弈与训练
# =============================================================================
def self_play_game(net, n_sims, rng, c_puct=1.5):
    """
    自己和自己下一盘棋，返回训练样本 [(局面, 搜索的访问分布 π, 最终结果 z), ...]。
    z 从"该局面轮到的一方"的角度：赢 +1，平 0，输 -1。
    前 2 步按访问分布**随机**落子（温度 1），之后选访问最多的着法——前者让开局多样化。
    """
    board, history = EMPTY, []
    while winner(board) is None:
        root = puct_search(board, net, n_sims, c_puct, rng, dirichlet=(0.5, 0.25))
        tau = 1.0 if len(history) < 2 else 0.0
        pi = visit_distribution(root, tau)
        history.append((board, visit_distribution(root, 1.0)))
        moves, probs = list(pi), np.array(list(pi.values()))
        board = play(board, moves[rng.choice(len(moves), p=probs)])
    w = winner(board)
    return [(b, pi, 0.0 if w == 0 else (1.0 if to_move(b) == w else -1.0)) for b, pi in history]


def argmax_random(d, rng):
    """字典中值最大的键；并列时随机选一个（避免总是偏向编号小的着法）。"""
    best = max(d.values())
    cands = [k for k, v in d.items() if v >= best - 1e-12]
    return cands[rng.integers(len(cands))]


def policy_move(net, board, rng):
    """只用策略（"直觉"），不做任何搜索：选先验概率最大的着法。"""
    return argmax_random(net.predict(board)[0], rng)


def puct_move(net, board, n_sims, rng):
    """PUCT 搜索 n_sims 次，选访问次数最多的着法。"""
    root = puct_search(board, net, n_sims, 1.5, rng)
    return argmax_random({m: ch.N for m, ch in root.children.items()}, rng)


def losses_vs_perfect(choose, rng, n_games=100):
    """和完美对手下 n_games 盘（先后手各一半），返回输了几盘。完美对手不会输，所以最好的结果是 0。"""
    losses = 0
    for g in range(n_games):
        board, me = EMPTY, (1 if g % 2 == 0 else -1)
        while winner(board) is None:
            if to_move(board) == me:
                m = choose(board)
            else:
                opts = sorted(optimal_moves(board))
                m = opts[rng.integers(len(opts))]
            board = play(board, m)
        losses += winner(board) == -me
    return losses


def accuracy(choose, positions):
    """在一组局面上，choose 选到最优着法的比例。"""
    return float(np.mean([choose(b) in optimal_moves(b) for b in positions]))


def main():
    rng = np.random.default_rng(0)
    net = TableNet()
    crit = critical_positions()
    seen = set()

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：自我对弈训练（每轮 50 盘，每步 64 次 PUCT 模拟）")
    print("=" * 78)
    print("   轮次   累计   见过的   价值的平均误差*    只用策略在见过的     与完美对手下 100 盘，输了几盘")
    print("          对局    局面    （训练 ≥10 次的局面）  关键局面上的正确率     只用策略     PUCT（16 次模拟）")
    for it in range(0, 41):
        if it > 0:
            for _ in range(50):
                for b, pi, z in self_play_game(net, 64, rng):
                    seen.add(b)
                    net.update(b, pi, z)
        if it in (0, 1, 5, 10, 20, 40):
            many = [b for b in seen if net.count.get(b, 0) >= 10]
            verr = np.mean([abs(net.value[b] - solve(b)) for b in many]) if many else float("nan")
            seen_crit = [b for b in crit if b in seen]
            pol = accuracy(lambda b: policy_move(net, b, rng), seen_crit) if seen_crit else float("nan")
            lp = losses_vs_perfect(lambda b: policy_move(net, b, rng), rng)
            ls = losses_vs_perfect(lambda b: puct_move(net, b, 16, rng), rng)
            verr_s = "   —  " if np.isnan(verr) else f"{verr:6.3f}"
            pol_s = "   —  " if np.isnan(pol) else f"{pol * 100:5.1f}%"
            print(f"   {it:4d}   {it * 50:5d}   {len(seen):5d}        {verr_s}               {pol_s}"
                  f"             {lp:4d}          {ls:4d}")
    print("   * 与极小极大真实值（1 赢、0 平、-1 输，从轮到的一方看）之差的绝对值的平均")
    print("  观察：")
    print("  - 第 0 轮（没有训练）：策略是均匀的、价值全是 0，只用策略就等于随机下，输掉大多数对局。")
    print("  - 训练之后，价值表的误差迅速降到很小：自我对弈的胜负结果教会了它\"这个局面会怎样\"。")
    print("  - 只用策略（完全不搜索）的正确率和对局成绩都大幅提高：策略的训练目标是**搜索后的访问分布**，")
    print("    它把搜索的结论\"蒸馏\"成了直觉。AlphaGo Zero 论文中，只用策略网络（不搜索）就已经达到了很高的水平。")
    print("  - 对局成绩的变化并不单调（100 盘棋的随机波动不小），而且训练 2000 盘之后仍然会输一些棋：")
    print("    完美对手会把棋局带到自我对弈中从没出现过的局面，而查找表在那里一无所知（见演示 2、3）。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：学到的\"直觉\"让搜索更高效——关键局面上选到最优着法的比例")
    print("=" * 78)
    blank = TableNet()                               # 没训练过的"网络"：均匀先验、价值 0
    seen_crit = [b for b in crit if b in seen]
    unseen_crit = [b for b in crit if b not in seen]
    for label, P in [(f"自我对弈中见过的关键局面（{len(seen_crit)} 个）", seen_crit),
                     (f"自我对弈中没见过的关键局面（{len(unseen_crit)} 个）", unseen_crit)]:
        print(f"\n  {label}：只用训练后的策略（不搜索）：{accuracy(lambda b: policy_move(net, b, rng), P) * 100:.1f}%")
        print("     模拟次数    PUCT + 训练后的表    PUCT + 没训练的表    UCT（随机走子）")
        for n in [4, 16, 64, 256]:
            a = accuracy(lambda b: puct_move(net, b, n, rng), P)
            a0 = accuracy(lambda b: puct_move(blank, b, n, rng), P)
            u = accuracy(lambda b: best_move(mcts(b, n, 1.4, rng), rng), P)
            print(f"      {n:5d}          {a * 100:5.1f}%              {a0 * 100:5.1f}%             {u * 100:5.1f}%")
    print("\n  观察：")
    print("  - 见过的局面：训练后的表让搜索**高效得多**——4 次模拟的正确率就远高于没训练的表和 UCT。")
    print("    先验告诉搜索\"先看哪几步\"，价值代替了粗糙的随机走子。")
    print("  - 但模拟次数很少（4、16）时，搜索反而**不如**直接用策略：只有几次访问时，\"访问最多\"是一个很粗糙的信号，")
    print("    容易被一两个有噪声的价值估计带偏。模拟够多（64、256）时，搜索才超过策略。")
    print("    这就是 AlphaZero 下棋时每步要做几百上千次模拟的原因：**搜索要足够深，才能改进直觉**。")
    print("  - 没见过的局面：训练后的表和没训练的表**几乎一样**——查找表不能泛化，")
    print("    在一个局面上学到的东西，对另一个局面毫无帮助。这里的搜索全靠终局判断，与 UCT 差不多。")
    print("    真正的 AlphaZero 用神经网络，从见过的局面中学到的模式能泛化到没见过的局面——")
    print("    围棋的局面多到自我对弈永远不可能见全，**泛化是一切的前提**。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：自我对弈没见过的是哪些局面？")
    print("=" * 78)
    dec_seen = np.mean([solve(b) != 0 for b in seen_crit])
    dec_unseen = np.mean([solve(b) != 0 for b in unseen_crit])
    print(f"  关键局面共 {len(crit)} 个：见过 {len(seen_crit)} 个，没见过 {len(unseen_crit)} 个。")
    print(f"  已经\"分出胜负\"（极小极大价值不是和棋）的比例：见过的 {dec_seen * 100:.1f}%，没见过的 {dec_unseen * 100:.1f}%")
    print("  观察：")
    print("  - 自我对弈的双方都在认真下棋，棋局大多沿着\"合理\"的路线发展；没见过的局面大多要有一方")
    print("    先下出明显的错着才会出现（已经分出胜负的比例高得多）。这类局面往往是\"一步杀\"或\"必须堵\"，")
    print("    靠搜索很容易解决（演示 2 中没见过的局面上，搜索的正确率同样很高）。")
    print("  - 自我对弈把学习集中在\"高水平对局会遇到的局面\"上；为了让它也见识一些不那么\"正常\"的局面，")
    print("    AlphaZero 在根节点加入 Dirichlet 噪声、前几步按访问分布随机落子——又一次是探索。")


if __name__ == "__main__":
    main()
