File size: 17,288 Bytes
bc29ee3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
#!/usr/bin/env python3
"""Summarize Linear/Ridge and nonlinear conditional probe fold results."""

from __future__ import annotations

import argparse
import csv
import itertools
import json
import math
from collections import defaultdict
from pathlib import Path
from typing import Any

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np


FAMILIES = ("self_forcing", "causal_forcing", "hy_worldplay")
ROLES = ("early", "middle", "late", "final")


def read_csv(path: Path) -> list[dict[str, Any]]:
    with path.open(newline="", encoding="utf-8") as handle:
        rows = list(csv.DictReader(handle))
    for row in rows:
        for key in ("target_step", "held_out_prompt", "seed", "layer_index", "test_tokens"):
            if key in row:
                row[key] = int(row[key])
        for key in ("mse", "nMSE", "nRMSE", "r2", "cosine"):
            row[key] = float(row[key])
    return rows


def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
    if not rows:
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    fields: list[str] = []
    for row in rows:
        for key in row:
            if key not in fields:
                fields.append(key)
    with path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(rows)


def bootstrap(values: list[float], seed: int, rounds: int = 10000):
    values = np.asarray(values, dtype=np.float64)
    rng = np.random.default_rng(seed)
    if values.size == 0:
        return float("nan"), float("nan"), float("nan")
    indices = rng.integers(0, values.size, size=(rounds, values.size))
    means = values[indices].mean(axis=1)
    return float(values.mean()), float(np.quantile(means, 0.025)), float(np.quantile(means, 0.975))


def exact_signflip(values: list[float]) -> float:
    values = np.asarray(values, dtype=np.float64)
    values = values[np.isfinite(values)]
    if not values.size:
        return float("nan")
    observed = abs(float(values.mean()))
    exceed = 0
    total = 1 << int(values.size)
    for mask in range(total):
        signed = np.asarray(
            [value if (mask >> index) & 1 else -value for index, value in enumerate(values)]
        )
        if abs(float(signed.mean())) >= observed - 1e-15:
            exceed += 1
    return float((exceed + 1) / (total + 1))


