Inside a General-Purpose LLM Evaluation Framework

Liao Jiayi Liao Jiayi

Inside a General-Purpose LLM Evaluation Framework

Translated from Chinese with AI · Read the original

I mainly examined OpenCompass and LightEval. Their overall logic is similar, centered on models and datasets.

LightEval

A typical command:

lighteval accelerate \
"model_name=openai-community/gpt2,batch_size=1" \
hellaswag

1. Select the Evaluation Backend

lighteval-backend

2. Using Accelerate as an Example, Build a Complete Evaluation Pipeline

pipeline = Pipeline(
tasks=tasks,
pipeline_parameters=pipeline_params,
evaluation_tracker=evaluation_tracker,
model_config=model_config,
)

It includes:

  • Tasks: provide inference requirements such as datasets, models, and evaluation rules;
  • EvaluationTracker: tracking information, integration with external hubs, result formatting, and so on;

3. Build the Dataset

Tasks specify datasets. In the hellaswag task, the hf_xx attributes identify the dataset and its splits. The Metrics.exact_match setting specifies how predictions are compared with targets: exact matching here.

hellaswag = LightevalTaskConfig(
name="hellaswag",
prompt_function=hellaswag_prompt,
hf_repo="Rowan/hellaswag",
hf_subset="default",
hf_avail_splits=["train", "test", "validation"],
evaluation_splits=["validation"],
few_shots_split=None,
few_shots_select=None,
generation_size=1,
metrics=[
Metrics.exact_match,
],
stop_sequence=["\n"],
version=0,
)

In the pipeline, extract the dataset’s validation portion and split it according to sampling_method. sampling_method, how we query/sample information from the model, has three forms:

  • GENERATIVE: Sample text from the distribution
  • LOGPROBS: Sample/retrieve probability scores for choices
  • PERPLEXITY: Sample/retrieve probability scores across sequences

4. Evaluate the Model

Take SamplingMethod.GENERATIVE as an example:

case SamplingMethod.GENERATIVE:
model_outputs = self.model.greedy_until(docs)
outputs[sampling_method] = model_outputs

This also has several steps:

  1. Divide the dataset into several splits, fine enough to track inference progress
  2. Construct a Torch DataLoader
  3. Preprocess data and prompts using the model’s tokenizer
  4. Run inference and return responses

5. Compute Metrics

For the earlier hellaswag example, the metric is exact_match. The key method is:

@abstractmethod
def compute(self, doc: Doc, model_response: ModelResponse, **kwargs):
raise NotImplementedError
  • doc: contains the original gold/target text;
  • model_response: the model’s inference output, or prediction;

Compare after normalization. Metrics use different comparison methods, then collect and aggregate results through aggregation_function. Here is the implementation of exact_match:

def compute(self, doc: Doc, model_response: ModelResponse, **kwargs) -> float:
"""Computes the metric over a list of golds and predictions for one single sample.
Args:
doc (Doc): The document containing gold references.
model_response (ModelResponse): The model's response containing predictions.
**kwargs: Additional keyword arguments.
Returns:
float: Aggregated score over the current sample's items.
"""
results = []
# We might need to flatten golds if they are a list of lists
golds = doc.get_golds()
for gold in golds:
for pred in model_response.final_text:
results.append(self.compute_one_item(gold=gold, pred=pred))
return self.aggregation_function(results)

OpenCompass

OpenCompass is similar: users configure a model and dataset. For example:

opencompass --models hf_qwen2_0_5b_instruct_cpu --datasets demo_gsm8k_chat_gen
  • model: a small Qwen model
  • dataset: GSM8K

1. Partition the Dataset

partitioner = PARTITIONERS.build(cfg.infer.partitioner)
tasks = partitioner(cfg)

Users can specify a Partitioner; by default, splitting follows max_number_workers. Each Partitioner constructs task configurations from its partitions and returns a list, for example NumWorkerPartitioner:

for dataset in chunks:
tasks.append(
Config({
'models': [model],
'datasets': [[dataset]],
'work_dir': work_dir,
**add_cfg
}))

2. Construct and Execute the Runner

runner = RUNNERS.build(cfg.infer.runner)
runner(tasks)

Initialize a runner with task configurations and invoke launch. For example, LocalRunner uses multiple processes for parallel execution, unlike the LightEval flow above:

pbar = tqdm(total=len(tasks))
lock = Lock()
def submit(task, index):
task = TASKS.build(dict(cfg=task, type=self.task_cfg['type']))
num_gpus = task.num_gpus
assert len(gpus) >= num_gpus
while True:
lock.acquire()
if sum(gpus > 0) >= num_gpus:
gpu_ids = np.where(gpus)[0][:num_gpus]
gpus[gpu_ids] -= 1
lock.release()
break
lock.release()
time.sleep(1)
res = self._launch(task, gpu_ids, index)
pbar.update()
with lock:
gpus[gpu_ids] += 1
return res
with ThreadPoolExecutor(
max_workers=self.max_num_workers) as executor:
status = executor.map(submit, tasks, range(len(tasks)))

Because execution is parallel, GPU resource records are locked, and ThreadPoolExecutor submits work concurrently.

3. Evaluation

OpenCompass evaluation differs from LightEval in these ways:

  • OpenCompass designs evaluation with the same Runner/Task architecture as inference. One benefit is that users can run evaluation separately;
  • LightEval integrates evaluation into its pipeline, so it cannot run independently;

The overall flow resembles LightEval, with preprocessing, scoring, and saving.