evalstone/bash/collect_results.py
sora e26fa0fe44 feat: restructure output layout and add --folder-name
- Add --folder-name CLI arg; defaults to safe model name, or
  {model}_THINKING when --thinking is enabled.
- Move benchmark outputs under output/{folder_name}/{benchmark}/...
- Remove redundant {model_name} subfolder from predictions/reports/reviews.
- Update collect_results.py to write both CSV and Excel to
  results/{folder_name}.csv/xlsx.
- Update perf backup/restore paths to match the new layout.
- Patch evalscope CacheManager to drop model_name from cache paths.
2026-07-23 03:06:52 +00:00

627 lines
26 KiB
Python

#!/usr/bin/env python3
"""
Collect benchmark results from an EvalScope output directory and write a summary
Excel/CSV similar to:
P800模型能力评测结果 - DS4-Flash-INT8-NO-Thinking-2.0-FULL.csv
Rules:
- Scores are averaged across seeds / multi-runs.
- Perf metrics (latency, TTFT, TPOT, TPS, tokens) are recomputed from raw
predictions across all seeds / multi-runs, so resuming from a checkpoint
no longer resets cumulative statistics.
"""
import argparse
import json
import statistics
from pathlib import Path
import numpy as np
import pandas as pd
# Map benchmark -> capability domain (same as the reference CSV)
BENCHMARK_DOMAIN = {
'bigcodebench': '代码与工程',
'humaneval': '代码与工程',
'live_code_bench': '代码与工程',
'aime24': '推理与数学',
'aime25': '推理与数学',
'aime26': '推理与数学',
'hmmt26': '推理与数学',
'imo_answerbench': '推理与数学',
'gsm8k': '推理与数学',
'competition_math': '推理与数学',
'bbh': '推理与数学',
'drop': '推理与数学',
'gpqa_diamond': '知识与语言理解',
'hle': '知识与语言理解',
'hle': '知识与语言理解',
'hle_low': '知识与语言理解',
'mmlu_pro': '知识与语言理解',
'simple_qa': '知识与语言理解',
'super_gpqa': '知识与语言理解',
'mmlu': '知识与语言理解',
'cmmlu': '知识与语言理解',
'arc': '知识与语言理解',
'hellaswag': '知识与语言理解',
'trivia_qa': '知识与语言理解',
'winogrande': '知识与语言理解',
'longbench_v2': '长上下文',
'openai_mrcr': '长上下文',
'tau2_bench': '智能体与工具',
'general_fc': '智能体与工具',
'bfcl_v3': '智能体与工具',
}
# Column order matching the reference CSV
OUTPUT_COLUMNS = [
'分类',
'Benchmark',
'得分',
'实测时间(h)',
'总样本数',
'延迟_mean(s)',
'输出TPS',
'请求QPS',
'输入tokens_mean',
'输出tokens_mean',
'累计总tokens',
'TTFT_mean(s)',
'TTFT P90',
'TTFT P99',
'TPOT_mean(s)',
'TPOT P90',
'TPOT P99',
]
# Some evalscope output directory names do not match the canonical benchmark
# name (e.g. the hle benchmark writes to the `hle_low` directory because of
# subset naming). Map them to the canonical name here so the summary uses a
# consistent label.
BENCHMARK_NAME_ALIAS = {
'hle_low': 'hle',
}
def percentile(values, q):
"""Return the q-th percentile using numpy's linear interpolation."""
if not values:
return np.nan
return float(np.percentile(values, q))
def read_predictions(pred_file: Path):
"""Yield perf_metrics dicts from a predictions JSONL file."""
for obj in read_predictions_with_index(pred_file):
if obj['perf_metrics'] is not None:
yield obj['perf_metrics']
def read_predictions_with_index(pred_file: Path):
"""Yield ``{'index', 'perf_metrics'}`` dicts from a predictions JSONL file."""
if not pred_file.exists():
return
with open(pred_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
model_output = obj.get('model_output', {})
pm = model_output.get('perf_metrics')
if not pm and 'choices' in model_output:
choices = model_output['choices']
if choices and 'message' in choices[0]:
pm = choices[0]['message'].get('perf_metrics')
yield {'index': obj.get('index'), 'perf_metrics': pm}
def load_backup_summary(output_dir: Path, benchmark: str, model_name: str):
"""Load the durable ``perf_stats_backup/<benchmark>__<model>.json``.
Returns the ``summary`` dict (with ``latency`` / ``ttft`` / ``tpot`` /
``throughput`` / ``usage`` sub-dicts) or ``None`` if no backup exists.
"""
try:
from perf_backup import get_backup_paths
except ImportError:
return None
perf_path, _ = get_backup_paths(Path(output_dir), benchmark, model_name)
if not perf_path.exists():
return None
try:
payload = json.loads(perf_path.read_text(encoding='utf-8'))
return payload.get('summary')
except Exception:
return None
def find_report(output_dir: Path, benchmark: str, model_name: str):
"""Find the first report JSON for a benchmark/model under output_dir."""
bench_dir = output_dir / benchmark
if not bench_dir.exists():
return None
for seed_dir in sorted(bench_dir.iterdir()):
if not seed_dir.is_dir():
continue
for candidate in find_report_candidates(seed_dir, benchmark, model_name):
if candidate.exists():
return candidate
return None
def find_report_candidates(seed_dir: Path, benchmark: str, model_name: str):
"""Return candidate report paths, accepting variant naming patterns."""
# The actual report filename may not match the directory name exactly
# (e.g. hle_low directory holds hle.json). Try all .json files under
# the reports directory.
candidates = [
seed_dir / 'reports' / f'{benchmark}.json',
]
for p in (seed_dir / 'reports').glob('*.json'):
candidates.append(p)
return candidates
def find_all_reports(output_dir: Path, benchmark: str, model_name: str):
"""Find all report JSONs for a benchmark/model (across seeds/runs)."""
bench_dir = output_dir / benchmark
reports = []
if not bench_dir.exists():
return reports
for seed_dir in sorted(bench_dir.iterdir()):
if not seed_dir.is_dir():
continue
for candidate in find_report_candidates(seed_dir, benchmark, model_name):
if candidate.exists() and candidate not in reports:
reports.append(candidate)
break
return reports
def find_archive_predictions(output_dir: Path, benchmark: str, model_name: str):
"""Return the durable per-sample archive maintained by ``perf_backup.py``.
The archive contains every sample ever produced across all runs and
breakpoints for this benchmark/model (deduplicated by ``index``). When
present, the summary aggregator should prefer it over the raw
``predictions/*.jsonl`` files because the latter can be overwritten on
restart.
"""
try:
from perf_backup import get_backup_paths
except ImportError:
return []
_, archive_path = get_backup_paths(output_dir, benchmark, model_name)
if archive_path.exists():
return [archive_path]
return []
def find_all_predictions(output_dir: Path, benchmark: str, model_name: str):
"""Find all predictions JSONL files for a benchmark/model.
The durable ``predictions_archive/<benchmark>__<model>.jsonl`` (if
present) is returned **first** so its deduplicated per-sample records
dominate any smaller predictions that a fresh run may have written.
"""
files = list(find_archive_predictions(output_dir, benchmark, model_name))
bench_dir = output_dir / benchmark
if not bench_dir.exists():
return files
for seed_dir in sorted(bench_dir.iterdir()):
if not seed_dir.is_dir():
continue
pred_dir = seed_dir / 'predictions'
if pred_dir.exists():
files.extend(sorted(pred_dir.rglob('*.jsonl')))
return files
def parse_log_duration(log_file: Path):
"""Parse first and last timestamp from eval_log.log and return duration in hours."""
if not log_file.exists():
return np.nan
from datetime import datetime
first_dt = None
last_dt = None
fmt = '%Y-%m-%d %H:%M:%S'
with open(log_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if len(line) < 19:
continue
try:
dt = datetime.strptime(line[:19], fmt)
except ValueError:
continue
if first_dt is None:
first_dt = dt
last_dt = dt
if first_dt is None or last_dt is None or last_dt <= first_dt:
return np.nan
return (last_dt - first_dt).total_seconds() / 3600.0
def extract_score(report_data: dict) -> float:
"""Extract the top-level score from a report JSON."""
score = report_data.get('score')
if score is not None:
return float(score)
metrics = report_data.get('metrics', [])
for m in metrics:
if m.get('name') == 'mean_acc':
return float(m.get('score', m.get('macro_score', 0.0)))
return 0.0
def collect_benchmark(output_dir: Path, benchmark: str, model_name: str):
"""Collect aggregated results for one benchmark."""
reports = find_all_reports(output_dir, benchmark, model_name)
if not reports:
return None
scores = []
summary0 = None
n_samples_unique = 0
for report in reports:
try:
data = json.loads(report.read_text(encoding='utf-8'))
scores.append(extract_score(data))
if summary0 is None:
summary0 = data.get('perf_metrics', {}).get('summary', {})
n_samples_unique = summary0.get('n_samples', 0)
except Exception:
continue
avg_score = float(np.mean(scores)) if scores else 0.0
# Aggregate raw prediction perf metrics across all seeds/runs.
# This fixes the breakpoint-resume issue where cumulative stats are reset.
# Predictions are deduplicated by sample `index` so the archive (which
# spans every run) and the latest ``predictions/*.jsonl`` don't double
# count the same sample.
pred_files = find_all_predictions(output_dir, benchmark, model_name)
seen_indexes = set()
latencies = []
ttfts = []
tpots = []
input_tokens = []
output_tokens = []
sample_indexes = []
for pf in pred_files:
for obj in read_predictions_with_index(pf):
idx = obj['index']
if idx is None or idx in seen_indexes:
# Still keep perf data even when we lack an index, so older
# benchmark files without `index` don't get dropped.
pm = obj['perf_metrics']
else:
seen_indexes.add(idx)
sample_indexes.append(idx)
pm = obj['perf_metrics']
if pm is None:
continue
if 'latency' in pm:
latencies.append(float(pm['latency']))
if 'ttft' in pm:
ttfts.append(float(pm['ttft']))
if 'tpot' in pm:
tpots.append(float(pm['tpot']))
itok = pm.get('input_tokens')
otok = pm.get('output_tokens')
if itok is None and 'usage' in pm:
itok = pm['usage'].get('input_tokens')
otok = pm['usage'].get('output_tokens')
if itok is not None:
input_tokens.append(int(itok))
if otok is not None:
output_tokens.append(int(otok))
# If we don't have raw predictions but have a perf_stats backup, that
# represents a known-good summary captured right after a clean run —
# preferable to summary0 (which may be the just-reset run).
backup_summary = None
if not latencies:
backup_summary = load_backup_summary(output_dir, benchmark, model_name)
if latencies:
latency_mean = float(np.mean(latencies))
total_compute_time = float(np.sum(latencies))
total_output_tokens = sum(output_tokens)
avg_output_tps = total_output_tokens / total_compute_time if total_compute_time > 0 else np.nan
avg_req_ps = len(latencies) / total_compute_time if total_compute_time > 0 else np.nan
input_tok_mean = float(np.mean(input_tokens)) if input_tokens else np.nan
output_tok_mean = float(np.mean(output_tokens)) if output_tokens else np.nan
total_tokens = sum(input_tokens) + sum(output_tokens)
ttft_mean = float(np.mean(ttfts)) if ttfts else np.nan
ttft_p90 = percentile(ttfts, 90)
ttft_p99 = percentile(ttfts, 99)
tpot_mean = float(np.mean(tpots)) if tpots else np.nan
tpot_p90 = percentile(tpots, 90)
tpot_p99 = percentile(tpots, 99)
# The actual sample count we just rebuilt from raw predictions is more
# reliable than the (possibly reset) report summary's n_samples.
if sample_indexes:
n_samples_unique = len(sample_indexes)
elif summary0:
# Fallback to report summary if raw predictions are unavailable
latency_mean = summary0.get('latency', {}).get('mean', np.nan)
avg_output_tps = summary0.get('throughput', {}).get('avg_output_tps', np.nan)
avg_req_ps = summary0.get('throughput', {}).get('avg_req_ps', np.nan)
input_tok_mean = summary0.get('tokens', {}).get('input_tokens', {}).get('mean', np.nan)
output_tok_mean = summary0.get('tokens', {}).get('output_tokens', {}).get('mean', np.nan)
total_tokens = summary0.get('tokens', {}).get('total_tokens_count', np.nan)
ttft_mean = summary0.get('ttft', {}).get('mean', np.nan)
ttft_p90 = summary0.get('ttft', {}).get('90%', np.nan)
ttft_p99 = summary0.get('ttft', {}).get('99%', np.nan)
tpot_mean = summary0.get('tpot', {}).get('mean', np.nan)
tpot_p90 = summary0.get('tpot', {}).get('90%', np.nan)
tpot_p99 = summary0.get('tpot', {}).get('99%', np.nan)
# If the just-read report looks like a freshly-reset run (smaller
# n_samples than the durable backup), prefer the backup's summary so
# the cumulative numbers are not lost.
try:
from perf_backup import get_backup_paths
backup_path, _ = get_backup_paths(output_dir, benchmark, model_name)
if backup_path.exists():
payload = json.loads(backup_path.read_text(encoding='utf-8'))
if (payload.get('n_samples') or 0) > (summary0.get('n_samples') or 0):
backup_summary = payload.get('summary') or {}
latency_mean = backup_summary.get('latency', {}).get('mean', latency_mean)
avg_output_tps = backup_summary.get('throughput', {}).get('avg_output_tps', avg_output_tps)
avg_req_ps = backup_summary.get('throughput', {}).get('avg_req_ps', avg_req_ps)
input_tok_mean = backup_summary.get('usage', {}).get('input_tokens', {}).get('mean', input_tok_mean)
output_tok_mean = backup_summary.get('usage', {}).get('output_tokens', {}).get('mean', output_tok_mean)
total_tokens = backup_summary.get('usage', {}).get('total_tokens_count', total_tokens)
ttft_mean = backup_summary.get('ttft', {}).get('mean', ttft_mean)
ttft_p90 = backup_summary.get('ttft', {}).get('90%', ttft_p90)
ttft_p99 = backup_summary.get('ttft', {}).get('99%', ttft_p99)
tpot_mean = backup_summary.get('tpot', {}).get('mean', tpot_mean)
tpot_p90 = backup_summary.get('tpot', {}).get('90%', tpot_p90)
tpot_p99 = backup_summary.get('tpot', {}).get('99%', tpot_p99)
n_samples_unique = payload.get('n_samples') or n_samples_unique
except Exception:
pass
elif backup_summary:
latency_mean = backup_summary.get('latency', {}).get('mean', np.nan)
avg_output_tps = backup_summary.get('throughput', {}).get('avg_output_tps', np.nan)
avg_req_ps = backup_summary.get('throughput', {}).get('avg_req_ps', np.nan)
input_tok_mean = backup_summary.get('usage', {}).get('input_tokens', {}).get('mean', np.nan)
output_tok_mean = backup_summary.get('usage', {}).get('output_tokens', {}).get('mean', np.nan)
total_tokens = backup_summary.get('usage', {}).get('total_tokens_count', np.nan)
ttft_mean = backup_summary.get('ttft', {}).get('mean', np.nan)
ttft_p90 = backup_summary.get('ttft', {}).get('90%', np.nan)
ttft_p99 = backup_summary.get('ttft', {}).get('99%', np.nan)
tpot_mean = backup_summary.get('tpot', {}).get('mean', np.nan)
tpot_p90 = backup_summary.get('tpot', {}).get('90%', np.nan)
tpot_p99 = backup_summary.get('tpot', {}).get('99%', np.nan)
else:
return None
# Duration: prefer the durable active timer maintained by perf_backup.py,
# which only counts time when run_task() is actually executing. This avoids
# counting idle gaps caused by manual interruption/resume.
# If no active timer exists, fall back to log duration or latency estimate.
active_time_hours = np.nan
try:
from perf_backup import load_active_time
active_seconds = load_active_time(output_dir, benchmark, model_name)
if active_seconds > 0:
active_time_hours = active_seconds / 3600.0
except Exception:
pass
if not np.isnan(active_time_hours):
duration_hours = active_time_hours
else:
# Legacy fallback: sum of wall-clock durations from eval_log.log.
# For sandbox benchmarks the main log may not cover sandbox execution,
# so we also estimate wall time as compute_time/batch_size and take max.
duration_hours = 0.0
batch_size = 4
for report in reports:
log_file = report.parent.parent.parent / 'logs' / 'eval_log.log'
d = parse_log_duration(log_file)
if not np.isnan(d):
duration_hours += d
cfg_file = report.parent.parent.parent / 'configs' / 'task_config.yaml'
if cfg_file.exists():
try:
import yaml
cfg = yaml.safe_load(cfg_file.read_text(encoding='utf-8'))
batch_size = int(cfg.get('eval_batch_size', batch_size))
except Exception:
pass
compute_wall_estimate = (total_compute_time / batch_size / 3600.0) if latencies and batch_size > 0 else np.nan
if duration_hours <= 0:
duration_hours = compute_wall_estimate
else:
duration_hours = max(duration_hours, compute_wall_estimate)
return {
'分类': BENCHMARK_DOMAIN.get(benchmark, '其他'),
'Benchmark': BENCHMARK_NAME_ALIAS.get(benchmark, benchmark),
'得分': round(avg_score, 4),
'实测时间(h)': round(duration_hours, 4) if not np.isnan(duration_hours) else np.nan,
'总样本数': n_samples_unique,
'延迟_mean(s)': round(latency_mean, 5) if not np.isnan(latency_mean) else np.nan,
'输出TPS': round(avg_output_tps, 2) if not np.isnan(avg_output_tps) else np.nan,
'请求QPS': round(avg_req_ps, 4) if not np.isnan(avg_req_ps) else np.nan,
'输入tokens_mean': round(input_tok_mean, 2) if not np.isnan(input_tok_mean) else np.nan,
'输出tokens_mean': round(output_tok_mean, 2) if not np.isnan(output_tok_mean) else np.nan,
'累计总tokens': total_tokens if not np.isnan(total_tokens) else np.nan,
'TTFT_mean(s)': round(ttft_mean, 5) if not np.isnan(ttft_mean) else np.nan,
'TTFT P90': round(ttft_p90, 5) if not np.isnan(ttft_p90) else np.nan,
'TTFT P99': round(ttft_p99, 5) if not np.isnan(ttft_p99) else np.nan,
'TPOT_mean(s)': round(tpot_mean, 5) if not np.isnan(tpot_mean) else np.nan,
'TPOT P90': round(tpot_p90, 5) if not np.isnan(tpot_p90) else np.nan,
'TPOT P99': round(tpot_p99, 5) if not np.isnan(tpot_p99) else np.nan,
}
def eval_benchmark(benchmark_names: list, output_dir: Path, model_name: str,
out_name: str = None, excel_output_dir: Path = None):
"""Collect results for a specific list of benchmarks.
This is the public entry point intended for use by ``run.py`` and other
scripts that already know which benchmarks they ran.
Args:
benchmark_names: List of canonical benchmark names to aggregate (e.g.
``['aime24', 'gsm8k', 'arc']``). Only benchmarks with reports on
disk will appear in the summary.
output_dir: EvalScope output root directory.
model_name: Model name to look up reports/predictions for.
out_name: Output file name (without extension). Defaults to the safe
model name.
excel_output_dir: Optional separate directory for the Excel summary.
If provided, the Excel file is written here while the CSV stays
under ``output_dir/results/<model>/``.
Returns:
(csv_path, xlsx_path) tuple.
"""
return collect_all(output_dir, model_name, out_name,
include_benchmarks=benchmark_names,
excel_output_dir=excel_output_dir)
def collect_all(output_dir: Path, model_name: str, out_name: str = None,
include_benchmarks: list = None,
excel_output_dir: Path = None):
"""Collect benchmarks under ``output_dir`` and write summary Excel/CSV."""
if not output_dir.exists():
raise FileNotFoundError(f'Output directory not found: {output_dir}')
safe_model = model_name.replace('/', '_').replace('\\', '_').replace(' ', '_')
# Discover benchmarks
if include_benchmarks is not None:
# Whitelist mode: only aggregate benchmarks that were actually run in
# this invocation. Directory names may differ from canonical names
# (e.g. evalscope writes the hle benchmark to the hle_low directory),
# so we canonicalize each directory name via BENCHMARK_NAME_ALIAS and
# check membership in the caller's list. Order follows the caller's
# include_benchmarks list.
include_set = set(include_benchmarks)
discovered = [] # list of (dir_name, canonical_name)
for bench_dir in output_dir.iterdir():
if not bench_dir.is_dir() or bench_dir.name == safe_model:
continue
if not find_all_reports(output_dir, bench_dir.name, model_name):
continue
canonical = BENCHMARK_NAME_ALIAS.get(bench_dir.name, bench_dir.name)
if canonical in include_set or bench_dir.name in include_set:
discovered.append((bench_dir.name, canonical))
order = {name: idx for idx, name in enumerate(include_benchmarks)}
discovered.sort(key=lambda x: order.get(x[1], len(include_benchmarks)))
benchmarks = [dir_name for dir_name, _ in discovered]
else:
# Legacy behaviour: scan everything on disk.
benchmarks = []
for bench_dir in sorted(output_dir.iterdir()):
if not bench_dir.is_dir() or bench_dir.name == safe_model:
continue
reports = find_all_reports(output_dir, bench_dir.name, model_name)
if reports:
benchmarks.append(bench_dir.name)
rows = []
for benchmark in benchmarks:
row = collect_benchmark(output_dir, benchmark, model_name)
if row:
rows.append(row)
if not rows:
print(f'No results found for model {model_name} under {output_dir}')
return None, None
df = pd.DataFrame(rows, columns=OUTPUT_COLUMNS)
df = df.drop_duplicates(subset=['Benchmark'], keep='first')
# Add total row
total_score = df['得分'].mean()
total_time = df['实测时间(h)'].sum()
total_row = {
'分类': '总计',
'Benchmark': '',
'得分': round(total_score, 4),
'实测时间(h)': round(total_time, 4),
}
for col in OUTPUT_COLUMNS:
if col not in total_row:
total_row[col] = np.nan
df = pd.concat([df, pd.DataFrame([total_row], columns=OUTPUT_COLUMNS)], ignore_index=True)
if out_name is None:
out_name = safe_model
# Excel can optionally be written to a separate project-level results dir
# so that the latest summary per model is easy to find independently of
# the per-run output directory. When a separate dir is given, put both
# CSV and Excel there.
if excel_output_dir is not None:
summary_dir = Path(excel_output_dir)
else:
summary_dir = output_dir / 'results' / safe_model
summary_dir.mkdir(parents=True, exist_ok=True)
csv_path = summary_dir / f'{out_name}.csv'
xlsx_path = summary_dir / f'{out_name}.xlsx'
df.to_csv(csv_path, index=False, encoding='utf-8-sig')
df.to_excel(xlsx_path, index=False)
print(f'Summary written to:')
print(f' CSV: {csv_path}')
print(f' Excel: {xlsx_path}')
return csv_path, xlsx_path
def main():
parser = argparse.ArgumentParser(description='Collect EvalScope benchmark results into Excel/CSV')
parser.add_argument('--output-dir', required=True, help='EvalScope output directory')
parser.add_argument('--model', default='DeepSeek-V4-Flash-Int8', help='Model name used in reports')
parser.add_argument('--out-name', default=None, help='Output file name (without extension); defaults to safe model name')
parser.add_argument('--include', default=None,
help='Comma-separated benchmark whitelist (default: all on disk)')
parser.add_argument('--benchmarks', '--benchmarks', dest='benchmarks', default=None,
help='Alias for --include')
parser.add_argument('--excel-output-dir', default=None,
help='Separate directory for the Excel summary; defaults to <project_root>/results')
args = parser.parse_args()
# Default Excel output dir to project-level results/ so the latest summary
# per model is easy to find independently of the per-run output directory.
if args.excel_output_dir is None:
excel_output_dir = Path(__file__).parent.parent / 'results'
else:
excel_output_dir = Path(args.excel_output_dir)
include_benchmarks = None
if args.include:
include_benchmarks = [b.strip() for b in args.include.split(',') if b.strip()]
elif args.benchmarks:
include_benchmarks = [b.strip() for b in args.benchmarks.split(',') if b.strip()]
if include_benchmarks is not None:
eval_benchmark(include_benchmarks, Path(args.output_dir), args.model, args.out_name,
excel_output_dir=excel_output_dir)
else:
collect_all(Path(args.output_dir), args.model, args.out_name,
excel_output_dir=excel_output_dir)
if __name__ == '__main__':
main()