2001jdev commited on
Commit
30fefbe
·
verified ·
1 Parent(s): 6e8314b

Initial release: checkpoints, loader and model card

Browse files
README.md ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ language: [en]
4
+ tags:
5
+ - clinical-trials
6
+ - information-retrieval
7
+ - reranking
8
+ - graph-neural-network
9
+ - biomedical
10
+ library_name: pytorch
11
+ pipeline_tag: text-classification
12
+ ---
13
+
14
+ # CrossGAT Trial Eligibility — 5-fold CV checkpoints
15
+
16
+ A 3-class eligibility model that **reranks clinical-trial retrieval candidates**.
17
+ It scores a patient description against a trial's inclusion/exclusion criteria as
18
+ `irrelevant / excluded / eligible`, by fusing a graph encoder over parsed
19
+ eligibility criteria with a text branch over whole-document embeddings.
20
+
21
+ These are the **five cross-validation checkpoints** — one per fold, so each is
22
+ trained on 80% of the data and never saw its own fold's topics. Use them to
23
+ reproduce the reported numbers, or ensemble them. For a single deployment model,
24
+ see [crossgat-trial-eligibility-full](https://huggingface.co/2001jdev/crossgat-trial-eligibility-full).
25
+
26
+ ## ⚠️ This model is not self-contained
27
+
28
+ It does **not** take raw text. It takes *parsed eligibility graphs* — entity and
29
+ relation structures extracted from trial criteria and patient descriptions by an
30
+ LLM. Producing those needs the generator from the project repo plus an LLM
31
+ endpoint (~38 GB VRAM).
32
+
33
+ **If you just want to reproduce the results**, the graphs for all 13,229 TREC CT
34
+ trials are published as a dataset — no LLM needed:
35
+ [clinical-trials-eligibility-graphs-rerank](https://huggingface.co/datasets/2001jdev/clinical-trials-eligibility-graphs-rerank).
36
+
37
+ ## What it is for, and what it is not for
38
+
39
+ **It reranks. It does not retrieve.** Trained on an eligibility target, it reorders
40
+ *within* the candidate set a first-stage retriever already found — promoting
41
+ eligible trials over merely topically-relevant ones.
42
+
43
+ **It cannot judge disease relevance.** Per-topic AUC for relevant-vs-rest is
44
+ **0.533** — chance. Whether a trial is even on the right disease is entirely the
45
+ first stage's job. This is why it must be *fused* with retrieval, never used to
46
+ replace it.
47
+
48
+ ## Recommended configuration
49
+
50
+ ```
51
+ score = (1 - w) * retrieval_norm + w * p_eligible_norm with w = 0.5
52
+ ```
53
+
54
+ Both terms min-max normalised **within a topic**. `p_eligible` = P(eligible) alone
55
+ wins over the alternatives in all four runs, at every cutoff, in both metric
56
+ families — even though `expected_gain` models the graded gain directly and is what
57
+ training optimises.
58
+
59
+ ## Results
60
+
61
+ 5-fold cross-validation, TREC Clinical Trials 2021–23, **162 topics**, paired
62
+ Wilcoxon signed-rank against each run's own first stage. Metric is
63
+ **graded NDCG@10** (gain `2^label - 1` = 0/1/3).
64
+
65
+ First stages: hybrid BM25+`embeddinggemma-300m-medical` (retrieval 0.5973) and
66
+ dense-only `MedEmbed-base-v0.1` (retrieval 0.5187).
67
+
68
+ | Variant | gemma-med | Δ | medembed | Δ |
69
+ |---|---|---|---|---|
70
+ | w=0.3 `p_eligible` | 0.6340 | +0.0367 *** | 0.5467 | +0.0280 *** |
71
+ | w=0.3 `expected_gain` | 0.6300 | +0.0327 *** | 0.5433 | +0.0246 *** |
72
+ | w=0.3 `one_minus_irrelevant` | 0.6230 | +0.0257 *** | 0.5334 | +0.0147 *** |
73
+ | w=0.5 `p_eligible` | 0.6515 | +0.0542 *** | 0.5644 | +0.0457 *** |
74
+ | w=0.5 `expected_gain` | 0.6485 | +0.0512 *** | 0.5572 | +0.0385 *** |
75
+ | w=0.5 `one_minus_irrelevant` | 0.6302 | +0.0329 *** | 0.5377 | +0.0190 ** |
76
+ | w=0.7 `p_eligible` | 0.6491 | +0.0518 *** | 0.5548 | +0.0361 *** |
77
+ | w=0.7 `expected_gain` | 0.6455 | +0.0482 *** | 0.5536 | +0.0349 *** |
78
+ | w=0.7 `one_minus_irrelevant` | 0.6262 | +0.0289 ** | 0.5405 | +0.0218 ** |
79
+ | w=1 `p_eligible` | 0.6191 | +0.0218 n.s. | 0.5202 | +0.0015 n.s. |
80
+ | w=1 `expected_gain` | 0.6163 | +0.0190 n.s. | 0.5222 | +0.0035 n.s. |
81
+ | w=1 `one_minus_irrelevant` | 0.6049 | +0.0076 n.s. | 0.5148 | -0.0039 n.s. |
82
+
83
+ `***` p<0.001, `**` p<0.01, `*` p<0.05.
84
+
85
+ **Read both metric families.** `raw` scores unjudged documents as 0 (punishing a
86
+ system for surfacing trials TREC never pooled); `condensed` deletes them first
87
+ (flattering a system that surfaces many). Neither is honest alone — the gap between
88
+ them, read against `judged@10`, says how far apart they can be. Where the two agree
89
+ the result is robust; at w=1.0 they diverge sharply and `raw` loses significance
90
+ entirely.
91
+
92
+ ## Which checkpoint to load
93
+
94
+ Each fold ships two: `ndcg.ckpt` (selected on `val_ndcg10`) and `f1.ckpt`
95
+ (selected on `val_macro_f1`).
96
+
97
+ **Every number above uses `ndcg`.** The `f1` checkpoints classify better
98
+ (macro F1 0.597 ± 0.020 vs 0.561 ± 0.035 on held-out folds) but have **not** been
99
+ benchmarked for reranking — offered for completeness, not recommended blindly.
100
+
101
+ Note `val_ndcg10` is flat across epochs 1–5, so the per-fold selected epochs
102
+ (1/2/5/2/4) are largely noise around a plateau. Do not read them as convergence.
103
+
104
+ ## Usage
105
+
106
+ ```python
107
+ pip install "crossgat @ git+https://github.com/JDev2001/msc_v2"
108
+
109
+ from crossgat_hf import load, score, rerank_score
110
+
111
+ pipe = load("2001jdev/crossgat-trial-eligibility-cv", fold=0, metric="ndcg")
112
+ out = score(pipe, patient_graph, inc_graph, exc_graph,
113
+ patient_text="65yo male, type 2 diabetes, HbA1c 8.1% ...",
114
+ trial_text="Metformin in adults with type 2 diabetes ...")
115
+
116
+ out["class_probs"] # {'irrelevant': .., 'excluded': .., 'eligible': ..}
117
+ rerank_score(out) # p_eligible — fuse this with your retrieval score
118
+ ```
119
+
120
+ `patient_text` and `trial_text` are **required**: the text branch is part of the
121
+ model, and omitting them raises rather than silently scoring an all-zero branch.
122
+
123
+ ## Training
124
+
125
+ - **Data:** TREC Clinical Trials 2021–23 qrels/topics; eligibility criteria parsed into graphs (CHIA-style entities/relations)
126
+ - **Objective:** cross-entropy + **LambdaRank** (k=10) on topic-grouped batches (4 topics/batch)
127
+ - **Hard negatives:** 40/topic drawn from the retrieval candidate distribution (+5,307 samples), mined from *hybrid gemma-med* run files
128
+ - **Text branch:** `abhinand/MedEmbed-base-v0.1`, whole-document embeddings of patient description and trial title+summary
129
+ - **Splits:** 5-fold CV over patients, seed fixed; each fold's test topics never seen in its training
130
+
131
+ ## Limitations
132
+
133
+ - **The first stage is the ceiling.** medembed *with* reranking still scores below gemma-med *without* it. A better retriever buys more than more reranker tuning.
134
+ - **Do not use w=1.0.** Pure model ranking loses significance on both retrievers (p=0.65 / 0.85). It ranks better inside the judged pool but drifts out of it — `judged@10` falls from 0.92 to 0.80.
135
+ - **The hard-negative gain is retriever-sensitive.** +0.0175 (p=0.007) on gemma-med, +0.0148 (p=0.050) on medembed — the negatives were mined from gemma-med candidates and transfer only partly.
136
+ - **Within-collection evidence only.** 162 topics, one corpus, one set of assessors. Nothing about generalisation to another trial collection follows.
137
+ - **Not a clinical device.** Research artefact. It does not determine patient eligibility and must not be used to make enrolment decisions.
138
+
139
+ ## Citation & provenance
140
+
141
+ Trained on TREC Clinical Trials track data (2021–23). Users must comply with the
142
+ TREC data usage agreements for the underlying collection and relevance judgments.
143
+
144
+ Checkpoint SHA-256 prefixes and the exact training configuration are in
145
+ `checkpoint_manifest.json`. Every benchmark run in the source repository writes a
146
+ `manifest.json` recording checkpoint hashes, git state and sanity checks.
checkpoint_manifest.json ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cv": {
3
+ "fold0/ndcg": {
4
+ "original_name": "joint-fold0-ndcg-epoch=01-val_ndcg10=0.6293.ckpt",
5
+ "sha256_12": "d64902e332cd",
6
+ "size_mb": 27.3
7
+ },
8
+ "fold0/f1": {
9
+ "original_name": "joint-fold0-f1-epoch=15-val_macro_f1=0.6327.ckpt",
10
+ "sha256_12": "af1611c7a7a8",
11
+ "size_mb": 27.3
12
+ },
13
+ "fold1/ndcg": {
14
+ "original_name": "joint-fold1-ndcg-epoch=02-val_ndcg10=0.6497.ckpt",
15
+ "sha256_12": "8c9e5b222ece",
16
+ "size_mb": 27.3
17
+ },
18
+ "fold1/f1": {
19
+ "original_name": "joint-fold1-f1-epoch=07-val_macro_f1=0.6015.ckpt",
20
+ "sha256_12": "69b5668b994c",
21
+ "size_mb": 27.3
22
+ },
23
+ "fold2/ndcg": {
24
+ "original_name": "joint-fold2-ndcg-epoch=05-val_ndcg10=0.5998.ckpt",
25
+ "sha256_12": "9ca8c1c3419f",
26
+ "size_mb": 27.3
27
+ },
28
+ "fold2/f1": {
29
+ "original_name": "joint-fold2-f1-epoch=04-val_macro_f1=0.5922.ckpt",
30
+ "sha256_12": "c3ee31254421",
31
+ "size_mb": 27.3
32
+ },
33
+ "fold3/ndcg": {
34
+ "original_name": "joint-fold3-ndcg-epoch=02-val_ndcg10=0.6571.ckpt",
35
+ "sha256_12": "9cfb1cf3cb33",
36
+ "size_mb": 27.3
37
+ },
38
+ "fold3/f1": {
39
+ "original_name": "joint-fold3-f1-epoch=05-val_macro_f1=0.5765.ckpt",
40
+ "sha256_12": "5236ff7beccb",
41
+ "size_mb": 27.3
42
+ },
43
+ "fold4/ndcg": {
44
+ "original_name": "joint-fold4-ndcg-epoch=04-val_ndcg10=0.7009.ckpt",
45
+ "sha256_12": "c29677ab75dc",
46
+ "size_mb": 27.3
47
+ },
48
+ "fold4/f1": {
49
+ "original_name": "joint-fold4-f1-epoch=07-val_macro_f1=0.5839.ckpt",
50
+ "sha256_12": "355b93e402fd",
51
+ "size_mb": 27.3
52
+ }
53
+ },
54
+ "full": {
55
+ "model": {
56
+ "original_name": "joint-fulldata-epoch=03.ckpt",
57
+ "sha256_12": "dbd1930dc442",
58
+ "size_mb": 27.3
59
+ }
60
+ }
61
+ }
checkpoints/fold0/f1.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:af1611c7a7a84b0f0998feb076167c99d8b1aea9d0de6771d77249384c07d7ae
3
+ size 28643449
checkpoints/fold0/ndcg.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d64902e332cd9b93757209a56a0557cc3fa11dd12e7b1d351ade9878ca3e6deb
3
+ size 28643449
checkpoints/fold1/f1.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:69b5668b994cb226776bad3d8e48165044d04764e768581c0d320b19f76c8eb2
3
+ size 28643513
checkpoints/fold1/ndcg.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8c9e5b222ecec6b3147eba3dc95085f152d45126596862867edb4130ad60e240
3
+ size 28643513
checkpoints/fold2/f1.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c3ee312544215487a8363d46a8e486db9c75c60e2c8a0cbfce57f3ea86f1f22b
3
+ size 28643513
checkpoints/fold2/ndcg.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9ca8c1c3419f5dde902aff1c8a59530e6f259838b8f2866e5357319b890ff4d1
3
+ size 28643513
checkpoints/fold3/f1.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5236ff7beccba7c851f052cbae16eb600211e56e93f47fdecf3b00ff7cc073ae
3
+ size 28643513
checkpoints/fold3/ndcg.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9cfb1cf3cb3347f651f8cf3cc9a0a25c2702a58baaf0f491c177b3009ce90727
3
+ size 28643513
checkpoints/fold4/f1.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:355b93e402fd9617ba806bd439befc66737f3acb2dcb4ae0a2592d7250e5148a
3
+ size 28643513
checkpoints/fold4/ndcg.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c29677ab75dcd3ebd3e0e919d05300af3933c161631ab068bb157488d4d7912e
3
+ size 28643513
crossgat_hf.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load a CrossGAT eligibility checkpoint from this repo and score a pair.
2
+
3
+ This model is NOT self-contained. It scores a *patient graph* against a trial's
4
+ *inclusion/exclusion graphs* — parsed entity/relation structures, not raw text.
5
+ Producing those graphs needs the eligibility-graph generator from the project
6
+ repository, which in turn needs an LLM endpoint. See the README.
7
+
8
+ What this file gives you is the loading and scoring half: given graphs (from the
9
+ companion dataset, or from your own generator), it returns the 3-class posterior.
10
+
11
+ pip install "crossgat @ git+https://github.com/JDev2001/msc_v2"
12
+ from crossgat_hf import load, score
13
+
14
+ pipe = load("2001jdev/crossgat-trial-eligibility-cv", fold=0, metric="ndcg")
15
+ out = score(pipe, patient_graph, inc_graph, exc_graph,
16
+ patient_text="65yo male, type 2 diabetes, HbA1c 8.1%",
17
+ trial_text="Metformin in adults with type 2 diabetes ...")
18
+ out["probs"] # [P(irrelevant), P(excluded), P(eligible)]
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ CLASSES = ("irrelevant", "excluded", "eligible")
25
+
26
+
27
+ def load(repo_id: str, fold: int | None = None, metric: str = "ndcg",
28
+ revision: str | None = None):
29
+ """Download one checkpoint and return a ready InferencePipeline.
30
+
31
+ fold=None loads ``checkpoints/model.ckpt`` (the full-data refit repo);
32
+ otherwise ``checkpoints/fold{fold}/{metric}.ckpt``.
33
+
34
+ metric selects which checkpoint the fold's training saved:
35
+ "ndcg" -- selected on val_ndcg10, used for every benchmark number reported
36
+ "f1" -- selected on val_macro_f1; classifies better (macro F1 .597 vs .561)
37
+ but has NOT been benchmarked for reranking.
38
+ """
39
+ from huggingface_hub import hf_hub_download
40
+ from crossgat.inference.pipeline import InferencePipeline
41
+
42
+ if metric not in ("ndcg", "f1"):
43
+ raise ValueError(f"metric must be 'ndcg' or 'f1', got {metric!r}")
44
+ name = "checkpoints/model.ckpt" if fold is None else f"checkpoints/fold{fold}/{metric}.ckpt"
45
+ path = hf_hub_download(repo_id=repo_id, filename=name, revision=revision)
46
+ return InferencePipeline(path)
47
+
48
+
49
+ def score(pipeline, patient_graph, inc_graph, exc_graph,
50
+ patient_text: str, trial_text: str) -> dict[str, Any]:
51
+ """Score one patient/trial pair.
52
+
53
+ Graphs are objects with ``.entities`` and ``.relations`` (crossgat RawGraph, or
54
+ anything with those attributes -- e.g. json.load of a file from the companion
55
+ graph dataset passed through ``crossgat.data.schemas``).
56
+
57
+ patient_text / trial_text feed the model's text branch and are REQUIRED: the
58
+ 3-class checkpoints fuse whole-document embeddings with the graph features, and
59
+ omitting them raises rather than silently scoring against an all-zero branch.
60
+ Use the TREC topic description and the trial's brief_title + brief_summary.
61
+ """
62
+ out = pipeline.predict_chia(
63
+ patient_entities=patient_graph.entities,
64
+ patient_relations=patient_graph.relations,
65
+ inc_trial_entities=inc_graph.entities,
66
+ inc_trial_relations=inc_graph.relations,
67
+ exc_trial_entities=exc_graph.entities,
68
+ exc_trial_relations=exc_graph.relations,
69
+ patient_text=patient_text,
70
+ trial_text=trial_text,
71
+ )
72
+ out["label"] = CLASSES[out["pred"]]
73
+ out["class_probs"] = dict(zip(CLASSES, out["probs"]))
74
+ return out
75
+
76
+
77
+ def rerank_score(out: dict, score_fn: str = "p_eligible") -> float:
78
+ """Reduce the posterior to the scalar a ranking sorts by.
79
+
80
+ ``p_eligible`` is the recommended one: it wins on both first stages tested, at
81
+ every cutoff and in both metric families. Fuse it with the retrieval score as
82
+ ``(1 - w) * retrieval_norm + w * model_norm`` at **w = 0.5**, min-max
83
+ normalising both within a topic. Do not replace the first stage outright
84
+ (w = 1.0): that loses significance on both retrievers.
85
+ """
86
+ p_irr, p_exc, p_elig = out["probs"]
87
+ if score_fn == "p_eligible":
88
+ return p_elig
89
+ if score_fn == "one_minus_irrelevant":
90
+ return 1.0 - p_irr
91
+ if score_fn == "expected_gain":
92
+ return 1.0 * p_exc + 3.0 * p_elig
93
+ raise ValueError(f"unknown score_fn {score_fn!r}")