薬剤師のプログラミング学習日記

プログラミングやコンピュータに関する記事を書いていきます

腎機能評価ツールを作ろう

前回記事「eGFRとクレアチニンクリアランスの計算式をグラフ描画する」の続きです。
f:id:enokisaute:20200407234000p:plain


ウィジェット(スライダーなどの部品)を配置する

マウス操作で年齢を変化させることのできるスライダーを付けるには次のようなコードを書き加えます。

from matplotlib.widgets import Slider
 
def update_age(val):
    pass
    # スライダーの値が変更された時の処理
 
# widgetの表示位置とサイズ. axes([left, bottom, width, height])
age_slider_pos = fig.add_axes([0.1, 0.22, 0.8, 0.025])
# widgetオブジェクトのインスタンス作成. (axes, ラベル, 最小値, 最大値, 初期値, 刻み幅,  値のフォーマット)
age_slider = Slider(age_slider_pos, '年齢', 20, 100, valinit=age, valstep=1, valfmt="%1.0f")
# 値が変更されたらupdate_ageが呼ばれる
age_slider.on_changed(update_age)

fig.add_axes()で位置とサイズを指定してスライダーのコンストラクタに渡しています。axes([])内のleft, bottomはグラフ左下隅の位置、width, heightはサイズで、各値は0~1の範囲で1が図の最大値となります。また、スライダーの値を変更可能にするためオブジェクトへの参照をage_sliderに保持しておき、値の変更時にon_changed(update_age)のupdate_ageが呼び出されます。

コードの順番が逆になりましたが、スライダーなどの各部品を配置するために図の大きさとレイアウトは以下のように設定しています。figsizeの引数は自分のパソコンの画面に応じて適当なサイズに変更できます。

# 図の作成. figsize=横縦のサイズ(インチ)
fig = plt.figure(figsize=(6.9, 7.2), facecolor='beige', edgecolor='black', linewidth=1)
# レイアウトを調整
fig.subplots_adjust(left=0.345, bottom=0.37, top=0.9)

ここまでのところでコードを実行しても、update_age()の中の処理を書いていないのでスライダーを動かしても何も起こりません。

性別、身長、クレアチニン値、体重を変更させることのできる他のウィジェットも同じ要領で配置していきます。位置やサイズについては、実際にコードを実行して自分の目で確認しながら決めていきました。


ウィジェットの値変更時の処理を書く

今までコールバック関数は各ウィジェット毎に書いていましたが、まとめて1つに書くことができたみたいなので、今回はそのようにしました。
先に、プロット作成時にLine2Dオブジェクトへの参照を取得するよう変更しておきます。

graph1, = ax.plot(weight, y1, 'blue', label='CCr')
graph2, = ax.plot(weight, y2, 'red', label='個別eGFR')
graph3, = ax.plot([x_s, x_e], [y3, y3], color='green', label='標準化eGFR')


また、変更されたパラメータでCCr、標準化eGFR、個別eGFRを再計算させることのできるメソッドをクラスに追加します。

    def calc_kf(self, age, cre, height, sex):
        self.age = age
        self.cre = cre
        self.height = height
        self.sex = sex
        ccr = self.ccr()
        i_gfr = self.indiv_egfr()
        s_gfr = self.stand_egfr()
        return ccr, i_gfr, s_gfr


こちらがコールバック関数です。上で作成したウィジェットにこの関数を紐づけます。

def update_graph(val):
    _age = age_slider.val
    _cre = cre_slider.val
    _hgt = hgt_slider.val
    _sex = sex_button.value_selected
    y1, y2, y3 = erf.calc_kf(_age, _cre, _hgt, _sex)
    graph1.set_ydata(y1)
    graph2.set_ydata(y2)
    graph3.set_ydata(y3)
    fig.canvas.draw_idle()

これでスライターを動かしたり、男性・女性のラジオボタンを変更したときにグラフも連動して動くようになりました。

グラフにテキストを表示する

これも大きくは他のウィジェットと変わりません。変更する必要があるオブジェクトについては参照を変数に持っておきます。図の下部には各計算式を表示することにしました。ここのコードは変わり映えしないので、ページ下の全体のコードを参照してください。

図の左側テキストボックスに体重を入力しますが、ここの入力チェックを行うコードは次のように作りました。try~exceptで入力された数字の文字列を浮動小数点に変換して、想定される例外(例えば、数字を入力するところなのにアルファベットを入力した、など変換がうまくいかなかった)が発生したらweightに0を代入、変換がうまくいった場合でも、入力値が0~100の範囲になければ0を返すようにしています。ここの処理はあまり深く考えず、単純にしています。

# 体重テキストボックスの入力チェック
def textbox_valcheck(wgt_text):
    try:
        weight_p = float(wgt_text)
    except ValueError:
        weight_p = 0
    else:
        if weight_p < 0 or weight_p > 100:
            weight_p = 0
    return weight_p

あとはこの取得した体重を使って、update_textarea()でグラフ中の点と図左側の文字列を更新しています。

体重一点における体表面積、CCr、個別eGFRを求める

これまではグラフ描画のため体重はNumpy配列で初期化時に与えたものを使っていましたが、テキストボックスに体重を入力するので、その体重における、ピンポイントでの結果を返すメソッドを作成します。

    # ある体重一点でのCCr, 個別eGFR, 体表面積を返す. テキスト表示用
    def calc_kf_point(self, weight_p):
        temp_w = self.weight
        self.weight = weight_p
        cp = self.ccr()
        gp = self.indiv_egfr()
        bsap = self.bsa()
        self.weight = temp_w
        return cp, gp, bsap

一点の体重を受け取り、インスタンス変数に代入して各計算メソッドを呼び出しています。しかし、このままだとself.weightが上書きされているので今度グラフ描画するときにうまくいきません。そこで一旦temp_w変数にself.weightを預かっておいてもらい、計算が終わった後で書き換えたself.weightに戻すという方法を取ることにしました。ちょっと苦し紛れ感は否めませんが。
グラフ中の点もこのメソッドを使って更新するようにします。

def update_scatter(weight_p):
    cp, gp, _ = ekf.calc_kf_point(weight_p)
    graph4.set_data(weight_p, cp)  # ccr
    graph5.set_data(weight_p, gp)  # egfr


実際に動かしてCCrとeGFRの違いをみる

まず、すぐにわかるのは年齢による変化の違いです。個別eGFRに比べてCCrは年齢の影響を大きく受けるということがわかります。
また、体重(横軸)による変化では個別eGFRは体重が大きくなるほど変化は緩やかになりますが、CCrの傾きは一定(体重の関数としてみると直線の式だから当たり前ですが)で、肥満患者では腎機能が過大評価されるということがわかります。しかし、身長がある程度大きければ(=肥満ではない場合)、個別eGFRとCCrとの差はそれほど大きくはなりません。
次に、痩せた高齢者を想定してスライダーを動かしてみます。Creは低↓、年齢は高↑、体重は低↓のようなケースです。この場合も年齢が高くなるとCCrは大きく下がります。また、Creが基準値程度のときと比べ、小さくなるほど両者の乖離は大きくなってきます。この場合、個別eGFRはCCrよりも高い値となるため、過量投与とならないよう注意しなくてはいけない、ということでした。


今回のコードです。

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider
from matplotlib.widgets import RadioButtons
from matplotlib.widgets import TextBox
 
# 腎機能評価ツールを作る2
 
F_SIZE = 12.5          # 左側テキスト, 図タイトルのフォントサイズ
FORMULA_F_SIZE = 11    # 下部数式のフォントサイズ
 
class EvalRenalFunc:
    def __init__(self, age, cre, height, weight, sex):
        self.age = age
        self.cre = cre
        self.height = height
        self.weight = weight
        self.sex = sex
 
    # CCr(Cockcroft-Gault式)
    def ccr(self):
        if self.sex == '女性':
            factor = 0.85
        else:
            factor = 1.0
        return (140 - self.age) * self.weight * factor / 72.0 / self.cre
 
    # 標準化eGFR(Creat式)
    def stand_egfr(self):
        if self.sex == '女性':
            factor = 0.739
        else:
            factor = 1.0
        return 194 * self.cre ** -1.094 * self.age ** -0.287 * factor
 
    # 個別eGFR
    def indiv_egfr(self):
        bsa = self.bsa()
        return self.stand_egfr() * bsa / 1.73
 
    # 体表面積(Du Bois式)
    def bsa(self):
        return self.weight ** 0.425 * self.height ** 0.725 * 0.007184
 
    # 変更されたパラメータでCCr, 個別eGFR, 標準化eGFRを再計算する. グラフ描画用
    def calc_kf(self, age, cre, height, sex):
        self.age = age
        self.cre = cre
        self.height = height
        self.sex = sex
        ccr = self.ccr()
        i_gfr = self.indiv_egfr()
        s_gfr = self.stand_egfr()
        return ccr, i_gfr, s_gfr
 
    # ある体重一点でのCCr, 個別eGFR, 体表面積を返す. テキスト表示用
    def calc_kf_point(self, weight_p):
        temp_w = self.weight
        self.weight = weight_p
        cp = self.ccr()
        gp = self.indiv_egfr()
        bsap = self.bsa()
        self.weight = temp_w
        return cp, gp, bsap
 
 
