anhtld commited on
Commit
9df1580
·
verified ·
1 Parent(s): 3c50a02

Add CTT source-evidence dominance features

Browse files
workspace/scripts/eval_learned_dominance_selector.py CHANGED
@@ -36,6 +36,36 @@ BASIC_FEATURE_NAMES = [
36
  ]
37
  FEATURE_NAMES = BASIC_FEATURE_NAMES
38
  CONTEXT_HASH_WIDTH = 8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
 
41
  def main(argv: list[str] | None = None) -> int:
@@ -49,6 +79,15 @@ def main(argv: list[str] | None = None) -> int:
49
  parser.add_argument("--calibration-target-index", type=Path, required=True)
50
  parser.add_argument("--eval-input", type=Path, required=True)
51
  parser.add_argument("--eval-target-index", type=Path, required=True)
 
 
 
 
 
 
 
 
 
52
  parser.add_argument(
53
  "--checkpoint-template",
54
  default="runs/ctt_residual_full_seed{seed}/model.pt",
@@ -58,7 +97,7 @@ def main(argv: list[str] | None = None) -> int:
58
  parser.add_argument("--ridge-lambdas", default="0,0.01,0.1,1,10,100")
59
  parser.add_argument(
60
  "--feature-set",
61
- choices=("basic", "tangent", "context", "context_tangent"),
62
  default="basic",
63
  help="Deployment-visible feature family for candidate-level dominance fitting.",
64
  )
@@ -96,6 +135,10 @@ def main(argv: list[str] | None = None) -> int:
96
  args.eval_target_index,
97
  chart_feature_mode=chart_feature_mode,
98
  )
 
 
 
 
99
 
100
  calibration_dataset = _candidate_dataset(
101
  calibration_rows,
@@ -104,6 +147,7 @@ def main(argv: list[str] | None = None) -> int:
104
  k=args.k,
105
  feature_set=args.feature_set,
106
  target=args.target,
 
107
  )
108
  eval_dataset = _candidate_dataset(
109
  eval_rows,
@@ -112,6 +156,7 @@ def main(argv: list[str] | None = None) -> int:
112
  k=args.k,
113
  feature_set=args.feature_set,
114
  target=args.target,
 
115
  )
116
  best = _fit_select_ridge(calibration_dataset, lambdas=lambdas)
117
  eval_cases = _evaluate_dataset(eval_dataset, best["weights"], best["mean"], best["std"], tau=best["tau"])
@@ -157,12 +202,15 @@ def main(argv: list[str] | None = None) -> int:
157
  "feature_std": best["std"].tolist(),
158
  "calibration_input": str(args.calibration_input),
159
  "eval_input": str(args.eval_input),
 
160
  "data_hash": eval_index.get("content_hash"),
161
  "split_hash": eval_index.get("split_hash"),
162
  "calibration_target_content_hash": calibration_index.get("content_hash"),
163
  "calibration_target_split_hash": calibration_index.get("split_hash"),
164
  "eval_target_content_hash": eval_index.get("content_hash"),
165
  "eval_target_split_hash": eval_index.get("split_hash"),
 
 
166
  "num_calibration_rows": len(calibration_rows),
167
  "num_eval_rows": len(eval_rows),
168
  "num_calibration_candidates": len(calibration_dataset["samples"]),
@@ -217,7 +265,9 @@ def _candidate_dataset(
217
  k: int,
218
  feature_set: str = "basic",
219
  target: str = "utility_margin",
 
220
  ) -> dict[str, Any]:
 
221
  samples: list[dict[str, Any]] = []
222
  by_row: dict[int, list[int]] = defaultdict(list)
223
  for row_index, row in enumerate(rows):
@@ -231,6 +281,7 @@ def _candidate_dataset(
231
  tangents = row.get("generated_tangents", [])[:k]
232
  candidate_types = row.get("candidate_types", [])[:k]
233
  source_task_ids = row.get("candidate_source_task_ids", [])[:k]
 
234
  if not scores or len(scores) != len(utilities):
235
  continue
236
  score_mean = sum(scores) / len(scores)
@@ -255,6 +306,14 @@ def _candidate_dataset(
255
  else "",
256
  },
257
  tangent=tangent,
 
 
 
 
 
 
 
 
258
  num_candidates=len(scores),
259
  feature_set=feature_set,
260
  )
@@ -308,12 +367,15 @@ def _feature_names(feature_set: str) -> list[str]:
308
  *[f"tangent_{index:02d}" for index in range(21)],
309
  *[f"abs_tangent_{index:02d}" for index in range(21)],
310
  ]
