Spaces:
Sleeping
Sleeping
Update evaluate.py
Browse files- evaluate.py +270 -141
evaluate.py
CHANGED
|
@@ -81,9 +81,6 @@ SUB_TYPE_TO_ABBR = {
|
|
| 81 |
"Answer Refusal": "AR"
|
| 82 |
}
|
| 83 |
|
| 84 |
-
# Sub type 简称到全称的映射(用于调试)
|
| 85 |
-
ABBR_TO_SUB_TYPE = {v: k for k, v in SUB_TYPE_TO_ABBR.items()}
|
| 86 |
-
|
| 87 |
# 指标列表
|
| 88 |
METRICS = ["LLM", "P", "R", "F1", "BLEU-1"]
|
| 89 |
|
|
@@ -175,7 +172,6 @@ class LexicalMetricsCalculator:
|
|
| 175 |
return 2 * prec * rec / (prec + rec) if (prec + rec) > 0 else 0.0
|
| 176 |
|
| 177 |
def _bleu1(self, prediction: str, reference: str, **kwargs) -> float:
|
| 178 |
-
# Tokenize
|
| 179 |
pred_tokens = self.text_processor.tokenize(prediction, remove_stopwords=False)
|
| 180 |
ref_tokens = self.text_processor.tokenize(reference, remove_stopwords=False)
|
| 181 |
|
|
@@ -184,27 +180,25 @@ class LexicalMetricsCalculator:
|
|
| 184 |
if pred_tokens == ref_tokens:
|
| 185 |
return 1.0
|
| 186 |
|
| 187 |
-
# Use sentence_bleu to calculate BLEU-1 (weights=(1, 0, 0, 0) means use only 1-gram)
|
| 188 |
try:
|
| 189 |
-
# Add smoothing function to avoid zero score issues
|
| 190 |
smoothie = SmoothingFunction().method4
|
| 191 |
bleu_score = sentence_bleu(
|
| 192 |
-
[ref_tokens],
|
| 193 |
pred_tokens,
|
| 194 |
-
weights=(1.0, 0, 0, 0),
|
| 195 |
smoothing_function=smoothie
|
| 196 |
)
|
| 197 |
return float(bleu_score)
|
| 198 |
-
except Exception
|
| 199 |
return 0.0
|
| 200 |
|
| 201 |
|
| 202 |
# =========================================================
|
| 203 |
-
# 第二部分:LLM-as-judge 评估器
|
| 204 |
# =========================================================
|
| 205 |
|
| 206 |
class LLMJudgeEvaluator:
|
| 207 |
-
"""LLM-as-judge 评估器"""
|
| 208 |
|
| 209 |
SCORE_RUBRIC = """
|
| 210 |
**Score 0 (Incorrect / Miss):**
|
|
@@ -232,6 +226,7 @@ class LLMJudgeEvaluator:
|
|
| 232 |
self.base_url = self.config.get('base_url') or os.getenv('OPENAI_BASE_URL', 'https://api.openai.com/v1')
|
| 233 |
self.model_name = self.config.get('model_name', 'gpt-4o-mini')
|
| 234 |
self.temperature = self.config.get('temperature', 0)
|
|
|
|
| 235 |
self._client = None
|
| 236 |
|
| 237 |
def _get_client(self):
|
|
@@ -241,60 +236,120 @@ class LLMJudgeEvaluator:
|
|
| 241 |
self._client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
| 242 |
return self._client
|
| 243 |
|
| 244 |
-
def
|
| 245 |
-
|
|
|
|
|
|
|
| 246 |
|
| 247 |
### Scoring Rubric
|
| 248 |
{self.SCORE_RUBRIC}
|
| 249 |
|
| 250 |
-
###
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
{
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
-
|
| 257 |
-
{assistant_answer if assistant_answer else "(No answer provided)"}
|
| 258 |
|
|
|
|
| 259 |
### Output Format
|
| 260 |
-
Output strictly in JSON format:
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
@tenacity.retry(
|
| 267 |
stop=tenacity.stop_after_attempt(3),
|
| 268 |
wait=tenacity.wait_exponential(multiplier=1, min=1, max=10),
|
| 269 |
retry=tenacity.retry_if_exception_type(Exception),
|
| 270 |
)
|
| 271 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
try:
|
| 273 |
client = self._get_client()
|
| 274 |
-
prompt = self.
|
| 275 |
|
| 276 |
response = client.chat.completions.create(
|
| 277 |
model=self.model_name,
|
| 278 |
messages=[{"role": "user", "content": prompt}],
|
| 279 |
temperature=self.temperature,
|
| 280 |
-
timeout=
|
| 281 |
)
|
| 282 |
|
| 283 |
response_text = response.choices[0].message.content.strip()
|
| 284 |
-
|
| 285 |
|
| 286 |
-
if json_match:
|
| 287 |
-
result = json.loads(json_match.group())
|
| 288 |
-
score = float(result.get('score', 0))
|
| 289 |
-
valid_scores = [0, 0.25, 0.5, 0.75, 1]
|
| 290 |
-
if score not in valid_scores:
|
| 291 |
-
score = min(valid_scores, key=lambda x: abs(x - score))
|
| 292 |
-
return {'score': score, 'reasoning': result.get('reasoning', ''), 'success': True}
|
| 293 |
-
else:
|
| 294 |
-
return {'score': 0, 'reasoning': 'Parse failed', 'success': False}
|
| 295 |
except Exception as e:
|
| 296 |
-
logger.error(f"LLM评估失败: {e}")
|
| 297 |
-
return {'score': 0, 'reasoning': str(e), 'success': False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
|
| 300 |
# =========================================================
|
|
@@ -305,21 +360,18 @@ class ScoreCollector:
|
|
| 305 |
"""分数收集器,用于按类别统计加权平均"""
|
| 306 |
|
| 307 |
def __init__(self):
|
| 308 |
-
self.scores = defaultdict(list)
|
| 309 |
-
self.weights = defaultdict(int)
|
| 310 |
|
| 311 |
def add(self, category: str, score: float):
|
| 312 |
-
"""添加一个分数"""
|
| 313 |
self.scores[category].append(score)
|
| 314 |
self.weights[category] += 1
|
| 315 |
|
| 316 |
def get_weighted_average(self, category: str = None) -> float:
|
| 317 |
-
"""获取加权平均分数"""
|
| 318 |
if category:
|
| 319 |
scores = self.scores.get(category, [])
|
| 320 |
return np.mean(scores) if scores else 0.0
|
| 321 |
|
| 322 |
-
# 计算所有分数的加权平均
|
| 323 |
total_score = 0
|
| 324 |
total_weight = 0
|
| 325 |
for cat, scores in self.scores.items():
|
|
@@ -328,7 +380,6 @@ class ScoreCollector:
|
|
| 328 |
return total_score / total_weight if total_weight > 0 else 0.0
|
| 329 |
|
| 330 |
def get_all_averages(self) -> Dict[str, float]:
|
| 331 |
-
"""获取所有类别的平均值"""
|
| 332 |
return {cat: np.mean(scores) if scores else 0.0
|
| 333 |
for cat, scores in self.scores.items()}
|
| 334 |
|
|
@@ -348,7 +399,7 @@ class SubmissionEvaluator:
|
|
| 348 |
if self.enable_llm_judge and llm_config:
|
| 349 |
try:
|
| 350 |
self.llm_judge = LLMJudgeEvaluator(llm_config)
|
| 351 |
-
logger.info("LLM-as-judge 已启用")
|
| 352 |
except Exception as e:
|
| 353 |
logger.warning(f"LLM-as-judge 初始化失败: {e}")
|
| 354 |
self.enable_llm_judge = False
|
|
@@ -363,129 +414,206 @@ class SubmissionEvaluator:
|
|
| 363 |
raise ValueError(f"文件格式错误: 缺少 'predictions' 字段")
|
| 364 |
return data['predictions']
|
| 365 |
|
| 366 |
-
def evaluate_single_prediction(self, pred: Dict
|
| 367 |
-
"""评估单个预测
|
| 368 |
system_answer = pred.get('system_answer', '').strip()
|
| 369 |
original_answer = pred.get('original_answer', '').strip()
|
| 370 |
|
| 371 |
-
# 计算词法指标
|
| 372 |
lexical = self.lexical_calculator.calculate(system_answer, original_answer)
|
| 373 |
|
| 374 |
-
|
| 375 |
'P': lexical['precision'],
|
| 376 |
'R': lexical['recall'],
|
| 377 |
'F1': lexical['f1'],
|
| 378 |
-
'BLEU-1': lexical['bleu1']
|
| 379 |
-
'LLM': 0.0
|
| 380 |
}
|
| 381 |
-
|
| 382 |
-
# LLM 评估
|
| 383 |
-
if self.enable_llm_judge and enable_llm and self.llm_judge:
|
| 384 |
-
llm_result = self.llm_judge.evaluate_single(
|
| 385 |
-
question=pred.get('question_text', ''),
|
| 386 |
-
ground_truth=original_answer,
|
| 387 |
-
assistant_answer=system_answer
|
| 388 |
-
)
|
| 389 |
-
if llm_result['success']:
|
| 390 |
-
result['LLM'] = llm_result['score']
|
| 391 |
-
|
| 392 |
-
return result
|
| 393 |
|
| 394 |
-
def evaluate_dataset(self, predictions: List[Dict], dataset_name: str) -> Dict[str, Any]:
|
| 395 |
"""
|
| 396 |
评估单个数据集,按 main_type 和 sub_type 分类统计
|
| 397 |
-
|
| 398 |
-
Returns:
|
| 399 |
-
包含按指标、按 main_type、按 sub_type 分类的分数
|
| 400 |
"""
|
| 401 |
-
#
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
for metric in METRICS}
|
| 405 |
|
| 406 |
-
# 按
|
| 407 |
-
|
|
|
|
| 408 |
|
| 409 |
-
#
|
| 410 |
-
|
| 411 |
-
main_type_counts = defaultdict(int)
|
| 412 |
-
sub_type_counts = defaultdict(int)
|
| 413 |
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
# 获取类型信息
|
| 416 |
main_type_full = pred.get('question_type', {}).get('main_type', '')
|
| 417 |
sub_type_full = pred.get('question_type', {}).get('sub_type', '')
|
| 418 |
|
| 419 |
-
# 映射到内部标识
|
| 420 |
main_type = MAIN_TYPE_MAPPING.get(main_type_full, '')
|
| 421 |
-
sub_type_abbr = SUB_TYPE_TO_ABBR.get(sub_type_full,
|
| 422 |
-
|
| 423 |
-
#
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
#
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
# 构建结果
|
| 443 |
result = {
|
| 444 |
-
'total':
|
| 445 |
-
'main_type_counts':
|
| 446 |
-
'sub_type_counts':
|
| 447 |
}
|
| 448 |
|
| 449 |
-
#
|
| 450 |
for metric in METRICS:
|
| 451 |
-
result[metric] = {
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
scores = self.evaluate_single_prediction(pred)
|
| 457 |
-
all_scores.append(scores[metric])
|
| 458 |
-
result[metric]['overall'] = np.mean(all_scores) if all_scores else 0.0
|
| 459 |
-
|
| 460 |
-
# 按 main_type 计算
|
| 461 |
-
result[metric]['recall'] = {
|
| 462 |
-
'score': metric_main_collectors[metric].get('recall', ScoreCollector()).get_weighted_average(),
|
| 463 |
-
'sub_types': {}
|
| 464 |
-
}
|
| 465 |
-
result[metric]['reasoning'] = {
|
| 466 |
-
'score': metric_main_collectors[metric].get('reasoning', ScoreCollector()).get_weighted_average(),
|
| 467 |
-
'sub_types': {}
|
| 468 |
-
}
|
| 469 |
-
result[metric]['application'] = {
|
| 470 |
-
'score': metric_main_collectors[metric].get('application', ScoreCollector()).get_weighted_average(),
|
| 471 |
-
'sub_types': {}
|
| 472 |
}
|
| 473 |
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
|
| 490 |
return result
|
| 491 |
|
|
@@ -676,7 +804,8 @@ def evaluate_submission(dyadic_path: str, multiparty_path: str,
|
|
| 676 |
'api_key': os.getenv('OPENAI_API_KEY'),
|
| 677 |
'base_url': os.getenv('OPENAI_BASE_URL', 'https://api.openai.com/v1'),
|
| 678 |
'model_name': os.getenv('LLM_JUDGE_MODEL', 'gpt-4o-mini'),
|
| 679 |
-
'temperature': 0
|
|
|
|
| 680 |
}
|
| 681 |
_evaluator = SubmissionEvaluator(llm_config=llm_config, enable_llm_judge=True)
|
| 682 |
|
|
|
|
| 81 |
"Answer Refusal": "AR"
|
| 82 |
}
|
| 83 |
|
|
|
|
|
|
|
|
|
|
| 84 |
# 指标列表
|
| 85 |
METRICS = ["LLM", "P", "R", "F1", "BLEU-1"]
|
| 86 |
|
|
|
|
| 172 |
return 2 * prec * rec / (prec + rec) if (prec + rec) > 0 else 0.0
|
| 173 |
|
| 174 |
def _bleu1(self, prediction: str, reference: str, **kwargs) -> float:
|
|
|
|
| 175 |
pred_tokens = self.text_processor.tokenize(prediction, remove_stopwords=False)
|
| 176 |
ref_tokens = self.text_processor.tokenize(reference, remove_stopwords=False)
|
| 177 |
|
|
|
|
| 180 |
if pred_tokens == ref_tokens:
|
| 181 |
return 1.0
|
| 182 |
|
|
|
|
| 183 |
try:
|
|
|
|
| 184 |
smoothie = SmoothingFunction().method4
|
| 185 |
bleu_score = sentence_bleu(
|
| 186 |
+
[ref_tokens],
|
| 187 |
pred_tokens,
|
| 188 |
+
weights=(1.0, 0, 0, 0),
|
| 189 |
smoothing_function=smoothie
|
| 190 |
)
|
| 191 |
return float(bleu_score)
|
| 192 |
+
except Exception:
|
| 193 |
return 0.0
|
| 194 |
|
| 195 |
|
| 196 |
# =========================================================
|
| 197 |
+
# 第二部分:LLM-as-judge 评估器(支持批量评估)
|
| 198 |
# =========================================================
|
| 199 |
|
| 200 |
class LLMJudgeEvaluator:
|
| 201 |
+
"""LLM-as-judge 评估器 - 支持批量评估"""
|
| 202 |
|
| 203 |
SCORE_RUBRIC = """
|
| 204 |
**Score 0 (Incorrect / Miss):**
|
|
|
|
| 226 |
self.base_url = self.config.get('base_url') or os.getenv('OPENAI_BASE_URL', 'https://api.openai.com/v1')
|
| 227 |
self.model_name = self.config.get('model_name', 'gpt-4o-mini')
|
| 228 |
self.temperature = self.config.get('temperature', 0)
|
| 229 |
+
self.batch_size = self.config.get('batch_size', 5)
|
| 230 |
self._client = None
|
| 231 |
|
| 232 |
def _get_client(self):
|
|
|
|
| 236 |
self._client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
| 237 |
return self._client
|
| 238 |
|
| 239 |
+
def _build_batch_prompt(self, qa_pairs: List[Dict[str, str]]) -> str:
|
| 240 |
+
"""构建批量评估的提示词,包含临时 ID"""
|
| 241 |
+
prompt = f"""You are an impartial judge evaluating an AI assistant's answers.
|
| 242 |
+
You will evaluate {len(qa_pairs)} questions and provide scores for each.
|
| 243 |
|
| 244 |
### Scoring Rubric
|
| 245 |
{self.SCORE_RUBRIC}
|
| 246 |
|
| 247 |
+
### Questions and Answers
|
| 248 |
+
"""
|
| 249 |
+
for item in qa_pairs:
|
| 250 |
+
prompt += f"""
|
| 251 |
+
--- Question ID: {item['temp_id']} ---
|
| 252 |
+
Question: {item['question']}
|
| 253 |
+
Ground Truth: {item['ground_truth']}
|
| 254 |
+
Assistant Answer: {item['assistant_answer'] if item['assistant_answer'] else "(No answer provided)"}
|
| 255 |
|
| 256 |
+
"""
|
|
|
|
| 257 |
|
| 258 |
+
prompt += """
|
| 259 |
### Output Format
|
| 260 |
+
Output strictly in JSON format as a list of objects, each containing the question_id and score:
|
| 261 |
+
[
|
| 262 |
+
{"question_id": 0, "score": 0.75, "reasoning": "<explanation>"},
|
| 263 |
+
{"question_id": 1, "score": 0.5, "reasoning": "<explanation>"},
|
| 264 |
+
...
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
Each score must be one of: 0, 0.25, 0.5, 0.75, or 1.
|
| 268 |
+
The number of objects must equal the number of questions.
|
| 269 |
+
The question_id must match the ID provided above.
|
| 270 |
"""
|
| 271 |
+
return prompt
|
| 272 |
+
|
| 273 |
+
def _parse_batch_response(self, response_text: str, qa_pairs: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
| 274 |
+
"""解析批量评估的响应,按临时 ID 匹配"""
|
| 275 |
+
expected_count = len(qa_pairs)
|
| 276 |
+
|
| 277 |
+
# 创建 ID 到结果的映射
|
| 278 |
+
result_map = {item['temp_id']: None for item in qa_pairs}
|
| 279 |
+
|
| 280 |
+
try:
|
| 281 |
+
# 尝试解析 JSON 数组
|
| 282 |
+
json_match = re.search(r'\[.*\]', response_text, re.DOTALL)
|
| 283 |
+
if json_match:
|
| 284 |
+
parsed_results = json.loads(json_match.group())
|
| 285 |
+
|
| 286 |
+
if isinstance(parsed_results, list):
|
| 287 |
+
for r in parsed_results:
|
| 288 |
+
if isinstance(r, dict) and 'question_id' in r:
|
| 289 |
+
qid = r['question_id']
|
| 290 |
+
score = float(r.get('score', 0))
|
| 291 |
+
valid_scores = [0, 0.25, 0.5, 0.75, 1]
|
| 292 |
+
if score not in valid_scores:
|
| 293 |
+
score = min(valid_scores, key=lambda x: abs(x - score))
|
| 294 |
+
result_map[qid] = {
|
| 295 |
+
'score': score,
|
| 296 |
+
'reasoning': r.get('reasoning', ''),
|
| 297 |
+
'success': True
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
# 为每个问题返回结果(未匹配的使用默认值)
|
| 301 |
+
results = []
|
| 302 |
+
for item in qa_pairs:
|
| 303 |
+
qid = item['temp_id']
|
| 304 |
+
if result_map[qid] is not None:
|
| 305 |
+
results.append(result_map[qid])
|
| 306 |
+
else:
|
| 307 |
+
logger.warning(f"问题 ID {qid} 未在响应中找到,使用默认值")
|
| 308 |
+
results.append({'score': 0, 'reasoning': 'Not found in response', 'success': False})
|
| 309 |
+
|
| 310 |
+
return results
|
| 311 |
+
|
| 312 |
+
except Exception as e:
|
| 313 |
+
logger.error(f"解析批量响应失败: {e}")
|
| 314 |
+
return [{'score': 0, 'reasoning': str(e), 'success': False} for _ in range(expected_count)]
|
| 315 |
|
| 316 |
@tenacity.retry(
|
| 317 |
stop=tenacity.stop_after_attempt(3),
|
| 318 |
wait=tenacity.wait_exponential(multiplier=1, min=1, max=10),
|
| 319 |
retry=tenacity.retry_if_exception_type(Exception),
|
| 320 |
)
|
| 321 |
+
def evaluate_batch(self, qa_pairs: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
| 322 |
+
"""批量评估多个问答对"""
|
| 323 |
+
if not qa_pairs:
|
| 324 |
+
return []
|
| 325 |
+
|
| 326 |
try:
|
| 327 |
client = self._get_client()
|
| 328 |
+
prompt = self._build_batch_prompt(qa_pairs)
|
| 329 |
|
| 330 |
response = client.chat.completions.create(
|
| 331 |
model=self.model_name,
|
| 332 |
messages=[{"role": "user", "content": prompt}],
|
| 333 |
temperature=self.temperature,
|
| 334 |
+
timeout=120
|
| 335 |
)
|
| 336 |
|
| 337 |
response_text = response.choices[0].message.content.strip()
|
| 338 |
+
return self._parse_batch_response(response_text, qa_pairs)
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
except Exception as e:
|
| 341 |
+
logger.error(f"批量LLM评估失败: {e}")
|
| 342 |
+
return [{'score': 0, 'reasoning': str(e), 'success': False} for _ in range(len(qa_pairs))]
|
| 343 |
+
|
| 344 |
+
def evaluate_single(self, question: str, ground_truth: str, assistant_answer: str) -> Dict[str, Any]:
|
| 345 |
+
"""单个评估(向后兼容)"""
|
| 346 |
+
result = self.evaluate_batch([{
|
| 347 |
+
'temp_id': 0,
|
| 348 |
+
'question': question,
|
| 349 |
+
'ground_truth': ground_truth,
|
| 350 |
+
'assistant_answer': assistant_answer
|
| 351 |
+
}])
|
| 352 |
+
return result[0] if result else {'score': 0, 'reasoning': 'Failed', 'success': False}
|
| 353 |
|
| 354 |
|
| 355 |
# =========================================================
|
|
|
|
| 360 |
"""分数收集器,用于按类别统计加权平均"""
|
| 361 |
|
| 362 |
def __init__(self):
|
| 363 |
+
self.scores = defaultdict(list)
|
| 364 |
+
self.weights = defaultdict(int)
|
| 365 |
|
| 366 |
def add(self, category: str, score: float):
|
|
|
|
| 367 |
self.scores[category].append(score)
|
| 368 |
self.weights[category] += 1
|
| 369 |
|
| 370 |
def get_weighted_average(self, category: str = None) -> float:
|
|
|
|
| 371 |
if category:
|
| 372 |
scores = self.scores.get(category, [])
|
| 373 |
return np.mean(scores) if scores else 0.0
|
| 374 |
|
|
|
|
| 375 |
total_score = 0
|
| 376 |
total_weight = 0
|
| 377 |
for cat, scores in self.scores.items():
|
|
|
|
| 380 |
return total_score / total_weight if total_weight > 0 else 0.0
|
| 381 |
|
| 382 |
def get_all_averages(self) -> Dict[str, float]:
|
|
|
|
| 383 |
return {cat: np.mean(scores) if scores else 0.0
|
| 384 |
for cat, scores in self.scores.items()}
|
| 385 |
|
|
|
|
| 399 |
if self.enable_llm_judge and llm_config:
|
| 400 |
try:
|
| 401 |
self.llm_judge = LLMJudgeEvaluator(llm_config)
|
| 402 |
+
logger.info(f"LLM-as-judge 已启用,批量大小: {self.llm_judge.batch_size}")
|
| 403 |
except Exception as e:
|
| 404 |
logger.warning(f"LLM-as-judge 初始化失败: {e}")
|
| 405 |
self.enable_llm_judge = False
|
|
|
|
| 414 |
raise ValueError(f"文件格式错误: 缺少 'predictions' 字段")
|
| 415 |
return data['predictions']
|
| 416 |
|
| 417 |
+
def evaluate_single_prediction(self, pred: Dict) -> Dict[str, float]:
|
| 418 |
+
"""评估单个预测的词法指标(不包含 LLM)"""
|
| 419 |
system_answer = pred.get('system_answer', '').strip()
|
| 420 |
original_answer = pred.get('original_answer', '').strip()
|
| 421 |
|
|
|
|
| 422 |
lexical = self.lexical_calculator.calculate(system_answer, original_answer)
|
| 423 |
|
| 424 |
+
return {
|
| 425 |
'P': lexical['precision'],
|
| 426 |
'R': lexical['recall'],
|
| 427 |
'F1': lexical['f1'],
|
| 428 |
+
'BLEU-1': lexical['bleu1']
|
|
|
|
| 429 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
+
def evaluate_dataset(self, predictions: List[Dict], dataset_name: str, enable_llm: bool = True) -> Dict[str, Any]:
|
| 432 |
"""
|
| 433 |
评估单个数据集,按 main_type 和 sub_type 分类统计
|
|
|
|
|
|
|
|
|
|
| 434 |
"""
|
| 435 |
+
# 存储每个问题的词法指标
|
| 436 |
+
all_lexical_metrics = []
|
| 437 |
+
category_lexical_metrics = {cat: [] for cat in SUB_TYPE_TO_ABBR.values()}
|
|
|
|
| 438 |
|
| 439 |
+
# 按 main_type 和 sub_type 分类的收集器(词法指标)
|
| 440 |
+
main_type_lexical = {mt: [] for mt in ['recall', 'reasoning', 'application']}
|
| 441 |
+
sub_type_lexical = {st: [] for st in SUB_TYPE_TO_ABBR.values()}
|
| 442 |
|
| 443 |
+
# 存储每个问题的完整信息(用于后续分类)
|
| 444 |
+
questions_info = []
|
|
|
|
|
|
|
| 445 |
|
| 446 |
+
# 第一遍:计算词法指标,收集问题信息
|
| 447 |
+
for idx, pred in enumerate(predictions):
|
| 448 |
+
system_answer = pred.get('system_answer', '').strip()
|
| 449 |
+
original_answer = pred.get('original_answer', '').strip()
|
| 450 |
+
question_text = pred.get('question_text', '')
|
| 451 |
+
|
| 452 |
# 获取类型信息
|
| 453 |
main_type_full = pred.get('question_type', {}).get('main_type', '')
|
| 454 |
sub_type_full = pred.get('question_type', {}).get('sub_type', '')
|
| 455 |
|
|
|
|
| 456 |
main_type = MAIN_TYPE_MAPPING.get(main_type_full, '')
|
| 457 |
+
sub_type_abbr = SUB_TYPE_TO_ABBR.get(sub_type_full, '')
|
| 458 |
+
|
| 459 |
+
# 计算词法指标
|
| 460 |
+
lexical_scores = self.evaluate_single_prediction(pred)
|
| 461 |
+
|
| 462 |
+
# 存储信息
|
| 463 |
+
questions_info.append({
|
| 464 |
+
'index': idx,
|
| 465 |
+
'main_type': main_type,
|
| 466 |
+
'sub_type': sub_type_abbr,
|
| 467 |
+
'lexical_scores': lexical_scores,
|
| 468 |
+
'question_text': question_text,
|
| 469 |
+
'original_answer': original_answer,
|
| 470 |
+
'system_answer': system_answer
|
| 471 |
+
})
|
| 472 |
+
|
| 473 |
+
# 收集词法指标
|
| 474 |
+
all_lexical_metrics.append(lexical_scores)
|
| 475 |
+
|
| 476 |
+
# 按类别收集词法指标
|
| 477 |
+
if sub_type_abbr and sub_type_abbr in category_lexical_metrics:
|
| 478 |
+
category_lexical_metrics[sub_type_abbr].append(lexical_scores)
|
| 479 |
+
|
| 480 |
+
# 按 main_type 收集
|
| 481 |
+
if main_type and main_type in main_type_lexical:
|
| 482 |
+
main_type_lexical[main_type].append(lexical_scores)
|
| 483 |
+
|
| 484 |
+
# 按 sub_type 收集
|
| 485 |
+
if sub_type_abbr and sub_type_abbr in sub_type_lexical:
|
| 486 |
+
sub_type_lexical[sub_type_abbr].append(lexical_scores)
|
| 487 |
+
|
| 488 |
+
# =====================================================
|
| 489 |
+
# LLM-as-judge 批量评估
|
| 490 |
+
# =====================================================
|
| 491 |
+
llm_scores_by_index = {} # 索引 -> LLM分数
|
| 492 |
+
|
| 493 |
+
if self.enable_llm_judge and enable_llm and self.llm_judge:
|
| 494 |
+
# 准备批量评估数据
|
| 495 |
+
batch_size = self.llm_judge.batch_size
|
| 496 |
+
total_questions = len(questions_info)
|
| 497 |
+
total_batches = (total_questions + batch_size - 1) // batch_size
|
| 498 |
+
|
| 499 |
+
logger.info(f"开始批量LLM评估,共 {total_questions} 个问题,分成 {total_batches} 批(每批最多 {batch_size} 个)")
|
| 500 |
+
|
| 501 |
+
for batch_start in range(0, total_questions, batch_size):
|
| 502 |
+
batch_end = min(batch_start + batch_size, total_questions)
|
| 503 |
+
batch_questions = questions_info[batch_start:batch_end]
|
| 504 |
+
current_batch_num = batch_start // batch_size + 1
|
| 505 |
+
|
| 506 |
+
logger.info(f"评估第 {current_batch_num}/{total_batches} 批,包含 {len(batch_questions)} 个问题...")
|
| 507 |
+
|
| 508 |
+
# 准备批量数据,添加临时 ID
|
| 509 |
+
batch_data = []
|
| 510 |
+
for i, q in enumerate(batch_questions):
|
| 511 |
+
batch_data.append({
|
| 512 |
+
'temp_id': batch_start + i, # 使用原始索引作为临时 ID
|
| 513 |
+
'question': q['question_text'],
|
| 514 |
+
'ground_truth': q['original_answer'],
|
| 515 |
+
'assistant_answer': q['system_answer']
|
| 516 |
+
})
|
| 517 |
+
|
| 518 |
+
# 批量调用 LLM
|
| 519 |
+
batch_results = self.llm_judge.evaluate_batch(batch_data)
|
| 520 |
+
|
| 521 |
+
# 存储结果
|
| 522 |
+
for i, result in enumerate(batch_results):
|
| 523 |
+
original_idx = batch_start + i
|
| 524 |
+
if result['success']:
|
| 525 |
+
llm_scores_by_index[original_idx] = result['score']
|
| 526 |
+
else:
|
| 527 |
+
llm_scores_by_index[original_idx] = 0.0
|
| 528 |
+
|
| 529 |
+
success_count = len([r for r in batch_results if r['success']])
|
| 530 |
+
logger.info(f"第 {current_batch_num} 批完成,成功 {success_count}/{len(batch_questions)} 个")
|
| 531 |
+
|
| 532 |
+
logger.info(f"LLM批量评估完成,总成功 {len(llm_scores_by_index)}/{total_questions} 个")
|
| 533 |
+
|
| 534 |
+
# =====================================================
|
| 535 |
+
# 收集完整的分数(词法 + LLM)
|
| 536 |
+
# =====================================================
|
| 537 |
+
|
| 538 |
+
# 用于收集按类别的 LLM 分数
|
| 539 |
+
main_type_llm = {mt: [] for mt in ['recall', 'reasoning', 'application']}
|
| 540 |
+
sub_type_llm = {st: [] for st in SUB_TYPE_TO_ABBR.values()}
|
| 541 |
+
|
| 542 |
+
for q in questions_info:
|
| 543 |
+
idx = q['index']
|
| 544 |
+
llm_score = llm_scores_by_index.get(idx, 0.0)
|
| 545 |
+
|
| 546 |
+
# 按 main_type 收集 LLM 分数
|
| 547 |
+
if q['main_type'] and q['main_type'] in main_type_llm:
|
| 548 |
+
main_type_llm[q['main_type']].append(llm_score)
|
| 549 |
+
|
| 550 |
+
# 按 sub_type 收集 LLM 分数
|
| 551 |
+
if q['sub_type'] and q['sub_type'] in sub_type_llm:
|
| 552 |
+
sub_type_llm[q['sub_type']].append(llm_score)
|
| 553 |
+
|
| 554 |
+
# =====================================================
|
| 555 |
+
# 计算各类别的平均分
|
| 556 |
+
# =====================================================
|
| 557 |
+
|
| 558 |
+
def calc_avg(scores_list):
|
| 559 |
+
return np.mean(scores_list) if scores_list else 0.0
|
| 560 |
+
|
| 561 |
+
# 计算整体词法平均
|
| 562 |
+
overall_lexical = {
|
| 563 |
+
'P': calc_avg([m['P'] for m in all_lexical_metrics]),
|
| 564 |
+
'R': calc_avg([m['R'] for m in all_lexical_metrics]),
|
| 565 |
+
'F1': calc_avg([m['F1'] for m in all_lexical_metrics]),
|
| 566 |
+
'BLEU-1': calc_avg([m['BLEU-1'] for m in all_lexical_metrics])
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
# 计算整体 LLM 平均
|
| 570 |
+
overall_llm = calc_avg(list(llm_scores_by_index.values()))
|
| 571 |
|
| 572 |
# 构建结果
|
| 573 |
result = {
|
| 574 |
+
'total': len(predictions),
|
| 575 |
+
'main_type_counts': {mt: len(main_type_lexical[mt]) for mt in main_type_lexical},
|
| 576 |
+
'sub_type_counts': {st: len(sub_type_lexical[st]) for st in sub_type_lexical}
|
| 577 |
}
|
| 578 |
|
| 579 |
+
# 为每个指标构建结果
|
| 580 |
for metric in METRICS:
|
| 581 |
+
result[metric] = {
|
| 582 |
+
'overall': 0.0,
|
| 583 |
+
'recall': {'score': 0.0, 'sub_types': {}},
|
| 584 |
+
'reasoning': {'score': 0.0, 'sub_types': {}},
|
| 585 |
+
'application': {'score': 0.0, 'sub_types': {}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
}
|
| 587 |
|
| 588 |
+
if metric == 'LLM':
|
| 589 |
+
# LLM 指标
|
| 590 |
+
result[metric]['overall'] = overall_llm
|
| 591 |
+
for mt in ['recall', 'reasoning', 'application']:
|
| 592 |
+
result[metric][mt]['score'] = calc_avg(main_type_llm.get(mt, []))
|
| 593 |
+
for st, abbr in SUB_TYPE_TO_ABBR.items():
|
| 594 |
+
if abbr in ['UPR', 'CRR', 'KR']:
|
| 595 |
+
result[metric]['recall']['sub_types'][abbr] = calc_avg(sub_type_llm.get(abbr, []))
|
| 596 |
+
elif abbr in ['TR', 'MCR', 'RET']:
|
| 597 |
+
result[metric]['reasoning']['sub_types'][abbr] = calc_avg(sub_type_llm.get(abbr, []))
|
| 598 |
+
elif abbr in ['TTL', 'CD', 'AR']:
|
| 599 |
+
result[metric]['application']['sub_types'][abbr] = calc_avg(sub_type_llm.get(abbr, []))
|
| 600 |
+
else:
|
| 601 |
+
# 词法指标
|
| 602 |
+
metric_key = metric if metric != 'BLEU-1' else 'BLEU-1'
|
| 603 |
+
result[metric]['overall'] = overall_lexical.get(metric_key, 0.0)
|
| 604 |
+
|
| 605 |
+
for mt in ['recall', 'reasoning', 'application']:
|
| 606 |
+
scores = [m[metric_key] for m in main_type_lexical.get(mt, [])]
|
| 607 |
+
result[metric][mt]['score'] = calc_avg(scores)
|
| 608 |
+
|
| 609 |
+
for st, abbr in SUB_TYPE_TO_ABBR.items():
|
| 610 |
+
scores = [m[metric_key] for m in sub_type_lexical.get(abbr, [])]
|
| 611 |
+
if abbr in ['UPR', 'CRR', 'KR']:
|
| 612 |
+
result[metric]['recall']['sub_types'][abbr] = calc_avg(scores)
|
| 613 |
+
elif abbr in ['TR', 'MCR', 'RET']:
|
| 614 |
+
result[metric]['reasoning']['sub_types'][abbr] = calc_avg(scores)
|
| 615 |
+
elif abbr in ['TTL', 'CD', 'AR']:
|
| 616 |
+
result[metric]['application']['sub_types'][abbr] = calc_avg(scores)
|
| 617 |
|
| 618 |
return result
|
| 619 |
|
|
|
|
| 804 |
'api_key': os.getenv('OPENAI_API_KEY'),
|
| 805 |
'base_url': os.getenv('OPENAI_BASE_URL', 'https://api.openai.com/v1'),
|
| 806 |
'model_name': os.getenv('LLM_JUDGE_MODEL', 'gpt-4o-mini'),
|
| 807 |
+
'temperature': 0,
|
| 808 |
+
'batch_size': 5 # 每批最多5个问题
|
| 809 |
}
|
| 810 |
_evaluator = SubmissionEvaluator(llm_config=llm_config, enable_llm_judge=True)
|
| 811 |
|