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;
}