MSG
Merge pull request #4 from MSghais/experiment/small_model_building_testing
59e2c8a
Raw
History Blame Contribute Delete
7.23 kB
"""
benchmarks/bfcl.py
───────────────────
Berkeley Function-Calling Leaderboard (BFCL) β€” local eval adapter.
What it tests: Can the model call functions with correct names,
argument names, and argument values given a user request + schema?
Dataset: gorilla-llm/Berkeley-Function-Calling-Leaderboard on HF Hub
OR local JSONL at cfg["data_path"]
Scoring: Exact-match on function name + fuzzy-match on arguments.
"""
from __future__ import annotations
import json
import re
from typing import Any
from slm_evals.benchmarks.base import BaseBenchmark
SYSTEM_PROMPT = """\
You are a helpful assistant with access to the following functions.
To call a function, respond ONLY with a JSON object in this exact format:
{"name": "<function_name>", "arguments": {<key>: <value>, ...}}
Do not add any explanation or text outside the JSON.
"""
class BFCLBenchmark(BaseBenchmark):
"""
BFCL function-calling benchmark.
Config keys (in benchmark_overrides.bfcl):
data_path – local JSONL file (optional; falls back to HF Hub)
categories – list of categories to filter on (optional)
strict – bool, require perfect arg match (default: False)
"""
name = "bfcl"
# ── Dataset ───────────────────────────────────────────────────────────────
def load_dataset(self) -> list[dict]:
data_path = self.cfg.get("data_path")
if data_path:
return self._load_local(data_path)
else:
return self._load_from_hub()
def _load_local(self, path: str) -> list[dict]:
samples = []
with open(path) as f:
for line in f:
line = line.strip()
if line:
samples.append(json.loads(line))
return samples
def _load_from_hub(self) -> list[dict]:
"""
Pulls the BFCL dataset from HF Hub.
Requires: pip install datasets
"""
try:
from datasets import load_dataset
except ImportError:
raise ImportError("pip install datasets (required to fetch BFCL from Hub)")
ds = load_dataset(
"gorilla-llm/Berkeley-Function-Calling-Leaderboard",
split="train",
trust_remote_code=True,
)
return list(ds)
# ── Prompt ────────────────────────────────────────────────────────────────
def build_prompt(self, sample: dict) -> str:
functions_block = json.dumps(sample.get("function", []), indent=2)
user_query = sample.get("question", sample.get("input", ""))
return (
f"{SYSTEM_PROMPT}\n"
f"Available functions:\n{functions_block}\n\n"
f"User: {user_query}\n"
f"Assistant:"
)
# ── Evaluation ────────────────────────────────────────────────────────────
def evaluate_sample(self, sample: dict, prediction: str) -> dict:
"""
1. Parse model output as JSON.
2. Check function name matches ground truth.
3. Check arguments (strict = exact keys+values, else fuzzy).
"""
ground_truth = sample.get("ground_truth", {})
if isinstance(ground_truth, str):
try:
ground_truth = json.loads(ground_truth)
except json.JSONDecodeError:
ground_truth = {}
# ── Parse prediction ──────────────────────────────────────────────────
parsed = self._extract_json(prediction)
if parsed is None:
return {
"passed": False,
"score": 0.0,
"note": "Could not parse JSON from model output",
}
# ── Check function name ───────────────────────────────────────────────
expected_name = (
ground_truth.get("name")
or ground_truth.get("function_name")
or ""
)
predicted_name = parsed.get("name", "")
name_ok = predicted_name.strip() == expected_name.strip()
if not name_ok:
return {
"passed": False,
"score": 0.2, # partial credit: valid JSON produced
"note": f"Wrong fn name: got '{predicted_name}', expected '{expected_name}'",
}
# ── Check arguments ───────────────────────────────────────────────────
expected_args = ground_truth.get("arguments", ground_truth.get("args", {}))
predicted_args = parsed.get("arguments", parsed.get("args", {}))
strict = self.cfg.get("strict", False)
arg_score = self._score_args(expected_args, predicted_args, strict=strict)
passed = arg_score >= (1.0 if strict else 0.8)
return {
"passed": passed,
"score": round((0.5 + 0.5 * arg_score), 3), # name correct = 0.5, args = 0.5
"note": f"arg_match={arg_score:.2f}",
}
# ── Helpers ───────────────────────────────────────────────────────────────
@staticmethod
def _extract_json(text: str) -> dict | None:
"""Extract the first JSON object from free-form model output."""
# Try direct parse first
try:
return json.loads(text.strip())
except json.JSONDecodeError:
pass
# Regex fallback: find first {...} block
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
return None
@staticmethod
def _score_args(
expected: dict,
predicted: dict,
strict: bool = False,
) -> float:
"""Return 0–1 argument match score."""
if not expected:
return 1.0 if not predicted else 0.8
if strict:
return 1.0 if expected == predicted else 0.0
# Fuzzy: what fraction of expected keys are correctly predicted?
hits = 0
for key, exp_val in expected.items():
pred_val = predicted.get(key)
if pred_val is None:
continue
if str(pred_val).strip().lower() == str(exp_val).strip().lower():
hits += 1
elif str(exp_val).strip().lower() in str(pred_val).strip().lower():
hits += 0.5 # partial credit
return hits / len(expected)