|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""V95: objective-support activation law for the V94 RELATED ability specialist. |
| 3 | +
|
| 4 | +Residual |
| 5 | +-------- |
| 6 | +V94 showed that RELATED relative-ability evidence helps objective-cold validation |
| 7 | +but should receive zero weight in session-cold validation. The proposed missing |
| 8 | +applicability variable is epistemic support for the target objective. |
| 9 | +
|
| 10 | +Separator |
| 11 | +--------- |
| 12 | +Take one deterministic objective-cold outer fold. For every held-out objective, |
| 13 | +reserve a deterministic support pool and a disjoint fixed evaluation set. Reveal |
| 14 | +nested amounts of labelled target-objective support (0, 1, 2, 4, 8, 16, 32+ per |
| 15 | +objective), refit V75 and the V94 RELATED expert, and score the *same* evaluation |
| 16 | +rows at every support level. |
| 17 | +
|
| 18 | +Prediction |
| 19 | +---------- |
| 20 | +Optimal RELATED blend weight should be high at zero support and decline toward |
| 21 | +zero as target-objective support increases. |
| 22 | +
|
| 23 | +Cheap causal ablation |
| 24 | +--------------------- |
| 25 | +At support 8 and 32+, shuffle only the newly revealed support labels before |
| 26 | +fitting the RELATED expert. Extra rows without valid target information should |
| 27 | +not reproduce a lawful support benefit. |
| 28 | +
|
| 29 | +No leaderboard score or hidden-test outcome is used anywhere in construction, |
| 30 | +fitting, weighting, or the promotion decision. |
| 31 | +""" |
| 32 | +from __future__ import annotations |
| 33 | + |
| 34 | +import argparse |
| 35 | +import hashlib |
| 36 | +import json |
| 37 | +from pathlib import Path |
| 38 | + |
| 39 | +import numpy as np |
| 40 | +from scipy.stats import spearmanr |
| 41 | +from sklearn.linear_model import LogisticRegression |
| 42 | +from sklearn.metrics import log_loss |
| 43 | + |
| 44 | +from v71_mastery_events import load_transcript |
| 45 | +from v75_canonical_trajectory import load_training, SEED |
| 46 | +from v85_evidence_state import build_v75 |
| 47 | +from v93_shift_robust_validation import folds_from_groups |
| 48 | +from v94_related_control import segmented_control, build_control |
| 49 | + |
| 50 | + |
| 51 | +LEVELS = [0, 1, 2, 4, 8, 16, "32+"] |
| 52 | +GRID = np.linspace(0.0, 0.6, 25) |
| 53 | + |
| 54 | + |
| 55 | +def stable_key(obj: str, response_id: str) -> str: |
| 56 | + return hashlib.sha256(f"V95|{SEED}|{obj}|{response_id}".encode()).hexdigest() |
| 57 | + |
| 58 | + |
| 59 | +def make_fixed_support_design(frame, val_idx, objective): |
| 60 | + """Return disjoint nested support pools and one fixed evaluation index. |
| 61 | +
|
| 62 | + Up to half of each held-out objective (capped at 64 rows) is reserved as its |
| 63 | + support pool. The complement is scored at every support level, eliminating |
| 64 | + changing-evaluation-set confounding. Small objectives saturate naturally; |
| 65 | + the result records realised support counts for every level. |
| 66 | + """ |
| 67 | + val_idx = np.asarray(val_idx, dtype=int) |
| 68 | + response = ( |
| 69 | + frame.response_id.astype(str).to_numpy() |
| 70 | + if "response_id" in frame |
| 71 | + else np.arange(len(frame)).astype(str) |
| 72 | + ) |
| 73 | + pools = {} |
| 74 | + eval_parts = [] |
| 75 | + for g in sorted(np.unique(objective[val_idx])): |
| 76 | + idx = val_idx[objective[val_idx] == g] |
| 77 | + idx = np.asarray(sorted(idx, key=lambda i: stable_key(str(g), response[i])), dtype=int) |
| 78 | + pool_n = min(64, len(idx) // 2) |
| 79 | + pools[str(g)] = idx[:pool_n] |
| 80 | + eval_parts.append(idx[pool_n:]) |
| 81 | + eval_idx = np.concatenate(eval_parts) if eval_parts else np.array([], dtype=int) |
| 82 | + return pools, np.asarray(sorted(eval_idx), dtype=int) |
| 83 | + |
| 84 | + |
| 85 | +def support_indices(pools, level): |
| 86 | + out = [] |
| 87 | + counts = [] |
| 88 | + for g in sorted(pools): |
| 89 | + pool = pools[g] |
| 90 | + k = len(pool) if level == "32+" else min(int(level), len(pool)) |
| 91 | + counts.append(k) |
| 92 | + if k: |
| 93 | + out.append(pool[:k]) |
| 94 | + idx = np.concatenate(out) if out else np.array([], dtype=int) |
| 95 | + return np.asarray(sorted(idx), dtype=int), np.asarray(counts, dtype=int) |
| 96 | + |
| 97 | + |
| 98 | +def fit_predict(X, y, train_idx, eval_idx, y_train_override=None): |
| 99 | + yy = y[train_idx] if y_train_override is None else np.asarray(y_train_override, dtype=int) |
| 100 | + m = LogisticRegression( |
| 101 | + C=0.25, |
| 102 | + max_iter=300, |
| 103 | + solver="liblinear", |
| 104 | + random_state=SEED, |
| 105 | + ).fit(X[train_idx], yy) |
| 106 | + return np.clip(m.predict_proba(X[eval_idx])[:, 1], 1e-5, 1 - 1e-5) |
| 107 | + |
| 108 | + |
| 109 | +def best_blend(y, p0, pa): |
| 110 | + curve = [] |
| 111 | + for w in GRID: |
| 112 | + q = np.clip((1 - w) * p0 + w * pa, 1e-5, 1 - 1e-5) |
| 113 | + curve.append({"w": float(w), "ll": float(log_loss(y, q))}) |
| 114 | + return min(curve, key=lambda z: z["ll"]), curve |
| 115 | + |
| 116 | + |
| 117 | +def realised_support_summary(counts): |
| 118 | + if not len(counts): |
| 119 | + return {"min": 0, "median": 0.0, "mean": 0.0, "max": 0, "objectives": 0} |
| 120 | + return { |
| 121 | + "min": int(np.min(counts)), |
| 122 | + "median": float(np.median(counts)), |
| 123 | + "mean": float(np.mean(counts)), |
| 124 | + "max": int(np.max(counts)), |
| 125 | + "objectives": int(len(counts)), |
| 126 | + } |
| 127 | + |
| 128 | + |
| 129 | +def run(a): |
| 130 | + f = load_training(a.features, a.labels).reset_index(drop=True) |
| 131 | + cache = { |
| 132 | + sid: load_transcript(a.transcripts / f"{sid}.csv") |
| 133 | + for sid in f.session_id.astype(str).unique() |
| 134 | + } |
| 135 | + |
| 136 | + related_text, related_num = [], [] |
| 137 | + for i, r in f.iterrows(): |
| 138 | + d = cache[str(r.session_id)] |
| 139 | + t, z = segmented_control(d, str(r.learning_objective), "related") |
| 140 | + related_text.append(t) |
| 141 | + related_num.append(z) |
| 142 | + if (i + 1) % 2500 == 0: |
| 143 | + print("rows", i + 1) |
| 144 | + |
| 145 | + X0 = build_v75(f, cache) |
| 146 | + Xr = build_control(related_text, related_num) |
| 147 | + y = f.target.to_numpy(int) |
| 148 | + obj = ( |
| 149 | + f.learning_objective_id |
| 150 | + if "learning_objective_id" in f |
| 151 | + else f.learning_objective |
| 152 | + ).astype(str).to_numpy() |
| 153 | + |
| 154 | + # Cheapest sufficient causal world: the first deterministic GroupKFold |
| 155 | + # objective-cold split, held fixed for every support dose. |
| 156 | + base_train, heldout = folds_from_groups(obj)[0] |
| 157 | + pools, eval_idx = make_fixed_support_design(f, heldout, obj) |
| 158 | + if not len(eval_idx): |
| 159 | + raise RuntimeError("V95 fixed evaluation set is empty") |
| 160 | + |
| 161 | + eval_y = y[eval_idx] |
| 162 | + results = [] |
| 163 | + predictions = {} |
| 164 | + support_by_level = {} |
| 165 | + |
| 166 | + for level in LEVELS: |
| 167 | + sup_idx, counts = support_indices(pools, level) |
| 168 | + train_idx = np.concatenate([np.asarray(base_train, dtype=int), sup_idx]) |
| 169 | + p0 = fit_predict(X0, y, train_idx, eval_idx) |
| 170 | + pr = fit_predict(Xr, y, train_idx, eval_idx) |
| 171 | + b, curve = best_blend(eval_y, p0, pr) |
| 172 | + ll0 = float(log_loss(eval_y, p0)) |
| 173 | + llr = float(log_loss(eval_y, pr)) |
| 174 | + label = str(level) |
| 175 | + results.append({ |
| 176 | + "support": label, |
| 177 | + "realised_support_per_objective": realised_support_summary(counts), |
| 178 | + "support_rows_total": int(len(sup_idx)), |
| 179 | + "eval_rows": int(len(eval_idx)), |
| 180 | + "v75": ll0, |
| 181 | + "related_ability": llr, |
| 182 | + "best": b, |
| 183 | + "gain_vs_v75": float(ll0 - b["ll"]), |
| 184 | + "blend_curve": curve, |
| 185 | + }) |
| 186 | + predictions[label] = (p0, pr) |
| 187 | + support_by_level[label] = (sup_idx, counts) |
| 188 | + print("SUPPORT", label, "V75", ll0, "RELATED", llr, "BEST", b) |
| 189 | + |
| 190 | + # Information-destruction ablation: same revealed rows and class marginal, |
| 191 | + # but support labels are deterministically shuffled. Only RELATED is refit; |
| 192 | + # this asks whether valid labelled support, rather than row count alone, |
| 193 | + # improves the specialist representation. |
| 194 | + ablations = {} |
| 195 | + rng = np.random.RandomState(SEED + 95) |
| 196 | + for level in (8, "32+"): |
| 197 | + label = str(level) |
| 198 | + sup_idx, _ = support_by_level[label] |
| 199 | + train_idx = np.concatenate([np.asarray(base_train, dtype=int), sup_idx]) |
| 200 | + yy = y[train_idx].copy() |
| 201 | + nbase = len(base_train) |
| 202 | + if len(sup_idx) > 1: |
| 203 | + yy[nbase:] = yy[nbase:][rng.permutation(len(sup_idx))] |
| 204 | + pr_bad = fit_predict(Xr, y, train_idx, eval_idx, y_train_override=yy) |
| 205 | + normal_pr = predictions[label][1] |
| 206 | + ablations[label] = { |
| 207 | + "normal_related_ll": float(log_loss(eval_y, normal_pr)), |
| 208 | + "shuffled_support_related_ll": float(log_loss(eval_y, pr_bad)), |
| 209 | + "valid_information_gain": float(log_loss(eval_y, pr_bad) - log_loss(eval_y, normal_pr)), |
| 210 | + } |
| 211 | + print("ABLATION", label, ablations[label]) |
| 212 | + |
| 213 | + weights = np.asarray([r["best"]["w"] for r in results], dtype=float) |
| 214 | + # Use realised median support, with 32+ naturally reflecting the whole fixed pool. |
| 215 | + dose = np.asarray([r["realised_support_per_objective"]["median"] for r in results], dtype=float) |
| 216 | + rho = float(spearmanr(dose, weights).statistic) if len(np.unique(dose)) > 1 else 0.0 |
| 217 | + near_monotone_steps = int(np.sum(np.diff(weights) <= 0.025 + 1e-12)) |
| 218 | + possible_steps = len(weights) - 1 |
| 219 | + delta = float(weights[0] - weights[-1]) |
| 220 | + low_gain = float(results[0]["gain_vs_v75"]) |
| 221 | + high_weight = float(weights[-1]) |
| 222 | + |
| 223 | + clean = ( |
| 224 | + near_monotone_steps >= possible_steps - 1 |
| 225 | + and rho <= -0.75 |
| 226 | + and delta >= 0.15 |
| 227 | + and low_gain >= 0.003 |
| 228 | + and high_weight <= 0.15 |
| 229 | + ) |
| 230 | + partial = rho <= -0.50 and delta >= 0.10 and low_gain >= 0.002 |
| 231 | + if clean: |
| 232 | + verdict = "PROMOTE_OBJECTIVE_SUPPORT_ACTIVATION" |
| 233 | + elif partial: |
| 234 | + verdict = "R5_REFINE_EFFECTIVE_SUPPORT" |
| 235 | + else: |
| 236 | + verdict = "SUPPRESS_OBJECTIVE_SUPPORT" |
| 237 | + |
| 238 | + out = { |
| 239 | + "primary": "objective-support-activation-law", |
| 240 | + "design": { |
| 241 | + "outer_world": "first deterministic objective-cold GroupKFold split", |
| 242 | + "support_levels": [str(x) for x in LEVELS], |
| 243 | + "fixed_eval_rows": int(len(eval_idx)), |
| 244 | + "heldout_objectives": int(len(pools)), |
| 245 | + "support_pool_rule": "stable hash order; reserve up to half/objective capped at 64; score fixed complement", |
| 246 | + "note": "No leaderboard score or hidden-test outcome used in fitting, weighting, or decision.", |
| 247 | + }, |
| 248 | + "support_response": results, |
| 249 | + "ablations": ablations, |
| 250 | + "decision": { |
| 251 | + "spearman_support_vs_weight": rho, |
| 252 | + "near_monotone_steps": near_monotone_steps, |
| 253 | + "possible_steps": possible_steps, |
| 254 | + "weight_drop_zero_to_32plus": delta, |
| 255 | + "zero_support_gain_vs_v75": low_gain, |
| 256 | + "high_support_weight": high_weight, |
| 257 | + "verdict": verdict, |
| 258 | + "precommit": { |
| 259 | + "promote": "near-monotone (<=1 tolerance step), rho<=-0.75, weight drop>=0.15, zero-support gain>=0.003, 32+ weight<=0.15", |
| 260 | + "refine": "rho<=-0.50, weight drop>=0.10, zero-support gain>=0.002", |
| 261 | + "otherwise": "suppress raw objective support and seek another observable", |
| 262 | + }, |
| 263 | + }, |
| 264 | + } |
| 265 | + Path(a.out).write_text(json.dumps(out, indent=2)) |
| 266 | + print(json.dumps(out, indent=2)) |
| 267 | + |
| 268 | + |
| 269 | +if __name__ == "__main__": |
| 270 | + p = argparse.ArgumentParser() |
| 271 | + p.add_argument("--features", type=Path, required=True) |
| 272 | + p.add_argument("--labels", type=Path, required=True) |
| 273 | + p.add_argument("--transcripts", type=Path, required=True) |
| 274 | + p.add_argument("--out", default="v95_objective_support_activation.json") |
| 275 | + run(p.parse_args()) |
0 commit comments