def prompt_metric_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Average seeds within each prompt, keeping prompt as statistical unit."""
    grouped: dict[tuple, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        grouped[
            (
                row["method"], row["model_family"], row["layer_role"],
                row["target_step"], row["probe"], row["held_out_prompt"],
            )
        ].append(row)
    result = []
    for key, values in sorted(grouped.items(), key=lambda item: tuple(map(str, item[0]))):
        method, family, role, step, probe, prompt = key
        item = {
            "method": method,
            "model_family": family,
            "layer_role": role,
            "target_step": step,
            "probe": probe,
            "held_out_prompt": prompt,
            "seed_count": len(values),
        }
        for metric in ("mse", "nMSE", "nRMSE", "r2", "cosine"):
            item[metric] = float(np.mean([float(value[metric]) for value in values]))
        result.append(item)
    return result


def gain_rows(prompt_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    baseline_name = {"linear": "within_affine", "nonlinear": "step_only"}
    grouped = defaultdict(dict)
    for row in prompt_rows:
        grouped[
            (row["method"], row["model_family"], row["layer_role"], row["target_step"], row["held_out_prompt"])
        ][row["probe"]] = row
    result = []
    for key, probes in sorted(grouped.items(), key=lambda item: tuple(map(str, item[0]))):
        method, family, role, step, prompt = key
        baseline = probes.get(baseline_name[method])
        if baseline is None:
            continue
        for probe, current in probes.items():
            reference_mse = float(baseline["mse"])
            current_mse = float(current["mse"])
            result.append({
                "method": method,
                "model_family": family,
                "layer_role": role,
                "target_step": step,
                "held_out_prompt": prompt,
                "baseline_probe": baseline_name[method],
                "probe": probe,
                "baseline_mse": reference_mse,
                "probe_mse": current_mse,
                "gain": (reference_mse - current_mse) / max(reference_mse, 1e-12),
                "delta_r2": float(current["r2"]) - float(baseline["r2"]),
            })
    return result


def summarize_gains(gains: list[dict[str, Any]]) -> list[dict[str, Any]]:
    grouped = defaultdict(list)
    for row in gains:
        grouped[(row["method"], row["model_family"], row["layer_role"], row["target_step"], row["probe"])].append(row)
    result = []
    for key, values in sorted(grouped.items(), key=lambda item: tuple(map(str, item[0]))):
        method, family, role, step, probe = key
        values = sorted(values, key=lambda row: row["held_out_prompt"])
        numbers = [float(row["gain"]) for row in values]
        mean, low, high = bootstrap(numbers, 1000 + sum(ord(ch) for ch in str(key)))
        result.append({
            "method": method,
            "model_family": family,
            "layer_role": role,
            "target_step": step,
            "probe": probe,
            "baseline_probe": values[0]["baseline_probe"],
            "prompt_count": len(numbers),
            "gain_mean": mean,
            "gain_ci95_low": low,
            "gain_ci95_high": high,
            "wins": int(sum(number > 0 for number in numbers)),
            "signflip_p": exact_signflip(numbers),
        })
    return result


def summarize_layer_gains(gains: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Average target steps inside each prompt, then summarize across prompts."""
    prompt_groups = defaultdict(list)
    for row in gains:
        prompt_groups[
            (
                row["method"], row["model_family"], row["layer_role"],
                row["probe"], row["held_out_prompt"], row["baseline_probe"],
            )
        ].append(float(row["gain"]))
    groups = defaultdict(list)
    for key, values in prompt_groups.items():
        method, family, role, probe, _prompt, baseline = key
        groups[(method, family, role, probe, baseline)].append(float(np.mean(values)))
    result = []
    for key, values in sorted(groups.items(), key=lambda item: tuple(map(str, item[0]))):
        method, family, role, probe, baseline = key
        mean, low, high = bootstrap(values, 17000 + sum(ord(ch) for ch in str(key)))
        result.append({
            "method": method,
            "model_family": family,
            "layer_role": role,
            "probe": probe,
            "baseline_probe": baseline,
            "target_step_count": 3,
            "prompt_count": len(values),
            "gain_mean": mean,
            "gain_ci95_low": low,
            "gain_ci95_high": high,
            "wins": int(sum(value > 0 for value in values)),
            "signflip_p": exact_signflip(values),
        })
    return result


