bastasie commited on
Commit
dccfc0c
·
verified ·
1 Parent(s): c982b31

Upload opi_mathqa_full.py

Browse files
Files changed (1) hide show
  1. opi_mathqa_full.py +268 -0
opi_mathqa_full.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Full OPi-vs-GELU Math-QA experiment on math-ai/StackMathQA.
3
+
4
+ Design:
5
+ * StackMathQA 100K/200K/400K/800K/1600K
6
+ * question-level leakage-safe split
7
+ * SentencePiece BPE tokenizer trained on TRAIN ONLY
8
+ * identical encoder-decoder Transformer baseline vs OPi FFN
9
+ * OPi activation derived from k(x)=log|cos(pi*x/log(x))|
10
+ with a smooth squared-cosine form and a sign-preserving multiplicative gate
11
+ * teacher-forced validation loss/perplexity
12
+ * greedy generation on held-out questions
13
+ * token accuracy, normalized exact match, character F1, ROUGE-L
14
+ * numerical-answer extraction/equivalence when possible
15
+ * checkpointing, JSON report, CSV predictions, SHA-256/SHA-512
16
+
17
+ The full 100K file is 181 MB. StackMathQA also provides 200K, 400K, 800K and
18
+ 1.6M configurations. The official dataset describes Q and A as LaTeX-encoded
19
+ math text and CC-BY-4.0.
20
+ """
21
+ import argparse, csv, hashlib, json, math, os, random, re, time
22
+ from collections import Counter
23
+ from pathlib import Path
24
+ from urllib.request import Request, urlopen
25
+
26
+ import torch
27
+ import torch.nn as nn
28
+ import torch.nn.functional as F
29
+ import sentencepiece as spm
30
+
31
+ SEED=42
32
+ random.seed(SEED); torch.manual_seed(SEED)
33
+
34
+ CONFIGS={
35
+ '100k':'stackmathqa100k', '200k':'stackmathqa200k', '400k':'stackmathqa400k',
36
+ '800k':'stackmathqa800k', '1600k':'stackmathqa1600k'}
37
+ BASE='https://huggingface.co/datasets/math-ai/StackMathQA/resolve/main/data/{cfg}/all.jsonl'
38
+ EXPECTED={
39
+ '100k':'ddf7526cb21600dcf93991e400393eb9adc3549a6077d0cd0b0d37c45e1b037a'}
40
+
41
+ SPECIAL=['<pad>','<bos>','<eos>','<unk>']
42
+
43
+
44
+ def set_seed(seed):
45
+ random.seed(seed); torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)
46
+
47
+
48
+ def sha(path, alg='sha256'):
49
+ h=hashlib.new(alg)
50
+ with open(path,'rb') as f:
51
+ for b in iter(lambda:f.read(1024*1024),b''): h.update(b)
52
+ return h.hexdigest()
53
+
54
+
55
+ def download(url,path):
56
+ path=Path(path); path.parent.mkdir(parents=True,exist_ok=True)
57
+ if path.exists(): return
58
+ req=Request(url,headers={'User-Agent':'OPi-MathQA/2.0'})
59
+ with urlopen(req,timeout=60) as r, open(path,'wb') as f:
60
+ total=int(r.headers.get('Content-Length') or 0); done=0; t=time.time()
61
+ while True:
62
+ b=r.read(4*1024*1024)
63
+ if not b: break
64
+ f.write(b); done+=len(b)
65
+ if total and time.time()-t>2:
66
+ print(f'\rdownload {done/1e6:.1f}/{total/1e6:.1f} MB',end='',flush=True); t=time.time()
67
+ print()
68
+
69
+
70
+ def norm(s):
71
+ s=s.replace('\r',' ').replace('\n',' ')
72
+ s=re.sub(r'\s+',' ',s).strip().lower()
73
+ return s
74
+
75
+
76
+ def canonical_question(q):
77
+ # Remove harmless whitespace differences while preserving LaTeX symbols.
78
+ return norm(q)
79
+
80
+
81
+ def load_rows(path,limit=0):
82
+ out=[]; seen=set(); questions=set()
83
+ with open(path,encoding='utf-8') as f:
84
+ for line in f:
85
+ if not line.strip(): continue
86
+ x=json.loads(line); q=(x.get('Q') or '').strip(); a=(x.get('A') or '').strip()
87
+ if not q or not a: continue
88
+ qk=canonical_question(q); k=(qk,norm(a))
89
+ if k in seen: continue
90
+ seen.add(k); out.append((q,a,x.get('meta',{})))
91
+ questions.add(qk)
92
+ if limit and len(out)>=limit: break
93
+ return out
94
+
95
+
96
+ def split(rows, seed=SEED, test=.10, val=.10):
97
+ # All answers for the same question stay in one split.
98
+ out={'train':[],'val':[],'test':[]}
99
+ for row in rows:
100
+ qk=canonical_question(row[0])
101
+ h=int(hashlib.sha256((str(seed)+'|'+qk).encode()).hexdigest()[:12],16)/float(16**12-1)
102
+ out['test' if h<test else 'val' if h<test+val else 'train'].append(row)
103
+ return out
104
+
105
+
106
+ def train_spm(train_rows, path, vocab_size=16000):
107
+ path=Path(path); path.parent.mkdir(parents=True,exist_ok=True)
108
+ if path.exists(): return
109
+ corpus=path.with_suffix('.txt')
110
+ with open(corpus,'w',encoding='utf-8') as f:
111
+ for q,a,_ in train_rows:
112
+ f.write(q.replace('\n',' ')+'\n'); f.write(a.replace('\n',' ')+'\n')
113
+ spm.SentencePieceTrainer.Train(
114
+ input=str(corpus), model_prefix=str(path.with_suffix('')),
115
+ vocab_size=vocab_size, model_type='bpe', character_coverage=1.0,
116
+ pad_id=0, bos_id=1, eos_id=2, unk_id=3,
117
+ user_defined_symbols=[], hard_vocab_limit=False)
118
+ corpus.unlink(missing_ok=True)
119
+
120
+
121
+ class Tok:
122
+ def __init__(self, model):
123
+ self.sp=spm.SentencePieceProcessor(model_file=str(model))
124
+ self.pad=self.sp.pad_id(); self.bos=self.sp.bos_id(); self.eos=self.sp.eos_id(); self.unk=self.sp.unk_id()
125
+ def enc(self,s,maxlen):
126
+ ids=[self.bos]+self.sp.encode(s,out_type=int)[:maxlen-2]+[self.eos]
127
+ return ids+[self.pad]*(maxlen-len(ids))
128
+ def dec(self,ids):
129
+ ids=[i for i in ids if i not in (self.pad,self.bos)]
130
+ if self.eos in ids: ids=ids[:ids.index(self.eos)]
131
+ return self.sp.decode(ids)
132
+ def __len__(self): return self.sp.get_piece_size()
133
+
134
+
135
+ def batch(rows,tok,maxq,maxa,device):
136
+ q=torch.tensor([tok.enc(r[0],maxq) for r in rows],dtype=torch.long,device=device)
137
+ a=torch.tensor([tok.enc(r[1],maxa) for r in rows],dtype=torch.long,device=device)
138
+ return q,a
139
+
140
+
141
+ class OPiActivation(nn.Module):
142
+ """Sign-preserving activation derived from OPi's phase/kernel.
143
+
144
+ k(x)=log|cos(u(x))|, u(x)=pi*x/log(x).
145
+ We use 0.5*log(cos(u)^2+eps), a smooth numerical version, then use the
146
+ kernel as a multiplicative gate on z so the activation retains sign/information.
147
+ """
148
+ def __init__(self, dim, alpha=.65, beta=.75, offset=1.5, eps=1e-5):
149
+ super().__init__(); self.register_buffer('alpha',torch.tensor(float(alpha))); self.register_buffer('beta',torch.tensor(float(beta))); self.offset=offset; self.eps=eps
150
+ def forward(self,z):
151
+ x=self.offset+F.softplus(z)
152
+ u=math.pi*x/torch.log(x)
153
+ k=.5*torch.log(torch.cos(u).square()+self.eps)
154
+ gate=1.0 + torch.clamp(self.beta, -0.95, 0.95)*torch.tanh(self.alpha*k)
155
+ return z*gate
156
+
157
+
158
+ class FFN(nn.Module):
159
+ def __init__(self,d,ff,opi):
160
+ super().__init__(); self.fc1=nn.Linear(d,ff); self.act=OPiActivation(ff) if opi else nn.GELU(); self.fc2=nn.Linear(ff,d)
161
+ def forward(self,x): return self.fc2(self.act(self.fc1(x)))
162
+
163
+ class EncBlock(nn.Module):
164
+ def __init__(self,d,heads,ff,drop,opi):
165
+ super().__init__(); self.att=nn.MultiheadAttention(d,heads,batch_first=True,dropout=drop); self.n1=nn.LayerNorm(d); self.ff=FFN(d,ff,opi); self.n2=nn.LayerNorm(d); self.drop=nn.Dropout(drop)
166
+ def forward(self,x,padmask):
167
+ y,_=self.att(x,x,x,key_padding_mask=padmask,need_weights=False); x=self.n1(x+self.drop(y)); return self.n2(x+self.drop(self.ff(x)))
168
+
169
+ class DecBlock(nn.Module):
170
+ def __init__(self,d,heads,ff,drop,opi):
171
+ super().__init__(); self.selfatt=nn.MultiheadAttention(d,heads,batch_first=True,dropout=drop); self.cross=nn.MultiheadAttention(d,heads,batch_first=True,dropout=drop); self.n1=nn.LayerNorm(d); self.n2=nn.LayerNorm(d); self.n3=nn.LayerNorm(d); self.ff=FFN(d,ff,opi); self.drop=nn.Dropout(drop)
172
+ def forward(self,x,mem,srcmask,tgtmask):
173
+ y,_=self.selfatt(x,x,x,attn_mask=tgtmask,need_weights=False); x=self.n1(x+self.drop(y))
174
+ y,_=self.cross(x,mem,mem,key_padding_mask=srcmask,need_weights=False); x=self.n2(x+self.drop(y)); return self.n3(x+self.drop(self.ff(x)))
175
+
176
+ class Model(nn.Module):
177
+ def __init__(self,vocab,d=256,heads=8,ff=512,layers=4,drop=.1,opi=False,maxlen=512):
178
+ super().__init__(); self.d=d; self.maxlen=maxlen; self.emb=nn.Embedding(vocab,d,padding_idx=0); self.pos=nn.Embedding(maxlen,d); self.enc=nn.ModuleList([EncBlock(d,heads,ff,drop,opi) for _ in range(layers)]); self.dec=nn.ModuleList([DecBlock(d,heads,ff,drop,opi) for _ in range(layers)]); self.out=nn.Linear(d,vocab)
179
+ def forward(self,src,tgt):
180
+ S=src.size(1); T=tgt.size(1); ps=torch.arange(S,device=src.device)[None,:]; pt=torch.arange(T,device=tgt.device)[None,:]
181
+ mem=self.emb(src)+self.pos(ps); srcmask=src.eq(0)
182
+ for b in self.enc: mem=b(mem,srcmask)
183
+ y=self.emb(tgt)+self.pos(pt); mask=torch.triu(torch.ones(T,T,device=tgt.device,dtype=torch.bool),1)
184
+ for b in self.dec: y=b(y,mem,srcmask,mask)
185
+ return self.out(y)
186
+
187
+
188
+ def lcs(a,b):
189
+ prev=[0]*(len(b)+1)
190
+ for x in a:
191
+ cur=[0]
192
+ for j,y in enumerate(b,1): cur.append(prev[j-1]+1 if x==y else max(prev[j],cur[-1]))
193
+ prev=cur
194
+ return prev[-1]
195
+
196
+
197
+ def charf(ref,hyp):
198
+ rc=Counter(ref); hc=Counter(hyp); ov=sum((rc&hc).values())
199
+ if not ref or not hyp: return 0.0
200
+ p=ov/len(hyp); r=ov/len(ref); return 2*p*r/(p+r) if p+r else 0.0
201
+
202
+ NUM_RE=re.compile(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?',re.I)
203
+
204
+ def nums(s):
205
+ return [round(float(x),10) for x in NUM_RE.findall(s)]
206
+
207
+ def metric_rows(refs,hyps):
208
+ tok_ok=tok_total=0; exact=0; cf=[]; rl=[]; numeq=[]
209
+ for r,h in zip(refs,hyps):
210
+ rt=norm(r).split(); ht=norm(h).split(); n=min(len(rt),len(ht)); tok_ok+=sum(rt[i]==ht[i] for i in range(n)); tok_total+=len(rt)
211
+ exact+=norm(r)==norm(h); cf.append(charf(norm(r),norm(h))); L=lcs(rt,ht); rl.append(L/max(1,len(rt)))
212
+ rn,hn=nums(r),nums(h); numeq.append(bool(rn) and rn==hn)
213
+ return {'token_accuracy':tok_ok/max(1,tok_total),'exact_match':exact/max(1,len(refs)),'char_f1':sum(cf)/max(1,len(cf)),'rougeL_recall':sum(rl)/max(1,len(rl)),'numeric_answer_match_when_both_numeric':sum(numeq)/max(1,len(numeq))}
214
+
215
+
216
+ def generate(model,rows,tok,device,maxq,maxa,bs=4):
217
+ model.eval(); refs=[]; hyps=[]
218
+ with torch.no_grad():
219
+ for i in range(0,len(rows),bs):
220
+ chunk=rows[i:i+bs]; src=torch.tensor([tok.enc(r[0],maxq) for r in chunk],dtype=torch.long,device=device); B=src.size(0); out=torch.full((B,1),tok.bos,dtype=torch.long,device=device)
221
+ for _ in range(maxa-1):
222
+ logits=model(src,out); nxt=logits[:,-1].argmax(-1); out=torch.cat([out,nxt[:,None]],1)
223
+ if bool((nxt==tok.eos).all()): break
224
+ for j,r in enumerate(chunk): refs.append(r[1]); hyps.append(tok.dec(out[j].tolist()))
225
+ return refs,hyps
226
+
227
+
228
+ def eval_loss(model,rows,tok,device,maxq,maxa,bs):
229
+ model.eval(); lossf=nn.CrossEntropyLoss(ignore_index=tok.pad); total=n=0
230
+ with torch.no_grad():
231
+ for i in range(0,len(rows),bs):
232
+ q,a=batch(rows[i:i+bs],tok,maxq,maxa,device); z=model(q,a[:,:-1]); loss=lossf(z.reshape(-1,z.size(-1)),a[:,1:].reshape(-1)); total+=float(loss)*q.size(0); n+=q.size(0)
233
+ return total/max(1,n)
234
+
235
+
236
+ def train(model,train_rows,val_rows,tok,args,device):
237
+ opt=torch.optim.AdamW(model.parameters(),lr=args.lr,weight_decay=.01); lossf=nn.CrossEntropyLoss(ignore_index=tok.pad); best=float('inf'); best_state=None; history=[]
238
+ for ep in range(1,args.epochs+1):
239
+ model.train(); random.shuffle(train_rows); total=n=0; t=time.time()
240
+ for i in range(0,len(train_rows),args.batch):
241
+ q,a=batch(train_rows[i:i+args.batch],tok,args.max_q,args.max_a,device); z=model(q,a[:,:-1]); loss=lossf(z.reshape(-1,z.size(-1)),a[:,1:].reshape(-1)); opt.zero_grad(set_to_none=True); loss.backward(); nn.utils.clip_grad_norm_(model.parameters(),1.0); opt.step(); total+=float(loss)*q.size(0); n+=q.size(0)
242
+ vl=eval_loss(model,val_rows[:args.val_eval],tok,device,args.max_q,args.max_a,args.eval_batch); rec={'epoch':ep,'train_loss':total/max(1,n),'val_loss':vl,'val_ppl':math.exp(min(vl,20)),'seconds':time.time()-t}; history.append(rec); print(json.dumps(rec),flush=True)
243
+ if vl<best: best=vl; best_state={k:v.detach().cpu().clone() for k,v in model.state_dict().items()};
244
+ if args.patience and ep-history.index(min(history,key=lambda x:x['val_loss']))+1>=args.patience and ep>args.patience: break
245
+ if best_state: model.load_state_dict(best_state)
246
+ return history
247
+
248
+
249
+ def main():
250
+ ap=argparse.ArgumentParser(); ap.add_argument('--config',choices=CONFIGS,default='100k'); ap.add_argument('--data',default=''); ap.add_argument('--download',action='store_true'); ap.add_argument('--rows',type=int,default=0); ap.add_argument('--epochs',type=int,default=3); ap.add_argument('--batch',type=int,default=16); ap.add_argument('--eval-batch',type=int,default=8); ap.add_argument('--val-eval',type=int,default=2000); ap.add_argument('--test-eval',type=int,default=2000); ap.add_argument('--max-q',type=int,default=384); ap.add_argument('--max-a',type=int,default=512); ap.add_argument('--vocab',type=int,default=16000); ap.add_argument('--d',type=int,default=256); ap.add_argument('--heads',type=int,default=8); ap.add_argument('--ff',type=int,default=512); ap.add_argument('--layers',type=int,default=4); ap.add_argument('--lr',type=float,default=2e-4); ap.add_argument('--patience',type=int,default=2); ap.add_argument('--device',default='cpu'); ap.add_argument('--out',default='opi_mathqa_report.json'); ap.add_argument('--predictions',default='opi_mathqa_predictions.csv'); ap.add_argument('--workdir',default='opi_mathqa_work'); ap.add_argument('--seed',type=int,default=42)
251
+ args=ap.parse_args(); set_seed(args.seed)
252
+ work=Path(args.workdir); work.mkdir(parents=True,exist_ok=True); path=Path(args.data or work/f'{CONFIGS[args.config]}.jsonl')
253
+ if args.download: download(BASE.format(cfg=CONFIGS[args.config]),path)
254
+ if not path.exists(): raise SystemExit(f'Dataset missing: {path}. Use --download on a network-enabled machine.')
255
+ got=sha(path); print('dataset_sha256',got);
256
+ if args.config in EXPECTED and not args.data: print('expected_sha256',EXPECTED[args.config],'match=',got==EXPECTED[args.config])
257
+ rows=load_rows(path,args.rows); splits=split(rows,args.seed); print('rows',len(rows),'splits',{k:len(v) for k,v in splits.items()})
258
+ spm_path=work/'tokenizer.model'; train_spm(splits['train'],spm_path,args.vocab); tok=Tok(spm_path); print('vocab',len(tok))
259
+ device=torch.device(args.device); report={'dataset':{'name':'math-ai/StackMathQA','config':args.config,'rows':len(rows),'splits':{k:len(v) for k,v in splits.items()},'sha256':got,'sha512':sha(path,'sha512')},'model':vars(args),'results':{}}
260
+ for name,opi in [('baseline_gelu',False),('opi',True)]:
261
+ set_seed(args.seed); model=Model(len(tok),args.d,args.heads,args.ff,args.layers,.1,opi,max(args.max_q,args.max_a)).to(device); params=sum(p.numel() for p in model.parameters()); print(name,'params',params)
262
+ hist=train(model,splits['train'],splits['val'],tok,args,device); refs,hyps=generate(model,splits['test'][:args.test_eval],tok,device,args.max_q,args.max_a,args.eval_batch); m=metric_rows(refs,hyps); m['test_loss']=eval_loss(model,splits['test'][:args.test_eval],tok,device,args.max_q,args.max_a,args.eval_batch); m['ppl']=math.exp(min(m['test_loss'],20)); m['params']=params; m['history']=hist; report['results'][name]=m
263
+ with open(args.predictions.replace('.csv',f'_{name}.csv'),'w',newline='',encoding='utf-8') as f:
264
+ w=csv.writer(f); w.writerow(['question','reference','prediction']); w.writerows(zip([r[0] for r in splits['test'][:args.test_eval]],refs,hyps))
265
+ torch.save({'state_dict':model.state_dict(),'tokenizer':str(spm_path),'args':vars(args),'metrics':m},work/f'{name}.pt')
266
+ Path(args.out).write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8'); print(json.dumps(report['results'],indent=2))
267
+
268
+ if __name__=='__main__': main()