Skip to content

llmsr: generalize the metric contract into an Objective (per-group refit, score vectors, declarable optimizer) #107

Description

@maxwellsdm1867

Follow-on to #96, #97, #98. Full design doc lands with slice 1 at docs/llmsr-objective-formulation.md.

Problem

Symbolic regression looks for a functional FORM. The constants are usually a nuisance. The fit path conflates them:

score = m( f(X; theta*), y )        theta* = argmin_theta l( f(X;theta), y )

One pooled dataset, one theta, one scalar. If the same law governs 40 cells with 40 different constant sets, this scores the form against a single compromise theta and charges the FORM for a mismatch that belongs to the PARAMETERS. A correct law loses to a wrong-but-flexible one.

Wanted:

score = < m( f(X_i; theta*_i), y_i ) >_i      theta*_i = argmin_theta l( f(X_i;theta), y_i )

Each group (cell, trial, subject, condition) refits its own constants under the same form. This is profile likelihood: per-group constants are nuisance parameters profiled out, so what remains measures only the form.

What #96 to #98 shipped, and the gap

Shipped: an arbitrary callable (y_pred, y_true) -> float registered from the scientist's own module, data_shape dispatch, and guard for hard accept/reject.

Not expressible: per-group refit, a score vector, any scorer that must refit or rescale as part of computing the error, per-point weights, or parameter-dependent penalties. The scorer sees neither X nor theta, although Constraint.check does see theta, so chi-squared with per-point sigma cannot be written at all. The only workaround is closing over module-level data, which welds a metric to one dataset.

Formulation

Five plug-in points, defaults reproducing current behavior:

partition:  Data               -> [Group]    # who gets their own theta  (default: one group)
bind:       (equation, Group)  -> CallPlan   # today a closed two-value enum
score:      (form, group, fit) -> float      # per-group, MAY refit or rescale internally
constrain:  (pred, ctx)        -> bool       # hard gates, per group
optimize:   (loss_fn, ctx)     -> theta      # today: hardcoded BFGS

The load-bearing change is the control inversion. Today the pipeline fits and then hands the metric a finished prediction, so a metric can never refit and therefore can never profile out a nuisance parameter. The scorer should instead receive the form and the group plus a refit primitive it may call, which is what lets you (for example) regress out a per-cell gain and offset before measuring shape error.

No vendor fork required

The vendored FunSearch buffer is already vector-native: register_program(program, island_id, scores_per_test) takes a DICT, _reduce_score means over it for island ranking, and _get_signature uses the full tuple as a cluster key. Wheeler currently calls it with {"data": score}, a single key, which is where the vector is destroyed. Passing {group_id: score_i} recovers upstream's design at no cost and leaves vendor/ unforked, which matters given d0b05b7 frames it as a plug-in for LLM-SR rather than our code.

The buffer is a search heuristic; the winner is selected in best from submissions.jsonl, which keeps the raw vector. So the buffer can take a coarse signal without costing final-selection precision.

Related defect surfaced while designing this

fit.py hardcodes multi-start BFGS (_N_RESTARTS = 6). BFGS uses a numerical gradient, identically zero on a piecewise-constant objective, so it terminates at x0. Measured on a representative event-count objective:

BFGS starts that MOVED off x0: 0/13
best value found: 1.0        (0.0 is a perfect fit)
Nelder-Mead, same starts:    0.0     exact

Spike-distance and event-count losses are piecewise constant in theta, so the spike_train shape opened by #97 is fitted by an optimizer that cannot fit it: it degrades to best-of-7 random search. Per-group refit multiplies this by the group count. Addressed in slice 3.

Slices

  1. --group-by with per-group theta and the raw vector into scores_per_test. No vendor change. Backward compatible: no --group-by means one group, byte-identical to today.
  2. The score(form, group, fit) inversion with the refit primitive, plus a declared cluster_by quantizer (raw floats make every signature unique, so island clustering degenerates; this is already true today with a single float score).
  3. Declarable optimize with a gradient-free default for non-smooth objectives, groups fanned out in parallel to recover the cost.

Open question for slice 1

Failed groups. A form that nails 38 of 40 and fails 2, versus one mediocre on all 40: whichever way the NaN policy falls decides the winner. That is a scientific call, not a default worth inventing. It is also where the #98 hard constraints land, since a guard may now fail on a subset of groups.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions