File size: 32,784 Bytes
d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 8956871 d06fa57 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 | """
LLM-SRBench 评估脚本 (浓缩版)
将 benchmark 评估流程整合为单个脚本:
1) 数据读取与预处理
2) 调用符号回归函数 (placeholder)
3) 评估结果
4) 汇总多次运行的结果
Usage:
# 测试单个问题
sr-harness benchmark --exp_name test_my_algorithm --datasets lsrtransform --problem_names II.6.15b_1_0
# 测试单个数据集
sr-harness benchmark --exp_name test_my_algorithm --datasets bio_pop_growth
# 测试一系列问题
sr-harness benchmark --problem_names MatSci2 MatSci19 CRK28 BPG1 PO6
# 测试特定算法
sr-harness benchmark --algorithm linear --exp_name test_linear_fitting
"""
from __future__ import annotations
import re
import time
import json
import h5py
import shlex
import dotenv
import logging
import argparse
import datasets
import numpy as np
import sr_harness_engine as engine
from sr_harness_engine.tree import transform
from pathlib import Path
from datetime import datetime
from socket import gethostname
from typing import Callable, Dict, List
from scipy.stats import kendalltau
from scipy.optimize import least_squares
from sklearn.metrics import mean_absolute_percentage_error
from sr_harness.utils import (
add_minus_flags,
get_symbolic_acc,
log_exception,
sanitize_filename,
save_args,
seed_all,
setup_logging,
tag2ansi,
)
from sr_harness._vendor.llmsr_bench.core import SEDTask, SRResult, Problem
from sr_harness._vendor.llmsr_bench.algorithms import get_update_parser, get_algorithm, list_algorithms
DATASET_SPLITS = {
"lsrtransform": "lsr_transform",
"bio_pop_growth": "lsr_synth_bio_pop_growth",
"chem_react": "lsr_synth_chem_react",
"matsci": "lsr_synth_matsci",
"phys_osc": "lsr_synth_phys_osc",
}
dotenv.load_dotenv() # Load environment variables from .env file if present
SCRIPT_NAME = "benchmark"
_logger = logging.getLogger(f"sr_harness.{SCRIPT_NAME}")
def setup_parser(parser: argparse.ArgumentParser | None = None) -> argparse.ArgumentParser:
"""Configure CLI arguments for ``sr-harness benchmark`` (LLM-SRBench evaluation).
Args:
parser: Optional existing :class:`argparse.ArgumentParser` to populate.
Returns:
argparse.ArgumentParser: Parser configured with dataset, problem, and algorithm flags.
"""
if parser is None:
parser = argparse.ArgumentParser(
prog="sr-harness benchmark",
description="LLM-SRBench Evaluation Script.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
else:
parser.description = "LLM-SRBench Evaluation Script."
parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
parser.add_argument("--algorithm", required=True, choices=list_algorithms(), help=(
"Symbolic-regression algorithm to evaluate."
))
parser.add_argument("--name", default=f"{SCRIPT_NAME}", help=(
"Experiment task name used when auto-generating exp_name."
))
parser.add_argument("--exp_name", default=None, help=(
"Experiment name. Defaults to a timestamped name."
))
parser.add_argument("--save_dir", default=f"./logs/{SCRIPT_NAME}", help=(
"Root directory for logs and run artifacts."
))
parser.add_argument("--seed", type=int, default=-1, help=(
"Random seed. Default -1 means using current system time."
))
parser.add_argument("--save_path", default=None, help=(
"Path to save agent logs and artifacts. Default is auto-generated from --save_dir and --exp_name."
))
parser.add_argument("--verbose", action=argparse.BooleanOptionalAction, default=False, help=(
"Enable verbose agent logging."
))
parser.add_argument("--debug", action=argparse.BooleanOptionalAction, default=False, help=(
"Enable debug mode (verbose + raise caught exceptions)."
))
parser.add_argument("--data_root", type=str, default=str(Path("data") / "llm-srbench-data"), help=(
"HDF5 数据文件所在目录"
))
parser.add_argument("--datasets", type=str, default=None, nargs="+", choices=list(DATASET_SPLITS.keys()), help=(
"数据集名称, 默认评估全部数据集"
))
parser.add_argument("--problem_names", type=str, default=None, nargs="+", help=(
"仅评估指定问题(方程)ID, 默认评估全部问题"
))
parser.add_argument("--skip_existing", action=argparse.BooleanOptionalAction, default=False, help=(
"如果结果文件已存在则跳过评估"
))
parser.add_argument("--skip_successful", action=argparse.BooleanOptionalAction, default=True, help=(
"如果结果文件已存在且成功则跳过评估"
))
parser.add_argument("--anonymize", action=argparse.BooleanOptionalAction, default=False, help=(
"Anonymize agent-facing variables as x1..xn and target as y."
))
# 解析 --algorithm 参数以获取对应的 update_parser
algorithm_probe = argparse.ArgumentParser(add_help=False)
algorithm_probe.add_argument("--algorithm", choices=list_algorithms())
probe_args, _ = algorithm_probe.parse_known_args()
if probe_args.algorithm and (update_parser_fn := get_update_parser(probe_args.algorithm)):
parser = update_parser_fn(parser)
add_minus_flags(parser)
return parser
def load_problems(dataset_name: str, data_root: str, hf_repo_id = "nnheui/llm-srbench") -> List[Problem]:
"""Download (if needed) and load benchmark problems for one LLM-SRBench dataset split.
Args:
dataset_name: Benchmark dataset identifier (e.g., ``"lsrtransform"`` or ``"matsci"``).
data_root: Local cache directory where Hugging Face dataset files are stored.
hf_repo_id: Hugging Face dataset repository ID (defaults to ``"nnheui/llm-srbench"``).
Returns:
List[Problem]: Loaded benchmark problem instances.
"""
split_name = DATASET_SPLITS[dataset_name]
# 尝试从本地 parquet 文件加载公式元信息
data_dir = Path(data_root) / "data"
if not data_dir.exists():
_logger.note(f"Local parquet not found. Downloading from {hf_repo_id}... (If you wait too long, consider downloading it manually)")
ds = datasets.load_dataset(hf_repo_id, split=split_name, cache_dir='./data/hf_cache')
elif not (data_path := data_dir.glob(f"{split_name}-*.parquet").__iter__().__next__()).exists():
_logger.note(f"Local parquet not found. Downloading from {hf_repo_id}... (If you wait too long, consider downloading it manually)")
ds = datasets.load_dataset(hf_repo_id, split=split_name, cache_dir='./data/hf_cache')
else:
_logger.note(f"Loading parquet from local: {data_path}")
ds = datasets.load_dataset("parquet", data_files=str(data_path), split="train")
# 从 HDF5 读取数值样本
h5file_path = Path(data_root) / "lsr_bench_data.hdf5"
problems = []
with h5py.File(h5file_path, "r") as f:
for entry in ds:
name = entry["name"]
# HDF5 路径: /lsr_transform/<name> 或 /lsr_synth/<domain>/<name>
if split_name == "lsr_transform":
h5_path = f"/lsr_transform/{name}"
else:
h5_path = f"/lsr_synth/{dataset_name}/{name}"
expression = entry["expression"]
samples = {k: v[...].astype(np.float64) for k, v in f[h5_path].items()}
# expression 中有一些 P(t) 这样的写法,需要将它替换成 P
for symbol in entry['symbols']:
try:
expression = re.sub(rf"\b{re.escape(symbol)}\b\(t\)", symbol, expression)
except Exception as e:
_logger.warning(f"Failed to replace {symbol}(t) -> {symbol} in {entry['expression']!r}")
# expression 中有一些 pi, e 这样的常数, 需要将它替换成数值
for key, var in {'pi': np.pi, 'e': np.e}.items():
if key not in entry['symbols']:
try:
expression = re.sub(rf"\b{key}\b", str(var), expression)
except Exception as e:
_logger.warning(f"Failed to replace {key} -> {var} in {entry['expression']!r}")
expression = expression.replace("^", "**")
# Chemical Reaction 数据集中存在一些错误设置的数值常数,替换为 __C0, __C1, ... 以便后续重新确定数值常数
if dataset_name == 'chem_react':
# 0.18997742423620262_z, 0.547523655303147_s, .5_alpha, 1e-3_k -> __C0, __C1, __C2, __C3
expression = re.sub(
r"(?<![A-Za-z_])(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?_[A-Za-z_]\w*",
lambda m, c=iter(range(100)): f"__C{next(c)}",
expression,
)
# Physical Oscillation / Chemical Reaction 数据集中公式的常数未被正确设置,需要拟合后替换成数值
if dataset_name in {'phys_osc', 'chem_react'}:
formula = engine.parse(expression)
if unknowns := sorted({var.name for var in formula.iter_preorder() if isinstance(var, engine.Variable) and var.name not in entry['symbols']}):
train, y = samples["train"], samples["train"][:, 0]
base = {sym: train[:, i] for i, sym in enumerate(entry["symbols"])}
def residual(c):
data = base | {k: np.full(len(y), v) for k, v in zip(unknowns, c)}
return formula.eval(data).flatten() - y
starts = [np.full(len(unknowns), x) for x in (0.1, 0.5, 1.0, 2.0)]
starts += [np.where(np.arange(len(unknowns)) == i, 0.01, 0.5) for i in range(len(unknowns))]
rng = np.random.default_rng(0)
starts += [np.exp(rng.uniform(np.log(0.01), np.log(2.0), len(unknowns))) for _ in range(4)]
fits = [least_squares(residual, x0, bounds=(0, np.inf), max_nfev=5000).x for x0 in starts]
values = min(fits, key=lambda c: np.mean(residual(c) ** 2))
for key, val in zip(unknowns, values):
expression = re.sub(rf"\b{re.escape(key)}\b", repr(float(val)), expression)
gt_expression = engine.parse(expression)
# 确保没有未知变量
variables = {var.name for var in gt_expression.iter_preorder() if isinstance(var, engine.Variable)}
if missing := set(variables) - set(entry['symbols']):
_logger.warning(f"[{dataset_name}] {name} has unknown variables in expression: {missing}. This problem is SKIPPED.")
continue
# 确保 gt_expression 足够准确
data = {sym: samples["train"][:, i] for i, sym in enumerate(entry["symbols"])}
y_pred = gt_expression.eval(data)
y_true = data[entry["symbols"][0]]
metrics = {
'mae': float(np.mean(np.abs(y_true - y_pred))),
'mape': float(mean_absolute_percentage_error(y_true, y_pred)),
'rmse': float(np.sqrt(np.mean((y_true - y_pred) ** 2))),
'r2': float(1 - np.mean((y_true - y_pred) ** 2) / np.var(y_true)) if np.var(y_true) > 0 else float("nan"),
}
if not (metrics['r2'] > 0.99):
_logger.warning(
f"[{dataset_name}] {name} has low R^2 ({metrics['r2']:.4f}) between gt_expression and train samples "
f"(MAE={metrics['mae']:.4e}, MAPE={metrics['mape']:.4e}, RMSE={metrics['rmse']:.4e}). "
f"This problem is NOT skipped, but the results may be unreliable."
)
problems.append(Problem(
dataset_identifier=dataset_name,
equation_idx=name,
symbols=entry["symbols"],
symbol_descs=entry["symbol_descs"],
symbol_properties=entry["symbol_properties"],
raw_expression=entry["expression"],
gt_expression=gt_expression,
samples=samples,
))
return problems
def anonymize_problem(problem: Problem) -> Problem:
"""Return a copy of a benchmark problem with variable names and descriptions replaced by generic symbols.
Numeric arrays remain untouched so the run measures symbolic discovery without
domain-specific text hints.
Args:
problem: Original benchmark :class:`Problem` instance.
Returns:
Problem: Anonymized copy of ``problem``.
"""
target = problem.symbols[0]
features = problem.symbols[1:]
feature_mapping = {name: f"x{i}" for i, name in enumerate(features, start=1)} | {target: "y"}
anonymized_symbols = [feature_mapping[sym] for sym in problem.symbols]
anonymized_symbol_descs = ["target variable", *[f"input variable {i}" for i in range(1, len(features) + 1)]]
anonymized_expression = transform(
problem.gt_expression,
lambda node: (
engine.Variable(feature_mapping[node.name])
if isinstance(node, engine.Variable) else node
),
)
anonymized_expression_str = anonymized_expression.to_str()
_logger.debug(
f"[{problem.equation_idx} @ {problem.dataset_identifier}]"
f"Anonymization enabled. "
f"Variable mapping: {feature_mapping}\n"
f"Original formula: {target} = {problem.gt_expression}\n"
f"Anonymized formula: {anonymized_symbols[0]} = {anonymized_expression_str}\n"
)
anonymized_problem = Problem(
dataset_identifier=problem.dataset_identifier,
equation_idx=problem.equation_idx,
symbols=anonymized_symbols,
symbol_descs=anonymized_symbol_descs,
symbol_properties=problem.symbol_properties,
gt_expression=anonymized_expression,
raw_expression="<None>",
samples=problem.samples,
)
return anonymized_problem
def compute_metrics(y_pred: np.ndarray, y_true: np.ndarray) -> Dict[str, float]:
"""Compute regression accuracy metrics ($R^2$, MSE, NMSE, RMSE, MAE, MAPE, sMAPE, Kendall $\tau$) between predictions and targets.
Args:
y_pred: Model-predicted target values.
y_true: Ground-truth target observations.
Returns:
Dict[str, float]: Dictionary of evaluation metric names and float values.
"""
mask = ~np.isnan(y_pred) # 原始的 LLM-SRBench 就是这么做的,可能导致潜在的问题,但为了保持一致,我们也采用相同的过滤方式。
y_pred, y_true = y_pred[mask], y_true[mask]
if len(y_true) == 0:
return {
"mse": float("nan"), "nmse": float("nan"), "r2": float("nan"),
"kdt": float("nan"), "mape": float("nan"), "num_valid_points": 0
}
else:
var = np.var(y_true)
mse = float(np.mean((y_true - y_pred) ** 2))
nmse = float(mse / var) if var > 0 else float("nan")
r2 = float(1 - nmse)
kdt = float(kendalltau(y_true, y_pred)[0]) if len(y_true) > 1 else float("nan")
mape = float(mean_absolute_percentage_error(y_true, y_pred))
acc01 = np.mean(np.abs(y_true - y_pred) <= 0.1 * np.abs(y_true))
return {
"mse": mse, "nmse": nmse, "r2": r2, "acc01": acc01,
"kdt": kdt, "mape": mape, "num_valid_points": len(y_true),
}
def evaluate_problem(args, problem: Problem, sr_fn: Callable, exp_path: Path) -> Dict:
"""Run the selected symbolic-regression algorithm on one benchmark problem and evaluate its discovered formula.
Computes in-domain and out-of-domain numerical metrics and checks symbolic
equivalence against the ground-truth expression.
Args:
args: Parsed CLI arguments controlling algorithm and evaluation settings.
problem: Benchmark :class:`Problem` to solve.
sr_fn: Callable implementing the selected symbolic-regression baseline.
exp_path: Output directory where per-problem run artifacts are saved.
Returns:
Dict: Per-problem evaluation record including discovered formula, metrics,
symbolic accuracy, runtime, and token/cost usage.
"""
task = problem.create_task()
start_time = time.time()
result = sr_fn(args, task)
search_time = time.time() - start_time
# ID test
X_id = problem.test_samples[:, 1:]
y_id = problem.test_samples[:, 0]
id_metrics = compute_metrics(y_pred=result.predict(X_id), y_true=y_id)
# OOD test (if available)
ood_metrics = None
if problem.ood_test_samples is not None:
X_ood = problem.ood_test_samples[:, 1:]
y_ood = problem.ood_test_samples[:, 0]
ood_metrics = compute_metrics(y_pred=result.predict(X_ood), y_true=y_ood)
# Symbolic Accurate
try:
data = {sym: problem.test_samples[:, i] for i, sym in enumerate(problem.symbols)}
f_true = problem.gt_expression
f_pred = engine.parse(result.expression.replace("^", "**").replace("np.", "").replace("math.", ""))
symbolic_acc = get_symbolic_acc(
f_true,
f_pred,
data,
return_details=True,
llm_provider=getattr(args, "llm_provider", None),
llm_model=getattr(args, "llm_model", None),
)
foo = lambda x: tag2ansi(('[green bold]EQUIVALENT[reset]' if x is True else '[red bold]NOT EQUIVALENT[reset]' if x is False else f'[gray bold]{x!r}[reset]'))
_logger.note(tag2ansi(
f"[{problem.equation_idx}] The predicted formula is judged to be {foo(symbolic_acc['equivalent'])} since {symbolic_acc.get('reason')}:\n"
f" f_true = [green]{f_true.to_str()}[reset]\n"
f" f_pred = [red]{f_pred.to_str()}[reset]"
f"Manually modify [green bold]{exp_path}[reset] if you want to override the symbolic accuracy judgment."
))
except Exception as e:
symbolic_acc = {
"equivalent": None,
"reason": f"symbolic accuracy check failed: [{type(e).__name__}] {e}",
}
_logger.warning(f"[{problem.equation_idx}] Symbolic accuracy check failed: {log_exception(e)}")
benchmark_result = {
"equation_id": problem.equation_idx,
"dataset_identifier": problem.dataset_identifier,
"gt_expression": problem.gt_expression.to_str(),
"discovered_expression": result.expression,
"num_train": len(problem.train_samples),
"num_test": len(problem.test_samples),
"search_time": search_time,
"id_metrics": id_metrics,
"ood_metrics": ood_metrics,
"symbolic_acc": symbolic_acc['equivalent'],
"symbolic_acc_detail": symbolic_acc['reason'],
}
if result.metadata:
benchmark_result.update(result.metadata)
return benchmark_result
def log_result(result: Dict):
"""Format and log the evaluation summary for a single benchmark problem to the console.
Args:
result: Per-problem evaluation result dictionary returned by :func:`evaluate_problem`.
"""
lines = []
lines.append(f'[gray]{"=" * 50}')
lines.append(f"[blue bold]Problem {result['equation_id']} @ {result.get('dataset_identifier', 'Unknown')} evaluated.[reset]")
lines.append(f'[gray]{"-" * 50}')
lines.append(f"[blue]GT: [green]{result['gt_expression']}[reset]")
lines.append(f"[blue]Discovered: [red]{result['discovered_expression']}[reset]")
if (symbolic_acc := result.get("symbolic_acc")) is None:
lines.append(f"[blue]Symbolic Accurate:[reset] [gray]N/A[reset]")
elif symbolic_acc:
lines.append(f"[blue]Symbolic Accurate:[reset] [green]Yes[reset]")
else:
lines.append(f"[blue]Symbolic Accurate:[reset] [red]No[reset]")
if 'id_metrics' in result and result['id_metrics'] is not None:
lines.append(
f"[blue]In-Domain: R2={result['id_metrics'].get('r2', float('nan')):.6f}, "
f"MSE={result['id_metrics'].get('mse', float('nan')):.6f}, "
f"NMSE={result['id_metrics'].get('nmse', float('nan')):.6f}, "
f"MAPE={result['id_metrics'].get('mape', float('nan')):.6f}, "
f"Acc@0.1={result['id_metrics'].get('acc01', float('nan')):.6f}, "
f"Valid Points={result['id_metrics'].get('num_valid_points', 0)}"
)
if 'ood_metrics' in result and result['ood_metrics'] is not None:
lines.append(
f"[blue]Out-of-Domain: R2={result['ood_metrics']['r2']:.6f}, "
f"MSE={result['ood_metrics']['mse']:.6f}, "
f"NMSE={result['ood_metrics']['nmse']:.6f}, "
f"MAPE={result['ood_metrics']['mape']:.6f}, "
f"Acc@0.1={result['ood_metrics']['acc01']:.6f}, "
f"Valid Points={result['ood_metrics']['num_valid_points']}"
)
lines.append(f"[blue]Search time:[reset] {result['search_time']:.2f}s")
lines.append(f'[gray]{"=" * 50}')
return tag2ansi("\n".join(lines))
def aggregate_results(results: List[Dict]) -> Dict:
"""Aggregate per-problem evaluation records into dataset-level summary statistics.
Args:
results: List of per-problem evaluation dictionaries.
Returns:
Dict: Summary statistics including symbolic accuracy rate, mean/median
metrics, runtime, and token/cost totals.
"""
def safe_mean(key, group):
vals = [r[group][key] for r in results if r[group] is not None and not np.isnan(r[group][key])]
return float(np.mean(vals)) if vals else float("nan")
n = len(results)
# R² 达标率
r2_thresholds = [0.5, 0.9, 0.99, 0.999]
r2_hit_rates = {}
for thr in r2_thresholds:
hits = sum(1 for r in results if r["id_metrics"] is not None and r["id_metrics"]["r2"] >= thr)
r2_hit_rates[f"r2>={thr}"] = {"count": hits, "rate": hits / n if n > 0 else 0}
summary = {
"dataset": "|".join(sorted({r.get("dataset_identifier") for r in results} - {None})),
"total_problems": n,
"avg_search_time": float(np.mean([r["search_time"] for r in results])),
"avg_symbolic_acc": float(np.mean([r.get("symbolic_acc") is True for r in results])),
"r2_hit_rates": r2_hit_rates,
}
summary["id_metrics"] = {
"avg_mse": safe_mean("mse", "id_metrics"),
"avg_nmse": safe_mean("nmse", "id_metrics"),
"avg_r2": safe_mean("r2", "id_metrics"),
"avg_acc01": safe_mean("acc01", "id_metrics"),
"avg_kdt": safe_mean("kdt", "id_metrics"),
"avg_mape": safe_mean("mape", "id_metrics"),
"avg_num_valid_points": safe_mean("num_valid_points", "id_metrics"),
}
if any(r["ood_metrics"] is not None for r in results):
summary["ood_metrics"] = {
"avg_mse": safe_mean("mse", "ood_metrics"),
"avg_nmse": safe_mean("nmse", "ood_metrics"),
"avg_r2": safe_mean("r2", "ood_metrics"),
"avg_acc01": safe_mean("acc01", "ood_metrics"),
"avg_kdt": safe_mean("kdt", "ood_metrics"),
"avg_mape": safe_mean("mape", "ood_metrics"),
"avg_num_valid_points": safe_mean("num_valid_points", "ood_metrics"),
}
return summary
def conclude_results(results: List[Dict], llmsr_datasets: List[str], save_path: str):
# 汇总
"""Write per-dataset and overall benchmark summary tables to ``summary/`` and print them to the console.
Args:
results: Complete list of per-problem evaluation records across datasets.
llmsr_datasets: List of evaluated dataset names.
save_path: Root experiment directory where ``summary/`` files are written.
"""
results = [r for r in results if r.get("dataset_identifier") in llmsr_datasets]
summary = aggregate_results(results)
# 打印汇总
lines = []
lines.extend([
f'[gray]{"=" * 50}[reset]',
f"[red bold]Summary of {'|'.join(llmsr_datasets)} ({summary['total_problems']} problems)[reset]",
f'[gray]{"-" * 50}[reset]',
f" [red]Avg R2 (In-Domain):[reset] {summary['id_metrics']['avg_r2']:.6f}",
f" [red]Avg MSE (In-Domain):[reset] {summary['id_metrics']['avg_mse']:.6f}",
f" [red]Avg NMSE (In-Domain):[reset] {summary['id_metrics']['avg_nmse']:.6f}",
f" [red]Avg MAPE (In-Domain):[reset] {summary['id_metrics']['avg_mape']:.6f}",
f" [red]Avg KDT (In-Domain):[reset] {summary['id_metrics']['avg_kdt']:.6f}",
f" [red]Avg Acc@0.1 (In-Domain):[reset] {summary['id_metrics']['avg_acc01']:.6f}",
f" [red]Avg Valid Points (In-Domain):[reset] {summary['id_metrics']['avg_num_valid_points']:.1f}",
])
if "ood_metrics" in summary:
lines.extend([
f'[gray]{"-" * 50}[reset]',
f" [red]Avg R2 (Out-of-Domain):[reset] {summary['ood_metrics']['avg_r2']:.6f}",
f" [red]Avg MSE (Out-of-Domain):[reset] {summary['ood_metrics']['avg_mse']:.6f}",
f" [red]Avg NMSE (Out-of-Domain):[reset] {summary['ood_metrics']['avg_nmse']:.6f}",
f" [red]Avg MAPE (Out-of-Domain):[reset] {summary['ood_metrics']['avg_mape']:.6f}",
f" [red]Avg KDT (Out-of-Domain):[reset] {summary['ood_metrics']['avg_kdt']:.6f}",
f" [red]Avg Acc@0.1 (Out-of-Domain):[reset] {summary['ood_metrics']['avg_acc01']:.6f}",
f" [red]Avg Valid Points (Out-of-Domain):[reset] {summary['ood_metrics']['avg_num_valid_points']:.1f}",
])
lines.append(f'[gray]{"-" * 50}[reset]')
for thr, info in summary["r2_hit_rates"].items():
lines.append(f" [red]{thr:>10}:[reset] {info['count']:>5}/{summary['total_problems']} ({info['rate']:.1%})")
lines.append(f'[gray]{"-" * 50}[reset]')
lines.append(f" [red]Avg Search Time:[reset] {summary['avg_search_time']:.2f}s")
lines.append(f" [red]Avg Symbolic Accurate Rate:[reset] {summary['avg_symbolic_acc']:.6f}")
lines.append(f'[gray]{"=" * 50}[reset]')
_logger.note(tag2ansi("\n" + "\n".join(lines)))
# 保存汇总
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, "a", encoding="utf-8") as f:
json.dump(summary, f, indent=2, default=str, allow_nan=True)
_logger.note(f"Summary saved to {save_path}")
def run_benchmark(args: argparse.Namespace) -> int:
# 加载问题集
"""Execute the full LLM-SRBench evaluation loop across the selected datasets and problems.
Args:
args: Parsed CLI arguments specifying algorithm, datasets, and output path.
Returns:
int: Process exit code (``0`` on success).
"""
args.datasets = args.datasets or list(DATASET_SPLITS.keys())
problems = []
for dataset in args.datasets:
problems.extend(load_problems(dataset, args.data_root))
_logger.note(f"Load {len(problems)} problems in total from datasets: {args.datasets}")
if args.problem_names is not None:
if unknown_problems := set(args.problem_names) - set(p.equation_idx for p in problems):
_logger.warning(f"Unknown problems specified: {unknown_problems}")
problems = [p for p in problems if p.equation_idx in args.problem_names]
_logger.note(f"Filtered to {len(problems)} problems: {[p.equation_idx for p in problems]}")
if not problems:
_logger.error("No valid problems found after filtering. Check your --problem_names.")
return 1
# 将 agent-facing 问题匿名化。样本矩阵列顺序不变,只替换符号名和自然语言描述。
if args.anonymize:
problems = [anonymize_problem(problem) for problem in problems]
_logger.note("Anonymization enabled for benchmark tasks.")
# 获取算法的 run 函数
sr_fn = get_algorithm(args.algorithm)
# 逐个问题运行 SR 并评估
results = []
interrupted = False
for i, problem in enumerate(problems):
_logger.note(f"[{i+1}/{len(problems)}] {problem.equation_idx}: {problem.gt_expression.to_str()}")
exp_path = Path(args.save_path) / "results" / f"{problem.dataset_identifier}_{problem.equation_idx}.jsonl"
exp_path.parent.mkdir(parents=True, exist_ok=True)
if exp_path.exists():
lines = [json.loads(line) for line in exp_path.read_text(encoding="utf-8").splitlines() if line.strip()]
successful_lines = [line for line in lines if 'error' not in line]
if args.skip_successful and successful_lines:
result = successful_lines[-1]
results.append(result)
_logger.info(tag2ansi(
f"Successful result already exists at {exp_path} ([red bold]{len(successful_lines)} successful records), skipping...\n"
f"Last successful result:\n{log_result(result)}"
))
continue
if args.skip_existing and lines:
result = successful_lines[-1] if successful_lines else lines[-1]
results.append(result)
_logger.info(tag2ansi(
f"Result already exists at {exp_path} ([red bold]{len(lines)} records with [red bold]{len(successful_lines)} successful), skipping...\n"
f"Last result:\n{log_result(result)}"
))
continue
try:
result = evaluate_problem(args, problem, sr_fn, exp_path)
results.append(result)
_logger.note(f"\n{log_result(result)}")
with open(exp_path, "a", encoding="utf-8") as f:
f.write(json.dumps(result, default=str, allow_nan=True) + "\n")
_logger.note(f"Result saved to {exp_path}")
except KeyboardInterrupt:
_logger.warning("Evaluation interrupted by user.")
interrupted = True
break
except Exception as e:
_logger.error(f" ERROR: {log_exception(e)}")
result = {
"equation_id": problem.equation_idx,
"dataset_identifier": problem.dataset_identifier,
"gt_expression": problem.gt_expression.to_str(),
"discovered_expression": None,
"num_train": len(problem.train_samples),
"num_test": len(problem.test_samples),
"search_time": float("nan"),
"id_metrics": {
"mse": float("nan"), "nmse": float("nan"), "r2": float("nan"),
"kdt": float("nan"), "mape": float("nan"), "acc01": float("nan"),
"num_valid_points": 0
},
"ood_metrics": None,
"symbolic_acc": False,
"error": str(e),
}
results.append(result)
with open(exp_path, "a", encoding="utf-8") as f:
f.write(json.dumps(result, default=str, allow_nan=True) + "\n")
if args.debug: raise
# 汇总结果
for dataset in args.datasets:
conclude_results(results, [dataset], save_path=Path(args.save_path) / "summary" / f"{dataset}.json")
conclude_results(results, list(DATASET_SPLITS.keys()), save_path=Path(args.save_path) / "summary" / f"all.json")
if interrupted:
return 130
return 1 if any("error" in result for result in results) else 0
def main(args: argparse.Namespace) -> int:
"""Entry point for the ``sr-harness benchmark`` subcommand.
Args:
args: Parsed command-line arguments.
Returns:
int: Process exit code.
"""
if args.exp_name is None:
now = datetime.now()
args.exp_name = sanitize_filename(
f"{now:%Y%m%d}_{args.name}_{now:%H%M%S}_{gethostname()}"
)
else:
args.exp_name = sanitize_filename(args.exp_name)
if args.debug:
args.verbose = True
if args.seed == -1:
args.seed = int(datetime.now().timestamp() * 1000) % (2**32 - 1)
seed_all(args.seed)
save_path = Path(args.save_path) if args.save_path else Path(args.save_dir) / args.exp_name
save_path.mkdir(parents=True, exist_ok=True)
args.save_path = str(save_path)
command = getattr(args, "invocation", ["sr-harness", "benchmark"])
args.invocation = " ".join(map(shlex.quote, command))
setup_logging(
info_level="debug" if args.verbose else "info",
exp_name=args.exp_name,
save_path=save_path / "info.log",
force=True,
)
_logger.note(f"Args: {args}")
save_args(args, save_path / "args.json")
exit_code = run_benchmark(args)
_logger.note(tag2ansi(f"Experiment completed. Re-run the script with [green bold]{args.invocation}[reset]"))
return exit_code
if __name__ == "__main__":
raise SystemExit(main(setup_parser().parse_args()))
|