列线图是为临床预测模型提供了一个使用的工具,借助列线图可以把指标转变为预测概率,但是近年来随着网页计算器的出现,列线图的使用没有原来广泛。但是,最近随着预测模型解释的流行,发现列线图还具有作为线性模型解释的工具的潜力,所以又想着把之前“用python绘制nomogram”的项目做完,那是之前的一个想法,后来因为水平不够没有完全完成,现在借助AI的力量,终于可以初步实现python版本的nomogram的绘制。
绘制列线图一共有三个步骤:1. 准备数据,是将原始数据形成三个df,(data_label,meta_df 和score_df,由form_nomogram_data函数完成;2. 后处理数据,处理以上三个表格,合并了onehot编码的数据并计算了预测概率;3.绘制列线图,是使用plotly绘制。三个步骤都是必须的,都分别提供了相应的函数。
代码见后,不能说没有bug,欢迎大家试用。
实现列线图的绘制是第一步,下一步是为了尝试开发列线图的线性模型解释功能,尝试用列线图来解释线性模型(逻辑回归模型、cox模型,甚至立方样条模型?),总体上是类比SHAP分析,希望能实现全局解释,即比较变量之间的重要性和局部解释,即展示变量当前值对预测结果的贡献。
理论上,全局性解释和局部解释都是可以实现的,但是要真正像SHAP分析那样发挥作用,恐怕还有一些的弯路要走。
不过,以模型解释为目的,列线图不是唯一的选择,meta_df中的值是带正负号的值,类似SHAP值,可以反映贡献的大小和方向,变量之间也是可比的,使用这个值来解释模型,也是一个考虑,直接用还是要处理一下,比如像SHAP值那样都减个平均数。
至于score_df对应的是列线图线段的长短,就都是正值,现在在考虑怎样可视化能更好的进行模型解释,做成蜂窝图的话,就有点和SHAP混淆了。
from sklearn.preprocessing import LabelEncoder
import statsmodels.formula.api as smf
import pandas as pd
import numpy as np
import itertools
import plotly.graph_objects as go
import numpy as np
import pandas as pd
def form_nomogram_data(data, cat_cols, outcome_col, var_cols):
data_label = data.loc[:, var_cols]
# 对分类变量进行独热编码
data_onehot = pd.get_dummies(data_label, columns=cat_cols, drop_first=True, dtype=int)
# 更新var_cols以包含独热编码后的列名
updated_var_cols = data_onehot.columns.tolist()
# 对结果变量进行标签编码
le = LabelEncoder()
data_onehot[outcome_col] = le.fit_transform(data[outcome_col])
# print(data_onehot.info())
# 构建逻辑回归模型公式
formula = outcome_col + '~' + '+'.join(updated_var_cols)
# 构建线性模型,列线图需要的是模型的参数
model_logit = smf.logit(formula, data_onehot).fit()
model_logit_params = model_logit.params
cols = model_logit_params.index.to_list()
params = model_logit_params.values
# 形成meta数据,系数与变量值的乘积,带有正负号,可以指示方向
meta_df = data_onehot.loc[:, cols[1:]]
meta_df['Intercept'] = np.repeat(1, meta_df.shape[0])
# meta数据1,beta与X的乘积,现在没有连续变量和分类变量的区别
for col, beta in zip(cols, params):
meta_df[col] = [x * beta for x in meta_df[col]]
# 求最大数据,即每个变量最大值和最小值之间distances中最大的一个, 这个最大的distance会处理为100, 其它的distance根据比例绘制
ls_max_distance = []
for col in cols:
one_distance = np.max(meta_df[col].values) - np.min(meta_df[col].values)
print(col + ':' + f"{one_distance}")
ls_max_distance.append(one_distance)
max_distance = np.max(ls_max_distance)
# 形成score数据,用于形成列线图
score_df = meta_df.copy()[cols]
for col in cols:
score_df[col] = (meta_df[col] - meta_df[col].min()) * 100 / max_distance
return data_label, meta_df, score_df
#中间函数,不之间使用
def _cat_reunion(df,ununion_cols,reunion_cols):
for i in np.arange(len(ununion_cols)):
if len(reunion_cols[i])>1:
df[reunion_cols[i]]=df[ununion_cols[i]].sum(axis=1)
else:
df[reunion_cols[i]]=df[cat_cols[i]]
ununion_cols_flat = list(itertools.chain(*ununion_cols))
df_reunion = df.drop(ununion_cols_flat, axis=1)
return df_reunion
def postprocessing(meta_df,score_df,ununion_cols,reunion_cols): meta_df=_cat_reunion(meta_df,ununion_cols=ununion_cols,reunion_cols=reunion_cols)
meta_df['total'] = meta_df.sum(axis=1)
meta_df['probability'] = [1 / (1 + np.exp(-z)) for z in meta_df['total']]
score_df=_cat_reunion(score_df,ununion_cols=ununion_cols,reunion_cols=reunion_cols)
score_df['total'] = score_df.sum(axis=1)
score_df['probability'] = meta_df['probability']
return meta_df,score_df
def plot_nomogram(score_df,data_label,meta_df,prob_range = [0, 0.1, 0.2, 0.4, 0.5, 0.6, 0.8, 0.85, 0.9]):
# fig整体布局,三行两列,
# title='Explaination of Linear Model'
fig = go.Figure().set_subplots(rows=3, cols=2, vertical_spacing=0.01,
horizontal_spacing=0.01, column_widths=[0.1, 0.9],
row_heights=[0.1, 0.5, 0.2])
fig.update_yaxes(
autorange=False,
visible=False
) # 禁止自动调整刻度,通用
fig.update_xaxes(visible=False) # 禁止自动调整刻度,通用
fig.update_yaxes(row=1, range=[2, 4])
fig.update_yaxes(row=2, range=[4-data_label.columns.shape[0]*2, 4])
fig.update_yaxes(row=3, range=[0, 4])
fig.update_xaxes(row=1, col=2, range=[-5, 105])
fig.update_xaxes(row=2, col=2, range=[-5, 105])
fig.update_xaxes(row=3, col=2, range=[-5, max(score_df['total'])*1.2])
fig.update_layout(width=1000, height=600, showlegend=False, paper_bgcolor="#ffffff", plot_bgcolor="#ffffff")
# ---------------------------------------绘制100标尺------------------------------------------------------------#
# python arrange的规则是“前包后不包”,所以要多一位数
fig.add_trace(go.Scatter(mode='lines+markers', y=np.repeat(3, 105 // 5),
x=np.arange(0, 105, 5),
marker={'symbol': '142', "color": 'blue', 'size': 15},
), row=1, col=2)
# 绘制次要刻度
fig.add_trace(go.Scatter(mode='lines+markers',
x=np.arange(0, 101),
y=np.repeat(3, 101), # 离开x轴的位置
marker={'symbol': '142', 'color': 'blue'},
), row=1, col=2)
# 绘制数据数字标签,原始数据
fig.add_trace(go.Scatter(mode='text',
x=np.arange(0, 105, 5),
y=np.repeat(3.5, 105 // 5),
text=np.arange(0, 105, 5),
), row=1, col=2)
# 绘制左侧label,col=1
fig.add_trace(go.Scatter(mode='text', x=[-3], y=[3], text='Points'), row=1, col=1)
#----------------------------变量---------------------------------------------------------
for i,col in enumerate(score_df.columns.difference(['Intercept','total','probability'])):
if data_label[col].dtype.kind in 'if':
step=score_df[col].max()/(data_label[col].max()-data_label[col].min())
if meta_df[col].min()<0:
text=[int(x) for x in np.arange(np.floor(min(data_label[col])),np.floor(max(data_label[col])+2),2)][::-1]
else:
text=[int(x) for x in np.arange(np.floor(min(data_label[col])),np.floor(max(data_label[col])+2),2)]
x_range = np.arange(0, max(score_df[col]) + 2*step, 2*step)
# 主刻度
fig.add_trace(go.Scatter(
mode='lines+markers',
y=np.repeat(3-2*i, len(x_range)),
x=x_range,
marker={'symbol': '142', "color": 'red'}
), row=2, col=2)
# 绘制数据数字标签,原始数据
fig.add_trace(go.Scatter(
mode='text',
x=x_range,
y=np.repeat(3.5-2*i, len(x_range)),
text=text,#1step_age 对应1 岁
), row=2, col=2)
# 绘制左侧label,col=1
fig.add_trace(go.Scatter(
mode='text',
x=[-3],
y=[3-2*i],
text=col
), row=2, col=1)
if data_label[col].dtype.kind in 'O':
x_range=np.unique(score_df[col])
if meta_df[col].min()<0:
text=data_label[col].unique()[::-1]
else:
text=data_label[col].unique()
#主刻度
fig.add_trace(go.Scatter(mode='lines+markers',
y=np.repeat(3-2*i,len(x_range)),
x=x_range,
marker={'symbol':'142',"color":'red'},
), row=2,col=2)
#绘制数据数字标签,这应该是原始数据才对
fig.add_trace(go.Scatter(mode='text',
x=x_range,#标尺数据
y=np.repeat(3.5-2*i,len(x_range)),
text=data_label[col].unique(),#原始数据
),row=2,col=2)
#绘制左侧label,col=1
fig.add_trace(go.Scatter(mode='text',x=[-3],
y=[3-2*i],text=col),row=2,col=1)
# ----------------------------------total score------------------------------------------
# 总分与各个变量得分没有对应关系,是和概率之间有对应关系,所以可以是独立的坐标系
step_total=(score_df['total'].max()-score_df['total'].min())/20
x_total_range = np.arange(score_df['total'].min()*0.8, max(score_df['total']) *1.2, step_total)
fig.add_trace(go.Scatter(mode='lines+markers', y=np.repeat(3, len(x_total_range)),
x=x_total_range, #
marker={'symbol': '142', "color": 'green', 'size': 15},
), row=3, col=2)
# 绘制数据数字标签,这应该是原始数据才对
fig.add_trace(go.Scatter(mode='text',
x=x_total_range, # 标尺数据
y=np.repeat(3.5, len(x_total_range)),
text=x_total_range.round(0) # 原始数据
), row=3, col=2)
# 绘制左侧label,col=1
fig.add_trace(go.Scatter(mode='text', x=[-3], y=[3], text='Total'), row=3, col=1)
# #----------------------------------------prob-用total划线而标记probality----------------------------------------#
# 给定一个概率的列表
x_proba_range = [] # 取toal_betaX_std的值
proba_text_label = []
for x in prob_range:
# <于0.1的,且距离0.1最近的值
proba_closest = max(score_df[score_df['probability'] <= x]['probability'], default=None)
if proba_closest is not None:
label = round(proba_closest, 2)
value = score_df[score_df['probability'] == proba_closest]['total']
x_proba_range.append(value)
proba_text_label.append(label)
x_proba_range = np.unique(x_proba_range)
proba_text_label = np.unique(proba_text_label)
fig.add_trace(go.Scatter(mode='lines+markers', y=np.repeat(1, len(x_proba_range)),
x=x_proba_range,
marker={'symbol': '142', "color": 'green'},
), row=3, col=2)
# 绘制数据数字标签,这应该是原始数据才对
fig.add_trace(go.Scatter(mode='text',
x=x_proba_range, # 标尺数据
y=np.repeat(1.5, len(x_proba_range)),
text=proba_text_label # 原始数据
), row=3, col=2)
# 绘制左侧label,col=1
fig.add_trace(go.Scatter(mode='text', x=[-3], y=[1], text='prob'), row=3, col=1)
return fig
if __name__ == "__main__":
data=pd.read_csv('data_dev_factor_cleaned_remove_space.csv')
#排在第一的label作为哑变量
data['hypertension']=pd.Categorical(data['hypertension'],categories=['No','Yes'],ordered=True)
data['ejection']=pd.Categorical(data['ejection'],categories=['Good','Fair','Poor'],ordered=True)
data['sex']=pd.Categorical(data['sex'],categories=['Male','Female'],ordered=True)
#指定变量的类型,待改进
cat_cols = ['sex', 'hypertension','ejection']
continuous_cols = ['age', 'bmi']
outcome_col = 'outcome'
var_cols = continuous_cols + cat_cols
data_label, meta_df, score_df = form_nomogram_data(data, cat_cols, outcome_col, var_cols)
#必要的处理,转换变量和计算概率
meta_df,score_df=postprocessing(meta_df,score_df,ununion_cols=[['sex_Female'],['hypertension_Yes'],['ejection_Poor','ejection_Fair']],reunion_cols=['sex','hypertension','ejection'])
#fig
fig = plot_nomogram(score_df,data_label,meta_df,prob_range=[0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9])
fig.show()