Machine Learning Final Project

BY

Chris Porcelli

-and-

Patrick Pan


Introduction

The genetic programming package GPJPP, written in Java, served as the motivation and starting point for this project. We were curious as to its extension and application in exploring problems in genetic programming. The particular problem we applied this package to was one which has probably held the attention of people since childhood and has been spoken about since ancient times in Greek myths, helping to imprison a mythical creature known as the minotaur in an island known as Crete. This artifice is more commonly referred to as a maze.

Mazes in themselves are fascinating creations of their own right, destined to forever confound minds, challenge mankind's spatial authority, serve as exercises in navigation and ultimately test one's patience (after one's navigational prowess and tolerance is at an ebb). However, we were not interested in mazes, scintillating though they may be. On the contrary, we were more concerned with their solution. After all, when all is said and done, and one has studied a maze and gazed into its depths in awe of its intricate beauty, praising the ingenuity of its craft and its labyrinthine paths, when one has finally exhausted his supply of goodwill and diplomacy, when one's patience has finally run out, all that one wishes to do at that point is to solve the blasted thing!

Approach

Solutions to mazes that vex one's patience and general solutions that promise liberation from captivity represent a heavenly gift to those who have sought a trapdoor out of the labyrinthine madness in which they find themselves ensconced. Our ambitions in such an undertaking were quite a bit more modest, as were our expectations. We were aware of the "right hand" rule of searching mazes, which is to basically place one's right hand upon the wall and follow the wall to its eventual exit. However, it does not provide the solution when applied to disconnected mazes (mazes in which the exit is not connected to the borders of the outer wall).

Our approach was to attempt to generate a general solution for searching mazes (or certain types of mazes), with the aid of the GPJPP genetic programming package mentioned above. We followed the tree-structure of program statements, using the Symbolic Regression program as an example of the format such program statements must take. To this end, the programming "language" we devised made use of similar structures such as functions and terminals. There are eight different expressions, four of which are functions and four of which are terminals. They are:

  "N?"  -  Predicate function determining whether north wall is open
  "S?"  -  Predicate function determining whether south wall is open
  "E?"  -  Predicate function determining whether east wall is open
  "W?"  -  Predicate function determining whether west wall is open

  "N"   -  Move north.
  "S"   -  Move south.
  "E"   -  Move east.
  "W"   -  Move west.

We took advantage of the program tree structure to specify that the left branch of any predicate function specified the command(or another function) that would be evaluated if the the predicate was true, while the right branch would be evaluated if the predicate turned out to be false.

For instance, the following program:


    N?
   / \
  N   S

Would mean: if there wasn't a wall to the north, move north. Otherwise, move south.

In such a way, a tree could be constructed that would provide an algorithm for navigating through a maze. We also stipulated that the program that was making its way through a maze would self-destruct if it ever ran into a wall. This would force the program trees to be more "intelligent", rather than simply bouncing against every wall it ran up against. There was another problem in the actual evaluation of a program: whether or not it actually found the exit. If we were to assume that every program that was generated/evolved would be guaranteed to find a solution eventually, there would no longer be a problem of solving a maze! However, in reality, there are programs which will not find a solution, and would run forever as long as it avoided running into walls. In order to prevent this from occurring, we specified that a program would have a "time-to-live" value, which is the number of turns it is allowed to take before self-destructing. Every program statement, that is, predicate functions and terminal commands, takes exactly 1 turn to evaluate. In this sense, there is a time limit in which a program must find an exit or be destroyed. After these specifications the final matter left was the maze. We had to specify the format of the maze, its generation, and the evaluation of the program in the maze. We decided to actually specify the maze and other constraints in a file that would be loaded up by the program. The mazes themselves were generated by hand, since we wanted to know the solution of the maze beforehand. Also, the solution to the maze was included in the file as well, in the form of a matrix of fitness values.

The exact format of the file is as follows:


MAZE_DIMENSIONS(W H) START(X Y) END(X Y) TIME-TO-LIVE
-----------
Actual Maze
-----------
--------------
Fitness Matrix
--------------

The two mazes we trained the GP on are as follows:

Default maze - solvable by "right-hand" rule

10 10 1 0 9 4 80
X XXXXXXXX
X X X   XX
X X X X  X
X   X XX X
X XXX  X  
X   X XXXX
X XXX XX X
X   X X  X
X X     XX
XXXXXXXXXX
-1  6 -1 -1 -1 -1 -1 -1 -1 -1
-1  6 -1  4 -1  2  1  1 -1 -1
-1  5 -1  4 -1  2 -1  1  1 -1
-1  5  5  5 -1  2 -1 -1  1 -1
-1  5 -1 -1 -1  2  2 -1  1  0
-1  5  4  5 -1  2 -1 -1 -1 -1
-1  5 -1 -1 -1  2 -1 -1  4 -1
-1  5  4  4 -1  3 -1  3  4 -1
-1  5 -1  3  3  3  3  3 -1 -1
-1 -1 -1 -1 -1 -1 -1 -1 -1 -1

Circular Maze - not solvable by "right hand" rule

8 8 0 4 3 4 80
XXXXXXXX
X      X
X XXXX X
X XX   X
  X  X X
X XXXX X
X      X
XXXXXXXX
-1 -1 -1 -1 -1 -1 -1 -1
-1  4  4  4  3  3  3 -1
-1  5 -1 -1 -1 -1  3 -1
-1  5 -1 -1  1  2  2 -1
 6  5 -1  0  1 -1  3 -1
-1  5 -1 -1 -1 -1  3 -1
-1  5  4  4  4  3  3 -1
-1 -1 -1 -1 -1 -1 -1 -1

Results

There was some initial difficulty in debugging the Java code we had written from scratch for program evaluation and loading, but apart from those technicalities, we did not encounter any other unexpected difficulties.

The following program trees were generated from several runs, with a population of 50 programs traversing a maze. Fitness is measured by the location of a program when it terminates.


Default "Regular" Maze




Circular Maze



Related Work

We selected two research papers for reading. Due to the time constraints, we were unable to find anything closely related to the topic we chose to investigate.

The first paper is entitled "Automatic Programming of a Time-Optimal Robot Controller and an Analog Electrical Circuit to Implement the Robot Controller by Means of Genetic Programming", by Koza, Bennett, Keane, and Andre. In this paper, the authors discuss a gp-created program to control a robot to arrive at a destination while minimizing the time spent. While this paper is somewhat related in topic to our project, they are more concerned with physical models of robots and use circular coordinate systems to represent robotic navigation (rather than our more simplified view of ordinates). Their evaluation of a program features a more complex set of arithmetic and trigonometric operators to minimize time.

The second paper is entitled "Future Work and Practical Applications of Genetic Programming", and is written by John Koza. This paper is actually a chapter of a volume, Handbook of Evolutionary Computation. It is a description of future areas for research and application of Genetic Programming. The summaries and descriptions of other promising areas are rather brief, but the section concerning the handling of complex data structures can be applied to our problem.

Conclusions

After evolving several generations of algorithms, we noticed a trend in the data: the best fitness value seemed to stabilize at a certain point. We believe this is because of the time allotted for program evaluation. The programs are actually dying before they are able to completely search the maze. Experiments in lengthening the time-to-live to a large number could prove to be fruitful in the future.

The "default" maze, which could be solved by the right-hand rule, produced quite a few surprising programs that were able to venture far into the maze. The circular maze, on the other hand, seemed to create fragments of programs that had a "right-hand" rule strategy and simply got the program close to the exit, sometimes even using a "left-hand" rule! Part of what may have affected the results and evolution is the choice of fitness values. The matrix was generated by hand, and values were suggested based upon their proximity to the exit. The result may not have been an accurate measurement of the effectiveness of the program in navigation. Another thing was the small number of sample mazes we evolved the programs upon. We only used two mostly due to time constraints (plus those mazes were annoying to create). There seem to be rich avenues of exploration in this problem for future work as well.

Appendix

Init File "gpmaze.ini"

PopulationSize            = 500
NumberOfGenerations       = 51
CrossoverProbability      = 90.0
CreationProbability       = 0.0
CreationType              = RampedHalf
MaximumDepthForCreation   = 9
MaximumDepthForCrossover  = 26
MaximumComplexity         = 300
SelectionType             = Probabilistic
TournamentSize            = 7
DemeSize                  = 100
DemeticMigProbability     = 100.0
SwapMutationProbability   = 0.0
ShrinkMutationProbability = 0.0
TerminationFitness        = 0.3
GoodRuns                  = 5
DemeticGrouping           = false
AddBestToNewPopulation    = true
SteadyState               = false
PrintDetails              = false
PrintPopulation           = false
PrintExpression           = true
PrintTree                 = true
TreeFontSize              = 12
UseADFs                   = false
TestDiversity             = true
ComplexityAffectsFitness  = true
CheckpointGens            = 0

Java Code


import java.io.*;
import gpjpp.*;

// Maze class

class Maze {
  boolean[][] maze;
  int[][] fitness;
  int rows, cols;
  pos start, end;
  int ttl;

  Maze(int rows, int cols) {
    this.rows    = rows;
    this.cols    = cols;
    this.maze    = new boolean[cols][rows];
    this.fitness = new int[cols][rows];
    start        = new pos(0, 0);
    end          = new pos(0, 0);
  }

  public boolean isNopen(pos location) {
    if (location.y == 0) { return false; }
    return this.maze[location.x][location.y - 1];
  }

  public boolean isSopen(pos location) {
    if (location.y == this.rows - 1) { return false; }
    return this.maze[location.x][location.y + 1];
  }

  public boolean isEopen(pos location) {
    if (location.x == this.cols - 1) { return false; }
    return this.maze[location.x + 1][location.y];
  }

  public boolean isWopen(pos location) {
    if (location.x == 0) { return false; }
    return this.maze[location.x - 1][location.y];
  }

  public pos moveN(pos location) {
    if ((location.y > 0) &&
	(this.isNopen(location))) { location.y -= 1; }
    return location;
  }

  public pos moveS(pos location) {
    if ((location.y < this.rows - 1) &&
	(this.isSopen(location))) { location.y += 1; }
    return location;
  }

  public pos moveE(pos location) {
    if ((location.x < this.cols - 1) &&
	(this.isEopen(location))) { location.x += 1; }
    return location;
  }

  public pos moveW(pos location) {
    if ((location.x > 0) &&
	(this.isWopen(location))) { location.x -= 1; }
    return location;
  }
}

// X, Y coordinate positions

class pos {
  int x, y;

  pos(int x, int y) {
    this.x = x;
    this.y = y;
  }
}

//=====================================================================

// GP maze search tree

class GPMaze extends GPRun {

  //  must override GPRun.createNodeSet to return
  //  initialized set of functions & terminals

  public static Maze maze;

  protected GPAdfNodeSet createNodeSet(GPVariables cfg) {
    GPAdfNodeSet adfNs = new GPAdfNodeSet();

    adfNs.reserveSpace(1);
    GPNodeSet ns0 = new GPNodeSet(8);
    adfNs.put(0, ns0);

    ns0.putNode(new GPNode(0, "N?", 2));
    ns0.putNode(new GPNode(1, "S?", 2));
    ns0.putNode(new GPNode(2, "E?", 2));
    ns0.putNode(new GPNode(3, "W?", 2));
    ns0.putNode(new GPNode(4, "N"));
    ns0.putNode(new GPNode(5, "S"));
    ns0.putNode(new GPNode(6, "E"));
    ns0.putNode(new GPNode(7, "W"));

    // The following is a possible addition (time permitting)
    //	ns0.putNode(new GPNode(8,  "BACK1");
    //	ns0.putNode(new GPNode(9,  "BACK2");
    //	ns0.putNode(new GPNode(10, "BACK3");
    //	ns0.putNode(new GPNode(11, "BACK4");

    return adfNs;
  }

  // must override GPRun.createPopulation to return
  //  GPMaze-specific population
  protected GPPopulation createPopulation(GPVariables cfg,
					  GPAdfNodeSet adfNs)
  {
    return new GPMazePopulation(cfg, adfNs);
  }

  // maze loader
  protected void loadMaze(String mazeName) throws IOException {
    InputStream     in = new FileInputStream(mazeName);
    StreamTokenizer st = new StreamTokenizer(in);
    int tokenType, rows, cols;
    Maze m;

    // read in header info

    st.nextToken();  rows      = (int)st.nval;
    st.nextToken();  cols      = (int)st.nval;
    m = new Maze(rows, cols);
    st.nextToken();  m.start.x = (int)st.nval;
    st.nextToken();  m.start.y = (int)st.nval;
    st.nextToken();  m.end.x   = (int)st.nval;
    st.nextToken();  m.end.y   = (int)st.nval;
    st.nextToken();  m.ttl     = (int)st.nval;

    // read in maze

    // disable whitespace parsing
    st.ordinaryChar(' ');  st.wordChars(' ', ' ');

    for (int j = 0; j < cols; j++) {
      // read in a line of the maze
      st.nextToken();
      for (int i = 0; i < rows; i++) {
	if (st.sval.charAt(i) == 'X') {
	  m.maze[i][j] = false;
	}
	else {
	  m.maze[i][j] = true;
	}
      }
    }

    // re-enable whitespace parsing
    st.ordinaryChar(' ');  st.whitespaceChars(' ', ' ');

    // read in fitness values

    for (int j = 0; j < cols; j++) {
      for (int i = 0; i < rows; i++) {
	st.nextToken();  m.fitness[i][j] = (int)st.nval;
      }
    }

    // Verbose debugging info
    System.out.println("ROWS: " + m.rows);
    System.out.println("COLS: " + m.cols);
    System.out.println("Start: (" + m.start.x + ", " + m.start.y + ")");
    System.out.println("End: (" + m.end.x + ", " + m.end.y + ")");
    System.out.println("TTL: " + m.ttl);

    in.close();

    this.maze = m;
  }

  // construct this test case
  GPMaze(String mazeName, String baseName) throws IOException {
    super(baseName, true);
    // load in maze
    loadMaze(mazeName);
  }

  // main application function
  public static void main(String[] args) throws IOException {

    // compute base file name from command line parameter
    String baseName, mazeName;

    switch (args.length) {
    case 2:
      mazeName = args[0];
      baseName = args[1];
      break;
    case 1:
      mazeName = args[0];
      baseName = "gpmaze";
      break;
    default:
      mazeName = "default.dat";
      baseName = "gpmaze";
    }

    // construct the test case
    GPMaze test = new GPMaze(mazeName, baseName);

    // run the test
    test.run();

    // make sure all threads are killed
    System.exit(0);
  }
}

//=====================================================================

// extend GPGene to evaluate program

class GPMazeGene extends GPGene {

  // public null constructor required during stream loading only

  // this constructor called when new genes are created
  GPMazeGene(GPNode gpo) { super(gpo); }

  // this constructor called when genes are cloned during reproduction
  GPMazeGene(GPMazeGene gpo) { super(gpo); }

  // called when genes are cloned during reproduction
  protected Object clone() { return new GPMazeGene(this); }

  // ID routine isA() required only for streams

  // must override GPGene.createChild to create GPMaze instances
  public GPGene createChild(GPNode gpo) { return new GPMazeGene(gpo); }

  /*  Code is now useless...
    // protected divide, here protected by IEEE floating point standards
    double divide(double x, double y) {
    // return NaN or INF if y = 0
    return x/y;
    }
    */

  // called by GPMazeGP.evaluate() with a given maze
  pos evaluate(GPMazeGene root, Maze m, pos location, int ttl)
  {
    int branch;

    //  Check for terminal conditions:

    if ((location.x == m.end.x) &&
	(location.y == m.end.y))
      {  //  program has found the exit: end.
	return location;
      }

    if (ttl <= 0) {  //  running time is up: end.
      return location;
    }

    // run the maze-searching program on the given maze
    // for ttl turns

    if (isFunction()) {  //  this node contains a branch predicate
      switch (node.value()) {
      case 0:  // N? - Is it possible to go north?
	if (m.isNopen(location)) {  //  yes: execute left prg branch
	  branch = 0;
	}
	else {  //  no: execute right prg branch
	  branch = 1;
	}

	break;
      case 1:  // S? - Is it possible to go south?
	if (m.isSopen(location)) {
	  branch = 0;
	}
	else {
	  branch = 1;
	}

	break;
      case 2:  // E? - Is it possible to go east?
	if (m.isEopen(location)) {
	  branch = 0;
	}
	else {
	  branch = 1;
	}

	break;
      case 3:  // W? - Is it possible to go west?
	if (m.isWopen(location)) {
	  branch = 0;
	}
	else {
	  branch = 1;
	}

	break;
      default:
	throw new RuntimeException("Undefined function type " +
				   node.value());
      }

      location = ((GPMazeGene)get(branch)).evaluate(root,
						    m,
						    location,
						    ttl - 1);
    } else {  //  an actual movement command
      switch (node.value()) {
      case 4:  // move North
	if (! m.isNopen(location)) { ttl = 0; }
	location = m.moveN(location);  break;
      case 5:  // move South
	if (! m.isSopen(location)) { ttl = 0; }
	location = m.moveS(location);  break;
      case 6:  // move East
	if (! m.isEopen(location)) { ttl = 0; }
	location = m.moveE(location);  break;
      case 7:  // move West
	if (! m.isWopen(location)) { ttl = 0; }
	location = m.moveW(location);  break;

      default:
	throw new RuntimeException("Undefined terminal type " +
				   node.value());
      }

      // Go back to root of tree

      location = root.evaluate(root, m, location, ttl - 1);
    }

    return location;
  }
}

//=====================================================================

class GPMazeGP extends GP {

  //this constructor called when new GPs are created
  GPMazeGP(int genes) { super(genes); }

  //this constructor called when GPs are cloned during reproduction
  GPMazeGP(GPMazeGP gpo) { super(gpo); }

  //called when GPs are cloned during reproduction
  protected Object clone() { return new GPMazeGP(this); }

  //must override GP.createGene to create GPMazeGene instances
  public GPGene createGene(GPNode gpo) { return new GPMazeGene(gpo); }

  //must override GP.evaluate to return standard fitness
  public double evaluate(GPVariables cfg) {
    double rawFitness;
    pos location = new pos(GPMaze.maze.start.x, GPMaze.maze.start.y);

    location = ((GPMazeGene)get(0)).evaluate((GPMazeGene)get(0),
					     GPMaze.maze,
					     location,
					     GPMaze.maze.ttl);

    rawFitness = (double)GPMaze.maze.fitness[location.x][location.y] / 10.0;
    if (rawFitness < 0.0) {
      System.out.println("Fitness: " + rawFitness + "(" +
			 location.x + ", " + location.y + ")");
    }

    double stdF = Math.abs(rawFitness);
    if (cfg.ComplexityAffectsFitness)
      // add 0.1 for optimum length (10)
      stdF += (double)length()/100.0;

    return stdF;
  }
}

//=====================================================================

// extend GPPopulation to create Maze GP trees

class GPMazePopulation extends GPPopulation {

  //this constructor called when new populations are created
  GPMazePopulation(GPVariables gpVar, GPAdfNodeSet adfNs) {
    super(gpVar, adfNs);
  }

  //populations are not cloned in standard runs
  //GPMazePopulation(GPMazePopulation gpo) { super(gpo); }
  //protected Object clone() { return new GPMazePopulation(this); }

  //must override GPPopulation.createGP to create GPMaze instances
  public GP createGP(int numOfGenes) { return new GPMazeGP(numOfGenes); }
}