311
- if feature_set == "tangent":
312
- return [*BASIC_FEATURE_NAMES, *tangent_names]
313
- if feature_set == "context":
314
- return [*BASIC_FEATURE_NAMES, *context_names]
315
- if feature_set == "context_tangent":
316
- return [*BASIC_FEATURE_NAMES, *context_names, *tangent_names]
 
 
 
317
  raise ValueError(f"unknown feature_set: {feature_set}")
318
 
319
 
@@ -329,6 +391,7 @@ def _candidate_feature(
329
  num_candidates: int,
330
  feature_set: str,
331
  context: dict[str, Any] | None = None,
 
332
  ) -> np.ndarray:
333
  tangent = np.asarray(tangent, dtype=float).reshape(-1)
334
  if tangent.size < 21:
@@ -354,22 +417,161 @@ def _candidate_feature(
354
  )
355
  if feature_set == "basic":
356
  return basic
357
- if feature_set == "tangent":
358
- return np.concatenate([basic, tangent.astype(float), np.abs(tangent).astype(float)])
359
- if feature_set == "context":
360
- return np.concatenate([basic, _context_feature(context or {})])
361
- if feature_set == "context_tangent":
362
- return np.concatenate(
363
- [
364
- basic,
365
- _context_feature(context or {}),
366
- tangent.astype(float),
367
- np.abs(tangent).astype(float),
368
- ]
369
- )
370
  raise ValueError(f"unknown feature_set: {feature_set}")
371
 
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  def _context_feature(context: dict[str, Any]) -> np.ndarray:
374
  target_task = str(context.get("target_task_id", ""))
375
  source_task = str(context.get("source_task_id", ""))
