R / Rcpp: 4937 ± 4.22
Average of 15 million simulated games. Bounds are a 95% bootstrap confidence interval with 15,000 iterations.
As a supplement to the wonderful Gittins index answers, I wanted to show an empirical method that produces the same result. This solution predicts which robot to move using a neural net that's been trained by a genetic algorithm.
Strategy:
In order to optimally move our robots across the number lines, we need to determine a function that maps each robot's state information to a value that represents our decision to move it. This is called an action-value function. For simplicity, let's choose to always move the robot with the highest action-value.
At this point we could use the Gittins index as our action-value, but let's empirically estimate an action-value function instead.
Estimating the Action-Value Function
I decided to use a neural net (NN) to approximate the action-value function. The NN is fully connected with two hidden layers, each having ten neurons. Weights are initialized using the He method, and biases are initialized uniformly on \$[-1,1]\$. The activation functions are all rectified linear units (ReLU), except the activation function for the output, which is a ReLU clamped to \$[0, 1]\$.
Neural nets often have better performance when the inputs are symmetrically distributed and on the interval \$[-1, 1]\$, but in this problem the input state information of a robot is its current position on the interval \$[0 .. 9]\$ and the number of times it has been moved on the interval \$[0 .. \infty)\$.
I transformed the position of the robot to the \$[-1, 1]\$ interval by shifting and scaling.
To transform the total number of moves I first simulated 10 million games in which I always moved the same robot. This gave a distribution for the total number of moves, which I used to estimate an upper bound. The distribution of the total number of moves is heavily right skewed, so I applied a log transform to reduce skew, and then normalized the result using the log of my estimated upper bound in order to arrive at the \$[-1, 1]\$ interval for total number of moves. Note: Because the total number of moves can be zero, I added one to the total number of moves before taking the log.
Training the Neural Net
In reinforcement learning there are a number of well-established methods for training neural nets that predict action-values, but I have decided to completely ignore them because I've been having a lot of fun with genetic algorithms recently. There's no fun like gradient-free fun.
The genetic algorithm that I used updated NN weights and biases, but not the NN architecture itself. The initial population was 225 chromosomes, and fitness for each chromosome was calculated by evaluating its corresponding action-value function over 7500 games.
It is often desirable for neural net weights and biases to be relatively small and for the model's learned representation to be distributed across the neurons. To achieve this, I added batch normalization to the hidden layers of the neural nets. Regularizing the models in this way greatly improved performance and helped to ensure that the mutation function continued to be effective late in training by keeping the weights and biases close to the mutation function's generating distributions.
The batch normalization's \$\gamma\$ and \$\beta\$ parameters were also updated by the genetic algorithm.
Learned Action-Values:
This graph shows the final neural net's predicted action-values for robots in the most commonly traversed subset of the state space. Note: Because these estimated action-values are not with reference to any rewards, they only have meaning with respect to each other. I have arbitrarily scaled them to be between zero and one.

