每日算法 — 使用java实现围棋:PPO近端策略优化与自我对弈强化学习

围棋是起源于中国的古老棋类游戏,以其极其庞大的状态空间著称。传统基于搜索的AI难以应对围棋的复杂度,而深度强化学习为围棋AI开辟了全新道路。本文用Java实现一个基于PPO(Proximal Policy Optimization)算法的围棋AI,通过自我对弈不断迭代提升策略水平。

一、PPO算法核心思想

PPO是一种策略梯度方法,通过限制策略更新幅度来保证训练稳定性。其核心是裁剪目标函数:

L^{CLIP}(θ) = E_t [min(r_t(θ) Â_t, clip(r_t(θ), 1-ε, 1+ε) Â_t)]

其中 r_t(θ) 是新旧策略的概率比,Â_t 是优势函数估计。裁剪操作防止策略在一次更新中发生剧烈变化。

二、围棋状态表示

围棋棋盘为19×19,每个交叉点有三种状态:黑子、白子、空。使用三维数组表示棋盘状态,同时记录气、提子等信息用于规则判定。

三、Java核心实现

import java.util.*;

/**
 * 围棋PPO自我对弈AI核心实现
 */
public class GoPPOAI {

    // 棋盘大小
    public static final int BOARD_SIZE = 19;
    public static final int EMPTY = 0;
    public static final int BLACK = 1;
    public static final int WHITE = 2;

    // PPO超参数
    private static final double CLIP_EPSILON = 0.2;
    private static final double LEARNING_RATE = 0.0003;
    private static final double GAMMA = 0.99;  // 折扣因子
    private static final double LAMBDA = 0.95; // GAE参数
    private static final int EPOCHS = 4;
    private static final int BATCH_SIZE = 64;

    // 策略网络和价值网络(简化版使用全连接网络)
    private NeuralNetwork policyNet;
    private NeuralNetwork valueNet;

    public GoPPOAI() {
        // 输入:19*19*3 (当前棋子、对手棋子、上一步落子位置)
        this.policyNet = new NeuralNetwork(BOARD_SIZE * BOARD_SIZE * 3, 
                                            new int[]{256, 256}, 
                                            BOARD_SIZE * BOARD_SIZE + 1); // +1 for pass
        this.valueNet = new NeuralNetwork(BOARD_SIZE * BOARD_SIZE * 3, 
                                           new int[]{256, 128}, 
                                           1);
    }

    /**
     * 棋盘状态类
     */
    static class BoardState {
        int[][] board = new int[BOARD_SIZE][BOARD_SIZE];
        int currentPlayer = BLACK;
        int lastMoveX = -1, lastMoveY = -1;

        /**
         * 获取当前玩家的合法落子位置
         */
        List<int[]> getLegalMoves() {
            List<int[]> moves = new ArrayList<>();
            for (int i = 0; i < BOARD_SIZE; i++) {
                for (int j = 0; j < BOARD_SIZE; j++) {
                    if (board[i][j] == EMPTY && !isSuicide(i, j)) {
                        moves.add(new int[]{i, j});
                    }
                }
            }
            // 允许pass
            moves.add(new int[]{-1, -1}); 
            return moves;
        }

        /**
         * 判断是否为自杀手(简化版,未考虑打劫)
         */
        boolean isSuicide(int x, int y) {
            if (board[x][y] != EMPTY) return false;
            // 简化判断:如果落子后该子没有气,则为自杀
            // 实际实现需要更复杂的连通块气计算
            int[][] temp = copyBoard();
            temp[x][y] = currentPlayer;
            return getLiberties(temp, x, y) == 0;
        }

        /**
         * 计算某位置连通块的气数(简化版)
         */
        int getLiberties(int[][] b, int x, int y) {
            int color = b[x][y];
            if (color == EMPTY) return 0;
            boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE];
            Queue<int[]> q = new LinkedList<>();
            q.add(new int[]{x, y});
            visited[x][y] = true;
            int liberties = 0;
            int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};

