Flappy Bird是一款标志性的手机游戏,由于其简单和清晰的规则,它已成为演示机器学习原理的理想试验场。 在这个项目中,我们不仅实现了游戏本身,而且还实现了两种不同的神经网络架构,这些架构经过训练,可以使用进化算法来玩游戏。
项目的主要特点:
-在Julia标准库上完全实现,无需外部依赖
-游戏在终端中的符号渲染
-两种神经网络架构:全连接(DenseNet)和卷积(CNN)
-学习的遗传算法
-用于管理培训和测试的交互式菜单
该项目演示了如何,即使只使用编程语言的基本功能,也可以创建一个完整的机器学习系统。 飞扬的鸟不是偶然选择的-它是一个理想的试验台。:
-简单的规则,但困难的技能
-清晰的奖励系统(通过管道飞行的积分)
-离散动作(跳或不跳)
-视觉上可理解的学习进度
在命令行上使用符号图形表明,演示机器学习概念不需要复杂的图形库-算法和原理本身很重要。
技术栈:
-Julia作为科学计算的高性能语言
-遗传算法作为无梯度优化方法
-神经网络作为通用近似值
-最大可移植性的控制台界面
这个项目的目的是"从内部"展示神经网络和进化算法是如何工作的,没有深入学习框架的魔力。
现在让我们继续到项目的实际实施。 Flappy Bird实现基于两个封装所有游戏对象状态的关键数据结构,以及一组确定游戏物理和平衡的全局参数。 这种简约的方法是典型的游戏引擎,其中数据和逻辑的清晰分离确保了可预测性和易于调试。
鸟对象是一个受控的鸟,游戏的主角。 它的字段存储全动态状态:垂直位置y(平滑度的小数),速度v(正向下,负向上),当前分数s(通过的管道的整数个数)和布尔活动标志a,指示鸟是否活着。 这四个参数足以描述鸟类运动的整个机制-它对重力的反应,跳跃的冲动以及与边界的相互作用。
每个管道由包含三个参数的管道结构表示:水平x坐标(跟踪管道相对于鸟的位置),垂直通道位置gy(确定安全间隙在哪里)和通道标志p(消除重复评分)。 管道向鸟移动,创造了游戏的主要挑战-需要准确地通过狭窄的间隙飞行。
GP数组包含七个数字常量,定义游戏世界的几何和物理。 这些是领域的宽度和高度,重力和跳跃的力量,管道的大小,以及它们的运动速度。 这种集中式配置使得在不影响底层逻辑的情况下,可以轻松平衡游戏的复杂性并对参数进行实验。
Init_game函数创建一个起始状态,以零速度和空管道列表将鸟返回到屏幕中心。 这个简单的过程确保了每个游戏情节的可重复性,这对于学习过程至关重要—神经网络必须从一致的,确定性的例子中学习。
这些组件一起形成了一个紧凑但完整的游戏模型,其中每个参数都有明确的作用,对象的交互由一组最小的规则描述。
mutable struct Bird
y::Float64
v::Float64
s::Int
a::Bool
end
mutable struct Pipe
x::Float64
gy::Int
p::Bool
end
const GP = [40.0, 15.0, 0.4, -2.0, 4.0, 6.0, 1.2]
function init_game()
Bird(GP[2] ÷ 2, 0.0, 0, true), Pipe[]
end
功能 update_bird!
垂直速度首先更新:如果一个信号被发送 jump
接下来,该函数检查比赛场地的边界。 如果鸟下降到下限以下,它的位置固定在最小值,并且速度被重置,防止它离开屏幕。 如果鸟上升到上限以上,则视为失败-活动标志设置为 false
function update_bird!(b, jump)
b.v = jump ? GP[4] : b.v + GP[3]
b.y += b.v
if b.y < 1
b.y = 1; b.v = 0
elseif b.y > GP[2]
b.a = false
end
end
功能 update_pipes!
首先,该功能移除超出屏幕左侧边框完全消失的管道。 它支持一个数组 pipes
新管道的产生基于保持障碍物恒定流动的原则。 如果没有管道或最后一个管道移动得足够远,则会在屏幕右侧创建一个新管道。 其垂直通过位置在可接受的范围内随机选择,提供多种轨迹。 这种机制保证了无尽的和不可预测的游戏性,这是学习适应性行为所必需的。
function update_pipes!(pipes, b)
filter!(p -> p.x > -GP[5], pipes)
for p in pipes
p.x -= GP[7]
if !p.p && p.x < 5
p.p = true
b.s += 1
end
end
if isempty(pipes) || pipes[end].x < GP[1] - 20
push!(pipes, Pipe(GP[1], rand(4:Int(GP[2]) - Int(GP[6]) - 2), false))
end
end
功能 check_collision
首先,鸟的位置四舍五入为整数以简化计算,该整数对应于终端中的离散渲染网格。 然后对每个管道检查水平重叠:如果鸟(位于固定水平位置5处)在管道的宽度内,则启动垂直碰撞检查。
垂直检查确定鸟是否进入了管道之间的安全间隙。 如果鸟的垂直位置在上部管道上方或下部管道下方,则记录碰撞。 在这种情况下,鸟类活动标志设置为 false
function check_collision(b, pipes)
by = round(Int, b.y)
for p in pipes
if 5 >= p.x && 5 <= p.x + GP[5] - 1
if by < p.gy || by > p.gy + GP[6] - 1
b.a = false
return
end
end
end
end
功能 extract_state
该算法首先搜索尚未被鸟通过并且位于其前面的最近的无法通行的管道。 如果没有这样的管道(在游戏开始时或在所有管道之后),则返回默认状态:完全连接网络的零向量,或者卷积网络的中心有鸟的地图。
对于卷积网络 (state_type == :conv)
对于完全连接的网络 (state_type == :basic)
关键方面是将所有值归一化到[0.1]或-1,1]附近的范围,这加速和稳定神经网络训练,防止梯度问题并确保优化算法的收敛。
function extract_state(b, pipes, state_type=:basic)
closest = nothing
for p in pipes
if !p.p && p.x + GP[5] >= 5
closest = p
break
end
end
if closest === nothing
if state_type == :conv
state = zeros(Float64, Int(GP[2]), Int(GP[1]))
state[Int(GP[2])÷2, 5] = 1.0
return state
else
return zeros(Float64, 5)
end
end
if state_type == :conv
state = zeros(Float64, Int(GP[2]), Int(GP[1]))
by = clamp(round(Int, b.y), 1, Int(GP[2]))
state[by, 5] = 1.0
for p in pipes
px = round(Int, p.x)
start_x = max(1, px)
end_x = min(Int(GP[1]), px + Int(GP[5]) - 1)
if start_x <= end_x
for x in start_x:end_x
for y in 1:Int(GP[2])
if y < p.gy || y > p.gy + Int(GP[6]) - 1
state[y, x] = 0.5
end
end
end
end
end
return state
else
return [
b.y / GP[2],
clamp(b.v / 5.0, -1.0, 1.0),
(closest.x - 5) / GP[1],
closest.gy / GP[2],
(closest.gy + GP[6]) / GP[2]
]
end
end
run_episode()
该函数从初始化一个新的游戏状态开始-创建一只鸟和一个空的管道列表。 然后主游戏周期开始,只要鸟还活着并且不超过步数限制,这种方法就可以防止无休止的游戏。 在每一步,以对应于网络架构的格式提取游戏的当前状态(basicconv
神经网络接收此状态并返回跳转的概率。 0.5的阈值将连续概率转换为二元解。: truefalse
如果启用了渲染模式(render=true
function run_episode(network, state_type=:basic; render=false, max_steps=1000)
bird, pipes = init_game()
step = 0
while bird.a && step < max_steps
state = extract_state(bird, pipes, state_type)
if state_type == :conv
action = conv_forward(network, state) > 0.5
else
action = dense_forward(network, state) > 0.5
end
update_bird!(bird, action)
update_pipes!(pipes, bird)
check_collision(bird, pipes)
if render
draw(bird, pipes)
sleep(0.1)
end
step += 1
end
return bird.s
end
draw()
主循环贯穿比赛场地的每一行。 h
绘制整个字段后,显示下边框和游戏状态。 如果鸟已经死了,则会显示一条关于游戏结束的消息,其中包含表情符号"💀"和最终得分。
function draw(b, pipes)
print("\033[2J\033[H")
w, h = Int(GP[1]), Int(GP[2])
println("="^w)
println("FLAPPY BIRD AI | Счет: $(b.s)")
println("="^w)
for y in 1:h
print("|")
print(y == round(Int, b.y) ? "🤖" : " ")
for x in 7:w-2
is_pipe = false
for p in pipes
px = round(Int, p.x)
if x >= px && x < px + Int(GP[5])
if y < p.gy || y > p.gy + Int(GP[6]) - 1
print("█")
is_pipe = true
break
end
end
end
!is_pipe && print(" ")
end
println("|")
end
println("="^w)
println("测试运行")
!b.a && println("\n💀 Игра окончена! Счет: $(b.s)")
end
该项目实现了两种神经网络架构,每种架构都由自己的数据结构表示。 这些结构封装了所有可训练的网络参数,这使得在进化学习期间有效地操纵它们成为可能。
mutable struct DenseNetw1b1w2b2
mutable struct ConvNetconv_weightsconv_biasfc_weightsfc_bias
mutable struct DenseNet
w1::Matrix{Float64}
b1::Vector{Float64}
w2::Matrix{Float64}
b2::Vector{Float64}
end
mutable struct ConvNet
conv_weights::Array{Float64,4}
conv_bias::Vector{Float64}
fc_weights::Matrix{Float64}
fc_bias::Vector{Float64}
end
function init_dense()
DenseNet(
randn(8, 5) * 0.1,
zeros(8),
randn(1, 8) * 0.1,
zeros(1)
)
end
function init_conv()
height, width = 15, 40
filters = 4
conv_size = (height - 2) * (width - 2) * filters
ConvNet(
randn(3, 3, 1, filters) * 0.1,
zeros(filters),
randn(1, conv_size) * 0.1,
zeros(1)
)
end
dense_forward()conv_forward()
全连接网络(dense_forwardw1b1tanhw2b2
卷积网络(conv_forwardmax(..., 0.0)
这两种方法之间的关键区别在于,完全连接的网络接收预处理的高级特征(位置、速度、到管道的距离),而卷积网络使用比赛场地的"原始"像素,并学会自行识别相关模式。 这两个函数都返回范围(0,1)中跳跃的概率,然后将其转换为阈值为0.5的二进制解。
function dense_forward(net::DenseNet, state)
h = tanh.(net.w1 * state .+ net.b1)
logits = net.w2 * h .+ net.b2
return 1.0 / (1.0 + exp(-logits[1]))
end
function conv_forward(net::ConvNet, state)
height, width = size(state)
filters = size(net.conv_weights, 4)
conv_out = zeros(Float64, height-2, width-2, filters)
for f in 1:filters
for i in 1:height-2
for j in 1:width-2
sum_val = 0.0
for ki in 1:3
for kj in 1:3
row = i + ki - 1
col = j + kj - 1
if row <= height && col <= width
sum_val += state[row, col] * net.conv_weights[ki, kj, 1, f]
end
end
end
conv_out[i, j, f] = max(sum_val + net.conv_bias[f], 0.0)
end
end
end
flattened = vec(conv_out)
logits = net.fc_weights * flattened .+ net.fc_bias
return 1.0 / (1.0 + exp(-logits[1]))
end
evaluate_network()
该功能启动几个独立的游戏集(默认情况下为3),禁用渲染以获得最大的计算速度。 对于每一集,她都打电话 run_episode
使用多个事件而不是一个事件通过平滑诸如管道初始位置等随机因素的影响来增加评估的可靠性。 这三个情节提供了估计精度和计算成本之间的平衡,这在进化算法的每一代中评估数百个网络时非常重要。
参数 state_typerun_episode
function evaluate_network(network, state_type=:basic, episodes=3)
total = 0.0
for _ in 1:episodes
score = run_episode(network, state_type; render=false)
total += score
end
return total / episodes
end
train_genetic!()
该算法代代相传。 每一代人都是从评估人口中的所有网络开始的。 evaluate_network
如果最佳生成网络超过目标分数 target_score
新一代的创造采用了"精英主义与突变"的策略。 所有精英网络在下一代保持不变(精英主义)。 其余的地方充满了通过克隆随机选择的精英个体并随后可能发生突变而创建的后代。
突变发生概率 mutation_rate
循环重复,直到达到最大代数或目标计数。 该函数返回最终人口,所有世代的统计数据,找到的最佳网络及其分数。 这种进化方法不需要梯度或错误的反向传播,使得学习可以用来演示人工智能的基本原理。
function train_genetic!(population, state_type=:basic; target_score=20, max_generations=100, mutation_rate=0.3)
results = []
best_score = 0.0
best_network = nothing
for generation in 1:max_generations
scores = [evaluate_network(net, state_type) for net in population]
gen_best = maximum(scores)
gen_avg = sum(scores) / length(scores)
gen_worst = minimum(scores)
push!(results, (generation, gen_best, gen_avg, gen_worst))
println("Поколение $generation | Лучший: ", round(gen_best, digits=2), " /平均: ", round(gen_avg, digits=2), " /最坏的: ", round(gen_worst, digits=2))
if gen_best > best_score
best_score = gen_best
best_idx = argmax(scores)
best_network = deepcopy(population[best_idx])
end
if gen_best >= target_score
println("✓ Достигнут целевой счет $target_score")
break
end
sorted_idx = sortperm(scores, rev=true)
elite_size = max(1, length(population) ÷ 4)
elites = population[sorted_idx[1:elite_size]]
new_pop = deepcopy(elites)
while length(new_pop) < length(population)
parent = rand(elites)
if state_type == :conv
child = ConvNet(
copy(parent.conv_weights),
copy(parent.conv_bias),
copy(parent.fc_weights),
copy(parent.fc_bias)
)
if rand() < mutation_rate
child.conv_weights .+= randn(size(child.conv_weights)...) * 0.1
child.conv_bias .+= randn(size(child.conv_bias)...) * 0.1
child.fc_weights .+= randn(size(child.fc_weights)...) * 0.1
child.fc_bias .+= randn(size(child.fc_bias)...) * 0.1
end
else
child = DenseNet(
copy(parent.w1),
copy(parent.b1),
copy(parent.w2),
copy(parent.b2)
)
if rand() < mutation_rate
child.w1 .+= randn(size(child.w1)...) * 0.1
child.b1 .+= randn(size(child.b1)...) * 0.1
child.w2 .+= randn(size(child.w2)...) * 0.1
child.b2 .+= randn(size(child.b2)...) * 0.1
end
end
push!(new_pop, child)
end
population = new_pop[1:length(population)]
end
return population, results, best_network, best_score
end
function argmax(x)
max_val = -Inf
max_idx = 1
for (i, val) in enumerate(x)
if val > max_val
max_val = val
max_idx = i
end
end
return max_idx
end
struct NetworkResult
networkDenseNetConvNetstate_type:basic:conv
test_scoretraining_stats
params
struct NetworkResult
name::String
architecture::String
network::Any
state_type::Symbol
test_score::Float64
training_stats::Vector{Any}
params::Int
end
下面的代码实现了一个完整的交互式系统,用于训练、测试和比较神经网络,作为Flappy Bird AI项目的一部分。 该系统围绕几个专门的菜单构建,每个菜单负责工作流程的特定阶段。
主菜单(main_menu
全连接网络学习菜单(train_dense_menutrain_conv_menu
测试菜单(testing_menu
结果比较功能(compare_results
所有菜单都使用统一的设计,屏幕清洁,分隔符和信息标题,创造一个专业的用户体验专门使用控制台。 该系统演示了如何通过结构良好的文本界面使复杂的机器学习过程易于访问和直观。
function main_menu()
results = NetworkResult[]
while true
print("\033[2J\033[H")
println("="^60)
println(" 飞扬的鸟-进化算法")
println("="^60)
if !isempty(results)
println("\nОБУЧЕННЫЕ СЕТИ:")
for (i, r) in enumerate(results)
println("$i. $(r.name) ($(r.architecture)): ", round(r.test_score, digits=2), " | Параметров: $(r.params)")
end
end
println("\n" * "-"^60)
println("学习菜单:")
println("1. 训练一个完全连接的网络")
println("2. 训练卷积网络(CNN)")
println("3. 使用图形进行测试运行")
println("4. 比较所有训练好的网络")
println("5. 出口;出口")
println("-"^60)
print("\nВыбор: ")
choice = readline()
if choice == "1"
results = train_dense_menu(results)
elseif choice == "2"
results = train_conv_menu(results)
elseif choice == "3"
testing_menu(results)
elseif choice == "4"
compare_results(results)
elseif choice == "5"
println("出口;出口")
return
end
end
end
function train_dense_menu(results)
print("\033[2J\033[H")
println("="^60)
println("全连接网络训练(进化算法)")
println("="^60)
println("\nАрхитектура сети:")
println("输入层:5个神经元(位置,速度,到管道的距离)")
println("隐藏层:8个具有TANH激活功能的神经元")
println("输出层:1个带乙状结肠的神经元(跳跃概率)")
println("总参数: 5×8 + 8 + 8×1 + 1 = 57")
print("\nВведите имя для сети: ")
name = readline()
print("人口中的网络数量: ")
pop_size = parse(Int, readline())
print("最大代数: ")
max_generations = parse(Int, readline())
print("止损目标账户: ")
target_score = parse(Float64, readline())
print("训练后的测试集: ")
test_episodes = parse(Int, readline())
println("\nСоздание популяции...")
population = [init_dense() for _ in 1:pop_size]
println("进化学习的开始。..")
println("Цель: достичь счета $target_score")
println("-"^60)
final_pop, training_stats, best_net, best_score = train_genetic!(
population, :basic,
target_score=target_score,
max_generations=max_generations,
mutation_rate=0.3
)
println("\nТестовый прогон лучшей сети...")
test_score = evaluate_network(best_net, :basic, test_episodes)
push!(results, NetworkResult(
name,
"Полносвязная (5-8-1)",
best_net,
:basic,
test_score,
training_stats,
57
))
println("\nОбучение завершено!")
println("最佳学习成绩: ", round(best_score, digits=2))
println("测试帐户: ", round(test_score, digits=2))
println("按Enter继续。..")
readline()
return results
end
function testing_menu(results)
if isempty(results)
println("没有训练有素的网络!")
sleep(2)
return
end
while true
print("\033[2J\033[H")
println("="^60)
println("使用图形进行测试运行")
println("="^60)
println("\nВыберите сеть для тестирования (0 для выхода):")
for (i, r) in enumerate(results)
println("$i. $(r.name) ($(r.architecture)): ", round(r.test_score, digits=2))
end
print("\nНомер сети: ")
input = readline()
if input == "0"
return
end
idx = tryparse(Int, input)
if idx === nothing || idx < 1 || idx > length(results)
println("打错了!")
sleep(2)
continue
end
chosen = results[idx]
println("\nЗапуск демонстрации сети '$(chosen.name)'...")
println("按Ctrl+C退出演示")
println("3秒后开始。..")
sleep(3)
try
score = run_episode(chosen.network, chosen.state_type; render=true, max_steps=1000)
println("\nДемонстрация завершена! Счет: $score")
catch e
if !(e isa InterruptException)
println("\nОшибка при выполнении: $e")
end
end
println("\nНажмите Enter для продолжения...")
readline()
end
end
function train_conv_menu(results)
print("\033[2J\033[H")
println("="^60)
println("卷积网络训练(CNN)是一种进化算法")
println("="^60)
println("\nАрхитектура сети:")
println("入口:15×40(比赛场地的高度×宽度)")
println("卷积层:4个3×3滤波器,ReLU激活")
println("全连接层:1个具有乙状结肠的神经元")
println("总参数:~500")
print("\nВведите имя для сети: ")
name = readline()
print("人口中的网络数量: ")
pop_size = parse(Int, readline())
print("最大代数: ")
max_generations = parse(Int, readline())
print("止损目标账户: ")
target_score = parse(Float64, readline())
print("训练后的测试集: ")
test_episodes = parse(Int, readline())
println("\nСоздание популяции...")
population = [init_conv() for _ in 1:pop_size]
println("进化学习的开始。..")
println("Цель: достичь счета $target_score")
println("-"^60)
final_pop, training_stats, best_net, best_score = train_genetic!(
population, :conv,
target_score=target_score,
max_generations=max_generations,
mutation_rate=0.3
)
println("\nТестовый прогон лучшей сети...")
test_score = evaluate_network(best_net, :conv, test_episodes)
height, width = 15, 40
filters = 4
conv_params = 3 * 3 * 1 * filters + filters
fc_params = (height-2) * (width-2) * filters + 1
total_params = conv_params + fc_params
push!(results, NetworkResult(
name,
"Сверточная (Conv3x3×4 → FC)",
best_net,
:conv,
test_score,
training_stats,
total_params
))
println("\nОбучение завершено!")
println("最佳学习成绩: ", round(best_score, digits=2))
println("测试帐户: ", round(test_score, digits=2))
println("按Enter继续。..")
readline()
return results
end
function compare_results(results)
if isempty(results)
println("没有结果可以比较!")
sleep(2)
return
end
print("\033[2J\033[H")
println("="^60)
println("训练网络的比较")
println("="^60)
sorted_results = sort(results, by=r->r.test_score, rev=true)
println("\nРейтинг сетей (по тестовому счету):")
println("-"^60)
println("地点/名称/架构|帐户/参数")
println("-"^60)
for (i, r) in enumerate(sorted_results)
place = i == 1 ? "🥇" : i == 2 ? "🥈" : i == 3 ? "🥉" : " $i."
println("$place $(r.name) | $(r.architecture) | ", round(r.test_score, digits=2), " | $(r.params)")
end
if any(r -> !isempty(r.training_stats), sorted_results)
println("\nСтатистика обучения лучшей сети:")
best = sorted_results[1]
if !isempty(best.training_stats)
println("Количество поколений: $(length(best.training_stats))")
last_stat = best.training_stats[end]
first_stat = best.training_stats[1]
println("初始平均分: ", round(first_stat[3], digits=2))
println("最后最好成绩: ", round(last_stat[2], digits=2))
println("改善工程: ", round(last_stat[2] - first_stat[2], digits=2))
end
end
println("\nНажмите Enter для продолжения...")
readline()
end
要启动项目,您需要在命令行中调用 main_menu().
</no-translation>

</no-translation>

如果您自己运行这两种类型的神经网络的训练,您会注意到卷积网络(CNN)的训练速度比完全连接的慢得多。 这是由于体系结构和正在处理的数据量的根本差异:CNN使用比赛场地的完整15x40状态矩阵(600个值)工作,在每个步骤对多个过滤器执行多个卷积操作。 与此同时,一个完全连接的网络只接收五个预处理特征作为输入,这就需要少几个数量级的计算。 这种学习率的差异清楚地表明了模型复杂性和计算成本之间的权衡-更强大的架构能够更好地解决问题,但需要更多的资源进行培训。
基于所提出的结果,可以得出关于进化算法的工作以及Flappy Bird game问题中各种神经网络架构的有效性的几个重要结论。
两个神经网络,全连接和卷积,实现了相同的最高得分58.0分。
完全连接的网络表现出非凡的效率:仅使用57个参数,它实现了与具有2017个参数的卷积网络相同的结果(35倍以上)。 这说明了机器学习的一个重要原理:更复杂的模型并不总是意味着更好的性能,特别是当任务可以通过简单的手段成功解决时。
进化算法已经显示出很高的效率,在短短14代中实现了最大的结果。 人口的初始平均得分仅为0.49分,这表明随机初始化的网络实际上无法播放。 在14代人中,已经有57.51点的改进-生产力的指数增长,证明了进化优化方法的力量。
下面提供了两个网络如何决定发挥结果,两个网络上升到现场的顶部,并计算当他们需要停止跳跃进入洞的时刻。
.gif)
-
简单与复杂:对于规则明确、状态空间有限的任务,简单的体系结构可以和复杂的体系结构一样高效,但需要的计算资源要少得多。
-
进化算法工作:无梯度遗传方法成功地优化了两种架构,显示了其对强化学习任务的适用性。
-
算法收敛:快速收敛(14代)表示进化算法的精心选择的参数-种群大小,突变率和选择策略。
-
性能上限:两个网络的最高得分相同,可能表明在给定的游戏配置中达到了最大可能的结果,也可能表明游戏环境本身的局限性。
这些结果证实,正确实现的进化算法即使对于复杂的任务也可以有效地训练神经网络,并且体系结构的选择应该与正在解决的问题的具体情况相对应,