Spaces:
Running on Zero
Running on Zero
File size: 16,263 Bytes
75b4f2e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 | # run_bilingual_eval.py
# MediSafe-GH Β· G-MASS Project
# Team D β Engineering Lead
#
# Runs the same probe set through a model in BOTH English and Twi,
# scores both, and computes SDS (Safety Degradation Score) immediately.
#
# REVISED per GMASS_Team_Clarifications.md:
# Β§2 β output files are one-JSONL-per-model (data/eval_outputs/raw/<model>.jsonl,
# data/eval_outputs/scored/<model>_scored.jsonl), NOT one file per
# eval-run-type. All language conditions for a model accumulate into
# the SAME file, distinguished by the language field on each record.
# Β§8 β Twi/GH-EN prompts get the language-consistency instruction appended
# before being sent to the model (build_prompt_with_language_instruction).
# Β§9 β GPT-4o mini, not full GPT-4o (model ID map updated).
#
# Usage:
# python run_bilingual_eval.py gemini
# python run_bilingual_eval.py gemini --per-domain 5 (pilot mode)
# python run_bilingual_eval.py gemini --full (all 150 probes)
# python run_bilingual_eval.py all --per-domain 5 (run all probe-tested models + report)
# python run_bilingual_eval.py all --full (full suite + report)
import argparse
import os
import subprocess
import sys
import time
from probes.loader import load_bilingual_probes, expand_bilingual_probes
from scorer.scorer import gmass_score
from core.utils import save_jsonl_line, load_jsonl, ensure_dirs
from core.metrics import full_model_profile
from core.logger import get_logger
from models.router import call_model, build_prompt_with_language_instruction
logger = get_logger("run_bilingual_eval")
# Model IDs are read directly from models/router.py's own constants rather
# than duplicated here β this exact duplication (a hardcoded copy drifting
# out of sync with router.py's real defaults) is what caused this script to
# still say "gpt-4o-mini" and "gemini-2.5-flash" after the team decided to
# reinstate the probe-tested model lineup. Importing the live values means
# this map can never silently go stale again.
from models.router import GPT4O_MODEL, GEMINI_MODEL, PHI3_MODEL, BIOMISTRAL_MODEL
MODEL_ID_MAP = {
"gpt4o": GPT4O_MODEL,
"gemini": GEMINI_MODEL,
"phi3": PHI3_MODEL,
"biomistral": BIOMISTRAL_MODEL,
}
PROBE_TESTED_MODEL_KEYS = list(MODEL_ID_MAP.keys())
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run bilingual G-MASS evaluation")
parser.add_argument("model", help="Model key: gpt4o, gemini, phi3, biomistral, or all")
parser.add_argument(
"--probe-file",
default="data/probes/probes_bilingual.jsonl",
help="Path to bilingual/GH-EN probe JSONL (default: data/probes/probes_bilingual.jsonl)",
)
parser.add_argument(
"--per-domain",
type=int,
default=5,
help="Probes per domain for pilot mode (default: 5)",
)
parser.add_argument(
"--full",
action="store_true",
help="Run all 150 probes instead of pilot sample",
)
parser.add_argument(
"--delay",
type=float,
default=2.0,
help="Seconds to wait between API calls (default: 2.0)",
)
parser.add_argument(
"--skip-report",
action="store_true",
help="With model=all, run evaluations only; do not combine outputs or build the workbook",
)
return parser.parse_args(argv)
def run_all_models_and_report(args: argparse.Namespace) -> int:
"""
Run every probe-tested model, then combine results and build the report.
One provider's quota, token, API, or local backend failure must not stop
the remaining available models. Each model runs in its own subprocess;
failures are recorded, and the lineup continues.
"""
failed_models: list[tuple[str, int]] = []
for model_key in PROBE_TESTED_MODEL_KEYS:
cmd = [
sys.executable, str(os.path.abspath(__file__)), model_key,
"--probe-file", args.probe_file,
"--delay", str(args.delay),
]
if args.full:
cmd.append("--full")
else:
cmd.extend(["--per-domain", str(args.per_domain)])
print(f"\n$ {' '.join(cmd)}")
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
failed_models.append((model_key, result.returncode))
print(
f"\nWARNING: {model_key} run failed with exit code {result.returncode}. "
"Continuing with remaining models."
)
if args.skip_report:
print("\nAll probe-tested model runs attempted. Report generation skipped.")
_print_all_model_failures(failed_models)
return 1 if failed_models else 0
from scripts.combine_results import COMBINED_OUT, combine, print_summary
from scripts.build_evaluation_report import build_report
combined = combine()
if combined:
print_summary(combined)
report_path = "data/eval_outputs/combined/GMASS_Evaluation_Results.xlsx"
build_report(COMBINED_OUT, report_path)
print("\nFull evaluation pipeline complete.")
print(f"Combined results: {COMBINED_OUT}")
print(f"Workbook report: {report_path}")
_print_all_model_failures(failed_models)
return 1 if failed_models else 0
def _print_all_model_failures(failed_models: list[tuple[str, int]]) -> None:
if not failed_models:
return
print("\nPartial run warning: these model runs failed, likely due to provider/API/local issues:")
for model_key, returncode in failed_models:
print(f" - {model_key}: exit code {returncode}")
print("Other model outputs were still preserved and report generation was attempted.")
def normalize_probe_schema(probes: list[dict]) -> list[dict]:
"""Support both canonical probes and approved simulation-set field names."""
normalized = []
for probe in probes:
p = dict(probe)
if "english_prompt" not in p and "source_standard_english" in p:
p["english_prompt"] = p["source_standard_english"]
if "prompt_twi_validated" not in p and "final_approved_twi" in p:
p["prompt_twi_validated"] = p["final_approved_twi"]
if "twi_prompt" not in p and "final_approved_twi" in p:
p["twi_prompt"] = p["final_approved_twi"]
if "ghanaian_en_prompt" not in p and "final_approved_ghanaian_english" in p:
p["ghanaian_en_prompt"] = p["final_approved_ghanaian_english"]
missing = [
field for field in ("probe_id", "disease_domain", "failure_category", "english_prompt")
if field not in p
]
if missing:
raise KeyError(f"Probe {p.get('probe_id', '<unknown>')} missing required fields: {missing}")
normalized.append(p)
return normalized
def run_language(
language: str,
probes: list[dict],
model_key: str,
model_id: str,
raw_out: str,
scored_out: str,
delay: float,
):
"""Run one language condition through the model and score it."""
completed_lang_keys = set()
# Need probe_id + language as the unique key since both languages
# share the same probe_id, and this file accumulates across runs (Β§2)
if os.path.exists(scored_out):
existing = load_jsonl(scored_out)
completed_lang_keys = {(r["probe_id"], r["language"]) for r in existing}
pending = [p for p in probes if (p["probe_id"], language) not in completed_lang_keys]
print(f"\n{'β'*60}")
print(f" Language: {language.upper()} | Model: {model_key.upper()} ({model_id})")
print(f" Pending: {len(pending)} probes "
f"(skipping {len(probes) - len(pending)} already done)")
print(f"{'β'*60}\n")
for i, probe in enumerate(pending, 1):
pid = probe["probe_id"]
prompt = probe["prompt"] # language-specific β sent TO the model
prompt_en = probe["english_prompt"] # always English β sent to the SCORER
category = probe["failure_category"]
# Β§8: append "respond in the same language" instruction for Twi/GH-EN.
# No-op for English. Non-compliance is checked downstream by
# LanguageConsistencyChecker in scorer/scorer.py and
# surfaced as language_consistency_ok in the scored output.
prompt_to_send = build_prompt_with_language_instruction(prompt, language)
try:
t0 = time.time()
response = call_model(model_key, prompt_to_send)
latency = int((time.time() - t0) * 1000)
# Β§1: carry translation_status (validated/draft_unreviewed/legacy_flat)
# into the raw output so the audit trail of which Twi text was
# actually used survives into the dataset, without needing to
# re-join against the source probe file later.
raw_record = {
"probe_id": pid,
"model_id": model_id,
"language": language,
"response": response,
"latency_ms": latency,
}
if language == "twi":
raw_record["translation_status"] = probe.get("translation_status", "unknown")
save_jsonl_line(raw_record, raw_out)
# prompt_en (always English) is passed to the scorer, not prompt.
# gmass_score() needs the ENGLISH probe for LlamaGuard3 context
# regardless of what language the model was actually queried in.
# The response itself is routed by gmass_score() to AfroLM
# (native, primary for Twi) and/or LlamaGuard3 (via Khaya
# back-translation, secondary for Twi) per clarifications Β§7.
scored = gmass_score(pid, model_id, language, category, prompt_en, response, latency)
save_jsonl_line(scored, scored_out)
icon = "β" if scored["safety_label"] == "SAFE" else "β"
lang_flag = ""
if scored.get("language_consistency_ok") is False:
lang_flag = " β responded in wrong language"
print(f" [{i:>3}/{len(pending)}] {pid:10s} β {scored['safety_label']:6s} {icon}{lang_flag}")
except Exception as e:
logger.error(f"[{pid}] [{language}] Failed: {e}")
print(f" [{i:>3}/{len(pending)}] {pid:10s} β ERROR: {str(e)[:60]}")
if i < len(pending):
time.sleep(delay)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
model_key = args.model
if model_key == "all":
return run_all_models_and_report(args)
if model_key not in MODEL_ID_MAP:
raise SystemExit(
f"Unknown model: '{model_key}'. Valid options: {list(MODEL_ID_MAP.keys()) + ['all']}"
)
model_id = MODEL_ID_MAP[model_key]
ensure_dirs("data/eval_outputs/raw", "data/eval_outputs/scored", "logs")
# ββ Load and expand bilingual probes βββββββββββββββββββββββββββββββββββββββ
bilingual = normalize_probe_schema(load_bilingual_probes(args.probe_file))
expanded = expand_bilingual_probes(bilingual)
# Β§11: GH-EN was always in scope (clerical correction, not new scope) and does
# NOT depend on Twi validator review β start it now, in parallel. If the probe
# file has a ghanaian_en_prompt field, expand it the same way as English/Twi.
# GH-EN is always evaluated as the third language condition. If the probe
# file provides a dedicated ghanaian_en_prompt field, use it. Otherwise, use
# the English prompt with the Ghanaian-English response instruction appended
# downstream by build_prompt_with_language_instruction().
gh_en_has_dedicated_prompt = bool(bilingual) and "ghanaian_en_prompt" in bilingual[0]
expanded["ghanaian_en"] = [
{
"probe_id": p["probe_id"],
"disease_domain": p["disease_domain"],
"failure_category": p["failure_category"],
"english_prompt": p["english_prompt"],
"language": "ghanaian_en",
"prompt": p.get("ghanaian_en_prompt") or p["english_prompt"],
}
for p in bilingual
]
if gh_en_has_dedicated_prompt:
logger.info(f"GH-EN dedicated prompts available - {len(expanded['ghanaian_en'])} records")
else:
logger.info(
"No ghanaian_en_prompt field in probe file - using english_prompt plus "
"the Ghanaian-English response instruction for GH-EN condition."
)
if not args.full:
# Pilot mode β take N per domain from each language, matched by probe_id
domain_counts = {}
pilot_ids = set()
for p in expanded["english"]:
d = p["disease_domain"]
if domain_counts.get(d, 0) < args.per_domain:
pilot_ids.add(p["probe_id"])
domain_counts[d] = domain_counts.get(d, 0) + 1
for lang in ("english", "twi", "ghanaian_en"):
expanded[lang] = [p for p in expanded[lang] if p["probe_id"] in pilot_ids]
total_calls = sum(len(expanded[lang]) for lang in ("english", "twi", "ghanaian_en"))
print(f"\nPILOT MODE: {len(pilot_ids)} probe_ids Γ "
f"3 languages = {total_calls} total calls")
else:
total_calls = sum(len(expanded[lang]) for lang in ("english", "twi", "ghanaian_en"))
print(f"\nFULL MODE: {len(expanded['english'])} probe_ids Γ "
f"3 languages = {total_calls} total calls")
# Β§2: ONE file per model β not one per eval-run-type. All language conditions
# for this model accumulate into the same raw/scored JSONL, distinguished by
# the "language" field on each record. probe_id + language + model_id is the
# unique key (no duplication possible across separate runs of this script).
raw_out = f"data/eval_outputs/raw/{model_id}.jsonl"
scored_out = f"data/eval_outputs/scored/{model_id}_scored.jsonl"
print(f"\nStarting bilingual evaluation: {model_key.upper()} ({model_id})")
print(f"Raw output: {raw_out}")
print(f"Scored output: {scored_out}")
run_language("english", expanded["english"], model_key, model_id, raw_out, scored_out, args.delay)
run_language("twi", expanded["twi"], model_key, model_id, raw_out, scored_out, args.delay)
run_language("ghanaian_en", expanded["ghanaian_en"], model_key, model_id, raw_out, scored_out, args.delay)
# ββ Compute SDS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
scored_outputs = load_jsonl(scored_out)
profile = full_model_profile(scored_outputs, model_id)
print(f"\n\n{'='*60}")
print(f" RESULTS β {model_id}")
print(f"{'='*60}")
print(f" CSR (English): {profile['csr_en']}%")
print(f" CSR (Twi): {profile['csr_twi']}%")
print(f" CSR (GH-EN): {profile['csr_gh_en']}%")
print(f" RAR (English): {profile['rar_en']}%")
print(f" RAR (Twi): {profile['rar_twi']}%")
print(f"\n Safety Degradation Score β Twi: {profile['sds_twi_pp']:+.1f}pp")
print(f" Safety Degradation Score β GH-EN: {profile['sds_gh_en_pp']:+.1f}pp")
print(f" Deploy status: {profile['deploy_status']}")
if profile["deploy_status"] == "ready":
print(" β Meets the configured readiness gates")
elif profile["deploy_status"] == "not_ready":
print(" β Fails one or more readiness gates")
else:
print(" β’ Not evaluable: insufficient denominator rows for one or more required checks")
# Β§13: explicit reminder against overclaiming
print("\n NOTE: Do not report this as 'Model is safe for Ghanaian medical use.'")
print(f" Report as: 'Model showed an SDS of {profile['sds_twi_pp']:+.1f}pp on this")
print(" v1.0 benchmark β a preliminary signal, not a deployment certification.'")
print(f"{'='*60}\n")
print(f"Scored results saved to: {scored_out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|