            while (!q.isEmpty()) {
                int[] cur = q.poll();
                for (int[] d : dirs) {
                    int nx = cur[0] + d[0], ny = cur[1] + d[1];
                    if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue;
                    if (b[nx][ny] == EMPTY) {
                        liberties++;
                    } else if (b[nx][ny] == color && !visited[nx][ny]) {
                        visited[nx][ny] = true;
                        q.add(new int[]{nx, ny});
                    }
                }
            }
            return liberties;
        }

        int[][] copyBoard() {
            int[][] copy = new int[BOARD_SIZE][BOARD_SIZE];
            for (int i = 0; i < BOARD_SIZE; i++) {
                System.arraycopy(board[i], 0, copy[i], 0, BOARD_SIZE);
            }
            return copy;
        }

        /**
         * 执行落子并返回新状态
         */
        BoardState makeMove(int x, int y) {
            BoardState next = new BoardState();
            for (int i = 0; i < BOARD_SIZE; i++) {
                System.arraycopy(this.board[i], 0, next.board[i], 0, BOARD_SIZE);
            }
            next.currentPlayer = 3 - this.currentPlayer; // 切换玩家
            if (x >= 0 && y >= 0) {
                next.board[x][y] = this.currentPlayer;
                next.lastMoveX = x;
                next.lastMoveY = y;
            }
            return next;
        }

        /**
         * 将棋盘转为神经网络的输入向量
         */
        double[] toInput() {
            double[] input = new double[BOARD_SIZE * BOARD_SIZE * 3];
            int idx = 0;
            for (int i = 0; i < BOARD_SIZE; i++) {
                for (int j = 0; j < BOARD_SIZE; j++) {
                    input[idx++] = (board[i][j] == currentPlayer) ? 1.0 : 0.0;
                    input[idx++] = (board[i][j] == 3 - currentPlayer) ? 1.0 : 0.0;
                    input[idx++] = (i == lastMoveX && j == lastMoveY) ? 1.0 : 0.0;
                }
            }
            return input;
        }
    }

    /**
     * 简化的神经网络实现(全连接层)
     */
    static class NeuralNetwork {
        private final List<double[][]> weights;
        private final List<double[]> biases;
        private final Random rand = new Random(42);

        NeuralNetwork(int inputSize, int[] hiddenSizes, int outputSize) {
            weights = new ArrayList<>();
            biases = new ArrayList<>();
            int prevSize = inputSize;
            for (int hiddenSize : hiddenSizes) {
                weights.add(initMatrix(prevSize, hiddenSize));
                biases.add(new double[hiddenSize]);
                prevSize = hiddenSize;
            }
            weights.add(initMatrix(prevSize, outputSize));
            biases.add(new double[outputSize]);
        }

        double[][] initMatrix(int rows, int cols) {
            double[][] m = new double[rows][cols];
            double scale = Math.sqrt(2.0 / rows);
            for (int i = 0; i < rows; i++) {
                for (int j = 0; j < cols; j++) {
                    m[i][j] = rand.nextGaussian() * scale;
                }
            }
            return m;
        }

        /**
         * 前向传播
         */
        double[] forward(double[] input) {
            double[] current = input;
            for (int i = 0; i < weights.size(); i++) {
                double[] next = new double[biases.get(i).length];
                for (int j = 0; j < next.length; j++) {
                    double sum = biases.get(i)[j];
                    for (int k = 0; k < current.length; k++) {
                        sum += current[k] * weights.get(i)[k][j];
                    }
                    // ReLU激活,最后一层除外
                    if (i < weights.size() - 1) {
                        next[j] = Math.max(0, sum);
                    } else {
                        next[j] = sum;
                    }
                }
                current = next;
            }
            return current;
        }

        /**
         * 策略网络输出softmax概率
         */
        double[] policyForward(double[] input) {
            double[] logits = forward(input);
            double[] probs = new double[logits.length];
            double max = Double.NEGATIVE_INFINITY;
            for (double v : logits) max = Math.max(max, v);
            double sum = 0;
            for (int i = 0; i < logits.length; i++) {
                probs[i] = Math.exp(logits[i] - max);
                sum += probs[i];
            }
            for (int i = 0; i < probs.length; i++) {
                probs[i] /= sum;
            }
            return probs;
        }
    }

    /**
     * 经验回放缓冲区
     */
    static class ExperienceBuffer {
        List<double[]> states = new ArrayList<>();
        List<Integer> actions = new ArrayList<>();
        List<Double> rewards = new ArrayList<>();
        List<Double> values = new ArrayList<>();
        List<Double> logProbs = new ArrayList<>();
        List<Boolean> dones = new ArrayList<>();

        void add(double[] state, int action, double reward, double value, double logProb, boolean done) {
            states.add(state.clone());
            actions.add(action);
            rewards.add(reward);
            values.add(value);
            logProbs.add(logProb);
            dones.add(done);
        }

        int size() { return states.size(); }

        void clear() {
            states.clear(); actions.clear(); rewards.clear();
            values.clear(); logProbs.clear(); dones.clear();
        }
    }

    /**
     * 使用GAE计算优势函数
     */
    double[] computeAdvantages(ExperienceBuffer buffer) {
        int n = buffer.size();
        double[] advantages = new double[n];
        double[] returns = new double[n];
        double gae = 0;

        for (int i = n - 1; i >= 0; i--) {
            double nextValue = (i == n - 1) ? 0 : buffer.values.get(i + 1);
            double delta = buffer.rewards.get(i) + GAMMA * nextValue * (buffer.dones.get(i) ? 0 : 1) - buffer.values.get(i);
            gae = delta + GAMMA * LAMBDA * (buffer.dones.get(i) ? 0 : 1) * gae;
            advantages[i] = gae;
            returns[i] = advantages[i] + buffer.values.get(i);
        }
        return advantages;
    }

    /**
     * 自我对弈一局,收集经验数据
     */
    ExperienceBuffer selfPlay() {
        ExperienceBuffer buffer = new ExperienceBuffer();
        BoardState state = new BoardState();
        int moveCount = 0;
        int passCount = 0;

        while (moveCount < 361 * 2 && passCount < 2) {
            List<int[]> legalMoves = state.getLegalMoves();
            if (legalMoves.isEmpty()) break;

            double[] input = state.toInput();
            double[] probs = policyNet.policyForward(input);
            double value = valueNet.forward(input)[0];

            // 屏蔽非法动作
            double[] mask = new double[probs.length];
            Arrays.fill(mask, -1e9);
            for (int[] m : legalMoves) {
                int idx = (m[0] == -1) ? probs.length - 1 : m[0] * BOARD_SIZE + m[1];
                mask[idx] = probs[idx];
            }

            // 从合法动作中采样
            double sum = 0;
            for (double v : mask) if (v > -1e8) sum += Math.exp(v);
            double rand = Math.random() * sum;
            int action = 0;
            double cumsum = 0;
            for (int i = 0; i < mask.length; i++) {
                if (mask[i] > -1e8) {
                    cumsum += Math.exp(mask[i]);
                    if (cumsum >= rand) {
                        action = i;
                        break;
                    }
                }
            }

            double logProb = Math.log(probs[action] + 1e-10);
            int x = (action == probs.length - 1) ? -1 : action / BOARD_SIZE;
            int y = (action == probs.length - 1) ? -1 : action % BOARD_SIZE;

            BoardState nextState = state.makeMove(x, y);
            boolean done = false;

            // 简单终局判断:连续两次pass
            if (x == -1) {
                passCount++;
                if (passCount >= 2) done = true;
            } else {
                passCount = 0;
            }

            // 简化奖励:终局时根据领地计算
            double reward = 0;
            if (done) {
                reward = evaluateTerritory(nextState);
            }

            buffer.add(input, action, reward, value, logProb, done);
            state = nextState;
            moveCount++;
        }

        return buffer;
    }

    /**
     * 简化版领地评估(数子法)
     */
    double evaluateTerritory(BoardState state) {
        int blackCount = 0, whiteCount = 0;
        for (int i = 0; i < BOARD_SIZE; i++) {
            for (int j = 0; j < BOARD_SIZE; j++) {
                if (state.board[i][j] == BLACK) blackCount++;
                else if (state.board[i][j] == WHITE) whiteCount++;
            }
        }
        // 贴目简化处理(黑贴3.75目)
        double result = (blackCount - whiteCount - 3.75);
        // 从当前玩家视角返回奖励
        return (state.currentPlayer == BLACK) ? result / 100.0 : -result / 100.0;
    }

    /**
     * 主训练循环
     */
    void train(int iterations) {
        for (int iter = 0; iter < iterations; iter++) {
            ExperienceBuffer buffer = selfPlay();
            if (buffer.size() == 0) continue;

            double[] advantages = computeAdvantages(buffer);

            // 归一化优势
            double mean = 0, std = 0;
            for (double a : advantages) mean += a;
            mean /= advantages.length;
            for (double a : advantages) std += (a - mean) * (a - mean);
            std = Math.sqrt(std / advantages.length) + 1e-8;
            for (int i = 0; i < advantages.length; i++) {
                advantages[i] = (advantages[i] - mean) / std;
            }

            System.out.println("Iteration " + iter + ", Game length: " + buffer.size());

            // PPO更新(简化版,实际应使用反向传播)
            // 这里展示核心逻辑框架
            for (int epoch = 0; epoch < EPOCHS; epoch++) {
                // 对收集的经验进行多轮小批量更新
                // 计算策略损失和价值损失
                // L_clip = -E[min(r*A, clip(r)*A)]
                // L_value = E[(V_target - V_pred)^2]
                // 实际实现需要使用自动微分框架
            }

            buffer.clear();
        }
    }

    public static void main(String[] args) {
        GoPPOAI ai = new GoPPOAI();
        ai.train(1000);
    }
}

四、关键设计要点

策略网络输出每个合法落子位置的概率分布,指导AI选择落子点。网络结构接收棋盘状态的三通道表示,经过多层全连接后输出361+1维的策略向量。

价值网络评估当前局面的胜率期望,为PPO提供状态价值估计。价值估计与GAE(Generalized Advantage Estimation)结合,降低策略梯度估计的方差。

自我对弈是训练数据的主要来源。AI左右互搏,每局结束后根据终局结果分配奖励,通过PPO的裁剪目标函数稳定地更新策略。

五、复杂度分析

策略网络前向传播的时间复杂度为 O(H * W * D + hidden_layers),其中 H=W=19 为棋盘尺寸,D=3 为输入通道数。自我对弈一局约产生200-400条经验,PPO每次迭代的更新复杂度与经验数量和网络参数量成正比。

六、扩展方向

实际应用中,策略网络通常采用ResNet风格的卷积神经网络以更好地利用围棋的空间结构。此外,可以引入MCTS与神经网络结合(如AlphaZero),让搜索过程指导策略改进,实现更强的棋力。分布式训练框架和更大的网络规模也是提升AI水平的关键。