DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Walking AI: Simple Neural Network from Scratch
Kira L
Kira L

Posted on

Walking AI: Simple Neural Network from Scratch

In this tutorial, we are going to make a walking or running AI from scratch in JavaScript, with matter.js as the physics engine. If you don't plan on using JavaScript or matter.js, you can certainly follow along, but you will have to rewrite the code. If you would like to watch a video about this, go here. The final project can be seen here, and the GitHub repository is here.

As a disclaimer, this is not an end-to-end tutorial. I explain the most difficult parts, but it is up to you to do the parameter-fiddling, graphics, and general structure.

What We Will Cover:

  • Making the Robot
  • The Machine Learning
    • Getting the inputs
    • Running the neural network
    • Drawing the Robot
    • Displaying Its (Currently Random) Gait
  • The Genetic Algorithm
    • How to Rank the Robots
    • Which Robots Should Reproduce, and How Many?
    • The Reproduction
  • Parameters to Fiddle With
  • My Final Result

Making the Robot

Over half of the source code is just making the robots exist. If you haven't used matter.js before, you can download it here. You can read the whole documentation here, but the functions we will need are:

//set up
const {Engine,Composite,Render,World,Bodies,Body,Detector,Constraint,Runner} = Matter;
var engine = Engine.create();
var runner = Runner.create();

// creates a static rectangle (can't move)
var ground = Bodies.rectangle(x, y, width, height, {isStatic: true, collisionFilter: {
    category: 1
}});

//creates a rectangle that can be moved
Bodies.rectangle(x, y, width, height, paramsObject);

//creates a circle that can be moved
Bodies.circle(x, y, radius, paramsObject);

//draws a rectangle/circle/polygon in the HTML canvas
ctx.beginPath();
ctx.moveTo(verts[0].x, verts[0].y)// go to the first vertex
for (var i = 1; i < verts.length; i++) {
    ctx.lineTo(verts[i].x, verts[i].y); // draw each of the next verticies
}
ctx.lineTo(verts[0].x, verts[0].y); //go back to the first one
ctx.fill(); // fill it
ctx.stroke(); // add a border

//makes an object that won't intersect with anything
var paramsObject = {
    collisionFilter: {
        category: 2,
        group: Body.nextGroup(false),
        mask: 1
    },
    // other parameters such as friction, density, etc. here
}
//and then pass paramsObject into where you create the rectangle/circle

//add something to the world
World.add(engine.world, [list of things to add])
Enter fullscreen mode Exit fullscreen mode

Since more than one robot will race at a time, we will create a class called Bob (the robots are named Bob), and a list called bobs which will store all of the Bobs.

var ground = Bodies.rectangle(600, 600, 1200, 100, {isStatic: true, collisionFilter: {category: 1}});
var bobs = [];

class Bob{
    constructor(weights){
        // Make all of the body parts here.
        // I won't include the code to make the body parts because it's too long.
        // Go to graphics.js in the source code if you want to copy exactly how I did it,
        // but I would recommend designing the robot on your own.

        // add all of the body parts to the world
        World.add(engine.world, [
            ground,
            this.rightThigh, 
            this.leftThigh, 
            this.rightShin, 
            this.leftShin, 
            this.torso, 
            this.head, 
            this.arm, 

            this.leftTorsoToLeg, 
            this.rightKnee, 
            this.leftKnee, 
            this.rightTorsoToLeg, 
            this.sholder,
            this.neck
        ]);
        bobs.push(this); //add this to the list of bobs
    }
    draw(col){
        //draws each limb in the color specified
        appearRect(this.leftThigh.vertices, col);
        appearRect(this.leftShin.vertices, col);

        appearRect(this.rightThigh.vertices, col);
        appearRect(this.rightShin.vertices, col);

        appearRect(this.torso.vertices, col);
        appearCirc(this.head, col);
        appearRect(this.arm.vertices, col);
    }
}
Enter fullscreen mode Exit fullscreen mode

The appearRect and appearCirc functions draw the rectangles and circles (you can write the functions yourself). Now, every time you want to create a robot, use new Bob([list of weights]). When you want to draw all of the robots, just iterate through the list bobs and draw() each of them. To remove all of the robots, you need to use:

World.clear(engine.world);
Engine.clear(engine);
bobs = [];
Enter fullscreen mode Exit fullscreen mode

The Machine Learning