age = 70
cre = 1.0
height = 165
x_s, x_e = 20, 100    # 横軸の始点と終点
y_s, y_e = 10, 100    # 縦軸の始点と終点
weight = np.arange(x_s, x_e, 0.5)
wgt_point = 50        # 体重入力テキストボックスの初期値
 
erf = EvalRenalFunc(age, cre, height, weight, '男性')
y1 = erf.ccr()           # CCr
y2 = erf.indiv_egfr()    # 個別eGFR
y3 = erf.stand_egfr()    # 標準化eGFR
 
# 図の作成. figsize=横縦のサイズ(インチ)
fig = plt.figure(figsize=(6.9, 7.2), facecolor='beige', edgecolor='black', linewidth=1)
# レイアウトを調整
fig.subplots_adjust(left=0.345, bottom=0.37, top=0.9)
# 図のタイトル
fig.suptitle('eGFR・CCrの計算', fontsize=F_SIZE, y=0.95)
ax = fig.add_subplot(111)   # (行, 列, 番号)
 
graph1, = ax.plot(weight, y1, 'blue', label='CCr')
graph2, = ax.plot(weight, y2, 'red', label='個別eGFR')
graph3, = ax.plot([x_s, x_e], [y3, y3], color='green', label='標準化eGFR')
ccr_p, gfr_p,  bsa_p = erf.calc_kf_point(wgt_point)
graph4, = ax.plot(wgt_point, ccr_p, ms=5, color='blue', marker='o', alpha=0.7)
graph5, = ax.plot(wgt_point, gfr_p, ms=5, color='red', marker='o', alpha=0.7)
 
# 軸の表示範囲を設定
ax.set_xlim(x_s, x_e)
ax.set_ylim(y_s, y_e)
# 軸のラベルを設定
ax.set_xlabel('体重(kg)')
ax.set_ylabel(r'$\rmmL/min$')
# グリッドを設定
ax.grid(which='major',color='black',linestyle='--')
# 凡例を配置
ax.legend()
# 縦軸が~30までの範囲を塗りつぶし
rect_x = [x_s, x_e]
rect_y = 30
ax.fill_between(rect_x, rect_y, facecolor='red', alpha=0.2)
# 右側にも縦軸があるグラフにする
ax2 = ax.twinx()
ax2.set_ylim(y_s, y_e)
ax2.set_ylabel(r'$\rmmL/min/1.73m^2$', color='green')
 
# 体重テキストボックスの入力チェック
def textbox_valcheck(wgt_text):
    try:
        weight_p = float(wgt_text)
    except ValueError:
        weight_p = 0
    else:
        if weight_p < 0 or weight_p > 100:
            weight_p = 0
    return weight_p
 
# グラフ中の点を更新
def update_scatter(weight_p):
    cp, gp, _ = erf.calc_kf_point(weight_p)
    graph4.set_data(weight_p, cp)  # ccr
    graph5.set_data(weight_p, gp)  # egfr
 
# グラフ中の点と図左側のテキストを更新
def update_textarea(weight_p):
    if weight_p == 0:
        label = np.full(3, "-----")
    else:
        cp, gp, bsap = erf.calc_kf_point(weight_p)
        label = ["{:.3f}".format(bsap), round(cp, 1), round(gp, 1)]
    update_scatter(weight_p)
    bsa_text.set_text(label[0])
    ccr_text.set_text(label[1])
    gfr_text.set_text(label[2])
 
# ウィジェット更新時に呼ばれる
def update_graph(val):
    _age = age_slider.val
    _cre = cre_slider.val
    _hgt = hgt_slider.val
    _sex = sex_button.value_selected
    y1, y2, y3 = erf.calc_kf(_age, _cre, _hgt, _sex)
    graph1.set_ydata(y1)
    graph2.set_ydata(y2)
    graph3.set_ydata(y3)
    _weight = textbox_valcheck(weight_text.text)
    update_textarea(_weight)
    fig.canvas.draw_idle()
 
# widgetの表示位置とサイズ. axes([left, under, width, height])
axcolor = 'lightyellow'
sex_botton_pos = plt.axes([0.05, 0.75, 0.14, 0.14], facecolor=axcolor)
hgt_slider_pos = plt.axes([0.1, 0.27, 0.8, 0.025])
age_slider_pos = plt.axes([0.1, 0.22, 0.8, 0.025])
cre_slider_pos = plt.axes([0.1, 0.17, 0.8, 0.025])
wgt_text_pos = plt.axes([0.11, 0.65, 0.1, 0.05], facecolor=axcolor)
 
# widgetオブジェクトのインスタンス作成. Slider(axes, ラベル, 最小値, 最大値, 初期値, 刻み幅,  値のフォーマット)
sex_button = RadioButtons(sex_botton_pos, active=0, labels=('男性', '女性'),
                          activecolor='blue')
cre_slider = Slider(cre_slider_pos, 'Cre', 0.5, 3.0, valinit=cre, valstep=0.01)
age_slider = Slider(age_slider_pos, '年齢', 20, 100, valinit=age, valstep=1, valfmt="%1.0f")
hgt_slider = Slider(hgt_slider_pos, '身長', 140, 190, valinit=height, valstep=1, valfmt="%1.0f")
weight_text = TextBox(wgt_text_pos, label='体重: ', initial=str(wgt_point), label_pad=0.1)
 
# 各widgetのフォントサイズを変更
for c in sex_button.circles:
    c.set_radius(0.06)
cre_slider.label.set_size(F_SIZE)
age_slider.label.set_size(F_SIZE)
hgt_slider.label.set_size(F_SIZE)
weight_text.label.set_size(F_SIZE)
 
# 値が変更されたらupdate_graphが呼ばれる
hgt_slider.on_changed(update_graph)
cre_slider.on_changed(update_graph)
age_slider.on_changed(update_graph)
sex_button.on_clicked(update_graph)
weight_text.on_text_change(update_graph)
 
# fig内でのaxes座標を取得してテキストを配置(横位置, 縦位置, 文字列). 図左側
ax_pos = ax.get_position()
bsa_y_offset = (0.33, 0.37)
ccr_y_offset = (0.42, 0.46)
igfr_y_offset = (0.51, 0.55)
fig.text(ax_pos.x1 - 0.85, ax_pos.y1 - bsa_y_offset[0], r"体表面積($\rmm^2$): ", fontsize=F_SIZE)
fig.text(ax_pos.x1 - 0.85, ax_pos.y1 - ccr_y_offset[0], r"CCr($\rmmL/min$): ", fontsize=F_SIZE)
fig.text(ax_pos.x1 - 0.85, ax_pos.y1 - igfr_y_offset[0], r"個別eGFR($\rmmL/min$): ", fontsize=F_SIZE)
 
color = 'purple'
# BSA, CCr, 個別eGFRの値. こちらは更新されるのでtextオブジェクトへの参照を持っておく
bsa_text = fig.text(ax_pos.x1 - 0.85, ax_pos.y1 - bsa_y_offset[1], round(bsa_p, 3), fontsize=F_SIZE, color=color)
ccr_text = fig.text(ax_pos.x1 - 0.85, ax_pos.y1 - ccr_y_offset[1], round(ccr_p, 1), fontsize=F_SIZE, color=color)
gfr_text = fig.text(ax_pos.x1 - 0.85, ax_pos.y1 - igfr_y_offset[1], round(gfr_p, 1), fontsize=F_SIZE, color=color)
 
fig.text(ax_pos.x1 - 0.85, 0.12,
         "※ 体表面積$(Du bois$式$)(m^2)=0.007184$ × 体重$(kg)^{0.425} $× 身長$(cm)^{0.725}$",
         fontsize=FORMULA_F_SIZE)
fig.text(ax_pos.x1 - 0.85, 0.07,
         "※ $CCr(CG$式$)(mL/min)=(140$ -年齢$) $ × 体重$(kg) $ / $ (72$ × $Cre(mg/dL))$   #女性は×0.85",
         fontsize=FORMULA_F_SIZE)
fig.text(ax_pos.x1 - 0.85, 0.02,
         "※ 個別$eGFR(mL/min)=$標準化$eGFR(mL/min/1.73m^2) $ × 体表面積$(m^2) $ / $ 1.73$",
         fontsize=FORMULA_F_SIZE)
 
plt.show()