Code:
R Code to Generate Policy
# Parameters
iters <- 100
popNum <- 225
layers <- c(10, 10)
neval <- 7500
mutRate <- 0.001
# Evolve
population <- generatePopulation(popNum, layers, neval)
out <- rep(NA, iters)
for(i in 1:iters){
# Evaluate population fitness
fitness = evaluatePopulation(population, neval)
out[i] = mean(fitness)
# Keep track of best policy so far
minFit = fitness[length(fitness)]
if(min(fitness) <= minFit){
minFit = min(fitness)
minPolicy = population[[which.min(fitness)]]
}
# Selection
cutoff = quantile(fitness, 0.5)
population = population[fitness <= cutoff]
# Crossover
offspringNum = (popNum - length(population))
offspring = lapply(1:offspringNum, function(i){
inds = sample(1:length(population), 2, FALSE)
crossover(population[[ inds[1] ]], population[[ inds[2] ]])
})
population = c(population, offspring)
# Mutate
population = lapply(1:length(population), function(i){
mutate(population[[i]], rate = mutRate)
})
# Elitism
eliteInds = c(round(0.99 * popNum):popNum)
population[eliteInds] = lapply(eliteInds, function(i){minPolicy})
}
R Code Helper Functions
#' Evaluate Population
#' @export
evaluatePopulation <- function(population, neval){
sapply(population, function(x){
return(mean(evaluatePolicy(x, neval)))
})
}
#' Generate Random Population
#' @export
generatePopulation <- function(size, layers, neval, maxCost = Inf){
# Population
population = list()
# Increase yield
while(TRUE){
# Generate seed population
out = lapply(1:size, function(i){
generateChromosome(layers)
})
# Calculate fitness
fitness = evaluatePopulation(out, neval)
fitness[fitness > maxCost] = NA
inds = order(fitness)
out = out[inds]
fitness = fitness[inds]
# Seed population
population = c(population, out[!is.na(fitness)])
# Report
if(length(population) >= size){
population = population[1:size]
break
} else {
message(paste0("Found: ", length(population)))
}
}
# Return
return(population)
}
Rcpp Code for Genetic Algorithm
#include <Rcpp.h>
using namespace Rcpp;
//* Initialize Environment
// [[Rcpp::export]]
NumericMatrix initEnv() {
return(NumericMatrix (4, 2));
}
//* Update Environment
// [[Rcpp::export]]
NumericMatrix updateEnv(
NumericMatrix env,
int a
) {
//Increase movement count
env(a, 1) += 1;
//Move robot
if(env(a, 0) == 0){
env(a, 0) = 1;
} else {
NumericVector r = Rcpp::runif(1, 0.0, 1.0);
if(r[0] >= 0.5){
env(a, 0) += 1;
} else {
env(a, 0) -= 1;
}
}
//Return
return(env);
}
//* Calculate Total Cost
// [[Rcpp::export]]
double envCost(NumericMatrix env) {
double lambda = 0;
for(int i = 0; i < env.nrow(); i++){
lambda += pow(env(i, 1), 2);
}
return(lambda);
}
//* Generate Chromosome
// [[Rcpp::export]]
List generateChromosome(
IntegerVector layers
) {
//Create container for the NN matrices
List chrom;
//Create weight and bias matrices for each layer
for(int i = 0; i < layers.length() + 1; i++){
//Determine matrix dimensions
int inputNum;
int outputNum;
if(i == 0){
inputNum = 2;
outputNum = layers[i];
} else if(i == layers.length()){
inputNum = layers[i - 1];
outputNum = 1;
} else {
inputNum = layers[i - 1];
outputNum = layers[i];
}
//Initialize weights and biases
NumericVector wtList = Rcpp::rnorm(outputNum * inputNum, 0.0, sqrt(0.5 * inputNum));
NumericVector biList = Rcpp::runif(outputNum, -1.0, 1.0);
NumericMatrix wt (outputNum, inputNum, wtList.begin());
NumericMatrix bi (outputNum, 1, biList.begin());
//Record
List chLayer = List::create(
Named("wt") = wt,
Named("bi") = bi
);
chrom.push_back(chLayer);
}
//Initialize batch normalization matrix
NumericMatrix batch (layers.length(), 2);
NumericVector batchVals = Rcpp::rnorm(batch.size(), 0.0, 1.0);
for(int i = 0; i < batch.size(); i++){
batch[i] = batchVals[i];
}
//Return
List out = List::create(
Named("layers") = layers,
Named("chrom") = chrom,
Named("batch") = batch
);
return(out);
}
//* Crossover
// [[Rcpp::export]]
List crossover(
List x,
List y,
double rate = -1,
double scale = -1
) {
//Set parameters
NumericVector probs = Rcpp::runif(2, 0.0, 1.0);
if(rate >= 0){
probs[0] = rate;
}
if(scale >= 0){
probs[1] = scale;
}
//Crossover the NN
List out = clone(x);
//Access the components
List chrom = out["chrom"];
List xChrom = x["chrom"];
List yChrom = y["chrom"];
//Crossover weights and biases
for(int i = 0; i < chrom.length(); i++){
//Reference the layer
List chLayer = chrom[i];
List xLayer = xChrom[i];
List yLayer = yChrom[i];
//Access the matrices
NumericMatrix wt = chLayer["wt"];
NumericMatrix bi = chLayer["bi"];
NumericMatrix xWt = xLayer["wt"];
NumericMatrix xBi = xLayer["bi"];
NumericMatrix yWt = yLayer["wt"];
NumericMatrix yBi = yLayer["bi"];
//Crossover
LogicalVector doCross_wt = (Rcpp::runif(wt.size(), 0.0, 1.0) < probs[0]);
LogicalVector doCross_bi = (Rcpp::runif(bi.size(), 0.0, 1.0) < probs[0]);
for(int j = 0; j < wt.size(); j++){
if(doCross_wt[j]){
wt[j] = probs[1] * (xWt[j] - yWt[j]) + xWt[j];
}
}
for(int j = 0; j < bi.size(); j++){
if(doCross_bi[j]){
bi[j] = probs[1] * (xBi[j] - yBi[j]) + xBi[j];
}
}
}
//Crossover gamma and beta
NumericMatrix batch = out["batch"];
NumericMatrix xBatch = x["batch"];
NumericMatrix yBatch = y["batch"];
LogicalVector doCross_yb = (Rcpp::runif(batch.size(), 0.0, 1.0) < probs[0]);
for(int i = 0; i < batch.size(); i++){
if(doCross_yb[i]){
batch[i] = probs[1] * (xBatch[i] - yBatch[i]) + xBatch[i];
}
}
//Return
return(out);
}
//* Mutate
// [[Rcpp::export]]
List mutate(
List x,
double rate = -1
) {
//Set parameters
NumericVector probs = Rcpp::runif(1, 0.0, 1.0);
if(rate >= 0){
probs[0] = rate;
}
//Access components
List out = clone(x);
List chrom = out["chrom"];
//Mutate weights and biases
for(int i = 0; i < chrom.length(); i++){
//Access the weights and biases
List chLayer = chrom[i];
NumericMatrix wt = chLayer["wt"];
NumericMatrix bi = chLayer["bi"];
//Mutate
int numInputs = wt.ncol();
LogicalVector doMut_wt = (Rcpp::runif(wt.size(), 0.0, 1.0) <= probs[0]);
LogicalVector doMut_bi = (Rcpp::runif(bi.size(), 0.0, 1.0) <= probs[0]);
for(int j = 0; j < wt.size(); j++){
if(doMut_wt[j]){
wt[j] = Rcpp::rnorm(1, 0.0, sqrt(0.5 * numInputs))[0];
}
}
for(int j = 0; j < bi.size(); j++){
if(doMut_bi[j]){
bi[j] = Rcpp::runif(1, -1.0, 1.0)[0];
}
}
}
//Mutate gamma and beta
NumericMatrix batch = out["batch"];
LogicalVector doMut_yb = (Rcpp::runif(batch.size(), 0.0, 1.0) <= probs[0]);
for(int i = 0; i < batch.size(); i++){
if(doMut_yb[i]){
batch[i] = Rcpp::rnorm(1, 0.0, 1.0)[0];
}
}
//Return
return(out);
}
//* Neural Net Prediction
//*
//* Environment:
//* env[0] = Position
//* env[1] = Total moves
//*
// [[Rcpp::export]]
NumericVector predictNN(
NumericVector env,
List chromosome
) {
//Initialize the output
NumericVector pred (1);
//Process environment
//Normalizing to be in [-1, 1]
NumericVector vIn (2);
vIn[0] = (env[0] / 5 - 1);
vIn[1] = 2 * (log(env[1] + 1) / log(2000)) - 1;
//Compute NN outputs
List chrom = chromosome["chrom"];
NumericMatrix batch = chromosome["batch"];
for(int i = 0; i < chrom.length(); i++){
//Access the layer
List chLayer = chrom[i];
NumericMatrix wt = chLayer["wt"];
NumericMatrix bi = chLayer["bi"];
//Calculate activation
NumericVector vOut (wt.nrow());
for(int j = 0; j < wt.nrow(); j++){
NumericVector wtVec = wt(j, _);
NumericVector biVec = bi(j, _);
NumericVector act = {sum(wtVec * vIn) + biVec};
vOut[j] = act[0];
}
//Apply batch normalization
if(vOut.size() > 1){
vOut = (vOut - mean(vOut)) / sd(vOut);
vOut = batch(i, 0) * vOut + batch(i, 1);
}
//Apply ReLU
for(int j = 0; j < vOut.length(); j++){
NumericVector relu = {0.0, vOut[j]};
vOut[j] = max(relu);
}
//Copy
vIn = clone(vOut);
}
//Record
pred[0] = vIn[0];
//Return
return(Rcpp::clamp(0.0, pred, 1.0));
}
//* NN Prediction to Action
// [[Rcpp::export]]
int predToResponse(NumericVector pred){
//Calculate response
double maxPred = max(pred);
IntegerVector response;
for(int i = 0; i < 4; i++){
if(pred[i] >= maxPred){
response.push_back(i);
}
}
int action = Rcpp::sample(response, 1)[0];
//Return
return(action);
}
//* Evaluate Chromosome
// [[Rcpp::export]]
NumericVector evaluatePolicy(
List chromosome,
int iters
) {
//Initialize parameters
NumericVector out (iters);
NumericVector pred (4);
//Run episodes
for(int i = 0; i < iters; i++){
//Episode parameters
NumericMatrix env = initEnv();
//Complete episode
bool done = false;
while(!done){
//Update state
for(int j = 0; j < 4; j++){
NumericVector state = env(j, _);
pred[j] = predictNN(state, chromosome)[0];
}
int a = predToResponse(pred);
env = updateEnv(env, a);
//Check victory condition
for(int j = 0; j < 4; j++){
if(env(j, 0) >= 10){
out[i] = envCost(env);
done = true;
break;
}
}
}
}
//Return
return(out);
}