FunctionFit.kt
src/jvmMain/kotlin/afield/math/functions/FunctionFit.kt · 774 lines
package and imports — 65 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Function")
package afield.math.functions
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.s
import afield.data.collections.times
import afield.document.context.Why
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.document.workspace.path
import afield.geometry.mesh.topology
import afield.graphics.render.rangeTo
import afield.math.autodiff.FnNonDifferentiable
import afield.math.autodiff.compareTo
import afield.math.autodiff.cos
import afield.math.autodiff.div
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.sin
import afield.math.autodiff.times
import afield.math.linalg.bar
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.linalg.x
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.Gradient
import afield.runtime.execution.end
import afield.runtime.lifecycle.once
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.simulation.automata.trajectory
import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.m
import afield.document.context.why
import afield.geometry.mesh.gradient
import afield.graphics.vulkan.buffers.add
import afield.lang.stacksl.h
import afield.math.autodiff.ADGradient
import afield.math.autodiff.FnAdMetric
import afield.math.autodiff.FnGradientMode
import afield.math.autodiff.exactFitObjective
import afield.math.autodiff.exactFitRefusal
import afield.math.autodiff.grad
import afield.math.autodiff.pow
import afield.math.autodiff.round
import afield.math.linalg.i
import afield.runtime.execution.start
import afield.study.ui.numbers
import afield.ui.output.h
import kotlin.math.abs
import kotlin.math.sqrt
import kotlin.random.Random
Gradient-based fitting of Fn graphs — the differentiable counterpart to the genetic
evolve in FunctionEvolve.kt.
Where evolve searches over structure (seeded mutation of the graph), this file tunes
the numbers: the numeric leaves of a fixed-structure graph are the θ-vector, and Adam
descends the RMSE loss against a target Fn. The two are complementary — fitHybrid
runs evolve to discover a shape, then polishes its constants here.
A lone fitGradient is local and will stall in a frequency local-minimum when the seed's oscillator frequencies are far from the target's (RMSE-vs-frequency is periodic, not convex); fitBest wraps it in deterministic jittered restarts and is the entry to reach for when the starting numbers may be wrong; its own KDoc has the shape of that landscape.
The θ extract/apply primitives ride on Functions.Fn.mapGraphNumbers, whose post-order
DFS is stable and deterministic with preserveSharing = false: the i-th numeric leaf
seen while reading is the i-th leaf written while applying, so a plain index counter is
a sound θ↔graph bijection. Everything is deterministic given a seed: Adam itself has no
randomness, and only an optional FnFitConfig init-jitter draws from a single Random, so a
fit re-run with the same seed comes back identical. FnEvolveRun holds to the same rule.
fun Functions.Fn.extractParameters(): DoubleArray
Reads the numeric leaves of this into a θ-vector in stable post-order-DFS order.
Every const/param leaf (anything carrying numbers["value"]) contributes one entry.
The read is non-destructive: Functions.Fn.mapGraphNumbers returns each value unchanged,
so the returned graph is discarded and only the ordered collection matters.
fun Functions.Fn.extractParameters(): DoubleArray {
val out = ArrayList<Double>()
mapGraphNumbers(preserveSharing = false) { value, _, _ ->
out.add(value)
value
}
return out.toDoubleArray()
}
fun Functions.Fn.parameterCount(): Int
The number of tunable numeric leaves in this graph — the dimensionality of θ.
fun Functions.Fn.parameterCount(): Int {
var n = 0
mapGraphNumbers(preserveSharing = false) { value, _, _ ->
n++
value
}
return n
}
fun Functions.Fn.applyParameters(theta: DoubleArray): Functions.Fn
Rebuilds this graph with its numeric leaves replaced, left-to-right, by theta.
Uses the identical traversal as extractParameters, so theta[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) always lands on the
leaf that produced extractParameters()[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240). If theta is shorter than the leaf count the
surplus leaves keep their value (defensive; callers should pass a full vector); extra
entries are ignored.
fun Functions.Fn.applyParameters(theta: DoubleArray): Functions.Fn {
var index = 0
return mapGraphNumbers(preserveSharing = false) { value, _, _ ->
val next = if (index < theta.size) theta[index] else value
index++
next
}
}
fun rmseLossAgainst
An RMSE loss (lower is better) of a candidate against target over an even grid across
domain. A named convenience, equivalent to FnMetric.rmse.lossAgainst(...) — which is the
general form the fitters take for any FnMetric.
Returns +∞ when no grid point is jointly finite, so a degenerate candidate is maximally
penalised rather than silently scoring zero.
fun rmseLossAgainst(
target: Functions.Fn,
domain: Functions.Domain = Functions.Domain.DEFAULT,
samples: Int = 128
): (Functions.Fn) -> Double = FnMetric.rmse.lossAgainst(target, domain, samples)
A Functions.Domain is a ClosedRange<Double>, so the fitters accept either a bare range
(0.0 .. 1.0) or a Domain and normalise here — no Functions.Domain(...) ceremony at the
call site. fitGradient and fitHybrid default to the bounded unit domain
Functions.Domain.DEFAULT. fitBest instead uses its target's domain when that domain is
finite, falling back to the unit domain for unbounded or empty targets.
private fun ClosedRange<Double>.asFnDomain(): Functions.Domain =
this as? Functions.Domain ?: Functions.Domain(start, endInclusive)
data class FnFitStep
One descent step's record: the loss (the metric distance) after applying the step.
data class FnFitStep(
val iteration: Int,
val loss: Double
)
data class FnFitConfig
Immutable configuration + seed for a fitGradient run, kept for provenance.
data class FnFitConfig(
val iterations: Int,
val learningRate: Double,
val beta1: Double,
val beta2: Double,
val epsilon: Double,
val fdStep: Double,
val samples: Int,
val domain: Functions.Domain,
val rngSeed: Long,
val initJitter: Double,
/** Which gradient the run was *asked* for; [FnFitRun.usedExactGradient] says what it got. */
val gradient: FnGradientMode = FnGradientMode.FINITE_DIFFERENCE
)
data class FnFitRun
Result and provenance of a fitGradient run, mirroring the shape of FnEvolveRun.
losses is the full error trajectory including the starting point, so
losses.first() == initialError and losses.last() == finalError.
initialError / finalError are the distance under the fit's FnMetric (RMSE by default)
at the start and end. The name is metric-neutral — a custom metric need not be
a root-mean-square, so "error" reads truthfully whatever objective the fit descended.
data class FnFitRun(
val fitted: Functions.Fn,
val parameters: DoubleArray,
val initialParameters: DoubleArray,
val steps: List<FnFitStep>,
val losses: List<Double>,
val initialError: Double,
val finalError: Double,
val iterations: Int,
val evaluations: Int,
val config: FnFitConfig,
/**
* Whether the run really used the exact reverse-mode gradient from `autodiff_fn.kt`.
*
* Distinct from `config.gradient`, which records what was *asked* for:
* [FnGradientMode.AUTO] falls back to finite differences without complaining, and this is how
* you find out. See [exactFitRefusal] for why.
*/
val usedExactGradient: Boolean = false
) {
val improved: Boolean get() = finalError < initialError
True when the fit ended strictly closer to the target than it started.
override fun equals(other: Any?): Boolean
DoubleArray needs value-based equality/hash to behave in collections and asserts.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is FnFitRun) return false
return parameters.contentEquals(other.parameters) &&
initialParameters.contentEquals(other.initialParameters) &&
steps == other.steps &&
losses == other.losses &&
initialError == other.initialError &&
finalError == other.finalError &&
iterations == other.iterations &&
evaluations == other.evaluations &&
config == other.config
}
override fun hashCode(): Int
override fun hashCode(): Int {
var result = parameters.contentHashCode()
result = 31 * result + initialParameters.contentHashCode()
result = 31 * result + losses.hashCode()
result = 31 * result + finalError.hashCode()
return result
}
}
Central (symmetric) finite-difference gradient of lossOfTheta at theta.
The per-parameter step is relative — fdStep * (1 + |θ_i|) — so a small step probes a
near-zero constant finely while still moving a large one meaningfully. A parameter whose
±perturbation makes the loss non-finite (a structural / degenerate leaf, e.g. a period
that collapses a domain) has its gradient frozen to 0.0 for this step rather than
poisoning the whole update with a NaN.
private fun finiteDifferenceGradient(
theta: DoubleArray,
fdStep: Double,
lossOfTheta: (DoubleArray) -> Double
): DoubleArray {
val grad = DoubleArray(theta.size)
for (i in theta.indices) {
val h = fdStep * (1.0 + abs(theta[i]))
if (h == 0.0 || !h.isFinite()) {
grad[i] = 0.0
continue
}
val original = theta[i]
theta[i] = original + h
val lossPlus = lossOfTheta(theta)
theta[i] = original - h
val lossMinus = lossOfTheta(theta)
theta[i] = original
grad[i] = if (lossPlus.isFinite() && lossMinus.isFinite()) {
(lossPlus - lossMinus) / (2.0 * h)
} else {
0.0
}
}
return grad
}
const val FN_FREE_PARAMETER_PREFIX: String = "rectsl.param:"
The name-node prefix a lowering gives a number that is meant to be solved for (RectSL's
free-param, Kotlin's FreeParamTrack). A graph carrying one is a track with declared unknowns,
and fitGradient refuses it: it would move every number, key times and clamp bounds included.
private fun Functions.Fn.namesFreeParameters(): Boolean {
val node = graphNode()
if (node.op == "name" && node.strings["name"]?.startsWith(FN_FREE_PARAMETER_PREFIX) == true) return true
return node.children.any { it.fn.namesFreeParameters() }
}
fun fitGradient
Fits the numeric leaves of seed to target by Adam descent on the RMSE loss.
The graph structure of seed is fixed; only its numbers move. This is the
differentiable counterpart to evolve: pass the shape you want (e.g.
sin(freq) * amp + offset with rough starting constants) and it tunes the constants.
@param target the function to match.
@param seed the fixed-structure graph whose numeric leaves are optimised.
@param domain even sample grid for the loss (shared with matchFitness).
@param iterations number of Adam steps (>= 0).
@param lr Adam learning rate.
@param fdStep base relative step for the finite-difference gradient.
@param metric the distance the descent minimises (lower is better). Defaults to FnMetric.rmse,
which reproduces the historic root-mean-square objective exactly; pass e.g.
FnMetric.huber or FnMetric.lp to reshape the loss landscape.
@param initJitter if > 0, θ is perturbed by ±initJitter*(1+|θ|) before descent using
rngSeed — a deterministic warm-shake for escaping a flat start.
@param gradient how the descent direction is obtained. The default
FnGradientMode.FINITE_DIFFERENCE is the historic path and is bit-for-bit unchanged.
FnGradientMode.EXACT differentiates the graph itself (autodiff_fn.kt) — one loss
evaluation per step instead of 2n+1, and no fdStep to tune — and throws with a
diagnostic if the graph or metric is outside the supported set.
FnGradientMode.AUTO takes the exact path where it applies and falls back silently;
FnFitRun.usedExactGradient reports which happened.
@param adMetric the differentiable form of metric, for the exact path. When omitted it is
derived from metric via FnAdMetric.of, which recognises the singleton
FnMetric.rmse / FnMetric.mae / FnMetric.logCosh; pass it explicitly for a bespoke
objective.
@return an FnFitRun with the fitted graph and the full loss trajectory.
body — 126 lines, from line 315
fun fitGradient(
target: Functions.Fn,
seed: Functions.Fn,
domain: ClosedRange<Double> = Functions.Domain.DEFAULT,
iterations: Int = 200,
lr: Double = 0.05,
beta1: Double = 0.9,
beta2: Double = 0.999,
epsilon: Double = 1e-8,
fdStep: Double = 1e-4,
samples: Int = 128,
metric: FnMetric = FnMetric.rmse,
rngSeed: Long = 0L,
initJitter: Double = 0.0,
gradient: FnGradientMode = FnGradientMode.FINITE_DIFFERENCE,
adMetric: FnAdMetric? = null,
onIteration: ((iteration: Int, total: Int) -> Unit)? = null
): FnFitRun {
require(iterations >= 0) { "iterations must be non-negative" }
require(lr > 0.0) { "lr must be positive" }
require(samples >= 2) { "samples must be >= 2" }
require(!seed.namesFreeParameters()) {
"this graph is a track with declared free parameters; fitGradient would move every number in " +
"it, key times, clamp bounds and easing constants included, and break the track. " +
"Call Track.fitParameters, which moves only the free parameters."
}
val dom = domain.asFnDomain()
val config = FnFitConfig(
iterations, lr, beta1, beta2, epsilon, fdStep, samples, dom, rngSeed, initJitter, gradient
)
val loss = metric.lossAgainst(target, dom, samples)
val lossOfTheta: (DoubleArray) -> Double = { theta -> loss(seed.applyParameters(theta)) }
// The exact gradient, or null when this run stays on finite differences. Resolved once, before
// the loop, so the descent itself has no per-step branch beyond a null check.
val exactGradient: ((DoubleArray) -> ADGradient)? =
if (gradient == FnGradientMode.FINITE_DIFFERENCE) {
null
} else {
val differentiable = adMetric ?: FnAdMetric.of(metric)
val built = differentiable?.let { exactFitObjective(target, seed, dom, samples, it) }
if (gradient == FnGradientMode.EXACT && built == null) {
val why = if (differentiable == null) {
"the metric has no differentiable form — pass adMetric = ... explicitly " +
"(FnAdMetric.of only recognises the singleton FnMetric.rmse / mae / logCosh)"
} else {
exactFitRefusal(target, seed, dom, samples) ?: "unknown"
}
error("fitGradient(gradient = EXACT) cannot differentiate this fit: $why")
}
built
}
val rng = Random(rngSeed)
val initialTheta = seed.extractParameters()
val theta = initialTheta.copyOf()
if (initJitter > 0.0) {
for (i in theta.indices) {
theta[i] += (rng.nextDouble() * 2.0 - 1.0) * initJitter * (1.0 + abs(theta[i]))
}
}
var evaluations = 0
fun evalLoss(t: DoubleArray): Double {
evaluations++
return lossOfTheta(t)
}
val startLoss = evalLoss(theta)
val losses = mutableListOf(startLoss)
val steps = mutableListOf<FnFitStep>()
// Adam moment estimates.
val m = DoubleArray(theta.size)
val v = DoubleArray(theta.size)
// Bounded like evolve/rewriteUntilStable — a fit can never disappear into an
// animation loop. Politely offers a progress bar unless the caller reports itself.
val reporter = if (onIteration == null) afield.ui.output.progressReporter("fit") else null
val report: (Int, Int) -> Unit =
onIteration ?: { iteration, total -> reporter?.update(iteration.toLong(), total.toLong()) }
try {
for (iteration in 1..iterations) {
val grad = if (exactGradient != null) {
evaluations++
exactGradient(theta).gradient
} else {
finiteDifferenceGradient(theta, fdStep) { evalLoss(it) }
}
val t = iteration
for (i in theta.indices) {
val g = grad[i]
if (!g.isFinite()) continue
m[i] = beta1 * m[i] + (1.0 - beta1) * g
v[i] = beta2 * v[i] + (1.0 - beta2) * g * g
val mHat = m[i] / (1.0 - Math.pow(beta1, t.toDouble()))
val vHat = v[i] / (1.0 - Math.pow(beta2, t.toDouble()))
val update = lr * mHat / (sqrt(vHat) + epsilon)
if (update.isFinite()) theta[i] -= update
}
val stepLoss = evalLoss(theta)
losses += stepLoss
steps += FnFitStep(iteration, stepLoss)
report(iteration, iterations)
}
} finally {
reporter?.finish()
}
val finalLoss = losses.last()
return FnFitRun(
fitted = seed.applyParameters(theta),
parameters = theta,
initialParameters = initialTheta,
steps = steps,
losses = losses,
initialError = startLoss,
finalError = finalLoss,
iterations = iterations,
evaluations = evaluations,
config = config,
usedExactGradient = exactGradient != null
)
}
fun fitBest
Robust fit by multiple jittered restarts — the recommended entry point when the seed's numbers might be far from the target's.
A single fitGradient is local: Adam follows the RMSE gradient into the nearest basin.
For parameters that enter the loss linearly (amplitude, offset) that basin is the global
one and one shot suffices. But oscillator frequency/phase enter the loss periodically,
so RMSE-vs-frequency is riddled with local minima: a lone descent from the wrong frequency
can drift into a spurious basin and stall. This runs restarts independent fits — restart 0 from the
clean seed, the rest from ±restartJitter*(1+|θ|) shakes of it (each seeded from
rngSeed + k) — and keeps the lowest-RMSE result. One of the shakes usually lands in the
global basin. Fully deterministic given rngSeed.
For a structurally wrong seed (missing operators, wrong topology) reach for fitHybrid,
which searches shape with evolve first; fitBest only searches numbers.
@param domain sampling range for the loss. When omitted, use target's domain if it is finite;
otherwise use the bounded unit domain Functions.Domain.DEFAULT.
@param metric the distance minimised and compared across restarts (lower is better).
Defaults to FnMetric.rmse; the restart story above holds for any metric whose
landscape is multimodal in frequency/phase.
body — 51 lines, from line 464
fun fitBest(
target: Functions.Fn,
seed: Functions.Fn,
domain: ClosedRange<Double> = target.domain.takeIf { it.isFinite } ?: Functions.Domain.DEFAULT,
restarts: Int = 12,
iterations: Int = 300,
lr: Double = 0.05,
beta1: Double = 0.9,
beta2: Double = 0.999,
epsilon: Double = 1e-8,
fdStep: Double = 1e-4,
samples: Int = 128,
metric: FnMetric = FnMetric.rmse,
restartJitter: Double = 0.6,
rngSeed: Long = 0L,
gradient: FnGradientMode = FnGradientMode.FINITE_DIFFERENCE,
adMetric: FnAdMetric? = null
): FnFitRun {
require(restarts >= 1) { "restarts must be >= 1" }
val reporter = afield.ui.output.progressReporter("fit best")
var best: FnFitRun? = null
try {
for (k in 0 until restarts) {
// Restart 0 is the un-jittered anchor: fitBest is never worse than a plain fit.
val jitter = if (k == 0) 0.0 else restartJitter
val run = fitGradient(
target = target,
seed = seed,
domain = domain,
iterations = iterations,
lr = lr,
beta1 = beta1,
beta2 = beta2,
epsilon = epsilon,
fdStep = fdStep,
samples = samples,
metric = metric,
rngSeed = rngSeed + k,
initJitter = jitter,
gradient = gradient,
adMetric = adMetric,
onIteration = { _, _ -> } // suppress inner bars; the outer one tracks restarts
)
if (best == null || run.finalError < best.finalError) best = run
reporter.update((k + 1).toLong(), restarts.toLong())
}
} finally {
reporter.finish()
}
return best!!
}
data class FnHybridRun
Result and provenance of a fitHybrid run: the structural search then the polish.
data class FnHybridRun(
val evolveRun: FnEvolveRun,
val fitRun: FnFitRun
) {
val best: Functions.Fn get() = fitRun.fitted
The final polished graph.
val seedError: Double get() = -evolveRun.initialScore
RMSE at the very start (seed), after evolve, and after the gradient polish.
val evolvedError: Double get() = fitRun.initialError
val finalError: Double get() = fitRun.finalError
fun fitHybrid
Structure-then-numbers fitting: run evolve to discover a graph shape that roughly
matches target, then hand its numeric leaves to fitGradient for a precise polish.
This is the natural marriage of the two optimisers in this package — genetic search is
good at finding the right operators and topology but converges slowly on exact constants;
Adam nails the constants once the shape is right. Deterministic given rngSeed: the
evolve phase seeds from rngSeed, the fit phase from rngSeed + 1.
body — 48 lines, from line 539
fun fitHybrid(
target: Functions.Fn,
seed: Functions.Fn,
domain: ClosedRange<Double> = Functions.Domain.DEFAULT,
evolveIterations: Int = 64,
evolveLambda: Int = 6,
fitIterations: Int = 200,
lr: Double = 0.05,
beta1: Double = 0.9,
beta2: Double = 0.999,
epsilon: Double = 1e-8,
fdStep: Double = 1e-4,
samples: Int = 128,
complexityWeight: Double = 0.0,
metric: FnMetric = FnMetric.rmse,
rngSeed: Long = 0L,
mutations: List<Pair<String, FnMutation>> = FnMutations.defaults(),
gradient: FnGradientMode = FnGradientMode.FINITE_DIFFERENCE,
adMetric: FnAdMetric? = null
): FnHybridRun {
val dom = domain.asFnDomain()
val fitness = matchFitness(target, dom, samples, complexityWeight, metric)
val evolveRun = evolve(
seed = seed,
iterations = evolveIterations,
lambda = evolveLambda,
rngSeed = rngSeed,
mutations = mutations,
fitness = fitness
)
val fitRun = fitGradient(
target = target,
seed = evolveRun.best,
domain = dom,
iterations = fitIterations,
lr = lr,
beta1 = beta1,
beta2 = beta2,
epsilon = epsilon,
fdStep = fdStep,
samples = samples,
metric = metric,
rngSeed = rngSeed + 1,
gradient = gradient,
adMetric = adMetric
)
return FnHybridRun(evolveRun, fitRun)
}
fun Functions.Fn.normalizeNumericLeaves(): Functions.Fn
Rewrites every numeric leaf of this graph as a const, whether or not its value changed — so a
fitted graph and its seed have the same leaf representation and can be compared structurally.
This exists because of a real trap in applyParameters. It rides on
Functions.Fn.mapGraphNumbers, which replaces a leaf only when the transform returns a different
value, and the replacement is always a const. So after a fit, a leaf whose θ moved has become
const while a leaf whose θ happened to land on its original value is still param — and
graphSignature records op and tags, so two fits of the same seed can produce graphs whose
signatures differ structurally for a reason that has nothing to do with structure. That is the
"gradient-fitted graphs re-entering evolution" landmine: a structural-novelty or dedup pass over
fitted graphs sees phantom variety.
Normalising to const everywhere removes that inconsistency. It does not make the signature
value-blind — graphSignature includes numbers, so a graph whose constants moved is
still a different signature, which is correct. What it fixes is the part that was an artefact.
Values, domains and names are preserved exactly, so this changes no evaluation:
normalizeNumericLeaves().eval(x) == eval(x) for every x.
fun Functions.Fn.normalizeNumericLeaves(): Functions.Fn = rewriteGraph(preserveSharing = false) { context, copy ->
val value = context.node.numbers["value"] ?: return@rewriteGraph copy
if (context.node.op == "const") copy else Functions.c(value, copy.domain).withName(copy.name)
}
data class FnRefineRound
One round of fitEvolveRounds: what the structural search found, and what the fit did to it.
data class FnRefineRound(
/** 1-based round index. */
val round: Int,
/** The structural search for this round. */
val evolveRun: FnEvolveRun,
/** The numeric refinement of [evolveRun]'s best. */
val fitRun: FnFitRun,
/** Whether this round's refinement really got the exact gradient, or fell back. */
val usedExactGradient: Boolean,
/** Why the exact path was refused this round, when it was. Null when it was used. */
val refusal: String?
) {
val error: Double get() = fitRun.finalError
The error after this round's refinement.
data class FnRefineRun
Result and provenance of a fitEvolveRounds run.
data class FnRefineRun(
/** The best graph found across all rounds, with its numeric leaves normalised. */
val best: Functions.Fn,
/** The error of [best]. */
val bestError: Double,
/** The error of the original seed, for the improvement ratio. */
val seedError: Double,
/** Every round, in order. */
val rounds: List<FnRefineRound>
) {
val exactRounds: Int get() = rounds.count { it.usedExactGradient }
How many rounds got the exact gradient rather than falling back to finite differences.
val errors: List<Double> get() = rounds.map { it.error }
The error after each round, so the alternation is plottable.
fun fitEvolveRounds
Alternates structural search and numeric refinement for rounds rounds — the marriage the
two halves of this package were always heading for, and the thing fitHybrid only does once.
Each round runs evolve to look for a better shape, then hands that shape's numeric leaves to
fitGradient to nail its constants, then normalises the result with normalizeNumericLeaves and
feeds it back in as the next round's seed. Genetic search is good at finding operators and topology
and bad at exact constants; gradient descent is the reverse.
Measured, and it is not the free win it sounds like
The motivating hypothesis was that a polished graph is a better starting point for the next
mutation than an unpolished one, because a mutation's fitness is then judged against a shape that
has been given its best shot. FnRefineRoundsTest tested that at equal budget on
cos(2)·sin(1.5, …) from cos(3.4)·sin(1.0, …) — one 160-iteration evolve plus one 480-iteration
fit (fitHybrid) against four interleaved rounds of 40 + 120 — and the single pass won: RMSE
0.1935 against 0.2644, from a seed at 1.1857. On that landscape, spending the evolve budget in four
short bursts costs more than the polishing between them buys, because a 40-iteration evolve is a
much weaker structural search than a 160-iteration one and the polish does not make up for it.
So this is offered as a tool with a shape, not an improvement: it is the right thing when the structural search is cheap relative to the fit, when you want to watch the alternation (the per-round record is plottable), or when a round's polish genuinely unlocks a mutation the unpolished shape would have had rejected. It is the wrong thing when one long structural search would do. Try both; FnRefineRun.errors and FnHybridRun both report it.
The normalisation between rounds is not cosmetic — see normalizeNumericLeaves for the trap it
closes. Without it, whether a leaf comes back as param or const depends on whether its value
happened to move, and a fitted graph re-entering evolution carries that inconsistency into every
structural comparison downstream.
gradient defaults to FnGradientMode.AUTO rather than EXACT because evolve's op-substitution
mutations freely produce graphs the exact path cannot differentiate (a sin becomes a saw, and
saw is a piecewise oscillator with no formula — see FnNonDifferentiable). EXACT would abort
such a round; AUTO refines it with finite differences instead, and
FnRefineRound.usedExactGradient plus FnRefineRound.refusal record what happened, so
FnRefineRun.exactRounds tells you how much of the run got exact derivatives.
What exact gradients buy here is also measured, and it is precision rather than speed: over four
rounds of the case above, finite differences reached RMSE 0.182600 in 585 ms using 14884 loss
evaluations, and the exact path 0.182557 in 592 ms using 964. A 15× reduction in evaluations that
is not a wall-clock win at all — building the tape costs about what those extra graph evaluations
did. The exact path's real advantages are that it is exact, has no fdStep to tune, and scales far
better as the parameter count grows (finite differences is 2n evaluations per step; reverse mode is
one, whatever n is). Do not reach for it expecting it to be faster on a four-parameter graph.
Deterministic given rngSeed: round k evolves from rngSeed + 2k and fits from rngSeed + 2k+1.
body — 78 lines, from line 696
fun fitEvolveRounds(
target: Functions.Fn,
seed: Functions.Fn,
domain: ClosedRange<Double> = Functions.Domain.DEFAULT,
rounds: Int = 4,
evolveIterations: Int = 40,
evolveLambda: Int = 6,
fitIterations: Int = 120,
lr: Double = 0.05,
fdStep: Double = 1e-4,
samples: Int = 128,
complexityWeight: Double = 0.0,
metric: FnMetric = FnMetric.rmse,
gradient: FnGradientMode = FnGradientMode.AUTO,
adMetric: FnAdMetric? = null,
rngSeed: Long = 0L,
mutations: List<Pair<String, FnMutation>> = FnMutations.defaults()
): FnRefineRun {
require(rounds >= 1) { "rounds must be >= 1" }
val dom = domain.asFnDomain()
val loss = metric.lossAgainst(target, dom, samples)
val fitness = matchFitness(target, dom, samples, complexityWeight, metric)
var current = seed.normalizeNumericLeaves()
val seedError = loss(current)
var best = current
var bestError = seedError
val records = mutableListOf<FnRefineRound>()
val reporter = afield.ui.output.progressReporter("refine")
try {
for (round in 1..rounds) {
val evolveRun = evolve(
seed = current,
iterations = evolveIterations,
lambda = evolveLambda,
rngSeed = rngSeed + 2L * round,
mutations = mutations,
fitness = fitness
)
val refusal = exactFitRefusal(target, evolveRun.best, dom, samples)
val fitRun = fitGradient(
target = target,
seed = evolveRun.best,
domain = dom,
iterations = fitIterations,
lr = lr,
fdStep = fdStep,
samples = samples,
metric = metric,
rngSeed = rngSeed + 2L * round + 1L,
gradient = gradient,
adMetric = adMetric,
onIteration = { _, _ -> }
)
records += FnRefineRound(
round = round,
evolveRun = evolveRun,
fitRun = fitRun,
usedExactGradient = fitRun.usedExactGradient,
refusal = if (fitRun.usedExactGradient) null else refusal
)
// Normalise before re-entry: the next round's evolve, and anything comparing signatures
// downstream, must not see a leaf-representation artefact.
current = fitRun.fitted.normalizeNumericLeaves()
val error = loss(current)
if (error < bestError) {
bestError = error
best = current
}
reporter.update(round.toLong(), rounds.toLong())
}
} finally {
reporter.finish()
}
return FnRefineRun(best, bestError, seedError, records)
}