每日算法 — 使用java实现Flappy Bird:深度Q网络与经验回放策略

引言

Flappy Bird是一款看似简单却极具挑战的休闲游戏。传统规则下玩家需要精准控制小鸟的跳跃时机穿越管道。本文用Java实现DQN(Deep Q-Network)深度强化学习算法,让AI通过经验回放自动学习最优跳跃策略,无需任何人工特征工程。

DQN核心原理

DQN将Q-Learning与深度神经网络结合,用神经网络近似Q值函数。关键创新包括:
– 经验回放(Experience Replay):存储历史转移样本,随机抽样打破数据相关性
– 目标网络(Target Network):独立的目标Q网络稳定训练过程
– ε-贪心探索:平衡探索与利用

Java实现

import java.util.*;

/**
 * Flappy Bird DQN智能体
 * 使用深度Q网络学习最优跳跃策略
 */
public class FlappyBirdDQN {

    // 神经网络参数
    private final int stateSize = 4;   // 输入特征维度
    private final int hiddenSize = 24; // 隐藏层神经元数
    private final int actionSize = 2;  // 动作空间:0=不跳,1=跳跃

    // 主网络与目标网络权重
    private final double[][] W1, W1Target; // 输入->隐藏 (stateSize x hiddenSize)
    private final double[] b1, b1Target;   // 隐藏层偏置
    private final double[][] W2, W2Target; // 隐藏->输出 (hiddenSize x actionSize)
    private final double[] b2, b2Target;   // 输出层偏置

    // 超参数
    private final double gamma = 0.95;      // 折扣因子
    private final double epsilon = 1.0;     // 初始探索率
    private final double epsilonMin = 0.01;
    private final double epsilonDecay = 0.995;
    private final double learningRate = 0.001;

    // 经验回放缓冲区
    private final List<Experience> memory;
    private final int memoryCapacity = 2000;
    private final int batchSize = 32;

    private double currentEpsilon;
    private int trainingStep = 0;
    private final int targetUpdateFreq = 100; // 每100步同步目标网络

    public FlappyBirdDQN() {
        // 初始化主网络权重(Xavier初始化)
        W1 = xavierInit(stateSize, hiddenSize);
        b1 = new double[hiddenSize];
        W2 = xavierInit(hiddenSize, actionSize);
        b2 = new double[actionSize];

        // 目标网络初始与主网络相同
        W1Target = deepCopy(W1);
        b1Target = b1.clone();
        W2Target = deepCopy(W2);
        b2Target = b2.clone();

        memory = new ArrayList<>();
        currentEpsilon = epsilon;
    }

    /**
     * 根据当前状态选择动作
     * @param state [鸟高度, 鸟速度, 下管道距离, 上下管道开口中心]
     * @return 0=不跳,1=跳跃
     */
    public int act(double[] state) {
        // ε-贪心策略
        if (Math.random() <= currentEpsilon) {
            return (int) (Math.random() * actionSize); // 随机探索
        }

        // 利用:选择Q值最大的动作
        double[] qValues = forward(state, W1, b1, W2, b2);
        return qValues[0] > qValues[1] ? 0 : 1;
    }

    /**
     * 存储经验到回放缓冲区
     */
    public void remember(double[] state, int action, double reward, 
                          double[] nextState, boolean done) {
        if (memory.size() >= memoryCapacity) {
            memory.remove(0); // 淘汰最旧的经验
        }
        memory.add(new Experience(state, action, reward, nextState, done));
    }

