ctt artifacts 2026-07-02 workspace/scripts/build_data_accounting.py
Browse files
workspace/scripts/build_data_accounting.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
from collections import Counter, defaultdict
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 14 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 15 |
+
|
| 16 |
+
from dovla_cil.data.datasets import CILDataset # noqa: E402
|
| 17 |
+
from dovla_cil.generation.tangent_targets import ( # noqa: E402
|
| 18 |
+
DEFAULT_BASE_CANDIDATE_TYPES,
|
| 19 |
+
action_matrix,
|
| 20 |
+
action_shape,
|
| 21 |
+
choose_base_record,
|
| 22 |
+
label_from_delta_utility,
|
| 23 |
+
record_utility,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main(argv: list[str] | None = None) -> int:
|
| 28 |
+
parser = argparse.ArgumentParser(description="Build CIL chart data accounting tables.")
|
| 29 |
+
parser.add_argument("--dataset", type=Path, required=True)
|
| 30 |
+
parser.add_argument("--out-dir", type=Path, default=Path("runs/data_accounting"))
|
| 31 |
+
parser.add_argument("--epsilon", type=float, default=0.05)
|
| 32 |
+
parser.add_argument("--split-fractions", default="0.70,0.15,0.15")
|
| 33 |
+
parser.add_argument("--split-seed", type=int, default=0)
|
| 34 |
+
parser.add_argument(
|
| 35 |
+
"--base-candidate-types",
|
| 36 |
+
default=",".join(DEFAULT_BASE_CANDIDATE_TYPES),
|
| 37 |
+
)
|
| 38 |
+
args = parser.parse_args(argv)
|
| 39 |
+
|
| 40 |
+
fractions = _parse_split_fractions(args.split_fractions)
|
| 41 |
+
base_candidate_types = _parse_csv(args.base_candidate_types)
|
| 42 |
+
dataset = CILDataset(args.dataset)
|
| 43 |
+
rows: dict[str, dict[str, Any]] = {
|
| 44 |
+
split: _empty_row(split) for split in ("train", "val", "test")
|
| 45 |
+
}
|
| 46 |
+
skip_counts: Counter[str] = Counter()
|
| 47 |
+
|
| 48 |
+
for group in dataset.iter_groups():
|
| 49 |
+
if not group:
|
| 50 |
+
skip_counts["empty_group"] += 1
|
| 51 |
+
continue
|
| 52 |
+
split = _assign_split(group[0].group_id, fractions=fractions, seed=args.split_seed)
|
| 53 |
+
row = rows[split]
|
| 54 |
+
base = choose_base_record(group, base_candidate_types=base_candidate_types)
|
| 55 |
+
if base is None:
|
| 56 |
+
skip_counts["missing_base"] += 1
|
| 57 |
+
continue
|
| 58 |
+
base_action = action_matrix(base)
|
| 59 |
+
if not base_action:
|
| 60 |
+
skip_counts["empty_base_action"] += 1
|
| 61 |
+
continue
|
| 62 |
+
base_shape = action_shape(base_action)
|
| 63 |
+
base_utility = record_utility(base)
|
| 64 |
+
row["chart_ids"].add(group[0].group_id)
|
| 65 |
+
row["state_hashes"].add(group[0].state_hash)
|
| 66 |
+
row["task_ids"].add(group[0].task_id)
|
| 67 |
+
seed = group[0].metadata.get("episode_seed", group[0].metadata.get("random_seed"))
|
| 68 |
+
if seed is not None:
|
| 69 |
+
row["seeds"].add(str(seed))
|
| 70 |
+
for record in group:
|
| 71 |
+
action = action_matrix(record)
|
| 72 |
+
if not action or action_shape(action) != base_shape:
|
| 73 |
+
skip_counts["unusable_branch"] += 1
|
| 74 |
+
continue
|
| 75 |
+
row["num_branches"] += 1
|
| 76 |
+
candidate_type = str(record.candidate_type)
|
| 77 |
+
row["candidate_type_counts"][candidate_type] += 1
|
| 78 |
+
if record.record_id == base.record_id:
|
| 79 |
+
row["num_base_branches"] += 1
|
| 80 |
+
if candidate_type == "expert" or candidate_type.endswith("_expert"):
|
| 81 |
+
row["num_expert"] += 1
|
| 82 |
+
if record.record_id == base.record_id:
|
| 83 |
+
label = "neutral"
|
| 84 |
+
else:
|
| 85 |
+
label = label_from_delta_utility(
|
| 86 |
+
record_utility(record) - base_utility,
|
| 87 |
+
epsilon=args.epsilon,
|
| 88 |
+
)
|
| 89 |
+
row[f"num_{label}"] += 1
|
| 90 |
+
|
| 91 |
+
materialized = [_finalize_row(row) for row in rows.values()]
|
| 92 |
+
payload = {
|
| 93 |
+
"schema_version": 1,
|
| 94 |
+
"dataset": str(args.dataset),
|
| 95 |
+
"epsilon": args.epsilon,
|
| 96 |
+
"split_fractions": fractions,
|
| 97 |
+
"split_seed": args.split_seed,
|
| 98 |
+
"split_hash": _split_hash(materialized),
|
| 99 |
+
"skip_counts": dict(sorted(skip_counts.items())),
|
| 100 |
+
"rows": materialized,
|
| 101 |
+
}
|
| 102 |
+
out_dir = args.out_dir
|
| 103 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 104 |
+
(out_dir / "table.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
| 105 |
+
(out_dir / "table.tex").write_text(_latex_table(materialized) + "\n")
|
| 106 |
+
(out_dir / "report.md").write_text(_markdown_report(payload) + "\n")
|
| 107 |
+
print(json.dumps({"out_dir": str(out_dir), "split_hash": payload["split_hash"]}, indent=2))
|
| 108 |
+
return 0
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _empty_row(split: str) -> dict[str, Any]:
|
| 112 |
+
return {
|
| 113 |
+
"split": split,
|
| 114 |
+
"task_ids": set(),
|
| 115 |
+
"seeds": set(),
|
| 116 |
+
"chart_ids": set(),
|
| 117 |
+
"state_hashes": set(),
|
| 118 |
+
"num_branches": 0,
|
| 119 |
+
"num_base_branches": 0,
|
| 120 |
+
"num_positive": 0,
|
| 121 |
+
"num_neutral": 0,
|
| 122 |
+
"num_negative": 0,
|
| 123 |
+
"num_expert": 0,
|
| 124 |
+
"candidate_type_counts": Counter(),
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _finalize_row(row: dict[str, Any]) -> dict[str, Any]:
|
| 129 |
+
split = row["split"]
|
| 130 |
+
num_branches = int(row["num_branches"])
|
| 131 |
+
return {
|
| 132 |
+
"split": split,
|
| 133 |
+
"num_tasks": len(row["task_ids"]),
|
| 134 |
+
"num_seeds": len(row["seeds"]),
|
| 135 |
+
"num_charts": len(row["chart_ids"]),
|
| 136 |
+
"num_branches": num_branches,
|
| 137 |
+
"num_base_branches": int(row["num_base_branches"]),
|
| 138 |
+
"num_positive": int(row["num_positive"]),
|
| 139 |
+
"num_neutral": int(row["num_neutral"]),
|
| 140 |
+
"num_negative": int(row["num_negative"]),
|
| 141 |
+
"num_expert": int(row["num_expert"]),
|
| 142 |
+
"num_train_only_retrieval_rows": num_branches if split == "train" else 0,
|
| 143 |
+
"num_eval_only_rows": num_branches if split != "train" else 0,
|
| 144 |
+
"candidate_type_counts": dict(sorted(row["candidate_type_counts"].items())),
|
| 145 |
+
"chart_hashes": sorted(_hash_id(value) for value in row["chart_ids"]),
|
| 146 |
+
"state_hashes": sorted(_hash_id(value) for value in row["state_hashes"]),
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _latex_table(rows: list[dict[str, Any]]) -> str:
|
| 151 |
+
lines = [
|
| 152 |
+
"% Auto-generated by scripts/build_data_accounting.py",
|
| 153 |
+
"\\begin{tabular}{lrrrrrrrrrrr}",
|
| 154 |
+
"\\toprule",
|
| 155 |
+
"Split & Tasks & Seeds & Charts & Branches & Base & Positive & Neutral & Negative & Expert & TrainRows & EvalRows \\\\",
|
| 156 |
+
"\\midrule",
|
| 157 |
+
]
|
| 158 |
+
for row in rows:
|
| 159 |
+
lines.append(
|
| 160 |
+
f"{row['split']} & {row['num_tasks']} & {row['num_seeds']} & "
|
| 161 |
+
f"{row['num_charts']} & {row['num_branches']} & {row['num_base_branches']} & "
|
| 162 |
+
f"{row['num_positive']} & {row['num_neutral']} & {row['num_negative']} & "
|
| 163 |
+
f"{row['num_expert']} & {row['num_train_only_retrieval_rows']} & "
|
| 164 |
+
f"{row['num_eval_only_rows']} \\\\"
|
| 165 |
+
)
|
| 166 |
+
lines.extend(["\\bottomrule", "\\end{tabular}"])
|
| 167 |
+
return "\n".join(lines)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _markdown_report(payload: dict[str, Any]) -> str:
|
| 171 |
+
lines = [
|
| 172 |
+
"# CIL Chart Data Accounting",
|
| 173 |
+
"",
|
| 174 |
+
f"Dataset: `{payload['dataset']}`",
|
| 175 |
+
f"Split seed: `{payload['split_seed']}`",
|
| 176 |
+
f"Split hash: `{payload['split_hash']}`",
|
| 177 |
+
"",
|
| 178 |
+
"| Split | Tasks | Seeds | Charts | Branches | Base | Positive | Neutral | Negative | Expert | Train rows | Eval rows |",
|
| 179 |
+
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
| 180 |
+
]
|
| 181 |
+
for row in payload["rows"]:
|
| 182 |
+
lines.append(
|
| 183 |
+
f"| {row['split']} | {row['num_tasks']} | {row['num_seeds']} | "
|
| 184 |
+
f"{row['num_charts']} | {row['num_branches']} | {row['num_base_branches']} | "
|
| 185 |
+
f"{row['num_positive']} | {row['num_neutral']} | {row['num_negative']} | "
|
| 186 |
+
f"{row['num_expert']} | {row['num_train_only_retrieval_rows']} | "
|
| 187 |
+
f"{row['num_eval_only_rows']} |"
|
| 188 |
+
)
|
| 189 |
+
if payload["skip_counts"]:
|
| 190 |
+
lines.extend(["", "Skip counts:", ""])
|
| 191 |
+
lines.extend(f"- `{key}`: {value}" for key, value in payload["skip_counts"].items())
|
| 192 |
+
return "\n".join(lines)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _parse_csv(value: str) -> tuple[str, ...]:
|
| 196 |
+
return tuple(item.strip() for item in value.split(",") if item.strip())
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _parse_split_fractions(value: str) -> dict[str, float]:
|
| 200 |
+
parts = [float(item.strip()) for item in value.split(",") if item.strip()]
|
| 201 |
+
if len(parts) != 3:
|
| 202 |
+
raise ValueError("--split-fractions must contain train,val,test values")
|
| 203 |
+
total = sum(parts)
|
| 204 |
+
if total <= 0.0 or any(part < 0.0 for part in parts):
|
| 205 |
+
raise ValueError("split fractions must be non-negative with positive sum")
|
| 206 |
+
train, val, test = [part / total for part in parts]
|
| 207 |
+
return {"train": train, "val": val, "test": test}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _assign_split(group_id: str, *, fractions: dict[str, float], seed: int) -> str:
|
| 211 |
+
value = _stable_uniform(group_id, seed=seed)
|
| 212 |
+
if value < fractions["train"]:
|
| 213 |
+
return "train"
|
| 214 |
+
if value < fractions["train"] + fractions["val"]:
|
| 215 |
+
return "val"
|
| 216 |
+
return "test"
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _stable_uniform(value: str, *, seed: int) -> float:
|
| 220 |
+
digest = hashlib.sha256(f"{seed}:{value}".encode("utf-8")).digest()
|
| 221 |
+
return int.from_bytes(digest[:8], "big") / float(2**64)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _hash_id(value: str) -> str:
|
| 225 |
+
return hashlib.sha256(str(value).encode()).hexdigest()
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _split_hash(rows: list[dict[str, Any]]) -> str:
|
| 229 |
+
digest_rows = [
|
| 230 |
+
{
|
| 231 |
+
"split": row["split"],
|
| 232 |
+
"chart_hashes": row["chart_hashes"],
|
| 233 |
+
"state_hashes": row["state_hashes"],
|
| 234 |
+
}
|
| 235 |
+
for row in rows
|
| 236 |
+
]
|
| 237 |
+
return hashlib.sha256(json.dumps(digest_rows, sort_keys=True).encode()).hexdigest()
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
if __name__ == "__main__":
|
| 241 |
+
raise SystemExit(main())
|