Spaces:
Running
Running
| <html lang="ko"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>LEADBOARD — 신약 예측 도구 시험대</title> | |
| <meta name="description" content="One yardstick, per discipline, for drug prediction tools. Baselines and experimental error published in advance."> | |
| <!-- <head> 는 선택이 아니다: 플랫폼이 자기 태그를 문서에 주입하는데, 받을 head 가 없으면 | |
| 그것들이 doctype 앞에 놓이고 페이지 전체가 쿼크 모드로 떨어진다. --> | |
| <style> | |
| :root{ | |
| --bg:#fbfcfd;--bg2:#f0f3f7;--card:#fff;--ink:#0b0d12;--ink2:#3a4150; | |
| --muted:#6d7585;--line:#e6e9ef;--line2:#d0d6e0; | |
| --acc:#1f4fa3;--acc2:#3d7ae0;--soft:#eaf0fb; | |
| --warn:#8a5c00;--warnbg:#fff6e2;--warnln:#f0d9a0; | |
| --good:#0f6b43;--goodbg:#e6f6ee;--bad:#9c2f24;--badbg:#fdeceb; | |
| --m:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; | |
| --sh:0 1px 2px rgba(13,18,30,.05),0 2px 6px rgba(13,18,30,.05); | |
| --sh2:0 8px 16px -6px rgba(13,18,30,.14),0 20px 36px -12px rgba(13,18,30,.14); | |
| } | |
| @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){ | |
| --bg:#090b0f;--bg2:#101319;--card:#141821;--ink:#f1f4f8;--ink2:#c8cfdb; | |
| --muted:#8b94a5;--line:#212734;--line2:#2e3646;--acc:#8cb0f0;--acc2:#6a95e4; | |
| --soft:#111a2b;--warn:#e8bd5e;--warnbg:#241d0e;--warnln:#4a3c1a; | |
| --good:#4fd396;--goodbg:#0c2418;--bad:#f59285;--badbg:#2b1411; | |
| --sh:0 1px 2px rgba(0,0,0,.4);--sh2:0 12px 32px rgba(0,0,0,.55);}} | |
| :root[data-theme="dark"]{ | |
| --bg:#090b0f;--bg2:#101319;--card:#141821;--ink:#f1f4f8;--ink2:#c8cfdb; | |
| --muted:#8b94a5;--line:#212734;--line2:#2e3646;--acc:#8cb0f0;--acc2:#6a95e4; | |
| --soft:#111a2b;--warn:#e8bd5e;--warnbg:#241d0e;--warnln:#4a3c1a; | |
| --good:#4fd396;--goodbg:#0c2418;--bad:#f59285;--badbg:#2b1411; | |
| --sh:0 1px 2px rgba(0,0,0,.4);--sh2:0 12px 32px rgba(0,0,0,.55);} | |
| *{box-sizing:border-box} | |
| /* 히어로 뒤 그라디언트가 좌우로 음수 inset 을 쓴다. z-index 를 내려도 스크롤 범위에는 | |
| 남아 가로 스크롤이 생긴다. clip 은 hidden 과 달리 sticky 헤더를 깨뜨리지 않는다. */ | |
| html{overflow-x:clip} | |
| body{margin:0;overflow-x:clip;background:var(--bg);color:var(--ink);line-height:1.62;font-size:15px; | |
| font-family:-apple-system,BlinkMacSystemFont,"Pretendard","Apple SD Gothic Neo", | |
| "Malgun Gothic",Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased} | |
| .wrap{max-width:1060px;margin:0 auto;padding:0 24px} | |
| a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline} | |
| .m,code{font-family:var(--m);font-variant-numeric:tabular-nums} | |
| .dim{color:var(--muted)} | |
| nav{position:sticky;top:0;z-index:30;background:color-mix(in srgb,var(--bg) 78%,transparent); | |
| backdrop-filter:saturate(180%) blur(16px);border-bottom:1px solid var(--line)} | |
| nav .in{max-width:1060px;margin:0 auto;padding:12px 24px;display:flex;align-items:center;gap:11px} | |
| .lg{font-weight:850;letter-spacing:-.04em;font-size:18px;color:var(--ink)} | |
| .lg:hover{text-decoration:none}.lg i{color:var(--acc);font-style:normal} | |
| .sp{flex:1} | |
| /* 진입 부문 배지. 46부문 중 어디서 시작할지 몰라 아무 데도 시작하지 못하는 | |
| 것을 막으려는 표시다. */ | |
| .prizebox{background:linear-gradient(135deg,#fffdf7 0%,#fdf4e3 100%);border:1px solid #f0dfba;border-radius:12px;padding:16px 18px} | |
| .prizebox .amt{font-size:28px;font-weight:900;color:#a97c1a;letter-spacing:-1px} | |
| .prizebox p{font-size:13.5px;color:#6b5a36;margin:6px 0;line-height:1.7} | |
| .prizebox .fine{font-size:11.5px;color:#8a7649;border-top:1px solid #f0dfba;padding-top:9px;margin-top:8px} | |
| .prizebox .cand{font-size:13px;color:#4a3f24;margin-top:8px} | |
| .chip.st1{background:#e8f5e9;color:#2e7d32;border-color:#c8e6c9} | |
| .chip{display:inline-flex;align-items:center;font-size:11px;font-family:var(--m); | |
| padding:3px 9px;border-radius:99px;background:var(--soft);color:var(--acc);font-weight:700} | |
| .chip.g{background:transparent;border:1px solid var(--line2);color:var(--muted)} | |
| .chip.ok{background:var(--goodbg);color:var(--good)} | |
| .chip.wr{background:var(--warnbg);color:var(--warn)} | |
| .chip.bd{background:var(--badbg);color:var(--bad)} | |
| .lang{padding:5px 11px;border-radius:8px;border:1px solid var(--line2);background:var(--card); | |
| color:var(--ink2);font-size:12px;font-weight:650;cursor:pointer;font-family:inherit} | |
| .lang:hover{border-color:var(--acc);color:var(--acc)} | |
| .btn{display:inline-flex;align-items:center;gap:8px;padding:10px 19px;border-radius:11px; | |
| background:linear-gradient(180deg,var(--acc2),var(--acc));color:#fff;font-size:14px; | |
| font-weight:650;border:0;cursor:pointer;box-shadow:var(--sh);transition:transform .1s,filter .15s} | |
| .btn:hover{filter:brightness(1.08);text-decoration:none} | |
| .btn:active{transform:translateY(1px)}.btn:disabled{opacity:.45;cursor:default} | |
| /* 자매 시험대 띠 — ODC/OMC 로 건너가는 자리. 사이트가 혼자 서 있지 않다는 표시이기도 하다 */ | |
| .sib{border-bottom:1px solid var(--line);background:var(--bg2)} | |
| .sib .in{max-width:1060px;margin:0 auto;padding:10px 24px;display:flex;gap:10px; | |
| align-items:center;flex-wrap:wrap;font-size:12.5px} | |
| .sib .lb{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;font-size:10.5px;font-weight:700} | |
| .sib a{display:inline-flex;align-items:center;gap:7px;padding:5px 12px;border-radius:99px; | |
| border:1px solid var(--line2);background:var(--card);color:var(--ink2);font-weight:600} | |
| .sib a:hover{border-color:var(--acc);color:var(--acc);text-decoration:none} | |
| .sib a span{color:var(--muted);font-weight:400} | |
| /* 발견 카드 — 서사가 아니라 수치가 주인공이 되게 한다 */ | |
| .finds{display:grid;grid-template-columns:repeat(auto-fit,minmax(288px,1fr));gap:15px} | |
| .find{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:21px; | |
| box-shadow:var(--sh);border-top:3px solid var(--acc)} | |
| .find b{display:block;font-size:16.5px;font-weight:750;letter-spacing:-.02em;margin-bottom:8px;line-height:1.4} | |
| .find p{font-size:13.5px;color:var(--muted);line-height:1.62;margin:0} | |
| .prose{font-size:15.5px;color:var(--ink2);line-height:1.78;max-width:44em} | |
| .prose p{margin:0 0 16px} | |
| .cite{font-size:12.5px;color:var(--muted);font-style:italic;border-left:2px solid var(--line2); | |
| padding-left:13px;margin:6px 0 0} | |
| /* 지표 정의 */ | |
| .defs{display:grid;grid-template-columns:auto 1fr;gap:0;border:1px solid var(--line); | |
| border-radius:16px;overflow:hidden;background:var(--card);box-shadow:var(--sh)} | |
| .defs dt{padding:14px 18px;font-family:var(--m);font-size:13px;font-weight:750;color:var(--acc); | |
| border-top:1px solid var(--line);white-space:nowrap} | |
| .defs dd{padding:14px 18px 14px 0;margin:0;font-size:13.5px;color:var(--ink2); | |
| border-top:1px solid var(--line);line-height:1.6} | |
| .defs dt:first-of-type,.defs dd:first-of-type{border-top:0} | |
| .hero{padding:78px 0 46px;position:relative} | |
| .hero::before{content:"";position:absolute;inset:-70% -30% auto -30%;height:520px;z-index:-1; | |
| background:radial-gradient(ellipse 55% 50% at 32% 55%,color-mix(in srgb,var(--acc) 16%,transparent),transparent 70%)} | |
| .brand{font-size:clamp(46px,9vw,80px);font-weight:900;letter-spacing:-.05em;margin:0;line-height:.96} | |
| .brand i{color:var(--acc);font-style:normal} | |
| .subttl{font-size:12.5px;color:var(--muted);margin:9px 0 0;letter-spacing:.11em;text-transform:uppercase} | |
| .tag{font-size:clamp(23px,3.9vw,34px);font-weight:750;margin:30px 0 14px; | |
| letter-spacing:-.03em;line-height:1.32;max-width:17em} | |
| .lede{font-size:16.5px;color:var(--ink2);margin:0;max-width:38em;line-height:1.72} | |
| .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(128px,1fr));gap:1px; | |
| margin:44px 0 0;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden} | |
| .stat{background:var(--card);padding:17px 19px} | |
| .stat b{display:block;font-size:30px;font-weight:850;font-family:var(--m);letter-spacing:-.045em;line-height:1.1} | |
| .stat span{font-size:11.5px;color:var(--muted)} | |
| .sec{margin:76px 0 0} | |
| .sec h2{font-size:11.5px;font-weight:750;letter-spacing:.17em;text-transform:uppercase; | |
| color:var(--acc);margin:0 0 8px} | |
| .sec .lead2{font-size:15.5px;color:var(--ink2);margin:0 0 22px;max-width:46em} | |
| h3{font-size:18.5px;font-weight:720;margin:30px 0 9px;letter-spacing:-.018em} | |
| p{margin:0 0 15px} | |
| .terr{background:var(--card);border:1px solid var(--line);border-radius:16px; | |
| padding:22px 24px 18px;box-shadow:var(--sh)} | |
| .trow{display:grid;grid-template-columns:104px 1fr 60px;align-items:center;gap:12px;padding:4px 0;font-size:13px} | |
| .trow .nm{font-weight:650;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| .trow .nm a{color:var(--ink)} | |
| .trk{position:relative;height:19px;background:var(--bg2);border-radius:5px;overflow:hidden} | |
| .trk .fill{position:absolute;left:0;top:0;bottom:0;border-radius:5px; | |
| background:linear-gradient(90deg,var(--acc2),var(--acc));opacity:.85} | |
| .trk .fill.in{background:linear-gradient(90deg,#e0a83c,#c98d1d)} | |
| .trk .one{position:absolute;top:-3px;bottom:-3px;width:2px;background:var(--ink);opacity:.42} | |
| .trow .v{font-family:var(--m);font-size:12px;color:var(--muted);text-align:right} | |
| .trow .v.in{color:var(--warn);font-weight:700} | |
| .taxis{display:grid;grid-template-columns:104px 1fr 60px;gap:12px;margin-top:11px; | |
| font-size:11px;color:var(--muted);font-family:var(--m)} | |
| .taxis .lbl{display:flex;justify-content:space-between;gap:8px} | |
| .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:15px} | |
| .cc{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px; | |
| display:block;box-shadow:var(--sh);transition:box-shadow .2s,transform .13s,border-color .2s} | |
| a.cc:hover{box-shadow:var(--sh2);transform:translateY(-3px);border-color:var(--line2);text-decoration:none} | |
| .cc.off{opacity:.5} | |
| .cc .ico{font-size:25px;line-height:1} | |
| .cc .nm{font-weight:720;font-size:17px;margin:11px 0 4px;color:var(--ink); | |
| display:flex;align-items:center;gap:8px;flex-wrap:wrap} | |
| .cc .ds{font-size:13px;color:var(--muted);line-height:1.55;min-height:40px} | |
| .cc .mt{margin-top:14px;padding-top:12px;border-top:1px solid var(--line); | |
| font-family:var(--m);font-size:11.5px;color:var(--muted); | |
| display:flex;justify-content:space-between;gap:8px} | |
| .crumb{font-size:13px;color:var(--muted);margin:30px 0 12px} | |
| .ttl{font-size:clamp(30px,5vw,42px);font-weight:850;letter-spacing:-.035em;margin:0 0 6px; | |
| display:flex;align-items:center;gap:12px;flex-wrap:wrap} | |
| .pills{display:flex;gap:7px;flex-wrap:wrap;margin:24px 0 4px} | |
| .pilltab{padding:9px 17px;border-radius:11px;font-size:13.5px;font-weight:650; | |
| color:var(--muted);border:1px solid var(--line);background:var(--card)} | |
| .pilltab:hover{text-decoration:none;color:var(--ink2);border-color:var(--line2)} | |
| .pilltab.on{background:var(--ink);color:var(--bg);border-color:var(--ink)} | |
| .tw{overflow-x:auto;border:1px solid var(--line);border-radius:16px;background:var(--card); | |
| box-shadow:var(--sh);margin:18px 0} | |
| table{width:100%;border-collapse:collapse;font-size:13.5px} | |
| th{text-align:right;font-weight:650;color:var(--muted);font-size:10.5px;letter-spacing:.08em; | |
| text-transform:uppercase;padding:13px;border-bottom:1px solid var(--line);white-space:nowrap} | |
| th:first-child,td:first-child{text-align:center;width:48px} | |
| th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3){text-align:left} | |
| td{padding:13px;border-bottom:1px solid var(--line);text-align:right; | |
| font-family:var(--m);font-variant-numeric:tabular-nums;white-space:nowrap} | |
| td:nth-child(2){font-family:inherit;font-weight:680} | |
| tbody tr:last-child td{border-bottom:0} | |
| tbody tr:hover td{background:var(--bg2)} | |
| tr.base td{color:var(--muted)} | |
| .rk{display:inline-flex;width:25px;height:25px;align-items:center;justify-content:center; | |
| border-radius:8px;background:var(--bg2);font-size:12px;font-weight:750} | |
| .rk.g1{background:#f0c44a26;color:#9c7710}.rk.g2{background:#aab4c426;color:#77828f} | |
| .rk.g3{background:#c9885526;color:#95612f} | |
| .gain{color:var(--good);font-weight:700} | |
| .loss{color:var(--bad);font-weight:700} | |
| .sub2{font-size:11.5px;color:var(--muted);font-family:inherit;font-weight:400;margin-top:3px} | |
| .cc .bl{font-size:12.5px;color:var(--acc);font-weight:600;margin:-1px 0 6px} | |
| .blurbig{font-size:17px;color:var(--ink2);font-weight:600;margin:2px 0 8px} | |
| .mini{display:inline-block;height:6px;border-radius:3px;background:var(--acc);opacity:.5; | |
| vertical-align:middle;margin-right:7px} | |
| .fig{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:19px 21px; | |
| margin:16px 0;font-family:var(--m);font-size:13px;line-height:1.95;overflow-x:auto;box-shadow:var(--sh)} | |
| .fig .n{color:var(--acc);font-weight:750} | |
| .note{background:var(--warnbg);border:1px solid var(--warnln);border-radius:13px; | |
| padding:15px 18px;font-size:14.5px;margin:18px 0;color:var(--ink2);line-height:1.65} | |
| .note.ok{background:var(--goodbg);border-color:color-mix(in srgb,var(--good) 30%,transparent)} | |
| .note.n{background:var(--card);border-color:var(--line)} | |
| .kv{display:grid;grid-template-columns:190px 1fr;gap:10px 18px;font-size:14px;margin:15px 0} | |
| .kv dt{color:var(--muted)}.kv dd{margin:0;font-family:var(--m);font-size:13px;word-break:break-word} | |
| label{display:block;font-size:13.5px;font-weight:680;margin:17px 0 6px} | |
| .hint{font-size:12px;color:var(--muted);font-weight:400} | |
| input[type=text],textarea{width:100%;padding:12px 14px;border:1px solid var(--line2); | |
| border-radius:11px;background:var(--card);color:var(--ink);font-family:var(--m);font-size:13px} | |
| input:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--acc) 45%,transparent); | |
| outline-offset:-1px;border-color:var(--acc)} | |
| .drop{border:2px dashed var(--line2);border-radius:13px;padding:30px;text-align:center; | |
| color:var(--muted);font-size:14px;cursor:pointer;transition:.15s} | |
| .drop:hover,.drop.hot{border-color:var(--acc);background:var(--soft);color:var(--acc)} | |
| .steps{counter-reset:s;list-style:none;padding:0;margin:18px 0} | |
| .steps li{counter-increment:s;position:relative;padding:0 0 26px 46px;border-left:2px solid var(--line);margin-left:15px} | |
| .steps li:last-child{border-left-color:transparent;padding-bottom:0} | |
| .steps li::before{content:counter(s);position:absolute;left:-16px;top:-3px;width:30px;height:30px; | |
| border-radius:50%;background:var(--soft);color:var(--acc);font-weight:750;font-size:13px; | |
| display:flex;align-items:center;justify-content:center;font-family:var(--m);border:3px solid var(--bg)} | |
| .steps b{display:block;font-size:16px;margin-bottom:4px} | |
| ol.cl{list-style:none;counter-reset:c;padding:0;margin:18px 0; | |
| display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:0} | |
| ol.cl li{counter-increment:c;position:relative;padding:16px 16px 16px 52px;border-top:1px solid var(--line)} | |
| ol.cl li::before{content:counter(c);position:absolute;left:8px;top:16px;width:30px;height:30px; | |
| border-radius:9px;background:var(--soft);color:var(--acc);font-weight:750;font-size:13px; | |
| display:flex;align-items:center;justify-content:center;font-family:var(--m)} | |
| ol.cl b{display:block;font-size:15px;margin-bottom:3px} | |
| ol.cl span{font-size:13px;color:var(--muted);line-height:1.5} | |
| .skel{height:150px;border-radius:16px;background:linear-gradient(90deg,var(--bg2),var(--card),var(--bg2)); | |
| background-size:200% 100%;animation:sh 1.3s infinite} | |
| @keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}} | |
| footer{margin-top:88px;padding:28px 0 64px;border-top:1px solid var(--line); | |
| font-size:13px;color:var(--muted);display:flex;gap:20px;flex-wrap:wrap;align-items:center} | |
| @media(max-width:640px){ | |
| .kv{grid-template-columns:1fr}.kv dt{margin-top:8px} | |
| .trow,.taxis{grid-template-columns:78px 1fr 48px;gap:8px;font-size:12px}} | |
| </style> | |
| </head> | |
| <body> | |
| <nav><div class="in"> | |
| <a class="lg" href="#/">LEAD<i>BOARD</i></a> | |
| <span class="chip g" id="spec">v1.1</span> | |
| <span class="sp"></span> | |
| <button class="lang" id="lang"></button> | |
| <span id="who" style="font-size:13px;color:var(--muted)"></span> | |
| </div></nav> | |
| <div class="sib"><div class="in" id="sibbar"></div></div> | |
| <div id="app"><div class="wrap"><div class="skel" style="margin-top:70px"></div></div></div> | |
| <footer class="wrap" id="foot"></footer> | |
| <script src="i18n.js"></script> | |
| <script> | |
| const $=s=>document.querySelector(s), app=$("#app"); | |
| const f=(x,d=3)=>(x==null||isNaN(x))?"—":Number(x).toFixed(d); | |
| const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); | |
| const W=h=>`<div class="wrap">${h}</div>`; | |
| /* 제출자는 허깅페이스 계정이다. 이름만 적으면 누구인지 확인할 길이 없다. */ | |
| const hfUser=u=>u&&u!=="—" | |
| ?`<a href="https://huggingface.co/${encodeURIComponent(u)}" target="_blank" rel="noopener">${esc(u)}</a>` | |
| :"—"; | |
| let CATS=null, ME={user:null}, BOARDS={}, L=null, t=null; | |
| /* 언어 결정 순서: 저장된 선택 → 브라우저 설정 → 영어. | |
| 브라우저를 먼저 보면 한 번 고른 선택이 새로고침마다 뒤집힌다. */ | |
| function pickLang(){ | |
| const saved=localStorage.getItem("lb-lang"); | |
| if(saved && window.T[saved]) return saved; | |
| return (navigator.language||"").toLowerCase().startsWith("ko") ? "ko" : "en"; | |
| } | |
| function setLang(l){ | |
| L=l; t=window.T[l]; | |
| localStorage.setItem("lb-lang", l); | |
| document.documentElement.lang=t.htmlLang; | |
| document.title=t.title; | |
| $("#lang").textContent=t.langBtn; | |
| paintChrome(); | |
| route(); | |
| } | |
| function paintChrome(){ | |
| $("#who").innerHTML=ME.user?`${esc(ME.user.user)} · <a href="/logout">${t.logout}</a>` | |
| :(ME.login_enabled?`<a href="/login?next=${encodeURIComponent(location.hash||"#/")}">${t.login}</a>`:""); | |
| $("#sibbar").innerHTML=`<span class="lb">${t.sib}</span> | |
| <a href="https://huggingface.co/spaces/FINAL-Bench/open-discovery-challenge"> | |
| 🧪 Open Discovery Challenge <span>${t.sibOdc}</span></a> | |
| <a href="https://huggingface.co/spaces/FINAL-Bench/open-materials-challenge"> | |
| 🔋 Open Materials Challenge <span>${t.sibOmc}</span></a>`; | |
| $("#foot").innerHTML=`<span>LEADBOARD · FINAL-Bench / VIDRAFT</span> | |
| <span class="dim">${t.srcLabel} <a href="https://www.ebi.ac.uk/chembl/">ChEMBL</a> · | |
| <a href="https://creativecommons.org/licenses/by-sa/3.0/">CC BY-SA 3.0</a></span> | |
| <span class="sp"></span><a href="/api/health">API</a> | |
| <a href="https://huggingface.co/spaces/FINAL-Bench/open-discovery-challenge">ODC</a> | |
| <a href="https://huggingface.co/spaces/FINAL-Bench/open-materials-challenge">OMC</a>`; | |
| } | |
| async function j(u,o){const r=await fetch(u,o);if(!r.ok)throw new Error(await r.text());return r.json();} | |
| /* ═══ 측정 한계 지형 ═══ */ | |
| function terrain(items,link){ | |
| const MAX=3.0, pc=v=>Math.min(100,100*v/MAX); | |
| return `<div class="terr">${items.map(b=>{ | |
| const inside=b.ratio<1.0; | |
| const nm=link?`<a href="#/b/${encodeURIComponent(b.board)}">${esc(b.board)}</a>`:esc(b.board); | |
| return `<div class="trow"><div class="nm">${nm}</div> | |
| <div class="trk"><div class="fill ${inside?"in":""}" style="width:${pc(b.ratio)}%"></div> | |
| <div class="one" style="left:${pc(1)}%"></div></div> | |
| <div class="v ${inside?"in":""}">${f(b.ratio,2)}×</div></div>`;}).join("")} | |
| <div class="taxis"><span></span><span class="lbl"> | |
| <i>0</i><i>← ${t.terrLeft} · 1.0 · ${t.terrRight} →</i><i>${MAX.toFixed(1)}</i> | |
| </span><span></span></div></div>`; | |
| } | |
| /* 분류 전용 부문은 MAE 기준선이 없다. 그런 부문을 0 으로 처리하면 지형도 맨 왼쪽에 | |
| 붙어 "측정 한계에 도달했다"로 보인다. 재지 못한 것과 재서 작은 것은 다르므로 비운다. */ | |
| const blurb=b=>esc(L==="ko"?(b.blurb||""):(b.blurb_en||b.blurb||"")); | |
| /* 부문마다 주지표가 다르다. 판정을 한 곳에만 둔다. | |
| 화면이 MAE 만 알고 있어서, 프로파일 상관으로 채점되는 Phenotype 이 "학습 기준선이 | |
| 상수 예측을 0.003 차이로 간신히 이긴다"로 보였다. 실제 지표로는 0.092 대 0.159 로 | |
| 잡음 바닥의 7.6배 차이다. 지표를 잘못 띄우면 부문이 고장 난 것처럼 읽힌다. */ | |
| const metricOf=b=>{ | |
| if((b||{}).metric==="profile_corr") return {key:"profile_corr",hb:true,label:"프로파일 상관"}; | |
| const bl=Object.values((b||{}).baselines||{}); | |
| if(bl.length&&bl.every(v=>v.mae==null)&&bl.some(v=>v.auroc!=null)) | |
| return {key:"auroc",hb:true,label:"AUROC"}; | |
| return {key:"mae",hb:false,label:"MAE"}; | |
| }; | |
| const metricLabel=b=>{const m=metricOf(b); | |
| return L==="ko"?(m.label+" ("+(m.hb?"높을수록":"낮을수록")+" 좋음)") | |
| :(m.label+" ("+(m.hb?"higher":"lower")+" is better)");}; | |
| const blScore=(b,v)=>v?v[metricOf(b).key]:null; | |
| /* 측정 한계비는 오차 지표 위에서만 뜻이 있다. 상관 부문에서 MAE 로 계산하면 | |
| 3.40/0.0088 = 385 같은 무의미한 수가 나온다. 재지 못한 것은 비운다. */ | |
| const ratioOf=b=>{ | |
| const nf=(b.noise_floor||{}).sd_single; | |
| if(metricOf(b).key!=="mae"||!nf) return null; | |
| const maes=Object.values(b.baselines||{}).map(v=>v.mae).filter(v=>v!=null); | |
| if(!maes.length) return null; | |
| return Math.min(...maes)/nf; | |
| }; | |
| async function boot(){ | |
| try{ME=await j("/api/me");}catch(e){} | |
| CATS=await j("/api/categories"); | |
| CATS.categories.forEach(c=>(c.board_cards||[]).forEach(b=>BOARDS[b.board]=b)); | |
| $("#spec").textContent=CATS.spec; | |
| $("#lang").onclick=()=>setLang(L==="ko"?"en":"ko"); | |
| setLang(pickLang()); | |
| } | |
| addEventListener("hashchange",route); | |
| function route(){ | |
| if(!t)return; | |
| const p=(location.hash||"#/").slice(2).split("/").filter(Boolean);scrollTo(0,0); | |
| if(!p.length)return hub(); | |
| if(p[0]==="c")return category(p[1]); | |
| if(p[0]==="b")return board(decodeURIComponent(p[1]),p[2]||"rank"); | |
| hub(); | |
| } | |
| /* ═══ 허브 ═══ */ | |
| function hub(){ | |
| const T0=CATS.totals, bs=Object.values(BOARDS); | |
| const items=bs.map(b=>({board:b.board,ratio:ratioOf(b)})) | |
| .filter(x=>x.ratio!=null).sort((a,b)=>a.ratio-b.ratio); | |
| const inside=items.filter(x=>x.ratio<1.3).length; | |
| const lost=bs.filter(b=>b.beats_constant===false).length; | |
| app.innerHTML=W(` | |
| <div class="hero"> | |
| <h1 class="brand">LEAD<i>BOARD</i></h1> | |
| <p class="subttl">${t.heroKicker}</p> | |
| <p class="tag">${t.heroTag}</p> | |
| <p class="lede">${t.heroLede}</p> | |
| <div class="stats"> | |
| <div class="stat"><b id="st_subs">—</b><span>${t.statSubs}</span></div> | |
| <div class="stat"><b>${T0.open}</b><span>${t.statBoards}</span></div> | |
| <div class="stat"><b>${bs.reduce((s,b)=>s+b.n_test,0).toLocaleString()}</b><span>${t.statCompounds}</span></div> | |
| <div class="stat"><b>${CATS.categories.filter(c=>c.open).length}</b><span>${t.statFields}</span></div> | |
| <div class="stat"><b>8</b><span>${t.statClauses}</span></div> | |
| <div class="stat"><b>${T0.planned}</b><span>${t.statPlanned}</span></div> | |
| </div> | |
| </div> | |
| <div class="sec"><h2>${t.secWhy}</h2> | |
| <div class="prose"><p>${t.why1}</p><p>${t.why2}</p><p>${t.why3}</p> | |
| <p class="cite">${t.whyCite}</p></div></div> | |
| <div class="sec"><h2>${t.secFound}</h2> | |
| <p class="lead2">${t.secFoundLead}</p> | |
| <div class="finds"> | |
| <div class="find"><b>${t.found1t}</b><p>${t.found1d}</p></div> | |
| <div class="find"><b>${t.found2t}</b><p>${t.found2d}</p></div> | |
| <div class="find"><b>${t.found3t}</b><p>${t.found3d}</p></div> | |
| </div></div> | |
| <div class="sec"><h2>${t.secLeaders}</h2><p class="lead2">${t.secLeadersLead}</p> | |
| <div id="leaders"><div class="skel" style="height:200px"></div></div></div> | |
| <div class="sec prizebox" id="prizebox"></div> | |
| <div class="sec"><h2>${t.secBrowse}</h2><p class="lead2">${t.secBrowseLead}</p> | |
| <div class="grid">${CATS.categories.map(cardHTML).join("")}</div></div> | |
| <div class="sec"><h2>${t.secEvidence}</h2> | |
| <p class="lead2">${t.secEvidenceLead} ${t.insideCount(inside)}</p> | |
| ${terrain(items,true)} | |
| ${lost?`<div class="note">${t.lostNote(lost)}</div>`:""}</div> | |
| <div class="sec"><h2>${t.secHow}</h2> | |
| <ol class="steps"> | |
| <li><b>${t.how1t}</b><p class="dim" style="font-size:14px">${t.how1d}</p></li> | |
| <li><b>${t.how2t}</b><p class="dim" style="font-size:14px">${t.how2d}</p></li> | |
| <li><b>${t.how3t}</b><p class="dim" style="font-size:14px">${t.how3d}</p></li> | |
| </ol></div> | |
| <div class="sec"><h2>${t.secMetric}</h2> | |
| <dl class="defs"> | |
| <dt>MAE</dt><dd>${t.mMae}</dd> | |
| <dt>AUROC</dt><dd>${t.mAuroc}</dd> | |
| <dt>PR-AUC</dt><dd>${t.mPrauc}</dd> | |
| <dt>${t.noiseH}</dt><dd>${t.mNoise}</dd> | |
| <dt>${t.ratioLabel}</dt><dd>${t.mRatio}</dd> | |
| <dt>${t.thLeak}</dt><dd>${t.mLeak}</dd> | |
| </dl></div> | |
| <div class="sec"><h2>${t.secMethod}</h2><p class="lead2">${t.secMethodLead}</p> | |
| <ol class="cl">${t.clauses.map(([a,b])=>`<li><b>${a}</b><span>${b}</span></li>`).join("")}</ol></div> | |
| <div class="sec"><h2>${t.secOwn}</h2> | |
| <div class="prose"><p>${t.own1}</p><p>${t.own2}</p><p>${t.own3}</p></div></div> | |
| <div class="sec"><h2>${t.secData}</h2> | |
| <div class="note n">${t.dataNote}<br><br> | |
| ${t.srcLabel} · <a href="https://www.ebi.ac.uk/chembl/">ChEMBL_37 (EMBL-EBI)</a> · | |
| <a href="https://creativecommons.org/licenses/by-sa/3.0/">CC BY-SA 3.0 Unported</a></div></div>`); | |
| paintLeaders(); | |
| } | |
| /* 부문별 1위 표. 비동기로 채운다 - 원장을 부문 수만큼 읽어야 해서 첫 그림을 막으면 안 된다. */ | |
| /* 상금 안내. 순위표만 있고 왜 지금 해야 하는지가 없어 29부문이 비어 있었다. */ | |
| function renderPrize(d){ | |
| const el=$("#prizebox"); if(!el||!d||!d.prize) return; | |
| const p=d.prize, s=d.standing||[]; | |
| el.innerHTML=`<h2>${t.prizeH}</h2> | |
| <div class="amt">$${p.amount_usd.toLocaleString()} <small>USD</small></div> | |
| <p>${t.prizeLead(p.amount_usd.toLocaleString(), p.closes)}</p> | |
| <p>${t.prizeRule(p.n_boards)}</p> | |
| <div class="cand"><b>${t.prizeStanding}</b> — ${ | |
| s.length? s.slice(0,6).map(x=>`${esc(x.user)} <span class="dim">(${t.prizeFirsts(x.firsts)})</span>`).join(" · ") | |
| : t.prizeNone}</div> | |
| <div class="fine">${t.prizeHost}</div>`; | |
| } | |
| async function paintLeaders(){ | |
| const el=$("#leaders"); if(!el)return; | |
| let d; try{ d=await j("/api/leaders"); }catch(e){ el.innerHTML=""; return; } | |
| renderPrize(d); | |
| // 제출 수는 원장을 훑어야 나오므로 순위 응답에 실려 온다. 실패하면 - 로 둔다. | |
| // 0 으로 두면 "아무도 안 냈다"로 읽히는데, 그것과 "못 셌다"는 다르다. | |
| const se = $("#st_subs"); | |
| if (se) se.textContent = (d.submissions==null) ? "—" : d.submissions.toLocaleString(); | |
| const rows=d.rows.map(r=>{ | |
| const ld=r.leader; // 전역 L 은 언어다. 가리지 않는다 | |
| // 점수는 부문마다 주지표가 다르다. 회귀 부문은 MAE, 분류 전용 부문은 AUROC. | |
| const hb=!!r.higher_better; | |
| const score=ld?(ld.score!=null?ld.score:(hb?ld.auroc:ld.mae)):null; | |
| const bas=r.baseline_best_score!=null?r.baseline_best_score | |
| :(hb?r.baseline_best_auroc:r.baseline_best); | |
| let vs="—"; | |
| if(ld&&score!=null&&bas!=null){ | |
| const better=hb?score>bas:score<bas; | |
| const gap=hb?(score-bas):(bas-score); | |
| vs=`<span class="${better?"gain":"loss"}">${better?"▲":"▼"} ${Math.abs(gap).toFixed(3)}</span>`; | |
| } | |
| return `<tr> | |
| <td style="text-align:left"><a href="#/b/${encodeURIComponent(r.board)}">${esc(r.board)}</a> | |
| <span class="chip g" style="margin-left:6px">${r.split_grade}/${r.answer_grade}</span> | |
| <div class="sub2">${esc(L==="ko"?(r.blurb||""):(r.blurb_en||r.blurb||""))}</div></td> | |
| <td style="text-align:left">${ld?esc(ld.method):`<span class="dim">${t.noLeader}</span>`}</td> | |
| <td style="text-align:left">${ld?hfUser(ld.user):"—"}</td> | |
| <td>${score!=null?f(score):"—"}</td> | |
| <td>${vs}</td> | |
| <td>${(r.n_test||0).toLocaleString()}</td></tr>`;}).join(""); | |
| el.innerHTML=`<div class="tw"><table><thead><tr> | |
| <th style="text-align:left;width:auto">${t.thBoard}</th> | |
| <th style="text-align:left">${t.thBest}</th><th style="text-align:left">${t.thUser}</th> | |
| <th>${t.thScore}</th><th>${t.thVs}</th><th>${t.thN}</th></tr></thead> | |
| <tbody>${rows}</tbody></table></div> | |
| <p class="dim" style="font-size:13px">${d.held?t.leadersSome(d.held,d.n):t.leadersEmpty(d.n)}</p>`; | |
| } | |
| function cardHTML(c){ | |
| const on=c.open>0, bs=c.board_cards||[]; | |
| const tot=bs.reduce((s,b)=>s+b.n_test,0); | |
| const inner=`<div class="ico">${c.icon}</div> | |
| <div class="nm">${esc(L==="ko"?c.name:c.en)} | |
| <span class="chip ${on?"ok":"g"}">${on?t.openN(c.open):t.prep}</span></div> | |
| <div class="ds">${esc(L==="ko"?c.desc:(c.desc_en||c.desc))}</div> | |
| <div class="mt"><span>${t.cardBoards(c.open,c.planned)}</span> | |
| <span>${tot?t.cardCompounds(tot.toLocaleString()):"—"}</span></div>`; | |
| return on?`<a class="cc" href="#/c/${c.id}">${inner}</a>`:`<div class="cc off">${inner}</div>`; | |
| } | |
| /* ═══ 분야 ═══ */ | |
| function category(id){ | |
| const c=CATS.categories.find(x=>x.id===id); if(!c)return hub(); | |
| const bs=(c.board_cards||[]).slice().sort((a,b)=>b.n_test-a.n_test); | |
| const items=bs.map(b=>({board:b.board,ratio:ratioOf(b)})) | |
| .filter(x=>x.ratio!=null).sort((a,b)=>a.ratio-b.ratio); | |
| const nm=L==="ko"?c.name:c.en; | |
| app.innerHTML=W(` | |
| <div class="crumb"><a href="#/">${t.all}</a> › ${esc(nm)}</div> | |
| <h1 class="ttl">${c.icon} ${esc(nm)}</h1> | |
| <p class="lede">${esc(L==="ko"?c.desc:(c.desc_en||c.desc))}</p> | |
| <div class="stats" style="margin-top:26px"> | |
| <div class="stat"><b>${c.open}</b><span>${t.statBoards}</span></div> | |
| <div class="stat"><b>${bs.reduce((s,b)=>s+b.n_test,0).toLocaleString()}</b><span>${t.statCompounds}</span></div> | |
| <div class="stat"><b>${c.planned}</b><span>${t.statPlanned}</span></div></div> | |
| ${bs.length?` | |
| <div class="sec"><h2>${t.secBrowse}</h2> | |
| ${bs.some(x=>x.starter)?`<p class="lead2">${t.starterLead}</p>`:""} | |
| <div class="grid">${bs.slice().sort((a,b)=>(b.starter?1:0)-(a.starter?1:0)).map(boardMini).join("")}</div></div> | |
| <div class="sec"><h2>${t.secEvidence}</h2><p class="lead2">${t.secEvidenceLead}</p> | |
| ${terrain(items,true)}</div>` | |
| :`<div class="note">${t.emptyCat}</div>`}`); | |
| } | |
| function boardMini(b){ | |
| const bl=b.baselines[b.best_baseline]||{}; | |
| return `<a class="cc" href="#/b/${encodeURIComponent(b.board)}"> | |
| <div class="nm">${esc(b.board)}<span class="chip">${b.split_grade}/${b.answer_grade}</span>${ | |
| b.starter?`<span class="chip st1">${t.starterTag}</span>`:""}</div> | |
| <div class="bl">${blurb(b)}</div> | |
| <div class="ds">${t.cardCompounds(b.n_test.toLocaleString())} · ${t.noiseH} ${f((b.noise_floor||{}).sd_single)}<br> | |
| ${esc(b.best_baseline)} ${f(blScore(b,bl))}</div> | |
| <div class="mt"><span>${esc(b.source)}</span><span>${ratioOf(b)==null?"—":f(ratioOf(b),2)+"×"}</span></div></a>`; | |
| } | |
| /* ═══ 부문 ═══ */ | |
| async function board(name,tab){ | |
| const b=BOARDS[name]||await j("/api/board/"+encodeURIComponent(name)); | |
| const cat=CATS.categories.find(c=>(c.boards||[]).includes(name)); | |
| const cn=cat?(L==="ko"?cat.name:cat.en):""; | |
| const TT=[["rank",t.tabRank],["submit",t.tabSubmit],["guide",t.tabGuide],["info",t.tabInfo]]; | |
| app.innerHTML=W(` | |
| <div class="crumb"><a href="#/">${t.all}</a> › <a href="#/c/${cat?cat.id:""}">${esc(cn)}</a> › ${esc(b.board)}</div> | |
| <h1 class="ttl">${esc(b.board)}<span class="chip">${b.split_grade}/${b.answer_grade}</span></h1> | |
| <p class="blurbig">${blurb(b)}</p> | |
| <p class="lede">${esc(b.source)} · ${esc(b.type)} · | |
| ${t.cardCompounds(b.n_test.toLocaleString())} · ${t.noiseH} <b>${f(b.noise_floor.sd_single)}</b></p> | |
| <div class="pills">${TT.map(([k,l])=> | |
| `<a class="pilltab ${k===tab?"on":""}" href="#/b/${encodeURIComponent(name)}/${k}">${l}</a>`).join("")}</div> | |
| <div id="pane"><div class="skel" style="margin-top:20px"></div></div>`); | |
| if(tab==="rank")return paneRank(b); | |
| if(tab==="submit")return paneSubmit(b); | |
| if(tab==="guide")return paneGuide(b); | |
| paneInfo(b); | |
| } | |
| async function paneRank(b){ | |
| const d=await j(`/api/board/${encodeURIComponent(b.board)}/leaderboard`); | |
| const nf=d.noise_floor, ent=d.rows.filter(r=>r.kind!=="baseline"); | |
| const worst=Math.max(...d.rows.map(r=>r.mae||0))||1; | |
| const leak=l=>l==="high"?`<span class="chip bd">${t.leakHigh}</span>` | |
| :l==="elevated"?`<span class="chip wr">${t.leakElev}</span>`:`<span class="chip g">—</span>`; | |
| const rows=d.rows.map(r=>{ | |
| const g=r.rank&&r.rank<=3?` g${r.rank}`:""; | |
| const w=Math.max(4,58*(r.mae||0)/worst); | |
| return `<tr class="${r.kind!=="entry"?"base":""}"> | |
| <td>${r.rank?`<span class="rk${g}">${r.rank}</span>`:"·"}</td> | |
| <td>${esc(r.method)}</td><td>${r.kind==="entry"?hfUser(r.user):esc(r.user)}</td> | |
| <td><span class="mini" style="width:${w}px"></span>${f(r.mae)}</td> | |
| <td>${f(r.auroc)}</td><td>${f(r.prauc)}</td> | |
| <td>${r.kind==="baseline"?`<span class="chip g">${t.baseline}</span>`:leak(r.leak)}</td> | |
| <td>${r.kind==="baseline"?"—":r.kind==="reference"?`<span class="chip wr">${t.reference}</span>` | |
| :(r.verified?`<span class="chip ok">${t.verified}</span>` | |
| :`<span class="chip g">${t.submitted}</span>`)}</td></tr>`;}).join(""); | |
| $("#pane").innerHTML=` | |
| <div class="tw"><table><thead><tr><th>${t.thRank}</th><th>${t.thMethod}</th><th>${t.thUser}</th> | |
| <th>MAE</th><th>AUROC</th><th>PR-AUC</th><th>${t.thLeak}</th><th>${t.thGrade}</th></tr></thead> | |
| <tbody>${rows}</tbody></table></div> | |
| <div class="note">${t.noiseNote(f(nf))}</div> | |
| <p class="dim" style="font-size:13px">${ent.length | |
| ? t.updated(new Date(d.updated*1000).toLocaleString(L==="ko"?"ko-KR":"en-GB")) | |
| : t.noEntries}</p>`; | |
| } | |
| /* 부문 값이 채워진 참여 가이드. 일반문으로 두면 참가자가 자기 부문에 맞게 고쳐야 하고, | |
| 대부분 그 단계에서 떨어진다. */ | |
| function promptFor(b){ | |
| const nf=(b.noise_floor||{}).sd_single; | |
| const mk=metricOf(b); | |
| const bl=Object.entries(b.baselines||{}) | |
| .map(([k,v])=>` ${k}: ${mk.label} ${f(v[mk.key])}`).join("\n"); | |
| const unit = b.type && b.type.indexOf("logS")>=0 | |
| ? "logS = log10(용해도 in mol/L)" | |
| : b.threshold==null ? "부문 정의값" : "p 스케일 = -log10(농도 in M)"; | |
| return [ | |
| `나는 신약 예측 도구 시험대 LEADBOARD 의 "${b.board}" 부문에 참가한다.`, | |
| `아래 조건대로 예측 파일을 만드는 Python 스크립트를 작성해 달라.`, | |
| ``, | |
| `[과제]`, | |
| ` 입력 ${b.board.toLowerCase()}_test.json — compound_id 와 SMILES 만 들어 있다 (${b.n_test}개)`, | |
| ` 출력 predictions.csv — 두 열: compound_id,prediction`, | |
| ` 예측값 ${unit}`, | |
| b.target && b.target!=="—" ? ` 대상 ${b.target} · ${b.type}` : ` 대상 ${b.type}`, | |
| ``, | |
| `[학습 자료]`, | |
| b.cut ? ` ChEMBL 에서 ${b.target} 의 ${b.type} 활성을 받아 쓴다.` : ` 공개 자료를 직접 큐레이션해 쓴다.`, | |
| b.cut ? ` ★ ${b.cut}년 이후 처음 보고된 화합물은 학습에 넣지 말 것 — 그것이 테스트셋이다.` : ``, | |
| ` 같은 화합물이 여러 논문에 있으면 InChIKey 로 접어 중앙값을 쓸 것.`, | |
| ``, | |
| `[제약]`, | |
| ` 방법에 제한은 없다. 지문+트리, 그래프 신경망, 사전학습 모델, 물리 계산 모두 가능.`, | |
| ` 정답을 조회해 채우지 말 것. 예측값이 실측과 소수 자리까지 일치하는 밀도를 검사한다.`, | |
| ``, | |
| `[평가]`, | |
| ` 주지표 ${metricLabel(b)}`, | |
| nf ? ` 이 부문의 실험 재현 오차는 ${f(nf)} 이다. 그보다 작은 차이는 순위로 인정되지 않는다.` : ``, | |
| ` 넘어야 할 기준선:`, | |
| bl, | |
| ``, | |
| `스크립트는 자료 수집 → 정제 → 특징 → 학습 → 예측 → CSV 저장까지 한 파일로 작성해 달라.` | |
| ].filter(x=>x!==``||true).join("\n"); | |
| } | |
| function starterFor(b){ | |
| /* 부문은 세 종류다. 하나의 뼈대로 덮으면 나머지 둘은 돌아가지 않는다. | |
| ChEMBL 표적 활성 target_chembl_id + standard_type 으로 조회한다. | |
| ChEMBL 물성·전세포 표적이 없거나 균이다. standard_type 과 assay 설명으로 고른다. | |
| 그 외 (JUMP 등) ChEMBL 에 없다. 조회문을 지어내지 않는다. | |
| 세 번째를 첫 번째 뼈대로 내보내다가, 표적 자리에 em dash 가 박힌 코드를 | |
| 참가자에게 주고 있었다. 그대로 돌리면 빈 훈련셋이 나온다. */ | |
| const isChembl=/^CHEMBL[0-9]+$/.test(String(b.target||"")); | |
| const stype=b.stype||b.type; | |
| /* 활성 조회로 만들어지지 않는 부문이 두 종류 더 있다. 승인·퇴출 부문은 molecule | |
| 엔드포인트를 쓰고, 안전역 부문은 두 값의 차이다. 이 셋을 한 덩어리로 묶어 | |
| "ChEMBL 에서 나오지 않는다"고 적으면 사실이 아닌 안내가 된다 - 셋 다 ChEMBL 이다. */ | |
| if(b.query_kind==="molecule") return starterMolecule(b); | |
| if(b.query_kind==="derived") return starterDerived(b); | |
| if(!isChembl && !b.stype) return starterNoChembl(b); | |
| const q=[` q = {"standard_type": "${stype}", "standard_relation": "=",`]; | |
| if(isChembl) q.push(` "target_chembl_id": "${b.target}",`); | |
| q.push(` "limit": 1000, "offset": off}`); | |
| const filt=[]; | |
| if(b.assay_keep) filt.push(`ak = re.compile(r"${b.assay_keep}", re.I)`); | |
| if(b.assay_drop) filt.push(`ad = re.compile(r"${b.assay_drop}", re.I)`); | |
| return [ | |
| `# ${b.board} 제출 뼈대`, | |
| `import json, math, re, requests, numpy as np`, | |
| `from rdkit import Chem`, | |
| `from rdkit.Chem import rdFingerprintGenerator`, | |
| `import lightgbm as lgb`, | |
| ``, | |
| `# 1) 훈련 자료 (ChEMBL 에서 받는다)`, | |
| `API = "https://www.ebi.ac.uk/chembl/api/data/activity.json"`, | |
| `rows, off = [], 0`, | |
| `while True:`, | |
| ...q, | |
| ` d = requests.get(API, params=q, timeout=120).json()`, | |
| ` rows += d["activities"]; off += 1000`, | |
| ` if off >= d["page_meta"]["total_count"]: break`, | |
| ``, | |
| filt.length?`# 2) 실험법 선별 — 같은 이름의 측정도 조건이 다르면 값이 다르다`:``, | |
| ...filt, | |
| ``, | |
| `# 3) 정제 — InChIKey 로 접고, 기준일 이후는 버린다`, | |
| `rec = {}`, | |
| `for r in rows:`, | |
| ` s, v, u, y = r["canonical_smiles"], r["standard_value"], r["standard_units"], r["document_year"]`, | |
| ` if not (s and v and y): continue`, | |
| b.assay_keep?` if not ak.search(r.get("assay_description") or ""): continue`:``, | |
| b.assay_drop?` if ad.search(r.get("assay_description") or ""): continue`:``, | |
| b.log===false?` if u not in (None, ""): continue # 이 부문 값은 무단위다` | |
| :` if u != "nM": continue`, | |
| ` m = Chem.MolFromSmiles(s)`, | |
| ` if m is None or float(v) <= 0: continue`, | |
| ` k = Chem.MolToInchiKey(m)`, | |
| ` rec.setdefault(k, {"y": [], "p": [], "m": m})`, | |
| b.log===false?` rec[k]["y"].append(int(y)); rec[k]["p"].append(float(v))` | |
| :` rec[k]["y"].append(int(y)); rec[k]["p"].append(9.0 - math.log10(float(v)))`, | |
| b.clip?`rec = {k: v for k, v in rec.items()`:``, | |
| b.clip?` if ${b.clip[0]} <= float(np.median(v["p"])) <= ${b.clip[1]}} # 물리적 범위 밖은 입력 오류다`:``, | |
| `train = {k: v for k, v in rec.items() if min(v["y"]) < ${b.cut||2022}} # ★ 테스트셋 제외`, | |
| ``, | |
| `# 4) 특징 · 학습`, | |
| `gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)`, | |
| `X = np.array([gen.GetFingerprintAsNumPy(v["m"]) for v in train.values()])`, | |
| `Y = np.array([float(np.median(v["p"])) for v in train.values()])`, | |
| `model = lgb.LGBMRegressor(n_estimators=500, learning_rate=0.05).fit(X, Y)`, | |
| ``, | |
| `# 5) 예측 · 저장`, | |
| `test = json.load(open("${b.board.toLowerCase()}_test.json", encoding="utf-8"))`, | |
| `Xt = np.array([gen.GetFingerprintAsNumPy(Chem.MolFromSmiles(c["smiles"]))`, | |
| ` for c in test["compounds"]])`, | |
| `p = model.predict(Xt)`, | |
| `with open("predictions.csv", "w") as f:`, | |
| ` f.write("compound_id,prediction\n")`, | |
| ` for c, v in zip(test["compounds"], p):`, | |
| ` f.write("%s,%.4f\n" % (c["compound_id"], v))` | |
| ].filter(x=>x!==``||true).join("\n"); | |
| } | |
| /* ChEMBL 에서 나오지 않는 부문. 조회문을 지어내지 않고, 자료가 어디 있는지 밝힌다. | |
| 훈련 라벨은 우리가 만든 판본이므로 대신 만들어 줄 수 없다 - 규칙 그대로, | |
| 참가자가 공개 자료를 직접 큐레이션한다. */ | |
| function starterMolecule(b){ | |
| /* 승인·퇴출 부문. 활성 조회가 아니라 molecule 엔드포인트에서 승인 소분자를 받는다. */ | |
| return [ | |
| `# ${b.board} 제출 뼈대`, | |
| `#`, | |
| `# ${b.query_note||"활성 조회가 아니라 molecule 엔드포인트를 쓴다."}`, | |
| ``, | |
| `import json, requests, numpy as np`, | |
| `from rdkit import Chem`, | |
| `from rdkit.Chem import rdFingerprintGenerator`, | |
| `import lightgbm as lgb`, | |
| ``, | |
| `# 1) 승인 소분자 목록 (활성이 아니라 분자 자체를 받는다)`, | |
| `API = "https://www.ebi.ac.uk/chembl/api/data/molecule.json"`, | |
| `rows, off = [], 0`, | |
| `while True:`, | |
| ` q = {"max_phase": 4, "molecule_type": "Small molecule",`, | |
| ` "limit": 1000, "offset": off}`, | |
| ` d = requests.get(API, params=q, timeout=180).json()`, | |
| ` rows += d["molecules"]; off += 1000`, | |
| ` if off >= d["page_meta"]["total_count"]: break`, | |
| ``, | |
| `# 2) 라벨 — withdrawn_flag`, | |
| `pairs = []`, | |
| `for m in rows:`, | |
| ` s = (m.get("molecule_structures") or {}).get("canonical_smiles")`, | |
| ` if not (s and m.get("first_approval")): continue`, | |
| ` pairs.append((s, 1 if m.get("withdrawn_flag") else 0, m["first_approval"]))`, | |
| `# ★ 시험 화합물과 겹치지 않게 구성할 것. 승인 연도로 비교군을 맞추면 달력이 답을 준다.`, | |
| ``, | |
| `gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)`, | |
| `mols = [Chem.MolFromSmiles(s) for s, _, _ in pairs]`, | |
| `X = np.array([gen.GetFingerprintAsNumPy(m) for m in mols if m is not None])`, | |
| `Y = np.array([y for (s, y, _), m in zip(pairs, mols) if m is not None])`, | |
| `model = lgb.LGBMClassifier(n_estimators=500, learning_rate=0.05).fit(X, Y)`, | |
| ``, | |
| `test = json.load(open("${b.board.toLowerCase()}_test.json", encoding="utf-8"))`, | |
| `Xt = np.array([gen.GetFingerprintAsNumPy(Chem.MolFromSmiles(c["smiles"]))`, | |
| ` for c in test["compounds"]])`, | |
| `p = model.predict_proba(Xt)[:, 1]`, | |
| `with open("predictions.csv", "w") as f:`, | |
| ` f.write("compound_id,prediction\n")`, | |
| ` for c, v in zip(test["compounds"], p):`, | |
| ` f.write("%s,%.4f\n" % (c["compound_id"], v))` | |
| ].join("\n"); | |
| } | |
| function starterDerived(b){ | |
| /* 안전역 부문. 단일 측정이 아니라 두 값의 차이다. */ | |
| return [ | |
| `# ${b.board} 제출 뼈대`, | |
| `#`, | |
| `# ${b.query_note||"두 값의 차이로 만들어지는 부문이다."}`, | |
| `#`, | |
| `# 실측 참고: 양쪽을 따로 예측해 빼는 쪽이, 차이를 직접 학습하는 것보다 좋았다.`, | |
| ``, | |
| `import json, math, requests, numpy as np`, | |
| `from rdkit import Chem`, | |
| `from rdkit.Chem import rdFingerprintGenerator`, | |
| `import lightgbm as lgb`, | |
| ``, | |
| `API = "https://www.ebi.ac.uk/chembl/api/data/activity.json"`, | |
| ``, | |
| `def pull(target):`, | |
| ` rows, off = [], 0`, | |
| ` while True:`, | |
| ` q = {"target_chembl_id": target, "standard_type": "IC50",`, | |
| ` "standard_relation": "=", "limit": 1000, "offset": off}`, | |
| ` d = requests.get(API, params=q, timeout=120).json()`, | |
| ` rows += d["activities"]; off += 1000`, | |
| ` if off >= d["page_meta"]["total_count"]: break`, | |
| ` rec = {}`, | |
| ` for r in rows:`, | |
| ` s, v, u = r["canonical_smiles"], r["standard_value"], r["standard_units"]`, | |
| ` if not (s and v and u == "nM") or float(v) <= 0: continue`, | |
| ` m = Chem.MolFromSmiles(s)`, | |
| ` if m is None: continue`, | |
| ` rec.setdefault(Chem.MolToInchiKey(m), {"m": m, "p": []})`, | |
| ` rec[Chem.MolToInchiKey(m)]["p"].append(9.0 - math.log10(float(v)))`, | |
| ` return rec`, | |
| ``, | |
| `herg = pull("CHEMBL240") # 심장 쪽`, | |
| `tgt = pull("CHEMBL_표적_ID") # ← 이 부문의 표적으로 바꾼다`, | |
| ``, | |
| `# 안전역 = 표적 활성 - hERG 활성. 각각 예측기를 만들고 예측값을 뺀다.`, | |
| `gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)`, | |
| ``, | |
| `def fit(rec):`, | |
| ` X = np.array([gen.GetFingerprintAsNumPy(v["m"]) for v in rec.values()])`, | |
| ` Y = np.array([float(np.median(v["p"])) for v in rec.values()])`, | |
| ` return lgb.LGBMRegressor(n_estimators=500, learning_rate=0.05).fit(X, Y)`, | |
| ``, | |
| `m_t, m_h = fit(tgt), fit(herg)`, | |
| `test = json.load(open("${b.board.toLowerCase()}_test.json", encoding="utf-8"))`, | |
| `Xt = np.array([gen.GetFingerprintAsNumPy(Chem.MolFromSmiles(c["smiles"]))`, | |
| ` for c in test["compounds"]])`, | |
| `p = m_t.predict(Xt) - m_h.predict(Xt)`, | |
| `with open("predictions.csv", "w") as f:`, | |
| ` f.write("compound_id,prediction\n")`, | |
| ` for c, v in zip(test["compounds"], p):`, | |
| ` f.write("%s,%.4f\n" % (c["compound_id"], v))` | |
| ].join("\n"); | |
| } | |
| function starterNoChembl(b){ | |
| return [ | |
| `# ${b.board} 제출 뼈대`, | |
| `#`, | |
| `# 이 부문은 ChEMBL 활성 조회로 만들어지지 않는다. 출처: ${b.source}`, | |
| `# 훈련 라벨은 본 시험대가 만든 판본이라 배포하지 않는다. 규칙 3조대로`, | |
| `# 공개 원자료에서 직접 만들어 써야 한다.`, | |
| `#`, | |
| `# 아래는 학습·예측·저장 부분이다. load_training() 만 채우면 된다.`, | |
| ``, | |
| `import json, numpy as np`, | |
| `from rdkit import Chem`, | |
| `from rdkit.Chem import rdFingerprintGenerator`, | |
| `import lightgbm as lgb`, | |
| ``, | |
| `def load_training():`, | |
| ` """(SMILES, 목표값) 목록을 낸다.`, | |
| ` 출처: ${b.source}`, | |
| ` ${b.cut?("★ "+b.cut+"년 이후 처음 보고된 화합물은 넣지 말 것 — 그것이 테스트셋이다."):"시험 화합물과 겹치지 않게 구성할 것."}`, | |
| ` """`, | |
| ` raise NotImplementedError("공개 원자료에서 직접 구성한다")`, | |
| ``, | |
| `pairs = load_training()`, | |
| `gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)`, | |
| `mols = [Chem.MolFromSmiles(s) for s, _ in pairs]`, | |
| `X = np.array([gen.GetFingerprintAsNumPy(m) for m in mols if m is not None])`, | |
| `Y = np.array([y for (s, y), m in zip(pairs, mols) if m is not None])`, | |
| `model = lgb.LGBMRegressor(n_estimators=500, learning_rate=0.05).fit(X, Y)`, | |
| ``, | |
| `test = json.load(open("${b.board.toLowerCase()}_test.json", encoding="utf-8"))`, | |
| `Xt = np.array([gen.GetFingerprintAsNumPy(Chem.MolFromSmiles(c["smiles"]))`, | |
| ` for c in test["compounds"]])`, | |
| `p = model.predict(Xt)`, | |
| `with open("predictions.csv", "w") as f:`, | |
| ` f.write("compound_id,prediction\n")`, | |
| ` for c, v in zip(test["compounds"], p):`, | |
| ` f.write("%s,%.4f\n" % (c["compound_id"], v))` | |
| ].join("\n"); | |
| } | |
| function paneGuide(b){ | |
| const pr=promptFor(b), sk=starterFor(b); | |
| const mk=metricOf(b); | |
| const bl=Object.entries(b.baselines||{}).sort((x,y)=> | |
| (mk.hb?-1:1)*((x[1][mk.key]??0)-(y[1][mk.key]??0))); | |
| $("#pane").innerHTML=` | |
| <p class="lede" style="margin:6px 0 18px">${t.gdIntro}</p> | |
| <h3>${t.gdWhat}</h3><p class="dim" style="font-size:14.5px">${t.gdWhatD}</p> | |
| <h3>${t.gdPrompt}</h3><p class="dim" style="font-size:14px">${t.gdPromptD}</p> | |
| <div style="position:relative"> | |
| <button class="btn" id="cp1" style="position:absolute;right:12px;top:12px;padding:6px 13px;font-size:12px">${t.gdCopy}</button> | |
| <pre class="fig" style="white-space:pre-wrap;margin:0">${esc(pr)}</pre></div> | |
| <h3>${t.gdStarter}</h3><p class="dim" style="font-size:14px">${t.gdStarterD}</p> | |
| <div style="position:relative"> | |
| <button class="btn" id="cp2" style="position:absolute;right:12px;top:12px;padding:6px 13px;font-size:12px">${t.gdCopy}</button> | |
| <pre class="fig" style="white-space:pre;overflow-x:auto;margin:0;font-size:12px">${esc(sk)}</pre></div> | |
| <h3>${t.gdTarget}</h3><p class="dim" style="font-size:14px">${t.gdTargetD}</p> | |
| <div class="tw"><table><thead><tr><th style="text-align:left;width:auto">${t.baseH}</th> | |
| <th>${esc(metricLabel(b))}</th></tr></thead><tbody> | |
| ${bl.map(([k,v])=>`<tr><td style="text-align:left">${esc(k)}</td> | |
| <td>${v[mk.key]!=null?f(v[mk.key]):"—"}</td></tr>`).join("")} | |
| </tbody></table></div> | |
| <h3>${t.gdRules}</h3> | |
| <ol class="cl" style="grid-template-columns:1fr"> | |
| ${[t.gdR1,t.gdR2,t.gdR3,t.gdR4].map(x=>`<li><span>${x}</span></li>`).join("")} | |
| </ol>`; | |
| const wireCopy=(id,txt)=>{const el=$("#"+id); if(!el)return; | |
| el.onclick=async()=>{try{await navigator.clipboard.writeText(txt); | |
| el.textContent=t.gdCopied; setTimeout(()=>el.textContent=t.gdCopy,1600);}catch(e){}};}; | |
| wireCopy("cp1",pr); wireCopy("cp2",sk); | |
| } | |
| function paneInfo(b){ | |
| const nf=b.noise_floor, bl=b.baselines; | |
| $("#pane").innerHTML=` | |
| <h3>${t.gradeH}</h3> | |
| <dl class="kv"> | |
| <dt>${t.splitGrade} <b>${b.split_grade}</b></dt> | |
| <dd>${esc(t.gradeNote[b.split_grade]||b.split_grade_note||"")}</dd> | |
| <dt>${t.answerGrade} <b>${b.answer_grade}</b></dt> | |
| <dd>${esc(t.gradeNote[b.answer_grade]||b.answer_grade_note||"")}</dd></dl> | |
| <h3>${t.holdoutH}</h3> | |
| <dl class="kv"> | |
| <dt>${t.kSource}</dt><dd>${esc(b.source)} · ${esc(b.target)} · ${esc(b.type)}</dd> | |
| <dt>${t.kCut}</dt><dd>${b.cut} — ${t.cutNote}</dd> | |
| <dt>${t.kTrainTest}</dt><dd>${b.n_train.toLocaleString()} / ${b.n_test.toLocaleString()}</dd> | |
| <dt>${t.kOverlap}</dt><dd>${t.overlapNote}</dd> | |
| <dt>${t.kThr}</dt><dd>${b.threshold} · ${t.posRate((b.positive_rate*100).toFixed(1))}</dd> | |
| <dt>${t.kSha}</dt><dd>${esc(b.dataset_sha)}</dd> | |
| <dt>${t.kLicense}</dt><dd>${/JUMP/i.test(b.source||"") | |
| ? "JUMP Cell Painting · CC0" | |
| : "ChEMBL · CC BY-SA 3.0"}</dd></dl> | |
| <h3>${t.noiseH} <span class="dim" style="font-weight:400;font-size:14px">(4)</span></h3> | |
| <div class="fig">${t.noiseDesc}<br> | |
| ${t.noisePairs(nf.pairs.toLocaleString(),nf.compounds.toLocaleString())}<br> | |
| median ${f(nf.median)} · mean ${f(nf.mean)} · p90 ${f(nf.p90)}<br> | |
| <b>${t.noiseSd} <span class="n">${f(nf.sd_single)}</span></b></div> | |
| <div class="note">${t.nearNote(b.near_pct,b.near_pct>45)}</div> | |
| <h3>${t.baseH} <span class="dim" style="font-weight:400;font-size:14px">(3)</span></h3> | |
| <div class="tw"><table><thead><tr><th style="width:auto">·</th><th>${t.baseH}</th><th>MAE</th> | |
| <th>AUROC</th><th>PR-AUC</th></tr></thead><tbody> | |
| ${Object.entries(bl).map(([k,v])=>`<tr><td>·</td><td>${esc(k)}</td><td>${f(v.mae)}</td> | |
| <td>${f(v.auroc)}</td><td>${f(v.prauc)}</td></tr>`).join("")} | |
| <tr class="base"><td>·</td><td>${t.noiseFloorRow}</td><td>${f(nf.sd_single)}</td><td>—</td><td>—</td></tr> | |
| </tbody></table></div> | |
| <div class="note ${b.beats_constant?"ok":""}">${b.beats_constant?t.beatsYes:t.beatsNo}</div>`; | |
| } | |
| function paneSubmit(b){ | |
| const url=`/api/board/${encodeURIComponent(b.board)}/testset`; | |
| $("#pane").innerHTML=` | |
| <ol class="steps"> | |
| <li><b>${t.step1}</b> | |
| <p class="dim" style="font-size:13.5px">${t.step1d(b.n_test.toLocaleString())}<br>${t.licLine}</p> | |
| <a class="btn" href="${url}" download>${t.dl}</a> | |
| <span class="hint"> ${esc(b.board.toLowerCase())}_test.json</span></li> | |
| <li><b>${t.step2}</b> | |
| ${ME.user?` | |
| <label>${t.fMethod} <span class="hint">${t.fMethodH}</span></label> | |
| <input type="text" id="m" maxlength="60"> | |
| <label>${t.fTrain} <span class="hint">${t.fTrainH}</span></label> | |
| <input type="text" id="td"> | |
| <label>${t.fPred} <span class="hint">${t.fPredH}</span></label> | |
| <div class="drop" id="drop">${t.drop}<input type="file" id="file" accept=".json,.csv,.txt" hidden></div> | |
| <textarea id="p" rows="6" style="margin-top:11px"></textarea> | |
| <p style="margin-top:15px"><button class="btn" id="go">${t.send}</button> | |
| <span class="hint" id="msg"></span></p>` | |
| :`<div class="note">${t.needLogin} | |
| <a href="/login?next=${encodeURIComponent(location.hash)}">${t.login}</a></div>`}</li> | |
| <li><b>${t.step3}</b><p class="dim" style="font-size:13.5px">${t.step3d}</p></li> | |
| </ol> | |
| <div class="note">${t.ladderNote(f(b.noise_floor.sd_single),ME.daily_cap||5)}</div>`; | |
| wire(b); | |
| } | |
| function parsePred(x){ | |
| x=x.trim(); if(x.startsWith("{"))return JSON.parse(x); | |
| const o={}; | |
| x.split(/\r?\n/).forEach((ln,i)=>{const p=ln.split(/[,\t]/);if(p.length<2)return; | |
| const k=p[0].trim();if(!k||(i===0&&isNaN(parseFloat(p[1]))))return; | |
| const v=parseFloat(p[1]);if(!isNaN(v))o[k]=v;}); | |
| if(!Object.keys(o).length)throw new Error("no readable entries"); | |
| return o; | |
| } | |
| function wire(b){ | |
| const drop=$("#drop"),file=$("#file"),ta=$("#p"),go=$("#go"); | |
| const load=f0=>{const r=new FileReader();r.onload=()=>{ta.value=r.result; | |
| drop.textContent=`${f0.name} — ${(f0.size/1024).toFixed(0)} KB`;};r.readAsText(f0);}; | |
| if(drop){ | |
| drop.onclick=()=>file.click(); | |
| file.onchange=e=>e.target.files[0]&&load(e.target.files[0]); | |
| ["dragenter","dragover"].forEach(v=>drop.addEventListener(v,e=>{e.preventDefault();drop.classList.add("hot");})); | |
| ["dragleave","drop"].forEach(v=>drop.addEventListener(v,e=>{e.preventDefault();drop.classList.remove("hot");})); | |
| drop.addEventListener("drop",e=>e.dataTransfer.files[0]&&load(e.dataTransfer.files[0])); | |
| } | |
| if(!go)return; | |
| go.onclick=async()=>{ | |
| const msg=$("#msg");let preds; | |
| try{preds=parsePred(ta.value);}catch(e){msg.textContent=t.parseFail(e.message);return;} | |
| msg.textContent=t.sending(Object.keys(preds).length.toLocaleString());go.disabled=true; | |
| try{const r=await j("/api/submit",{method:"POST",headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify({board:b.board,method:($("#m").value||"").trim(), | |
| predictions:preds,training_data:($("#td").value||"").trim()})}); | |
| msg.textContent=t.accepted(r.submission_id); | |
| }catch(e){let x=String(e.message||e);try{x=JSON.parse(x).detail||x;}catch(_){}msg.textContent=x;} | |
| go.disabled=false; | |
| }; | |
| } | |
| boot().catch(e=>{app.innerHTML=W(`<div class="note" style="margin-top:50px"> | |
| ${(window.T&&window.T.en.loadFail)||"Load failed."} ${esc(e.message||e)}</div>`);}); | |
| </script> | |
| </body> | |
| </html> | |