Spaces:
Running
Running
Download app.mjs from Hugdrigo/prismaudit-model-lab: direct link, hf CLI and curl.
- Browser
- Download file 17.6 kB
-
https://huggingface.co/spaces/Hugdrigo/prismaudit-model-lab/resolve/main/app.mjs
- Command line
-
hf download hf://spaces/Hugdrigo/prismaudit-model-lab/app.mjs
-
curl -L -o app.mjs https://huggingface.co/spaces/Hugdrigo/prismaudit-model-lab/resolve/main/app.mjs
17.6 kB
| import { VERSION, parseCSV, validateRows, evaluate, fairness, rocCurve, thresholdSweep, bootstrapInterval, syntheticRows, toCSV, round } from './core.mjs'; | |
| const $ = id => document.getElementById(id); | |
| const state = { preset:'support', rows:syntheticRows('support'), threshold:.5, source:'synthetic', fileName:null, sweep:[], current:null, group:null, interval:null, page:'overview' }; | |
| const pct = v => v === null || !Number.isFinite(v) ? '—' : `${(v*100).toFixed(1)}%`; | |
| const score = v => v === null || !Number.isFinite(v) ? '—' : v.toFixed(3); | |
| const number = n => n.toLocaleString('en-US'); | |
| const h = t => String(t).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| const color = {green:'#b4f887',purple:'#b2a1ff',blue:'#70d5f7',amber:'#ffc277',dim:'#566d7b',grid:'#2b3c47'}; | |
| let toastTimer; | |
| function toast(message,isError=false){ const el=$('toast');el.textContent=message;el.className=`toast show${isError?' error':''}`;clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),3900); } | |
| function alertMessage(message){const el=$('globalAlert');el.textContent=message;el.hidden=false;window.scrollTo({top:0,behavior:'smooth'});} | |
| function clearAlert(){$('globalAlert').hidden=true;} | |
| function svgBase(content,label=''){return `<svg viewBox="0 0 680 275" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" aria-label="${h(label)}"><defs><linearGradient id="plotFill" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#b4f887" stop-opacity=".18"/><stop offset="100%" stop-color="#b4f887" stop-opacity="0"/></linearGradient></defs>${content}</svg>`;} | |
| function svgAxes(xlabel,ylabel){let out='';for(let i=0;i<=5;i++){const x=48+(620/5)*i,y=230-(196/5)*i;out+=`<line x1="48" y1="${y}" x2="668" y2="${y}" stroke="${color.grid}" stroke-width="1" stroke-dasharray="2 5"/><text x="39" y="${y+4}" fill="#6f8795" font-size="11" text-anchor="end">${(i/5).toFixed(1)}</text><text x="${x}" y="250" fill="#6f8795" font-size="11" text-anchor="middle">${(i/5).toFixed(1)}</text>`;}return out+`<text x="358" y="270" font-size="11" fill="#96a9b4" text-anchor="middle">${h(xlabel)}</text><text x="13" y="139" font-size="11" fill="#96a9b4" text-anchor="middle" transform="rotate(-90 13 139)">${h(ylabel)}</text>`;} | |
| const X = x=>48+620*x, Y=y=>230-196*y; | |
| function pathFor(points, xkey, ykey){let active=false;return points.map(p=>{const x=p[xkey],y=p[ykey];if(y===null || !Number.isFinite(y)){active=false;return '';}const prefix=active?'L':'M';active=true;return `${prefix}${X(x).toFixed(2)},${Y(y).toFixed(2)}`;}).filter(Boolean).join(' ');} | |
| function renderRoc(){const points=rocCurve(state.rows);let content=svgAxes('False positive rate','True positive rate');content+=`<path d="M${X(0)} ${Y(0)} L${X(1)} ${Y(1)}" stroke="${color.dim}" stroke-width="2" stroke-dasharray="5 7"/>`;if(points.length){content+=`<path d="${pathFor(points,'fpr','tpr')}" fill="none" stroke="${color.green}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`;const tail=points[points.length-1];content+=`<circle cx="${X(tail.fpr)}" cy="${Y(tail.tpr)}" r="4" fill="${color.green}"/>`;}else content+=`<text x="370" y="125" fill="#ffc277" font-size="16" text-anchor="middle">ROC undefined: only one ground-truth class</text>`;$('rocChart').innerHTML=svgBase(content,'ROC curve');} | |
| function renderCalibration(){const bins=state.current.bins;let content=svgAxes('Mean predicted score','Observed positive frequency');content+=`<path d="M${X(0)} ${Y(0)} L${X(1)} ${Y(1)}" stroke="${color.dim}" stroke-width="2" stroke-dasharray="5 7"/>`;const active=bins.filter(b=>b.n);content+=active.map(b=>`<line x1="${X(b.confidence)}" y1="${Y(0)}" x2="${X(b.confidence)}" y2="${Y(b.outcome)}" stroke="${color.purple}" stroke-opacity=".28" stroke-width="7"/><circle cx="${X(b.confidence)}" cy="${Y(b.outcome)}" r="5" fill="${color.purple}"><title>Bin ${b.bin}: n=${b.n}, mean score=${score(b.confidence)}, observed=${pct(b.outcome)}</title></circle>`).join('');if(active.length>1)content+=`<path d="${pathFor(active,'confidence','outcome')}" fill="none" stroke="${color.purple}" stroke-width="2"/>`;$('calChart').innerHTML=svgBase(content,'Calibration chart');} | |
| function renderThreshold(){const sw=state.sweep;let content=svgAxes('Decision threshold','Metric score');for(const [key,tone] of [['f1','green'],['precision','purple'],['recall','blue']])content+=`<path d="${pathFor(sw,'threshold',key)}" fill="none" stroke="${color[tone]}" stroke-width="2.6" stroke-linecap="round"/>`;content+=`<line x1="${X(state.threshold)}" y1="34" x2="${X(state.threshold)}" y2="230" stroke="${color.amber}" stroke-width="2" stroke-dasharray="4 4"/>`;const y = state.current.f1===null?.5:state.current.f1;content+=`<circle cx="${X(state.threshold)}" cy="${Y(y)}" r="5" fill="${color.amber}" stroke="#111d27" stroke-width="2"/>`;$('thresholdChart').innerHTML=svgBase(content,'F1, precision and recall versus threshold');} | |
| function renderInsights(){const m=state.current,f=state.group;const notes=[];if(m.n<100)notes.push(['Small evaluation set',`Only ${number(m.n)} rows are available. Estimates may be unstable.`,true]);if(m.auc===null)notes.push(['Ranking unavailable','ROC AUC requires both positive and negative ground-truth observations.',true]);else if(m.auc<.6)notes.push(['Limited discrimination',`ROC AUC is ${score(m.auc)}. Check signal quality and whether the scores rank outcomes usefully.`,true]);else notes.push(['Discrimination snapshot',`ROC AUC of ${score(m.auc)} describes ranking on this dataset, not performance in production.`,false]);if(m.ece>=.10)notes.push(['Calibration needs review',`Estimated calibration error is ${pct(m.ece)} across ten equal-width bins.`,true]);else notes.push(['Calibration diagnostic',`Estimated calibration error is ${pct(m.ece)}. Validate calibration on an independent sample.`,false]);if(f.groups.length<2)notes.push(['One observed cohort','At least two groups are required for between-cohort comparisons.',true]);else if(!f.reliable)notes.push(['Cohort uncertainty','Some cohorts have fewer than 30 records or lack a required observed outcome class. Gaps need cautious interpretation.',true]);else notes.push(['Cohort differences',`Observed positive decision rate gap is ${pct(f.demographicParityGap)} across ${f.groups.length} cohorts; this is not a fairness verdict.`,f.demographicParityGap>.15]);if(m.prevalence<.1||m.prevalence>.9)notes.push(['Class imbalance',`Only ${pct(m.prevalence)} of the observed records are positive. Accuracy alone may be misleading.`,true]);$('insightList').innerHTML=notes.slice(0,4).map(([title,body,warn])=>`<div class="insight${warn?' warn':''}"><span class="insight-icon">${warn?'!':'✓'}</span><div><b>${h(title)}</b><p>${h(body)}</p></div></div>`).join('');} | |
| function renderGroupBars(){const groups=state.group.groups;const max=Math.max(.01,...groups.map(g=>g.positiveRate));$('groupBars').innerHTML=groups.map(g=>`<div class="group-row"><span class="group-label">${h(g.group)}${g.n<30?' ⚠':''}</span><div class="group-bar"><div style="width:${(g.positiveRate/max*100).toFixed(1)}%"></div></div><span class="group-val">${pct(g.positiveRate)}</span></div>`).join('');} | |
| function renderTable(){const threshold=state.threshold;const rows=[...state.sweep].sort((a,b)=>Math.abs(a.threshold-threshold)-Math.abs(b.threshold-threshold)).slice(0,9).sort((a,b)=>a.threshold-b.threshold);$('sweepBody').innerHTML=rows.map(r=>`<tr class="${Math.abs(r.threshold-threshold)<.025?'current':''}"><td>${r.threshold.toFixed(2)}</td><td>${pct(r.f1)}</td><td>${pct(r.precision)}</td><td>${pct(r.recall)}</td><td>${pct(r.accuracy)}</td><td>${pct(r.positiveRate)}</td></tr>`).join('');$('groupsBody').innerHTML=state.group.groups.map(g=>`<tr><td>${h(g.group)}${g.n<30?' ⚠':''}</td><td>${number(g.n)}</td><td>${pct(g.prevalence)}</td><td>${pct(g.positiveRate)}</td><td>${pct(g.recall)}</td><td>${pct(g.fpr)}</td><td>${pct(g.accuracy)}</td></tr>`).join('');} | |
| function renderProfile(){const m=state.current;const groups=state.group.groups;$('dataProfile').innerHTML=[['Records',number(m.n)],['Cohorts',number(groups.length)],['Positive labels',pct(m.prevalence)],['Score range',`${Math.min(...state.rows.map(x=>x.score)).toFixed(2)}–${Math.max(...state.rows.map(x=>x.score)).toFixed(2)}`],['Positive decisions',number(m.tp+m.fp)],['Data origin',state.source==='synthetic'?'Synthetic':'CSV import']].map(([k,v])=>`<div class="data-item"><span>${h(k)}</span><strong>${h(v)}</strong></div>`).join('');} | |
| function updatePage(){document.querySelectorAll('[data-page]').forEach(btn=>{const active=btn.dataset.page===state.page;btn.classList.toggle('active',active);active?btn.setAttribute('aria-current','page'):btn.removeAttribute('aria-current');});document.querySelectorAll('.page').forEach(p=>p.hidden=p.id!==`page-${state.page}`);} | |
| function renderAll(){const m=state.current=evaluate(state.rows,state.threshold), f=state.group=fairness(state.rows,state.threshold);$('thresholdValue').textContent=state.threshold.toFixed(2);$('thresholdRange').value=Math.round(state.threshold*100);$('metricAccuracy').textContent=pct(m.accuracy);$('metricF1').textContent=score(m.f1);$('metricAuc').textContent=score(m.auc);$('metricBrier').textContent=score(m.brier);$('metricPrecision').textContent=pct(m.precision);$('metricRecall').textContent=pct(m.recall);$('metricSpecificity').textContent=pct(m.specificity);$('metricBalanced').textContent=pct(m.balancedAccuracy);$('tpCount').textContent=number(m.tp);$('fpCount').textContent=number(m.fp);$('tnCount').textContent=number(m.tn);$('fnCount').textContent=number(m.fn);$('confusionFoot').textContent=`Predicted positive at score ≥ ${state.threshold.toFixed(2)}. Total = ${number(m.n)} observations.`;$('sampleSize').textContent=`${number(m.n)} OBSERVATIONS`;$('aucTag').textContent=score(m.auc);$('eceTag').textContent=score(m.ece);$('gapParity').textContent=pct(f.demographicParityGap);$('gapRecall').textContent=pct(f.equalOpportunityGap);$('gapFpr').textContent=pct(f.falsePositiveRateGap);$('minGroup').textContent=number(f.minimumGroupN);$('fairnessWarning').className=`fairness-warning${f.reliable?' good':''}`;$('fairnessWarning').textContent=f.groups.length<2?'Only one group is present: between-cohort parity comparisons are undefined.':f.reliable?'All cohorts have at least 30 rows and both outcome classes. Statistical uncertainty and context still matter.':'Caution: one or more groups have fewer than 30 records, or one group lacks an observed outcome class. Some conditional rate gaps may be undefined or unstable.';renderRoc();renderCalibration();renderThreshold();renderInsights();renderGroupBars();renderTable();renderProfile();const eligible=state.sweep.filter(s=>s.f1!==null).sort((a,b)=>b.f1-a.f1 || a.threshold-b.threshold);const best=eligible[0];$('bestThreshold').textContent=best?best.threshold.toFixed(2):'N/A';$('bestNote').textContent=best?`Highest displayed F1: ${pct(best.f1)} at cutoff ${best.threshold.toFixed(2)}. Calculated on the same data; not validated for deployment.`:'No defined F1 score is available for this sample.';$('applyBest').disabled=!best;state.interval=null;$('bootstrapResult').textContent='Awaiting calculation';} | |
| function loadRows(rows,source,fileName){state.rows=validateRows(rows);state.source=source;state.fileName=fileName||null;state.threshold=.5;state.sweep=thresholdSweep(rows);if(source==='uploaded'){$('datasetSelect').value='uploaded';$('datasetMeta').textContent=`CSV · ${number(rows.length)} rows`;$('modeNotice').innerHTML='<strong>Imported dataset.</strong> Evaluation happens entirely in your browser. Verify labels, consent and data provenance before interpreting results.';}else{$('datasetSelect').value=state.preset;$('datasetMeta').textContent=`Synthetic · ${number(rows.length)} records`;$('modeNotice').innerHTML='<strong>Demonstration mode.</strong> All records are fictional and algorithmically generated. No ML training happens in this application.';}renderAll();clearAlert();} | |
| function showPage(page){if(!['overview','threshold','fairness','data'].includes(page))return;state.page=page;updatePage();window.scrollTo({top:0,behavior:'smooth'});} | |
| function saveFile(filename,content,type){const blob=new Blob([content],{type});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),5000);toast(`Saved ${filename}`);} | |
| function safeReport(){const m=state.current,f=state.group;const summary={app:'PrismAudit',version:VERSION,project:24,createdAt:new Date().toISOString(),dataSource:state.source,fileName:state.fileName,recordCount:state.rows.length,threshold:state.threshold,metrics:{n:m.n,tp:m.tp,fp:m.fp,tn:m.tn,fn:m.fn,prevalence:m.prevalence,accuracy:m.accuracy,precision:m.precision,recall:m.recall,specificity:m.specificity,f1:m.f1,balancedAccuracy:m.balancedAccuracy,fpr:m.fpr,fnr:m.fnr,positiveRate:m.positiveRate,auc:m.auc,brier:m.brier,ece:m.ece},calibration:m.bins,fairness:{demographicParityGap:f.demographicParityGap,equalOpportunityGap:f.equalOpportunityGap,falsePositiveRateGap:f.falsePositiveRateGap,reliable:f.reliable,groups:f.groups.map(({group,n,prevalence,positiveRate,recall,fpr,accuracy})=>({group,n,prevalence,positiveRate,recall,fpr,accuracy}))},thresholdSweep:state.sweep,bootstrap:state.interval,disclaimer:'Descriptive audit only. Not training, causal assessment, compliance certification or deployment recommendation. Missing rates are represented as null. Synthetic examples are fictional.'};return summary;} | |
| async function handleFile(file){if(!file)return;if(file.size>2*1024*1024){toast('File exceeds the 2 MB limit.',true);return;}if(!file.name.toLowerCase().endsWith('.csv')){toast('Please use a .csv file.',true);return;}try{const rows=parseCSV(await file.text());let custom=$('datasetSelect').querySelector('option[value="uploaded"]');if(!custom){custom=document.createElement('option');custom.value='uploaded';$('datasetSelect').appendChild(custom);}custom.textContent=`Imported: ${file.name.slice(0,28)}`;loadRows(rows,'uploaded',file.name);toast(`Imported ${number(rows.length)} records. Nothing was uploaded to a server.`);showPage('overview');}catch(error){toast(error.message,true);alertMessage(`Import failed: ${error.message}`);}finally{$('fileInput').value='';}} | |
| document.querySelectorAll('[data-page]').forEach(btn=>btn.addEventListener('click',()=>showPage(btn.dataset.page))); | |
| $('thresholdRange').addEventListener('input',event=>{state.threshold=Number(event.target.value)/100;renderAll();}); | |
| $('resetThreshold').addEventListener('click',()=>{state.threshold=.5;renderAll();}); | |
| $('datasetSelect').addEventListener('change',event=>{if(event.target.value==='uploaded')return;state.preset=event.target.value;loadRows(syntheticRows(state.preset),'synthetic');toast(`Switched to ${event.target.options[event.target.selectedIndex].text}.`);}); | |
| $('applyBest').addEventListener('click',()=>{const best=state.sweep.filter(s=>s.f1!==null).sort((a,b)=>b.f1-a.f1 || a.threshold-b.threshold)[0];if(!best)return;state.threshold=best.threshold;renderAll();toast(`Evaluating cutoff ${best.threshold.toFixed(2)}. This is not a deployment recommendation.`);}); | |
| $('runBootstrap').addEventListener('click',()=>{const btn=$('runBootstrap');btn.disabled=true;$('bootstrapResult').textContent='Calculating 240 seeded resamples…';setTimeout(()=>{try{const res=state.interval=bootstrapInterval(state.rows,state.threshold);$('bootstrapResult').textContent=res.lower===null?'Interval unavailable: too many undefined replicate metrics.':`95% percentile interval for F1: [${score(res.lower)}, ${score(res.upper)}] · ${res.valid}/${res.repeats} valid repeats · seed 24`;}catch(error){toast(error.message,true);$('bootstrapResult').textContent=error.message;}finally{btn.disabled=false;}},40);}); | |
| $('exportReport').addEventListener('click',()=>saveFile('prismaudit_report.json',JSON.stringify(safeReport(),null,2),'application/json')); | |
| $('exportSweep').addEventListener('click',()=>saveFile('prismaudit_thresholds.csv',toCSV(state.sweep,['threshold','f1','accuracy','balancedAccuracy','precision','recall','positiveRate','parityGap']),'text/csv;charset=utf-8')); | |
| $('exportGroups').addEventListener('click',()=>saveFile('prismaudit_groups.csv',toCSV(state.group.groups,['group','n','prevalence','positiveRate','recall','fpr','accuracy']),'text/csv;charset=utf-8')); | |
| $('downloadSample').addEventListener('click',()=>saveFile('prismaudit_example.csv',toCSV(syntheticRows('support',120),['id','score','label','group']),'text/csv;charset=utf-8')); | |
| $('restoreDemo').addEventListener('click',()=>{state.preset='support';loadRows(syntheticRows('support'),'synthetic');toast('Restored fictional example data.');}); | |
| $('importTop').addEventListener('click',()=>$('fileInput').click());$('browseData').addEventListener('click',event=>{event.stopPropagation();$('fileInput').click();});$('fileInput').addEventListener('change',event=>handleFile(event.target.files[0])); | |
| const drop=$('dropZone');drop.addEventListener('click',()=>$('fileInput').click());drop.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();$('fileInput').click();}});for(const ev of ['dragenter','dragover'])drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('dragging');});for(const ev of ['dragleave','drop'])drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('dragging');});drop.addEventListener('drop',e=>handleFile(e.dataTransfer?.files?.[0])); | |
| loadRows(state.rows,'synthetic');updatePage(); | |