Files
Blum-Finance-4B/evaluations/evaluate_mmlu_finance.py
ModelHub XC 452e39348e 初始化项目,由ModelHub XC社区提供模型
Model: Italianhype/Blum-Finance-4B
Source: Original Platform
2026-08-25 18:44:18 +08:00

415 lines
14 KiB
Python

#!/usr/bin/env python3
"""Evaluate a causal language model on the finance-related MMLU subjects."""
from __future__ import annotations
import argparse
import json
import math
import statistics
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
import pyarrow.parquet as parquet
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
DATASET_ID = "cais/mmlu"
DATASET_REVISION = "c30699e8356da336a370243923dbaf21066bb9fe"
DEFAULT_SUBJECTS = (
"business_ethics",
"econometrics",
"high_school_macroeconomics",
"high_school_microeconomics",
"management",
"marketing",
"professional_accounting",
)
ANSWER_LABELS = ("A", "B", "C", "D")
@dataclass(frozen=True)
class MmluExample:
question: str
choices: tuple[str, ...]
answer: int
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--model", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--cache-dir", default=".artifacts/mmlu-finance")
parser.add_argument("--revision", default=None)
parser.add_argument("--subjects", nargs="+", default=list(DEFAULT_SUBJECTS))
parser.add_argument("--few-shot", type=int, default=5)
parser.add_argument("--batch-size", type=int, default=2)
parser.add_argument("--max-samples-per-subject", type=int, default=None)
parser.add_argument("--device", choices=("auto", "cpu", "cuda", "mps"), default="auto")
return parser.parse_args()
def resolve_device(requested: str) -> str:
if requested != "auto":
return requested
if torch.cuda.is_available():
return "cuda"
if torch.backends.mps.is_available():
return "mps"
return "cpu"
def dataset_url(subject: str, split: str) -> str:
filename = f"{split}-00000-of-00001.parquet"
return (
f"https://huggingface.co/datasets/{DATASET_ID}/resolve/"
f"{DATASET_REVISION}/{subject}/{filename}"
)
def download_file(url: str, destination: Path, attempts: int = 6) -> None:
if destination.exists() and destination.stat().st_size > 0:
return
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + ".part")
last_error: BaseException | None = None
for attempt in range(1, attempts + 1):
try:
with (
urllib.request.urlopen(url, timeout=120) as response,
temporary.open("wb") as output,
):
while chunk := response.read(1024 * 1024):
output.write(chunk)
temporary.replace(destination)
return
except (OSError, urllib.error.URLError) as exc:
last_error = exc
temporary.unlink(missing_ok=True)
if attempt == attempts:
break
delay = min(2 ** (attempt - 1), 30)
print(
json.dumps(
{
"download_retry": attempt,
"delay_seconds": delay,
"url": url,
"error": str(exc),
}
),
flush=True,
)
time.sleep(delay)
raise RuntimeError(f"Unable to download {url} after {attempts} attempts") from last_error
def load_split(cache_dir: Path, subject: str, split: str) -> list[MmluExample]:
path = cache_dir / subject / f"{split}.parquet"
download_file(dataset_url(subject, split), path)
records = parquet.read_table(path).to_pylist()
return [
MmluExample(
question=str(record["question"]),
choices=tuple(str(choice) for choice in record["choices"]),
answer=int(record["answer"]),
)
for record in records
]
def format_example(example: MmluExample, include_answer: bool) -> str:
lines = [example.question]
lines.extend(
f"{label}. {choice}"
for label, choice in zip(ANSWER_LABELS, example.choices, strict=True)
)
if include_answer:
lines.append(f"Answer: {ANSWER_LABELS[example.answer]}")
else:
lines.append("Answer:")
return "\n".join(lines)
def build_prompt(subject: str, few_shot: Iterable[MmluExample], test: MmluExample) -> str:
readable_subject = subject.replace("_", " ")
header = (
"The following are multiple choice questions (with answers) "
f"about {readable_subject}.\n\n"
)
demonstrations = "\n\n".join(
format_example(example, include_answer=True) for example in few_shot
)
return f"{header}{demonstrations}\n\n{format_example(test, include_answer=False)}"
def answer_token_ids(tokenizer: Any) -> list[int]:
result: list[int] = []
for label in ANSWER_LABELS:
encoded = tokenizer.encode(f" {label}", add_special_tokens=False)
if len(encoded) != 1:
raise ValueError(f"Answer label {label!r} is not a single token: {encoded}")
result.append(encoded[0])
return result
def chunks(values: list[Any], size: int) -> Iterable[list[Any]]:
for start in range(0, len(values), size):
yield values[start : start + size]
def write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".part")
temporary.write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
temporary.replace(path)
def write_jsonl_atomic(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".part")
with temporary.open("w", encoding="utf-8") as output:
for row in rows:
output.write(json.dumps(row, sort_keys=True) + "\n")
temporary.replace(path)
def load_subject_checkpoint(
output_dir: Path,
subject: str,
expected_config: dict[str, Any],
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
subject_dir = output_dir / "subjects" / subject
result_path = subject_dir / "result.json"
predictions_path = subject_dir / "predictions.jsonl"
if not result_path.exists() or not predictions_path.exists():
return None
result = json.loads(result_path.read_text(encoding="utf-8"))
predictions = [
json.loads(line)
for line in predictions_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
if result.get("sample_size") != len(predictions):
return None
if result.get("evaluation_config") != expected_config:
return None
return result, predictions
def write_subject_checkpoint(
output_dir: Path,
subject: str,
result: dict[str, Any],
predictions: list[dict[str, Any]],
) -> None:
subject_dir = output_dir / "subjects" / subject
write_json_atomic(subject_dir / "result.json", result)
write_jsonl_atomic(subject_dir / "predictions.jsonl", predictions)
def wilson_interval(correct: int, total: int, z: float = 1.959963984540054) -> tuple[float, float]:
if total == 0:
return 0.0, 0.0
proportion = correct / total
denominator = 1 + (z * z / total)
centre = proportion + z * z / (2 * total)
margin = z * math.sqrt((proportion * (1 - proportion) + z * z / (4 * total)) / total)
return (centre - margin) / denominator, (centre + margin) / denominator
def evaluate_subject(
*,
model: Any,
tokenizer: Any,
device: str,
subject: str,
dev: list[MmluExample],
test: list[MmluExample],
few_shot_count: int,
batch_size: int,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
few_shot = dev[:few_shot_count]
answer_ids = torch.tensor(answer_token_ids(tokenizer), device=device)
predictions: list[dict[str, Any]] = []
started = time.perf_counter()
for batch in chunks(test, batch_size):
prompts = [build_prompt(subject, few_shot, example) for example in batch]
encoded = tokenizer(
prompts,
padding=True,
return_tensors="pt",
add_special_tokens=True,
).to(device)
with torch.inference_mode():
logits = model(**encoded).logits
# Left padding keeps every final prompt token at the final sequence index.
final_positions = torch.full(
(len(batch),),
encoded["input_ids"].shape[1] - 1,
device=device,
dtype=torch.long,
)
row_indices = torch.arange(len(batch), device=device)
final_logits = logits[row_indices, final_positions]
choice_logits = final_logits.index_select(dim=1, index=answer_ids)
probabilities = torch.softmax(choice_logits.float(), dim=1).cpu()
predicted = choice_logits.argmax(dim=1).cpu().tolist()
for example, prediction, probability in zip(
batch, predicted, probabilities.tolist(), strict=True
):
predictions.append(
{
"subject": subject,
"expected": ANSWER_LABELS[example.answer],
"predicted": ANSWER_LABELS[prediction],
"correct": prediction == example.answer,
"choice_probabilities": {
label: round(value, 8)
for label, value in zip(ANSWER_LABELS, probability, strict=True)
},
}
)
correct = sum(int(row["correct"]) for row in predictions)
total = len(predictions)
lower, upper = wilson_interval(correct, total)
return (
{
"subject": subject,
"correct": correct,
"sample_size": total,
"accuracy": round(correct / total, 8) if total else None,
"confidence_interval_95": [round(lower, 8), round(upper, 8)],
"duration_seconds": round(time.perf_counter() - started, 3),
},
predictions,
)
def main() -> None:
args = parse_args()
if args.few_shot < 0 or args.batch_size < 1:
raise ValueError("few-shot must be non-negative and batch-size must be positive")
output_dir = Path(args.output_dir)
cache_dir = Path(args.cache_dir)
output_dir.mkdir(parents=True, exist_ok=True)
device = resolve_device(args.device)
dtype = torch.bfloat16 if device in {"cuda", "mps"} else torch.float32
# Resolve every dataset dependency before allocating model memory. A transient
# CDN failure must not invalidate hours of completed inference.
splits: dict[str, tuple[list[MmluExample], list[MmluExample]]] = {}
for subject in args.subjects:
dev = load_split(cache_dir, subject, "dev")
test = load_split(cache_dir, subject, "test")
if args.max_samples_per_subject is not None:
test = test[: args.max_samples_per_subject]
splits[subject] = (dev, test)
tokenizer = AutoTokenizer.from_pretrained(args.model, revision=args.revision)
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "left"
model = AutoModelForCausalLM.from_pretrained(
args.model,
revision=args.revision,
dtype=dtype,
low_cpu_mem_usage=True,
).to(device)
model.eval()
subject_results: list[dict[str, Any]] = []
predictions: list[dict[str, Any]] = []
for subject in args.subjects:
dev, test = splits[subject]
evaluation_config = {
"dataset_revision": DATASET_REVISION,
"few_shot": args.few_shot,
"model_revision": args.revision,
"sample_size": len(test),
}
checkpoint = load_subject_checkpoint(
output_dir,
subject,
evaluation_config,
)
if checkpoint is None:
result, subject_predictions = evaluate_subject(
model=model,
tokenizer=tokenizer,
device=device,
subject=subject,
dev=dev,
test=test,
few_shot_count=args.few_shot,
batch_size=args.batch_size,
)
result["evaluation_config"] = evaluation_config
write_subject_checkpoint(
output_dir,
subject,
result,
subject_predictions,
)
else:
result, subject_predictions = checkpoint
print(
json.dumps(
{
"subject": subject,
"status": "resumed_from_checkpoint",
"sample_size": result["sample_size"],
}
),
flush=True,
)
subject_results.append(result)
predictions.extend(subject_predictions)
print(json.dumps(result, sort_keys=True), flush=True)
total = sum(row["sample_size"] for row in subject_results)
correct = sum(row["correct"] for row in subject_results)
lower, upper = wilson_interval(correct, total)
accuracies = [
row["accuracy"] for row in subject_results if row["accuracy"] is not None
]
summary = {
"benchmark": "MMLU finance and business subset",
"dataset": DATASET_ID,
"dataset_revision": DATASET_REVISION,
"model": args.model,
"model_revision": args.revision,
"evaluated_at": datetime.now(timezone.utc).isoformat(),
"device": device,
"dtype": str(dtype).replace("torch.", ""),
"few_shot": args.few_shot,
"sample_limit_per_subject": args.max_samples_per_subject,
"subjects": subject_results,
"sample_size": total,
"micro_accuracy": round(correct / total, 8) if total else None,
"macro_accuracy": round(statistics.mean(accuracies), 8) if accuracies else None,
"confidence_interval_95": [round(lower, 8), round(upper, 8)],
"status": "community_evaluation",
"official_leaderboard_result": False,
}
write_json_atomic(output_dir / "results.json", summary)
write_jsonl_atomic(output_dir / "predictions.jsonl", predictions)
print(json.dumps(summary, indent=2, sort_keys=True))
if __name__ == "__main__":
main()