f1_timediff.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. #!/usr/bin/env python3
  2. """
  3. End-to-end evaluation script: from raw JSONL to Timediff results
  4. Workflow:
  5. 1. Read the raw JSONL file
  6. 2. Merge data by video_id
  7. 3. Compute Timediff metrics
  8. 4. Output results (including exp_name)
  9. """
  10. import json
  11. import argparse
  12. import os
  13. from collections import defaultdict
  14. def load_reference_labels(reference_file):
  15. """Load label data from a reference file (indexed by (video_id, begin, end, dataset_name, idx))."""
  16. print(f" Loading labels from reference file: {reference_file}")
  17. labels_by_key = {}
  18. with open(reference_file, 'r', encoding='utf-8') as f:
  19. for line in f:
  20. if not line.strip():
  21. continue
  22. try:
  23. item = json.loads(line)
  24. vid = item.get("video_id")
  25. dataset = item.get("dataset_name")
  26. begin = item.get("begin")
  27. end = item.get("end")
  28. idx = item.get("idx")
  29. if not vid:
  30. continue
  31. # Use (video_id, begin, end, dataset_name, idx) as the unique key
  32. key = (vid, begin, end, dataset, idx)
  33. label = item.get("label")
  34. if label is not None:
  35. labels_by_key[key] = {
  36. "begin": begin,
  37. "end": end,
  38. "idx": idx,
  39. "label": label,
  40. "tag": item.get("tag")
  41. }
  42. except json.JSONDecodeError:
  43. continue
  44. print(f" Loaded {len(labels_by_key)} label entries from reference file")
  45. return labels_by_key
  46. def load_data(input_file, reference_labels=None):
  47. """
  48. Load data and match labels by (video_id, begin, end, dataset_name, idx).
  49. No merging is performed; each record is returned directly.
  50. """
  51. print(f"Step 1/3: Reading data: {input_file}")
  52. results = []
  53. ref_label_used_count = 0
  54. with open(input_file, 'r', encoding='utf-8') as f:
  55. for line_num, line in enumerate(f, 1):
  56. if not line.strip():
  57. continue
  58. try:
  59. item = json.loads(line)
  60. vid = item.get("video_id")
  61. dataset = item.get("dataset_name")
  62. if not vid:
  63. continue
  64. begin = item.get("begin")
  65. end = item.get("end")
  66. idx = item.get("idx")
  67. tag = item.get("tag")
  68. pred = item.get("pred")
  69. label = item.get("label")
  70. # If label is missing, try exact-key lookup from reference
  71. if label is None and reference_labels:
  72. ref_key = (vid, begin, end, dataset, idx)
  73. if ref_key in reference_labels:
  74. label = reference_labels[ref_key]["label"]
  75. ref_label_used_count += 1
  76. results.append({
  77. "video_id": vid,
  78. "dataset_name": dataset,
  79. "begin": begin,
  80. "end": end,
  81. "idx": idx,
  82. "tag": tag,
  83. "pred": pred,
  84. "label": label
  85. })
  86. except json.JSONDecodeError as e:
  87. print(f" Warning: JSON parsing failed at line {line_num}")
  88. continue
  89. print(f" Read {len(results)} records")
  90. if reference_labels:
  91. print(f" Backfilled {ref_label_used_count} labels from reference file")
  92. return results
  93. def extract_timestamps(label_dict):
  94. """Extract all timestamps from a label dictionary and return (first, last)."""
  95. if not isinstance(label_dict, dict):
  96. return None, None
  97. timestamps = []
  98. for key in label_dict.keys():
  99. try:
  100. timestamps.append(int(key))
  101. except (ValueError, TypeError):
  102. continue
  103. if not timestamps:
  104. return None, None
  105. return min(timestamps), max(timestamps)
  106. def calculate_timediff_single(sample, k=2, alpha=0.5, enable_penalty=True):
  107. """
  108. Compute timediff for a single sample (with penalty terms).
  109. Args:
  110. sample: A single record containing video_id, begin, end, idx, tag, pred, label
  111. k: Tolerance hyperparameter (default: 2)
  112. alpha: Penalty factor (default: 0.5)
  113. enable_penalty: Whether to enable the penalty mechanism (default: True)
  114. Returns:
  115. A timediff result dictionary, or None if it cannot be computed
  116. """
  117. video_id = sample.get("video_id")
  118. tag = sample.get("tag")
  119. begin = sample.get("begin")
  120. end = sample.get("end")
  121. idx = sample.get("idx")
  122. pred = sample.get("pred")
  123. label = sample.get("label")
  124. if label is None:
  125. return None
  126. first_time, last_time = extract_timestamps(label)
  127. if first_time is None or last_time is None:
  128. return None
  129. # Compute response time
  130. response_time = None
  131. if pred is not None:
  132. if isinstance(pred, dict):
  133. timestamps = []
  134. for key in pred.keys():
  135. try:
  136. t = int(key)
  137. if t >= first_time:
  138. timestamps.append(t)
  139. except (ValueError, TypeError):
  140. continue
  141. if timestamps:
  142. response_time = min(timestamps)
  143. elif isinstance(pred, str):
  144. if pred.strip():
  145. if begin >= first_time:
  146. response_time = begin
  147. # Compute raw timediff
  148. if response_time is None:
  149. base_timediff = last_time - first_time
  150. else:
  151. base_timediff = response_time - first_time
  152. # Count predicted responses outside valid range (only when penalty is enabled)
  153. out_of_range_count = 0
  154. penalty = 0
  155. if enable_penalty and pred is not None:
  156. valid_range_start = first_time - k
  157. valid_range_end = last_time + k
  158. if isinstance(pred, dict):
  159. for key in pred.keys():
  160. try:
  161. t = int(key)
  162. if pred[key] and (t < valid_range_start or t > valid_range_end):
  163. out_of_range_count += 1
  164. except (ValueError, TypeError):
  165. continue
  166. elif isinstance(pred, str):
  167. if pred.strip():
  168. if begin is not None and (begin < valid_range_start or begin > valid_range_end):
  169. out_of_range_count += 1
  170. penalty = alpha * out_of_range_count
  171. # Compute final timediff
  172. timediff_final = base_timediff + penalty
  173. return {
  174. "video_id": video_id,
  175. "tag": tag,
  176. "begin": begin,
  177. "end": end,
  178. "idx": idx,
  179. "first_time": first_time,
  180. "last_time": last_time,
  181. "response_time": response_time,
  182. "base_timediff": base_timediff,
  183. "out_of_range_count": out_of_range_count,
  184. "penalty": penalty,
  185. "timediff": timediff_final
  186. }
  187. def calculate_overlap_single(sample):
  188. """
  189. Compute Recall, Precision, and F1 for a single sample.
  190. Args:
  191. sample: A single record containing video_id, begin, end, idx, tag, pred, label
  192. Returns:
  193. A metric dictionary, or None if it cannot be computed
  194. """
  195. video_id = sample.get("video_id")
  196. tag = sample.get("tag")
  197. begin = sample.get("begin")
  198. end = sample.get("end")
  199. idx = sample.get("idx")
  200. pred = sample.get("pred")
  201. label = sample.get("label")
  202. # Get speaking seconds from predict and label
  203. pred_seconds = get_speaking_seconds_from_content(pred, begin, end)
  204. label_seconds = get_speaking_seconds_from_content(label, begin, end)
  205. if not label_seconds:
  206. # No label available, cannot compute
  207. return None
  208. # Compute intersection (true positives)
  209. tp = len(pred_seconds & label_seconds)
  210. # Compute Recall and Precision
  211. recall = tp / len(label_seconds) if label_seconds else 0
  212. precision = tp / len(pred_seconds) if pred_seconds else 0
  213. # Compute F1
  214. f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
  215. return {
  216. "video_id": video_id,
  217. "tag": tag,
  218. "begin": begin,
  219. "end": end,
  220. "idx": idx,
  221. "recall": recall,
  222. "precision": precision,
  223. "f1": f1,
  224. "tp": tp,
  225. "predict_count": len(pred_seconds),
  226. "label_count": len(label_seconds)
  227. }
  228. def get_speaking_seconds_from_content(content, begin, end):
  229. """
  230. Extract speaking seconds from pred or label content.
  231. Returns a set.
  232. """
  233. speaking_seconds = set()
  234. if content is None:
  235. return speaking_seconds
  236. if isinstance(content, dict):
  237. # Dict format: key is timestamp
  238. for k in content.keys():
  239. try:
  240. t = int(k)
  241. speaking_seconds.add(t)
  242. except (ValueError, TypeError):
  243. continue
  244. elif isinstance(content, str) and content.strip():
  245. # String format: assume speaking throughout [begin, end]
  246. if begin is not None and end is not None:
  247. for t in range(begin, end + 1):
  248. speaking_seconds.add(t)
  249. return speaking_seconds
  250. def safe_div(n, d):
  251. return n / d if d else 0
  252. def f1_from_pr(p, r):
  253. return 2 * p * r / (p + r) if (p + r) > 0 else 0
  254. def evaluate_metrics(data_list, k=2, alpha=0.5, enable_penalty=True):
  255. """
  256. Compute all evaluation metrics (directly iterating through each sample).
  257. Args:
  258. data_list: List of samples, each containing video_id, begin, end, idx, tag, pred, label
  259. k: Tolerance hyperparameter (default: 2)
  260. alpha: Penalty factor (default: 0.5)
  261. enable_penalty: Whether to enable the penalty mechanism (default: True)
  262. """
  263. print(f"\nStep 2/3: Computing evaluation metrics")
  264. if enable_penalty:
  265. print(f" Penalty parameters: k={k}, alpha={alpha} (enabled)")
  266. else:
  267. print(f" Penalty mechanism: disabled")
  268. # Compute Timediff
  269. print(f" Computing Timediff...")
  270. all_timediffs = []
  271. timediffs_by_tag = defaultdict(list)
  272. total_samples_by_tag = defaultdict(int)
  273. valid_timediff_by_tag = defaultdict(int)
  274. for sample in data_list:
  275. tag = sample.get("tag")
  276. total_samples_by_tag[tag] += 1
  277. timediff_result = calculate_timediff_single(
  278. sample, k=k, alpha=alpha, enable_penalty=enable_penalty
  279. )
  280. if timediff_result:
  281. all_timediffs.append(timediff_result)
  282. timediffs_by_tag[tag].append(timediff_result["timediff"])
  283. valid_timediff_by_tag[tag] += 1
  284. print(f" Computed {len(all_timediffs)} timediff values")
  285. for tag in sorted(total_samples_by_tag.keys()):
  286. print(f" {tag}: {valid_timediff_by_tag[tag]}/{total_samples_by_tag[tag]} valid samples")
  287. # Compute Recall, Precision, F1 (sample-level)
  288. print(f" Computing Recall, Precision, and F1 (sample-level)...")
  289. all_overlap_metrics = []
  290. # Supports both:
  291. # - macro: average per-sample metrics (equal weight)
  292. # - micro: globally aggregate TP/Count before P/R/F1 (second-level weighting)
  293. overlap_by_tag = defaultdict(lambda: {
  294. "recall_list": [],
  295. "precision_list": [],
  296. "f1_list": [],
  297. "tp_sum": 0,
  298. "predict_count_sum": 0,
  299. "label_count_sum": 0,
  300. "sample_count": 0
  301. })
  302. overall_overlap_sums = {"tp_sum": 0, "predict_count_sum": 0, "label_count_sum": 0}
  303. for sample in data_list:
  304. metrics = calculate_overlap_single(sample)
  305. if metrics:
  306. all_overlap_metrics.append(metrics)
  307. tag = metrics["tag"]
  308. overlap_by_tag[tag]["recall_list"].append(metrics["recall"])
  309. overlap_by_tag[tag]["precision_list"].append(metrics["precision"])
  310. overlap_by_tag[tag]["f1_list"].append(metrics["f1"])
  311. overlap_by_tag[tag]["tp_sum"] += metrics["tp"]
  312. overlap_by_tag[tag]["predict_count_sum"] += metrics["predict_count"]
  313. overlap_by_tag[tag]["label_count_sum"] += metrics["label_count"]
  314. overlap_by_tag[tag]["sample_count"] += 1
  315. overall_overlap_sums["tp_sum"] += metrics["tp"]
  316. overall_overlap_sums["predict_count_sum"] += metrics["predict_count"]
  317. overall_overlap_sums["label_count_sum"] += metrics["label_count"]
  318. print(f" Computed metrics for {len(all_overlap_metrics)} samples")
  319. return all_timediffs, timediffs_by_tag, all_overlap_metrics, overlap_by_tag, overall_overlap_sums
  320. def main():
  321. parser = argparse.ArgumentParser(
  322. description="End-to-end evaluation: from raw JSONL to Timediff results"
  323. )
  324. parser.add_argument("input", help="Input raw JSONL file")
  325. parser.add_argument("--output", required=True, help="Output result JSON file")
  326. parser.add_argument("--reference", help="Reference JSONL file (used to fill labels when missing in input)")
  327. parser.add_argument("--verbose", action="store_true", help="Save detailed timediff data")
  328. parser.add_argument("--enable-penalty", action="store_true", default=True, help="Enable penalty mechanism (default: enabled)")
  329. parser.add_argument("--disable-penalty", dest="enable_penalty", action="store_false", help="Disable penalty mechanism")
  330. parser.add_argument("--k", type=int, default=2, help="Tolerance hyperparameter (default: 2)")
  331. parser.add_argument("--alpha", type=float, default=0.5, help="Penalty factor (default: 0.5)")
  332. args = parser.parse_args()
  333. # Extract exp_name from file name (remove path and extension)
  334. exp_name = os.path.splitext(os.path.basename(args.input))[0]
  335. print("=" * 80)
  336. print(f"Experiment name: {exp_name}")
  337. print("=" * 80)
  338. # If a reference file is provided, load labels first
  339. reference_labels = None
  340. if args.reference:
  341. reference_labels = load_reference_labels(args.reference)
  342. # Step 1: Load data (no merging, process by sample)
  343. data_list = load_data(args.input, reference_labels)
  344. # Step 2: Compute metrics
  345. all_timediffs, timediffs_by_tag, all_overlap_metrics, overlap_by_tag, overall_overlap_sums = evaluate_metrics(
  346. data_list, k=args.k, alpha=args.alpha, enable_penalty=args.enable_penalty
  347. )
  348. # Step 3: Generate results
  349. print(f"\nStep 3/3: Generating results")
  350. # Compute Timediff statistics
  351. all_timediff_values = [item["timediff"] for item in all_timediffs]
  352. # Compute Overlap statistics
  353. all_recall = [m["recall"] for m in all_overlap_metrics]
  354. all_precision = [m["precision"] for m in all_overlap_metrics]
  355. all_f1 = [m["f1"] for m in all_overlap_metrics]
  356. # macro: equal-weight average across samples
  357. macro_recall = sum(all_recall) / len(all_recall) if all_recall else 0
  358. macro_precision = sum(all_precision) / len(all_precision) if all_precision else 0
  359. macro_f1 = sum(all_f1) / len(all_f1) if all_f1 else 0
  360. # micro: compute after aggregating TP/Count (closer to overall second-level performance)
  361. micro_recall = safe_div(overall_overlap_sums["tp_sum"], overall_overlap_sums["label_count_sum"])
  362. micro_precision = safe_div(overall_overlap_sums["tp_sum"], overall_overlap_sums["predict_count_sum"])
  363. micro_f1 = f1_from_pr(micro_precision, micro_recall)
  364. # Compute penalty statistics
  365. total_penalty = sum(item["penalty"] for item in all_timediffs)
  366. total_out_of_range = sum(item["out_of_range_count"] for item in all_timediffs)
  367. result = {
  368. "exp_name": exp_name,
  369. "penalty_params": {
  370. "enabled": args.enable_penalty,
  371. "k": args.k,
  372. "alpha": args.alpha
  373. },
  374. "overall": {
  375. "timediff": {
  376. "count": len(all_timediff_values),
  377. "avg": sum(all_timediff_values) / len(all_timediff_values) if all_timediff_values else 0,
  378. "min": min(all_timediff_values) if all_timediff_values else 0,
  379. "max": max(all_timediff_values) if all_timediff_values else 0,
  380. "total_penalty": total_penalty,
  381. "total_out_of_range_count": total_out_of_range
  382. },
  383. # Backward compatibility: keep original top-level fields as macro values
  384. "recall": macro_recall,
  385. "precision": macro_precision,
  386. "f1": macro_f1,
  387. # New: output both macro and micro to avoid ambiguity in overall semantics
  388. "overlap_macro": {
  389. "sample_count": len(all_overlap_metrics),
  390. "recall": macro_recall,
  391. "precision": macro_precision,
  392. "f1": macro_f1
  393. },
  394. "overlap_micro": {
  395. "sample_count": len(all_overlap_metrics),
  396. "tp": overall_overlap_sums["tp_sum"],
  397. "predict_count": overall_overlap_sums["predict_count_sum"],
  398. "label_count": overall_overlap_sums["label_count_sum"],
  399. "recall": micro_recall,
  400. "precision": micro_precision,
  401. "f1": micro_f1
  402. }
  403. },
  404. "by_tag": {},
  405. "details": {
  406. "timediff": all_timediffs if args.verbose else [],
  407. "overlap": all_overlap_metrics if args.verbose else []
  408. }
  409. }
  410. # Aggregate by tag
  411. all_tags = set(timediffs_by_tag.keys()) | set(overlap_by_tag.keys())
  412. for tag in sorted(all_tags):
  413. tag_result = {}
  414. # Timediff statistics
  415. if tag in timediffs_by_tag:
  416. values = timediffs_by_tag[tag]
  417. tag_result["timediff"] = {
  418. "count": len(values),
  419. "avg": sum(values) / len(values) if values else 0,
  420. "min": min(values) if values else 0,
  421. "max": max(values) if values else 0
  422. }
  423. # Overlap statistics
  424. if tag in overlap_by_tag:
  425. recalls = overlap_by_tag[tag]["recall_list"]
  426. precisions = overlap_by_tag[tag]["precision_list"]
  427. f1s = overlap_by_tag[tag]["f1_list"]
  428. tag_macro_recall = sum(recalls) / len(recalls) if recalls else 0
  429. tag_macro_precision = sum(precisions) / len(precisions) if precisions else 0
  430. tag_macro_f1 = sum(f1s) / len(f1s) if f1s else 0
  431. tag_micro_recall = safe_div(overlap_by_tag[tag]["tp_sum"], overlap_by_tag[tag]["label_count_sum"])
  432. tag_micro_precision = safe_div(overlap_by_tag[tag]["tp_sum"], overlap_by_tag[tag]["predict_count_sum"])
  433. tag_micro_f1 = f1_from_pr(tag_micro_precision, tag_micro_recall)
  434. # Backward compatibility: still expose macro in top-level fields
  435. tag_result["recall"] = tag_macro_recall
  436. tag_result["precision"] = tag_macro_precision
  437. tag_result["f1"] = tag_macro_f1
  438. tag_result["overlap_macro"] = {
  439. "sample_count": overlap_by_tag[tag]["sample_count"],
  440. "recall": tag_macro_recall,
  441. "precision": tag_macro_precision,
  442. "f1": tag_macro_f1
  443. }
  444. tag_result["overlap_micro"] = {
  445. "sample_count": overlap_by_tag[tag]["sample_count"],
  446. "tp": overlap_by_tag[tag]["tp_sum"],
  447. "predict_count": overlap_by_tag[tag]["predict_count_sum"],
  448. "label_count": overlap_by_tag[tag]["label_count_sum"],
  449. "recall": tag_micro_recall,
  450. "precision": tag_micro_precision,
  451. "f1": tag_micro_f1
  452. }
  453. result["by_tag"][tag] = tag_result
  454. # Ensure output directory exists, then save results
  455. out_dir = os.path.dirname(args.output)
  456. if out_dir:
  457. os.makedirs(out_dir, exist_ok=True)
  458. with open(args.output, 'w', encoding='utf-8') as f:
  459. json.dump(result, f, ensure_ascii=False, indent=2)
  460. print(f" Results saved to: {args.output}")
  461. # Print summary statistics
  462. print("\n" + "=" * 80)
  463. print("Evaluation Results")
  464. print("=" * 80)
  465. print(f"Experiment name: {exp_name}\n")
  466. # Print by tag
  467. for tag in sorted(all_tags):
  468. print(f"{tag}:")
  469. if tag in timediffs_by_tag:
  470. values = timediffs_by_tag[tag]
  471. avg_timediff = sum(values) / len(values) if values else 0
  472. print(f" Timediff: sample_count={len(values)}, avg={avg_timediff:.2f}s, "
  473. f"min={min(values):.2f}s, max={max(values):.2f}s")
  474. if tag in overlap_by_tag:
  475. recalls = overlap_by_tag[tag]["recall_list"]
  476. precisions = overlap_by_tag[tag]["precision_list"]
  477. f1s = overlap_by_tag[tag]["f1_list"]
  478. avg_recall = sum(recalls) / len(recalls) if recalls else 0
  479. avg_precision = sum(precisions) / len(precisions) if precisions else 0
  480. avg_f1 = sum(f1s) / len(f1s) if f1s else 0
  481. tag_micro_recall = safe_div(overlap_by_tag[tag]["tp_sum"], overlap_by_tag[tag]["label_count_sum"])
  482. tag_micro_precision = safe_div(overlap_by_tag[tag]["tp_sum"], overlap_by_tag[tag]["predict_count_sum"])
  483. tag_micro_f1 = f1_from_pr(tag_micro_precision, tag_micro_recall)
  484. print(f" Overlap (macro, equal weight per sample):")
  485. print(f" Recall: {avg_recall:.4f}")
  486. print(f" Precision: {avg_precision:.4f}")
  487. print(f" F1: {avg_f1:.4f}")
  488. print(f" Overlap (micro, second-level aggregation):")
  489. print(f" Recall: {tag_micro_recall:.4f}")
  490. print(f" Precision: {tag_micro_precision:.4f}")
  491. print(f" F1: {tag_micro_f1:.4f}")
  492. print()
  493. # Overall statistics
  494. print("=" * 80)
  495. print(f"Overall:")
  496. print(f" Timediff: sample_count={len(all_timediff_values)}, "
  497. f"avg={result['overall']['timediff']['avg']:.2f}s")
  498. print(f" Overlap (macro, equal weight per sample), sample_count={result['overall']['overlap_macro']['sample_count']}:")
  499. print(f" Recall: {result['overall']['overlap_macro']['recall']:.4f}")
  500. print(f" Precision: {result['overall']['overlap_macro']['precision']:.4f}")
  501. print(f" F1: {result['overall']['overlap_macro']['f1']:.4f}")
  502. print(f" Overlap (micro, second-level aggregation):")
  503. print(f" Recall: {result['overall']['overlap_micro']['recall']:.4f}")
  504. print(f" Precision: {result['overall']['overlap_micro']['precision']:.4f}")
  505. print(f" F1: {result['overall']['overlap_micro']['f1']:.4f}")
  506. print("=" * 80)
  507. if __name__ == "__main__":
  508. main()