Skip to content

feat: measure the shortlist funnel loss, then size the shortlist per patient - #30

Open
majdabd wants to merge 4 commits into
mainfrom
feat/agentic-matching
Open

feat: measure the shortlist funnel loss, then size the shortlist per patient#30
majdabd wants to merge 4 commits into
mainfrom
feat/agentic-matching

Conversation

@majdabd

@majdabd majdabd commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Two commits: measure the pipeline's largest loss, then act on its dominant cause.


1. feat(eval): expose the shortlist funnel loss

recall@k measures the first-level candidate list. nDCG@k measures 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.

Run Track First level Shortlist Size Loss
qwen36_medcpt 2021 0.866 0.577 196 −33.4%
h100_baichuan 2021 0.866 0.581 197 −32.9%
qwen36_medcpt 2022 0.813 0.517 155 −36.4%
l40_trec23 2023 0.644 0.254 243 −60.6%

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 reports shortlist_recall, shortlist_size, funnel_depth_loss and shortlist_selection_delta whenever top_trials.txt is present.

Where the loss comes from (TREC 2021):

Stage Recall Cause
Full first level (2000) 0.866
First-level top-200 0.595 Depth: −0.272 (94% of the loss)
Actual shortlist (196) 0.577 Ordering: −0.017 (6% of the loss)

shortlist_selection_delta is 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 depth

Depth 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.json from 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.

Track Gain at equal compute
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 — 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.

Two effects, reported separately

At alpha=0.25 the 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

  • Default fixed, so enabling this is an explicit A/B, never a silent change. Verified: existing configs parse to policy: fixed.
  • Degrades to the fixed depth when first-level scores are absent (resumed runs).
  • Never exceeds what the reasoner can actually consume (rag.max_trials_rag), since trials past that cap get no eligibility output and vanish from the ranking.
  • Writes shortlist_depth.json recording the decision and the depth the old sizing would have chosen.

Verification

  • 433 tests pass; ruff check src/ tests/ clean.
  • 12 new tests for the depth policy, 2 for the funnel metrics.
  • The production choose_shortlist_depth() was replayed over the real runs and reproduces the offline simulator's depths and recalls exactly.
  • No behaviour change under default config; runs predating top_trials.txt still evaluate, with funnel keys None.

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

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>
Copilot AI lite review requested due to automatic review settings August 6, 2026 09:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.txt and compute shortlist/funnel metrics in evaluate().
  • Include new funnel metrics in the returned per-query rows and mean aggregate.
  • 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 shortlist is truthy and retrieved is truthy. This skips reporting when top_trials.txt exists 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 explicit None sentinel (see _shortlist_for_patient) and checking is not None avoids conflating “absent” with “empty” and still keeps metrics None when 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>
@majdabd majdabd changed the title feat(eval): expose the shortlist funnel loss in TREC metrics feat: measure the shortlist funnel loss, then size the shortlist per patient Aug 6, 2026
majdabd added 2 commits August 6, 2026 14:31
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants