| <!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> |
|
|
| |
| <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> |
|
|
| |
| <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>R²</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> |
|
|
| |
| <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>R²</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>R²</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> |
|
|