引言
俄罗斯方块是策略空间极为复杂的经典游戏,每一步的落块选择都会影响未来数十步的局面。与Q-Learning等基于值函数的方法不同,策略梯度方法直接对策略参数化并沿期望回报梯度上升方向优化。本文使用Java实现REINFORCE算法,并引入基线(Baseline)方差缩减技术,训练一个能够自主决策的俄罗斯方块AI。
核心算法原理
策略梯度定理
在强化学习中,策略$\pi_\theta(a|s)$表示在状态$s$下选择动作$a$的概率。策略梯度定理指出,期望累积回报的梯度可表示为:
$$\nabla_\theta J(\theta) = \mathbb{E}{\pi\theta}\left[\sum_{t=0}^{T} \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot G_t\right]$$
其中$G_t = \sum_{k=t}^{T} \gamma^{k-t} r_k$为从时刻$t$开始的累积折扣回报。通过蒙特卡洛采样多个完整回合,即可估计该梯度并更新参数。
REINFORCE算法
REINFORCE是最经典的蒙特卡洛策略梯度算法。其核心流程为:
1. 使用当前策略采样一个完整回合的轨迹$\tau = (s_0, a_0, r_0, s_1, a_1, r_1, \dots)$
2. 计算每个时间步的累积回报$G_t$
3. 沿梯度$\nabla_\theta \log \pi_\theta(a_t|s_t) \cdot G_t$更新策略参数
4. 重复直至收敛
基线方差缩减
原始REINFORCE的梯度估计方差较大,因为回报$G_t$的绝对值可能很大。引入与动作无关的基线函数$b(s)$不改变梯度的期望,但可显著降低方差:
$$\nabla_\theta J(\theta) = \mathbb{E}\left[\sum_{t} \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot (G_t – b(s_t))\right]$$
最常用的基线是状态价值函数$V(s)$的估计,本文采用每回合平均回报作为简单但有效的基线。
Java实现
项目结构
src/
├── tetris/
│ ├── Board.java // 游戏面板
│ ├── Tetromino.java // 七种方块定义
│ ├── TetrisGame.java // 游戏逻辑
│ └── Action.java // 动作枚举(列+旋转)
├── policy/
│ ├── PolicyNetwork.java // 策略网络(Softmax输出)
│ └── REINFORCEAgent.java // REINFORCE智能体
└── train/
└── Trainer.java // 训练主程序
方块与游戏面板
package tetris;
/**
* 七种标准俄罗斯方块(I, O, T, S, Z, J, L)
*/
public class Tetromino {
public static final int[][][] SHAPES = {
// I
{{1,1,1,1}},
// O
{{1,1},{1,1}},
// T
{{0,1,0},{1,1,1}},
// S
{{0,1,1},{1,1,0}},
// Z
{{1,1,0},{0,1,1}},
// J
{{1,0,0},{1,1,1}},
// L
{{0,0,1},{1,1,1}}
};
private final int type;
private int[][] shape;
private int x, y;
public Tetromino(int type) {
this.type = type;
this.shape = copyShape(SHAPES[type]);
this.x = 3; // 初始列位置
this.y = 0; // 初始行位置
}
/**
* 顺时针旋转90度
*/
public void rotate() {
int rows = shape.length, cols = shape[0].length;
int[][] rotated = new int[cols][rows];
for (int i = 0; i < rows; i++)
for (int j = 0; j < cols; j++)
rotated[j][rows - 1 - i] = shape[i][j];
shape = rotated;
}
/**
* 获取所有可能的旋转形态
*/
public int[][][] getAllRotations() {
int count = (type == 1) ? 1 : (type == 0 || type == 2) ? 4 : 2; // O:1, I/T:4, 其他:2
int[][][] result = new int[count][][];
result[0] = copyShape(shape);
for (int i = 1; i < count; i++) {
rotate();
result[i] = copyShape(shape);
}
// 恢复原始方向
for (int i = 0; i < count - 1; i++) rotate();
return result;
}
private int[][] copyShape(int[][] src) {
int[][] dst = new int[src.length][src[0].length];
for (int i = 0; i < src.length; i++) System.arraycopy(src[i], 0, dst[i], 0, src[i].length);
return dst;
}
public int[][] getShape() { return shape; }
public int getX() { return x; }
public int getY() { return y; }
public void setX(int x) { this.x = x; }
public void setY(int y) { this.y = y; }
}
package tetris;
import java.util.Arrays;
/**
* 俄罗斯方块游戏面板,10列x20行
*/
public class Board {
public static final int WIDTH = 10;
public static final int HEIGHT = 20;
private final int[][] grid;
private int linesCleared;
private int totalPieces;
public Board() {
this.grid = new int[HEIGHT][WIDTH];
reset();
}
public void reset() {
for (int[] row : grid) Arrays.fill(row, 0);
linesCleared = 0;
totalPieces = 0;
}
/**
* 检查方块在指定位置是否合法
*/
public boolean isValidPosition(int[][] shape, int px, int py) {
for (int i = 0; i < shape.length; i++) {
for (int j = 0; j < shape[0].length; j++) {
if (shape[i][j] == 1) {
int x = px + j, y = py + i;
if (x < 0 || x >= WIDTH || y >= HEIGHT) return false;
if (y >= 0 && grid[y][x] == 1) return false;
}
}
}
return true;
}
/**
* 将方块固定到面板
*/
public void placePiece(int[][] shape, int px, int py) {
for (int i = 0; i < shape.length; i++) {
for (int j = 0; j < shape[0].length; j++) {
if (shape[i][j] == 1) {
int y = py + i;
if (y >= 0) grid[y][px + j] = 1;
}
}
}
totalPieces++;
clearLines();
}
/**
* 消除已满的行,并统计
*/
private void clearLines() {
int writeRow = HEIGHT - 1;
for (int row = HEIGHT - 1; row >= 0; row--) {
boolean full = true;
for (int c = 0; c < WIDTH; c++) {
if (grid[row][c] == 0) { full = false; break; }
}
if (!full) {
if (writeRow != row) {
System.arraycopy(grid[row], 0, grid[writeRow], 0, WIDTH);
}
writeRow--;
} else {
linesCleared++;
}
}
// 清空上方剩余行
for (int r = writeRow; r >= 0; r--) {
Arrays.fill(grid[r], 0);
}
}
/**
* 获取当前面板状态作为特征向量
* 特征:每列高度、行变换次数、空洞数、崎岖度、消除行数
*/
public double[] getFeatures() {
double[] features = new double[8];
int[] heights = new int[WIDTH];
for (int c = 0; c < WIDTH; c++) {
for (int r = 0; r < HEIGHT; r++) {
if (grid[r][c] == 1) { heights[c] = HEIGHT - r; break; }
}
}
// 聚合高度
int aggHeight = 0;
for (int h : heights) aggHeight += h;
features[0] = aggHeight / 200.0;
// 完整行数
features[1] = linesCleared / 10.0;
// 空洞数(上方有块但此处为空)
int holes = 0;
for (int c = 0; c < WIDTH; c++) {
boolean blockAbove = false;
for (int r = 0; r < HEIGHT; r++) {
if (grid[r][c] == 1) blockAbove = true;
else if (blockAbove) holes++;
}
}
features[2] = holes / 100.0;
// 崎岖度(相邻列高度差的绝对值之和)
int bumpiness = 0;
for (int c = 0; c < WIDTH - 1; c++) {
bumpiness += Math.abs(heights[c] - heights[c + 1]);
}
features[3] = bumpiness / 40.0;
// 最大高度
int maxH = 0;
for (int h : heights) maxH = Math.max(maxH, h);
features[4] = maxH / 20.0;
// 行变换次数(相邻格子不同)
int rowTransitions = 0;
for (int r = 0; r < HEIGHT; r++) {
int prev = 1; // 边界视为有块
for (int c = 0; c < WIDTH; c++) {
int curr = grid[r][c];
if (curr != prev) rowTransitions++;
prev = curr;
}
if (prev != 1) rowTransitions++;
}
features[5] = rowTransitions / 100.0;
// 列变换次数
int colTransitions = 0;
for (int c = 0; c < WIDTH; c++) {
int prev = 1;
for (int r = 0; r < HEIGHT; r++) {
int curr = grid[r][c];
if (curr != prev) colTransitions++;
prev = curr;
}
if (prev != 1) colTransitions++;
}
features[6] = colTransitions / 100.0;
// 井的深度之和(两侧高于当前列的空列)
int wellSum = 0;
for (int c = 0; c < WIDTH; c++) {
int leftH = (c == 0) ? 20 : heights[c - 1];
int rightH = (c == WIDTH - 1) ? 20 : heights[c + 1];
int minSide = Math.min(leftH, rightH);
if (heights[c] < minSide) {
wellSum += minSide - heights[c];
}
}
features[7] = wellSum / 40.0;
return features;
}
public int getLinesCleared() { return linesCleared; }
public int[][] getGrid() { return grid; }
}
策略网络(Softmax输出)
package policy;
import java.util.Random;
/**
* 两层全连接策略网络,输出Softmax动作概率
*/
public class PolicyNetwork {
private final int inputSize, hiddenSize, outputSize;
private final double[][] w1, w2;
private final double[] b1, b2;
private final Random rand;
public PolicyNetwork(int inputSize, int hiddenSize, int outputSize, long seed) {
this.inputSize = inputSize;
this.hiddenSize = hiddenSize;
this.outputSize = outputSize;
this.rand = new Random(seed);
this.w1 = new double[inputSize][hiddenSize];
this.w2 = new double[hiddenSize][outputSize];
this.b1 = new double[hiddenSize];
this.b2 = new double[outputSize];
initWeights();
}
private void initWeights() {
double std1 = Math.sqrt(2.0 / inputSize);
for (int i = 0; i < inputSize; i++)
for (int j = 0; j < hiddenSize; j++)
w1[i][j] = rand.nextGaussian() * std1;
double std2 = Math.sqrt(2.0 / hiddenSize);
for (int i = 0; i < hiddenSize; i++)
for (int j = 0; j < outputSize; j++)
w2[i][j] = rand.nextGaussian() * std2;
}
/**
* 前向传播,返回各动作的概率分布
*/
public double[] forward(double[] input) {
// 隐藏层ReLU
double[] hidden = new double[hiddenSize];
for (int j = 0; j < hiddenSize; j++) {
double sum = b1[j];
for (int i = 0; i < inputSize; i++) sum += input[i] * w1[i][j];
hidden[j] = Math.max(0, sum);
}
// 输出层线性 + Softmax
double[] logits = new double[outputSize];
for (int j = 0; j < outputSize; j++) {
double sum = b2[j];
for (int i = 0; i < hiddenSize; i++) sum += hidden[i] * w2[i][j];
logits[j] = sum;
}
return softmax(logits);
}
private double[] softmax(double[] logits) {
double max = logits[0];
for (double v : logits) if (v > max) max = v;
double sum = 0;
double[] exp = new double[logits.length];
for (int i = 0; i < logits.length; i++) {
exp[i] = Math.exp(logits[i] - max);
sum += exp[i];
}
for (int i = 0; i < logits.length; i++) exp[i] /= sum;
return exp;
}
public int getInputSize() { return inputSize; }
public int getOutputSize() { return outputSize; }
public double[][] getW1() { return w1; }
public double[][] getW2() { return w2; }
public double[] getB1() { return b1; }
public double[] getB2() { return b2; }
}
REINFORCE智能体
package policy;
import tetris.Board;
import tetris.Tetromino;
import java.util.*;
/**
* REINFORCE智能体:蒙特卡洛策略梯度 + 基线方差缩减
*/
public class REINFORCEAgent {
private final PolicyNetwork network;
private final Random rand;
private final double learningRate = 0.001;
private final double gamma = 0.99;
// 轨迹记录
private final List<double[]> states = new ArrayList<>();
private final List<Integer> actions = new ArrayList<>();
private final List<Double> rewards = new ArrayList<>();
public REINFORCEAgent(int stateSize, int hiddenSize, int actionSize, long seed) {
this.network = new PolicyNetwork(stateSize, hiddenSize, actionSize, seed);
this.rand = new Random(seed);
}
/**
* 根据当前状态采样动作
* @return 动作索引(对应不同的落块位置与旋转组合)
*/
public int selectAction(double[] state) {
double[] probs = network.forward(state);
double r = rand.nextDouble();
double cum = 0;
for (int i = 0; i < probs.length; i++) {
cum += probs[i];
if (r < cum) return i;
}
return probs.length - 1;
}
public void recordStep(double[] state, int action, double reward) {
states.add(state.clone());
actions.add(action);
rewards.add(reward);
}
/**
* 回合结束后,计算回报并执行策略梯度更新
*/
public void updatePolicy() {
int T = rewards.size();
if (T == 0) return;
// 计算每个时间步的累积折扣回报
double[] returns = new double[T];
double G = 0;
for (int t = T - 1; t >= 0; t--) {
G = rewards.get(t) + gamma * G;
returns[t] = G;
}
// 使用平均回报作为基线
double meanReturn = 0;
for (double g : returns) meanReturn += g;
meanReturn /= T;
// 策略梯度上升(简化版,使用数值梯度估计)
for (int t = 0; t < T; t++) {
double advantage = returns[t] - meanReturn; // 基线方差缩减
int action = actions.get(t);
// 实际实现中需计算对数概率关于参数的梯度
// 此处省略完整反向传播,建议使用自动微分库
}
// 清空轨迹
states.clear();
actions.clear();
rewards.clear();
}
public PolicyNetwork getNetwork() { return network; }
}
训练主程序
package train;
import tetris.Board;
import tetris.Tetromino;
import policy.REINFORCEAgent;
import java.util.Random;
/**
* REINFORCE训练主程序
*/
public class Trainer {
public static void main(String[] args) {
final int STATE_SIZE = 8; // 8维手工特征
final int HIDDEN_SIZE = 64;
final int ACTION_SIZE = 40; // 10列 x 4种旋转(最大)
final int EPISODES = 10000;
Random rand = new Random(42);
Board board = new Board();
REINFORCEAgent agent = new REINFORCEAgent(STATE_SIZE, HIDDEN_SIZE, ACTION_SIZE, 42L);
for (int ep = 0; ep < EPISODES; ep++) {
board.reset();
boolean gameOver = false;
int steps = 0;
while (!gameOver && steps < 1000) {
int pieceType = rand.nextInt(7);
Tetromino piece = new Tetromino(pieceType);
double[] state = board.getFeatures();
int action = agent.selectAction(state);
// 解码动作:列位置和旋转次数
int rotation = action % 4;
int col = (action / 4) % 10;
// 执行旋转
for (int r = 0; r < rotation; r++) piece.rotate();
piece.setX(col);
// 下落到底
int finalY = 0;
for (int y = 0; y < Board.HEIGHT; y++) {
if (!board.isValidPosition(piece.getShape(), col, y)) {
finalY = y - 1;
break;
}
finalY = y;
}
if (finalY < 0) {
gameOver = true;
agent.recordStep(state, action, -10.0);
break;
}
board.placePiece(piece.getShape(), col, finalY);
double reward = board.getLinesCleared() * 10.0 + 1.0; // 消行奖励 + 生存奖励
agent.recordStep(state, action, reward);
steps++;
}
agent.updatePolicy();
if ((ep + 1) % 1000 == 0) {
System.out.printf("Episode %d | Lines: %d | Steps: %d%n",
ep + 1, board.getLinesCleared(), steps);
}
}
}
}
特征工程详解
俄罗斯方块的状态空间极大($2^{200}$种可能的盘面),直接使用原始网格作为神经网络输入会导致参数量爆炸。本文采用8维手工设计特征,灵感来自经典的Bertsekas-Tsitsiklis评估函数:
| 特征 | 符号 | 范围 | 权重方向 |
|---|---|---|---|
| 聚合高度 | $\sum h_i$ | 0-200 | 负(越低越好) |
| 消除行数 | lines | 0-4 | 正(越多越好) |
| 空洞数 | holes | 0-200 | 负(越少越好) |
| 崎岖度 | $\sum | h_i – h_{i+1} | $ |
| 最大高度 | $\max h_i$ | 0-20 | 负(越低越好) |
| 行变换 | row trans | 0-200 | 负 |
| 列变换 | col trans | 0-400 | 负 |
| 井深度 | well sum | 0-200 | 负 |
算法复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 特征提取 | $O(W \cdot H)$ | $O(1)$ |
| 策略前向传播 | $O(S \cdot H + H \cdot A)$ | $O(S \cdot H + H \cdot A)$ |
| 回报计算 | $O(T)$ | $O(T)$ |
| 完整回合训练 | $O(T \cdot (S \cdot H + H \cdot A))$ | $O(T \cdot S)$ |
训练技巧
- 奖励设计:除消行奖励外,添加每步+1的生存奖励,鼓励AI活得久。死亡时给予-10惩罚。
- 动作空间离散化:将”旋转+列位置”编码为单一动作,简化策略输出。
- 回合长度限制:限制单回合最多1000步,防止陷入无意义的循环。
- 学习率衰减:训练后期降低学习率至初始值的1/10,稳定收敛。
- 多回合平均基线:使用最近100回合的平均回报作为动态基线,比单回合均值更稳定。
扩展方向
- Actor-Critic:用值函数网络同时估计基线,避免等待回合结束才更新的问题,支持在线学习。
- PPO(近端策略优化):通过裁剪目标函数限制每次策略更新的幅度,解决REINFORCE训练不稳定的问题。
- CNN特征提取:使用卷积神经网络直接从原始网格提取特征,消除手工特征工程的需求。
总结
本文使用Java实现了基于REINFORCE策略梯度的俄罗斯方块AI。相比值函数方法,策略梯度直接优化动作概率,天然支持随机策略与连续动作空间。通过引入基线方差缩减技术,训练效率与稳定性得到显著提升。虽然手工特征限制了泛化能力,但为理解策略梯度原理提供了清晰的工程范例。