Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"/> | |
| <title>Logistic Regression Assumption Checker</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{background:#0d1117;color:#c9d1d9;font-family:'Segoe UI',system-ui,sans-serif;overflow-x:hidden} | |
| .header{background:linear-gradient(135deg,#161b22,#1a2332);padding:18px 24px;border-bottom:1px solid #30363d;display:flex;align-items:center;gap:16px;flex-wrap:wrap} | |
| .header h1{font-size:1.35rem;color:#58d68d;font-weight:700} | |
| .header .subtitle{font-size:.82rem;color:#8b949e} | |
| .badge{background:#1e3a5f;color:#58a6ff;padding:2px 10px;border-radius:12px;font-size:.7rem;font-weight:600} | |
| .main{display:grid;grid-template-columns:340px 1fr;gap:0;height:calc(100vh - 64px)} | |
| .sidebar{background:#161b22;border-right:1px solid #30363d;padding:16px;overflow-y:auto} | |
| .panel{background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:14px} | |
| .panel-title{font-size:.85rem;font-weight:700;color:#58a6ff;margin-bottom:10px;display:flex;align-items:center;gap:6px} | |
| .panel-title .icon{width:18px;height:18px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:.7rem;font-weight:800} | |
| label{display:block;font-size:.75rem;color:#8b949e;margin-bottom:3px;margin-top:8px} | |
| label:first-of-type{margin-top:0} | |
| input[type=number],input[type=range],select{width:100%;background:#0d1117;border:1px solid #30363d;color:#c9d1d9;border-radius:5px;padding:6px 8px;font-size:.82rem;outline:none} | |
| input[type=number]:focus,select:focus{border-color:#58a6ff} | |
| input[type=range]{padding:0;height:6px;-webkit-appearance:none;appearance:none;border:none;border-radius:3px;background:#21262d;cursor:pointer} | |
| input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:#58a6ff;cursor:pointer} | |
| .range-row{display:flex;align-items:center;gap:8px} | |
| .range-val{font-size:.78rem;color:#58a6ff;min-width:36px;text-align:right;font-weight:600} | |
| .btn{background:linear-gradient(135deg,#238636,#2ea043);color:#fff;border:none;border-radius:6px;padding:10px 16px;font-size:.88rem;font-weight:700;cursor:pointer;width:100%;margin-top:12px;transition:transform .15s} | |
| .btn:hover{transform:scale(1.02)} | |
| .btn:active{transform:scale(.98)} | |
| .content{padding:20px;overflow-y:auto} | |
| /* Traffic light dashboard */ | |
| .dashboard{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px} | |
| .card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:14px;text-align:center;transition:border-color .3s,box-shadow .3s} | |
| .card.green{border-color:#238636;box-shadow:0 0 12px #23863622} | |
| .card.yellow{border-color:#d29922;box-shadow:0 0 12px #d2992222} | |
| .card.red{border-color:#da3633;box-shadow:0 0 12px #da363322} | |
| .card .light{width:36px;height:36px;border-radius:50%;margin:0 auto 8px;display:flex;align-items:center;justify-content:center;font-size:1.1rem;font-weight:800} | |
| .card.green .light{background:radial-gradient(circle,#58d68d,#238636);color:#0d1117} | |
| .card.yellow .light{background:radial-gradient(circle,#f0c040,#d29922);color:#0d1117} | |
| .card.red .light{background:radial-gradient(circle,#f85149,#da3633);color:#fff} | |
| .card .name{font-size:.78rem;font-weight:700;margin-bottom:3px} | |
| .card .detail{font-size:.68rem;color:#8b949e} | |
| /* Overall score */ | |
| .score-bar{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:16px;margin-bottom:20px;display:flex;align-items:center;gap:16px} | |
| .score-circle{width:64px;height:64px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:1.5rem;font-weight:800;flex-shrink:0} | |
| .score-info h2{font-size:1rem;margin-bottom:4px} | |
| .score-info p{font-size:.78rem;color:#8b949e} | |
| /* Charts area */ | |
| .charts{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px} | |
| .chart-box{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:14px} | |
| .chart-box h3{font-size:.82rem;color:#58a6ff;margin-bottom:8px;text-align:center} | |
| canvas{width:100%!important;height:auto!important} | |
| /* Correlation matrix input */ | |
| .corr-grid{display:grid;gap:3px;margin-top:6px} | |
| .corr-cell{width:100%;background:#0d1117;border:1px solid #30363d;color:#c9d1d9;border-radius:3px;padding:3px;font-size:.72rem;text-align:center} | |
| .corr-cell.diag{background:#1e3a5f;color:#58a6ff;font-weight:700} | |
| .corr-label{font-size:.68rem;color:#8b949e;text-align:center;font-weight:600} | |
| /* Recommendations */ | |
| .recs{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:16px} | |
| .recs h3{font-size:.88rem;color:#f0c040;margin-bottom:10px} | |
| .rec-item{display:flex;gap:8px;margin-bottom:8px;font-size:.78rem;align-items:flex-start} | |
| .rec-icon{flex-shrink:0;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:800} | |
| .rec-icon.warn{background:#d29922;color:#0d1117} | |
| .rec-icon.fix{background:#da3633;color:#fff} | |
| .rec-icon.ok{background:#238636;color:#fff} | |
| /* Responsive */ | |
| @media(max-width:900px){.main{grid-template-columns:1fr}.sidebar{border-right:none;border-bottom:1px solid #30363d}} | |
| @media(max-width:600px){.dashboard{grid-template-columns:repeat(2,1fr)}.charts{grid-template-columns:1fr}} | |
| .credit{text-align:center;padding:10px;font-size:.68rem;color:#484f58;border-top:1px solid #30363d} | |
| .credit a{color:#58a6ff;text-decoration:none} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1>Logistic Regression Assumption Checker</h1> | |
| <span class="badge">ML-II Ch03 S4</span> | |
| <span class="subtitle">Enter your dataset properties and get instant diagnostics</span> | |
| </div> | |
| <div class="main"> | |
| <!-- SIDEBAR: Inputs --> | |
| <div class="sidebar"> | |
| <div class="panel" style="border-color:#58d68d"> | |
| <div class="panel-title" style="color:#58d68d"><div class="icon" style="background:#58d68d;color:#0d1117">★</div> Load Example Dataset</div> | |
| <label>Choose a preset to auto-fill all fields</label> | |
| <select id="presetDataset" onchange="loadPreset(this.value)" style="border-color:#58d68d"> | |
| <option value="">-- Select a dataset --</option> | |
| <option value="titanic">Titanic Survival (clean, well-behaved)</option> | |
| <option value="credit">Credit Card Default (large, imbalanced)</option> | |
| <option value="heart">Heart Disease UCI (small, clinical)</option> | |
| <option value="churn">Telecom Customer Churn (collinear features)</option> | |
| <option value="medical">ICU Readmission (repeated measures, messy)</option> | |
| </select> | |
| <div id="presetDesc" style="font-size:.7rem;color:#8b949e;margin-top:6px;min-height:28px"></div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-title"><div class="icon" style="background:#238636;color:#fff">1</div> Dataset Properties</div> | |
| <label>Number of features (predictors)</label> | |
| <input type="number" id="nFeatures" value="5" min="1" max="200"/> | |
| <label>Total sample size (n)</label> | |
| <input type="number" id="nSamples" value="200" min="2" max="1000000"/> | |
| <label>Number of positive events (Y=1)</label> | |
| <input type="number" id="nEvents" value="40" min="0" max="1000000"/> | |
| <label>Outcome type</label> | |
| <select id="outcomeType"> | |
| <option value="binary">Binary (0/1)</option> | |
| <option value="ordinal">Ordinal (3+ ordered)</option> | |
| <option value="nominal">Nominal (3+ unordered)</option> | |
| <option value="continuous">Continuous</option> | |
| </select> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-title"><div class="icon" style="background:#d29922;color:#0d1117">2</div> Independence</div> | |
| <label>Data collection method</label> | |
| <select id="independence"> | |
| <option value="independent">Cross-sectional (independent)</option> | |
| <option value="repeated">Repeated measures / longitudinal</option> | |
| <option value="clustered">Clustered / grouped (e.g., students in schools)</option> | |
| <option value="timeseries">Time series</option> | |
| </select> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-title"><div class="icon" style="background:#da3633;color:#fff">3</div> Multicollinearity</div> | |
| <label>Highest VIF among features</label> | |
| <div class="range-row"> | |
| <input type="range" id="maxVIF" min="1" max="30" step="0.5" value="2"/> | |
| <span class="range-val" id="vifVal">2.0</span> | |
| </div> | |
| <label>Highest pairwise |correlation|</label> | |
| <div class="range-row"> | |
| <input type="range" id="maxCorr" min="0" max="1" step="0.01" value="0.3"/> | |
| <span class="range-val" id="corrVal">0.30</span> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-title"><div class="icon" style="background:#58a6ff;color:#0d1117">4</div> Linearity in Log-Odds</div> | |
| <label>Have you checked the Box-Tidwell test?</label> | |
| <select id="boxTidwell"> | |
| <option value="not_checked">Not checked yet</option> | |
| <option value="passed">Yes — all features pass</option> | |
| <option value="some_fail">Some features fail</option> | |
| <option value="many_fail">Most features fail</option> | |
| </select> | |
| <label>Lowess / GAM smoothed plots</label> | |
| <select id="lowess"> | |
| <option value="not_checked">Not checked yet</option> | |
| <option value="linear">Plots look linear</option> | |
| <option value="mild_curve">Mild curvature</option> | |
| <option value="strong_curve">Strong curvature / U-shapes</option> | |
| </select> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-title"><div class="icon" style="background:#bc8cff;color:#0d1117">5</div> Outliers</div> | |
| <label>Outlier situation</label> | |
| <select id="outliers"> | |
| <option value="none">No extreme outliers</option> | |
| <option value="few">A few mild outliers</option> | |
| <option value="some">Some influential points (Cook's D > 1)</option> | |
| <option value="extreme">Extreme values present</option> | |
| </select> | |
| </div> | |
| <button class="btn" onclick="runCheck()">Run Assumption Check</button> | |
| </div> | |
| <!-- CONTENT: Results --> | |
| <div class="content" id="results"> | |
| <div style="text-align:center;padding:80px 20px;color:#484f58"> | |
| <div style="font-size:3rem;margin-bottom:16px">⚙</div> | |
| <p style="font-size:1rem;color:#8b949e">Configure your dataset properties on the left,<br/>then click <strong style="color:#58d68d">Run Assumption Check</strong> to see results.</p> | |
| <p style="font-size:.78rem;margin-top:12px">Part of <em>ML-II: Supervised Learning Classification</em> by Dr Milan Amrut Joshi</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="credit"> | |
| ML-II Book · Chapter 03 Section 4 · Dr Milan Amrut Joshi · | |
| <a href="https://huggingface.co/mlnjsh" target="_blank">huggingface.co/mlnjsh</a> | |
| </div> | |
| <script> | |
| // ═══════════════════════════════════════ | |
| // PRESET DATASETS | |
| // ═══════════════════════════════════════ | |
| const PRESETS = { | |
| titanic: { | |
| desc: 'Classic ML dataset. 891 passengers, binary survival outcome, well-separated features, no major violations. A textbook example of when logistic regression works well.', | |
| nFeatures: 6, nSamples: 891, nEvents: 342, | |
| outcomeType: 'binary', independence: 'independent', | |
| maxVIF: 1.8, maxCorr: 0.31, | |
| boxTidwell: 'passed', lowess: 'linear', | |
| outliers: 'few' | |
| }, | |
| credit: { | |
| desc: 'Large-scale financial dataset. 30,000 clients, 23 features (payment history, bill amounts, demographics). High class imbalance (22% default). Some features are correlated (bill amounts across months).', | |
| nFeatures: 23, nSamples: 30000, nEvents: 6636, | |
| outcomeType: 'binary', independence: 'independent', | |
| maxVIF: 7.5, maxCorr: 0.79, | |
| boxTidwell: 'some_fail', lowess: 'mild_curve', | |
| outliers: 'some' | |
| }, | |
| heart: { | |
| desc: 'UCI Heart Disease dataset. 303 patients, 13 clinical features (age, cholesterol, blood pressure, etc.). Small sample, but features are mostly independent. Good for teaching.', | |
| nFeatures: 13, nSamples: 303, nEvents: 138, | |
| outcomeType: 'binary', independence: 'independent', | |
| maxVIF: 2.5, maxCorr: 0.43, | |
| boxTidwell: 'passed', lowess: 'linear', | |
| outliers: 'few' | |
| }, | |
| churn: { | |
| desc: 'Telecom churn prediction. 7,043 customers, 19 features including monthly charges, total charges, tenure, and contract type. Total charges and tenure are highly correlated (VIF > 12).', | |
| nFeatures: 19, nSamples: 7043, nEvents: 1869, | |
| outcomeType: 'binary', independence: 'independent', | |
| maxVIF: 12.5, maxCorr: 0.83, | |
| boxTidwell: 'some_fail', lowess: 'mild_curve', | |
| outliers: 'few' | |
| }, | |
| medical: { | |
| desc: 'ICU readmission within 30 days. 1,200 patient encounters from 480 unique patients (repeated admissions). Only 8 features but very few positive events (72 readmissions). Multiple assumption violations.', | |
| nFeatures: 8, nSamples: 1200, nEvents: 72, | |
| outcomeType: 'binary', independence: 'repeated', | |
| maxVIF: 3.2, maxCorr: 0.52, | |
| boxTidwell: 'not_checked', lowess: 'strong_curve', | |
| outliers: 'extreme' | |
| } | |
| }; | |
| function loadPreset(key) { | |
| if (!key || !PRESETS[key]) { | |
| document.getElementById('presetDesc').textContent = ''; | |
| return; | |
| } | |
| const p = PRESETS[key]; | |
| document.getElementById('presetDesc').textContent = p.desc; | |
| // Fill all fields | |
| document.getElementById('nFeatures').value = p.nFeatures; | |
| document.getElementById('nSamples').value = p.nSamples; | |
| document.getElementById('nEvents').value = p.nEvents; | |
| document.getElementById('outcomeType').value = p.outcomeType; | |
| document.getElementById('independence').value = p.independence; | |
| document.getElementById('maxVIF').value = p.maxVIF; | |
| document.getElementById('vifVal').textContent = p.maxVIF.toFixed(1); | |
| document.getElementById('maxCorr').value = p.maxCorr; | |
| document.getElementById('corrVal').textContent = p.maxCorr.toFixed(2); | |
| document.getElementById('boxTidwell').value = p.boxTidwell; | |
| document.getElementById('lowess').value = p.lowess; | |
| document.getElementById('outliers').value = p.outliers; | |
| // Auto-run the check | |
| runCheck(); | |
| } | |
| // Slider value display | |
| document.getElementById('maxVIF').addEventListener('input', e => { | |
| document.getElementById('vifVal').textContent = parseFloat(e.target.value).toFixed(1); | |
| }); | |
| document.getElementById('maxCorr').addEventListener('input', e => { | |
| document.getElementById('corrVal').textContent = parseFloat(e.target.value).toFixed(2); | |
| }); | |
| function runCheck() { | |
| const nF = parseInt(document.getElementById('nFeatures').value) || 1; | |
| const nS = parseInt(document.getElementById('nSamples').value) || 10; | |
| const nE = parseInt(document.getElementById('nEvents').value) || 0; | |
| const outcome = document.getElementById('outcomeType').value; | |
| const indep = document.getElementById('independence').value; | |
| const vif = parseFloat(document.getElementById('maxVIF').value); | |
| const corr = parseFloat(document.getElementById('maxCorr').value); | |
| const bt = document.getElementById('boxTidwell').value; | |
| const low = document.getElementById('lowess').value; | |
| const outl = document.getElementById('outliers').value; | |
| const epp = nF > 0 ? nE / nF : 999; // events per predictor | |
| const nNeg = nS - nE; | |
| const eppNeg = nF > 0 ? nNeg / nF : 999; | |
| const minEPP = Math.min(epp, eppNeg); | |
| // ── Evaluate each assumption ── | |
| const results = []; | |
| // 1. Binary outcome | |
| if (outcome === 'binary') { | |
| results.push({ name: 'Binary Outcome', status: 'green', detail: 'Y is binary (0/1) — perfect.', score: 100 }); | |
| } else if (outcome === 'ordinal') { | |
| results.push({ name: 'Binary Outcome', status: 'yellow', detail: 'Ordinal Y — consider ordinal logistic regression.', score: 50 }); | |
| } else if (outcome === 'nominal') { | |
| results.push({ name: 'Binary Outcome', status: 'yellow', detail: 'Nominal Y — use multinomial logistic regression.', score: 40 }); | |
| } else { | |
| results.push({ name: 'Binary Outcome', status: 'red', detail: 'Continuous Y — logistic regression is not appropriate.', score: 0 }); | |
| } | |
| // 2. Independence | |
| if (indep === 'independent') { | |
| results.push({ name: 'Independence', status: 'green', detail: 'Cross-sectional data — observations are independent.', score: 100 }); | |
| } else if (indep === 'clustered') { | |
| results.push({ name: 'Independence', status: 'yellow', detail: 'Clustered data — consider GEE or mixed-effects logistic.', score: 40 }); | |
| } else if (indep === 'repeated') { | |
| results.push({ name: 'Independence', status: 'red', detail: 'Repeated measures — standard LR will give wrong SEs.', score: 15 }); | |
| } else { | |
| results.push({ name: 'Independence', status: 'red', detail: 'Time series — observations are autocorrelated.', score: 10 }); | |
| } | |
| // 3. Multicollinearity | |
| let mcScore = 100; | |
| let mcStatus = 'green'; | |
| let mcDetail = `Max VIF = ${vif.toFixed(1)}, Max |r| = ${corr.toFixed(2)} — no issues.`; | |
| if (vif > 10 || corr > 0.9) { | |
| mcScore = 10; mcStatus = 'red'; | |
| mcDetail = `VIF=${vif.toFixed(1)}, |r|=${corr.toFixed(2)} — severe multicollinearity!`; | |
| } else if (vif > 5 || corr > 0.7) { | |
| mcScore = 45; mcStatus = 'yellow'; | |
| mcDetail = `VIF=${vif.toFixed(1)}, |r|=${corr.toFixed(2)} — moderate concern.`; | |
| } else if (vif > 3 || corr > 0.5) { | |
| mcScore = 70; mcStatus = 'yellow'; | |
| mcDetail = `VIF=${vif.toFixed(1)}, |r|=${corr.toFixed(2)} — mild, probably OK.`; | |
| } | |
| results.push({ name: 'No Multicollinearity', status: mcStatus, detail: mcDetail, score: mcScore }); | |
| // 4. Linearity in log-odds | |
| let linScore = 100, linStatus = 'green', linDetail = ''; | |
| if (bt === 'not_checked' && low === 'not_checked') { | |
| linScore = 60; linStatus = 'yellow'; | |
| linDetail = 'Not tested yet — run Box-Tidwell or check lowess plots.'; | |
| } else if (bt === 'many_fail' || low === 'strong_curve') { | |
| linScore = 10; linStatus = 'red'; | |
| linDetail = 'Strong non-linearity detected — add polynomial terms or use splines.'; | |
| } else if (bt === 'some_fail' || low === 'mild_curve') { | |
| linScore = 50; linStatus = 'yellow'; | |
| linDetail = 'Some non-linearity — consider transformations for affected features.'; | |
| } else { | |
| linDetail = 'Linearity tests passed — log-odds relationship looks linear.'; | |
| } | |
| results.push({ name: 'Linear Log-Odds', status: linStatus, detail: linDetail, score: linScore }); | |
| // 5. Sample size | |
| let ssScore = 100, ssStatus = 'green', ssDetail = ''; | |
| if (minEPP >= 20) { | |
| ssDetail = `${minEPP.toFixed(0)} events/feature — excellent sample size.`; | |
| } else if (minEPP >= 10) { | |
| ssScore = 75; ssStatus = 'green'; | |
| ssDetail = `${minEPP.toFixed(0)} events/feature — adequate (10+ rule met).`; | |
| } else if (minEPP >= 5) { | |
| ssScore = 40; ssStatus = 'yellow'; | |
| ssDetail = `${minEPP.toFixed(1)} events/feature — borderline, coefficients may be biased.`; | |
| } else { | |
| ssScore = 10; ssStatus = 'red'; | |
| ssDetail = `${minEPP.toFixed(1)} events/feature — too few! MLE may not converge.`; | |
| } | |
| results.push({ name: 'Sample Size', status: ssStatus, detail: ssDetail, score: ssScore }); | |
| // 6. Outliers | |
| if (outl === 'none') { | |
| results.push({ name: 'No Extreme Outliers', status: 'green', detail: 'No extreme outliers — model is stable.', score: 100 }); | |
| } else if (outl === 'few') { | |
| results.push({ name: 'No Extreme Outliers', status: 'green', detail: 'A few mild outliers — usually acceptable.', score: 85 }); | |
| } else if (outl === 'some') { | |
| results.push({ name: 'No Extreme Outliers', status: 'yellow', detail: 'Influential points detected — investigate Cook\'s D.', score: 45 }); | |
| } else { | |
| results.push({ name: 'No Extreme Outliers', status: 'red', detail: 'Extreme values present — may distort coefficients.', score: 15 }); | |
| } | |
| // Overall score | |
| const avgScore = Math.round(results.reduce((s, r) => s + r.score, 0) / results.length); | |
| // Generate recommendations | |
| const recs = []; | |
| results.forEach(r => { | |
| if (r.status === 'red') { | |
| recs.push({ level: 'fix', text: `<strong>${r.name}:</strong> ${r.detail}` }); | |
| } else if (r.status === 'yellow') { | |
| recs.push({ level: 'warn', text: `<strong>${r.name}:</strong> ${r.detail}` }); | |
| } | |
| }); | |
| if (recs.length === 0) { | |
| recs.push({ level: 'ok', text: 'All assumptions look good! Your model should produce reliable results.' }); | |
| } | |
| // ── Render results ── | |
| let overallColor = avgScore >= 75 ? '#58d68d' : avgScore >= 50 ? '#f0c040' : '#f85149'; | |
| let overallBg = avgScore >= 75 ? '#23863644' : avgScore >= 50 ? '#d2992244' : '#da363344'; | |
| let overallLabel = avgScore >= 75 ? 'Good to go' : avgScore >= 50 ? 'Needs attention' : 'Serious issues'; | |
| let html = ` | |
| <div class="score-bar"> | |
| <div class="score-circle" style="background:${overallBg};color:${overallColor};border:3px solid ${overallColor}">${avgScore}</div> | |
| <div class="score-info"> | |
| <h2 style="color:${overallColor}">${overallLabel}</h2> | |
| <p>Overall assumption score: ${avgScore}/100. ${results.filter(r=>r.status==='green').length} of 6 assumptions satisfied.</p> | |
| </div> | |
| </div> | |
| <div class="dashboard"> | |
| ${results.map(r => ` | |
| <div class="card ${r.status}"> | |
| <div class="light">${r.status === 'green' ? '✓' : r.status === 'yellow' ? '!' : '✗'}</div> | |
| <div class="name">${r.name}</div> | |
| <div class="detail">${r.detail}</div> | |
| </div> | |
| `).join('')} | |
| </div> | |
| <div class="charts"> | |
| <div class="chart-box"> | |
| <h3>Events-Per-Feature Analysis</h3> | |
| <canvas id="eppChart" width="400" height="260"></canvas> | |
| </div> | |
| <div class="chart-box"> | |
| <h3>Assumption Radar</h3> | |
| <canvas id="radarChart" width="400" height="260"></canvas> | |
| </div> | |
| </div> | |
| <div class="recs"> | |
| <h3>${recs.some(r => r.level === 'fix') ? 'Action Required' : recs.some(r => r.level === 'warn') ? 'Recommendations' : 'All Clear'}</h3> | |
| ${recs.map(r => ` | |
| <div class="rec-item"> | |
| <div class="rec-icon ${r.level}">${r.level === 'fix' ? '!' : r.level === 'warn' ? '?' : '✓'}</div> | |
| <div>${r.text}</div> | |
| </div> | |
| `).join('')} | |
| </div> | |
| `; | |
| document.getElementById('results').innerHTML = html; | |
| // ── Draw EPP bar chart ── | |
| drawEPPChart(nF, nE, nNeg); | |
| // ── Draw Radar chart ── | |
| drawRadarChart(results); | |
| } | |
| function drawEPPChart(nF, nE, nNeg) { | |
| const canvas = document.getElementById('eppChart'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| const W = canvas.width, H = canvas.height; | |
| ctx.clearRect(0, 0, W, H); | |
| const epp = nF > 0 ? nE / nF : 0; | |
| const eppNeg = nF > 0 ? nNeg / nF : 0; | |
| const bars = [ | |
| { label: 'Events/Feature', value: epp, color: epp >= 10 ? '#58d68d' : epp >= 5 ? '#f0c040' : '#f85149' }, | |
| { label: 'Non-Events/Feature', value: eppNeg, color: eppNeg >= 10 ? '#58d68d' : eppNeg >= 5 ? '#f0c040' : '#f85149' }, | |
| ]; | |
| const maxVal = Math.max(epp, eppNeg, 25); | |
| const barW = 60, gap = 80; | |
| const chartL = 80, chartB = 40, chartH = H - 80, chartW = W - 100; | |
| // Axes | |
| ctx.strokeStyle = '#30363d'; ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(chartL, 20); ctx.lineTo(chartL, 20 + chartH); ctx.lineTo(chartL + chartW, 20 + chartH); ctx.stroke(); | |
| // Threshold lines | |
| [10, 20].forEach(thresh => { | |
| const y = 20 + chartH - (thresh / maxVal) * chartH; | |
| ctx.strokeStyle = thresh === 10 ? '#da363366' : '#23863666'; | |
| ctx.setLineDash([4, 4]); | |
| ctx.beginPath(); ctx.moveTo(chartL, y); ctx.lineTo(chartL + chartW, y); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle = thresh === 10 ? '#da3633' : '#238636'; | |
| ctx.font = '11px system-ui'; | |
| ctx.fillText(thresh === 10 ? 'Min (10)' : 'Good (20)', chartL + chartW - 52, y - 4); | |
| }); | |
| // Bars | |
| bars.forEach((b, i) => { | |
| const x = chartL + 50 + i * (barW + gap); | |
| const barH = (Math.min(b.value, maxVal) / maxVal) * chartH; | |
| const y = 20 + chartH - barH; | |
| // Gradient fill | |
| const grad = ctx.createLinearGradient(x, y, x, 20 + chartH); | |
| grad.addColorStop(0, b.color); | |
| grad.addColorStop(1, b.color + '44'); | |
| ctx.fillStyle = grad; | |
| ctx.beginPath(); | |
| ctx.roundRect(x, y, barW, barH, [4, 4, 0, 0]); | |
| ctx.fill(); | |
| // Value label | |
| ctx.fillStyle = '#c9d1d9'; | |
| ctx.font = 'bold 13px system-ui'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(b.value.toFixed(1), x + barW / 2, y - 8); | |
| // Bar label | |
| ctx.fillStyle = '#8b949e'; | |
| ctx.font = '10px system-ui'; | |
| ctx.fillText(b.label, x + barW / 2, 20 + chartH + 16); | |
| }); | |
| ctx.textAlign = 'start'; | |
| } | |
| function drawRadarChart(results) { | |
| const canvas = document.getElementById('radarChart'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| const W = canvas.width, H = canvas.height; | |
| ctx.clearRect(0, 0, W, H); | |
| const cx = W / 2, cy = H / 2 + 5; | |
| const R = Math.min(W, H) / 2 - 35; | |
| const n = results.length; | |
| const angles = results.map((_, i) => (Math.PI * 2 * i) / n - Math.PI / 2); | |
| // Grid rings | |
| [0.25, 0.5, 0.75, 1.0].forEach(frac => { | |
| ctx.strokeStyle = '#21262d'; | |
| ctx.lineWidth = 0.8; | |
| ctx.beginPath(); | |
| for (let i = 0; i <= n; i++) { | |
| const a = (Math.PI * 2 * (i % n)) / n - Math.PI / 2; | |
| const x = cx + Math.cos(a) * R * frac; | |
| const y = cy + Math.sin(a) * R * frac; | |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); | |
| } | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| }); | |
| // Axis lines | |
| angles.forEach(a => { | |
| ctx.strokeStyle = '#21262d'; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, cy); | |
| ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R); | |
| ctx.stroke(); | |
| }); | |
| // Data polygon | |
| ctx.fillStyle = '#58a6ff22'; | |
| ctx.strokeStyle = '#58a6ff'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| results.forEach((r, i) => { | |
| const frac = r.score / 100; | |
| const x = cx + Math.cos(angles[i]) * R * frac; | |
| const y = cy + Math.sin(angles[i]) * R * frac; | |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); | |
| }); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| // Data points | |
| results.forEach((r, i) => { | |
| const frac = r.score / 100; | |
| const x = cx + Math.cos(angles[i]) * R * frac; | |
| const y = cy + Math.sin(angles[i]) * R * frac; | |
| ctx.fillStyle = r.status === 'green' ? '#58d68d' : r.status === 'yellow' ? '#f0c040' : '#f85149'; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| }); | |
| // Labels | |
| ctx.font = '10px system-ui'; | |
| ctx.textAlign = 'center'; | |
| const shortNames = ['Binary', 'Independence', 'Multicollin.', 'Log-Odds', 'Sample Size', 'Outliers']; | |
| results.forEach((r, i) => { | |
| const lx = cx + Math.cos(angles[i]) * (R + 22); | |
| const ly = cy + Math.sin(angles[i]) * (R + 22); | |
| ctx.fillStyle = r.status === 'green' ? '#58d68d' : r.status === 'yellow' ? '#f0c040' : '#f85149'; | |
| ctx.fillText(shortNames[i] || r.name, lx, ly + 4); | |
| }); | |
| } | |
| // Run check on load with defaults | |
| window.addEventListener('load', () => { setTimeout(runCheck, 300); }); | |
| </script> | |
| </body> | |
| </html> | |