    /**
     * 从经验回放中随机抽样训练网络
     */
    public void replay() {
        if (memory.size() < batchSize) return;

        // 随机抽样小批量经验
        List<Experience> batch = sampleBatch();

        for (Experience exp : batch) {
            // 当前状态Q值
            double[] qCurrent = forward(exp.state, W1, b1, W2, b2);

            // 目标Q值:reward + gamma * max(Q_target(next_state))
            double target = exp.reward;
            if (!exp.done) {
                double[] qNext = forward(exp.nextState, W1Target, b1Target, 
                                          W2Target, b2Target);
                target += gamma * max(qNext);
            }

            // 仅更新所选动作的Q值,其余不变
            double[] targetQ = qCurrent.clone();
            targetQ[exp.action] = target;

            // 反向传播更新主网络
            backward(exp.state, qCurrent, targetQ);
        }

        // 衰减探索率
        if (currentEpsilon > epsilonMin) {
            currentEpsilon *= epsilonDecay;
        }

        // 定期同步目标网络
        trainingStep++;
        if (trainingStep % targetUpdateFreq == 0) {
            syncTargetNetwork();
        }
    }

    /**
     * 前向传播:ReLU隐藏层 + 线性输出层
     */
    private double[] forward(double[] state, double[][] w1, double[] b1,
                              double[][] w2, double[] b2) {
        // 隐藏层:state @ W1 + b1,ReLU激活
        double[] hidden = new double[hiddenSize];
        for (int i = 0; i < hiddenSize; i++) {
            double sum = b1[i];
            for (int j = 0; j < stateSize; j++) {
                sum += state[j] * w1[j][i];
            }
            hidden[i] = Math.max(0, sum); // ReLU
        }

        // 输出层:hidden @ W2 + b2(线性)
        double[] output = new double[actionSize];
        for (int i = 0; i < actionSize; i++) {
            double sum = b2[i];
            for (int j = 0; j < hiddenSize; j++) {
                sum += hidden[j] * w2[j][i];
            }
            output[i] = sum;
        }

        return output;
    }

    /**
     * 反向传播(简化版梯度下降)
     */
    private void backward(double[] state, double[] pred, double[] target) {
        // 输出层误差
        double[] dOutput = new double[actionSize];
        for (int i = 0; i < actionSize; i++) {
            dOutput[i] = 2 * (pred[i] - target[i]) / actionSize;
        }

        // 隐藏层前向值(需重新计算)
        double[] hidden = new double[hiddenSize];
        for (int i = 0; i < hiddenSize; i++) {
            double sum = b1[i];
            for (int j = 0; j < stateSize; j++) {
                sum += state[j] * W1[j][i];
            }
            hidden[i] = Math.max(0, sum);
        }

        // 更新W2, b2
        for (int i = 0; i < actionSize; i++) {
            for (int j = 0; j < hiddenSize; j++) {
                W2[j][i] -= learningRate * dOutput[i] * hidden[j];
            }
            b2[i] -= learningRate * dOutput[i];
        }

        // 隐藏层误差(ReLU导数)
        double[] dHidden = new double[hiddenSize];
        for (int i = 0; i < hiddenSize; i++) {
            double sum = 0;
            for (int j = 0; j < actionSize; j++) {
                sum += dOutput[j] * W2[i][j];
            }
            dHidden[i] = hidden[i] > 0 ? sum : 0; // ReLU导数
        }

        // 更新W1, b1
        for (int i = 0; i < hiddenSize; i++) {
            for (int j = 0; j < stateSize; j++) {
                W1[j][i] -= learningRate * dHidden[i] * state[j];
            }
            b1[i] -= learningRate * dHidden[i];
        }
    }

    private void syncTargetNetwork() {
        for (int i = 0; i < W1.length; i++) {
            W1Target[i] = W1[i].clone();
        }
        System.arraycopy(b1, 0, b1Target, 0, b1.length);
        for (int i = 0; i < W2.length; i++) {
            W2Target[i] = W2[i].clone();
        }
        System.arraycopy(b2, 0, b2Target, 0, b2.length);
    }

    private List<Experience> sampleBatch() {
        List<Experience> batch = new ArrayList<>();
        Random rand = new Random();
        for (int i = 0; i < batchSize; i++) {
            batch.add(memory.get(rand.nextInt(memory.size())));
        }
        return batch;
    }

    private double max(double[] arr) {
        double m = arr[0];
        for (double v : arr) if (v > m) m = v;
        return m;
    }

