ToxicScope / scripts /evaluate_test.py
nhdang117's picture
Update code to 20260924 version
46834c5 verified
Raw History Blame Contribute Delete
6.92 kB
"""Build a comparable report on the same rows, with traceable prediction sources.
Use --full-phobert to rerun all PhoBERT rows instead of validated supplied predictions.
"""
from pathlib import Path
import sys,json,hashlib,argparse,time
ROOT=Path(__file__).resolve().parents[1];sys.path.insert(0,str(ROOT))
import numpy as np
import pandas as pd
from model_registry import registry,slug
from model_service import service
from metrics import evaluate,bootstrap_f1_ci
def main():
parser=argparse.ArgumentParser();parser.add_argument('--full-phobert',action='store_true');args=parser.parse_args()
frames={s:pd.read_csv(ROOT/f'data/processed/{s}.csv',keep_default_na=False) for s in ['train','dev','test']}
train,dev,test=[frames[s] for s in ['train','dev','test']]
dev_tune=dev.loc[~dev.clean.isin(set(train.clean))]
unseen=~test.clean.isin(set(train.clean));strict=unseen & ~test.clean.isin(set(dev_tune.clean))
masks=dict(test_official=np.ones(len(test),bool),test_unseen_train=unseen.to_numpy(),test_strict=strict.to_numpy())
ids=[m['id'] for m in registry() if m['available'] and (args.full_phobert or m['id']!='phobert')]
last=[0.]
def progress(p,s):
if time.monotonic()-last[0]>8:
print(f'{p:.0%} {s}',flush=True);last[0]=time.monotonic()
data=service.predict_many(test.free_text.tolist(),ids,progress)
if data['errors']: raise RuntimeError(data['errors'])
per_model={}
for m in data['rows'][0]['models']:
key=m['id'];entries=[next(a for a in r['models'] if a['id']==key) for r in data['rows']]
per_model[key]=dict(name=m['name'],origin=m['origin'],source='inference_on_supplied_test',
pred=np.array([a['label']=='TOXIC' for a in entries],int),
p=np.array([a['prob_toxic'] for a in entries],float) if m['prob_toxic'] is not None else None,
margin=np.array([a['decision_score'] for a in entries],float) if m['decision_score'] is not None else None)
verification={}
if not args.full_phobert:
original=pd.read_csv(ROOT/'reports/original/phobert_test_predictions_selected_seed.csv',keep_default_na=False)
assert original.free_text.tolist()==test.free_text.tolist()
assert original.label_bin.tolist()==test.label_bin.tolist()
indices=np.random.default_rng(42).choice(len(test),16,replace=False)
check=service.predict_many(test.free_text.iloc[indices].tolist(),['phobert'])
if check['errors']: raise RuntimeError(check['errors'])
actual=np.array([r['models'][0]['prob_toxic'] for r in check['rows']])
expected=original.prob_toxic.to_numpy()[indices]
difference=float(np.abs(actual-expected).max())
if difference>1e-4: raise RuntimeError(f'PhoBERT checkpoint mismatch: max probability difference {difference}. Run --full-phobert.')
verification=dict(sample_n=16,seed=42,max_abs_probability_difference=difference,tolerance=1e-4,
policy='Full report uses supplied predictions; 16 sampled rows verified by live checkpoint inference.')
per_model['phobert']=dict(name='PhoBERT · seed 87',origin='original_checkpoint',
source='supplied_predictions_verified_16_rows',pred=original.pred.to_numpy(),p=original.prob_toxic.to_numpy(),margin=None)
y=test.label_bin.to_numpy();records=[]
predictions=test[['free_text','label_bin']].copy();predictions['in_test_strict']=strict
historic=pd.read_csv(ROOT/'reports/original/baseline_test_predictions.csv')
comparison=[]
for key,values in per_model.items():
predictions[key+'__pred']=values['pred']
if values['p'] is not None: predictions[key+'__prob_toxic']=values['p']
if values['margin'] is not None: predictions[key+'__decision_score']=values['margin']
for subset,mask in masks.items():
metrics=evaluate(y[mask],values['pred'][mask],values['p'][mask] if values['p'] is not None else None,
values['margin'][mask] if values['margin'] is not None else None)
if subset=='test_strict':metrics['f1_macro_ci95']=bootstrap_f1_ci(y[mask],values['pred'][mask])
records.append(dict(id=key,name=values['name'],origin=values['origin'],source=values['source'],subset=subset,**metrics))
for c in historic.columns:
if c.startswith('pred__') and slug(c[6:])==key:
comparison.append(dict(id=key,agreement=float(np.mean(historic[c].to_numpy()==values['pred']))))
report=dict(title='ToxicScope · đánh giá các mô hình đang triển khai',
dataset=dict(name='UIT-ViHSD',train_n=len(train),dev_n=len(dev),dev_tune_n=len(dev_tune),
**{s:int(m.sum()) for s,m in masks.items()},
sha256={s:hashlib.sha256((ROOT/f'data/processed/{s}.csv').read_bytes()).hexdigest() for s in frames}),
records=records,phobert_verification=verification,baseline_historical_agreement=comparison,
notes=[
'Test official là 6.671 dòng còn lại sau tiền xử lý, không phải toàn bộ 6.680 dòng thô.',
'Test strict loại clean xuất hiện trong Train hoặc Dev tune; vẫn có va chạm ở biểu diễn token. Không khẳng định độc lập tuyệt đối ở mọi mức biểu diễn.',
'Checkpoint BiLSTM (Word2Vec + BiLSTM128, ngưỡng 0,67), baseline và PhoBERT đều được chọn trên Dev tune; ngưỡng BiLSTM được khóa bằng Dev tune trước khi đánh giá Test.',
'6 classifier baseline được fit lại bằng tham số Dev đã chốt. FastText baseline được train lại vì file gốc thiếu vectors_ngrams.npy; chỉ số hiện tại không thay thế báo cáo lịch sử.',
'27 cấu hình DL chưa có trọng số; không được đưa vào vote hay bịa chỉ số Test. 33 dòng Dev lịch sử chứa 30 cấu hình phân biệt.',
'Điểm Dev và Test trình bày riêng. Mô hình Majority luôn SAFE được dùng làm đối chứng và bị loại khỏi vote.',
'Khoảng tin cậy F1: bootstrap phân tầng 300 lần trên cặp nhãn/dự đoán; chỉ phản ánh bất định lấy mẫu, không phản ánh phương sai huấn luyện.',
'Phân loại độc hại là thành phần của đề tài. Không có cạnh lan truyền/timestamp trong dữ liệu hiện có để đo mạng lan truyền thực tế.'
])
(ROOT/'reports/evaluation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
predictions.to_csv(ROOT/'reports/deployment_test_predictions.csv',index=False,encoding='utf-8-sig')
print('Wrote evaluation.json',len(per_model),'models',masks['test_strict'].sum(),'strict rows',flush=True)
print('PhoBERT verification',verification,flush=True)
if __name__=='__main__':main()