From 4426392a8c7f638a9fb8145b6d98ec6e0303c073 Mon Sep 17 00:00:00 2001 From: owls-on-wires <204238320+owls-on-wires@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:25:50 -0400 Subject: [PATCH 1/8] Add keplearn: deterministic symbolic regression by best-first recursive reduction Rust engine, built from a pinned release tag of the public repository by install.sh (no vendored source or binaries); the build toolchain is removed from the image after the build. The scikit-learn wrapper shells out to the installed binary and satisfies the est / model() / max_time / random_state contract with sympy-compatible model strings in the data's feature names. --- algorithms/keplearn/LICENSE | 21 ++ algorithms/keplearn/environment.yml | 9 + algorithms/keplearn/install.sh | 19 ++ algorithms/keplearn/metadata.yml | 16 ++ experiment/methods/keplearn/__init__.py | 0 experiment/methods/keplearn/regressor.py | 251 +++++++++++++++++++++++ 6 files changed, 316 insertions(+) create mode 100644 algorithms/keplearn/LICENSE create mode 100644 algorithms/keplearn/environment.yml create mode 100644 algorithms/keplearn/install.sh create mode 100644 algorithms/keplearn/metadata.yml create mode 100644 experiment/methods/keplearn/__init__.py create mode 100644 experiment/methods/keplearn/regressor.py diff --git a/algorithms/keplearn/LICENSE b/algorithms/keplearn/LICENSE new file mode 100644 index 000000000..ae342e9e2 --- /dev/null +++ b/algorithms/keplearn/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Chandler Freeman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/algorithms/keplearn/environment.yml b/algorithms/keplearn/environment.yml new file mode 100644 index 000000000..96b694b47 --- /dev/null +++ b/algorithms/keplearn/environment.yml @@ -0,0 +1,9 @@ +channels: + - conda-forge +dependencies: + - numpy + - sympy + - scikit-learn + - rust + - compilers + - git diff --git a/algorithms/keplearn/install.sh b/algorithms/keplearn/install.sh new file mode 100644 index 000000000..3d7db4408 --- /dev/null +++ b/algorithms/keplearn/install.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euxo pipefail +# Build keplearn from the pinned public release tag (no vendored source or +# binaries in the srbench tree, per CONTRIBUTING). The crate has no +# dependencies, so the only network fetches are this clone and nothing else; +# rust/cargo come from conda-forge via environment.yml. +TAG=v0.1.0 +git clone --depth 1 --branch "$TAG" https://github.com/owls-on-wires/keplearn.git keplearn-src +cd keplearn-src +cargo build --release +install -m 0755 target/release/keplearn "${CONDA_PREFIX}/bin/keplearn" +cd .. +rm -rf keplearn-src +# The toolchain is only needed for this build. Removing it here keeps the +# image layer near the base size instead of ~3.2 GB, because the environment +# install and this script run inside the same Docker RUN layer. +micromamba remove -y -n base rust compilers git +keplearn --version +echo "keplearn: installed ${TAG} from source at ${CONDA_PREFIX}/bin/keplearn" diff --git a/algorithms/keplearn/metadata.yml b/algorithms/keplearn/metadata.yml new file mode 100644 index 000000000..06b5acd77 --- /dev/null +++ b/algorithms/keplearn/metadata.yml @@ -0,0 +1,16 @@ +authors: + - Chandler Freeman +email: chandler@mnty.sh +name: keplearn +description: | + Keplearn is a deterministic symbolic regression engine written in Rust. It + recovers closed-form equations by best-first recursive reduction: the + target is reduced through chains of invertible operations (divide by a + fitted factor, target-side shells, fitted inner-affine nodes) until a + terminal monomial or composite fit explains it, and candidates are chosen + on an internal holdout with an MDL complexity penalty. The engine has no + randomness; runs are reproducible byte for byte. install.sh builds it from + a pinned release tag of the public repository, and this Python wrapper + shells out to the binary (training data as a TSV on stdin, closed-form + model as JSON on stdout). +url: https://github.com/owls-on-wires/keplearn diff --git a/experiment/methods/keplearn/__init__.py b/experiment/methods/keplearn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/experiment/methods/keplearn/regressor.py b/experiment/methods/keplearn/regressor.py new file mode 100644 index 000000000..edffb6941 --- /dev/null +++ b/experiment/methods/keplearn/regressor.py @@ -0,0 +1,251 @@ +"""SRBench wrapper for the Keplearn engine. + +Keplearn is a deterministic symbolic-regression engine written in Rust, built +from a pinned release tag by algorithms/keplearn/install.sh. This +scikit-learn-compatible estimator shells out to the installed binary: it +feeds the training data as a TSV on stdin and parses the emitted +closed-form model from stdout JSON. `predict`/`model` evaluate the emitted expression +directly (no engine round-trip), so no held-out engine call is needed. + +Time budget: SRBench injects the per-fit MAXTIME by setting `est.max_time` (see +experiment/evaluate_model.py). fit() reads it at call time and passes it to the binary +as `--timeout `; the engine stops gracefully at that budget and emits best-so-far. +""" + +import json +import os +import re +import subprocess + +import numpy as np +import sympy as sp +from sklearn.base import BaseEstimator, RegressorMixin + +# Path to the engine binary (installed by algorithms/keplearn/install.sh). +BIN = os.environ.get("KEPLEARN_BIN", "/opt/conda/bin/keplearn") + +# numpy evaluators matching the engine's emitted grammar (same set as the harness). +_NP = { + "sqrt": np.sqrt, "sin": np.sin, "cos": np.cos, "tan": np.tan, + "exp": np.exp, "log": np.log, "arcsin": np.arcsin, "arccos": np.arccos, + "tanh": np.tanh, "abs": np.abs, +} + + +class KeplearnRegressor(BaseEstimator, RegressorMixin): + def __init__(self, max_time=60, sample=500, random_state=None, max_depth=None): + self.max_time = max_time # SRBench MAXTIME (seconds); overwritten by harness + self.sample = sample + self.random_state = random_state # engine is deterministic; seeds the holdout shuffle + self.max_depth = max_depth + + def fit(self, X, y): + X = np.asarray(X, dtype=float) + y = np.asarray(y, dtype=float).ravel() + self.n_features_in_ = X.shape[1] + # Training-target statistics for predict-side hygiene (mean fallback + range clip). + self.y_mean_ = float(np.mean(y)) if y.size else 0.0 + self.y_lo_ = float(np.min(y)) if y.size else 0.0 + self.y_hi_ = float(np.max(y)) if y.size else 0.0 + cols = [f"x{i + 1}" for i in range(self.n_features_in_)] + header = "\t".join(cols + ["target"]) + rows = [ + "\t".join(f"{v:.12g}" for v in X[i]) + f"\t{y[i]:.12g}" + for i in range(len(y)) + ] + tsv = header + "\n" + "\n".join(rows) + "\n" + + cmd = [ + BIN, "--target", "target", "--top-k", "1", + "--sample", str(int(self.sample)), + "--timeout", str(int(float(self.max_time) * 1000)), # seconds -> ms + ] + if self.random_state is not None: + cmd += ["--seed", str(int(self.random_state))] + if self.max_depth is not None: + cmd += ["--max-depth", str(int(self.max_depth))] + + # grace over the engine's own --timeout so the subprocess wrapper never + # pre-empts the engine's graceful best-so-far emit. + proc = subprocess.run( + cmd, input=tsv, capture_output=True, text=True, + timeout=float(self.max_time) + 30, + ) + out = proc.stdout or "" + try: + res = json.loads(out[out.index("{"):]) + except Exception: + res = {"results": []} + results = res.get("results") or [] + # `model` is a full closed-form forward model in x1..xN over the fitted + # feature columns, with the affine fit already baked in. + self.model_str_ = results[0]["model"] if results else "0" + return self + + def predict(self, X): + X = np.asarray(X, dtype=float) + env = {f"x{i + 1}": X[:, i] for i in range(X.shape[1])} + env.update(_NP) + with np.errstate(all="ignore"): + try: + v = eval(self.model_str_.replace("^", "**"), {"__builtins__": {}}, env) + except Exception: + v = self.y_mean_ + v = np.asarray(np.broadcast_to(np.asarray(v, dtype=float), (X.shape[0],)), dtype=float) + # Predict-side hygiene: non-finite -> training mean; clip to the training target + # range so extrapolation blow-ups the selector cannot see can't wreck the score. + v = np.where(np.isfinite(v), v, self.y_mean_) + return np.clip(v, self.y_lo_, self.y_hi_) + + +est = KeplearnRegressor() + +# Deterministic engine: a single (empty) config, i.e. no hyperparameter tuning. +hyper_params = [{}] + +# Raw features (our affine-monomial fit bakes constants; scaling x would perturb +# them). fit consumes a plain ndarray. +eval_kwargs = {"scale_x": False, "scale_y": False, "use_dataframe": False} + + +# ── Output-only symbolic normalization ──────────────────────────────────────── +# Applied to the emitted model STRING before SRBench's grader sees it. PURELY +# cosmetic: it never touches predict(), the engine, or the search — it only presents +# the form the engine already found in a shape SymPy's conservative simplify can match +# against ground truth (fold sqrt(a)*sqrt(b) -> sqrt(a*b), Float->Integer/Rational, +# 0.2387 -> 3/(4*pi), etc.). Every coefficient snap is gated on a TIGHT tolerance so a +# genuine approximation's messy coefficients are left as-is (its wrong STRUCTURE keeps +# it correctly rejected). Any failure falls back to the un-normalized string, so this +# can never make the pipeline worse than before. +import math as _math + +_SNAP_Q = (2, 3, 4, 5, 6) # simple-rational denominators considered +_SNAP_REL = 1e-7 # coefficient/exponent snap tolerance (relative) +_PI_REL = 1e-6 # transcendental (pi) recognition tolerance + + +def _snap_float(f): + """Snap a Float to an exact Integer / simple Rational / k*pi^m, else leave it.""" + try: + v = float(f) + except Exception: + return f + a = abs(v) + r = round(v) + if abs(v - r) <= 1e-9 + _SNAP_REL * max(abs(r), 1): + return sp.Integer(r) + for q in _SNAP_Q: + p = round(v * q) + if p != 0 and abs(v - p / q) <= _SNAP_REL * max(a, 1): + return sp.Rational(p, q) + for base, sym in ((_math.pi, sp.pi), (_math.pi ** 2, sp.pi ** 2), + (1.0 / _math.pi, 1 / sp.pi), (1.0 / (_math.pi ** 2), 1 / sp.pi ** 2)): + k = v / base + for q in (1, 2, 3, 4, 5, 6): + p = round(k * q) + if p != 0 and abs(k - p / q) <= _PI_REL * max(abs(k), 1): + return sp.Rational(p, q) * sym + return f + + +def _fold_mul(m): + """Merge a product/quotient of RADICALS into one radical: + sqrt(a)*sqrt(b)/sqrt(c) -> sqrt(a*b/c). + ONLY fires when every non-constant factor is a plain (+/-1/2) power, so it never + pulls a truth-OUTSIDE factor INTO a radical (mom*sqrt(..) must stay mom*sqrt(..), + else it stops matching a truth that keeps the factor outside — that regressed + feynman_III_10_19 / II_6_15a). At least two radicals required to do anything.""" + rad, nonrad, const = [], [], [] + for f in m.args: + if f.is_Pow and f.exp == sp.Rational(1, 2): + rad.append(f.base) + elif f.is_Pow and f.exp == sp.Rational(-1, 2): + rad.append(1 / f.base) + elif f.is_Number: + const.append(f) + else: + nonrad.append(f) + if len(rad) >= 2 and not nonrad: + # pure product/quotient of radicals -> one radical + return sp.Mul(*const) * sp.sqrt(sp.Mul(*rad)) + if len(rad) == 1 and nonrad: + # one radical + plain factors: fold the plain factors IN only if ALL their free + # symbols already appear inside the radical argument (=> the fold produces genuine + # cancellation, e.g. (w/c)*sqrt(1-pi^2 c^2/(d^2 w^2)) -> sqrt(w^2/c^2 - pi^2/d^2)). + # If a factor's vars are ABSENT inside it is a truth-outside factor (mom*sqrt(..), + # coef*p_d*z*sqrt(..)) that must stay out — folding it regressed those. + f = sp.Mul(*nonrad) + if f.free_symbols and f.free_symbols <= rad[0].free_symbols: + return sp.Mul(*const) * sp.sqrt(sp.expand(f ** 2 * rad[0])) + return m + return m + + +def _normalize(expr): + # Plain (non-positive) symbols: we must NOT let SymPy auto-split sqrt(a*b) into + # sqrt(a)*sqrt(b) or extract factors out of radicals (that diverges from the ground + # truth's compact form and the grader's conservative simplify can't bridge it). So + # we only (1) snap Float atoms to exact numbers and (2) manually FOLD products/ + # factors back into a single radical — building sqrt(...) directly, which plain + # symbols leave intact. No powsimp/cancel (they re-split or expand). + try: + expr = expr.replace(lambda x: x.is_Float, lambda x: _snap_float(x)) + except Exception: + pass + try: + expr = expr.replace(lambda x: x.is_Mul, _fold_mul) + except Exception: + pass + return expr + + +def model(est, X=None): + """Return a sympy-parseable model string in the dataset's feature names, run through + an output-only normalization pass (see above).""" + s = getattr(est, "model_str_", "0") + n = int(getattr(est, "n_features_in_", 0)) + if X is not None and hasattr(X, "columns"): + names = list(X.columns) + else: + names = [f"x_{i}" for i in range(n)] + # engine x1..xN (1-indexed) -> feature names; two-pass via placeholders so a + # feature literally named "x2" can't collide with the engine's x2. + for i in range(n, 0, -1): + s = re.sub(rf"\bx{i}\b", f"__V{i}__", s) + for i in range(n, 0, -1): + nm = names[i - 1] if (i - 1) < len(names) else f"x_{i - 1}" + s = s.replace(f"__V{i}__", str(nm)) + s = s.replace("arcsin", "asin").replace("arccos", "acos").replace("^", "**") + names_s = [str(nm) for nm in names] + + def _plain(): # un-normalized fallback = the previous behavior + loc = {"abs": sp.Abs} + loc.update({nm: sp.Symbol(nm) for nm in names_s}) + return str(sp.sympify(s, locals=loc)) + + try: + # Plain symbols (NOT positive): positivity makes SymPy auto-split radicals, which + # diverges from truth's form. Force feature names to Symbols so a column named + # like a sympy function (beta, gamma, E, Q, ...) can't parse as a FunctionClass. + loc = {"abs": sp.Abs} + loc.update({nm: sp.Symbol(nm) for nm in names_s}) + expr = _normalize(sp.sympify(s, locals=loc)) + # If any feature name collides with SRBench clean_pred_model's x{i}/X{i} remap + # pattern, emit 0-indexed generic so the grader's remapper RECONSTRUCTS the names + # instead of scrambling ours (the feynman_I_11_19 collision: features are + # literally x1,x2,x3 and clean_pred_model rewrites 'x'+str(i) -> features[i]). + if any(re.fullmatch(r"[Xx]_?\d+", nm) for nm in names_s): + subs = {sp.Symbol(names_s[i]): sp.Symbol(f"x{i}") + for i in range(len(names_s))} + expr = expr.subs(subs, simultaneous=True) + out = str(expr) + return out if (out and out.lower() not in ("nan", "zoo", "oo")) else _plain() + except Exception: + return _plain() + + +def complexity(est): + try: + return int(sp.count_ops(sp.sympify(model(est)))) + except Exception: + return 0 From 78cbcd77e992e7bf3d2f3a41df294d4cf7b99936 Mon Sep 17 00:00:00 2001 From: Guilherme Seidyo Imai Aldeia Date: Thu, 17 Sep 2026 12:12:07 -0400 Subject: [PATCH 2/8] workflow pushes dev images to dockerhub. Tagged versions are still generated from main --- .github/workflows/docker.yml | 75 ++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index cee7f4781..466b87cb7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,7 +2,10 @@ name: Docker Images on: push: + branches: + - dev tags: ['v*'] + workflow_dispatch: permissions: contents: read @@ -15,6 +18,38 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # A dev push gets the development tag. A vX.Y tag is a release: publish + # its numeric version and promote it to latest. + - name: Select image tags + id: image_tags + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + version="${GITHUB_REF_NAME#v}" + if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then + echo "Release tags must use the v form (for example v0.1)." >&2 + exit 1 + fi + echo "base_tag=$version" >> "$GITHUB_OUTPUT" + { + echo "tags<> "$GITHUB_OUTPUT" + else + if [[ "$GITHUB_REF_NAME" != "dev" ]]; then + echo "Development images may only be published from the dev branch." >&2 + exit 1 + fi + echo "base_tag=dev" >> "$GITHUB_OUTPUT" + { + echo "tags<> "$GITHUB_OUTPUT" + fi - uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -25,9 +60,7 @@ jobs: context: . file: baseDockerfile push: true - tags: | - ${{ env.ORG }}/base:latest - ${{ env.ORG }}/base:${{ github.ref_name }} + tags: ${{ steps.image_tags.outputs.tags }} cache-from: type=gha,scope=base cache-to: type=gha,scope=base,mode=max @@ -66,6 +99,36 @@ jobs: - xgboost steps: - uses: actions/checkout@v4 + - name: Select image tags + id: image_tags + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + version="${GITHUB_REF_NAME#v}" + if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then + echo "Release tags must use the v form (for example v0.1)." >&2 + exit 1 + fi + echo "base_tag=$version" >> "$GITHUB_OUTPUT" + { + echo "tags<> "$GITHUB_OUTPUT" + else + if [[ "$GITHUB_REF_NAME" != "dev" ]]; then + echo "Development images may only be published from the dev branch." >&2 + exit 1 + fi + echo "base_tag=dev" >> "$GITHUB_OUTPUT" + { + echo "tags<> "$GITHUB_OUTPUT" + fi - uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -85,10 +148,8 @@ jobs: file: ${{ steps.dockerfile.outputs.path }} build-args: | ALGORITHM=${{ matrix.algorithm }} - BASE_IMAGE=${{ env.ORG }}/base:${{ github.ref_name }} + BASE_IMAGE=${{ env.ORG }}/base:${{ steps.image_tags.outputs.base_tag }} push: true - tags: | - ${{ env.ORG }}/${{ matrix.algorithm }}:latest - ${{ env.ORG }}/${{ matrix.algorithm }}:${{ github.ref_name }} + tags: ${{ steps.image_tags.outputs.tags }} cache-from: type=gha,scope=${{ matrix.algorithm }} cache-to: type=gha,scope=${{ matrix.algorithm }},mode=max From 6092bc772d1e59a8f906e7514f191ccce3747314 Mon Sep 17 00:00:00 2001 From: Guilherme Seidyo Imai Aldeia Date: Thu, 17 Sep 2026 14:19:43 -0400 Subject: [PATCH 3/8] updated references to Pantara --- .github/workflows/docker.yml | 1 + README.md | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 466b87cb7..41281d74e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -88,6 +88,7 @@ jobs: - lightgbm - nesymres - operon + - pantara - ps-tree - pysr - qlattice diff --git a/README.md b/README.md index 30fd0e9b8..af77492fc 100755 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ To handle the lack of a unified framework, we've specified minimal requirements The current edition of the benchmark (SRBench 2025, reported in our [_call for action_ paper](#call-for-action)) evaluates **25** symbolic regression methods under a unified experimental setup: every method runs from a docker container, with hyperparameter tuning and **30** independent runs per dataset, on **24** datasets from [PMLB](https://github.com/EpistasisLab/penn-ml-benchmarks) plus a set of first-principles regression problems. This roster includes the 14 methods from the original SRBench together with the methods staged since then. -Methods currently benchmarked: +Methods currently benchmarked or staged for the next benchmark update: | Method | | | |:--|:--|:--| @@ -32,9 +32,11 @@ Methods currently benchmarked: | **FEAT** - [paper](https://openreview.net/pdf?id=Hke-JhA9Y7) | **FFX** - [paper](https://link.springer.com/chapter/10.1007/978-1-4614-1770-5_13) | **Genetic Engine** - [paper](https://dl.acm.org/doi/10.1145/3564719.3568697) | | **GPGomea** - [paper](http://dx.doi.org/10.1162/evco_a_00278) | **GPlearn** - [paper]() | **GPZGD** - [paper](https://doi.org/10.1145/3377930.3390237) | | **ITEA** - [paper](https://direct.mit.edu/evco/article-pdf/29/3/367/1959462/evco_a_00285.pdf) | **NeSymRes** - [paper](http://proceedings.mlr.press/v139/biggio21a/biggio21a.pdf) | **Operon** - [paper](https://link.springer.com/article/10.1007/s10710-019-09371-3) | -| **Ps-Tree** - [paper](https://www.sciencedirect.com/science/article/pii/S2210650222000335) | **PySR** - [paper](https://arxiv.org/abs/2305.01582) | **Qlattice** - [paper](https://arxiv.org/abs/2104.05417) | -| **Rils-rols** - [paper](http://dx.doi.org/10.1186/s40537-023-00743-2) | **TIR** - [paper](https://doi.org/10.1145/3597312) | **TPSR** - [paper](https://openreview.net/forum?id=0rVXQEeFEL) | -| **uDSR** - [paper](https://proceedings.neurips.cc/paper_files/paper/2022/file/dbca58f35bddc6e4003b2dd80e42f838-Paper-Conference.pdf) | | | +| **Pantara** - [code](https://github.com/Yapock22/pantara) | **Ps-Tree** - [paper](https://www.sciencedirect.com/science/article/pii/S2210650222000335) | **PySR** - [paper](https://arxiv.org/abs/2305.01582) | +| **Qlattice** - [paper](https://arxiv.org/abs/2104.05417) | **Rils-rols** - [paper](http://dx.doi.org/10.1186/s40537-023-00743-2) | **TIR** - [paper](https://doi.org/10.1145/3597312) | +| **TPSR** - [paper](https://openreview.net/forum?id=0rVXQEeFEL) | **uDSR** - [paper](https://proceedings.neurips.cc/paper_files/paper/2022/file/dbca58f35bddc6e4003b2dd80e42f838-Paper-Conference.pdf) | | + +Pantara is staged for benchmarking and is covered by the container build and test workflows; it is not included in the SRBench 2025 result figures above. The full experiment code and results for this edition live on the [`srbench_2025`](https://github.com/cavalab/srbench/tree/srbench_2025) branch (raw results in [`results/`](https://github.com/cavalab/srbench/tree/srbench_2025/results)). If you are choosing baselines for a new symbolic regression paper, please use this roster and these results rather than the 2021 tables below. @@ -177,4 +179,3 @@ v1.0 was reported in our GECCO 2018 paper: # Contact William La Cava ([@lacava](https://github.com/lacava)), william dot lacava at childrens dot harvard dot edu - From f290121529f5370791453487a0ed2f58db6bf111 Mon Sep 17 00:00:00 2001 From: Guilherme Seidyo Imai Aldeia Date: Thu, 17 Sep 2026 14:20:04 -0400 Subject: [PATCH 4/8] Using protected operations on uDSR --- experiment/methods/udsr/regressor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiment/methods/udsr/regressor.py b/experiment/methods/udsr/regressor.py index 6b9b512c8..f7b0e4a5b 100644 --- a/experiment/methods/udsr/regressor.py +++ b/experiment/methods/udsr/regressor.py @@ -88,7 +88,7 @@ def fit(self, X, y): # number) will simply returns a minimal reward. With protected=True, # "protected" functions will prevent floating-point errors, but may # introduce discontinuities in the learned functions. - "protected" : False, + "protected" : True, # You can add artificial reward noise directly to the reward function. # Note this does NOT add noise to the dataset. From 54dd5899840cd80d7d2d8a48b99aa21f310d40b7 Mon Sep 17 00:00:00 2001 From: Guilherme Seidyo Imai Aldeia Date: Thu, 17 Sep 2026 15:07:36 -0400 Subject: [PATCH 5/8] Including GP-Elite on README. updated docker image workflow to use custom org --- .github/workflows/docker.yml | 20 ++++++++++++-------- README.md | 12 ++++++------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 41281d74e..c9722a265 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,7 +11,10 @@ permissions: contents: read env: - ORG: ${{ secrets.DOCKER_HUB_USERNAME }} + # Image consumers (Compose and Apptainer) pull from the public srbench + # namespace. Keep this independent of the personal Docker Hub account used + # to authenticate the workflow. + IMAGE_ORG: srbench jobs: base: @@ -34,8 +37,8 @@ jobs: echo "base_tag=$version" >> "$GITHUB_OUTPUT" { echo "tags<> "$GITHUB_OUTPUT" else @@ -46,7 +49,7 @@ jobs: echo "base_tag=dev" >> "$GITHUB_OUTPUT" { echo "tags<> "$GITHUB_OUTPUT" fi @@ -82,6 +85,7 @@ jobs: - ffx - geneticengine - gpgomea + - gp-elite - gplearn - gpzgd - itea @@ -114,8 +118,8 @@ jobs: echo "base_tag=$version" >> "$GITHUB_OUTPUT" { echo "tags<> "$GITHUB_OUTPUT" else @@ -126,7 +130,7 @@ jobs: echo "base_tag=dev" >> "$GITHUB_OUTPUT" { echo "tags<> "$GITHUB_OUTPUT" fi @@ -149,7 +153,7 @@ jobs: file: ${{ steps.dockerfile.outputs.path }} build-args: | ALGORITHM=${{ matrix.algorithm }} - BASE_IMAGE=${{ env.ORG }}/base:${{ steps.image_tags.outputs.base_tag }} + BASE_IMAGE=${{ env.IMAGE_ORG }}/base:${{ steps.image_tags.outputs.base_tag }} push: true tags: ${{ steps.image_tags.outputs.tags }} cache-from: type=gha,scope=${{ matrix.algorithm }} diff --git a/README.md b/README.md index af77492fc..04e517d3a 100755 --- a/README.md +++ b/README.md @@ -30,13 +30,13 @@ Methods currently benchmarked or staged for the next benchmark update: | **Bingo** - [paper](https://dl.acm.org/doi/10.1145/3520304.3534031) | **Brush** - [paper](https://royalsocietypublishing.org/rsta/article/384/2317/20240588/481208/Towards-symbolic-regression-for-interpretable) | **BSR** - [paper](https://arxiv.org/abs/1910.08892) | | **E2E** - [paper](https://papers.neurips.cc/paper_files/paper/2022/file/42eb37cdbefd7abae0835f4b67548c39-Paper-Conference.pdf) | **EPLEX** - [paper](https://direct.mit.edu/evco/article-pdf/27/3/377/1858632/evco_a_00224.pdf) | **EQL** - [paper](http://proceedings.mlr.press/v80/sahoo18a/sahoo18a.pdf) | | **FEAT** - [paper](https://openreview.net/pdf?id=Hke-JhA9Y7) | **FFX** - [paper](https://link.springer.com/chapter/10.1007/978-1-4614-1770-5_13) | **Genetic Engine** - [paper](https://dl.acm.org/doi/10.1145/3564719.3568697) | -| **GPGomea** - [paper](http://dx.doi.org/10.1162/evco_a_00278) | **GPlearn** - [paper]() | **GPZGD** - [paper](https://doi.org/10.1145/3377930.3390237) | -| **ITEA** - [paper](https://direct.mit.edu/evco/article-pdf/29/3/367/1959462/evco_a_00285.pdf) | **NeSymRes** - [paper](http://proceedings.mlr.press/v139/biggio21a/biggio21a.pdf) | **Operon** - [paper](https://link.springer.com/article/10.1007/s10710-019-09371-3) | -| **Pantara** - [code](https://github.com/Yapock22/pantara) | **Ps-Tree** - [paper](https://www.sciencedirect.com/science/article/pii/S2210650222000335) | **PySR** - [paper](https://arxiv.org/abs/2305.01582) | -| **Qlattice** - [paper](https://arxiv.org/abs/2104.05417) | **Rils-rols** - [paper](http://dx.doi.org/10.1186/s40537-023-00743-2) | **TIR** - [paper](https://doi.org/10.1145/3597312) | -| **TPSR** - [paper](https://openreview.net/forum?id=0rVXQEeFEL) | **uDSR** - [paper](https://proceedings.neurips.cc/paper_files/paper/2022/file/dbca58f35bddc6e4003b2dd80e42f838-Paper-Conference.pdf) | | +| **GPGomea** - [paper](http://dx.doi.org/10.1162/evco_a_00278) | **GP-ELITE** - [code](https://github.com/ariel95500-create/gp-elite) | **GPlearn** - [paper]() | +| **GPZGD** - [paper](https://doi.org/10.1145/3377930.3390237) | **ITEA** - [paper](https://direct.mit.edu/evco/article-pdf/29/3/367/1959462/evco_a_00285.pdf) | **NeSymRes** - [paper](http://proceedings.mlr.press/v139/biggio21a/biggio21a.pdf) | +| **Operon** - [paper](https://link.springer.com/article/10.1007/s10710-019-09371-3) | **Pantara** - [code](https://github.com/Yapock22/pantara) | **Ps-Tree** - [paper](https://www.sciencedirect.com/science/article/pii/S2210650222000335) | +| **PySR** - [paper](https://arxiv.org/abs/2305.01582) | **Qlattice** - [paper](https://arxiv.org/abs/2104.05417) | **Rils-rols** - [paper](http://dx.doi.org/10.1186/s40537-023-00743-2) | +| **TIR** - [paper](https://doi.org/10.1145/3597312) | **TPSR** - [paper](https://openreview.net/forum?id=0rVXQEeFEL) | **uDSR** - [paper](https://proceedings.neurips.cc/paper_files/paper/2022/file/dbca58f35bddc6e4003b2dd80e42f838-Paper-Conference.pdf) | -Pantara is staged for benchmarking and is covered by the container build and test workflows; it is not included in the SRBench 2025 result figures above. +GP-ELITE and Pantara are staged for benchmarking and are covered by the container build and test workflows; they are not included in the SRBench 2025 result figures above. The full experiment code and results for this edition live on the [`srbench_2025`](https://github.com/cavalab/srbench/tree/srbench_2025) branch (raw results in [`results/`](https://github.com/cavalab/srbench/tree/srbench_2025/results)). If you are choosing baselines for a new symbolic regression paper, please use this roster and these results rather than the 2021 tables below. From fa32e2497d6ee08c5ba2e700391763922a22ab5a Mon Sep 17 00:00:00 2001 From: owls-on-wires <204238320+owls-on-wires@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:16:30 -0400 Subject: [PATCH 6/8] Address review: one canonical expression for predict/model/complexity; remove clip predict, model() and complexity() now share a single SymPy expression built at fit time, so predict(X) matches evaluating model(est, X) wherever the expression is finite. The range clip is removed. The one remaining deviation is that rows where the expression is non-finite return the training mean so metric computation stays defined. The complexity() fallback now does a token-level operator count of the engine string instead of returning 0. --- experiment/methods/keplearn/regressor.py | 121 ++++++++++++++--------- 1 file changed, 74 insertions(+), 47 deletions(-) diff --git a/experiment/methods/keplearn/regressor.py b/experiment/methods/keplearn/regressor.py index edffb6941..1b4081ceb 100644 --- a/experiment/methods/keplearn/regressor.py +++ b/experiment/methods/keplearn/regressor.py @@ -43,10 +43,9 @@ def fit(self, X, y): X = np.asarray(X, dtype=float) y = np.asarray(y, dtype=float).ravel() self.n_features_in_ = X.shape[1] - # Training-target statistics for predict-side hygiene (mean fallback + range clip). + # Training-target mean: the fallback value for rows where the model is + # non-finite (see predict). self.y_mean_ = float(np.mean(y)) if y.size else 0.0 - self.y_lo_ = float(np.min(y)) if y.size else 0.0 - self.y_hi_ = float(np.max(y)) if y.size else 0.0 cols = [f"x{i + 1}" for i in range(self.n_features_in_)] header = "\t".join(cols + ["target"]) rows = [ @@ -80,22 +79,46 @@ def fit(self, X, y): # `model` is a full closed-form forward model in x1..xN over the fitted # feature columns, with the affine fit already baked in. self.model_str_ = results[0]["model"] if results else "0" + self._build_canonical() return self + def _build_canonical(self): + """Build ONE canonical SymPy expression from the engine string; predict, + model() and complexity() all use this same object, so the predicting + function and the reported model string cannot diverge.""" + raw = (self.model_str_.replace("arcsin", "asin") + .replace("arccos", "acos") + .replace("^", "**")) + try: + loc = {"abs": sp.Abs} + loc.update({f"x{i + 1}": sp.Symbol(f"x{i + 1}") + for i in range(self.n_features_in_)}) + expr = _normalize(sp.sympify(raw, locals=loc)) + syms = [sp.Symbol(f"x{i + 1}") for i in range(self.n_features_in_)] + self.expr_ = expr + self._predict_fn = sp.lambdify(syms, expr, "numpy") + except Exception: + self.expr_ = None + self._predict_fn = None + def predict(self, X): X = np.asarray(X, dtype=float) - env = {f"x{i + 1}": X[:, i] for i in range(X.shape[1])} - env.update(_NP) + fn = getattr(self, "_predict_fn", None) with np.errstate(all="ignore"): try: - v = eval(self.model_str_.replace("^", "**"), {"__builtins__": {}}, env) + if fn is not None: + v = fn(*[X[:, i] for i in range(X.shape[1])]) + else: + env = {f"x{i + 1}": X[:, i] for i in range(X.shape[1])} + env.update(_NP) + v = eval(self.model_str_.replace("^", "**"), {"__builtins__": {}}, env) except Exception: v = self.y_mean_ v = np.asarray(np.broadcast_to(np.asarray(v, dtype=float), (X.shape[0],)), dtype=float) - # Predict-side hygiene: non-finite -> training mean; clip to the training target - # range so extrapolation blow-ups the selector cannot see can't wreck the score. - v = np.where(np.isfinite(v), v, self.y_mean_) - return np.clip(v, self.y_lo_, self.y_hi_) + # Sole deviation from evaluating the model expression verbatim: rows + # where it is non-finite (poles, domain edges) fall back to the + # training mean so downstream metric computation stays defined. + return np.where(np.isfinite(v), v, self.y_mean_) est = KeplearnRegressor() @@ -200,52 +223,56 @@ def _normalize(expr): def model(est, X=None): - """Return a sympy-parseable model string in the dataset's feature names, run through - an output-only normalization pass (see above).""" - s = getattr(est, "model_str_", "0") + """Return the canonical model as a sympy-parseable string in the dataset's + feature names. This is the SAME expression predict evaluates (built once in + fit), so predict(X) matches evaluating model(est, X) on every row where the + expression is finite.""" n = int(getattr(est, "n_features_in_", 0)) if X is not None and hasattr(X, "columns"): - names = list(X.columns) + names = [str(c) for c in X.columns] else: names = [f"x_{i}" for i in range(n)] - # engine x1..xN (1-indexed) -> feature names; two-pass via placeholders so a - # feature literally named "x2" can't collide with the engine's x2. + expr = getattr(est, "expr_", None) + if expr is None: + return _string_model(est, names) + try: + # If any feature name collides with SRBench clean_pred_model's x{i}/X{i} + # remap pattern, emit 0-indexed generic names so the grader's remapper + # reconstructs the real names instead of scrambling ours. + if any(re.fullmatch(r"[Xx]_?\d+", nm) for nm in names): + subs = {sp.Symbol(f"x{i + 1}"): sp.Symbol(f"x{i}") for i in range(n)} + else: + subs = {sp.Symbol(f"x{i + 1}"): sp.Symbol(names[i]) for i in range(n)} + out = str(expr.subs(subs, simultaneous=True)) + return out if (out and out.lower() not in ("nan", "zoo", "oo")) else _string_model(est, names) + except Exception: + return _string_model(est, names) + + +def _string_model(est, names): + """Fallback when no canonical expression exists (engine string failed to + sympify): plain string mapping of the raw engine model, matching what the + fallback predict path evaluates.""" + s = getattr(est, "model_str_", "0") + n = int(getattr(est, "n_features_in_", 0)) for i in range(n, 0, -1): s = re.sub(rf"\bx{i}\b", f"__V{i}__", s) for i in range(n, 0, -1): nm = names[i - 1] if (i - 1) < len(names) else f"x_{i - 1}" s = s.replace(f"__V{i}__", str(nm)) - s = s.replace("arcsin", "asin").replace("arccos", "acos").replace("^", "**") - names_s = [str(nm) for nm in names] - - def _plain(): # un-normalized fallback = the previous behavior - loc = {"abs": sp.Abs} - loc.update({nm: sp.Symbol(nm) for nm in names_s}) - return str(sp.sympify(s, locals=loc)) - - try: - # Plain symbols (NOT positive): positivity makes SymPy auto-split radicals, which - # diverges from truth's form. Force feature names to Symbols so a column named - # like a sympy function (beta, gamma, E, Q, ...) can't parse as a FunctionClass. - loc = {"abs": sp.Abs} - loc.update({nm: sp.Symbol(nm) for nm in names_s}) - expr = _normalize(sp.sympify(s, locals=loc)) - # If any feature name collides with SRBench clean_pred_model's x{i}/X{i} remap - # pattern, emit 0-indexed generic so the grader's remapper RECONSTRUCTS the names - # instead of scrambling ours (the feynman_I_11_19 collision: features are - # literally x1,x2,x3 and clean_pred_model rewrites 'x'+str(i) -> features[i]). - if any(re.fullmatch(r"[Xx]_?\d+", nm) for nm in names_s): - subs = {sp.Symbol(names_s[i]): sp.Symbol(f"x{i}") - for i in range(len(names_s))} - expr = expr.subs(subs, simultaneous=True) - out = str(expr) - return out if (out and out.lower() not in ("nan", "zoo", "oo")) else _plain() - except Exception: - return _plain() + return s.replace("arcsin", "asin").replace("arccos", "acos").replace("^", "**") def complexity(est): - try: - return int(sp.count_ops(sp.sympify(model(est)))) - except Exception: - return 0 + """Ops count of the SAME canonical expression predict evaluates. Fallback + (no canonical expression): a token-level operator count of the raw engine + string, which errs toward over-counting rather than reporting 0.""" + expr = getattr(est, "expr_", None) + if expr is not None: + try: + return int(sp.count_ops(expr)) + except Exception: + pass + raw = getattr(est, "model_str_", "0") + return len(re.findall( + r"\*\*|[+\-*/]|\b(?:sin|cos|tan|sqrt|exp|log|tanh|arcsin|arccos|abs)\b", raw)) From fc23d03580058a8ff052f5b912c8f5f5120db85e Mon Sep 17 00:00:00 2001 From: Guilherme Seidyo Imai Aldeia Date: Mon, 21 Sep 2026 14:30:23 -0400 Subject: [PATCH 7/8] added keplearn and pir to hardcoded image build --- .github/workflows/docker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c9722a265..71f87f324 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -89,10 +89,12 @@ jobs: - gplearn - gpzgd - itea + - keplearn - lightgbm - nesymres - operon - pantara + - pir - ps-tree - pysr - qlattice From 953e66c8333375c953e57cddca57e8a0f8a8d546 Mon Sep 17 00:00:00 2001 From: Guilherme Seidyo Imai Aldeia Date: Mon, 21 Sep 2026 14:41:28 -0400 Subject: [PATCH 8/8] disabling push to dockerhub at this moment --- .github/workflows/docker.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 71f87f324..06097544d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,6 +18,8 @@ env: jobs: base: + # Temporarily disabled + if: ${{ false }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -68,6 +70,8 @@ jobs: cache-to: type=gha,scope=base,mode=max algorithms: + # Temporarily disabled + if: ${{ false }} needs: base runs-on: ubuntu-latest strategy: