cxrfescore 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cxrfescore/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """CXRFEScore: fact-level embedding metric for chest X-ray report evaluation."""
2
+
3
+ from cxrfescore.cache_utils import inspect_cache
4
+ from cxrfescore.metric import CXRFEScore
5
+
6
+ __all__ = ["CXRFEScore", "inspect_cache"]
7
+ __version__ = "0.2.2"
@@ -0,0 +1,129 @@
1
+ """Helpers for inspecting CXRFEScore on-disk caches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pickle
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional, Union
8
+
9
+ from cxrfescore.metric import DEFAULT_CACHE_DIR
10
+
11
+
12
+ def _format_size(num_bytes: int) -> str:
13
+ if num_bytes < 1024:
14
+ return f"{num_bytes} B"
15
+ if num_bytes < 1024**2:
16
+ return f"{num_bytes / 1024:.2f} KB"
17
+ if num_bytes < 1024**3:
18
+ return f"{num_bytes / 1024**2:.2f} MB"
19
+ return f"{num_bytes / 1024**3:.2f} GB"
20
+
21
+
22
+ def _sample_entry(key: Any, value: Any) -> Dict[str, Any]:
23
+ sample: Dict[str, Any] = {"key": key}
24
+ if hasattr(value, "shape"):
25
+ sample["value_kind"] = "array"
26
+ sample["shape"] = tuple(value.shape)
27
+ sample["dtype"] = str(getattr(value, "dtype", ""))
28
+ else:
29
+ sample["value_kind"] = type(value).__name__
30
+ value_repr = repr(value)
31
+ if len(value_repr) > 120:
32
+ value_repr = value_repr[:120] + "..."
33
+ sample["value_repr"] = value_repr
34
+ return sample
35
+
36
+
37
+ def inspect_cache(
38
+ cache_dir: Optional[Union[str, Path]] = None,
39
+ max_samples: int = 5,
40
+ *,
41
+ print_report: bool = True,
42
+ ) -> Dict[str, Any]:
43
+ """
44
+ Inspect CXRFEScore cache pickle files under ``cache_dir``.
45
+
46
+ Looks for ``*.pkl`` recursively (sentence→facts at the root and
47
+ fact→embedding files under ``embeddings/<encoder>/``).
48
+
49
+ Args:
50
+ cache_dir: Base cache directory. Defaults to the platform user cache
51
+ dir for ``cxrfescore`` (same default as ``CXRFEScore``).
52
+ max_samples: How many key/value samples to show per file.
53
+ print_report: If True, print a human-readable report.
54
+
55
+ Returns:
56
+ A summary dict with ``cache_dir``, ``exists``, and a ``files`` list.
57
+ """
58
+ root = Path(cache_dir) if cache_dir is not None else Path(DEFAULT_CACHE_DIR)
59
+ summary: Dict[str, Any] = {
60
+ "cache_dir": str(root),
61
+ "exists": root.exists(),
62
+ "files": [],
63
+ }
64
+
65
+ if print_report:
66
+ print("=" * 60)
67
+ print(f"CXRFEScore cache: {root}")
68
+ print("=" * 60)
69
+
70
+ if not root.exists():
71
+ if print_report:
72
+ print("Cache directory does not exist yet.")
73
+ return summary
74
+
75
+ pkl_paths = sorted(root.rglob("*.pkl"))
76
+ if not pkl_paths:
77
+ if print_report:
78
+ print("No .pkl cache files found.")
79
+ return summary
80
+
81
+ for path in pkl_paths:
82
+ rel = str(path.relative_to(root))
83
+ file_info: Dict[str, Any] = {
84
+ "path": str(path),
85
+ "relative_path": rel,
86
+ "size_bytes": path.stat().st_size,
87
+ "size_human": _format_size(path.stat().st_size),
88
+ "entries": None,
89
+ "error": None,
90
+ "samples": [],
91
+ }
92
+
93
+ if print_report:
94
+ print(f"\n--- {rel} ({file_info['size_human']}) ---")
95
+
96
+ try:
97
+ with open(path, "rb") as f:
98
+ data = pickle.load(f)
99
+ if not isinstance(data, dict):
100
+ file_info["error"] = f"expected dict, got {type(data).__name__}"
101
+ if print_report:
102
+ print(f"Unexpected type: {type(data).__name__}")
103
+ else:
104
+ file_info["entries"] = len(data)
105
+ keys = list(data.keys())[:max_samples]
106
+ for key in keys:
107
+ file_info["samples"].append(_sample_entry(key, data[key]))
108
+ if print_report:
109
+ print(f"entries: {len(data)}")
110
+ for sample in file_info["samples"]:
111
+ if sample["value_kind"] == "array":
112
+ print(
113
+ f" {sample['key']!r} -> array "
114
+ f"shape={sample['shape']} dtype={sample['dtype']}"
115
+ )
116
+ else:
117
+ print(f" {sample['key']!r} -> {sample['value_repr']}")
118
+ if len(data) > max_samples:
119
+ print(f" ... ({len(data) - max_samples} more)")
120
+ except Exception as e:
121
+ file_info["error"] = str(e)
122
+ if print_report:
123
+ print(f"failed to load: {e}")
124
+
125
+ summary["files"].append(file_info)
126
+
127
+ if print_report:
128
+ print()
129
+ return summary
cxrfescore/metric.py ADDED
@@ -0,0 +1,864 @@
1
+ """CXRFEScore metric implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import pickle
8
+ import textwrap
9
+ from typing import Dict, List, Optional, Tuple, Union
10
+
11
+ import numpy as np
12
+ import torch
13
+ from nltk.tokenize import sent_tokenize
14
+ from platformdirs import user_cache_dir
15
+ from sklearn.metrics.pairwise import cosine_similarity
16
+ from torch.utils.data import DataLoader, Dataset
17
+ from tqdm import tqdm
18
+ from transformers import (
19
+ AutoModel,
20
+ AutoTokenizer,
21
+ BertForSequenceClassification,
22
+ BertTokenizer,
23
+ T5ForConditionalGeneration,
24
+ T5TokenizerFast,
25
+ )
26
+
27
+ from cxrfescore.nltk_setup import ensure_nltk_resources
28
+ from cxrfescore.text_utils import parse_facts, remove_consecutive_repeated_words_from_text
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
33
+
34
+ DEFAULT_CACHE_DIR = user_cache_dir("cxrfescore")
35
+
36
+ # SRR-BERT-Leaves uses a classification head; we only use BERT CLS embeddings.
37
+ SRR_BERT_LEAVES_NUM_LABELS = 55
38
+ SRR_BERT_LEAVES_TOKENIZER = "microsoft/BiomedVLP-CXR-BERT-general"
39
+ SRR_BERT_LEAVES_MAX_LENGTH = 128
40
+
41
+
42
+ def _safe_model_dirname(model_name: str) -> str:
43
+ """Filesystem-safe directory name for a Hugging Face model id."""
44
+ return model_name.replace("/", "__")
45
+
46
+ class TextDataset(Dataset):
47
+ def __init__(self, texts):
48
+ self.texts = texts
49
+
50
+ def __len__(self):
51
+ return len(self.texts)
52
+
53
+ def __getitem__(self, i):
54
+ return self.texts[i]
55
+
56
+
57
+ def get_text_collate_batch_fn(tokenizer_func):
58
+ def collate_batch_fn(batch):
59
+ encoding = tokenizer_func(batch)
60
+ return {"encoding": encoding}
61
+
62
+ return collate_batch_fn
63
+
64
+
65
+ def create_text_dataset_and_dataloader(texts, batch_size, num_workers, tokenizer_func):
66
+ collate_batch_fn = get_text_collate_batch_fn(tokenizer_func)
67
+ dataset = TextDataset(texts)
68
+ dataloader = DataLoader(
69
+ dataset,
70
+ batch_size=batch_size,
71
+ shuffle=False,
72
+ num_workers=num_workers,
73
+ collate_fn=collate_batch_fn,
74
+ pin_memory=True,
75
+ )
76
+ return dataset, dataloader
77
+
78
+
79
+ class CXRFEScore:
80
+ """
81
+ Fact-level cosine similarity between radiology reports using a T5 fact
82
+ extractor and CXR-BERT / CXRFE embeddings.
83
+
84
+ Steps:
85
+ 1. Split hypothesis and reference reports into sentences.
86
+ 2. Extract facts with a T5 fact extractor.
87
+ 3. Embed unique facts with a CXR-BERT-style encoder.
88
+ 4. For each pair, compute soft bipartite matching as the average of
89
+ mean row-wise and mean column-wise maximum cosine similarities.
90
+ """
91
+
92
+ SUPPORTED_MODELS = [
93
+ "pamessina/CXRFE",
94
+ "microsoft/BiomedVLP-CXR-BERT-specialized",
95
+ "microsoft/BiomedVLP-BioViL-T",
96
+ "StanfordAIMI/SRR-BERT-Leaves",
97
+ ]
98
+
99
+ MODEL_EMBEDDING_DIMENSIONS = {
100
+ "pamessina/CXRFE": 128,
101
+ "microsoft/BiomedVLP-CXR-BERT-specialized": 128,
102
+ "microsoft/BiomedVLP-BioViL-T": 128,
103
+ "StanfordAIMI/SRR-BERT-Leaves": 768,
104
+ }
105
+
106
+ # projected: get_projected_text_embeddings (CXRFE / CXR-BERT family)
107
+ # cls: BERT CLS token from BertForSequenceClassification.bert
108
+ ENCODER_BACKEND = {
109
+ "pamessina/CXRFE": "projected",
110
+ "microsoft/BiomedVLP-CXR-BERT-specialized": "projected",
111
+ "microsoft/BiomedVLP-BioViL-T": "projected",
112
+ "StanfordAIMI/SRR-BERT-Leaves": "cls",
113
+ }
114
+
115
+ def __init__(
116
+ self,
117
+ encoder_model_name: str = "pamessina/CXRFE",
118
+ extractor_model_name: str = "pamessina/T5FactExtractor",
119
+ device: Optional[str] = None,
120
+ batch_size: int = 128,
121
+ num_workers: int = 3,
122
+ verbose: bool = False,
123
+ use_cache: bool = True,
124
+ cache_dir: Optional[str] = None,
125
+ ):
126
+ """
127
+ Initialize the CXRFEScore metric.
128
+
129
+ Args:
130
+ encoder_model_name: Hugging Face encoder. Must be one of
131
+ ``SUPPORTED_MODELS``.
132
+ extractor_model_name: Hugging Face T5 fact extractor.
133
+ device: Device string (``'cuda'``, ``'cpu'``). Auto-detected if None.
134
+ batch_size: Default batch size for fact extraction / embeddings.
135
+ num_workers: DataLoader workers for fact extraction.
136
+ verbose: Enable logging and progress bars.
137
+ use_cache: Enable in-memory caching for facts/embeddings. Disk
138
+ persistence is **manual** via ``save_cache()`` (not automatic
139
+ after every batch).
140
+ cache_dir: Base cache directory. Defaults to the platform user
141
+ cache dir for ``cxrfescore``. Sentence→facts live under this
142
+ directory; fact embeddings live under
143
+ ``cache_dir/embeddings/<encoder_model_name>/`` so encoders
144
+ do not share embedding caches.
145
+ """
146
+ if encoder_model_name not in self.SUPPORTED_MODELS:
147
+ raise ValueError(
148
+ f"Unsupported model name: {encoder_model_name}. "
149
+ f"Please choose from: {self.SUPPORTED_MODELS}"
150
+ )
151
+
152
+ if cache_dir is None:
153
+ cache_dir = DEFAULT_CACHE_DIR
154
+
155
+ if use_cache and cache_dir is None:
156
+ raise ValueError("cache_dir must be provided if use_cache is True.")
157
+
158
+ ensure_nltk_resources()
159
+
160
+ self.device = (
161
+ torch.device(device)
162
+ if device
163
+ else torch.device("cuda" if torch.cuda.is_available() else "cpu")
164
+ )
165
+ self.encoder_model_name = encoder_model_name
166
+ self.extractor_model_name = extractor_model_name
167
+ self.encoder_backend = self.ENCODER_BACKEND[encoder_model_name]
168
+ self.encoder_max_length = (
169
+ SRR_BERT_LEAVES_MAX_LENGTH if self.encoder_backend == "cls" else None
170
+ )
171
+ self.default_batch_size = batch_size
172
+ self.default_num_workers = num_workers
173
+ self.verbose = verbose
174
+ self.use_cache = use_cache
175
+ self.cache_dir = cache_dir
176
+ self.facts_cache_dir = cache_dir
177
+ self.embed_cache_dir = os.path.join(
178
+ cache_dir, "embeddings", _safe_model_dirname(encoder_model_name)
179
+ )
180
+ self.embedding_dimension = self.MODEL_EMBEDDING_DIMENSIONS[
181
+ self.encoder_model_name
182
+ ]
183
+ self.sent_to_facts_cache = {}
184
+ self.fact_to_embedding_cache = {}
185
+ if self.use_cache:
186
+ self._load_cache()
187
+
188
+ if self.verbose:
189
+ logger.info(
190
+ f"Initializing CXRFEScore with encoder model: {self.encoder_model_name} and"
191
+ f" extractor model: {self.extractor_model_name}, use_cache: {self.use_cache},"
192
+ f" cache_dir: {self.cache_dir}, embed_cache_dir: {self.embed_cache_dir},"
193
+ f" default batch size: {self.default_batch_size},"
194
+ f" default num workers: {self.default_num_workers}."
195
+ )
196
+ logger.info(f"Using device: {self.device}")
197
+
198
+ self._load_encoder()
199
+
200
+ self.extractor_tokenizer = T5TokenizerFast.from_pretrained(
201
+ extractor_model_name
202
+ )
203
+ self.extractor_model = T5ForConditionalGeneration.from_pretrained(
204
+ extractor_model_name
205
+ )
206
+ self.extractor_model.to(self.device)
207
+ self.extractor_model.eval()
208
+
209
+ def _load_encoder(self):
210
+ """Load the configured fact encoder and its tokenizer."""
211
+ if self.encoder_backend == "projected":
212
+ self.encoder_tokenizer = AutoTokenizer.from_pretrained(
213
+ self.encoder_model_name, trust_remote_code=True
214
+ )
215
+ self.encoder_model = AutoModel.from_pretrained(
216
+ self.encoder_model_name, trust_remote_code=True
217
+ )
218
+ self.encoder_model.to(self.device)
219
+ self.encoder_model.eval()
220
+ return
221
+
222
+ if self.encoder_backend == "cls":
223
+ # Classification weights are unused; we only read BERT CLS states.
224
+ self.encoder_tokenizer = BertTokenizer.from_pretrained(
225
+ SRR_BERT_LEAVES_TOKENIZER
226
+ )
227
+ self.encoder_model = BertForSequenceClassification.from_pretrained(
228
+ self.encoder_model_name, num_labels=SRR_BERT_LEAVES_NUM_LABELS
229
+ )
230
+ self.encoder_model.to(self.device)
231
+ self.encoder_model.eval()
232
+ return
233
+
234
+ raise ValueError(f"Unknown encoder_backend: {self.encoder_backend}")
235
+
236
+ def _load_cache(self):
237
+ """Load fact and embedding caches from disk if they exist."""
238
+ facts_cache_path = os.path.join(self.facts_cache_dir, "sent_to_facts.pkl")
239
+ embed_cache_path = os.path.join(
240
+ self.embed_cache_dir, "fact_to_embedding.pkl"
241
+ )
242
+
243
+ if os.path.exists(facts_cache_path):
244
+ try:
245
+ with open(facts_cache_path, "rb") as f:
246
+ self.sent_to_facts_cache = pickle.load(f)
247
+ if self.verbose:
248
+ logger.info(
249
+ f"Loaded {len(self.sent_to_facts_cache)} sentence-to-fact "
250
+ f"mappings from {facts_cache_path}."
251
+ )
252
+ except (pickle.UnpicklingError, EOFError):
253
+ logger.warning(
254
+ f"Could not load sentence-to-fact mappings from {facts_cache_path}. "
255
+ "Starting with an empty cache."
256
+ )
257
+
258
+ if os.path.exists(embed_cache_path):
259
+ try:
260
+ with open(embed_cache_path, "rb") as f:
261
+ self.fact_to_embedding_cache = pickle.load(f)
262
+ if self.verbose:
263
+ logger.info(
264
+ f"Loaded {len(self.fact_to_embedding_cache)} fact embeddings "
265
+ f"from {embed_cache_path}."
266
+ )
267
+ except (pickle.UnpicklingError, EOFError):
268
+ logger.warning(
269
+ f"Could not load fact embeddings from {embed_cache_path}. "
270
+ "Starting with an empty cache."
271
+ )
272
+
273
+ def _save_cache_file(self, data: dict, directory: str, filename: str):
274
+ """Atomically save a cache dictionary to a pickle file."""
275
+ if not self.use_cache:
276
+ return
277
+
278
+ os.makedirs(directory, exist_ok=True)
279
+
280
+ final_path = os.path.join(directory, filename)
281
+ temp_path = final_path + ".tmp"
282
+
283
+ try:
284
+ with open(temp_path, "wb") as f:
285
+ pickle.dump(data, f)
286
+ os.replace(temp_path, final_path)
287
+ if self.verbose:
288
+ logger.info(f"Saved {len(data)} entries to {final_path}.")
289
+ except Exception as e:
290
+ logger.error(f"Failed to save cache to {final_path}: {e}")
291
+ if os.path.exists(temp_path):
292
+ os.remove(temp_path)
293
+
294
+ def _save_sent_to_facts_cache(self):
295
+ self._save_cache_file(
296
+ self.sent_to_facts_cache, self.facts_cache_dir, "sent_to_facts.pkl"
297
+ )
298
+
299
+ def _save_fact_to_embedding_cache(self):
300
+ self._save_cache_file(
301
+ self.fact_to_embedding_cache,
302
+ self.embed_cache_dir,
303
+ "fact_to_embedding.pkl",
304
+ )
305
+
306
+ def save_cache(self):
307
+ """
308
+ Persist in-memory caches to disk.
309
+
310
+ Call this explicitly after scoring a large batch (or at the end of a
311
+ job). Caches are updated in memory during ``compute`` /
312
+ ``extract_facts`` / ``embed_facts``, but are **not** written to disk
313
+ automatically.
314
+ """
315
+ self._save_sent_to_facts_cache()
316
+ self._save_fact_to_embedding_cache()
317
+
318
+ def inspect_cache(self, max_samples: int = 5, *, print_report: bool = True):
319
+ """Inspect on-disk caches for this metric's ``cache_dir``."""
320
+ from cxrfescore.cache_utils import inspect_cache
321
+
322
+ return inspect_cache(
323
+ self.cache_dir, max_samples=max_samples, print_report=print_report
324
+ )
325
+
326
+ @staticmethod
327
+ def _aggregate_facts(
328
+ report_sents: List[str], sent_to_facts_map: Dict[str, List[str]]
329
+ ) -> List[str]:
330
+ """Aggregate unique facts from a list of sentences."""
331
+ report_facts = []
332
+ seen_facts = set()
333
+ for sent in report_sents:
334
+ sent = sent.strip()
335
+ facts = sent_to_facts_map[sent]
336
+ if len(facts) == 0 and any(char.isalpha() for char in sent):
337
+ facts = [sent]
338
+ for fact in facts:
339
+ if fact not in seen_facts:
340
+ report_facts.append(fact)
341
+ seen_facts.add(fact)
342
+ return report_facts
343
+
344
+ def _extract_facts_batch(
345
+ self,
346
+ sentences: List[str],
347
+ batch_size: Optional[int] = None,
348
+ num_workers: Optional[int] = None,
349
+ ) -> List[List[str]]:
350
+ """Extract facts from a batch of sentences using the T5 fact extractor."""
351
+ if batch_size is None:
352
+ batch_size = self.default_batch_size
353
+ if num_workers is None:
354
+ num_workers = self.default_num_workers
355
+
356
+ if self.verbose:
357
+ logger.info(
358
+ f"Extracting facts from {len(sentences)} sentences in batches of "
359
+ f"{batch_size} with {num_workers} workers."
360
+ )
361
+
362
+ if self.use_cache:
363
+ output = [self.sent_to_facts_cache.get(sent, None) for sent in sentences]
364
+ missing_indices = [i for i in range(len(sentences)) if output[i] is None]
365
+ if not missing_indices:
366
+ if self.verbose:
367
+ logger.info(
368
+ f"All sentences found in cache. Returning {len(output)} "
369
+ "sentences-to-facts mappings."
370
+ )
371
+ return output
372
+ if self.verbose:
373
+ logger.info(
374
+ f"Cache miss for {len(missing_indices)}/{len(sentences)} sentences."
375
+ )
376
+ sentences_to_process = [sentences[i] for i in missing_indices]
377
+ else:
378
+ sentences_to_process = sentences
379
+
380
+ _, dataloader = create_text_dataset_and_dataloader(
381
+ texts=sentences_to_process,
382
+ batch_size=batch_size,
383
+ num_workers=num_workers,
384
+ tokenizer_func=lambda x: self.extractor_tokenizer(
385
+ x, padding="longest", return_tensors="pt"
386
+ ),
387
+ )
388
+
389
+ new_facts = [None] * len(sentences_to_process)
390
+ offset = 0
391
+ with torch.no_grad():
392
+ iterator = (
393
+ tqdm(dataloader, total=len(dataloader), mininterval=2)
394
+ if self.verbose
395
+ else dataloader
396
+ )
397
+ for batch in iterator:
398
+ encoding = batch["encoding"]
399
+ input_ids = encoding["input_ids"].to(self.device)
400
+ attention_mask = encoding["attention_mask"].to(self.device)
401
+ max_len = input_ids.shape[1] * 4
402
+ output_ids = self.extractor_model.generate(
403
+ input_ids=input_ids,
404
+ attention_mask=attention_mask,
405
+ max_new_tokens=max_len,
406
+ num_beams=1,
407
+ )
408
+ output_texts_batch = self.extractor_tokenizer.batch_decode(
409
+ output_ids, skip_special_tokens=True
410
+ )
411
+ for i, output_text in enumerate(output_texts_batch):
412
+ facts = parse_facts(output_text)
413
+ facts = [
414
+ remove_consecutive_repeated_words_from_text(f) for f in facts
415
+ ]
416
+ new_facts[offset + i] = facts
417
+ offset += len(output_texts_batch)
418
+ assert offset == len(sentences_to_process)
419
+ assert None not in new_facts
420
+
421
+ if self.use_cache:
422
+ for i, orig_idx in enumerate(missing_indices):
423
+ output[orig_idx] = new_facts[i]
424
+ self.sent_to_facts_cache.update(
425
+ {
426
+ sent: facts
427
+ for sent, facts in zip(sentences_to_process, new_facts)
428
+ }
429
+ )
430
+ else:
431
+ output = new_facts
432
+ assert None not in output
433
+ return output
434
+
435
+ def _get_embeddings_batch(
436
+ self,
437
+ texts: List[str],
438
+ batch_size: Optional[int] = None,
439
+ ) -> np.ndarray:
440
+ """Generate embeddings for a list of texts in batches."""
441
+ if batch_size is None:
442
+ batch_size = self.default_batch_size
443
+
444
+ if self.verbose:
445
+ logger.info(
446
+ f"Generating embeddings for {len(texts)} texts in batches of {batch_size}."
447
+ )
448
+
449
+ if not texts:
450
+ return np.array([])
451
+
452
+ if self.use_cache:
453
+ text_embeddings = np.empty(
454
+ (len(texts), self.embedding_dimension), dtype=np.float32
455
+ )
456
+ missing_indices = []
457
+ for i, text in enumerate(texts):
458
+ embedding = self.fact_to_embedding_cache.get(text, None)
459
+ if embedding is None:
460
+ missing_indices.append(i)
461
+ else:
462
+ text_embeddings[i] = embedding
463
+ if not missing_indices:
464
+ if self.verbose:
465
+ logger.info(
466
+ f"All texts found in cache. Returning {len(text_embeddings)} embeddings."
467
+ )
468
+ return text_embeddings
469
+ if self.verbose:
470
+ logger.info(f"Cache miss for {len(missing_indices)} texts.")
471
+ texts_to_process = [texts[i] for i in missing_indices]
472
+ else:
473
+ texts_to_process = texts
474
+
475
+ self.encoder_model.eval()
476
+
477
+ new_embeddings = []
478
+ iterator = (
479
+ tqdm(
480
+ range(0, len(texts_to_process), batch_size),
481
+ desc="Generating embeddings",
482
+ )
483
+ if self.verbose
484
+ else range(0, len(texts_to_process), batch_size)
485
+ )
486
+
487
+ with torch.no_grad():
488
+ for i in iterator:
489
+ batch_texts = texts_to_process[i : i + batch_size]
490
+ if self.encoder_backend == "projected":
491
+ # Prefer tokenizer __call__ (batch_encode_plus is missing on some
492
+ # custom remote-code tokenizers under newer transformers).
493
+ inputs = self.encoder_tokenizer(
494
+ batch_texts,
495
+ add_special_tokens=True,
496
+ padding="longest",
497
+ return_tensors="pt",
498
+ )
499
+ input_ids = inputs["input_ids"].to(self.device)
500
+ attention_mask = inputs["attention_mask"].to(self.device)
501
+ batch_embeddings = self.encoder_model.get_projected_text_embeddings(
502
+ input_ids=input_ids, attention_mask=attention_mask
503
+ )
504
+ elif self.encoder_backend == "cls":
505
+ inputs = self.encoder_tokenizer(
506
+ batch_texts,
507
+ padding="longest",
508
+ truncation=True,
509
+ max_length=self.encoder_max_length,
510
+ return_tensors="pt",
511
+ return_attention_mask=True,
512
+ )
513
+ input_ids = inputs["input_ids"].to(self.device)
514
+ attention_mask = inputs["attention_mask"].to(self.device)
515
+ bert_out = self.encoder_model.bert(
516
+ input_ids, attention_mask=attention_mask, return_dict=True
517
+ )
518
+ batch_embeddings = bert_out.last_hidden_state[:, 0, :]
519
+ else:
520
+ raise ValueError(
521
+ f"Unknown encoder_backend: {self.encoder_backend}"
522
+ )
523
+ new_embeddings.append(batch_embeddings.cpu().numpy())
524
+
525
+ new_embeddings = np.concatenate(new_embeddings, axis=0)
526
+
527
+ if self.use_cache:
528
+ text_embeddings[missing_indices] = new_embeddings
529
+ self.fact_to_embedding_cache.update(
530
+ {
531
+ text: embedding
532
+ for text, embedding in zip(texts_to_process, new_embeddings)
533
+ }
534
+ )
535
+ return text_embeddings
536
+ return new_embeddings
537
+
538
+ def extract_facts(
539
+ self,
540
+ reports: List[str],
541
+ batch_size: Optional[int] = None,
542
+ num_workers: Optional[int] = None,
543
+ ) -> List[List[str]]:
544
+ """
545
+ Extract unique facts from full radiology reports.
546
+
547
+ Each input string is treated as a **full report** (not a single
548
+ sentence): it is sentence-split with NLTK, facts are extracted per
549
+ sentence with the T5 fact extractor, then unique facts are aggregated
550
+ in report order — the same pipeline used by ``compute``.
551
+
552
+ Args:
553
+ reports: List of full report strings.
554
+ batch_size: Batch size for the fact extractor.
555
+ num_workers: DataLoader workers for fact extraction.
556
+
557
+ Returns:
558
+ A list of fact lists, one per input report.
559
+ """
560
+ if self.verbose:
561
+ logger.info(
562
+ f"extract_facts: treating {len(reports)} inputs as full reports "
563
+ "(sentence-split → extract → aggregate)."
564
+ )
565
+
566
+ sents_per_report = [sent_tokenize(report) for report in reports]
567
+
568
+ all_unique_sents = set()
569
+ for report_sents in sents_per_report:
570
+ all_unique_sents.update(s.strip() for s in report_sents if s.strip())
571
+ all_unique_sents_list = list(all_unique_sents)
572
+
573
+ facts_per_unique_sent = self._extract_facts_batch(
574
+ all_unique_sents_list, batch_size=batch_size, num_workers=num_workers
575
+ )
576
+ sent_to_facts = {
577
+ sent: facts
578
+ for sent, facts in zip(all_unique_sents_list, facts_per_unique_sent)
579
+ }
580
+ return [
581
+ self._aggregate_facts(sents, sent_to_facts) for sents in sents_per_report
582
+ ]
583
+
584
+ def embed_facts(
585
+ self,
586
+ facts: List[str],
587
+ batch_size: Optional[int] = None,
588
+ ) -> np.ndarray:
589
+ """
590
+ Embed a list of fact strings with the configured encoder.
591
+
592
+ Args:
593
+ facts: Fact strings (already extracted; not full reports).
594
+ batch_size: Batch size for embedding.
595
+
596
+ Returns:
597
+ Array of shape ``(len(facts), embedding_dimension)``.
598
+ """
599
+ return self._get_embeddings_batch(facts, batch_size=batch_size)
600
+
601
+ def compute(
602
+ self,
603
+ hyps: List[str],
604
+ refs: List[str],
605
+ batch_size: Optional[int] = None,
606
+ num_workers: Optional[int] = None,
607
+ ) -> Dict[str, Union[float, np.ndarray]]:
608
+ """
609
+ Compute fact-level similarity between hypothesis and reference reports.
610
+
611
+ Returns:
612
+ Dict with ``mean_similarity`` and ``per_pair_similarity``.
613
+ """
614
+ if len(hyps) != len(refs):
615
+ raise ValueError(
616
+ "The number of hypothesis and reference reports must be the same."
617
+ )
618
+
619
+ if self.verbose:
620
+ logger.info("Splitting reports into sentences...")
621
+ hyps_sents_per_report = [sent_tokenize(report) for report in hyps]
622
+ refs_sents_per_report = [sent_tokenize(report) for report in refs]
623
+
624
+ all_unique_sents = set()
625
+ for report_sents in hyps_sents_per_report:
626
+ all_unique_sents.update(s.strip() for s in report_sents if s.strip())
627
+ for report_sents in refs_sents_per_report:
628
+ all_unique_sents.update(s.strip() for s in report_sents if s.strip())
629
+ all_unique_sents_list = list(all_unique_sents)
630
+
631
+ if self.verbose:
632
+ logger.info(
633
+ f"Found {len(all_unique_sents_list)} unique sentences. Extracting facts..."
634
+ )
635
+
636
+ facts_per_unique_sent = self._extract_facts_batch(
637
+ all_unique_sents_list, batch_size=batch_size, num_workers=num_workers
638
+ )
639
+ sent_to_facts = {
640
+ sent: facts
641
+ for sent, facts in zip(all_unique_sents_list, facts_per_unique_sent)
642
+ }
643
+
644
+ hyps_facts = [
645
+ self._aggregate_facts(sents, sent_to_facts)
646
+ for sents in hyps_sents_per_report
647
+ ]
648
+ refs_facts = [
649
+ self._aggregate_facts(sents, sent_to_facts)
650
+ for sents in refs_sents_per_report
651
+ ]
652
+
653
+ unique_facts = set()
654
+ for facts in hyps_facts:
655
+ unique_facts.update(facts)
656
+ for facts in refs_facts:
657
+ unique_facts.update(facts)
658
+ unique_facts = list(unique_facts)
659
+
660
+ if self.verbose:
661
+ logger.info(f"Found {len(unique_facts)} unique facts.")
662
+
663
+ if not unique_facts:
664
+ return {
665
+ "mean_similarity": 0.0,
666
+ "per_pair_similarity": np.zeros(len(hyps), dtype=np.float32),
667
+ }
668
+
669
+ unique_embeddings = self._get_embeddings_batch(
670
+ unique_facts, batch_size=batch_size
671
+ )
672
+ fact_to_embedding_idx = {fact: i for i, fact in enumerate(unique_facts)}
673
+
674
+ per_pair_similarity = []
675
+ pair_iterator = (
676
+ tqdm(
677
+ zip(hyps_facts, refs_facts),
678
+ total=len(hyps),
679
+ desc="Computing pair-wise similarity",
680
+ )
681
+ if self.verbose
682
+ else zip(hyps_facts, refs_facts)
683
+ )
684
+
685
+ for hyp_facts, ref_facts in pair_iterator:
686
+ hyp_facts = [f.strip() for f in hyp_facts if f.strip()]
687
+ ref_facts = [f.strip() for f in ref_facts if f.strip()]
688
+
689
+ if not hyp_facts or not ref_facts:
690
+ per_pair_similarity.append(0.0)
691
+ continue
692
+
693
+ hyp_indices = [fact_to_embedding_idx[f] for f in hyp_facts]
694
+ ref_indices = [fact_to_embedding_idx[f] for f in ref_facts]
695
+
696
+ hyp_embeddings = unique_embeddings[hyp_indices]
697
+ ref_embeddings = unique_embeddings[ref_indices]
698
+
699
+ similarity_matrix = cosine_similarity(hyp_embeddings, ref_embeddings)
700
+
701
+ mean_row_max = np.mean(np.max(similarity_matrix, axis=1))
702
+ mean_col_max = np.mean(np.max(similarity_matrix, axis=0))
703
+
704
+ pair_score = (mean_row_max + mean_col_max) / 2.0
705
+ per_pair_similarity.append(pair_score)
706
+
707
+ per_pair_similarity_np = np.array(per_pair_similarity, dtype=np.float32)
708
+ mean_similarity = np.mean(per_pair_similarity_np).item()
709
+
710
+ if self.verbose:
711
+ logger.info("Similarity calculation complete.")
712
+
713
+ return {
714
+ "mean_similarity": mean_similarity,
715
+ "per_pair_similarity": per_pair_similarity_np,
716
+ }
717
+
718
+ def __call__(
719
+ self,
720
+ hyps: List[str],
721
+ refs: List[str],
722
+ batch_size: Optional[int] = None,
723
+ num_workers: Optional[int] = None,
724
+ ) -> Dict[str, Union[float, np.ndarray]]:
725
+ return self.compute(
726
+ hyps=hyps, refs=refs, batch_size=batch_size, num_workers=num_workers
727
+ )
728
+
729
+ def visualize_fact_similarity(
730
+ self,
731
+ ref_report: str,
732
+ cand_report: str,
733
+ figsize: Tuple[int, int] = (10, 8),
734
+ fontsize: int = 10,
735
+ wrap_width: int = 40,
736
+ ):
737
+ """
738
+ Visualize fact-level cosine similarity between a reference and candidate.
739
+
740
+ Requires the optional ``viz`` extra: ``pip install cxrfescore[viz]``.
741
+ """
742
+ try:
743
+ import matplotlib.pyplot as plt
744
+ from matplotlib.colors import LinearSegmentedColormap
745
+ from matplotlib.patches import Rectangle
746
+ except ImportError as e:
747
+ raise ImportError(
748
+ "Visualization requires matplotlib. Install with: "
749
+ "pip install cxrfescore[viz]"
750
+ ) from e
751
+
752
+ ref_sents = [s.strip() for s in sent_tokenize(ref_report) if s.strip()]
753
+ cand_sents = [s.strip() for s in sent_tokenize(cand_report) if s.strip()]
754
+
755
+ ref_facts = self._extract_facts_batch(ref_sents)
756
+ cand_facts = self._extract_facts_batch(cand_sents)
757
+
758
+ ref_facts = self._aggregate_facts(
759
+ ref_sents, {sent: facts for sent, facts in zip(ref_sents, ref_facts)}
760
+ )
761
+ cand_facts = self._aggregate_facts(
762
+ cand_sents, {sent: facts for sent, facts in zip(cand_sents, cand_facts)}
763
+ )
764
+
765
+ print("--- Candidate Report (Y-axis) ---")
766
+ print(cand_report)
767
+ print("\n--- Extracted Candidate Facts ---")
768
+ for fact in cand_facts:
769
+ print(f"- {fact}")
770
+
771
+ print("\n--- Reference Report (X-axis) ---")
772
+ print(ref_report)
773
+ print("\n--- Extracted Reference Facts ---")
774
+ for fact in ref_facts:
775
+ print(f"- {fact}")
776
+
777
+ if not ref_facts or not cand_facts:
778
+ print(
779
+ "\nCould not generate visualization: One or both reports have no facts."
780
+ )
781
+ return
782
+
783
+ ref_embeddings = self._get_embeddings_batch(ref_facts)
784
+ cand_embeddings = self._get_embeddings_batch(cand_facts)
785
+
786
+ sim_matrix = cosine_similarity(cand_embeddings, ref_embeddings)
787
+
788
+ wrapped_ref_labels = [textwrap.fill(s, width=wrap_width) for s in ref_facts]
789
+ wrapped_cand_labels = [textwrap.fill(s, width=wrap_width) for s in cand_facts]
790
+
791
+ fig, ax = plt.subplots(figsize=figsize)
792
+
793
+ colors = [
794
+ (1.0, 0.0, 0.0),
795
+ (1.0, 0.5, 0.0),
796
+ (1.0, 1.0, 1.0),
797
+ (0.0, 0.5, 1.0),
798
+ (0.0, 0.0, 1.0),
799
+ ]
800
+ positions = [0.0, 0.25, 0.5, 0.75, 1.0]
801
+ custom_cmap = LinearSegmentedColormap.from_list(
802
+ "red_white_blue", list(zip(positions, colors))
803
+ )
804
+
805
+ im = ax.imshow(sim_matrix, cmap=custom_cmap, vmin=-1, vmax=1)
806
+
807
+ cbar = fig.colorbar(im, ax=ax)
808
+ cbar.ax.set_ylabel("Cosine Similarity", rotation=-90, va="bottom")
809
+
810
+ ax.set_xticks(np.arange(len(wrapped_ref_labels)))
811
+ ax.set_yticks(np.arange(len(wrapped_cand_labels)))
812
+ ax.set_xticklabels(wrapped_ref_labels, rotation=90)
813
+ ax.set_yticklabels(wrapped_cand_labels)
814
+
815
+ ax.set_xlabel("Reference Facts", fontweight="bold")
816
+ ax.set_ylabel("Candidate Facts", fontweight="bold")
817
+ ax.set_title("Fact Similarity Matrix", fontweight="bold", fontsize=14)
818
+
819
+ for i in range(len(cand_facts)):
820
+ for j in range(len(ref_facts)):
821
+ color = "black" if -0.5 <= sim_matrix[i, j] <= 0.5 else "white"
822
+ ax.text(
823
+ j,
824
+ i,
825
+ f"{sim_matrix[i, j]:.2f}",
826
+ ha="center",
827
+ va="center",
828
+ color=color,
829
+ fontsize=fontsize,
830
+ )
831
+
832
+ highlight_cells = set()
833
+ for i in range(sim_matrix.shape[0]):
834
+ max_val = np.max(sim_matrix[i, :])
835
+ max_cols = np.where(sim_matrix[i, :] == max_val)[0]
836
+ for j in max_cols:
837
+ highlight_cells.add((i, j))
838
+ for j in range(sim_matrix.shape[1]):
839
+ max_val = np.max(sim_matrix[:, j])
840
+ max_rows = np.where(sim_matrix[:, j] == max_val)[0]
841
+ for i in max_rows:
842
+ highlight_cells.add((i, j))
843
+ for i, j in highlight_cells:
844
+ ax.add_patch(
845
+ Rectangle(
846
+ (j - 0.5, i - 0.5),
847
+ 1,
848
+ 1,
849
+ fill=False,
850
+ edgecolor="lightgreen",
851
+ lw=3,
852
+ )
853
+ )
854
+
855
+ fig.tight_layout()
856
+ plt.show()
857
+
858
+ if sim_matrix.size > 0:
859
+ mean_row_max = np.mean(np.max(sim_matrix, axis=1))
860
+ mean_col_max = np.mean(np.max(sim_matrix, axis=0))
861
+ pair_score = (mean_row_max + mean_col_max) / 2.0
862
+ print(f"\nCXRFEScore for this pair: {pair_score:.4f}")
863
+ else:
864
+ print("\nCXRFEScore for this pair: 0.0")
@@ -0,0 +1,29 @@
1
+ """Ensure required NLTK tokenizers are available."""
2
+
3
+ import nltk
4
+
5
+ REQUIRED_RESOURCES = ["punkt", "punkt_tab"]
6
+
7
+
8
+ def ensure_nltk_resources():
9
+ """Download missing NLTK resources needed for sentence tokenization."""
10
+ missing_resources = []
11
+ for resource in REQUIRED_RESOURCES:
12
+ try:
13
+ nltk.data.find(f"tokenizers/{resource}")
14
+ except LookupError:
15
+ missing_resources.append(resource)
16
+
17
+ if not missing_resources:
18
+ return
19
+
20
+ print(f"Downloading missing NLTK resources: {missing_resources}...")
21
+ try:
22
+ for resource in missing_resources:
23
+ nltk.download(resource, quiet=True)
24
+ for resource in missing_resources:
25
+ nltk.data.find(f"tokenizers/{resource}")
26
+ print("NLTK resources downloaded successfully.")
27
+ except Exception as e:
28
+ print(f"Error downloading NLTK data: {e}")
29
+ print(f" python -m nltk.downloader {' '.join(missing_resources)}")
@@ -0,0 +1,71 @@
1
+ """Helpers for parsing and cleaning extracted radiology facts."""
2
+
3
+ import json
4
+ import re
5
+
6
+ _COMMA_SEPARATED_LIST_REGEX = re.compile(r'\[\s*(\".+?\"(\s*,\s*\".+?\")*)?\s*\]?')
7
+
8
+
9
+ def parse_facts(txt: str) -> list:
10
+ """Parse a JSON-like list of facts from T5 fact-extractor output."""
11
+ match = _COMMA_SEPARATED_LIST_REGEX.search(txt)
12
+ if match is None:
13
+ return []
14
+ facts_str = match.group()
15
+ if facts_str[-1] != "]":
16
+ facts_str += "]"
17
+ facts = json.loads(facts_str)
18
+ seen = set()
19
+ clean_facts = []
20
+ for fact in facts:
21
+ if fact not in seen:
22
+ clean_facts.append(fact)
23
+ seen.add(fact)
24
+ return clean_facts
25
+
26
+
27
+ def _substrings_are_equal(text, i, j, k):
28
+ for x in range(k):
29
+ if text[i + x] != text[j + x]:
30
+ return False
31
+ return True
32
+
33
+
34
+ def remove_consecutive_repeated_words_from_text(text, ks=None):
35
+ """Remove consecutive repeated word n-grams from text."""
36
+ if ks is None:
37
+ ks = [1, 2, 3, 4, 5, 6, 7, 8]
38
+ assert type(ks) is int or type(ks) is list
39
+ if type(ks) is int:
40
+ ks = [ks]
41
+ else:
42
+ assert len(ks) > 0
43
+ assert all(type(x) is int for x in ks)
44
+
45
+ tokens = text.split()
46
+ lower_tokens = text.lower().split()
47
+ dedup_tokens = []
48
+ dedup_lower_tokens = []
49
+
50
+ for k in ks:
51
+ for i in range(len(lower_tokens)):
52
+ skip = False
53
+ for j in range(k):
54
+ s = i - j
55
+ e = s + k - 1
56
+ if (
57
+ s - k >= 0
58
+ and e < len(lower_tokens)
59
+ and _substrings_are_equal(lower_tokens, s, s - k, k)
60
+ ):
61
+ skip = True
62
+ break
63
+ if skip:
64
+ continue
65
+ dedup_tokens.append(tokens[i])
66
+ dedup_lower_tokens.append(lower_tokens[i])
67
+ tokens = dedup_tokens
68
+ lower_tokens = dedup_lower_tokens
69
+ dedup_tokens = []
70
+ dedup_lower_tokens = []
71
+ return " ".join(tokens)
@@ -0,0 +1,237 @@
1
+ Metadata-Version: 2.4
2
+ Name: cxrfescore
3
+ Version: 0.2.2
4
+ Summary: Fact-level embedding metric for evaluating chest X-ray radiology report generation.
5
+ Project-URL: Homepage, https://github.com/PabloMessina/CXRFEScore
6
+ Project-URL: Bug Tracker, https://github.com/PabloMessina/CXRFEScore/issues
7
+ Project-URL: Paper, https://aclanthology.org/2024.findings-acl.236/
8
+ Author-email: Pablo Messina <pablo.messina.g@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2024 Pablo Messina
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Classifier: Intended Audience :: Science/Research
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: OS Independent
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
36
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
37
+ Requires-Python: >=3.10
38
+ Requires-Dist: nltk>=3.8.0
39
+ Requires-Dist: numpy>=1.24.0
40
+ Requires-Dist: platformdirs>=4.0.0
41
+ Requires-Dist: scikit-learn>=1.3.0
42
+ Requires-Dist: torch>=2.0.0
43
+ Requires-Dist: tqdm>=4.65.0
44
+ Requires-Dist: transformers>=4.40.0
45
+ Provides-Extra: dev
46
+ Requires-Dist: build>=1.0.0; extra == 'dev'
47
+ Requires-Dist: matplotlib>=3.7.0; extra == 'dev'
48
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
49
+ Requires-Dist: twine>=5.0.0; extra == 'dev'
50
+ Provides-Extra: viz
51
+ Requires-Dist: matplotlib>=3.7.0; extra == 'viz'
52
+ Description-Content-Type: text/markdown
53
+
54
+ # CXRFEScore
55
+
56
+ Fact-level embedding metric for evaluating chest X-ray radiology report generation.
57
+
58
+ CXRFEScore extracts factual statements from reports with a T5 fact extractor, embeds them with CXRFE (or compatible CXR-BERT models), and scores hypothesis/reference pairs via soft bipartite matching of fact embeddings.
59
+
60
+ Based on:
61
+
62
+ > Messina et al., *Extracting and Encoding: Leveraging Large Language Models and Medical Knowledge to Enhance Radiological Text Representation*, Findings of ACL 2024.
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ pip install cxrfescore
68
+ ```
69
+
70
+ Optional visualization support:
71
+
72
+ ```bash
73
+ pip install cxrfescore[viz]
74
+ ```
75
+
76
+ **Python:** 3.10+
77
+
78
+ ### Runtime dependencies (installed automatically)
79
+
80
+ | Package | Role |
81
+ |---|---|
82
+ | `torch` | Model inference |
83
+ | `transformers` | Load Hugging Face models |
84
+ | `numpy` | Arrays / aggregation |
85
+ | `scikit-learn` | Cosine similarity |
86
+ | `nltk` | Sentence splitting |
87
+ | `tqdm` | Progress bars |
88
+ | `platformdirs` | Default cache directory |
89
+
90
+ Optional: `matplotlib` via `cxrfescore[viz]` for `visualize_fact_similarity`.
91
+
92
+ ### Additional requirements (not pip packages)
93
+
94
+ On first use the metric downloads:
95
+
96
+ - **Encoder (default):** [`pamessina/CXRFE`](https://huggingface.co/pamessina/CXRFE)
97
+ - **Fact extractor:** [`pamessina/T5FactExtractor`](https://huggingface.co/pamessina/T5FactExtractor)
98
+ - **NLTK** `punkt` / `punkt_tab` (auto-downloaded)
99
+
100
+ Supported alternate encoders:
101
+
102
+ - `microsoft/BiomedVLP-CXR-BERT-specialized`
103
+ - `microsoft/BiomedVLP-BioViL-T`
104
+ - `StanfordAIMI/SRR-BERT-Leaves` (CLS embeddings only; label head unused)
105
+
106
+ A GPU is recommended but not required. CPU works and is slower.
107
+
108
+ Known-good stack from development: PyTorch 2.x + `transformers` 4.x / 5.x.
109
+
110
+ ## Quick start
111
+
112
+ ```python
113
+ from cxrfescore import CXRFEScore
114
+
115
+ metric = CXRFEScore(device="cuda") # or "cpu"
116
+
117
+ hyps = [
118
+ "There is a small right pleural effusion. The heart size is normal.",
119
+ ]
120
+ refs = [
121
+ "Small right pleural effusion. Normal heart size.",
122
+ ]
123
+
124
+ result = metric(hyps, refs)
125
+ print(result["mean_similarity"])
126
+ print(result["per_pair_similarity"])
127
+ ```
128
+
129
+ ### Extract facts or embeddings only
130
+
131
+ `extract_facts` treats each input string as a **full report**: sentence-split → T5 fact extraction → unique-fact aggregation (same pipeline as scoring).
132
+
133
+ ```python
134
+ facts_per_report = metric.extract_facts(hyps)
135
+ embeddings = metric.embed_facts(facts_per_report[0]) # shape (num_facts, dim)
136
+ ```
137
+
138
+ ### Caching
139
+
140
+ With `use_cache=True` (default), results are kept **in memory** during the session:
141
+
142
+ - Sentence→facts: `{cache_dir}/sent_to_facts.pkl` (shared across encoders)
143
+ - Fact→embedding: `{cache_dir}/embeddings/<encoder_name>/fact_to_embedding.pkl` (per encoder)
144
+
145
+ Disk writes are **not** automatic. Call `save_cache()` explicitly after a large batch or at the end of a job:
146
+
147
+ ```python
148
+ metric = CXRFEScore(use_cache=True, cache_dir="/path/to/cache")
149
+ # ... score / extract / embed ...
150
+ metric.save_cache()
151
+ ```
152
+
153
+ Disable with `use_cache=False`.
154
+
155
+ Inspect what is already on disk:
156
+
157
+ ```python
158
+ from cxrfescore import inspect_cache
159
+
160
+ inspect_cache() # default cache location
161
+ # or: metric.inspect_cache() # uses this instance's cache_dir
162
+ ```
163
+
164
+ ### Visualization
165
+
166
+ ```python
167
+ # requires: pip install cxrfescore[viz]
168
+ metric.visualize_fact_similarity(ref_report=refs[0], cand_report=hyps[0])
169
+ ```
170
+
171
+ ## Examples
172
+
173
+ | File | Purpose |
174
+ |---|---|
175
+ | [`examples/colab_smoke.md`](examples/colab_smoke.md) | Full Colab smoke + adversarial pairs |
176
+ | [`examples/minimal_cxrfe.md`](examples/minimal_cxrfe.md) | Default CXRFE encoder |
177
+ | [`examples/minimal_cxr_bert_specialized.md`](examples/minimal_cxr_bert_specialized.md) | CXR-BERT specialized |
178
+ | [`examples/minimal_biovil_t.md`](examples/minimal_biovil_t.md) | BioViL-T |
179
+ | [`examples/minimal_srr_bert_leaves.md`](examples/minimal_srr_bert_leaves.md) | SRR-BERT-Leaves CLS |
180
+ | [`examples/minimal_extract_embed.md`](examples/minimal_extract_embed.md) | `extract_facts` / `embed_facts` |
181
+
182
+ ## Try it on Google Colab
183
+
184
+ After publishing, install in Colab (GPU runtime recommended):
185
+
186
+ ```python
187
+ !pip install cxrfescore
188
+ ```
189
+
190
+ For a pre-release check against TestPyPI:
191
+
192
+ ```python
193
+ !pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ "cxrfescore==0.2.2"
194
+ ```
195
+
196
+ ## Citation
197
+
198
+ If you use CXRFEScore, please cite:
199
+
200
+ > Pablo Messina, Rene Vidal, Denis Parra, Alvaro Soto, and Vladimir Araujo. 2024. [Extracting and Encoding: Leveraging Large Language Models and Medical Knowledge to Enhance Radiological Text Representation](https://aclanthology.org/2024.findings-acl.236/). In *Findings of the Association for Computational Linguistics: ACL 2024*, pages 3955–3986, Bangkok, Thailand. Association for Computational Linguistics.
201
+
202
+ ```bibtex
203
+ @inproceedings{messina-etal-2024-extracting,
204
+ title = "Extracting and Encoding: Leveraging Large Language Models and Medical Knowledge to Enhance Radiological Text Representation",
205
+ author = "Messina, Pablo and
206
+ Vidal, Rene and
207
+ Parra, Denis and
208
+ Soto, Alvaro and
209
+ Araujo, Vladimir",
210
+ editor = "Ku, Lun-Wei and
211
+ Martins, Andre and
212
+ Srikumar, Vivek",
213
+ booktitle = "Findings of the Association for Computational Linguistics: ACL 2024",
214
+ month = aug,
215
+ year = "2024",
216
+ address = "Bangkok, Thailand",
217
+ publisher = "Association for Computational Linguistics",
218
+ url = "https://aclanthology.org/2024.findings-acl.236/",
219
+ doi = "10.18653/v1/2024.findings-acl.236",
220
+ pages = "3955--3986"
221
+ }
222
+ ```
223
+
224
+ Paper: [ACL Anthology](https://aclanthology.org/2024.findings-acl.236/) · [PDF](https://aclanthology.org/2024.findings-acl.236.pdf) · [arXiv](https://arxiv.org/abs/2407.01948)
225
+
226
+ ## Development
227
+
228
+ ```bash
229
+ pip install -e ".[dev]"
230
+ pytest # unit + mocked tests (default)
231
+ pytest -m integration # downloads HF models; needs network / GPU optional
232
+ python -m build
233
+ ```
234
+
235
+ ## License
236
+
237
+ MIT
@@ -0,0 +1,9 @@
1
+ cxrfescore/__init__.py,sha256=tsUqqaP-lTvptQGvxbGkEJUDZX9WUNAF9iz387GLJZM,237
2
+ cxrfescore/cache_utils.py,sha256=CRvKcgYsvpI_dH9Qjc8eORj3clWXjgSrYdChi0jiw-I,4303
3
+ cxrfescore/metric.py,sha256=7AtCjufG-QmJIV3pCcHy1-g1nHIhDpnwdERaZQItRs4,31831
4
+ cxrfescore/nltk_setup.py,sha256=8fi6taw7FvSk-6RojT_tW9Zv5PFli0juaSGt6kOXaFg,953
5
+ cxrfescore/text_utils.py,sha256=KQHdb_S_D9OcTgKn23QXtwihVjgDF9zRkqsbZE1jCfQ,2016
6
+ cxrfescore-0.2.2.dist-info/METADATA,sha256=F88uNfIXgdgCUVROmMvaBeEdbfjwPpB3mQu1yZkJoKU,8440
7
+ cxrfescore-0.2.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ cxrfescore-0.2.2.dist-info/licenses/LICENSE,sha256=pX4oAsWen33iCK5RLpf1DLrrCpVUlDw8EwjjRXRj2Q8,1070
9
+ cxrfescore-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Pablo Messina
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.