"""Hugging Face Space entry point for the CKB prediction package. The public Space serves two stable programmatic endpoints (``/predict`` and ``/warmup``) as well as a browser-first Gradio experience. Model artifacts are loaded lazily and never retain a visitor's submitted data. """ from __future__ import annotations import html import shutil import tempfile import threading import time import uuid from pathlib import Path from typing import Any from urllib.parse import quote import gradio as gr import pandas as pd import spaces from predictions.predict_single import CKBSingleSamplePredictor MODEL_DIR = Path(__file__).resolve().parent / "predictions" SHAP_CACHE_DIR = Path(tempfile.gettempdir()) / "ckb_space_shap" SUPPLEMENTARY_IMAGE_DIR = Path(__file__).resolve().parent / "assets" / "supplementary" SHAP_CACHE_TTL_SECONDS = 60 * 60 SHAP_LIGHT_CANVAS_MARKER = "ckb-force-plot-light-canvas" SHAP_LIGHT_CANVAS_HEAD = f""" """ FEATURES = [ "sex", "age", "edu_level", "marital_status", "work", "retire", "hh_size", "smoking", "alcohol", "height_cm", "weight_kg", "waist_cm", "sbp_mmhg", "dbp_mmhg", "bp_drugs", "self_health", "chronic_pain", ] FEATURE_LABELS = { "sex": "性别编码", "age": "年龄(岁)", "edu_level": "教育程度编码", "marital_status": "婚姻状态编码", "work": "工作状态编码", "retire": "退休状态编码", "hh_size": "家庭人口数", "smoking": "吸烟编码", "alcohol": "饮酒编码", "height_cm": "身高(cm)", "weight_kg": "体重(kg)", "waist_cm": "腰围(cm)", "sbp_mmhg": "收缩压(mmHg)", "dbp_mmhg": "舒张压(mmHg)", "bp_drugs": "降压药使用编码", "self_health": "自评健康编码", "chronic_pain": "慢性疼痛编码", } FEATURE_HELP = { "sex": "请使用训练数据中的原始分类编码。", "age": "以完整岁数填写。", "edu_level": "请使用训练数据中的原始分类编码。", "marital_status": "请使用训练数据中的原始分类编码。", "work": "请使用训练数据中的原始分类编码。", "retire": "请使用训练数据中的原始分类编码。", "hh_size": "同住家庭成员人数。", "smoking": "请使用训练数据中的原始分类编码。", "alcohol": "请使用训练数据中的原始分类编码。", "height_cm": "连续数值,单位为厘米。", "weight_kg": "连续数值,单位为千克。", "waist_cm": "连续数值,单位为厘米。", "sbp_mmhg": "连续数值,单位为 mmHg。", "dbp_mmhg": "连续数值,单位为 mmHg。", "bp_drugs": "请使用训练数据中的原始分类编码。", "self_health": "请使用训练数据中的原始分类编码。", "chronic_pain": "请使用训练数据中的原始分类编码。", } FEATURE_GROUPS = [ ( "01", "基本资料", "人口与社会特征", ["sex", "age", "edu_level", "marital_status", "work", "retire", "hh_size"], ), ( "02", "生活与感受", "行为及健康自评", ["smoking", "alcohol", "self_health", "chronic_pain"], ), ( "03", "体征与用药", "身体测量与血压信息", ["height_cm", "weight_kg", "waist_cm", "sbp_mmhg", "dbp_mmhg", "bp_drugs"], ), ] INTEGER_FEATURES = { "sex", "age", "edu_level", "marital_status", "work", "retire", "hh_size", "smoking", "alcohol", "sbp_mmhg", "dbp_mmhg", "bp_drugs", "self_health", "chronic_pain", } # Display text is deliberately human-readable; the paired values remain the # frozen numeric encodings expected by the trained model. CATEGORICAL_CHOICES = { "sex": [("Male", 0), ("Female", 1)], "edu_level": [("Low", 1), ("Intermediate", 2), ("High", 3)], "marital_status": [ ("Other observed status", 0), ("Married or partnered", 1), ], "work": [("No", 0), ("Yes", 1)], "retire": [("No", 0), ("Yes", 1)], "smoking": [("No", 0), ("Yes", 1)], "alcohol": [("No", 0), ("Yes", 1)], "bp_drugs": [("No", 0), ("Yes", 1)], "self_health": [ ("Very good or excellent", 1), ("Good", 2), ("Fair or regular", 3), ("Poor or very poor", 4), ], "chronic_pain": [("No", 0), ("Yes", 1)], } EXAMPLE_VALUES = { "sex": 0, "age": 59, "edu_level": 1, "marital_status": 1, "work": 1, "retire": 0, "hh_size": 5, "smoking": 1, "alcohol": 1, "height_cm": 163.8, "weight_kg": 59.6, "waist_cm": 81.1, "sbp_mmhg": 125, "dbp_mmhg": 67, "bp_drugs": 0, "self_health": 2, "chronic_pain": 0, } TABLE_COLUMNS = ["代码", "预测结局", "概率", "冻结阈值", "风险判定"] DEFAULT_EN_FEATURE_LABELS = { "sex": "Sex", "age": "Age (years)", "edu_level": "Education level", "marital_status": "Married or partnered", "work": "Currently working", "retire": "Retired", "hh_size": "Household size", "smoking": "Smoking", "alcohol": "Alcohol use", "height_cm": "Height (cm)", "weight_kg": "Weight (kg)", "waist_cm": "Waist (cm)", "sbp_mmhg": "Systolic BP (mmHg)", "dbp_mmhg": "Diastolic BP (mmHg)", "bp_drugs": "Blood-pressure medication", "self_health": "Self-rated health", "chronic_pain": "Chronic pain", } DEFAULT_EN_FEATURE_HELP = {feature: "" for feature in FEATURES} TABLE_COLUMNS_EN = ["Code", "Outcome", "Probability", "Frozen threshold", "Risk decision"] # Presentation-only labels and cumulative-incidence reference values supplied # for the public demo. They do not participate in model inference or risk # classification, which always uses the frozen per-outcome Youden threshold. OUTCOME_PRESENTATION = { "A00_B99": { "en": "Infectious and parasitic diseases", "zh": "感染性疾病", "model_en": "MLP", "model_zh": "MLP", "low_5": 0.7, "low_10": 1.9, "high_5": 1.5, "high_10": 4.6, }, "C00_D48": { "en": "Neoplasms", "zh": "肿瘤", "model_en": "MLP", "model_zh": "MLP", "low_5": 1.9, "low_10": 4.8, "high_5": 4.3, "high_10": 9.5, }, "E00_E90": { "en": "Endocrine and metabolic diseases", "zh": "内分泌代谢疾病", "model_en": "MLP", "model_zh": "MLP", "low_5": 1.2, "low_10": 3.6, "high_5": 5.2, "high_10": 12.4, }, "F00_F99": { "en": "Mental and behavioural disorders", "zh": "精神和行为障碍", "model_en": "Random Forest", "model_zh": "随机森林", "low_5": 0.2, "low_10": 0.5, "high_5": 0.6, "high_10": 1.3, }, "G00_G99": { "en": "Diseases of the nervous system", "zh": "神经系统疾病", "model_en": "MLP", "model_zh": "MLP", "low_5": 0.6, "low_10": 2.7, "high_5": 2.5, "high_10": 7.7, }, "H00_H95": { "en": "Diseases of the eye and ear", "zh": "眼耳疾病", "model_en": "Random Forest", "model_zh": "随机森林", "low_5": 0.6, "low_10": 2.4, "high_5": 2.6, "high_10": 8.9, }, "I00_I99": { "en": "Diseases of the circulatory system", "zh": "循环系统疾病", "model_en": "MLP", "model_zh": "MLP", "low_5": 2.6, "low_10": 9.9, "high_5": 16.1, "high_10": 38.1, }, "J00_J99": { "en": "Diseases of the respiratory system", "zh": "呼吸系统疾病", "model_en": "MLP", "model_zh": "MLP", "low_5": 2.0, "low_10": 6.4, "high_5": 5.9, "high_10": 18.6, }, "K00_K93": { "en": "Diseases of the digestive system", "zh": "消化系统疾病", "model_en": "MLP", "model_zh": "MLP", "low_5": 3.1, "low_10": 9.2, "high_5": 5.6, "high_10": 16.0, }, "L00_L99": { "en": "Diseases of the skin", "zh": "皮肤病", "model_en": "Random Forest", "model_zh": "随机森林", "low_5": 0.1, "low_10": 0.5, "high_5": 0.3, "high_10": 1.2, }, "M00_M99": { "en": "Musculoskeletal diseases", "zh": "肌肉骨骼疾病", "model_en": "MLP", "model_zh": "MLP", "low_5": 1.2, "low_10": 5.5, "high_5": 2.5, "high_10": 11.0, }, "N00_N99": { "en": "Diseases of the genitourinary system", "zh": "泌尿生殖系统疾病", "model_en": "MLP", "model_zh": "MLP", "low_5": 1.6, "low_10": 4.6, "high_5": 3.2, "high_10": 9.2, }, "death": { "en": "All-cause death", "zh": "全因死亡", "model_en": "MLP", "model_zh": "MLP", "low_5": 0.9, "low_10": 2.4, "high_5": 8.0, "high_10": 20.2, }, } # Keep Chinese presentation copy in unicode escapes so it remains intact when # the Space is edited from Windows shells with different console code pages. _OUTCOME_ZH = { "A00_B99": "\u611f\u67d3\u6027\u75be\u75c5", "C00_D48": "\u80bf\u7624", "E00_E90": "\u5185\u5206\u6ccc\u4ee3\u8c22\u75be\u75c5", "F00_F99": "\u7cbe\u795e\u548c\u884c\u4e3a\u969c\u788d", "G00_G99": "\u795e\u7ecf\u7cfb\u7edf\u75be\u75c5", "H00_H95": "\u773c\u8033\u75be\u75c5", "I00_I99": "\u5faa\u73af\u7cfb\u7edf\u75be\u75c5", "J00_J99": "\u547c\u5438\u7cfb\u7edf\u75be\u75c5", "K00_K93": "\u6d88\u5316\u7cfb\u7edf\u75be\u75c5", "L00_L99": "\u76ae\u80a4\u75c5", "M00_M99": "\u808c\u8089\u9aa8\u9abc\u75be\u75c5", "N00_N99": "\u6ccc\u5c3f\u751f\u6b96\u7cfb\u7edf\u75be\u75c5", "death": "\u5168\u56e0\u6b7b\u4ea1", } for _outcome_code, _outcome_name_zh in _OUTCOME_ZH.items(): OUTCOME_PRESENTATION[_outcome_code]["zh"] = _outcome_name_zh OUTCOME_PRESENTATION["F00_F99"]["model_zh"] = "\u968f\u673a\u68ee\u6797" OUTCOME_PRESENTATION["H00_H95"]["model_zh"] = "\u968f\u673a\u68ee\u6797" OUTCOME_PRESENTATION["L00_L99"]["model_zh"] = "\u968f\u673a\u68ee\u6797" CUSTOM_CSS = """ :root { --ckb-blue-950: #082f49; --ckb-blue-900: #0c4a6e; --ckb-blue-700: #0369a1; --ckb-blue-600: #0284c7; --ckb-blue-100: #e0f2fe; --ckb-blue-50: #f0f9ff; --ckb-cyan-100: #cffafe; --ckb-green-700: #15803d; --ckb-green-600: #16a34a; --ckb-green-100: #dcfce7; --ckb-amber-700: #b45309; --ckb-amber-100: #fef3c7; --ckb-slate-950: #0f172a; --ckb-slate-700: #334155; --ckb-slate-600: #475569; --ckb-slate-400: #94a3b8; --ckb-slate-200: #e2e8f0; --ckb-slate-100: #f1f5f9; --ckb-white: #ffffff; --ckb-shadow-sm: 0 1px 2px rgb(15 23 42 / 0.05); --ckb-shadow-md: 0 16px 40px rgb(12 74 110 / 0.10); --ckb-radius-lg: 20px; --ckb-radius-md: 14px; --ckb-radius-sm: 10px; --ckb-motion-fast: 180ms; --ckb-motion-base: 280ms; --ckb-layer-field: 10; --ckb-layer-dropdown: 40; } .gradio-container { background: radial-gradient(circle at 10% 0%, rgb(207 250 254 / 0.75), transparent 24rem), radial-gradient(circle at 90% 9%, rgb(224 242 254 / 0.95), transparent 25rem), var(--ckb-blue-50); color: var(--ckb-slate-950); color-scheme: light !important; font-family: Aptos, "Segoe UI", "Microsoft YaHei UI", "Noto Sans SC", sans-serif; } #ckb-shell { width: min(1180px, 100%); margin: 0 auto; padding: 24px 18px 52px; color: var(--ckb-slate-950); } #ckb-shell * { box-sizing: border-box; } .ckb-skip { position: absolute; left: -10000px; top: auto; overflow: hidden; width: 1px; height: 1px; } .ckb-skip:focus { left: 18px; top: 18px; z-index: 1000; width: auto; height: auto; padding: 10px 14px; border-radius: var(--ckb-radius-sm); background: var(--ckb-blue-950); color: var(--ckb-white); font-weight: 700; } .ckb-hero { position: relative; isolation: isolate; overflow: hidden; min-height: 286px; padding: clamp(30px, 6vw, 68px); border: 1px solid rgb(255 255 255 / 0.28); border-radius: 28px; background: linear-gradient(123deg, var(--ckb-blue-950) 0%, var(--ckb-blue-900) 56%, var(--ckb-blue-700) 100%); box-shadow: var(--ckb-shadow-md); color: var(--ckb-white) !important; } .ckb-hero::before, .ckb-hero::after { position: absolute; z-index: -1; display: block; border-radius: 999px; content: ""; filter: blur(2px); } .ckb-hero::before { top: -138px; right: -58px; width: 322px; height: 322px; border: 1px solid rgb(207 250 254 / 0.30); background: radial-gradient(circle, rgb(34 211 238 / 0.30), transparent 67%); } .ckb-hero::after { right: 18%; bottom: -185px; width: 360px; height: 360px; background: radial-gradient(circle, rgb(22 163 74 / 0.24), transparent 67%); } .ckb-hero__content { position: relative; max-width: 720px; } .ckb-eyebrow, .ckb-chip, .ckb-section-kicker, .ckb-result-kicker { display: inline-flex; align-items: center; gap: 8px; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.08em; line-height: 1; text-transform: uppercase; } .ckb-hero .ckb-eyebrow { color: var(--ckb-cyan-100) !important; -webkit-text-fill-color: var(--ckb-cyan-100); } .ckb-mark { display: inline-grid; width: 28px; height: 28px; place-items: center; border: 1px solid rgb(207 250 254 / 0.36); border-radius: 9px; background: rgb(255 255 255 / 0.10); } .ckb-mark svg { width: 17px; height: 17px; stroke: currentColor; } .ckb-hero h1 { max-width: 13ch; margin: 16px 0 14px; color: var(--ckb-white) !important; -webkit-text-fill-color: var(--ckb-white); font-family: Georgia, "Noto Serif SC", serif; font-size: clamp(2.15rem, 5.1vw, 4.25rem); font-weight: 650; letter-spacing: -0.045em; line-height: 1.04; } .ckb-hero p { max-width: 62ch; margin: 0; color: #f0f9ff !important; -webkit-text-fill-color: #f0f9ff; font-size: clamp(1rem, 1.4vw, 1.12rem); line-height: 1.7; } .ckb-hero__meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; } .ckb-chip { min-height: 34px; padding: 8px 11px; border: 1px solid rgb(255 255 255 / 0.16); border-radius: 999px; background: rgb(255 255 255 / 0.10); color: var(--ckb-white) !important; -webkit-text-fill-color: var(--ckb-white); letter-spacing: 0.02em; text-transform: none; } .ckb-chip svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; } .ckb-trust-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 18px 0 24px; } .ckb-trust-card { min-width: 0; padding: 16px 18px; border: 1px solid var(--ckb-blue-100); border-radius: var(--ckb-radius-md); background: rgb(255 255 255 / 0.78); box-shadow: var(--ckb-shadow-sm); } .ckb-trust-card strong { display: block; color: var(--ckb-blue-900); font-family: Georgia, "Noto Serif SC", serif; font-size: 1.42rem; font-variant-numeric: tabular-nums; line-height: 1.1; } .ckb-trust-card span { display: block; margin-top: 5px; color: var(--ckb-slate-600); font-size: 0.86rem; line-height: 1.35; } .ckb-language-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin: -4px 0 14px; } .ckb-language-switch, .ckb-theme-switch { display: inline-flex; gap: 4px; padding: 4px; border: 1px solid var(--ckb-blue-100); border-radius: 999px; background: rgb(255 255 255 / 0.88); box-shadow: var(--ckb-shadow-sm); } .ckb-language-switch button, .ckb-theme-switch button { min-width: 74px; min-height: 36px; padding: 7px 12px; border: 0; border-radius: 999px; background: transparent; color: var(--ckb-slate-600); cursor: pointer; font: inherit; font-size: 0.82rem; font-weight: 750; transition: background var(--ckb-motion-fast) ease, color var(--ckb-motion-fast) ease, transform var(--ckb-motion-fast) ease; } .ckb-theme-switch button { min-width: 58px; } .ckb-language-switch button:hover, .ckb-theme-switch button:hover { background: var(--ckb-blue-50); color: var(--ckb-blue-900); } .ckb-language-switch button:active, .ckb-theme-switch button:active { transform: scale(0.98); } .ckb-language-switch button[aria-pressed="true"], .ckb-theme-switch button[aria-pressed="true"] { background: var(--ckb-blue-900); color: var(--ckb-white); } .ckb-language-switch button:focus-visible, .ckb-theme-switch button:focus-visible { outline: 3px solid rgb(2 132 199 / 0.32); outline-offset: 2px; } .ckb-status { display: flex; align-items: flex-start; gap: 10px; min-height: 52px; margin: 0 0 20px; padding: 14px 16px; border: 1px solid var(--ckb-blue-100); border-radius: var(--ckb-radius-md); background: rgb(255 255 255 / 0.82); color: var(--ckb-slate-700); box-shadow: var(--ckb-shadow-sm); line-height: 1.5; } .ckb-status__dot { flex: 0 0 auto; width: 10px; height: 10px; margin-top: 7px; border-radius: 50%; background: var(--ckb-blue-600); box-shadow: 0 0 0 4px var(--ckb-blue-100); } .ckb-status--ready { border-color: var(--ckb-green-100); background: #f6fff8; } .ckb-status--ready .ckb-status__dot { background: var(--ckb-green-600); box-shadow: 0 0 0 4px var(--ckb-green-100); } .ckb-status--warning { border-color: var(--ckb-amber-100); background: #fffbeb; } .ckb-status--warning .ckb-status__dot { background: var(--ckb-amber-700); box-shadow: 0 0 0 4px var(--ckb-amber-100); } .ckb-panel { padding: clamp(22px, 3vw, 34px); border: 1px solid var(--ckb-blue-100); border-radius: var(--ckb-radius-lg); background: rgb(255 255 255 / 0.88); box-shadow: var(--ckb-shadow-md); } .ckb-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; } .ckb-section-kicker { color: var(--ckb-blue-700); } .ckb-section-heading h2 { margin: 7px 0 6px; color: var(--ckb-slate-950); font-family: Georgia, "Noto Serif SC", serif; font-size: clamp(1.55rem, 3vw, 2.1rem); letter-spacing: -0.025em; line-height: 1.16; } .ckb-section-heading p { max-width: 58ch; margin: 0; color: var(--ckb-slate-600); font-size: 0.95rem; line-height: 1.6; } .ckb-coding-note { flex: 0 0 auto; max-width: 265px; padding: 10px 12px; border-radius: var(--ckb-radius-sm); background: var(--ckb-blue-50); color: var(--ckb-blue-900); font-size: 0.82rem; font-weight: 650; line-height: 1.45; } .ckb-input-grid { gap: 14px; } .ckb-form-card { height: 100%; padding: 17px; border: 1px solid var(--ckb-slate-200); border-radius: var(--ckb-radius-md); background: var(--ckb-white); box-shadow: var(--ckb-shadow-sm); } .ckb-form-card .form-card__top { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; } .ckb-form-card .form-card__index { display: inline-grid; flex: 0 0 auto; width: 30px; height: 30px; place-items: center; border-radius: 9px; background: var(--ckb-blue-100); color: var(--ckb-blue-900); font-size: 0.78rem; font-weight: 800; font-variant-numeric: tabular-nums; } .ckb-form-card h3 { margin: 1px 0 3px; color: var(--ckb-slate-950); font-size: 1rem; } .ckb-form-card p { margin: 0; color: var(--ckb-slate-600); font-size: 0.8rem; line-height: 1.4; } #ckb-shell .ckb-form-card .block { min-width: 0; margin-bottom: 10px; } #ckb-shell .ckb-form-card label { color: var(--ckb-slate-700); font-size: 0.86rem; font-weight: 700; } #ckb-shell .ckb-form-card input { min-height: 48px; border: 1px solid var(--ckb-slate-200); border-radius: var(--ckb-radius-sm); background: var(--ckb-white); color: var(--ckb-slate-950); font-size: 1rem; font-variant-numeric: tabular-nums; transition: border-color var(--ckb-motion-fast) ease, box-shadow var(--ckb-motion-fast) ease; } #ckb-shell .ckb-form-card input:hover { border-color: var(--ckb-blue-600); } #ckb-shell .ckb-form-card input:focus { border-color: var(--ckb-blue-600); box-shadow: 0 0 0 3px rgb(2 132 199 / 0.20); outline: none; } .ckb-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 24px; } #predict-button, #reset-button { min-height: 48px; border-radius: var(--ckb-radius-sm); font-weight: 750; transition: transform var(--ckb-motion-fast) ease, box-shadow var(--ckb-motion-fast) ease, background var(--ckb-motion-fast) ease; } #predict-button { min-width: 172px; border: 0; background: var(--ckb-green-600); box-shadow: 0 10px 22px rgb(22 163 74 / 0.22); color: var(--ckb-white); } #predict-button:hover { background: var(--ckb-green-700); box-shadow: 0 13px 26px rgb(22 163 74 / 0.29); transform: translateY(-1px); } #predict-button:active { transform: translateY(0) scale(0.985); } #predict-button:focus-visible, #reset-button:focus-visible { outline: 3px solid rgb(2 132 199 / 0.35); outline-offset: 3px; } #reset-button { border: 1px solid var(--ckb-slate-200); background: var(--ckb-white); color: var(--ckb-slate-700); } #reset-button:hover { border-color: var(--ckb-blue-600); color: var(--ckb-blue-900); transform: translateY(-1px); } .ckb-privacy-copy { display: flex; flex: 1 1 250px; align-items: center; gap: 8px; margin: 0; color: var(--ckb-slate-600); font-size: 0.82rem; line-height: 1.45; } .ckb-privacy-copy svg { flex: 0 0 auto; width: 17px; height: 17px; stroke: var(--ckb-blue-700); stroke-width: 2; } .ckb-results { margin-top: 26px; padding: clamp(22px, 3vw, 34px); border: 1px solid var(--ckb-blue-100); border-radius: var(--ckb-radius-lg); background: rgb(255 255 255 / 0.90); box-shadow: var(--ckb-shadow-md); } .ckb-results-header { margin-bottom: 17px; } .ckb-results-header h2 { margin: 7px 0 0; color: var(--ckb-slate-950); font-family: Georgia, "Noto Serif SC", serif; font-size: clamp(1.5rem, 3vw, 2rem); } .ckb-result-card, .ckb-result-placeholder { min-height: 122px; padding: 20px; border: 1px solid var(--ckb-blue-100); border-radius: var(--ckb-radius-md); background: linear-gradient(112deg, var(--ckb-blue-50), var(--ckb-white)); } .ckb-result-placeholder { display: grid; place-items: center; color: var(--ckb-slate-600); text-align: center; } .ckb-result-card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } .ckb-result-kicker { color: var(--ckb-blue-700); } .ckb-result-card h3 { margin: 6px 0 0; color: var(--ckb-blue-950); font-family: Georgia, "Noto Serif SC", serif; font-size: 1.7rem; } .ckb-result-badge { flex: 0 0 auto; padding: 8px 10px; border-radius: 999px; background: var(--ckb-green-100); color: var(--ckb-green-700); font-size: 0.8rem; font-weight: 800; } .ckb-result-badge--review { background: var(--ckb-amber-100); color: var(--ckb-amber-700); } .ckb-result-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 18px 0 0; } .ckb-result-metrics div { padding: 11px; border-radius: var(--ckb-radius-sm); background: rgb(255 255 255 / 0.76); } .ckb-result-metrics dt { color: var(--ckb-slate-600); font-size: 0.76rem; } .ckb-result-metrics dd { margin: 4px 0 0; color: var(--ckb-slate-950); font-size: 1rem; font-weight: 800; font-variant-numeric: tabular-nums; } .ckb-risk-visual { margin-top: 18px; padding: 20px; border: 1px solid var(--ckb-slate-200); border-radius: var(--ckb-radius-md); background: var(--ckb-white); } .ckb-risk-visual__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 16px; } .ckb-risk-visual h3 { margin: 0; color: var(--ckb-slate-950); font-size: 1.05rem; } .ckb-risk-visual p { margin: 5px 0 0; color: var(--ckb-slate-600); font-size: 0.82rem; line-height: 1.45; } .ckb-legend { display: flex; flex-wrap: wrap; gap: 10px; color: var(--ckb-slate-600); font-size: 0.76rem; } .ckb-legend span { display: inline-flex; align-items: center; gap: 5px; } .ckb-legend i { width: 9px; height: 9px; border-radius: 50%; background: var(--ckb-green-600); } .ckb-legend .ckb-legend--high { background: var(--ckb-amber-700); } .ckb-risk-row { display: grid; grid-template-columns: minmax(88px, 0.8fr) minmax(130px, 2fr) 54px; gap: 10px; align-items: center; margin: 10px 0; } .ckb-risk-label { overflow: hidden; color: var(--ckb-slate-700); font-size: 0.78rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .ckb-risk-track { position: relative; height: 11px; overflow: hidden; border-radius: 999px; background: var(--ckb-slate-100); } .ckb-risk-fill { display: block; width: var(--risk-width); height: 100%; border-radius: inherit; background: var(--ckb-green-600); } .ckb-risk-row--high .ckb-risk-fill { background: var(--ckb-amber-700); } .ckb-risk-threshold { position: absolute; top: 0; bottom: 0; left: var(--threshold); width: 2px; background: var(--ckb-slate-950); opacity: 0.58; } .ckb-risk-value { color: var(--ckb-slate-950); font-size: 0.8rem; font-weight: 800; font-variant-numeric: tabular-nums; text-align: right; } #outcome-table { margin-top: 18px; overflow-x: auto; } #outcome-table table { min-width: 680px; border-collapse: separate; border-spacing: 0; } #outcome-table thead { background: var(--ckb-blue-50); } #outcome-table th { color: var(--ckb-blue-900); font-size: 0.78rem; font-weight: 800; } #outcome-table td { color: var(--ckb-slate-700); font-size: 0.84rem; } #outcome-table th, #outcome-table td { border-color: var(--ckb-slate-200); } #shap-result { overflow: hidden !important; margin-top: 18px; border: 1px solid var(--ckb-blue-700); border-radius: var(--ckb-radius-md); background: var(--ckb-white); box-shadow: var(--ckb-shadow-sm); } #shap-result > button { position: relative; display: flex !important; align-items: center; width: 100%; min-height: 64px; padding: 14px 68px 14px 22px !important; border: 0 !important; border-radius: var(--ckb-radius-md); background: linear-gradient(112deg, var(--ckb-blue-950), var(--ckb-blue-700)); color: var(--ckb-white) !important; cursor: pointer; font-size: 0.98rem; font-weight: 800; letter-spacing: 0.01em; line-height: 1.35; text-align: left; touch-action: manipulation; transition: background 180ms ease, box-shadow 180ms ease, transform 180ms ease; } #shap-result > button::after { position: absolute; right: 26px; width: 11px; height: 11px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; content: ""; transform: rotate(45deg) translateY(-3px); transform-origin: center; transition: transform 180ms ease; } #shap-result > button:hover { background: linear-gradient(112deg, var(--ckb-blue-900), var(--ckb-blue-600)); box-shadow: inset 0 0 0 999px rgb(255 255 255 / 0.05); } #shap-result > button:active { transform: scale(0.995); } #shap-result > button:focus-visible { outline: 3px solid rgb(2 132 199 / 0.42); outline-offset: -5px; } #shap-result > button.open { border-bottom: 1px solid var(--ckb-blue-100) !important; border-radius: var(--ckb-radius-md) var(--ckb-radius-md) 0 0; background: var(--ckb-blue-50); color: var(--ckb-blue-950) !important; } #shap-result > button.open::after { transform: rotate(225deg) translateY(-3px); } .ckb-shap-placeholder { display: grid; min-height: 108px; place-items: center; color: var(--ckb-slate-600); text-align: center; } .ckb-shap-gallery { padding: 4px 2px 2px; } .ckb-shap-gallery__header { margin: 0 0 16px; } .ckb-shap-gallery__header .ckb-section-kicker { color: var(--ckb-blue-700); } .ckb-shap-gallery__header h3 { margin: 6px 0 0; color: var(--ckb-slate-950); font-size: 1.2rem; } .ckb-shap-gallery__header p { margin: 6px 0 0; color: var(--ckb-slate-600); font-size: 0.84rem; line-height: 1.5; } .ckb-shap-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } .ckb-shap-card { min-width: 0; overflow: hidden; border: 1px solid var(--ckb-slate-200); border-radius: var(--ckb-radius-sm); background: var(--ckb-white); box-shadow: var(--ckb-shadow-sm); } .ckb-shap-card--cluster { grid-column: 1 / -1; } .ckb-shap-card__header { padding: 13px 14px 10px; border-bottom: 1px solid var(--ckb-slate-100); background: linear-gradient(112deg, var(--ckb-blue-50), var(--ckb-white)); } .ckb-shap-card__kind { margin: 0; color: var(--ckb-blue-700); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; } .ckb-shap-card h4 { margin: 4px 0 0; color: var(--ckb-slate-950); font-size: 0.94rem; line-height: 1.35; } .ckb-shap-frame { display: block; width: 100%; height: 330px; border: 0; background: var(--ckb-white); } #raw-result { margin-top: 16px; border: 1px solid var(--ckb-slate-200); border-radius: var(--ckb-radius-sm); background: var(--ckb-white); } #raw-result button { min-height: 44px; color: var(--ckb-slate-700); font-weight: 750; } .ckb-footer-note { margin: 20px 0 0; color: var(--ckb-slate-600); font-size: 0.82rem; line-height: 1.6; } .ckb-footer-note code { padding: 2px 5px; border-radius: 5px; background: var(--ckb-slate-100); color: var(--ckb-blue-900); } .ckb-hero, .ckb-trust-card, .ckb-panel, .ckb-results { will-change: auto; } /* The hero now carries one concise model statement; the old labels and count tiles remain in the DOM only so legacy locale initialisation stays stable. */ .ckb-eyebrow, .ckb-hero__meta, .ckb-trust-row { display: none !important; } .ckb-hero { min-height: 230px; display: grid; align-items: center; } .ckb-hero p { max-width: 66ch; } .ckb-section-heading > div > p, .ckb-coding-note { display: none !important; } /* Gradio renders dropdown lists in a popover. Keeping animated ancestors transform-free and elevating the portal fixes the offset seen in the form. */ #ckb-shell .ckb-form-card .block, #ckb-shell .ckb-form-card .wrap { position: relative; overflow: visible !important; } #ckb-shell .ckb-form-card .block { z-index: 0; } #ckb-shell .ckb-form-card .block:focus-within { z-index: var(--ckb-layer-field); } body .secondary-wrap, body [role="listbox"] { z-index: var(--ckb-layer-dropdown) !important; } #ckb-shell .ckb-form-card button, #ckb-shell .ckb-form-card [role="combobox"] { min-height: 48px; border-radius: var(--ckb-radius-sm); } #ckb-shell, #outcome-table, #outcome-table .html-container, #shap-result, #shap-result .wrap, #shap-result .form, #shap-result .html-container, #shap-gallery, #shap-gallery .wrap, #shap-gallery .html-container, .ckb-image-gallery, .ckb-image-card, .ckb-image-card__force { color-scheme: light !important; } .ckb-image-frame, .ckb-reference-figure, .ckb-cluster-profile-figure { color-scheme: only light !important; } #shap-result .wrap, #shap-result .form, #shap-result .html-container, #shap-gallery, #shap-gallery .wrap, #shap-gallery .html-container { background: var(--ckb-white) !important; color: var(--ckb-slate-950) !important; } #ckb-shell .ckb-panel .ckb-section-heading, #ckb-shell .ckb-panel .ckb-section-heading h2, #ckb-shell .ckb-panel .ckb-section-heading p, #ckb-shell .ckb-status, #ckb-shell .ckb-status strong, #ckb-shell .ckb-results-header, #ckb-shell .ckb-results-header h2, #ckb-shell .ckb-result-placeholder, #ckb-shell .ckb-footer-note { color: var(--ckb-slate-950) !important; } #ckb-shell .ckb-panel .ckb-section-heading .ckb-section-kicker, #ckb-shell .ckb-results-header .ckb-section-kicker { color: var(--ckb-blue-700) !important; } #ckb-shell .ckb-status { color: var(--ckb-slate-700) !important; } #ckb-shell .ckb-status span, #ckb-shell .ckb-status strong { color: inherit !important; } #ckb-shell .ckb-result-placeholder, #ckb-shell .ckb-footer-note { color: var(--ckb-slate-600) !important; } #ckb-shell .ckb-footer-note code { color: var(--ckb-blue-900) !important; } #ckb-shell .ckb-form-card, #ckb-shell .ckb-form-card > .styler, #ckb-shell .ckb-form-card .form, #ckb-shell .ckb-form-card .container { background: var(--ckb-white) !important; color: var(--ckb-slate-950) !important; } #ckb-shell .ckb-form-card .block { background: transparent !important; border-color: transparent !important; } #ckb-shell .ckb-form-card .wrap, #ckb-shell .ckb-form-card .wrap-inner, #ckb-shell .ckb-form-card .secondary-wrap { background: var(--ckb-white) !important; border-color: var(--ckb-slate-200) !important; color: var(--ckb-slate-950) !important; } #ckb-shell .ckb-form-card .form-card__index { color: var(--ckb-blue-900) !important; } #ckb-shell .ckb-form-card h3 { color: var(--ckb-slate-950) !important; } #ckb-shell .ckb-form-card p { color: var(--ckb-slate-600) !important; } #ckb-shell .ckb-form-card [data-testid="block-info"], #ckb-shell .ckb-form-card label { color: var(--ckb-slate-700) !important; } body .options[role="listbox"] { border: 1px solid var(--ckb-slate-200) !important; border-radius: var(--ckb-radius-sm) !important; background: var(--ckb-white) !important; box-shadow: var(--ckb-shadow-md) !important; } body .options[role="listbox"] [role="option"] { background: var(--ckb-white) !important; color: var(--ckb-slate-950) !important; } body .options[role="listbox"] [role="option"]:hover, body .options[role="listbox"] [role="option"][aria-selected="true"] { background: var(--ckb-blue-50) !important; color: var(--ckb-blue-950) !important; } .ckb-outcome-table { margin-top: 18px; overflow: hidden; border: 1px solid var(--ckb-slate-200); border-radius: var(--ckb-radius-md); background: var(--ckb-white); box-shadow: var(--ckb-shadow-sm); } .ckb-outcome-table__header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; padding: 18px 20px 15px; border-bottom: 1px solid var(--ckb-slate-100); background: linear-gradient(112deg, var(--ckb-blue-50), var(--ckb-white)); } .ckb-outcome-table__header h3 { margin: 5px 0 0; color: var(--ckb-slate-950); font-size: 1.08rem; } .ckb-outcome-table__header > p { max-width: 50ch; margin: 0; color: var(--ckb-slate-600); font-size: 0.8rem; line-height: 1.45; text-align: right; } .ckb-outcome-table__scroll { overflow-x: auto; } .ckb-outcome-table table { width: 100%; min-width: 720px; border-collapse: collapse; } .ckb-outcome-table th, .ckb-outcome-table td { padding: 12px 14px; border-bottom: 1px solid var(--ckb-slate-100); text-align: left; } .ckb-outcome-table th { color: var(--ckb-blue-900); background: rgb(240 249 255 / 0.72); font-size: 0.76rem; font-weight: 850; letter-spacing: 0.025em; } .ckb-outcome-table td { color: var(--ckb-slate-700); font-size: 0.84rem; vertical-align: middle; } .ckb-outcome-table tbody tr:last-child td { border-bottom: 0; } .ckb-outcome-table tbody tr:hover { background: var(--ckb-blue-50); } .ckb-outcome-table__code { color: var(--ckb-slate-950) !important; font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; } .ckb-outcome-table__decision { display: inline-flex; padding: 5px 8px; border-radius: 999px; background: var(--ckb-green-100); color: var(--ckb-green-700); font-size: 0.75rem; font-weight: 800; white-space: nowrap; } .ckb-outcome-table__decision--high { background: var(--ckb-amber-100); color: var(--ckb-amber-700); } .ckb-image-placeholder { display: grid; min-height: 108px; place-items: center; padding: 20px; color: var(--ckb-slate-600); text-align: center; } .ckb-image-gallery { padding: 2px; } .ckb-image-gallery__header { margin: 0 0 20px; } .ckb-image-gallery__header h3 { margin: 6px 0 0; color: var(--ckb-slate-950); font-size: 1.2rem; } .ckb-image-gallery__header p { max-width: 74ch; margin: 6px 0 0; color: var(--ckb-slate-600); font-size: 0.84rem; line-height: 1.5; } .ckb-image-section + .ckb-image-section { margin-top: 22px; } .ckb-image-section__header { margin: 0 0 10px; } .ckb-image-section__header h4 { margin: 0; color: var(--ckb-blue-900); font-size: 0.9rem; font-weight: 850; } .ckb-image-grid { display: grid; gap: 14px; } .ckb-image-grid--clusters { grid-template-columns: 1fr; } .ckb-image-grid--outcomes { grid-template-columns: repeat(2, minmax(0, 1fr)); } .ckb-image-card { min-width: 0; overflow: hidden; border: 1px solid var(--ckb-slate-200); border-radius: var(--ckb-radius-sm); background: var(--ckb-white); box-shadow: var(--ckb-shadow-sm); } .ckb-image-card--selected { border-color: var(--ckb-blue-600); box-shadow: 0 0 0 2px rgb(2 132 199 / 0.14), var(--ckb-shadow-sm); } .ckb-image-card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 13px 10px; border-bottom: 1px solid var(--ckb-slate-100); background: linear-gradient(112deg, var(--ckb-blue-50), var(--ckb-white)); } .ckb-image-card__kind { margin: 0; color: var(--ckb-blue-700); font-size: 0.7rem; font-weight: 850; letter-spacing: 0.06em; text-transform: uppercase; } .ckb-image-card h4 { margin: 4px 0 0; color: var(--ckb-slate-950); font-size: 0.9rem; line-height: 1.35; } .ckb-image-card__badge { flex: 0 0 auto; max-width: 50%; padding: 5px 8px; border-radius: 999px; background: var(--ckb-blue-100); color: var(--ckb-blue-900); font-size: 0.7rem; font-weight: 800; line-height: 1.2; text-align: center; } .ckb-image-card__badge--high { background: var(--ckb-amber-100); color: var(--ckb-amber-700); } .ckb-image-frame { display: block; width: 100%; height: 218px; border: 0; background: var(--ckb-white); } .ckb-image-card__context { display: grid; grid-template-columns: minmax(205px, 0.9fr) minmax(0, 1.1fr); gap: 12px; align-items: stretch; padding: 12px; background: #fbfdff; } .ckb-image-card__curve { min-width: 0; padding: 3px; border: 1px solid var(--ckb-slate-100); border-radius: 8px; background: var(--ckb-white); } .ckb-reference-figure { display: block; width: 100%; height: 100%; min-height: 184px; object-fit: contain; border-radius: 6px; background: var(--ckb-white); } .ckb-cluster-profile-figure { display: block; width: 100%; max-height: 230px; object-fit: contain; padding: 10px 12px; background: var(--ckb-white); } .ckb-image-card__force { border-top: 1px solid var(--ckb-slate-100); background: var(--ckb-white); } .ckb-risk-curve { display: block; width: 100%; height: auto; } .ckb-risk-curve__grid path { fill: none; stroke: #dbeafe; stroke-width: 1; } .ckb-risk-curve__low, .ckb-risk-curve__high { fill: none; stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; } .ckb-risk-curve__low { stroke: #0d9488; } .ckb-risk-curve__high { stroke: #d97706; } .ckb-risk-curve__legend text, .ckb-risk-curve__axis { fill: var(--ckb-slate-600); font-size: 10px; font-family: ui-sans-serif, system-ui, sans-serif; } .ckb-risk-curve__point--low circle { fill: #0d9488; }.ckb-risk-curve__point--low text { fill: #0f766e; }.ckb-risk-curve__point--high circle { fill: #d97706; }.ckb-risk-curve__point--high text { fill: #b45309; } .ckb-risk-curve__point text { font-size: 10px; font-weight: 800; font-family: ui-sans-serif, system-ui, sans-serif; } .ckb-image-card__explanation { display: flex; flex-direction: column; justify-content: center; min-width: 0; } .ckb-image-card__explanation p { margin: 0; color: var(--ckb-slate-600); font-size: 0.78rem; line-height: 1.55; } .ckb-image-card__explanation .ckb-image-card__risk { margin-bottom: 7px; color: var(--ckb-blue-900); font-size: 0.76rem; font-weight: 850; } /* Theme selection is explicit: the page never follows the browser/system theme. */ html[data-ckb-theme="light"] .gradio-container { color-scheme: light !important; } #ckb-shell[data-theme="dark"] { --ckb-blue-950: #082f49; --ckb-blue-900: #0c4a6e; --ckb-blue-700: #7dd3fc; --ckb-blue-600: #38bdf8; --ckb-blue-100: #153b58; --ckb-blue-50: #0f1d31; --ckb-cyan-100: #164e63; --ckb-green-700: #bbf7d0; --ckb-green-600: #4ade80; --ckb-green-100: #14532d; --ckb-amber-700: #fcd34d; --ckb-amber-100: #78350f; --ckb-slate-950: #f8fafc; --ckb-slate-700: #e2e8f0; --ckb-slate-600: #cbd5e1; --ckb-slate-400: #94a3b8; --ckb-slate-200: #334155; --ckb-slate-100: #1e293b; --ckb-white: #111827; --ckb-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.32); --ckb-shadow-md: 0 16px 40px rgb(0 0 0 / 0.34); color-scheme: dark !important; } html[data-ckb-theme="dark"] .gradio-container { background: radial-gradient(circle at 10% 0%, rgb(8 47 73 / 0.76), transparent 24rem), radial-gradient(circle at 90% 9%, rgb(15 35 57 / 0.9), transparent 25rem), #020617; color: #f8fafc; color-scheme: dark !important; } html[data-ckb-theme="dark"] #ckb-shell, html[data-ckb-theme="dark"] #outcome-table, html[data-ckb-theme="dark"] #shap-result, html[data-ckb-theme="dark"] #shap-gallery, html[data-ckb-theme="dark"] .ckb-image-gallery, html[data-ckb-theme="dark"] .ckb-image-card, html[data-ckb-theme="dark"] .ckb-image-card__force { color-scheme: dark !important; } html[data-ckb-theme="dark"] #ckb-shell .ckb-panel, html[data-ckb-theme="dark"] #ckb-shell .ckb-results { background: rgb(15 23 42 / 0.94); border-color: var(--ckb-slate-200); } html[data-ckb-theme="dark"] #ckb-shell .ckb-status--ready { background: #102a1c; border-color: #166534; } html[data-ckb-theme="dark"] #ckb-shell .ckb-status--warning { background: #422006; border-color: #92400e; } html[data-ckb-theme="dark"] #ckb-shell .ckb-result-metrics div { background: #172033; } html[data-ckb-theme="dark"] #ckb-shell .ckb-image-card__context { background: #111c2d; } html[data-ckb-theme="dark"] #ckb-shell .ckb-language-switch, html[data-ckb-theme="dark"] #ckb-shell .ckb-theme-switch { background: #111827; border-color: var(--ckb-slate-200); } html[data-ckb-theme="dark"] #ckb-shell .ckb-language-switch button[aria-pressed="true"], html[data-ckb-theme="dark"] #ckb-shell .ckb-theme-switch button[aria-pressed="true"], html[data-ckb-theme="dark"] #ckb-shell #predict-button, html[data-ckb-theme="dark"] #ckb-shell #shap-result > button { color: #ffffff !important; } html[data-ckb-theme="dark"] #ckb-shell .ckb-hero, html[data-ckb-theme="dark"] #ckb-shell .ckb-hero h1, html[data-ckb-theme="dark"] #ckb-shell .ckb-hero p { color: #ffffff !important; -webkit-text-fill-color: #ffffff !important; } html[data-ckb-theme="dark"] #ckb-shell #shap-result > button.open { color: #e0f2fe !important; } html[data-ckb-theme="dark"] #ckb-shell .ckb-image-frame, html[data-ckb-theme="dark"] #ckb-shell .ckb-reference-figure, html[data-ckb-theme="dark"] #ckb-shell .ckb-cluster-profile-figure { background: #ffffff !important; color-scheme: only light !important; } html[data-ckb-theme="dark"] body .options[role="listbox"] { border-color: #334155 !important; background: #111827 !important; } html[data-ckb-theme="dark"] body .options[role="listbox"] [role="option"] { background: #111827 !important; color: #f8fafc !important; } html[data-ckb-theme="dark"] body .options[role="listbox"] [role="option"]:hover, html[data-ckb-theme="dark"] body .options[role="listbox"] [role="option"][aria-selected="true"] { background: #0f2f4a !important; color: #f8fafc !important; } @media (max-width: 820px) { #ckb-shell { padding: 14px 12px 36px; } .ckb-hero { min-height: 0; border-radius: var(--ckb-radius-lg); } .ckb-trust-row { grid-template-columns: 1fr; } .ckb-section-heading { display: block; } .ckb-coding-note { max-width: none; margin-top: 14px; } .ckb-risk-row { grid-template-columns: 104px minmax(90px, 1fr) 48px; gap: 8px; } .ckb-shap-grid { grid-template-columns: 1fr; } .ckb-shap-card--cluster { grid-column: auto; } .ckb-image-grid--clusters { grid-template-columns: 1fr; } .ckb-image-grid--outcomes { grid-template-columns: 1fr; } } @media (max-width: 520px) { #ckb-shell { padding-inline: 8px; } .ckb-hero, .ckb-panel, .ckb-results { border-radius: 16px; } .ckb-hero { padding: 28px 22px; } .ckb-hero h1 { max-width: 11ch; } .ckb-panel, .ckb-results { padding: 18px; } .ckb-result-card__header, .ckb-risk-visual__header { display: block; } .ckb-result-badge, .ckb-legend { margin-top: 10px; } .ckb-shap-frame { height: 360px; } .ckb-outcome-table__header { display: block; } .ckb-outcome-table__header > p { margin-top: 7px; text-align: left; } .ckb-image-grid--clusters { grid-template-columns: 1fr; } .ckb-image-frame { height: 210px; } .ckb-image-card__context { grid-template-columns: 1fr; } .ckb-actions { align-items: stretch; } #predict-button, #reset-button { width: 100%; } .ckb-privacy-copy { flex-basis: 100%; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } } """ HEAD = """ """ _predictor: CKBSingleSamplePredictor | None = None _load_lock = threading.Lock() _inference_lock = threading.Lock() def get_predictor() -> CKBSingleSamplePredictor: """Load the large model bundle once, on the first prediction request.""" global _predictor if _predictor is None: with _load_lock: if _predictor is None: _predictor = CKBSingleSamplePredictor(MODEL_DIR) return _predictor def _clean_expired_shap_reports() -> None: """Remove old, per-request SHAP files from the ephemeral Space filesystem.""" if not SHAP_CACHE_DIR.exists(): return deadline = time.time() - SHAP_CACHE_TTL_SECONDS for report_dir in SHAP_CACHE_DIR.iterdir(): try: if report_dir.is_dir() and report_dir.stat().st_mtime < deadline: shutil.rmtree(report_dir, ignore_errors=True) except OSError: continue def _ensure_light_shap_canvas(plot_path: str) -> None: """Keep generated SHAP HTML legible when the host browser uses dark mode.""" path = Path(plot_path) try: document = path.read_text(encoding="utf-8") if SHAP_LIGHT_CANVAS_MARKER in document: return if "" not in document: return path.write_text( document.replace("", SHAP_LIGHT_CANVAS_HEAD + "", 1), encoding="utf-8", ) except (OSError, UnicodeError): return def _shap_force_plot_card(kind: str, title: str, plot_path: str, *, cluster: bool = False) -> str: """Embed one trusted, locally-generated SHAP HTML force plot by temporary URL.""" card_class = " ckb-shap-card--cluster" if cluster else "" _ensure_light_shap_canvas(plot_path) plot_url = "/gradio_api/file=" + quote(Path(plot_path).resolve().as_posix(), safe="/") return f"""

{kind.title()}

{html.escape(title)}

""" def _shap_force_gallery(result: dict[str, Any]) -> str: """Create the one-cluster plus 13-outcome SHAP explanation gallery.""" try: force_plots = result["force_plots"] cluster = result["cluster"] cluster_label = str(cluster.get("cluster_label") or cluster["provisional_cluster_label"]) cards = [ _shap_force_plot_card( "cluster", f"Frozen cluster explanation · {cluster_label}", force_plots["cluster"][cluster_label], cluster=True, ) ] for outcome_code, outcome in result["outcomes"].items(): cards.append( _shap_force_plot_card( "outcome", f"{outcome_code} · {outcome['outcome']}", force_plots[outcome_code], ) ) except (KeyError, OSError, TypeError): return ( '
' "SHAP force plots could not be rendered for this request." "
" ) return """ " def _locale_attrs(en: str, zh: str) -> str: """Return safe text attributes used by the language switcher.""" return ( f'data-locale-en="{html.escape(en, quote=True)}" ' f'data-locale-zh="{html.escape(zh, quote=True)}"' ) def _outcome_presentation(code: str, outcome: dict[str, Any]) -> dict[str, Any]: """Get display-only reference data without influencing inference.""" fallback_name = str(outcome.get("outcome", code)) return OUTCOME_PRESENTATION.get( code, { "en": fallback_name, "zh": fallback_name, "model_en": "Model", "model_zh": "模型", "low_5": 0.0, "low_10": 0.0, "high_5": 0.0, "high_10": 0.0, }, ) def _force_plot_frame(title_en: str, title_zh: str, plot_path: str) -> str: """Embed one trusted locally-generated SHAP force plot.""" _ensure_light_shap_canvas(plot_path) plot_url = "/gradio_api/file=" + quote(Path(plot_path).resolve().as_posix(), safe="/") aria_en = title_en + " SHAP force plot" aria_zh = title_zh + " SHAP 力图" return f""" """ def _supplementary_image_url(path: Path) -> str: """Return a temporary Gradio file URL for a bundled static figure.""" return "/gradio_api/file=" + quote(path.resolve().as_posix(), safe="/") def _risk_reference_figure( code: str, presentation: dict[str, Any], *, is_high_risk: bool, ) -> str: """Show the supplied low/high cumulative-risk figure for this outcome.""" risk_key = "high" if is_high_risk else "low" risk_en, risk_zh = ("High risk", "高风险") if is_high_risk else ("Low risk", "低风险") figure_path = SUPPLEMENTARY_IMAGE_DIR / f"{code}_{risk_key}.png" if not figure_path.is_file(): return _cumulative_risk_svg(code, presentation) display_code = code.replace("_", "–") alt_en = f"{display_code} cumulative risk curve with the {risk_en.lower()} trajectory emphasised" alt_zh = f"{display_code} 累积风险曲线,突出显示{risk_zh}轨迹" return f""" {html.escape(alt_en, quote=True)} """ def _cluster_profile_figure(label: str) -> str: """Show the supplied C1/C2/C3 cluster-profile illustration.""" figure_path = SUPPLEMENTARY_IMAGE_DIR / f"{label}.png" title_en, title_zh = f"Cluster {label} risk profile map", f"聚类 {label} 风险概览图" if not figure_path.is_file(): return '
Cluster profile illustration is unavailable.
' return f""" {html.escape(title_en, quote=True)} """ def _cumulative_risk_svg(code: str, presentation: dict[str, Any]) -> str: """Create a compact native risk curve from the supplied reference values.""" low_5, low_10 = float(presentation["low_5"]), float(presentation["low_10"]) high_5, high_10 = float(presentation["high_5"]), float(presentation["high_10"]) chart_max = max(low_5, low_10, high_5, high_10, 1.0) * 1.18 def y(value: float) -> float: return 137 - (value / chart_max * 90) low_y5, low_y10, high_y5, high_y10 = y(low_5), y(low_10), y(high_5), y(high_10) label_en = f"{code.replace('_', '–')} cumulative-risk reference curves" label_zh = f"{code.replace('_', '–')} 累积风险参考曲线" return f""" {low_5:.1f}%{low_10:.1f}% {high_5:.1f}%{high_10:.1f}% 0 years5 years10 years """ def _outcome_image_card(code: str, outcome: dict[str, Any], plot_path: str) -> str: """Render a compact outcome card with context above its SHAP force plot.""" presentation = _outcome_presentation(code, outcome) probability = max(0.0, min(1.0, float(outcome["probability"]))) threshold = max(0.0, min(1.0, float(outcome["youden_threshold"]))) is_high_risk = probability >= threshold risk_en, risk_zh = ("High risk", "\u9ad8\u98ce\u9669") if is_high_risk else ("Low risk", "\u4f4e\u98ce\u9669") decision_en = "at or above" if is_high_risk else "below" decision_zh = "\u8fbe\u5230\u6216\u9ad8\u4e8e" if is_high_risk else "\u4f4e\u4e8e" explanation_en = ( f"Predicted probability is {probability * 100:.1f}%, " f"{decision_en} the frozen threshold of {threshold * 100:.1f}%. Reference cumulative incidence: " f"low-risk {presentation['low_5']:.1f}% at 5 years and {presentation['low_10']:.1f}% at 10 years; " f"high-risk {presentation['high_5']:.1f}% and {presentation['high_10']:.1f}%." ) explanation_zh = ( f"\u9884\u6d4b\u6982\u7387\u4e3a {probability * 100:.1f}%\uff0c{decision_zh}\u51bb\u7ed3\u9608\u503c " f"{threshold * 100:.1f}%\u3002\u53c2\u8003\u7d2f\u79ef\u53d1\u751f\u7387\uff1a\u4f4e\u98ce\u9669\u7ec4 5 \u5e74 {presentation['low_5']:.1f}%\u300110 \u5e74 " f"{presentation['low_10']:.1f}%\uff1b\u9ad8\u98ce\u9669\u7ec4\u5206\u522b\u4e3a {presentation['high_5']:.1f}% \u548c {presentation['high_10']:.1f}%\u3002" ) display_code = code.replace("_", "\u2013") title_en, title_zh = str(presentation["en"]), str(presentation["zh"]) return f"""

