Hugdrigo's picture
Upload 6 files
2038eb3 verified
Raw History Blame Contribute Delete
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;
}