def summarize_metrics(prompt_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    grouped = defaultdict(list)
    for row in prompt_rows:
        grouped[(row["method"], row["model_family"], row["layer_role"], row["target_step"], row["probe"])].append(row)
    result = []
    for key, values in sorted(grouped.items(), key=lambda item: tuple(map(str, item[0]))):
        method, family, role, step, probe = key
        item = {
            "method": method,
            "model_family": family,
            "layer_role": role,
            "target_step": step,
            "probe": probe,
            "prompt_count": len(values),
        }
        for metric_index, metric in enumerate(("mse", "nMSE", "nRMSE", "r2", "cosine")):
            numbers = [float(row[metric]) for row in values]
            mean, low, high = bootstrap(numbers, 9000 + metric_index + sum(ord(ch) for ch in str(key)))
            item[f"{metric}_mean"] = mean
            item[f"{metric}_ci95_low"] = low
            item[f"{metric}_ci95_high"] = high
        result.append(item)
    return result


def plot_primary(gains: list[dict[str, Any]], output: Path) -> None:
    fig, axes = plt.subplots(1, 2, figsize=(14, 5), sharey=True)
    for ax, method, title in zip(axes, ("linear", "nonlinear"), ("Linear/Ridge", "Nonlinear MLP")):
        selected = [
            row for row in gains
            if row["method"] == method and row["probe"] in ({"fusion_same"} if method == "linear" else {"both_correct"})
        ]
        x = np.arange(len(ROLES))
        width = 0.24
        for family_index, family in enumerate(FAMILIES):
            values = []
            lows = []
            highs = []
            for role in ROLES:
                group = [row for row in selected if row["model_family"] == family and row["layer_role"] == role]
                nums = [float(row["gain"]) for row in group]
                mean, low, high = bootstrap(nums, 1234 + family_index * 100 + ROLES.index(role))
                values.append(mean)
                lows.append(mean - low)
                highs.append(high - mean)
            pos = x + (family_index - (len(FAMILIES) - 1) / 2) * width
            ax.bar(pos, values, width, yerr=[lows, highs], capsize=3, label=family)
        ax.axhline(0, color="black", linewidth=0.8)
        ax.set_xticks(x, ROLES)
        ax.set_ylabel("Gain vs step-only baseline" if method == "nonlinear" else "Gain vs within-affine baseline")
        ax.set_title(title)
        ax.grid(axis="y", alpha=0.25)
    axes[1].legend(fontsize=9)
    fig.tight_layout()
    fig.savefig(output, dpi=180)
    plt.close(fig)


def plot_controls(gains: list[dict[str, Any]], output: Path) -> None:
    probes = ["fusion_same", "fusion_step_duplicate", "fusion_wrong_step", "fusion_distant", "fusion_batch_shuffle", "fusion_zero", "fusion_noise"]
    labels = {
        "fusion_same": "correct",
        "fusion_step_duplicate": "step duplicate",
        "fusion_wrong_step": "wrong step",
        "fusion_distant": "distant",
        "fusion_batch_shuffle": "other video",
        "fusion_zero": "zero",
        "fusion_noise": "noise",
    }
    fig, axes = plt.subplots(
        1,
        len(FAMILIES),
        figsize=(5.7 * len(FAMILIES), 5),
        sharey=True,
    )
    axes = np.atleast_1d(axes)
    for ax, family in zip(axes, FAMILIES):
        values = []
        errors = []
        for probe in probes:
            group = [row for row in gains if row["method"] == "linear" and row["model_family"] == family and row["layer_role"] == "final" and row["probe"] == probe]
            nums = [float(row["gain"]) for row in group]
            mean, low, high = bootstrap(nums, 4000 + probes.index(probe))
            values.append(mean)
            errors.append((mean - low, high - mean))
        y = np.arange(len(probes))
        ax.errorbar(values, y, xerr=np.asarray(errors).T, fmt="o", capsize=3)
        ax.axvline(0, color="black", linewidth=0.8)
        ax.set_yticks(y, [labels[p] for p in probes])
        ax.set_title(family)
        ax.grid(axis="x", alpha=0.25)
    axes[0].set_xlabel("Linear MSE gain")
    fig.tight_layout()
    fig.savefig(output, dpi=180)
    plt.close(fig)


def build_report(metrics: list[dict[str, Any]], gains: list[dict[str, Any]], output: Path, config: dict[str, Any]) -> None:
    lines = [
        "# Conditional prediction and incremental chunk information",
        "",
        "This report uses 10 prompt-grouped held-out folds. The test prompt and its",
        "other-video donor are excluded from training; nonlinear seeds are averaged",
        "within prompt before confidence intervals are computed.",
        "",
        "The primary endpoint is `fusion_same` vs `within_affine` for Linear/Ridge",
        "and `both_correct` vs `step_only` for the nonlinear MLP.",
        "",
        "| method | family | role | step | probe | gain | 95% CI | wins | sign-flip p |",
        "|---|---|---|---:|---|---:|---|---:|---:|",
    ]
    primary = [
        row for row in gains
        if (row["method"] == "linear" and row["probe"] == "fusion_same")
        or (row["method"] == "nonlinear" and row["probe"] == "both_correct")
    ]
    for row in primary:
        lines.append(
            f"| {row['method']} | {row['model_family']} | {row['layer_role']} | {row['target_step']} | "
            f"{row['probe']} | {row['gain_mean']:.4f} | [{row['gain_ci95_low']:.4f}, {row['gain_ci95_high']:.4f}] | "
            f"{row['wins']}/{row['prompt_count']} | {row['signflip_p']:.4f} |"
        )
    lines += [
        "",
        "Interpretation: positive gain means that adding the auxiliary feature lowers held-out MSE.",
        "The exact sign-flip test treats prompt, not token, as the independent unit.",
        "Absolute MSE is not compared across model families because feature dimensions and",
        "conditioning paths differ.",
        "",
        "## Files",
        "",
        "- `probe_folds_unified.csv`",
        "- `probe_prompt_averaged.csv`",
        "- `probe_metrics_summary.csv`",
        "- `probe_gain_summary.csv`",
        "- `conditional_gain_by_layer.png`",
        "- `conditional_control_comparison.png`",
        "",
        "```json",
        json.dumps(config, indent=2, ensure_ascii=False),
        "```",
    ]
    output.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main() -> None:
    global FAMILIES
    parser = argparse.ArgumentParser()
    parser.add_argument("--linear_csv", type=Path, required=True)
    parser.add_argument("--nonlinear_dir", type=Path, required=True)
    parser.add_argument("--output_dir", type=Path, required=True)
    parser.add_argument("--families", default=",".join(FAMILIES))
    parser.add_argument(
        "--chunk_pairing",
        choices=("matched_slot", "boundary_to_all"),
        default="matched_slot",
    )
    args = parser.parse_args()
    requested_families = tuple(
        value.strip() for value in args.families.split(",") if value.strip()
    )
    unknown = set(requested_families) - set(FAMILIES)
    if not requested_families or unknown:
        raise ValueError(f"Invalid families: {requested_families}; unknown={sorted(unknown)}")
    FAMILIES = requested_families
    args.output_dir.mkdir(parents=True, exist_ok=True)

    linear = read_csv(args.linear_csv)
    linear = [row for row in linear if row["model_family"] in FAMILIES]
    linear = [{**row, "method": "linear"} for row in linear]
    nonlinear = []
    for family in FAMILIES:
        path = args.nonlinear_dir / f"nonlinear_probe_{family}_folds.csv"
        rows = read_csv(path)
        nonlinear.extend({**row, "method": "nonlinear"} for row in rows)
    expected_linear = 1320 * len(FAMILIES)
    expected_nonlinear = 2160 * len(FAMILIES)
    if len(linear) != expected_linear:
        raise ValueError(f"Expected {expected_linear} linear rows, found {len(linear)}")
    if len(nonlinear) != expected_nonlinear:
        raise ValueError(f"Expected {expected_nonlinear} nonlinear rows, found {len(nonlinear)}")
    unified = linear + nonlinear
    prompt_rows = prompt_metric_rows(unified)
    gains = gain_rows(prompt_rows)
    metrics = summarize_metrics(prompt_rows)
    gain_summary = summarize_gains(gains)
    layer_gain_summary = summarize_layer_gains(gains)
    write_csv(args.output_dir / "probe_folds_unified.csv", unified)
    write_csv(args.output_dir / "probe_prompt_averaged.csv", prompt_rows)
    write_csv(args.output_dir / "probe_metrics_summary.csv", metrics)
    write_csv(args.output_dir / "probe_gain_by_prompt.csv", gains)
    write_csv(args.output_dir / "probe_gain_summary.csv", gain_summary)
    write_csv(args.output_dir / "probe_layer_gain_summary.csv", layer_gain_summary)
    plot_primary(gains, args.output_dir / "conditional_gain_by_layer.png")
    plot_controls(gains, args.output_dir / "conditional_control_comparison.png")
    config = {
        "linear_rows": len(linear),
        "nonlinear_rows": len(nonlinear),
        "families": list(FAMILIES),
        "chunk_pairing": args.chunk_pairing,
        "prompt_averaged_rows": len(prompt_rows),
        "gain_rows": len(gains),
        "prompt_count": 10,
        "target_chunks": [2, 3],
        "target_steps": [1, 2, 3],
        "linear_baseline": "within_affine",
        "nonlinear_baseline": "step_only",
        "nonlinear_seed_aggregation": "mean within held-out prompt",
        "outer_split": "held-out prompt plus its cyclic other-video donor",
    }
    (args.output_dir / "config.json").write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
    build_report(metrics, gain_summary, args.output_dir / "REPORT.md", config)
    print(f"[complete] {args.output_dir} unified={len(unified)} gains={len(gains)}", flush=True)


if __name__ == "__main__":
    main()