python版本的列线图绘制(二分类)

python版本的列线图绘制(二分类)

列线图是为临床预测模型提供了一个使用的工具,借助列线图可以把指标转变为预测概率,但是近年来随着网页计算器的出现,列线图的使用没有原来广泛。但是,最近随着预测模型解释的流行,发现列线图还具有作为线性模型解释的工具的潜力,所以又想着把之前“用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混淆了。

绘制列线图代码

  1. 形成绘制列线图的数据
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

  1. 后处理数据
#中间函数,不之间使用
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
   
  1. 绘制列线图
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()

你可能感兴趣的:(预测模型构建和评价,人工智能,数据分析,机器学习,python)