Outcome risk panel

{html.escape(display_code)} · {html.escape(title_en)}

{risk_en}
{_risk_reference_figure(code, presentation, is_high_risk=is_high_risk)}

{risk_en}

{html.escape(explanation_en)}

{_force_plot_frame(display_code + ' ' + title_en, display_code + ' ' + title_zh, plot_path)}
""" def _cluster_image_card(label: str, plot_path: str) -> str: """Render only the assigned cluster profile and its matching SHAP force plot.""" title_en, title_zh = f"Cluster {label}", f"\u805a\u7c7b {label}" state_en, state_zh = "Assigned cluster", "\u672c\u6b21\u5206\u914d" return f"""

Cluster attribution

{title_en}

{state_en}
{_cluster_profile_figure(label)}
{_force_plot_frame(title_en, title_zh, plot_path)}
""" def _image_results_gallery(result: dict[str, Any]) -> str: """Create the assigned-cluster and 13-outcome image-result panels.""" try: force_plots = result["force_plots"] cluster = result["cluster"] selected_label = str(cluster.get("cluster_label") or cluster["provisional_cluster_label"]) cluster_cards = [_cluster_image_card(selected_label, force_plots["cluster"][selected_label])] outcome_cards = [ _outcome_image_card(code, outcome, force_plots[code]) for code, outcome in result["outcomes"].items() ] except (KeyError, OSError, TypeError): return '
Image results could not be rendered for this request.
' return """ " @spaces.GPU def run_prediction( features: dict[str, float], sample_id: str | None = None, *, include_force_plots: bool = False, ) -> dict[str, Any]: """Run one prediction; browser-only SHAP files are temporary and auto-expire.""" predictor = get_predictor() with _inference_lock: if include_force_plots: SHAP_CACHE_DIR.mkdir(parents=True, exist_ok=True) _clean_expired_shap_reports() output_dir = SHAP_CACHE_DIR / uuid.uuid4().hex try: result = predictor.predict_one( features, output_dir=output_dir, make_force_plots=True, sample_id=sample_id, ) result["_shap_force_gallery_html"] = _image_results_gallery(result) except Exception: shutil.rmtree(output_dir, ignore_errors=True) raise else: with tempfile.TemporaryDirectory(prefix="ckb_prediction_") as output_dir: result = predictor.predict_one( features, output_dir=output_dir, make_force_plots=False, sample_id=sample_id, ) # The predictor writes an audit JSON to its output directory. The API is # intentionally stateless, so that temporary file is removed and its now # invalid local path is not returned to callers. result.pop("result_json", None) result.pop("force_plots", None) return result def warm_up_model() -> str: """Load prediction-time artifacts after a visitor opens the demo.""" try: predictor = get_predictor() with _inference_lock: predictor._ensure_cluster_shap() for outcome in predictor.outcome_meta: predictor._load_outcome_bundle(outcome) return ( '
' '' '模型已就绪。预测可立即开始;提交数据不会被此 Space 持久保存。' "
" ) except Exception: return ( '
' '' '预热尚未完成。仍可提交预测,系统会在请求时自动重试加载。' "
" ) def predict_json_api(request: dict[str, Any]) -> dict[str, Any]: """Single-object API used by external Python, JavaScript, or curl clients.""" if not isinstance(request, dict): raise gr.Error("Request must be a JSON object.") features = request.get("features", request) if not isinstance(features, dict): raise gr.Error("The 'features' field must be a JSON object.") missing = [feature for feature in FEATURES if feature not in features] if missing: raise gr.Error(f"Missing required input variables: {missing}") sample_id = request.get("sample_id") if sample_id is not None: sample_id = str(sample_id) if len(sample_id) > 200: raise gr.Error("sample_id must be no longer than 200 characters.") return run_prediction( {feature: features[feature] for feature in FEATURES}, sample_id=sample_id, ) def _outcome_table(result: dict[str, Any]) -> pd.DataFrame: rows: list[dict[str, Any]] = [] for code, outcome in result["outcomes"].items(): is_high_risk = outcome["risk_class"] == "high_risk" rows.append( { "代码": code, "预测结局": outcome["outcome"], "概率": f"{outcome['probability'] * 100:.1f}%", "冻结阈值": f"{outcome['youden_threshold'] * 100:.1f}%", "风险判定": "At or above threshold" if is_high_risk else "Below threshold", } ) return pd.DataFrame(rows).set_axis(TABLE_COLUMNS_EN, axis="columns") def _outcome_table_html(result: dict[str, Any]) -> str: """Render the outcome table as fully bilingual native HTML.""" rows: list[str] = [] for code, outcome in result["outcomes"].items(): presentation = _outcome_presentation(code, outcome) probability = max(0.0, min(1.0, float(outcome["probability"]))) threshold = max(0.0, min(1.0, float(outcome["youden_threshold"]))) is_high_risk = probability >= threshold risk_en, risk_zh = ("At or above threshold", "达到或高于阈值") if is_high_risk else ("Below threshold", "低于阈值") rows.append( f""" {html.escape(code.replace('_', '–'))} {html.escape(str(presentation['en']))} {probability * 100:.1f}% {threshold * 100:.1f}% {risk_en} """ ) return """

