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.
@@ -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)