For this project, I did not use tensorflow.js or any other machine learning library. Implementing a very simple neural network and Genetic Algorithm from scratch is surprisingly easy if you understand the theory behind it!

I started with the most simple neural network possible, and never actually ended up needing anything more complicated. This neural network has neither biases (the biases actually made it worse) nor hidden layers. All it does is take 7 inputs with information about where the robot is, multiplies them by the appropriate weights, and gives 4 outputs which describe where the robot should move in the future.

Getting the inputs

Just like any other machine learning project, we need to start with the data preprocessing. We generally want all of the inputs to be from 0-1, but this isn't strict. If there is a specific input that you think is 5 times as important, try making it range from 0-5 instead of from 0-1.

// obj is the robot to be moved
var inputs = [
    obj.leftTorsoToLeg.angleA/Math.PI/2, //angle of left torso
    obj.rightTorsoToLeg.angleA/Math.PI/2, //angle of right torso
    obj.rightKnee.angleA/Math.PI/2, //angle of right knee
    obj.leftKnee.angleA/Math.PI/2, //angle of left knee
    obj.torso.angle/Math.PI/2, //angle of torso
    1/(1+Math.E**(550-obj.leftShin.bounds.max.y)), //lowest point off the ground of the left shin
    1/(1+Math.E**(550-obj.rightShin.bounds.max.y)) //lowest point off the ground of right shin
];
Enter fullscreen mode Exit fullscreen mode

Let's explain what each of these inputs are. First, we will break down 1/(1+Math.E**(550-obj.something.bounds.max.y))). 550-obj.something.bounds.max.y is the distance from the limb's lowest point to the ground, and 1/(1+Math.E**x)) is a sigmoid. We include a sigmoid because the distance from the ground can be extremely large or extremely small, and we need to normalize it.

obj.leftTorsoToLeg.angleA/Math.PI/2 is the angle of the left hip. We divide by Math.PI/2 so that all of the angles range from 0 to 1 instead of from 0 to 2PI.

Running the neural network

var outputs = [0,0,0,0,0];

for (var i = 0; i < 35; i++) {
    outputs[Math.floor(i/5)] += obj.weights[i] * inputs[i%7];
}
Enter fullscreen mode Exit fullscreen mode

The % operator is the modulus, or the remainder when divided by 7. The code above is a shorter way of writing

var outputs = [0,0,0,0,0];

outputs[0] += obj.weights[0] * inputs[0];
outputs[0] += obj.weights[1] * inputs[1];
outputs[0] += obj.weights[2] * inputs[2];
outputs[0] += obj.weights[3] * inputs[3];
outputs[0] += obj.weights[4] * inputs[4];
outputs[0] += obj.weights[5] * inputs[5];
outputs[0] += obj.weights[6] * inputs[6];

outputs[1] += obj.weights[7] * inputs[0];
outputs[1] += obj.weights[8] * inputs[1];
outputs[1] += obj.weights[9] * inputs[2];
...
outputs[4] += obj.weights[28] * inputs[4];
outputs[4] += obj.weights[29] * inputs[5];
outputs[4] += obj.weights[30] * inputs[6];
Enter fullscreen mode Exit fullscreen mode

Each of the outputs is the linear combination of the inputs and its weights. The first output uses weights 0-6, the 2nd uses 7-12, the 3rd uses 13-18, the 4th uses 19-24, and the 5th uses 25-30.

obj.weights is a list containing all of the weights for that specific robot. For example, the winning weights in my program look like:

[0.18834910252749903,-0.42210118210117537,-0.282405069062347,-0.18779796377809643,0.35392962793905547,0.08652163281465311,-0.1683227913757347,0.27437336159984244,-0.15736460024327373,0.14172118611462192,-0.4330814082625428,0.28958751579459086,-0.2359942212566043,0.3178187768335743,0.13653278898043975,-0.45054794905994267,-0.06280852816771779,-0.3340736301275634,-0.1783600329925001,0.17661413127755907,-0.4968709401087665,-0.04941657163272649,0.0806457051422557,-0.10155357399245674,0.107063353032232954,-0.4223661866478451,-0.2831760111970353,0.3557805746944544,0.25778944810578747,0.24074724355018923,0.47785061674252083,0.2546941475880225,-0.2816248228446361,0.0388214927192042,0.39670983755588035,-0.08301800688060372,-0.05630540145803672,-0.09999896706725496,-0.008475885592672955,0.039582396033190456]
Enter fullscreen mode Exit fullscreen mode

