Repository navigation
fix(report): report eval, generate and pipeline use one paired arm summary (#229 P1-3) #250
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c642036
7f7dfee
e062773
03efe3c
94730e7
a92f424
fcc02f2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| ### Changed | ||
|
|
||
| - **`report generate` and the pipeline summary average each arm over the complete pairs, as `report eval` does** — the | ||
| Overall table, the topic and source breakdowns, and the `stats` block of `--format json` used | ||
| to average the context arm and the baseline arm over every question scored in each, so the two | ||
| means could cover different questions and their difference was not the delta printed beside | ||
| them. The `pipeline` run did the same for the summary it prints and stores in | ||
| `steps.evaluation.summary`. All of them now cover only the questions scored in both arms, and | ||
| the delta is the difference of those means. For an eval file with an unpaired question, the | ||
| context and baseline numbers change; for a file the judge writes, the delta does not. The report | ||
| says how many questions are unpaired (`paired_questions` and `unpaired_questions` in the JSON, | ||
| and a note plus the Count cell in the Markdown). To see the new numbers, run `report generate` | ||
| on an existing eval file again. | ||
| The top and bottom performer tables of `report generate` rank the complete pairs too. The | ||
| dashboard and the `report model-compare` run summary still average each arm over its own | ||
| scored questions, so for a file with an unpaired question their context and baseline means | ||
| differ from the ones `report generate` prints. | ||
|
|
||
| ### Fixed | ||
|
|
||
| - **`report eval` computes the delta from unrounded means** — the Delta was the difference of the | ||
| two arm means after each had been rounded to one decimal, so it could be 0.1 away from the | ||
| difference of the unrounded means. It is now the unrounded difference rounded once for display, | ||
| so it can move by 0.1 in the Summary, Breakdown and Difficulty tables. The arm means themselves | ||
| print as before. Where questions are unpaired, the Count cell now shows them, and a note says the | ||
| means are over the complete pairs. | ||
| The win rate and the degradation rate in Key Observations divide by the complete pairs, not by | ||
| every question, so they can rise for a file with an unpaired question. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,10 +35,6 @@ | |
| _TZ = None | ||
|
|
||
|
|
||
| def avg(lst): | ||
| return round(sum(lst) / len(lst), 1) if lst else 0.0 | ||
|
|
||
|
|
||
| def significance_test(evals): | ||
| """Paired t-test, Wilcoxon, and Cohen's d_z on (with_docs - without_docs) pairs. | ||
|
|
||
|
|
@@ -91,22 +87,41 @@ def _difficulty(e: Record, default): | |
|
|
||
|
|
||
| def stats(qs): | ||
| valid = [e for e in qs if has_scores(e)] | ||
| with_s = [e.score(CONTEXT) for e in valid] | ||
| without_s = [e.score(BASELINE) for e in valid] | ||
| deltas = [d for e in valid if (d := e.delta) is not None] | ||
| """The arms and delta over the complete pairs (:func:`eval.stats.paired_summary`). | ||
|
|
||
| ``count`` is every question in ``qs``, ``unpaired`` the ones left out of the means. | ||
| Rounded here, once, for display: the delta is the difference of the unrounded means. | ||
| """ | ||
| s = eval_stats.paired_summary(qs) | ||
| return { | ||
| "count": len(qs), | ||
| "valid": len(valid), | ||
| "with_avg": avg(with_s), | ||
| "without_avg": avg(without_s), | ||
| "delta_avg": round(avg(with_s) - avg(without_s), 1), | ||
| "improvements": sum(1 for d in deltas if d > 0), | ||
| "degradations": sum(1 for d in deltas if d < 0), | ||
| "neutral": sum(1 for d in deltas if d == 0), | ||
| "count": s.total, | ||
| "valid": s.paired, | ||
| "unpaired": s.unpaired, | ||
| "with_avg": round(s.with_mean, 1), | ||
| "without_avg": round(s.without_mean, 1), | ||
| "delta_avg": round(s.delta_mean, 1), | ||
| "improvements": s.improvements, | ||
| "degradations": s.degradations, | ||
| "neutral": s.neutral, | ||
| } | ||
|
|
||
|
|
||
| def _count_cell(s: dict) -> str: | ||
| """The question count, with the unpaired questions the arm means leave out, if any.""" | ||
| return str(s["count"]) if not s["unpaired"] else f'{s["count"]} ({s["unpaired"]} unpaired)' | ||
|
|
||
|
|
||
| def _unpaired_note(s: dict) -> list[str]: | ||
| """A line saying what the Summary means leave out; nothing when every question is paired.""" | ||
| if not s["unpaired"]: | ||
| return [] | ||
| return [ | ||
| f'_Arm means and delta are over the {s["valid"]} complete pairs; ' | ||
| f'{s["unpaired"]} unpaired (scored in one arm only) left out._', | ||
| "", | ||
| ] | ||
|
|
||
|
|
||
| def fmt_delta(d): | ||
| if d is None: | ||
| return "N/A" | ||
|
|
@@ -267,11 +282,12 @@ def generate_report(eval_path: str, out_path: str, qa_json_out: str | None = Non | |
| "## Summary", | ||
| "| Set | Count | Context arm | Baseline | Delta |", | ||
| "|---|---:|---:|---:|---:|", | ||
| f'| **All** | {all_stats["count"]} | {all_stats["with_avg"]} | {all_stats["without_avg"]} | **{all_stats["delta_avg"]:+}** |', | ||
| f'| Generated (dynamic) | {dyn_stats["count"]} | {dyn_stats["with_avg"]} | {dyn_stats["without_avg"]} | **{dyn_stats["delta_avg"]:+}** |', | ||
| f'| Golden (static) | {static_stats["count"]} | {static_stats["with_avg"]} | {static_stats["without_avg"]} | **{static_stats["delta_avg"]:+}** |', | ||
| f'| **All** | {_count_cell(all_stats)} | {all_stats["with_avg"]} | {all_stats["without_avg"]} | **{all_stats["delta_avg"]:+}** |', | ||
| f'| Generated (dynamic) | {_count_cell(dyn_stats)} | {dyn_stats["with_avg"]} | {dyn_stats["without_avg"]} | **{dyn_stats["delta_avg"]:+}** |', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocking (still open from @antonwolfy): the Generated (dynamic), Golden (static), by-type and by-difficulty rows (also lines 287, 341, 356) call Mutating Suggested fix: add a fixture with an unpaired question in one of these groups and assert the rendered count cell, as the test already does for the All row.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Covered at a92f424, no code change.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Covered in 03efe3c: test_eval_report_group_rows_count_their_unpaired_questions has unpaired questions in the dynamic and golden groups and asserts their count cells. Removing the unpaired branch at any of those call sites fails it. |
||
| f'| Golden (static) | {_count_cell(static_stats)} | {static_stats["with_avg"]} | {static_stats["without_avg"]} | **{static_stats["delta_avg"]:+}** |', | ||
| "", | ||
| ] | ||
| lines += _unpaired_note(all_stats) | ||
|
|
||
| # Statistical significance | ||
| sig = significance_test(evals) | ||
|
|
@@ -322,7 +338,7 @@ def _val(key): | |
| ("**Total**", all_stats), | ||
| ]: | ||
| lines.append( | ||
| f'| {label} | {s["count"]} | {s["with_avg"]} | {s["without_avg"]} ' | ||
| f'| {label} | {_count_cell(s)} | {s["with_avg"]} | {s["without_avg"]} ' | ||
| f'| **{s["delta_avg"]:+}** | {s["improvements"]} | {s["degradations"]} | {s["neutral"]} |' | ||
| ) | ||
| lines.append("") | ||
|
|
@@ -337,7 +353,7 @@ def _val(key): | |
| if d in diff_groups: | ||
| s = stats(diff_groups[d]) | ||
| lines.append( | ||
| f'| {d} | {s["count"]} | {s["with_avg"]} | {s["without_avg"]} ' | ||
| f'| {d} | {_count_cell(s)} | {s["with_avg"]} | {s["without_avg"]} ' | ||
| f'| **{s["delta_avg"]:+}** | {s["improvements"]} | {s["degradations"]} |' | ||
| ) | ||
| lines.append("") | ||
|
|
@@ -454,10 +470,10 @@ def _val(key): | |
| f'- **Dynamic vs Static agreement:** {dyn_stats["delta_avg"]:+} vs {static_stats["delta_avg"]:+} — ' | ||
| + ("good consistency, benchmark is stable." if abs(dyn_stats["delta_avg"] - static_stats["delta_avg"]) < 3 | ||
| else "notable gap — investigate question quality."), | ||
| f'- **Win rate:** {round(all_stats["improvements"]/all_stats["count"]*100) if all_stats["count"] else 0}% questions improved with context ' | ||
| f'({all_stats["improvements"]}/{all_stats["count"]}).', | ||
| f'- **Win rate:** {round(all_stats["improvements"]/all_stats["valid"]*100) if all_stats["valid"] else 0}% questions improved with context ' | ||
| f'({all_stats["improvements"]}/{all_stats["valid"]}).', | ||
| f'- **Degradations:** {all_stats["degradations"]} questions ' | ||
| f'({round(all_stats["degradations"]/all_stats["count"]*100) if all_stats["count"] else 0}%).', | ||
| f'({round(all_stats["degradations"]/all_stats["valid"]*100) if all_stats["valid"] else 0}%).', | ||
| "", | ||
| ] | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
blocking (still open from @antonwolfy): this is the only one of the three consumers whose rounding order (round-of-the-unrounded-difference vs difference-of-the-rounded-means) is untested.
The
test_pipeline_summary.pyfixture has mean values with no fractional component, so both rounding orders give the same result. Replacing this line withround(round(summary.with_mean, 1) - round(summary.without_mean, 1), 1)still passes every test intest_pipeline_summary.pyandtest_report_arm_agreement.py.Suggested fix: add a fixture with means like 70.04/50.06 so the rounding order is pinned, as
test_report_arm_agreement.pyalready does forreport evalandreport generate.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Covered at a92f424, no code change.
test_summary_rounds_the_delta_from_the_unrounded_means(03efe3c,tests/test_pipeline_summary.py) uses means 60.04 / 50.06: the delta of the unrounded means is 10.0, and the difference of the rounded means is 9.9. Replacing line 390 withround(round(summary.with_mean, 1) - round(summary.without_mean, 1), 1)fails that test, and so does dropping theround.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Covered in 03efe3c: the pipeline summary test uses means 60.04 and 50.06 and expects a delta of 10.0. Computing the delta from the rounded means fails it.