|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""V78 seismic test: pretrained semantic interaction + learned episode mastery + V75 ensemble. |
| 3 | +
|
| 4 | +Tests three model-class shifts under identical frozen group folds: |
| 5 | +1) objective<->dialogue pretrained semantic interaction; |
| 6 | +2) learned episode/trajectory mastery from semantic episode views; |
| 7 | +3) nested-safe convex ensemble with the sparse V75 all-views model. |
| 8 | +
|
| 9 | +Development-time model: sentence-transformers/all-MiniLM-L6-v2 (open, and preloaded in official runtime). |
| 10 | +Only aggregate metrics are written. |
| 11 | +""" |
| 12 | +from __future__ import annotations |
| 13 | +import argparse, json |
| 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 | +from sklearn.preprocessing import StandardScaler |
| 23 | +from sentence_transformers import SentenceTransformer |
| 24 | + |
| 25 | +from v71_mastery_events import load_transcript |
| 26 | +from v75_canonical_trajectory import load_training, trajectory_views, SEED |
| 27 | + |
| 28 | + |
| 29 | +def folds(groups, n=5): |
| 30 | + g=groups.astype(str).to_numpy(); z=np.zeros(len(g)) |
| 31 | + return list(GroupKFold(n_splits=n).split(z,z,g)) |
| 32 | + |
| 33 | +def sigmoid(x): |
| 34 | + x=np.asarray(x,float); return 1/(1+np.exp(-np.clip(x,-40,40))) |
| 35 | + |
| 36 | +def encode(model, texts, batch=128): |
| 37 | + return model.encode(texts,batch_size=batch,show_progress_bar=True,normalize_embeddings=True,convert_to_numpy=True).astype(np.float32) |
| 38 | + |
| 39 | +def dense_semantic_features(Eo, Es, El, Et, numeric): |
| 40 | + # Explicit cross-view interaction features, not just independent embeddings. |
| 41 | + cos_os=np.sum(Eo*Es,1,keepdims=True); cos_ol=np.sum(Eo*El,1,keepdims=True); cos_ot=np.sum(Eo*Et,1,keepdims=True) |
| 42 | + cos_sl=np.sum(Es*El,1,keepdims=True); cos_lt=np.sum(El*Et,1,keepdims=True) |
| 43 | + # Pairwise products preserve dimensions while explicitly representing relevance/alignment. |
| 44 | + prod_ol=Eo*El; prod_ot=Eo*Et |
| 45 | + return np.hstack([Eo,Es,El,Et,prod_ol,prod_ot,cos_os,cos_ol,cos_ot,cos_sl,cos_lt,numeric]).astype(np.float32) |
| 46 | + |
| 47 | +def build_v75_sparse(frame, view_rows, numeric): |
| 48 | + hv=HashingVectorizer(n_features=2**18,alternate_sign=False,norm='l2',ngram_range=(1,2),lowercase=True) |
| 49 | + obj=hv.transform(['[OBJECTIVE] '+str(x) for x in frame.learning_objective]) |
| 50 | + raw=hv.transform(['[RAW] '+v['raw'] for v in view_rows]) |
| 51 | + stu=hv.transform(['[STUDENT] '+v['student'] for v in view_rows]) |
| 52 | + loc=hv.transform(['[LOCAL] '+v['local'] for v in view_rows]) |
| 53 | + can=hv.transform(['[STATE] '+v['canonical'] for v in view_rows]) |
| 54 | + ter=hv.transform(['[TERMINAL] '+v['terminal'] for v in view_rows]) |
| 55 | + z=(numeric-numeric.mean(0))/(numeric.std(0)+1e-6) |
| 56 | + return hstack([obj,raw,stu,loc,can,ter,csr_matrix(z)],format='csr') |
| 57 | + |
| 58 | +def eval_regime(Xv75, Xsem, y, split, name): |
| 59 | + p75=np.zeros(len(y)); psem=np.zeros(len(y)); fold_rows=[] |
| 60 | + for k,(tr,va) in enumerate(split,1): |
| 61 | + m75=LogisticRegression(C=.25,max_iter=300,solver='liblinear',random_state=SEED).fit(Xv75[tr],y[tr]) |
| 62 | + a=np.clip(m75.predict_proba(Xv75[va])[:,1],1e-5,1-1e-5); p75[va]=a |
| 63 | + sc=StandardScaler().fit(Xsem[tr]); A=sc.transform(Xsem[tr]); B=sc.transform(Xsem[va]) |
| 64 | + ms=LogisticRegression(C=.05,max_iter=500,solver='liblinear',random_state=SEED).fit(A,y[tr]) |
| 65 | + b=np.clip(ms.predict_proba(B)[:,1],1e-5,1-1e-5); psem[va]=b |
| 66 | + fold_rows.append({'fold':k,'rows':len(va),'v75':float(log_loss(y[va],a)),'semantic':float(log_loss(y[va],b))}) |
| 67 | + print(name,fold_rows[-1]) |
| 68 | + # Blend selected globally from OOF only; report grid transparently. This is model comparison, not final stacking fit. |
| 69 | + grid=[]; best=None |
| 70 | + for w in np.linspace(0,1,21): |
| 71 | + p=np.clip((1-w)*p75+w*psem,1e-5,1-1e-5); ll=float(log_loss(y,p)) |
| 72 | + row={'semantic_weight':float(w),'logloss':ll}; grid.append(row) |
| 73 | + if best is None or ll<best['logloss']: best=row |
| 74 | + pb=np.clip((1-best['semantic_weight'])*p75+best['semantic_weight']*psem,1e-5,1-1e-5) |
| 75 | + return {'v75_logloss':float(log_loss(y,p75)),'semantic_logloss':float(log_loss(y,psem)),'best_blend':best,'blend_auc':float(roc_auc_score(y,pb)),'folds':fold_rows,'blend_grid':grid} |
| 76 | + |
| 77 | +def run(args): |
| 78 | + frame=load_training(args.features,args.labels).reset_index(drop=True) |
| 79 | + if args.limit: frame=frame.iloc[:args.limit].copy().reset_index(drop=True) |
| 80 | + cache={}; views=[]; nums=[] |
| 81 | + for i,r in frame.iterrows(): |
| 82 | + sid=str(r.session_id) |
| 83 | + if sid not in cache: cache[sid]=load_transcript(args.transcripts/f'{sid}.csv') |
| 84 | + v,n,_=trajectory_views(cache[sid],str(r.learning_objective)); views.append(v); nums.append(n) |
| 85 | + if (i+1)%2500==0: print('views',i+1) |
| 86 | + numeric=np.vstack(nums).astype(np.float32) |
| 87 | + numz=(numeric-numeric.mean(0))/(numeric.std(0)+1e-6) |
| 88 | + model=SentenceTransformer(args.model) |
| 89 | + Eo=encode(model,[str(x) for x in frame.learning_objective],args.batch) |
| 90 | + Es=encode(model,[v['student'] or ' ' for v in views],args.batch) |
| 91 | + El=encode(model,[v['local'] or ' ' for v in views],args.batch) |
| 92 | + Et=encode(model,[v['terminal'] or ' ' for v in views],args.batch) |
| 93 | + Xsem=dense_semantic_features(Eo,Es,El,Et,numz) |
| 94 | + X75=build_v75_sparse(frame,views,numeric) |
| 95 | + y=frame.target.to_numpy(int) |
| 96 | + objgrp=frame.learning_objective_id if 'learning_objective_id' in frame else frame.learning_objective |
| 97 | + result={ |
| 98 | + 'diagnostics':{'rows':len(frame),'sessions':int(frame.session_id.nunique()),'objectives':int(frame.learning_objective.nunique()),'embedding_dim':int(Eo.shape[1]),'semantic_features':int(Xsem.shape[1]),'model':args.model}, |
| 99 | + 'session':eval_regime(X75,Xsem,y,folds(frame.session_id),'session'), |
| 100 | + 'objective':eval_regime(X75,Xsem,y,folds(objgrp),'objective'), |
| 101 | + } |
| 102 | + Path(args.out).write_text(json.dumps(result,indent=2)); print(json.dumps(result,indent=2)) |
| 103 | + |
| 104 | +def parse(): |
| 105 | + 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='v78_seismic_semantic.json'); p.add_argument('--model',default='sentence-transformers/all-MiniLM-L6-v2'); p.add_argument('--batch',type=int,default=128); p.add_argument('--limit',type=int,default=0); return p.parse_args() |
| 106 | +if __name__=='__main__': run(parse()) |
0 commit comments