GitHub avatar

Fox's Blog

Laupok Built an AI That Plays Super Mario World by Itself -- How It Works

Laupok built an AI that plays Super Mario World by itself -- how it works

Laupok built an artificial intelligence that plays Super Mario World fully autonomously. It starts from zero: no pre-written scripts, no recorded frames. The AI learns alone, through random mutations and natural selection, to finish the game's levels. The project runs on BizHawk, a multiplatform emulator, via a Lua script of about 4200 lines.

What makes this project fascinating is that it rests on biological concepts applied to computing: Darwin's theory of evolution, artificial neural networks, and above all a specific algorithm called NEAT (NeuroEvolution of Augmenting Topologies). The AI knows nothing about the game at first. It tries actions at random, fails thousands of times, and little by little figures out how to move forward, jump, and survive.

In this article, we'll break all of that down -- concept by concept, line of code by line of code.

Laupok introducing the NEAT algorithm on camera


The setup: BizHawk, Lua, and Super Mario World

The BizHawk emulator

BizHawk is an open-source emulator supporting a ton of consoles -- NES, SNES, Genesis, PS1, Game Boy, and more. Its special feature is running Lua scripts alongside the game. Those scripts have access to the emulation's RAM, meaning they can read -- and modify -- any game data in real time.

Concretely, that means you can:

  • Read Mario's position in the level
  • Know which sprites (enemies, items) are on screen
  • Know each tile's state around Mario
  • Control the gamepad -- press any button

That's exactly what you need to make an AI play.

Super Mario World memory addresses

In Super Mario World RAM, each datum sits at a specific address. It's a bit like a neighborhood: each address is a "house" holding information. For example:

Address Data
0x94-0x95 Mario's X position (16-bit, little-endian)
0x96-0x97 Mario's Y position
0x14C8+i Sprite i state (>7 = alive)
0xE4+i Sprite i low X position
0x14E0+i Sprite i high X position
0xD8+i Sprite i low Y position
0x14D4+i Sprite i high Y position
0x170B+i Extended sprite i type
0x0100 Game state (12 = level finished)
0x13D4 Pause active
0x0071 Mario death animation (9 = dead)
0x1C800+... Level tile table

Sprite position uses two bytes: a "low" byte and a "high" byte, because position can exceed 255 pixels. The formula is always low + high × 256.

For tiles, it's more complex: base address is 0x1C800, and you compute the offset from the tile's x and y coords in the world, with a 16-pixel-per-tile step.

Super Mario World with debug overlay showing sprite memory addresses and Mario's position


The basics: genetic algorithms and neural networks

Before diving into code, you need two fundamental concepts. Without them, nothing else makes sense.

Genetic algorithms

A genetic algorithm is a simulation of evolution theory. The core idea: you create a population of individuals, each with slightly different traits ("genes"). You let them "live" in an environment. Those doing best survive and reproduce. Those doing badly disappear.

Laupok illustrates it with a Kirby analogy:

  • A Kirby population appears on terrain with spikes and tomatoes
  • Spikes remove hit points, tomatoes restore them
  • Each Kirby has genes: size, speed, hit points, behavior (flee, seek tomatoes, rush anywhere)

Double helix DNA with labels "the baby", "size", "speed", "color" -- the genes making an individual

  • After 15 seconds, you look at who survived longest
  • The best Kirby reproduces with the others: babies inherit half the best one's genes and half the "worst" one's
  • Babies undergo random mutations (a bit bigger, a bit faster...)
  • Old Kirbies get replaced by the new ones
  • Relaunch

After 180 generations (~15 hours), the Kirbies went from 15 seconds of survival to 15 minutes. They became small (reduced hitbox), fast, and permanently flee danger.

Kirby simulation generation 0: colored circles scattered randomly on a black background, all similar size

Kirby simulation generation 1866: Kirbies are smaller, faster, and systematically flee dangers

Kirby simulation stats: fitness, hit point count, each individual's behavior ranked by performance

The crucial point: you don't define the solution. The algorithm finds it alone. And that's exactly what makes it powerful for problems where you don't know which parameter combination would be optimal.

Artificial neural networks

A neural network is a simplified mathematical model of the human brain. It consists of:

  • Input neurons (inputs): what the network "sees"
  • Output neurons (outputs): what the network "decides"
  • Connections (weights): each connection has a weight amplifying or attenuating the signal

The principle is simple: each input neuron sends its value. It gets multiplied by the connection weight, then added to other signals. If the result exceeds a threshold (the activation function), the output neuron fires.

In Laupok's analogy with Mario and the mouse bit:

  • The input neuron = distance between Mario and the bit
  • The connection weight = Mario's sensitivity
  • The output neuron = Mario screams or not

The closer the bit, the higher the input value. If the weight is strong, the output signal is strong, and Mario would scream. Changing the weight changes Mario's sensitivity.

"Mario is scared" demo: Mario facing a Boo with a synapse bar showing the input-output connection weight

In the AI's real neural network, it's the same logic, but at massive scale:

  • 99 input neurons (11×9 tiles of Mario's view)
  • 8 output neurons (A, B, X, Y, Up, Down, Left, Right)
  • Hidden neurons in between
  • Hundreds of connections with varied weights

NEAT: the algorithm that changes everything

The problem with classic genetic algorithms

If you naively combine a genetic algorithm with a neural network, you have a problem: you create 100 totally different neural networks, and you can't compare them. Each has its own neurons, connections, weights. How do you know whether two networks are "close" or "far"?

That's where NEAT comes in -- NeuroEvolution of Augmenting Topologies. Invented by Kenneth Stanley and Risto Miikkulainen in 2002, it's an algorithm solving exactly that problem.

Species

NEAT's first key mechanism is species. When a neural network becomes too different from another, it's classified into a different species. Similarity computes via three parameters:

  1. Excess (EXCES_COEF = 0.50): the number of connections with no relation between two networks (different innovations)
  2. Disjoint: same, but for connections sitting in the middle
  3. Weight difference (POIDSDIFF_COEF = 0.92): the average weight difference between connections sharing the same innovation

The score formula:

score = (EXCES_COEF × disjoint) / max(nbConnections1 + nbConnections2, 1)
      + POIDSDIFF_COEF × weightDiff

If that score is below DIFF_LIMITE (1.0), both networks are in the same species. Otherwise, a new species is created.

Innovations

That's NEAT's genius. Every time a connection is created, it gets a unique global innovation number. That number follows the neural network even as it reproduces.

Concretely, when a baby is created by crossover, it inherits its parents' innovations. If two networks share the same innovation, it means they have a connection from the same ancestor. That's what lets you compare different-sized networks.

Crossover

When two neural networks reproduce, crossover works like this:

Laupok explaining crossover with "CROSSOVER" overlay text

  1. The fittest network becomes the "dominant parent"
  2. The baby inherits all of the dominant's connections
  3. For each connection sharing the same innovation, the other parent can replace it (50% chance)
  4. Only the non-dominant parent's active connections can replace

That guarantees the baby is always at least as good as the best parent.

Mutations

After crossover, the baby undergoes mutations with configurable probabilities:

Laupok explaining mutations with "(small change = mutation)" overlay text

Mutation Probability Effect
Connection weight reset 25% Weight fully randomized
Weight mutation 95% Weight varies by ±0.80
Add connection 85% New connection between two not-yet-linked neurons
Add neuron 39% A hidden neuron inserted between two connected neurons

The neuron-add rate matters: it's what lets the network grow. At first, there are only inputs and outputs. Gradually, hidden neurons appear, making the network more and more complex.


The code: full walkthrough

Constants

The script starts with a constants block defining all tuning:

-- Mario's view around him
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 pixels wide
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 pixels tall
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tiles
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tiles

-- Neural network
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 inputs (visible tiles)
NB_OUTPUT = 8  -- A, B, X, Y, Up, Down, Left, Right
NB_INDIVIDU_POPULATION = 100  -- individuals per population
NB_NEURONE_MAX = 100000  -- hidden neuron cap

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- value when level is done
NB_FRAME_RESET_BASE = 33  -- frames with no progress before reset
NB_FRAME_RESET_PROGRES = 300  -- frames if progress detected

-- Species
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Mutations
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT is 99 because Mario's view is 11×9 tiles. Each tile is one input neuron. If the tile is empty, it's 0. If it's a block, it's 1. If it's an enemy, it's -1.

The 8 outputs map to SNES pad buttons: A, B, X, Y, Up, Down, Left, Right. Start, Select, L, and R are excluded to limit its decisions to the essentials.

Data structures

The script defines three main structures:

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- current neuron value
    neurone.id = 0        -- unique id
    neurone.type = ""     -- "input", "output", or "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- source neuron id
    connexion.sortie = 0     -- destination neuron id
    connexion.actif = true   -- can be disabled if a hidden neuron is inserted
    connexion.poids = 0      -- connection weight
    connexion.innovation = 0 -- unique innovation number
    connexion.allume = false -- for display: true if signal passes
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- hidden neuron count
        fitness = 1,          -- performance (distance traveled)
        idEspeceParent = 0,   -- which species it belongs to
        lesNeurones = {},     -- neuron array
        lesConnexions = {}    -- connection array
    }
    -- Init with inputs
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Then outputs
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

At first, each network holds only inputs and outputs: no hidden neurons, no connections. The algorithm decides whether it needs any.

Mutations in detail

Weight mutation

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25%: full weight reset
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75%: ±0.80 variation
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

The initial weight is always 1 or -1 (genererPoids()). The ±0.80 variation can swing it between negative and positive values, radically changing network behavior.

Adding a connection

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Randomize the neuron list
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Valid connection: input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Check no connection already exists
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

You can't connect an output to an input (that would create a cycle), and you can't connect two already-linked neurons. Randomization guarantees exploring different possibilities each time.

Adding a neuron

The most interesting mutation:

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Randomize connections
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Disable the existing connection
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Create the hidden neuron
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Connect input to hidden neuron
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Connect hidden neuron to output
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

The mechanism: you take an existing connection, disable it, and insert a hidden neuron in the middle. The original connection gets replaced by two new ones: input→hidden and hidden→output. Like "cutting" a wire to splice in a switch.

That's what makes NEAT "augmenting topologies": the network grows over time. It starts simple and only gets complex if needed.

FeedForward

The function propagating signals through the network:

function feedForward(unReseau)
    -- Reset output neurons
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Propagation
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Each active connection sends input_value × weight to the output neuron. The value accumulates (adds up). The allume flag is just for the network's visual display.

Reading the game memory

The getLesInputs() function translates Super Mario World's world into data the network understands:

function getLesInputs()
    local lesInputs = {}
    -- Init to 0 (gray = nothing)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Sprites (enemies) = -1 (black)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Tiles (blocks) = tile value (white if > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

The input grid is a Mario-centered view: 11 tiles wide, 9 tall. Each tile is:

  • 0 (gray): nothing
  • 1 (white): solid block
  • -1 (black): enemy

Enemies are read from two RAM lists: normal sprites (0x14C8-0x14F8) and extended sprites (0x170B-0x173B). For each living sprite (state > 7), compute its tile position relative to Mario and set -1 in the matching cell.

Fitness: how the AI knows it's improving

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Level done!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario moving right
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Update inputs
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

Fitness is simple: it's the distance traveled rightward. If Mario advances 10 pixels, fitness rises by 10. If Mario goes back, nothing happens (no penalty). If the level is done (address 0x0100 == 12), fitness becomes 1,000,000.

It's intentionally simple. Only rightward progress counts: killing enemies earns nothing, dying costs nothing.

The smart reset

If Mario doesn't move for 33 frames, reset the level and move to the next individual. But if Mario made progress (its current fitness differs from the start), wait 300 frames -- giving the network a chance to "get" what it did right.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
     nbFrameStop = nbFrameStop + 1
     local nbFrameReset = NB_FRAME_RESET_BASE
     if fitnessInit ~= laPopulation[idPopulation].fitness
        and memory.readbyte(0x0071) ~= 9 then
         nbFrameReset = NB_FRAME_RESET_PROGRES
     end
     if nbFrameStop > nbFrameReset then
         nbFrameStop = 0
         lancerNiveau()
         idPopulation = idPopulation + 1
         -- ...
     end
end

The memory.readbyte(0x0071) ~= 9 check verifies Mario isn't in death animation. Resetting becomes pointless when Mario is already in death animation.

The main loop

The loop runs at 30 fps (Super Mario World's normal speed):

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Display (network, info)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- speed up
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- The 3 vital functions
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Reset if no progress
    -- ...
    -- New generation once all individuals tested
    -- ...
end

The three vital functions are majReseau, feedForward, and appliquerLesBoutons. Disable just one and Mario stops moving.

Crossover

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

The baby inherits from the fitter parent. For each connection sharing the same innovation, the other parent has a 50% chance to replace it -- but only if the connection is active. That's an important fix: without it, useless hidden neurons could get created.

Species selection

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Average fitness per species
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Each species creates children proportional to its average fitness
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

The idea: a species with average fitness 10,000 gets to create way more children than one with average fitness 1. That's natural selection in action.

choisirParent uses roulette selection: the fitter an individual, the likelier it gets picked as a parent.

Saving and loading

Populations save to .pop files:

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

Saving also includes the best individual of all previous populations. If the old population's best beats the new one's best, we take the old one back as base. It's a form of elitism: you never lose the best.

Drawing the network

Laupok added a neural network visualizer overlaid on the game:

function dessinerUnReseau(unReseau)
    -- Inputs: 11×9 grid around Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- enemy
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- block
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Outputs: 8 buttons
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Connections
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

Super useful for understanding what the network does. Active connections are white, inactive ones semi-transparent. Inputs are a white/black/gray cell grid. Outputs show which buttons are pressed.


Results

What the AI learned

Over hours (and days) of running, the AI discovered on its own:

  1. Moving right: the most basic behavior, but one requiring holding Right down
  2. Jumping over enemies: by connecting an "enemy detected" input to the A or B button
  3. Avoiding obstacles: some networks learned to briefly back up to better move forward
  4. Finishing levels: the best individual managed to finish Super Mario World's first level

Mario controlled by the AI facing a Boo in a Super Mario World level -- the neural network decides actions in real time

Limitations

The project has limits:

  • One level: the AI trains on one specific level. It doesn't automatically generalize to other levels
  • Training time: it takes dozens of hours to reach satisfying results
  • No understanding: the AI doesn't "understand" what it does. It optimizes a fitness function (distance traveled) via random mutations
  • T-bagging: Laupok notes Mario tends to jump in place when it sees an enemy, simply because that raises fitness (it advances a bit while jumping)

How to reproduce the experiment

Laupok shared everything. Here are the steps:

  1. Download BizHawk at tasvideos.org (Download section)
  2. Get a USA Super Mario World ROM (private copy of your own cartridge)
  3. Download the Lua script from Pastebin -- rename to mario.lua
  4. Place the script in the same folder as the ROM
  5. Launch BizHawk, open the ROM
  6. In the Lua console: dofile("mario.lua") or via the Script > Open Script menu
  7. Save a state at the level start (Savestate > Save State menu) and name it debut.state
  8. Relaunch the script -- it works

The script includes a form with options:

  • Speed up: disables the 30 fps cap to go faster
  • Show network: displays the neural network overlay
  • Show info: shows a banner with generation, fitness, species count
  • Pause: pauses execution
  • Save/Load: persists the current population to a .pop file

Sources and references

Resource Link
Laupok's main video j'ai créé une IA qui joue à mario toute seule
Code review + setup video comment setup l'ia + code source review
Full source code Pastebin Jcvdqhqm
Original NEAT paper Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
N8Programs tutorial NEAT implementation walkthrough (JavaScript, but concepts are identical)
16blings (Laupok's inspiration) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Super Mario World memory map SMW Central - RAM Map

Conclusion

What Laupok did is take an academic algorithm (NEAT, 2002), rewrite it in Lua for an emulator (BizHawk), and apply it to Super Mario World. The result: an AI learning from scratch to play the game, with zero prior knowledge, purely through random mutations and natural selection.

It's a beautiful example of genetic algorithm power. The recipe has three ingredients: natural selection, a bit of Lua, and lots of patience. No deep learning, no GPU, no massive training dataset.

The code is commented, shared, and Laupok made two explainer videos -- one for the big concepts, one for the code. If the topic interests you, dive in. It's more approachable than it looks.

Laupok a créé une IA qui joue à Super Mario World toute seule -- comment ça marche

Laupok a créé une IA qui joue à Super Mario World toute seule -- comment ça marche

Laupok a créé une intelligence artificielle qui joue à Super Mario World de manière totalement autonome. Elle part de zéro : aucun script prédéfini, aucune frame enregistrée. L'IA apprend seule, à coups de mutations aléatoires et de sélection naturelle, à finir les niveaux du jeu. Le projet tourne sur BizHawk, un émulateur multiplateforme, via un script Lua d'environ 4200 lignes.

Ce qui rend ce projet fascinant, c'est qu'il repose sur des concepts biologiques appliqués à l'informatique : la théorie de l'évolution de Darwin, les réseaux de neurones artificiels, et surtout un algorithme spécifique appelé NEAT (NeuroEvolution of Augmenting Topologies). L'IA ne connaît rien au jeu au départ. Elle teste des actions au hasard, échoue des milliers de fois, et petit à petit, elle comprend comment avancer, sauter, et survivre.

Dans cet article, on va décortiquer tout ça -- concept par concept, ligne de code par ligne de code.

Laupok introduit l'algorithme NEAT devant la caméra


Le setup : BizHawk, Lua, et Super Mario World

L'émulateur BizHawk

BizHawk est un émulateur open source qui supporte une tonne de consoles -- NES, SNES, Genesis, PS1, Game Boy, et bien d'autres. Sa particularité, c'est qu'il permet de lancer des scripts Lua en parallèle du jeu. Ces scripts ont accès à la mémoire vive (RAM) de l'émulation, ce qui signifie qu'ils peuvent lire -- et modifier -- n'importe quelle donnée du jeu en temps réel.

Concrètement, ça veut dire qu'on peut :

  • Lire la position de Mario dans le niveau
  • Savoir quels sprites (ennemis, items) sont à l'écran
  • Connaître l'état de chaque tile (bloc) autour de Mario
  • Contrôler la manette -- appuyer sur n'importe quel bouton

C'est exactement ce dont on a besoin pour faire jouer une IA.

Les adresses mémoire de Super Mario World

Dans la RAM de Super Mario World, chaque donnée est stockée à une adresse spécifique. C'est un peu comme un quartier : chaque adresse correspond à une "maison" qui contient une information. Par exemple :

Adresse Donnée
0x94-0x95 Position X de Mario (16 bits, little-endian)
0x96-0x97 Position Y de Mario
0x14C8+i État du sprite i (>7 = vivant)
0xE4+i Position X basse du sprite i
0x14E0+i Position X haute du sprite i
0xD8+i Position Y basse du sprite i
0x14D4+i Position Y haute du sprite i
0x170B+i Type de l'extended sprite i
0x0100 État du jeu (12 = niveau fini)
0x13D4 Pause active
0x0071 Animation de mort de Mario (9 = mort)
0x1C800+... Table des tiles du niveau

La position des sprites utilise deux octets : un octet "bas" et un octet "haut", parce que la position peut dépasser 255 pixels. La formule est toujours bas + haut × 256.

Pour les tiles, c'est plus complexe : l'adresse de base est 0x1C800, et on calcule l'offset en fonction des coordonnées x et y de la tile dans le monde, avec un pas de 16 pixels par tile.

Super Mario World avec overlay de débogage montrant les adresses mémoire des sprites et la position de Mario


Les bases : algorithmes génétiques et réseaux de neurones

Avant de plonger dans le code, il faut comprendre deux concepts fondamentaux. Sans eux, le reste n'a aucun sens.

Les algorithmes génétiques

Un algorithme génétique, c'est une simulation de la théorie de l'évolution. L'idée centrale : on crée une population d'individus, chacun avec des caractéristiques (des "gènes") légèrement différentes. On les fait "vivre" dans un environnement. Ceux qui s'en sortent le mieux survivent et se reproduisent. Ceux qui s'en sortent mal disparaissent.

Laupok illustre ça avec une analogie de Kirby :

  • Une population de Kirby apparaît sur un terrain avec des piques et des tomates
  • Les piques enlèvent des points de vie, les tomates en redonnent
  • Chaque Kirby a des gènes : taille, vitesse, points de vie, comportement (fuir, chercher des tomates, foncer n'importe où)

Double hélice d'ADN avec les étiquettes "le bébé", "taille", "vitesse", "couleur" -- les gènes qui composent un individu

  • Après 15 secondes, on regarde qui a survécu le plus longtemps
  • Le meilleur Kirby se reproduit avec les autres : les bébés héritent de la moitié des gènes du meilleur et de la moitié du "pire"
  • Les bébés subissent des mutations aléatoires (un peu plus grand, un peu plus rapide...)
  • Les anciens Kirby sont remplacés par les nouveaux
  • On relance

Après 180 générations (~15 heures), les Kirby passent de 15 secondes de survie à 15 minutes. Ils sont devenus petits (zone de collision réduite), rapides, et fuient le danger en permanence.

Simulation Kirby génération 0 : des cercles colorés dispersés aléatoirement sur un fond noir, tous de taille similaire

Simulation Kirby génération 1866 : les Kirby sont plus petits, plus rapides, et fuient systématiquement les dangers

Statistiques de la simulation Kirby : fitness, nombre de points de vie, comportement de chaque individu classés par performance

Le point crucial : on ne définit pas la solution. L'algorithme la trouve tout seul. Et c'est exactement ça qui le rend puissant pour des problèmes où on ne sait pas quelle combinaison de paramètres serait optimale.

Les réseaux de neurones artificiels

Un réseau de neurones, c'est un modèle mathématique simplifié du cerveau humain. Il se compose de :

  • Neurones d'entrée (inputs) : ce que le réseau "voit"
  • Neurones de sortie (outputs) : ce que le réseau "décide"
  • Connexions (poids) : chaque connexion a un poids qui amplifie ou atténue le signal

Le principe est simple : chaque neurone d'entrée envoie sa valeur. Elle est multipliée par le poids de la connexion, puis additionnée aux autres signaux. Si le résultat dépasse un certain seuil (la fonction d'activation), le neurone de sortie s'active.

Dans l'analogie de Laupok avec Mario et le bout de la souris :

  • Le neurone d'entrée = la distance entre Mario et le bout
  • Le poids de la connexion = la sensibilité de Mario
  • Le neurone de sortie = Mario crie ou pas

Plus le bout est proche, plus la valeur d'entrée est élevée. Si le poids est fort, le signal envoyé en sortie est fort, et Mario crierait. En modifiant le poids, on modifie la sensibilité de Mario.

Démo "Mario a peur" : Mario face à un Boo avec une barre de synapse affichant le poids de la connexion entre l'entrée et la sortie

Dans le vrai réseau de neurones de l'IA, c'est la même logique, mais à une échelle massive :

  • 99 neurones d'entrée (11×9 tiles de la vue de Mario)
  • 8 neurones de sortie (A, B, X, Y, Haut, Bas, Gauche, Droite)
  • Des neurones cachés (hidden) entre les deux
  • Des centaines de connexions avec des poids variés

NEAT : l'algorithme qui change tout

Le problème des algorithmes génétiques classiques

Si on combine un algorithme génétique avec un réseau de neurones de manière naïve, on a un problème : on crée 100 réseaux de neurones complètement différents, et on ne sait pas les comparer. Chacun a ses propres neurones, ses propres connexions, ses propres poids. Comment savoir si deux réseaux sont "proches" ou "loin" ?

C'est là qu'intervient NEAT -- NeuroEvolution of Augmenting Topologies. Inventé par Kenneth Stanley et Risto Miikkulainen en 2002, c'est un algorithme qui résout exactement ce problème.

Les espèces

Le premier mécanisme clé de NEAT, ce sont les espèces. Quand un réseau de neurones devient trop différent d'un autre, il est classé dans une espèce différente. La similarité est calculée via trois paramètres :

  1. Excess (EXCES_COEF = 0.50) : le nombre de connexions qui n'ont aucun rapport entre deux réseaux (innovations différentes)
  2. Disjoint : pareil, mais pour les connexions qui se situent au milieu
  3. Weight difference (POIDSDIFF_COEF = 0.92) : la différence de poids moyenne entre les connexions partageant la même innovation

La formule de score :

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

Si ce score est inférieur à DIFF_LIMITE (1.0), les deux réseaux sont dans la même espèce. Sinon, on crée une nouvelle espèce.

Les innovations

C'est le génie de NEAT. Chaque fois qu'une connexion est créée, elle reçoit un numéro d'innovation unique et global. Ce numéro suit le réseau de neurones même quand il se reproduit.

Concrètement, quand un bébé est créé par crossover, il hérite des innovations de ses parents. Si deux réseaux partagent la même innovation, ça veut dire qu'ils ont une connexion qui vient du même ancêtre. C'est ce qui permet de comparer des réseaux de tailles différentes.

Le crossover

Quand deux réseaux de neurones se reproduisent, le crossover fonctionne ainsi :

Laupok explique le concept de crossover avec le texte "CROSSOVER" en overlay

  1. Le réseau le plus performant devient le "parent dominant"
  2. Le bébé hérite de toutes les connexions du dominant
  3. Pour chaque connexion partageant la même innovation, l'autre parent peut la remplacer (50% de chances)
  4. Seules les connexions actives du parent non-dominant peuvent remplacer

Ça garantit que le bébé est toujours au moins aussi bon que le meilleur parent.

Les mutations

Après le crossover, le bébé subit des mutations avec des probabilités configurables :

Laupok explique les mutations avec le texte "(petite modif = mutation)" en overlay

Mutation Probabilité Effet
Reset poids connexion 25% Le poids est totalement randomisé
Mutation poids 95% Le poids varie de ±0.80
Ajout connexion 85% Nouvelle connexion entre deux neurones pas encore liés
Ajout neurone 39% Un neurone caché est inséré entre deux neurones connectés

Le taux d'ajout de neurone est important : c'est ce qui permet au réseau de grandir. Au départ, il n'y a que des entrées et des sorties. Progressivement, des neurones cachés apparaissent, rendant le réseau de plus en plus complexe.


Le code : walkthrough complet

Les constantes

Le script commence par un bloc de constantes qui définissent tout le paramétrage :

-- Vue de Mario autour de lui
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 pixels en largeur
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 pixels en hauteur
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tiles
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tiles

-- Réseau de neurones
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 inputs (tiles visibles)
NB_OUTPUT = 8  -- A, B, X, Y, Haut, Bas, Gauche, Droite
NB_INDIVIDU_POPULATION = 100  -- individus par population
NB_NEURONE_MAX = 100000  -- limite de neurones cachés

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- valeur quand le niveau est terminé
NB_FRAME_RESET_BASE = 33  -- frames sans progrès avant reset
NB_FRAME_RESET_PROGRES = 300  -- frames si progrès détecté

-- Espèces
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Mutations
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

Le NB_INPUT est de 99 parce que la vue de Mario fait 11×9 tiles. Chaque tile est un neurone d'entrée. Si la tile est vide, elle vaut 0. Si c'est un bloc, elle vaut 1. Si c'est un ennemi, elle vaut -1.

Les 8 sorties correspondent aux boutons de la manette SNES : A, B, X, Y, Haut, Bas, Gauche, Droite. On exclut Start, Select, L et R pour limiter ses décisions à l'essentiel.

Les structures de données

Le script définit trois structures principales :

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- valeur actuelle du neurone
    neurone.id = 0        -- identifiant unique
    neurone.type = ""     -- "input", "output", ou "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- ID du neurone source
    connexion.sortie = 0     -- ID du neurone destination
    connexion.actif = true   -- peut être désactivé si un neurone caché est inséré
    connexion.poids = 0      -- poids de la connexion
    connexion.innovation = 0 -- numéro d'innovation unique
    connexion.allume = false -- pour l'affichage : true si le signal passe
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- nombre de neurones cachés
        fitness = 1,          -- performance (distance parcourue)
        idEspeceParent = 0,   -- à quelle espèce il appartient
        lesNeurones = {},     -- tableau de neurones
        lesConnexions = {}    -- tableau de connexions
    }
    -- Initialisation avec les inputs
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Puis les outputs
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

Au départ, chaque réseau contient seulement des inputs et des outputs : aucun neurone caché, aucune connexion. C'est l'algorithme qui va décider s'il en faut.

Les mutations en détail

Mutation des poids

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : reset total du poids
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : variation de ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

Le poids initial est toujours 1 ou -1 (genererPoids()). La variation de ±0.80 peut le faire osciller entre des valeurs négatives et positives, ce qui change radicalement le comportement du réseau.

Ajout de connexion

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Randomisation de la liste des neurones
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Connexion valide : input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Vérifier qu'il n'y a pas déjà une connexion
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

On ne peut pas connecter un output à un input (ça créerait un cycle), et on ne peut pas connecter deux neurones déjà liés. La randomisation garantit qu'on explore différentes possibilités à chaque fois.

Ajout de neurone

C'est la mutation la plus intéressante :

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Randomisation des connexions
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Désactiver la connexion existante
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Créer le neurone caché
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Connecter l'entrée au neurone caché
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Connecter le neurone caché à la sortie
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

Le mécanisme : on prend une connexion existante, on la désactive, et on insère un neurone caché au milieu. La connexion d'origine est remplacée par deux nouvelles connexions : entrée→caché et caché→sortie. C'est comme si on "coupait" un fil pour intercaler un interrupteur.

C'est ce qui rend NEAT "augmenting topologies" : le réseau grandit avec le temps. Il commence simple et devient complexe uniquement si c'est nécessaire.

Le feedForward

C'est la fonction qui propage les signaux à travers le réseau :

function feedForward(unReseau)
    -- Reset des neurones de sortie
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Propagation
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Chaque connexion active envoie valeur_entrée × poids vers le neurone de sortie. La valeur est cumulée (additionnée). Le drapeau allume est juste pour l'affichage visuel du réseau.

La lecture de la mémoire du jeu

La fonction getLesInputs() est celle qui traduit le monde de Super Mario World en données compréhensibles par le réseau :

function getLesInputs()
    local lesInputs = {}
    -- Initialisation à 0 (gris = rien)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Les sprites (ennemis) = -1 (noir)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Les tiles (blocs) = valeur de la tile (blanc si > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

La grille d'inputs est une vue centrée sur Mario : 11 tiles en largeur, 9 en hauteur. Chaque tile vaut :

  • 0 (gris) : rien
  • 1 (blanc) : bloc solide
  • -1 (noir) : ennemi

Les ennemis sont lus depuis deux listes dans la RAM : les sprites normaux (0x14C8-0x14F8) et les extended sprites (0x170B-0x173B). Pour chaque sprite vivant (état > 7), on calcule sa position en tiles par rapport à Mario et on met -1 dans la case correspondante.

Le fitness : comment l'IA sait si elle progresse

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Niveau terminé !
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario avance vers la droite
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Mise à jour des inputs
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

La fitness est simple : c'est la distance parcourue vers la droite. Si Mario avance de 10 pixels, la fitness augmente de 10. Si Mario recule, rien ne se passe (pas de pénalité). Si le niveau est terminé (adresse 0x0100 == 12), la fitness devient 1 000 000.

C'est intentionnellement simple. Seule la progression vers la droite compte : tuer des ennemis ne rapporte rien, mourir ne coûte rien.

Le reset intelligent

Si Mario ne bouge pas pendant 33 frames, on reset le niveau et on passe à l'individu suivant. Mais si Mario a fait des progrès (sa fitness actuelle est différente de celle au début), on attend 300 frames -- ça donne une chance au réseau de "comprendre" ce qu'il a fait de bien.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

La condition memory.readbyte(0x0071) ~= 9 vérifie que Mario n'est pas en animation de mort. Le reset devient inutile quand Mario est déjà en animation de mort.

La boucle principale

La boucle tourne à 30 fps (la vitesse normale de Super Mario World) :

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Affichage (réseau, infos)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- accélérer
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- Les 3 fonctions vitales
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Reset si pas de progrès
    -- ...
    -- Nouvelle génération si tous les individus testés
    -- ...
end

Les trois fonctions vitales sont majReseau, feedForward, et appliquerLesBoutons. Si on en désactive une seule, Mario ne bouge plus.

Le crossover

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

Le bébé hérite du meilleur parent. Pour chaque connexion partageant la même innovation, l'autre parent a 50% de chances de la remplacer -- mais seulement si la connexion est active. C'est un correctif important : sans ça, des neurones cachés inutiles pouvaient être créés.

La sélection par espèces

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Calcul fitness moyenne par espèce
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Chaque espèce crée un nombre d'enfants proportionnel à sa fitness moyenne
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

L'idée : une espèce avec une fitness moyenne de 10 000 a le droit de créer beaucoup plus d'enfants qu'une espèce avec une fitness moyenne de 1. C'est la sélection naturelle en action.

Le choisirParent utilise une sélection par roulette : plus un individu a de fitness, plus il a de chances d'être sélectionné comme parent.

La sauvegarde et le chargement

Les populations sont sauvegardées dans des fichiers .pop :

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

La sauvegarde inclut aussi le meilleur individu de toutes les populations précédentes. Si le meilleur de l'ancienne population est meilleur que le meilleur de la nouvelle, on reprend l'ancien comme base. C'est une forme d'élitisme : on ne perd jamais le meilleur.

Le dessin du réseau

Laupok a ajouté un visualiseur du réseau de neurones en surimpression du jeu :

function dessinerUnReseau(unReseau)
    -- Inputs : grille 11×9 autour de Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- ennemi
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- bloc
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Outputs : 8 boutons
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Connexions
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

C'est super utile pour comprendre ce que le réseau fait. Les connexions actives sont blanches, les inactives sont semi-transparentes. Les inputs sont une grille de cases blanches/noires/grises. Les outputs montrent quels boutons sont enfoncés.


Les résultats

Ce que l'IA a appris

Au fil des heures (et des jours) d'exécution, l'IA a découvert par elle-même :

  1. Avancer vers la droite : le comportement le plus basique, mais qui nécessite de maintenir le bouton Droite enfoncé
  2. Sauter par-dessus les ennemis : en connectant un input "ennemi détecté" au bouton A ou B
  3. Éviter les obstacles : certains réseaux ont appris à reculer temporairement pour mieux avancer
  4. Finir des niveaux : le meilleur individu a pu terminer le premier niveau de Super Mario World

Mario contrôlé par l'IA face à un Boo dans un niveau de Super Mario World -- le réseau de neurones décide des actions en temps réel

Les Limitations

Le projet a ses limites :

  • Un seul niveau : l'IA est entraînée sur un seul niveau spécifique. Elle ne généralise pas automatiquement à d'autres niveaux
  • Temps d'entraînement : il faut des dizaines d'heures pour atteindre des résultats satisfaisants
  • Pas de compréhension : l'IA ne "comprend" pas ce qu'elle fait. Elle optimise une fonction de fitness (distance parcourue) via des mutations aléatoires
  • T-bagging : Laupok note que Mario a tendance à sauter sur place quand il voit un ennemi, simplement parce que ça augmente la fitness (il avance un peu en sautant)

Comment reproduire l'expérience

Laupok a tout partagé. Voici les étapes :

  1. Télécharger BizHawk sur tasvideos.org (section Download)
  2. Obtenir une ROM USA de Super Mario World (copie privée de ta propre cartouche)
  3. Télécharger le script Lua depuis Pastebin -- renommer en mario.lua
  4. Placer le script dans le même dossier que la ROM
  5. Lancer BizHawk, ouvrir la ROM
  6. Dans la console Lua : dofile("mario.lua") ou via le menu Script > Open Script
  7. Sauvegarder un state au début du niveau (menu Savestate > Save State) et le nommer debut.state
  8. Relancer le script -- ça marche

Le script inclut un formulaire avec des options :

  • Accélérer : désactive la limite de 30 fps pour aller plus vite
  • Afficher réseau : montre le réseau de neurones en surimpression
  • Afficher infos : affiche un bandeau avec la génération, la fitness, le nombre d'espèces
  • Pause : met en pause l'exécution
  • Sauvegarder/Charger : persiste la population actuelle dans un fichier .pop

Sources et références

Ressource Lien
Vidéo principale de Laupok j'ai créé une IA qui joue à mario toute seule
Vidéo code review + setup comment setup l'ia + code source review
Code source complet Pastebin Jcvdqhqm
Article original de NEAT Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
Tutoriel de N8Programs NEAT implementation walkthrough (JavaScript, mais les concepts sont identiques)
16blings (inspiration de Laupok) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Mémoire de Super Mario World SMW Central - RAM Map

Conclusion

Ce que Laupok a fait, c'est prendre un algorithme académique (NEAT, 2002), le réécrire en Lua pour un émulateur (BizHawk), et l'appliquer à Super Mario World. Le résultat : une IA qui apprend de zéro à jouer au jeu, sans aucune connaissance préalable, uniquement par mutations aléatoires et sélection naturelle.

C'est un exemple magnifique de la puissance des algorithmes génétiques. La recette tient en trois ingrédients : la sélection naturelle, un peu de Lua, et beaucoup de patience. Aucun deep learning, aucun GPU, aucune donnée d'entraînement massive.

Le code est commenté, partagé, et Laupok a fait deux vidéos explicatives -- une pour les grands concepts, une pour le code. Si le sujet t'intéresse, plonge dedans. C'est plus accessible qu'il n'y paraît.

Laupok 做了个自己玩超级马里奥世界的 AI,它是怎么跑的

一个自己玩超级马里奥世界的 AI。从零起步:失业预录帧。AI 靠随机变异和自然选择学会通关。项目跑在 BizHawk 上,一个约 4200 行的 Lua 脚本。

Laupok 做了个自己玩超级马里奥世界的 AI,它是怎么跑的

Laupok 做了个自己玩超级马里奥世界的人工智能。全自动。从零起步:没有预设脚本,没有录像帧。AI 靠随机变异和自然选择学会通关。项目跑在 BizHawk 上,多平台模拟器,经约 4200 行 Lua 脚本。

迷人的是它用的概念全是生物学往计算机搬:达尔文进化论、人工神经网络,还有个叫 NEAT(NeuroEvolution of Augmenting Topologies)的算法。AI 开局对游戏一无所知。随机试动作,死几千次,慢慢学会前进、跳、活命。

这篇全拆,概念一个个来,代码一行行来。

Laupok 在镜头前介绍 NEAT 算法


环境:BizHawk、Lua、超级马里奥世界

BizHawk 模拟器

BizHawk 是开源模拟器,机种一堆,NES、SNES、Genesis、PS1、Game Boy 等等。特点是能并行跑 Lua 脚本。脚本读写模拟内存(RAM),实时读改游戏任何数据。

具体说能:

  • 读马里奥在关卡的位置
  • 知道屏幕上哪些 sprite(敌人、道具)
  • 知道马里奥周围每块 tile 状态
  • 按手柄,任意键

做 AI 正好全要。

超级马里奥世界内存地址

SMW 的 RAM 里每个数据固定地址。像街区:每地址是装信息的"房子"。比如:

地址 数据
0x94-0x95 马里奥 X 位置(16 位,小端)
0x96-0x97 马里奥 Y 位置
0x14C8+i sprite i 状态(>7 活着)
0xE4+i sprite i X 低字节
0x14E0+i sprite i X 高字节
0xD8+i sprite i Y 低字节
0x14D4+i sprite i Y 高字节
0x170B+i extended sprite i 类型
0x0100 游戏状态(12 = 过关)
0x13D4 暂停
0x0071 马里奥死亡动画(9 = 死了)
0x1C800+... 关卡 tile 表

sprite 位置两字节:低字节加高字节,位置超 255 像素就这么办。公式永远低 + 高 × 256。

tile 复杂:基址 0x1C800,按 tile 在世界 x y 坐标算偏移,tile 16 像素步进。

带调试 overlay 的超级马里奥世界,显示 sprite 内存地址和马里奥位置


前置:遗传算法和神经网络

先讲两个基本概念。不懂后面全白看。

遗传算法

遗传算法就是进化论模拟。核心:建种群,个体基因各不相同。放环境里活。活得好的生,活不好的死。

Laupok 用卡比打比方:

  • 卡比种群出生在带刺和番茄的场上
  • 刺扣血,番茄回血
  • 每只卡比有基因:大小、速度、血量、行为(跑、找番茄、乱冲)

DNA 双螺旋,标“宝宝”、大小、速度、颜色,组成个体的基因

  • 15 秒后看谁活最久
  • 最好的卡比和其他的生:宝宝一半基因来自最好的,一半来自"最烂"
  • 宝宝随机变异(大一点、快一点……)
  • 老卡比换掉新的
  • 重来

180 代(约 15 小时)后,卡比从活 15 秒到活 15 分钟。变小了(受击面积小)、快了、永远躲危险。

卡比模拟第 0 代:黑底彩色圆随机散,大小差不多

卡比模拟第 1866 代:卡比更小更快,系统躲危险

卡比模拟统计:fitness、血量、每只行为按成绩排

关键:不定义解。算法自己找出来。这就是它在不知道最优参数组合长什么样的问题上强的原因。

人工神经网络

神经网络是简化版人脑数学模型。组成:

  • 输入神经元:网络"看"到的
  • 输出神经元:网络"决定"的
  • 连接(权重):每条连接有个权重放大或衰减信号

原理简单:每个输入发值。乘连接权重,加别的信号。如果结果过阈(激活函数),输出神经元激活。

Laupok 拿马里奥和鼠标指针打比方:

  • 输入神经元 = 马里奥和指针距离
  • 连接权重 = 马里奥敏感度
  • 输出神经元 = 马里奥叫不叫

指针越近输入值越大。权重强输出信号强,马里奥叫。改权重改马里奥敏感度。

"马里奥害怕"演示:马里奥面對 Boo,突触条显示输入输出连接权重

真 AI 神经网络同理,规模大:

  • 99 输入神经元(马里奥视野 11x9 tile)
  • 8 输出神经元(A、B、X、Y、上、下、左、右)
  • 中间隐藏神经元
  • 几百条权重各异的连接

NEAT:改命的算法

朴素遗传加神经网络的问题

遗传算法加神经网络直接拼,有麻烦:造 100 个完全不同的神经网络,没法比。神经元、连接、权重各不同。两网络"近"还是"远"?

NEAT 上场,NeuroEvolution of Augmenting Topologies。Kenneth Stanley 和 Risto Miikkulainen 2002 年发明,专解这个问题。

物种

NEAT 第一关键机制物种。神经网络和另一个差太多,分不同物种。相似度三参数:

  1. Excess(EXCES_COEF = 0.50):两网络风马牛不相及的连接数(不同 innovation)
  2. Disjoint:同上,但是中间位置的连接
  3. 权重差(POIDSDIFF_COEF = 0.92):共享 innovation 连接的平均权重差

分数公式:

分数 = (EXCES_COEF × disjoint) / max(连接数1 + 连接数2, 1)
       + POIDSDIFF_COEF × 权重差

分数小于 DIFF_LIMITE(1.0),两网络同物种。否则开新物种。

innovation

NEAT 的天才。每条连接出生拿全局唯一 innovation 号。号码跟网络走,繁殖也带着。

实际宝宝交叉继承亲本 innovation。两网络共享 innovation,说明这条连接同祖。大小不同的网络也能比了。

交叉

两神经网络繁殖,交叉这么跑:

Laupok 讲 crossover,叠字"CROSSOVER"

  1. 成绩好的当"显性亲本"
  2. 宝宝全盘继承显性的连接
  3. 每条共享 innovation 的连接,非显性亲本 50% 概率替换
  4. 只有非显性亲本活跃连接能替换

保证宝宝至少不比最好的亲本差。

变异

交叉后宝宝按可配概率变异:

Laupok 讲变异,叠字"(petite modif = mutation)"

变异 概率 效果
连接权重重置 25% 权重全随机
权重变异 95% 权重 ±0.80 浮动
加连接 85% 没连的两神经元连上
加神经元 39% 两连着的神经元中间插隐藏神经元

加神经元概率重要:网络长大。开局只有输入输出。隐藏神经元慢慢冒出来,网络越来越复杂。


代码:完整走读

常量

脚本开头一串常量定全部参数:

-- 马里奥周围视野
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 宽 176 像素
TAILLE_VUE_H = TAILLE_TILE * 9   -- 高 144 像素
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tile
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tile

-- 神经网络
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 输入(看得到的 tile)
NB_OUTPUT = 8  -- A、B、X、Y、上、下、左、右
NB_INDIVIDU_POPULATION = 100  -- 每代个体
NB_NEURONE_MAX = 100000  -- 隐藏神经元上限

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- 过关分值
NB_FRAME_RESET_BASE = 33  -- 没进展多少帧重置
NB_FRAME_RESET_PROGRES = 300  -- 有进展给多少帧

-- 物种
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- 变异
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT 99 因为马里奥视野 11x9 tile。每 tile 一输入。tile 空 0。块 1。敌人 -1。

8 输出对应 SNES 手柄键:A、B、X、Y、上、下、左、右。Start、Select、L、R 不用,决策越少越好。

数据结构

脚本定三个主结构:

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- 神经元当前值
    neurone.id = 0        -- 唯一标识
    neurone.type = ""     -- "input"、"output" 或 "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- 源神经元 ID
    connexion.sortie = 0     -- 目标神经元 ID
    connexion.actif = true   -- 插隐藏神经元可关掉
    connexion.poids = 0      -- 连接权重
    connexion.innovation = 0 -- 唯一 innovation 号
    connexion.allume = false -- 显示用:有信号过就亮
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- 隐藏神经元数
        fitness = 1,          -- 成绩(走多远)
        idEspeceParent = 0,   -- 属于哪个物种
        lesNeurones = {},     -- 神经元表
        lesConnexions = {}    -- 连接表
    }
    -- 先初始化输入
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- 再输出
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

开局每个网络只有输入输出:无隐藏神经元,无连接。要不要长算法定。

变异细看

权重变异

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25%:权重全重置
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75%:±0.80 浮动
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

初始权重永远 1 或 -1(genererPoids())。±0.80 浮动能在正负间荡,网络行为大变。

加连接

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- 神经元表打乱
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- 合法连接:输入→输出、隐藏→隐藏、隐藏→输出
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- 查是不是已经连了
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

输出连输入不行(成环),连过的 pair 不重连。打乱保证每次探索不同可能。

加神经元

最有意思的变异:

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- 连接打乱
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- 关掉已有连接
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- 建隐藏神经元
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- 输入连隐藏
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- 隐藏连输出
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

机制:挑条已有连接关掉,中间插隐藏神经元。原来一条变两条:输入→隐藏和隐藏→输出。像把电线剪断中间串开关。

这就是 NEAT "augmenting topologies":网络随时间长大。简单开局,只在必要时变复杂。

feedForward

信号前向传播函数:

function feedForward(unReseau)
    -- 输出神经元清零
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- 传播
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

每条活跃连接送 输入值 × 权重 到输出神经元。值累加。allume 旗只管网络可视化:有信号过就亮。

读游戏内存

getLesInputs() 把超级马里奥世界翻译成网络懂的数据:

function getLesInputs()
    local lesInputs = {}
    -- 初始化 0(灰 = 空)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- sprite(敌人)= -1(黑)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- tile(块)= tile 值(> 0 白)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

输入格是以马里奥为中心的视野:宽 11 tile,高 9 tile。每格值:

  • 0(灰):空
  • 1(白):实心块
  • -1(黑):敌人

敌人从 RAM 两表读:普通 sprite(0x14C8-0x14F8)和 extended sprite(0x170B-0x173B)。每活 sprite(状态 > 7)算相对马里奥的 tile 位置,对应格写 -1。

fitness:AI 怎么知道有没有进步

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- 过关!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- 马里奥向右走
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- 更新输入
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

fitness 简单粗暴:向右走多远。马里奥右移 10 像素 fitness 加 10。后退不扣分。过关(地址 0x0100 == 12)fitness 直接 100 万。

故意简单。只有向右算数:杀怪不加分,死不扣分。

智能重置

马里奥 33 帧不动,重置关卡换下个个体。马里奥有进展(fitness 和开局不一样),等 300 帧,给网络"想明白"的机会。

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

memory.readbyte(0x0071) ~= 9 查马里奥不在死亡动画。死了重置没意义。

主循环

循环跑 30 fps(超级马里奥世界正常速度):

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- 显示(网络、信息)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- 加速
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- 3 个 vital 函数
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- 没进展重置
    -- ...
    -- 全测完新一代
    -- ...
end

majReseau、feedForward、appliquerLesBoutons 三个缺一,马里奥不动。

交叉

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

宝宝继承好亲本。每条共享 innovation 的连接,差亲本 50% 概率替换,只限活跃连接。重要修:不然没用的隐藏神经元乱生。

按物种选

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- 算每物种平均 fitness
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- 每物种按平均 fitness 比例生孩子
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

思路:平均 fitness 10000 的物种比平均 1 的多生得多孩子。这就是自然选择。

choisirParent 轮盘赌:fitness 越高当爹概率越大。

保存和加载

种群存 .pop 文件:

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

存全部种群加之前所有代最好的。如果老最好的比新一代最好的强,拿老的当底子。一种精英主义:最好的永不丢。

画网络

Laupok 给网络加了游戏内 overlay 可视化:

function dessinerUnReseau(unReseau)
    -- 输入:马里奥周围 11x9 格
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- 敌人
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- 块
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- 输出:8 个键
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- 连接
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

看网络在干嘛超有用。活跃连接白色,不活跃半透明。输入是白黑灰格子。输出看哪个键按着。


成果

AI 学会了什么

跑几小时(几天)下来,AI 自己悟出:

  1. 向右走:最基础,但右键得一直按着
  2. 跳过敌人:敌人输入连 A 或 B 键
  3. 绕障碍:有些网络学会暂时后退再前进
  4. 过关:最好的个体打通超级马里奥世界第一关

AI 操作的马里奥在超级马里奥世界里面对 Boo,神经网络实时决策

局限

项目有墙:

  • 只会一关:单关训的,不自动泛化到别的关
  • 训练时间:像样结果要几十小时
  • 不懂:AI 不"理解"自己干嘛。按 fitness 函数(走多远)随机变异优化
  • 蹭地:Laupok 说马里奥见敌人爱原地跳,因为跳也往前蹭涨 fitness

复现实验

Laupok 全开源了。步骤:

  1. 下 BizHawk tasvideos.org(Download 区)
  2. 搞 USA 版超级马里奥世界 ROM(自己卡带备份)
  3. 下 Lua 脚本 Pastebin,改名 mario.lua
  4. 脚本和 ROM 放同一文件夹
  5. 开 BizHawk,打开 ROM
  6. Lua 控制台:dofile("mario.lua") 或菜单 Script > Open Script
  7. 存档开头(菜单 Savestate > Save State)起名 debut.state
  8. 重跑脚本,成了

脚本带表单选项:

  • 加速:关 30 fps 上限跑更快
  • 显示网络:overlay 显示神经网络
  • 显示信息:横幅显示代数、fitness、物种数
  • 暂停:暂停执行
  • 保存/加载:当前种群存 .pop 文件

来源和参考

资源 链接
Laupok 主视频 j'ai créé une IA qui joue à mario toute seule
代码 review 加环境视频 comment setup l'ia + code source review
完整源码 Pastebin Jcvdqhqm
NEAT 原论文 Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
N8Programs 教程 NEAT implementation walkthrough(JavaScript,但概念一样)
16blings(Laupok 灵感) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
超级马里奥世界内存 SMW Central - RAM Map

结语

Laupok 做的事:拿学术算法(NEAT,2002),Lua 重写给模拟器(BizHawk),用到超级马里奥世界。结果:AI 从零学会玩游戏,零先验知识,纯随机变异加自然选择。

遗传算法威力的漂亮例子。配方三样:自然选择,一点 Lua,大把耐心。不用深度学习,不用 GPU,不用海量训练数据。

代码有注释,开源,Laupok 做了两个讲解视频,一个讲大概念,一个讲代码。感兴趣直接跳, 比看着好懂。

Laupokが1人で遊ぶスーパーマリオワールドAIを作った。仕組みはこうだ

スーパーマリオワールドを自律プレイするLaupok製AIの解説。NEATアルゴリズム、BizHawk、4200行Luaスクリプトの完全ウォークスルー。

Laupokが1人で遊ぶスーパーマリオワールドAIを作った。仕組みはこうだ

Laupokはスーパーマリオワールドを完全自律で遊ぶ人工知能を作った。ゼロから始まる。定義済みスクリプトなし、記録フレームなしだ。AIはランダム変異と自然選択の力で1人で学んで、ゲームの面をクリアする。プロジェクトはマルチプラットエミュレータBizHawk上で、約4200行のLuaスクリプト経由で回る。

このプロジェクトを魅惑的にするのは、情報学に応用した生物概念の上に立ってることだ。ダーウィンの進化論と人工ニューラルネット、そしてNEAT(NeuroEvolution of Augmenting Topologies)って特定アルゴリズムだ。AIは最初ゲームを何も知らない。ランダムに行動を試して、何千回も失敗して、少しずつ前に進んで、跳んで、生き延びる方法を理解する。

この記事では全部ほどいていく。概念ごと、コード行ごとだ。

カメラの前でNEATアルゴリズムを紹介するLaupok


セットアップ:BizHawkとLuaとスーパーマリオワールド

BizHawkエミュレータ

BizHawkは大量の本体に対応するオープンソースエミュレータだ。NES、SNES、Genesis、PS1、Game Boy、その他いろいろだ。特徴はゲームと並行にLuaスクリプトを走らせられることだ。このスクリプトはエミュレーションのRAMにアクセスできる。つまりリアルタイムでゲームの任意データを読み書きできる。

具体的にはこうできる。

  • 面内のマリオ位置を読む
  • 画面にいるスプライト(敵、アイテム)を知る
  • マリオ周辺の各タイル(ブロック)状態を知る
  • コントローラーを操作する。任意ボタンを押せる

AIを遊ばせるのにまさに要るものだ。

スーパーマリオワールドのメモリアドレス

スーパーマリオワールドのRAMでは各データが特定アドレスに保存される。街区みたいなものだ。各アドレスが情報を含む「家」に対応する。たとえばこうだ。

アドレス データ
0x94-0x95 マリオのX位置(16ビット、リトルエンディアン)
0x96-0x97 マリオのY位置
0x14C8+i スプライトiの状態(7超は生存)
0xE4+i スプライトiの下位X位置
0x14E0+i スプライトiの上位X位置
0xD8+i スプライトiの下位Y位置
0x14D4+i スプライトiの上位Y位置
0x170B+i extendedスプライトiの種別
0x0100 ゲーム状態(12は面クリア)
0x13D4 ポーズ中
0x0071 マリオ死亡アニメ(9は死亡)
0x1C800+... 面のタイルテーブル

スプライト位置は2バイト使う。下位バイトと上位バイトだ。位置が255ピクセルを超えうるからね。式はつねに下位 + 上位 × 256だ。

タイルはもっと複雑だ。ベースアドレスは0x1C800で、世界内のタイル座標xとyからオフセットを計算する。タイルあたり16ピクセル刻みだ。

スプライトのメモリアドレスとマリオ位置を示すデバッグオーバーレイ付きスーパーマリオワールド


基礎:遺伝アルゴリズムとニューラルネット

コードに潜る前に2つの根本概念を理解しなきゃいけない。これなしでは残りが無意味だ。

遺伝アルゴリズム

遺伝アルゴリズムは進化論のシミュレーションだ。中心発想:集団の個体を作って、各々少し違う特徴(「遺伝子」)を持つ。環境で「生き」させる。うまくやったものが生き残って繁殖する。ダメなものは消える。

Laupokはカービィの例えで示す。

  • トゲとトマトのある地形にカービィ集団が現れる
  • トゲは体力を奪い、トマトは回復する
  • 各カービィは遺伝子を持つ。大きさ、速さ、体力、挙動(逃げる、トマト探す、どこでも突っ込む)

「赤ちゃん」「大きさ」「速さ」「色」のラベル付き二重らせんDNA。個体を構成する遺伝子

  • 15秒後、誰がいちばん長く生き残ったか見る
  • 最良カービィが他と繁殖する。赤ちゃんは最良の遺伝子半分と「最悪」の半分を受け継ぐ
  • 赤ちゃんはランダム変異を受ける(ちょっと大きい、ちょっと速いなど)
  • 古いカービィは新しいのに置き換わる
  • 再開する

180世代後(約15時間)、カービィは生存15秒から15分になる。小さく(当たり判定縮小)、速く、常に危険から逃げるようになった。

カービィシミュレーション世代0:黒背景にランダム散らばった色付き円、大きさは全部似てる

カービィシミュレーション世代1866:カービィは小さく速く、危険から体系的に逃げる

カービィシミュレーション統計:fitness、体力数、性能順の各個体挙動

重要な点:解を定義しない。アルゴリズムが1人で見つける。まさにそれが、どのパラメータ組み合わせが最適か分からない問題に強力なんだ。

人工ニューラルネット

ニューラルネットは人脳の単純化数学モデルだ。構成はこうだ。

  • 入力ニューロン(inputs):ネットが「見る」もの
  • 出力ニューロン(outputs):ネットが「決める」もの
  • 接続(重み):各接続は信号を増幅か減衰する重みを持つ

仕組みは単純だ。各入力ニューロンが値を送る。接続重みで掛けられて、他の信号と足される。結果があるしきい値(活性化関数)を超えたら出力ニューロンが発火する。

マリオとネズミの切れ端のLaupokの例えではこうだ。

  • 入力ニューロン = マリオと切れ端の距離
  • 接続重み = マリオの敏感さ
  • 出力ニューロン = マリオが叫ぶかどうか

切れ端が近いほど入力値は高い。重みが強ければ出力信号は強くて、マリオは叫ぶだろう。重みを変えればマリオの敏感さが変わる。

「マリオが怖がる」デモ:Booに向き合うマリオと、入力と出力間の接続重みを示すシナプスバー

AIの本物ニューラルネットでは同じ理屈だが、大規模だ。

  • 99入力ニューロン(マリオ視界11×9タイル)
  • 8出力ニューロン(A、B、X、Y、上、下、左、右)
  • 間にある隠れニューロン(hidden)
  • 重み様々の数百接続

NEAT:全部変えるアルゴリズム

古典遺伝アルゴリズムの問題

遺伝アルゴリズムとニューラルネットを素朴に組み合わせると問題が出る。まったく違うニューラルネット100個を作って、比べ方が分からない。各々独自ニューロン、独自接続、独自重みだ。2つのネットが「近い」か「遠い」かどう知るのか。

ここでNEAT、NeuroEvolution of Augmenting Topologiesの出番だ。Kenneth StanleyとRisto Miikkulainenが2002年に発明した。この問題を正確に解くアルゴリズムだ。

種

NEATの最初の鍵仕組みは種だ。ニューラルネットが別のものと違いすぎると、違う種に分類される。類似度は3パラメータで計算する。

  1. Excess(EXCES_COEF = 0.50):2ネット間で無関係な接続数(違う innovation)
  2. Disjoint:同じだが、真ん中にある接続について
  3. 重み差(POIDSDIFF_COEF = 0.92):同じ innovation を共有する接続間の平均重み差

スコア式:

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

このスコアがDIFF_LIMITE(1.0)未満なら2ネットは同じ種だ。そうじゃなければ新種を作る。

innovation

NEATの天才的なところだ。接続が作られるたび、グローバルに一意なinnovation番号を受ける。この番号は繁殖してもニューラルネットに付いてくる。

具体的には、交叉で赤ちゃんが作られるとき両親の innovation を受け継ぐ。2ネットが同じ innovation を共有したら、同じ祖先由来の接続があるってことだ。大きさの違うネットを比べられるのはこれのおかげだ。

交叉

2つのニューラルネットが繁殖するとき、交叉はこう動く。

「CROSSOVER」テキストのオーバーレイで交叉概念を説明するLaupok

  1. 高性能なネットが「優性親」になる
  2. 赤ちゃんは優性の全接続を受け継ぐ
  3. 同じ innovation を共有する各接続について、もう片方の親が置き換えうる(50パーセント)
  4. 非優性親の活性接続だけが置き換えられる

赤ちゃんが最良親と少なくとも同等なことが保証される。

変異

交叉後、赤ちゃんは設定可能確率で変異を受ける。

「(小変更 = 変異)」テキストのオーバーレイで変異を説明するLaupok

変異 確率 効果
接続重みリセット 25% 重みは完全ランダム化
重み変異 95% 重みはプラスマイナス0.80変動
接続追加 85% 未接続2ニューロン間の新規接続
ニューロン追加 39% 接続済み2ニューロン間に隠れニューロン挿入

ニューロン追加率が大事だ。ネットが育つのを許す。最初は入出力だけだ。少しずつ隠れニューロンが現れて、ネットはますます複雑になる。


コード:完全ウォークスルー

定数

スクリプトは全部のパラメータを定義する定数ブロックから始まる。

-- マリオ周辺の視界
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 幅176ピクセル
TAILLE_VUE_H = TAILLE_TILE * 9   -- 高さ144ピクセル
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11タイル
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9タイル

-- ニューラルネット
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99入力(見えるタイル)
NB_OUTPUT = 8  -- A、B、X、Y、上、下、左、右
NB_INDIVIDU_POPULATION = 100  -- 集団あたり個体数
NB_NEURONE_MAX = 100000  -- 隠れニューロン上限

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- 面終了時の値
NB_FRAME_RESET_BASE = 33  -- 進歩なしリセットまでのフレーム
NB_FRAME_RESET_PROGRES = 300  -- 進歩検出時のフレーム

-- 種
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- 変異
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUTが99なのはマリオ視界が11×9タイルだからだ。各タイルが入力ニューロン1つだ。タイルが空なら0だ。ブロックなら1だ。敵なら-1だ。

8出力はSNESコントローラーのボタンに対応する。A、B、X、Y、上、下、左、右だ。決定を要点に絞るためStart、Select、L、Rは除く。

データ構造

スクリプトは3つの主要構造を定義する。

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- ニューロンの現在値
    neurone.id = 0        -- 一意ID
    neurone.type = ""     -- 「input」「output」「hidden」
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- ソースニューロンID
    connexion.sortie = 0     -- 宛先ニューロンID
    connexion.actif = true   -- 隠れニューロン挿入で無効化されうる
    connexion.poids = 0      -- 接続重み
    connexion.innovation = 0 -- 一意innovation番号
    connexion.allume = false -- 表示用:信号通過でtrue
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- 隠れニューロン数
        fitness = 1,          -- 性能(進んだ距離)
        idEspeceParent = 0,   -- どの種に属するか
        lesNeurones = {},     -- ニューロンテーブル
        lesConnexions = {}    -- 接続テーブル
    }
    -- 入力で初期化
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- 次に出力
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

最初、各ネットは入出力だけを含む。隠れニューロンなし、接続なしだ。要るかどうかはアルゴリズムが決める。

変異の詳細

重み変異

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25%:重みの完全リセット
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75%:プラスマイナス0.80の変動
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

初期重みはつねに1か-1だ(genererPoids())。プラスマイナス0.80の変動で負値と正値の間を揺れて、ネット挙動が根本から変わりうる。

接続追加

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- ニューロン一覧のランダム化
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- 有効接続:inputからoutput、hiddenからhidden、hiddenからoutput
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- 接続済みでないか確認
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

outputからinputには繋げない(輪を作る)。繋がってる2ニューロンも繋げない。ランダム化で毎回違う可能性を探ることが保証される。

ニューロン追加

いちばん面白い変異だ。

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- 接続のランダム化
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- 既存接続を無効化
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- 隠れニューロンを作る
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- 入力を隠れニューロンへ接続
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- 隠れニューロンを出力へ接続
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

仕組み:既存接続を1つ取って無効化して、真ん中に隠れニューロンを挿入する。元の接続は2つの新接続に置き換わる。入力から隠れへと隠れから出力へだ。電線を切って間にスイッチを挟むみたいなものだ。

これがNEATの「augmenting topologies」を作る。ネットは時間とともに育つ。単純に始まって、必要なら複雑になるだけだ。

feedForward

信号をネットに伝える関数だ。

function feedForward(unReseau)
    -- 出力ニューロンのリセット
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- 伝播
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

各活性接続は出力ニューロンへ入力値 × 重みを送る。値は累積(加算)される。allumeフラグはネットの視覚表示用だ。

ゲームメモリの読み取り

getLesInputs()関数はスーパーマリオワールド世界をネットが分かるデータに翻訳する。

function getLesInputs()
    local lesInputs = {}
    -- 0で初期化(灰は無)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- スプライト(敵)= -1(黒)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- タイル(ブロック)= タイル値(0超で白)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

入力グリッドはマリオ中心の視界だ。幅11タイル、高さ9だ。各タイルはこうだ。

  • 0(灰):無
  • 1(白):固体ブロック
  • -1(黒):敵

敵はRAM内2リストから読む。通常スプライト(0x14C8-0x14F8)とextendedスプライト(0x170B-0x173B)だ。各生存スプライト(状態7超)について、マリオ相対のタイル位置を計算して対応マスに-1を置く。

fitness:AIが進歩を知る方法

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- 面終了!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- マリオが右へ進む
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- 入力の更新
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

fitnessは単純だ。右へ進んだ距離だ。マリオが10ピクセル進めばfitnessは10増える。マリオが戻っても何も起きない(罰なし)。面終了(アドレス0x0100が12)でfitnessは1,000,000になる。

わざと単純だ。数えるのは右への進行だけだ。敵を倒しても点なし、死んでも損なしだ。

賢いリセット

マリオが33フレーム動かなければ面をリセットして次の個体に行く。でもマリオが進歩してたら(現fitnessが開始時と違う)、300フレーム待つ。ネットが「うまくいったことを理解」する機会を与える。

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
     nbFrameStop = nbFrameStop + 1
     local nbFrameReset = NB_FRAME_RESET_BASE
     if fitnessInit ~= laPopulation[idPopulation].fitness
        and memory.readbyte(0x0071) ~= 9 then
         nbFrameReset = NB_FRAME_RESET_PROGRES
     end
     if nbFrameStop > nbFrameReset then
         nbFrameStop = 0
         lancerNiveau()
         idPopulation = idPopulation + 1
         -- ...
     end
end

memory.readbyte(0x0071) ~= 9条件はマリオが死亡アニメ中か確認する。マリオがすでに死亡アニメならリセットは無駄だ。

メインループ

ループは30fpsで回る(スーパーマリオワールドの通常速度)。

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- 表示(ネット、情報)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- 高速化
    else
        emu.limitframerate(true)   -- 30fps
    end

    -- 3つの生命線関数
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- 進歩なければリセット
    -- ...
    -- 全個体試験で新世代
    -- ...
end

3つの生命線関数はmajReseau、feedForward、appliquerLesBoutonsだ。1つ切るだけでマリオは動かなくなる。

交叉

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

赤ちゃんは最良親を受け継ぐ。同じ innovation を共有する各接続について、もう片方の親が50パーセントで置き換える。ただし接続が活性の場合だけだ。大事な修正だ。これなしでは無駄な隠れニューロンが作られうる。

種による選択

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- 種ごとの平均fitness計算
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- 各種は平均fitness比例の子数を作る
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

考え方:平均fitness 10,000の種は平均1の種よりずっと多くの子を作る権利がある。行動中の自然選択だ。

choisirParentはルーレット選択を使う。fitnessが高い個体ほど親に選ばれやすい。

保存と読み込み

集団は.popファイルに保存される。

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

保存は過去全集団の最良個体も含む。旧集団の最良が新集団の最良より良ければ、旧を土台に取り戻す。エリート主義の一形態だ。最良は絶対失わない。

ネットの描画

Laupokはゲーム上に重ねるニューラルネット可視化を足した。

function dessinerUnReseau(unReseau)
    -- 入力:マリオ周辺11×9グリッド
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- 敵
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- ブロック
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- 出力:8ボタン
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- 接続
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

ネットが何してるか理解するのに超便利だ。活性接続は白、不活性は半透明だ。入力は白黒灰マスのグリッドだ。出力はどのボタンが押されてるか示す。


結果

AIが学んだこと

何時間も(何日も)回すうちに、AIは1人で発見した。

  1. 右へ進む:いちばん基本的挙動だが、右ボタン押しっぱなしが必要だ
  2. 敵を飛び越える:「敵検出」入力をAかBボタンに繋いで
  3. 障害物を避ける:一部のネットはもっと先へ進むため一時後退を学んだ
  4. 面を終える:最良個体はスーパーマリオワールド1面を終えられた

スーパーマリオワールドの面でBooに向き合うAI操作マリオ。ニューラルネットがリアルタイムで行動決定

限界

プロジェクトには限界がある。

  • 1面だけ:AIは特定1面で訓練される。他面に自動一般化しない
  • 訓練時間:満足いく結果に数十時間要る
  • 理解なし:AIはやってることを「分かって」ない。fitness関数(進んだ距離)をランダム変異で最適化してる
  • 屈伸煽り:敵を見るとマリオはその場跳びしがちだとLaupokは記す。跳ぶと少し進んでfitnessが上がるからだ

実験を再現する方法

Laupokは全部共有した。手順はこうだ。

  1. BizHawkを落とす。tasvideos.orgから(Download節)
  2. スーパーマリオワールドのUSA ROMを入手する(自分のカートリッジの私的コピー)
  3. Luaスクリプトを落とす。Pastebinから。mario.luaに改名する
  4. ROMと同じフォルダにスクリプトを置く
  5. BizHawkを起動してROMを開く
  6. Luaコンソールで:dofile("mario.lua")かメニューScript > Open Scriptから
  7. 面頭でstateを保存する(メニューSavestate > Save State)。debut.stateと名付ける
  8. スクリプトを再起動する。動く

スクリプトは選択肢付きフォームを含む。

  • 高速化:30fps制限を外して速く進める
  • ネット表示:ニューラルネットを重ねて見せる
  • 情報表示:世代、fitness、種数の帯を表示する
  • Pause:実行を止める
  • 保存や読み込み:現集団を.popファイルに永続化する

出典と参照

リソース リンク
Laupokのメイン動画 j'ai créé une IA qui joue à mario toute seule
コードレビューとsetup動画 comment setup l'ia + code source review
完全ソース Pastebin Jcvdqhqm
NEAT原論文 Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
N8Programsのチュートリアル NEAT implementation walkthrough(JavaScriptだが概念は同一)
16blings(Laupokの着想元) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
スーパーマリオワールドのメモリ SMW Central - RAM Map

結論

Laupokがやったのは、学術アルゴリズム(NEAT、2002年)を取って、エミュレータ(BizHawk)用にLuaで書き直して、スーパーマリオワールドに適用したことだ。結果:事前知識ゼロで1人でゲームを遊ぶことを学ぶAIだ。ランダム変異と自然選択だけだ。

遺伝アルゴリズムの力の見事な例だ。レシピは3材料だ。自然選択、少しのLua、大量の忍耐だ。ディープラーニングなし、GPUなし、大量訓練データなしだ。

コードはコメント付きで共有されて、Laupokは2つの解説動画を作った。1つは大概念用、1つはコード用だ。興味が出たら潜って。見た目より取っ付きやすいよ。

Laupok이 혼자 Super Mario World를 하는 AI를 만들었어요. 어떻게 동작하나요

Laupok이 혼자 Super Mario World를 하는 AI를 만들었어요. 어떻게 동작하나요

Laupok이 Super Mario World를 완전히 자율로 플레이하는 인공지능을 만들었어요. 0에서 출발해요. 미리 정해진 스크립트도 녹화된 프레임도 없어요. AI는 무작위 변이와 자연 선택으로 레벨을 끝내는 법을 혼자 배워요. 프로젝트는 멀티플랫폼 에뮬레이터 BizHawk 위에서 약 4200줄 Lua 스크립트로 돌아요.

이 프로젝트를 매혹적으로 만드는 것은 생물학 개념을 컴퓨터에 적용했다는 거예요. Darwin의 진화론과 인공 신경망, 그리고 NEAT(NeuroEvolution of Augmenting Topologies)라는 특정 알고리즘이요. AI는 처음에 게임에 대해 아무것도 몰라요. 무작위로 행동을 테스트하고 수천 번 실패하고, 조금씩 앞으로 가고 점프하고 살아남는 법을 이해해요.

이 글에서는 그 모든 것을 뜯어봐요. 개념별로, 코드 줄별로.

카메라 앞에서 NEAT 알고리즘을 소개하는 Laupok


설정: BizHawk, Lua, Super Mario World

BizHawk 에뮬레이터

BizHawk는 여러 콘솔을 지원하는 오픈 소스 에뮬레이터예요. NES, SNES, Genesis, PS1, Game Boy 등등. 특징은 게임과 나란히 Lua 스크립트를 실행할 수 있다는 거예요. 이 스크립트들은 에뮬레이션의 RAM에 닿아서, 게임의 어떤 자료든 실시간으로 읽고 수정할 수 있어요.

구체적으로 할 수 있는 것은요.

  • 레벨 속 Mario 위치 읽기
  • 화면의 스프라이트(적, 아이템) 알기
  • Mario 주변 각 타일(블록) 상태 알기
  • 패드 조종. 어떤 버튼이든 누르기

AI가 플레이하게 하기에 정확히 필요한 것이에요.

Super Mario World 메모리 주소

Super Mario World RAM에서 각 자료는 특정 주소에 저장돼요. 동네 같아요. 각 주소가 정보를 담은 "집" 하나예요. 예를 들면요.

주소 자료
0x94-0x95 Mario X 위치 (16비트, 리틀엔디언)
0x96-0x97 Mario Y 위치
0x14C8+i 스프라이트 i 상태 (>7이면 살아 있음)
0xE4+i 스프라이트 i 하위 X 위치
0x14E0+i 스프라이트 i 상위 X 위치
0xD8+i 스프라이트 i 하위 Y 위치
0x14D4+i 스프라이트 i 상위 Y 위치
0x170B+i 확장 스프라이트 i 종류
0x0100 게임 상태 (12이면 레벨 끝)
0x13D4 일시정지 활성
0x0071 Mario 죽음 애니메이션 (9이면 사망)
0x1C800+... 레벨 타일 표

스프라이트 위치는 2바이트를 써요. 하위 바이트와 상위 바이트. 위치가 255픽셀을 넘을 수 있으니까요. 공식은 항상 하위 + 상위 × 256.

타일은 더 복잡해요. 베이스 주소 0x1C800, 월드에서 타일의 x와 y 좌표로 오프셋을 계산하고, 타일당 16픽셀 간격이에요.

스프라이트 메모리 주소와 Mario 위치를 보여주는 디버그 오버레이가 있는 Super Mario World


기초: 유전 알고리즘과 신경망

코드에 들어가기 전에 두 가지 기본 개념을 이해해야 해요. 없으면 나머지가 의미가 없어요.

유전 알고리즘

유전 알고리즘이란 진화론의 시뮬레이션이에요. 핵심 생각: 서로 조금씩 다른 특성("유전자")을 가진 개체들의 집단을 만들어요. 환경에서 "살게" 해요. 잘 해내는 것들이 살아남고 번식해요. 못 해내는 것들은 사라져요.

Laupok은 Kirby 비유로 보여줘요.

  • 가시와 토마토가 있는 지형에 Kirby 집단이 나와요
  • 가시는 체력을 깎고 토마토는 채워줘요
  • 각 Kirby에는 유전자가 있어요. 크기, 속도, 체력, 행동(도망, 토마토 찾기, 아무 데나 돌진)

"아기", "크기", "속도", "색" 라벨이 있는 이중 나선 DNA. 개체를 이루는 유전자

  • 15초 뒤 누가 가장 오래 살아남았는지 봐요
  • 최고 Kirby가 다른 것들과 번식해요. 아기들은 최고의 절반과 "최악"의 절반 유전자를 물려받아요
  • 아기들은 무작위 변이를 겪어요 (조금 더 크고, 조금 더 빠르고...)
  • 옛 Kirby들은 새들로 교체돼요
  • 다시 시작해요

180세대(~15시간) 뒤 Kirby들은 생존 15초에서 15분으로 가요. 작아지고(충돌 범위 축소), 빨라지고, 끊임없이 위험을 피해요.

Kirby 시뮬레이션 0세대: 검은 바탕에 무작위로 흩어진 색 원들, 크기가 비슷해요

Kirby 시뮬레이션 1866세대: Kirby들이 더 작고 빠르고 체계적으로 위험을 피해요

Kirby 시뮬레이션 통계: 성능순으로 정렬된 각 개체의 적합도와 체력, 행동

핵심 지점: 해법을 정하지 않아요. 알고리즘이 혼자 찾아요. 최적의 매개변수 조합이 무엇인지 모르는 문제에 강력해지는 이유가 바로 그거예요.

인공 신경망

신경망은 인간 뇌의 단순 수학 모델이에요. 구성은요.

  • 입력 뉴런 (inputs): 네트워크가 "보는" 것
  • 출력 뉴런 (outputs): 네트워크가 "정하는" 것
  • 연결 (가중치): 각 연결에는 신호를 키우거나 줄이는 가중치가 있어요

원리는 단순해요. 각 입력 뉴런이 값을 보내요. 연결 가중치가 곱해지고 다른 신호들에 더해져요. 결과가 일정 임계값(활성화 함수)을 넘으면 출력 뉴런이 켜져요.

Mario와 쥐 끝 비유에서요.

  • 입력 뉴런 = Mario와 끝 사이 거리
  • 연결 가중치 = Mario 민감도
  • 출력 뉴런 = Mario가 소리를 지르거나 말거나

끝이 가까울수록 입력값이 커져요. 가중치가 강하면 출력 신호가 강하고, Mario가 소리를 질러요. 가중치를 바꾸면 Mario 민감도가 바뀌어요.

Mario가 Boo 앞에서 시냅스 바가 입력과 출력 사이 연결 가중치를 보여주는 "Mario가 무서워요" 데모

AI의 진짜 신경망에서는 같은 논리지만 규모가 달라요.

  • 입력 뉴런 99개 (Mario 시야 11×9 타일)
  • 출력 뉴런 8개 (A, B, X, Y, 위, 아래, 왼쪽, 오른쪽)
  • 숨은 뉴런 (hidden) 사이에
  • 가중치가 제각각인 수백 개 연결

NEAT: 모든 것을 바꾸는 알고리즘

고전 유전 알고리즘의 문제

유전 알고리즘과 신경망을 순진하게 합치면 문제가 있어요. 완전히 다른 신경망 100개를 만들고 비교할 방법을 몰라요. 각자 자기 뉴런과 연결, 가중치가 있어요. 두 네트워크가 "가까운지" "먼지" 어떻게 알까요?

거기에 NEAT가 와요. NeuroEvolution of Augmenting Topologies. Kenneth Stanley와 Risto Miikkulainen이 2002년에 발명했고, 바로 이 문제를 풀어요.

종

NEAT의 첫 핵심 구조는 종이에요. 신경망이 다른 것과 너무 달라지면 다른 종으로 분류돼요. 유사도는 세 매개변수로 계산돼요.

  1. Excess (EXCES_COEF = 0.50): 두 네트워크 사이 아무 관계 없는 연결 수 (다른 혁신)
  2. Disjoint: 마찬가지지만 중간에 있는 연결에 대해
  3. 가중치 차이 (POIDSDIFF_COEF = 0.92): 같은 혁신을 공유하는 연결 사이 평균 가중치 차이

점수 공식:

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

이 점수가 DIFF_LIMITE(1.0)보다 작으면 두 네트워크는 같은 종이에요. 아니면 새 종을 만들어요.

혁신

NEAT의 천재성이에요. 연결이 만들어질 때마다 고유하고 전역적인 혁신 번호를 받아요. 이 번호는 번식해도 신경망을 따라다녀요.

구체적으로 아기가 교차로 만들어지면 부모의 혁신을 물려받아요. 두 네트워크가 같은 혁신을 공유하면 같은 조상에서 온 연결이 있다는 뜻이에요. 크기가 다른 네트워크를 비교할 수 있게 하는 것이 바로 이거예요.

교차

두 신경망이 번식하면 교차가 이렇게 동작해요.

"CROSSOVER" 오버레이 글자와 함께 교차 개념을 설명하는 Laupok

  1. 성능이 좋은 네트워크가 "우성 부모"가 돼요
  2. 아기는 우성의 모든 연결을 물려받아요
  3. 같은 혁신을 공유하는 각 연결마다 다른 부모가 대신할 수 있어요 (50퍼센트 확률)
  4. 비우성 부모의 활성 연결만이 대신할 수 있어요

이렇게 하면 아기는 항상 최고 부모만큼은 좋아요.

변이

교차 뒤 아기는 설정 가능한 확률로 변이를 겪어요.

 "(petite modif = mutation)" 오버레이 글자와 함께 변이를 설명하는 Laupok

변이 확률 효과
연결 가중치 리셋 25% 가중치가 완전히 무작위돼요
가중치 변이 95% 가중치가 앞뒤 0.80씩 변해요
연결 추가 85% 아직 연결되지 않은 두 뉴런 사이 새 연결
뉴런 추가 39% 연결된 두 뉴런 사이에 숨은 뉴런 삽입

뉴런 추가율이 중요해요. 네트워크가 자라게 하는 것이 바로 이거예요. 처음에는 입력과 출력뿐이에요. 서서히 숨은 뉴런들이 나타나고 네트워크가 점점 복잡해져요.


코드: 전체 따라가기

상수

스크립트는 모든 매개변수를 정하는 상수 블록으로 시작해요.

-- Mario 주변 시야
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 너비 176픽셀
TAILLE_VUE_H = TAILLE_TILE * 9   -- 높이 144픽셀
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11타일
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9타일

-- 신경망
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99개 입력 (보이는 타일)
NB_OUTPUT = 8  -- A, B, X, Y, Haut, Bas, Gauche, Droite
NB_INDIVIDU_POPULATION = 100  -- 집단당 개체
NB_NEURONE_MAX = 100000  -- 숨은 뉴런 한도

-- 적합도
FITNESS_LEVEL_FINI = 1000000  -- 레벨을 마치면 값
NB_FRAME_RESET_BASE = 33  -- 진전 없이 리셋까지 프레임
NB_FRAME_RESET_PROGRES = 300  -- 진전이 감지되면 프레임

-- 종
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- 변이
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT이 99인 것은 Mario 시야가 11×9 타일이라서예요. 각 타일이 입력 뉴런 하나예요. 타일이 비었으면 0. 블록이면 1. 적이면 -1.

8개 출력은 SNES 패드 버튼에 해당해요. A, B, X, Y, 위, 아래, 왼쪽, 오른쪽. 결정을 핵심으로 한정하려고 Start, Select, L, R은 빼요.

자료구조

스크립트는 세 가지 주요 구조를 정해요.

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- 현재 뉴런 값
    neurone.id = 0        -- 고유 식별자
    neurone.type = ""     -- "input", "output", 또는 "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- 원천 뉴런 ID
    connexion.sortie = 0     -- 목적 뉴런 ID
    connexion.actif = true   -- 숨은 뉴런이 삽입되면 비활성화될 수 있어요
    connexion.poids = 0      -- 연결 가중치
    connexion.innovation = 0 -- 고유 혁신 번호
    connexion.allume = false -- 표시용: 신호가 지나가면 true
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- 숨은 뉴런 수
        fitness = 1,          -- 성능 (간 거리)
        idEspeceParent = 0,   -- 어느 종에 속해요
        lesNeurones = {},     -- 뉴런 표
        lesConnexions = {}    -- 연결 표
    }
    -- 입력으로 초기화
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- 다음 출력
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

처음에 각 네트워크는 입력과 출력만 있어요. 숨은 뉴런도 연결도 없어요. 필요한지 정하는 것이 알고리즘이에요.

변이 자세히

가중치 변이

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : 가중치 완전 리셋
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : 앞뒤 0.80씩 변동
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

초기 가중치는 항상 1 또는 -1(genererPoids()). 앞뒤 0.80 변동이 음수와 양수 사이를 오가게 해서 네트워크 행동을 근본적으로 바꿔요.

연결 추가

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- 뉴런 목록 무작위화
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- 유효 연결: input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- 이미 연결이 있는지 확인
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

출력을 입력에 연결할 수 없어요 (사이클이 생겨요). 이미 연결된 두 뉴런도 연결할 수 없어요. 무작위화가 매번 다른 가능성을 탐색하도록 보장해요.

뉴런 추가

가장 흥미로운 변이예요.

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- 연결 무작위화
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- 기존 연결 비활성화
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- 숨은 뉴런 만들기
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- 입력을 숨은 뉴런에 연결
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- 숨은 뉴런을 출력에 연결
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

구조: 기존 연결 하나를 가져와 비활성화하고, 중간에 숨은 뉴런을 끼워요. 원래 연결이 두 새 연결로 바뀌어요. 입력에서 숨은, 숨은에서 출력. 전선을 잘라서 스위치를 끼우는 것과 같아요.

NEAT가 "augmenting topologies"인 이유가 바로 이거예요. 네트워크가 시간과 함께 자라요. 단순하게 시작해서 필요할 때만 복잡해져요.

피드포워드

네트워크에 신호를 전파하는 함수예요.

function feedForward(unReseau)
    -- 출력 뉴런 리셋
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- 전파
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

각 활성 연결이 입력값 × 가중치를 출력 뉴런으로 보내요. 값은 누적(덧셈)돼요. allume 깃발은 네트워크 시각 표시용일 뿐이에요.

게임 메모리 읽기

getLesInputs() 함수는 Super Mario World 세상을 네트워크가 이해하는 자료로 바꿔요.

function getLesInputs()
    local lesInputs = {}
    -- 0으로 초기화 (회색 = 없음)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- 스프라이트(적) = -1 (검정)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- 타일(블록) = 타일 값 (0보다 크면 흰색)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

입력 격자는 Mario 중심 시야예요. 너비 11타일, 높이 9타일. 각 타일 값은요.

  • 0 (회색): 없음
  • 1 (흰색): 고체 블록
  • -1 (검정): 적

적은 RAM의 두 목록에서 읽어요. 일반 스프라이트(0x14C8-0x14F8)와 확장 스프라이트(0x170B-0x173B). 살아 있는 각 스프라이트(상태 > 7)마다 Mario 기준 타일 위치를 계산하고 해당 칸에 -1을 넣어요.

적합도: AI가 진전하는지 아는 방법

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- 레벨 끝!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario가 오른쪽으로 가요
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- 입력 고치기
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

적합도는 단순해요. 오른쪽으로 간 거리예요. Mario가 10픽셀 가면 적합도가 10 올라가요. 뒤로 가면 아무 일도 없어요 (벌점 없어요). 레벨이 끝나면(주소 0x0100 == 12) 적합도가 1,000,000이 돼요.

일부러 단순해요. 오른쪽으로 가는 진전만이 중요해요. 적을 죽여도 아무것도 없고 죽어도 잃는 것 없어요.

영리한 리셋

Mario가 33프레임 동안 움직이지 않으면 레벨을 리셋하고 다음 개체로 넘어가요. 하지만 Mario가 진전했으면(현재 적합도가 시작 때와 다르면) 300프레임 기다려요. 네트워크가 잘한 것을 "이해"할 기회를 주는 거예요.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

memory.readbyte(0x0071) ~= 9 조건은 Mario가 죽음 애니메이션 중이 아닌지 확인해요. Mario가 이미 죽음 애니메이션 중이면 리셋이 소용없어져요.

주 루프

루프는 30fps(Super Mario World 정상 속도)로 돌아요.

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- 표시 (네트워크, 정보)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- 빠르게
    else
        emu.limitframerate(true)   -- 30fps
    end

    -- 3개 생명 함수
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- 진전 없으면 리셋
    -- ...
    -- 모든 개체를 테스트하면 새 세대
    -- ...
end

세 생명 함수는 majReseau, feedForward, appliquerLesBoutons예요. 하나만 꺼도 Mario가 더 이상 움직이지 않아요.

교차

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

아기는 좋은 부모를 물려받아요. 같은 혁신을 공유하는 각 연결마다 다른 부모가 50퍼센트 확률로 대신해요. 하지만 연결이 활성일 때만이요. 중요한 수정이에요. 이것이 없으면 쓸모없는 숨은 뉴런이 만들어질 수 있었어요.

종별 선택

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- 종별 평균 적합도 계산
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- 각 종은 평균 적합도에 비례해 아이를 만들어요
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

생각: 평균 적합도 10,000인 종은 평균 1인 종보다 훨씬 많은 아이를 만들 권리가 있어요. 동작 중인 자연 선택이에요.

choisirParent는 룰렛 선택을 써요. 적합도가 높은 개체일수록 부모로 뽑힐 확률이 높아요.

저장과 불러오기

집단은 .pop 파일에 저장돼요.

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

저장에는 이전 모든 집단 중 최고 개체도 포함돼요. 옛 집단 최고가 새 집단 최고보다 좋으면 옛 것을 토대로 다시 시작해요. 일종의 엘리트주의예요. 최고를 절대 잃지 않아요.

네트워크 그리기

Laupok은 게임 위에 신경망 시각화를 더했어요.

function dessinerUnReseau(unReseau)
    -- 입력: Mario 주변 11×9 격자
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- 적
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- 블록
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- 출력: 8개 버튼
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- 연결
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

네트워크가 무엇을 하는지 이해하는 데 아주 유용해요. 활성 연결은 흰색, 비활성은 반투명. 입력은 흰색과 검정, 회색 칸 격자. 출력은 어떤 버튼이 눌렸는지 보여줘요.


결과

AI가 배운 것

실행 몇 시간(그리고 며칠)이 지나면서 AI는 혼자 발견했어요.

  1. 오른쪽으로 가기: 가장 기본 행동이지만 오른쪽 버튼을 누르고 있어야 해요
  2. 적 위로 점프하기: "적 감지" 입력을 A나 B 버튼에 연결해서
  3. 장애물 피하기: 어떤 네트워크는 더 잘 가려고 잠시 뒤로 가는 법을 배웠어요
  4. 레벨 끝내기: 최고 개체가 Super Mario World 첫 레벨을 마칠 수 있었어요

Super Mario World 레벨에서 Boo 앞의 AI 조종 Mario. 신경망이 실시간으로 행동을 정해요

한계

프로젝트의 한계예요.

  • 단일 레벨: AI는 특정 레벨 하나로 훈련돼요. 다른 레벨로 자동으로 일반화하지 않아요
  • 훈련 시간: 만족스러운 결과에 수십 시간이 필요해요
  • 이해 없음: AI는 무엇을 하는지 "이해"하지 않아요. 무작위 변이로 적합도 함수(간 거리)를 최적화해요
  • T-배깅: Mario가 적을 보면 제자리 점프하는 경향이 있다고 Laupok은 적어요. 점프하면서 조금 가서 적합도가 올라가니까요

실험 재현 방법

Laupok이 전부 공유했어요. 단계는 이렇습니다.

  1. tasvideos.org에서 BizHawk 다운로드 (Download 섹션)
  2. Super Mario World USA 롬 구하기 (자기 카트리지의 사적 복사)
  3. Pastebin에서 Lua 스크립트 다운로드. mario.lua로 이름 바꾸기
  4. 롬과 같은 폴더에 스크립트 두기
  5. BizHawk 실행, 롬 열기
  6. Lua 콘솔에서: dofile("mario.lua") 또는 메뉴 Script > Open Script
  7. 레벨 시작 지점에 세이브 스테이트 저장 (메뉴 Savestate > Save State). debut.state라고 이름 짓기
  8. 스크립트 다시 실행. 돼요

스크립트에 옵션 폼이 들어 있어요.

  • 가속: 30fps 한도를 꺼서 더 빠르게
  • 네트워크 표시: 신경망을 오버레이로 보여줘요
  • 정보 표시: 세대와 적합도, 종 수를 담은 배너를 보여줘요
  • 일시정지: 실행을 멈춰요
  • 저장과 불러오기: 현재 집단을 .pop 파일에 저장해요

출처와 참고

자료 링크
Laupok 주 영상 j'ai créé une IA qui joue à mario toute seule
코드 리뷰와 설정 영상 comment setup l'ia + code source review
완전한 소스 코드 Pastebin Jcvdqhqm
NEAT 원본 논문 Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
N8Programs 튜토리얼 NEAT implementation walkthrough (JavaScript지만 개념은 같아요)
16blings (Laupok 영감) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Super Mario World 메모리 SMW Central - RAM Map

결론

Laupok이 한 것은 학술 알고리즘(NEAT, 2002년)을 가져와 에뮬레이터(BizHawk)용 Lua로 다시 쓰고 Super Mario World에 적용한 거예요. 결과: 사전 지식 없이 0에서 게임하는 법을 배우는 AI. 무작위 변이와 자연 선택만으로요.

유전 알고리즘의 힘을 보여주는 아름다운 예예요. 레시피는 세 재료예요. 자연 선택, Lua 조금, 인내 많이. 딥러닝도 없고, GPU도 없고, 대규모 훈련 자료도 없어요.

코드는 주석이 있고, 공유되고, Laupok이 설명 영상 둘을 만들었어요. 큰 개념용 하나, 코드용 하나. 주제에 관심 있으면 뛰어들어 보세요. 보기보다 쉬워요.

Laupok kendi kendine Super Mario World oynayan bir yapay zekâ yarattı, nasıl çalışıyor

Laupok kendi kendine Super Mario World oynayan bir yapay zekâ yarattı, nasıl çalışıyor

Laupok Super Mario World oyununu tamamen özerk oynayan bir yapay zekâ yarattı. Sıfırdan başlar: önceden tanımlı script yok, kaydedilmiş kare yok. Yapay zekâ rastgele mutasyonlar ve doğal seçilim darbeleriyle oyunun seviyelerini bitirmeyi kendi kendine öğrenir. Proje çok platformlu bir emülatör olan BizHawk üzerinde yaklaşık 4200 satırlık bir Lua scriptiyle döner.

Bu projeyi büyüleyici yapan şey biyolojik kavramların bilişime uygulanmasına dayanmasıdır: Darwinin evrim teorisi, yapay sinir ağları ve hepsinden önemlisi NEAT (NeuroEvolution of Augmenting Topologies) adlı belirli bir algoritma. Yapay zekâ başlangıçta oyun hakkında hiçbir şey bilmez. Rastgele aksiyonları test eder, binlerce kez başarısız olur ve yavaş yavaş ilerlemeyi, zıplamayı ve hayatta kalmayı anlar.

Bu makalede bütün bunları didikleyeceğiz: kavram kavram, kod satırı kod satırı.

Laupok kameranın önünde NEAT algoritmasını tanıtıyor


Kurulum: BizHawk, Lua ve Super Mario World

BizHawk emülatörü

BizHawk bir sürü konsolu destekleyen açık kaynak bir emülatördür: NES, SNES, Genesis, PS1, Game Boy ve daha niceleri. Özelliği oyunla paralel Lua scriptleri çalıştırmaya izin vermesidir. Bu scriptler emülasyonun RAM belleğine erişir, yani oyunun herhangi bir verisini gerçek zamanlı okuyabilir ve değiştirebilir.

Somut olarak bu şu demektir:

  • Seviyede Marionun konumu okunabilir
  • Ekranda hangi spriteların (düşmanlar, eşyalar) olduğu bilinebilir
  • Mario etrafındaki her karenin (blok) durumu bilinebilir
  • Gamepad kontrol edilebilir: herhangi bir düğmeye basılabilir

Bir yapay zekâyı oynatmak için gereken tam olarak budur.

Super Mario World bellek adresleri

Super Mario World RAMinde her veri belirli bir adreste saklanır. Biraz mahalle gibidir: her adres bir bilgi içeren bir "eve" karşılık gelir. Örneğin:

Adres Veri
0x94-0x95 Marionun X konumu (16 bit, little-endian)
0x96-0x97 Marionun Y konumu
0x14C8+i i spritının durumu (>7 = canlı)
0xE4+i i spritının düşük X konumu
0x14E0+i i spritının yüksek X konumu
0xD8+i i spritının düşük Y konumu
0x14D4+i i spritının yüksek Y konumu
0x170B+i i genişletilmiş spritının türü
0x0100 Oyun durumu (12 = seviye bitti)
0x13D4 Duraklatma aktif
0x0071 Marionun ölüm animasyonu (9 = ölü)
0x1C800+... Seviyenin kare tablosu

Spriteların konumu iki bayt kullanır: bir "düşük" bayt ve bir "yüksek" bayt, çünkü konum 255 pikseli aşabilir. Formül hep düşük + yüksek × 256 şeklindedir.

Kareler için daha karmaşıktır: taban adres 0x1C800dür ve ofset dünyada karenin x ve y koordinatlarına göre, kare başına 16 piksel adımla hesaplanır.

Sprit bellek adreslerini ve Marionun konumunu gösteren hata ayıklama katmanlı Super Mario World


Temeller: genetik algoritmalar ve sinir ağları

Koda dalmadan önce iki temel kavramı anlamak gerekir. Onlar olmadan gerisi anlamsızdır.

Genetik algoritmalar

Bir genetik algoritma evrim teorisinin bir simülasyonudur. Merkez fikir: her biri biraz farklı özellikli ("gen") popülasyon yaratılır. Bir ortamda "yaşatılır". En iyi idare edenler hayatta kalır ve ürer. Kötü idare edenler kaybolur.

Laupok bunu bir Kirby benzetmesiyle gösterir:

  • Dikenler ve domateslerle bir arazide bir Kirby popülasyonu belirir
  • Dikenler can puanı götürür, domatesler geri verir
  • Her Kirbynin genleri vardır: boyut, hız, can puanı, davranış (kaçmak, domates aramak, her yere dalmak)

"bebek", "boyut", "hız", "renk" etiketli çift sarmal DNA: bir bireyi oluşturan genler

  • 15 saniye sonra kimin en uzun hayatta kaldığına bakılır
  • En iyi Kirby diğerleriyle ürer: bebekler en iyinin genlerinin yarısını ve "en kötünün" yarısını kalıtır
  • Bebekler rastgele mutasyonlara uğrar (biraz daha büyük, biraz daha hızlı...)
  • Eski Kirbyler yenileriyle değiştirilir
  • Yeniden başlatılır

180 nesil (~15 saat) sonra Kirbyler 15 saniyelik hayatta kalmadan 15 dakikaya geçer. Küçüldüler (azalmış çarpışma bölgesi), hızlandılar ve tehlikeden sürekli kaçarlar.

Simülasyon Kirby nesil 0: siyah fonda rastgele dağılmış renkli daireler, hepsi benzer boyutta

Simülasyon Kirby nesil 1866: Kirbyler daha küçük, daha hızlı ve tehlikelerden sistematik kaçar

Kirby simülasyonu istatistikleri: performansa göre sınıflanmış her bireyin fitness, can puanı sayısı, davranışı

Kritik nokta: çözümü tanımlamayız. Algoritma onu kendi kendine bulur. Ve onu hangi parametre kombinasyonunun optimal olacağını bilmediğimiz problemler için güçlü yapan tam olarak budur.

Yapay sinir ağları

Bir sinir ağı insan beyninin basitleştirilmiş matematiksel bir modelidir. Şunlardan oluşur:

  • Girdi nöronları (inputs): ağın "gördüğü"
  • Çıktı nöronları (outputs): ağın "karar verdiği"
  • Bağlantılar (ağırlıklar): her bağlantının sinyali güçlendiren ya da zayıflatan bir ağırlığı vardır

İlke basittir: her girdi nöronu değerini gönderir. Bağlantının ağırlığıyla çarpılır, sonra diğer sinyallerle toplanır. Sonuç belli bir eşiği aşarsa (aktivasyon fonksiyonu) çıktı nöronu etkinleşir.

Mario ve farenin ucuyla Laupok benzetmesinde:

  • Girdi nöronu = Mario ile uç arasındaki mesafe
  • Bağlantının ağırlığı = Marionun hassasiyeti
  • Çıktı nöronu = Mario bağırır ya da bağırmaz

Uç ne kadar yakınsa girdi değeri o kadar yüksek olur. Ağırlık güçlüyse çıktıya gönderilen sinyal güçlüdür ve Mario bağırırdı. Ağırlığı değiştirerek Marionun hassasiyeti değiştirilir.

"Mario korkuyor" demosu: sinaps çubuğuyla girdi ve çıktı arasındaki bağlantının ağırlığını gösteren bir Boo karşısında Mario

Yapay zekânın gerçek sinir ağında aynı mantıktır ama devasa ölçekte:

  • 99 girdi nöronu (Marionun görüşünün 11×9 karesi)
  • 8 çıktı nöronu (A, B, X, Y, Yukarı, Aşağı, Sol, Sağ)
  • Arada gizli nöronlar (hidden)
  • Çeşitli ağırlıklı yüzlerce bağlantı

NEAT: her şeyi değiştiren algoritma

Klasik genetik algoritmaların sorunu

Bir genetik algoritmayı naif şekilde bir sinir ağıyla birleştirirsek bir sorunumuz olur: tamamen farklı 100 sinir ağı yaratırız ve onları karşılaştırmayı bilmeyiz. Her birinin kendi nöronları, kendi bağlantıları, kendi ağırlıkları vardır. İki ağın "yakın" mı "uzak" mı olduğu nereden bilinir?

NEAT: NeuroEvolution of Augmenting Topologies devreye girdiği yer burasıdır. 2002de Kenneth Stanley ve Risto Miikkulainen tarafından icat edilmiş, tam bu sorunu çözen bir algoritmadır.

Türler

NEATin ilk kilit mekanizması türlerdir. Bir sinir ağı diğerinden çok farklılaştığında farklı bir türe sınıflanır. Benzerlik üç parametreyle hesaplanır:

  1. Excess (EXCES_COEF = 0.50): iki ağ arasında hiç ilişkisi olmayan bağlantıların sayısı (farklı inovasyonlar)
  2. Disjoint: aynısı ama ortada yer alan bağlantılar için
  3. Ağırlık farkı (POIDSDIFF_COEF = 0.92): aynı inovasyonu paylaşan bağlantılar arasındaki ortalama ağırlık farkı

Skor formülü:

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

Bu skor DIFF_LIMITE (1.0) değerinden düşükse iki ağ aynı türdedir. Yoksa yeni bir tür oluşturulur.

İnovasyonlar

NEATin dehası budur. Bir bağlantı her oluşturulduğunda benzersiz ve global bir inovasyon numarası alır. Bu numara sinir ağını ürediğinde bile izler.

Somut olarak çaprazlamayla bir bebek yaratıldığında ebeveynlerinin inovasyonlarını kalıtır. İki ağ aynı inovasyonu paylaşıyorsa aynı atadan gelen bir bağlantıları olduğu demektir. Farklı boyutlardaki ağları karşılaştırmaya izin veren budur.

Çaprazlama

İki sinir ağı ürediğinde çaprazlama şöyle çalışır:

Laupok "CROSSOVER" metni bindirmeli çaprazlama kavramını açıklıyor

  1. En performanslı ağ "baskın ebeveyn" olur
  2. Bebek baskının tüm bağlantılarını kalıtır
  3. Aynı inovasyonu paylaşan her bağlantı için diğer ebeveyn onun yerine geçebilir (%50 şans)
  4. Yalnızca baskın olmayanın aktif bağlantıları yer değiştirebilir

Bu bebeğin hep en iyi ebeveyn kadar iyi olmasını garanti eder.

Mutasyonlar

Çaprazlamadan sonra bebek yapılandırılabilir olasılıklarla mutasyonlara uğrar:

Laupok "(küçük değişiklik = mutasyon)" metni bindirmeli mutasyonları açıklıyor

Mutasyon Olasılık Etki
Bağlantı ağırlığı sıfırlama %25 Ağırlık tamamen rastgelelenir
Ağırlık mutasyonu %95 Ağırlık ±0,80 değişir
Bağlantı ekleme %85 Henüz bağlı olmayan iki nöron arasında yeni bağlantı
Nöron ekleme %39 Bağlı iki nöron arasına gizli bir nöron yerleştirilir

Nöron ekleme hızı önemlidir: ağın büyümesine izin veren budur. Başlangıçta yalnızca girdiler ve çıktılar vardır. Yavaş yavaş gizli nöronlar belirir, ağı gittikçe karmaşıklaştırır.


Kod: eksiksiz izlenecek yol

Sabitler

Script her şeyi tanımlayan bir sabit bloğuyla başlar:

-- Marionun etrafındaki görüşü
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- genişlikte 176 piksel
TAILLE_VUE_H = TAILLE_TILE * 9   -- yükseklikte 144 piksel
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 kare
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 kare

-- Sinir ağı
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 girdi (görünür kareler)
NB_OUTPUT = 8  -- A, B, X, Y, Yukarı, Aşağı, Sol, Sağ
NB_INDIVIDU_POPULATION = 100  -- popülasyon başına birey
NB_NEURONE_MAX = 100000  -- gizli nöron limiti

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- seviye bitince değer
NB_FRAME_RESET_BASE = 33  -- ilerlemesiz sıfırlamadan önce kareler
NB_FRAME_RESET_PROGRES = 300  -- ilerleme tespit edilirse kareler

-- Türler
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Mutasyonlar
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT 99dur çünkü Marionun görüşü 11×9 karedir. Her kare bir girdi nöronudur. Kare boşsa 0 değerindedir. Blურვasa 1 değerindedir. Düşmansa -1 değerindedir.

8 çıktı SNES gamepad düğmelerine karşılık gelir: A, B, X, Y, Yukarı, Aşağı, Sol, Sağ. Kararlarını özle sınırlamak için Start, Select, L ve R hariç tutulur.

Veri yapıları

Script üç ana yapı tanımlar:

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- nöronun mevcut değeri
    neurone.id = 0        -- benzersiz tanımlayıcı
    neurone.type = ""     -- "input", "output" ya da "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- kaynak nöronun IDsi
    connexion.sortie = 0     -- hedef nöronun IDsi
    connexion.actif = true   -- gizli bir nöron yerleştirilirse devre dışı kalabilir
    connexion.poids = 0      -- bağlantının ağırlığı
    connexion.innovation = 0 -- benzersiz inovasyon numarası
    connexion.allume = false -- görüntü için: sinyal geçerse true
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- gizli nöron sayısı
        fitness = 1,          -- performans (kat edilen mesafe)
        idEspeceParent = 0,   -- hangi türe ait
        lesNeurones = {},     -- nöron dizisi
        lesConnexions = {}    -- bağlantı dizisi
    }
    -- Girdilerle başlatma
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Sonra çıktılar
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

Başlangıçta her ağ yalnızca girdiler ve çıktılar içerir: gizli nöron yok, bağlantı yok. Gerekip gerekmediğine algoritma karar verecektir.

Mutasyonlar ayrıntılı

Ağırlık mutasyonu

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : ağırlığın toplam sıfırlanması
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : ±0.80 değişim
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

Başlangıç ağırlığı hep 1 ya da -1dir (genererPoids()). ±0,80 değişim onu negatif ve pozitif değerler arasında salındırabilir, bu da ağın davranışını kökten değiştirir.

Bağlantı ekleme

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Nöron listesinin rastgelelenmesi
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Geçerli bağlantı: input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Zaten bir bağlantı olmadığını doğrula
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

Bir output bir inputa bağlanamaz (döngü yaratırdı) ve zaten bağlı iki nöron bağlanamaz. Rastgeleleme her seferinde farklı olasılıkları keşfetmeyi garanti eder.

Nöron ekleme

En ilginç mutasyon budur:

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Bağlantıların rastgelelenmesi
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Mevcut bağlantıyı devre dışı bırak
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Gizli nöronu oluştur
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Girdiyi gizli nörona bağla
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Gizli nöronu çıktıya bağla
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

Mekanizma: mevcut bir bağlantı alınır, devre dışı bırakılır ve ortaya gizli bir nöron yerleştirilir. Orijinal bağlantı iki yeni bağlantıyla değiştirilir: girdi→gizli ve gizli→çıktı. Bu araya bir anahtar sokmak için bir teli "kesmek" gibidir.

NEATi "augmenting topologies" yapan budur: ağ zamanla büyür. Basit başlar ve yalnızca gerekirse karmaşıklaşır.

feedForward

Sinyalleri ağda yayan fonksiyondur:

function feedForward(unReseau)
    -- Çıktı nöronlarının sıfırlanması
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Yayılım
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Her aktif bağlantı girdi_değeri × ağırlık değerini çıktı nöronuna gönderir. Değer birikimlidir (toplanır). allume bayrağı sadece ağın görsel gösterimi içindir.

Oyun belleğinin okunması

getLesInputs() fonksiyonu Super Mario World dünyasını ağın anlayabildiği verilere çevirendir:

function getLesInputs()
    local lesInputs = {}
    -- 0a başlatma (gri = hiçbir şey)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Spritelar (düşmanlar) = -1 (siyah)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Kareler (bloklar) = karenin değeri (> 0 ise beyaz)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

Girdi ızgarası Mario merkezli bir görünümdür: genişlikte 11 kare, yükseklikte 9. Her kare değeri:

  • 0 (gri): hiçbir şey
  • 1 (beyaz): katı blok
  • -1 (siyah): düşman

Düşmanlar RAMdeki iki listeden okunur: normal spritelar (0x14C8-0x14F8) ve genişletilmiş spritelar (0x170B-0x173B). Her canlı sprite için (durum > 7) Marioya göre kare cinsinden konumu hesaplanır ve ilgili kutuya -1 konur.

Fitness: yapay zekâ ilerlediğini nereden bilir

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Seviye bitti!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario sağa ilerliyor
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Girdilerin güncellenmesi
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

Fitness basittir: sağa kat edilen mesafedir. Mario 10 piksel ilerlerse fitness 10 artar. Mario geri giderse hiçbir şey olmaz (ceza yok). Seviye biterse (adres 0x0100 == 12) fitness 1 000 000 olur.

Bu bilinçli basittir. Yalnızca sağa ilerleme sayılır: düşman öldürmek bir şey kazandırmaz, ölmek bir şeye mal olmaz.

Akıllı sıfırlama

Mario 33 kare hareket etmezse seviyeyi sıfırlar ve sonraki bireye geçilir. Ama Mario ilerleme kaydettiyse (mevcut fitnessi baştakinden farklıysa) 300 kare beklenir: bu ağa iyi yaptığı şeyi "anlama" şansı verir.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

memory.readbyte(0x0071) ~= 9 koşulu Marionun ölüm animasyonunda olmadığını doğrular. Mario zaten ölüm animasyonundayken sıfırlama gereksiz olur.

Ana döngü

Döngü 30 fps döner (Super Mario World normal hızı):

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Görüntüleme (ağ, bilgiler)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- hızlandır
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- 3 hayati fonksiyon
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- İlerleme yoksa sıfırla
    -- ...
    -- Tüm bireyler test edildiyse yeni nesil
    -- ...
end

Üç hayati fonksiyon majReseau, feedForward ve appliquerLesBoutons dur. Birini bile devre dışı bırakırsak Mario artık hareket etmez.

Çaprazlama

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

Bebek en iyi ebeveyni kalıtır. Aynı inovasyonu paylaşan her bağlantı için diğer ebeveynin onun yerine geçme şansı %50dir: ama yalnızca bağlantı aktifse. Bu önemli bir düzeltmedir: o olmadan gereksiz gizli nöronlar yaratılabilirdi.

Türlerle seçim

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Tür başına ortalama fitness hesabı
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Her tür ortalama fitnessiyle orantılı çocuk sayısı yaratır
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

Fikir: ortalama fitnessi 10 000 olan bir tür, ortalama fitnessi 1 olan bir türden çok daha fazla çocuk yaratma hakkına sahiptir. Harekete geçen doğal seçilimdir.

choisirParent rulet seçimi kullanır: bir bireyin fitnessi ne kadar yüksekse ebeveyn seçilme şansı o kadar artar.

Kaydetme ve yükleme

Popülasyonlar .pop dosyalarına kaydedilir:

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

Kaydetme tüm önceki popülasyonların en iyi bireyini de içerir. Eski popülasyonun en iyisi yeninin en iyisinden iyiyse eski temel olarak alınır. Bu bir elitizm biçimidir: en iyi asla kaybedilmez.

Ağın çizimi

Laupok oyunun üstüne sinir ağının bir görselleştiricisini ekledi:

function dessinerUnReseau(unReseau)
    -- Girdiler: Mario etrafında 11×9 ızgara
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- düşman
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- blok
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Çıktılar: 8 düğme
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Bağlantılar
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

Ağın ne yaptığını anlamak için süper kullanışlıdır. Aktif bağlantılar beyazdır, inaktifler yarı saydamdır. Girdiler beyaz/siyah/gri kutu ızgarasıdır. Çıktılar hangi düğmelerin basılı olduğunu gösterir.


Sonuçlar

Yapay zekânın öğrendikleri

Çalışmanın saatleri (ve günleri) boyunca yapay zekâ kendi kendine keşfetti:

  1. Sağa ilerlemek: en temel davranış ama Sağ düğmeyi basılı tutmayı gerektirir
  2. Düşmanların üstünden zıplamak: "düşman tespit edildi" girdisini A ya da B düğmesine bağlayarak
  3. Engellerden kaçınmak: bazı ağlar daha iyi ilerlemek için geçici geri gitmeyi öğrendi
  4. Seviyeleri bitirmek: en iyi birey Super Mario World ilk seviyesini bitirebildi

Super Mario World bir seviyede bir Boo karşısında yapay zekâca kontrol edilen Mario: sinir ağı aksiyonları gerçek zamanlı kararlaştırır

Sınırlamalar

Projenin limitleri var:

  • Tek seviye: yapay zekâ belirli tek bir seviyede eğitilir. Diğer seviyelere otomatik genellemez
  • Eğitim süresi: tatmin edici sonuçlara ulaşmak için onlarca saat gerekir
  • Anlama yok: yapay zekâ ne yaptığını "anlamaz". Rastgele mutasyonlarla bir fitness fonksiyonunu (kat edilen mesafe) optimize eder
  • T-bagging: Laupok Marionun bir düşman görünce yerinde zıplama eğiliminde olduğunu not eder, sırf bu fitnessi artırdığı için (zıplayarak biraz ilerler)

Deneyimi nasıl yeniden üretmeli

Laupok her şeyi paylaştı. İşte adımlar:

  1. BizHawkı indir tasvideos.org üzerinden (Download bölümü)
  2. Super Mario World ABD ROMu edin (kendi kartuşunun özel kopyası)
  3. Lua scriptini indir Pastebin üzerinden: mario.lua olarak yeniden adlandır
  4. Scripti ROM ile aynı klasöre yerleştir
  5. BizHawkı çalıştır, ROMu aç
  6. Lua konsolunda: dofile("mario.lua") ya da Script > Open Script menüsüyle
  7. Seviye başında bir durum kaydet (Savestate > Save State menüsü) ve debut.state olarak adlandır
  8. Scripti yeniden çalıştır: çalışır

Script seçenekli bir form içerir:

  • Hızlandır: daha hızlı gitmek için 30 fps limitini devre dışı bırakır
  • Ağı göster: sinir ağını bindirmeli gösterir
  • Bilgileri göster: nesil, fitness, tür sayısıyla bir şerit gösterir
  • Duraklat: yürütmeyi duraklatır
  • Kaydet/Yükle: mevcut popülasyonu bir .pop dosyasında saklar

Kaynaklar ve referanslar

Kaynak Bağlantı
Laupok ana videosu j'ai créé une IA qui joue à mario toute seule
Kod incelemesi + kurulum videosu comment setup l'ia + code source review
Tam kaynak kodu Pastebin Jcvdqhqm
NEAT orijinal makalesi Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
N8Programs öğreticisi NEAT implementation walkthrough (JavaScript ama kavramlar aynı)
16blings (Laupok ilhamı) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Super Mario World belleği SMW Central - RAM Map

Sonuç

Laupokun yaptığı şey akademik bir algoritmayı (NEAT, 2002) alıp bir emülatör (BizHawk) için Luada yeniden yazmak ve Super Mario Worlde uygulamaktır. Sonuç: önceden bilgi olmadan, yalnızca rastgele mutasyonlar ve doğal seçilimle oyunu oynamayı sıfırdan öğrenen bir yapay zekâ.

Bu genetik algoritmaların gücünün muhteşem bir örneğidir. Tarif üç malzemeye sığar: doğal seçilim, biraz Lua ve çok sabır. Derin öğrenme yok, GPU yok, devasa eğitim verisi yok.

Kod yorumlu, paylaşılmış ve Laupok iki açıklayıcı video yapmış: biri büyük kavramlar, biri kod için. Konu ilgini çekerse dal içine. Göründüğünden daha erişilebilir.

Laupok ha creato un'IA che gioca a Super Mario World da sola: come funziona

Laupok ha creato un'IA che gioca a Super Mario World da sola: come funziona

Laupok ha creato un'intelligenza artificiale che gioca a Super Mario World in modo totalmente autonomo. Parte da zero: nessuno script predefinito, nessun frame registrato. L'IA impara da sola, a colpi di mutazioni casuali e selezione naturale, a finire i livelli del gioco. Il progetto gira su BizHawk, un emulatore multipiattaforma, via uno script Lua di circa 4200 righe.

Ciò che rende questo progetto affascinante, è che poggia su concetti biologici applicati all'informatica: la teoria dell'evoluzione di Darwin, le reti neurali artificiali, e soprattutto un algoritmo specifico chiamato NEAT (NeuroEvolution of Augmenting Topologies). L'IA non sa niente del gioco all'inizio. Prova azioni a caso, fallisce migliaia di volte, e piano piano capisce come avanzare, saltare, e sopravvivere.

In questo articolo, smontiamo tutto questo: concetto per concetto, riga di codice per riga di codice.

Laupok introduit l'algorithme NEAT devant la caméra


Il setup: BizHawk, Lua, e Super Mario World

L'emulatore BizHawk

BizHawk è un emulatore open source che supporta una tonnellata di console: NES, SNES, Genesis, PS1, Game Boy, e molte altre. La sua particolarità, è che permette di lanciare script Lua in parallelo al gioco. Questi script hanno accesso alla memoria viva (RAM) dell'emulazione, il che significa che possono leggere, e modificare, qualsiasi dato del gioco in tempo reale.

In concreto, vuol dire che possiamo:

  • Leggere la posizione di Mario nel livello
  • Sapere quali sprite (nemici, item) sono a schermo
  • Conoscere lo stato di ogni tile (blocco) intorno a Mario
  • Controllare il pad: premere qualsiasi pulsante

È esattamente ciò di cui abbiamo bisogno per far giocare un'IA.

Gli indirizzi memoria di Super Mario World

Nella RAM di Super Mario World, ogni dato è salvato a un indirizzo specifico. È un po' come un quartiere: ogni indirizzo corrisponde a una "casa" che contiene un'informazione. Per esempio:

Indirizzo Dato
0x94-0x95 Posizione X di Mario (16 bit, little-endian)
0x96-0x97 Posizione Y di Mario
0x14C8+i Stato dello sprite i (>7 = vivo)
0xE4+i Posizione X bassa dello sprite i
0x14E0+i Posizione X alta dello sprite i
0xD8+i Posizione Y bassa dello sprite i
0x14D4+i Posizione Y alta dello sprite i
0x170B+i Tipo dell'extended sprite i
0x0100 Stato del gioco (12 = livello finito)
0x13D4 Pausa attiva
0x0071 Animazione di morte di Mario (9 = morte)
0x1C800+... Tabella delle tile del livello

La posizione degli sprite usa due byte: un byte "basso" e un byte "alto", perché la posizione può superare 255 pixel. La formula è sempre basso + alto × 256.

Per le tile, è più complesso: l'indirizzo di base è 0x1C800, e si calcola l'offset in base alle coordinate x e y della tile nel mondo, con un passo di 16 pixel per tile.

Super Mario World avec overlay de débogage montrant les adresses mémoire des sprites et la position de Mario


Le basi: algoritmi genetici e reti neurali

Prima di tuffarci nel codice, bisogna capire due concetti fondamentali. Senza, il resto non ha senso.

Gli algoritmi genetici

Un algoritmo genetico, è una simulazione della teoria dell'evoluzione. L'idea centrale: creiamo una popolazione di individui, ognuno con caratteristiche (i "geni") leggermente diverse. Li facciamo "vivere" in un ambiente. Quelli che se la cavano meglio sopravvivono e si riproducono. Quelli che se la cavano male spariscono.

Laupok lo illustra con un'analogia di Kirby:

  • Una popolazione di Kirby appare su un terreno con spuntoni e pomodori
  • Gli spuntoni tolgono punti vita, i pomodori ne ridanno
  • Ogni Kirby ha geni: taglia, velocità, punti vita, comportamento (fuggire, cercare pomodori, andare a caso)
  • Dopo 15 secondi, guardiamo chi è sopravvissuto più a lungo
  • Il miglior Kirby si riproduce con gli altri: i bebè ereditano metà dei geni del migliore e metà del "peggiore"
  • I bebè subiscono mutazioni casuali (un po' più grande, un po' più rapido...)
  • I vecchi Kirby sono sostituiti dai nuovi
  • Si rilancia

Double hélice d'ADN avec les étiquettes "le bébé", "taille", "vitesse", "couleur" -- les gènes qui composent un individu

Dopo 180 generazioni (circa 15 ore), i Kirby passano da 15 secondi di sopravvivenza a 15 minuti. Sono diventati piccoli (zona di collisione ridotta), rapidi, e fuggono il pericolo in permanenza.

Simulation Kirby génération 0 : des cercles colorés dispersés aléatoirement sur un fond noir, tous de taille similaire

Simulation Kirby génération 1866 : les Kirby sont plus petits, plus rapides, et fuient systématiquement les dangers

Statistiques de la simulation Kirby : fitness, nombre de points de vie, comportement de chaque individu classés par performance

Il punto cruciale: non definiamo la soluzione. L'algoritmo la trova da solo. Ed è esattamente ciò che lo rende potente per problemi dove non sappiamo quale combinazione di parametri sarebbe ottimale.

Le reti neurali artificiali

Una rete neurale, è un modello matematico semplificato del cervello umano. Si compone di:

  • Neuroni di ingresso (input): ciò che la rete "vede"
  • Neuroni di uscita (output): ciò che la rete "decide"
  • Connessioni (pesi): ogni connessione ha un peso che amplifica o attenua il segnale

Il principio è semplice: ogni neurone di ingresso invia il suo valore. È moltiplicato per il peso della connessione, poi sommato agli altri segnali. Se il risultato supera una certa soglia (la funzione di attivazione), il neurone di uscita si attiva.

Nell'analogia di Laupok con Mario e il pezzo di topo:

  • Il neurone di ingresso = la distanza tra Mario e il pezzo
  • Il peso della connessione = la sensibilità di Mario
  • Il neurone di uscita = Mario urla o no

Più il pezzo è vicino, più il valore di ingresso è alto. Se il peso è forte, il segnale inviato in uscita è forte, e Mario urlerebbe. Modificando il peso, modifichi la sensibilità di Mario.

Démo "Mario a peur" : Mario face à un Boo avec une barre de synapse affichant le poids de la connexion entre l'entrée et la sortie

Nella vera rete neurale dell'IA, è la stessa logica, ma su scala massiccia:

  • 99 neuroni di ingresso (11×9 tile della vista di Mario)
  • 8 neuroni di uscita (A, B, X, Y, Su, Giù, Sinistra, Destra)
  • Neuroni nascosti (hidden) tra i due
  • Centinaia di connessioni con pesi vari

NEAT: l'algoritmo che cambia tutto

Il problema degli algoritmi genetici classici

Se combiniamo un algoritmo genetico con una rete neurale in modo ingenuo, abbiamo un problema: creiamo 100 reti neurali completamente diverse, e non sappiamo confrontarle. Ognuna ha i suoi neuroni, le sue connessioni, i suoi pesi. Come sapere se due reti sono "vicine" o "lontane"?

È lì che interviene NEAT: NeuroEvolution of Augmenting Topologies. Inventato da Kenneth Stanley e Risto Miikkulainen nel 2002, è un algoritmo che risolve esattamente questo problema.

Le specie

Il primo meccanismo chiave di NEAT, sono le specie. Quando una rete neurale diventa troppo diversa da un'altra, è classificata in una specie diversa. La similarità è calcolata via tre parametri:

  1. Excess (EXCES_COEF = 0.50): il numero di connessioni che non hanno alcun rapporto tra due reti (innovazioni diverse)
  2. Disjoint: uguale, ma per le connessioni che stanno nel mezzo
  3. Differenza di peso (POIDSDIFF_COEF = 0.92): la differenza media di peso tra le connessioni che condividono la stessa innovazione

La formula di punteggio:

score = (EXCES_COEF × disjoint) / max(nbConnessioni1 + nbConnessioni2, 1)
      + POIDSDIFF_COEF × diffPesi

Se questo punteggio è sotto DIFF_LIMITE (1.0), le due reti sono nella stessa specie. Altrimenti, si crea una nuova specie.

Le innovazioni

È il genio di NEAT. Ogni volta che una connessione è creata, riceve un numero di innovazione unico e globale. Questo numero segue la rete neurale anche quando si riproduce.

In concreto, quando un bebè è creato per crossover, eredita le innovazioni dei suoi genitori. Se due reti condividono la stessa innovazione, vuol dire che hanno una connessione che viene dallo stesso antenato. È ciò che permette di confrontare reti di taglie diverse.

Il crossover

Quando due reti neurali si riproducono, il crossover funziona così:

Laupok explique le concept de crossover avec le texte "CROSSOVER" en overlay

  1. La rete più performante diventa il "genitore dominante"
  2. Il bebè eredita tutte le connessioni del dominante
  3. Per ogni connessione che condivide la stessa innovazione, l'altro genitore può sostituirla (50% di probabilità)
  4. Solo le connessioni attive del genitore non dominante possono sostituire

Garantisce che il bebè sia sempre almeno bravo quanto il miglior genitore.

Le mutazioni

Dopo il crossover, il bebè subisce mutazioni con probabilità configurabili:

Laupok explique les mutations avec le texte "(petite modif = mutation)" en overlay

Mutazione Probabilità Effetto
Reset peso connessione 25% Il peso è totalmente randomizzato
Mutazione peso 95% Il peso varia di ±0.80
Aggiunta connessione 85% Nuova connessione tra due neuroni non ancora collegati
Aggiunta neurone 39% Un neurone nascosto è inserito tra due neuroni connessi

Il tasso di aggiunta neurone è importante: è ciò che permette alla rete di crescere. All'inizio, ci sono solo ingressi e uscite. Progressivamente, neuroni nascosti appaiono, rendendo la rete sempre più complessa.


Il codice: walkthrough completo

Le costanti

Lo script inizia con un blocco di costanti che definiscono tutto il setup:

-- Vista di Mario intorno a lui (11x9 tile)
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 pixel in larghezza
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 pixel in altezza
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tile
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tile

-- Rete neurale
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 input (tile visibili)
NB_OUTPUT = 8  -- A, B, X, Y, Su, Giù, Sinistra, Destra
NB_INDIVIDU_POPULATION = 100  -- individui per popolazione
NB_NEURONE_MAX = 100000  -- limite di neuroni nascosti

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- valore quando il livello è finito
NB_FRAME_RESET_BASE = 33  -- frame senza progresso prima del reset
NB_FRAME_RESET_PROGRES = 300  -- frame se progresso rilevato

-- Specie
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Mutazioni
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

Il NB_INPUT è 99 perché la vista di Mario fa 11×9 tile. Ogni tile è un neurone di ingresso. Se la tile è vuota, vale 0. Se è un blocco, vale 1. Se è un nemico, vale -1.

Le 8 uscite corrispondono ai pulsanti del pad SNES: A, B, X, Y, Su, Giù, Sinistra, Destra. Escludiamo Start, Select, L e R per limitare le sue decisioni all'essenziale.

Le strutture dati

Lo script definisce tre strutture principali:

-- crea un neurone vuoto
function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- valore attuale del neurone
    neurone.id = 0        -- identificatore unico
    neurone.type = ""     -- "input", "output", o "hidden"
    return neurone
end

-- crea una connessione vuota
function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- ID del neurone sorgente
    connexion.sortie = 0     -- ID del neurone destinazione
    connexion.actif = true   -- può essere disattivata se un neurone nascosto è inserito
    connexion.poids = 0      -- peso della connessione
    connexion.innovation = 0 -- numero di innovazione unico
    connexion.allume = false -- per la visualizzazione: true se il segnale passa
    return connexion
end

-- crea una rete con input e output, senza connessioni
function newReseau()
    local reseau = {
        nbNeurone = 0,        -- numero di neuroni nascosti
        fitness = 1,          -- performance (distanza percorsa)
        idEspeceParent = 0,   -- a quale specie appartiene
        lesNeurones = {},     -- tabella di neuroni
        lesConnexions = {}    -- tabella di connessioni
    }
    -- Inizializzazione con gli input
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Poi gli output
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

All'inizio, ogni rete contiene solo input e output: nessun neurone nascosto, nessuna connessione. È l'algoritmo che deciderà se ne servono.

Le mutazioni in dettaglio

Mutazione dei pesi

-- muta i pesi: reset totale o variazione ±0.80
function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25%: reset totale del peso
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75%: variazione di ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

Il peso iniziale è sempre 1 o -1 (genererPoids()). La variazione di ±0.80 può farlo oscillare tra valori negativi e positivi, il che cambia radicalmente il comportamento della rete.

Aggiunta di connessione

-- aggiunge una connessione tra due neuroni non ancora collegati
function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Randomizzazione della lista dei neuroni
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Connessione valida: input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Verifica che non ci sia già una connessione
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

Non possiamo connettere un output a un input (creerebbe un ciclo), e non possiamo connettere due neuroni già collegati. La randomizzazione garantisce di esplorare possibilità diverse ogni volta.

Aggiunta di neurone

È la mutazione più interessante:

-- inserisce un neurone nascosto nel mezzo di una connessione
function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Randomizzazione delle connessioni
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Disattiva la connessione esistente
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Crea il neurone nascosto
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Connette l'ingresso al neurone nascosto
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Connette il neurone nascosto all'uscita
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

Il meccanismo: prendiamo una connessione esistente, la disattiviamo, e inseriamo un neurone nascosto in mezzo. La connessione di origine è sostituita da due nuove connessioni: ingresso→nascosto e nascosto→uscita. È come se "tagliassimo" un filo per intercalare un interruttore.

È ciò che rende NEAT "augmenting topologies": la rete cresce col tempo. Inizia semplice e diventa complessa solo se necessario.

Il feedForward

È la funzione che propaga i segnali attraverso la rete:

-- propaga i segnali ingresso→uscita sommandoli
function feedForward(unReseau)
    -- Reset dei neuroni di uscita
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Propagazione
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Ogni connessione attiva invia valore_ingresso × peso verso il neurone di uscita. Il valore è cumulato (sommato). Il flag allume è solo per la visualizzazione grafica della rete.

La lettura della memoria del gioco

La funzione getLesInputs() è quella che traduce il mondo di Super Mario World in dati comprensibili dalla rete:

-- costruisce la griglia 11x9: 0 vuoto, 1 blocco, -1 nemico
function getLesInputs()
    local lesInputs = {}
    -- Inizializzazione a 0 (grigio = niente)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Gli sprite (nemici) = -1 (nero)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Le tile (blocchi) = valore della tile (bianco se > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

La griglia di input è una vista centrata su Mario: 11 tile in larghezza, 9 in altezza. Ogni tile vale:

  • 0 (grigio): niente
  • 1 (bianco): blocco solido
  • -1 (nero): nemico

I nemici sono letti da due liste nella RAM: gli sprite normali (0x14C8-0x14F8) e gli extended sprite (0x170B-0x173B). Per ogni sprite vivo (stato > 7), calcoliamo la sua posizione in tile rispetto a Mario e mettiamo -1 nella casella corrispondente.

Il fitness: come l'IA sa se progredisce

-- aggiorna fitness con la distanza percorsa verso destra
function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Livello finito!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario avanza verso destra
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Aggiornamento degli input
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

La fitness è semplice: è la distanza percorsa verso destra. Se Mario avanza di 10 pixel, la fitness aumenta di 10. Se Mario indietreggia, non succede niente (nessuna penalità). Se il livello è finito (indirizzo 0x0100 == 12), la fitness diventa 1 000 000.

È volutamente semplice. Solo la progressione verso destra conta: uccidere nemici non rende niente, morire non costa niente.

Il reset intelligente

Se Mario non si muove per 33 frame, resettiamo il livello e passiamo all'individuo successivo. Ma se Mario ha fatto progressi (la sua fitness attuale è diversa da quella all'inizio), aspettiamo 300 frame: dà una chance alla rete di "capire" cosa ha fatto di bene.

-- reset se Mario è fermo: 33 frame, 300 se ci sono progressi
if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

La condizione memory.readbyte(0x0071) ~= 9 verifica che Mario non sia in animazione di morte. Il reset diventa inutile quando Mario è già in animazione di morte.

Il ciclo principale

Il ciclo gira a 30 fps (la velocità normale di Super Mario World):

-- ciclo principale: aggiorna, propaga, premi pulsanti
while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Visualizzazione (rete, info)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- accelerare
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- Le 3 funzioni vitali
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Reset se niente progresso
    -- ...
    -- Nuova generazione se tutti gli individui testati
    -- ...
end

Le tre funzioni vitali sono majReseau, feedForward, e appliquerLesBoutons. Se ne disattiviamo una sola, Mario non si muove più.

Il crossover

-- il bebè eredita dal migliore, l'altro può sostituire al 50%
function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

Il bebè eredita dal miglior genitore. Per ogni connessione che condivide la stessa innovazione, l'altro genitore ha il 50% di probabilità di sostituirla: ma solo se la connessione è attiva. È una correzione importante: senza, neuroni nascosti inutili potevano essere creati.

La selezione per specie

-- ogni specie crea figli in proporzione alla sua fitness media
function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Calcolo fitness media per specie
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Ogni specie crea un numero di figli proporzionale alla sua fitness media
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

L'idea: una specie con una fitness media di 10 000 ha il diritto di creare molti più figli di una specie con una fitness media di 1. È la selezione naturale in azione.

Il choisirParent usa una selezione a roulette: più un individuo ha fitness, più ha probabilità di essere selezionato come genitore.

Il salvataggio e il caricamento

Le popolazioni sono salvate in file .pop:

-- salva neuroni e connessioni in un file .pop
function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

Il salvataggio include anche il miglior individuo di tutte le popolazioni precedenti. Se il migliore della vecchia popolazione è migliore del migliore della nuova, riprendiamo il vecchio come base. È una forma di elitismo: non perdiamo mai il migliore.

Il disegno della rete

Laupok ha aggiunto un visualizzatore della rete neurale in overlay sul gioco:

-- disegna input, output e connessioni in overlay
function dessinerUnReseau(unReseau)
    -- Input: griglia 11×9 intorno a Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- nemico
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- blocco
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Output: 8 pulsanti
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Connessioni
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

È super utile per capire cosa fa la rete. Le connessioni attive sono bianche, le inattive sono semi-trasparenti. Gli input sono una griglia di caselle bianche/nere/grigie. Gli output mostrano quali pulsanti sono premuti.


I risultati

Cosa l'IA ha imparato

Col passare di ore (e giorni) di esecuzione, l'IA ha scoperto da sola:

  1. Avanzare verso destra: il comportamento più basico, ma che richiede di tenere premuto il pulsante Destra
  2. Saltare sopra i nemici: connettendo un input "nemico rilevato" al pulsante A o B
  3. Evitare gli ostacoli: certe reti hanno imparato a indietreggiare temporaneamente per avanzare meglio
  4. Finire livelli: il miglior individuo ha potuto finire il primo livello di Super Mario World

Mario contrôlé par l'IA face à un Boo dans un niveau de Super Mario World -- le réseau de neurones décide des actions en temps réel

I limiti

Il progetto ha i suoi limiti:

  • Un solo livello: l'IA è allenata su un solo livello specifico. Non generalizza automaticamente ad altri livelli
  • Tempo di allenamento: servono decine di ore per raggiungere risultati soddisfacenti
  • Niente comprensione: l'IA non "capisce" cosa fa. Ottimizza una funzione di fitness (distanza percorsa) via mutazioni casuali
  • T-bagging: Laupok nota che Mario tende a saltare sul posto quando vede un nemico, semplicemente perché aumenta la fitness (avanza un po' saltando)

Come riprodurre l'esperimento

Laupok ha condiviso tutto. Ecco i passi:

  1. Scarica BizHawk su tasvideos.org (sezione Download)
  2. Ottieni una ROM USA di Super Mario World (copia privata della tua cartuccia)
  3. Scarica lo script Lua da Pastebin: rinomina in mario.lua
  4. Metti lo script nella stessa cartella della ROM
  5. Lancia BizHawk, apri la ROM
  6. Nella console Lua: dofile("mario.lua") o via il menu Script > Open Script
  7. Salva uno state all'inizio del livello (menu Savestate > Save State) e chiamalo debut.state
  8. Rilancia lo script: funziona

Lo script include un form con opzioni:

  • Accelerare: disattiva il limite di 30 fps per andare più in fretta
  • Mostra rete: mostra la rete neurale in overlay
  • Mostra info: mostra un banner con generazione, fitness, numero di specie
  • Pausa: mette in pausa l'esecuzione
  • Salva/Carica: persiste la popolazione attuale in un file .pop

Fonti e riferimenti

Risorsa Link
Video principale di Laupok ho creato un'IA che gioca a mario da sola
Video code review + setup come setuppare l'ia + code source review
Codice sorgente completo Pastebin Jcvdqhqm
Articolo originale di NEAT Stanley e Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
Tutorial di N8Programs NEAT implementation walkthrough (JavaScript, ma i concetti sono identici)
16blings (ispirazione di Laupok) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Memoria di Super Mario World SMW Central - RAM Map

Conclusione

Ciò che Laupok ha fatto, è prendere un algoritmo accademico (NEAT, 2002), riscriverlo in Lua per un emulatore (BizHawk), e applicarlo a Super Mario World. Il risultato: un'IA che impara da zero a giocare al gioco, senza alcuna conoscenza previa, solo con mutazioni casuali e selezione naturale.

È un esempio magnifico della potenza degli algoritmi genetici. La ricetta sta in tre ingredienti: la selezione naturale, un po' di Lua, e tanta pazienza. Niente deep learning, niente GPU, niente dati di allenamento enormi.

Il codice è commentato, condiviso, e Laupok ha fatto due video esplicativi: uno per i grandi concetti, uno per il codice. Se il tema ti interessa, tuffati dentro. È più accessibile di quanto sembri.

Laupok hat eine KI gebaut, die Super Mario World allein spielt, so funktioniert es

Laupok hat eine KI gebaut, die Super Mario World allein spielt, so funktioniert es

Laupok hat eine künstliche Intelligenz gebaut, die Super Mario World völlig autonom spielt. Sie startet bei null: Kein vordefiniertes Skript, kein aufgezeichneter Frame. Die KI lernt allein, durch zufällige Mutationen und natürliche Selektion, die Spiellevel zu beenden. Das Projekt läuft auf BizHawk, einem Multi-Plattform-Emulator, via ein Lua-Skript von etwa 4200 Zeilen.

Was dieses Projekt faszinierend macht, ist, dass es auf biologischen Konzepten angewandt auf Informatik beruht: Darwins Evolutionstheorie, künstliche neuronale Netze und vor allem ein spezifischer Algorithmus namens NEAT (NeuroEvolution of Augmenting Topologies). Die KI weiß anfangs nichts über das Spiel. Sie testet zufällige Aktionen, scheitert tausendfach, und nach und nach versteht sie, wie man vorankommt, springt und überlebt.

In diesem Artikel zerlegen wir das alles, Konzept für Konzept, Codezeile für Codezeile.

Laupok stellt den NEAT-Algorithmus vor der Kamera vor


Das Setup: BizHawk, Lua und Super Mario World

Der BizHawk-Emulator

BizHawk ist ein Open-Source-Emulator, der eine Tonne Konsolen unterstützt, NES, SNES, Genesis, PS1, Game Boy und viele mehr. Seine Besonderheit ist, dass er Lua-Skripte parallel zum Spiel starten kann. Diese Skripte haben Zugriff auf den Arbeitsspeicher (RAM) der Emulation, was heißt, dass sie alle Spieldaten live lesen und modifizieren können.

Konkret heißt das, man kann:

  • Marios Position im Level lesen
  • Wissen, welche Sprites (Gegner, Items) auf dem Bildschirm sind
  • Den Zustand jeder Tile (Block) um Mario kennen
  • Den Controller steuern, jeden Button drücken

Genau das braucht man, um eine KI spielen zu lassen.

Die Speicheradressen von Super Mario World

Im RAM von Super Mario World liegt jede Dateneinheit an einer spezifischen Adresse. Ein bisschen wie ein Viertel: Jede Adresse entspricht einem "Haus", das eine Information enthält. Zum Beispiel:

Adresse Daten
0x94-0x95 Marios X-Position (16 Bit, Little-Endian)
0x96-0x97 Marios Y-Position
0x14C8+i Zustand des Sprites i (>7 = lebend)
0xE4+i Untere X-Position des Sprites i
0x14E0+i Obere X-Position des Sprites i
0xD8+i Untere Y-Position des Sprites i
0x14D4+i Obere Y-Position des Sprites i
0x170B+i Typ des Extended Sprites i
0x0100 Spielzustand (12 = Level beendet)
0x13D4 Pause aktiv
0x0071 Marios Todesanimation (9 = tot)
0x1C800+... Level-Tile-Tabelle

Die Sprite-Position nutzt zwei Bytes: Ein "unteres" und ein "oberes" Byte, weil die Position 255 Pixel übersteigen kann. Die Formel ist immer unten + oben × 256.

Für Tiles ist es komplexer: Die Basisadresse ist 0x1C800, und man berechnet den Offset je nach x- und y-Koordinaten der Tile in der Welt, mit 16 Pixeln Schritt pro Tile.

Super Mario World mit Debug-Overlay, das Sprite-Speicheradressen und Marios Position zeigt


Die Basics: Genetische Algorithmen und neuronale Netze

Bevor wir in den Code tauchen, muss man zwei fundamentale Konzepte verstehen. Ohne sie ergibt der Rest keinen Sinn.

Die genetischen Algorithmen

Ein genetischer Algorithmus ist eine Simulation der Evolutionstheorie. Die zentrale Idee: Man erschafft eine Population von Individuen, jedes mit leicht unterschiedlichen Eigenschaften ("Genen"). Man lässt sie in einer Umgebung "leben". Die, die sich am besten schlagen, überleben und pflanzen sich fort. Die, die sich schlecht schlagen, verschwinden.

Laupok illustriert das mit einer Kirby-Analogie:

  • Eine Kirby-Population erscheint auf einem Terrain mit Stacheln und Tomaten
  • Die Stacheln nehmen Lebenspunkte, die Tomaten geben welche zurück
  • Jeder Kirby hat Gene: Größe, Geschwindigkeit, Lebenspunkte, Verhalten (fliehen, Tomaten suchen, irgendwohin rennen)

Doppel-DNA-Helix mit Labels "das Baby", "Größe", "Geschwindigkeit", "Farbe", die Gene, die ein Individuum bilden

  • Nach 15 Sekunden schaut man, wer am längsten überlebt hat
  • Der beste Kirby pflanzt sich mit den anderen fort: Die Babys erben die Hälfte der Gene des Besten und die Hälfte des "Schlechtesten"
  • Die Babys erleiden zufällige Mutationen (etwas größer, etwas schneller...)
  • Die alten Kirbys werden durch die neuen ersetzt
  • Man startet neu

Nach 180 Generationen (~15 Stunden) gehen die Kirbys von 15 Sekunden Überleben auf 15 Minuten. Sie wurden klein (reduzierte Kollisionszone), schnell und fliehen permanent vor Gefahr.

Kirby-Simulation Generation 0: zufällig verstreute farbige Kreise auf schwarzem Grund, alle ähnlich groß

Kirby-Simulation Generation 1866: die Kirbys sind kleiner, schneller und fliehen systematisch vor Gefahren

Statistiken der Kirby-Simulation: Fitness, Lebenspunktezahl, Verhalten jedes Individuums sortiert nach Leistung

Der entscheidende Punkt: Man definiert die Lösung nicht. Der Algorithmus findet sie allein. Und genau das macht ihn mächtig für Probleme, bei denen man nicht weiß, welche Parameterkombination optimal wäre.

Die künstlichen neuronalen Netze

Ein neuronales Netz ist ein vereinfachtes mathematisches Modell des menschlichen Gehirns. Es besteht aus:

  • Input-Neuronen: Was das Netz "sieht"
  • Output-Neuronen: Was das Netz "entscheidet"
  • Verbindungen (Gewichte): Jede Verbindung hat ein Gewicht, das das Signal verstärkt oder dämpft

Das Prinzip ist simpel: Jedes Input-Neuron schickt seinen Wert. Er wird mit dem Verbindungsgewicht multipliziert, dann zu den anderen Signalen addiert. Wenn das Ergebnis eine gewisse Schwelle übersteigt (die Aktivierungsfunktion), aktiviert sich das Output-Neuron.

In Laupoks Analogie mit Mario und dem Mäuseende:

  • Das Input-Neuron = die Distanz zwischen Mario und dem Ende
  • Das Verbindungsgewicht = Marios Sensibilität
  • Das Output-Neuron = Mario schreit oder nicht

Je näher das Ende, desto höher der Inputwert. Wenn das Gewicht stark ist, ist das Outputsignal stark, und Mario würde schreien. Indem man das Gewicht ändert, ändert man Marios Sensibilität.

Demo "Mario hat Angst": Mario vor einem Buu mit Synapsenbalken, der das Verbindungsgewicht zwischen Input und Output zeigt

Im echten neuronalen Netz der KI ist es dieselbe Logik, aber in massivem Maßstab:

  • 99 Input-Neuronen (11×9 Tiles von Marios Sicht)
  • 8 Output-Neuronen (A, B, X, Y, Hoch, Runter, Links, Rechts)
  • Versteckte Neuronen (hidden) dazwischen
  • Hunderte Verbindungen mit variierten Gewichten

NEAT: der Algorithmus, der alles ändert

Das Problem klassischer genetischer Algorithmen

Wenn man einen genetischen Algorithmus naiv mit einem neuronalen Netz kombiniert, hat man ein Problem: Man erschafft 100 völlig verschiedene neuronale Netze und weiß nicht, wie man sie vergleicht. Jedes hat eigene Neuronen, eigene Verbindungen, eigene Gewichte. Wie weiß man, ob zwei Netze "nah" oder "fern" sind?

Hier kommt NEAT ins Spiel, NeuroEvolution of Augmenting Topologies. Erfunden von Kenneth Stanley und Risto Miikkulainen 2002, löst es exakt dieses Problem.

Die Spezies

Der erste Schlüsselmechanismus von NEAT sind die Spezies. Wenn ein neuronales Netz zu verschieden von einem anderen wird, wird es in eine andere Spezies klassifiziert. Die Ähnlichkeit wird via drei Parameter berechnet:

  1. Excess (EXCES_COEF = 0.50): Die Zahl der Verbindungen, die gar nichts miteinander zu tun haben (verschiedene Innovationen)
  2. Disjoint: Dasselbe, aber für Verbindungen, die in der Mitte liegen
  3. Gewichtsdifferenz (POIDSDIFF_COEF = 0.92): Die mittlere Gewichtsdifferenz zwischen Verbindungen mit derselben Innovation

Die Score-Formel:

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

Wenn dieser Score unter DIFF_LIMITE (1.0) liegt, sind beide Netze in derselben Spezies. Sonst erstellt man eine neue Spezies.

Die Innovationen

Das ist NEATs Genie. Jedes Mal, wenn eine Verbindung erstellt wird, erhält sie eine global eindeutige Innovations-Nummer. Diese Nummer folgt dem neuronalen Netz, selbst wenn es sich fortpflanzt.

Konkret erbt ein per Crossover erstelltes Baby die Innovationen seiner Eltern. Wenn zwei Netze dieselbe Innovation teilen, heißt das, dass sie eine Verbindung haben, die vom selben Vorfahren kommt. Das erlaubt, verschieden große Netze zu vergleichen.

Der Crossover

Wenn sich zwei neuronale Netze fortpflanzen, funktioniert der Crossover so:

Laupok erklärt das Crossover-Konzept mit "CROSSOVER"-Text-Overlay

  1. Das leistungsstärkste Netz wird der "dominante Elternteil"
  2. Das Baby erbt alle Verbindungen des Dominanten
  3. Für jede Verbindung mit derselben Innovation kann der andere Elternteil sie ersetzen (50% Chance)
  4. Nur aktive Verbindungen des nicht-dominanten Elternteils können ersetzen

Das garantiert, dass das Baby immer mindestens so gut ist wie der beste Elternteil.

Die Mutationen

Nach dem Crossover erleidet das Baby Mutationen mit konfigurierbaren Wahrscheinlichkeiten:

Laupok erklärt Mutationen mit "(kleine Änderung = Mutation)"-Text-Overlay

Mutation Wahrscheinlichkeit Effekt
Verbindungsgewicht-Reset 25% Das Gewicht wird total randomisiert
Gewichts-Mutation 95% Das Gewicht variiert um ±0.80
Verbindungs-Hinzufügung 85% Neue Verbindung zwischen zwei noch nicht verbundenen Neuronen
Neuronen-Hinzufügung 39% Ein verstecktes Neuron wird zwischen zwei verbundenen Neuronen eingefügt

Die Neuronen-Hinzufügungsrate ist wichtig: Sie erlaubt dem Netz zu wachsen. Anfangs gibt es nur Inputs und Outputs. Progressiv erscheinen versteckte Neuronen, was das Netz immer komplexer macht.


Der Code: kompletter Walkthrough

Die Konstanten

Das Skript beginnt mit einem Konstantenblock, der das ganze Setting definiert:

-- Marios Sicht um ihn herum
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 Pixel Breite
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 Pixel Höhe
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 Tiles
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 Tiles

-- Neuronales Netz
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 Inputs (sichtbare Tiles)
NB_OUTPUT = 8  -- A, B, X, Y, Hoch, Runter, Links, Rechts
NB_INDIVIDU_POPULATION = 100  -- Individuen pro Population
NB_NEURONE_MAX = 100000  -- Limit versteckter Neuronen

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- Wert wenn Level beendet
NB_FRAME_RESET_BASE = 33  -- Frames ohne Fortschritt vor Reset
NB_FRAME_RESET_PROGRES = 300  -- Frames wenn Fortschritt erkannt

-- Spezies
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Mutationen
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

Das NB_INPUT ist 99, weil Marios Sicht 11×9 Tiles macht. Jede Tile ist ein Input-Neuron. Wenn die Tile leer ist, ist sie 0 wert. Wenn es ein Block ist, ist sie 1 wert. Wenn es ein Gegner ist, ist sie -1 wert.

Die 8 Outputs entsprechen den SNES-Controller-Buttons: A, B, X, Y, Hoch, Runter, Links, Rechts. Man schließt Start, Select, L und R aus, um seine Entscheidungen aufs Wesentliche zu begrenzen.

Die Datenstrukturen

Das Skript definiert drei Hauptstrukturen:

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- aktueller Neuronwert
    neurone.id = 0        -- eindeutige Kennung
    neurone.type = ""     -- "input", "output" oder "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- Quellneuron-ID
    connexion.sortie = 0     -- Zielneuron-ID
    connexion.actif = true   -- kann deaktiviert werden wenn ein verstecktes Neuron eingefügt wird
    connexion.poids = 0      -- Verbindungsgewicht
    connexion.innovation = 0 -- eindeutige Innovationsnummer
    connexion.allume = false -- für Anzeige : true wenn Signal durchgeht
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- Zahl versteckter Neuronen
        fitness = 1,          -- Leistung (zurückgelegte Distanz)
        idEspeceParent = 0,   -- zu welcher Spezies es gehört
        lesNeurones = {},     -- Neuronentabelle
        lesConnexions = {}    -- Verbindungstabelle
    }
    -- Initialisierung mit Inputs
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Dann Outputs
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

Anfangs enthält jedes Netz nur Inputs und Outputs: Kein verstecktes Neuron, keine Verbindung. Der Algorithmus entscheidet, ob er welche braucht.

Die Mutationen im Detail

Gewichts-Mutation

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : totaler Gewichtsreset
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : Variation um ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

Das Initialgewicht ist immer 1 oder -1 (genererPoids()). Die ±0.80-Variation kann es zwischen negativen und positiven Werten oszillieren lassen, was das Netzverhalten radikal ändert.

Verbindungs-Hinzufügung

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Randomisierung der Neuronenliste
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Gültige Verbindung : input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Prüfen dass noch keine Verbindung existiert
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

Man kann keinen Output mit einem Input verbinden (das würde einen Zyklus erzeugen), und man kann keine zwei schon verbundenen Neuronen verbinden. Die Randomisierung garantiert, dass man jedes Mal verschiedene Möglichkeiten erkundet.

Neuronen-Hinzufügung

Das ist die interessanteste Mutation:

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Randomisierung der Verbindungen
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Existierende Verbindung deaktivieren
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Verstecktes Neuron erstellen
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Input mit verstecktem Neuron verbinden
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Verstecktes Neuron mit Output verbinden
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

Der Mechanismus: Man nimmt eine existierende Verbindung, deaktiviert sie und fügt ein verstecktes Neuron in der Mitte ein. Die Ursprungsverbindung wird durch zwei neue ersetzt: Input→hidden und hidden→Output. Als schneide man ein Kabel durch, um einen Schalter dazwischenzusetzen.

Das macht NEAT "augmenting topologies": Das Netz wächst mit der Zeit. Es startet simpel und wird nur komplex, wenn nötig.

Das FeedForward

Das ist die Funktion, die Signale durchs Netz propagiert:

function feedForward(unReseau)
    -- Reset der Output-Neuronen
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Propagation
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Jede aktive Verbindung schickt Inputwert × Gewicht zum Output-Neuron. Der Wert wird kumuliert (addiert). Das allume-Flag ist nur für die visuelle Netzanzeige.

Das Lesen des Spielspeichers

Die Funktion getLesInputs() übersetzt die Super-Mario-World-Welt in netzverständliche Daten:

function getLesInputs()
    local lesInputs = {}
    -- Initialisierung auf 0 (grau = nichts)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Die Sprites (Gegner) = -1 (schwarz)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Die Tiles (Blöcke) = Tile-Wert (weiß wenn > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

Das Input-Grid ist eine auf Mario zentrierte Sicht: 11 Tiles breit, 9 hoch. Jede Tile ist wert:

  • 0 (grau): nichts
  • 1 (weiß): solider Block
  • -1 (schwarz): Gegner

Gegner werden aus zwei RAM-Listen gelesen: Normale Sprites (0x14C8-0x14F8) und Extended Sprites (0x170B-0x173B). Für jeden lebenden Sprite (Zustand > 7) berechnet man seine Tile-Position relativ zu Mario und setzt -1 ins entsprechende Feld.

Die Fitness: wie die KI weiß, ob sie vorankommt

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Level beendet !
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario geht nach rechts voran
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Input-Update
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

Die Fitness ist simpel: Es ist die nach rechts zurückgelegte Distanz. Wenn Mario 10 Pixel vorankommt, steigt die Fitness um 10. Wenn Mario zurückgeht, passiert nichts (keine Penalty). Wenn das Level beendet ist (Adresse 0x0100 == 12), wird die Fitness 1.000.000.

Absichtlich simpel. Nur Fortschritt nach rechts zählt: Gegner töten bringt nichts, Sterben kostet nichts.

Der intelligente Reset

Wenn sich Mario 33 Frames nicht bewegt, resettet man das Level und geht zum nächsten Individuum. Aber wenn Mario Fortschritt gemacht hat (seine aktuelle Fitness unterscheidet sich von der am Anfang), wartet man 300 Frames, das gibt dem Netz eine Chance zu "verstehen", was es gut gemacht hat.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

Die Bedingung memory.readbyte(0x0071) ~= 9 prüft, dass Mario nicht in Todesanimation ist. Der Reset wird unnütz, wenn Mario schon in Todesanimation ist.

Die Hauptschleife

Die Schleife läuft mit 30 fps (Super Mario Worlds normale Geschwindigkeit):

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Anzeige (Netz, Infos)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- beschleunigen
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- Die 3 vitalen Funktionen
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Reset wenn kein Fortschritt
    -- ...
    -- Neue Generation wenn alle Individuen getestet
    -- ...
end

Die drei vitalen Funktionen sind majReseau, feedForward und appliquerLesBoutons. Wenn man nur eine deaktiviert, bewegt sich Mario nicht mehr.

Der Crossover

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

Das Baby erbt vom besten Elternteil. Für jede Verbindung mit derselben Innovation hat der andere Elternteil 50% Chance zu ersetzen, aber nur wenn die Verbindung aktiv ist. Ein wichtiger Fix: Ohne das konnten nutzlose versteckte Neuronen erstellt werden.

Die Spezies-Selektion

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Mittlere Fitness pro Spezies berechnen
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Jede Spezies erstellt Kinderzahl proportional zu ihrer mittleren Fitness
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

Die Idee: Eine Spezies mit mittlerer Fitness 10.000 darf viel mehr Kinder erstellen als eine mit mittlerer Fitness 1. Das ist natürliche Selektion in Aktion.

Das choisirParent nutzt Roulette-Selektion: Je mehr Fitness ein Individuum hat, desto höher seine Chance, als Elternteil selektiert zu werden.

Das Speichern und Laden

Populationen werden in .pop-Dateien gespeichert:

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

Das Speichern schließt auch das beste Individuum aller vorherigen Populationen ein. Wenn das Beste der alten Population besser ist als das Beste der neuen, nimmt man das alte als Basis wieder. Eine Form von Elitismus: Man verliert nie das Beste.

Das Netzzeichnen

Laupok hat einen neuronalen Netz-Visualisierer als Spiel-Overlay hinzugefügt:

function dessinerUnReseau(unReseau)
    -- Inputs : 11×9-Grid um Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- Gegner
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- Block
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Outputs : 8 Buttons
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Verbindungen
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

Super nützlich, um zu verstehen, was das Netz tut. Aktive Verbindungen sind weiß, inaktive semi-transparent. Inputs sind ein Grid weiß/schwarz/grauer Felder. Outputs zeigen, welche Buttons gedrückt sind.


Die Ergebnisse

Was die KI gelernt hat

Im Laufe stunden- (und tage-) langer Läufe hat die KI allein entdeckt:

  1. Nach rechts vorankommen: Das basischste Verhalten, erfordert aber, den Rechts-Button gedrückt zu halten
  2. Über Gegner springen: Indem man einen "Gegner erkannt"-Input mit Button A oder B verbindet
  3. Hindernissen ausweichen: Manche Netze lernten, temporär zurückzuweichen, um besser voranzukommen
  4. Level beenden: Das beste Individuum konnte Super Mario Worlds erstes Level beenden

Von der KI gesteuerter Mario vor einem Buu in einem Super-Mario-World-Level, das neuronale Netz entscheidet live über Aktionen

Die Grenzen

Das Projekt hat seine Grenzen:

  • Ein einziges Level: Die KI ist auf ein spezifisches Level trainiert. Sie generalisiert nicht automatisch auf andere Level
  • Trainingszeit: Es braucht Dutzende Stunden, um befriedigende Ergebnisse zu erreichen
  • Kein Verständnis: Die KI "versteht" nicht, was sie tut. Sie optimiert eine Fitnessfunktion (zurückgelegte Distanz) via Zufallsmutationen
  • T-Bagging: Laupok merkt an, dass Mario dazu neigt, auf der Stelle zu springen, wenn er einen Gegner sieht, einfach weil das die Fitness erhöht (er kommt springend etwas voran)

Das Experiment reproduzieren

Laupok hat alles geteilt. Hier die Schritte:

  1. BizHawk herunterladen auf tasvideos.org (Download-Bereich)
  2. Eine USA-ROM von Super Mario World besorgen (Privatkopie deiner eigenen Cartridge)
  3. Das Lua-Skript herunterladen von Pastebin, umbenennen zu mario.lua
  4. Das Skript in denselben Ordner wie die ROM legen
  5. BizHawk starten, die ROM öffnen
  6. In der Lua-Konsole: dofile("mario.lua") oder via Menü Skript > Skript öffnen
  7. Einen State am Levelanfang speichern (Menü Savestate > State speichern) und debut.state nennen
  8. Das Skript neu starten, es geht

Das Skript enthält ein Formular mit Optionen:

  • Beschleunigen: Deaktiviert das 30-fps-Limit, um schneller zu gehen
  • Netz anzeigen: Zeigt das neuronale Netz als Overlay
  • Infos anzeigen: Zeigt ein Banner mit Generation, Fitness, Spezieszahl
  • Pause: Pausiert die Ausführung
  • Speichern/Laden: Persistiert die aktuelle Population in einer .pop-Datei

Quellen und Referenzen

Ressource Link
Laupoks Hauptvideo Ich habe eine KI gebaut, die Mario allein spielt
Video Code-Review + Setup KI-Setup + Quellcode-Review
Kompletter Quellcode Pastebin Jcvdqhqm
Originaler NEAT-Artikel Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
N8Programs-Tutorial NEAT implementation walkthrough (JavaScript, aber Konzepte identisch)
16blings (Laupoks Inspiration) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Super-Mario-World-Speicher SMW Central - RAM Map

Fazit

Was Laupok gemacht hat, ist einen akademischen Algorithmus (NEAT, 2002) nehmen, ihn in Lua für einen Emulator (BizHawk) neu schreiben und auf Super Mario World anwenden. Das Ergebnis: Eine KI, die von null lernt, das Spiel zu spielen, ohne jegliches Vorwissen, nur durch Zufallsmutationen und natürliche Selektion.

Ein wunderschönes Beispiel für die Macht genetischer Algorithmen. Das Rezept passt in drei Zutaten: Natürliche Selektion, etwas Lua und viel Geduld. Kein Deep Learning, keine GPU, keine massiven Trainingsdaten.

Der Code ist kommentiert, geteilt, und Laupok hat zwei Erklärvideos gemacht, eines für die großen Konzepte, eines für den Code. Wenn dich das Thema interessiert, tauch ein. Zugänglicher, als es scheint.

Laupok создал ИИ, играющий в Super Mario World сам -- как это работает

Laupok создал ИИ, играющий в Super Mario World сам -- как это работает

Laupok создал искусственный интеллект, играющий в Super Mario World полностью автономно. Он стартует с нуля : никаких предписанных скриптов, никаких записанных кадров. ИИ учится сам, рывками случайных мутаций и естественным отбором, проходить уровни игры. Проект крутится на BizHawk, мультиплатформенном эмуляторе, через Lua-скрипт примерно на 4200 строк.

Захватывает в этом проекте то, что он опирается на биологические концепции, приложенные к информатике : теорию эволюции Дарвина, искусственные нейросети и особенно специфический алгоритм под названием NEAT (NeuroEvolution of Augmenting Topologies). ИИ ничего не знает про игру на старте. Он тестирует случайные действия, проваливается тысячи раз и понемногу понимает, как двигаться, прыгать и выживать.

В этой статье разберём всё это -- концепцию за концепцией, строку кода за строкой.

Laupok вводит алгоритм NEAT перед камерой


Сетап : BizHawk, Lua и Super Mario World

Эмулятор BizHawk

BizHawk это опенсорсный эмулятор, поддерживающий тонну консолей -- NES, SNES, Genesis, PS1, Game Boy и многие другие. Его особенность в том, что он позволяет запускать Lua-скрипты параллельно игре. Эти скрипты имеют доступ к оперативной памяти (RAM) эмуляции, а значит могут читать -- и менять -- любые данные игры в реальном времени.

Конкретно это значит, что можно :

  • Читать позицию Марио в уровне
  • Знать, какие спрайты (враги, предметы) на экране
  • Знать состояние каждого тайла (блока) вокруг Марио
  • Управлять геймпадом -- жать любую кнопку

Это ровно то, что нужно, чтобы заставить играть ИИ.

Адреса памяти Super Mario World

В RAM Super Mario World каждая данная хранится по конкретному адресу. Это немного как квартал : каждый адрес соответствует "дому", содержащему информацию. Например :

Adresse Donnée
0x94-0x95 Position X de Mario (16 bits, little-endian)
0x96-0x97 Position Y de Mario
0x14C8+i État du sprite i (>7 = vivant)
0xE4+i Position X basse du sprite i
0x14E0+i Position X haute du sprite i
0xD8+i Position Y basse du sprite i
0x14D4+i Position Y haute du sprite i
0x170B+i Type de l'extended sprite i
0x0100 État du jeu (12 = niveau fini)
0x13D4 Pause active
0x0071 Animation de mort de Mario (9 = mort)
0x1C800+... Table des tiles du niveau

Позиция спрайтов использует два байта : младший байт и старший байт, потому что позиция может превышать 255 пикселей. Формула всегда младший + старший × 256.

Для тайлов сложнее : базовый адрес 0x1C800, а офсет вычисляется по координатам x и y тайла в мире с шагом 16 пикселей на тайл.

Super Mario World с дебаг-оверлеем, показывающим адреса памяти спрайтов и позицию Марио


Основы : генетические алгоритмы и нейросети

Прежде чем нырять в код, надо понять две фундаментальные концепции. Без них остальное бессмысленно.

Генетические алгоритмы

Генетический алгоритм это симуляция теории эволюции. Центральная идея : создаём популяцию индивидов, у каждого слегка разные характеристики ("гены"). Заставляем их "жить" в среде. Те, что справляются лучше, выживают и размножаются. Те, что справляются плохо, исчезают.

Laupok иллюстрирует это аналогией с Kirby :

  • Популяция Kirby появляется на поле с шипами и помидорами
  • Шипы отнимают жизни, помидоры возвращают
  • У каждого Kirby гены : размер, скорость, жизни, поведение (бежать, искать помидоры, переть куда попало)

Двойная спираль ДНК с метками "малыш", "размер", "скорость", "цвет" -- гены, составляющие индивида

  • Через 15 секунд смотрим, кто выжил дольше всех
  • Лучший Kirby размножается с остальными : малыши наследуют половину генов лучшего и половину "худшего"
  • Малыши претерпевают случайные мутации (чуть больше, чуть быстрее...)
  • Старые Kirby заменяются новыми
  • Перезапускаем

После 180 поколений (~15 часов) Kirby переходят с 15 секунд выживания к 15 минутам. Они стали маленькими (уменьшенная зона коллизии), быстрыми и постоянно убегают от опасности.

Симуляция Kirby поколение 0 : цветные круги, случайно разбросанные на чёрном фоне, все похожего размера

Симуляция Kirby поколение 1866 : Kirby меньше, быстрее и систематически убегают от опасностей

Статистика симуляции Kirby : fitness, число жизней, поведение каждого индивида с ранжированием по результату

Ключевой момент : мы не определяем решение. Алгоритм находит его сам. И ровно это делает его мощным для задач, где неизвестно, какая комбинация параметров была бы оптимальной.

Искусственные нейросети

Нейросеть это упрощённая математическая модель человеческого мозга. Она состоит из :

  • Входных нейронов (inputs) : что сеть "видит"
  • Выходных нейронов (outputs) : что сеть "решает"
  • Связей (веса) : у каждой связи есть вес, усиливающий или ослабляющий сигнал

Принцип прост : каждый входной нейрон отправляет своё значение. Оно умножается на вес связи, затем суммируется с остальными сигналами. Если результат превышает порог (функция активации), выходной нейрон активируется.

В аналогии Laupok с Марио и кончиком мыши :

  • Входной нейрон = дистанция между Марио и кончиком
  • Вес связи = чувствительность Марио
  • Выходной нейрон = Марио кричит или нет

Чем ближе кончик, тем выше входное значение. Если вес сильный, посылаемый на выход сигнал сильный, и Марио закричал бы. Меняя вес, меняешь чувствительность Марио.

Демо "Марио боится" : Марио лицом к Boo с полосой синапса, показывающей вес связи между входом и выходом

В настоящей нейросети ИИ та же логика, но в массивном масштабе :

  • 99 входных нейронов (11×9 тайлов вида Марио)
  • 8 выходных нейронов (A, B, X, Y, Вверх, Вниз, Влево, Вправо)
  • Скрытые нейроны (hidden) между ними
  • Сотни связей с разными весами

NEAT : алгоритм, меняющий всё

Проблема классических генетических алгоритмов

Если скомбинировать генетический алгоритм с нейросетью наивно, будет проблема : создаём 100 совершенно разных нейросетей, и не знаем, как их сравнивать. У каждой свои нейроны, свои связи, свои веса. Как понять, "близки" две сети или "далеки"?

Здесь входит NEAT -- NeuroEvolution of Augmenting Topologies. Изобретённый Кеннетом Стэнли и Ристо Мииккулайненом в 2002, это алгоритм, решающий ровно эту проблему.

Виды

Первый ключевой механизм NEAT это виды. Когда нейросеть становится слишком отличной от другой, она классифицируется в другой вид. Схожесть вычисляется через три параметра :

  1. Excess (EXCES_COEF = 0.50) : число связей, никак не соотносящихся между двумя сетями (разные инновации)
  2. Disjoint : то же, но для связей, находящихся в середине
  3. Разница весов (POIDSDIFF_COEF = 0.92) : средняя разница весов между связями, разделяющими одну инновацию

Формула балла :

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

Если этот балл ниже DIFF_LIMITE (1.0), обе сети в одном виде. Иначе создаём новый вид.

Инновации

В этом гениальность NEAT. Каждый раз, когда создаётся связь, она получает уникальный глобальный номер инновации. Этот номер следует за нейросетью, даже когда она размножается.

Конкретно, когда малыш создаётся скрещиванием, он наследует инновации родителей. Если две сети разделяют одну инновацию, это значит, у них есть связь от одного предка. Именно это позволяет сравнивать сети разных размеров.

Скрещивание

Когда две нейросети размножаются, скрещивание работает так :

Laupok объясняет концепцию скрещивания с текстом "CROSSOVER" в оверлее

  1. Более результативная сеть становится "доминантным родителем"
  2. Малыш наследует все связи доминанта
  3. Для каждой связи, разделяющей одну инновацию, другой родитель может её заменить (50 % шансов)
  4. Заменять могут только активные связи недоминантного родителя

Это гарантирует, что малыш всегда как минимум не хуже лучшего родителя.

Мутации

После скрещивания малыш претерпевает мутации с настраиваемыми вероятностями :

Laupok объясняет мутации с текстом "(малое изменение = мутация)" в оверлее

Mutation Probabilité Effet
Reset poids connexion 25% Le poids est totalement randomisé
Mutation poids 95% Le poids varie de ±0.80
Ajout connexion 85% Nouvelle connexion entre deux neurones pas encore liés
Ajout neurone 39% Un neurone caché est inséré entre deux neurones connectés

Частота добавления нейронов важна : именно она позволяет сети расти. На старте есть только входы и выходы. Постепенно появляются скрытые нейроны, делая сеть всё сложнее.


Код : полный walkthrough

Константы

Скрипт начинается с блока констант, задающих всю параметризацию :

-- Вид Марио вокруг него
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 пикселей в ширину
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 пикселя в высоту
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 тайлов
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 тайлов

-- Нейросеть
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 входов (видимые тайлы)
NB_OUTPUT = 8  -- A, B, X, Y, Вверх, Вниз, Влево, Вправо
NB_INDIVIDU_POPULATION = 100  -- индивидов на популяцию
NB_NEURONE_MAX = 100000  -- лимит скрытых нейронов

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- значение при завершении уровня
NB_FRAME_RESET_BASE = 33  -- кадров без прогресса до сброса
NB_FRAME_RESET_PROGRES = 300  -- кадров при детекте прогресса

-- Виды
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Мутации
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT равен 99, потому что вид Марио 11×9 тайлов. Каждый тайл это входной нейрон. Если тайл пуст, он равен 0. Если блок, равен 1. Если враг, равен -1.

8 выходов соответствуют кнопкам геймпада SNES : A, B, X, Y, Вверх, Вниз, Влево, Вправо. Start, Select, L и R исключаем, чтобы лимитировать его решения существенным.

Структуры данных

Скрипт определяет три главные структуры :

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- текущее значение нейрона
    neurone.id = 0        -- уникальный идентификатор
    neurone.type = ""     -- "input", "output" или "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- ID исходного нейрона
    connexion.sortie = 0     -- ID нейрона назначения
    connexion.actif = true   -- может деактивироваться, если вставлен скрытый нейрон
    connexion.poids = 0      -- вес связи
    connexion.innovation = 0 -- уникальный номер инновации
    connexion.allume = false -- для отображения : true, если сигнал проходит
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- число скрытых нейронов
        fitness = 1,          -- результат (пройденная дистанция)
        idEspeceParent = 0,   -- к какому виду принадлежит
        lesNeurones = {},     -- таблица нейронов
        lesConnexions = {}    -- таблица связей
    }
    -- Инициализация входами
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Затем выходы
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

На старте каждая сеть содержит только входы и выходы : никаких скрытых нейронов, никаких связей. Именно алгоритм решит, нужны ли они.

Мутации в деталях

Мутация весов

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : полный сброс веса
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : вариация ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

Начальный вес всегда 1 или -1 (genererPoids()). Вариация ±0.80 может качать его между отрицательными и положительными значениями, радикально меняя поведение сети.

Добавление связи

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Рандомизация списка нейронов
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Валидная связь : input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Проверить, что связи ещё нет
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

Нельзя соединять выход со входом (это создало бы цикл), и нельзя соединять два уже связанных нейрона. Рандомизация гарантирует исследование разных возможностей каждый раз.

Добавление нейрона

Это самая интересная мутация :

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Рандомизация связей
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Деактивировать существующую связь
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Создать скрытый нейрон
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Соединить вход со скрытым нейроном
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Соединить скрытый нейрон с выходом
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

Механизм : берём существующую связь, её деактивируем и вставляем скрытый нейрон в середину. Исходная связь заменяется двумя новыми : вход→скрытый и скрытый→выход. Будто "режем" провод, чтобы вставить выключатель.

Именно это делает NEAT "augmenting topologies" : сеть растёт со временем. Начинает простой и усложняется, только если нужно.

FeedForward

Это функция, распространяющая сигналы через сеть :

function feedForward(unReseau)
    -- Сброс выходных нейронов
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Распространение
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Каждая активная связь отправляет значение_входа × вес в нейрон выхода. Значение кумулируется (суммируется). Флаг allume только для визуального отображения сети.

Чтение памяти игры

Функция getLesInputs() та, что переводит мир Super Mario World в понятные сети данные :

function getLesInputs()
    local lesInputs = {}
    -- Инициализация в 0 (серый = ничего)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Спрайты (враги) = -1 (чёрный)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Тайлы (блоки) = значение тайла (белый, если > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

Сетка входов это вид, центрированный на Марио : 11 тайлов в ширину, 9 в высоту. Каждый тайл равен :

  • 0 (серый) : ничего
  • 1 (белый) : твёрдый блок
  • -1 (чёрный) : враг

Враги читаются из двух списков в RAM : нормальные спрайты (0x14C8-0x14F8) и extended-спрайты (0x170B-0x173B). Для каждого живого спрайта (состояние > 7) вычисляем его позицию в тайлах относительно Марио и ставим -1 в соответствующую клетку.

Fitness : как ИИ знает, прогрессирует ли

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Уровень завершён !
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Марио движется вправо
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Обновление входов
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

Fitness прост : это пройденная вправо дистанция. Если Марио продвинулся на 10 пикселей, fitness растёт на 10. Если Марио отступает, ничего не происходит (без штрафа). Если уровень завершён (адрес 0x0100 == 12), fitness становится 1 000 000.

Это намеренно просто. Считается только прогресс вправо : убийство врагов ничего не даёт, смерть ничего не стоит.

Умный сброс

Если Марио не двигается 33 кадра, сбрасываем уровень и переходим к следующему индивиду. Но если Марио прогрессировал (текущий fitness отличен от стартового), ждём 300 кадров -- это даёт сети шанс "понять" сделанное хорошо.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

Условие memory.readbyte(0x0071) ~= 9 проверяет, что Марио не в анимации смерти. Сброс становится бесполезным, когда Марио уже в анимации смерти.

Главный цикл

Цикл крутится на 30 fps (нормальная скорость Super Mario World) :

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Отображение (сеть, инфо)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- ускорить
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- 3 витальные функции
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Сброс при отсутствии прогресса
    -- ...
    -- Новое поколение, если все индивиды протестированы
    -- ...
end

Три витальные функции это majReseau, feedForward и appliquerLesBoutons. Отключи одну, Марио перестанет двигаться.

Скрещивание

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

Малыш наследует лучшего родителя. Для каждой связи, разделяющей одну инновацию, у другого родителя 50 % шансов её заменить -- но только если связь активна. Это важный фикс : без него могли создаваться бесполезные скрытые нейроны.

Отбор по видам

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Средний fitness на вид
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Каждый вид создаёт детей пропорционально среднему fitness
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

Идея : вид со средним fitness 10 000 имеет право создать куда больше детей, чем вид со средним fitness 1. Это естественный отбор в действии.

choisirParent использует отбор рулеткой : чем выше fitness индивида, тем больше шансов быть выбранным родителем.

Сохранение и загрузка

Популяции сохраняются в файлы .pop :

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

Сохранение включает и лучшего индивида всех предыдущих популяций. Если лучший из старой популяции лучше лучшего из новой, берём старого за основу. Это форма элитизма : лучшего не теряем никогда.

Отрисовка сети

Laupok добавил визуализатор нейросети поверх игры :

function dessinerUnReseau(unReseau)
    -- Входы : сетка 11×9 вокруг Марио
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- враг
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- блок
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Выходы : 8 кнопок
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Связи
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

Суперполезно для понимания того, что делает сеть. Активные связи белые, неактивные полупрозрачные. Входы это сетка бело-чёрно-серых клеток. Выходы показывают, какие кнопки нажаты.


Результаты

Чему научился ИИ

За часы (и дни) прогона ИИ открыл сам :

  1. Двигаться вправо : самое базовое поведение, но требующее держать кнопку Вправо зажатой
  2. Прыгать через врагов : соединяя вход "детект врага" с кнопкой A или B
  3. Избегать препятствий : некоторые сети научились временно отступать, чтобы лучше продвинуться
  4. Проходить уровни : лучший индивид смог завершить первый уровень Super Mario World

Марио под управлением ИИ лицом к Boo в уровне Super Mario World -- нейросеть решает действия в реальном времени

Лимиты

У проекта есть лимиты :

  • Один уровень : ИИ обучен на одном конкретном уровне. Он не обобщает автоматически на другие уровни
  • Время обучения : нужны десятки часов, чтобы достичь удовлетворительных результатов
  • Нет понимания : ИИ не "понимает", что делает. Он оптимизирует функцию fitness (пройденная дистанция) через случайные мутации
  • T-bagging : Laupok отмечает, что Марио склонен прыгать на месте при виде врага, просто потому что это растит fitness (он слегка продвигается в прыжке)

Как воспроизвести эксперимент

Laupok всё расшарил. Вот шаги :

  1. Скачать BizHawk на tasvideos.org (секция Download)
  2. Получить USA ROM Super Mario World (приватная копия собственного картриджа)
  3. Скачать Lua-скрипт с Pastebin -- переименовать в mario.lua
  4. Положить скрипт в ту же папку, что ROM
  5. Запустить BizHawk, открыть ROM
  6. В консоли Lua : dofile("mario.lua") или через меню Script > Open Script
  7. Сохранить state в начале уровня (меню Savestate > Save State) и назвать debut.state
  8. Перезапустить скрипт -- работает

Скрипт включает форму с опциями :

  • Ускорить : отключает лимит 30 fps, чтобы идти быстрее
  • Показать сеть : показывает нейросеть поверх
  • Показать инфо : показывает баннер с поколением, fitness, числом видов
  • Пауза : ставит выполнение на паузу
  • Сохранить/Загрузить : персистит текущую популяцию в файл .pop

Источники и отсылки

Ресурс Ссылка
Главное видео Laupok я создал ИИ, играющий в марио сам
Видео код-ревью + сетап как настроить ии + обзор исходника
Полный исходник Pastebin Jcvdqhqm
Оригинальная статья NEAT Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
Туториал от N8Programs NEAT implementation walkthrough (JavaScript, но концепции идентичны)
16blings (вдохновение Laupok) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Память Super Mario World SMW Central - RAM Map

Заключение

Сделанное Laupok это взять академический алгоритм (NEAT, 2002), переписать его на Lua для эмулятора (BizHawk) и применить к Super Mario World. Итог : ИИ, учащийся с нуля играть в игру, безо всякого предварительного знания, только случайными мутациями и естественным отбором.

Прекрасный пример мощи генетических алгоритмов. Рецепт в трёх ингредиентах : естественный отбор, немного Lua и много терпения. Никакого deep learning, никакого GPU, никаких массивных обучающих данных.

Код прокомментирован, расшарен, а Laupok сделал два объясняющих видео -- одно про большие концепции, одно про код. Если тема интересна, ныряй. Это доступнее, чем кажется.

Laupok creó una IA que juega sola a Super Mario World: cómo funciona

Laupok creó una IA que juega sola a Super Mario World: cómo funciona

Laupok creó una inteligencia artificial que juega a Super Mario World de forma totalmente autónoma. Parte de cero: ningún script predefinido, ningún frame grabado. La IA aprende sola, a base de mutaciones aleatorias y selección natural, a terminar los niveles del juego. El proyecto corre sobre BizHawk, un emulador multiplataforma, vía un script Lua de unas 4200 líneas.

Lo que hace fascinante ese proyecto es que se apoya en conceptos biológicos aplicados a la informática: la teoría de la evolución de Darwin, las redes de neuronas artificiales, y sobre todo un algoritmo específico llamado NEAT (NeuroEvolution of Augmenting Topologies). La IA no sabe nada del juego al principio. Prueba acciones al azar, falla miles de veces, y poco a poco entiende cómo avanzar, saltar, y sobrevivir.

En este artículo, vamos a diseccionar todo eso: concepto por concepto, línea de código por línea de código.

Laupok introduce el algoritmo NEAT ante la cámara


El setup: BizHawk, Lua, y Super Mario World

El emulador BizHawk

BizHawk es un emulador open source que soporta un montón de consolas: NES, SNES, Genesis, PS1, Game Boy, y muchas más. Su particularidad es que permite lanzar scripts Lua en paralelo al juego. Esos scripts tienen acceso a la memoria viva (RAM) de la emulación, lo que significa que pueden leer, y modificar, cualquier dato del juego en tiempo real.

En concreto, eso significa que podemos:

  • Leer la posición de Mario en el nivel
  • Saber qué sprites (enemigos, items) hay en pantalla
  • Conocer el estado de cada tile (bloque) alrededor de Mario
  • Controlar el mando: pulsar cualquier botón

Es justo lo que hace falta para que juegue una IA.

Las direcciones de memoria de Super Mario World

En la RAM de Super Mario World, cada dato se guarda en una dirección específica. Es un poco como un barrio: cada dirección corresponde a una "casa" que contiene una información. Por ejemplo:

Dirección Dato
0x94-0x95 Posición X de Mario (16 bits, little-endian)
0x96-0x97 Posición Y de Mario
0x14C8+i Estado del sprite i (>7 = vivo)
0xE4+i Posición X baja del sprite i
0x14E0+i Posición X alta del sprite i
0xD8+i Posición Y baja del sprite i
0x14D4+i Posición Y alta del sprite i
0x170B+i Tipo del extended sprite i
0x0100 Estado del juego (12 = nivel terminado)
0x13D4 Pausa activa
0x0071 Animación de muerte de Mario (9 = muerto)
0x1C800+... Tabla de los tiles del nivel

La posición de los sprites usa dos bytes: un byte "bajo" y un byte "alto", porque la posición puede superar 255 píxeles. La fórmula siempre es bajo + alto × 256.

Para los tiles, es más complejo: la dirección base es 0x1C800, y se calcula el offset según las coordenadas x e y del tile en el mundo, con un paso de 16 píxeles por tile.

Super Mario World con overlay de depuración mostrando las direcciones de memoria de los sprites y la posición de Mario


Las bases: algoritmos genéticos y redes de neuronas

Antes de zambullirnos en el código, hay que entender dos conceptos fundamentales. Sin ellos, el resto no tiene sentido.

Los algoritmos genéticos

Un algoritmo genético es una simulación de la teoría de la evolución. La idea central: se crea una población de individuos, cada uno con características (unos "genes") ligeramente distintas. Se los hace "vivir" en un entorno. Los que mejor se adaptan sobreviven y se reproducen. Los que peor se adaptan desaparecen.

Laupok lo ilustra con una analogía de Kirby:

  • Una población de Kirby aparece en un terreno con pinchos y tomates
  • Los pinchos quitan puntos de vida, los tomates los devuelven
  • Cada Kirby tiene genes: tamaño, velocidad, puntos de vida, comportamiento (huir, buscar tomates, correr a cualquier sitio)

Doble hélice de ADN con las etiquetas "el bebé", "tamaño", "velocidad", "color": los genes que componen un individuo

  • Tras 15 segundos, miramos quién sobrevivió más tiempo
  • El mejor Kirby se reproduce con los demás: los bebés heredan la mitad de los genes del mejor y la mitad del "peor"
  • Los bebés sufren mutaciones aleatorias (un poco más grande, un poco más rápido...)
  • Los viejos Kirby se reemplazan por los nuevos
  • Se relanza

Tras 180 generaciones (~15 horas), los Kirby pasan de 15 segundos de supervivencia a 15 minutos. Se volvieron pequeños (zona de colisión reducida), rápidos, y huyen del peligro permanentemente.

Simulación Kirby generación 0: círculos coloreados dispersos aleatoriamente sobre un fondo negro, todos de tamaño similar

Simulación Kirby generación 1866: los Kirby son más pequeños, más rápidos, y huyen sistemáticamente de los peligros

Estadísticas de la simulación Kirby: fitness, número de puntos de vida, comportamiento de cada individuo clasificados por rendimiento

El punto crucial: no definimos la solución. El algoritmo la encuentra solo. Y eso es justo lo que lo hace potente para problemas donde no sabemos qué combinación de parámetros sería óptima.

Las redes de neuronas artificiales

Una red de neuronas es un modelo matemático simplificado del cerebro humano. Se compone de:

  • Neuronas de entrada (inputs): lo que la red "ve"
  • Neuronas de salida (outputs): lo que la red "decide"
  • Conexiones (pesos): cada conexión tiene un peso que amplifica o atenúa la señal

El principio es simple: cada neurona de entrada envía su valor. Se multiplica por el peso de la conexión, y luego se suma a las demás señales. Si el resultado supera cierto umbral (la función de activación), la neurona de salida se activa.

En la analogía de Laupok con Mario y el trozo de queso:

  • La neurona de entrada = la distancia entre Mario y el trozo
  • El peso de la conexión = la sensibilidad de Mario
  • La neurona de salida = Mario grita o no

Cuanto más cerca el trozo, más alto el valor de entrada. Si el peso es fuerte, la señal enviada en salida es fuerte, y Mario gritaría. Modificando el peso, se modifica la sensibilidad de Mario.

Demo "Mario tiene miedo": Mario frente a un Boo con una barra de sinapsis mostrando el peso de la conexión entre la entrada y la salida

En la verdadera red de neuronas de la IA, es la misma lógica, pero a escala masiva:

  • 99 neuronas de entrada (11×9 tiles de la vista de Mario)
  • 8 neuronas de salida (A, B, X, Y, Arriba, Abajo, Izquierda, Derecha)
  • Neuronas ocultas (hidden) entre medias
  • Cientos de conexiones con pesos variados

NEAT: el algoritmo que lo cambia todo

El problema de los algoritmos genéticos clásicos

Si combinamos un algoritmo genético con una red de neuronas de forma ingenua, tenemos un problema: creamos 100 redes de neuronas por completo distintas, y no sabemos compararlas. Cada una tiene sus propias neuronas, sus propias conexiones, sus propios pesos. ¿Cómo saber si dos redes están "cerca" o "lejos"?

Ahí interviene NEAT: NeuroEvolution of Augmenting Topologies. Inventado por Kenneth Stanley y Risto Miikkulainen en 2002, es un algoritmo que resuelve justo ese problema.

Las especies

El primer mecanismo clave de NEAT son las especies. Cuando una red de neuronas se vuelve demasiado distinta de otra, se clasifica en una especie distinta. La similitud se calcula vía tres parámetros:

  1. Excess (EXCES_COEF = 0.50): el número de conexiones que no tienen relación entre dos redes (innovaciones distintas)
  2. Disjoint: igual, pero para las conexiones que se sitúan en medio
  3. Weight difference (POIDSDIFF_COEF = 0.92): la diferencia de peso media entre las conexiones que comparten la misma innovación

La fórmula de puntuación:

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

Si esa puntuación es inferior a DIFF_LIMITE (1.0), ambas redes están en la misma especie. Si no, se crea una especie nueva.

Las innovaciones

Esa es la genialidad de NEAT. Cada vez que se crea una conexión, recibe un número de innovación único y global. Ese número sigue a la red de neuronas incluso cuando se reproduce.

En concreto, cuando un bebé se crea por crossover, hereda las innovaciones de sus padres. Si dos redes comparten la misma innovación, significa que tienen una conexión que viene del mismo ancestro. Eso permite comparar redes de tamaños distintos.

El crossover

Cuando dos redes de neuronas se reproducen, el crossover funciona así:

Laupok explica el concepto de crossover con el texto "CROSSOVER" en overlay

  1. La red más rendidora se vuelve el "padre dominante"
  2. El bebé hereda todas las conexiones del dominante
  3. Para cada conexión que comparte la misma innovación, el otro padre puede reemplazarla (50% de probabilidad)
  4. Solo las conexiones activas del padre no dominante pueden reemplazar

Eso garantiza que el bebé siempre sea al menos tan bueno como el mejor padre.

Las mutaciones

Tras el crossover, el bebé sufre mutaciones con probabilidades configurables:

Laupok explica las mutaciones con el texto "(pequeña modif = mutación)" en overlay

Mutación Probabilidad Efecto
Reset peso conexión 25% El peso se aleatoriza totalmente
Mutación peso 95% El peso varía ±0.80
Añadir conexión 85% Nueva conexión entre dos neuronas aún no ligadas
Añadir neurona 39% Una neurona oculta se inserta entre dos neuronas conectadas

La tasa de añadir neurona es importante: es lo que permite a la red crecer. Al principio, solo hay entradas y salidas. Progresivamente, aparecen neuronas ocultas, haciendo la red cada vez más compleja.


El código: repaso completo

Las constantes

El script empieza con un bloque de constantes que definen todo el parametrizado:

-- Vista de Mario a su alrededor
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 píxeles en anchura
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 píxeles en altura
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tiles
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tiles

-- Red de neuronas
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 inputs (tiles visibles)
NB_OUTPUT = 8  -- A, B, X, Y, Haut, Bas, Gauche, Droite
NB_INDIVIDU_POPULATION = 100  -- individuos por población
NB_NEURONE_MAX = 100000  -- límite de neuronas ocultas

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- valor cuando el nivel se termina
NB_FRAME_RESET_BASE = 33  -- frames sin progreso antes de reset
NB_FRAME_RESET_PROGRES = 300  -- frames si hay progreso detectado

-- Especies
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Mutaciones
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

El NB_INPUT es 99 porque la vista de Mario es 11×9 tiles. Cada tile es una neurona de entrada. Si el tile está vacío, vale 0. Si es un bloque, vale 1. Si es un enemigo, vale -1.

Las 8 salidas corresponden a los botones del mando SNES: A, B, X, Y, Arriba, Abajo, Izquierda, Derecha. Excluimos Start, Select, L y R para limitar sus decisiones a lo esencial.

Las estructuras de datos

El script define tres estructuras principales:

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- valor actual de la neurona
    neurone.id = 0        -- identificador único
    neurone.type = ""     -- "input", "output", o "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- ID de la neurona fuente
    connexion.sortie = 0     -- ID de la neurona destino
    connexion.actif = true   -- puede desactivarse si se inserta una neurona oculta
    connexion.poids = 0      -- peso de la conexión
    connexion.innovation = 0 -- número de innovación único
    connexion.allume = false -- para la visualización: true si la señal pasa
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- número de neuronas ocultas
        fitness = 1,          -- rendimiento (distancia recorrida)
        idEspeceParent = 0,   -- a qué especie pertenece
        lesNeurones = {},     -- tabla de neuronas
        lesConnexions = {}    -- tabla de conexiones
    }
    -- Inicialización con los inputs
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Luego los outputs
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

Al principio, cada red solo contiene inputs y outputs: ninguna neurona oculta, ninguna conexión. Es el algoritmo el que decidirá si hacen falta.

Las mutaciones en detalle

Mutación de los pesos

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : reset total del peso
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : variación de ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

El peso inicial siempre es 1 o -1 (genererPoids()). La variación de ±0.80 puede hacerlo oscilar entre valores negativos y positivos, lo que cambia radicalmente el comportamiento de la red.

Añadir conexión

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Aleatorización de la lista de neuronas
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Conexión válida : input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Comprobar que no haya ya una conexión
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

No se puede conectar un output a un input (crearía un ciclo), y no se pueden conectar dos neuronas ya ligadas. La aleatorización garantiza que exploramos distintas posibilidades cada vez.

Añadir neurona

Es la mutación más interesante:

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Aleatorización de las conexiones
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Desactivar la conexión existente
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Crear la neurona oculta
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Conectar la entrada a la neurona oculta
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Conectar la neurona oculta a la salida
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

El mecanismo: cogemos una conexión existente, la desactivamos, e insertamos una neurona oculta en medio. La conexión de origen se reemplaza por dos nuevas conexiones: entrada→oculta y oculta→salida. Es como si "cortáramos" un cable para intercalar un interruptor.

Eso hace NEAT "augmenting topologies": la red crece con el tiempo. Empieza simple y solo se vuelve compleja si hace falta.

El feedForward

Es la función que propaga las señales a través de la red:

function feedForward(unReseau)
    -- Reset de las neuronas de salida
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Propagación
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Cada conexión activa envía valor_entrada × peso hacia la neurona de salida. El valor se acumula (suma). La bandera allume es solo para la visualización del red.

La lectura de la memoria del juego

La función getLesInputs() es la que traduce el mundo de Super Mario World en datos comprensibles para la red:

function getLesInputs()
    local lesInputs = {}
    -- Inicialización a 0 (gris = nada)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Los sprites (enemigos) = -1 (negro)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Los tiles (bloques) = valor del tile (blanco si > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

La parrilla de inputs es una vista centrada en Mario: 11 tiles en anchura, 9 en altura. Cada tile vale:

  • 0 (gris): nada
  • 1 (blanco): bloque sólido
  • -1 (negro): enemigo

Los enemigos se leen desde dos listas en la RAM: los sprites normales (0x14C8-0x14F8) y los extended sprites (0x170B-0x173B). Para cada sprite vivo (estado > 7), calculamos su posición en tiles respecto a Mario y ponemos -1 en la casilla correspondiente.

El fitness: cómo la IA sabe si progresa

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- ¡Nivel terminado!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario avanza hacia la derecha
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Actualización de los inputs
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

El fitness es simple: es la distancia recorrida hacia la derecha. Si Mario avanza 10 píxeles, el fitness sube 10. Si Mario retrocede, no pasa nada (sin penalización). Si el nivel se termina (dirección 0x0100 == 12), el fitness se vuelve 1 000 000.

Es intencionadamente simple. Solo la progresión hacia la derecha cuenta: matar enemigos no aporta nada, morir no cuesta nada.

El reset inteligente

Si Mario no se mueve durante 33 frames, reseteamos el nivel y pasamos al siguiente individuo. Pero si Mario progresó (su fitness actual es distinto del inicial), esperamos 300 frames: eso da una oportunidad a la red de "entender" lo que hizo bien.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

La condición memory.readbyte(0x0071) ~= 9 comprueba que Mario no esté en animación de muerte. El reset se vuelve inútil cuando Mario ya está en animación de muerte.

El bucle principal

El bucle gira a 30 fps (la velocidad normal de Super Mario World):

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Visualización (red, infos)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- acelerar
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- Las 3 funciones vitales
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Reset si no hay progreso
    -- ...
    -- Nueva generación si todos los individuos probados
    -- ...
end

Las tres funciones vitales son majReseau, feedForward, y appliquerLesBoutons. Si desactivamos una sola, Mario ya no se mueve.

El crossover

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

El bebé hereda del mejor padre. Para cada conexión que comparte la misma innovación, el otro padre tiene 50% de probabilidad de reemplazarla, pero solo si la conexión está activa. Es un correctivo importante: sin eso, podían crearse neuronas ocultas inútiles.

La selección por especies

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Cálculo fitness medio por especie
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Cada especie crea un número de hijos proporcional a su fitness medio
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

La idea: una especie con un fitness medio de 10 000 tiene derecho a crear muchos más hijos que una especie con un fitness medio de 1. Es la selección natural en acción.

El choisirParent usa una selección por ruleta: cuanto más fitness un individuo, más probabilidad de ser seleccionado como padre.

El guardado y la carga

Las poblaciones se guardan en archivos .pop:

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

El guardado incluye también el mejor individuo de todas las poblaciones previas. Si el mejor de la población vieja es mejor que el mejor de la nueva, retomamos el viejo como base. Es una forma de elitismo: nunca perdemos el mejor.

El dibujo de la red

Laupok añadió un visualizador de la red de neuronas en overlay del juego:

function dessinerUnReseau(unReseau)
    -- Inputs : parrilla 11×9 alrededor de Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- enemigo
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- bloque
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Outputs : 8 botones
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Conexiones
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

Es superútil para entender qué hace la red. Las conexiones activas son blancas, las inactivas semitransparentes. Los inputs son una parrilla de casillas blancas/negras/grises. Los outputs muestran qué botones están pulsados.


Los resultados

Lo que la IA aprendió

Con las horas (y los días) de ejecución, la IA descubrió por sí sola:

  1. Avanzar hacia la derecha: el comportamiento más básico, pero que necesita mantener el botón Derecha pulsado
  2. Saltar sobre los enemigos: conectando un input "enemigo detectado" al botón A o B
  3. Evitar los obstáculos: algunas redes aprendieron a retroceder temporalmente para avanzar mejor
  4. Terminar niveles: el mejor individuo pudo terminar el primer nivel de Super Mario World

Mario controlado por la IA frente a un Boo en un nivel de Super Mario World: la red de neuronas decide las acciones en tiempo real

Las limitaciones

El proyecto tiene sus límites:

  • Un solo nivel: la IA se entrena en un solo nivel específico. No generaliza automáticamente a otros niveles
  • Tiempo de entrenamiento: hacen falta decenas de horas para alcanzar resultados satisfactorios
  • Sin comprensión: la IA no "entiende" lo que hace. Optimiza una función de fitness (distancia recorrida) vía mutaciones aleatorias
  • T-bagging: Laupok nota que Mario tiende a saltar en el sitio cuando ve un enemigo, simplemente porque eso aumenta el fitness (avanza un poco saltando)

Cómo reproducir el experimento

Laupok lo compartió todo. Aquí los pasos:

  1. Descargar BizHawk en tasvideos.org (sección Download)
  2. Obtener una ROM USA de Super Mario World (copia privada de tu propio cartucho)
  3. Descargar el script Lua desde Pastebin: renombrar a mario.lua
  4. Colocar el script en la misma carpeta que la ROM
  5. Lanzar BizHawk, abrir la ROM
  6. En la consola Lua: dofile("mario.lua") o vía el menú Script > Open Script
  7. Guardar un state al inicio del nivel (menú Savestate > Save State) y llamarlo debut.state
  8. Relanzar el script: funciona

El script incluye un formulario con opciones:

  • Acelerar: desactiva el límite de 30 fps para ir más rápido
  • Mostrar red: muestra la red de neuronas en overlay
  • Mostrar infos: muestra una banda con la generación, el fitness, el número de especies
  • Pausa: pausa la ejecución
  • Guardar/Cargar: persiste la población actual en un archivo .pop

Fuentes y referencias

Recurso Enlace
Vídeo principal de Laupok j'ai créé une IA qui joue à mario toute seule
Vídeo code review + setup comment setup l'ia + code source review
Código fuente completo Pastebin Jcvdqhqm
Artículo original de NEAT Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
Tutorial de N8Programs NEAT implementation walkthrough (JavaScript, pero los conceptos son idénticos)
16blings (inspiración de Laupok) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Memoria de Super Mario World SMW Central - RAM Map

Conclusión

Lo que hizo Laupok es coger un algoritmo académico (NEAT, 2002), reescribirlo en Lua para un emulador (BizHawk), y aplicarlo a Super Mario World. El resultado: una IA que aprende desde cero a jugar al juego, sin ningún conocimiento previo, solo por mutaciones aleatorias y selección natural.

Es un ejemplo magnífico de la potencia de los algoritmos genéticos. La receta cabe en tres ingredientes: la selección natural, un poco de Lua, y mucha paciencia. Ningún deep learning, ningún GPU, ningún dato de entrenamiento masivo.

El código está comentado, compartido, y Laupok hizo dos vídeos explicativos: uno para los grandes conceptos, otro para el código. Si el tema te interesa, sumérgete. Es más accesible de lo que parece.

Laupok construiu uma IA que joga Super Mario World sozinha -- como funciona

Uma análise aprofundada do projeto de Laupok: uma IA baseada em NEAT que aprende a jogar Super Mario World de forma autônoma. Algoritmos genéticos, redes neurais, neuroevolução de topologias crescentes e 4200 linhas de Lua.

Laupok construiu uma IA que joga Super Mario World sozinha -- como funciona

Laupok construiu uma inteligência artificial que joga Super Mario World de forma completamente autônoma. Sem entradas pré-programadas, sem quadros gravados. A IA aprende sozinha, através de mutações aleatórias e seleção natural, para completar os fases do jogo. O projeto roda no BizHawk, um emulador multiplataforma, através de um script Lua de aproximadamente 4200 linhas.

O que torna este projeto fascinante é que ele se baseia em conceitos biológicos aplicados à computação: a teoria da evolução de Darwin, redes neurais artificiais e, o mais importante, um algoritmo específico chamado NEAT (NeuroEvolution of Augmenting Topologies). A IA não sabe nada sobre o jogo no início. Ela tenta coisas aleatórias, falha milhares de vezes e gradualmente descobre como se movimentar, pular e sobreviver.

Neste artigo, vamos analisar tudo -- conceito por conceito, linha de código por linha de código.

Laupok apresenta o algoritmo NEAT na câmera


A configuração: BizHawk, Lua e Super Mario World

O emulador BizHawk

BizHawk é um emulador de código aberto que suporta muitos consoles -- NES, SNES, Genesis, PS1, Game Boy e muitos outros. Sua característica principal é que ele pode executar scripts Lua junto com o jogo. Esses scripts têm acesso à RAM (memória de acesso aleatório) da emulação, o que significa que podem ler -- e modificar -- quaisquer dados do jogo em tempo real.

Concretamente, isso significa que você pode:

  • Ler a posição de Mario no fase
  • Saber quais sprites (inimigos, itens) estão na tela
  • Saber o estado de cada tile (bloco) ao redor de Mario
  • Controlar o controle -- pressionar qualquer botão

Isso é exatamente o que você precisa para fazer uma IA jogar.

Endereços de memória de Super Mario World

Na RAM de Super Mario World, cada pedaço de dado é armazenado em um endereço específico. É como um bairro: cada endereço corresponde a uma "casa" que contém uma informação. Por exemplo:

Endereço Dado
0x94-0x95 Posição X de Mario (16 bits, little-endian)
0x96-0x97 Posição Y de Mario
0x14C8+i Estado do sprite i (>7 = vivo)
0xE4+i Byte baixo da posição X do sprite i
0x14E0+i Byte alto da posição X do sprite i
0xD8+i Byte baixo da posição Y do sprite i
0x14D4+i Byte alto da posição Y do sprite i
0x170B+i Tipo do sprite estendido i
0x0100 Estado do jogo (12 = fase concluída)
0x13D4 Pausa ativa
0x0071 Animação da morte de Mario (9 = morto)
0x1C800+... Tabela de tiles do fase

As posições dos sprites usam dois bytes: um byte "baixo" e um byte "alto", porque a posição pode exceder 255 pixels. A fórmula é sempre baixo + alto × 256.

Para tiles é mais complexo: o endereço base é 0x1C800 e você calcula o offset baseado nas coordenadas x e y do tile no mundo, com um passo de 16 pixels por tile.

Super Mario World com uma sobreposição de depuração mostrando endereços de memória dos sprites e a posição de Mario


O básico: algoritmos genéticos e redes neurais

Antes de mergulhar no código, você precisa entender dois conceitos fundamentais. Sem eles, nada mais faz sentido.

Algoritmos genéticos

Um algoritmo genético é uma simulação da teoria da evolução. A ideia central: você cria uma população de indivíduos, cada um com características ligeiramente diferentes ("genes"). Você os deixa "viver" em um ambiente. Os que se saem melhor sobrevivem e se reproduzem. Os que se saem mal desaparecem.

Laupok ilustra isso com uma analogia de Kirby:

  • Uma população de Kirbys aparece em um terreno com espinhos e tomates
  • Os espinhos removem pontos de vida, os tomates restauram
  • Cada Kirby tem genes: tamanho, velocidade, HP, comportamento (fugir, procurar tomates, correr cegamente)

Dupla hélice de DNA com rótulos "the baby", "size", "speed", "color" -- os genes que compõem um indivíduo

  • Após 15 segundos, você verifica quem sobreviveu mais tempo
  • O melhor Kirby se reproduz com os outros: os filhos herdam metade dos genes do melhor e metade dos do "pior"
  • Os filhos sofrem mutações aleatórias (um pouco maiores, um pouco mais rápidos...)
  • Os Kirbys antigos são substituídos pelos novos
  • Você reinicia

Após 180 gerações (~15 horas), os Kirbys passam de 15 segundos de sobrevivência a 15 minutos. Eles ficaram pequenos (hitbox menor), rápidos e fogem constantemente do perigo.

Simulação de Kirby geração 0: círculos coloridos aleatoriamente espalhados em um fundo preto, todos com tamanho semelhante

Simulação de Kirby geração 1866: Kirbys são menores, mais rápidos e fogem sistematicamente do perigo

Estatísticas da simulação de Kirby: fitness, HP, comportamento de cada indivíduo classificado por desempenho

O ponto crucial: você não define a solução. O algoritmo encontra por conta própria. E é exatamente isso que o torna poderoso para problemas onde você não sabe qual seria a combinação ótima de parâmetros.

Redes neurais artificiais

Uma rede neural é um modelo matemático simplificado do cérebro humano. Ela consiste em:

  • Neurônios de entrada: o que a rede "vê"
  • Neurônios de saída: o que a rede "decide"
  • Conexões (pesos): cada conexão tem um peso que amplifica ou atenua o sinal

O princípio é simples: cada neurônio de entrada envia seu valor. Ele é multiplicado pelo peso da conexão, depois somado a outros sinais. Se o resultado excede um certo limiar (a função de ativação), o neurônio de saída dispara.

Na analogia de Laupok com Mario e o cursor do mouse:

  • Neurônio de entrada = distância entre Mario e o cursor
  • Peso da conexão = sensibilidade de Mario
  • Neurônio de saída = Mario grita ou não

Quanto mais perto o cursor, maior o valor de entrada. Se o peso for forte, o sinal de saída é forte, e Mario gritaria. Ao mudar o peso, você muda a sensibilidade de Mario.

A demonstração "Mario está assustado": Mario enfrenta um Boo com uma barra de sinapse mostrando o peso da conexão entre entrada e saída

Na rede neural da IA real, é a mesma lógica, mas em escala massiva:

  • 99 neurônios de entrada (11×9 tiles da visão de Mario)
  • 8 neurônios de saída (A, B, X, Y, Cima, Baixo, Esquerda, Direita)
  • Neurônios ocultos entre eles
  • Centenas de conexões com pesos variados

NEAT: o algoritmo que muda tudo

O problema com algoritmos genéticos básicos

Se você combina ingenuamente um algoritmo genético com uma rede neural, tem um problema: você cria 100 redes neurais completamente diferentes e não consegue compará-las. Cada uma tem seus próprios neurônios, conexões e pesos. Como você sabe se duas redes são "similares" ou "diferentes"?

É aqui que o NEAT entra -- NeuroEvolution of Augmenting Topologies. Inventado por Kenneth Stanley e Risto Miikkulainen em 2002, ele resolve exatamente esse problema.

Espécies

O primeiro mecanismo-chave do NEAT são as espécies. Quando uma rede neural se torna muito diferente de outra, ela é classificada em uma espécie diferente. A similaridade é calculada através de três parâmetros:

  1. Excesso (EXCES_COEF = 0.50): o número de conexões que não têm nada em comum entre duas redes (inovações diferentes)
  2. Disjunta: idêntico, mas para conexões no meio
  3. Diferença de peso (POIDSDIFF_COEF = 0.92): a diferença média de peso entre conexões que compartilham a mesma inovação

A fórmula da pontuação:

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

Se esta pontuação estiver abaixo de DIFF_LIMITE (1.0), as duas redes estão na mesma espécie. Caso contrário, uma nova espécie é criada.

Inovações

Esta é a genialidade do NEAT. Toda vez que uma conexão é criada, ela recebe um número de inovação único e global. Este número acompanha a rede neural mesmo quando ela se reproduce.

Concretamente, quando um filho é criado através de crossover, ele herda as inovações de seus pais. Se duas redes compartilham a mesma inovação, significa que elas têm uma conexão do mesmo ancestral. É isso que permite comparar redes de tamanhos diferentes.

Crossover

Quando duas redes neurais se reproduzem, o crossover funciona assim:

Laupok explica o conceito de crossover com o texto "CROSSOVER" sobreposto

  1. A rede com melhor desempenho se torna o "pai dominante"
  2. O filho herda todas as conexões do dominante
  3. Para cada conexão que compartilha a mesma inovação, o outro pai pode substituí-la (50% de chance)
  4. Apenas conexões ativas do pai não-dominante podem substituir

Isso garante que o filho seja sempre pelo menos tão bom quanto o melhor pai.

Mutações

Após o crossover, o filho sofre mutações com probabilidades configuráveis:

Laupok explica mutações com o texto "(small modif = mutation)" sobreposto

Mutação Probabilidade Efeito
Redefinir peso da conexão 25% O peso é completamente randomizado
Mutação de peso 95% O peso varia em ±0.80
Adicionar conexão 85% Nova conexão entre dois neurônios não ligados
Adicionar neurônio 39% Um neurônio oculto é inserido entre dois neurônios conectados

A taxa de adição de neurônios é importante: é ela que permite que a rede cresça. No início, há apenas entradas e saídas. Gradualmente, neurônios ocultos aparecem, tornando a rede cada vez mais complexa.


O código: análise completa

Constantes

O script começa com um bloco de constantes que define todas as configurações:

-- Mario's view around him
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 pixels wide
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 pixels tall
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tiles
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tiles

-- Neural network
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 inputs (visible tiles)
NB_OUTPUT = 8  -- A, B, X, Y, Up, Down, Left, Right
NB_INDIVIDU_POPULATION = 100  -- individuals per population
NB_NEURONE_MAX = 100000  -- max hidden neurons

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- value when level is finished
NB_FRAME_RESET_BASE = 33  -- frames without progress before reset
NB_FRAME_RESET_PROGRES = 300  -- frames if progress detected

-- Species
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Mutations
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT é 99 porque a visão de Mario é de 11×9 tiles. Cada tile é um neurônio de entrada. Tile vazio = 0. Bloco = 1. Inimigo = -1.

As 8 saídas correspondem aos botões do controle do SNES: A, B, X, Y, Cima, Baixo, Esquerda, Direita. Start, Select, L e R são excluídos para que não "distrainham" Mario.

Estruturas de dados

O script define três estruturas principais:

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- current neuron value
    neurone.id = 0        -- unique identifier
    neurone.type = ""     -- "input", "output", or "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- source neuron ID
    connexion.sortie = 0     -- destination neuron ID
    connexion.actif = true   -- can be disabled if a hidden neuron is inserted
    connexion.poids = 0      -- connection weight
    connexion.innovation = 0 -- unique innovation number
    connexion.allume = false -- for display: true if signal passes
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- number of hidden neurons
        fitness = 1,          -- performance (distance traveled)
        idEspeceParent = 0,   -- which species it belongs to
        lesNeurones = {},     -- neuron array
        lesConnexions = {}    -- connection array
    }
    -- Initialize with inputs
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Then outputs
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

No início, cada rede tem apenas entradas e saídas. Sem neurônios ocultos, sem conexões. O algoritmo decide se algum é necessário.

Mutações em detalhe

Mutação de peso

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25%: total weight reset
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75%: variation of ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

O peso inicial é sempre 1 ou -1 (genererPoids()). A variação de ±0.80 pode oscilá-lo entre valores negativos e positivos, mudando radicalmente o comportamento da rede.

Adicionando uma conexão

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Shuffle the neuron list
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Valid connection: input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Check no connection already exists
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

Você não pode conectar uma saída a uma entrada (isso criaria um ciclo) e não pode conectar dois neurônios que já estão ligados. Embaralhar garante que diferentes possibilidades sejam exploradas a cada vez.

Adicionando um neurônio

Esta é a mutação mais interessante:

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Shuffle connections
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Disable the existing connection
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Create the hidden neuron
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Connect input to hidden neuron
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Connect hidden neuron to output
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

O mecanismo: você pega uma conexão existente, desativa-a e insere um neurônio oculto no meio. A conexão original é substituída por duas novas: entrada→oculto e oculto→saída. É como cortar um fio para inserir um interruptor.

É isso que torna o NEAT "augmenting topologies": a rede cresce ao longo do tempo. Ela começa simples e se torna complexa apenas quando necessário.

O feedForward

Esta é a função que propaga os sinais pela rede:

function feedForward(unReseau)
    -- Reset output neurons
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Propagation
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Cada conexão ativa envia valor_da_entrada × peso para o neurônio de saída. O valor é acumulado (somado). A flag allume é apenas para a exibição visual da rede.

Lendo a memória do jogo

A função getLesInputs() traduz o mundo de Super Mario World em dados que a rede pode entender:

function getLesInputs()
    local lesInputs = {}
    -- Initialize to 0 (gray = nothing)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Sprites (enemies) = -1 (black)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Tiles (blocks) = tile value (white if > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

A grade de entrada é uma visão centrada em Mario: 11 tiles de largura, 9 de altura. O valor de cada tile:

  • 0 (cinza): nada
  • 1 (branco): bloco sólido
  • -1 (preto): inimigo

Os inimigos são lidos de duas listas na RAM: sprites normais (0x14C8-0x14F8) e sprites estendidos (0x170B-0x173B). Para cada sprite vivo (estado > 7), sua posição em tiles relativa a Mario é calculada e -1 é colocado na célula correspondente.

Fitness: como a IA sabe que está progredindo

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Level finished!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario moved right
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Update inputs
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

Fitness é simples: é a distância percorrida para a direita. Se Mario se move 10 pixels, o fitness aumenta em 10. Se Mario se move para a esquerda, nada acontece (sem penalidade). Se a fase é concluída (endereço 0x0100 == 12), o fitness se torna 1.000.000.

É intencionalmente simples. Sem bônus por matar inimigos, sem penalidade por morrer. Apenas: mova-se para a direita.

Reset inteligente

Se Mario não se move por 33 quadros, o fase é reiniciado e passamos para o próximo indivíduo. Mas se Mario fez progresso (o fitness atual difere do início), esperamos 300 quadros -- dando à rede a chance de "entender" o que ela fez de certo.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

A condição memory.readbyte(0x0071) ~= 9 verifica que Mario não está em sua animação de morte. Não há sentido reiniciar se Mario já está morto.

O loop principal

O loop roda a 30 fps (velocidade normal do Super Mario World):

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Display (network, info)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- speed up
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- The 3 vital functions
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Reset if no progress
    -- ...
    -- New generation if all individuals tested
    -- ...
end

As três funções vitais são majReseau, feedForward e appliquerLesBoutons. Desative qualquer uma delas e Mario para de se mover.

Crossover

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

O filho herda do melhor pai. Para cada conexão que compartilha a mesma inovação, o outro pai tem 50% de chance de substituí-la -- mas apenas se a conexão estiver ativa. Esta é uma correção importante: sem ela, neurônios ocultos inúteis poderiam ser criados.

Seleção por espécies

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Calculate average fitness per species
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Each species creates a number of children proportional to its average fitness
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

A ideia: uma espécie com fitness médio de 10.000 pode criar muito mais filhos do que uma espécie com fitness médio de 1. Isso é seleção natural em ação.

choisirParent usa seleção por roleta: quanto maior o fitness de um indivíduo, maior a probabilidade de ser selecionado como pai.

Salvando e carregando

As populações são salvas em arquivos .pop:

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

O salvamento também inclui o melhor indivíduo de todas as populações anteriores. Se o melhor da população antiga for melhor que o novo, revertemos para o antigo como base. Esta é uma forma de elitismo: o melhor nunca é perdido.

Visualização da rede

Laupok adicionou um visualizador de rede neural sobreposto ao jogo:

function dessinerUnReseau(unReseau)
    -- Inputs: 11×9 grid around Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- enemy
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- block
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Outputs: 8 buttons
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Connections
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

É incrivelmente útil para entender o que a rede faz. Conexões ativas são brancas, inativas são semitransparentes. As entradas são uma grade de células brancas/pretas/cinza. As saídas mostram quais botões estão sendo pressionados.


Resultados

O que a IA aprendeu

Ao longo de horas (e dias) de execução, a IA descobriu por conta própria:

  1. Mover-se para a direita: o comportamento mais básico, mas que requer segurar o botão Direita
  2. Pular sobre inimigos: conectando uma entrada "inimigo detectado" ao botão A ou B
  3. Evitar obstáculos: algumas redes aprenderam a recuar temporariamente para avançar mais
  4. Completar fases: o melhor indivíduo conseguiu completar a primeira fase do Super Mario World

Mario controlado pela IA enfrentando um Boo em uma fase do Super Mario World -- a rede neural decide ações em tempo real

Limitações

O projeto tem suas limitações:

  • Fase única: a IA é treinada em uma fase específica. Ela não se generaliza automaticamente para outras fases
  • Tempo de treinamento: são necessárias dezenas de horas para obter resultados satisfatórios
  • Sem compreensão: a IA não "entende" o que está fazendo. Ela otimiza uma função de fitness (distância percorrida) através de mutações aleatórias
  • T-bagging: Laupok observa que Mario tende a pular no lugar ao ver um inimigo, simplesmente porque isso aumenta o fitness (ele avança um pouco enquanto pula)

Como reproduzir o experimento

Laupok compartilhou tudo. Aqui estão os passos:

  1. Baixe o BizHawk em tasvideos.org (seção Download)
  2. Obtenha uma ROM dos EUA de Super Mario World (cópia privada do seu próprio cartucho)
  3. Baixe o script Lua do Pastebin -- renomeie para mario.lua
  4. Coloque o script na mesma pasta que a ROM
  5. Inicie o BizHawk, abra a ROM
  6. No console Lua: dofile("mario.lua") ou via o menu Script > Open Script
  7. Salve um estado no início da fase (menu Savestate > Save State) e nomeie como debut.state
  8. Reinicie o script -- funciona

O script inclui um formulário com opções:

  • Acelerar: desabilita o limite de 30 fps para ir mais rápido
  • Mostrar rede: exibe a rede neural sobreposta ao jogo
  • Mostrar informações: exibe um banner com geração, fitness e contagem de espécies
  • Pausa: pausa a execução
  • Salvar/Carregar: persiste a população atual em um arquivo .pop

Fontes e referências

Recurso Link
Vídeo principal de Laupok I built an AI that plays Mario by itself
Revisão do código + vídeo de configuração How to set up the AI + source code review
Código-fonte completo Pastebin Jcvdqhqm
Artigo original do NEAT Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
Tutorial N8Programs NEAT implementation walkthrough (JavaScript, mas os conceitos são idênticos)
16blings (inspiração de Laupok) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Memória de Super Mario World SMW Central - RAM Map

Conclusão

O que Laupok fez foi pegar um algoritmo acadêmico (NEAT, 2002), reescrevê-lo em Lua para um emulador (BizHawk) e aplicá-lo ao Super Mario World. O resultado: uma IA que aprende do zero a jogar o jogo, sem conhecimento prévio, apenas através de mutações aleatórias e seleção natural.

É um belo exemplo do poder dos algoritmos genéticos. Sem deep learning, sem GPU, sem milhões de dados de treinamento. Apenas seleção natural, um pouco de Lua e muita paciência.

O código é comentado, compartilhado, e Laupok fez dois vídeos explicativos -- um para os grandes conceitos e outro para o código. Se o tema te interessa, mergulhe. É mais acessível do que parece.

Laupok membuat AI yang memainkan Super Mario World sendiri -- cara kerjanya

Laupok membuat AI yang memainkan Super Mario World sendiri -- cara kerjanya

Laupok membuat kecerdasan buatan yang memainkan Super Mario World secara sepenuhnya otonom. Ia berangkat dari nol : tanpa skrip yang ditentukan sebelumnya, tanpa frame yang direkam. AI belajar sendiri, lewat mutasi acak dan seleksi alam, menyelesaikan level permainan. Proyek berjalan di BizHawk, emulator multiplatform, lewat skrip Lua sekitar 4200 baris.

Yang membuat proyek ini menarik adalah ia bersandar pada konsep biologis yang diterapkan pada informatika : teori evolusi Darwin, jaringan saraf buatan, dan terutama algoritma spesifik bernama NEAT (NeuroEvolution of Augmenting Topologies). AI tidak tahu apa pun soal permainan pada awalnya. Ia menguji aksi acak, gagal ribuan kali, dan sedikit demi sedikit, ia paham cara maju, melompat, dan bertahan.

Dalam artikel ini, kita akan membedah semuanya -- konsep per konsep, baris kode per baris kode.

Laupok memperkenalkan algoritma NEAT di depan kamera


Penyiapan : BizHawk, Lua, dan Super Mario World

Emulator BizHawk

BizHawk adalah emulator open source yang mendukung segunung konsol -- NES, SNES, Genesis, PS1, Game Boy, dan banyak lainnya. Kekhasannya adalah ia memungkinkan menjalankan skrip Lua paralel dengan permainan. Skrip ini punya akses ke memori (RAM) emulasi, yang berarti mereka bisa membaca -- dan mengubah -- data permainan apa pun waktu nyata.

Konkretnya, itu berarti kita bisa :

  • Membaca posisi Mario di level
  • Mengetahui sprite (musuh, item) mana yang ada di layar
  • Mengetahui keadaan setiap tile (blok) di sekitar Mario
  • Mengendalikan stik -- menekan tombol apa pun

Tepat itulah yang kita butuhkan untuk membuat AI bermain.

Alamat memori Super Mario World

Di RAM Super Mario World, setiap data disimpan di alamat spesifik. Ini agak seperti perumahan : setiap alamat cocok dengan sebuah "rumah" yang berisi informasi. Misalnya :

Alamat Data
0x94-0x95 Posisi X Mario (16 bit, little-endian)
0x96-0x97 Posisi Y Mario
0x14C8+i Keadaan sprite i (>7 = hidup)
0xE4+i Posisi X rendah sprite i
0x14E0+i Posisi X tinggi sprite i
0xD8+i Posisi Y rendah sprite i
0x14D4+i Posisi Y tinggi sprite i
0x170B+i Tipe extended sprite i
0x0100 Keadaan permainan (12 = level selesai)
0x13D4 Jeda aktif
0x0071 Animasi kematian Mario (9 = mati)
0x1C800+... Tabel tile level

Posisi sprite memakai dua oktet : oktet "rendah" dan oktet "tinggi", karena posisi bisa melebihi 255 piksel. Formulaselalu rendah + tinggi × 256.

Untuk tile, lebih kompleks : alamat dasarnya 0x1C800, dan offset dihitung menurut koordinat x dan y tile di dunia, dengan langkah 16 piksel per tile.

Super Mario World dengan overlay debug yang menunjukkan alamat memori sprite dan posisi Mario


Dasar : algoritma genetika dan jaringan saraf

Sebelum menyelam ke kode, harus paham dua konsep fundamental. Tanpa mereka, sisanya tidak masuk akal.

Algoritma genetika

Algoritma genetika adalah simulasi teori evolusi. Ide sentralnya : kita membuat populasi individu, masing-masing dengan karakteristik ("gen") yang sedikit berbeda. Kita membuat mereka "hidup" di lingkungan. Yang paling berhasil bertahan dan bereproduksi. Yang buruk hilang.

Laupok menggambarkan ini dengan analogi Kirby :

  • Populasi Kirby muncul di medan dengan duri dan tomat
  • Duri mengurangi nyawa, tomat mengembalikannya
  • Setiap Kirby punya gen : ukuran, kecepatan, nyawa, perilaku (kabur, mencari tomat, menyerbu ke mana saja)

Heliks ganda DNA dengan label "bayi", "ukuran", "kecepatan", "warna" -- gen yang menyusun individu

  • Setelah 15 detik, kita lihat siapa bertahan paling lama
  • Kirby terbaik bereproduksi dengan lainnya : bayi mewarisi setengah gen dari yang terbaik dan setengah dari yang "terburuk"
  • Bayi mengalami mutasi acak (sedikit lebih besar, sedikit lebih cepat...)
  • Kirby lama diganti yang baru
  • Kita jalankan ulang

Setelah 180 generasi (~15 jam), Kirby beralih dari 15 detik bertahan hidup menjadi 15 menit. Mereka menjadi kecil (zona tabrakan mengecil), cepat, dan kabur dari bahaya terus-menerus.

Simulasi Kirby generasi 0 : lingkaran berwarna tersebar acak pada latar hitam, semuanya berukuran mirip

Simulasi Kirby generasi 1866 : Kirby lebih kecil, lebih cepat, dan selalu kabur dari bahaya

Statistik simulasi Kirby : fitness, jumlah nyawa, perilaku tiap individu diperingkat berdasar performa

Poin krusialnya : kita tidak mendefinisikan solusinya. Algoritmalah yang menemukannya sendiri. Dan tepat itulah yang membuatnya kuat untuk masalah di mana kita tidak tahu kombinasi parameter apa yang optimal.

Jaringan saraf buatan

Jaringan saraf adalah model matematis sederhana dari otak manusia. Ia terdiri atas :

  • Neuron masukan (input) : apa yang "dilihat" jaringan
  • Neuron keluaran (output) : apa yang "diputuskan" jaringan
  • Koneksi (bobot) : setiap koneksi punya bobot yang memperkuat atau melemahkan sinyal

Prinsipnya sederhana : setiap neuron masukan mengirim nilainya. Ia dikalikan bobot koneksi, lalu ditambahkan ke sinyal lain. Kalau hasilnya melampaui ambang tertentu (fungsi aktivasi), neuron keluaran aktif.

Dalam analogi Laupok dengan Mario dan ujung tikus :

  • Neuron masukan = jarak antara Mario dan ujung
  • Bobot koneksi = sensitivitas Mario
  • Neuron keluaran = Mario berteriak atau tidak

Makin dekat ujungnya, makin tinggi nilai masukannya. Kalau bobotnya kuat, sinyal yang dikirim ke keluaran kuat, dan Mario berteriak. Dengan mengubah bobot, kita mengubah sensitivitas Mario.

Demo "Mario takut" : Mario menghadapi Boo dengan bar sinaps yang menampilkan bobot koneksi antara masukan dan keluaran

Di jaringan saraf sungguhan AI, logikanya sama, tapi dalam skala masif :

  • 99 neuron masukan (tile pandangan Mario 11×9)
  • 8 neuron keluaran (A, B, X, Y, Atas, Bawah, Kiri, Kanan)
  • Neuron tersembunyi (hidden) di antara keduanya
  • Ratusan koneksi dengan bobot bervariasi

NEAT : algoritma yang mengubah segalanya

Masalah algoritma genetika klasik

Kalau kita menggabung algoritma genetika dengan jaringan saraf secara naif, kita punya masalah : kita membuat 100 jaringan saraf yang sama sekali berbeda, dan kita tidak tahu cara membandingkannya. Masing-masing punya neuron sendiri, koneksi sendiri, bobot sendiri. Bagaimana tahu apakah dua jaringan "dekat" atau "jauh"?

Di sinilah NEAT berperan -- NeuroEvolution of Augmenting Topologies. Diciptakan oleh Kenneth Stanley dan Risto Miikkulainen pada 2002, ini algoritma yang memecahkan tepat masalah ini.

Spesies

Mekanisme kunci pertama NEAT adalah spesies. Ketika jaringan saraf menjadi terlalu berbeda dari lainnya, ia diklasifikasikan dalam spesies berbeda. Kemiripan dihitung lewat tiga parameter :

  1. Excess (EXCES_COEF = 0.50) : jumlah koneksi yang tidak ada hubungannya antara dua jaringan (inovasi berbeda)
  2. Disjoint : sama, tapi untuk koneksi yang terletak di tengah
  3. Perbedaan bobot (POIDSDIFF_COEF = 0.92) : perbedaan bobot rata-rata antara koneksi yang berbagi inovasi yang sama

Formula skornya :

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

Kalau skor ini di bawah DIFF_LIMITE (1.0), kedua jaringan ada dalam spesies yang sama. Kalau tidak, kita membuat spesies baru.

Inovasi

Inilah kejeniusan NEAT. Setiap kali koneksi dibuat, ia menerima nomor inovasi yang unik dan global. Nomor ini mengikuti jaringan saraf bahkan ketika ia bereproduksi.

Konkretnya, ketika bayi dibuat lewat crossover, ia mewarisi inovasi orang tuanya. Kalau dua jaringan berbagi inovasi yang sama, artinya mereka punya koneksi yang berasal dari leluhur yang sama. Inilah yang memungkinkan membandingkan jaringan berukuran berbeda.

Crossover

Ketika dua jaringan saraf bereproduksi, crossover bekerja begini :

Laupok menjelaskan konsep crossover dengan teks "CROSSOVER" dalam overlay

  1. Jaringan paling performan menjadi "orang tua dominan"
  2. Bayi mewarisi semua koneksi dari yang dominan
  3. Untuk setiap koneksi yang berbagi inovasi yang sama, orang tua lain bisa menggantikannya (peluang 50%)
  4. Hanya koneksi aktif dari orang tua non-dominan yang bisa menggantikan

Itu menjamin bayi selalu setidaknya sebaik orang tua terbaik.

Mutasi

Setelah crossover, bayi mengalami mutasi dengan probabilitas yang bisa dikonfigurasi :

Laupok menjelaskan mutasi dengan teks "(perubahan kecil = mutasi)" dalam overlay

Mutasi Probabilitas Efek
Reset bobot koneksi 25% Bobot diacak total
Mutasi bobot 95% Bobot bervariasi ±0.80
Tambah koneksi 85% Koneksi baru antara dua neuron yang belum terhubung
Tambah neuron 39% Neuron tersembunyi disisipkan antara dua neuron yang terhubung

Tingkat penambahan neuron penting : inilah yang memungkinkan jaringan tumbuh. Pada awalnya, hanya ada masukan dan keluaran. Berangsur, neuron tersembunyi muncul, membuat jaringan makin kompleks.


Kodenya : walkthrough lengkap

Konstanta

Skrip dimulai dengan blok konstanta yang mendefinisikan seluruh pengaturan :

-- Tampilan Mario di sekitarnya
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 piksel lebarnya
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 piksel tingginya
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tile
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tile

-- Jaringan saraf
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 input (tile yang terlihat)
NB_OUTPUT = 8  -- A, B, X, Y, Atas, Bawah, Kiri, Kanan
NB_INDIVIDU_POPULATION = 100  -- individu per populasi
NB_NEURONE_MAX = 100000  -- batas neuron tersembunyi

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- nilai ketika level selesai
NB_FRAME_RESET_BASE = 33  -- frame tanpa kemajuan sebelum reset
NB_FRAME_RESET_PROGRES = 300  -- frame kalau kemajuan terdeteksi

-- Spesies
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Mutasi
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT adalah 99 karena tampilan Mario 11×9 tile. Setiap tile adalah satu neuron masukan. Kalau tile kosong, nilainya 0. Kalau blok, nilainya 1. Kalau musuh, nilainya -1.

8 keluaran cocok dengan tombol stik SNES : A, B, X, Y, Atas, Bawah, Kiri, Kanan. Kita mengecualikan Start, Select, L dan R untuk membatasi keputusannya pada yang esensial.

Struktur data

Skrip mendefinisikan tiga struktur utama :

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- nilai neuron saat ini
    neurone.id = 0        -- pengenal unik
    neurone.type = ""     -- "input", "output", atau "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- ID neuron sumber
    connexion.sortie = 0     -- ID neuron tujuan
    connexion.actif = true   -- bisa dinonaktifkan kalau neuron tersembunyi disisipkan
    connexion.poids = 0      -- bobot koneksi
    connexion.innovation = 0 -- nomor inovasi unik
    connexion.allume = false -- untuk tampilan : true kalau sinyal lewat
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- jumlah neuron tersembunyi
        fitness = 1,          -- performa (jarak ditempuh)
        idEspeceParent = 0,   -- termasuk spesies mana
        lesNeurones = {},     -- tabel neuron
        lesConnexions = {}    -- tabel koneksi
    }
    -- Inisialisasi dengan input
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Lalu output
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

Pada awalnya, setiap jaringan hanya berisi input dan output : tanpa neuron tersembunyi, tanpa koneksi. Algoritmalah yang akan memutuskan apakah perlu.

Mutasi secara detail

Mutasi bobot

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : reset total bobot
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : variasi ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

Bobot awal selalu 1 atau -1 (genererPoids()). Variasi ±0.80 bisa membuatnya berosilasi antara nilai negatif dan positif, yang mengubah radikal perilaku jaringan.

Penambahan koneksi

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Pengacakan daftar neuron
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Koneksi valid : input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Memeriksa belum ada koneksi
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

Kita tidak bisa menghubungkan output ke input (itu menciptakan siklus), dan tidak bisa menghubungkan dua neuron yang sudah terhubung. Pengacakan menjamin kita menjelajahi kemungkinan berbeda setiap kali.

Penambahan neuron

Ini mutasi paling menarik :

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Pengacakan koneksi
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Menonaktifkan koneksi yang ada
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Membuat neuron tersembunyi
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Menghubungkan masukan ke neuron tersembunyi
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Menghubungkan neuron tersembunyi ke keluaran
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

Mekanismenya : kita mengambil koneksi yang ada, menonaktifkannya, dan menyisipkan neuron tersembunyi di tengah. Koneksi asal diganti dua koneksi baru : masukan→tersembunyi dan tersembunyi→keluaran. Seolah kita "memotong" kabel untuk menyelipkan sakelar.

Inilah yang membuat NEAT "augmenting topologies" : jaringan tumbuh seiring waktu. Ia mulai sederhana dan menjadi kompleks hanya kalau perlu.

FeedForward

Ini fungsi yang menyebarkan sinyal lewat jaringan :

function feedForward(unReseau)
    -- Reset neuron keluaran
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Penyebaran
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Setiap koneksi aktif mengirim nilai_masukan × bobot ke neuron keluaran. Nilainya diakumulasi (ditambahkan). Bendera allume hanya untuk tampilan visual jaringan.

Pembacaan memori permainan

Fungsi getLesInputs() inilah yang menerjemahkan dunia Super Mario World menjadi data yang bisa dipahami jaringan :

function getLesInputs()
    local lesInputs = {}
    -- Inisialisasi ke 0 (abu-abu = tidak ada)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Sprite (musuh) = -1 (hitam)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Tile (blok) = nilai tile (putih kalau > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

Kisi input adalah tampilan yang berpusat pada Mario : 11 tile lebarnya, 9 tingginya. Setiap tile bernilai :

  • 0 (abu-abu) : tidak ada
  • 1 (putih) : blok padat
  • -1 (hitam) : musuh

Musuh dibaca dari dua daftar di RAM : sprite normal (0x14C8-0x14F8) dan extended sprite (0x170B-0x173B). Untuk setiap sprite yang hidup (keadaan > 7), kita menghitung posisinya dalam tile relatif terhadap Mario dan menaruh -1 di petak yang cocok.

Fitness : cara AI tahu kalau ia maju

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Level selesai!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario maju ke kanan
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Pembaruan input
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

Fitness itu sederhana : yaitu jarak yang ditempuh ke kanan. Kalau Mario maju 10 piksel, fitness naik 10. Kalau Mario mundur, tidak terjadi apa-apa (tanpa penalti). Kalau level selesai (alamat 0x0100 == 12), fitness menjadi 1 000 000.

Ini sengaja sederhana. Hanya progresi ke kanan yang dihitung : membunuh musuh tidak menghasilkan apa-apa, mati tidak merugikan apa-apa.

Reset cerdas

Kalau Mario tidak bergerak selama 33 frame, kita me reset level dan lanjut ke individu berikutnya. Tapi kalau Mario membuat kemajuan (fitness saat ini berbeda dari awal), kita menunggu 300 frame -- itu memberi kesempatan pada jaringan untuk "memahami" apa yang dilakukannya dengan baik.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
     nbFrameStop = nbFrameStop + 1
     local nbFrameReset = NB_FRAME_RESET_BASE
     if fitnessInit ~= laPopulation[idPopulation].fitness
        and memory.readbyte(0x0071) ~= 9 then
         nbFrameReset = NB_FRAME_RESET_PROGRES
     end
     if nbFrameStop > nbFrameReset then
         nbFrameStop = 0
         lancerNiveau()
         idPopulation = idPopulation + 1
         -- ...
     end
end

Kondisi memory.readbyte(0x0071) ~= 9 memeriksa bahwa Mario tidak sedang dalam animasi kematian. Reset menjadi tidak berguna ketika Mario sudah dalam animasi kematian.

Loop utama

Loop berjalan pada 30 fps (kecepatan normal Super Mario World) :

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Tampilan (jaringan, info)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- mempercepat
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- 3 fungsi vital
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Reset kalau tidak ada kemajuan
    -- ...
    -- Generasi baru kalau semua individu diuji
    -- ...
end

Tiga fungsi vital adalah majReseau, feedForward, dan appliquerLesBoutons. Kalau kita menonaktifkan satu saja, Mario tidak bergerak lagi.

Crossover

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

Bayi mewarisi orang tua terbaik. Untuk setiap koneksi yang berbagi inovasi yang sama, orang tua lain punya peluang 50% menggantikannya -- tapi hanya kalau koneksinya aktif. Ini perbaikan penting : tanpanya, neuron tersembunyi yang tidak berguna bisa tercipta.

Seleksi berdasar spesies

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Menghitung fitness rata-rata per spesies
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Setiap spesies membuat sejumlah anak proporsional dengan fitness rata-ratanya
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

Idenya : spesies dengan fitness rata-rata 10 000 berhak membuat jauh lebih banyak anak daripada spesies dengan fitness rata-rata 1. Inilah seleksi alam beraksi.

choisirParent memakai seleksi roulette : makin tinggi fitness individu, makin besar peluangnya dipilih sebagai orang tua.

Penyimpanan dan pemuatan

Populasi disimpan dalam berkas .pop :

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

Penyimpanan juga menyertakan individu terbaik dari semua populasi sebelumnya. Kalau yang terbaik dari populasi lama lebih baik daripada yang terbaik dari yang baru, kita mengambil yang lama sebagai dasar. Ini bentuk elitisme : kita tidak pernah kehilangan yang terbaik.

Penggambaran jaringan

Laupok menambahkan visualisator jaringan saraf yang melapisi permainan :

function dessinerUnReseau(unReseau)
    -- Input : kisi 11×9 di sekitar Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- musuh
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- blok
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Output : 8 tombol
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Koneksi
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

Ini sangat berguna untuk memahami apa yang dilakukan jaringan. Koneksi aktif berwarna putih, yang tidak aktif semi-transparan. Input adalah kisi petak putih/hitam/abu-abu. Output menunjukkan tombol mana yang ditekan.


Hasilnya

Apa yang dipelajari AI

Sepanjang jam (dan hari) berjalan, AI menemukan sendiri :

  1. Maju ke kanan : perilaku paling dasar, tapi membutuhkan menahan tombol Kanan ditekan
  2. Melompati musuh : dengan menghubungkan input "musuh terdeteksi" ke tombol A atau B
  3. Menghindari rintangan : beberapa jaringan belajar mundur sementara untuk maju lebih baik
  4. Menyelesaikan level : individu terbaik bisa menyelesaikan level pertama Super Mario World

Mario yang dikendalikan AI menghadapi Boo di level Super Mario World -- jaringan saraf memutuskan aksi waktu nyata

Keterbatasan

Proyek punya batasnya :

  • Satu level saja : AI dilatih pada satu level spesifik. Ia tidak menggeneralisasi otomatis ke level lain
  • Waktu pelatihan : butuh puluhan jam untuk mencapai hasil yang memuaskan
  • Tanpa pemahaman : AI tidak "memahami" apa yang dilakukannya. Ia mengoptimalkan fungsi fitness (jarak ditempuh) lewat mutasi acak
  • T-bagging : Laupok mencatat Mario cenderung melompat di tempat ketika melihat musuh, hanya karena itu meningkatkan fitness (ia maju sedikit sambil melompat)

Cara mereproduksi pengalaman

Laupok membagikan semuanya. Berikut langkahnya :

  1. Unduh BizHawk di tasvideos.org (bagian Download)
  2. Dapatkan ROM USA Super Mario World (salinan privat dari kartridmu sendiri)
  3. Unduh skrip Lua dari Pastebin -- ganti nama menjadi mario.lua
  4. Taruh skrip di folder yang sama dengan ROM
  5. Jalankan BizHawk, buka ROM
  6. Di konsol Lua : dofile("mario.lua") atau lewat menu Script > Open Script
  7. Simpan state di awal level (menu Savestate > Save State) dan namai debut.state
  8. Jalankan ulang skrip -- berhasil

Skrip menyertakan formulir dengan opsi :

  • Mempercepat : menonaktifkan batas 30 fps untuk melaju lebih cepat
  • Menampilkan jaringan : menunjukkan jaringan saraf yang melapisi
  • Menampilkan info : menampilkan spanduk dengan generasi, fitness, jumlah spesies
  • Jeda : menjeda eksekusi
  • Simpan/Muat : menyimpan populasi saat ini dalam berkas .pop

Sumber dan referensi

Sumber daya Tautan
Video utama Laupok j'ai créé une IA qui joue à mario toute seule
Video code review + setup comment setup l'ia + code source review
Kode sumber lengkap Pastebin Jcvdqhqm
Artikel asli NEAT Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
Tutorial N8Programs NEAT implementation walkthrough (JavaScript, tapi konsepnya identik)
16blings (inspirasi Laupok) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Memori Super Mario World SMW Central - RAM Map

Kesimpulan

Apa yang dilakukan Laupok adalah mengambil algoritma akademis (NEAT, 2002), menulisnya ulang dalam Lua untuk emulator (BizHawk), dan menerapkannya pada Super Mario World. Hasilnya : AI yang belajar dari nol memainkan permainan, tanpa pengetahuan sebelumnya apa pun, hanya lewat mutasi acak dan seleksi alam.

Ini contoh indah kekuatan algoritma genetika. Resepnya muat dalam tiga bahan : seleksi alam, sedikit Lua, dan banyak kesabaran. Tanpa deep learning, tanpa GPU, tanpa data pelatihan masif.

Kodenya dikomentari, dibagikan, dan Laupok membuat dua video penjelasan -- satu untuk konsep besar, satu untuk kode. Kalau topik ini menarik buatmu, selami. Ini lebih mudah diakses daripada kelihatannya.

Laupok ने AI बनाई जो Super Mario World खुद खेलती है, यह कैसे काम करता है

Laupok ने AI बनाई जो Super Mario World खुद खेलती है, यह कैसे काम करता है

Laupok ने कृत्रिम बुद्धि बनाई है जो Super Mario World पूरी तरह स्वायत्त खेलती है। वह शून्य से शुरू करती है : कोई पूर्वनिर्धारित स्क्रिप्ट नहीं, कोई रिकॉर्ड किया फ्रेम नहीं। AI खुद सीखती है, बेतरतीब उत्परिवर्तन और प्राकृतिक चयन से, खेल के स्तर खत्म करना। प्रोजेक्ट BizHawk पर चलता है, बहु मंच emulator, लगभग 4200 पंक्तियों की Lua स्क्रिप्ट से।

जो इस प्रोजेक्ट को आकर्षक बनाता है, वह है कि यह कंप्यूटिंग पर लागू जैविक अवधारणाओं पर टिका है : Darwin का विकास सिद्धांत, कृत्रिम neural network, और खासकर एक विशिष्ट एल्गोरिदम जिसे NEAT (NeuroEvolution of Augmenting Topologies) कहते हैं। AI शुरू में खेल के बारे में कुछ नहीं जानती। वह बेतरतीब क्रियाएँ परखती है, हजारों बार विफल होती है, और धीरे धीरे समझती है कि आगे कैसे बढ़ना, कूदना और बचना है।

इस लेख में, हम यह सब चीड़ेंगे, अवधारणा दर अवधारणा, कोड पंक्ति दर कोड पंक्ति।

Laupok कैमरे के सामने NEAT एल्गोरिदम पेश करता है


setup : BizHawk, Lua, और Super Mario World

BizHawk emulator

BizHawk ओपन सोर्स emulator है जो बहुत सारे console समर्थन करता है, NES, SNES, Genesis, PS1, Game Boy, और बहुत अन्य। उसकी विशेषता यह है कि वह खेल के समानांतर Lua स्क्रिप्ट चलाने देता है। इन स्क्रिप्टों की emulation की RAM तक पहुँच होती है, यानी वे वास्तविक समय में खेल का कोई भी डेटा पढ़ सकते हैं, और बदल सकते हैं।

ठोस रूप में, इसका मतलब है कि हम कर सकते हैं :

  • स्तर में Mario की स्थिति पढ़ना
  • जानना कि कौन से sprite (शत्रु, item) स्क्रीन पर हैं
  • Mario के आसपास हर tile (ब्लॉक) की स्थिति जानना
  • controller नियंत्रित करना, किसी भी बटन दबाना

ठीक वही है जो AI को खिलवाने के लिए चाहिए।

Super Mario World स्मृति पते

Super Mario World RAM में, हर डेटा विशिष्ट पते पर संग्रहित है। यह थोड़ा मोहल्ले जैसा है : हर पता "घर" से मेल खाता है जिसमें जानकारी होती है। उदाहरण के लिए :

पता डेटा
0x94-0x95 Mario की X स्थिति (16 बिट, little-endian)
0x96-0x97 Mario की Y स्थिति
0x14C8+i sprite i की स्थिति (>7 = जीवित)
0xE4+i sprite i की निचली X स्थिति
0x14E0+i sprite i की ऊपरी X स्थिति
0xD8+i sprite i की निचली Y स्थिति
0x14D4+i sprite i की ऊपरी Y स्थिति
0x170B+i extended sprite i का प्रकार
0x0100 खेल स्थिति (12 = स्तर खत्म)
0x13D4 सक्रिय pause
0x0071 Mario मृत्यु एनीमेशन (9 = मरा)
0x1C800+... स्तर tile तालिका

sprite स्थिति दो octet इस्तेमाल करती है : "निचला" octet और "ऊपरी" octet, क्योंकि स्थिति 255 pixel से पार कर सकती है। सूत्र हमेशा निचला + ऊपरी × 256 है।

tile के लिए, यह जटिल है : आधार पता 0x1C800 है, और offset दुनिया में tile के x और y निर्देशांक अनुसार गणना किया जाता है, प्रति tile 16 pixel कदम के साथ।

डिबग overlay वाला Super Mario World जो sprite स्मृति पते और Mario स्थिति दिखाता है


आधार : आनुवंशिक एल्गोरिदम और neural network

कोड में गोता लगाने से पहले, दो मौलिक अवधारणाएँ समझनी होंगी। उनके बिना, बाकी का कोई अर्थ नहीं।

आनुवंशिक एल्गोरिदम

आनुवंशिक एल्गोरिदम विकास सिद्धांत का अनुकरण है। केंद्रीय विचार : हम व्यक्तियों की आबादी बनाते हैं, हर एक थोड़ी अलग विशेषताओं ("जीन") के साथ। हम उन्हें वातावरण में "जीवित" कराते हैं। जो सबसे अच्छा करते हैं वे बचते और प्रजनन करते हैं। जो बुरा करते हैं वे गायब हो जाते हैं।

Laupok इसे Kirby सादृश्य से दर्शाता है :

  • काँटों और टमाटरों वाले मैदान पर Kirby की आबादी प्रकट होती है
  • काँटे जीवन अंक हटाते हैं, टमाटर वापस देते हैं
  • हर Kirby के जीन हैं : आकार, गति, जीवन अंक, व्यवहार (भागना, टमाटर खोजना, कहीं भी दौड़ना)

DNA दोहरी कुंडली "le bébé", "taille", "vitesse", "couleur" लेबल के साथ, व्यक्ति बनाने वाले जीन

  • 15 सेकंड बाद, देखते हैं कौन सबसे लंबा बचा
  • सबसे अच्छा Kirby दूसरों से प्रजनन करता है : बच्चे सबसे अच्छे के आधे जीन और "सबसे बुरे" के आधे विरासत में पाते हैं
  • बच्चे बेतरतीब उत्परिवर्तन झेलते हैं (थोड़ा बड़ा, थोड़ा तेज...)
  • पुराने Kirby नए से बदले जाते हैं
  • फिर से चलाते हैं

180 पीढ़ियों (~15 घंटे) बाद, Kirby 15 सेकंड बचने से 15 मिनट जाते हैं। वे छोटे हो गए (टक्कर क्षेत्र घटा), तेज, और लगातार खतरे से भागते हैं।

Kirby अनुकरण पीढ़ी 0 : बेतरतीब बिखरे रंगीन गोले काली पृष्ठभूमि पर, सभी समान आकार

Kirby अनुकरण पीढ़ी 1866 : Kirby छोटे, तेज हैं, और व्यवस्थित खतरे से भागते हैं

Kirby अनुकरण आँकड़े : fitness, जीवन अंक संख्या, प्रदर्शन से वर्गीकृत हर व्यक्ति का व्यवहार

महत्वपूर्ण बिंदु : हम समाधान परिभाषित नहीं करते। एल्गोरिदम उसे खुद खोजता है। और ठीक यही उसे उन समस्याओं के लिए शक्तिशाली बनाता है जहाँ हम नहीं जानते कि पैरामीटर का कौन सा संयोजन इष्टतम होगा।

कृत्रिम neural network

neural network मानव मस्तिष्क का सरलीकृत गणितीय मॉडल है। वह बना है :

  • प्रवेश neuron (input) : network क्या "देखता" है
  • निकास neuron (output) : network क्या "तय" करता है
  • कनेक्शन (भार) : हर कनेक्शन का भार होता है जो संकेत बढ़ाता या घटाता है

सिद्धांत सरल है : हर प्रवेश neuron अपना मान भेजता है। वह कनेक्शन भार से गुणा होता है, फिर अन्य संकेतों में जोड़ा जाता है। अगर परिणाम निश्चित सीमा (सक्रियण फंक्शन) पार करता है, तो निकास neuron सक्रिय होता है।

Mario और चूहे के टुकड़े के साथ Laupok सादृश्य में :

  • प्रवेश neuron = Mario और टुकड़े के बीच दूरी
  • कनेक्शन भार = Mario की संवेदनशीलता
  • निकास neuron = Mario चिल्लाता है या नहीं

टुकड़ा जितना करीब, प्रवेश मान उतना ऊँचा। अगर भार मजबूत है, तो निकास भेजा संकेत मजबूत है, और Mario चिल्लाएगा। भार बदलकर, Mario की संवेदनशीलता बदलती है।

Mario डरा डेमो : Boo के सामने Mario जिसमें प्रवेश और निकास के बीच कनेक्शन भार दिखाती synapse पट्टी

AI के असली neural network में, वही तर्क है, लेकिन बड़े पैमाने पर :

  • 99 प्रवेश neuron (Mario दृश्य के 11×9 tile)
  • 8 निकास neuron (A, B, X, Y, ऊपर, नीचे, बाएँ, दाएँ)
  • दोनों के बीच छिपे neuron (hidden)
  • विविध भारों वाले सैकड़ों कनेक्शन

NEAT : एल्गोरिदम जो सब बदलता है

क्लासिक आनुवंशिक एल्गोरिदम की समस्या

अगर हम neural network के साथ आनुवंशिक एल्गोरिदम भोले तरीके से जोड़ते हैं, तो हमें समस्या है : हम 100 पूरी तरह अलग neural network बनाते हैं, और नहीं जानते उनकी तुलना कैसे करें। हर एक के अपने neuron, अपने कनेक्शन, अपने भार हैं। कैसे जानें कि दो network "करीब" हैं या "दूर" ?

यहीं NEAT आता है, NeuroEvolution of Augmenting Topologies। Kenneth Stanley और Risto Miikkulainen ने 2002 में आविष्कृत, यह एल्गोरिदम ठीक यह समस्या सुलझाता है।

प्रजातियाँ

NEAT का पहला मुख्य तंत्र प्रजातियाँ हैं। जब neural network दूसरे से बहुत अलग हो जाता है, तो वह अलग प्रजाति में वर्गीकृत होता है। समानता तीन पैरामीटर से गणना की जाती है :

  1. Excess (EXCES_COEF = 0.50) : कनेक्शन संख्या जिनका दो network के बीच कोई संबंध नहीं (अलग नवाचार)
  2. Disjoint : वही, लेकिन बीच में स्थित कनेक्शन के लिए
  3. Weight difference (POIDSDIFF_COEF = 0.92) : वही नवाचार साझा करने वाले कनेक्शन के बीच औसत भार अंतर

स्कोर सूत्र :

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

अगर यह स्कोर DIFF_LIMITE (1.0) से कम है, तो दोनों network उसी प्रजाति में हैं। वरना, नई प्रजाति बनती है।

नवाचार

यह NEAT की प्रतिभा है। हर बार जब कनेक्शन बनता है, तो उसे अद्वितीय और वैश्विक नवाचार संख्या मिलती है। यह संख्या neural network का अनुसरण करती है जब वह प्रजनन करता है।

ठोस रूप में, जब बच्चा crossover से बनता है, वह अपने माता पिता के नवाचार विरासत में पाता है। अगर दो network वही नवाचार साझा करते हैं, तो इसका मतलब है कि उनका कनेक्शन उसी पूर्वज से आता है। यही अलग आकार के network तुलना करने देता है।

crossover

जब दो neural network प्रजनन करते हैं, तो crossover इस तरह काम करता है :

Laupok crossover अवधारणा समझाता है जिसमें "CROSSOVER" पाठ overlay में

  1. सबसे प्रदर्शनकारी network "प्रमुख माता पिता" बनता है
  2. बच्चा प्रमुख के सभी कनेक्शन विरासत में पाता है
  3. वही नवाचार साझा करने वाले हर कनेक्शन के लिए, दूसरा माता पिता उसे बदल सकता है (50% संभावना)
  4. सिर्फ गैर प्रमुख माता पिता के सक्रिय कनेक्शन बदल सकते हैं

यह गारंटी देता है कि बच्चा हमेशा सर्वश्रेष्ठ माता पिता जितना अच्छा है।

उत्परिवर्तन

crossover बाद, बच्चा कॉन्फ़िगर करने योग्य संभावनाओं से उत्परिवर्तन झेलता है :

Laupok उत्परिवर्तन समझाता है जिसमें "(petite modif = mutation)" पाठ overlay में

उत्परिवर्तन संभावना प्रभाव
कनेक्शन भार reset 25% भार पूरी तरह बेतरतीब होता है
भार उत्परिवर्तन 95% भार ±0.80 बदलता है
कनेक्शन जोड़ना 85% अभी जुड़े नहीं दो neuron के बीच नया कनेक्शन
neuron जोड़ना 39% जुड़े दो neuron के बीच छिपा neuron डाला जाता है

neuron जोड़ दर महत्वपूर्ण है : यही network को बढ़ने देती है। शुरू में, सिर्फ प्रवेश और निकास हैं। धीरे धीरे, छिपे neuron प्रकट होते हैं, network जटिल बनता जाता है।


कोड : पूरा walkthrough

स्थिरांक

स्क्रिप्ट स्थिरांक ब्लॉक से शुरू होती है जो सारा पैरामीटर परिभाषित करता है :

-- Mario के आसपास दृश्य
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- चौड़ाई में 176 pixel
TAILLE_VUE_H = TAILLE_TILE * 9   -- ऊँचाई में 144 pixel
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tile
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tile

-- neural network
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 input (दिखते tile)
NB_OUTPUT = 8  -- A, B, X, Y, ऊपर, नीचे, बाएँ, दाएँ
NB_INDIVIDU_POPULATION = 100  -- प्रति आबादी व्यक्ति
NB_NEURONE_MAX = 100000  -- छिपे neuron सीमा

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- स्तर खत्म होने पर मान
NB_FRAME_RESET_BASE = 33  -- प्रगति रहित reset से पहले frame
NB_FRAME_RESET_PROGRES = 300  -- प्रगति पकड़ी जाने पर frame

-- प्रजातियाँ
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- उत्परिवर्तन
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT 99 है क्योंकि Mario दृश्य 11×9 tile करता है। हर tile प्रवेश neuron है। अगर tile खाली है, तो वह 0 है। अगर ब्लॉक है, तो वह 1 है। अगर शत्रु है, तो वह -1 है।

8 निकास SNES controller बटन से मेल खाते हैं : A, B, X, Y, ऊपर, नीचे, बाएँ, दाएँ। निर्णय जरूरी तक सीमित करने के लिए Start, Select, L और R बाहर हैं।

डेटा संरचनाएँ

स्क्रिप्ट तीन मुख्य संरचनाएँ परिभाषित करती है :

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- neuron का वर्तमान मान
    neurone.id = 0        -- अद्वितीय पहचान
    neurone.type = ""     -- "input", "output", या "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- स्रोत neuron ID
    connexion.sortie = 0     -- गंतव्य neuron ID
    connexion.actif = true   -- निष्क्रिय हो सकता है अगर छिपा neuron डाला जाए
    connexion.poids = 0      -- कनेक्शन भार
    connexion.innovation = 0 -- अद्वितीय नवाचार संख्या
    connexion.allume = false -- प्रदर्शन के लिए : true अगर संकेत गुजरता है
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- छिपे neuron संख्या
        fitness = 1,          -- प्रदर्शन (तय दूरी)
        idEspeceParent = 0,   -- किस प्रजाति का है
        lesNeurones = {},     -- neuron तालिका
        lesConnexions = {}    -- कनेक्शन तालिका
    }
    -- input से आरंभीकरण
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- फिर output
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

शुरू में, हर network में सिर्फ input और output होते हैं : कोई छिपा neuron नहीं, कोई कनेक्शन नहीं। एल्गोरिदम तय करेगा कि क्या चाहिए।

विस्तार में उत्परिवर्तन

भार उत्परिवर्तन

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : भार का कुल reset
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : ±0.80 बदलाव
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

प्रारंभिक भार हमेशा 1 या -1 (genererPoids()) होता है। ±0.80 बदलाव उसे नकारात्मक और सकारात्मक मानों के बीच दोलन करा सकता है, जो network व्यवहार मौलिक बदलता है।

कनेक्शन जोड़ना

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- neuron सूची बेतरतीब करना
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- वैध कनेक्शन : input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- जाँचना कि पहले से कनेक्शन नहीं है
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

हम output को input से नहीं जोड़ सकते (यह चक्र बनाएगा), और हम पहले से जुड़े दो neuron नहीं जोड़ सकते। बेतरतीबता गारंटी देती है कि हम हर बार अलग संभावनाएँ खोजते हैं।

neuron जोड़ना

यह सबसे दिलचस्प उत्परिवर्तन है :

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- कनेक्शन बेतरतीब करना
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- मौजूदा कनेक्शन निष्क्रिय करना
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- छिपा neuron बनाना
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- प्रवेश को छिपे neuron से जोड़ना
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- छिपे neuron को निकास से जोड़ना
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

तंत्र : हम मौजूदा कनेक्शन लेते हैं, उसे निष्क्रिय करते हैं, और बीच में छिपा neuron डालते हैं। मूल कनेक्शन दो नए कनेक्शन से बदला जाता है : प्रवेश→छिपा और छिपा→निकास। जैसे हम switch डालने के लिए तार "काटते" हैं।

यही NEAT को "augmenting topologies" बनाता है : network समय के साथ बढ़ता है। वह सरल शुरू होता है और सिर्फ जरूरत होने पर जटिल बनता है।

feedForward

यह फंक्शन है जो network में संकेत फैलाता है :

function feedForward(unReseau)
    -- निकास neuron reset
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- फैलाव
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

हर सक्रिय कनेक्शन निकास neuron की ओर प्रवेश_मान × भार भेजता है। मान जमा (जोड़ा) होता है। allume ध्वज सिर्फ network के दृश्य प्रदर्शन के लिए है।

खेल स्मृति पढ़ना

getLesInputs() फंक्शन वह है जो Super Mario World दुनिया को network समझने वाले डेटा में बदलता है :

function getLesInputs()
    local lesInputs = {}
    -- 0 पर आरंभीकरण (धूसर = कुछ नहीं)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- sprite (शत्रु) = -1 (काला)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- tile (ब्लॉक) = tile मान (0 से ज्यादा होने पर सफेद)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

input ग्रिड Mario केंद्रित दृश्य है : चौड़ाई में 11 tile, ऊँचाई में 9। हर tile है :

  • 0 (धूसर) : कुछ नहीं
  • 1 (सफेद) : ठोस ब्लॉक
  • -1 (काला) : शत्रु

शत्रु RAM में दो सूचियों से पढ़े जाते हैं : सामान्य sprite (0x14C8-0x14F8) और extended sprite (0x170B-0x173B)। हर जीवित sprite (स्थिति > 7) के लिए, हम Mario सापेक्ष tile में उसकी स्थिति गणना करते हैं और संबंधित खाने में -1 रखते हैं।

fitness : AI कैसे जानती है कि वह प्रगति कर रही है

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- स्तर खत्म !
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario दाएँ आगे बढ़ता है
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- input अद्यतन
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

fitness सरल है : यह दाएँ तय दूरी है। अगर Mario 10 pixel आगे बढ़ता है, तो fitness 10 बढ़ती है। अगर Mario पीछे हटता है, तो कुछ नहीं होता (कोई दंड नहीं)। अगर स्तर खत्म होता है (पता 0x0100 == 12), तो fitness 1 000 000 हो जाती है।

यह जानबूझकर सरल है। सिर्फ दाएँ प्रगति गिनती है : शत्रु मारना कुछ नहीं लाता, मरना कुछ खर्च नहीं करता।

बुद्धिमान reset

अगर Mario 33 frame नहीं हिलता, तो हम स्तर reset करते हैं और अगले व्यक्ति पर जाते हैं। लेकिन अगर Mario ने प्रगति की है (उसकी वर्तमान fitness शुरुआती से अलग है), तो हम 300 frame प्रतीक्षा करते हैं, यह network को उसने अच्छा क्या किया यह "समझने" का मौका देता है।

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

memory.readbyte(0x0071) ~= 9 शर्त जाँचती है कि Mario मृत्यु एनीमेशन में नहीं है। जब Mario पहले ही मृत्यु एनीमेशन में है तो reset बेकार हो जाता है।

मुख्य लूप

लूप 30 fps (Super Mario World सामान्य गति) चलता है :

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- प्रदर्शन (network, जानकारी)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- तेज करना
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- 3 महत्वपूर्ण फंक्शन
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- प्रगति न होने पर Reset
    -- ...
    -- सभी व्यक्ति परखे जाने पर नई पीढ़ी
    -- ...
end

तीन महत्वपूर्ण फंक्शन majReseau, feedForward, और appliquerLesBoutons हैं। अगर हम एक भी निष्क्रिय करते हैं, तो Mario नहीं हिलता।

crossover

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

बच्चा सर्वश्रेष्ठ माता पिता विरासत में पाता है। वही नवाचार साझा करने वाले हर कनेक्शन के लिए, दूसरे माता पिता के पास 50% बदलने की संभावना है, लेकिन सिर्फ कनेक्शन सक्रिय होने पर। यह महत्वपूर्ण सुधार है : इसके बिना, बेकार छिपे neuron बनाए जा सकते थे।

प्रजाति चयन

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- प्रति प्रजाति औसत fitness गणना
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- हर प्रजाति अपनी औसत fitness अनुपात में बच्चों की संख्या बनाती है
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

विचार : 10 000 औसत fitness वाली प्रजाति को 1 औसत fitness वाली प्रजाति से कहीं ज्यादा बच्चे बनाने का अधिकार है। यह क्रिया में प्राकृतिक चयन है।

choisirParent roulette चयन इस्तेमाल करता है : व्यक्ति की fitness जितनी ज्यादा, माता पिता चुने जाने की संभावना उतनी ज्यादा।

सहेजना और लोड करना

आबादी .pop फाइलों में सहेजी जाती हैं :

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

सहेजने में पिछली सभी आबादी का सर्वश्रेष्ठ व्यक्ति भी शामिल है। अगर पुरानी आबादी का सर्वश्रेष्ठ नई आबादी के सर्वश्रेष्ठ से बेहतर है, तो हम पुराने को आधार बनाते हैं। यह अभिजातवाद रूप है : हम सर्वश्रेष्ठ कभी नहीं खोते।

network चित्रण

Laupok ने खेल पर neural network visualizer जोड़ा :

function dessinerUnReseau(unReseau)
    -- Input : Mario के आसपास 11×9 ग्रिड
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- शत्रु
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- ब्लॉक
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Output : 8 बटन
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- कनेक्शन
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

यह समझने के लिए बहुत उपयोगी है कि network क्या करता है। सक्रिय कनेक्शन सफेद हैं, निष्क्रिय अर्ध पारदर्शी हैं। input सफेद/काले/धूसर खानों की ग्रिड हैं। output दिखाते हैं कि कौन से बटन दबे हैं।


परिणाम

AI ने क्या सीखा

निष्पादन के घंटों (और दिनों) के साथ, AI ने खुद खोजा :

  1. दाएँ आगे बढ़ना : सबसे बुनियादी व्यवहार, लेकिन जिसके लिए दाएँ बटन दबाए रखना पड़ता है
  2. शत्रुओं के ऊपर कूदना : "शत्रु पकड़ा" input को A या B बटन से जोड़कर
  3. बाधाओं से बचना : कुछ network बेहतर आगे बढ़ने के लिए अस्थायी पीछे हटना सीखे
  4. स्तर खत्म करना : सर्वश्रेष्ठ व्यक्ति Super Mario World का पहला स्तर पूरा कर पाया

Super Mario World स्तर में Boo के सामने AI नियंत्रित Mario, neural network वास्तविक समय क्रियाएँ तय करता है

सीमाएँ

प्रोजेक्ट की सीमाएँ हैं :

  • एक ही स्तर : AI विशिष्ट एक स्तर पर प्रशिक्षित है। वह अन्य स्तरों पर अपने आप सामान्यीकरण नहीं करती
  • प्रशिक्षण समय : संतोषजनक परिणाम पाने के लिए दसियों घंटे चाहिए
  • कोई समझ नहीं : AI "समझती" नहीं कि वह क्या करती है। वह बेतरतीब उत्परिवर्तन से fitness फंक्शन (तय दूरी) अनुकूलित करती है
  • T-bagging : Laupok नोट करता है कि Mario शत्रु देखकर जगह पर कूदता है, बस इसलिए कि यह fitness बढ़ाता है (कूदते हुए थोड़ा आगे बढ़ता है)

अनुभव कैसे दोहराएँ

Laupok ने सब साझा किया। चरण यहाँ हैं :

  1. tasvideos.org से BizHawk डाउनलोड करो (Download खंड)
  2. Super Mario World की USA ROM लो (अपने कार्ट्रिज की निजी प्रति)
  3. Pastebin से Lua स्क्रिप्ट डाउनलोड करो, mario.lua नाम दो
  4. स्क्रिप्ट ROM वाले फोल्डर में रखो
  5. BizHawk चलाओ, ROM खोलो
  6. Lua console में : dofile("mario.lua") या मेनू Script > Open Script से
  7. स्तर शुरुआत में state सहेजो (मेनू Savestate > Save State) और उसे debut.state नाम दो
  8. स्क्रिप्ट फिर से चलाओ, यह काम करता है

स्क्रिप्ट में विकल्पों वाला फॉर्म शामिल है :

  • तेज करना : तेज जाने के लिए 30 fps सीमा निष्क्रिय करता है
  • network दिखाना : overlay में neural network दिखाता है
  • जानकारी दिखाना : पीढ़ी, fitness, प्रजाति संख्या वाला banner दिखाता है
  • Pause : निष्पादन रोकता है
  • सहेजना/लोड करना : वर्तमान आबादी .pop फाइल में बनाए रखता है

स्रोत और संदर्भ

संसाधन लिंक
Laupok का मुख्य वीडियो j'ai créé une IA qui joue à mario toute seule
वीडियो code review + setup comment setup l'ia + code source review
पूरा स्रोत कोड Pastebin Jcvdqhqm
NEAT का मूल लेख Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
N8Programs ट्यूटोरियल NEAT implementation walkthrough (JavaScript, लेकिन अवधारणाएँ समान हैं)
16blings (Laupok प्रेरणा) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Super Mario World स्मृति SMW Central - RAM Map

निष्कर्ष

Laupok ने जो किया, वह है शैक्षणिक एल्गोरिदम (NEAT, 2002) लेना, उसे emulator (BizHawk) के लिए Lua में फिर से लिखना, और उसे Super Mario World पर लागू करना। परिणाम : AI जो शून्य से खेल खेलना सीखती है, बिना किसी पूर्व ज्ञान, सिर्फ बेतरतीब उत्परिवर्तन और प्राकृतिक चयन से।

यह आनुवंशिक एल्गोरिदम शक्ति का शानदार उदाहरण है। नुस्खा तीन सामग्री में सिमटता है : प्राकृतिक चयन, थोड़ा Lua, और बहुत धैर्य। कोई deep learning नहीं, कोई GPU नहीं, कोई विशाल प्रशिक्षण डेटा नहीं।

कोड टिप्पणित, साझा है, और Laupok ने दो व्याख्यात्मक वीडियो बनाए हैं, एक बड़ी अवधारणाओं के लिए, एक कोड के लिए। अगर विषय तुम्हारी रुचि का है, तो गोता लगाओ। यह दिखता से ज्यादा सुलभ है।

أنشأ Laupok ذكاء اصطناعيا يلعب Super Mario World وحده، كيف يعمل

أنشأ Laupok ذكاء اصطناعيا يلعب Super Mario World وحده، شرح آلية عمله.

أنشأ Laupok ذكاء اصطناعيا يلعب Super Mario World وحده، كيف يعمل

أنشأ Laupok ذكاء اصطناعيا يلعب Super Mario World باستقلالية تامة. ينطلق من الصفر : لا سكربت مُعَد مسبقا، ولا إطار مسجل. يتعلم الذكاء وحده، بضربات طفرات عشوائية وانتقاء طبيعي، إنهاء مستويات اللعبة. يعمل المشروع على BizHawk، محاكي متعدد المنصات، عبر سكربت Lua من حوالي 4200 سطر.

ما يجعل هذا المشروع ساحرا أنه يرتكز على مفاهيم بيولوجية مطبقة على المعلوميات : نظرية داروين في التطور، والشبكات العصبونية الاصطناعية، وقبل كل شيء خوارزمية بعينها تسمى NEAT (NeuroEvolution of Augmenting Topologies). لا يعرف الذكاء شيئا عن اللعبة في البداية. يجرب أفعالا عشوائيا، ويفشل آلاف المرات، وشيئا فشيئا، يفهم كيف يتقدم ويقفز وينجو.

في هذا المقال سنشرّح كل ذلك، مفهوما بمفهوم وسطر كود بسطر كود.

Laupok introduit l'algorithme NEAT devant la caméra


الإعداد : BizHawk وLua وSuper Mario World

محاكي BizHawk

BizHawk محاكي مفتوح المصدر يدعم طنا من المنصات، NES وSNES وGenesis وPS1 وGame Boy وغيرها الكثير. خصوصيته أنه يسمح بإطلاق سكربتات Lua بالتوازي مع اللعبة. لهذه السكربتات وصول إلى ذاكرة الوصول العشوائي (RAM) الخاصة بالمحاكاة، مما يعني أنها تستطيع قراءة أي بيانات لعبة في الوقت الحقيقي وتعديلها.

عمليا، يعني ذلك أننا نستطيع :

  • قراءة موضع Mario في المستوى
  • معرفة أي sprites (أعداء وعناصر) على الشاشة
  • معرفة حالة كل tile (كتلة) حول Mario
  • التحكم بيد التحكم، الضغط على أي زر

وهذا بالضبط ما نحتاجه لجعل ذكاء اصطناعي يلعب.

عناوين ذاكرة Super Mario World

في RAM الخاصة بـSuper Mario World، تُخزَّن كل بيانة في عنوان بعينه. إنه كحي : كل عنوان يقابل "بيتا" يحوي معلومة. مثلا :

العنوان البيانة
0x94-0x95 موضع X لـMario (16 بت، little-endian)
0x96-0x97 موضع Y لـMario
0x14C8+i حالة sprite رقم i (>7 = حي)
0xE4+i موضع X المنخفض لـsprite رقم i
0x14E0+i موضع X المرتفع لـsprite رقم i
0xD8+i موضع Y المنخفض لـsprite رقم i
0x14D4+i موضع Y المرتفع لـsprite رقم i
0x170B+i نوع extended sprite رقم i
0x0100 حالة اللعبة (12 = مستوى منتهٍ)
0x13D4 إيقاف مؤقت نشط
0x0071 حركة موت Mario (9 = ميت)
0x1C800+... جدول tiles المستوى

يستخدم موضع sprites بايتين : بايت "منخفض" وبايت "مرتفع"، لأن الموضع قد يتجاوز 255 بكسل. والصيغة دائما منخفض + مرتفع × 256.

بالنسبة للـtiles، الأمر أعقد : العنوان الأساسي 0x1C800، ونحسب الإزاحة حسب إحداثيي x وy للـtile في العالم، بخطوة 16 بكسل لكل tile.

Super Mario World avec overlay de débogage montrant les adresses mémoire des sprites et la position de Mario


الأساسيات : خوارزميات جينية وشبكات عصبونية

قبل الغوص في الكود، يجب فهم مفهومين أساسيين. دونهما، لا معنى للباقي.

الخوارزميات الجينية

الخوارزمية الجينية محاكاة لـنظرية التطور. الفكرة المركزية : ننشئ مجموعة أفراد، لكل واحد خصائص ("جينات") مختلفة قليلا. نجعلهم "يعيشون" في بيئة. من ينجح أفضل ينجو ويتكاثر. ومن ينجح سيئا يختفي.

يوضح Laupok ذلك بمثال Kirby :

  • تظهر مجموعة Kirby على أرض فيها أشواك وطماطم
  • الأشواك تنزع نقاط حياة، والطماطم تعيدها
  • لكل Kirby جينات : حجم وسرعة ونقاط حياة وسلوك (هروب وبحث عن طماطم واندفاع أي مكان)

Double hélice d'ADN avec les étiquettes "le bébé", "taille", "vitesse", "couleur" -- les gènes qui composent un individu

  • بعد 15 ثانية، ننظر من نجا أطول
  • أفضل Kirby يتكاثر مع الآخرين : يرث الأطفال نصف جينات الأفضل ونصف "الأسوأ"
  • يتعرض الأطفال لـطفرات عشوائية (أكبر قليلا وأسرع قليلا...)
  • يُستبدَل Kirby القدامى بالجدد
  • نعيد الإطلاق

بعد 180 جيلا (~15 ساعة)، ينتقل Kirby من 15 ثانية نجاة إلى 15 دقيقة. صاروا صغارا (منطقة تصادم مخفضة) وسريعين، ويهربون من الخطر باستمرار.

Simulation Kirby génération 0 : des cercles colorés dispersés aléatoirement sur un fond noir, tous de taille similaire

Simulation Kirby génération 1866 : les Kirby sont plus petits, plus rapides, et fuient systématiquement les dangers

Statistiques de la simulation Kirby : fitness, nombre de points de vie, comportement de chaque individu classés par performance

النقطة الحاسمة : لا نعرّف الحل. تجده الخوارزمية وحدها. وهذا بالضبط ما يجعلها قوية لمشاكل لا نعرف أي تركيبة معاملات ستكون مثلى.

الشبكات العصبونية الاصطناعية

الشبكة العصبونية نموذج رياضي مبسط للدماغ البشري. تتكون من :

  • عصبونات مدخل (inputs) : ما "يراه" الشبكة
  • عصبونات مخرج (outputs) : ما "تقرره" الشبكة
  • اتصالات (أوزان) : لكل اتصال وزن يضخم الإشارة أو يوهنها

المبدأ بسيط : كل عصبون مدخل يرسل قيمته. تُضرَب بوزن الاتصال، ثم تُجمَع مع الإشارات الأخرى. إذا تجاوزت النتيجة عتبة معينة (دالة التفعيل)، ينشط عصبون المخرج.

في مثال Laupok مع Mario وطرف الفأرة :

  • عصبون المدخل = المسافة بين Mario والطرف
  • وزن الاتصال = حساسية Mario
  • عصبون المخرج = هل يصرخ Mario أم لا

كلما كان الطرف أقرب، ارتفعت قيمة المدخل. وإذا كان الوزن قويا، كانت الإشارة المرسلة للمخرج قوية، وصرخ Mario. بتعديل الوزن، نعدل حساسية Mario.

Démo "Mario a peur" : Mario face à un Boo avec une barre de synapse affichant le poids de la connexion entre l'entrée et la sortie

في الشبكة العصبونية الحقيقية للذكاء، نفس المنطق، لكن بمقياس ضخم :

  • 99 عصبون مدخل (11×9 tiles من رؤية Mario)
  • 8 عصبونات مخرج (A وB وX وY وأعلى وأسفل ويسار ويمين)
  • عصبونات مخفية (hidden) بين الاثنين
  • مئات الاتصالات بأوزان متنوعة

NEAT : الخوارزمية التي تغيّر كل شيء

مشكلة الخوارزميات الجينية التقليدية

إذا دمجنا خوارزمية جينية مع شبكة عصبونية بسذاجة، نواجه مشكلة : ننشئ 100 شبكة عصبونية مختلفة تماما، ولا نعرف مقارنتها. لكل واحدة عصبوناتها واتصالاتها وأوزانها. كيف نعرف إن كانت شبكتان "قريبتين" أو "بعيدتين" ؟

هنا يتدخل NEAT، NeuroEvolution of Augmenting Topologies. اخترعه Kenneth Stanley وRisto Miikkulainen في 2002، وهو خوارزمية تحل هذه المشكلة بالضبط.

الأنواع

أول آلية أساسية في NEAT هي الأنواع. عندما تصبح شبكة عصبونية مختلفة أكثر من اللازم عن أخرى، تُصنَّف في نوع مختلف. يُحسَب التشابه عبر ثلاثة معاملات :

  1. Excess (EXCES_COEF = 0.50) : عدد الاتصالات التي لا علاقة لها بين شبكتين (ابتكارات مختلفة)
  2. Disjoint : نفس الشيء، لكن للاتصالات الواقعة في الوسط
  3. فرق الوزن (POIDSDIFF_COEF = 0.92) : فرق الأوزان المتوسط بين الاتصالات المتشاركة نفس الابتكار

صيغة النتيجة :

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

إذا كانت هذه النتيجة أدنى من DIFF_LIMITE (1.0)، فالشبكتان في نفس النوع. وإلا، ننشئ نوعا جديدا.

الابتكارات

إنه عبقرية NEAT. كلما أُنشئ اتصال، يتلقى رقما ابتكار فريدا وشاملا. يتبع هذا الرقم الشبكة العصبونية حتى عندما تتكاثر.

عمليا، عندما يُنشأ طفل بالتزاوج، يرث ابتكارات والديه. وإذا تشاركت شبكتان نفس الابتكار، فهذا يعني أن لديهما اتصالا من نفس السلف. وهذا ما يسمح بمقارنة شبكات بأحجام مختلفة.

التزاوج

عندما تتكاثر شبكتان عصبونيتان، يعمل التزاوج هكذا :

Laupok explique le concept de crossover avec le texte "CROSSOVER" en overlay

  1. تصبح الشبكة الأعلى أداء "الوالد المهيمن"
  2. يرث الطفل كل اتصالات المهيمن
  3. لكل اتصال متشارك نفس الابتكار، يمكن للوالد الآخر استبداله (50% احتمال)
  4. وحده الاتصالات النشطة للوالد غير المهيمن يمكنها الاستبدال

وهذا يضمن أن الطفل جيد على الأقل كأفضل والد.

الطفرات

بعد التزاوج، يتعرض الطفل لطفرات باحتمالات قابلة للضبط :

Laupok explique les mutations avec le texte "(petite modif = mutation)" en overlay

الطفرة الاحتمال الأثر
إعادة ضبط وزن اتصال 25% يُعشَّن الوزن تماما
طفرة وزن 95% يتغير الوزن بـ±0.80
إضافة اتصال 85% اتصال جديد بين عصبونين غير مرتبطين بعد
إضافة عصبون 39% يُدرَج عصبون مخفي بين عصبونين متصلين

معدل إضافة العصبون مهم : إنه ما يسمح للشبكة بـالنمو. في البداية، لا توجد إلا مداخل ومخارج. وتدريجيا، تظهر عصبونات مخفية، فتصبح الشبكة أعقد فأعقد.


الكود : شرح كامل

الثوابت

يبدأ السكربت بكتلة ثوابت تعرّف كل الضبط :

-- رؤية Mario حوله
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 بكسل عرضا
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 بكسل ارتفاعا
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tiles
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tiles

-- الشبكة العصبونية
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 مدخلا (tiles مرئية)
NB_OUTPUT = 8  -- A وB وX وY وأعلى وأسفل ويسار ويمين
NB_INDIVIDU_POPULATION = 100  -- أفراد لكل مجموعة
NB_NEURONE_MAX = 100000  -- حد العصبونات المخفية

-- اللياقة
FITNESS_LEVEL_FINI = 1000000  -- قيمة عندما ينتهي المستوى
NB_FRAME_RESET_BASE = 33  -- إطارات دون تقدم قبل إعادة الضبط
NB_FRAME_RESET_PROGRES = 300  -- إطارات إذا كُشف تقدم

-- الأنواع
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- الطفرات
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

قيمة NB_INPUT هي 99 لأن رؤية Mario تبلغ 11×9 tiles. كل tile عصبون مدخل. إذا كانت tile فارغة، قيمتها 0. وإذا كانت كتلة، قيمتها 1. وإذا كانت عدوا، قيمتها -1.

تطابق المخارج الثمانية أزرار يد SNES : A وB وX وY وأعلى وأسفل ويسار ويمين. نستبعد Start وSelect وL وR لحصر قراراته في الأساس.

هياكل البيانات

يعرّف السكربت ثلاث بنى رئيسية :

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- القيمة الحالية للعصبون
    neurone.id = 0        -- معرف فريد
    neurone.type = ""     -- "input" أو "output" أو "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- معرف العصبون المصدر
    connexion.sortie = 0     -- معرف العصبون الوجهة
    connexion.actif = true   -- يمكن تعطيله إذا أُدرج عصبون مخفي
    connexion.poids = 0      -- وزن الاتصال
    connexion.innovation = 0 -- رقم ابتكار فريد
    connexion.allume = false -- للعرض : true إذا مرت الإشارة
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- عدد العصبونات المخفية
        fitness = 1,          -- الأداء (المسافة المقطوعة)
        idEspeceParent = 0,   -- لأي نوع ينتمي
        lesNeurones = {},     -- جدول عصبونات
        lesConnexions = {}    -- جدول اتصالات
    }
    -- التهيئة مع المداخل
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- ثم المخارج
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

في البداية، تحوي كل شبكة مداخل ومخارج فقط : لا عصبون مخفي، ولا اتصال. الخوارزمية هي التي ستقرر إن لزم الأمر.

الطفرات بالتفصيل

طفرة الأوزان

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : إعادة ضبط كاملة للوزن
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : تغير بـ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

الوزن الأولي دائما 1 أو -1 (genererPoids()). يمكن لتغير ±0.80 أن يؤرجحه بين قيم سالبة وموجبة، مما يغيّر جذريا سلوك الشبكة.

إضافة اتصال

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- عشوائية قائمة العصبونات
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- اتصال صالح : مدخل←مخرج ومخفي←مخفي ومخفي←مخرج
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- تحقق من عدم وجود اتصال فعلا
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

لا يمكن ربط مخرج بمدخل (سيخلق دورة)، ولا يمكن ربط عصبونين مرتبطين فعلا. تضمن العشوائية استكشاف احتمالات مختلفة في كل مرة.

إضافة عصبون

إنها الطفرة الأكثر إثارة :

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- عشوائية الاتصالات
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- تعطيل الاتصال الموجود
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- إنشاء العصبون المخفي
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- ربط المدخل بالعصبون المخفي
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- ربط العصبون المخفي بالمخرج
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

الآلية : نأخذ اتصالا موجودا، نعطّله، وندرج عصبونا مخفيا في الوسط. يُستبدَل الاتصال الأصلي باتصالين جديدين : مدخل←مخفي ومخفي←مخرج. كأننا "نقطع" سلكا لإقحام مفتاح.

وهذا ما يجعل NEAT "متزايدة الطوبولوجيات" : تكبر الشبكة مع الوقت. تبدأ بسيطة وتصبح أعقد فقط إذا لزم الأمر.

feedForward

إنها الدالة التي تنشر الإشارات عبر الشبكة :

function feedForward(unReseau)
    -- إعادة ضبط عصبونات المخرج
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- الانتشار
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

يرسل كل اتصال نشط قيمة_المدخل × الوزن نحو عصبون المخرج. تُجمَع القيمة (تُجمَع). علم allume فقط للعرض البصري للشبكة.

قراءة ذاكرة اللعبة

دالة getLesInputs() هي التي تترجم عالم Super Mario World إلى بيانات مفهومة للشبكة :

function getLesInputs()
    local lesInputs = {}
    -- تهيئة إلى 0 (رمادي = لا شيء)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- sprites (أعداء) = -1 (أسود)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- tiles (كتل) = قيمة tile (أبيض إذا > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

شبكة المداخل رؤية متمركزة على Mario : 11 tile عرضا و9 ارتفاعا. كل tile تساوي :

  • 0 (رمادي) : لا شيء
  • 1 (أبيض) : كتلة صلبة
  • -1 (أسود) : عدو

تُقرَأ الأعداء من قائمتين في RAM : sprites العادية (0x14C8-0x14F8) وextended sprites (0x170B-0x173B). لكل sprite حي (حالة > 7)، نحسب موضعه بـtiles بالنسبة لـMario ونضع -1 في الخانة الموافقة.

اللياقة : كيف يعرف الذكاء إن تقدم

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- انتهى المستوى !
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- يتقدم Mario نحو اليمين
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- تحديث المداخل
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

اللياقة بسيطة : إنها المسافة المقطوعة نحو اليمين. إذا تقدم Mario 10 بكسل، تزيد اللياقة 10. وإذا تراجع Mario، لا يحدث شيء (لا عقوبة). وإذا انتهى المستوى (العنوان 0x0100 == 12)، تصبح اللياقة 1 000 000.

إنها بسيطة عمدا. وحده التقدم نحو اليمين يُحتسَب : قتل الأعداء لا يعطي شيئا، والموت لا يkost شيئا.

إعادة الضبط الذكية

إذا لم يتحرك Mario 33 إطارا، نعيد ضبط المستوى وننتقل للفرد التالي. لكن إذا أحرز Mario تقدما (لياقته الحالية مختلفة عن لياقة البداية)، ننتظر 300 إطار، فهذا يعطي الشبكة فرصة "لفهم" ما فعلته جيدا.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

يتحقق الشرط memory.readbyte(0x0071) ~= 9 من أن Mario ليس في حركة موت. تصبح إعادة الضبط عديمة الفائدة عندما يكون Mario فعلا في حركة موت.

الحلقة الرئيسية

تدور الحلقة بسرعة 30 fps (السرعة العادية لـSuper Mario World) :

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- العرض (شبكة ومعلومات)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- تسريع
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- الدوال الثلاث الحيوية
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- إعادة ضبط إذا لا تقدم
    -- ...
    -- جيل جديد إذا اختُبر كل الأفراد
    -- ...
end

الدوال الثلاث الحيوية هي majReseau وfeedForward وappliquerLesBoutons. وإذا عطّلنا واحدة فقط، لا يتحرك Mario بعد.

التزاوج

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

يرث الطفل الوالد الأفضل. ولكل اتصال متشارك نفس الابتكار، للوالد الآخر 50% احتمال الاستبدال، لكن فقط إذا كان الاتصال نشطا. إنه تصحيح مهم : دونه، كان يمكن إنشاء عصبونات مخفية عديمة الفائدة.

الانتقاء بالأنواع

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- حساب اللياقة المتوسطة لكل نوع
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- ينشئ كل نوع عدد أطفال متناسبا مع لياقته المتوسطة
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

الفكرة : نوع بلياقة متوسطة 10 000 له الحق في إنشاء أطفال أكثر بكثير من نوع بلياقة متوسطة 1. إنه الانتقاء الطبيعي أثناء العمل.

يستخدم choisirParent انتقاء بالروليت : كلما ارتفعت لياقة فرد، زاد احتمال اختياره والدا.

الحفظ والتحميل

تُحفَظ المجموعات في ملفات .pop :

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

يتضمن الحفظ أيضا أفضل فرد من كل المجموعات السابقة. إذا كان أفضل المجموعة القديمة أفضل من أفضل الجديدة، نستعيد القديم كأساس. إنه شكل نخبوية : لا نفقد الأفضل أبدا.

رسم الشبكة

أضاف Laupok عارض شبكة عصبونية فوق اللعبة :

function dessinerUnReseau(unReseau)
    -- المداخل : شبكة 11×9 حول Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- عدو
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- كتلة
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- المخارج : 8 أزرار
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- الاتصالات
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

إنه مفيد جدا لفهم ما تفعله الشبكة. الاتصالات النشطة بيضاء، وغير النشطة شبه شفافة. المداخل شبكة خانات بيضاء/سوداء/رمادية. والمخارج تظهر أي أزرار مضغوطة.


النتائج

ما تعلمه الذكاء

على مدى ساعات (وأيام) التشغيل، اكتشف الذكاء وحده :

  1. التقدم نحو اليمين : السلوك الأساسي، لكنه يتطلب إبقاء زر اليمين مضغوطا
  2. القفز فوق الأعداء : بربط مدخل "كُشف عدو" بالزر A أو B
  3. تجنب العوائق : تعلمت بعض الشبكات التراجع مؤقتا للتقدم أفضل
  4. إنهاء مستويات : استطاع أفضل فرد إنهاء أول مستوى في Super Mario World

Mario contrôlé par l'IA face à un Boo dans un niveau de Super Mario World -- le réseau de neurones décide des actions en temps réel

الحدود

للمشروع حدوده :

  • مستوى واحد : يُدرَّب الذكاء على مستوى بعينه. لا يعمم تلقائيا لمستويات أخرى
  • زمن تدريب : يلزم عشرات الساعات لبلوغ نتائج مُرضية
  • لا فهم : لا "يفهم" الذكاء ما يفعله. إنه يحسّن دالة لياقة (مسافة مقطوعة) عبر طفرات عشوائية
  • T-bagging : يلاحظ Laupok أن Mario يميل للقفز في مكانه عندما يرى عدوا، لمجرد أن ذلك يزيد اللياقة (يتقدم قليلا بالقفز)

كيف تعيد التجربة

شارك Laupok كل شيء. إليكم الخطوات :

  1. تنزيل BizHawk على tasvideos.org (قسم Download)
  2. الحصول على ROM أمريكية لـSuper Mario World (نسخة خاصة من خرطوشتك الخاصة)
  3. تنزيل سكربت Lua من Pastebin، وإعادة تسميته mario.lua
  4. وضع السكربت في نفس مجلد ROM
  5. تشغيل BizHawk، وفتح ROM
  6. في طرفية Lua : dofile("mario.lua") أو عبر قائمة Script > Open Script
  7. حفظ state في بداية المستوى (قائمة Savestate > Save State) وتسميتها debut.state
  8. إعادة تشغيل السكربت، يعمل

يتضمن السكربت نموذجا مع خيارات :

  • تسريع : يعطل حد 30 fps للذهاب أسرع
  • عرض شبكة : يعرض الشبكة العصبونية فوق اللعبة
  • عرض معلومات : يعرض شريطا مع الجيل واللياقة وعدد الأنواع
  • إيقاف مؤقت : يوقف التنفيذ
  • حفظ/تحميل : يثبت المجموعة الحالية في ملف .pop

المصادر والمراجع

المورد الرابط
فيديو Laupok الرئيسي أنتجت ذكاء اصطناعيا يلعب ماريو وحده
فيديو مراجعة الكود + الإعداد كيف تجهز الذكاء + مراجعة الكود المصدري
الكود المصدري الكامل Pastebin Jcvdqhqm
مقال NEAT الأصلي Stanley وMiikkulainen، "Evolving Neural Networks through Augmenting Topologies"، 2002
شرح N8Programs NEAT implementation walkthrough (JavaScript، لكن المفاهيم متطابقة)
16blings (إلهام Laupok) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
ذاكرة Super Mario World SMW Central - RAM Map

الخلاصة

ما فعله Laupok هو أخذ خوارزمية أكاديمية (NEAT، 2002)، وإعادة كتابتها في Lua لمحاكي (BizHawk)، وتطبيقها على Super Mario World. النتيجة : ذكاء اصطناعي يتعلم من الصفر لعب اللعبة، دون أي معرفة مسبقة، فقط بطفرات عشوائية وانتقاء طبيعي.

إنه مثال رائع لقوة الخوارزميات الجينية. الوصفة في ثلاثة مكونات : الانتقاء الطبيعي وقليل من Lua والكثير من الصبر. لا تعلم عميق، ولا GPU، ولا بيانات تدريب ضخمة.

الكود مُعلَّق ومُشارَك، وصنع Laupok فيديوهين تفسيريين، واحد للمفاهيم الكبرى وواحد للكود. إذا كان الموضوع يهمك، اغُص فيه. إنه أسهل مما يبدو.

Laupok đã tạo AI chơi Super Mario World một mình -- nó chạy thế nào

Laupok đã tạo AI chơi Super Mario World một mình -- nó chạy thế nào

Laupok đã tạo một trí tuệ nhân tạo chơi Super Mario World hoàn toàn tự chủ. Nó khởi đầu từ con số không : không script định sẵn, không frame ghi sẵn. AI tự học, bằng đột biến ngẫu nhiên và chọn lọc tự nhiên, để qua màn game. Dự án chạy trên BizHawk, trình giả lập đa nền tảng, qua script Lua khoảng 4200 dòng.

Điều khiến dự án này cuốn hút là nó dựa trên khái niệm sinh học áp vào tin học : thuyết tiến hóa của Darwin, mạng nơ-ron nhân tạo, và nhất là một thuật toán cụ thể tên NEAT (NeuroEvolution of Augmenting Topologies). AI ban đầu không biết gì về game. Nó thử hành động ngẫu nhiên, thất bại hàng nghìn lần, và dần dần hiểu cách tiến lên, nhảy, và sống sót.

Trong bài này, ta sẽ mổ xẻ tất cả -- khái niệm theo khái niệm, dòng mã theo dòng mã.

Laupok giới thiệu thuật toán NEAT trước camera


Setup : BizHawk, Lua, và Super Mario World

Trình giả lập BizHawk

BizHawk là trình giả lập open source hỗ trợ cả đống console -- NES, SNES, Genesis, PS1, Game Boy, và nhiều nữa. Đặc biệt ở chỗ nó cho chạy script Lua song song với game. Các script này truy cập bộ nhớ RAM của giả lập, nghĩa là chúng có thể đọc -- và sửa -- bất kỳ dữ liệu nào của game thời gian thực.

Cụ thể, nghĩa là ta có thể :

  • Đọc vị trí Mario trong màn
  • Biết sprite (kẻ thù, vật phẩm) nào trên màn hình
  • Biết trạng thái mỗi tile (khối) quanh Mario
  • Điều khiển tay cầm -- bấm bất kỳ nút nào

Đó chính xác là những gì cần để AI chơi.

Địa chỉ bộ nhớ của Super Mario World

Trong RAM Super Mario World, mỗi dữ liệu nằm ở địa chỉ cụ thể. Giống khu phố : mỗi địa chỉ là một "ngôi nhà" chứa một thông tin. Ví dụ :

Địa chỉ Dữ liệu
0x94-0x95 Vị trí X của Mario (16-bit, little-endian)
0x96-0x97 Vị trí Y của Mario
0x14C8+i Trạng thái sprite i (>7 = sống)
0xE4+i Vị trí X thấp của sprite i
0x14E0+i Vị trí X cao của sprite i
0xD8+i Vị trí Y thấp của sprite i
0x14D4+i Vị trí Y cao của sprite i
0x170B+i Kiểu extended sprite i
0x0100 Trạng thái game (12 = qua màn)
0x13D4 Tạm dừng đang bật
0x0071 Animation chết của Mario (9 = chết)
0x1C800+... Bảng tile của màn

Vị trí sprite dùng hai byte : một byte "thấp" và một byte "cao", vì vị trí có thể vượt 255 pixel. Công thức luôn là thấp + cao × 256.

Với tile, phức tạp hơn : địa chỉ cơ sở là 0x1C800, và ta tính offset theo tọa độ x và y của tile trong thế giới, với bước 16 pixel mỗi tile.

Super Mario World với overlay gỡ lỗi hiện địa chỉ bộ nhớ sprite và vị trí Mario


Nền tảng : thuật toán di truyền và mạng nơ-ron

Trước khi lặn vào mã, cần hiểu hai khái niệm nền. Không có chúng, phần còn lại vô nghĩa.

Thuật toán di truyền

Thuật toán di truyền là mô phỏng thuyết tiến hóa. Ý tưởng cốt lõi : ta tạo quần thể cá thể, mỗi cá thể có đặc điểm hơi khác nhau (các "gen"). Ta cho chúng "sống" trong môi trường. Kẻ xoay xở tốt nhất sống sót và sinh sản. Kẻ kém biến mất.

Laupok minh họa bằng phép tương tự Kirby :

  • Quần thể Kirby xuất hiện trên địa hình có gai và cà chua
  • Gai trừ điểm sinh mệnh, cà chua hồi lại
  • Mỗi Kirby có gen : kích thước, tốc độ, điểm sinh mệnh, hành vi (chạy trốn, tìm cà chua, lao bừa)

Chuỗi xoắn ADN với nhãn "đứa bé", "kích thước", "tốc độ", "màu sắc" -- các gen tạo nên cá thể

  • Sau 15 giây, ta xem ai sống lâu nhất
  • Kirby giỏi nhất sinh sản với các Kirby khác : con thừa hưởng nửa gen từ đứa giỏi nhất và nửa từ đứa "tệ nhất"
  • Các con chịu đột biến ngẫu nhiên (to hơn chút, nhanh hơn chút...)
  • Kirby cũ bị thay bằng Kirby mới
  • Chạy lại

Sau 180 thế hệ (~15 giờ), Kirby đi từ 15 giây sống sót lên 15 phút. Chúng thành nhỏ (vùng va chạm giảm), nhanh, và luôn chạy trốn nguy hiểm.

Mô phỏng Kirby thế hệ 0 : các vòng tròn màu rải ngẫu nhiên trên nền đen, kích thước tương đương

Mô phỏng Kirby thế hệ 1866 : Kirby nhỏ hơn, nhanh hơn, và lánh nguy hiểm có hệ thống

Thống kê mô phỏng Kirby : fitness, điểm sinh mệnh, hành vi mỗi cá thể xếp theo hiệu năng

Điểm then chốt : ta không định nghĩa lời giải. Thuật toán tự tìm ra. Và đó chính xác là thứ khiến nó mạnh với bài toán nơi ta không biết tổ hợp tham số nào tối ưu.

Mạng nơ-ron nhân tạo

Mạng nơ-ron là mô hình toán rút gọn của não người. Nó gồm :

  • Nơ-ron đầu vào (input) : những gì mạng "thấy"
  • Nơ-ron đầu ra (output) : những gì mạng "quyết"
  • Kết nối (trọng số) : mỗi kết nối có trọng số khuếch đại hay làm suy tín hiệu

Nguyên tắc đơn giản : mỗi nơ-ron đầu vào gửi giá trị của nó. Nó được nhân với trọng số kết nối, rồi cộng với các tín hiệu khác. Nếu kết quả vượt ngưỡng nào đó (hàm kích hoạt), nơ-ron đầu ra kích hoạt.

Trong phép tương tự của Laupok với Mario và đầu chuột :

  • Nơ-ron đầu vào = khoảng cách giữa Mario và đầu chuột
  • Trọng số kết nối = độ nhạy của Mario
  • Nơ-ron đầu ra = Mario hét hay không

Đầu chuột càng gần, giá trị đầu vào càng cao. Nếu trọng số mạnh, tín hiệu gửi ra mạnh, và Mario sẽ hét. Sửa trọng số, ta sửa độ nhạy của Mario.

Demo "Mario sợ hãi" : Mario đối mặt Boo với thanh synapse hiện trọng số kết nối giữa đầu vào và đầu ra

Trong mạng nơ-ron thật của AI, đó là cùng logic, nhưng ở quy mô lớn :

  • 99 nơ-ron đầu vào (11×9 tile của tầm nhìn Mario)
  • 8 nơ-ron đầu ra (A, B, X, Y, Lên, Xuống, Trái, Phải)
  • Nơ-ron ẩn (hidden) ở giữa
  • Hàng trăm kết nối với trọng số đa dạng

NEAT : thuật toán thay đổi mọi thứ

Vấn đề của thuật toán di truyền cổ điển

Nếu kết hợp thuật toán di truyền với mạng nơ-ron một cách ngây thơ, ta gặp vấn đề : ta tạo 100 mạng nơ-ron hoàn toàn khác nhau, mà không biết so sánh chúng. Mỗi cái có nơ-ron riêng, kết nối riêng, trọng số riêng. Làm sao biết hai mạng "gần" hay "xa"?

Đó là lúc NEAT -- NeuroEvolution of Augmenting Topologies -- xuất hiện. Do Kenneth Stanley và Risto Miikkulainen phát minh năm 2002, đó là thuật toán giải đúng bài toán này.

Các loài

Cơ chế mấu chốt đầu tiên của NEAT là các loài. Khi một mạng nơ-ron trở nên quá khác mạng khác, nó được xếp vào loài khác. Độ tương đồng tính qua ba tham số :

  1. Excess (EXCES_COEF = 0.50) : số kết nối không liên quan gì giữa hai mạng (đổi mới khác nhau)
  2. Disjoint : tương tự, nhưng cho kết nối nằm ở giữa
  3. Chênh lệch trọng số (POIDSDIFF_COEF = 0.92) : chênh lệch trọng số trung bình giữa các kết nối chung đổi mới

Công thức điểm :

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

Nếu điểm này dưới DIFF_LIMITE (1.0), hai mạng cùng loài. Ngược lại, tạo loài mới.

Các đổi mới

Đó là thiên tài của NEAT. Mỗi khi kết nối được tạo, nó nhận số đổi mới duy nhất và toàn cục. Số này theo mạng nơ-ron ngay cả khi nó sinh sản.

Cụ thể, khi đứa bé sinh từ lai ghép, nó thừa hưởng đổi mới của bố mẹ. Nếu hai mạng chung đổi mới, nghĩa là chúng có kết nối tới từ cùng tổ tiên. Đó là thứ cho phép so sánh mạng kích thước khác nhau.

Lai ghép

Khi hai mạng nơ-ron sinh sản, lai ghép chạy như sau :

Laupok giải thích khái niệm lai ghép với chữ "CROSSOVER" phủ trên

  1. Mạng hiệu năng nhất thành "bố mẹ trội"
  2. Đứa bé thừa hưởng mọi kết nối của bên trội
  3. Với mỗi kết nối chung đổi mới, bố mẹ kia có thể thay thế (50% cơ hội)
  4. Chỉ kết nối đang hoạt động của bố mẹ không trội mới được thay

Việc này đảm bảo đứa bé luôn ít nhất giỏi bằng bố mẹ giỏi nhất.

Đột biến

Sau lai ghép, đứa bé chịu đột biến với xác suất cấu hình được :

Laupok giải thích đột biến với chữ "(sửa nhỏ = đột biến)" phủ trên

Đột biến Xác suất Hiệu ứng
Reset trọng số kết nối 25% Trọng số ngẫu nhiên hoàn toàn
Đột biến trọng số 95% Trọng số thay đổi ±0.80
Thêm kết nối 85% Kết nối mới giữa hai nơ-ron chưa nối
Thêm nơ-ron 39% Nơ-ron ẩn chèn giữa hai nơ-ron đã nối

Tỷ lệ thêm nơ-ron quan trọng : đó là thứ cho phép mạng lớn lên. Ban đầu, chỉ có đầu vào và đầu ra. Dần dần, nơ-ron ẩn xuất hiện, khiến mạng ngày càng phức tạp.


Mã nguồn : walkthrough đầy đủ

Các hằng số

Script bắt đầu bằng khối hằng số định nghĩa mọi tham số :

-- Tầm nhìn của Mario quanh nó
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 pixel chiều rộng
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 pixel chiều cao
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tile
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tile

-- Mạng nơ-ron
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 input (tile nhìn thấy)
NB_OUTPUT = 8  -- A, B, X, Y, Lên, Xuống, Trái, Phải
NB_INDIVIDU_POPULATION = 100  -- cá thể cho mỗi quần thể
NB_NEURONE_MAX = 100000  -- giới hạn nơ-ron ẩn

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- giá trị khi qua màn
NB_FRAME_RESET_BASE = 33  -- frame không tiến triển trước reset
NB_FRAME_RESET_PROGRES = 300  -- frame nếu phát hiện tiến triển

-- Loài
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Đột biến
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT là 99 vì tầm nhìn Mario là 11×9 tile. Mỗi tile là một nơ-ron đầu vào. Nếu tile trống, nó bằng 0. Nếu là khối, nó bằng 1. Nếu là kẻ thù, nó bằng -1.

8 đầu ra khớp các nút tay cầm SNES : A, B, X, Y, Lên, Xuống, Trái, Phải. Ta loại Start, Select, L và R để giới hạn quyết định của nó ở mức cần thiết.

Cấu trúc dữ liệu

Script định nghĩa ba cấu trúc chính :

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- giá trị hiện tại của nơ-ron
    neurone.id = 0        -- định danh duy nhất
    neurone.type = ""     -- "input", "output", hay "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- ID nơ-ron nguồn
    connexion.sortie = 0     -- ID nơ-ron đích
    connexion.actif = true   -- có thể tắt nếu nơ-ron ẩn được chèn
    connexion.poids = 0      -- trọng số kết nối
    connexion.innovation = 0 -- số đổi mới duy nhất
    connexion.allume = false -- để hiển thị : true nếu tín hiệu đi qua
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- số nơ-ron ẩn
        fitness = 1,          -- hiệu năng (khoảng cách đi được)
        idEspeceParent = 0,   -- thuộc loài nào
        lesNeurones = {},     -- bảng nơ-ron
        lesConnexions = {}    -- bảng kết nối
    }
    -- Khởi tạo với input
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Rồi tới output
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

Ban đầu, mỗi mạng chỉ chứa input và output : không nơ-ron ẩn, không kết nối. Thuật toán sẽ quyết có cần chúng hay không.

Đột biến chi tiết

Đột biến trọng số

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25% : reset toàn bộ trọng số
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75% : thay đổi ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

Trọng số ban đầu luôn là 1 hay -1 (genererPoids()). Thay đổi ±0.80 có thể khiến nó dao động giữa giá trị âm và dương, thay đổi triệt để hành vi mạng.

Thêm kết nối

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Ngẫu nhiên hóa danh sách nơ-ron
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Kết nối hợp lệ : input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Kiểm tra chưa có kết nối
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

Ta không thể nối output tới input (sẽ tạo chu trình), và không thể nối hai nơ-ron đã nối. Ngẫu nhiên hóa đảm bảo ta khám phá các khả năng khác nhau mỗi lần.

Thêm nơ-ron

Đây là đột biến thú vị nhất :

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Ngẫu nhiên hóa kết nối
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Tắt kết nối hiện có
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Tạo nơ-ron ẩn
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Nối đầu vào tới nơ-ron ẩn
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Nối nơ-ron ẩn tới đầu ra
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

Cơ chế : lấy kết nối hiện có, tắt nó, và chèn nơ-ron ẩn vào giữa. Kết nối gốc được thay bằng hai kết nối mới : vào→ẩn và ẩn→ra. Như thể ta "cắt" dây để chèn công tắc.

Đó là thứ khiến NEAT thành "augmenting topologies" : mạng lớn dần theo thời gian. Nó khởi đầu đơn giản và chỉ phức tạp khi cần.

feedForward

Đây là hàm lan truyền tín hiệu qua mạng :

function feedForward(unReseau)
    -- Reset nơ-ron đầu ra
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Lan truyền
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

Mỗi kết nối đang hoạt động gửi giá_trị_vào × trọng_số tới nơ-ron đầu ra. Giá trị được cộng dồn. Cờ allume chỉ để hiển thị trực quan mạng.

Đọc bộ nhớ game

Hàm getLesInputs() là thứ dịch thế giới Super Mario World thành dữ liệu mạng hiểu được :

function getLesInputs()
    local lesInputs = {}
    -- Khởi tạo về 0 (xám = không có gì)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Sprite (kẻ thù) = -1 (đen)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Tile (khối) = giá trị tile (trắng nếu > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

Lưới input là khung nhìn căn giữa Mario : 11 tile chiều rộng, 9 chiều cao. Mỗi tile bằng :

  • 0 (xám) : không có gì
  • 1 (trắng) : khối đặc
  • -1 (đen) : kẻ thù

Kẻ thù được đọc từ hai danh sách trong RAM : sprite thường (0x14C8-0x14F8) và extended sprite (0x170B-0x173B). Với mỗi sprite sống (trạng thái > 7), ta tính vị trí theo tile tương đối với Mario và đặt -1 vào ô tương ứng.

Fitness : AI biết mình tiến triển thế nào

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Qua màn!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario tiến sang phải
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Cập nhật input
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

Fitness đơn giản : là khoảng cách đi sang phải. Nếu Mario tiến 10 pixel, fitness tăng 10. Nếu Mario lùi, không gì xảy ra (không phạt). Nếu qua màn (địa chỉ 0x0100 == 12), fitness thành 1 000 000.

Cố ý đơn giản. Chỉ tiến sang phải được tính : giết kẻ thù không cho gì, chết không mất gì.

Reset thông minh

Nếu Mario không động đậy 33 frame, ta reset màn và chuyển cá thể tiếp theo. Nhưng nếu Mario đã tiến triển (fitness hiện khác lúc bắt đầu), ta chờ 300 frame -- điều này cho mạng cơ hội "hiểu" điều nó làm tốt.

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

Điều kiện memory.readbyte(0x0071) ~= 9 kiểm tra Mario không trong animation chết. Reset thành vô ích khi Mario đã trong animation chết.

Vòng lặp chính

Vòng lặp chạy ở 30 fps (tốc độ bình thường của Super Mario World) :

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Hiển thị (mạng, thông tin)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- tăng tốc
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- 3 hàm sống còn
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Reset nếu không tiến triển
    -- ...
    -- Thế hệ mới nếu mọi cá thể đã thử
    -- ...
end

Ba hàm sống còn là majReseau, feedForward, và appliquerLesBoutons. Nếu tắt một trong ba, Mario không động đậy nữa.

Lai ghép

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

Đứa bé thừa hưởng từ bố mẹ giỏi hơn. Với mỗi kết nối chung đổi mới, bố mẹ kia có 50% cơ hội thay thế -- nhưng chỉ nếu kết nối đang hoạt động. Đây là sửa quan trọng : không có nó, nơ-ron ẩn vô ích có thể được tạo.

Chọn lọc theo loài

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Tính fitness trung bình cho mỗi loài
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Mỗi loài tạo số con tỷ lệ với fitness trung bình
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

Ý tưởng : loài có fitness trung bình 10 000 được tạo nhiều con hơn hẳn loài có fitness trung bình 1. Đó là chọn lọc tự nhiên trong hành động.

choisirParent dùng chọn lọc roulette : cá thể càng fit càng có cơ hội được chọn làm bố mẹ.

Lưu và tải

Quần thể được lưu trong tệp .pop :

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

Lưu gồm cả cá thể giỏi nhất của mọi quần thể trước. Nếu cá thể giỏi nhất quần thể cũ giỏi hơn cá thể giỏi nhất quần thể mới, ta lấy lại cái cũ làm nền. Đó là dạng ưu tú : không bao giờ mất cái giỏi nhất.

Vẽ mạng

Laupok thêm trình trực quan hóa mạng nơ-ron phủ lên game :

function dessinerUnReseau(unReseau)
    -- Input : lưới 11×9 quanh Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- kẻ thù
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- khối
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Output : 8 nút
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Kết nối
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

Cực hữu ích để hiểu mạng đang làm gì. Kết nối đang hoạt động màu trắng, không hoạt động bán trong suốt. Input là lưới ô trắng/đen/xám. Output hiện nút nào đang nhấn.


Kết quả

AI đã học được gì

Qua hàng giờ (và hàng ngày) chạy, AI tự phát hiện :

  1. Tiến sang phải : hành vi cơ bản nhất, nhưng đòi giữ nút Phải
  2. Nhảy qua kẻ thù : bằng cách nối input "phát hiện kẻ thù" tới nút A hay B
  3. Tránh chướng ngại : vài mạng học lùi tạm để tiến tốt hơn
  4. Qua màn : cá thể giỏi nhất đã qua được màn đầu tiên của Super Mario World

Mario do AI điều khiển đối mặt Boo trong màn Super Mario World -- mạng nơ-ron quyết hành động thời gian thực

Giới hạn

Dự án có giới hạn :

  • Một màn duy nhất : AI huấn luyện trên một màn cụ thể. Nó không khái quát tự động sang màn khác
  • Thời gian huấn luyện : cần hàng chục giờ để đạt kết quả ưng ý
  • Không hiểu : AI không "hiểu" điều nó làm. Nó tối ưu hàm fitness (khoảng cách đi được) qua đột biến ngẫu nhiên
  • T-bagging : Laupok ghi nhận Mario có xu hướng nhảy tại chỗ khi thấy kẻ thù, đơn giản vì việc đó tăng fitness (nó tiến chút khi nhảy)

Cách tái tạo thí nghiệm

Laupok đã chia sẻ mọi thứ. Các bước :

  1. Tải BizHawk trên tasvideos.org (mục Download)
  2. Lấy ROM USA Super Mario World (bản sao riêng từ băng của chính bạn)
  3. Tải script Lua từ Pastebin -- đổi tên thành mario.lua
  4. Đặt script cùng thư mục với ROM
  5. Chạy BizHawk, mở ROM
  6. Trong console Lua : dofile("mario.lua") hay qua menu Script > Open Script
  7. Lưu state ở đầu màn (menu Savestate > Save State) và đặt tên debut.state
  8. Chạy lại script -- chạy được

Script gồm form với tùy chọn :

  • Tăng tốc : tắt giới hạn 30 fps để đi nhanh hơn
  • Hiện mạng : hiện mạng nơ-ron phủ lên
  • Hiện thông tin : hiện dải băng với thế hệ, fitness, số loài
  • Tạm dừng : dừng thực thi
  • Lưu/Tải : bền vững quần thể hiện tại vào tệp .pop

Nguồn và tham chiếu

Tài nguyên Link
Video chính của Laupok j'ai créé une IA qui joue à mario toute seule
Video review mã + setup comment setup l'ia + code source review
Mã nguồn đầy đủ Pastebin Jcvdqhqm
Bài gốc NEAT Stanley và Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
Hướng dẫn của N8Programs NEAT implementation walkthrough (JavaScript, nhưng khái niệm giống hệt)
16blings (cảm hứng của Laupok) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
Bộ nhớ Super Mario World SMW Central - RAM Map

Kết luận

Điều Laupok làm là lấy thuật toán học thuật (NEAT, 2002), viết lại bằng Lua cho trình giả lập (BizHawk), và áp vào Super Mario World. Kết quả : AI học từ con số không để chơi game, không kiến thức trước nào, chỉ bằng đột biến ngẫu nhiên và chọn lọc tự nhiên.

Đó là ví dụ tuyệt đẹp về sức mạnh thuật toán di truyền. Công thức gói trong ba thành phần : chọn lọc tự nhiên, chút Lua, và nhiều kiên nhẫn. Không deep learning, không GPU, không dữ liệu huấn luyện khổng lồ.

Mã được comment, chia sẻ, và Laupok làm hai video giải thích -- một cho khái niệm lớn, một cho mã. Nếu chủ đề khiến bạn hứng thú, hãy lặn vào. Nó dễ tiếp cận hơn vẻ ngoài.

Laupok สร้าง AI ที่เล่น Super Mario World ได้เอง -- มันทำงานอย่างไร

บทความเชิงลึกเกี่ยวกับโปรเจกต์ของ Laupok: AI ที่ใช้ NEAT เรียนรู้การเล่น Super Mario World ได้อย่างอิสระ อัลกอริทึมพันธุกรรม โครงข่ายประสาทเทียม การวิวัฒน์โครงข่ายประสาทแบบเพิ่มขยาย และ Lua 4200 บรรทัด

Laupok สร้าง AI ที่เล่น Super Mario World ได้เอง -- มันทำงานอย่างไร

Laupok สร้างปัญญาประดิษฐ์ที่เล่น Super Mario World ได้อย่างสมบูรณ์แบบอิสระ ไม่มีการบันทึกอินพุตล่วงหน้า ไม่มีเฟรมที่บันทึกไว้ AI เรียนรู้ด้วยตัวเอง ผ่านการกลายพันธุ์แบบสุ่มและการคัดเลือกโดยธรรมชาติ เพื่อผ่านด่านต่างๆ ของเกม โปรเจกต์นี้รันบน BizHawk ซึ่งเป็นอิมิวเลเตอร์หลายแพลตฟอร์ม ผ่านสคริปต์ Lua ประมาณ 4200 บรรทัด

สิ่งที่ทำให้โปรเจกต์นี้น่าทึ่งคือมันพึ่งพาแนวคิดทางชีววิทยาที่นำมาประยุกต์ใช้กับการคำนวณ: ทฤษฎีวิวัฒนาการ ของดาร์วิน โครงข่ายประสาทเทียม และที่สำคัญที่สุดคืออัลกอริทึมเฉพาะที่เรียกว่า NEAT (NeuroEvolution of Augmenting Topologies) หรือการวิวัฒน์โครงข่ายประสาทแบบเพิ่มขยาย AI ไม่รู้จักเกมเลยในตอนแรก มันลองทำสิ่งต่างๆ แบบสุ่ม ล้มเหลวหลายพันครั้ง และค่อยๆ หาวิธีที่จะเคลื่อนที่ กระโดด และอยู่รอด

ในบทความนี้ เราจะอธิบายทุกอย่าง -- ทีละแนวคิด ทีละบรรทัดโค้ด

Laupok อธิบายอัลกอริทึม NEAT หน้ากล้อง


การตั้งค่า: BizHawk, Lua, และ Super Mario World

อิมิวเลเตอร์ BizHawk

BizHawk เป็นอิมิวเลเตอร์โอเพนซอร์สที่รองรับคอนโซลจำนวนมาก -- NES, SNES, Genesis, PS1, Game Boy และอีกมากมาย คุณสมบัติเด่นคือมันสามารถรัน สคริปต์ Lua ไปพร้อมกับเกมได้ สคริปต์เหล่านี้สามารถเข้าถึง RAM (หน่วยความจำเข้าถึงโดยสุ่ม) ของอิมิวเลเตอร์ได้ หมายความว่าสามารถอ่าน -- และแก้ไข -- ข้อมูลเกมใดๆ ได้แบบเรียลไทม์

ในทางปฏิบัติ นั่นหมายความว่าคุณสามารถ:

  • อ่านตำแหน่งของมาริโอในด่าน
  • รู้ว่าสไปรต์ใดบ้าง (ศัตรู ไอเทม) อยู่บนหน้าจอ
  • รู้สถานะของไทล์ (บล็อก) ทุกชิ้นรอบตัวมาริโอ
  • ควบคุมจอย -- กดปุ่มใดก็ได้

นี่คือสิ่งที่คุณต้องการเพื่อให้ AI เล่น

ที่อยู่หน่วยความจำของ Super Mario World

ใน RAM ของ Super Mario World ข้อมูลทุกชิ้นถูกเก็บไว้ในที่อยู่เฉพาะ มันเหมือนย่านที่อยู่อาศัย: ที่อยู่แต่ละจุดตรงกับ "บ้าน" หนึ่งหลังที่มีข้อมูลหนึ่งชิ้น ตัวอย่างเช่น:

ที่อยู่ ข้อมูล
0x94-0x95 ตำแหน่ง X ของมาริโอ (16 บิต, little-endian)
0x96-0x97 ตำแหน่ง Y ของมาริโอ
0x14C8+i สถานะสไปรต์ i (>7 = มีชีวิต)
0xE4+i ตำแหน่ง X ต่ำของสไปรต์ i
0x14E0+i ตำแหน่ง X สูงของสไปรต์ i
0xD8+i ตำแหน่ง Y ต่ำของสไปรต์ i
0x14D4+i ตำแหน่ง Y สูงของสไปรต์ i
0x170B+i ประเภทสไปรต์ขยาย i
0x0100 สถานะเกม (12 = ผ่านด่าน)
0x13D4 หยุดชั่วคราวทำงาน
0x0071 แอนิเมชันตายของมาริโอ (9 = ตาย)
0x1C800+... ตารางไทล์ของด่าน

ตำแหน่งสไปรต์ใช้สองไบต์: ไบต์ "ต่ำ" และไบต์ "สูง" เพราะตำแหน่งอาจเกิน 255 พิกเซล สูตรคือเสมอ low + high × 256

สำหรับไทล์จะซับซ้อนกว่า: ที่อยู่ฐานคือ 0x1C800 และคุณคำนวณออฟเซตตามพิกัด x และ y ของไทล์ในโลก โดยมีขั้น 16 พิกเซลต่อไทล์

Super Mario World พร้อมโอเวอร์เลย์ดีบักที่แสดงที่อยู่หน่วยความจำของสไปรต์และตำแหน่งของมาริโอ


พื้นฐาน: อัลกอริทึมพันธุกรรมและโครงข่ายประสาทเทียม

ก่อนที่จะดำดิ่งเข้าไปในโค้ด คุณต้องเข้าใจแนวคิดพื้นฐานสองอย่าง ถ้าไม่มีมัน สิ่งอื่นจะไม่สมเหตุสมผล

อัลกอริทึมพันธุกรรม

อัลกอริทึมพันธุกรรมเป็นการจำลอง ทฤษฎีวิวัฒนาการ แนวคิดหลัก: คุณสร้าง ประชากร ของสิ่งมีชีวิต แต่ละตัวมีลักษณะที่แตกต่างกันเล็กน้อย ("ยีน") คุณปล่อยให้มัน "มีชีวิต" ในสภาพแวดล้อม ตัวที่ทำได้ดีที่สุดจะอยู่รอดและสืบพันธุ์ ตัวที่ทำได้ไม่ดีจะสูญพันธุ์ไป

Laupok อธิบายเรื่องนี้ด้วยการเปรียบเทียบ Kirby:

  • ประชากรของ Kirby ปรากฏบนพื้นผิวที่มีหนามและมะเขือเทศ
  • หนามลดพลังชีวิต มะเขือเทศกู้คืนมัน
  • แต่ละ Kirby มียีน: ขนาด ความเร็ว พลังชีวิต พฤติกรรม (หนี หามะเขือเทศ วิ่งแบบมั่วๆ)

เกลียวคู่ดีเอ็นเอพร้อมป้าย "the baby", "size", "speed", "color" -- ยีนที่ประกอบขึ้นเป็นสิ่งมีชีวิตหนึ่งตัว

  • หลังจาก 15 วินาที คุณตรวจสอบว่าใครอยู่รอดนานที่สุด
  • Kirby ที่ดีที่สุดสืบพันธุ์กับตัวอื่น: ลูกจะได้รับยีนครึ่งหนึ่งจากตัวที่ดีที่สุดและอีกครึ่งจากตัวที่ "แย่ที่สุด"
  • ลูกผ่านการกลายพันธุ์แบบสุ่ม (mutations) (ใหญ่ขึ้นเล็กน้อย เร็วขึ้นเล็กน้อย...)
  • Kirby เก่าถูกแทนที่ด้วยตัวใหม่
  • คุณเริ่มต้นใหม่

หลังจาก 180 รุ่น (~15 ชั่วโมง) Kirby จากที่อยู่รอด 15 วินาทีกลายเป็น 15 นาที พวกมันเล็กลง (hitbox เล็ก) เร็วขึ้น และหนีอันตรายอยู่ตลอดเวลา

การจำลอง Kirby รุ่นที่ 0: วงกลมหลากสีกระจายแบบสุ่มบนพื้นหลังดำ ทุกตัวมีขนาดใกล้เคียงกัน

การจำลอง Kirby รุ่นที่ 1866: Kirby เล็กลง เร็วขึ้น และหนีอันตรายอย่างเป็นระบบ

สถิติการจำลอง Kirby: ความ Fitness พลังชีวิต พฤติกรรมของแต่ละบุคคลเรียงตามประสิทธิภาพ

จุดสำคัญ: คุณไม่ได้กำหนดวิธีแก้ปัญหา อัลกอริทึม ค้นหามันเอง และนั่นคือสิ่งที่ทำให้มันมีประสิทธิภาพสำหรับปัญหาที่คุณไม่รู้ว่าการผสมผสานพารามิเตอร์ที่เหมาะสมที่สุดจะเป็นอย่างไร

โครงข่ายประสาทเทียม

โครงข่ายประสาทเทียมเป็นแบบจำลองทางคณิตศาสตร์ที่เรียบง่ายของสมองมนุษย์ ประกอบด้วย:

  • นิวรอนอินพุต: สิ่งที่โครงข่าย "มองเห็น"
  • นิวรอนเอาต์พุต: สิ่งที่โครงข่าย "ตัดสินใจ"
  • การเชื่อมต่อ (น้ำหนัก): การเชื่อมต่อแต่ละจุดมี น้ำหนัก ที่ขยายหรือลดสัญญาณ

หลักการคือง่าย: นิวรอนอินพุตแต่ละตัวส่งค่าของมัน คูณด้วยน้ำหนักการเชื่อมต่อ แล้วบวกกับสัญญาณอื่นๆ ถ้าผลลัพธ์เกินเกณฑ์บางอย่าง (ฟังก์ชันเปิดใช้งาน) นิวรอนเอาต์พุตจะทำงาน

ในการเปรียบเทียบของ Laupok กับมาริโอและเคอร์เซอร์เมาส์:

  • นิวรอนอินพุต = ระยะห่างระหว่างมาริโอกับเคอร์เซอร์
  • น้ำหนักการเชื่อมต่อ = ความอ่อนไหวของมาริโอ
  • นิวรอนเอาต์พุต = มาริโอกรีดร้องหรือไม่

ยิ่งเคอร์เซอร์ใกล้ ค่าอินพุตก็ยิ่งสูง ถ้าน้ำหนักแรง สัญญาณเอาต์พุตก็แรง และมาริโอจะกรีดร้อง เมื่อเปลี่ยนน้ำหนัก คุณก็เปลี่ยนความอ่อนไหวของมาริโอ

เดโม "มาริโอกลัว": มาริอยืนหน้า Boo พร้อมแถบซินแนปส์ที่แสดงน้ำหนักการเชื่อมต่อระหว่างอินพุตและเอาต์พุต

ในโครงข่ายประสาทเทียมของ AI จริง มันเป็นตรรกะเดียวกัน แต่ในขนาดมหึมา:

  • นิวรอนอินพุต 99 ตัว (ตาราง 11×9 ไทล์จากมุมมองของมาริโอ)
  • นิวรอนเอาต์พุต 8 ตัว (A, B, X, Y, ขึ้น ลง ซ้าย ขวา)
  • นิวรอนซ่อน ระหว่างนั้น
  • การเชื่อมต่อหลายร้อยจุดที่มีน้ำหนักต่างกัน

NEAT: อัลกอริทึมที่เปลี่ยนทุกอย่าง

ปัญหาของอัลกอริทึมพันธุกรรมพื้นฐาน

ถ้าคุณผสมอัลกอริทึมพันธุกรรมกับโครงข่ายประสาทเทียมอย่างง่ายๆ คุณมีปัญหา: คุณสร้างโครงข่ายประสาทเทียม 100 ตัวที่แตกต่างกันโดยสิ้นเชิง และคุณไม่สามารถเปรียบเทียบมันได้ แต่ละตัวมีนิวรอน การเชื่อมต่อ และน้ำหนักของตัวเอง คุณจะรู้ได้อย่างไรว่าโครงข่ายสองตัว "คล้ายกัน" หรือ "ต่างกัน"?

นี่คือที่ที่ NEAT เข้ามา -- NeuroEvolution of Augmenting Topologies หรือการวิวัฒน์โครงข่ายประสาทแบบเพิ่มขยาย ประดิษฐ์โดย Kenneth Stanley และ Risto Miikkulainen ในปี 2002 มันแก้ปัญหานี้ได้พอดี

สายพันธุ์ (Species)

กลไกสำคัญตัวแรกของ NEAT คือ สายพันธุ์ เมื่อโครงข่ายประสาทเทียมตัวหนึ่งแตกต่างจากอีกตัวมากเกินไป มันจะถูกจัดเป็นสายพันธุ์ต่างออกไป ความคล้ายคลึงคำนวณผ่านพารามิเตอร์สามตัว:

  1. ส่วนเกิน (EXCES_COEF = 0.50): จำนวนการเชื่อมต่อที่ไม่มีอะไรเหมือนกันระหว่างสองโครงข่าย (นวัตกรรมที่ต่างกัน)
  2. แยก (Disjoint): เหมือนกัน แต่สำหรับการเชื่อมต่อที่อยู่ตรงกลาง
  3. ความแตกต่างของน้ำหนัก (POIDSDIFF_COEF = 0.92): ค่าเฉลี่ยของความแตกต่างน้ำหนักระหว่างการเชื่อมต่อที่มีนวัตกรรมเดียวกัน

สูตรคะแนน:

score = (EXCES_COEF × disjoint) / max(nbConnexions1 + nbConnexions2, 1)
      + POIDSDIFF_COEF × diffPoids

ถ้าคะแนนนี้ต่ำกว่า DIFF_LIMITE (1.0) โครงข่ายสองตัวอยู่ในสายพันธุ์เดียวกัน มิฉะนั้นจะสร้างสายพันธุ์ใหม่

นวัตกรรม (Innovations)

นี่คือความอัจฉริยะของ NEAT ทุกครั้งที่สร้างการเชื่อมต่อใหม่ มันจะได้รับหมายเลข นวัตกรรม ที่ไม่ซ้ำกันและเป็นสากล หมายเลขนี้ติดตามโครงข่ายประสาทเทียมแม้ว่ามันจะสืบพันธุ์ก็ตาม

ในทางปฏิบัติ เมื่อลูกถูกสร้างผ่านการข้ามพันธุ์ (crossover) มันจะได้รับนวัตกรรมจากพ่อแม่ ถ้าโครงข่ายสองตัวมีนวัตกรรมเดียวกัน นั่นหมายความว่ามันมีการเชื่อมต่อจากบรรพบุรุษเดียวกัน นี่คือสิ่งที่ทำให้สามารถเปรียบเทียบโครงข่ายที่มีขนาดต่างกันได้

การข้ามพันธุ์ (Crossover)

เมื่อโครงข่ายประสาทเทียมสองตัวสืบพันธุ์ การข้ามพันธุ์ ทำงานดังนี้:

Laupok อธิบายแนวคิดการข้ามพันธุ์พร้อมข้อความ "CROSSOVER" ซ้อนทับ

  1. โครงข่ายที่มีประสิทธิภาพดีกว่ากลายเป็น "พ่อแม่เด่น"
  2. ลูกได้รับการเชื่อมต่อทั้งหมดจากพ่อแม่เด่น
  3. สำหรับการเชื่อมต่อแต่ละจุดที่มีนวัตกรรมเดียวกัน พ่อแม่อีกฝ่ายสามารถแทนที่มันได้ (โอกาส 50%)
  4. เฉพาะการเชื่อมต่อที่ยังทำงานจากพ่อแม่ไม่เด่นเท่านั้นที่สามารถแทนที่ได้

การรับประกันนี้ทำให้ลูกต้องดีพอๆ กับพ่อแม่ที่ดีที่สุดเสมอ

การกลายพันธุ์ (Mutations)

หลังจากการข้ามพันธุ์ ลูกผ่านการกลายพันธุ์ด้วยความน่าจะเป็นที่กำหนดได้:

Laupok อธิบายการกลายพันธุ์พร้อมข้อความ "(small modif = mutation)" ซ้อนทับ

การกลายพันธุ์ ความน่าจะเป็น ผลลัพธ์
รีเซ็ตน้ำหนักการเชื่อมต่อ 25% น้ำหนักถูกสุ่มทั้งหมด
การกลายพันธุ์น้ำหนัก 95% น้ำหนักเปลี่ยนแปลง ±0.80
เพิ่มการเชื่อมต่อ 85% การเชื่อมต่อใหม่ระหว่างนิวรอนสองตัวที่ไม่ได้เชื่อมต่อกัน
เพิ่มนิวรอน 39% นิวรอนซ่อนหนึ่งตัวถูกแทรกระหว่างนิวรอนสองตัวที่เชื่อมต่อกัน

อัตราการเพิ่มนิวรอนสำคัญมาก: มันคือสิ่งที่ทำให้โครงข่าย เติบโต ได้ ในตอนแรกมีแค่อินพุตและเอาต์พุต ค่อยๆ นิวรอนซ่อนปรากฏ ทำให้โครงข่ายซับซ้อนขึ้นเรื่อยๆ


โค้ด: การเดินชมทั้งหมด

ค่าคงที่

สคริปต์เริ่มต้นด้วยบล็อกค่าคงที่ที่กำหนดการตั้งค่าทั้งหมด:

-- Mario's view around him
TAILLE_TILE = 16
TAILLE_VUE_W = TAILLE_TILE * 11  -- 176 pixels wide
TAILLE_VUE_H = TAILLE_TILE * 9   -- 144 pixels tall
NB_TILE_W = TAILLE_VUE_W / TAILLE_TILE  -- 11 tiles
NB_TILE_H = TAILLE_VUE_H / TAILLE_TILE  -- 9 tiles

-- Neural network
NB_INPUT = NB_TILE_W * NB_TILE_H  -- 99 inputs (visible tiles)
NB_OUTPUT = 8  -- A, B, X, Y, Up, Down, Left, Right
NB_INDIVIDU_POPULATION = 100  -- individuals per population
NB_NEURONE_MAX = 100000  -- max hidden neurons

-- Fitness
FITNESS_LEVEL_FINI = 1000000  -- value when level is finished
NB_FRAME_RESET_BASE = 33  -- frames without progress before reset
NB_FRAME_RESET_PROGRES = 300  -- frames if progress detected

-- Species
EXCES_COEF = 0.50
POIDSDIFF_COEF = 0.92
DIFF_LIMITE = 1.00

-- Mutations
CHANCE_MUTATION_RESET_CONNEXION = 0.25
POIDS_CONNEXION_MUTATION_AJOUT = 0.80
CHANCE_MUTATION_POIDS = 0.95
CHANCE_MUTATION_CONNEXION = 0.85
CHANCE_MUTATION_NEURONE = 0.39

NB_INPUT เป็น 99 เพราะมุมมองของมาริโอคือ 11×9 ไทล์ แต่ละไทล์เป็นนิวรอนอินพุตหนึ่งตัว ไทล์ว่าง = 0 บล็อก = 1 ศัตรู = -1

เอาต์พุต 8 ตัวตรงกับปุ่มจอย SNES: A, B, X, Y, ขึ้น ลง ซ้าย ขวา Start, Select, L และ R ถูกตัดออกเพื่อไม่ให้มาริโอ "เสียสมาธิ"

โครงสร้างข้อมูล

สคริปต์กำหนดโครงสร้างหลักสามอย่าง:

function newNeurone()
    local neurone = {}
    neurone.valeur = 0    -- current neuron value
    neurone.id = 0        -- unique identifier
    neurone.type = ""     -- "input", "output", or "hidden"
    return neurone
end

function newConnexion()
    local connexion = {}
    connexion.entree = 0     -- source neuron ID
    connexion.sortie = 0     -- destination neuron ID
    connexion.actif = true   -- can be disabled if a hidden neuron is inserted
    connexion.poids = 0      -- connection weight
    connexion.innovation = 0 -- unique innovation number
    connexion.allume = false -- for display: true if signal passes
    return connexion
end

function newReseau()
    local reseau = {
        nbNeurone = 0,        -- number of hidden neurons
        fitness = 1,          -- performance (distance traveled)
        idEspeceParent = 0,   -- which species it belongs to
        lesNeurones = {},     -- neuron array
        lesConnexions = {}    -- connection array
    }
    -- Initialize with inputs
    for j = 1, NB_INPUT, 1 do
        ajouterNeurone(reseau, j, "input", 1)
    end
    -- Then outputs
    for j = NB_INPUT + 1, NB_INPUT + NB_OUTPUT, 1 do
        ajouterNeurone(reseau, j, "output", 0)
    end
    return reseau
end

ในตอนแรก แต่ละโครงข่ายมีแค่อินพุตและเอาต์พุต ไม่มีนิวรอนซ่อน ไม่มีการเชื่อมต่อ อัลกอริทึมตัดสินใจว่าจำเป็นต้องมีหรือไม่

การกลายพันธุ์โดยละเอียด

การกลายพันธุ์น้ำหนัก

function mutationPoidsConnexions(unReseau)
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            if math.random() < CHANCE_MUTATION_RESET_CONNEXION then
                -- 25%: total weight reset
                unReseau.lesConnexions[i].poids = genererPoids()
            else
                -- 75%: variation of ±0.80
                if math.random() >= 0.5 then
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids - POIDS_CONNEXION_MUTATION_AJOUT
                else
                    unReseau.lesConnexions[i].poids =
                        unReseau.lesConnexions[i].poids + POIDS_CONNEXION_MUTATION_AJOUT
                end
            end
        end
    end
end

น้ำหนักเริ่มต้นเป็น 1 หรือ -1 เสมอ (genererPoids()) การเปลี่ยนแปลง ±0.80 สามารถเปลี่ยนให้อยู่ระหว่างค่าลบและบวก เปลี่ยนพฤติกรรมของโครงข่ายอย่าง-radically

เพิ่มการเชื่อมต่อ

function mutationAjouterConnexion(unReseau)
    local liste = {}
    -- Shuffle the neuron list
    for i, v in ipairs(unReseau.lesNeurones) do
        local pos = math.random(1, #liste+1)
        table.insert(liste, pos, v)
    end

    local traitement = false
    for i = 1, #liste, 1 do
        for j = 1, #liste, 1 do
            if i ~= j then
                local n1 = liste[i]
                local n2 = liste[j]
                -- Valid connection: input→output, hidden→hidden, hidden→output
                if (n1.type == "input" and n2.type == "output") or
                   (n1.type == "hidden" and n2.type == "hidden") or
                   (n1.type == "hidden" and n2.type == "output") then
                    -- Check no connection already exists
                    local dejaConnexion = false
                    for k = 1, #unReseau.lesConnexions, 1 do
                        if unReseau.lesConnexions[k].entree == n1.id
                            and unReseau.lesConnexions[k].sortie == n2.id then
                            dejaConnexion = true
                            break
                        end
                    end
                    if dejaConnexion == false then
                        traitement = true
                        ajouterConnexion(unReseau, n1.id, n2.id)
                    end
                end
            end
            if traitement then break end
        end
        if traitement then break end
    end
end

คุณไม่สามารถเชื่อมเอาต์พุตกับอินพุตได้ (นั่นจะสร้างวงจร) และไม่สามารถเชื่อมนิวรอนสองตัวที่เชื่อมต่อกันอยู่แล้ว การสลับรับประกันว่าจะสำรวจความเป็นไปได้ที่ต่างกันทุกครั้ง

เพิ่มนิวรอน

นี่คือการกลายพันธุ์ที่น่าสนใจที่สุด:

function mutationAjouterNeurone(unReseau)
    if #unReseau.lesConnexions == 0 then return nil end
    if unReseau.nbNeurone == NB_NEURONE_MAX then return nil end

    -- Shuffle connections
    local listeRandom = {}
    for i = 1, #unReseau.lesConnexions, 1 do
        local pos = math.random(1, #listeRandom+1)
        table.insert(listeRandom, pos, i)
    end

    for i = 1, #listeRandom, 1 do
        if unReseau.lesConnexions[listeRandom[i]].actif then
            -- Disable the existing connection
            unReseau.lesConnexions[listeRandom[i]].actif = false
            unReseau.nbNeurone = unReseau.nbNeurone + 1
            local indice = unReseau.nbNeurone + NB_INPUT + NB_OUTPUT

            -- Create the hidden neuron
            ajouterNeurone(unReseau, indice, "hidden", 1)

            -- Connect input to hidden neuron
            ajouterConnexion(unReseau,
                unReseau.lesConnexions[listeRandom[i]].entree,
                indice, genererPoids())

            -- Connect hidden neuron to output
            ajouterConnexion(unReseau,
                indice,
                unReseau.lesConnexions[listeRandom[i]].sortie,
                genererPoids())
            break
        end
    end
end

กลไกคือ: คุณเอาการเชื่อมต่อที่มีอยู่ ปิดการใช้งานมัน และแทรกนิวรอนซ่อนไว้ตรงกลาง การเชื่อมต่อดั้งเดิมถูกแทนที่ด้วยสองจุดใหม่: อินพุต→ซ่อน และ ซ่อน→เอาต์พุต มันเหมือนการตัดสายไฟเพื่อต่อสวิตช์เข้าไป

นี่คือสิ่งที่ทำให้ NEAT เป็น "การเพิ่มขยายโครงสร้าง": โครงข่าย เติบโต ไปตามเวลา มันเริ่มง่ายๆ และซับซ้อนก็ต่อเมื่อจำเป็น

feedForward

นี่คือฟังก์ชันที่แพร่กระจายสัญญาณผ่านโครงข่าย:

function feedForward(unReseau)
    -- Reset output neurons
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur = 0
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].allume = false
        end
    end

    -- Propagation
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local avantTraitement = unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur
            unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur =
                unReseau.lesNeurones[unReseau.lesConnexions[i].entree].valeur *
                unReseau.lesConnexions[i].poids +
                unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur

            if avantTraitement ~= unReseau.lesNeurones[unReseau.lesConnexions[i].sortie].valeur then
                unReseau.lesConnexions[i].allume = true
            else
                unReseau.lesConnexions[i].allume = false
            end
        end
    end
end

การเชื่อมต่อที่ยังทำงานแต่ละจุดส่ง ค่าอินพุต × น้ำหนัก ไปยังนิวรอนเอาต์พุต ค่าถูก สะสม (บวก) ธง allume ใช้สำหรับการแสดงโครงข่ายเท่านั้น

อ่านหน่วยความจำของเกม

ฟังก์ชัน getLesInputs() แปลงโลกของ Super Mario World เป็นข้อมูลที่โครงข่ายเข้าใจได้:

function getLesInputs()
    local lesInputs = {}
    -- Initialize to 0 (gray = nothing)
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            lesInputs[getIndiceLesInputs(i, j)] = 0
        end
    end

    -- Sprites (enemies) = -1 (black)
    local lesSprites = getLesSprites()
    for i = 1, #lesSprites, 1 do
        local input = convertirPositionPourInput(getLesSprites()[i])
        if input.x > 0 and input.x < (TAILLE_VUE_W / TAILLE_TILE) + 1 then
            lesInputs[getIndiceLesInputs(input.x, input.y)] = -1
        end
    end

    -- Tiles (blocks) = tile value (white if > 0)
    local lesTiles = getLesTiles()
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local indice = getIndiceLesInputs(i, j)
            if lesTiles[indice] ~= 0 then
                lesInputs[indice] = lesTiles[indice]
            end
        end
    end

    return lesInputs
end

ตารางอินพุตเป็นมุมมองที่:centered บนมาริโอ: กว้าง 11 ไทล์ สูง 9 ไทล์ ค่าของแต่ละไทล์:

  • 0 (เทา): ว่างเปล่า
  • 1 (ขาว): บล็อกแข็ง
  • -1 (ดำ): ศัตรู

ศัตรูถูกอ่านจากรายการสองรายการใน RAM: สไปรต์ปกติ (0x14C8-0x14F8) และสไปรต์ขยาย (0x170B-0x173B) สำหรับสไปรต์ที่ยังมีชีวิตทุกตัว (สถานะ > 7) ตำแหน่งไทล์เทียบกับมาริโอจะถูกคำนวณและ -1 จะถูกวางไว้ในเซลล์ที่ตรงกัน

Fitness: วิธีที่ AI รู้ว่ามันกำลังก้าวหน้า

function majReseau(unReseau, marioBase)
    local mario = getPositionMario()

    if not niveauFini and memory.readbyte(0x0100) == 12 then
        -- Level finished!
        unReseau.fitness = FITNESS_LEVEL_FINI
        niveauFini = true
    elseif marioBase.x < mario.x then
        -- Mario moved right
        unReseau.fitness = unReseau.fitness + (mario.x - marioBase.x)
        marioBase.x = mario.x
    end

    -- Update inputs
    local lesInputs = getLesInputs()
    for i = 1, NB_INPUT, 1 do
        unReseau.lesNeurones[i].valeur = lesInputs[i]
    end
end

Fitness ง่ายๆ: คือ ระยะทางที่เดินไปทางขวา ถ้ามาริโอเคลื่อนที่ 10 พิกเซล fitness จะเพิ่มขึ้น 10 ถ้ามาริโอเคลื่อนที่ทางซ้าย ไม่มีอะไรเกิดขึ้น (ไม่มีบทลงโทษ) ถ้าผ่านด่านแล้ว (ที่อยู่ 0x0100 == 12) fitness จะกลายเป็น 1,000,000

มันตั้งใจให้เรียบง่าย ไม่มีโบนัสสำหรับการฆ่าศัตรู ไม่มีบทลงโทษสำหรับการตาย แค่: เคลื่อนที่ไปทางขวา

การรีเซ็ตอัจฉริยะ

ถ้ามาริโอไม่เคลื่อนที่ 33 เฟรม ด่านจะรีเซ็ตและเราเปลี่ยนไปยังบุคคลถัดไป แต่ถ้ามาริโอทำได้ก้าวหน้า (fitness ปัจจุบันต่างจากจุดเริ่มต้น) เราจะรอ 300 เฟรม -- ให้โอกาสนามเรียนรู้ว่ามันทำอะไรถูก

if fitnessAvant == laPopulation[idPopulation].fitness
   and memory.readbyte(0x13D4) == 0 then
    nbFrameStop = nbFrameStop + 1
    local nbFrameReset = NB_FRAME_RESET_BASE
    if fitnessInit ~= laPopulation[idPopulation].fitness
       and memory.readbyte(0x0071) ~= 9 then
        nbFrameReset = NB_FRAME_RESET_PROGRES
    end
    if nbFrameStop > nbFrameReset then
        nbFrameStop = 0
        lancerNiveau()
        idPopulation = idPopulation + 1
        -- ...
    end
end

เงื่อนไข memory.readbyte(0x0071) ~= 9 ตรวจสอบว่ามาริโอไม่ได้อยู่ในแอนิเมชันตาย ไม่มีประโยชน์ที่จะรีเซ็ตถ้ามาริโอตายแล้ว

ลูปหลัก

ลูปทำงานที่ 30 fps (ความเร็วปกติของ Super Mario World):

while true do
    local fitnessAvant = laPopulation[idPopulation].fitness

    -- Display (network, info)
    if forms.ischecked(estAccelere) then
        emu.limitframerate(false)  -- speed up
    else
        emu.limitframerate(true)   -- 30 fps
    end

    -- The 3 vital functions
    majReseau(laPopulation[idPopulation], marioBase)
    feedForward(laPopulation[idPopulation])
    appliquerLesBoutons(laPopulation[idPopulation])

    emu.frameadvance()
    nbFrame = nbFrame + 1

    -- Reset if no progress
    -- ...
    -- New generation if all individuals tested
    -- ...
end

ฟังก์ชันสำคัญสามอย่างคือ majReseau, feedForward, และ appliquerLesBoutons ปิดใช้งานตัวใดตัวหนึ่ง มาริโอจะหยุดเคลื่อนที่

การข้ามพันธุ์

function crossover(unReseau1, unReseau2)
    local leReseau = newReseau()
    local leBon = unReseau1
    local leNul = unReseau2

    if leBon.fitness < leNul.fitness then
        leBon = unReseau2
        leNul = unReseau1
    end

    leReseau = copier(leBon)

    for i = 1, #leReseau.lesConnexions, 1 do
        for j = 1, #leNul.lesConnexions, 1 do
            if leReseau.lesConnexions[i].innovation == leNul.lesConnexions[j].innovation
               and leNul.lesConnexions[j].actif then
                if math.random() > 0.5 then
                    leReseau.lesConnexions[i] = leNul.lesConnexions[j]
                end
            end
        end
    end
    leReseau.fitness = 1
    return leReseau
end

ลูกได้รับมรดกจากพ่อแม่ที่ดีกว่า สำหรับการเชื่อมต่อแต่ละจุดที่มีนวัตกรรมเดียวกัน พ่อแม่อีกฝ่ายมีโอกาส 50% ที่จะแทนที่มัน -- แต่ เฉพาะเมื่อการเชื่อมต่อยังทำงานเท่านั้น นี่คือการแก้ไขที่สำคัญ: ถ้าไม่มีมัน นิวรอนซ่อนที่ไร้ประโยชน์อาจถูกสร้างขึ้น

การคัดเลือกสายพันธุ์

function nouvelleGeneration(laPopulation, lesEspeces)
    local laNouvellePopulation = newPopulation()
    local nbIndividuACreer = NB_INDIVIDU_POPULATION

    -- Calculate average fitness per species
    for i = 1, #lesEspeces, 1 do
        lesEspeces[i].fitnessMoyenne = 0
        for j = 1, #lesEspeces[i].lesReseaux, 1 do
            lesEspeces[i].fitnessMoyenne =
                lesEspeces[i].fitnessMoyenne + lesEspeces[i].lesReseaux[j].fitness
        end
        lesEspeces[i].fitnessMoyenne =
            lesEspeces[i].fitnessMoyenne / #lesEspeces[i].lesReseaux
    end

    -- Each species creates a number of children proportional to its average fitness
    for i = 1, #lesEspeces, 1 do
        local nbEnfant = math.ceil(
            #lesEspeces[i].lesReseaux *
            lesEspeces[i].fitnessMoyenne / fitnessMoyenneGlobal)

        for j = 1, nbEnfant, 1 do
            local unReseau = crossover(
                choisirParent(lesEspeces[i].lesReseaux),
                choisirParent(lesEspeces[i].lesReseaux))
            mutation(unReseau)
            laNouvellePopulation[indiceNouvelleEspece] = copier(unReseau)
        end
    end
end

แนวคิดคือ: สายพันธุ์ที่มี fitness เฉลี่ย 10,000 จะสร้างลูกได้มากกว่าสายพันธุ์ที่มี fitness เฉลี่ย 1 อย่างมาก นี่คือ การคัดเลือกโดยธรรมชาติ ที่ทำงานจริง

choisirParent ใช้การเลือกรูเล็ต: ยิ่ง fitness ของบุคคลสูงเท่าไหร่ ก็ยิ่งมีโอกาสถูกเลือกเป็นพ่อแม่มากเท่านั้น

การบันทึกและโหลด

ประชากรถูกบันทึกลงไฟล์ .pop:

function sauvegarderUnReseau(unReseau, fichier)
    io.write(unReseau.nbNeurone .. "\n")
    io.write(#unReseau.lesConnexions .. "\n")
    io.write(unReseau.fitness .. "\n")
    for i = 1, unReseau.nbNeurone, 1 do
        local indice = NB_INPUT + NB_OUTPUT + i
        io.write(unReseau.lesNeurones[indice].id .. "\n")
    end
    for i = 1, #unReseau.lesConnexions, 1 do
        local actif = 1
        if unReseau.lesConnexions[i].actif ~= true then actif = 0 end
        io.write(actif .. "\n" ..
            unReseau.lesConnexions[i].entree .. "\n" ..
            unReseau.lesConnexions[i].sortie .. "\n" ..
            unReseau.lesConnexions[i].poids .. "\n" ..
            unReseau.lesConnexions[i].innovation .. "\n")
    end
end

การบันทึกรวมถึงบุคคลที่ดีที่สุดจากประชากรก่อนหน้าทั้งหมดด้วย ถ้าบุคคลที่ดีที่สุดของประชากรเก่าดีกว่าตัวใหม่ เราจะย้อนกลับเป็นตัวเก่าเป็นฐาน นี่คือรูปแบบหนึ่งของ ความเป็นชนชั้น: สิ่งที่ดีที่สุดไม่มีวันสูญหาย

การแสดงโครงข่าย

Laupok เพิ่มตัวแสดงโครงข่ายประสาทเทียมที่ซ้อนทับบนเกม:

function dessinerUnReseau(unReseau)
    -- Inputs: 11×9 grid around Mario
    for i = 1, NB_TILE_W, 1 do
        for j = 1, NB_TILE_H, 1 do
            local xT = ENCRAGE_X_INPUT + (i - 1) * TAILLE_INPUT
            local yT = ENCRAGE_Y_INPUT + (j - 1) * TAILLE_INPUT
            local couleurFond = "gray"
            if unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur < 0 then
                couleurFond = "black"   -- enemy
            elseif unReseau.lesNeurones[getIndiceLesInputs(i, j)].valeur > 0 then
                couleurFond = "white"   -- block
            end
            gui.drawRectangle(xT, yT, TAILLE_INPUT, TAILLE_INPUT, "black", couleurFond)
        end
    end

    -- Outputs: 8 buttons
    for i = 1, NB_OUTPUT, 1 do
        local xT = ENCRAGE_X_OUTPUT
        local yT = ENCRAGE_Y_OUTPUT + ESPACE_Y_OUTPUT * (i - 1)
        if sigmoid(unReseau.lesNeurones[i + NB_INPUT].valeur) then
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "white")
        else
            gui.drawRectangle(xT, yT, TAILLE_OUTPUT_W, TAILLE_OUTPUT_H, "white", "black")
        end
    end

    -- Connections
    for i = 1, #unReseau.lesConnexions, 1 do
        if unReseau.lesConnexions[i].actif then
            local alpha = 25
            if unReseau.lesConnexions[i].allume then alpha = 255 end
            local couleur = forms.createcolor(255, 255, 255, alpha)
            gui.drawLine(
                lesPositions[unReseau.lesConnexions[i].entree].x,
                lesPositions[lesConnexions[i].entree].y,
                lesPositions[unReseau.lesConnexions[i].sortie].x,
                lesPositions[lesConnexions[i].sortie].y,
                couleur)
        end
    end
end

มันมีประโยชน์อย่างเหลือเชื่อสำหรับการทำความเข้าใจว่าโครงข่ายทำอะไร การเชื่อมต่อที่ยังทำงานเป็นสีขาว ที่ไม่ทำงานเป็นกึ่งโปร่งใส อินพุตเป็นตารางเซลล์สีขาว/ดำ/เทา เอาต์พุตแสดงว่าปุ่มใดถูกกด


ผลลัพธ์

สิ่งที่ AI เรียนรู้

ตลอดหลายชั่วโมง (และหลายวัน) ของการรัน AI ค้นพบด้วยตัวเอง:

  1. เคลื่อนที่ไปทางขวา: พฤติกรรมพื้นฐานที่สุด แต่ต้องกดปุ่มค้างไว้
  2. กระโดดข้ามศัตรู: โดยเชื่อมต่ออินพุต "ตรวจพบศัตรู" เข้ากับปุ่ม A หรือ B
  3. หลีกเลี่ยงสิ่งกีดขวาง: โครงข่ายบางตัวเรียนรู้ที่จะถอยกลับชั่วคราวเพื่อก้าวไปข้างหน้าไกลขึ้น
  4. ผ่านด่าน: บุคคลที่ดีที่สุดสามารถผ่านด่านแรกของ Super Mario World ได้

มาริโอที่ควบคุมโดย AI ยืนหน้า Boo ในด่าน Super Mario World -- โครงข่ายประสาทเทียมตัดสินใจการกระทำแบบเรียลไทม์

ข้อจำกัด

โปรเจกต์มีข้อจำกัดของมัน:

  • ด่านเดียว: AI ถูกฝึกบนด่านใดด่านหนึ่งโดยเฉพาะ มันไม่ได้ขยายไปยังด่านอื่นโดยอัตโนมัติ
  • เวลาฝึก: ต้องใช้เวลาหลายสิบชั่วโมงเพื่อให้ได้ผลลัพธ์ที่น่าพอใจ
  • ไม่เข้าใจ: AI ไม่ได้ "เข้าใจ" ว่ามันทำอะไร มันเพิ่มประสิทธิภาพฟังก์ชัน fitness (ระยะทางที่เดิน) ผ่านการกลายพันธุ์แบบสุ่ม
  • T-bagging: Laupok สังเกตว่ามาริโอกระโดดอยู่กับที่เมื่อเห็นศัตรู แค่เพราะมันเพิ่ม fitness (เขาเคลื่อนที่เล็กน้อยขณะกระโดด)

วิธีทำซ้ำการทดลอง

Laupok แบ่งปันทุกอย่าง นี่คือขั้นตอน:

  1. ดาวน์โหลด BizHawk จาก tasvideos.org (ส่วนดาวน์โหลด)
  2. หา ROM ของ Super Mario World เวอร์ชัน USA (สำเนาส่วนตัวจากตลับของคุณเอง)
  3. ดาวน์โหลดสคริปต์ Lua จาก Pastebin -- เปลี่ยนชื่อเป็น mario.lua
  4. วางสคริปต์ไว้ในโฟลเดอร์เดียวกับ ROM
  5. เริ่ม BizHawk เปิด ROM
  6. ใน Lua console: dofile("mario.lua") หรือผ่านเมนู Script > Open Script
  7. บันทึกสถานะ ที่จุดเริ่มต้นของด่าน (เมนู Savestate > Save State) และตั้งชื่อว่า debut.state
  8. เริ่มสคริปต์ใหม่ -- มันทำงานแล้ว

สคริปต์มีแบบฟอร์มพร้อมตัวเลือก:

  • Accelerate: ปิดการจำกัด 30 fps เพื่อให้เร็วขึ้น
  • Show network: แสดงโครงข่ายประสาทเทียมซ้อนทับบนเกม
  • Show info: แสดงแบนเนอร์พร้อมรุ่น, fitness และจำนวนสายพันธุ์
  • Pause: หยุดการรันชั่วคราว
  • Save/Load: บันทึกและโหลดประชากรปัจจุบันไปยังไฟล์ .pop

แหล่งอ้างอิงและแหล่งข้อมูล

ทรัพยากร ลิงก์
วิดีโอหลักของ Laupok I built an AI that plays Mario by itself
วิดีโอทบทวนโค้ด + การตั้งค่า How to set up the AI + source code review
ซอร์สโค้ดทั้งหมด Pastebin Jcvdqhqm
บทความ NEAT ดั้งเดิม Stanley & Miikkulainen, "Evolving Neural Networks through Augmenting Topologies", 2002
บทเรียน N8Programs NEAT implementation walkthrough (JavaScript แต่แนวคิดเหมือนกัน)
16blings (แรงบันดาลใจของ Laupok) AI plays Super Mario World
BizHawk tasvideos.org/BizHawk
หน่วยความจำ Super Mario World SMW Central - RAM Map

สรุป

สิ่งที่ Laupok ทำคือการนำอัลกอริทึมเชิงวิชาการ (NEAT, 2002) มาเขียนใหม่เป็น Lua สำหรับอิมิวเลเตอร์ (BizHawk) และนำไปใช้กับ Super Mario World ผลลัพธ์: AI ที่เรียนรู้จากศูนย์เพื่อเล่นเกม โดยไม่มีความรู้ล่วงหน้า ผ่านการกลายพันธุ์แบบสุ่มและการคัดเลือกโดยธรรมชาติเท่านั้น

มันเป็นตัวอย่างที่สวยงามของพลังของอัลกอริทึมพันธุกรรม ไม่มี deep learning ไม่มี GPU ไม่มีข้อมูลฝึกหลายล้านจุด แค่การคัดเลือกโดยธรรมชาติ Lua บางส่วน และความอดทนมากมาย

โค้ดมีหมายเหตุ แบ่งปัน และ Laupok ทำวิดีโออธิบายสองเรื่อง -- เรื่องใหญ่สำหรับแนวคิดหลัก อีกเรื่องสำหรับโค้ด ถ้าหัวข้อนี้น่าสนใจ ดำดิ่งเข้าไปเลย มันเข้าถึงง่ายกว่าที่ดูเหมือน

Related Articles