Wendy-Fly commited on
Commit
d9c9653
·
verified ·
1 Parent(s): d399f19

warm-start patch 3/3: true_skill_ranking.py

Browse files
patched/vendor/ranking_moderation/src/ranking_moderation/true_skill_ranking.py ADDED
@@ -0,0 +1,1481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Bradley-Terry ranking model for global ranking from pairwise comparisons.
3
+ This module provides the core ranking functionality using the Bradley-Terry model.
4
+ """
5
+
6
+ import logging
7
+ import json
8
+ import random
9
+ from pathlib import Path
10
+ from typing import Dict, List, Tuple, Any, Optional, TypeVar, Generic
11
+ from dataclasses import dataclass, field
12
+ from multiprocessing import Manager, Lock
13
+ import numpy as np
14
+ import time
15
+ import choix
16
+ from src.ranking_moderation.pairwise_comparison import PairwiseComparison
17
+ from scipy.optimize import minimize
18
+ from trueskill import Rating, rate_1vs1, quality_1vs1
19
+ from tqdm import tqdm
20
+ from concurrent.futures import ThreadPoolExecutor
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ T = TypeVar('T')
25
+
26
+ @dataclass
27
+ class RulerItem(Generic[T]):
28
+ """Represents an item in the ranking system.
29
+
30
+ Attributes:
31
+ item_id: Unique identifier for the item
32
+ score: Current ranking score (same as mu for TrueSkill)
33
+ mu: TrueSkill mu value (mean of the skill estimate)
34
+ sigma: TrueSkill sigma value (standard deviation of the skill estimate)
35
+ rank: Current rank position (optional)
36
+ item: The original item data
37
+ metadata: Additional metadata about the ranking process
38
+ """
39
+ item_id: str
40
+ item: T
41
+ score: float = 0.0
42
+ mu: float = 0.0 # Add mu field
43
+ sigma: float = 8.333
44
+ rank: Optional[int] = None
45
+ metadata: Dict[str, Any] = field(default_factory=dict)
46
+
47
+ def to_dict(self) -> Dict[str, Any]:
48
+ """Convert to dictionary for serialization."""
49
+ return {
50
+ 'item_id': self.item_id,
51
+ 'item': self.item,
52
+ 'score': self.score,
53
+ 'mu': self.mu, # Add mu to serialization
54
+ 'sigma': self.sigma,
55
+ 'rank': self.rank,
56
+ 'metadata': self.metadata
57
+ }
58
+
59
+ @classmethod
60
+ def from_dict(cls, data: Dict[str, Any]) -> 'RulerItem[T]':
61
+ """Create from dictionary."""
62
+ return cls(
63
+ item_id=data['item_id'],
64
+ item=data['item'],
65
+ score=data.get('score', 0.0),
66
+ mu=data.get('mu', 0.0), # Add mu to deserialization
67
+ sigma=data.get('sigma', 8.333),
68
+ rank=data.get('rank'),
69
+ metadata=data.get('metadata', {})
70
+ )
71
+
72
+ class TrueSkillRanking:
73
+ """
74
+ TrueSkill ranking model for global ranking from pairwise comparisons.
75
+ Uses pairwise comparisons and TrueSkill model for global ranking.
76
+ Supports iterative ranking with adaptive sampling based on current estimates.
77
+ """
78
+
79
+ def __init__(self,
80
+ pairwise_comparison: PairwiseComparison,
81
+ data_dir: str,
82
+ file_name: str,
83
+ convergence_threshold: float = 0.01,
84
+ max_iterations: int = 50,
85
+ max_workers: int = 10):
86
+ """Initialize TrueSkill ranking model.
87
+
88
+ Args:
89
+ pairwise_comparison: PairwiseComparison instance for handling comparisons
90
+ data_dir: Directory to store state files
91
+ convergence_threshold: Threshold for score convergence
92
+ """
93
+ logger.info(f"Initialize TrueSkillRanking.")
94
+ self.pairwise_comparison = pairwise_comparison
95
+ self.convergence_threshold = convergence_threshold
96
+ self.data_dir = Path(data_dir)
97
+ self.data_dir.mkdir(parents=True, exist_ok=True)
98
+ self.file_name = file_name
99
+ self.max_iterations = max_iterations
100
+ self.max_workers = max_workers
101
+ # Initialize process manager for shared data
102
+ self.manager = Manager()
103
+
104
+ # Initialize state
105
+ self.ruler_items = self.manager.list() # Store RulerItem instances
106
+
107
+ # Add process-safe locks
108
+ self._ruler_lock = Lock() # Lock for ruler items
109
+
110
+ # Load saved state if exists
111
+ self.load_state()
112
+
113
+ def get_state_file_path(self, filename: str) -> Path:
114
+ """Get the full path for a state file."""
115
+ return self.data_dir / filename
116
+
117
+ def save_state(self):
118
+ """Save current state to disk."""
119
+ logger.info(f"Saving state to {self.data_dir}")
120
+
121
+ try:
122
+ # Save ruler items with their scores
123
+ with self._ruler_lock:
124
+ items_data = [item.to_dict() for item in self.ruler_items]
125
+ with self.get_state_file_path(self.file_name).open("w") as f:
126
+ json.dump(items_data, f, indent=2, ensure_ascii=False)
127
+
128
+ logger.info("Successfully saved state")
129
+ except Exception as e:
130
+ logger.error(f"Failed to save state: {str(e)}")
131
+ raise
132
+
133
+ def load_state(self):
134
+ """Load state from disk if available."""
135
+ logger.info(f"Loading state from {self.data_dir}")
136
+
137
+ try:
138
+ # Load ruler items with their scores
139
+ with self._ruler_lock:
140
+ ruler_file = self.get_state_file_path(self.file_name)
141
+ if ruler_file.exists():
142
+ with ruler_file.open("r") as f:
143
+ items_data = json.load(f)
144
+ self.ruler_items.extend([RulerItem.from_dict(item) for item in items_data])
145
+ logger.info(f"Loaded {len(self.ruler_items)} ruler items")
146
+ except Exception as e:
147
+ logger.error(f"Failed to load state: {str(e)}")
148
+ raise
149
+
150
+ def get_current_scores(self) -> Dict[str, float]:
151
+ """Get current scores for all ruler items."""
152
+ with self._ruler_lock:
153
+ return {item.item_id: item.score for item in self.ruler_items}
154
+
155
+ def update_scores(self, new_scores: Dict[str, float]):
156
+ """Update scores for ruler items."""
157
+ with self._ruler_lock:
158
+ for item in self.ruler_items:
159
+ if item.item_id in new_scores:
160
+ item.score = new_scores[item.item_id]
161
+
162
+ def fit(self, items: List[RulerItem]) -> List[RulerItem]:
163
+ """Fit TrueSkill model to the items.
164
+
165
+ Args:
166
+ items: List of items to rank
167
+ max_iterations: Maximum number of iterations for TrueSkill
168
+ """
169
+ # Process each comparison result
170
+ comparison_info = self.pairwise_comparison.get_compare_information(
171
+ [item.item_id for item in items]
172
+ )
173
+ item_ids = set([])
174
+ data = []
175
+ for pair_key, results in comparison_info['comparison_results'].items():
176
+ id1, id2 = pair_key.split('_')
177
+ item_ids.add(id1)
178
+ item_ids.add(id2)
179
+
180
+ if results:
181
+ votes = {1: 0, -1: 0, 0: 0}
182
+ for result in results:
183
+ orig_id1, orig_id2 = result['original_ids']
184
+ if (orig_id1, orig_id2) == (id1, id2):
185
+ votes[result['judgment']] += 1
186
+ else:
187
+ votes[-result['judgment']] += 1
188
+
189
+ final_judgment = max(votes.items(), key=lambda x: x[1])[0]
190
+
191
+ if final_judgment > 0:
192
+ data.append((id1, id2, "win"))
193
+ elif final_judgment < 0:
194
+ data.append((id2, id1, "win"))
195
+ else:
196
+ data.append((id2, id1, "draw"))
197
+ ratings = {str(item.item_id): Rating(mu=50, sigma=50/3) for item in items}
198
+ mu_history = []
199
+ num_epochs = self.max_iterations
200
+ early_stop_threshold = 1e-3
201
+ logger.info(f"Fit by {len(data)} pair")
202
+ epoch_bar = tqdm(range(num_epochs), desc="Epochs", disable=True)
203
+ for epoch in epoch_bar:
204
+ random.shuffle(data)
205
+ delta_mu = []
206
+
207
+ for item_id_1, item_id_2, outcome in tqdm(data, desc=f"Epoch {epoch}", leave=False):
208
+ r1_old = ratings[item_id_1]
209
+ r2_old = ratings[item_id_2]
210
+
211
+ if outcome == "win":
212
+ r1_new, r2_new = rate_1vs1(r1_old, r2_old)
213
+ elif outcome == "lose":
214
+ r2_new, r1_new = rate_1vs1(r2_old, r1_old)
215
+ elif outcome == "draw":
216
+ r1_new, r2_new = rate_1vs1(r1_old, r2_old, drawn=True)
217
+ else:
218
+ continue
219
+
220
+ ratings[item_id_1] = r1_new
221
+ ratings[item_id_2] = r2_new
222
+
223
+ delta_mu.append(abs(r1_new.mu - r1_old.mu))
224
+ delta_mu.append(abs(r2_new.mu - r2_old.mu))
225
+
226
+ avg_delta = sum(delta_mu) / len(delta_mu)
227
+ mu_history.append(avg_delta)
228
+
229
+ epoch_bar.set_postfix(avg_delta_mu=f"{avg_delta:.5f}")
230
+
231
+ if avg_delta < early_stop_threshold:
232
+ logger.debug(f"Early stopped at epoch {epoch} (avg Δmu: {avg_delta:.5f})")
233
+ break
234
+ # Store results
235
+ for item in items:
236
+ item.score = ratings[str(item.item_id)].mu
237
+ item.mu = ratings[str(item.item_id)].mu
238
+ item.sigma = ratings[str(item.item_id)].sigma
239
+
240
+ return items
241
+
242
+ def ranking(self,
243
+ items: List[Dict],
244
+ num_comparisons_per_round: int = 50,
245
+ num_rounds: int = 3,
246
+ use_cache: bool = True) -> List[Dict]:
247
+ """Get global ranking of items using iterative TrueSkill model.
248
+
249
+ Args:
250
+ items: List of items to rank, each item should have 'item_id' field
251
+ num_comparisons_per_round: Number of comparisons to make in each round
252
+ num_rounds: Number of ranking rounds
253
+
254
+ Returns:
255
+ List of items sorted by their final estimated scores
256
+ """
257
+ logger.info(f"Starting ranking process with {len(items)} items, "
258
+ f"{num_comparisons_per_round} comparisons per round, "
259
+ f"{num_rounds} rounds")
260
+ if num_rounds <= 0:
261
+ logger.info("num_rounds <= 0, return the current ruler_items")
262
+ return [item.item for item in self.ruler_items]
263
+
264
+ # Convert input items to RulerItems
265
+ all_items = [
266
+ RulerItem(
267
+ item_id=str(item['item_id']),
268
+ item=item,
269
+ metadata={}
270
+ )
271
+ for item in items
272
+ ]
273
+ with self._ruler_lock:
274
+ self.ruler_items[:] = all_items
275
+
276
+ # Track consecutive rounds with max_rank_change = 0
277
+ consecutive_zero_changes = 0
278
+ required_consecutive_zeros = 20
279
+
280
+ for round_idx in range(num_rounds):
281
+ logger.info(f"Starting ranking round {round_idx + 1}/{num_rounds}")
282
+
283
+ # Sample pairs for comparison
284
+ pairs = self._sample_pairs(num_comparisons_per_round, all_items)
285
+
286
+ paired_item = [(item1.item, item2.item) for item1, item2 in pairs]
287
+
288
+ # Perform comparisons
289
+ self.pairwise_comparison.compare_pairs(paired_item, use_cache=use_cache)
290
+
291
+ # Fit model and get new scores
292
+ new_items = self.fit(all_items)
293
+
294
+ # Check convergence
295
+ current_scores = self.get_current_scores()
296
+ new_scores = {item.item_id: item.score for item in new_items}
297
+ if current_scores and self.convergence_threshold is not None:
298
+ # Calculate ranks based on scores
299
+ current_ranked_items = sorted(
300
+ [(item_id, score) for item_id, score in current_scores.items()],
301
+ key=lambda x: x[1],
302
+ reverse=True
303
+ )
304
+ new_ranked_items = sorted(
305
+ [(item_id, score) for item_id, score in new_scores.items()],
306
+ key=lambda x: x[1],
307
+ reverse=True
308
+ )
309
+
310
+ # Create rank mappings
311
+ current_ranks = {item_id: rank for rank, (item_id, _) in enumerate(current_ranked_items)}
312
+ new_ranks = {item_id: rank for rank, (item_id, _) in enumerate(new_ranked_items)}
313
+
314
+ # Calculate rank changes
315
+ rank_changes = []
316
+ for item_id in current_ranks:
317
+ if item_id in new_ranks: # Only consider items that have been compared
318
+ old_rank = current_ranks.get(item_id, 0)
319
+ new_rank = new_ranks[item_id]
320
+ rank_changes.append(abs(new_rank - old_rank))
321
+
322
+ if rank_changes: # Only calculate mean if we have valid rank changes
323
+ mean_rank_change = np.mean(rank_changes)
324
+ max_rank_change = np.max(rank_changes)
325
+
326
+ # Track items with their rank changes
327
+ item_rank_changes = []
328
+ for item_id in current_ranks:
329
+ if item_id in new_ranks:
330
+ old_rank = current_ranks[item_id]
331
+ new_rank = new_ranks[item_id]
332
+ rank_change = abs(new_rank - old_rank)
333
+ item_rank_changes.append((item_id, old_rank, new_rank, rank_change))
334
+
335
+ # Sort by rank change and get top 3
336
+ top_changes = sorted(item_rank_changes, key=lambda x: x[3], reverse=True)[:3]
337
+
338
+ logger.info(f"Round {round_idx + 1} rank changes - Mean: {mean_rank_change:.2f}, Max: {max_rank_change} "
339
+ f"(based on {len(rank_changes)} compared items)")
340
+
341
+ # Log top 3 changes
342
+ logger.info("Top 3 largest rank changes:")
343
+ for item_id, old_rank, new_rank, change in top_changes:
344
+ logger.info(f"Item {item_id}: {old_rank} -> {new_rank} (change: {change})")
345
+
346
+ # Update consecutive zero changes counter
347
+ if max_rank_change == 0:
348
+ consecutive_zero_changes += 1
349
+ logger.info(f"Consecutive rounds with no rank changes: {consecutive_zero_changes}/{required_consecutive_zeros}")
350
+ else:
351
+ consecutive_zero_changes = 0
352
+
353
+ # Consider converged if we have enough consecutive rounds with no rank changes
354
+ # or if mean rank change is below threshold
355
+ if consecutive_zero_changes >= required_consecutive_zeros or mean_rank_change < self.convergence_threshold:
356
+ logger.info(f"Converged after {round_idx + 1} rounds - ranks stabilized")
357
+ break
358
+ else:
359
+ logger.info(f"Round {round_idx + 1}: No compared items to evaluate convergence")
360
+
361
+ # Update scores
362
+ with self._ruler_lock:
363
+ sorted_items = sorted(
364
+ new_items,
365
+ key=lambda x: x.score,
366
+ reverse=True
367
+ )
368
+ # Update ranks
369
+ for rank, item in enumerate(sorted_items):
370
+ item.rank = rank
371
+ self.ruler_items[:] = sorted_items
372
+
373
+ # Save state after each round
374
+ self.save_state()
375
+
376
+ self.save_state()
377
+ logger.info(f"Completed ranking process, returning {len(sorted_items)} sorted items")
378
+
379
+ # Convert back to dictionary format
380
+ return [item.item for item in self.ruler_items]
381
+
382
+ def sample_pairs_by_rejection(self, n, k):
383
+ seen = set()
384
+ pbar = tqdm(total=k, desc="Sampling pairs", unit="pair", leave=False)
385
+ while len(seen) < k:
386
+ i = random.randrange(n)
387
+ # j 从 [0,n-2] 里挑一个,若 >= i 就跳过 i:
388
+ j = random.randrange(n-1)
389
+ if j >= i:
390
+ j += 1
391
+ # 保证 (i,j) 有序
392
+ a, b = (i, j) if i < j else (j, i)
393
+ if (a, b) not in seen:
394
+ seen.add((a, b))
395
+ pbar.update(1)
396
+ pbar.close()
397
+ return list(seen)
398
+
399
+ def _sample_pairs_by_score_gaps(self, items: List[RulerItem], current_scores: Dict[str, float],
400
+ comparison_counts: Dict[str, int], pair_comparison_counts: Dict[str, int],
401
+ num_comparisons: int) -> List[Tuple[RulerItem, RulerItem]]:
402
+ """Sample pairs based on score gaps between adjacent items in the ranking.
403
+
404
+ Args:
405
+ items: List of items to sample from
406
+ current_scores: Current scores for all items
407
+ comparison_counts: Number of comparisons for each item
408
+ pair_comparison_counts: Number of comparisons for each pair
409
+ num_comparisons: Number of pairs to sample
410
+
411
+ Returns:
412
+ List of (item1, item2) pairs to compare
413
+ """
414
+ # Sort items by score
415
+ sorted_items = sorted(items, key=lambda x: current_scores.get(x.item_id, 0), reverse=True)
416
+
417
+ # Calculate score differences between adjacent items
418
+ score_diffs = []
419
+ for i in range(len(sorted_items) - 1):
420
+ item1 = sorted_items[i]
421
+ item2 = sorted_items[i + 1]
422
+ score1 = current_scores.get(item1.item_id, 0)
423
+ score2 = current_scores.get(item2.item_id, 0)
424
+ score_diff = abs(score1 - score2)
425
+ score_diffs.append((score_diff, item1, item2))
426
+
427
+ if not score_diffs:
428
+ return []
429
+
430
+ # Calculate mean and standard deviation of score differences
431
+ mean_diff = np.mean([diff for diff, _, _ in score_diffs])
432
+ std_diff = np.std([diff for diff, _, _ in score_diffs])
433
+
434
+ # Select pairs with score differences significantly larger than mean
435
+ threshold = mean_diff + std_diff # Items with gaps larger than mean + 1 std
436
+ candidate_pairs = []
437
+
438
+ for diff, item1, item2 in score_diffs:
439
+ if diff > threshold:
440
+ pair_key = self.pairwise_comparison.get_pair_key(item1.item_id, item2.item_id)
441
+ # Only consider pairs that haven't been compared too many times
442
+ if pair_comparison_counts.get(pair_key, 0) < self.pairwise_comparison.max_comparisons_per_pair:
443
+ candidate_pairs.append((diff, item1, item2))
444
+
445
+ # Sort by score difference and take top num_comparisons
446
+ candidate_pairs.sort(reverse=True)
447
+ return [(item1, item2) for _, item1, item2 in candidate_pairs[:num_comparisons]]
448
+
449
+ def _sample_pairs_randomly(self, items: List[RulerItem], num_pairs: int,
450
+ pair_comparison_counts: Dict[str, int]) -> List[Tuple[RulerItem, RulerItem]]:
451
+ """Sample pairs randomly for comparison.
452
+
453
+ Args:
454
+ items: List of items to sample from
455
+ num_pairs: Number of pairs to sample
456
+ pair_comparison_counts: Dictionary of pair comparison counts
457
+
458
+ Returns:
459
+ List of (item1, item2) pairs to compare
460
+ """
461
+ logger.info(f"randomly sample {num_pairs} pairs")
462
+ n = len(items)
463
+ random_pairs = []
464
+ if n < 500:
465
+ all_idx_pairs = [(i, j) for i in range(n) for j in range(i + 1, n)]
466
+ valid_idx_pairs = []
467
+ for i, j in all_idx_pairs:
468
+ key = self.pairwise_comparison.get_pair_key(items[i].item_id, items[j].item_id)
469
+ if pair_comparison_counts.get(key, 0) < self.pairwise_comparison.max_comparisons_per_pair:
470
+ valid_idx_pairs.append((i, j))
471
+ if len(valid_idx_pairs) == 0:
472
+ return []
473
+ random.shuffle(valid_idx_pairs)
474
+ selected_idx_pairs = valid_idx_pairs[:min(num_pairs, len(valid_idx_pairs))]
475
+ for i, j in selected_idx_pairs:
476
+ random_pairs.append((items[i], items[j]))
477
+ else:
478
+
479
+ while len(random_pairs) < num_pairs:
480
+ i, j = random.sample(range(n), 2)
481
+ if i < j and (i, j) not in random_pairs:
482
+ pair_key = self.pairwise_comparison.get_pair_key(
483
+ items[i].item_id,
484
+ items[j].item_id
485
+ )
486
+ if pair_comparison_counts.get(pair_key, 0) < self.pairwise_comparison.max_comparisons_per_pair:
487
+ random_pairs.append((items[i], items[j]))
488
+
489
+ return random_pairs
490
+
491
+ def _sample_pairs(self, num_comparisons: int, items: List[RulerItem]) -> List[Tuple[Dict, Dict]]:
492
+ """Sample pairs for comparison based on current scores and comparison history."""
493
+ n = len(items)
494
+ epsilon = 1e-6
495
+ # Get comparison counts from pairwise_comparison
496
+ comparison_info = self.pairwise_comparison.get_compare_information(
497
+ [item.item_id for item in items]
498
+ )
499
+ comparison_counts = comparison_info['comparison_counts']
500
+ pair_comparison_counts = comparison_info['pair_comparison_counts']
501
+
502
+ # Calculate sampling quotas
503
+ num_random_pairs = max(1, int(num_comparisons * 0.10)) # 20% random
504
+ num_thompson_pairs = max(1, int(num_comparisons * 0.30)) # 40% Thompson
505
+ # num_adaptive_pairs = num_comparisons - num_random_pairs - num_thompson_pairs
506
+
507
+ # Get random pairs
508
+ random_pairs = self._sample_pairs_randomly(items, num_random_pairs, pair_comparison_counts)
509
+
510
+ current_scores = self.get_current_scores()
511
+ if not current_scores:
512
+ logger.info("First round or no scores available: using random sampling")
513
+ remaining_random_pairs = self._sample_pairs_randomly(
514
+ items,
515
+ num_comparisons - len(random_pairs),
516
+ pair_comparison_counts
517
+ )
518
+ return random_pairs + remaining_random_pairs
519
+
520
+ # Get Thompson sampling pairs
521
+ thompson_pairs = self._sample_pairs_by_thompson(items, num_thompson_pairs, pair_comparison_counts)
522
+
523
+ num_adaptive_pairs = num_comparisons - num_random_pairs - len(thompson_pairs)
524
+
525
+ # Get adaptive sampling pairs
526
+ total_comparisons = sum(comparison_counts.values())
527
+ avg_comparisons_per_item = total_comparisons / len(items) if items else 0
528
+
529
+ if avg_comparisons_per_item >= 10:
530
+ logger.info("Using score gap based sampling")
531
+ adaptive_pairs = self._sample_pairs_by_score_gaps(
532
+ items, current_scores, comparison_counts, pair_comparison_counts, num_adaptive_pairs
533
+ )
534
+
535
+ if len(adaptive_pairs) < num_adaptive_pairs:
536
+ logger.info(f"Score gap sampling only found {len(adaptive_pairs)} pairs, falling back to uncertainty sampling")
537
+ remaining_pairs = self._sample_pairs_by_uncertainty(
538
+ items, current_scores, comparison_counts, pair_comparison_counts,
539
+ num_adaptive_pairs - len(adaptive_pairs)
540
+ )
541
+ adaptive_pairs.extend(remaining_pairs)
542
+ else:
543
+ logger.info("Using adaptive sampling based on current scores")
544
+ adaptive_pairs = self._sample_pairs_by_uncertainty(
545
+ items, current_scores, comparison_counts, pair_comparison_counts, num_adaptive_pairs
546
+ )
547
+
548
+ # Combine all sampling strategies
549
+ return random_pairs + thompson_pairs + adaptive_pairs
550
+
551
+ def _sample_pairs_by_uncertainty(self, items: List[RulerItem], current_scores: Dict[str, float],
552
+ comparison_counts: Dict[str, int], pair_comparison_counts: Dict[str, int],
553
+ num_comparisons: int) -> List[Tuple[RulerItem, RulerItem]]:
554
+ """Sample pairs based on uncertainty in their relative ordering.
555
+
556
+ Args:
557
+ items: List of items to sample from
558
+ current_scores: Current scores for all items
559
+ comparison_counts: Number of comparisons for each item
560
+ pair_comparison_counts: Number of comparisons for each pair
561
+ num_comparisons: Number of pairs to sample
562
+
563
+ Returns:
564
+ List of (item1, item2) pairs to compare
565
+ """
566
+ n = len(items)
567
+ epsilon = 1e-6 # Small constant to avoid division by zero
568
+
569
+ # Use heap to maintain top-k pairs with highest uncertainty scores
570
+ import heapq
571
+ top_pairs = [] # Heap to store (score, i, j)
572
+
573
+ # Randomly sample a subset of pairs for evaluation
574
+ sample_size = min(num_comparisons * 1000, n * (n - 1) // 2) # Sample at most 2000x num_comparisons pairs
575
+ logger.info(f"Sample size is {sample_size}")
576
+
577
+ if n <= 5000:
578
+ candidate_pairs = random.sample([(i, j) for i in range(n) for j in range(i + 1, n)], sample_size)
579
+ else:
580
+ candidate_pairs = self.sample_pairs_by_rejection(n, sample_size)
581
+ for i, j in candidate_pairs:
582
+ id1 = items[i].item_id
583
+ id2 = items[j].item_id
584
+ pair_key = self.pairwise_comparison.get_pair_key(id1, id2)
585
+
586
+ if pair_comparison_counts.get(pair_key, 0) >= self.pairwise_comparison.max_comparisons_per_pair:
587
+ continue
588
+
589
+ # Get comparison counts for both items
590
+ count_i = comparison_counts.get(id1, 0)
591
+ count_j = comparison_counts.get(id2, 0)
592
+
593
+ # Calculate score difference term
594
+ score1 = current_scores.get(id1, 0)
595
+ score2 = current_scores.get(id2, 0)
596
+ score_diff = abs(score1 - score2)
597
+ score_term = 1 / (score_diff + epsilon)
598
+
599
+ # Calculate comparison count penalty term
600
+ count_term = 1 / np.sqrt(1 + count_i + count_j)
601
+
602
+ # Calculate final uncertainty score
603
+ uncertainty_score = score_term * count_term
604
+
605
+ # Use heap to maintain top-k
606
+ if len(top_pairs) < num_comparisons:
607
+ heapq.heappush(top_pairs, (uncertainty_score, i, j))
608
+ elif uncertainty_score > top_pairs[0][0]:
609
+ heapq.heapreplace(top_pairs, (uncertainty_score, i, j))
610
+
611
+ # Get final pairs from heap
612
+ return [(items[i], items[j]) for _, i, j in sorted(top_pairs, reverse=True)]
613
+
614
+ def estimate_new_item_score(self, new_item: RulerItem, ruler_items: List[RulerItem], num_of_epoch: int = 10) -> float:
615
+ """Estimate score for a new item while keeping existing item scores fixed.
616
+
617
+ Args:
618
+ new_item: New item to estimate score for
619
+ ruler_items: List of existing ruler items with fixed scores
620
+
621
+ Returns:
622
+ float: Estimated score (mu) for the new item
623
+ """
624
+ # Get comparison results for the new item
625
+ all_item_ids = [item.item_id for item in ruler_items] + [new_item.item_id]
626
+ comparison_info = self.pairwise_comparison.get_compare_information(all_item_ids)
627
+ comparison_results = comparison_info['comparison_results']
628
+
629
+ # Create fixed ratings for existing items
630
+ fixed_ratings = {
631
+ item.item_id: Rating(mu=item.mu, sigma=item.sigma)
632
+ for item in ruler_items
633
+ }
634
+
635
+ # Create initial rating for new item
636
+ new_rating = Rating(mu=50, sigma=50/3)
637
+
638
+ # Process each comparison result
639
+ item_ids = set([])
640
+ data = []
641
+ for pair_key, results in comparison_info['comparison_results'].items():
642
+ id1, id2 = pair_key.split('_')
643
+ if id1 != new_item.item_id and id2 != new_item.item_id:
644
+ continue
645
+ item_ids.add(id1)
646
+ item_ids.add(id2)
647
+
648
+ if results:
649
+ votes = {1: 0, -1: 0, 0: 0}
650
+ for result in results:
651
+ orig_id1, orig_id2 = result['original_ids']
652
+ if (orig_id1, orig_id2) == (id1, id2):
653
+ votes[result['judgment']] += 1
654
+ else:
655
+ votes[-result['judgment']] += 1
656
+
657
+ final_judgment = max(votes.items(), key=lambda x: x[1])[0]
658
+
659
+ if id1 == new_item.item_id:
660
+ new_item_id = id1
661
+ old_item_id = id2
662
+ elif id2 == new_item.item_id:
663
+ new_item_id = id2
664
+ old_item_id = id1
665
+ final_judgment = -final_judgment
666
+ else:
667
+ assert False, "New item is not in this pair"
668
+
669
+ if final_judgment > 0:
670
+ data.append((new_item_id, old_item_id, "win"))
671
+ elif final_judgment < 0:
672
+ data.append((new_item_id, old_item_id, "loose"))
673
+ else:
674
+ data.append((new_item_id, old_item_id, "draw"))
675
+
676
+ if not data:
677
+ logger.warning(f"No valid comparison data for new item {new_item.item_id}; return initial rating")
678
+ return new_rating.mu, new_rating.sigma
679
+
680
+ epoch_bar = tqdm(range(num_of_epoch), desc="Epochs", disable=True)
681
+ for epoch in epoch_bar:
682
+ random.shuffle(data)
683
+ delta_mu = []
684
+ new_rating_old = new_rating
685
+ for new_item_id, old_item_id, outcome in tqdm(data, desc=f"Epoch {epoch}", leave=False, disable=True):
686
+ if outcome == "win":
687
+ new_rating, _ = rate_1vs1(new_rating, fixed_ratings[old_item_id])
688
+ elif outcome == "loose":
689
+ _, new_rating = rate_1vs1(fixed_ratings[old_item_id], new_rating)
690
+ else:
691
+ new_rating, _ = rate_1vs1(new_rating, fixed_ratings[old_item_id], drawn=True)
692
+
693
+ delta_mu.append(abs(new_rating.mu - new_rating_old.mu))
694
+
695
+ if not delta_mu:
696
+ logger.warning(f"No rating updates for new item {new_item.item_id}; stop fitting")
697
+ break
698
+ avg_delta = sum(delta_mu) / len(delta_mu)
699
+ epoch_bar.set_postfix(avg_delta_mu=f"{avg_delta:.5f}")
700
+
701
+ if avg_delta < self.convergence_threshold:
702
+ logger.debug(f"Early stopped at epoch {epoch} (avg Δmu: {avg_delta:.5f})")
703
+ break
704
+
705
+ return new_rating.mu, new_rating.sigma
706
+
707
+
708
+ def _similarity_search(self, new_item: RulerItem, num_rounds: int, initial_comparisons: int, ruler_items: List[RulerItem]) -> Tuple[int, List[Dict]]:
709
+ round_candidates = []
710
+
711
+ for round_idx in range(num_rounds):
712
+ logger.debug(f"Starting round {round_idx + 1}/{num_rounds}")
713
+ round_info = {
714
+ 'round': round_idx + 1,
715
+ 'candidates': [],
716
+ 'sampling_method': None
717
+ }
718
+
719
+ if round_idx == 0:
720
+ # First round: compare with midpoint
721
+ midpoint_idx = len(ruler_items) // 2
722
+ midpoint_item = ruler_items[midpoint_idx]
723
+ candidates = [(midpoint_idx, midpoint_item)]
724
+
725
+ round_info['sampling_method'] = 'midpoint_comparison'
726
+ round_info['candidates'] = [{'id': midpoint_item.item_id, 'position': midpoint_idx,
727
+ 'score': midpoint_item.score, 'rank': midpoint_item.rank}]
728
+
729
+ else:
730
+ # Find items with most similar quality (closest to 1 in quality_1vs1)
731
+ quality_values = []
732
+ for i, item in enumerate(ruler_items):
733
+ pair_key = self.pairwise_comparison.get_pair_key(new_item.item_id, item.item_id)
734
+ if self.pairwise_comparison.get_comparison_count(pair_key) > 0:
735
+ continue
736
+
737
+ quality = quality_1vs1(new_item, item)
738
+ quality_values.append((i, item, quality))
739
+
740
+ # Sort by quality (highest first)
741
+ quality_values.sort(key=lambda x: -x[2])
742
+ candidates = [(i, item) for i, item, _ in quality_values[:initial_comparisons]]
743
+
744
+ if not candidates:
745
+ logger.info("No more pairs to compare, proceeding with final fitting")
746
+ break
747
+
748
+
749
+
750
+ round_info['sampling_method'] = 'quality_based'
751
+ round_info['candidates'] = [{'id': c[1].item_id, 'position': c[0],
752
+ 'score': c[1].score, 'rank': c[1].rank} for c in candidates]
753
+
754
+
755
+ # for debug
756
+ # round_info['quality_values'] = [(x[0], x[2]) for x in quality_values]
757
+
758
+
759
+ # Perform comparisons
760
+ pairs = [(new_item.item, candidate[1].item) for candidate in candidates]
761
+ self.pairwise_comparison.compare_pairs(pairs)
762
+
763
+ # Estimate new item score
764
+ new_item.mu, new_item.sigma = self.estimate_new_item_score(new_item, ruler_items)
765
+ round_info['item_trueskill'] = {'mu': new_item.mu, 'sigma': new_item.sigma}
766
+ round_candidates.append(round_info)
767
+ return round_idx, round_candidates
768
+
769
+
770
+ def _heuristic_search(self, new_item: RulerItem, num_rounds: int, initial_comparisons: int,
771
+ ruler_items: List[RulerItem], start_pos: int = None) -> Tuple[int, List[Dict]]:
772
+ # First round: uniform sampling from different score ranges
773
+ round_candidates = []
774
+
775
+ for round_idx in range(num_rounds):
776
+ logger.debug(f"Starting round {round_idx + 1}/{num_rounds}")
777
+ round_info = {
778
+ 'round': round_idx + 1,
779
+ 'candidates': [],
780
+ 'sampling_method': None
781
+ }
782
+
783
+ if round_idx == 0:
784
+ candidates = []
785
+ if start_pos is not None:
786
+ # warm-start:直接拿 embedding 估的位置当首轮唯一候选
787
+ idx = max(0, min(len(ruler_items) - 1, int(start_pos)))
788
+ temp_item = ruler_items[idx]
789
+ pair_key = self.pairwise_comparison.get_pair_key(new_item.item_id, temp_item.item_id)
790
+ if self.pairwise_comparison.get_comparison_count(pair_key) == 0:
791
+ candidates = [(idx, temp_item)]
792
+ round_info['sampling_method'] = 'heuristic_warm_start'
793
+ else:
794
+ # 原均匀分段采样
795
+ num_comparisons = initial_comparisons
796
+ segment_size = max(1, len(ruler_items) // num_comparisons)
797
+ for i in range(0, len(ruler_items), segment_size):
798
+ segment = ruler_items[i:i + segment_size]
799
+ if segment:
800
+ for temp_item in segment:
801
+ pair_key = self.pairwise_comparison.get_pair_key(new_item.item_id, temp_item.item_id)
802
+ if self.pairwise_comparison.get_comparison_count(pair_key) == 0:
803
+ candidates.append((i, temp_item))
804
+ break
805
+ round_info['sampling_method'] = 'uniform_segments'
806
+
807
+ round_info['candidates'] = [{'id': c[1].item_id, 'position': c[0], 'score': c[1].score, 'rank': c[1].rank}
808
+ for c in candidates]
809
+ logger.debug(f"Round {round_idx + 1} sampling info:")
810
+ logger.debug(f" Method: {round_info['sampling_method']}")
811
+ logger.debug(f" Total candidates: {len(candidates)}")
812
+ logger.debug(" Candidates details:")
813
+ for i, candidate in enumerate(round_info['candidates']):
814
+ logger.debug(f" {i+1}. ID: {candidate['id']}, Position: {candidate['position']}, Score: {candidate['score']:.4f}")
815
+ else:
816
+ # Find items with similar scores
817
+ score_diffs = []
818
+ for i, item in enumerate(ruler_items):
819
+ # Skip if this pair has already been compared
820
+ pair_key = self.pairwise_comparison.get_pair_key(new_item.item_id, item.item_id)
821
+ if self.pairwise_comparison.get_comparison_count(pair_key) > 0:
822
+ continue
823
+
824
+ score_diff = abs(item.score - new_item.score)
825
+ score_diffs.append((i, item, score_diff))
826
+
827
+ # Sort by score difference and take top candidates
828
+ score_diffs.sort(key=lambda x: x[2])
829
+ candidates = [(i, item) for i, item, _ in score_diffs[:initial_comparisons]]
830
+
831
+ # If no candidates to compare, we're done
832
+ if not candidates:
833
+ logger.info("No more pairs to compare, proceeding with final fitting")
834
+ break
835
+
836
+ round_info['sampling_method'] = 'score_based'
837
+ round_info['candidates'] = [{'id': c[1].item_id, 'position': c[0], 'score': c[1].score, 'rank': c[1].rank}
838
+ for c in candidates]
839
+ logger.debug(f"Round {round_idx + 1} sampling info:")
840
+ logger.debug(f" Method: {round_info['sampling_method']}")
841
+ logger.debug(f" Current new item score: {new_item.score:.4f}")
842
+ logger.debug(f" Total candidates: {len(candidates)}")
843
+ logger.debug(" Candidates details:")
844
+ for i, candidate in enumerate(round_info['candidates']):
845
+ logger.debug(f" {i+1}. ID: {candidate['id']}, Position: {candidate['position']}, Score: {candidate['score']:.4f}")
846
+
847
+
848
+
849
+ # Perform comparisons
850
+ pairs = [(new_item.item, candidate[1].item)
851
+ for candidate in candidates]
852
+ self.pairwise_comparison.compare_pairs(pairs)
853
+
854
+ # Estimate new item score while keeping existing scores fixed
855
+ new_item.score, new_item.sigma = self.estimate_new_item_score(new_item, ruler_items)
856
+ round_info['item_trueskill'] = {'mu': new_item.score, 'sigma': new_item.sigma}
857
+ round_candidates.append(round_info)
858
+ return round_idx, round_candidates
859
+
860
+ def _binary_search(self, new_item: RulerItem, ruler_items: List[RulerItem],
861
+ start_pos: int = None) -> Tuple[int, List[Dict]]:
862
+ left, right = 0, len(ruler_items) - 1
863
+ round_candidates = []
864
+ round_idx = 0
865
+ while left <= right:
866
+
867
+ logger.debug(f"Starting round {round_idx + 1}")
868
+ round_info = {
869
+ 'round': round_idx + 1,
870
+ 'candidates': [],
871
+ 'sampling_method': 'binary_search'
872
+ }
873
+
874
+ # warm-start:第一轮 mid 用 start_pos(夹到 [left, right])
875
+ if round_idx == 0 and start_pos is not None:
876
+ mid = max(left, min(right, int(start_pos)))
877
+ round_info['sampling_method'] = 'binary_search_warm_start'
878
+ else:
879
+ mid = (left + right) // 2
880
+ mid_item = ruler_items[mid]
881
+
882
+ pair_key = self.pairwise_comparison.get_pair_key(new_item.item_id, mid_item.item_id)
883
+ if self.pairwise_comparison.get_comparison_count(pair_key) == 0:
884
+ candidates = [(mid, mid_item)]
885
+ round_info['candidates'] = [{'id': mid_item.item_id, 'position': mid, 'score': mid_item.score, 'rank': mid_item.rank}]
886
+ else:
887
+ candidates = []
888
+ logger.debug(f"Round {round_idx + 1} sampling info:")
889
+ logger.debug(f" Method: {round_info['sampling_method']}")
890
+ logger.debug(f" Total candidates: {len(candidates)}")
891
+ logger.debug(" Candidates details:")
892
+ for i, candidate in enumerate(round_info['candidates']):
893
+ logger.debug(f" {i+1}. ID: {candidate['id']}, Position: {candidate['position']}, Score: {candidate['score']:.4f}")
894
+
895
+ # round_candidates.append(round_info)
896
+
897
+ # Perform comparisons
898
+ if candidates:
899
+ pairs = [(new_item.item, candidate[1].item) for candidate in candidates]
900
+ self.pairwise_comparison.compare_pairs(pairs)
901
+
902
+ # Estimate new item score while keeping existing scores fixed
903
+ new_item.score, new_item.sigma = self.estimate_new_item_score(new_item, ruler_items)
904
+
905
+
906
+ if new_item.score <= mid_item.score: # new_item is less violative than mid_item
907
+ left = mid + 1
908
+ else:
909
+ right = mid - 1
910
+
911
+
912
+ round_idx += 1
913
+
914
+ round_info['item_trueskill'] = {'mu': new_item.score, 'sigma': new_item.sigma}
915
+ round_candidates.append(round_info)
916
+ return round_idx, round_candidates
917
+
918
+
919
+
920
+ def _full_traversal(self, new_item: RulerItem, ruler_items: List[RulerItem]) -> Tuple[int, List[Dict]]:
921
+ all_candidates = []
922
+ round_idx = 0
923
+ round_candidates = []
924
+ for idx, item in enumerate(ruler_items):
925
+ logger.debug(f"Starting round {round_idx + 1}")
926
+ round_info = {
927
+ 'round': round_idx + 1,
928
+ 'candidates': [],
929
+ 'sampling_method': 'full_traversal'
930
+ }
931
+ candidates = []
932
+ pair_key = self.pairwise_comparison.get_pair_key(new_item.item_id, item.item_id)
933
+ if self.pairwise_comparison.get_comparison_count(pair_key) == 0:
934
+ all_candidates.append((idx, item))
935
+ candidates = [(idx, item)]
936
+ round_info['candidates'] = [{'id': item.item_id, 'position': idx, 'score': item.score, 'rank': item.rank}]
937
+
938
+ logger.debug(f"Round {round_idx + 1} sampling info:")
939
+ logger.debug(f" Method: {round_info['sampling_method']}")
940
+ logger.debug(" Candidates details:")
941
+ for i, candidate in enumerate(round_info['candidates']):
942
+ logger.debug(f" {i+1}. ID: {candidate['id']}, Position: {candidate['position']}, Score: {candidate['score']:.4f}")
943
+
944
+ round_candidates.append(round_info)
945
+
946
+ pairs = [(new_item.item, candidate[1].item) for candidate in all_candidates]
947
+ self.pairwise_comparison.compare_pairs(pairs)
948
+ return round_idx, round_candidates
949
+
950
+
951
+ def find_sample_index(self,
952
+ new_item: Dict,
953
+ initial_comparisons: int = 5,
954
+ num_rounds: int = 3,
955
+ search_method: str = 'binary_search',
956
+ start_pos: int = None) -> Dict:
957
+ """Find the insertion position for a new sample in the existing ranking.
958
+
959
+ Args:
960
+ new_item: New item to find position for, should have 'item_id' field
961
+ initial_comparisons: Number of initial random comparisons
962
+ num_rounds: Number of comparison rounds
963
+ search_method: Specify search method, 'binary_search' or 'heuristic_search'
964
+ start_pos: Warm-start position for binary/heuristic search. If None,
965
+ fall back to ruler midpoint / segment-uniform sampling.
966
+
967
+ Returns:
968
+ Dict containing:
969
+ - position: int, insertion position (0-based index)
970
+ - score: float, final score of the new item
971
+ - comparison_results: Dict, comparison results for the new item
972
+ - comparison_counts: Dict, comparison counts for the new item
973
+ - rounds_completed: int, number of rounds completed
974
+ - converged: bool, whether the score has converged
975
+ - round_candidates: List[Dict], candidates selected in each round
976
+ """
977
+ logger.info(f"Starting position finding process for new item {new_item['item_id']}")
978
+ new_item = RulerItem(
979
+ item_id=str(new_item['item_id']),
980
+ item=new_item,
981
+ metadata={}
982
+ )
983
+ # Get ruler items
984
+ with self._ruler_lock:
985
+ ruler_items = list(self.ruler_items)
986
+
987
+ if not ruler_items:
988
+ raise ValueError("No ruler items available. Please run get_ranking first.")
989
+
990
+
991
+ if search_method == 'heuristic_search':
992
+ round_idx, round_candidates = self._heuristic_search(new_item, num_rounds, initial_comparisons, ruler_items, start_pos=start_pos)
993
+ elif search_method == 'binary_search':
994
+ round_idx, round_candidates = self._binary_search(new_item, ruler_items, start_pos=start_pos)
995
+ elif search_method == 'full_traversal':
996
+ round_idx, round_candidates = self._full_traversal(new_item, ruler_items)
997
+ elif search_method == 'similarity_search':
998
+ round_idx, round_candidates = self._similarity_search(new_item, num_rounds, initial_comparisons, ruler_items)
999
+ else:
1000
+ raise ValueError(f"Unknown search method: {search_method}")
1001
+
1002
+
1003
+ # Estimate new item score while keeping existing scores fixed
1004
+ new_item.score, new_item.sigma = self.estimate_new_item_score(new_item, ruler_items)
1005
+ # Sort items by score
1006
+ all_items = ruler_items + [new_item]
1007
+ sorted_items = sorted(all_items, key=lambda x: x.score, reverse=True)
1008
+ insertion_pos = next(i for i, item in enumerate(sorted_items)
1009
+ if item.item_id == new_item.item_id)
1010
+
1011
+ # Get adjacent items if they exist
1012
+ prev_item = sorted_items[insertion_pos - 1] if insertion_pos > 0 else None
1013
+ next_item = sorted_items[insertion_pos + 1] if insertion_pos < len(sorted_items) - 1 else None
1014
+
1015
+ logger.debug(f"Final insertion position: {insertion_pos}, score: {new_item.score:.4f}")
1016
+ if prev_item:
1017
+ logger.debug(f"Previous item: ID={prev_item.item_id}, Score={prev_item.score:.4f}")
1018
+ logger.debug(f"Previous item details: {json.dumps(prev_item.item, ensure_ascii=False, indent=2)}")
1019
+ if next_item:
1020
+ logger.debug(f"Next item: ID={next_item.item_id}, Score={next_item.score:.4f}")
1021
+ logger.debug(f"Next item details: {json.dumps(next_item.item, ensure_ascii=False, indent=2)}")
1022
+ logger.debug(f"Current item: ID={new_item.item_id}, Score={new_item.score:.4f}")
1023
+ logger.debug(f"Current item details: {json.dumps(new_item.item, ensure_ascii=False, indent=2)}")
1024
+
1025
+ # Get comparison results for the new item
1026
+ all_item_ids = [item.item_id for item in ruler_items] + [new_item.item_id]
1027
+ all_comparison_info = self.pairwise_comparison.get_compare_information(all_item_ids)
1028
+
1029
+ # Filter comparison results to only include pairs with new_item
1030
+ filtered_comparison_results = {
1031
+ pair_key: results
1032
+ for pair_key, results in all_comparison_info['comparison_results'].items()
1033
+ if new_item.item_id in pair_key.split('_')
1034
+ }
1035
+
1036
+ # Filter comparison counts to only include new_item
1037
+ filtered_comparison_counts = {
1038
+ item_id: count
1039
+ for item_id, count in all_comparison_info['comparison_counts'].items()
1040
+ if item_id == new_item.item_id
1041
+ }
1042
+
1043
+ # Filter pair comparison counts to only include pairs with new_item
1044
+ filtered_pair_comparison_counts = {
1045
+ pair_key: count
1046
+ for pair_key, count in all_comparison_info['pair_comparison_counts'].items()
1047
+ if new_item.item_id in pair_key.split('_')
1048
+ }
1049
+
1050
+ return {
1051
+ 'position': insertion_pos,
1052
+ 'score': new_item.score,
1053
+ 'sigma': new_item.sigma,
1054
+ 'comparison_results': filtered_comparison_results,
1055
+ 'comparison_counts': filtered_comparison_counts,
1056
+ 'pair_comparison_counts': filtered_pair_comparison_counts,
1057
+ 'rounds_completed': round_idx + 1,
1058
+ 'round_candidates': round_candidates
1059
+ }
1060
+
1061
+ def find_sample_index_binary_search(self, new_item: Dict, comparison_time: int = 1
1062
+ ) -> Dict:
1063
+ """Find the insertion position for a new sample in the existing ranking with binary_search
1064
+
1065
+ Args:
1066
+ new_item: New item to find position for, should have 'item_id' field
1067
+
1068
+ Returns:
1069
+ Dict containing:
1070
+ - position: int, insertion position (0-based index)
1071
+ - comparison_results: Dict, comparison results for the new item
1072
+ - comparison_counts: Dict, comparison counts for the new item
1073
+ """
1074
+ logger.info(f"Starting position finding process for new item {new_item['item_id']}")
1075
+ new_item = RulerItem(
1076
+ item_id=str(new_item['item_id']),
1077
+ item=new_item,
1078
+ metadata={}
1079
+ )
1080
+
1081
+ # Get ruler items
1082
+ with self._ruler_lock:
1083
+ ruler_items = list(self.ruler_items)
1084
+ # 留一法,排出掉自身
1085
+ temp_ruler = [item for item in ruler_items if item.item_id != new_item.item_id]
1086
+
1087
+ if not temp_ruler:
1088
+ raise ValueError("No ruler items available. Please run get_ranking first.")
1089
+ try:
1090
+ left = 0
1091
+ right = len(temp_ruler)
1092
+ comparison_pairs = []
1093
+ while left < right:
1094
+ mid = (left + right) // 2
1095
+ ref_item = temp_ruler[mid]
1096
+ pair = (new_item.item, ref_item.item)
1097
+ # 不用cache,因为这里是二分查找,每次都要比较
1098
+ result = self.pairwise_comparison.compare_single_pair(pair, use_cache=False)
1099
+ judgment = result.get("judgment")
1100
+ item_id_a = result.get("item_id_a")
1101
+ item_id_b = result.get("item_id_b")
1102
+ # 判断new_item_id在A还是B
1103
+ if item_id_a == new_item.item_id:
1104
+ # new_item是A
1105
+ if judgment == 1:
1106
+ is_new_more_severe = True
1107
+ elif judgment == -1:
1108
+ is_new_more_severe = False
1109
+ else: # judgment == 0
1110
+ is_new_more_severe = False # 默认插后面
1111
+ elif item_id_b == new_item.item_id:
1112
+ # new_item是B
1113
+ if judgment == 1:
1114
+ is_new_more_severe = False
1115
+ elif judgment == -1:
1116
+ is_new_more_severe = True
1117
+ else: # judgment == 0
1118
+ is_new_more_severe = False
1119
+ else:
1120
+ logger.error(f"Neither item_id_a nor item_id_b matches new_item_id: {new_item.item_id}")
1121
+ raise ValueError("Pairwise comparison result does not match new_item_id.")
1122
+ comparison_pairs.append({
1123
+ "new_item_id": new_item.item_id,
1124
+ "ref_item_id": ref_item.item_id,
1125
+ "ref_item_rank": mid,
1126
+ "judgment": judgment,
1127
+ "item_id_a": item_id_a,
1128
+ "item_id_b": item_id_b,
1129
+ "result": result,
1130
+ "is_new_more_severe": is_new_more_severe
1131
+ })
1132
+ if is_new_more_severe:
1133
+ right = mid
1134
+ else:
1135
+ left = mid + 1
1136
+ logger.info(f"Final insertion position for new item {new_item.item_id}: {left}")
1137
+ return {
1138
+ "position": left, # 0-based插入点
1139
+ "comparison_pairs": comparison_pairs
1140
+ }
1141
+ except Exception as e:
1142
+ logger.error(f"Error in find_sample_index_binary_search: {str(e)}")
1143
+ raise
1144
+
1145
+ def _calculate_thompson_params(self, item_ids: List[str]) -> Dict[str, Dict[str, float]]:
1146
+ """Calculate Thompson Sampling parameters for items based on their comparison history.
1147
+
1148
+ Args:
1149
+ item_ids: List of item IDs to calculate parameters for
1150
+
1151
+ Returns:
1152
+ Dictionary mapping item IDs to their Thompson parameters (alpha, beta)
1153
+ """
1154
+ # Get comparison results for all items
1155
+ comparison_info = self.pairwise_comparison.get_compare_information(item_ids)
1156
+ comparison_results = comparison_info['comparison_results']
1157
+
1158
+ # Initialize parameters for all items
1159
+ thompson_params = {item_id: {'alpha': 1.0, 'beta': 1.0} for item_id in item_ids}
1160
+
1161
+ # Update parameters based on comparison results
1162
+ for pair_key, results in comparison_results.items():
1163
+ if not results:
1164
+ continue
1165
+
1166
+ # Parse item IDs from pair_key
1167
+ id1, id2 = pair_key.split('_')
1168
+ if id1 not in item_ids or id2 not in item_ids:
1169
+ continue
1170
+
1171
+ # Count votes for each judgment
1172
+ votes = {1: 0, -1: 0, 0: 0}
1173
+ for result in results:
1174
+ orig_id1, orig_id2 = result['original_ids']
1175
+ if (orig_id1, orig_id2) == (id1, id2):
1176
+ votes[result['judgment']] += 1
1177
+ else:
1178
+ votes[-result['judgment']] += 1
1179
+
1180
+ # Get the judgment with the most votes
1181
+ final_judgment = max(votes.items(), key=lambda x: x[1])[0]
1182
+
1183
+ # Update parameters based on final judgment
1184
+ if final_judgment > 0:
1185
+ thompson_params[id1]['alpha'] += 1.0
1186
+ thompson_params[id2]['beta'] += 1.0
1187
+ elif final_judgment < 0:
1188
+ thompson_params[id2]['alpha'] += 1.0
1189
+ thompson_params[id1]['beta'] += 1.0
1190
+
1191
+ return thompson_params
1192
+
1193
+ def _sample_pairs_by_thompson(self, items: List[RulerItem], num_pairs: int,
1194
+ pair_comparison_counts: Dict[str, int]) -> List[Tuple[RulerItem, RulerItem]]:
1195
+ """Sample pairs using Thompson Sampling.
1196
+
1197
+ Args:
1198
+ items: List of items to sample from
1199
+ num_pairs: Number of pairs to sample
1200
+ pair_comparison_counts: Dictionary of pair comparison counts
1201
+
1202
+ Returns:
1203
+ List of (item1, item2) pairs to compare
1204
+ """
1205
+ n = len(items)
1206
+ thompson_pairs = []
1207
+
1208
+ # Calculate Thompson parameters for all items
1209
+ item_ids = [item.item_id for item in items]
1210
+ thompson_params = self._calculate_thompson_params(item_ids)
1211
+
1212
+ # Sample from Beta distributions
1213
+ item_scores = {}
1214
+ for item in items:
1215
+ params = thompson_params[item.item_id]
1216
+ # Sample from Beta distribution
1217
+ score = np.random.beta(params['alpha'], params['beta'])
1218
+ item_scores[item.item_id] = score
1219
+
1220
+ # Sort items by Thompson scores
1221
+ sorted_items = sorted(items, key=lambda x: item_scores[x.item_id], reverse=True)
1222
+
1223
+ # Select pairs based on Thompson scores
1224
+ for i in range(min(n-1, num_pairs)):
1225
+ item1 = sorted_items[i]
1226
+ item2 = sorted_items[i+1]
1227
+
1228
+ pair_key = self.pairwise_comparison.get_pair_key(
1229
+ item1.item_id,
1230
+ item2.item_id
1231
+ )
1232
+
1233
+ if pair_comparison_counts.get(pair_key, 0) < self.pairwise_comparison.max_comparisons_per_pair:
1234
+ thompson_pairs.append((item1, item2))
1235
+
1236
+ return thompson_pairs
1237
+
1238
+ def understand_ruler_marks(self):
1239
+ item_ids = [item.item_id for item in self.ruler_items]
1240
+ compare_information = self.pairwise_comparison.get_compare_information(item_ids)
1241
+
1242
+ def _run_one_understanding(item, comparison_results):
1243
+
1244
+ if "understanding" in item.metadata:
1245
+ return item.metadata["understanding"]
1246
+
1247
+ understanding = self.pairwise_comparison.understand_by_pairs(item.item, compare_information["comparison_results"])
1248
+ return understanding
1249
+
1250
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
1251
+ futures = []
1252
+ all_items = list(self.ruler_items)
1253
+ for item in all_items:
1254
+ future = executor.submit(_run_one_understanding, item, comparison_results=compare_information["comparison_results"])
1255
+ futures.append((future, item))
1256
+
1257
+ # Get successfully compared pairs
1258
+ successful_items = []
1259
+ for i, (future, item) in tqdm(enumerate(futures), desc="Generating understanding", total=len(futures), leave=False):
1260
+ try:
1261
+ result = future.result()
1262
+ if result: # If comparison was successful
1263
+ item.metadata["understanding"] = result
1264
+
1265
+ if i % 10 == 0:
1266
+ with self._ruler_lock:
1267
+ self.ruler_items[:] = all_items
1268
+ self.save_state()
1269
+ except Exception as e:
1270
+ logger.error(f"Error generating understanding: {str(e)}")
1271
+
1272
+ # Save the remainder of i%10
1273
+ with self._ruler_lock:
1274
+ self.ruler_items[:] = all_items
1275
+ self.save_state()
1276
+
1277
+ #return successful_pairs
1278
+
1279
+ # i = 0
1280
+ # all_items = list(self.ruler_items)
1281
+ # for item in tqdm(all_items):
1282
+ # i += 1
1283
+ # if "understanding" in item.metadata:
1284
+ # continue
1285
+ # understanding = self.pairwise_comparison.understand_by_pairs(item.item, compare_information["comparison_results"])
1286
+ # item.metadata["understanding"] = understanding
1287
+ # if i % 10 == 0:
1288
+ # with self._ruler_lock:
1289
+ # self.ruler_items[:] = all_items
1290
+ # self.save_state()
1291
+
1292
+ self.save_state()
1293
+
1294
+
1295
+ def generate_ranking_reasoning(self):
1296
+ """Generate ranking reasoning for all items in the ranking."""
1297
+ # Get all items in the ranking
1298
+ all_items = list(self.ruler_items)
1299
+ for i in range(len(all_items)):
1300
+ item = all_items[i]
1301
+ prev_item = all_items[i-1] if i > 0 else None
1302
+ next_item = all_items[i+1] if i < len(all_items) - 1 else None
1303
+ if prev_item:
1304
+ comparison_result = self.pairwise_comparison.compare_single_pair(
1305
+ (item.item, prev_item.item)
1306
+ )
1307
+ original_id_a, original_id_b = comparison_result['original_ids']
1308
+ prev_conflit = False
1309
+ if original_id_a == item.item_id:
1310
+ a_str = "current"
1311
+ b_str = "previous"
1312
+ if comparison_result['judgment'] > 0:
1313
+ prev_conflit = True
1314
+ else:
1315
+ a_str = "previous"
1316
+ b_str = "current"
1317
+ if comparison_result['judgment'] < 0:
1318
+ prev_conflit = True
1319
+ item.metadata["prev_item_compare_result"] = f"""
1320
+ Comment A({a_str}): {original_id_a}
1321
+ Comment B({b_str}): {original_id_b}
1322
+
1323
+ {comparison_result['raw_response']}
1324
+ """
1325
+ item.metadata["prev_item_conflict"] = prev_conflit
1326
+ if next_item:
1327
+ comparison_result = self.pairwise_comparison.compare_single_pair(
1328
+ (item.item, next_item.item)
1329
+ )
1330
+ original_id_a, original_id_b = comparison_result['original_ids']
1331
+ next_conflit = False
1332
+ if original_id_a == item.item_id:
1333
+ a_str = "current"
1334
+ b_str = "next"
1335
+ if comparison_result['judgment'] < 0:
1336
+ next_conflit = True
1337
+ else:
1338
+ a_str = "next"
1339
+ b_str = "current"
1340
+ if comparison_result['judgment'] > 0:
1341
+ next_conflit = True
1342
+ item.metadata["next_item_compare_result"] = f"""
1343
+ Comment A({a_str}): {original_id_a}
1344
+ Comment B({b_str}): {original_id_b}
1345
+ {comparison_result['raw_response']}
1346
+ """
1347
+
1348
+ item.metadata["next_item_conflict"] = next_conflit
1349
+ with self._ruler_lock:
1350
+ self.ruler_items[:] = all_items
1351
+ self.save_state()
1352
+
1353
+ def compare_with_anchor(self, samples: List[Dict], anchor_rank: int=None, anchor_score=None, reuse_compair=True) -> List[Dict]:
1354
+ """Compare samples with an anchor sample at specified rank.
1355
+
1356
+ Args:
1357
+ samples: List of samples to compare with anchor
1358
+ anchor_rank: Rank of the anchor sample (0-based)
1359
+
1360
+ Returns:
1361
+ List of dictionaries containing:
1362
+ - sample: Original sample
1363
+ - is_better_than_anchor: Whether sample is better than anchor
1364
+ - comparison_result: Raw comparison result
1365
+ """
1366
+ # Get anchor sample
1367
+ with self._ruler_lock:
1368
+ anchor_item = None
1369
+ if anchor_rank is not None:
1370
+ anchor_item = next((item for item in self.ruler_items if item.rank == anchor_rank), None)
1371
+ if anchor_item is None:
1372
+ raise ValueError(f"No item found with rank {anchor_rank}")
1373
+ elif anchor_score is not None:
1374
+ for item in sorted(self.ruler_items, key=lambda x: x.score, reverse=True):
1375
+ if item.score < anchor_score:
1376
+ anchor_item = item
1377
+ break
1378
+ if anchor_item is None:
1379
+ raise ValueError(f"No item found with score less than {anchor_score}")
1380
+ else:
1381
+ raise ValueError("Either anchor_rank or anchor_score must be provided")
1382
+
1383
+ # Get history
1384
+ sample_ids = [str(s['item_id']) for s in samples]
1385
+ comparison_results = self.pairwise_comparison.load_data_to_cache_from_es(sample_ids)
1386
+
1387
+ results = []
1388
+ to_compare = []
1389
+
1390
+
1391
+ for sample in samples:
1392
+ sample_id = str(sample['item_id'])
1393
+ inferred = False
1394
+
1395
+ if reuse_compair:
1396
+ for other in self.ruler_items:
1397
+ if other.item_id == anchor_item.item_id:
1398
+ continue # 跳过锚点自身
1399
+ # 获取 sample vs other 的历史比对
1400
+ key = self.pairwise_comparison.get_pair_key(sample_id, other.item_id)
1401
+ history = comparison_results.get(key, [])
1402
+ if not history:
1403
+ continue
1404
+
1405
+ # 统计多数票,判断 sample 相对 other 的关系
1406
+ votes = {1: 0, -1: 0, 0: 0}
1407
+ for r in history:
1408
+ id1, id2 = r['original_ids']
1409
+ # 注意方向:若顺序颠倒,需要取反
1410
+ if (id1, id2) == (sample_id, other.item_id):
1411
+ votes[r['judgment']] += 1
1412
+ else:
1413
+ votes[-r['judgment']] += 1
1414
+ direction, count = max(votes.items(), key=lambda x: x[1])
1415
+
1416
+ # 传递性推断
1417
+ if other.rank > anchor_item.rank and direction < 0:
1418
+ # other 在锚点“之后”且 sample < other => sample < anchor
1419
+ # other 风险比 anchor 低,other 分数比 anchor 低。 然后同时 sample 风险比other 也低,所以 sample 风险比 anchor 低
1420
+ results.append({
1421
+ 'sample': sample,
1422
+ 'final_judgment': -1,
1423
+ 'reason': f"Sample({sample_id}) < Other({other.item_id}) and Other({other.item_id}) < anchor({anchor_item.item_id}). Infereed from {other.item_id}. {history[0]['raw_response']}"
1424
+ })
1425
+ inferred = True
1426
+ elif other.rank < anchor_item.rank and direction > 0:
1427
+ # other 在锚点“之前”且 sample > other => sample > anchor
1428
+ results.append({
1429
+ 'sample': sample,
1430
+ 'final_judgment': 1,
1431
+ 'reason': f"Sample({sample_id}) > Other({other.item_id}) and Other({other.item_id}) > anchor({anchor_item.item_id}). Infereed from {other.item_id}. {history[0]['raw_response']}"
1432
+ })
1433
+ inferred = True
1434
+
1435
+ if inferred:
1436
+ break # 一旦推断成功,跳出 other 循环
1437
+
1438
+ if not inferred:
1439
+ to_compare.append(sample)
1440
+
1441
+ if len(to_compare) > 0:
1442
+ # Prepare pairs for batch comparison
1443
+ pairs = [(sample, anchor_item.item) for sample in to_compare]
1444
+
1445
+ # Batch compare all samples with anchor
1446
+ self.pairwise_comparison.compare_pairs(pairs)
1447
+
1448
+ for sample in to_compare:
1449
+ try:
1450
+ sample_id = str(sample['item_id'])
1451
+ pair_key = self.pairwise_comparison.get_pair_key(sample_id, anchor_item.item_id)
1452
+
1453
+ # Get comparison results for this pair
1454
+ pair_results = self.pairwise_comparison.get_comparison_results(pair_key)
1455
+ # Get the most recent comparison result
1456
+ if pair_results:
1457
+ votes = {1: 0, -1: 0, 0: 0}
1458
+ for result in pair_results:
1459
+ orig_id1, orig_id2 = result['original_ids']
1460
+ if (orig_id1, orig_id2) == (sample_id, anchor_item.item_id):
1461
+ votes[result['judgment']] += 1
1462
+ else:
1463
+ votes[-result['judgment']] += 1
1464
+
1465
+ final_judgment = max(votes.items(), key=lambda x: x[1])[0]
1466
+ results.append({
1467
+ 'sample': sample,
1468
+ 'final_judgment': final_judgment,
1469
+ 'reason': pair_results[0]['raw_response']
1470
+ })
1471
+
1472
+ except Exception as e:
1473
+ logger.error(f"Error processing comparison result for sample {sample.get('item_id', '')}: {str(e)}")
1474
+ raise
1475
+ return results
1476
+
1477
+
1478
+
1479
+
1480
+
1481
+