OUTCOME PREDICTIONS

Outcome prediction details

Risk class is determined by the frozen threshold for each outcome.

""" + "".join(rows) + "
CodeOutcomeProbabilityFrozen thresholdRisk decision
" def _cluster_card(cluster: dict[str, Any]) -> str: label = str(cluster.get("cluster_label") or cluster["provisional_cluster_label"]) assigned = cluster["classification_status"] == "assigned" badge_class = "" if assigned else " ckb-result-badge--review" badge_text = "已完成分配" if assigned else "建议复核" return f"""
Cluster assignment

风险聚类:{html.escape(label)}

{badge_text}
最近中心距离
{cluster['distance_nearest']:.4f}
与次近中心的间隔
{cluster['margin']:.4f}
""" def _risk_visual(result: dict[str, Any]) -> str: """Render a responsive, accessible probability chart without extra dependencies.""" rows = sorted( result["outcomes"].items(), key=lambda item: float(item[1]["probability"]), reverse=True, ) bars: list[str] = [] for code, outcome in rows: probability = max(0.0, min(1.0, float(outcome["probability"]))) threshold = max(0.0, min(1.0, float(outcome["youden_threshold"]))) is_high_risk = outcome["risk_class"] == "high_risk" row_class = " ckb-risk-row--high" if is_high_risk else "" accessible_label = ( f"{code}: {outcome['outcome']}, probability {probability * 100:.1f}%, " f"threshold {threshold * 100:.1f}%" ) bars.append( f'
' f'{html.escape(code)}' f'" f'{probability * 100:.1f}%' "
" ) return """

