Skip to content

Commit c775ce4

Browse files
feat: add ResultCollector for experiment export, comparison and reporting (#53)
1 parent 5b695fd commit c775ce4

5 files changed

Lines changed: 645 additions & 1 deletion

File tree

src/evalwire/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
make_top_k_evaluator,
1616
)
1717
from evalwire.observability import setup_observability
18+
from evalwire.results import ResultCollector
1819
from evalwire.runner import ExperimentRunner
1920
from evalwire.uploader import DatasetUploader
2021

@@ -25,6 +26,7 @@
2526
__all__ = [
2627
"DatasetUploader",
2728
"ExperimentRunner",
29+
"ResultCollector",
2830
"make_contains_evaluator",
2931
"make_exact_match_evaluator",
3032
"make_json_match_evaluator",

src/evalwire/cli.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""evalwire CLI — ``evalwire upload`` and ``evalwire run`` commands."""
1+
"""evalwire CLI — ``evalwire upload``, ``evalwire run``, ``evalwire export``, ``evalwire compare``, and ``evalwire report`` commands."""
22

33
import sys
44
from typing import Literal, cast
@@ -204,3 +204,101 @@ def run_cmd(
204204
except Exception as exc:
205205
click.echo(f"Error: {exc}", err=True)
206206
sys.exit(2)
207+
208+
209+
@main.command("export")
210+
@click.option(
211+
"--experiment",
212+
"experiment_id",
213+
default=None,
214+
help="Phoenix experiment ID.",
215+
)
216+
@click.option(
217+
"--format",
218+
"fmt",
219+
type=click.Choice(["csv", "json"]),
220+
default="csv",
221+
show_default=True,
222+
help="Output format.",
223+
)
224+
@click.option(
225+
"--output",
226+
"output_path",
227+
default=None,
228+
help="Destination file path.",
229+
)
230+
def export_cmd(
231+
experiment_id: str | None,
232+
fmt: str,
233+
output_path: str | None,
234+
) -> None:
235+
"""Export experiment results to a CSV or JSON file."""
236+
if not experiment_id:
237+
raise click.UsageError("No experiment ID provided. Use --experiment.")
238+
try:
239+
from pathlib import Path as _Path
240+
241+
from evalwire.results import ResultCollector
242+
243+
client = _make_client()
244+
rc = ResultCollector(client)
245+
resolved_output = (
246+
_Path(output_path) if output_path else _Path(f"{experiment_id}.{fmt}")
247+
)
248+
rc.export(experiment_id, format=fmt, path=resolved_output) # ty: ignore[invalid-argument-type]
249+
click.echo(f"Exported results to {resolved_output}")
250+
except click.UsageError:
251+
raise
252+
except Exception as exc:
253+
click.echo(f"Error: {exc}", err=True)
254+
sys.exit(2)
255+
256+
257+
@main.command("compare")
258+
@click.argument("experiment_id_a")
259+
@click.argument("experiment_id_b")
260+
def compare_cmd(experiment_id_a: str, experiment_id_b: str) -> None:
261+
"""Compare two experiment runs by their mean evaluator scores."""
262+
try:
263+
from evalwire.results import ResultCollector
264+
265+
client = _make_client()
266+
rc = ResultCollector(client)
267+
comparison = rc.compare(experiment_id_a, experiment_id_b)
268+
269+
click.echo(f"Comparing {experiment_id_a!r} vs {experiment_id_b!r}")
270+
click.echo("")
271+
for name, info in sorted(comparison.items()):
272+
delta = info["delta"]
273+
sign = "+" if delta >= 0 else ""
274+
click.echo(
275+
f" {name}: {info['score_a']:.4f}{info['score_b']:.4f} ({sign}{delta:.4f})"
276+
)
277+
except Exception as exc:
278+
click.echo(f"Error: {exc}", err=True)
279+
sys.exit(2)
280+
281+
282+
@main.command("report")
283+
@click.option(
284+
"--experiment",
285+
"experiment_id",
286+
default=None,
287+
help="Phoenix experiment ID.",
288+
)
289+
def report_cmd(experiment_id: str | None) -> None:
290+
"""Generate a markdown summary report for an experiment."""
291+
if not experiment_id:
292+
raise click.UsageError("No experiment ID provided. Use --experiment.")
293+
try:
294+
from evalwire.results import ResultCollector
295+
296+
client = _make_client()
297+
rc = ResultCollector(client)
298+
report = rc.report(experiment_id)
299+
click.echo(report)
300+
except click.UsageError:
301+
raise
302+
except Exception as exc:
303+
click.echo(f"Error: {exc}", err=True)
304+
sys.exit(2)

src/evalwire/results.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""Result collection, export, comparison, and reporting for evalwire experiments."""
2+
3+
from __future__ import annotations
4+
5+
import csv
6+
import json
7+
from pathlib import Path
8+
from typing import TYPE_CHECKING, Any, Literal
9+
10+
if TYPE_CHECKING:
11+
from phoenix.client import Client
12+
13+
_SUPPORTED_FORMATS = {"csv", "json"}
14+
15+
16+
def _rows_from_ran_experiment(ran_experiment: dict[str, Any]) -> list[dict[str, Any]]:
17+
"""Convert a RanExperiment into a flat list of row dicts (one per task run)."""
18+
task_runs: list[Any] = ran_experiment["task_runs"]
19+
evaluation_runs: list[Any] = ran_experiment["evaluation_runs"]
20+
21+
eval_by_run_id: dict[str, dict[str, float | None]] = {}
22+
for ev in evaluation_runs:
23+
run_id = ev.experiment_run_id
24+
score: float | None = None
25+
if ev.result is not None:
26+
score = ev.result.get("score")
27+
eval_by_run_id.setdefault(run_id, {})[ev.name] = score
28+
29+
rows = []
30+
for run in task_runs:
31+
row: dict[str, Any] = {
32+
"run_id": run.id,
33+
"output": run.output,
34+
"error": run.error,
35+
}
36+
scores = eval_by_run_id.get(run.id, {})
37+
row.update(scores)
38+
rows.append(row)
39+
return rows
40+
41+
42+
def _mean_scores(ran_experiment: dict[str, Any]) -> dict[str, float]:
43+
"""Return mean score per evaluator for a RanExperiment."""
44+
evaluation_runs: list[Any] = ran_experiment["evaluation_runs"]
45+
totals: dict[str, list[float]] = {}
46+
for ev in evaluation_runs:
47+
if ev.result is not None:
48+
score = ev.result.get("score")
49+
if score is not None:
50+
totals.setdefault(ev.name, []).append(float(score))
51+
return {name: sum(vals) / len(vals) for name, vals in totals.items()}
52+
53+
54+
class ResultCollector:
55+
"""Fetch, export, compare, and report on evalwire experiment results.
56+
57+
Parameters
58+
----------
59+
client:
60+
An initialised ``phoenix.client.Client`` instance.
61+
"""
62+
63+
def __init__(self, client: Client) -> None:
64+
self._client = client
65+
66+
def get(self, experiment_id: str) -> Any:
67+
"""Fetch a completed experiment by its ID.
68+
69+
Parameters
70+
----------
71+
experiment_id:
72+
The Phoenix experiment ID.
73+
74+
Returns
75+
-------
76+
dict
77+
A ``RanExperiment`` dict with ``task_runs`` and ``evaluation_runs``.
78+
79+
Raises
80+
------
81+
ValueError
82+
If the experiment is not found.
83+
"""
84+
return self._client.experiments.get_experiment(experiment_id=experiment_id)
85+
86+
def export(
87+
self,
88+
experiment_id: str,
89+
format: Literal["csv", "json"],
90+
path: Path | str,
91+
) -> None:
92+
"""Export experiment results to a file.
93+
94+
Parameters
95+
----------
96+
experiment_id:
97+
The Phoenix experiment ID.
98+
format:
99+
Output format: ``"csv"`` or ``"json"``.
100+
path:
101+
Destination file path.
102+
103+
Raises
104+
------
105+
ValueError
106+
If *format* is not supported.
107+
"""
108+
if format not in _SUPPORTED_FORMATS:
109+
raise ValueError(
110+
f"Unsupported format {format!r}. Choose from: {sorted(_SUPPORTED_FORMATS)}"
111+
)
112+
ran = self.get(experiment_id)
113+
rows = _rows_from_ran_experiment(ran)
114+
path = Path(path)
115+
116+
if format == "csv":
117+
fieldnames = list(rows[0].keys()) if rows else ["run_id", "output", "error"]
118+
with open(path, "w", newline="") as f:
119+
writer = csv.DictWriter(f, fieldnames=fieldnames)
120+
writer.writeheader()
121+
writer.writerows(rows)
122+
else:
123+
path.write_text(json.dumps(rows, indent=2, default=str))
124+
125+
def compare(
126+
self,
127+
experiment_id_a: str,
128+
experiment_id_b: str,
129+
) -> dict[str, dict[str, float]]:
130+
"""Compare two experiments by their mean evaluator scores.
131+
132+
Parameters
133+
----------
134+
experiment_id_a:
135+
ID of the baseline experiment.
136+
experiment_id_b:
137+
ID of the comparison experiment.
138+
139+
Returns
140+
-------
141+
dict
142+
Mapping of evaluator name → ``{"score_a": …, "score_b": …, "delta": …}``.
143+
"""
144+
ran_a = self.get(experiment_id_a)
145+
ran_b = self.get(experiment_id_b)
146+
scores_a = _mean_scores(ran_a)
147+
scores_b = _mean_scores(ran_b)
148+
all_names = set(scores_a) | set(scores_b)
149+
result: dict[str, dict[str, float]] = {}
150+
for name in all_names:
151+
a = scores_a.get(name, 0.0)
152+
b = scores_b.get(name, 0.0)
153+
result[name] = {"score_a": a, "score_b": b, "delta": b - a}
154+
return result
155+
156+
def report(self, experiment_id: str) -> str:
157+
"""Generate a markdown summary report for an experiment.
158+
159+
Parameters
160+
----------
161+
experiment_id:
162+
The Phoenix experiment ID.
163+
164+
Returns
165+
-------
166+
str
167+
A markdown-formatted summary string.
168+
"""
169+
ran = self.get(experiment_id)
170+
scores = _mean_scores(ran)
171+
task_runs: list[Any] = ran["task_runs"]
172+
173+
lines = [
174+
f"# Experiment Report: {experiment_id}",
175+
"",
176+
f"**Total runs:** {len(task_runs)}",
177+
"",
178+
"## Evaluator Scores",
179+
"",
180+
]
181+
if scores:
182+
for name, score in sorted(scores.items()):
183+
lines.append(f"- **{name}**: {score:.4f}")
184+
else:
185+
lines.append("_No evaluator scores recorded._")
186+
187+
return "\n".join(lines)

0 commit comments

Comments
 (0)