@@ -649,10 +851,12 @@ def _write_provenance(out_dir: Path, args: argparse.Namespace) -> None:
649
  (out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n")
650
  hashes = {
651
  "calibration_input": _sha256(args.calibration_input),
652
- "calibration_target_index": _sha256(args.calibration_target_index),
653
  "eval_input": _sha256(args.eval_input),
654
- "eval_target_index": _sha256(args.eval_target_index),
655
  }
 
 
656
  (out_dir / "data_hash.txt").write_text(json.dumps(hashes, indent=2, sort_keys=True) + "\n")
657
  (out_dir / "split_hash.txt").write_text(
658
  json.dumps(
@@ -668,7 +872,7 @@ def _write_provenance(out_dir: Path, args: argparse.Namespace) -> None:
668
 
669
 
670
  def _index_hash(path: Path) -> dict[str, Any]:
671
- payload = json.loads(path.read_text())
672
  return {
673
  "split": payload.get("split"),
674
  "content_hash": payload.get("content_hash"),
@@ -685,6 +889,10 @@ def _sha256(path: Path) -> str:
685
  return h.hexdigest()
686
 
687
 
 
 
 
 
688
  def _fmt(value: Any) -> str:
689
  if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
690
  return "n/a"
 
36
  ]
37
  FEATURE_NAMES = BASIC_FEATURE_NAMES
38
  CONTEXT_HASH_WIDTH = 8
39
+ SOURCE_EVIDENCE_NAMES = [
40
+ "source_chart_found",
41
+ "source_positive_count_log",
42
+ "source_negative_count_log",
43
+ "source_nonbase_count_log",
44
+ "source_positive_rate",
45
+ "source_best_delta_utility",
46
+ "source_mean_delta_utility",
47
+ "source_best_utility",
48
+ "source_mean_utility",
49
+ "source_best_success",
50
+ "source_mean_success",
51
+ "source_best_progress",
52
+ "source_mean_progress",
53
+ "source_positive_safety_known_rate",
54
+ "source_positive_unsafe_rate_known",
55
+ "generated_to_source_positive_min_rms",
56
+ "generated_to_source_negative_min_rms",
57
+ "generated_source_pos_closer_than_neg",
58
+ ]
59
+ FEATURE_SET_CHOICES = (
60
+ "basic",
61
+ "tangent",
62
+ "context",
63
+ "context_tangent",
64
+ "source_evidence",
65
+ "tangent_source_evidence",
66
+ "context_source_evidence",
67
+ "context_tangent_source_evidence",
68
+ )
69
 
70
 
71
  def main(argv: list[str] | None = None) -> int:
 
79
  parser.add_argument("--calibration-target-index", type=Path, required=True)
80
  parser.add_argument("--eval-input", type=Path, required=True)
81
  parser.add_argument("--eval-target-index", type=Path, required=True)
82
+ parser.add_argument(
83
+ "--source-index",
84
+ type=Path,
85
+ default=None,
86
+ help=(
87
+ "Train split chart index used for source-evidence features. "
88
+ "Defaults to --calibration-target-index."
89
+ ),
90
+ )
91
  parser.add_argument(
92
  "--checkpoint-template",
93
  default="runs/ctt_residual_full_seed{seed}/model.pt",
 
97
  parser.add_argument("--ridge-lambdas", default="0,0.01,0.1,1,10,100")
98
  parser.add_argument(
99
  "--feature-set",
100
+ choices=FEATURE_SET_CHOICES,
101
  default="basic",
102
  help="Deployment-visible feature family for candidate-level dominance fitting.",
103
  )
 
135
  args.eval_target_index,
136
  chart_feature_mode=chart_feature_mode,
137
  )
138
+ source_index_path = _resolve_index_path(args.source_index or args.calibration_target_index)
139
+ source_evidence, source_index = (
140
+ _source_evidence_map(source_index_path) if _uses_source_evidence(args.feature_set) else ({}, {})
141
+ )
142
 
143
  calibration_dataset = _candidate_dataset(
144
  calibration_rows,
 
147
  k=args.k,
148
  feature_set=args.feature_set,
149
  target=args.target,
150
+ source_evidence=source_evidence,
151
  )
152
  eval_dataset = _candidate_dataset(
153
  eval_rows,
 
156
  k=args.k,
157
  feature_set=args.feature_set,
158
  target=args.target,
159
+ source_evidence=source_evidence,
160
  )
161
  best = _fit_select_ridge(calibration_dataset, lambdas=lambdas)
162
  eval_cases = _evaluate_dataset(eval_dataset, best["weights"], best["mean"], best["std"], tau=best["tau"])
 
202
  "feature_std": best["std"].tolist(),
203
  "calibration_input": str(args.calibration_input),
204
  "eval_input": str(args.eval_input),
205
+ "source_index": str(source_index_path) if _uses_source_evidence(args.feature_set) else None,
206
  "data_hash": eval_index.get("content_hash"),
207
  "split_hash": eval_index.get("split_hash"),
208
  "calibration_target_content_hash": calibration_index.get("content_hash"),
209
  "calibration_target_split_hash": calibration_index.get("split_hash"),
210
  "eval_target_content_hash": eval_index.get("content_hash"),
211
  "eval_target_split_hash": eval_index.get("split_hash"),
212
+ "source_content_hash": source_index.get("content_hash"),
213
+ "source_split_hash": source_index.get("split_hash"),
214
  "num_calibration_rows": len(calibration_rows),
215
  "num_eval_rows": len(eval_rows),
216
  "num_calibration_candidates": len(calibration_dataset["samples"]),
 
265
  k: int,
266
  feature_set: str = "basic",
267
  target: str = "utility_margin",
268
+ source_evidence: dict[str, dict[str, Any]] | None = None,
269
  ) -> dict[str, Any]:
270
+ source_evidence = source_evidence or {}
271
  samples: list[dict[str, Any]] = []
272
  by_row: dict[int, list[int]] = defaultdict(list)
273
  for row_index, row in enumerate(rows):
 
281
  tangents = row.get("generated_tangents", [])[:k]
282
  candidate_types = row.get("candidate_types", [])[:k]
283
  source_task_ids = row.get("candidate_source_task_ids", [])[:k]
284
+ source_chart_ids = row.get("candidate_source_chart_ids", [])[:k]
285
  if not scores or len(scores) != len(utilities):
286
  continue
287
  score_mean = sum(scores) / len(scores)
 
306
  else "",
307
  },
308
  tangent=tangent,
309
+ source_evidence=_source_evidence_feature(
310
+ source_evidence.get(
311
+ str(source_chart_ids[candidate_index])
312
+ if candidate_index < len(source_chart_ids)
313
+ else ""
314
+ ),
315
+ tangent=tangent,
316
+ ),
317
  num_candidates=len(scores),
318
  feature_set=feature_set,
319
  )
 
367
  *[f"tangent_{index:02d}" for index in range(21)],
368
  *[f"abs_tangent_{index:02d}" for index in range(21)],
369
  ]
370
+ names = list(BASIC_FEATURE_NAMES)
371
+ if _uses_context(feature_set):
372
+ names.extend(context_names)
373
+ if _uses_tangent(feature_set):
374
+ names.extend(tangent_names)
375
+ if _uses_source_evidence(feature_set):
376
+ names.extend(SOURCE_EVIDENCE_NAMES)
377
+ if feature_set in FEATURE_SET_CHOICES:
378
+ return names
379
  raise ValueError(f"unknown feature_set: {feature_set}")
