Neil Ang

Developer

A stunning likeness of Neil Ang
Hello world

Multi-layer perceptron (MLP) network in ruby

Posted on

If you've read the book Artificial Intelligence for Games you will find pseudo code for building an artificial neural network using a backpropagation technique.

I've taken the pseudo code and converted it to a basic implementation.

class InputReference
  attr_accessor :perceptron
  attr_accessor :weight

  def initialize(perceptron)
    @perceptron = perceptron
    @weight     = rand()
  end
end

class Perceptron
  GAIN = 0.3 # Learning rate
  H    = 2.0 # Shape of threshold function

  attr_accessor :input_references
  attr_accessor :state
  attr_accessor :error

  def initialize(incoming)
    @input_references = incoming.map { |i| InputReference.new(i) }
    @state = 0
    @error = 0
  end

  def feedforward
    result = input_references.inject(0) do |sum, input_ref|
      sum + (input_ref.weight * input_ref.perceptron.state)
    end

    self.state = threshold(result)
  end

  def adjust_weights(current_error)
    input_references.each do |input_ref|
      delta_weight = (GAIN * current_error) * input_ref.perceptron.state
      input_ref.weight += delta_weight
    end

    self.error = current_error
  end

  def incoming_weight(perceptron)
    ref = input_references.find { |input_ref| input_ref.perceptron == perceptron }
    ref ? ref.weight : 0.0
  end

  private

  def threshold(value)
    1.0 / (1.0 + Math.exp(-H * value))
  end
end

class NeuralNetwork
  attr_accessor :input_perceptrons
  attr_accessor :hidden_perceptrons
  attr_accessor :output_perceptrons

  def initialize(input_size, hidden_size, output_size = 1)
    @input_perceptrons  = Array.new(input_size)  { Perceptron.new([]) }
    @hidden_perceptrons = Array.new(hidden_size) { Perceptron.new(@input_perceptrons) }
    @output_perceptrons = Array.new(output_size) { Perceptron.new(@hidden_perceptrons) }
  end

  def learn_pattern(inputs, outputs)
    generate_output(inputs)
    backpropagation(outputs)
  end

  def generate_output(inputs)
    input_perceptrons.each_with_index do |input_perceptron, idx|
      input_perceptron.state = inputs[idx]
    end

    hidden_perceptrons.each(&:feedforward)
    output_perceptrons.each(&:feedforward)

    output_perceptrons.map(&:state)
  end

  def backpropagation(outputs)
    output_perceptrons.each_with_index do |output_perceptron, idx|
      state = output_perceptron.state
      error = state * (1.0 - state) * (outputs[idx] - state)
      output_perceptron.adjust_weights(error)
    end

    hidden_perceptrons.each_with_index do |hidden_perceptron, idx|
      result = output_perceptrons.inject(0) do |sum, output_perceptron|
        sum + output_perceptron.incoming_weight(hidden_perceptron) * output_perceptron.error
      end
      state = hidden_perceptron.state
      error = state * (1.0 - state) * result
      hidden_perceptron.adjust_weights(error)
    end
  end
end

Testing

To train and test the network a simple "exclusive or" test can be devised. Interestingly, this particular problem is only solvable through an artificial neural network that contains a hidden layer.

class ExclusiveOrTest
  def initialize
    @mlp = NeuralNetwork.new(2, 4, 1)
  end

  def train!(x = 2000)
    x.times do
      @mlp.learn_pattern([0,0], [0])
      @mlp.learn_pattern([0,1], [1])
      @mlp.learn_pattern([1,0], [1])
      @mlp.learn_pattern([1,1], [0])
    end
  end

  def evaluate(a, b)
    @mlp.generate_output([a, b]).first
  end

  def test(a, b)
    value = evaluate(a, b)
    state = value >= 0.5
    "#{a} xor #{b} = #{state} (#{value})"
  end
end

xor = ExclusiveOrTest.new
xor.train!

puts xor.test(0, 0) # 0 xor 0 = false (0.08868044830976042)
puts xor.test(1, 1) # 1 xor 1 = false (0.04243359517427669)
puts xor.test(1, 0) # 1 xor 0 = true (0.9408234917055486)
puts xor.test(0, 1) # 0 xor 1 = true (0.9412222977409437)

Extending the network

Although this implementation works, it only allows a single hidden layer to be used. We can further extend this example to allow for multiple hidden layers.

class InputReference
  attr_accessor :perceptron
  attr_accessor :weight

  def initialize(perceptron)
    @perceptron = perceptron
    @weight     = rand()
  end
end

class Perceptron
  GAIN = 0.8 # Learning rate
  H    = 2.0 # Shape of threshold function

  attr_accessor :input_references
  attr_accessor :state
  attr_accessor :error

  def initialize(incoming)
    @input_references = incoming.map { |i| InputReference.new(i) }
    @state = 0
    @error = 0
  end

  def feedforward
    result = input_references.inject(0) do |sum, input_ref|
      sum + (input_ref.weight * input_ref.perceptron.state)
    end

    self.state = threshold(result)
  end

  def adjust_weights(current_error)
    input_references.each do |input_ref|
      delta_weight = (GAIN * current_error) * input_ref.perceptron.state
      input_ref.weight += delta_weight
    end

    self.error = current_error
  end

  def incoming_weight(perceptron)
    ref = input_references.find { |input_ref| input_ref.perceptron == perceptron }
    ref ? ref.weight : 0.0
  end

  private

  def threshold(value)
    1.0 / (1.0 + Math.exp(-H * value))
  end
end

class NeuralNetwork
  attr_accessor :input_perceptrons
  attr_accessor :output_perceptrons
  attr_accessor :hidden_perceptron_layers

  def initialize(*args)
    input_size  = args.shift
    output_size = args.pop

    @input_perceptrons = Array.new(input_size)  { Perceptron.new([]) }

    previous_layer = @input_perceptrons
    @hidden_perceptron_layers = []
    args.each do |layer_size|
      hidden_layer = Array.new(layer_size) { Perceptron.new(previous_layer) }
      @hidden_perceptron_layers << hidden_layer
      previous_layer = hidden_layer
    end

    @output_perceptrons = Array.new(output_size) { Perceptron.new(previous_layer) }
  end

  def learn_pattern(inputs, outputs)
    generate_output(inputs)
    backpropagation(outputs)
  end

  def generate_output(inputs)
    input_perceptrons.each_with_index do |input_perceptron, idx|
      input_perceptron.state = inputs[idx]
    end

    hidden_perceptron_layers.each do |hidden_perceptron_layer|
      hidden_perceptron_layer.each(&:feedforward)
    end
    output_perceptrons.each(&:feedforward)

    output_perceptrons.map(&:state)
  end

  def backpropagation(outputs)
    output_perceptrons.each_with_index do |output_perceptron, idx|
      state = output_perceptron.state
      error = state * (1.0 - state) * (outputs[idx] - state)
      output_perceptron.adjust_weights(error)
    end

    previous_layer = output_perceptrons
    hidden_perceptron_layers.reverse.each do |hidden_perceptron_layer|
      hidden_perceptron_layer.each_with_index do |hidden_perceptron, idx|
        result = previous_layer.inject(0) do |sum, output_perceptron|
          sum + output_perceptron.incoming_weight(hidden_perceptron) * output_perceptron.error
        end
        state = hidden_perceptron.state
        error = state * (1.0 - state) * result
        hidden_perceptron.adjust_weights(error)
      end
      previous_layer = hidden_perceptron_layer
    end
  end
end

When introducing these additional layers into the network I personally found that training had to be greatly increased or the threshold and learning gain rate modified.