Skip to content

feat: warn on pathologically slow custom loss functions - #1301

Open
MilesCranmerBot wants to merge 2 commits into
astroautomata:masterfrom
MilesCranmerBot:bot/loss-speed-warning
Open

MilesCranmerBot wants to merge 2 commits into
astroautomata:masterfrom
MilesCranmerBot:bot/loss-speed-warning

Conversation

@MilesCranmerBot

Copy link
Copy Markdown
Contributor

Summary

When a user passes a custom loss (elementwise_loss, loss_function, or loss_function_expression), PySR now benchmarks it at the start of fit and warns if it is pathologically slow, using the built-in default squared-error loss as a yardstick:

  • The benchmark runs inside the user's own Julia process on their real data (same arrays/options the search will use): one warm-up call to trigger JIT, GC.gc(), then the median of 5 @elapsed passes. The default squared-error loss is timed identically in the same process, so the ratio (custom/default) is hardware/OS independent.
  • A warning is emitted — at most once per fit — only if the custom loss is both ≥50x slower than the default and takes ≥1 ms per pass over the data. The absolute floor means a fast loss that is merely "50x slower than nothing" never fires.
  • The warning is user-actionable: it prints both measured times, the likely causes (excessive allocations, Python callbacks via PythonCall, type instability — see @code_warntype), and the opt-out flag.
  • Opt out with check_loss_speed=False (on by default only when a custom loss is provided; there is zero overhead and no side effects when the default loss is used).
  • If the benchmark itself fails for any reason, it degrades silently — it can never break a user's fit.
  • v1 scope: losses only; custom operators are not benchmarked.

Full-objective losses (loss_function / loss_function_expression) are benchmarked with a constant tree on a real Dataset built from the user's data, and the baseline is eval_loss under default options (the user's options would just re-time their own loss). Elementwise losses are benchmarked with the same element-by-element call pattern the search uses.

Example warning:

UserWarning: Your custom loss (`elementwise_loss`) took 0.00654 s per pass over
your dataset, which is ~217965x slower than the default squared-error loss
(3e-08 s per pass), measured on the same data in the same Julia process. A loss
this slow will likely dominate the total search time. Common causes include:
excessive memory allocation inside the loss; calls back into Python from Julia
(e.g., via PythonCall); and type instability (you can check this with
`@code_warntype` on your loss). If this slowdown is expected, you can disable
this check with `PySRRegressor(..., check_loss_speed=False)`.

Testing

  • TestLossSpeedWarning (new): warning fires on an allocation-heavy elementwise_loss (plain and weighted) and on a slow loss_function; no warning with the default loss, with a fast custom loss, or with check_loss_speed=False; a failing benchmark degrades silently. All fits use 1 trivial iteration so the tests stay fast.
  • Full local runs: test_main.py (114 passed, 1 skipped), test_cli.py (6 passed), loss-related test_startup.py tests (notebook test needs the nbval plugin, skipped locally).
  • black, isort, and mypy (CI invocation) are clean.

MilesCranmerBot and others added 2 commits August 19, 2026 14:20
When a user passes a custom loss (`elementwise_loss`, `loss_function`, or
`loss_function_expression`), benchmark it inside their own Julia process on
their real data against the default squared-error loss, timed identically in
the same process so the ratio is hardware-independent (one warm-up call for
JIT, GC, then the median of 5 timed passes). Warn — at most once per fit —
only if the custom loss is both >=50x slower than the default and takes
>=1 ms per pass over the data, so a fast loss that is merely '50x slower
than nothing' never fires. The warning prints both measured times, likely
causes (excessive allocations, Python callbacks via PythonCall, type
instability — see `@code_warntype`), and the opt-out flag. The check is
opted out with `check_loss_speed=False`, degrades silently if the
benchmark itself fails, and adds zero overhead when no custom loss is used.
Custom operators are out of scope for this check.

Co-authored-by: Miles Cranmer <miles.cranmer@gmail.com>
Custom losses are Julia strings evaluated in-process; Python callbacks
are not a reachable failure mode.