13 项结局的预测概率

横线标记各结局冻结的 Youden 阈值;此图用于研究结果阅读,并非临床诊断。

低于阈值 达到或高于阈值
""" + "".join(bars) + "
" def predict_for_demo( *values: float | int | None, ) -> tuple[str, str, str, dict[str, Any], str]: missing = [ FEATURE_LABELS[feature] for feature, value in zip(FEATURES, values, strict=True) if value is None ] if missing: raise gr.Error(f"请补全以下变量后再预测:{', '.join(missing)}") features = { feature: float(value) for feature, value in zip(FEATURES, values, strict=True) if value is not None } result = run_prediction( features, sample_id="web_demo", include_force_plots=True, ) shap_gallery = result.pop( "_shap_force_gallery_html", '
Image results are unavailable for this request.
', ) return ( _cluster_card(result["cluster"]), _outcome_table_html(result), _risk_visual(result), result, shap_gallery, ) def restore_example_values() -> list[float]: """Restore the prefilled synthetic demonstration values without model work.""" return [EXAMPLE_VALUES[feature] for feature in FEATURES] with gr.Blocks( title="CKB 17-variable Risk Predictor", fill_width=True, ) as demo: with gr.Column(elem_id="ckb-shell"): gr.HTML( """ 跳转到预测输入
CKB research interface

