Spaces:
Running
Running
File size: 10,698 Bytes
2038eb3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | /** 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;
}
|