Skip to content

Commit bfc7639

Browse files
committed
trace ace: add V92 latent-state decomposition
1 parent 4701368 commit bfc7639

1 file changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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

Comments
 (0)