|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""V105: restore the missing V74 objective-prior + V75 student-state composition. |
| 3 | +
|
| 4 | +Repo reset hypothesis: V74 was promoted as a mandatory independent objective-difficulty |
| 5 | +prior, but the V75->V104 lineage largely rebuilt V75 without it. Test the smallest |
| 6 | +lawful composition before further routing work. |
| 7 | +
|
| 8 | +For each outer validation world: |
| 9 | + * V74 is fit only on outer-train and predicts outer-valid. |
| 10 | + * V75 and RELATED are fit only on outer-train and predict outer-valid. |
| 11 | + * Inner grouped OOF predictions on outer-train select a tiny convex grid. |
| 12 | + * RELATED weight is permitted only where the exact objective has zero support in the |
| 13 | + corresponding training fold. No test-batch aggregation or cross-response features. |
| 14 | +
|
| 15 | +A deterministic mixed-support world combines unseen-objective rows with held-out-session |
| 16 | +rows on otherwise seen objectives, so the gate is not judged only in pure objective-cold. |
| 17 | +""" |
| 18 | +from __future__ import annotations |
| 19 | +import argparse, json |
| 20 | +from pathlib import Path |
| 21 | +import numpy as np |
| 22 | +from sklearn.cluster import KMeans |
| 23 | +from sklearn.linear_model import LogisticRegression |
| 24 | +from sklearn.metrics import log_loss |
| 25 | +from sklearn.model_selection import GroupKFold |
| 26 | + |
| 27 | +from v71_mastery_events import load_transcript |
| 28 | +from v74_semantic_objective_prior import semantic_prior_predict |
| 29 | +from v75_canonical_trajectory import load_training, SEED |
| 30 | +from v85_evidence_state import build_v75 |
| 31 | +from v93_shift_robust_validation import folds_from_groups, obj_family, style_matrix |
| 32 | +from v94_related_control import segmented_control, build_control |
| 33 | + |
| 34 | +EPS=1e-5 |
| 35 | +W74=np.array([0.,.10,.20,.30,.40,.50]) |
| 36 | +WR=np.array([0.,.10,.20,.30,.40]) |
| 37 | + |
| 38 | + |
| 39 | +def ll(y,p): return float(log_loss(y,np.clip(p,EPS,1-EPS),labels=[0,1])) |
| 40 | + |
| 41 | +def fit_lr(X,y,tr,va): |
| 42 | + m=LogisticRegression(C=.25,max_iter=300,solver='liblinear',random_state=SEED).fit(X[tr],y[tr]) |
| 43 | + return np.clip(m.predict_proba(X[va])[:,1],EPS,1-EPS) |
| 44 | + |
| 45 | +def unseen_mask(keys,tr,va): |
| 46 | + seen=set(keys[tr].tolist()) |
| 47 | + return np.asarray([keys[i] not in seen for i in va],bool) |
| 48 | + |
| 49 | +def compose(p75,p74,pr,unseen,w74,wr): |
| 50 | + # RELATED is unavailable on supported objectives. Preserve convexity per row. |
| 51 | + rw=np.where(unseen,wr,0.0) |
| 52 | + base=np.maximum(0.0,1.0-w74-rw) |
| 53 | + return np.clip(base*p75+w74*p74+rw*pr,EPS,1-EPS) |
| 54 | + |
| 55 | +def mixed_support_folds(obj,sess,n=5): |
| 56 | + """Each fold has cold objectives plus session-held-out rows from remaining objectives.""" |
| 57 | + uo=np.unique(obj); us=np.unique(sess) |
| 58 | + of={x:i % n for i,x in enumerate(sorted(uo))} |
| 59 | + sf={x:i % n for i,x in enumerate(sorted(us))} |
| 60 | + out=[] |
| 61 | + idx=np.arange(len(obj)) |
| 62 | + for k in range(n): |
| 63 | + cold=np.asarray([of[x]==k for x in obj]) |
| 64 | + seen_session=np.asarray([(of[o]!=k and sf[s]==k) for o,s in zip(obj,sess)]) |
| 65 | + va=idx[cold|seen_session]; tr=idx[~(cold|seen_session)] |
| 66 | + out.append((tr,va)) |
| 67 | + return out |
| 68 | + |
| 69 | +def inner_oof(f,X75,Xr,y,outer_tr,groups,support_key): |
| 70 | + n=len(outer_tr); p75=np.zeros(n); p74=np.zeros(n); pr=np.zeros(n); uns=np.zeros(n,bool) |
| 71 | + g=groups[outer_tr] |
| 72 | + ns=min(3,len(np.unique(g))) |
| 73 | + for itr_l,iva_l in GroupKFold(ns).split(np.zeros(n),y[outer_tr],g): |
| 74 | + itr=outer_tr[itr_l]; iva=outer_tr[iva_l] |
| 75 | + p75[iva_l]=fit_lr(X75,y,itr,iva) |
| 76 | + pr[iva_l]=fit_lr(Xr,y,itr,iva) |
| 77 | + p74[iva_l],_=semantic_prior_predict(f.iloc[itr],f.iloc[iva]) |
| 78 | + uns[iva_l]=unseen_mask(support_key,itr,iva) |
| 79 | + return p75,p74,pr,uns |
| 80 | + |
| 81 | +def select_weights(y,p75,p74,pr,uns): |
| 82 | + best_base=(1e99,None); best_gate=(1e99,None) |
| 83 | + for a in W74: |
| 84 | + q=compose(p75,p74,pr,uns,a,0.0); v=ll(y,q) |
| 85 | + if v<best_base[0]: best_base=(v,{'w74':float(a),'wr':0.0}) |
| 86 | + for r in WR: |
| 87 | + if a+r>.80: continue |
| 88 | + q=compose(p75,p74,pr,uns,a,r); v=ll(y,q) |
| 89 | + if v<best_gate[0]: best_gate=(v,{'w74':float(a),'wr':float(r)}) |
| 90 | + return best_base,best_gate |
| 91 | + |
| 92 | +def run(a): |
| 93 | + f=load_training(a.features,a.labels).reset_index(drop=True) |
| 94 | + cache={sid:load_transcript(a.transcripts/f'{sid}.csv') for sid in f.session_id.astype(str).unique()} |
| 95 | + rt=[]; rz=[] |
| 96 | + for i,r in f.iterrows(): |
| 97 | + t,z=segmented_control(cache[str(r.session_id)],str(r.learning_objective),'related'); rt.append(t); rz.append(z) |
| 98 | + if (i+1)%2500==0: print('rows',i+1,flush=True) |
| 99 | + X75=build_v75(f,cache); Xr=build_control(rt,rz); y=f.target.to_numpy(int) |
| 100 | + obj=(f.learning_objective_id if 'learning_objective_id' in f else f.learning_objective).astype(str).to_numpy() |
| 101 | + support=f.learning_objective.astype(str).to_numpy(); sess=f.session_id.astype(str).to_numpy() |
| 102 | + fam=f.learning_objective.astype(str).map(obj_family).astype(str).to_numpy() |
| 103 | + style=KMeans(n_clusters=5,random_state=137,n_init=10).fit(style_matrix(f,cache)).labels_.astype(str) |
| 104 | + worlds={ |
| 105 | + 'objective_cold':(folds_from_groups(obj),obj), |
| 106 | + 'session_cold':(folds_from_groups(sess),sess), |
| 107 | + 'objective_family_cold':(folds_from_groups(fam),fam), |
| 108 | + 'style_cold':(folds_from_groups(style),style), |
| 109 | + 'mixed_support':(mixed_support_folds(obj,sess),obj), |
| 110 | + } |
| 111 | + out={'primary':'restore V74 objective prior + V75 state + lawful unsupported RELATED','worlds':{}} |
| 112 | + gains=[]; gate_gains=[] |
| 113 | + for name,(sp,inner_groups) in worlds.items(): |
| 114 | + P75=np.zeros(len(y)); P74=np.zeros(len(y)); PG=np.zeros(len(y)); PB=np.zeros(len(y)); PR=np.zeros(len(y)); U=np.zeros(len(y),bool) |
| 115 | + folds=[] |
| 116 | + for k,(tr,va) in enumerate(sp,1): |
| 117 | + p75=fit_lr(X75,y,tr,va); pr=fit_lr(Xr,y,tr,va); p74,_=semantic_prior_predict(f.iloc[tr],f.iloc[va]) |
| 118 | + uns=unseen_mask(support,tr,va) |
| 119 | + i75,i74,ir,iu=inner_oof(f,X75,Xr,y,tr,inner_groups,support) |
| 120 | + base,gate=select_weights(y[tr],i75,i74,ir,iu) |
| 121 | + qb=compose(p75,p74,pr,uns,base[1]['w74'],0.0) |
| 122 | + qg=compose(p75,p74,pr,uns,gate[1]['w74'],gate[1]['wr']) |
| 123 | + P75[va]=p75; P74[va]=p74; PR[va]=pr; PB[va]=qb; PG[va]=qg; U[va]=uns |
| 124 | + folds.append({'fold':k,'rows':int(len(va)),'unseen_fraction':float(uns.mean()), |
| 125 | + 'v75':ll(y[va],p75),'v74':ll(y[va],p74),'prior_state':ll(y[va],qb),'prior_state_related':ll(y[va],qg), |
| 126 | + 'selected_prior':base[1],'selected_gate':gate[1]}) |
| 127 | + print(name,folds[-1],flush=True) |
| 128 | + rec={'v75':ll(y,P75),'v74':ll(y,P74),'related':ll(y,PR),'prior_state':ll(y,PB),'prior_state_related':ll(y,PG), |
| 129 | + 'gain_prior_state_vs_v75':ll(y,P75)-ll(y,PB),'gain_gate_vs_v75':ll(y,P75)-ll(y,PG), |
| 130 | + 'gain_gate_vs_prior_state':ll(y,PB)-ll(y,PG),'unseen_fraction':float(U.mean()),'folds':folds} |
| 131 | + out['worlds'][name]=rec; gains.append(rec['gain_prior_state_vs_v75']); gate_gains.append(rec['gain_gate_vs_v75']) |
| 132 | + print(name,'SUMMARY',rec,flush=True) |
| 133 | + # Promotion is based on broad lawful transfer, not objective-cold alone. |
| 134 | + key=['session_cold','objective_cold','objective_family_cold','style_cold','mixed_support'] |
| 135 | + g=np.asarray([out['worlds'][x]['gain_gate_vs_v75'] for x in key]) |
| 136 | + gp=np.asarray([out['worlds'][x]['gain_prior_state_vs_v75'] for x in key]) |
| 137 | + mixed=out['worlds']['mixed_support']['gain_gate_vs_v75']; session=out['worlds']['session_cold']['gain_gate_vs_v75'] |
| 138 | + promote=(g.mean()>=.0015 and mixed>=.0010 and session>=-.0005 and g.min()>=-.0010) |
| 139 | + out['decision']={'mean_gate_gain':float(g.mean()),'mean_prior_state_gain':float(gp.mean()),'mixed_support_gain':float(mixed), |
| 140 | + 'worst_gate_gain':float(g.min()),'verdict':'PROMOTE_V105_COMPOSITION' if promote else 'DO_NOT_PROMOTE_V105', |
| 141 | + 'precommit':'promote iff mean five-world gate gain >= .0015, mixed-support >= .0010, session-cold >= -.0005, no world worse than -.0010'} |
| 142 | + Path(a.out).write_text(json.dumps(out,indent=2)); print(json.dumps(out,indent=2),flush=True) |
| 143 | +if __name__=='__main__': |
| 144 | + p=argparse.ArgumentParser(); p.add_argument('--features',type=Path,required=True); p.add_argument('--labels',type=Path,required=True); p.add_argument('--transcripts',type=Path,required=True); p.add_argument('--out',default='v105_prior_state_composition.json'); run(p.parse_args()) |
0 commit comments