136 lines
9.0 KiB
JavaScript
136 lines
9.0 KiB
JavaScript
/* ============================================================
|
|
calibrate.js — Calibration 탭 (COPASI joint 보정 + 정직한 검증)
|
|
============================================================ */
|
|
(function () {
|
|
'use strict';
|
|
let charts = {}, inited = false;
|
|
const AXIS_COLOR = { W: '#2f63c8', B: '#7c3aed', S: '#0e7490', D: '#1f8a5b', H: '#c2367f', F: '#c0561f', A: '#b07a12' };
|
|
function el(id) { return document.getElementById(id); }
|
|
function num(v, d) { return (v === null || v === undefined || isNaN(v)) ? '—' : (+v).toFixed(d); }
|
|
|
|
function init() {
|
|
if (inited) return; inited = true;
|
|
const cal = Store.calibration;
|
|
const sel = el('cal-target');
|
|
if (!cal || !cal.targets) {
|
|
el('cal-summary').innerHTML = '<div class="empty-state">calibration_result.json 없음 — <code>python -m digital_twin.copasi_calibrate</code> 실행 필요.</div>';
|
|
return;
|
|
}
|
|
Object.keys(cal.targets).forEach(k => sel.add(new Option((cal.targets[k] || {}).source_label || k, k)));
|
|
sel.addEventListener('change', () => render(sel.value));
|
|
renderValidation();
|
|
renderHeader();
|
|
renderCoupled();
|
|
render(sel.value || Object.keys(cal.targets)[0]);
|
|
}
|
|
|
|
function renderCoupled() {
|
|
const box = el('cal-coupled'); if (!box) return;
|
|
const cp = Store.coupled;
|
|
if (!cp || !cp.scenarios) { box.innerHTML = '<span class="panel-subtitle">coupled_scenarios.json 없음</span>'; return; }
|
|
const lab = s => (s.interventions || []).length ? s.disease + ' + ' + s.interventions.join('+') : s.disease;
|
|
box.innerHTML = cp.scenarios.map(s => {
|
|
const af = s.anagen_fraction_pct_of_healthy, ph = s.peak_hair_pct_of_healthy;
|
|
const cls = af >= 70 ? 'good' : af >= 30 ? 'warn' : 'bad';
|
|
return `<div class="coupled-row">
|
|
<span class="coupled-lab">${lab(s)}</span>
|
|
<span class="coupled-bar-wrap"><span class="coupled-bar ${cls}" style="width:${Math.min(100, af)}%"></span></span>
|
|
<span class="coupled-val">anagen ${num(af, 0)}% · peak ${num(ph, 0)}%</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderValidation() {
|
|
const box = el('cal-validation'); if (!box) return;
|
|
const v = Store.validation;
|
|
if (!v || !v.summary) { box.innerHTML = ''; return; }
|
|
const s = v.summary;
|
|
box.innerHTML = `
|
|
<div class="val-head">🔬 독립 검증 — 트윈 예측 vs 실제 논문 실험결과 (out-of-sample)</div>
|
|
<div class="val-cards">
|
|
<div class="val-card big"><div class="vv">${(s.twin_causal_accuracy*100).toFixed(1)}%</div><div class="vl">논문 인과 실험 일치 (n=${s.n_causal})</div></div>
|
|
<div class="val-card"><div class="vv">p${s.permutation_p < 0.0001 ? '<0.0001' : '='+s.permutation_p}</div><div class="vl">permutation (우연 대비)</div></div>
|
|
<div class="val-card"><div class="vv">[${(s.bootstrap_ci95||[]).map(x=>(x*100).toFixed(0)).join(', ')}]%</div><div class="vl">bootstrap 95% CI (1만회)</div></div>
|
|
<div class="val-card"><div class="vv">${s.ground_truth_records}</div><div class="vl">논문 실험결과 GT (멀티에이전트)</div></div>
|
|
<div class="val-card"><div class="vv">${(s.external_transcriptomic_accuracy*100).toFixed(0)}%</div><div class="vl">외부 GSE36169 (조성교란·참고)</div></div>
|
|
</div>
|
|
<div class="val-note">✅ 수정: ${(s.fixes_applied||[]).join(' · ')} | 트윈이 옳고 독립소스가 틀린 예: ${(s.twin_correct_where_sources_err||[]).join('; ')}</div>
|
|
${v.protein_modules ? `<div class="val-pm">🧬 단백질 분석 모듈 교차검증 (알파폴드 외): STRING PPI 축 응집 <b>${v.protein_modules.axes_ppi_significant}</b> (p<0.05) · 축 라벨일치 <b>${v.protein_modules.axes_label_matched}</b> · 질환 생물학일치 <b>${v.protein_modules.diseases_biology_matched}</b> · 실험 PDB <b>${v.protein_modules.experimental_pdb}</b></div>` : ''}
|
|
<div class="val-note dim">⚠️ ${(v.honest_caveats||[]).join(' · ')}</div>`;
|
|
}
|
|
|
|
function renderHeader() {
|
|
const cal = Store.calibration, s = cal.summary || {}, uq = cal.uncertainty || {};
|
|
const jr = s.joint_r2 || {};
|
|
const robust = (s.robust_axes || []).map(a => (cal.targets.reference.axis_label || {})[a] || a).join(', ');
|
|
el('cal-summary').innerHTML = `
|
|
<div class="cal-card big"><div class="mv">${num(s.loocv_cv_r2, 2)}</div><div class="ml">LOOCV 교차검증 R² (외표본·정직 지표)</div></div>
|
|
<div class="cal-card"><div class="mv">${num(jr.reference, 2)}</div><div class="ml">joint R² · 문헌(형상)</div></div>
|
|
<div class="cal-card"><div class="mv">${num(jr.gse11186, 2)}</div><div class="ml">joint R² · 실측 GSE11186</div></div>
|
|
<div class="cal-card"><div class="mv">${cal.n_fit_params}<span style="font-size:.5em">+${cal.n_fixed_params}fix</span></div><div class="ml">추정/고정 파라미터</div></div>
|
|
<div class="cal-card"><div class="mv">${num(cal.aicc, 0)}</div><div class="ml">AICc (복잡도 penalized)</div></div>
|
|
<div class="cal-card wide"><div class="mv-sm">견고 축: ${robust || '—'} · 비식별 ${uq.n_poorly_identified}/${cal.n_fit_params} · 경계고착 ${uq.n_at_bound}</div><div class="ml">${cal.approach || ''}</div></div>`;
|
|
// 정직성 배너
|
|
let banner = el('cal-banner');
|
|
if (!banner) { banner = document.createElement('div'); banner.id = 'cal-banner'; banner.className = 'cal-banner';
|
|
el('cal-summary').insertAdjacentElement('afterend', banner); }
|
|
banner.innerHTML = `⚖️ <b>정직한 보정</b>: 단일적합 R²(문헌 ${num((cal.descriptive_single_r2||{}).reference,2)}, 실측 ${num((cal.descriptive_single_r2||{}).gse11186,2)})은 데이터셋 간 전이 안 되는 <i>서술적 상한</i>. 1차 지표는 <b>단일 파라미터셋으로 두 데이터셋을 동시 적합한 joint R²</b>와 <b>LOOCV</b>. ${s.honesty_note || ''}`;
|
|
}
|
|
|
|
function render(key) {
|
|
const cal = Store.calibration;
|
|
const tg = (cal.targets || {})[key];
|
|
if (!tg) return;
|
|
const grid = el('cal-grid');
|
|
Object.values(charts).forEach(c => c.destroy()); charts = {};
|
|
const obs = tg.observed || [];
|
|
grid.innerHTML = obs.filter(st => tg.model_dense && tg.model_dense[st] && tg.data && tg.data[st]).map(st => {
|
|
const fq = (tg.fit_quality || {})[st] || {};
|
|
const r2 = fq.r2;
|
|
const cls = r2 >= 0.7 ? 'good' : r2 >= 0.3 ? 'warn' : 'bad';
|
|
return `<div class="cal-axis-card">
|
|
<div class="cal-axis-head"><b>${(tg.axis_label || {})[st] || st}</b>
|
|
<span class="cal-r2 ${cls}">R²=${num(r2, 2)}</span></div>
|
|
<div class="cal-axis-chart"><canvas id="cal-c-${st}"></canvas></div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
obs.forEach(st => {
|
|
if (!el(`cal-c-${st}`)) return;
|
|
const col = AXIS_COLOR[st] || '#6699ff';
|
|
const modelPts = (tg.model_dense_t || []).map((t, i) => ({ x: t, y: tg.model_dense[st][i] }));
|
|
const dataPts = (tg.timepoints_days || []).map((t, i) => ({ x: t, y: tg.data[st][i] }));
|
|
charts[st] = new Chart(el(`cal-c-${st}`), {
|
|
data: { datasets: [
|
|
{ type: 'line', label: '모델(joint 보정)', data: modelPts, borderColor: col, borderWidth: 2, pointRadius: 0, tension: .3 },
|
|
{ type: 'scatter', label: '데이터', data: dataPts, backgroundColor: '#1b1a16', borderColor: '#1b1a16', pointRadius: 3.5 },
|
|
] },
|
|
options: { responsive: true, maintainAspectRatio: false,
|
|
scales: { x: { type: 'linear', min: 0, ticks: { color: '#6b655a', font: { size: 10 }, callback: v => v + 'd' }, grid: { color: 'rgba(0,0,0,.08)' } },
|
|
y: { min: -0.05, max: 1.1, ticks: { color: '#6b655a', font: { size: 10 } }, grid: { color: 'rgba(0,0,0,.08)' } } },
|
|
plugins: { legend: { display: false } } },
|
|
});
|
|
});
|
|
|
|
// 추정 파라미터 + 식별가능성
|
|
const fp = cal.fitted_params || {}, uq = cal.uncertainty || {}, rse = uq.rel_stderr || {};
|
|
const fitNames = cal.fit_param_names || [];
|
|
el('cal-nparam').textContent = fitNames.length;
|
|
el('cal-param-table').innerHTML = fitNames.map(k => {
|
|
const poor = (uq.poorly_identified || []).includes(k);
|
|
const bound = (uq.params_at_bound || []).includes(k);
|
|
const flag = bound ? '⚠경계' : poor ? '·비식별' : '';
|
|
return `<div class="cal-prow"><span class="pk">${k}${flag ? ' <span class="pflag">' + flag + '</span>' : ''}</span><span class="pi">${num(rse[k], 2)}</span><span class="pa">${num(fp[k], 3)}</span></div>`;
|
|
}).join('');
|
|
|
|
// provenance + UQ 요약
|
|
const corr = (uq.strong_correlations || []).slice(0, 6).map(c => `${c[0]}~${c[1]}(${c[2]})`).join(', ');
|
|
el('cal-provenance').innerHTML =
|
|
`<p class="cal-note">${tg.is_real_data ? '✅ 실측 데이터' : '📐 문헌 합성(형상복원 점검)'} — ${tg.notes || ''}</p>` +
|
|
`<p style="color:var(--warn);font-size:.72rem">불확실성: 비식별 ${uq.n_poorly_identified}/${fitNames.length} · 경계고착 ${uq.n_at_bound} · 강상관쌍: ${corr || '없음'} · LOOCV CV-R²=${num((cal.loocv_reference||{}).cv_r2,2)}</p>` +
|
|
'<ul>' + (tg.provenance || []).map(p => `<li>${p}</li>`).join('') + '</ul>';
|
|
}
|
|
|
|
window.CalibrateTab = { init };
|
|
})();
|