Co-authored-by: Miles Cranmer <miles.cranmer@gmail.com>
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.67442% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pysr/sr.py 97.67% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@MilesCranmer

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bedf7bd10

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pysr/sr.py
Comment on lines +333 to +337
f(args...)
GC.gc()
# Median of a few timed passes over the dataset:
times = map(1:5) do _
@elapsed f(args...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the benchmark before running six full loss passes

For the pathologically slow losses this feature is intended to detect, the warm-up plus five timed evaluations can itself stall fit for an arbitrarily long time. This runs before equation_search, so timeout_in_seconds cannot limit it; for example, a 30-second full objective adds roughly three minutes before the search even starts. Benchmark a bounded sample or stop timing after a deadline rather than requiring six complete passes.

Useful? React with 👍 / 👎.

Comment thread pysr/sr.py
else
nothing
end
dataset = Dataset(X, y_vec; weights=w_vec)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve dataset metadata when benchmarking full objectives

When a valid loss_function or loss_function_expression reads dataset metadata supplied to fit, such as X_units, y_units, variable names, or the output index, this newly constructed Dataset contains defaults instead of the values passed to equation_search. The benchmark can therefore take a different branch or throw; because the exception is swallowed, the speed check silently produces no warning even though the same objective works during the real search. Construct the benchmark dataset with the same metadata as the search dataset.

Useful? React with 👍 / 👎.

Comment thread pysr/sr.py
if w === nothing
total = zero(eltype(y))
@inbounds for i in eachindex(y)
total += loss(y[i], y[i])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Benchmark elementwise losses on non-perfect predictions

Passing y[i] as both prediction and target benchmarks only the zero-residual path. A custom loss that cheaply returns zero for an exact match but performs expensive robust, probabilistic, or allocation-heavy work for nonzero residuals will appear fast here even though nearly every candidate evaluated during the search takes the slow path, so the intended warning is missed. Use representative predictions that differ from the targets; the weighted branch has the same issue.

Useful? React with 👍 / 👎.

Comment thread pysr/sr.py
# this is safe even though the operator sets differ):
default_options = SymbolicRegression.Options()
if kind === :loss_function
tree = options.node_type(T; val=one(T))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Benchmark representative trees for full objectives

The full-objective benchmark always supplies a single constant node, so it cannot detect costs that scale with expression size. This is especially relevant because loss_function explicitly supports symbolic preprocessing and regularization: an objective that traverses, copies, simplifies, or otherwise analyzes the candidate tree can be nearly free for this node but pathologically slow on the larger trees reached during the search. Benchmark at least one representative nontrivial tree rather than only a constant.

Useful? React with 👍 / 👎.

Comment thread pysr/sr.py
total = zero(eltype(y))
weight_total = zero(eltype(y))
@inbounds for i in eachindex(y)
total += loss(y[i], y[i], w[i])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle weighted LossFunctions objects without a third argument

When weights is supplied with a supported LossFunctions.jl object such as the documented LPDistLoss{3}(), the real evaluator computes weight * loss(prediction, target) because these SupervisedLoss objects do not accept a third scalar argument. This benchmark instead calls loss(y[i], y[i], w[i]), raising a MethodError that the surrounding Python handler silently swallows, so the speed check never runs for this valid configuration. Detect SupervisedLoss and apply the weight outside its two-argument call, matching the backend.

Useful? React with 👍 / 👎.

Comment thread pysr/sr.py
end
function _pysr_loss_speed_benchmark(kind::Symbol, loss::F, options, X, y, w) where {F}
if kind === :elementwise_loss
y_flat = vec(y)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Time one output at a time for elementwise losses

For multi-output y, vec(y) combines every output into one benchmark pass, while equation_search searches and evaluates each output on its own dataset. Consequently the measured absolute time is multiplied by the number of outputs: a loss taking less than the 1 ms threshold per real evaluation can cross the threshold here and emit a false warning solely because several outputs were concatenated. Benchmark each output separately, or use one representative output as the full-objective branch already does.

Useful? React with 👍 / 👎.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants