| |
| |
| |
|
|
| |
| const metricExplanations = { |
| 'AUC': { |
| description: "Probability that a randomly chosen positive instance is ranked higher than a randomly chosen negative instance.", |
| range: "0 to 1. 0.5 corresponds to random chance.", |
| formula: "Area Under the ROC Curve" |
| }, |
| 'Accuracy': { |
| description: "Proportion of all predictions that are correct.", |
| range: "0 to 1.", |
| formula: "(TP + TN) / (TP + TN + FP + FN)" |
| }, |
| 'Precision': { |
| description: "Proportion of positive predictions that are correct. A high value indicates a low false positive rate.", |
| range: "0 to 1.", |
| formula: "TP / (TP + FP)" |
| }, |
| 'Recall': { |
| description: "Proportion of actual positives correctly identified. Also called Sensitivity or True Positive Rate.", |
| range: "0 to 1.", |
| formula: "TP / (TP + FN)" |
| }, |
| 'Specificity': { |
| description: "Proportion of actual negatives correctly identified. Also called True Negative Rate.", |
| range: "0 to 1.", |
| formula: "TN / (TN + FP)" |
| }, |
| 'F1-Score': { |
| description: "Harmonic mean of Precision and Recall. Balances both metrics in a single value.", |
| range: "0 to 1.", |
| formula: "2 * (Precision * Recall) / (Precision + Recall)" |
| } |
| }; |
|
|
| |
| function randomGaussian(mean = 0, stdDev = 1) { |
| let u = 0, v = 0; |
| while (u === 0) u = Math.random(); |
| while (v === 0) v = Math.random(); |
| return mean + stdDev * Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); |
| } |
|
|
| |
| function calculateRocAndAuc(labels, scores) { |
| const pairs = labels.map((label, i) => ({ label, score: scores[i] })); |
| pairs.sort((a, b) => b.score - a.score); |
| let tp = 0, fp = 0; |
| const total_pos = labels.filter(l => l === 1).length; |
| const total_neg = labels.length - total_pos; |
| if (total_pos === 0 || total_neg === 0) { |
| return { rocPoints: [{ x: 0, y: 0 }, { x: 1, y: 1 }], auc: 0.5 }; |
| } |
| const rocPoints = [{ x: 0, y: 0 }]; |
| let auc = 0, prev_tpr = 0, prev_fpr = 0; |
| for (const pair of pairs) { |
| if (pair.label === 1) tp++; else fp++; |
| const tpr = total_pos > 0 ? tp / total_pos : 0; |
| const fpr = total_neg > 0 ? fp / total_neg : 0; |
| auc += (tpr + prev_tpr) / 2 * (fpr - prev_fpr); |
| rocPoints.push({ x: fpr, y: tpr }); |
| prev_tpr = tpr; |
| prev_fpr = fpr; |
| } |
| return { rocPoints, auc }; |
| } |
|
|
| |
| function drawConfusionMatrix(canvasId, tp, fp, tn, fn) { |
| const canvas = document.getElementById(canvasId); |
| const ctx = canvas.getContext('2d'); |
| const w = canvas.width, h = canvas.height; |
| ctx.clearRect(0, 0, w, h); |
|
|
| const margin = 50; |
| const gridW = w - margin, gridH = h - margin; |
| const cellW = gridW / 2, cellH = gridH / 2; |
| const max_val = Math.max(tp, fp, tn, fn); |
| const baseColor = [8, 48, 107]; |
|
|
| const cells = [ |
| { label: 'TN', value: tn, x: 0, y: cellH }, |
| { label: 'FP', value: fp, x: cellW, y: cellH }, |
| { label: 'FN', value: fn, x: 0, y: 0 }, |
| { label: 'TP', value: tp, x: cellW, y: 0 } |
| ]; |
|
|
| cells.forEach(cell => { |
| const intensity = max_val > 0 ? cell.value / max_val : 0; |
| ctx.fillStyle = `rgba(${baseColor[0]}, ${baseColor[1]}, ${baseColor[2]}, ${intensity})`; |
| ctx.fillRect(margin + cell.x, cell.y, cellW, cellH); |
| ctx.fillStyle = intensity > 0.5 ? 'white' : 'black'; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'middle'; |
| ctx.font = 'bold 20px Segoe UI'; |
| ctx.fillText(cell.label, margin + cell.x + cellW / 2, cell.y + cellH / 2 - 12); |
| ctx.font = '18px Segoe UI'; |
| ctx.fillText(cell.value, margin + cell.x + cellW / 2, cell.y + cellH / 2 + 12); |
| }); |
|
|
| ctx.fillStyle = '#333'; |
| ctx.font = 'bold 14px Segoe UI'; |
| ctx.fillText('Negative', margin + cellW / 2, gridH + 20); |
| ctx.fillText('Positive', margin + cellW + cellW / 2, gridH + 20); |
| ctx.save(); |
| ctx.translate(20, gridH / 2); |
| ctx.rotate(-Math.PI / 2); |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'middle'; |
| ctx.fillText('Positive', -cellH / 2, 0); |
| ctx.fillText('Negative', cellH / 2, 0); |
| ctx.restore(); |
| } |
|
|
| |
| const customDatalabelsPlugin = { |
| id: 'customDatalabels', |
| afterDatasetsDraw: (chart) => { |
| const ctx = chart.ctx; |
| ctx.save(); |
| ctx.font = 'bold 12px Segoe UI'; |
| ctx.fillStyle = 'white'; |
| ctx.textAlign = 'center'; |
| chart.data.datasets.forEach((dataset, i) => { |
| const meta = chart.getDatasetMeta(i); |
| meta.data.forEach((bar, index) => { |
| const data = dataset.data[index]; |
| if (bar.height > 15) { |
| ctx.textBaseline = 'bottom'; |
| ctx.fillText(data.toFixed(3), bar.x, bar.y + bar.height - 5); |
| } |
| }); |
| }); |
| ctx.restore(); |
| } |
| }; |
|
|
| |
| function debounce(func, wait) { |
| let timeout; |
| return function executedFunction(...args) { |
| const later = () => { |
| clearTimeout(timeout); |
| func(...args); |
| }; |
| clearTimeout(timeout); |
| timeout = setTimeout(later, wait); |
| }; |
| } |
|
|
| |
| function makeDraggable(element, handle) { |
| let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; |
| let isDragging = false; |
| |
| handle.onmousedown = (e) => { |
| |
| if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || |
| e.target.tagName === 'TEXTAREA' || e.target.classList.contains('slider')) { |
| return; |
| } |
| |
| isDragging = true; |
| pos3 = e.clientX; |
| pos4 = e.clientY; |
| document.onmouseup = closeDragElement; |
| document.onmousemove = elementDrag; |
| }; |
| |
| const elementDrag = (e) => { |
| if (!isDragging) return; |
| e.preventDefault(); |
| pos1 = pos3 - e.clientX; |
| pos2 = pos4 - e.clientY; |
| pos3 = e.clientX; |
| pos4 = e.clientY; |
| element.style.top = (element.offsetTop - pos2) + "px"; |
| element.style.left = (element.offsetLeft - pos1) + "px"; |
| }; |
| |
| const closeDragElement = () => { |
| isDragging = false; |
| document.onmouseup = null; |
| document.onmousemove = null; |
| }; |
| } |
|
|
| |
| function metricsTooltipCallback(context) { |
| const label = context.chart.data.labels[context.dataIndex]; |
| const value = context.raw.toFixed(3); |
| const explanation = metricExplanations[label]; |
| let tooltipText = [`${label}: ${value}`]; |
| if (explanation) { |
| tooltipText.push(''); |
| const roleLines = `${explanation.description}`.match(/.{1,50}(\s|$)/g) || []; |
| roleLines.forEach(line => tooltipText.push(line.trim())); |
| tooltipText.push(`Range: ${explanation.range}`); |
| tooltipText.push(`Formula: ${explanation.formula}`); |
| } |
| return tooltipText; |
| } |
|
|