380
 
381
 
 
391
  num_candidates: int,
392
  feature_set: str,
393
  context: dict[str, Any] | None = None,
394
+ source_evidence: np.ndarray | None = None,
395
  ) -> np.ndarray:
396
  tangent = np.asarray(tangent, dtype=float).reshape(-1)
397
  if tangent.size < 21:
 
417
  )
418
  if feature_set == "basic":
419
  return basic
420
+ parts = [basic]
421
+ if _uses_context(feature_set):
422
+ parts.append(_context_feature(context or {}))
423
+ if _uses_tangent(feature_set):
424
+ parts.extend([tangent.astype(float), np.abs(tangent).astype(float)])
425
+ if _uses_source_evidence(feature_set):
426
+ if source_evidence is None:
427
+ source_evidence = np.zeros(len(SOURCE_EVIDENCE_NAMES), dtype=float)
428
+ parts.append(np.asarray(source_evidence, dtype=float).reshape(-1))
429
+ if feature_set in FEATURE_SET_CHOICES:
430
+ return np.concatenate(parts)
 
 
431
  raise ValueError(f"unknown feature_set: {feature_set}")
432
 
433
 
434
+ def _uses_context(feature_set: str) -> bool:
435
+ return feature_set in {
436
+ "context",
437
+ "context_tangent",
438
+ "context_source_evidence",
439
+ "context_tangent_source_evidence",
440
+ }
441
+
442
+
443
+ def _uses_tangent(feature_set: str) -> bool:
444
+ return feature_set in {
445
+ "tangent",
446
+ "context_tangent",
447
+ "tangent_source_evidence",
448
+ "context_tangent_source_evidence",
449
+ }
450
+
451
+
452
+ def _uses_source_evidence(feature_set: str) -> bool:
453
+ return feature_set in {
454
+ "source_evidence",
455
+ "tangent_source_evidence",
456
+ "context_source_evidence",
457
+ "context_tangent_source_evidence",
458
+ }
459
+
460
+
461
+ def _source_evidence_map(index_path: Path) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
462
+ index_path = _resolve_index_path(index_path)
463
+ index = json.loads(index_path.read_text())
464
+ if not index.get("include_outcomes", False):
465
+ raise SystemExit(f"{index_path} must include train outcomes for source evidence")
466
+ grouped: dict[str, dict[str, Any]] = {}
467
+ for shard in index.get("shards", []):
468
+ shard_path = index_path.parent / shard["path"]
469
+ with np.load(shard_path, allow_pickle=False) as data:
470
+ chart_ids = data["chart_id"]
471
+ labels = data["label"]
472
+ is_base = data["is_base_branch"]
473
+ tangents = data["spline_tangent_code"]
474
+ utilities = data["utility"]
475
+ delta_utilities = data["delta_utility"]
476
+ outcomes = data["outcome_vector"]
477
+ for row in range(chart_ids.shape[0]):
478
+ if bool(is_base[row]):
479
+ continue
480
+ chart_id = str(chart_ids[row])
481
+ item = grouped.setdefault(
482
+ chart_id,
483
+ {
484
+ "positive_tangents": [],
485
+ "negative_tangents": [],
486
+ "num_nonbase": 0,
487
+ "positive_delta_utilities": [],
488
+ "positive_utilities": [],
489
+ "positive_success": [],
490
+ "positive_progress": [],
491
+ "positive_safety": [],
492
+ },
493
+ )
494
+ item["num_nonbase"] += 1
495
+ label = str(labels[row])
496
+ if label == "positive":
497
+ item["positive_tangents"].append(tangents[row].astype("float32"))
498
+ item["positive_delta_utilities"].append(float(delta_utilities[row]))
499
+ item["positive_utilities"].append(float(utilities[row]))
500
+ outcome = np.asarray(outcomes[row], dtype=float)
501
+ item["positive_success"].append(float(outcome[0]) if outcome.size > 0 else math.nan)
502
+ item["positive_progress"].append(float(outcome[1]) if outcome.size > 1 else math.nan)
503
+ item["positive_safety"].append(float(outcome[3]) if outcome.size > 3 else math.nan)
504
+ elif label == "negative":
505
+ item["negative_tangents"].append(tangents[row].astype("float32"))
506
+ return grouped, index
507
+
508
+
509
+ def _source_evidence_feature(source: dict[str, Any] | None, *, tangent: np.ndarray) -> np.ndarray:
510
+ if not source:
511
+ return np.zeros(len(SOURCE_EVIDENCE_NAMES), dtype=float)
512
+ positives = np.asarray(source.get("positive_tangents") or [], dtype=float).reshape(-1, 21)
513
+ negatives = np.asarray(source.get("negative_tangents") or [], dtype=float).reshape(-1, 21)
514
+ positive_count = int(positives.shape[0])
515
+ negative_count = int(negatives.shape[0])
516
+ nonbase_count = int(source.get("num_nonbase") or (positive_count + negative_count))
517
+ positive_delta = _clean_array(source.get("positive_delta_utilities") or [])
518
+ positive_utility = _clean_array(source.get("positive_utilities") or [])
519
+ positive_success = _clean_array(source.get("positive_success") or [])
520
+ positive_progress = _clean_array(source.get("positive_progress") or [])
521
+ positive_safety_raw = np.asarray(source.get("positive_safety") or [], dtype=float)
522
+ known_safety = positive_safety_raw[np.isfinite(positive_safety_raw)]
523
+ tangent = np.asarray(tangent, dtype=float).reshape(-1)
524
+ if tangent.size < 21:
525
+ tangent = np.pad(tangent, (0, 21 - tangent.size))
526
+ elif tangent.size > 21:
527
+ tangent = tangent[:21]
528
+ pos_dist = _min_rms_distance(tangent, positives)
529
+ neg_dist = _min_rms_distance(tangent, negatives)
530
+ return np.asarray(
531
+ [
532
+ 1.0,
533
+ math.log1p(positive_count),
534
+ math.log1p(negative_count),
535
+ math.log1p(nonbase_count),
536
+ positive_count / max(1.0, float(nonbase_count)),
537
+ _safe_max(positive_delta),
538
+ _safe_mean(positive_delta),
539
+ _safe_max(positive_utility),
540
+ _safe_mean(positive_utility),
541
+ _safe_max(positive_success),
542
+ _safe_mean(positive_success),
543
+ _safe_max(positive_progress),
544
+ _safe_mean(positive_progress),
545
+ float(known_safety.size) / max(1.0, float(positive_count)),
546
+ _safe_mean(known_safety),
547
+ pos_dist,
548
+ neg_dist,
549
+ float(pos_dist < neg_dist) if math.isfinite(pos_dist) and math.isfinite(neg_dist) else 0.0,
550
+ ],
551
+ dtype=float,
552
+ )
553
+
554
+
555
+ def _clean_array(values: Any) -> np.ndarray:
556
+ array = np.asarray(values, dtype=float).reshape(-1)
557
+ return array[np.isfinite(array)]
558
+
559
+
560
+ def _safe_mean(values: np.ndarray) -> float:
561
+ return float(values.mean()) if values.size else 0.0
562
+
563
+
564
+ def _safe_max(values: np.ndarray) -> float:
565
+ return float(values.max()) if values.size else 0.0
566
+
567
+
568
+ def _min_rms_distance(tangent: np.ndarray, candidates: np.ndarray) -> float:
569
+ if candidates.size == 0:
570
+ return 0.0
571
+ diff = candidates - tangent.reshape(1, -1)
572
+ return float(np.sqrt(np.mean(diff * diff, axis=1)).min())
573
+
574
+
575
  def _context_feature(context: dict[str, Any]) -> np.ndarray:
576
  target_task = str(context.get("target_task_id", ""))
577
  source_task = str(context.get("source_task_id", ""))
 
851
  (out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n")
852
  hashes = {
853
  "calibration_input": _sha256(args.calibration_input),
854
+ "calibration_target_index": _sha256(_resolve_index_path(args.calibration_target_index)),
855
  "eval_input": _sha256(args.eval_input),
856
+ "eval_target_index": _sha256(_resolve_index_path(args.eval_target_index)),
857
  }
858
+ if getattr(args, "source_index", None) is not None:
859
+ hashes["source_index"] = _sha256(_resolve_index_path(args.source_index))
860
  (out_dir / "data_hash.txt").write_text(json.dumps(hashes, indent=2, sort_keys=True) + "\n")
861
  (out_dir / "split_hash.txt").write_text(
862
  json.dumps(
 
872
 
873
 
874
  def _index_hash(path: Path) -> dict[str, Any]:
875
+ payload = json.loads(_resolve_index_path(path).read_text())
876
  return {
877
  "split": payload.get("split"),
878
  "content_hash": payload.get("content_hash"),
 
889
  return h.hexdigest()
890
 
891
 
892
+ def _resolve_index_path(path: Path) -> Path:
893
+ return path / "index.json" if path.is_dir() else path
894
+
895
+
896
  def _fmt(value: Any) -> str:
897
  if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
898
  return "n/a"