granuscore 1.0.0__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.
- granuscore/__init__.py +13 -0
- granuscore/artifcats.py +116 -0
- granuscore/bucket_output.py +53 -0
- granuscore/cache.py +189 -0
- granuscore/claim_splitter.py +300 -0
- granuscore/default_assets.py +38 -0
- granuscore/granularity_predictor.py +796 -0
- granuscore/loader.py +118 -0
- granuscore/pipeline.py +562 -0
- granuscore/text_cleaner.py +61 -0
- granuscore/utils.py +48 -0
- granuscore-1.0.0.dist-info/METADATA +242 -0
- granuscore-1.0.0.dist-info/RECORD +16 -0
- granuscore-1.0.0.dist-info/WHEEL +5 -0
- granuscore-1.0.0.dist-info/licenses/LICENSE +21 -0
- granuscore-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Literal, Any, get_args
|
|
7
|
+
|
|
8
|
+
import lightgbm as lgb
|
|
9
|
+
import faiss
|
|
10
|
+
import numpy as np
|
|
11
|
+
import torch
|
|
12
|
+
from hierarchy_transformers import HierarchyTransformer
|
|
13
|
+
from numpy.random.mtrand import Sequence
|
|
14
|
+
from openai import OpenAI
|
|
15
|
+
from sentence_transformers import SentenceTransformer
|
|
16
|
+
from tqdm import tqdm
|
|
17
|
+
from nltk.corpus import wordnet as wn
|
|
18
|
+
|
|
19
|
+
from granuscore.artifcats import ArtifactManager
|
|
20
|
+
from granuscore.bucket_output import PercentileOutput
|
|
21
|
+
from granuscore.cache import GranuscoreCache
|
|
22
|
+
from granuscore.default_assets import DEFAULT_LGB_MODEL, DEFAULT_ANCHORS, DEFAULT_FAISS_INDEX, DEFAULT_NOUN_SCORES, \
|
|
23
|
+
DEFAULT_ORIGINAL_VECTORS
|
|
24
|
+
from granuscore.utils import get_best_device, iter_batches, map_with_progress
|
|
25
|
+
|
|
26
|
+
_FAISS_THREADING_CONFIGURED = False
|
|
27
|
+
|
|
28
|
+
def configure_faiss_threading():
|
|
29
|
+
global _FAISS_THREADING_CONFIGURED
|
|
30
|
+
if _FAISS_THREADING_CONFIGURED:
|
|
31
|
+
return
|
|
32
|
+
_FAISS_THREADING_CONFIGURED = True
|
|
33
|
+
|
|
34
|
+
system = platform.system()
|
|
35
|
+
if system == "Darwin":
|
|
36
|
+
os.environ["OMP_NUM_THREADS"] = "1"
|
|
37
|
+
os.environ["OPENBLAS_NUM_THREADS"] = "1"
|
|
38
|
+
os.environ["MKL_NUM_THREADS"] = "1"
|
|
39
|
+
os.environ["VECLIB_MAXIMUM_THREADS"] = "1"
|
|
40
|
+
faiss.omp_set_num_threads(1)
|
|
41
|
+
print("[GranuScorer] macOS detected → using FAISS single-thread mode.")
|
|
42
|
+
else:
|
|
43
|
+
max_threads = faiss.omp_get_max_threads()
|
|
44
|
+
faiss.omp_set_num_threads(max_threads)
|
|
45
|
+
print(f"[GranuScorer] Linux detected → enabling FAISS parallelism ({max_threads} threads).")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
SearchMethod = Literal["nearest_neighbor", "random", "random_anchors", "radial_anchors"]
|
|
49
|
+
PredictorType = Literal["hit", "sentence_transformer", "llm"]
|
|
50
|
+
|
|
51
|
+
class GranularityPredictor(ABC):
|
|
52
|
+
"""
|
|
53
|
+
Pure interface defining the granularity predictor contract.
|
|
54
|
+
No implementation details.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
NO_FACT_SCORE = 5.0
|
|
58
|
+
percentile_output: PercentileOutput = None
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def score(
|
|
62
|
+
self,
|
|
63
|
+
answers: Sequence[str],
|
|
64
|
+
encoding_batch_size: int | None = None,
|
|
65
|
+
) -> np.ndarray:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def score_wo_cache(
|
|
70
|
+
self,
|
|
71
|
+
answers: Sequence[str],
|
|
72
|
+
encoding_batch_size: int | None = None,
|
|
73
|
+
) -> np.ndarray:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def extract_lgb_features(
|
|
78
|
+
self,
|
|
79
|
+
answers: Sequence[str],
|
|
80
|
+
encoding_batch_size: int | None = None,
|
|
81
|
+
) -> np.ndarray:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def __call__(self, answers, encoding_batch_size=None):
|
|
85
|
+
return self.score(answers, encoding_batch_size)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class BaseIndexGranularityPredictor(GranularityPredictor):
|
|
89
|
+
"""
|
|
90
|
+
Abstract base class for all granularity predictors.
|
|
91
|
+
|
|
92
|
+
Defines the public interface and shared cache behavior.
|
|
93
|
+
Concrete subclasses must implement embedding and feature extraction.
|
|
94
|
+
"""
|
|
95
|
+
DEFAULT_MODEL_NAME: str | None = None
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
model_name: str | None = None,
|
|
100
|
+
faiss_index_path: str | None = None,
|
|
101
|
+
lgb_model_path: str | None = None,
|
|
102
|
+
anchor_path: str | None = None,
|
|
103
|
+
search_method: SearchMethod = "random_anchors",
|
|
104
|
+
random_anchors_k: int = 999,
|
|
105
|
+
use_cache: bool = True,
|
|
106
|
+
):
|
|
107
|
+
"""
|
|
108
|
+
Initialize an index-based granularity predictor.
|
|
109
|
+
|
|
110
|
+
The default configuration reproduces the setup used in the paper and
|
|
111
|
+
works out of the box without requiring any additional resources.
|
|
112
|
+
|
|
113
|
+
Default configuration
|
|
114
|
+
---------------------
|
|
115
|
+
- model_name:
|
|
116
|
+
``"Hierarchy-Transformers/HiT-MiniLM-L12-WordNetNoun"``
|
|
117
|
+
- search_method:
|
|
118
|
+
``"random_anchors"``
|
|
119
|
+
- random_anchors_k:
|
|
120
|
+
``999``
|
|
121
|
+
- use_cache:
|
|
122
|
+
``True``
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
model_name:
|
|
127
|
+
Embedding model used to encode referential units.
|
|
128
|
+
If None, the predictor-specific default model is used.
|
|
129
|
+
|
|
130
|
+
faiss_index_path:
|
|
131
|
+
Optional path to a FAISS index containing searchable reference vectors.
|
|
132
|
+
Required for search methods relying on nearest-neighbor retrieval
|
|
133
|
+
(e.g. ``"nearest_neighbor"`` or ``"random"``).
|
|
134
|
+
|
|
135
|
+
lgb_model_path:
|
|
136
|
+
Optional path to the LightGBM granularity model used for final score
|
|
137
|
+
prediction.
|
|
138
|
+
|
|
139
|
+
anchor_path:
|
|
140
|
+
Optional path to precomputed anchor vectors used by anchor-based
|
|
141
|
+
search methods (e.g. ``"random_anchors"`` or ``"radial_anchors"``).
|
|
142
|
+
|
|
143
|
+
search_method:
|
|
144
|
+
Strategy used to retrieve or construct comparison vectors for feature
|
|
145
|
+
extraction.
|
|
146
|
+
|
|
147
|
+
random_anchors_k:
|
|
148
|
+
Number of anchor vectors used for ``"random_anchors"``.
|
|
149
|
+
|
|
150
|
+
use_cache:
|
|
151
|
+
Whether to cache computed granularity scores.
|
|
152
|
+
|
|
153
|
+
Notes
|
|
154
|
+
-----
|
|
155
|
+
The provided resources must be compatible with each other.
|
|
156
|
+
|
|
157
|
+
In particular, ``lgb_model_path``, ``search_method``,
|
|
158
|
+
``faiss_index_path``, ``anchor_path``, and related resources are expected
|
|
159
|
+
to originate from the same training/configuration setup.
|
|
160
|
+
|
|
161
|
+
For example, using the default LightGBM model trained for
|
|
162
|
+
``search_method="random_anchors"`` together with
|
|
163
|
+
``search_method="nearest_neighbor"`` may produce invalid results or
|
|
164
|
+
undefined behavior.
|
|
165
|
+
|
|
166
|
+
Similarly, anchor vectors, FAISS indices, reference percentile scores,
|
|
167
|
+
and LightGBM models must correspond to the same embedding space and
|
|
168
|
+
feature configuration.
|
|
169
|
+
|
|
170
|
+
Compatibility between custom resources is not validated automatically
|
|
171
|
+
and must be ensured by the user.
|
|
172
|
+
"""
|
|
173
|
+
if search_method not in get_args(SearchMethod):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"Invalid search_method '{search_method}'. "
|
|
176
|
+
f"Expected one of: {get_args(SearchMethod):}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
model_name = model_name or self.DEFAULT_MODEL_NAME
|
|
180
|
+
if model_name is None:
|
|
181
|
+
raise ValueError("model_name must be provided.")
|
|
182
|
+
|
|
183
|
+
self.search_method = search_method
|
|
184
|
+
self.random_anchors_k = random_anchors_k
|
|
185
|
+
|
|
186
|
+
manager = ArtifactManager()
|
|
187
|
+
|
|
188
|
+
# Defaults to settings in paper (random_anchors with k=999)
|
|
189
|
+
lgb_model_path = lgb_model_path or str(manager.ensure(DEFAULT_LGB_MODEL))
|
|
190
|
+
if search_method in {"random_anchors", "radial_anchors"}:
|
|
191
|
+
anchor_path = anchor_path or str(manager.ensure(DEFAULT_ANCHORS))
|
|
192
|
+
elif search_method in {"nearest_neighbor", "random"}:
|
|
193
|
+
faiss_index_path = faiss_index_path or str(manager.ensure(DEFAULT_FAISS_INDEX))
|
|
194
|
+
|
|
195
|
+
self.index = None
|
|
196
|
+
self.N = 0
|
|
197
|
+
if faiss_index_path is not None:
|
|
198
|
+
self.index = self._load_index(faiss_index_path)
|
|
199
|
+
self.N = self.index.ntotal
|
|
200
|
+
|
|
201
|
+
self.anchors = None
|
|
202
|
+
if anchor_path is not None:
|
|
203
|
+
self.anchors = np.load(anchor_path)
|
|
204
|
+
|
|
205
|
+
self.rng = np.random.default_rng(42) if search_method == "random" else None
|
|
206
|
+
|
|
207
|
+
self.model = self._load_model(model_name)
|
|
208
|
+
self.granu_predictor = self._load_granu_predictor(lgb_model_path)
|
|
209
|
+
|
|
210
|
+
if search_method == "random_anchors" and random_anchors_k == 999:
|
|
211
|
+
reference_scores_path = str(manager.ensure(DEFAULT_NOUN_SCORES))
|
|
212
|
+
self.percentile_output = PercentileOutput(reference_scores_path)
|
|
213
|
+
|
|
214
|
+
self.cache = None
|
|
215
|
+
if use_cache:
|
|
216
|
+
self.cache = GranuscoreCache(
|
|
217
|
+
scorer=self,
|
|
218
|
+
hierarchy_model=model_name,
|
|
219
|
+
faiss_index=str(faiss_index_path),
|
|
220
|
+
lgb_model=str(lgb_model_path),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@abstractmethod
|
|
224
|
+
def _load_model(self, model_name: str):
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
@abstractmethod
|
|
228
|
+
def _embed_answers(self, answers: Sequence[str]):
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
@abstractmethod
|
|
232
|
+
def _build_lgb_features(
|
|
233
|
+
self,
|
|
234
|
+
ans_vec: np.ndarray,
|
|
235
|
+
sims: np.ndarray,
|
|
236
|
+
neighbors: np.ndarray,
|
|
237
|
+
) -> np.ndarray:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
def _load_index(self, faiss_index: str):
|
|
241
|
+
configure_faiss_threading()
|
|
242
|
+
return faiss.read_index(faiss_index)
|
|
243
|
+
|
|
244
|
+
def _load_granu_predictor(self, granu_predictor_name: str | None):
|
|
245
|
+
if granu_predictor_name:
|
|
246
|
+
return lgb.Booster(model_file=granu_predictor_name)
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
def __call__(
|
|
250
|
+
self,
|
|
251
|
+
answers: Sequence[str],
|
|
252
|
+
encoding_batch_size: int | None = None,
|
|
253
|
+
):
|
|
254
|
+
return self.score(answers, encoding_batch_size)
|
|
255
|
+
|
|
256
|
+
def score(
|
|
257
|
+
self,
|
|
258
|
+
answers: Sequence[str],
|
|
259
|
+
encoding_batch_size: int | None = None,
|
|
260
|
+
):
|
|
261
|
+
if self.cache:
|
|
262
|
+
return self.cache.batch(answers, encoding_batch_size)
|
|
263
|
+
else:
|
|
264
|
+
return self.score_wo_cache(answers, encoding_batch_size)
|
|
265
|
+
|
|
266
|
+
def score_wo_cache(
|
|
267
|
+
self,
|
|
268
|
+
answers: Sequence[str],
|
|
269
|
+
encoding_batch_size: int | None = None,
|
|
270
|
+
):
|
|
271
|
+
if not self.granu_predictor:
|
|
272
|
+
raise ValueError("LGB model is None.")
|
|
273
|
+
|
|
274
|
+
features = self.extract_lgb_features(
|
|
275
|
+
answers=answers,
|
|
276
|
+
encoding_batch_size=encoding_batch_size,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return self.granu_predictor.predict(features)
|
|
280
|
+
|
|
281
|
+
def extract_lgb_features(
|
|
282
|
+
self,
|
|
283
|
+
answers: Sequence[str],
|
|
284
|
+
encoding_batch_size: int | None = None,
|
|
285
|
+
show_progress_bar: bool = False,
|
|
286
|
+
k: int = 1000
|
|
287
|
+
) -> np.ndarray:
|
|
288
|
+
"""
|
|
289
|
+
Compute the feature vectors fed into the LightGBM granularity model.
|
|
290
|
+
"""
|
|
291
|
+
encoding_batch_size = encoding_batch_size or len(answers)
|
|
292
|
+
k = min(k, self.N)
|
|
293
|
+
|
|
294
|
+
feats_all = []
|
|
295
|
+
|
|
296
|
+
batch_iter = iter_batches(answers, encoding_batch_size)
|
|
297
|
+
if show_progress_bar:
|
|
298
|
+
num_batches = (len(answers) + encoding_batch_size - 1) // encoding_batch_size
|
|
299
|
+
batch_iter = tqdm(
|
|
300
|
+
batch_iter,
|
|
301
|
+
total=num_batches,
|
|
302
|
+
desc="LGB features",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
for ans_batch in batch_iter:
|
|
306
|
+
ans_vec = self._embed_answers(ans_batch)
|
|
307
|
+
sims, neighbors = self._build_sim_neighbors(ans_vec, k)
|
|
308
|
+
feats_all.append(
|
|
309
|
+
self._build_lgb_features(
|
|
310
|
+
ans_vec=ans_vec,
|
|
311
|
+
sims=sims,
|
|
312
|
+
neighbors=neighbors,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return np.concatenate(feats_all, axis=0)
|
|
317
|
+
|
|
318
|
+
@abstractmethod
|
|
319
|
+
def _build_sim_neighbors(self, ans_vec: np.ndarray, k) -> tuple[Any, Any]:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
def _search(self, query_vec: np.ndarray, k: int, return_neighbors: bool = False):
|
|
323
|
+
"""
|
|
324
|
+
Search top-k most similar concepts to query_vec.
|
|
325
|
+
Returns (scores, indices) with shapes (k,), (k,)
|
|
326
|
+
"""
|
|
327
|
+
norms = np.linalg.norm(query_vec, axis=1, keepdims=True) + 1e-12
|
|
328
|
+
query_vec = query_vec / norms
|
|
329
|
+
|
|
330
|
+
scores, indices = self.index.search(query_vec, k)
|
|
331
|
+
if return_neighbors:
|
|
332
|
+
flat = indices.flatten() # shape: (batch * k,)
|
|
333
|
+
vecs = [self.index.reconstruct(int(i)) for i in flat]
|
|
334
|
+
neighbor_vectors = np.array(vecs).reshape(indices.shape[0], indices.shape[1], self.index.d)
|
|
335
|
+
return scores, indices, neighbor_vectors
|
|
336
|
+
return scores, indices
|
|
337
|
+
|
|
338
|
+
def _search_random(self, query_vec: np.ndarray, k: int, return_neighbors: bool = False):
|
|
339
|
+
"""
|
|
340
|
+
Random neighbor ablation:
|
|
341
|
+
- sample k random index vectors
|
|
342
|
+
- compute real similarity to query_vec
|
|
343
|
+
"""
|
|
344
|
+
norms = np.linalg.norm(query_vec, axis=1, keepdims=True) + 1e-12
|
|
345
|
+
query_vec = query_vec / norms
|
|
346
|
+
|
|
347
|
+
batch_size, d = query_vec.shape
|
|
348
|
+
k = min(k, self.N)
|
|
349
|
+
|
|
350
|
+
# 1) sample random indices
|
|
351
|
+
indices = self.rng.integers(0, self.N, size=(batch_size, k))
|
|
352
|
+
|
|
353
|
+
# 2) reconstruct vectors
|
|
354
|
+
flat = indices.flatten()
|
|
355
|
+
vecs = [self.index.reconstruct(int(i)) for i in flat]
|
|
356
|
+
neighbor_vectors = np.array(vecs).reshape(batch_size, k, d)
|
|
357
|
+
|
|
358
|
+
# 3) compute similarity
|
|
359
|
+
sims = np.einsum("bd,bkd->bk", query_vec, neighbor_vectors)
|
|
360
|
+
|
|
361
|
+
order = np.argsort(-sims, axis=1)
|
|
362
|
+
sims = np.take_along_axis(sims, order, axis=1)
|
|
363
|
+
indices = np.take_along_axis(indices, order, axis=1)
|
|
364
|
+
|
|
365
|
+
if return_neighbors:
|
|
366
|
+
order_exp = order[..., None]
|
|
367
|
+
neighbor_vectors = np.take_along_axis(neighbor_vectors, order_exp, axis=1)
|
|
368
|
+
return sims.astype(np.float32), indices, neighbor_vectors.astype(np.float32)
|
|
369
|
+
|
|
370
|
+
return sims.astype(np.float32), indices
|
|
371
|
+
|
|
372
|
+
def _search_anchors(self, query_vec, return_neighbors=False):
|
|
373
|
+
# normalize query
|
|
374
|
+
norms = np.linalg.norm(query_vec, axis=1, keepdims=True) + 1e-12
|
|
375
|
+
query_vec = query_vec / norms
|
|
376
|
+
|
|
377
|
+
b = query_vec.shape[0]
|
|
378
|
+
neighbor_vectors = np.broadcast_to(
|
|
379
|
+
self.anchors[None, :, :], # (1, k, d)
|
|
380
|
+
(b, self.anchors.shape[0], self.anchors.shape[1])
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# cosine similarity
|
|
384
|
+
neighbor_norms = np.linalg.norm(neighbor_vectors, axis=2, keepdims=True) + 1e-12
|
|
385
|
+
neighbor_normed = neighbor_vectors / neighbor_norms
|
|
386
|
+
|
|
387
|
+
sims = np.einsum("bd,bkd->bk", query_vec, neighbor_normed)
|
|
388
|
+
|
|
389
|
+
# canonical ordering (important!)
|
|
390
|
+
order = np.argsort(-sims, axis=1)
|
|
391
|
+
|
|
392
|
+
sims = np.take_along_axis(sims, order, axis=1)
|
|
393
|
+
|
|
394
|
+
if return_neighbors:
|
|
395
|
+
order_exp = order[..., None]
|
|
396
|
+
neighbor_vectors = np.take_along_axis(neighbor_vectors, order_exp, axis=1)
|
|
397
|
+
return sims.astype(np.float32), neighbor_vectors.astype(np.float32)
|
|
398
|
+
|
|
399
|
+
return sims.astype(np.float32)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class HitGranularityPredictor(BaseIndexGranularityPredictor):
|
|
403
|
+
DEFAULT_MODEL_NAME = "Hierarchy-Transformers/HiT-MiniLM-L12-WordNetNoun"
|
|
404
|
+
|
|
405
|
+
def __init__(
|
|
406
|
+
self,
|
|
407
|
+
model_name: str | None = None,
|
|
408
|
+
search_method: SearchMethod = "random_anchors",
|
|
409
|
+
random_anchors_k: int = 999,
|
|
410
|
+
use_cache: bool = True,
|
|
411
|
+
faiss_index_path: str | None = None,
|
|
412
|
+
lgb_model_path: str | None = None,
|
|
413
|
+
anchor_path: str | None = None,
|
|
414
|
+
original_vectors_path: str | None = None,
|
|
415
|
+
):
|
|
416
|
+
super().__init__(
|
|
417
|
+
model_name=model_name,
|
|
418
|
+
faiss_index_path=faiss_index_path,
|
|
419
|
+
lgb_model_path=lgb_model_path,
|
|
420
|
+
anchor_path=anchor_path,
|
|
421
|
+
search_method=search_method,
|
|
422
|
+
random_anchors_k=random_anchors_k,
|
|
423
|
+
use_cache=use_cache,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
self.original_vectors = None
|
|
427
|
+
|
|
428
|
+
if search_method in {"nearest_neighbor", "random"}:
|
|
429
|
+
manager = ArtifactManager()
|
|
430
|
+
|
|
431
|
+
if original_vectors_path is None:
|
|
432
|
+
original_vectors_path = str(
|
|
433
|
+
manager.ensure(DEFAULT_ORIGINAL_VECTORS)
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
self.original_vectors = np.load(
|
|
437
|
+
original_vectors_path
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def _load_model(self, model_name: str):
|
|
441
|
+
return HierarchyTransformer.from_pretrained(model_name).to(
|
|
442
|
+
get_best_device()
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def _build_sim_neighbors(self, ans_vec: np.ndarray, k) -> tuple[Any, Any]:
|
|
446
|
+
if self.search_method in ['radial_anchors', 'random_anchors']:
|
|
447
|
+
sims, neighbors = self._search_anchors(ans_vec, return_neighbors=True)
|
|
448
|
+
else:
|
|
449
|
+
if self.search_method == 'random':
|
|
450
|
+
sims, indices, neighbors = self._search_random(ans_vec, k, return_neighbors=True)
|
|
451
|
+
elif self.search_method == 'nearest_neighbor':
|
|
452
|
+
sims, indices, neighbors = self._search(ans_vec, k, return_neighbors=True)
|
|
453
|
+
else:
|
|
454
|
+
raise ValueError("Unknown search method '{}'".format(self.search_method))
|
|
455
|
+
neighbors = self.original_vectors[indices]
|
|
456
|
+
return sims, neighbors
|
|
457
|
+
|
|
458
|
+
def _embed_answers(self, answers: Sequence[str]):
|
|
459
|
+
if hasattr(answers, "tolist"): # pandas Series
|
|
460
|
+
answers = answers.tolist()
|
|
461
|
+
else:
|
|
462
|
+
answers = list(answers)
|
|
463
|
+
|
|
464
|
+
return self.model.encode(answers).astype("float32")
|
|
465
|
+
|
|
466
|
+
def _build_lgb_features(
|
|
467
|
+
self,
|
|
468
|
+
ans_vec: np.ndarray,
|
|
469
|
+
sims: np.ndarray,
|
|
470
|
+
neighbors: np.ndarray,
|
|
471
|
+
) -> np.ndarray:
|
|
472
|
+
"""
|
|
473
|
+
Build the feature vector that is fed into the LightGBM model.
|
|
474
|
+
|
|
475
|
+
Returns
|
|
476
|
+
-------
|
|
477
|
+
np.ndarray
|
|
478
|
+
Shape: [batch_size, 1 + k + k]
|
|
479
|
+
(dist0, similarities, distances)
|
|
480
|
+
"""
|
|
481
|
+
device = get_best_device()
|
|
482
|
+
|
|
483
|
+
ans_vec_t = torch.from_numpy(ans_vec).to(device)
|
|
484
|
+
neighbors_t = torch.from_numpy(neighbors).to(device)
|
|
485
|
+
|
|
486
|
+
# distance to origin
|
|
487
|
+
ans_vec_dist0 = self.model.manifold.dist0(ans_vec_t) # [b]
|
|
488
|
+
|
|
489
|
+
# distances to neighbors
|
|
490
|
+
dists = self.model.manifold.dist(
|
|
491
|
+
neighbors_t,
|
|
492
|
+
ans_vec_t.unsqueeze(1) # [b, 1, d]
|
|
493
|
+
) # [b, k]
|
|
494
|
+
|
|
495
|
+
features = np.concatenate(
|
|
496
|
+
[
|
|
497
|
+
ans_vec_dist0.cpu().unsqueeze(-1), # [b, 1]
|
|
498
|
+
sims, # [b, k]
|
|
499
|
+
dists.cpu(), # [b, k]
|
|
500
|
+
],
|
|
501
|
+
axis=1,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
return features
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class HitWithoutDis0Predictor(HitGranularityPredictor):
|
|
508
|
+
def _build_lgb_features(
|
|
509
|
+
self,
|
|
510
|
+
ans_vec: np.ndarray,
|
|
511
|
+
sims: np.ndarray,
|
|
512
|
+
neighbors: np.ndarray,
|
|
513
|
+
) -> np.ndarray:
|
|
514
|
+
"""
|
|
515
|
+
Build the feature vector that is fed into the LightGBM model.
|
|
516
|
+
|
|
517
|
+
Returns
|
|
518
|
+
-------
|
|
519
|
+
np.ndarray
|
|
520
|
+
Shape: [batch_size, 1 + k + k]
|
|
521
|
+
(dist0, similarities, distances)
|
|
522
|
+
"""
|
|
523
|
+
device = get_best_device()
|
|
524
|
+
|
|
525
|
+
ans_vec_t = torch.from_numpy(ans_vec).to(device)
|
|
526
|
+
neighbors_t = torch.from_numpy(neighbors).to(device)
|
|
527
|
+
|
|
528
|
+
# distances to neighbors
|
|
529
|
+
dists = self.model.manifold.dist(
|
|
530
|
+
neighbors_t,
|
|
531
|
+
ans_vec_t.unsqueeze(1) # [b, 1, d]
|
|
532
|
+
) # [b, k]
|
|
533
|
+
|
|
534
|
+
features = np.concatenate(
|
|
535
|
+
[
|
|
536
|
+
sims, # [b, k]
|
|
537
|
+
dists.cpu(), # [b, k]
|
|
538
|
+
],
|
|
539
|
+
axis=1,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
return features
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class SentenceTransformerGranularityPredictor(BaseIndexGranularityPredictor):
|
|
546
|
+
DEFAULT_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
|
|
547
|
+
|
|
548
|
+
def _embed_answers(self, answers: Sequence[str]):
|
|
549
|
+
if hasattr(answers, "tolist"): # pandas Series
|
|
550
|
+
answers = answers.tolist()
|
|
551
|
+
else:
|
|
552
|
+
answers = list(answers)
|
|
553
|
+
|
|
554
|
+
return self.model.encode(answers).astype("float32")
|
|
555
|
+
|
|
556
|
+
def _build_sim_neighbors(self, ans_vec: np.ndarray, k) -> tuple[Any, Any]:
|
|
557
|
+
if self.search_method in ['radial_anchors', 'random_anchors']:
|
|
558
|
+
sims, neighbors = self._search_anchors(ans_vec, return_neighbors=True)
|
|
559
|
+
else:
|
|
560
|
+
if self.search_method == 'random':
|
|
561
|
+
sims, indices, neighbors = self._search_random(ans_vec, k, return_neighbors=True)
|
|
562
|
+
elif self.search_method == 'nearest_neighbor':
|
|
563
|
+
sims, indices, neighbors = self._search(ans_vec, k, return_neighbors=True)
|
|
564
|
+
else:
|
|
565
|
+
raise ValueError("Unknown search method '{}'".format(self.search_method))
|
|
566
|
+
return sims, neighbors
|
|
567
|
+
|
|
568
|
+
def _build_lgb_features(self, ans_vec: np.ndarray, sims: np.ndarray, neighbors: np.ndarray) -> np.ndarray:
|
|
569
|
+
"""
|
|
570
|
+
Build the feature vector that is fed into the LightGBM model.
|
|
571
|
+
|
|
572
|
+
Returns
|
|
573
|
+
-------
|
|
574
|
+
np.ndarray
|
|
575
|
+
Shape: [batch_size, k]
|
|
576
|
+
(similarities)
|
|
577
|
+
"""
|
|
578
|
+
return sims
|
|
579
|
+
|
|
580
|
+
def _load_model(self, model_name: str):
|
|
581
|
+
return SentenceTransformer(model_name).to(get_best_device())
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
class LengthGranularityPredictor(GranularityPredictor):
|
|
585
|
+
"""Simply returns the char length of each answer."""
|
|
586
|
+
def __init__(self, use_cache: bool = True):
|
|
587
|
+
self.cache = None
|
|
588
|
+
if use_cache:
|
|
589
|
+
self.cache = GranuscoreCache(
|
|
590
|
+
scorer=self,
|
|
591
|
+
hierarchy_model=f'length',
|
|
592
|
+
faiss_index='None',
|
|
593
|
+
lgb_model='None',
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
def score(self, answers: Sequence[str], encoding_batch_size: int | None = None) -> np.ndarray:
|
|
597
|
+
if self.cache:
|
|
598
|
+
return self.cache.batch(answers, encoding_batch_size)
|
|
599
|
+
else:
|
|
600
|
+
return self.score_wo_cache(answers, encoding_batch_size)
|
|
601
|
+
|
|
602
|
+
def score_wo_cache(self, answers: Sequence[str], encoding_batch_size: int | None = None) -> np.ndarray:
|
|
603
|
+
return self.extract_lgb_features(answers, encoding_batch_size)
|
|
604
|
+
|
|
605
|
+
def extract_lgb_features(self, answers: Sequence[str], encoding_batch_size: int | None = None) -> np.ndarray:
|
|
606
|
+
if hasattr(answers, "tolist"):
|
|
607
|
+
answers = answers.tolist()
|
|
608
|
+
else:
|
|
609
|
+
answers = list(answers)
|
|
610
|
+
|
|
611
|
+
lengths = np.array([len(a) for a in answers], dtype=np.float32)
|
|
612
|
+
|
|
613
|
+
return lengths.reshape(-1, 1)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
class WordNetGranularityPredictor(GranularityPredictor):
|
|
617
|
+
"""Returns the avg depth of each answer inside wordnet hierarchy."""
|
|
618
|
+
def __init__(self, use_cache: bool = True):
|
|
619
|
+
self.cache = None
|
|
620
|
+
if use_cache:
|
|
621
|
+
self.cache = GranuscoreCache(
|
|
622
|
+
scorer=self,
|
|
623
|
+
hierarchy_model=f'wordnet',
|
|
624
|
+
faiss_index='None',
|
|
625
|
+
lgb_model='None',
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
def score(self, answers: Sequence[str], encoding_batch_size: int | None = None) -> np.ndarray:
|
|
629
|
+
if self.cache:
|
|
630
|
+
return self.cache.batch(answers, encoding_batch_size)
|
|
631
|
+
else:
|
|
632
|
+
return self.score_wo_cache(answers, encoding_batch_size)
|
|
633
|
+
|
|
634
|
+
def score_wo_cache(self, answers: Sequence[str], encoding_batch_size: int | None = None) -> np.ndarray:
|
|
635
|
+
return self.extract_lgb_features(answers, encoding_batch_size)
|
|
636
|
+
|
|
637
|
+
@staticmethod
|
|
638
|
+
def avg_wordnet_depth(word):
|
|
639
|
+
synsets = wn.synsets(word)
|
|
640
|
+
|
|
641
|
+
if not synsets:
|
|
642
|
+
return None
|
|
643
|
+
|
|
644
|
+
sense_depths = []
|
|
645
|
+
|
|
646
|
+
for s in synsets:
|
|
647
|
+
paths = s.hypernym_paths()
|
|
648
|
+
path_depths = [len(p) - 1 for p in paths] # root depth = 0
|
|
649
|
+
sense_depths.append(np.mean(path_depths))
|
|
650
|
+
|
|
651
|
+
return np.mean(sense_depths) if sense_depths else np.nan
|
|
652
|
+
|
|
653
|
+
def extract_lgb_features(self, answers: Sequence[str], encoding_batch_size: int | None = None) -> np.ndarray:
|
|
654
|
+
if hasattr(answers, "tolist"):
|
|
655
|
+
answers = answers.tolist()
|
|
656
|
+
else:
|
|
657
|
+
answers = list(answers)
|
|
658
|
+
|
|
659
|
+
lengths = np.array([self.avg_wordnet_depth(a) for a in answers], dtype=np.float32)
|
|
660
|
+
|
|
661
|
+
return lengths.reshape(-1, 1)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class LLMGranularityPredictor(GranularityPredictor):
|
|
665
|
+
"""Score with an LLM using a 5-point Likert scale."""
|
|
666
|
+
PROMPT = """You are an expert annotator for granularity.
|
|
667
|
+
|
|
668
|
+
Your task is to assign a granularity score to an answer using a 4-point Likert scale.
|
|
669
|
+
Granularity refers to how fine- / coarse-grained an answer is.
|
|
670
|
+
|
|
671
|
+
Always assign exactly one score: 1, 2, 3, or 4.
|
|
672
|
+
|
|
673
|
+
---
|
|
674
|
+
|
|
675
|
+
Examples:
|
|
676
|
+
Answer: "The Animals"
|
|
677
|
+
Granularity: 1
|
|
678
|
+
|
|
679
|
+
Example 2
|
|
680
|
+
Answer: "English rock band"
|
|
681
|
+
Granularity: 2
|
|
682
|
+
|
|
683
|
+
Example 3
|
|
684
|
+
Answer: "English band"
|
|
685
|
+
Granularity: 3
|
|
686
|
+
|
|
687
|
+
Example 4
|
|
688
|
+
Answer: "English musicians"
|
|
689
|
+
Granularity: 4
|
|
690
|
+
|
|
691
|
+
---
|
|
692
|
+
|
|
693
|
+
Example 6
|
|
694
|
+
Answer: "Seattle"
|
|
695
|
+
Granularity: 1
|
|
696
|
+
|
|
697
|
+
Example 7
|
|
698
|
+
Answer: "King County"
|
|
699
|
+
Granularity: 2
|
|
700
|
+
|
|
701
|
+
Example 8
|
|
702
|
+
Answer: "Washington"
|
|
703
|
+
Granularity: 3
|
|
704
|
+
|
|
705
|
+
Example 9
|
|
706
|
+
Answer: "United States"
|
|
707
|
+
Granularity: 4
|
|
708
|
+
|
|
709
|
+
---
|
|
710
|
+
|
|
711
|
+
Example 11
|
|
712
|
+
Answer: "Banksy"
|
|
713
|
+
Granularity: 1
|
|
714
|
+
|
|
715
|
+
Example 12
|
|
716
|
+
Answer: "a graffiti artist'"
|
|
717
|
+
Granularity: 2
|
|
718
|
+
|
|
719
|
+
Example 13
|
|
720
|
+
Answer: "a painter"
|
|
721
|
+
Granularity: 3
|
|
722
|
+
|
|
723
|
+
Example 14
|
|
724
|
+
Answer: "a political activist"
|
|
725
|
+
Granularity: 4
|
|
726
|
+
|
|
727
|
+
---
|
|
728
|
+
|
|
729
|
+
Now assign a granularity score. Output only the score.
|
|
730
|
+
|
|
731
|
+
Answer: "{answer}"
|
|
732
|
+
|
|
733
|
+
Granularity:
|
|
734
|
+
"""
|
|
735
|
+
|
|
736
|
+
def __init__(self, model_name: str = 'gpt-4.1-mini-2025-04-14', use_cache: bool = True):
|
|
737
|
+
self.model_name = model_name
|
|
738
|
+
self.provider = OpenAI()
|
|
739
|
+
self.cache = None
|
|
740
|
+
if use_cache:
|
|
741
|
+
self.cache = GranuscoreCache(
|
|
742
|
+
scorer=self,
|
|
743
|
+
hierarchy_model=f'LLM-{model_name}',
|
|
744
|
+
faiss_index='None',
|
|
745
|
+
lgb_model='None',
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
def score(self, answers: Sequence[str], encoding_batch_size: int | None = None) -> np.ndarray:
|
|
749
|
+
if self.cache:
|
|
750
|
+
return self.cache.batch(answers, encoding_batch_size)
|
|
751
|
+
else:
|
|
752
|
+
return self.score_wo_cache(answers, encoding_batch_size)
|
|
753
|
+
|
|
754
|
+
def score_wo_cache(self, answers: Sequence[str], encoding_batch_size: int | None = None) -> np.ndarray:
|
|
755
|
+
return self.extract_lgb_features(answers, encoding_batch_size)
|
|
756
|
+
|
|
757
|
+
def _score_single(self, answer: str) -> int:
|
|
758
|
+
for attempt in range(3):
|
|
759
|
+
try:
|
|
760
|
+
params = {
|
|
761
|
+
"model": self.model_name,
|
|
762
|
+
"input": [{'role': 'user', 'content': self.PROMPT.format(answer=answer)}],
|
|
763
|
+
"temperature": 0,
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
resp = self.provider.responses.create(**params)
|
|
767
|
+
text = resp.output_text.strip()
|
|
768
|
+
|
|
769
|
+
match = re.search(r"\b([1-4])\b", text)
|
|
770
|
+
if not match:
|
|
771
|
+
raise ValueError(f"No Likert score found: {text}")
|
|
772
|
+
|
|
773
|
+
return int(match.group(1))
|
|
774
|
+
|
|
775
|
+
except Exception as e:
|
|
776
|
+
if attempt == 2:
|
|
777
|
+
raise
|
|
778
|
+
time.sleep(1.5)
|
|
779
|
+
|
|
780
|
+
def extract_lgb_features(
|
|
781
|
+
self,
|
|
782
|
+
answers: Sequence[str],
|
|
783
|
+
encoding_batch_size: int | None = None,
|
|
784
|
+
) -> np.ndarray:
|
|
785
|
+
|
|
786
|
+
if hasattr(answers, "tolist"):
|
|
787
|
+
answers = answers.tolist()
|
|
788
|
+
else:
|
|
789
|
+
answers = list(answers)
|
|
790
|
+
|
|
791
|
+
scores = map_with_progress(
|
|
792
|
+
f=self._score_single,
|
|
793
|
+
xs=answers,
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
return np.array(scores, dtype=np.float32)
|