17 变量风险预测

一次完整输入,返回冻结 K=3 聚类分配与 13 项结局风险概率。表单与 API 共用同一套模型推理逻辑。

17 个输入变量 13 项概率结果 无持久化保存
""" ) gr.HTML( """
17完整输入变量
K = 3冻结最近中心聚类
13结局风险概率
""" ) warm_status = gr.HTML( '
' '' '正在后台预热模型。你可以先填写表单;模型完成加载后即可提交。' "
", elem_id="warm-status", ) with gr.Column(elem_id="risk-inputs", elem_classes="ckb-panel"): gr.HTML( """
Input workspace

填写预测变量

所有 17 项均为必填。分类变量必须与训练数据使用相同编码;本服务不会重新编码、插补或保存输入。

""" ) input_by_feature: dict[str, Any] = {} with gr.Row(elem_classes="ckb-input-grid"): for index, title, subtitle, group_features in FEATURE_GROUPS: with gr.Column(min_width=250): with gr.Group(elem_classes="ckb-form-card"): gr.HTML( f"""
{index}

{title}

{subtitle}

""" ) for feature in group_features: component_kwargs = { "label": DEFAULT_EN_FEATURE_LABELS[feature], "value": EXAMPLE_VALUES[feature], "elem_id": f"field-{feature}", } if feature in CATEGORICAL_CHOICES: input_by_feature[feature] = gr.Dropdown( choices=CATEGORICAL_CHOICES[feature], filterable=False, **component_kwargs, ) else: input_by_feature[feature] = gr.Number( precision=0 if feature in INTEGER_FEATURES else None, **component_kwargs, ) # Components are displayed in user-friendly groups, while the # predictor always receives its frozen feature order. inputs = [input_by_feature[feature] for feature in FEATURES] with gr.Row(elem_classes="ckb-actions"): submit = gr.Button("运行风险预测", variant="primary", elem_id="predict-button") reset = gr.Button("恢复演示数据", variant="secondary", elem_id="reset-button") gr.HTML( """