The genetic algorithm is the part chooses these weights. Until we make that, obj.weights can just be completely random.

Moving the robot

Now, once we have the outputs, we have to actually move the robot. In matter.js, it looks like this:

// move the body parts with the outputs of the NN
Body.setAngularVelocity(obj.rightThigh,activation(outputs[0]));
Body.setAngularVelocity(obj.leftThigh,activation(outputs[1]));
Body.setAngularVelocity(obj.rightShin,activation(outputs[2]));
Body.setAngularVelocity(obj.leftShin,activation(outputs[3]));
Body.setAngularVelocity(obj.arm,activation(outputs[4]));

var activation = x=>Math.sin(x); 
Enter fullscreen mode Exit fullscreen mode

This code sets the angular velocity of each of the limbs to the neural network's output. The angular velocity is basically how much the limb is turning. You canalso have the neural network control the angles themselves, or the angles of the joints instead of limbs, etc.

For the activation function, I found that a sine wave worked best. You can use a different (more normal) activation function if you would like to as well.

Displaying Its (Currently Random) Gait

We will need to display this gait, even though it is currently terrible. I will not go over the actual code for the graphics part, but 4 things are executed every 30 milliseconds:

  • moves the time in matter js forward 30 milliseconds.
  • displays the background and then draws each of the robots (64 of them run at a time).
  • moves each the robots based on their (currently random) neural networks.
  • checks if any robots died, and whether or not it should start a new generation.

The Genetic Algorithm

When you run the neural network now, it obviously won't walk, because it's random!

So, we have to teach it to learn. To do this, we will us the most simple possible genetic algorithm: asexual reproduction. This is split into three parts: ranking the robots, choosing which robots to reproduce, and the actual reproduction.

How to rank the robots

Once a robot's head goes below the red line (70 pixels off the ground), it dies. When a robot dies, it cannot move anymore. Also, to speed up training time, all the robots die after 10 seconds. The robots are then ranked by distance traveled before dying. Once all of the robots have died, the current generation ends and a new generation starts. You can tweak the ranking system, or completely change it, if needed.

//obj is the robot currently being moved
if(obj.head.bounds.max.y > 480 || timePassed > 100){
    //kill a robot. 
    //sets each body part static so the computer doesn't spend effort moving the dead body parts anymore
    Body.setStatic(obj.rightThigh, true); 
    Body.setStatic(obj.leftThigh, true); 
    Body.setStatic(obj.rightShin, true); 
    Body.setStatic(obj.leftShin, true); 
    Body.setStatic(obj.torso, true); 
    Body.setStatic(obj.arm, true); 
    Body.setStatic(obj.head, true); 

    obj.distanceTraveled = closestPos(obj); // the closest position to the starting line

    numberBotsDead++;
    if(numberBotsDead == bobs.length){
        endGeneration();
    }
}

function closestPos(ob){
    var limbs = [
        ob.rightThigh.bounds.min.x, // the limb's lowest x position
        ob.leftThigh.bounds.min.x, 
        ob.rightShin.bounds.min.x, 
        ob.leftShin.bounds.min.x, 
        ob.torso.bounds.min.x, 
        ob.arm.bounds.min.x, 
        ob.head.bounds.min.x, 
    ];
    return Math.min(...limbs); //the lowest of each limb's lowest x positions
}
Enter fullscreen mode Exit fullscreen mode

Which Robots Should Reproduce, and How Many?

Now, we have to choose which robots to kill, save, and reproduce. First, we need to rank the robots based on distance traveled:

// bobs is the list of robots in the previous generation
var sorted = bobs.sort((a,b)=>{
    return b.distanceTraveled - a.distanceTraveled;
});
Enter fullscreen mode Exit fullscreen mode

Now the first element in sorted is the best robot, and the last element in sorted is the worst.

Next, we will add the variations. We can't just add 64 of the best robot because it would prematurely kill new ideas.

Suppose the robots already found a pretty good way of walking. Then, one robot discovers a radically different way of walking, but doesn't go as fast as the original way. If we don't kill this new idea immediately, the new way of walking might evolve into something much better than the old way.

Because of this, we will add:

  • Variations of the top 7 placing weights.
  • 10 new, randomly generated weights.
  • The best 5 placing weights from the previous generation, to make sure it never gets worse.

Note that these numbers are completely arbitrary, so feel free to change them.

