SearchComparison.kt
src/jvmMain/kotlin/afield/study/search/SearchComparison.kt · 299 lines
package afield.study.search
import afield.graphics.color.Color4
import afield.media.image.Img
import afield.media.image.withLabel
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
Equal-budget comparison of search drivers for any GenomeOps and Objective: "does search beat random sampling here?"
Every arm gets exactly the same number of objective calls at every seed, and the result keeps each
run's per-evaluation SearchRun.trace, so the arms can be compared at any budget up to the one they
ran at. compareSearch runs it; braitenberg_vehicles.md §10 and alife_examples.md ("Does search
beat random?") read it.
class SearchArm<G>
One search driver as an arm of a SearchComparison: a name and how to run it for a budget at a seed.
class SearchArm<G>(
/** Short name for tables, plots and [SearchComparison.of]. */
val name: String,
/** Run the driver on `ops` and `objective` for exactly `budget` evaluations from `seed`. */
val run: (ops: GenomeOps<G>, objective: Objective<G>, budget: Int, seed: Long) -> SearchRun<G>,
) {
override fun toString(): String = name
The name.
companion object
companion object {
fun <G> random(): SearchArm<G>
The control: independent draws from GenomeOps.random.
fun <G> random(): SearchArm<G> = SearchArm("random") { o, f, b, s -> PopulationSearch.random(o, f, b, seed = s) }
fun <G> hillClimb(patience: Int = 30): SearchArm<G>
PopulationSearch.hillClimb, restarting after patience non-improving steps.
fun <G> hillClimb(patience: Int = 30): SearchArm<G> =
SearchArm("hillclimb") { o, f, b, s -> PopulationSearch.hillClimb(o, f, b, seed = s, patience = patience) }
fun <G> evolve
PopulationSearch.evolve, (µ + λ) with tournament 2.
fun <G> evolve(
mu: Int = 6,
lambda: Int = 12,
crossoverRate: Double = 0.3,
immigrantRate: Double = 0.0,
name: String = if (immigrantRate > 0.0) "immigrants" else "evolve",
): SearchArm<G> = SearchArm(name) { o, f, b, s ->
PopulationSearch.evolve(o, f, b, seed = s, mu = mu, lambda = lambda, crossoverRate = crossoverRate, immigrantRate = immigrantRate)
}
fun <G> standard(): List<SearchArm<G>>
The four arms braitenberg_vehicles.md §10 settled on: random, hill climbing, µ6/λ12 evolution,
and µ12/λ24 evolution with 25% immigrants (the one variant that beat random on vehicle brains).
fun <G> standard(): List<SearchArm<G>> = listOf(
random(), hillClimb(), evolve(),
evolve(mu = 12, lambda = 24, immigrantRate = 0.25),
)
}
}
class SearchComparisonCell<G>
One arm's run at one seed.
class SearchComparisonCell<G>(
/** The arm's name. */
val arm: String,
/** The search seed. */
val seed: Long,
/** The run, with its per-evaluation [SearchRun.trace]. */
val run: SearchRun<G>,
)
class SearchComparison<G>
compareSearch's result: every arm at every seed, all at budget evaluations.
class SearchComparison<G>(
/** What was searched, for the table header. */
val label: String,
/** Evaluations each run was allowed. */
val budget: Int,
/** Arm names, in run order; the first is the control the others are scored against. */
val arms: List<String>,
/** Every run, seed-major. */
val cells: List<SearchComparisonCell<G>>,
) {
fun of(arm: String): List<SearchComparisonCell<G>> = cells.filter { it.arm == arm }
The runs of arm, in seed order.
val seeds: Int get() = of(arms.first()).size
Seeds per arm.
fun best(arm: String): DoubleArray = of(arm).map { it.run.bestFitness }.toDoubleArray()
Best fitness of each seed's run of arm.
fun bestAt(arm: String, evaluations: Int): DoubleArray
Best-so-far of each seed's run of arm after evaluations evaluations (≤ budget).
fun bestAt(arm: String, evaluations: Int): DoubleArray =
of(arm).map { it.run.trace[min(evaluations, it.run.trace.size) - 1] }.toDoubleArray()
fun wins(a: String, b: String, evaluations: Int = budget): Int
Seeds at which a ended strictly above b, optionally after only evaluations.
fun wins(a: String, b: String, evaluations: Int = budget): Int {
val x = bestAt(a, evaluations)
val y = bestAt(b, evaluations)
return x.indices.count { x[it] > y[it] }
}
fun median(arm: String, evaluations: Int = budget): Double
Median per-seed best of arm after evaluations.
fun median(arm: String, evaluations: Int = budget): Double = quantile(bestAt(arm, evaluations).sortedArray(), 0.5)
fun leader(evaluations: Int = budget): String = arms.maxBy { median(it, evaluations) }
The arm with the highest median after evaluations.
fun champion(arm: String): G = of(arm).maxBy { it.run.bestFitness }.run.best!!.genome
The best genome any run of arm found.
fun rows(evaluations: Int = budget): String
One line per arm: median, IQR, min, max of the per-seed bests at evaluations, and wins against the control.
fun rows(evaluations: Int = budget): String = buildString {
val control = arms.first()
for (a in arms) {
val b = bestAt(a, evaluations).sortedArray()
append(" %-10s median %.4f IQR %.4f..%.4f range %.4f..%.4f".format(
a, quantile(b, 0.5), quantile(b, 0.25), quantile(b, 0.75), b.first(), b.last()))
if (a != control) append(" beats %s at %d/%d".format(control, wins(a, control, evaluations), b.size))
append('\n')
}
}
fun table(evaluations: Int = budget): String
rows under a header naming the label, budget and seed count.
fun table(evaluations: Int = budget): String = "%s, %d evals x %d seeds\n".format(label, evaluations, seeds) + rows(evaluations)
fun budgetTable(budgets: List<Int>): String
The same runs read at several budgets (each ≤ budget): one row per budget, each arm's median and
wins against the control. Equal to running each budget separately (pinned by SearchComparisonTest),
because none of the drivers schedules around its budget.
fun budgetTable(budgets: List<Int>): String = buildString {
append("%-7s".format("evals"))
for (a in arms) append(" %20s".format(a))
append('\n')
for (n in budgets) {
append("%-7d".format(n))
for (a in arms) {
val cell = if (a == arms.first()) "%.4f".format(median(a, n)) else "%.4f (%d/%d)".format(median(a, n), wins(a, arms.first(), n), seeds)
append(" %20s".format(cell))
}
append('\n')
}
}
fun perSeed(): String
Per-seed bests, one row per seed.
fun perSeed(): String = buildString {
append("seed")
for (a in arms) append(" %10s".format(a))
append('\n')
val byArm = arms.map { of(it) }
for (i in byArm[0].indices) {
append("%4d".format(byArm[0][i].seed))
for (r in byArm) append(" %10.4f".format(r[i].run.bestFitness))
append('\n')
}
}
fun curve(arm: String): Array<DoubleArray>
Best-so-far of arm after each evaluation across seeds: [q25, median, q75] rows.
fun curve(arm: String): Array<DoubleArray> {
val runs = of(arm)
return Array(budget) { t ->
val v = runs.map { it.run.trace[min(t, it.run.trace.size - 1)] }.sorted().toDoubleArray()
doubleArrayOf(quantile(v, 0.25), quantile(v, 0.5), quantile(v, 0.75))
}
}
fun medianRun(arm: String): SearchRun<G>
The run of arm whose best fitness is the median over seeds (the lower middle for an even count).
fun medianRun(arm: String): SearchRun<G> {
val runs = of(arm).sortedBy { it.run.bestFitness }
return runs[(runs.size - 1) / 2].run
}
fun fitnessRange(): ClosedFloatingPointRange<Double>
The fitness range plot uses when none is given: every arm's quartile band.
fun fitnessRange(): ClosedFloatingPointRange<Double> {
val all = arms.flatMap { a -> curve(a).flatMap { it.toList() } }.filter { it.isFinite() }
val lo = all.minOrNull() ?: 0.0
val hi = max(lo + 1e-9, all.maxOrNull() ?: 1.0)
return lo..hi
}
fun plot(width: Int = 300, height: Int = 300, yRange: ClosedFloatingPointRange<Double> = fitnessRange()): Img
Best-so-far fitness against evaluations: each arm's median as a line, its interquartile range as a
band, its final median written at the right. Arms take palette in order (grey for the control,
then blue, orange, green). Pass the same yRange to two plots to put them on one scale.
body — 55 lines, from line 189
fun plot(width: Int = 300, height: Int = 300, yRange: ClosedFloatingPointRange<Double> = fitnessRange()): Img {
var out = Img.filled(width, height, 3, 0.06f)
val curves = arms.associateWith { curve(it) }
val lo = yRange.start
val hi = max(lo + 1e-9, yRange.endInclusive)
val colours = arms.withIndex().associate { (i, a) -> a to palette[i % palette.size] }
val left = 32
val right = width - 40
val top = 8
val bottom = height - 18
fun px(t: Int) = (left + t.toDouble() / (budget - 1).coerceAtLeast(1) * (right - left)).roundToInt()
fun py(v: Double) = (bottom - (v - lo) / (hi - lo) * (bottom - top)).roundToInt().coerceIn(top, bottom)
fun put(x: Int, y: Int, c: FloatArray, a: Float) {
if (x !in 0 until width || y !in 0 until height) return
val o = (y * width + x) * 3
for (k in 0..2) out.data[o + k] = out.data[o + k] * (1 - a) + c[k] * a
}
val axis = floatArrayOf(0.45f, 0.46f, 0.5f)
for (y in top..bottom) put(left - 1, y, axis, 1f)
for (x in left..right) put(x, bottom + 1, axis, 1f)
val labels = ArrayList<Triple<String, Int, Int>>()
for (k in 0..4) {
val v = lo + (hi - lo) * k / 4
val y = py(v)
for (x in left - 4 until left - 1) put(x, y, axis, 1f)
for (x in left..right step 4) put(x, y, axis, 0.25f)
labels += Triple("%.2f".format(v), 1, y - 3)
}
for (n in listOf(0, budget / 2, budget)) {
val x = px((n - 1).coerceAtLeast(0))
for (y in bottom + 2..bottom + 4) put(x, y, axis, 1f)
val text = n.toString()
labels += Triple(text, (x - 3 * text.length).coerceIn(0, width - 6 * text.length), bottom + 7)
}
for ((a, c) in curves) for (t in 0 until budget) {
val x = px(t)
for (y in py(c[t][2])..py(c[t][0])) put(x, y, colours[a]!!, 0.12f)
}
for ((a, c) in curves) for (t in 0 until budget) {
val y0 = py(c[t][1])
val y1 = if (t + 1 < budget) py(c[t + 1][1]) else y0
for (y in min(y0, y1)..max(y0, y1)) for (dx in 0..1) put(px(t) + dx, y, colours[a]!!, 1f)
}
for (l in labels) out = out.withLabel(l.first, l.second, l.third, scale = 1, color = Color4(0.6, 0.62, 0.68))
// End-of-run medians, nudged apart so they never overprint.
val ends = curves.map { (a, c) -> Triple(a, c[budget - 1][1], py(c[budget - 1][1])) }.sortedBy { it.third }
var lastY = -100
for ((a, v, y) in ends) {
val yy = max(y - 3, lastY + 9)
lastY = yy
val c = colours[a]!!
out = out.withLabel("%.2f".format(v), right + 4, yy, scale = 1, color = Color4(c[0].toDouble(), c[1].toDouble(), c[2].toDouble()))
}
return out
}
override fun toString(): String = table()
companion object
companion object {
val palette: List<FloatArray>
Arm colours in order: grey (the control), blue, orange, green, then pink and yellow.
val palette: List<FloatArray> = listOf(
floatArrayOf(0.7f, 0.7f, 0.7f),
floatArrayOf(0.3f, 0.6f, 1f),
floatArrayOf(1f, 0.55f, 0.15f),
floatArrayOf(0.35f, 0.85f, 0.4f),
floatArrayOf(0.95f, 0.4f, 0.7f),
floatArrayOf(0.95f, 0.85f, 0.3f),
)
fun quantile(sorted: DoubleArray, q: Double): Double
Linear-interpolated quantile of an ascending array; NaN when empty.
fun quantile(sorted: DoubleArray, q: Double): Double {
if (sorted.isEmpty()) return Double.NaN
val p = q * (sorted.size - 1)
val i = p.toInt()
val f = p - i
return if (i + 1 < sorted.size) sorted[i] * (1 - f) + sorted[i + 1] * f else sorted[i]
}
}
}
fun <G> compareSearch
Run every arm in arms on ops and objective for exactly budget objective calls at every seed in
seeds. The first arm is the control the others' wins are counted against.
Seeds run in parallel on the common pool and are gathered in seed order; each run is a pure function of
its seed, so the numbers do not depend on the parallelism — provided objective is safe to call from
several threads at once (every objective in alife_evolve.kt and alife_vehicle_brains.kt is: each
call builds its own world). Pass parallel = false for one that is not.
Reading the result at a smaller budget (SearchComparison.bestAt, SearchComparison.budgetTable) is
exact for random sampling and the hill climber, whose draws do not depend on the budget; and for
evolution, because PopulationSearch.evolve does not schedule around its budget either.
fun <G> compareSearch(
ops: GenomeOps<G>,
objective: Objective<G>,
budget: Int = 300,
seeds: List<Long> = (1L..12L).toList(),
arms: List<SearchArm<G>> = SearchArm.standard(),
label: String = "search",
parallel: Boolean = true,
): SearchComparison<G> {
require(budget >= 1) { "budget must be >= 1" }
require(arms.isNotEmpty()) { "need at least one arm" }
require(arms.map { it.name }.toSet().size == arms.size) { "arm names must be distinct" }
val runSeed = { s: Long -> arms.map { a -> SearchComparisonCell(a.name, s, a.run(ops, objective, budget, s)) } }
val cells = if (parallel) seeds.parallelStream().map(runSeed).toList().flatten() else seeds.flatMap(runSeed)
return SearchComparison(label, budget, arms.map { it.name }, cells)
}