feat: measure the shortlist funnel loss, then size the shortlist per patient - #30
Open
majdabd wants to merge 4 commits into
Open
feat: measure the shortlist funnel loss, then size the shortlist per patient#30majdabd wants to merge 4 commits into
majdabd wants to merge 4 commits into
Conversation
recall@k measures the first-level candidate list and nDCG@k measures the
ranked list, so neither can see the pipeline's largest loss: the shortlist
handed to the eligibility reasoner is far shorter than the candidate list,
and a relevant trial dropped there can never be ranked.
Measured on the completed runs, the shortlist discards 33% (TREC 2021),
36% (2022) and 61% (2023) of the relevant trials the first level had already
found. The 2023 run shows why this stayed hidden: it has the best nDCG@10 of
any run (0.881) and the worst shortlist recall (0.254), because the condensed
metric only orders the judged trials that survive the funnel.
evaluate() now reports, whenever top_trials.txt is present:
- shortlist_recall .............. recall of the list the reasoner read
- shortlist_size
- funnel_depth_loss ............. recall given up by shortening alone
- shortlist_selection_delta ..... second-level ordering vs a plain
first-level top-N cut at the same depth
The split matters for what we fix first. On TREC 2021 depth accounts for
94% of the loss (0.866 -> 0.595 at top-200) and ordering for 6% (-> 0.577).
The selection delta is negative on all four runs (-0.032, -0.027, -0.022,
-0.009): the criterion reranker plus RRF shortlist fusion currently select
worse than taking the first-level top-N and doing nothing.
Runs without top_trials.txt still evaluate; the funnel keys report None.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends the TREC qrels evaluation to expose “funnel” metrics that quantify recall loss between the first-level candidate list and the second-level shortlist (when top_trials.txt exists), and adds tests to validate the new outputs.
Changes:
- Add parsing of
top_trials.txtand compute shortlist/funnel metrics inevaluate(). - Include new funnel metrics in the returned per-query rows and
meanaggregate. - Add unit tests covering funnel metric reporting and behavior when the shortlist file is absent.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| tests/test_qrels_eval.py | Adds tests asserting new funnel metrics and their absence (None) when top_trials.txt is missing. |
| src/trialmatchai/trec/qrels.py | Implements shortlist parsing and aggregates new funnel metrics into evaluate() results. |
Suppressed comments (1)
src/trialmatchai/trec/qrels.py:245
- Funnel metrics are currently computed only when
shortlistis truthy andretrievedis truthy. This skips reporting whentop_trials.txtexists but is empty (or when the first-level list is empty), despite the docstring/PR intent to report whenever the shortlist file is present. Using an explicitNonesentinel (see_shortlist_for_patient) and checkingis not Noneavoids conflating “absent” with “empty” and still keeps metricsNonewhen the file truly doesn’t exist.
shortlist = _shortlist_for_patient(patient_dir)
if shortlist and retrieved:
depth = len(shortlist)
short_r = recall_at_k(shortlist, rel_set, depth)
first_r = recall_at_k(retrieved, rel_set, depth)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+127
to
+136
| def _shortlist_for_patient(patient_dir: Path) -> list[str]: | ||
| """The trials that actually reached the eligibility (CoT) stage. | ||
|
|
||
| This is the funnel's narrowest point: a relevant trial dropped here is | ||
| unrecoverable, however good the first-level search was. | ||
| """ | ||
| shortlist = patient_dir / "top_trials.txt" | ||
| if not shortlist.exists(): | ||
| return [] | ||
| return [line.strip() for line in shortlist.read_text().splitlines() if line.strip()] |
Comment on lines
+189
to
+193
| - ``shortlist_recall`` -- recall of the list the reasoner actually read. | ||
| - ``funnel_depth_loss`` -- recall given up by shortening the list alone. | ||
| - ``shortlist_selection_delta`` -- second-level ordering minus a plain | ||
| first-level cut at the same depth. Negative means the second level | ||
| selected worse than doing nothing. |
…curve The shortlist divisor sizes every patient identically, but depth is 94% of the measured shortlist recall loss (PR #30) and no single number fits: the depth a patient needs to reach 90% of its own first-level recall ranges from 50 to 1550 trials, spread evenly across that range. Sizing for the worst case wastes ~65% of the reasoner's compute; sizing for the median drops the hard patients. Adds search.shortlist.policy: fixed existing divisor sizing, unchanged -- still the DEFAULT relative_to_max keep trials scoring >= alpha x this patient's top score A peaked first-level score curve means retrieval was confident and few trials are plausible; a flat curve means many are. The cut is relative to the patient's own maximum because RRF scores are only comparable within one patient. Tuned offline by replaying the completed runs' first_level_scores.json, so no GPU time. Compared against fixed depth AT EQUAL MEAN DEPTH, which is the only fair test -- a policy must spend compute better, not merely spend more: TREC 2021 +0.017 to +0.029 recall (peak at mean depth ~305) TREC 2022 +0.007 to +0.025 recall TREC 2023 -0.010 to +0.006 recall -- questionnaire topics, no gain Two other policies were tried and rejected: cumulative score mass tracked fixed depth to within +-0.003 everywhere, and half-max curve width won only below ~350 mean depth and lost above it. At alpha=0.25 the policy also chooses to go deeper (196->305 trials on 2021), which lifts shortlist recall 0.609->0.710. That part is bought with compute, not won for free; the equal-cost gain above is the free part. Both are real and they should be reported separately. Default stays "fixed" so enabling this is an explicit A/B. Degrades to the fixed depth when first-level scores are absent (resumed runs), never exceeds what the reasoner can consume, and writes shortlist_depth.json recording the decision and the depth the old sizing would have picked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ond-level scores The depth policy added in 91f724f is tuned only against replayed first-level scores. Whether the extra depth reaches nDCG@10 and P@10 -- rather than just shortlist recall -- needs a GPU run. This adds the reporting half of that experiment. compare_shortlist_ab.py reads the arms of a shortlist-policy A/B and always prints shortlist_size beside the quality metrics, because a depth policy produces two effects that must not be conflated: spending compute BETTER same mean shortlist size, more recall -- a free gain spending compute MORE bigger shortlist, bought with GPU time Reporting only the second reads as a much larger win than it is. The planned arms are fixed (~196 trials/patient), relative_to_max alpha 0.25 (~305, deeper), and relative_to_max alpha 0.33 (~196, equal cost). The equal-cost arm is the one that actually tests the policy. The job script itself stays untracked: *.slurm is gitignored repo-wide and none of the 26 existing job scripts are committed, since they encode site-specific paths, partitions and staging. Also persists second_level_scores.json for the whole second-level pool, not just the shortlist. With first_level_scores.json this makes shortlist fusion replayable offline, so the negative shortlist_selection_delta measured in 05c6f11 -- the fused shortlist selecting worse than a plain first-level cut at the same depth -- can be investigated without spending a GPU job on each candidate fix.
The llm_expansion search channel was dead. LLMQueryExpansionBackend (the Protocol), parse_llm_query_expansion (the parser), LLMQueryExpansion (the schema), the channel's 0.5 weight and search.first_level.llm_expansion_enabled all existed, but nothing ever built a backend and main.py never passed one to ClinicalTrialSearch. Setting the flag logged "no expander is enabled but no expander is configured" and contributed no terms. Adds FirstLevelQueryExpander, which subclasses QueryExpander to reuse its engine, chat template and structured-output machinery -- so it shares the one cached vLLM engine rather than loading a second copy -- and swaps in a retrieval-query prompt and schema. This is a genuinely different task from the existing expander, not a rename. QueryExpander enriches the patient SUMMARY (conditions plus narrative sentences) for the expand stage. This one writes RETRIEVAL QUERIES: short noun phrases that should match a trial's title, condition list or eligibility text, bucketed into the six fields the planner turns into weighted query channels. To support both, QueryExpander's prompt and schema became overridable class attributes; the base class's values are unchanged. The per-field maxItems caps are deliberately uneven. llm_max_terms is a SHARED budget across the five query fields, spent in field order, so a model that pads primary_queries starves biomarker_queries and treatment_queries entirely -- a test pins that behaviour. primary_queries is therefore capped at 3 (a patient has one main disease), leaving the budget for the later fields. Off by default: the builder returns None unless llm_expansion_enabled is set, and returns None rather than raising if the expander cannot be constructed. A failed expansion degrades to empty and is logged, because this is one channel of eight -- losing it should cost recall, not the run. Wired once per run in main_pipeline, not per patient, so the engine is resolved once.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits: measure the pipeline's largest loss, then act on its dominant cause.
1.
feat(eval): expose the shortlist funnel lossrecall@kmeasures the first-level candidate list.nDCG@kmeasures the ranked list. Neither can see the stage between them, which is where the pipeline loses the most: the shortlist handed to the eligibility reasoner is far shorter than the candidate list, and a relevant trial dropped there can never be ranked, however good the reasoning is.The 2023 row shows why this stayed hidden. That run has the best nDCG@10 of any run (0.881) and the worst shortlist recall (0.254). Condensed nDCG only orders the judged trials that survive the funnel, so it cannot report the three quarters that never arrived.
evaluate()now reportsshortlist_recall,shortlist_size,funnel_depth_lossandshortlist_selection_deltawhenevertop_trials.txtis present.Where the loss comes from (TREC 2021):
shortlist_selection_deltais negative on all four runs (−0.032, −0.027, −0.022, −0.009): at the same depth, the criterion reranker plus RRF fusion currently select worse than taking the first-level top-N and doing nothing. Small beside the depth loss, but free to recover — and it means the second level should be re-tested rather than assumed helpful. Not addressed here.2.
feat(search): per-patient shortlist depthDepth is 94% of the loss, and no single number fits. The depth a patient needs to reach 90% of its own first-level recall ranges from 50 to 1550 trials, spread evenly — 17 of 75 patients need ≤200, 21 need >700. Sizing for the worst case wastes about 65% of the reasoner's compute; sizing for the median drops the hard patients.
New
search.shortlist.policy:fixed— existing divisor sizing, unchanged. Still the default.relative_to_max— keep trials scoring ≥alpha× this patient's top score.A peaked first-level score curve means retrieval was confident and few trials are plausible; a flat curve means many are. The cut is relative to the patient's own maximum because RRF scores are comparable only within one patient.
Tuning
Done offline by replaying
first_level_scores.jsonfrom the completed runs — no GPU time. Compared against fixed depth at equal mean depth, which is the only fair test: a policy must spend compute better, not merely spend more.Two other policies were tried and rejected: cumulative score mass tracked fixed depth to within ±0.003 everywhere, and half-max curve width won only below ~350 mean depth and lost above it.
Two effects, reported separately
At
alpha=0.25the policy also chooses to go deeper (196 → 305 trials on 2021), lifting shortlist recall 0.609 → 0.710. That part is bought with compute. The equal-cost table above is the free part. Both are real and they should not be conflated.Safety
fixed, so enabling this is an explicit A/B, never a silent change. Verified: existing configs parse topolicy: fixed.rag.max_trials_rag), since trials past that cap get no eligibility output and vanish from the ranking.shortlist_depth.jsonrecording the decision and the depth the old sizing would have chosen.Verification
ruff check src/ tests/clean.choose_shortlist_depth()was replayed over the real runs and reproduces the offline simulator's depths and recalls exactly.top_trials.txtstill evaluate, with funnel keysNone.Not in this PR
The negative
shortlist_selection_delta, constraint/calculator tools for numeric criteria, and any iterative retrieval loop. This PR deliberately contains no agent loop — the measurements say depth allocation is the dominant lever, so it comes first and gets evaluated on its own.🤖 Generated with Claude Code