如何用Bokeh实现TP/TN/FP/FN可调的交互式动态ROC曲线
基于Bokeh的动态ROC曲线实现方案
你之前的交互框架无法联动ROC曲线,核心问题是CustomJS回调里只写了正弦曲线的坐标更新逻辑,没有嵌入混淆矩阵到ROC坐标的计算规则,同时缺少混淆矩阵的数值约束(TP+FN需固定为总正样本量、FP+TN需固定为总负样本量,否则指标计算会出现逻辑矛盾)。
实现要点
- 采用纯前端CustomJS回调实现计算逻辑,最终可导出为单HTML文件,离线即可演示,不需要启动Python服务
- 滑块做联动约束:调整TP时自动计算并更新FN值,调整FP时自动计算并更新TN值,避免出现混淆矩阵总和不固定的问题
- ROC曲线采用标准三点折线结构:从原点(0,0)出发,经过当前混淆矩阵对应的(FPR, TPR)点,最终到(1,1)点,和单阈值下分类器的ROC形态完全一致
- 同步计算并展示AUC、灵敏度、特异度三个核心指标,方便演示时对照数值变化
如果需要展示平滑的多段ROC曲线,只需要在JS回调里扩展
roc_src.data的fpr、tpr数组,加入多个模拟阈值对应的坐标点即可,核心的数据源更新逻辑不需要改动。
可直接运行的完整代码
from bokeh.plotting import figure, show, output_file from bokeh.models import Slider, CustomJS, ColumnDataSource, Span, Label from bokeh.layouts import column, row # 基础配置:固定总正/负样本量,避免混淆矩阵总和变动 TOTAL_POS = 100 TOTAL_NEG = 100 init_tp, init_fn, init_fp, init_tn = 75, 25, 20, 80 # 初始化ROC坐标点 init_fpr = init_fp / (init_fp + init_tn) init_tpr = init_tp / (init_tp + init_fn) roc_source = ColumnDataSource(data={ "fpr": [0, init_fpr, 1], "tpr": [0, init_tpr, 1] }) # 初始化指标数据源 init_auc = 0.5*(0+init_tpr)*init_fpr + 0.5*(init_tpr+1)*(1-init_fpr) metric_source = ColumnDataSource(data={ "auc": [init_auc], "sensitivity": [init_tpr], "specificity": [1 - init_fpr] }) # 画布配置 p = figure(width=550, height=500, title="动态ROC曲线演示", x_range=(0,1), y_range=(0,1)) p.xaxis.axis_label = "假阳性率 (FPR)" p.yaxis.axis_label = "真阳性率 (TPR)" # 随机分类基准线 p.line([0,1], [0,1], line_dash="dashed", color="gray", legend_label="随机分类基准") # 绘制ROC曲线 roc_line = p.line(x="fpr", y="tpr", source=roc_source, line_width=3, color="#1f77b4", legend_label="当前模型ROC") # 指标显示标签 auc_label = Label(x=0.6, y=0.2, text=f"AUC: {init_auc:.3f}", text_font_size="12pt") sens_label = Label(x=0.6, y=0.15, text=f"灵敏度: {init_tpr:.3f}", text_font_size="12pt") spec_label = Label(x=0.6, y=0.1, text=f"特异度: {1-init_fpr:.3f}", text_font_size="12pt") p.add_layout(auc_label) p.add_layout(sens_label) p.add_layout(spec_label) p.legend.location = "bottom_right" # 四个混淆矩阵滑块 tp_slider = Slider(start=0, end=TOTAL_POS, value=init_tp, step=1, title="真阳性(TP)") fn_slider = Slider(start=0, end=TOTAL_POS, value=init_fn, step=1, title="假阴性(FN)", disabled=True) fp_slider = Slider(start=0, end=TOTAL_NEG, value=init_fp, step=1, title="假阳性(FP)") tn_slider = Slider(start=0, end=TOTAL_NEG, value=init_tn, step=1, title="真阴性(TN)", disabled=True) # 纯JS回调逻辑,无需后端支持 callback = CustomJS(args=dict( tp=tp_slider, fn=fn_slider, fp=fp_slider, tn=tn_slider, roc_src=roc_source, total_pos=TOTAL_POS, total_neg=TOTAL_NEG, auc_lbl=auc_label, sens_lbl=sens_label, spec_lbl=spec_label ), code=""" // 数值约束:保证TP+FN=总正样本,FP+TN=总负样本 const tp_val = tp.value; const fn_val = total_pos - tp_val; fn.value = fn_val; const fp_val = fp.value; const tn_val = total_neg - fp_val; tn.value = tn_val; // 计算当前阈值下的FPR、TPR const fpr = fp_val / (fp_val + tn_val); const tpr = tp_val / (tp_val + fn_val); // 更新ROC曲线坐标 roc_src.data = { fpr: [0, fpr, 1], tpr: [0, tpr, 1] }; roc_src.change.emit(); // 梯形法计算AUC,更新指标显示 const auc = 0.5 * (0 + tpr) * fpr + 0.5 * (tpr + 1) * (1 - fpr); auc_lbl.text = `AUC: ${auc.toFixed(3)}`; sens_lbl.text = `灵敏度: ${tpr.toFixed(3)}`; spec_lbl.text = `特异度: ${(1-fpr).toFixed(3)}`; """) # 绑定滑块事件 tp_slider.js_on_change('value', callback) fp_slider.js_on_change('value', callback) # 页面布局 layout = column( p, row(tp_slider, fn_slider), row(fp_slider, tn_slider) ) output_file("dynamic_roc.html") show(layout)
效果说明
运行代码后会自动生成HTML文件,拖动滑块即可观察曲线变化规律:
- 当TP拉满、FP为0时,ROC曲线贴左上角,AUC=1,对应完美分类器
- 当TP=FN、FP=TN时,ROC曲线和对角基准线重合,AUC=0.5,对应随机猜测的分类器
- 当TP很低、FP很高时,ROC曲线会贴右下角,AUC<0.5,对应预测结果完全反向的分类器
如果需要支持FN、TN滑块也可手动调整,只要给两个滑块绑定回调,在回调里增加反向约束逻辑即可(比如调整FN时自动更新TP=总正样本-FN,调整TN时自动更新FP=总负样本-TN)。
内容的提问来源于stack exchange,提问作者Chris
相关产品推荐
相关产品推荐

