pauc.py 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211
  1. import argparse, json, os
  2. import time
  3. import threading
  4. from collections import defaultdict
  5. from concurrent.futures import ThreadPoolExecutor, as_completed
  6. from typing import Optional, Set, Tuple, Dict, Any
  7. import numpy as np
  8. try:
  9. from evaluation.gaming.gpt_client_utils import GPTClientFactory
  10. except ImportError:
  11. try:
  12. from proactvl_eval.gpt_client_utils import GPTClientFactory
  13. except ImportError:
  14. from gpt_client_utils import GPTClientFactory
  15. def _make_question_id(dataset_name: str, video_id: str) -> str:
  16. dataset_name = (dataset_name or "").strip()
  17. video_id = (video_id or "").strip()
  18. if dataset_name:
  19. # Avoid conflict with '=' separator used in custom_id
  20. return f"{dataset_name}::{video_id}"
  21. return video_id
  22. def _make_segment_id(seg: dict) -> str:
  23. """
  24. Unique ID for segment-jsonl: prefer idx (if present), otherwise begin-end.
  25. Note: avoid '=' because custom_id uses '=' as a separator.
  26. """
  27. dataset_name = str(seg.get("dataset_name", "") or "").strip()
  28. video_id = str(seg.get("video_id", "") or "").strip()
  29. idx = seg.get("idx", None)
  30. if idx is not None:
  31. return f"{dataset_name}::{video_id}::idx={idx}".replace("=", "_")
  32. begin = seg.get("begin", "")
  33. end = seg.get("end", "")
  34. return f"{dataset_name}::{video_id}::span={begin}-{end}".replace("=", "_")
  35. def _load_json_or_jsonl(fname: str):
  36. """
  37. Read JSON or JSONL:
  38. - JSON: return json.load result
  39. - JSONL: return list[dict] (json.loads per line)
  40. """
  41. try:
  42. with open(fname, "r", encoding="utf-8") as f:
  43. return json.load(f)
  44. except json.JSONDecodeError:
  45. data = []
  46. with open(fname, "r", encoding="utf-8") as f:
  47. for line in f:
  48. line = line.strip()
  49. if not line:
  50. continue
  51. data.append(json.loads(line))
  52. return data
  53. def _is_segment_jsonl_item(obj: dict) -> bool:
  54. # segment format: one item per line {video_id, begin, end, pred:{ts:txt}, label:{ts:txt}, dataset_name?}
  55. if not isinstance(obj, dict):
  56. return False
  57. if "video_id" not in obj or "begin" not in obj or "end" not in obj:
  58. return False
  59. if "pred" not in obj or "label" not in obj:
  60. return False
  61. return isinstance(obj.get("pred"), dict) and isinstance(obj.get("label"), dict)
  62. def _is_pred_segment_jsonl_item(obj: dict) -> bool:
  63. """pred-jsonl row: must contain video_id/begin/end/pred(dict); label may be missing or None."""
  64. if not isinstance(obj, dict):
  65. return False
  66. if "video_id" not in obj or "begin" not in obj or "end" not in obj:
  67. return False
  68. if "pred" not in obj:
  69. return False
  70. return isinstance(obj.get("pred"), dict)
  71. def _load_reference_labels(reference_file: str) -> Dict[Tuple[str, str, Any, Any], dict]:
  72. """
  73. Load label data from reference file (indexed by (video_id, dataset_name, begin, end)).
  74. Reference format: one JSON per line, containing at least video_id/dataset_name/begin/end/label(dict).
  75. """
  76. ref = {}
  77. if not reference_file:
  78. return ref
  79. with open(reference_file, "r", encoding="utf-8") as f:
  80. for line in f:
  81. line = line.strip()
  82. if not line:
  83. continue
  84. try:
  85. item = json.loads(line)
  86. except json.JSONDecodeError:
  87. continue
  88. vid = item.get("video_id")
  89. dataset = item.get("dataset_name")
  90. begin = item.get("begin")
  91. end = item.get("end")
  92. label = item.get("label")
  93. if not vid:
  94. continue
  95. if not isinstance(label, dict) or not label:
  96. continue
  97. ref[(vid, dataset, begin, end)] = label
  98. return ref
  99. def _build_label_lookup_from_segments(seg_list: list) -> Dict[Tuple[str, str, Any, Any], dict]:
  100. """Extract label mapping from segment list: (video_id, dataset_name, begin, end) -> label(dict)."""
  101. m = {}
  102. if not isinstance(seg_list, list):
  103. return m
  104. for seg in seg_list:
  105. if not isinstance(seg, dict):
  106. continue
  107. vid = seg.get("video_id")
  108. dataset = seg.get("dataset_name")
  109. begin = seg.get("begin")
  110. end = seg.get("end")
  111. label = seg.get("label")
  112. if not vid:
  113. continue
  114. if isinstance(label, dict) and label:
  115. m[(vid, dataset, begin, end)] = label
  116. return m
  117. def _join_ts_text_map(ts2text: dict) -> str:
  118. """Sort {timestamp(str): text} by time and join into one paragraph."""
  119. if not ts2text:
  120. return ""
  121. items = []
  122. for k, v in ts2text.items():
  123. try:
  124. t = float(k)
  125. except (TypeError, ValueError):
  126. continue
  127. if v is None:
  128. continue
  129. s = str(v).strip()
  130. if not s:
  131. continue
  132. items.append((t, s))
  133. items.sort(key=lambda x: x[0])
  134. return " ".join([s for _, s in items]).strip()
  135. def _merge_adjacent_pred_sents(all_pred_sents, delta_s: float = 1.0):
  136. """
  137. Merge predicted sentences whose adjacent time difference is delta_s:
  138. - Merge content (if previous chunk ends with "..." or "…", strip it before concatenation)
  139. - Use the start time of the continuous sequence as timestamp
  140. Input/output format: list[tuple[time(float), content(str)]]
  141. """
  142. if not all_pred_sents:
  143. return []
  144. def _strip_trailing_ellipsis(s: str) -> str:
  145. s = (s or "").rstrip()
  146. if s.endswith("..."):
  147. return s[:-3].rstrip()
  148. if s.endswith("…"):
  149. return s[:-1].rstrip()
  150. return s
  151. merged = []
  152. start_t, prev_t = all_pred_sents[0][0], all_pred_sents[0][0]
  153. buf = str(all_pred_sents[0][1]).strip()
  154. for t, sent in all_pred_sents[1:]:
  155. sent = str(sent).strip()
  156. # Merge only items whose adjacent time difference is delta_s
  157. if round(t - prev_t, 6) == round(delta_s, 6):
  158. buf = _strip_trailing_ellipsis(buf)
  159. if buf and sent:
  160. buf = f"{buf} {sent}"
  161. else:
  162. buf = buf + sent
  163. prev_t = t
  164. else:
  165. merged.append((start_t, buf))
  166. start_t, prev_t = t, t
  167. buf = sent
  168. merged.append((start_t, buf))
  169. return merged
  170. def _get_openai_client():
  171. """Build GPT client with Entra ID or API Key authentication."""
  172. return GPTClientFactory().build()
  173. _thread_local = threading.local()
  174. def _get_thread_client():
  175. """Use one client per thread to avoid potential thread-safety issues."""
  176. client = getattr(_thread_local, "client", None)
  177. if client is None:
  178. client = _get_openai_client()
  179. _thread_local.client = client
  180. return client
  181. def _judge_one_request(
  182. req: Dict[str, Any],
  183. openai_model: Optional[str],
  184. temperature: float,
  185. max_tokens: int,
  186. max_retries: int,
  187. retry_sleep_s: float,
  188. ) -> Tuple[str, Dict[str, Any]]:
  189. """
  190. Call `chat.completions` for a single request and return (custom_id, output_line_dict).
  191. Never raises exceptions (writes the minimum score "1" on failure).
  192. """
  193. custom_id = req.get("custom_id", "")
  194. body = req.get("body", {}) or {}
  195. model = body.get("model") or openai_model
  196. messages = body.get("messages")
  197. content = "1"
  198. if model and messages:
  199. last_err = None
  200. for attempt in range(max_retries + 1):
  201. try:
  202. client = _get_thread_client()
  203. resp = client.chat.completions.create(
  204. model=model,
  205. messages=messages,
  206. temperature=temperature,
  207. # max_tokens=max_tokens,
  208. )
  209. content = resp.choices[0].message.content or "1"
  210. break
  211. except Exception as e:
  212. last_err = e
  213. if attempt == max_retries:
  214. content = "1"
  215. print(f"[WARN] judge failed custom_id={custom_id}: {repr(last_err)}")
  216. else:
  217. time.sleep(retry_sleep_s)
  218. out_line = {
  219. "custom_id": custom_id,
  220. "response": {
  221. "body": {
  222. "choices": [
  223. {"message": {"content": str(content)}}
  224. ]
  225. }
  226. },
  227. }
  228. return custom_id, out_line
  229. def _sync_judge_from_requests(
  230. requests_jsonl: str,
  231. responses_jsonl: str,
  232. openai_model: Optional[str] = None,
  233. temperature: float = 0.0,
  234. max_tokens: int = 4,
  235. max_retries: int = 3,
  236. retry_sleep_s: float = 2.0,
  237. resume: bool = True,
  238. concurrency: int = 8,
  239. judge_limit: int = -1,
  240. print_every: int = 50,
  241. ):
  242. """
  243. Synchronously call `chat.completions` for each request in the JSONL generated by
  244. `create_openai_batch_input`, and write a response JSONL compatible with OpenAI Batch output
  245. so that `process_openai_batch_output` can be reused.
  246. """
  247. # Resume support: skip already completed custom_id entries
  248. done_custom_ids: Set[str] = set()
  249. if resume and os.path.exists(responses_jsonl):
  250. try:
  251. for line in open(responses_jsonl, encoding="utf-8"):
  252. if not line.strip():
  253. continue
  254. try:
  255. obj = json.loads(line)
  256. cid = obj.get("custom_id")
  257. if cid:
  258. done_custom_ids.add(cid)
  259. except json.JSONDecodeError:
  260. continue
  261. except OSError:
  262. pass
  263. out_dir = os.path.dirname(responses_jsonl)
  264. if out_dir:
  265. os.makedirs(out_dir, exist_ok=True)
  266. total = 0
  267. skipped = 0
  268. written = 0
  269. scheduled = 0 # Actual number of newly scheduled judge requests (excluding resume skips)
  270. if concurrency < 1:
  271. concurrency = 1
  272. # Use thread pool for concurrent requests; main thread writes files to avoid write conflicts
  273. with open(responses_jsonl, "a", encoding="utf-8") as f_out, ThreadPoolExecutor(max_workers=concurrency) as ex:
  274. in_flight = set()
  275. def _drain_some(min_done: int = 1):
  276. nonlocal written
  277. done_cnt = 0
  278. for fut in as_completed(in_flight):
  279. in_flight.remove(fut)
  280. _, out_line = fut.result()
  281. f_out.write(json.dumps(out_line, ensure_ascii=False) + "\n")
  282. done_cnt += 1
  283. written += 1
  284. if print_every > 0 and written % print_every == 0:
  285. print(f"[judge] written={written}, skipped={skipped}, total_seen={total}, in_flight={len(in_flight)}")
  286. if done_cnt >= min_done:
  287. break
  288. for line in open(requests_jsonl, encoding="utf-8"):
  289. if not line.strip():
  290. continue
  291. req = json.loads(line)
  292. custom_id = req.get("custom_id", "")
  293. total += 1
  294. if custom_id in done_custom_ids:
  295. skipped += 1
  296. continue
  297. if judge_limit is not None and judge_limit > 0 and scheduled >= judge_limit:
  298. break
  299. # print(requests_jsonl)
  300. fut = ex.submit(
  301. _judge_one_request,
  302. req,
  303. openai_model,
  304. temperature,
  305. max_tokens,
  306. max_retries,
  307. retry_sleep_s,
  308. )
  309. in_flight.add(fut)
  310. scheduled += 1
  311. # Control queue length to avoid memory spikes
  312. if len(in_flight) >= concurrency * 2:
  313. _drain_some(min_done=concurrency)
  314. # Finalize: flush all remaining items
  315. while in_flight:
  316. _drain_some(min_done=1)
  317. print(
  318. f"[judge done] written={written}, skipped={skipped}, total_seen={total}, "
  319. f"scheduled={scheduled}, output={responses_jsonl}"
  320. )
  321. def one_step_eval(
  322. pred_fname: str,
  323. gold_fname: Optional[str],
  324. output_fname: str,
  325. openai_model: str,
  326. n_examples: int = -1,
  327. max_scores: int = 3,
  328. resume: bool = False,
  329. concurrency: int = 8,
  330. judge_limit: int = 100,
  331. reference_fname: Optional[str] = None,
  332. start_score: float = 0.5,
  333. ):
  334. """
  335. Complete in one step:
  336. 1) Generate judge requests (JSONL)
  337. 2) Synchronously call OpenRouter `chat.completions` to generate judge responses (JSONL)
  338. 3) Aggregate into structured JSON (compatible with `stat_metric`)
  339. 4) Print final metrics
  340. """
  341. out_dir = os.path.dirname(output_fname)
  342. if out_dir:
  343. os.makedirs(out_dir, exist_ok=True)
  344. stem, _ = os.path.splitext(output_fname)
  345. requests_jsonl = stem + "judge_requests.jsonl"
  346. responses_jsonl = stem + "judge_responses.jsonl"
  347. if not gold_fname:
  348. gold_fname = pred_fname
  349. create_openai_batch_input(
  350. pred_fname=pred_fname,
  351. gold_fname=gold_fname,
  352. output_fname=requests_jsonl,
  353. n_examples=n_examples,
  354. openai_model=openai_model,
  355. reference_fname=reference_fname,
  356. )
  357. _sync_judge_from_requests(
  358. requests_jsonl=requests_jsonl,
  359. responses_jsonl=responses_jsonl,
  360. openai_model=openai_model,
  361. resume=resume,
  362. concurrency=concurrency,
  363. judge_limit=judge_limit,
  364. )
  365. process_openai_batch_output(
  366. pred_fname=pred_fname,
  367. gold_fname=gold_fname,
  368. input_fname=responses_jsonl,
  369. output_fname=output_fname,
  370. n_examples=n_examples,
  371. reference_fname=reference_fname,
  372. )
  373. def _count_judged_points(scored_json: str) -> Dict[str, int]:
  374. data = json.load(open(scored_json, encoding="utf-8"))
  375. num_examples = len(data) if isinstance(data, list) else 0
  376. num_turns = 0
  377. num_points = 0
  378. for ex in data or []:
  379. for turn in ex.get("answer", []) or []:
  380. num_turns += 1
  381. js = turn.get("judge_scores") or {}
  382. num_points += len(js)
  383. return {"num_examples": num_examples, "num_turns": num_turns, "num_judged_points": num_points}
  384. summary = {
  385. "pred_fname": pred_fname,
  386. "gold_fname": gold_fname,
  387. "reference_fname": reference_fname,
  388. "openai_model": openai_model,
  389. "judge_limit": judge_limit,
  390. "concurrency": concurrency,
  391. "resume": resume,
  392. "output_fname": output_fname,
  393. "start_score": start_score,
  394. }
  395. summary.update(_count_judged_points(output_fname))
  396. print(f"NOTE: max_score={max_scores}, so the largest y-axis is {max_scores-1}")
  397. for omega in [0, 0.5, 1]:
  398. res = stat_metric(output_fname, omega=omega, max_score=max_scores - 1, start_score=start_score)
  399. print(round(res["mean_auc"] * 100, 1), end=" & ")
  400. summary[f"mean_auc_omega_{omega}"] = float(res["mean_auc"])
  401. print("num_examples:", res["num_videos"])
  402. print()
  403. # New: aggregate by tag (following evaluate_end_to_end.py by_tag pattern)
  404. try:
  405. res_by_tag = stat_metric_by_tag(
  406. input_fname=output_fname,
  407. omegas=(0, 0.5, 1),
  408. max_score=max_scores - 1,
  409. start_score=start_score,
  410. ignore_empty=False,
  411. )
  412. summary["pauc_by_tag"] = res_by_tag
  413. if res_by_tag.get("by_tag"):
  414. print("By tag (PAUC):")
  415. for tag, stats in res_by_tag["by_tag"].items():
  416. print(f"{tag}:")
  417. for omega in ["0", "0.5", "1"]:
  418. v = stats["mean_auc"].get(omega)
  419. if v == v: # not NaN
  420. print(f" omega={omega}: mean_auc={v:.6f} ({stats['num_turns'].get(omega, 0)} turns)")
  421. else:
  422. print(f" omega={omega}: mean_auc=nan ({stats['num_turns'].get(omega, 0)} turns)")
  423. print()
  424. except Exception as e:
  425. print(f"[WARN] stat_metric_by_tag failed in one_step_eval: {repr(e)}")
  426. # Additional metric computed only on judged turns, useful for judge_limit small-sample sanity checks
  427. print(f"NOTE (judged_only): ignore empty turns (no judge_scores)")
  428. for omega in [0, 0.5, 1]:
  429. res_j = stat_metric(output_fname, omega=omega, max_score=max_scores - 1, ignore_empty=True, start_score=start_score)
  430. # If judge_limit is too small, res_j['num_turns'] may be 0 and np.mean can become nan
  431. mean_auc = res_j["mean_auc"]
  432. if mean_auc == mean_auc: # not NaN
  433. print(round(mean_auc * 100, 1), end=" & ")
  434. else:
  435. print("nan", end=" & ")
  436. summary[f"mean_auc_judged_only_omega_{omega}"] = float(mean_auc) if mean_auc == mean_auc else None
  437. print("num_turns_used:", res_j["num_turns"])
  438. print()
  439. # by_tag stats for judged_only (for small-sample/resume sanity checks)
  440. try:
  441. res_by_tag_j = stat_metric_by_tag(
  442. input_fname=output_fname,
  443. omegas=(0, 0.5, 1),
  444. max_score=max_scores - 1,
  445. start_score=start_score,
  446. ignore_empty=True,
  447. )
  448. summary["pauc_by_tag_judged_only"] = res_by_tag_j
  449. except Exception as e:
  450. print(f"[WARN] stat_metric_by_tag(ignore_empty=True) failed: {repr(e)}")
  451. summary_fname = os.path.splitext(output_fname)[0] + "summary.json"
  452. with open(summary_fname, "w", encoding="utf-8") as f:
  453. json.dump(summary, f, ensure_ascii=False, indent=2)
  454. print(f"[saved] {summary_fname}")
  455. def load_model_preds(pred_fname):
  456. """
  457. Load model prediction data.
  458. Supported formats:
  459. 1. Legacy format: one JSON object per line, with `question_id` and `model_response_list`
  460. 2. New format: a JSON array, each element has `video_id` and a `label` list, where each label may include `pred`
  461. 3. segment-jsonl: one segment per line, including `video_id`/`begin`/`end`/`pred(dict)`
  462. """
  463. res_dict = dict()
  464. # Try loading as JSON / JSONL (new format or segment-jsonl)
  465. try:
  466. data = _load_json_or_jsonl(pred_fname)
  467. if isinstance(data, list) and data:
  468. # 1) segment-jsonl: one segment per line
  469. if _is_segment_jsonl_item(data[0]):
  470. for seg in data:
  471. if not _is_segment_jsonl_item(seg):
  472. continue
  473. video_id = seg.get("video_id")
  474. if not video_id:
  475. continue
  476. dataset_name = seg.get("dataset_name", "")
  477. question_id = _make_question_id(dataset_name, video_id)
  478. pred_map = seg.get("pred") or {}
  479. for ts, content in pred_map.items():
  480. try:
  481. t = float(ts)
  482. except (ValueError, TypeError):
  483. continue
  484. if content is None:
  485. continue
  486. s = str(content).strip()
  487. if not s:
  488. continue
  489. res_dict.setdefault(question_id, []).append((t, s))
  490. return res_dict
  491. # 2) Legacy-new format: JSON array (each element is a video; label is a list; label_seg contains pred dict)
  492. first_item = data[0]
  493. if isinstance(first_item, dict) and "video_id" in first_item and isinstance(first_item.get("label"), list):
  494. for video_data in data:
  495. video_id = video_data.get('video_id')
  496. if video_id is None:
  497. continue
  498. dataset_name = video_data.get("dataset_name", "")
  499. question_id = _make_question_id(dataset_name, video_id)
  500. pred_list = []
  501. label_list = video_data.get('label', [])
  502. for label_seg in label_list:
  503. if 'pred' in label_seg and label_seg['pred']:
  504. for timestamp_str, content in label_seg['pred'].items():
  505. try:
  506. timestamp = float(timestamp_str)
  507. pred_list.append((timestamp, content))
  508. except (ValueError, TypeError):
  509. continue
  510. if pred_list:
  511. res_dict[question_id] = pred_list
  512. return res_dict
  513. except (json.JSONDecodeError, ValueError):
  514. pass
  515. # Old format: one JSON object per line
  516. for line in open(pred_fname, encoding='utf-8'):
  517. example = json.loads(line)
  518. res_dict[example['question_id']] = [(e['time'], e['content']) for e in example['model_response_list'] if e.get('role', 'assistant') == 'assistant']
  519. # model_response_list may contain user input in some cases
  520. return res_dict
  521. def create_openai_batch_input(pred_fname, gold_fname, output_fname, n_examples, openai_model, reference_fname: Optional[str] = None):
  522. game_instruction = (
  523. "You are an evaluator for a gaming commentary system. Your task is to rate the whether the predicted answer covers the key points of the ground truth answer. Use the following scale to assign a score:\n"
  524. "- 3: Mostly covered; the predicted answer covers all key information in the ground truth answer, though it may have minor inaccuracies or rephrases.\n"
  525. "- 2: Partially covered; the predicted answer has some correct information, but also contains significant inaccuracies or missing key points.\n"
  526. "- 1: Incorrect; the predicted answer may be related to the ground truth answer, but most of the information is missing, or the predicted answer is of very poor quality."
  527. "The similarity between past and current Predicted Answers must be considered, and penalties will be applied to both cases."
  528. "Output the score only, do not add more explanations."
  529. )
  530. # In segment-jsonl mode, do not pre-aggregate by video, otherwise overlapping windows get mixed.
  531. # So defer load_model_preds until format detection is complete.
  532. # Load gold (may be pred_fname itself or a separate gold file)
  533. gold_data_list = _load_json_or_jsonl(gold_fname) if gold_fname else []
  534. # Optional reference: used to backfill missing labels in pred/gold rows
  535. reference_labels = _load_reference_labels(reference_fname) if reference_fname else {}
  536. gold_label_lookup = _build_label_lookup_from_segments(gold_data_list) if isinstance(gold_data_list, list) else {}
  537. # Determine whether format is new or old
  538. is_new_format = False
  539. is_segment_format = False
  540. # Segment detection is more reliable via pred_fname naming (pred-segment-jsonl, label may be missing)
  541. pred_list_for_detect = _load_json_or_jsonl(pred_fname)
  542. if isinstance(pred_list_for_detect, list) and pred_list_for_detect:
  543. if _is_pred_segment_jsonl_item(pred_list_for_detect[0]):
  544. is_segment_format = True
  545. if (not is_segment_format) and isinstance(gold_data_list, list) and len(gold_data_list) > 0:
  546. first_item = gold_data_list[0]
  547. if _is_segment_jsonl_item(first_item):
  548. is_segment_format = True
  549. elif 'video_id' in first_item and 'label' in first_item:
  550. is_new_format = True
  551. if is_segment_format:
  552. # segment-jsonl: process line-by-line (iterate pred_fname), evaluate each segment independently
  553. pred_list = pred_list_for_detect if isinstance(pred_list_for_detect, list) else _load_json_or_jsonl(pred_fname)
  554. # Create output directory if directory name is not empty
  555. output_dir = os.path.dirname(output_fname)
  556. if output_dir:
  557. os.makedirs(output_dir, exist_ok=True)
  558. result_list = []
  559. added_examples = 0
  560. skipped_no_label = 0
  561. for seg in (pred_list or []):
  562. if not _is_pred_segment_jsonl_item(seg):
  563. continue
  564. seg_id = _make_segment_id(seg)
  565. vid = seg.get("video_id")
  566. dataset = seg.get("dataset_name")
  567. begin = float(seg.get("begin"))
  568. end = float(seg.get("end"))
  569. # Prefer in-segment label; otherwise backfill from gold_fname or reference
  570. label_dict = None
  571. if isinstance(seg.get("label"), dict) and seg.get("label"):
  572. label_dict = seg.get("label")
  573. else:
  574. key = (vid, dataset, seg.get("begin"), seg.get("end"))
  575. label_dict = gold_label_lookup.get(key) or reference_labels.get(key)
  576. if not isinstance(label_dict, dict) or not label_dict:
  577. skipped_no_label += 1
  578. continue
  579. gold_text = _join_ts_text_map(label_dict)
  580. if not gold_text:
  581. skipped_no_label += 1
  582. continue
  583. pred_map = seg.get("pred") or {}
  584. all_pred_sents = []
  585. for ts, content in (pred_map or {}).items():
  586. try:
  587. t = float(ts)
  588. except (ValueError, TypeError):
  589. continue
  590. if begin <= t <= end:
  591. s = str(content).strip()
  592. if s:
  593. all_pred_sents.append((t, s))
  594. all_pred_sents = sorted(all_pred_sents, key=lambda x: x[0])
  595. all_pred_sents = _merge_adjacent_pred_sents(all_pred_sents, delta_s=1.0)
  596. # Each segment has only one gold turn, so turn_i is fixed at 0
  597. turn_i = 0
  598. added_pred_sents = []
  599. for time, pred_sent in all_pred_sents:
  600. if pred_sent in added_pred_sents:
  601. continue
  602. added_pred_sents.append(pred_sent)
  603. model_input = (
  604. f"Ground Truth Answer: {gold_text}\n"
  605. f"Predicted Answer: {' '.join(added_pred_sents)}"
  606. )
  607. conversation = [
  608. {"role": "system", "content": game_instruction},
  609. {"role": "user", "content": model_input},
  610. ]
  611. # custom_id uses only 3 parts: {segment_id}={turn_i}={time}
  612. custom_id = f"{seg_id}={turn_i}={time}"
  613. result_list.append(
  614. {
  615. "custom_id": custom_id,
  616. "method": "POST",
  617. "url": "/v1/chat/completions",
  618. "body": {"model": openai_model, "messages": conversation},
  619. }
  620. )
  621. added_examples += 1
  622. if added_examples == n_examples:
  623. break
  624. with open(output_fname, "w", encoding="utf-8") as f_out:
  625. for example in result_list:
  626. f_out.write(json.dumps(example, ensure_ascii=False) + "\n")
  627. if skipped_no_label:
  628. print(f"[segment] skipped segments without label: {skipped_no_label}")
  629. return
  630. # Non-segment format: keep original question_id-based aggregation logic
  631. pred_data_dict = load_model_preds(pred_fname)
  632. if is_new_format:
  633. # New format: convert to old-format structure
  634. gold_data_dict = {}
  635. for video_data in gold_data_list:
  636. video_id = video_data.get('video_id')
  637. if video_id is None:
  638. continue
  639. dataset_name = video_data.get('dataset_name', '')
  640. question_id = _make_question_id(dataset_name, video_id)
  641. # Convert to old format while preserving dataset_name
  642. gold_data_dict[question_id] = {
  643. 'question_id': question_id,
  644. 'dataset_name': dataset_name,
  645. 'answer': []
  646. }
  647. label_list = video_data.get('label', [])
  648. for label_seg in label_list:
  649. if 'label_sentence' in label_seg:
  650. answer_turn = {
  651. 'reply_timespan': [label_seg['begin'], label_seg['end']],
  652. 'content': label_seg['label_sentence'],
  653. 'related_timespan': [label_seg['begin'], label_seg['end']] # Use same values
  654. }
  655. gold_data_dict[question_id]['answer'].append(answer_turn)
  656. else:
  657. # Old format
  658. gold_data_dict = {e['question_id']: e for e in gold_data_list}
  659. # Ensure old format also has dataset_name field (may be empty)
  660. for question_id in gold_data_dict:
  661. if 'dataset_name' not in gold_data_dict[question_id]:
  662. gold_data_dict[question_id]['dataset_name'] = ''
  663. # Create output directory if directory name is not empty
  664. output_dir = os.path.dirname(output_fname)
  665. if output_dir:
  666. os.makedirs(output_dir, exist_ok=True)
  667. added_examples = 0
  668. result_list = []
  669. for question_id, pred_data in pred_data_dict.items():
  670. if question_id not in gold_data_dict: continue
  671. gold_data = gold_data_dict[question_id]
  672. for turn_i, answer_turn in enumerate(gold_data['answer']):
  673. # find all pred sents in this ground truth answer span
  674. all_pred_sents = [(time, pred_sent) for time, pred_sent in pred_data \
  675. if answer_turn['reply_timespan'][0] <= time <= answer_turn['reply_timespan'][1]]
  676. # Sort by time
  677. all_pred_sents = sorted(all_pred_sents, key=lambda x: x[0])
  678. before_adjust_pred = all_pred_sents
  679. all_pred_sents = _merge_adjacent_pred_sents(all_pred_sents, delta_s=1.0)
  680. added_pred_sents = []
  681. for idx, (time, pred_sent) in enumerate(all_pred_sents):
  682. if pred_sent in added_pred_sents:
  683. continue
  684. added_pred_sents.append(pred_sent)
  685. model_input = (
  686. # f"Question: {gold_data['conversation'][0]['content']}\n"
  687. f"Ground Truth Answer: {answer_turn['content']}\n"
  688. f"Predicted Answer: {' '.join(added_pred_sents)}"
  689. )
  690. conversation = [
  691. {'role': 'system', 'content': game_instruction},
  692. {'role': 'user', 'content': model_input},
  693. ]
  694. # Include dataset_name in custom_id to ensure uniqueness
  695. dataset_name = gold_data.get('dataset_name', '')
  696. if dataset_name:
  697. custom_id = f"{dataset_name}={gold_data['question_id']}={turn_i}={time}"
  698. else:
  699. custom_id = f"{gold_data['question_id']}={turn_i}={time}"
  700. output_example = {
  701. "custom_id": custom_id, "method": "POST", "url": "/v1/chat/completions",
  702. "body": {"model": openai_model, "messages": conversation}
  703. }
  704. result_list.append(output_example)
  705. added_examples += 1
  706. if added_examples == n_examples: break
  707. with open(output_fname, 'w', encoding='utf-8') as f_out:
  708. for example in result_list:
  709. f_out.write(json.dumps(example, ensure_ascii=False) + '\n')
  710. def process_openai_batch_output(pred_fname, gold_fname, input_fname, output_fname, n_examples=-1, reference_fname: Optional[str] = None):
  711. # Load gold (may be pred_fname itself or a separate gold file)
  712. gold_data_list = _load_json_or_jsonl(gold_fname) if gold_fname else []
  713. reference_labels = _load_reference_labels(reference_fname) if reference_fname else {}
  714. gold_label_lookup = _build_label_lookup_from_segments(gold_data_list) if isinstance(gold_data_list, list) else {}
  715. # Determine whether format is new or old
  716. is_new_format = False
  717. is_segment_format = False
  718. pred_list_for_detect = _load_json_or_jsonl(pred_fname)
  719. if isinstance(pred_list_for_detect, list) and pred_list_for_detect:
  720. if _is_pred_segment_jsonl_item(pred_list_for_detect[0]):
  721. is_segment_format = True
  722. if (not is_segment_format) and isinstance(gold_data_list, list) and len(gold_data_list) > 0:
  723. first_item = gold_data_list[0]
  724. if _is_segment_jsonl_item(first_item):
  725. is_segment_format = True
  726. elif 'video_id' in first_item and 'label' in first_item:
  727. is_new_format = True
  728. if is_segment_format:
  729. # segment-jsonl: process line-by-line; each segment is one example with a single answer turn
  730. pred_list = pred_list_for_detect if isinstance(pred_list_for_detect, list) else _load_json_or_jsonl(pred_fname)
  731. gold_data_dict = {}
  732. for seg in (pred_list or []):
  733. if not _is_pred_segment_jsonl_item(seg):
  734. continue
  735. seg_id = _make_segment_id(seg)
  736. dataset_name = str(seg.get("dataset_name", "") or "")
  737. begin = float(seg.get("begin"))
  738. end = float(seg.get("end"))
  739. tag = seg.get("tag") or dataset_name
  740. vid = seg.get("video_id")
  741. dataset = seg.get("dataset_name")
  742. label_dict = None
  743. if isinstance(seg.get("label"), dict) and seg.get("label"):
  744. label_dict = seg.get("label")
  745. else:
  746. key = (vid, dataset, seg.get("begin"), seg.get("end"))
  747. label_dict = gold_label_lookup.get(key) or reference_labels.get(key)
  748. gold_text = _join_ts_text_map(label_dict or {})
  749. pred_map = seg.get("pred") or {}
  750. preds = []
  751. for ts, content in (pred_map or {}).items():
  752. try:
  753. t = float(ts)
  754. except (ValueError, TypeError):
  755. continue
  756. if begin <= t <= end:
  757. s = str(content).strip()
  758. if s:
  759. preds.append((t, s))
  760. preds = sorted(preds, key=lambda x: x[0])
  761. preds = _merge_adjacent_pred_sents(preds, delta_s=1.0)
  762. gold_data_dict[seg_id] = {
  763. "question_id": seg_id,
  764. "dataset_name": dataset_name,
  765. "tag": tag,
  766. "answer": [
  767. {
  768. "reply_timespan": [begin, end],
  769. "content": gold_text,
  770. "related_timespan": [begin, end],
  771. "preds": preds,
  772. "judge_scores": {},
  773. }
  774. ],
  775. }
  776. elif is_new_format:
  777. # New format: convert to old-format structure
  778. gold_data_dict = {}
  779. for video_data in gold_data_list:
  780. video_id = video_data.get('video_id')
  781. if video_id is None:
  782. continue
  783. dataset_name = video_data.get('dataset_name', '')
  784. tag = video_data.get("tag") or dataset_name
  785. question_id = _make_question_id(dataset_name, video_id)
  786. # Convert to old format while preserving dataset_name
  787. gold_data_dict[question_id] = {
  788. 'question_id': question_id,
  789. 'dataset_name': dataset_name,
  790. 'tag': tag,
  791. 'answer': []
  792. }
  793. label_list = video_data.get('label', [])
  794. for label_seg in label_list:
  795. if 'label_sentence' in label_seg:
  796. answer_turn = {
  797. 'reply_timespan': [label_seg['begin'], label_seg['end']],
  798. 'content': label_seg['label_sentence'],
  799. 'related_timespan': [label_seg['begin'], label_seg['end']] # Use same values
  800. }
  801. gold_data_dict[question_id]['answer'].append(answer_turn)
  802. else:
  803. # Old format
  804. gold_data_dict = {example['question_id']: example for example in gold_data_list}
  805. # Ensure old format also has dataset_name field (may be empty)
  806. for question_id in gold_data_dict:
  807. if 'dataset_name' not in gold_data_dict[question_id]:
  808. gold_data_dict[question_id]['dataset_name'] = ''
  809. if 'tag' not in gold_data_dict[question_id]:
  810. gold_data_dict[question_id]['tag'] = gold_data_dict[question_id].get('dataset_name', '')
  811. output_dict = dict()
  812. # Only non-segment format requires question_id-based pred aggregation
  813. pred_data_dict = None if is_segment_format else load_model_preds(pred_fname)
  814. for line in open(input_fname):
  815. example = json.loads(line)
  816. splits = example['custom_id'].split('=')
  817. # Parse custom_id: format may be {dataset_name}={question_id}={turn_i}={time} or {question_id}={turn_i}={time}
  818. if len(splits) == 4:
  819. # New format: includes dataset_name
  820. dataset_name, question_id, turn_id, pred_time = splits[0], splits[1], int(splits[2]), float(splits[3])
  821. elif len(splits) == 3:
  822. # Old format: no dataset_name
  823. question_id, turn_id, pred_time = splits[0], int(splits[1]), float(splits[2])
  824. else:
  825. # Compatibility handling: use the last three parts
  826. question_id, turn_id, pred_time = '='.join(splits[:-2]), int(splits[-2]), float(splits[-1])
  827. if question_id not in output_dict:
  828. gold_data = gold_data_dict[question_id]
  829. if is_segment_format:
  830. # Segment mode: gold_data already has prefilled preds/judge_scores
  831. output_dict[question_id] = gold_data
  832. else:
  833. pred_data = pred_data_dict.get(question_id, [])
  834. for answer_i, answer in enumerate(gold_data['answer']):
  835. gold_start_time, gold_end_time = answer['reply_timespan'][0], answer['reply_timespan'][1]
  836. answer['preds'] = [(time, sent) for time, sent in pred_data if gold_start_time <= time <= gold_end_time]
  837. answer['judge_scores'] = dict()
  838. output_dict[question_id] = gold_data
  839. gold_turn = output_dict[question_id]['answer'][turn_id]
  840. openai_judge_output = example['response']['body']['choices'][0]['message']['content']
  841. # NOTE: LLM output scores are in {1, 2, 3}, but when calculating PAUC area under curve we should use y \in {0, 1, 2}
  842. if openai_judge_output[0] in '012345':
  843. openai_judge_score = int(openai_judge_output[0]) - 1
  844. else:
  845. openai_judge_score = None
  846. if openai_judge_score is not None:
  847. if gold_turn['reply_timespan'][0] <= pred_time <= gold_turn['reply_timespan'][1]:
  848. gold_turn['judge_scores'][pred_time] = openai_judge_score
  849. # Handle missing prediction cases
  850. for question_id in gold_data_dict.keys():
  851. if question_id not in output_dict:
  852. # the model has no predictions in this ground truth reply span.
  853. gold_example = gold_data_dict[question_id]
  854. for answer in gold_example['answer']:
  855. answer['judge_scores'] = dict()
  856. answer['preds'] = list()
  857. output_dict[question_id] = gold_example
  858. # if there are some replies after related_timespan but in reply_timespan, and the score of these replies are lower than the last one in related_timespan,
  859. # remove the last turn, as this is possiblely from the second turn.
  860. for question_id, output_example in output_dict.items():
  861. for answer in output_example['answer']:
  862. if len(answer['judge_scores']) <= 1: continue
  863. if 'related_timespan' not in answer:
  864. continue # Skip if related_timespan is missing
  865. judge_score_items = sorted(answer['judge_scores'].items(), key=lambda x: x[0])
  866. last_score_in_related_timespan = 0
  867. for time, score in judge_score_items:
  868. if time <= answer['related_timespan'][1]:
  869. last_score_in_related_timespan = score
  870. elif score < last_score_in_related_timespan:
  871. del answer['judge_scores'][time]
  872. with open(output_fname, 'w', encoding='utf-8') as f_out:
  873. json.dump(list(output_dict.values()), f_out, ensure_ascii=False, indent=2)
  874. def stat_metric(input_fname, omega=0.5, max_score=2, start_score=0.5, ignore_empty: bool = False):
  875. pred_data = json.load(open(input_fname))
  876. auc_list = list()
  877. for example in pred_data:
  878. for turn in example['answer']:
  879. if not len(turn['judge_scores']):
  880. # if there is no pred reply, use start_score / max_score directly
  881. if not ignore_empty:
  882. auc_list.append(start_score / max_score)
  883. continue
  884. points = [[float(key) - turn['reply_timespan'][0], val] for key, val in turn['judge_scores'].items()]
  885. max_x, max_y = turn['reply_timespan'][1] - turn['reply_timespan'][0], max_score
  886. auc_list.append(area_under_line_ratio(points, max_x, max_y, omega, start_score))
  887. return {'mean_auc': np.mean(auc_list), 'num_videos': len(pred_data), 'num_turns': len(auc_list)}
  888. def stat_metric_by_tag(
  889. input_fname: str,
  890. omegas=(0, 0.5, 1),
  891. max_score: int = 2,
  892. start_score: float = 0.5,
  893. ignore_empty: bool = False,
  894. ):
  895. """
  896. Compute PAUC statistics by tag (consistent with the AUC definition in `stat_metric`).
  897. - overall: mean across all turns (equal weight per turn)
  898. - by_tag: mean across all turns under the same tag (equal weight per turn)
  899. """
  900. pred_data = json.load(open(input_fname, encoding="utf-8"))
  901. # Aggregation: tag -> omega -> list[auc]
  902. by_tag = defaultdict(lambda: defaultdict(list))
  903. overall = defaultdict(list) # omega -> list[auc]
  904. for example in pred_data or []:
  905. tag = example.get("tag") or example.get("dataset_name")
  906. for turn in example.get("answer", []) or []:
  907. if not len(turn.get("judge_scores") or {}):
  908. if ignore_empty:
  909. continue
  910. # No pred reply: use start_score/max_score as this turn's auc
  911. for omega in omegas:
  912. auc = start_score / max_score if max_score else 0
  913. overall[omega].append(auc)
  914. by_tag[tag][omega].append(auc)
  915. continue
  916. points = [[float(key) - turn['reply_timespan'][0], val] for key, val in (turn.get("judge_scores") or {}).items()]
  917. max_x, max_y = turn['reply_timespan'][1] - turn['reply_timespan'][0], max_score
  918. for omega in omegas:
  919. auc = area_under_line_ratio(points, max_x, max_y, omega, start_score)
  920. overall[omega].append(auc)
  921. by_tag[tag][omega].append(auc)
  922. def _mean(xs):
  923. return float(np.mean(xs)) if xs else float("nan")
  924. overall_out = {
  925. "num_examples": len(pred_data) if isinstance(pred_data, list) else 0,
  926. "num_turns": int(max(len(overall[omegas[0]]), 0)) if omegas else 0,
  927. "omegas": list(omegas),
  928. "mean_auc": {str(omega): _mean(overall[omega]) for omega in omegas},
  929. }
  930. by_tag_out = {}
  931. for tag in sorted(by_tag.keys(), key=lambda x: str(x)):
  932. by_tag_out[str(tag)] = {
  933. "num_turns": {str(omega): len(by_tag[tag][omega]) for omega in omegas},
  934. "mean_auc": {str(omega): _mean(by_tag[tag][omega]) for omega in omegas},
  935. }
  936. return {"overall": overall_out, "by_tag": by_tag_out}
  937. def area_under_line_ratio(points, max_x, max_y, omega=0.5, start_score=0.5):
  938. """
  939. Calculate the area enclosed between the polyline formed by the coordinate points and the x-axis.
  940. Note that adjacent points are not connected by a diagonal line. Instead, at the x-coordinate of the subsequent point (x_2), the y-value abruptly increases to y_2.
  941. :param points: A list containing (x, y) tuples, e.g., [(x1, y1), (x2, y2), ...]
  942. :return: The area (float)
  943. """
  944. if not len(points): return 0
  945. points = sorted(points, key=lambda p: p[0])
  946. # ----- ADJUSTING THE IMPORTANCE OF TIMELINESS -----
  947. # Yet, an answer generated near the end of the span may still have its value.
  948. # If this hyperparameter is 0, the x-axis remains unmodified—this is a scenario where timeliness is more important than correctness.
  949. # If this hyperparameter is 1, all points on the x-axis are shifted to 1—this is a scenario where correctness is more important than timeliness.
  950. points = [(x * (1 - omega), y) for x, y in points]
  951. points.append([max_x, points[-1][1]])
  952. prev_y, prev_x, area = start_score, 0, 0
  953. max_area = max_x * max_y
  954. for i in range(len(points)):
  955. x1, y1 = points[i]
  956. area += (x1 - prev_x) * prev_y
  957. prev_x, prev_y = x1, y1
  958. return area / max_area
  959. def openai_send_batch(batch_input_fname, description="debug"):
  960. client = _get_openai_client()
  961. batch_input_file = client.files.create(file=open(batch_input_fname, "rb"), purpose="batch")
  962. batch_input_file_id = batch_input_file.id
  963. batch_metadata = client.batches.create(
  964. input_file_id=batch_input_file_id,
  965. endpoint="/v1/chat/completions", completion_window="24h",
  966. metadata={"description": description})
  967. print(batch_input_fname)
  968. print(batch_metadata)
  969. def openai_get_batch(output_file_id, output_fname):
  970. client = _get_openai_client()
  971. if output_file_id is not None:
  972. file_response = client.files.content(output_file_id)
  973. print(f'saving result file {output_file_id} to {output_fname}')
  974. output_dir = os.path.dirname(output_fname)
  975. if output_dir:
  976. os.makedirs(output_dir, exist_ok=True)
  977. with open(output_fname, 'w') as f_out:
  978. f_out.write(file_response.text)
  979. else:
  980. print('output_file_id is None, batch not completed')
  981. if __name__ == '__main__':
  982. # import debugpy
  983. # debugpy.listen(9501)
  984. # print("Waiting for debugger to attach...")
  985. # debugpy.wait_for_client()
  986. parser = argparse.ArgumentParser()
  987. parser.add_argument('--func', type=str)
  988. parser.add_argument('--input_fname', type=str)
  989. parser.add_argument('--pred_fname', type=str)
  990. parser.add_argument('--gold_fname', type=str, default=None)
  991. parser.add_argument('--output_fname', type=str)
  992. parser.add_argument('--n_examples', type=int, default=-1)
  993. parser.add_argument('--openai_model', type=str, default='gpt-4.1')
  994. parser.add_argument('--max_scores', type=int, default=3)
  995. parser.add_argument('--resume', action='store_true', help='Resume in one_step mode (default: off). When enabled, already generated custom_id entries are skipped')
  996. parser.add_argument('--concurrency', type=int, default=8, help='Number of concurrent requests (threads) in one_step mode')
  997. parser.add_argument('--judge_limit', type=int, default=100, help='Maximum number of new judge requests in one_step mode (default: 100; use -1 for all)')
  998. parser.add_argument('--reference', type=str, default=None, help='Optional reference JSONL used to backfill labels by (video_id, dataset_name, begin, end)')
  999. parser.add_argument('--start_score', type=float, default=0.5, help='Initial score')
  1000. parser.add_argument('--description', type=str)
  1001. parser.add_argument('--file_id', type=str)
  1002. args = parser.parse_args()
  1003. print(args)
  1004. if args.func == 'batch_input':
  1005. if not args.gold_fname:
  1006. raise ValueError("batch_input 需要 --gold_fname(one_step 模式可省略)")
  1007. create_openai_batch_input(args.pred_fname, args.gold_fname, args.output_fname, args.n_examples, args.openai_model)
  1008. elif args.func == 'batch_output':
  1009. if not args.gold_fname:
  1010. raise ValueError("batch_output 需要 --gold_fname(one_step 模式可省略)")
  1011. process_openai_batch_output(args.pred_fname, args.gold_fname, args.input_fname, args.output_fname, args.n_examples)
  1012. elif args.func == 'stat_metric':
  1013. print(f"NOTE: max_score={args.max_scores}, so the largest y-axis is {args.max_scores-1}")
  1014. for omega in [0, 0.5, 1]:
  1015. res = stat_metric(args.input_fname, omega=omega, max_score=args.max_scores-1, start_score=args.start_score)
  1016. print(round(res['mean_auc'] * 100, 1), end=' & ')
  1017. print('num_examples:', res['num_videos'])
  1018. print()
  1019. elif args.func == 'stat_metric_by_tag':
  1020. max_score = args.max_scores - 1
  1021. res = stat_metric_by_tag(
  1022. input_fname=args.input_fname,
  1023. omegas=(0, 0.5, 1),
  1024. max_score=max_score,
  1025. start_score=args.start_score,
  1026. ignore_empty=False,
  1027. )
  1028. print(f"NOTE: max_score={args.max_scores}, so the largest y-axis is {max_score}")
  1029. # Print order: by_tag first, then overall (same organization as evaluate_end_to_end)
  1030. for tag, stats in res["by_tag"].items():
  1031. print(f"{tag}:")
  1032. for omega in ["0", "0.5", "1"]:
  1033. v = stats["mean_auc"].get(omega)
  1034. if v == v: # not NaN
  1035. print(f" omega={omega}: mean_auc={v:.6f} ({stats['num_turns'].get(omega, 0)} turns)")
  1036. else:
  1037. print(f" omega={omega}: mean_auc=nan ({stats['num_turns'].get(omega, 0)} turns)")
  1038. print("Overall:")
  1039. for omega in ["0", "0.5", "1"]:
  1040. v = res["overall"]["mean_auc"].get(omega)
  1041. if v == v:
  1042. print(f" omega={omega}: mean_auc={v:.6f}")
  1043. else:
  1044. print(f" omega={omega}: mean_auc=nan")
  1045. # Do not create a separate by_tag_output; write directly to output_fname (in-place if same as input)
  1046. if args.output_fname:
  1047. out_dir = os.path.dirname(args.output_fname)
  1048. if out_dir:
  1049. os.makedirs(out_dir, exist_ok=True)
  1050. with open(args.output_fname, "w", encoding="utf-8") as f_out:
  1051. json.dump(res, f_out, ensure_ascii=False, indent=2)
  1052. print(f"[saved] {args.output_fname}")
  1053. elif args.func == 'one_step':
  1054. one_step_eval(
  1055. pred_fname=args.pred_fname,
  1056. gold_fname=args.gold_fname,
  1057. output_fname=args.output_fname,
  1058. openai_model=args.openai_model,
  1059. n_examples=args.n_examples,
  1060. max_scores=args.max_scores,
  1061. resume=args.resume,
  1062. concurrency=args.concurrency,
  1063. judge_limit=args.judge_limit,
  1064. reference_fname=args.reference,
  1065. start_score=args.start_score,
  1066. )
  1067. elif args.func == 'send_batch':
  1068. openai_send_batch(batch_input_fname=args.input_fname, description=args.description)
  1069. elif args.func == 'get_batch':
  1070. openai_get_batch(output_file_id=args.file_id, output_fname=args.output_fname)
  1071. elif args.func == 'check_batch':
  1072. from datetime import datetime
  1073. client = _get_openai_client()
  1074. for task in client.batches.list(limit=20).data:
  1075. if task.metadata is not None:
  1076. print(task.id, datetime.fromtimestamp(task.created_at), task.metadata.get('description', ''),
  1077. task.status, f"{task.request_counts.completed} / {task.request_counts.total}",
  1078. task.output_file_id, end='\n\n')
  1079. else:
  1080. print(task.id, datetime.fromtimestamp(task.created_at), '',
  1081. task.status, f"{task.request_counts.completed} / {task.request_counts.total}",
  1082. task.output_file_id, end='\n\n')
  1083. else:
  1084. raise NotImplementedError