    private double[][] xavierInit(int inSize, int outSize) {
        double[][] w = new double[inSize][outSize];
        double scale = Math.sqrt(2.0 / (inSize + outSize));
        Random rand = new Random(42);
        for (int i = 0; i < inSize; i++) {
            for (int j = 0; j < outSize; j++) {
                w[i][j] = rand.nextGaussian() * scale;
            }
        }
        return w;
    }

    private double[][] deepCopy(double[][] src) {
        double[][] dst = new double[src.length][];
        for (int i = 0; i < src.length; i++) {
            dst[i] = src[i].clone();
        }
        return dst;
    }

    private static class Experience {
        final double[] state, nextState;
        final int action;
        final double reward;
        final boolean done;

        Experience(double[] s, int a, double r, double[] ns, boolean d) {
            state = s; action = a; reward = r; nextState = ns; done = d;
        }
    }

    /**
     * 环境模拟:简化的Flappy Bird状态转移
     */
    public static class FlappyEnv {
        double birdY = 0.5;    // 鸟的高度(0~1归一化)
        double birdVel = 0;    // 鸟的速度
        double pipeX = 1.0;    // 管道的水平位置
        double pipeGapY = 0.5; // 管道开口中心

        /**
         * 执行动作,返回 [nextState, reward, done]
         */
        public Object[] step(int action) {
            // 动作1=跳跃,给予向上速度
            if (action == 1) birdVel = -0.05;

            // 物理更新
            birdVel += 0.003; // 重力
            birdY += birdVel;
            pipeX -= 0.01;    // 管道左移

            boolean done = birdY < 0 || birdY > 1;
            double reward = 0.01; // 存活奖励

            // 碰撞检测(简化)
            if (pipeX > 0.05 && pipeX < 0.15) {
                if (birdY < pipeGapY - 0.08 || birdY > pipeGapY + 0.08) {
                    done = true;
                    reward = -1.0; // 碰撞惩罚
                }
            }

            // 通过管道奖励
            if (pipeX < -0.1) {
                reward = 1.0;
                pipeX = 1.0;
                pipeGapY = 0.2 + Math.random() * 0.6; // 新管道随机开口
            }

            double[] state = {birdY, birdVel, pipeX, pipeGapY};
            return new Object[]{state, reward, done};
        }

        public double[] reset() {
            birdY = 0.5; birdVel = 0; pipeX = 1.0; pipeGapY = 0.5;
            return new double[]{birdY, birdVel, pipeX, pipeGapY};
        }
    }

    public static void main(String[] args) {
        FlappyBirdDQN agent = new FlappyBirdDQN();
        FlappyEnv env = new FlappyEnv();

        int episodes = 500;
        for (int ep = 0; ep < episodes; ep++) {
            double[] state = env.reset();
            int score = 0;

            while (true) {
                int action = agent.act(state);
                Object[] result = env.step(action);
                double[] nextState = (double[]) result[0];
                double reward = (double) result[1];
                boolean done = (boolean) result[2];

                agent.remember(state, action, reward, nextState, done);
                agent.replay();

                state = nextState;
                if (reward > 0.5) score++;
                if (done) break;
            }

            if (ep % 50 == 0) {
                System.out.println("Episode " + ep + " Score: " + score 
                    + " Epsilon: " + String.format("%.3f", agent.currentEpsilon));
            }
        }
    }
}

训练技巧

  1. 奖励塑形:存活给予小正奖励,通过管道大奖励,碰撞大惩罚,引导智能体主动穿越管道
  2. 帧跳过(Frame Skip):每4帧执行一次动作决策,加速训练且保持控制精度
  3. 状态归一化:所有输入特征缩放至[0,1]区间,提升神经网络收敛速度

总结

本文用纯Java实现了DQN的核心组件:ε-贪心策略、经验回放缓冲区、目标网络同步。通过将Flappy Bird的状态抽象为4维向量,AI无需人工特征设计即可自主学习跳跃时机。该框架可扩展至更复杂的Atari游戏。