Skip to content

Commit 5518733

Browse files
MBemeraSunnyHaze
andauthored
refactor(core_text): split BenchDatasetEvaluatorQuestion.run and fix row indexing (#514)
* Split BenchDatasetEvaluatorQuestion.run into helpers and fix row indexing run() carried both evaluation modes inline at ~90 lines. Splitting it into per-mode helpers made two latent problems visible: - Match mode indexed the answer Series by position. That is a label lookup on a pandas Series, so any frame with a non-default index -- what an upstream filter produces -- raised KeyError. Now iterates dataframe.index. - Column validation ran after 'answer_match_result' had already been added, so a frame failing validation came back mutated. Also drops the unused numpy and json imports and the dead result_mask. * fix(core_text): validate semantic response count --------- Co-authored-by: Sunnyhaze <mxch1122@126.com>
1 parent e400859 commit 5518733

2 files changed

Lines changed: 232 additions & 95 deletions

File tree

dataflow/operators/core_text/eval/bench_dataset_evaluator_question.py

Lines changed: 144 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,9 @@
1111
from dataflow import get_logger
1212
from typing import Literal, Union
1313
import pandas as pd
14-
import numpy as np
1514
import time
16-
import os # 添加os模块导入
15+
import os
1716
import re
18-
import json
1917
import json5
2018

2119
@prompt_restrict(
@@ -32,7 +30,7 @@ def __init__(self,
3230
llm_serving: LLMServingABC = None,
3331
prompt_template: Union[AnswerJudgePromptQuestion, AnswerJudgeMultipleQuestionsPrompt, DIYPromptABC] = AnswerJudgePromptQuestion,
3432
support_subquestions: bool = False,
35-
keep_all_samples: bool = False
33+
keep_all_samples: bool = False,
3634
):
3735

3836
if eval_result_path is None:
@@ -44,7 +42,7 @@ def __init__(self,
4442
self.empty_responses_count = 0 # 添加空响应计数器
4543
self.keep_all_samples = keep_all_samples
4644
self.support_subquestions = support_subquestions
47-
45+
4846
if compare_method == "match":
4947
self.compare = self.math_verify_compare
5048
unit_manager = UnitTextManager()
@@ -203,7 +201,136 @@ def statistic(self, file_name_prefix: str, dataframe: pd.DataFrame, compare_meth
203201
self.logger.success(f"Statistics saved to {self.eval_result_path}")
204202

205203
return stats_df
206-
204+
205+
def _get_required_columns(
206+
self,
207+
input_test_answer_key: str,
208+
input_gt_answer_key: str,
209+
input_question_key: str,
210+
) -> list[str]:
211+
required_columns = [input_test_answer_key, input_gt_answer_key]
212+
if self.compare_method == "semantic":
213+
required_columns.append(input_question_key)
214+
return required_columns
215+
216+
def _run_match_evaluation(
217+
self,
218+
storage: DataFlowStorage,
219+
dataframe: pd.DataFrame,
220+
required_columns: list[str],
221+
) -> list[str]:
222+
for row_index in dataframe.index:
223+
answer = dataframe.at[row_index, self.test_answer_key]
224+
ground_truth = dataframe.at[row_index, self.gt_answer_key]
225+
final_answer = self.answer_extractor.extract_answer(answer, None)
226+
dataframe.at[row_index, "answer_match_result"] = self.compare(
227+
final_answer,
228+
ground_truth,
229+
)
230+
231+
storage.write(dataframe)
232+
self.statistic(storage.file_name_prefix, dataframe, self.compare_method)
233+
return required_columns + ["answer_match_result"]
234+
235+
def _build_semantic_inputs(self, valid_rows: pd.DataFrame) -> list[str]:
236+
return [
237+
self.prompt_template.build_prompt(
238+
question=row[self.question_key],
239+
answer=row[self.test_answer_key],
240+
reference_answer=row[self.gt_answer_key],
241+
)
242+
for _, row in valid_rows.iterrows()
243+
]
244+
245+
def _handle_missing_reference_answers(
246+
self,
247+
storage: DataFlowStorage,
248+
dataframe: pd.DataFrame,
249+
required_columns: list[str],
250+
skipped_count: int,
251+
) -> list[str]:
252+
self.logger.warning(
253+
"No valid samples with reference answers found. All samples skipped."
254+
)
255+
output_dataframe = (
256+
dataframe if self.keep_all_samples else dataframe.iloc[0:0].copy()
257+
)
258+
output_file = storage.write(output_dataframe)
259+
self.logger.info(
260+
f"Dataframe saved to {output_file}. Skipped {skipped_count} samples "
261+
"due to missing reference answers."
262+
)
263+
return required_columns + ["answer_match_result"]
264+
265+
def _apply_subquestion_results(
266+
self,
267+
dataframe: pd.DataFrame,
268+
valid_rows: pd.DataFrame,
269+
responses: list[str],
270+
results: list[str],
271+
) -> None:
272+
for row_index, response, result in zip(valid_rows.index, responses, results):
273+
correct_answer_count, total_subquestions = map(int, result.split("/"))
274+
dataframe.at[row_index, "correct_answer_num"] = correct_answer_count
275+
dataframe.at[row_index, "total_subquestions"] = total_subquestions
276+
dataframe.at[row_index, "answer_match_result"] = (
277+
correct_answer_count == total_subquestions and total_subquestions > 0
278+
)
279+
dataframe.at[row_index, "response_evaluation"] = response
280+
281+
def _apply_semantic_results(
282+
self,
283+
dataframe: pd.DataFrame,
284+
valid_rows: pd.DataFrame,
285+
responses: list[str],
286+
results: list,
287+
) -> None:
288+
if self.support_subquestions:
289+
self._apply_subquestion_results(dataframe, valid_rows, responses, results)
290+
return
291+
292+
for row_index, result in zip(valid_rows.index, results):
293+
dataframe.at[row_index, "answer_match_result"] = result
294+
295+
def _run_semantic_evaluation(
296+
self,
297+
storage: DataFlowStorage,
298+
dataframe: pd.DataFrame,
299+
required_columns: list[str],
300+
) -> list[str]:
301+
empty_reference_mask = dataframe[self.gt_answer_key].isna() | (
302+
dataframe[self.gt_answer_key] == ""
303+
)
304+
valid_rows = dataframe[~empty_reference_mask]
305+
skipped_count = int(empty_reference_mask.sum())
306+
307+
if valid_rows.empty:
308+
return self._handle_missing_reference_answers(
309+
storage,
310+
dataframe,
311+
required_columns,
312+
skipped_count,
313+
)
314+
315+
inputs = self._build_semantic_inputs(valid_rows)
316+
responses = self.llm_serving.generate_from_input(
317+
user_inputs=inputs,
318+
system_prompt=self.system_prompt,
319+
)
320+
response_count = 0 if responses is None else len(responses)
321+
if response_count != len(inputs):
322+
raise RuntimeError(
323+
"LLM serving returned an unexpected number of responses: "
324+
f"expected {len(inputs)}, got {response_count}."
325+
)
326+
327+
results = [self.ResolveResponse(response) for response in responses]
328+
self._apply_semantic_results(dataframe, valid_rows, responses, results)
329+
storage.write(dataframe)
330+
self.statistic(storage.file_name_prefix, dataframe, self.compare_method)
331+
self.empty_responses_count = 0
332+
return required_columns + ["answer_match_result"]
333+
207334
def run(
208335
self,
209336
storage:DataFlowStorage,
@@ -217,93 +344,15 @@ def run(
217344
self.question_key = input_question_key
218345

219346
dataframe = storage.read("dataframe")
220-
dataframe['answer_match_result'] = False
221-
answers = dataframe[self.test_answer_key]
222-
ground_truths = dataframe[self.gt_answer_key]
223-
224-
if self.compare_method == "match":
225-
required_columns = [input_test_answer_key, input_gt_answer_key]
226-
if self.check_column(
227-
required_columns=required_columns,
228-
dataframe=dataframe
229-
) is False:
230-
return required_columns
231-
232-
for i in range(len(answers)):
233-
final_answer = self.answer_extractor.extract_answer(answers[i], None)
234-
if self.compare(final_answer, ground_truths[i]):
235-
dataframe.at[i, 'answer_match_result'] = True
236-
else:
237-
dataframe.at[i, 'answer_match_result'] = False
238-
239-
output_file = storage.write(dataframe)
240-
241-
# 生成统计信息并直接写入JSON文件
242-
stats = self.statistic(storage.file_name_prefix, dataframe, self.compare_method)
243-
244-
return [self.test_answer_key, self.gt_answer_key, 'answer_match_result']
245-
else:
246-
required_columns = [input_test_answer_key, input_gt_answer_key, input_question_key]
247-
if self.check_column(
248-
required_columns=required_columns,
249-
dataframe=dataframe
250-
) is False:
251-
return required_columns
252-
253-
empty_reference_mask = dataframe[input_gt_answer_key].isna() | (dataframe[input_gt_answer_key] == '')
254-
skipped_rows = dataframe[empty_reference_mask]
255-
valid_rows = dataframe[~empty_reference_mask]
256-
skipped_count = len(skipped_rows)
257-
258-
if len(valid_rows) == 0:
259-
self.logger.warning("No valid samples with reference answers found. All samples skipped.")
260-
if self.keep_all_samples:
261-
output_file = storage.write(dataframe) # 保留所有行,但answer_match_result都为False
262-
else:
263-
output_file = storage.write(pd.DataFrame(columns=dataframe.columns)) # 不保留任何行
264-
self.logger.info(f"Dataframe saved to {output_file}. Skipped {skipped_count} samples due to missing reference answers.")
265-
return required_columns + ['answer_match_result']
266-
267-
# 只对有参考答案的行构建提示词并调用LLM
268-
inputs = [self.prompt_template.build_prompt(
269-
question=row[input_question_key],
270-
answer=row[input_test_answer_key],
271-
reference_answer=row[input_gt_answer_key]
272-
) for _, row in valid_rows.iterrows()]
273-
274-
responses = self.llm_serving.generate_from_input(user_inputs=inputs, system_prompt=self.system_prompt)
275-
276-
# if self.support_subquestions:
277-
# # 每个response是一个列表,连接一个长列表,比如[["true", "false"], ["true"]] -> ["true", "false", "true"]
278-
# responses = [item for sublist in responses for item in sublist]
279-
280-
results = [self.ResolveResponse(response) for response in responses]
281-
282-
# 创建结果掩码,与valid_rows长度相同
283-
result_mask = np.array(results, dtype=bool)
284-
285-
# 更新有效行的answer_match_result
286-
valid_indices = valid_rows.index
287-
if not self.support_subquestions:
288-
for i, idx in enumerate(valid_indices):
289-
dataframe.at[idx, 'answer_match_result'] = results[i]
290-
else:
291-
for i, idx in enumerate(valid_indices):
292-
correct_answer_num = int(results[i].split('/')[0])
293-
total_subquestions = int(results[i].split('/')[1])
294-
dataframe.at[idx, 'correct_answer_num'] = correct_answer_num
295-
dataframe.at[idx, 'total_subquestions'] = total_subquestions
296-
dataframe.at[idx, 'answer_match_result'] = (correct_answer_num == total_subquestions) and (total_subquestions > 0) # 全对为True,否则为False
297-
dataframe.at[idx, 'response_evaluation'] = responses[i] # 保存LLM的原始响应内容
298-
299-
output_file = storage.write(dataframe)
300-
301-
# 生成统计信息并直接写入JSON文件
302-
stats = self.statistic(storage.file_name_prefix, dataframe, self.compare_method)
303-
304-
# 重置空响应计数器
305-
self.empty_responses_count = 0
306-
307-
return [input_test_answer_key, input_gt_answer_key, input_question_key, 'answer_match_result']
347+
required_columns = self._get_required_columns(
348+
input_test_answer_key,
349+
input_gt_answer_key,
350+
input_question_key,
351+
)
352+
if not self.check_column(required_columns, dataframe):
353+
return required_columns
308354

309-
355+
dataframe["answer_match_result"] = False
356+
if self.compare_method == "match":
357+
return self._run_match_evaluation(storage, dataframe, required_columns)
358+
return self._run_semantic_evaluation(storage, dataframe, required_columns)

test/cpu_only/test_undefined_name_crashes.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,24 @@ def test_bench_evaluator_handles_missing_reference_answers(
156156
assert len(storage.dataframe) == expected_rows
157157

158158

159+
def test_bench_evaluator_validates_columns_before_reading_them():
160+
"""Missing input columns should be reported instead of raising KeyError."""
161+
from dataflow.prompts.model_evaluation.general import AnswerJudgePromptQuestion
162+
from dataflow.operators.core_text import BenchDatasetEvaluatorQuestion
163+
164+
evaluator = BenchDatasetEvaluatorQuestion(
165+
compare_method="semantic",
166+
llm_serving=object(),
167+
prompt_template=AnswerJudgePromptQuestion(),
168+
)
169+
storage = InMemoryStorage(pd.DataFrame({"question": ["a"]}))
170+
171+
returned_keys = evaluator.run(storage=storage, input_question_key="question")
172+
173+
assert returned_keys == ["generated_cot", "golden_answer", "question"]
174+
assert "answer_match_result" not in storage.dataframe.columns
175+
176+
159177
@pytest.mark.parametrize("support_subquestions", [False, True])
160178
def test_bench_match_mode_initializes_subquestion_setting(
161179
support_subquestions, tmp_path, monkeypatch
@@ -190,3 +208,73 @@ def test_bench_match_mode_initializes_subquestion_setting(
190208
"answer_match_result",
191209
]
192210
assert storage.dataframe["answer_match_result"].tolist() == [True]
211+
212+
213+
@pytest.mark.parametrize("compare_method", ["match", "semantic"])
214+
def test_bench_evaluator_handles_non_default_index(compare_method, tmp_path, monkeypatch):
215+
"""Upstream filters hand downstream a sliced frame whose index is not 0..n-1.
216+
217+
Row lookups used to index the Series by position, which is a label lookup
218+
on a pandas Series, so a gapped index raised KeyError.
219+
"""
220+
from dataflow.prompts.model_evaluation.general import AnswerJudgePromptQuestion
221+
from dataflow.operators.core_text import BenchDatasetEvaluatorQuestion
222+
223+
class StubLLMServing:
224+
def generate_from_input(self, user_inputs, system_prompt=None):
225+
return ['{"judgement_result": true}', '{"judgement_result": false}']
226+
227+
evaluator = BenchDatasetEvaluatorQuestion(
228+
compare_method=compare_method,
229+
eval_result_path=str(tmp_path / "statistics.json"),
230+
llm_serving=StubLLMServing(),
231+
prompt_template=AnswerJudgePromptQuestion(),
232+
)
233+
if compare_method == "match":
234+
monkeypatch.setattr(
235+
evaluator.answer_extractor, "extract_answer", lambda answer, _: answer
236+
)
237+
monkeypatch.setattr(
238+
evaluator, "compare", lambda answer, expected: answer == expected
239+
)
240+
sliced_frame = pd.DataFrame(
241+
{
242+
"question": ["q1", "q2"],
243+
"generated_cot": ["42", "7"],
244+
"golden_answer": ["42", "9"],
245+
},
246+
index=[3, 7],
247+
)
248+
storage = InMemoryStorage(sliced_frame)
249+
250+
evaluator.run(storage=storage, input_question_key="question")
251+
252+
assert storage.dataframe["answer_match_result"].tolist() == [True, False]
253+
254+
255+
def test_bench_evaluator_rejects_misaligned_llm_responses():
256+
"""Every semantic-evaluation prompt must receive exactly one response."""
257+
from dataflow.prompts.model_evaluation.general import AnswerJudgePromptQuestion
258+
from dataflow.operators.core_text import BenchDatasetEvaluatorQuestion
259+
260+
class StubLLMServing:
261+
def generate_from_input(self, user_inputs, system_prompt=None):
262+
return ['{"judgement_result": true}']
263+
264+
evaluator = BenchDatasetEvaluatorQuestion(
265+
compare_method="semantic",
266+
llm_serving=StubLLMServing(),
267+
prompt_template=AnswerJudgePromptQuestion(),
268+
)
269+
storage = InMemoryStorage(
270+
pd.DataFrame(
271+
{
272+
"question": ["q1", "q2"],
273+
"generated_cot": ["42", "7"],
274+
"golden_answer": ["42", "9"],
275+
}
276+
)
277+
)
278+
279+
with pytest.raises(RuntimeError, match="expected 2, got 1"):
280+
evaluator.run(storage=storage, input_question_key="question")

0 commit comments

Comments
 (0)