|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""V85: RGRS EvidenceEvent -> knowledge-state separator. |
| 3 | +
|
| 4 | +Compares three objective-cold arms: |
| 5 | +A0: V75 whole-session representation baseline. |
| 6 | +A1: explicit objective-conditioned EvidenceEvent IR with assistance/independence tags. |
| 7 | +A2: same EvidenceEvent IR with assistance/independence tags ablated. |
| 8 | +
|
| 9 | +Primary decision: A1 must beat A0 by >=0.003 log loss and materially beat A2 to count |
| 10 | +as a representation-level breakthrough. Otherwise retain as negative/conditional law. |
| 11 | +""" |
| 12 | +from __future__ import annotations |
| 13 | +import argparse, json, re |
| 14 | +from pathlib import Path |
| 15 | +import numpy as np |
| 16 | +import pandas as pd |
| 17 | +from scipy.sparse import csr_matrix, hstack |
| 18 | +from sklearn.feature_extraction.text import HashingVectorizer |
| 19 | +from sklearn.linear_model import LogisticRegression |
| 20 | +from sklearn.metrics import log_loss, roc_auc_score |
| 21 | +from sklearn.model_selection import GroupKFold |
| 22 | + |
| 23 | +from v71_mastery_events import load_transcript, tokens, jaccard, char_ngram_overlap |
| 24 | +from v75_canonical_trajectory import load_training, trajectory_views, SEED |
| 25 | +from v81_target_segment_phase import choose_target_segment, phase_for |
| 26 | + |
| 27 | +QUESTION_RE=re.compile(r"\?|\b(?:what|how|why|which|calculate|solve|find|show|explain|tell me|your turn|try|have a go)\b",re.I) |
| 28 | +POS_RE=re.compile(r"\b(?:correct|right|yes|exactly|great|brilliant|well done|good job|nice|perfect)\b",re.I) |
| 29 | +NEG_RE=re.compile(r"\b(?:not quite|incorrect|wrong|try again|check|remember|almost|no[, .])\b",re.I) |
| 30 | +HINT_RE=re.compile(r"\b(?:remember|think about|hint|try|look at|first|start by|let's|lets|together|we can|I'll|i will|let me)\b",re.I) |
| 31 | +SUPPLY_RE=re.compile(r"\b(?:the answer is|it is|equals|so .* is|that gives|we get|you should)\b",re.I) |
| 32 | +ACK_RE=re.compile(r"^(?:ok(?:ay)?|yes|yeah|yep|no|nope|thanks?|thank you|got it|sure|right|mhm|uh huh|great|cool)[.! ]*$",re.I) |
| 33 | + |
| 34 | +PHASE_WEIGHT={'OTHER':.45,'GOAL':.25,'PRIOR':.35,'GUIDED':.55,'INDEPENDENT':1.0,'APPLICATION':1.1} |
| 35 | + |
| 36 | + |
| 37 | +def rel(text,obj): |
| 38 | + return float(max(jaccard(tokens(str(text)),tokens(str(obj))),.5*char_ngram_overlap(str(text),str(obj)))) |
| 39 | + |
| 40 | + |
| 41 | +def substantive(s): |
| 42 | + s=str(s).strip() |
| 43 | + if not s or ACK_RE.match(s): return False |
| 44 | + # preserve short numeric/math answers |
| 45 | + if re.search(r"\d|[=+\-*/%]",s): return True |
| 46 | + return len(tokens(s))>=2 |
| 47 | + |
| 48 | + |
| 49 | +def phase_replay(df): |
| 50 | + ph='OTHER'; out=[] |
| 51 | + for t in df.content.fillna('').astype(str): |
| 52 | + ph=phase_for(t,ph); out.append(ph) |
| 53 | + return out |
| 54 | + |
| 55 | + |
| 56 | +def evidence_events(df,obj): |
| 57 | + d=df.reset_index(drop=True).copy(); phases=phase_replay(d) |
| 58 | + events=[] |
| 59 | + n=len(d) |
| 60 | + for i,row in d.iterrows(): |
| 61 | + if str(row.role).lower()!='student' or not substantive(row.content): continue |
| 62 | + # nearest preceding tutor question/prompt within 4 turns |
| 63 | + qidx=None |
| 64 | + for j in range(i-1,max(-1,i-5),-1): |
| 65 | + if str(d.iloc[j].role).lower()=='tutor' and QUESTION_RE.search(str(d.iloc[j].content)): |
| 66 | + qidx=j; break |
| 67 | + if qidx is None: continue |
| 68 | + q=str(d.iloc[qidx].content); ans=str(row.content) |
| 69 | + # immediate/near tutor feedback after response |
| 70 | + feedback=''; fbidx=None |
| 71 | + for j in range(i+1,min(n,i+4)): |
| 72 | + if str(d.iloc[j].role).lower()=='tutor': feedback=str(d.iloc[j].content); fbidx=j; break |
| 73 | + rq=rel(q,obj); ra=rel(ans,obj); relevance=max(rq,.4*ra) |
| 74 | + # Assistance is derived only from tutor turns after previous student response and before this answer. |
| 75 | + window=' '.join(str(d.iloc[j].content) for j in range(max(0,qidx-2),i) if str(d.iloc[j].role).lower()=='tutor') |
| 76 | + supplied=bool(SUPPLY_RE.search(window)); hinted=bool(HINT_RE.search(window)) |
| 77 | + assistance=1.0 if supplied else (.6 if hinted else 0.0) |
| 78 | + independent=(phases[i] in ('INDEPENDENT','APPLICATION') and assistance<.3) |
| 79 | + pos=bool(POS_RE.search(feedback)); neg=bool(NEG_RE.search(feedback)) |
| 80 | + # canonical state; tutor feedback is auxiliary, not ground truth |
| 81 | + if neg: state='UNRESOLVED_ERROR' |
| 82 | + elif pos and assistance>=.6: state='CORRECT_AFTER_GUIDANCE' |
| 83 | + elif pos and independent: state='INDEPENDENT_CORRECT' |
| 84 | + elif pos: state='SUPPORTED_CORRECT' |
| 85 | + else: state='UNJUDGED_RESPONSE' |
| 86 | + events.append({'i':i,'phase':phases[i],'q':q,'a':ans,'feedback':feedback,'rel':relevance, |
| 87 | + 'assistance':assistance,'independent':independent,'state':state, |
| 88 | + 'position':i/max(1,n-1),'pos':pos,'neg':neg}) |
| 89 | + return events |
| 90 | + |
| 91 | + |
| 92 | +def render(events,obj,ablate=False): |
| 93 | + keep=sorted(events,key=lambda e:(e['rel']*(.4+.6*e['position'])*PHASE_WEIGHT.get(e['phase'],.4)),reverse=True)[:16] |
| 94 | + keep=sorted(keep,key=lambda e:e['i']) |
| 95 | + rows=[] |
| 96 | + for e in keep: |
| 97 | + tags=[f"PHASE={e['phase']}",f"STATE={e['state']}",f"REL={e['rel']:.2f}",f"POS={int(e['pos'])}",f"NEG={int(e['neg'])}"] |
| 98 | + if not ablate: tags += [f"ASSIST={e['assistance']:.1f}",f"INDEP={int(e['independent'])}"] |
| 99 | + rows.append('['+' '.join(tags)+'] [Q] '+e['q']+' [STUDENT] '+e['a']) |
| 100 | + return f"[OBJECTIVE] {obj}\n"+'\n'.join(rows) |
| 101 | + |
| 102 | + |
| 103 | +def nums(events,ablate=False): |
| 104 | + if not events: return np.zeros(22 if not ablate else 16,float) |
| 105 | + E=events; rels=np.array([e['rel'] for e in E]); pos=np.array([e['pos'] for e in E],float); neg=np.array([e['neg'] for e in E],float) |
| 106 | + ind=np.array([e['independent'] for e in E],float); ass=np.array([e['assistance'] for e in E],float); positions=np.array([e['position'] for e in E]) |
| 107 | + app=np.array([e['phase']=='APPLICATION' for e in E],float); late=positions>=.6 |
| 108 | + base=[len(E),rels.mean(),rels.max(),pos.mean(),neg.mean(),positions[pos>0].max() if pos.any() else 0, |
| 109 | + positions[neg>0].max() if neg.any() else 0,pos[late].mean() if late.any() else 0,neg[late].mean() if late.any() else 0, |
| 110 | + app.mean(),(pos*rels).sum()/(rels.sum()+1e-6),(neg*rels).sum()/(rels.sum()+1e-6), |
| 111 | + float(any(e['state']=='UNRESOLVED_ERROR' for e in E[-3:])),float(any(e['state']=='INDEPENDENT_CORRECT' for e in E[-3:])), |
| 112 | + sum(e['state']=='INDEPENDENT_CORRECT' for e in E),sum(e['state']=='CORRECT_AFTER_GUIDANCE' for e in E)] |
| 113 | + if ablate: return np.asarray(base,float) |
| 114 | + extra=[ass.mean(),ass[-3:].mean() if len(ass)>=3 else ass.mean(),ind.mean(),ind[late].mean() if late.any() else 0, |
| 115 | + (pos*ind*rels).sum()/(rels.sum()+1e-6),(neg*(1-ass)*rels).sum()/(rels.sum()+1e-6)] |
| 116 | + return np.asarray(base+extra,float) |
| 117 | + |
| 118 | + |
| 119 | +def build_sparse(texts,Z,prefix): |
| 120 | + hv=HashingVectorizer(n_features=2**18,alternate_sign=False,norm='l2',ngram_range=(1,2),lowercase=True) |
| 121 | + X=hv.transform([f'[{prefix}] '+x for x in texts]); Z=np.vstack(Z); Z=(Z-Z.mean(0))/(Z.std(0)+1e-6) |
| 122 | + return hstack([X,csr_matrix(Z)],format='csr') |
| 123 | + |
| 124 | + |
| 125 | +def build_v75(frame,transcripts): |
| 126 | + rows=[]; ns=[] |
| 127 | + for _,r in frame.iterrows(): |
| 128 | + v,n,_=trajectory_views(transcripts[str(r.session_id)],str(r.learning_objective)); rows.append(v); ns.append(n) |
| 129 | + hv=HashingVectorizer(n_features=2**18,alternate_sign=False,norm='l2',ngram_range=(1,2),lowercase=True) |
| 130 | + parts=[hv.transform([f'[OBJECTIVE] {x}' for x in frame.learning_objective])] |
| 131 | + for k in rows[0].keys(): parts.append(hv.transform([f'[{k.upper()}] '+r[k] for r in rows])) |
| 132 | + Z=np.vstack(ns); Z=(Z-Z.mean(0))/(Z.std(0)+1e-6); parts.append(csr_matrix(Z)) |
| 133 | + return hstack(parts,format='csr') |
| 134 | + |
| 135 | + |
| 136 | +def oof(X,y,splits,name): |
| 137 | + p=np.zeros(len(y)); fs=[] |
| 138 | + for k,(tr,va) in enumerate(splits,1): |
| 139 | + m=LogisticRegression(C=.25,max_iter=300,solver='liblinear',random_state=SEED).fit(X[tr],y[tr]) |
| 140 | + q=np.clip(m.predict_proba(X[va])[:,1],1e-5,1-1e-5); p[va]=q |
| 141 | + row={'fold':k,'logloss':float(log_loss(y[va],q)),'auc':float(roc_auc_score(y[va],q))}; print(name,row); fs.append(row) |
| 142 | + return p,fs |
| 143 | + |
| 144 | + |
| 145 | +def run(a): |
| 146 | + f=load_training(a.features,a.labels).reset_index(drop=True) |
| 147 | + if a.limit: f=f.iloc[:a.limit].copy().reset_index(drop=True) |
| 148 | + cache={} |
| 149 | + for sid in f.session_id.astype(str).unique(): cache[sid]=load_transcript(a.transcripts/f'{sid}.csv') |
| 150 | + full_text=[]; abl_text=[]; full_num=[]; abl_num=[]; meta=[] |
| 151 | + for i,r in f.iterrows(): |
| 152 | + d=cache[str(r.session_id)]; seg,m=choose_target_segment(d,str(r.learning_objective)); ev=evidence_events(seg,str(r.learning_objective)) |
| 153 | + full_text.append(render(ev,str(r.learning_objective),False)); abl_text.append(render(ev,str(r.learning_objective),True)) |
| 154 | + full_num.append(nums(ev,False)); abl_num.append(nums(ev,True)); meta.append({'events':len(ev),**m}) |
| 155 | + if (i+1)%2500==0: print('rows',i+1) |
| 156 | + y=f.target.to_numpy(int); groups=(f.learning_objective_id if 'learning_objective_id' in f else f.learning_objective).astype(str).to_numpy() |
| 157 | + sp=list(GroupKFold(5).split(np.zeros(len(y)),y,groups)) |
| 158 | + X0=build_v75(f,cache); X1=build_sparse(full_text,full_num,'EVIDENCE'); X2=build_sparse(abl_text,abl_num,'EVIDENCE_ABL') |
| 159 | + p0,f0=oof(X0,y,sp,'A0_v75'); p1,f1=oof(X1,y,sp,'A1_evidence'); p2,f2=oof(X2,y,sp,'A2_ablation') |
| 160 | + ll0=float(log_loss(y,p0)); ll1=float(log_loss(y,p1)); ll2=float(log_loss(y,p2)) |
| 161 | + # Also test whether evidence is orthogonal to V75; fixed transparent grid only. |
| 162 | + blends=[]; best=None |
| 163 | + for w in np.linspace(0,1,21): |
| 164 | + q=np.clip((1-w)*p0+w*p1,1e-5,1-1e-5); ll=float(log_loss(y,q)); row={'evidence_weight':float(w),'logloss':ll}; blends.append(row) |
| 165 | + if best is None or ll<best['logloss']: best=row |
| 166 | + gain=ll0-ll1; causal=ll2-ll1 |
| 167 | + if gain>=.003 and causal>=.001: decision='REPRESENTATION_BREAKTHROUGH' |
| 168 | + elif gain>=.001 and causal>0: decision='PROMISING_PARTIAL' |
| 169 | + elif best['logloss']<=ll0-.001: decision='ORTHOGONAL_SIGNAL_ONLY' |
| 170 | + else: decision='REJECT_OR_REFINE_R5' |
| 171 | + out={'primary':'objective-cold','A0_v75':ll0,'A1_evidence':ll1,'A2_ablation':ll2,'gain_vs_A0':gain,'causal_assistance_gain':causal, |
| 172 | + 'best_blend':best,'decision':decision,'folds':{'A0':f0,'A1':f1,'A2':f2}, |
| 173 | + 'event_stats':{'mean_events':float(np.mean([m['events'] for m in meta])),'zero_event_fraction':float(np.mean([m['events']==0 for m in meta]))}} |
| 174 | + Path(a.out).write_text(json.dumps(out,indent=2)); print(json.dumps(out,indent=2)) |
| 175 | + |
| 176 | +if __name__=='__main__': |
| 177 | + 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='v85_evidence_state.json'); p.add_argument('--limit',type=int,default=0); run(p.parse_args()) |
0 commit comments