anhtld commited on
Commit
951390a
·
verified ·
1 Parent(s): a86d0a0

ctt artifacts 2026-07-02 workspace/scripts/train_ctt.py

Browse files
Files changed (1) hide show
  1. workspace/scripts/train_ctt.py +303 -0
workspace/scripts/train_ctt.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import random
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
16
+ if str(PROJECT_ROOT) not in sys.path:
17
+ sys.path.insert(0, str(PROJECT_ROOT))
18
+
19
+ import numpy as np # noqa: E402
20
+ import torch # noqa: E402
21
+
22
+ from cil.models import CTTConfig, CausalTangentTransport, ChartEncoder, TangentNormalizer # noqa: E402
23
+ from cil.models.ctt import chamfer_to_target_set, negative_boundary_loss # noqa: E402
24
+
25
+
26
+ @dataclass
27
+ class Chart:
28
+ chart_id: str
29
+ task_id: str
30
+ feature: torch.Tensor
31
+ positives: torch.Tensor
32
+ negatives: torch.Tensor
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> int:
36
+ parser = argparse.ArgumentParser(description="Train Causal Tangent Transport on chart DB.")
37
+ parser.add_argument("--config", type=Path, default=None)
38
+ parser.add_argument("--dataset", type=Path, default=Path("data/cil_charts/train/index.json"))
39
+ parser.add_argument("--out-dir", type=Path, default=Path("runs/ctt_residual_smoke"))
40
+ parser.add_argument("--variant", choices=("residual", "gated_residual"), default="residual")
41
+ parser.add_argument("--epochs", type=int, default=2)
42
+ parser.add_argument("--max-charts", type=int, default=64)
43
+ parser.add_argument("--neighbors", type=int, default=4)
44
+ parser.add_argument("--lr", type=float, default=1.0e-3)
45
+ parser.add_argument("--seed", type=int, default=0)
46
+ parser.add_argument("--pos-alignment", type=float, default=1.0)
47
+ parser.add_argument("--negative-boundary", type=float, default=0.25)
48
+ parser.add_argument("--cycle", type=float, default=0.1)
49
+ parser.add_argument("--diversity", type=float, default=0.05)
50
+ parser.add_argument("--negative-margin", type=float, default=0.2)
51
+ args = parser.parse_args(argv)
52
+ if args.config:
53
+ args = _merge_config(args, _load_simple_yaml(args.config))
54
+
55
+ random.seed(args.seed)
56
+ np.random.seed(args.seed)
57
+ torch.manual_seed(args.seed)
58
+
59
+ out_dir = args.out_dir
60
+ out_dir.mkdir(parents=True, exist_ok=True)
61
+ _write_run_provenance(out_dir, args)
62
+ charts, index = load_charts(args.dataset, max_charts=args.max_charts)
63
+ if len(charts) < 2:
64
+ raise SystemExit("CTT training requires at least two charts with positive tangents")
65
+
66
+ all_xi = torch.cat([chart.positives for chart in charts] + [chart.negatives for chart in charts if chart.negatives.numel()], dim=0)
67
+ normalizer = TangentNormalizer.fit(all_xi)
68
+ for chart in charts:
69
+ chart.positives[:] = normalizer.transform(chart.positives)
70
+ if chart.negatives.numel():
71
+ chart.negatives[:] = normalizer.transform(chart.negatives)
72
+
73
+ feature_dim = int(charts[0].feature.numel())
74
+ config = CTTConfig(
75
+ chart_feature_dim=feature_dim,
76
+ tangent_dim=int(charts[0].positives.shape[1]),
77
+ variant=args.variant,
78
+ )
79
+ encoder = ChartEncoder(feature_dim, output_dim=config.chart_dim)
80
+ ctt = CausalTangentTransport(config)
81
+ optimizer = torch.optim.Adam(list(encoder.parameters()) + list(ctt.parameters()), lr=args.lr)
82
+ pairs = _neighbor_pairs(charts, neighbors=args.neighbors)
83
+ log_lines = [
84
+ f"charts={len(charts)} pairs={len(pairs)} variant={args.variant}",
85
+ f"loss_weights pos={args.pos_alignment} neg={args.negative_boundary} cycle={args.cycle} diversity={args.diversity}",
86
+ ]
87
+
88
+ for epoch in range(args.epochs):
89
+ random.shuffle(pairs)
90
+ losses = []
91
+ for source_idx, target_idx in pairs:
92
+ source = charts[source_idx]
93
+ target = charts[target_idx]
94
+ xi_source = source.positives[random.randrange(source.positives.shape[0])].unsqueeze(0)
95
+ z_source = encoder(source.feature.unsqueeze(0))
96
+ z_target = encoder(target.feature.unsqueeze(0))
97
+ xi_hat = ctt(z_source, z_target, xi_source)
98
+ loss_pos = chamfer_to_target_set(xi_hat, target.positives)
99
+ loss_neg = negative_boundary_loss(
100
+ xi_hat,
101
+ target.negatives,
102
+ margin=args.negative_margin,
103
+ )
104
+ xi_cycle = ctt(z_target, z_source, xi_hat)
105
+ loss_cycle = (xi_cycle - xi_source).pow(2).mean()
106
+ loss = (
107
+ args.pos_alignment * loss_pos
108
+ + args.negative_boundary * loss_neg
109
+ + args.cycle * loss_cycle
110
+ )
111
+ optimizer.zero_grad()
112
+ loss.backward()
113
+ optimizer.step()
114
+ losses.append(float(loss.detach()))
115
+ mean_loss = sum(losses) / len(losses) if losses else math.nan
116
+ log_lines.append(f"epoch={epoch + 1} mean_loss={mean_loss:.6f}")
117
+
118
+ checkpoint = {
119
+ "config": config.to_dict(),
120
+ "chart_encoder": encoder.state_dict(),
121
+ "ctt": ctt.state_dict(),
122
+ "normalizer": normalizer.to_dict(),
123
+ "source_index": str(args.dataset),
124
+ "data_hash": index.get("content_hash"),
125
+ "split_hash": index.get("split_hash"),
126
+ }
127
+ torch.save(checkpoint, out_dir / "model.pt")
128
+ metrics = {
129
+ "report_type": "ctt_train",
130
+ "variant": args.variant,
131
+ "epochs": args.epochs,
132
+ "num_charts": len(charts),
133
+ "num_pairs": len(pairs),
134
+ "final_mean_loss": mean_loss,
135
+ "data_hash": index.get("content_hash"),
136
+ "split_hash": index.get("split_hash"),
137
+ "loss_weights": {
138
+ "pos_alignment": args.pos_alignment,
139
+ "negative_boundary": args.negative_boundary,
140
+ "cycle": args.cycle,
141
+ "diversity": args.diversity,
142
+ "negative_margin": args.negative_margin,
143
+ },
144
+ }
145
+ (out_dir / "train.log").write_text("\n".join(log_lines) + "\n")
146
+ (out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
147
+ (out_dir / "metrics_by_task.json").write_text("{}\n")
148
+ (out_dir / "metrics_by_seed.json").write_text("{}\n")
149
+ (out_dir / "eval.log").write_text("eval not run by train_ctt.py\n")
150
+ (out_dir / "table.tex").write_text(_table(metrics) + "\n")
151
+ (out_dir / "report.md").write_text(_report(metrics) + "\n")
152
+ print(json.dumps({"out_dir": str(out_dir), "final_mean_loss": mean_loss}, indent=2))
153
+ return 0
154
+
155
+
156
+ def load_charts(index_path: Path, *, max_charts: int | None = None) -> tuple[list[Chart], dict[str, Any]]:
157
+ index = json.loads(index_path.read_text())
158
+ if not index.get("include_outcomes", False):
159
+ raise SystemExit(f"{index_path} does not include outcomes; CTT training requires train split")
160
+ grouped: dict[str, dict[str, Any]] = {}
161
+ for shard in index.get("shards", []):
162
+ shard_path = index_path.parent / shard["path"]
163
+ with np.load(shard_path, allow_pickle=False) as data:
164
+ chart_ids = data["chart_id"]
165
+ task_ids = data["task_id"]
166
+ base_actions = data["base_action"]
167
+ labels = data["label"]
168
+ is_base = data["is_base_branch"]
169
+ spline_codes = data["spline_tangent_code"]
170
+ for row in range(chart_ids.shape[0]):
171
+ chart_id = str(chart_ids[row])
172
+ item = grouped.setdefault(
173
+ chart_id,
174
+ {
175
+ "task_id": str(task_ids[row]),
176
+ "base": None,
177
+ "positives": [],
178
+ "negatives": [],
179
+ },
180
+ )
181
+ if bool(is_base[row]):
182
+ item["base"] = base_actions[row].astype("float32")
183
+ label = str(labels[row])
184
+ if label == "positive":
185
+ item["positives"].append(spline_codes[row].astype("float32"))
186
+ elif label == "negative":
187
+ item["negatives"].append(spline_codes[row].astype("float32"))
188
+ charts = []
189
+ for chart_id, item in sorted(grouped.items()):
190
+ if item["base"] is None or not item["positives"]:
191
+ continue
192
+ positives = torch.as_tensor(np.asarray(item["positives"]), dtype=torch.float32)
193
+ negatives = torch.as_tensor(np.asarray(item["negatives"]), dtype=torch.float32)
194
+ charts.append(
195
+ Chart(
196
+ chart_id=chart_id,
197
+ task_id=str(item["task_id"]),
198
+ feature=torch.as_tensor(item["base"], dtype=torch.float32),
199
+ positives=positives,
200
+ negatives=negatives,
201
+ )
202
+ )
203
+ if max_charts is not None:
204
+ charts = charts[: int(max_charts)]
205
+ return charts, index
206
+
207
+
208
+ def _neighbor_pairs(charts: list[Chart], *, neighbors: int) -> list[tuple[int, int]]:
209
+ pairs: list[tuple[int, int]] = []
210
+ for target_idx, target in enumerate(charts):
211
+ candidates = []
212
+ for source_idx, source in enumerate(charts):
213
+ if source_idx == target_idx or source.task_id != target.task_id:
214
+ continue
215
+ distance = torch.linalg.vector_norm(source.feature - target.feature).item()
216
+ candidates.append((distance, source_idx))
217
+ for _, source_idx in sorted(candidates)[: int(neighbors)]:
218
+ pairs.append((source_idx, target_idx))
219
+ return pairs
220
+
221
+
222
+ def _write_run_provenance(out_dir: Path, args: argparse.Namespace) -> None:
223
+ config = vars(args) | {"config": None if args.config is None else str(args.config)}
224
+ (out_dir / "config.yaml").write_text("\n".join(f"{k}: {v}" for k, v in sorted(config.items())) + "\n")
225
+ (out_dir / "command.txt").write_text("python scripts/train_ctt.py " + " ".join(sys.argv[1:]) + "\n")
226
+ (out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n")
227
+ index = json.loads(Path(args.dataset).read_text())
228
+ (out_dir / "data_hash.txt").write_text(str(index.get("content_hash", "")) + "\n")
229
+ (out_dir / "split_hash.txt").write_text(str(index.get("split_hash", "")) + "\n")
230
+
231
+
232
+ def _run(command: list[str]) -> str:
233
+ try:
234
+ return subprocess.check_output(command, cwd=PROJECT_ROOT, text=True).strip()
235
+ except (subprocess.CalledProcessError, FileNotFoundError):
236
+ return ""
237
+
238
+
239
+ def _load_simple_yaml(path: Path) -> dict[str, Any]:
240
+ values: dict[str, Any] = {}
241
+ for line in path.read_text().splitlines():
242
+ stripped = line.strip()
243
+ if not stripped or stripped.startswith("#") or ":" not in stripped:
244
+ continue
245
+ key, raw = stripped.split(":", 1)
246
+ values[key.strip().replace("-", "_")] = _coerce(raw.strip())
247
+ return values
248
+
249
+
250
+ def _merge_config(args: argparse.Namespace, config: dict[str, Any]) -> argparse.Namespace:
251
+ for key, value in config.items():
252
+ if hasattr(args, key):
253
+ if key in {"dataset", "out_dir"}:
254
+ value = Path(value)
255
+ setattr(args, key, value)
256
+ return args
257
+
258
+
259
+ def _coerce(value: str) -> Any:
260
+ if value in {"true", "True"}:
261
+ return True
262
+ if value in {"false", "False"}:
263
+ return False
264
+ try:
265
+ if "." in value:
266
+ return float(value)
267
+ return int(value)
268
+ except ValueError:
269
+ return value
270
+
271
+
272
+ def _table(metrics: dict[str, Any]) -> str:
273
+ return "\n".join(
274
+ [
275
+ "% Auto-generated by scripts/train_ctt.py",
276
+ "\\begin{tabular}{lrrr}",
277
+ "\\toprule",
278
+ "Variant & Charts & Pairs & Final loss \\\\",
279
+ "\\midrule",
280
+ f"{metrics['variant']} & {metrics['num_charts']} & {metrics['num_pairs']} & {metrics['final_mean_loss']:.4f} \\\\",
281
+ "\\bottomrule",
282
+ "\\end{tabular}",
283
+ ]
284
+ )
285
+
286
+
287
+ def _report(metrics: dict[str, Any]) -> str:
288
+ return "\n".join(
289
+ [
290
+ "# CTT Train Report",
291
+ "",
292
+ f"Variant: `{metrics['variant']}`",
293
+ f"Charts: `{metrics['num_charts']}`",
294
+ f"Neighbor pairs: `{metrics['num_pairs']}`",
295
+ f"Final mean loss: `{metrics['final_mean_loss']:.6f}`",
296
+ "",
297
+ "This smoke run trains CTT from measured train positives; it is not a rollout success claim.",
298
+ ]
299
+ )
300
+
301
+
302
+ if __name__ == "__main__":
303
+ raise SystemExit(main())