haveKids(sorted[0], 25); //have 25 kids from the best one
haveKids(sorted[1], 10); //have 10 kids from the next best
haveKids(sorted[2], 5); //etc.
haveKids(sorted[3], 5);
haveKids(sorted[4], 4);
haveKids(sorted[5], 3);
haveKids(sorted[6], 2);

// ad 10 completely random ones
for (var i = 0; i < 10; i++) {
    var weights = [];
    for (var j = 0; j < 35; j++) {
        weights.push(rand());
    }
    new Bob(weights)
}

// in order to make sure it never gets worse, add back the best 5 from the previous generation
new Bob(sorted[4].weights);
new Bob(sorted[3].weights);
new Bob(sorted[2].weights);
new Bob(sorted[1].weights);
new Bob(sorted[0].weights);
Enter fullscreen mode Exit fullscreen mode

The Reproduction

Here, we will actually define the function haveKids(). Each "kid" is just its parent (one parent, not two) with some random changes. I call the amount of change the creativity (it's not a scientific term). As the robot is training, I can change the amount of creativity with a slider input (that's part of the HTML).

In your HTML:

Creativity: 
<input type="range" min="0.001" max="1" value="0.5" step="0.01" id="creativity">
Enter fullscreen mode Exit fullscreen mode
// numKids is the second parameter passed into the function haveKids
for (var i = 0; i < numKids; i++) { // repeat this code the number of kids times
    var newWeights = parent.weights.slice(); // when we change newWeights, we don't change the old weights.

    for (var j = 0; j < newWeights.length; j++) {
        if(Math.random() < 0.1){ // only change a weight 10% of the time
            var creativity = document.getElementById("creativity").value;
            newWeights[j] += (rand()**5)*creativity; //changes the new weight a little
        }
    }

    var newBob = new Bob(newWeights);
}

function rand(){
    return Math.random()-0.5;
}
Enter fullscreen mode Exit fullscreen mode

I use rand()**5, or rand() to the 5th power because it works best that way for me. Feel free to just use rand() or rand()/100, because that might work better for you.

And does it walk?

It most likely won't walk on its first try. If you're lucky, the robots might scoot on their first try. The final, most time consuming step is to fiddle with every possible parameter until it does walk.

Just like a baby, mine went from scooting, to crawling, to jitter-walking, to swinging their legs around their heads, to walking (all babies go through a leg swinging phase, right?). It took me approximately two weeks to get mine to walk as well as the video at the top of this article.

Parameters to Fiddle With

Here are a bunch of things that you can fiddle with to make your robot better. Everyone will have to try different combinations of these things in order to get their robot to walk.

  • If it is vibrate-walking, only let it move its limbs twice per second, instead of moving its limbs every time the screen is drawn (33 times a second for me).
  • Try making a more complicated genetic algorithm such as NEAT (I didn't try this, so I don't know if it is actually helpful).
  • Tinker with the physics. Try changing the friction, restitution, density, etc.
  • Change the inputs given to the neural network. For example, give the positions of the limbs instead of giving the angles.
  • Change what the neural network controls. For example, instead of controlling the angular velocity, maybe control the angle itself.
  • Maybe add hidden layers to the neural network? This may or may not be helpful, I haven't tried it yet.
  • Change the ranking system (currently just who goes furthest before dying). For example, you could rank them by speed, make the robots evade a death line that moves towards them, or give them a complicated fitness score that is a combination of everything.

My Final Result

If you would like to see my final result, go here! If you would like to watch a video about this, go here. If you want to see my other projects, go to kiraprograms.com. If you want to see my fully commented code, see the github repository:

GitHub logo i8sumPi / bob-the-walking-AI

A simple, yet effective, walking AI.

Bob the Walking AI

Bob was created using matter.js for the physics and a very simple neural network, with no hidden layers or biases. I did not use any Machine Learning libraries for this; I did it from scratch in JavaScript (see ml.js). This uses very simple evolution: Bobs die if their head goes below the red line, and the ones that move furthest may reproduce and evolve. Also, the activation function is a sine wave because it works best that way. Surprisingly, after hours of intense coding and tweaking, Bob actually learned how to run and skip (I call it running and skipping, it's not exact)! This project is of the most complex ones I have ever done, and I am honestly shocked that I got it working. However, I can't stop it from falling after around 4 steps. This is from a competition between me and n8progrmas…

Top comments (1)

Collapse
 
n8python profile image
n8programs

Very nice implementation!

"In defense of the modern web" β€” great classic DEV post