|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""V92: latent student-state decomposition. |
| 3 | +
|
| 4 | +Hypothesis: the transcript is a noisy measurement instrument. Predict post-test |
| 5 | +correctness from (a) whole-session V75, (b) non-target local ability, (c) target |
| 6 | +EvidenceEvents, and (d) objective difficulty, with explicit latent contrasts. |
| 7 | +All base predictions are objective-cold OOF; the meta-combiner is cross-fitted |
| 8 | +across the same held-out objective folds. Ablations test which latent components |
| 9 | +actually pay rent. |
| 10 | +""" |
| 11 | +from __future__ import annotations |
| 12 | +import argparse, json |
| 13 | +from pathlib import Path |
| 14 | +import numpy as np |
| 15 | +from scipy.sparse import hstack |
| 16 | +from sklearn.feature_extraction.text import HashingVectorizer |
| 17 | +from sklearn.linear_model import LogisticRegression |
| 18 | +from sklearn.metrics import log_loss |
| 19 | +from sklearn.model_selection import GroupKFold |
| 20 | + |
| 21 | +from v71_mastery_events import load_transcript |
| 22 | +from v75_canonical_trajectory import load_training, SEED |
| 23 | +from v81_target_segment_phase import choose_target_segment |
| 24 | +from v85_evidence_state import evidence_events, render, nums, build_sparse, build_v75, oof |
| 25 | +from v89_relative_ability_composition import non_target_ability, build_ability |
| 26 | + |
| 27 | +EPS=1e-5 |
| 28 | + |
| 29 | +def logit(p): |
| 30 | + p=np.clip(np.asarray(p,float),EPS,1-EPS) |
| 31 | + return np.log(p/(1-p)) |
| 32 | + |
| 33 | +def objective_matrix(texts): |
| 34 | + w=HashingVectorizer(n_features=2**16,alternate_sign=False,norm='l2',ngram_range=(1,2),lowercase=True) |
| 35 | + c=HashingVectorizer(n_features=2**16,alternate_sign=False,norm='l2',analyzer='char_wb',ngram_range=(3,5),lowercase=True) |
| 36 | + return hstack([w.transform(texts),c.transform(texts)],format='csr') |
| 37 | + |
| 38 | +def base_meta(p0,pa,pe,pd): |
| 39 | + l0,la,le,ld=map(logit,[p0,pa,pe,pd]) |
| 40 | + return np.c_[ |
| 41 | + l0,la,le,ld, |
| 42 | + la-ld, # ability relative to objective difficulty |
| 43 | + le-la, # target-specific deviation from general ability |
| 44 | + le-ld, # target evidence relative to difficulty |
| 45 | + np.abs(le-la), |
| 46 | + np.abs(la-ld), |
| 47 | + l0-la, |
| 48 | + l0-le, |
| 49 | + la*ld, |
| 50 | + le*la, |
| 51 | + ] |
| 52 | + |
| 53 | +def crossfit_meta(y,splits,p0,pa,pe,pd,cols=None,C=0.1): |
| 54 | + X=base_meta(p0,pa,pe,pd) |
| 55 | + if cols is not None: X=X[:,cols] |
| 56 | + q=np.zeros(len(y)); fold_rows=[] |
| 57 | + for k,(tr,va) in enumerate(splits): |
| 58 | + # The base inputs are themselves OOF predictions. Meta fit is restricted |
| 59 | + # to other objective-cold folds and scored on untouched held-out objectives. |
| 60 | + m=LogisticRegression(C=C,max_iter=1000,solver='lbfgs',random_state=SEED) |
| 61 | + m.fit(X[tr],y[tr]); q[va]=m.predict_proba(X[va])[:,1] |
| 62 | + fold_rows.append({'fold':k+1,'logloss':float(log_loss(y[va],np.clip(q[va],EPS,1-EPS)))}) |
| 63 | + return np.clip(q,EPS,1-EPS),fold_rows |
| 64 | + |
| 65 | +def run(a): |
| 66 | + f=load_training(a.features,a.labels).reset_index(drop=True) |
| 67 | + cache={sid:load_transcript(a.transcripts/f'{sid}.csv') for sid in f.session_id.astype(str).unique()} |
| 68 | + et=[]; ez=[]; at=[]; az=[] |
| 69 | + for i,r in f.iterrows(): |
| 70 | + d=cache[str(r.session_id)]; obj=str(r.learning_objective) |
| 71 | + seg,_=choose_target_segment(d,obj); ev=evidence_events(seg,obj) |
| 72 | + et.append(render(ev,obj,ablate=True)); ez.append(nums(ev,ablate=True)) |
| 73 | + t,z=non_target_ability(d,obj); at.append(t); az.append(z) |
| 74 | + if (i+1)%2500==0: print('rows',i+1) |
| 75 | + |
| 76 | + y=f.target.to_numpy(int) |
| 77 | + groups=(f.learning_objective_id if 'learning_objective_id' in f else f.learning_objective).astype(str).to_numpy() |
| 78 | + splits=list(GroupKFold(5).split(np.zeros(len(y)),y,groups)) |
| 79 | + |
| 80 | + X0=build_v75(f,cache) |
| 81 | + Xa=build_ability(at,az) |
| 82 | + Xe=build_sparse(et,ez,'EVIDENCE_ABL') |
| 83 | + Xd=objective_matrix(f.learning_objective.fillna('').astype(str).tolist()) |
| 84 | + p0,_=oof(X0,y,splits,'V75') |
| 85 | + pa,_=oof(Xa,y,splits,'ABILITY') |
| 86 | + pe,_=oof(Xe,y,splits,'TARGET') |
| 87 | + pd,_=oof(Xd,y,splits,'DIFFICULTY') |
| 88 | + |
| 89 | + # Full latent comparison and causal component ablations. |
| 90 | + full,folds=crossfit_meta(y,splits,p0,pa,pe,pd) |
| 91 | + # Column definitions from base_meta: 0 V75,1 ability,2 target,3 difficulty, |
| 92 | + # 4 ability-difficulty,5 target-ability,6 target-difficulty,... |
| 93 | + no_ability,_=crossfit_meta(y,splits,p0,pa,pe,pd,cols=[0,2,3,6,10]) |
| 94 | + no_difficulty,_=crossfit_meta(y,splits,p0,pa,pe,pd,cols=[0,1,2,5,7,9,10,12]) |
| 95 | + no_target,_=crossfit_meta(y,splits,p0,pa,pe,pd,cols=[0,1,3,4,8,9,11]) |
| 96 | + linear_only,_=crossfit_meta(y,splits,p0,pa,pe,pd,cols=[0,1,2,3]) |
| 97 | + |
| 98 | + # Reproduce V89-style cross-fitted convex composition as a strong control. |
| 99 | + fold=np.empty(len(y),int) |
| 100 | + for k,(_,va) in enumerate(splits): fold[va]=k |
| 101 | + blend=np.zeros(len(y)); selected=[] |
| 102 | + grid=np.arange(0,0.61,0.1) |
| 103 | + for k,(_,va) in enumerate(splits): |
| 104 | + tune=np.where(fold!=k)[0]; best=None |
| 105 | + for we in grid: |
| 106 | + for wa in grid: |
| 107 | + if we+wa>.8: continue |
| 108 | + p=np.clip((1-we-wa)*p0[tune]+we*pe[tune]+wa*pa[tune],EPS,1-EPS) |
| 109 | + ll=float(log_loss(y[tune],p)) |
| 110 | + if best is None or ll<best['ll']: best={'we':float(we),'wa':float(wa),'ll':ll} |
| 111 | + we,wa=best['we'],best['wa'] |
| 112 | + blend[va]=np.clip((1-we-wa)*p0[va]+we*pe[va]+wa*pa[va],EPS,1-EPS) |
| 113 | + selected.append({'fold':k+1,**best}) |
| 114 | + |
| 115 | + scores={ |
| 116 | + 'v75':float(log_loss(y,p0)), |
| 117 | + 'ability':float(log_loss(y,pa)), |
| 118 | + 'target':float(log_loss(y,pe)), |
| 119 | + 'difficulty':float(log_loss(y,pd)), |
| 120 | + 'v89_control':float(log_loss(y,blend)), |
| 121 | + 'latent_full':float(log_loss(y,full)), |
| 122 | + 'no_ability':float(log_loss(y,no_ability)), |
| 123 | + 'no_difficulty':float(log_loss(y,no_difficulty)), |
| 124 | + 'no_target':float(log_loss(y,no_target)), |
| 125 | + 'linear_only':float(log_loss(y,linear_only)), |
| 126 | + } |
| 127 | + scores['gain_vs_v89']=scores['v89_control']-scores['latent_full'] |
| 128 | + scores['gain_vs_v75']=scores['v75']-scores['latent_full'] |
| 129 | + # Require a meaningful improvement and positive ablation support. |
| 130 | + causal={ |
| 131 | + 'ability_value':scores['no_ability']-scores['latent_full'], |
| 132 | + 'difficulty_value':scores['no_difficulty']-scores['latent_full'], |
| 133 | + 'target_value':scores['no_target']-scores['latent_full'], |
| 134 | + 'contrast_value':scores['linear_only']-scores['latent_full'], |
| 135 | + } |
| 136 | + decision='PROMOTE_LATENT_DECOMPOSITION' if scores['gain_vs_v89']>=.002 and sum(v>0 for v in causal.values())>=2 else ('PARTIAL' if scores['gain_vs_v89']>0 else 'REJECT') |
| 137 | + out={'primary':'objective-cold-crossfitted','scores':scores,'causal_ablation_values':causal,'folds':folds,'v89_selected':selected,'decision':decision} |
| 138 | + Path(a.out).write_text(json.dumps(out,indent=2)); print(json.dumps(out,indent=2)) |
| 139 | + |
| 140 | +if __name__=='__main__': |
| 141 | + 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='v92_latent_state_decomposition.json'); run(p.parse_args()) |
0 commit comments