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 +7 -0
- cxrfescore/cache_utils.py +129 -0
- cxrfescore/metric.py +864 -0
- cxrfescore/nltk_setup.py +29 -0
- cxrfescore/text_utils.py +71 -0
- cxrfescore-0.2.2.dist-info/METADATA +237 -0
- cxrfescore-0.2.2.dist-info/RECORD +9 -0
- cxrfescore-0.2.2.dist-info/WHEEL +4 -0
- cxrfescore-0.2.2.dist-info/licenses/LICENSE +21 -0
cxrfescore/__init__.py
ADDED
|
@@ -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")
|
cxrfescore/nltk_setup.py
ADDED
|
@@ -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)}")
|
cxrfescore/text_utils.py
ADDED
|
@@ -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,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.
|