推理结束后会清除临时文件;请勿将本工具用作诊断或治疗决策。

""" ) with gr.Column(elem_classes="ckb-results"): gr.HTML( """
Prediction output

结果与概率分布

""" ) cluster_output = gr.HTML( '
提交一组完整变量后,此处将显示聚类分配与距离信息。
', elem_id="cluster-result", ) risk_visual = gr.HTML( '
概率分布图将在预测完成后出现。
', elem_id="risk-visual", ) outcome_output = gr.HTML( '
Run a prediction to generate outcome details.
', elem_id="outcome-table", ) with gr.Accordion("Show image results (16)", open=False, elem_id="shap-result"): shap_output = gr.HTML( '
Run a prediction to generate image results.
', elem_id="shap-gallery", ) with gr.Accordion("查看完整 JSON 响应", open=False, elem_id="raw-result"): raw_output = gr.JSON(label="完整响应") gr.HTML( '' ) submit.click( fn=predict_for_demo, inputs=inputs, outputs=[cluster_output, outcome_output, risk_visual, raw_output, shap_output], api_name="demo_predict", show_progress="full", ) reset.click(fn=restore_example_values, outputs=inputs, api_name=False) # A compact API-only endpoint. Keeping one JSON input makes it convenient # for external Python, JavaScript, and curl clients to call the Space. api_request = gr.JSON(label="request", visible=False) api_response = gr.JSON(label="response", visible=False) api_trigger = gr.Button(visible=False) api_trigger.click( fn=predict_json_api, inputs=api_request, outputs=api_response, api_name="predict", ) warmup_trigger = gr.Button(visible=False) warmup_trigger.click( fn=warm_up_model, outputs=warm_status, api_name="warmup", show_progress="hidden", ) demo.queue(default_concurrency_limit=1, max_size=16) if __name__ == "__main__": SHAP_CACHE_DIR.mkdir(parents=True, exist_ok=True) demo.launch( css=CUSTOM_CSS, head=HEAD, allowed_paths=[str(SHAP_CACHE_DIR), str(SUPPLEMENTARY_IMAGE_DIR)], )