|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""V104: single-separator test for session-level applicability. |
| 3 | +
|
| 4 | +V102 localized most oracle headroom to session resolution, while V103's multivariate |
| 5 | +session regressor overfit/regressed. This test asks the cheapest next question: does one |
| 6 | +runtime-visible session statistic lawfully separate sessions that want MORE vs LESS |
| 7 | +RELATED than frozen V97's 0.35 blend? |
| 8 | +
|
| 9 | +Selection is nested. Outer objective-cold folds are untouched. Inside each outer-train, |
| 10 | +objective-grouped OOF expert predictions are used to choose exactly one feature, |
| 11 | +threshold, direction, and two blend weights. The chosen rule is then applied unchanged |
| 12 | +to the outer validation fold. |
| 13 | +""" |
| 14 | +from __future__ import annotations |
| 15 | +import argparse, json |
| 16 | +from pathlib import Path |
| 17 | +import numpy as np |
| 18 | +from sklearn.linear_model import LogisticRegression |
| 19 | +from sklearn.metrics import log_loss |
| 20 | +from sklearn.model_selection import GroupKFold |
| 21 | +from v71_mastery_events import load_transcript, tokens |
| 22 | +from v75_canonical_trajectory import load_training, SEED |
| 23 | +from v85_evidence_state import build_v75 |
| 24 | +from v93_shift_robust_validation import folds_from_groups |
| 25 | +from v94_related_control import segmented_control, build_control |
| 26 | + |
| 27 | +EPS=1e-5 |
| 28 | +WEIGHTS=np.array([0.0,0.15,0.25,0.35,0.45,0.60]) |
| 29 | +FEATURE_NAMES=['n_rows','p0_mean','pr_mean','abs_disagree_mean','disagree_std','signed_disagree_mean', |
| 30 | + 'frac_abs_gt_03','frac_abs_gt_06','frac_abs_gt_10','p0_conf','pr_conf','turns','obj_len','n_objectives'] |
| 31 | + |
| 32 | + |
| 33 | +def fit_expert(X,y,tr,va): |
| 34 | + m=LogisticRegression(C=.25,max_iter=300,solver='liblinear',random_state=SEED).fit(X[tr],y[tr]) |
| 35 | + return np.clip(m.predict_proba(X[va])[:,1],EPS,1-EPS) |
| 36 | + |
| 37 | +def ll(y,p): return float(log_loss(y,np.clip(p,EPS,1-EPS),labels=[0,1])) |
| 38 | + |
| 39 | +def sf(p0,pr,idx,turns,obj_len,nobj): |
| 40 | + a=p0[idx]; b=pr[idx]; d=b-a |
| 41 | + return np.array([len(idx),a.mean(),b.mean(),np.mean(np.abs(d)),np.std(d),np.mean(d), |
| 42 | + np.mean(np.abs(d)>.03),np.mean(np.abs(d)>.06),np.mean(np.abs(d)>.10), |
| 43 | + np.mean(np.abs(a-.5)),np.mean(np.abs(b-.5)),np.mean(turns[idx]),np.mean(obj_len[idx]),float(nobj)],float) |
| 44 | + |
| 45 | +def session_table(y,p0,pr,sess,obj,turns,obj_len,indices): |
| 46 | + rows=[] |
| 47 | + for s in np.unique(sess[indices]): |
| 48 | + loc_global=indices[np.where(sess[indices]==s)[0]] |
| 49 | + z=sf(p0,pr,loc_global,turns,obj_len,len(np.unique(obj[loc_global]))) |
| 50 | + rows.append((s,loc_global,z)) |
| 51 | + return rows |
| 52 | + |
| 53 | +def choose_rule(y,p0,pr,rows): |
| 54 | + # Predeclared quantile thresholds; choose one feature + threshold + direction + two weights. |
| 55 | + Z=np.vstack([r[2] for r in rows]) |
| 56 | + best=(1e99,None) |
| 57 | + for j,name in enumerate(FEATURE_NAMES): |
| 58 | + vals=Z[:,j] |
| 59 | + for q in (0.2,0.35,0.5,0.65,0.8): |
| 60 | + t=float(np.quantile(vals,q)) |
| 61 | + for direction in ('low_more','high_more'): |
| 62 | + for w_more in (0.45,0.60): |
| 63 | + for w_less in (0.0,0.15,0.25,0.35): |
| 64 | + pred=np.empty(len(y)); mask=np.zeros(len(y),bool) |
| 65 | + for _,ix,z in rows: |
| 66 | + more=(z[j] <= t) if direction=='low_more' else (z[j] > t) |
| 67 | + w=w_more if more else w_less |
| 68 | + pred[ix]=(1-w)*p0[ix]+w*pr[ix]; mask[ix]=True |
| 69 | + v=ll(y[mask],pred[mask]) |
| 70 | + if v<best[0]: best=(v,{'feature':name,'feature_index':j,'threshold':t,'direction':direction,'w_more':w_more,'w_less':w_less}) |
| 71 | + return best |
| 72 | + |
| 73 | +def apply_rule(p0,pr,rows,rule,n): |
| 74 | + q=np.empty(n) |
| 75 | + j=rule['feature_index']; t=rule['threshold']; direction=rule['direction'] |
| 76 | + weights=[] |
| 77 | + for _,ix,z in rows: |
| 78 | + more=(z[j] <= t) if direction=='low_more' else (z[j] > t) |
| 79 | + w=rule['w_more'] if more else rule['w_less']; q[ix]=(1-w)*p0[ix]+w*pr[ix]; weights.extend([w]*len(ix)) |
| 80 | + return q,np.asarray(weights) |
| 81 | + |
| 82 | +def run(a): |
| 83 | + f=load_training(a.features,a.labels).reset_index(drop=True) |
| 84 | + cache={sid:load_transcript(a.transcripts/f'{sid}.csv') for sid in f.session_id.astype(str).unique()} |
| 85 | + rt=[]; rz=[] |
| 86 | + for i,r in f.iterrows(): |
| 87 | + t,z=segmented_control(cache[str(r.session_id)],str(r.learning_objective),'related'); rt.append(t); rz.append(z) |
| 88 | + if (i+1)%2500==0: print('rows',i+1,flush=True) |
| 89 | + X0=build_v75(f,cache); Xr=build_control(rt,rz); y=f.target.to_numpy(int) |
| 90 | + obj=(f.learning_objective_id if 'learning_objective_id' in f else f.learning_objective).astype(str).to_numpy() |
| 91 | + sess=f.session_id.astype(str).to_numpy(); text=f.learning_objective.astype(str).to_numpy() |
| 92 | + turns=np.asarray([len(cache[str(s)]) for s in sess],float); obj_len=np.asarray([len(tokens(x)) for x in text],float) |
| 93 | + p97_all=np.zeros(len(y)); p104_all=np.zeros(len(y)); folds=[] |
| 94 | + for k,(tr,va) in enumerate(folds_from_groups(obj),1): |
| 95 | + p0=np.zeros(len(y)); pr=np.zeros(len(y)) |
| 96 | + p0[va]=fit_expert(X0,y,tr,va); pr[va]=fit_expert(Xr,y,tr,va) |
| 97 | + ip0=np.zeros(len(y)); ipr=np.zeros(len(y)) |
| 98 | + inner=GroupKFold(min(3,len(np.unique(obj[tr])))).split(np.zeros(len(tr)),y[tr],obj[tr]) |
| 99 | + for itr_l,iva_l in inner: |
| 100 | + itr=tr[itr_l]; iva=tr[iva_l] |
| 101 | + ip0[iva]=fit_expert(X0,y,itr,iva); ipr[iva]=fit_expert(Xr,y,itr,iva) |
| 102 | + train_rows=session_table(y,ip0,ipr,sess,obj,turns,obj_len,tr) |
| 103 | + _,rule=choose_rule(y,ip0,ipr,train_rows) |
| 104 | + va_rows=session_table(y,p0,pr,sess,obj,turns,obj_len,va) |
| 105 | + q=np.empty(len(y)); qva,w=apply_rule(p0,pr,va_rows,rule,len(y)); q[va]=qva[va] |
| 106 | + q97=np.clip(.65*p0[va]+.35*pr[va],EPS,1-EPS); q104=np.clip(q[va],EPS,1-EPS) |
| 107 | + p97_all[va]=q97; p104_all[va]=q104 |
| 108 | + folds.append({'fold':k,'v97':ll(y[va],q97),'v104':ll(y[va],q104),'gain':ll(y[va],q97)-ll(y[va],q104), |
| 109 | + 'rule':rule,'mean_weight':float(np.mean(w))}) |
| 110 | + print(folds[-1],flush=True) |
| 111 | + l97=ll(y,p97_all); l104=ll(y,p104_all); gain=l97-l104 |
| 112 | + verdict='PROMOTE_TO_FOUR_WORLD_V104' if gain>=.002 else ('REFINE_SINGLE_SEPARATOR' if gain>=.0005 else 'SUPPRESS_SINGLE_SEPARATOR') |
| 113 | + out={'primary':'nested single session separator','v97':l97,'v104':l104,'gain_vs_v97':gain,'folds':folds, |
| 114 | + 'decision':{'verdict':verdict,'precommit':'promote only if objective-cold gain vs frozen V97 >= 0.002'}} |
| 115 | + Path(a.out).write_text(json.dumps(out,indent=2)); print(json.dumps(out,indent=2),flush=True) |
| 116 | +if __name__=='__main__': |
| 117 | + 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='v104_single_session_separator.json'); run(p.parse_args()) |
0 commit comments