BrainAge-Training / static /dashboard.html
bilalEthizo's picture
Multi-model training (SFCN/ResNet50/EfficientNet) + full-stats dashboard
ca81a9f verified
Raw
History Blame Contribute Delete
16.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>BrainAge Multi-Model Training</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
:root{--bg:#0a0e1a;--card:#131a2c;--card2:#0f1523;--bd:#233049;--tx:#e8eefb;
--mut:#8798b8;--acc:#38bdf8;--good:#4ade80;--warn:#fbbf24;--bad:#f87171;}
*{box-sizing:border-box}
body{margin:0;font-family:"Segoe UI",system-ui,sans-serif;background:var(--bg);
color:var(--tx);padding:1.25rem;max-width:1500px;margin:0 auto}
h1{margin:0 0 .2rem;font-size:1.5rem;color:var(--acc)}
h2{font-size:1.1rem;margin:1.6rem 0 .7rem;color:var(--tx);border-left:3px solid var(--acc);padding-left:.55rem}
h4{margin:.9rem 0 .4rem;font-size:.9rem;color:var(--mut);text-transform:uppercase;letter-spacing:.05em}
.sub{color:var(--mut);font-size:.88rem;margin-bottom:1rem}
.row{display:flex;flex-wrap:wrap;gap:.6rem;align-items:center;margin-bottom:.6rem}
.badge{display:inline-block;padding:.2rem .6rem;border-radius:999px;font-size:.78rem;font-weight:600;background:#1e3a5f;color:var(--acc)}
.badge.live{background:#14532d;color:var(--good);animation:pulse 2s infinite}
.badge.done{background:#3b2f14;color:var(--warn)}
.badge.err{background:#4c1d1d;color:var(--bad)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.55}}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:.65rem;margin-bottom:.6rem}
.card{background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:.7rem .9rem}
.card .l{font-size:.66rem;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}
.card .v{font-size:1.25rem;font-weight:700;margin-top:.15rem}
.pbar{background:#1a2236;border-radius:999px;height:9px;overflow:hidden;margin:.35rem 0 .9rem}
.pbar>div{height:100%;background:linear-gradient(90deg,#0284c7,#38bdf8);transition:width .4s}
/* one model per full-width block, sections stacked top->bottom */
.mcard{background:var(--card2);border:1px solid var(--bd);border-radius:12px;padding:1rem 1.1rem;margin-bottom:1.2rem}
.mcard h3{margin:0 0 .3rem;font-size:1.15rem;display:flex;flex-wrap:wrap;gap:.6rem;align-items:center}
.charts{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:.8rem}
.chart-box{background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:.6rem;height:230px}
.tbl-wrap{overflow-x:auto;max-height:340px;overflow-y:auto;border:1px solid var(--bd);border-radius:10px}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th,td{padding:.4rem .55rem;text-align:right;border-bottom:1px solid var(--bd);white-space:nowrap}
th{color:var(--mut);font-weight:600;position:sticky;top:0;background:#0d1424}
td:first-child,th:first-child{text-align:left}
.epoch-tbl td.pa{color:var(--acc)} .epoch-tbl td.pb{color:var(--warn)}
.best-row{background:rgba(74,222,128,.08)}
.metrics-tbl td:first-child{color:var(--mut)}
.metrics-tbl td:nth-child(2){font-weight:700}
.cmp th,.cmp td{text-align:center}
.cmp td:first-child,.cmp th:first-child{text-align:left}
.best{color:var(--good);font-weight:700}
.small{font-size:.76rem;color:var(--mut)}
.cols2{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
@media(max-width:820px){.cols2{grid-template-columns:1fr}}
.foot{margin-top:1.5rem;color:var(--mut);font-size:.8rem}
a{color:var(--acc)}
</style>
</head>
<body>
<h1>BrainAge Multi-Model Training</h1>
<p class="sub">Dual-branch (Network 1 image expert + Network 2 tabular MLP → fusion) · plug-and-play backbones · T4 GPU</p>
<!-- ── TOP: live status ───────────────────────────────── -->
<div class="row">
<span id="statusBadge" class="badge live">LIVE</span>
<span class="badge" id="curModel"></span>
<span class="badge" id="curPhase"></span>
<span class="small" id="gpu"></span>
</div>
<p class="small" id="message">Connecting…</p>
<div class="pbar"><div id="epBar" style="width:0%"></div></div>
<div class="grid">
<div class="card"><div class="l">Model</div><div class="v" id="mIdx"></div></div>
<div class="card"><div class="l">Epoch</div><div class="v" id="epoch"></div></div>
<div class="card"><div class="l">Batch</div><div class="v" id="batch"></div></div>
<div class="card"><div class="l">Train Loss</div><div class="v" id="trainLoss"></div></div>
<div class="card"><div class="l">Val MAE</div><div class="v" id="valMae"></div></div>
<div class="card"><div class="l">Phase A / B ep</div><div class="v" id="epCfg"></div></div>
</div>
<!-- ── comparison table ──────────────────────────────── -->
<h2>Model comparison — held-out TEST set</h2>
<div class="tbl-wrap" style="max-height:none">
<table class="cmp">
<thead><tr>
<th>Model</th><th>Params</th><th>Lifespan MAE (y)</th><th>Pediatric MAE (0-25)</th>
<th>Corrected MAE</th><th>RMSE</th><th></th><th>Pearson r</th><th>Spearman ρ</th>
<th>Age-bias slope</th><th>Phase A val</th><th>Phase B val (0-25)</th>
</tr></thead>
<tbody id="cmpBody"><tr><td colspan="12" class="small">Waiting for first model to finish…</td></tr></tbody>
</table>
</div>
<!-- ── per-model detail (stacked, full width each) ───── -->
<h2>Per-model detail</h2>
<div id="models"></div>
<p class="foot">
Auto-refreshes every 3s · <a href="/api/progress">JSON API</a> ·
Artifacts pushed to <a id="repoLink" href="#" target="_blank">model repo</a>
</p>
<script>
const COLORS=["#38bdf8","#a78bfa","#fb923c","#4ade80"];
const fmt=(v,d=3)=>(v==null||v===''||Number.isNaN(v))?'—':Number(v).toFixed(d);
const charts={};
function ensureModelCard(name,idx){
if(document.getElementById('mc-'+name)) return;
const div=document.createElement('div');
div.className='mcard'; div.id='mc-'+name;
div.innerHTML=`
<h3><span>${name}</span>
<span class="badge" id="st-${name}">pending</span>
<span class="small" id="pm-${name}"></span></h3>
<h4>Learning curves</h4>
<div class="charts">
<div class="chart-box"><canvas id="loss-${name}"></canvas></div>
<div class="chart-box"><canvas id="mae-${name}"></canvas></div>
<div class="chart-box"><canvas id="sc-${name}"></canvas></div>
</div>
<h4>Epoch-by-epoch log (newest first)</h4>
<div class="tbl-wrap">
<table class="epoch-tbl">
<thead><tr><th>Phase</th><th>Epoch</th><th>LR</th><th>Train Loss</th><th>Val MAE (y)</th><th>Time</th></tr></thead>
<tbody id="ep-${name}"><tr><td colspan="6" class="small">Waiting for first epoch…</td></tr></tbody>
</table>
</div>
<div class="cols2">
<div>
<h4>Test metrics (full suite)</h4>
<div class="tbl-wrap" style="max-height:none">
<table class="metrics-tbl"><tbody id="mt-${name}"><tr><td class="small">Runs after training…</td></tr></tbody></table>
</div>
</div>
<div>
<h4>Breakdown (age-bin / site / sex)</h4>
<div id="grp-${name}" class="small tbl-wrap" style="max-height:none;padding:.6rem"></div>
</div>
</div>`;
document.getElementById('models').appendChild(div);
const co=t=>({responsive:true,maintainAspectRatio:false,animation:false,
plugins:{legend:{labels:{color:'#8798b8',boxWidth:10,font:{size:10}}},
title:{display:true,text:t,color:'#8798b8',font:{size:11}}},
scales:{x:{ticks:{color:'#8798b8',font:{size:9}},grid:{color:'#233049'}},
y:{ticks:{color:'#8798b8',font:{size:9}},grid:{color:'#233049'}}}});
const cl=COLORS[idx%COLORS.length];
charts[name]={
loss:new Chart(document.getElementById('loss-'+name),{type:'line',
data:{labels:[],datasets:[{label:'A train',data:[],borderColor:cl,pointRadius:0,tension:.2},
{label:'B train',data:[],borderColor:'#fbbf24',pointRadius:0,tension:.2}]},
options:co('Train loss (Phase A→B)')}),
mae:new Chart(document.getElementById('mae-'+name),{type:'line',
data:{labels:[],datasets:[{label:'A val MAE',data:[],borderColor:cl,pointRadius:0,tension:.2},
{label:'B val MAE',data:[],borderColor:'#fbbf24',pointRadius:0,tension:.2}]},
options:co('Val MAE (years)')}),
scatter:new Chart(document.getElementById('sc-'+name),{type:'scatter',
data:{datasets:[{label:'pred vs true',data:[],backgroundColor:cl+'99',pointRadius:2},
{label:'ideal',data:[],type:'line',borderColor:'#4ade80',borderDash:[5,4],pointRadius:0}]},
options:co('Predicted vs true age (test)')}),
};
}
function updateModel(name,idx,ms){
ensureModelCard(name,idx);
const st=document.getElementById('st-'+name);
st.textContent=ms.status;
st.className='badge'+(ms.status==='training'||ms.status==='evaluating'?' live':
ms.status==='done'?' done':ms.status==='error'?' err':'');
document.getElementById('pm-'+name).textContent=
(ms.params_m?`${ms.params_m}M params`:'')+
(ms.best_a?` · A best ${fmt(ms.best_a.val_mae)}y @ep${ms.best_a.epoch}`:'')+
(ms.best_b?` · B best ${fmt(ms.best_b.val_mae)}y @ep${ms.best_b.epoch}`:'')+
(ms.error?` · ERROR: ${ms.error}`:'');
const la=ms.phaseA_log||[], lb=ms.phaseB_log||[], ch=charts[name];
const labs=[...la.map(r=>'A'+r.epoch),...lb.map(r=>'B'+r.epoch)];
ch.loss.data.labels=labs;
ch.loss.data.datasets[0].data=[...la.map(r=>r.train_loss),...lb.map(()=>null)];
ch.loss.data.datasets[1].data=[...la.map(()=>null),...lb.map(r=>r.train_loss)];
ch.loss.update('none');
ch.mae.data.labels=labs;
ch.mae.data.datasets[0].data=[...la.map(r=>r.val_mae),...lb.map(()=>null)];
ch.mae.data.datasets[1].data=[...la.map(()=>null),...lb.map(r=>r.val_mae)];
ch.mae.update('none');
// epoch-by-epoch table (newest first: Phase B rows above Phase A)
const bestA=Math.min(...la.map(r=>r.val_mae),Infinity);
const bestB=Math.min(...lb.map(r=>r.val_mae),Infinity);
const mk=(r,ph,cls,best)=>`<tr class="${r.val_mae===best?'best-row':''}">
<td class="${cls}">${ph}</td><td>${r.epoch}</td>
<td>${Number(r.lr).toExponential(1)}</td>
<td>${fmt(r.train_loss,4)}</td><td>${fmt(r.val_mae,3)}</td>
<td>${fmt(r.seconds,0)}s</td></tr>`;
let rows='';
[...lb].reverse().forEach(r=>rows+=mk(r,'Phase B','pb',bestB));
[...la].reverse().forEach(r=>rows+=mk(r,'Phase A','pa',bestA));
document.getElementById('ep-'+name).innerHTML=rows||'<tr><td colspan="6" class="small">Waiting for first epoch…</td></tr>';
if(ms.scatter&&ms.scatter.length){
ch.scatter.data.datasets[0].data=ms.scatter.map(([t,p])=>({x:t,y:p}));
const mx=Math.max(...ms.scatter.map(a=>Math.max(a[0],a[1])),1);
ch.scatter.data.datasets[1].data=[{x:0,y:0},{x:mx,y:mx}];
ch.scatter.update('none');
}
if(ms.eval){
const r=ms.eval.raw,c=ms.eval.bias_corrected;
const pe=ms.eval_pediatric&&ms.eval_pediatric.raw?ms.eval_pediatric.raw:null;
document.getElementById('mt-'+name).innerHTML=`
<tr><td colspan="2" style="color:var(--acc);font-weight:700">LIFESPAN (full test)</td></tr>
<tr><td>Test N</td><td>${r.n}</td></tr>
<tr><td>MAE (y)</td><td>${fmt(r.mae)}</td></tr>
<tr><td>Median AE</td><td>${fmt(r.median_ae)}</td></tr>
<tr><td>RMSE</td><td>${fmt(r.rmse)}</td></tr>
<tr><td></td><td>${fmt(r.r2)}</td></tr>
<tr><td>Pearson r</td><td>${fmt(r.pearson_r)}</td></tr>
<tr><td>Spearman ρ</td><td>${fmt(r.spearman_rho)}</td></tr>
<tr><td>Bias (ME)</td><td>${fmt(r.bias_me)}</td></tr>
<tr><td>Std err</td><td>${fmt(r.std_err)}</td></tr>
<tr><td>P90 AE</td><td>${fmt(r.p90_ae)}</td></tr>
<tr><td>Max AE</td><td>${fmt(r.max_ae)}</td></tr>
<tr><td>Age-bias slope (raw)</td><td>${fmt(ms.eval.age_bias_slope_raw)}</td></tr>
<tr><td>Bias-corrected MAE</td><td>${fmt(c.mae)}</td></tr>
<tr><td>Corrected slope</td><td>${fmt(ms.eval.age_bias_slope_corrected)}</td></tr>`+
(pe?`
<tr><td colspan="2" style="color:var(--warn);font-weight:700">PEDIATRIC 0-25 (Phase B)</td></tr>
<tr><td>Test N</td><td>${pe.n}</td></tr>
<tr><td>MAE (y)</td><td>${fmt(pe.mae)}</td></tr>
<tr><td>Median AE</td><td>${fmt(pe.median_ae)}</td></tr>
<tr><td>RMSE</td><td>${fmt(pe.rmse)}</td></tr>
<tr><td></td><td>${fmt(pe.r2)}</td></tr>
<tr><td>Pearson r</td><td>${fmt(pe.pearson_r)}</td></tr>`:'');
const g=x=>(x||[]).map(o=>`${o.group}: ${fmt(o.mae)}y (n=${o.n})`).join('<br>')||'—';
document.getElementById('grp-'+name).innerHTML=
`<b>By age bin</b><br>${g(ms.eval.per_age_bin)}<br><br>`+
`<b>By site</b><br>${g(ms.eval.per_site)}<br><br>`+
`<b>By sex</b><br>${g(ms.eval.per_sex)}`;
}
}
function updateCompare(models,per){
const done=models.filter(m=>per[m]&&per[m].eval);
if(!done.length) return;
const ped=m=>per[m].eval_pediatric&&per[m].eval_pediatric.raw?per[m].eval_pediatric.raw.mae:null;
const best={};
best.mae=Math.min(...done.map(m=>per[m].eval.raw.mae));
best.rmse=Math.min(...done.map(m=>per[m].eval.raw.rmse));
best.cmae=Math.min(...done.map(m=>per[m].eval.bias_corrected.mae));
best.r2=Math.max(...done.map(m=>per[m].eval.raw.r2));
const peds=done.map(ped).filter(v=>v!=null);
best.ped=peds.length?Math.min(...peds):null;
let rows='';
models.forEach(m=>{
const ms=per[m];
if(!ms||!ms.eval){rows+=`<tr><td>${m}</td><td colspan="11" class="small">${ms?ms.status:'pending'}</td></tr>`;return;}
const r=ms.eval.raw,c=ms.eval.bias_corrected,pv=ped(m);
const cl=(v,b)=>b!=null&&Math.abs(v-b)<1e-9?'best':'';
rows+=`<tr><td>${m}</td><td>${fmt(ms.params_m,1)}M</td>
<td class="${cl(r.mae,best.mae)}">${fmt(r.mae)}</td>
<td class="${cl(pv,best.ped)}">${fmt(pv)}</td>
<td class="${cl(c.mae,best.cmae)}">${fmt(c.mae)}</td>
<td class="${cl(r.rmse,best.rmse)}">${fmt(r.rmse)}</td>
<td class="${cl(r.r2,best.r2)}">${fmt(r.r2)}</td>
<td>${fmt(r.pearson_r)}</td><td>${fmt(r.spearman_rho)}</td>
<td>${fmt(ms.eval.age_bias_slope_raw)}</td>
<td>${ms.best_a?fmt(ms.best_a.val_mae):'—'}</td>
<td>${ms.best_b?fmt(ms.best_b.val_mae):'—'}</td></tr>`;
});
document.getElementById('cmpBody').innerHTML=rows;
}
async function poll(){
try{
const p=await (await fetch('/api/progress')).json();
document.getElementById('curModel').textContent='model: '+(p.current_model||'—');
document.getElementById('curPhase').textContent=p.current_phase||'—';
document.getElementById('gpu').textContent=p.gpu||'';
document.getElementById('message').textContent=p.message||'';
document.getElementById('epoch').textContent=`${p.epoch||0} / ${p.total_epochs||'?'}`;
document.getElementById('batch').textContent=`${p.batch||0} / ${p.total_batches||'?'}`;
document.getElementById('trainLoss').textContent=fmt(p.train_loss,4);
document.getElementById('valMae').textContent=fmt(p.val_mae,3)+' y';
document.getElementById('epCfg').textContent=`${p.phase_a_epochs} / ${p.phase_b_epochs}`;
const mi=(p.models||[]).indexOf(p.current_model);
document.getElementById('mIdx').textContent=`${mi>=0?mi+1:'—'} / ${(p.models||[]).length}`;
document.getElementById('epBar').style.width=(p.total_epochs>0?100*p.epoch/p.total_epochs:0)+'%';
if(p.model_repo){const a=document.getElementById('repoLink');
a.href='https://huggingface.co/'+p.model_repo;a.textContent=p.model_repo;}
(p.models||[]).forEach((m,i)=>updateModel(m,i,(p.per_model||{})[m]||{}));
updateCompare(p.models||[],p.per_model||{});
const b=document.getElementById('statusBadge');
if(p.done){b.textContent='COMPLETE';b.className='badge done';}
}catch(e){document.getElementById('message').textContent='Waiting for training server…';}
}
poll(); setInterval(poll,3000);
</script>
</body>
</html>