const controls = { classMode: document.getElementById("classMode"), threshold: document.getElementById("threshold"), thresholdValue: document.getElementById("thresholdValue"), ruleText: document.getElementById("ruleText"), checkBtn: document.getElementById("checkBtn"), showSolutionBtn: document.getElementById("showSolutionBtn"), newSamplesBtn: document.getElementById("newSamplesBtn"), resetBtn: document.getElementById("resetBtn"), }; const sampleArea = document.getElementById("sampleArea"); const matrixArea = document.getElementById("matrixArea"); const metricArea = document.getElementById("metricArea"); const feedbackStats = document.getElementById("feedbackStats"); const solutionBlock = document.getElementById("solutionBlock"); const solutionText = document.getElementById("solutionText"); let binarySamples = []; let threeClassSamples = []; function fmt(num, digits = 2) { return Number(num).toFixed(digits); } function round3(num) { return Number(num).toFixed(3); } function round2(num) { return Math.round(num * 100) / 100; } function rand(min, max) { return min + Math.random() * (max - min); } function generateBinarySamples() { const actualPattern = ["Positive", "Negative", "Positive", "Negative", "Positive", "Negative"]; const samples = []; for (let i = 0; i < 6; i += 1) { const actual = actualPattern[i]; const feature = round2(rand(0.2, 2.6)); const pPos = actual === "Positive" ? round2(rand(0.38, 0.92)) : round2(rand(0.12, 0.78)); samples.push({ id: `S${i + 1}`, feature, actual, pPos }); } binarySamples = samples; } function generateThreeProbabilities(actual) { const labels = ["A", "B", "C"]; const weights = { A: rand(0.1, 0.45), B: rand(0.1, 0.45), C: rand(0.1, 0.45), }; weights[actual] += rand(0.18, 0.5); const total = labels.reduce((sum, label) => sum + weights[label], 0); let pA = round2(weights.A / total); let pB = round2(weights.B / total); if (pA + pB > 0.98) { const scale = 0.98 / (pA + pB); pA = round2(pA * scale); pB = round2(pB * scale); } const pC = round2(1 - pA - pB); return { pA, pB, pC }; } function generateThreeClassSamples() { const actualPattern = ["A", "B", "C", "B", "C", "A"]; const samples = []; for (let i = 0; i < 6; i += 1) { const actual = actualPattern[i]; const feature = round2(rand(0.5, 2.4)); const probs = generateThreeProbabilities(actual); samples.push({ id: `S${i + 1}`, feature, actual, ...probs }); } threeClassSamples = samples; } function generateAllSamples() { generateBinarySamples(); generateThreeClassSamples(); } function computeBinaryPrediction(sample, threshold) { return sample.pPos >= threshold ? "Positive" : "Negative"; } function computeThreePrediction(sample, threshold) { if (sample.pA >= threshold) return "A"; if (sample.pB >= threshold) return "B"; return "C"; } function computeBinaryResults(threshold) { const rows = binarySamples.map((sample) => { const predicted = computeBinaryPrediction(sample, threshold); return { ...sample, predicted }; }); let tp = 0; let fp = 0; let tn = 0; let fn = 0; for (const row of rows) { if (row.actual === "Positive" && row.predicted === "Positive") tp += 1; if (row.actual === "Negative" && row.predicted === "Positive") fp += 1; if (row.actual === "Negative" && row.predicted === "Negative") tn += 1; if (row.actual === "Positive" && row.predicted === "Negative") fn += 1; } const precision = tp + fp === 0 ? 0 : tp / (tp + fp); const recall = tp + fn === 0 ? 0 : tp / (tp + fn); const sensitivity = recall; const specificity = tn + fp === 0 ? 0 : tn / (tn + fp); return { rows, matrix: { Positive: { Positive: tp, Negative: fn }, Negative: { Positive: fp, Negative: tn }, }, metrics: { precision, recall, sensitivity, specificity }, }; } function getOneVsRestMetrics(matrix, className) { const classes = ["A", "B", "C"]; const tp = matrix[className][className]; let fp = 0; let fn = 0; for (const c of classes) { if (c !== className) { fp += matrix[c][className]; fn += matrix[className][c]; } } const total = classes.reduce((sum, r) => sum + classes.reduce((acc, c) => acc + matrix[r][c], 0), 0); const tn = total - tp - fp - fn; const precision = tp + fp === 0 ? 0 : tp / (tp + fp); const recall = tp + fn === 0 ? 0 : tp / (tp + fn); const specificity = tn + fp === 0 ? 0 : tn / (tn + fp); return { precision, recall, sensitivity: recall, specificity }; } function computeThreeResults(threshold) { const rows = threeClassSamples.map((sample) => { const predicted = computeThreePrediction(sample, threshold); return { ...sample, predicted }; }); const classes = ["A", "B", "C"]; const matrix = { A: { A: 0, B: 0, C: 0 }, B: { A: 0, B: 0, C: 0 }, C: { A: 0, B: 0, C: 0 } }; for (const row of rows) { matrix[row.actual][row.predicted] += 1; } const perClass = classes.map((className) => getOneVsRestMetrics(matrix, className)); const macro = { precision: perClass.reduce((s, m) => s + m.precision, 0) / classes.length, recall: perClass.reduce((s, m) => s + m.recall, 0) / classes.length, sensitivity: perClass.reduce((s, m) => s + m.sensitivity, 0) / classes.length, specificity: perClass.reduce((s, m) => s + m.specificity, 0) / classes.length, }; return { rows, matrix, metrics: macro, perClass }; } function getCurrentResults() { const mode = controls.classMode.value; const t = Number(controls.threshold.value); return mode === "binary" ? computeBinaryResults(t) : computeThreeResults(t); } function samplePredictionSelect(rowId, options) { return ``; } function renderSamples(results) { const mode = controls.classMode.value; const rows = results.rows; if (mode === "binary") { sampleArea.innerHTML = `

1) Predict the class for each sample

${rows .map((row, index) => { if (index < 2) { return ` `; } return ` `; }) .join("")}
Sample Feature x Actual Class p(Positive) Your Predicted Class
${row.id} ${fmt(row.feature)} ${row.actual} ${fmt(row.pPos)} ${row.predicted} (worked example)
${row.id} ${fmt(row.feature)} ${row.actual} ${fmt(row.pPos)} ${samplePredictionSelect(row.id, ["Positive", "Negative"])}
`; return; } sampleArea.innerHTML = `

1) Predict the class for each sample

${rows .map((row, index) => { if (index < 2) { return ` `; } return ` `; }) .join("")}
Sample Feature x Actual Class p(A) p(B) p(C) Your Predicted Class
${row.id} ${fmt(row.feature)} ${row.actual} ${fmt(row.pA)} ${fmt(row.pB)} ${fmt(row.pC)} ${row.predicted} (worked example)
${row.id} ${fmt(row.feature)} ${row.actual} ${fmt(row.pA)} ${fmt(row.pB)} ${fmt(row.pC)} ${samplePredictionSelect(row.id, ["A", "B", "C"])}
`; } function renderMatrixInputs() { const mode = controls.classMode.value; if (mode === "binary") { matrixArea.innerHTML = `

2) Fill the confusion matrix

Rows = Actual class, Columns = Predicted class.

Actual \ PredictedPositiveNegative
Positive
Negative
`; return; } matrixArea.innerHTML = `

2) Fill the confusion matrix

Rows = Actual class, Columns = Predicted class.

Actual \ PredictedABC
A
B
C
`; } function renderMetricInputs() { const mode = controls.classMode.value; const detail = mode === "binary" ? "(for Positive class)" : "(macro-average across A, B, C)"; metricArea.innerHTML = `

3) Calculate metrics ${detail}

Enter decimal values (e.g., 0.667).

MetricYour value
Precision
Recall
Sensitivity
Specificity
`; } function updateRuleText() { const mode = controls.classMode.value; const threshold = Number(controls.threshold.value); controls.thresholdValue.textContent = fmt(threshold); if (mode === "binary") { controls.ruleText.textContent = "Rule: predict Positive if p(Positive) >= t, otherwise predict Negative. (Default t = 0.50)"; } else { controls.ruleText.textContent = "Rule: check A first, then B. If p(A) >= t predict A; else if p(B) >= t predict B; otherwise predict C."; } } function renderPage() { const results = getCurrentResults(); updateRuleText(); renderSamples(results); renderMatrixInputs(); renderMetricInputs(); feedbackStats.innerHTML = ""; } function readUserSampleAnswers() { const selects = [...document.querySelectorAll('select[data-role="sample-pred"]')]; const map = {}; for (const select of selects) { map[select.dataset.id] = select.value; } return map; } function readUserMatrix() { const inputs = [...document.querySelectorAll('input[data-role="matrix"]')]; const matrix = {}; for (const input of inputs) { matrix[input.dataset.key] = input.value === "" ? NaN : Number(input.value); } return matrix; } function readUserMetrics() { const inputs = [...document.querySelectorAll('input[data-role="metric"]')]; const metrics = {}; for (const input of inputs) { metrics[input.dataset.key] = input.value === "" ? NaN : Number(input.value); } return metrics; } function expectedMatrixFlat(results) { const mode = controls.classMode.value; if (mode === "binary") { return { "Positive-Positive": results.matrix.Positive.Positive, "Positive-Negative": results.matrix.Positive.Negative, "Negative-Positive": results.matrix.Negative.Positive, "Negative-Negative": results.matrix.Negative.Negative, }; } return { "A-A": results.matrix.A.A, "A-B": results.matrix.A.B, "A-C": results.matrix.A.C, "B-A": results.matrix.B.A, "B-B": results.matrix.B.B, "B-C": results.matrix.B.C, "C-A": results.matrix.C.A, "C-B": results.matrix.C.B, "C-C": results.matrix.C.C, }; } function approximatelyEqual(a, b, eps = 0.02) { return Math.abs(a - b) <= eps; } function checkAnswers() { const results = getCurrentResults(); const expectedPreds = {}; for (const row of results.rows.slice(2)) { expectedPreds[row.id] = row.predicted; } const userPreds = readUserSampleAnswers(); let predCorrect = 0; const predTotal = Object.keys(expectedPreds).length; for (const id of Object.keys(expectedPreds)) { if (userPreds[id] === expectedPreds[id]) { predCorrect += 1; } } const userMatrix = readUserMatrix(); const expectedMatrix = expectedMatrixFlat(results); let matrixCorrect = 0; const matrixTotal = Object.keys(expectedMatrix).length; for (const key of Object.keys(expectedMatrix)) { if (userMatrix[key] === expectedMatrix[key]) { matrixCorrect += 1; } } const userMetrics = readUserMetrics(); const expectedMetrics = results.metrics; let metricCorrect = 0; const metricTotal = 4; for (const metricName of ["precision", "recall", "sensitivity", "specificity"]) { if (approximatelyEqual(userMetrics[metricName], expectedMetrics[metricName])) { metricCorrect += 1; } } feedbackStats.innerHTML = ` Prediction check: ${predCorrect}/${predTotal} correct Confusion matrix check: ${matrixCorrect}/${matrixTotal} correct Metric check: ${metricCorrect}/${metricTotal} correct (tolerance +/- 0.02) Expected metrics: Precision=${round3(expectedMetrics.precision)}, Recall=${round3( expectedMetrics.recall, )}, Sensitivity=${round3(expectedMetrics.sensitivity)}, Specificity=${round3(expectedMetrics.specificity)} `; } function renderBinarySolution(results) { const steps = results.rows .map((row) => { return `
  • ${row.id}: p(Positive)=${fmt(row.pPos)}; compare with t=${fmt( controls.threshold.value, )} => predicted ${row.predicted}; actual ${row.actual}.
  • `; }) .join(""); solutionText.innerHTML = `

    Step A: Classify all 6 samples

      ${steps}

    Step B: Build confusion matrix (rows=actual, columns=predicted)

    TP=${results.matrix.Positive.Positive}, FN=${results.matrix.Positive.Negative}, FP=${results.matrix.Negative.Positive}, TN=${results.matrix.Negative.Negative}

    Actual \\ PredictedPositiveNegative
    Positive${results.matrix.Positive.Positive}${results.matrix.Positive.Negative}
    Negative${results.matrix.Negative.Positive}${results.matrix.Negative.Negative}

    Step C: Metrics

    Precision = TP/(TP+FP) = ${results.matrix.Positive.Positive}/(${results.matrix.Positive.Positive}+${results.matrix.Negative.Positive}) = ${round3( results.metrics.precision, )}

    Recall = TP/(TP+FN) = ${results.matrix.Positive.Positive}/(${results.matrix.Positive.Positive}+${results.matrix.Positive.Negative}) = ${round3( results.metrics.recall, )}

    Sensitivity = Recall = ${round3(results.metrics.sensitivity)}

    Specificity = TN/(TN+FP) = ${results.matrix.Negative.Negative}/(${results.matrix.Negative.Negative}+${results.matrix.Negative.Positive}) = ${round3( results.metrics.specificity, )}

    `; } function renderThreeSolution(results) { const threshold = Number(controls.threshold.value); const steps = results.rows .map((row) => { return `
  • ${row.id}: p(A)=${fmt(row.pA)}, p(B)=${fmt(row.pB)}, p(C)=${fmt( row.pC, )}. Since t=${fmt(threshold)}, prediction is ${row.predicted}; actual ${row.actual}.
  • `; }) .join(""); const classes = ["A", "B", "C"]; const perClass = classes .map((c, idx) => { return `
  • Class ${c}: Precision=${round3(results.perClass[idx].precision)}, Recall/Sensitivity=${round3( results.perClass[idx].recall, )}, Specificity=${round3(results.perClass[idx].specificity)}
  • `; }) .join(""); solutionText.innerHTML = `

    Step A: Classify all 6 samples

      ${steps}

    Step B: Build 3x3 confusion matrix (rows=actual, columns=predicted)

    Actual \\ PredictedABC
    A${results.matrix.A.A}${results.matrix.A.B}${results.matrix.A.C}
    B${results.matrix.B.A}${results.matrix.B.B}${results.matrix.B.C}
    C${results.matrix.C.A}${results.matrix.C.B}${results.matrix.C.C}

    Step C: One-vs-rest metrics for each class, then macro-average

      ${perClass}

    Macro Precision = ${round3(results.metrics.precision)}

    Macro Recall = ${round3(results.metrics.recall)}

    Macro Sensitivity = ${round3(results.metrics.sensitivity)}

    Macro Specificity = ${round3(results.metrics.specificity)}

    `; } function showSolution() { const results = getCurrentResults(); if (controls.classMode.value === "binary") { renderBinarySolution(results); } else { renderThreeSolution(results); } solutionBlock.hidden = false; } function resetInputs() { renderPage(); solutionBlock.hidden = true; } function regenerateSamples() { generateAllSamples(); resetInputs(); } controls.classMode.addEventListener("change", resetInputs); controls.threshold.addEventListener("input", resetInputs); controls.checkBtn.addEventListener("click", checkAnswers); controls.showSolutionBtn.addEventListener("click", showSolution); controls.newSamplesBtn.addEventListener("click", regenerateSamples); controls.resetBtn.addEventListener("click", resetInputs); generateAllSamples(); renderPage();