Spaces:
Running
Running
Download core.mjs from Hugdrigo/prismaudit-model-lab: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/spaces/Hugdrigo/prismaudit-model-lab/resolve/main/core.mjs
- Command line
-
hf download hf://spaces/Hugdrigo/prismaudit-model-lab/core.mjs
-
curl -L -o core.mjs https://huggingface.co/spaces/Hugdrigo/prismaudit-model-lab/resolve/main/core.mjs
10.7 kB
| /** PrismAudit analytical core. Pure functions; no network, no dependencies. MIT. */ | |
| export const VERSION = '1.0.0'; | |
| export const MAX_ROWS = 10000; | |
| export const REQUIRED_COLUMNS = ['score', 'label', 'group']; | |
| export const DEFAULT_THRESHOLD = 0.50; | |
| export function parseCSV(csv) { | |
| if (typeof csv !== 'string') throw new Error('Expected a CSV text file.'); | |
| csv = csv.replace(/^\uFEFF/, ''); | |
| const table = []; let row = [], val = '', quoted = false; | |
| for (let i = 0; i < csv.length; i++) { | |
| const c = csv[i]; | |
| if (quoted) { | |
| if (c === '"' && csv[i+1] === '"') { val += '"'; i++; } | |
| else if (c === '"') quoted = false; | |
| else val += c; | |
| } else if (c === '"') { | |
| if (val.trim() !== '') throw new Error('Unexpected quote in CSV field.'); | |
| quoted = true; | |
| } else if (c === ',') { row.push(val); val = ''; } | |
| else if (c === '\n' || c === '\r') { | |
| if (c === '\r' && csv[i+1] === '\n') i++; | |
| row.push(val); val = ''; | |
| if (row.some(v => v.trim().length)) table.push(row); | |
| row = []; | |
| if (table.length > MAX_ROWS + 1) throw new Error(`Maximum ${MAX_ROWS.toLocaleString()} data rows allowed.`); | |
| } else { val += c; } | |
| } | |
| if (quoted) throw new Error('Unclosed quoted field in CSV.'); | |
| row.push(val); if (row.some(v => v.trim().length)) table.push(row); | |
| if (table.length < 2) throw new Error('CSV must contain a header and at least one data row.'); | |
| const headers = table.shift().map(s => s.trim().toLowerCase()); | |
| if (new Set(headers).size !== headers.length) throw new Error('CSV has duplicate column names.'); | |
| for (const h of REQUIRED_COLUMNS) if (!headers.includes(h)) throw new Error(`Missing required column: ${h}.`); | |
| const indices = Object.fromEntries(headers.map((h, i) => [h, i])); | |
| if (table.length > MAX_ROWS) throw new Error(`Maximum ${MAX_ROWS.toLocaleString()} rows allowed.`); | |
| const rows = table.map((fields, i) => { | |
| if (fields.length !== headers.length) throw new Error(`Row ${i + 2}: expected ${headers.length} columns, got ${fields.length}.`); | |
| const rawScore = fields[indices.score].trim(), rawLabel = fields[indices.label].trim(); | |
| if (!/^(?:0(?:\.\d+)?|1(?:\.0+)?)$/.test(rawScore)) throw new Error(`Row ${i + 2}: score must be between 0 and 1.`); | |
| if (!/^[01]$/.test(rawLabel)) throw new Error(`Row ${i + 2}: label must be exactly 0 or 1.`); | |
| const group = fields[indices.group].trim(); | |
| if (!group || group.length > 72) throw new Error(`Row ${i + 2}: group is empty or exceeds 72 characters.`); | |
| const id = indices.id === undefined ? String(i + 1) : fields[indices.id].trim().slice(0,120); | |
| return { id, score: Number(rawScore), label: Number(rawLabel), group }; | |
| }); | |
| return validateRows(rows); | |
| } | |
| export function validateRows(rows) { | |
| if (!Array.isArray(rows) || rows.length < 1 || rows.length > MAX_ROWS) throw new Error(`Use 1 to ${MAX_ROWS.toLocaleString()} rows.`); | |
| const groupCount = new Set(); | |
| for (const [i, r] of rows.entries()) { | |
| if (!Number.isFinite(r.score) || r.score < 0 || r.score > 1) throw new Error(`Row ${i + 1}: invalid score.`); | |
| if (r.label !== 0 && r.label !== 1) throw new Error(`Row ${i + 1}: label must be 0 or 1.`); | |
| if (typeof r.group !== 'string' || !r.group.trim() || r.group.length > 72) throw new Error(`Row ${i + 1}: invalid group.`); | |
| groupCount.add(r.group); | |
| if (groupCount.size > 35) throw new Error('A maximum of 35 unique groups is supported.'); | |
| } | |
| return rows; | |
| } | |
| export const safeDivide = (a, b) => b === 0 ? null : a / b; | |
| export const round = (v, places = 4) => v === null || !Number.isFinite(v) ? null : Number(v.toFixed(places)); | |
| export function confusion(rows, threshold) { | |
| if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) throw new Error('Threshold must be between 0 and 1.'); | |
| let tp = 0, fp = 0, tn = 0, fn = 0; | |
| for (const r of rows) { | |
| const positive = r.score >= threshold; | |
| if (positive && r.label === 1) tp++; | |
| else if (positive && r.label === 0) fp++; | |
| else if (!positive && r.label === 0) tn++; | |
| else fn++; | |
| } | |
| return { tp, fp, tn, fn }; | |
| } | |
| /** Tie-aware Mann-Whitney (rank statistic) ROC AUC. Undefined if only one observed class. */ | |
| export function auc(rows) { | |
| let positives = 0, negatives = 0; | |
| for (const r of rows) r.label ? positives++ : negatives++; | |
| if (!positives || !negatives) return null; | |
| const sorted = [...rows].sort((a, b) => a.score - b.score); | |
| let rank = 1, sumPositiveRanks = 0; | |
| for (let i = 0; i < sorted.length;) { | |
| let j = i + 1; | |
| while (j < sorted.length && sorted[j].score === sorted[i].score) j++; | |
| const meanRank = (rank + (rank + j - i - 1)) / 2; | |
| for (let k = i; k < j; k++) if (sorted[k].label) sumPositiveRanks += meanRank; | |
| rank += j - i; i = j; | |
| } | |
| return (sumPositiveRanks - positives * (positives + 1) / 2) / (positives * negatives); | |
| } | |
| export function calibration(rows, bins = 10) { | |
| const slots = Array.from({length: bins}, (_, i) => ({index:i, n:0, totalScore:0, totalLabel:0})); | |
| let brierSum = 0; | |
| for (const r of rows) { | |
| const b = Math.min(bins - 1, Math.floor(r.score * bins)); | |
| slots[b].n++; slots[b].totalScore += r.score; slots[b].totalLabel += r.label; | |
| brierSum += (r.score - r.label) ** 2; | |
| } | |
| let ece = 0; | |
| const data = slots.map(({index,n,totalScore,totalLabel}) => { | |
| const confidence = safeDivide(totalScore,n), outcome = safeDivide(totalLabel,n); | |
| if (n) ece += n / rows.length * Math.abs(confidence - outcome); | |
| return {bin:index + 1, n, confidence, outcome}; | |
| }); | |
| return {bins:data, ece, brier:brierSum / rows.length}; | |
| } | |
| export function evaluate(rows, threshold = DEFAULT_THRESHOLD) { | |
| validateRows(rows); | |
| const {tp, fp, tn, fn} = confusion(rows, threshold); | |
| const n = rows.length, p = tp + fn, negative = tn + fp; | |
| const precision = safeDivide(tp, tp + fp); | |
| const recall = safeDivide(tp, p); | |
| const specificity = safeDivide(tn, negative); | |
| const f1 = precision === null || recall === null || precision + recall === 0 ? (precision === 0 || recall === 0 ? 0 : null) : 2 * precision * recall / (precision + recall); | |
| const balancedAccuracy = recall === null || specificity === null ? null : (recall + specificity) / 2; | |
| return { | |
| n, threshold, tp, fp, tn, fn, prevalence:p/n, | |
| accuracy:(tp + tn)/n, precision, recall, specificity, f1, | |
| balancedAccuracy, fpr:safeDivide(fp,negative), fnr:safeDivide(fn,p), | |
| positiveRate:(tp + fp)/n, auc:auc(rows), ...calibration(rows) | |
| }; | |
| } | |
| export function byGroup(rows, threshold) { | |
| const map = new Map(); | |
| for (const r of rows) { if (!map.has(r.group)) map.set(r.group, []); map.get(r.group).push(r); } | |
| return [...map.entries()].sort((a,b)=>a[0].localeCompare(b[0])).map(([group,subset])=>({group, ...evaluate(subset, threshold)})); | |
| } | |
| export function rangeOf(values) { const a=values.filter(x=>x!==null && Number.isFinite(x)); return a.length < 2 ? null : Math.max(...a)-Math.min(...a); } | |
| export function fairness(rows, threshold) { | |
| const groups=byGroup(rows,threshold); | |
| return {groups, demographicParityGap:rangeOf(groups.map(x=>x.positiveRate)), | |
| equalOpportunityGap:rangeOf(groups.map(x=>x.recall)), | |
| falsePositiveRateGap:rangeOf(groups.map(x=>x.fpr)), | |
| minimumGroupN:Math.min(...groups.map(x=>x.n)), | |
| reliable:groups.length >= 2 && groups.every(x=>x.n>=30) && groups.every(x=>x.recall!==null && x.fpr!==null)}; | |
| } | |
| export function rocCurve(rows) { | |
| const p = rows.reduce((s,r)=>s+r.label,0), n=rows.length-p; | |
| if (!p || !n) return []; | |
| const sorted=[...rows].sort((a,b)=>b.score-a.score); | |
| const points=[{fpr:0,tpr:0,threshold:1.000001}]; | |
| let tp=0, fp=0; | |
| for(let i=0;i<sorted.length;) { | |
| const score=sorted[i].score; | |
| while(i<sorted.length && sorted[i].score===score) { if(sorted[i].label)tp++;else fp++;i++; } | |
| points.push({fpr:fp/n,tpr:tp/p,threshold:score}); | |
| } | |
| return points; | |
| } | |
| export function thresholdSweep(rows, step = 0.05) { | |
| const out=[]; | |
| for(let i=0;i<=Math.round(1/step);i++) { | |
| const threshold=Number((i*step).toFixed(4)); | |
| const m=evaluate(rows,threshold), f=fairness(rows,threshold); | |
| out.push({threshold, f1:m.f1, accuracy:m.accuracy, balancedAccuracy:m.balancedAccuracy, precision:m.precision, recall:m.recall, positiveRate:m.positiveRate, parityGap:f.demographicParityGap}); | |
| } | |
| return out; | |
| } | |
| /** Deterministic noncryptographic PRNG, never for security or personal data. */ | |
| export function rng(seed) {let t=seed>>>0;return ()=>{t+=0x6D2B79F5;let x=t; x=Math.imul(x^x>>>15,x|1);x^=x+Math.imul(x^x>>>7,x|61);return ((x^x>>>14)>>>0)/4294967296;};} | |
| export function bootstrapInterval(rows, threshold, metric = 'f1', repeats = 240, seed = 24) { | |
| if(!['f1','accuracy','balancedAccuracy','auc','brier'].includes(metric))throw new Error('Unsupported metric.'); | |
| if (!Number.isInteger(repeats) || repeats < 50 || repeats > 500) throw new Error('Use 50 to 500 bootstrap repeats.'); | |
| validateRows(rows); const random=rng(seed), values=[]; | |
| for (let b=0;b<repeats;b++) { | |
| const sample=Array.from({length:rows.length},()=>rows[Math.floor(random()*rows.length)]); | |
| const metricValue=evaluate(sample,threshold)[metric]; | |
| if(metricValue!==null)values.push(metricValue); | |
| } | |
| if(values.length<Math.ceil(repeats*0.8))return {lower:null,upper:null,repeats,valid:values.length}; | |
| values.sort((a,b)=>a-b); | |
| const quantile=q=>{const at=(values.length-1)*q, lo=Math.floor(at), hi=Math.ceil(at);return values[lo]+(values[hi]-values[lo])*(at-lo);}; | |
| return {lower:quantile(.025),upper:quantile(.975),repeats,valid:values.length}; | |
| } | |
| export function csvEscape(v) {const s=String(v??'');return /[,"\r\n]/.test(s)?`"${s.replace(/"/g,'""')}"`:s;} | |
| export function toCSV(rows, columns) {return [columns.join(','), ...rows.map(r=>columns.map(c=>csvEscape(r[c])).join(','))].join('\r\n')+'\r\n';} | |
| export function syntheticRows(preset='support',count=720,seed=2026) { | |
| if(!['support','quality','education'].includes(preset)) throw new Error('Unknown demo dataset.'); | |
| const random=rng(seed), rows=[], cohorts=['North','Central','South']; | |
| const sigmoid=x=>1/(1+Math.exp(-x)); | |
| for(let i=0;i<count;i++) { | |
| const group=cohorts[i%3]; | |
| const index=i%3; | |
| const latent=random()*2-1; | |
| const groupDelta=preset==='support'?[.22,-.12,-.22][index]:preset==='quality'?[.05,.12,-.1][index]:[-.1,.2,-.16][index]; | |
| const base=preset==='support' ? -.2 : preset==='quality' ? -.48 : .1; | |
| const trueP=sigmoid(base+latent*3+groupDelta); | |
| const label=random()<trueP?1:0; | |
| const noise=(random()-.5)*.35; | |
| const score=Math.max(.005,Math.min(.995,sigmoid(base+latent*3+groupDelta*1.65+noise))); | |
| rows.push({id:`SYN-${String(i+1).padStart(4,'0')}`,score:Number(score.toFixed(4)),label,group}); | |
| } | |
| return rows; | |
| } | |