2024年12月31日火曜日

変則2交代勤務表による各社ソルバの性能評価まとめ

 前回・前前回のまとめになります。




このスケジュールナースのパワーをどこに使うかは、管理者によります。


ハード制約とソフト制約の重み設定で如何様にも変わるからです。
人材不足なブラックな職場は、さらに人を減らすことが出来てしまい、さらにブラックにすることも出来てしまうかもしれません。

あるいは、スタッフの負荷平準化を無視した、完全にスタッフの希望予定を満たすためだけの道具になってしまうかもしれません。

それは完全に管理者の意思によるもので、スケジュールナースが関知することではありません。

が、出来るならば、管理者のみならず、スタッフ全体の幸せを目指して、このソルバを開発している、ということは頭の片隅に置いていただければ、と思います。


時に、理不尽なシフトは、何十年経っても覚えているものですから。


2024年12月30日月曜日

Q 余剰分をなるべく均等に他の勤務日に割り振りする 条件設定は可能でしょうか?

 Ans.はい、可能です。

 現状、下のようになっているとします。











上の例では、たまたま物理限界の値と目的関数値が一致しています。これは、ブランク予定のときだけ計算できる特殊な例です。単純なコマ計算と同じくフルに限界まで人をアサイン出来たときに、コマ数による物理限界値とスケジュールナース目的関数値が一致します。

上の計測で、日勤者数は、5プラス1マイナス0の範囲に、休日については、全休日同じ値3名にアサインされていることに着目してください。

人間技ではとても出来ないことは、実感していただけるのではないでしょうか?
このとき、スケジュールナースは、次の結果になっておりました。

■夜勤と長日勤の日数が同数
■土日祝日の休みの回数の平準化 1 プラス1マイナス0
■夜勤回数の平準化             3 プラス1マイナス0
■連続した休み回数の平準化       2連休が4 プラス2マイナス0
■リーダ回数の平準化                0 プラス3マイナス0
■週休8回、祝休2回
 
スケジュールナースの能力の高さを示しているとも言えます。

しかし、今回は、たまたま上手くいったに過ぎません。予定が少しでも入ると、物理限界の値は、予測できません。言い換えると保証は出来ません。

一般に、何人スタッフが居ればよいか?というご質問に答えることが出来ないのは、予定がどういう風に入るか分からないからです。予定が固定されていれば、それについてLB(数理ソルバでのLowerBound)を求めれば、そのケースについては、何人要ります、と言えますが、一般的な予定と言われても、とても保証できるものではありません。その職場・プロジェクトで経験を積み重ねていくしか、精度の良い答えは出ないと思います。このあたりがナーススケジューリング問題の特徴でもあり難しさだと思います。

2024年12月29日日曜日

変則2交代、実務インスタンスによる性能比較


基本的に同じインスタンス上での比較になります。性能を比較するのに、各々別のインスタンスを使っていたのでは比較になりません。よく何秒で解けた、というのを目にしますが、他と同じ土俵で比較して、というのは見たことがありません。易しい問題なのか?難しいなのかは、他者には分からないです。易しい問題で数秒と言っても何の優位性もありません。

NSPではベンチマーク問題が多数ネットに転がっていますので、優位性があるのであれば、そちらで主張すべきと思います。(基準は、Gurobi/Cplexです。)

スケジュールナースが解いた問題をMPSファイルに変換することで、この目的を果たすことが出来ます。まさに自職場で使う状態で、同じMPSファイルでの比較ですので正確な性能比較が可能です。

ちなみに、最近の新しいプロジェクトは、ほぼ、この変則2交代です。

今回のまとめは、以下のようになりました。



以下、一つづつ見ていきます。最初は、スケジュールナースです。IntelCore5 8500で普通のPCです。


Gurobi結果です。NEOSサーバ上のデータです。Verboseデータを見ていると、内部の動きが分かります。解がいきなり183secで出てきますが、それまでは一切出てきません。しかしながら、LBは、すぐに0が出ているので、LP+CuttingPlaneという古典的数理手法であることは疑う余地がありません。


HIGHSは、スケジュールナースAlgorithm2として組み込まれています。Feasibleな解が2時間経ってようやく出てきます。内部のヒューリスティクスソルバが貧弱なのは、明らかなので、後述のLocal-MIPを組み込めば、劇的に改善できるでしょう。(誰かやりませんか?)

最後は、最近注目のLocal-MIPで、Ubuntus22、PCは、Ryzen 5950X上でのデータです。

考察
大体、いつも通りの結果となっています。エラー0に出来ているのは、いつも通りスケジュールナースとGurobiだけです。(Gurobiに対して9倍速と言っていますが、PCが違うのでちょっとアンフェアです。)

驚くべきは、やはりLocal-MIPだと思います。Feasibleな解が出てくる速度は、スケジュールナースを上回り非常に高速です。スケジュールナースを上回るソルバを初めてみました。エラー0には出来ないものの2時間後に目的関数値20に到達しており、メタヒューリスティクスソルバとしては、非常に優秀だと思います。

多くの他の勤務表ソフトの性能は、最先端性能のLocal-MIPを上回る結果は残せないでしょう。また、人間が行ったらHighsにも及ばない結果となるでしょう。

道具はあります。後はどのようにしてそれを使いこなすか? だけです。


参考までに生データです。














2024年12月28日土曜日

Q. 2025 1月当直拘束表は、1月6日スタートですが、Excel出力が1日からにズレる

 Ans. 申し訳ございません。Python記述にバグがありました。

<変更前>

def get_sheet_name():
    s=daydef[制約開始日]

    year_str=s[0:4]
    month=int(s[5:7])
    sheet_name=year_str+'年'+str(month)+'月'
    print("シート名=",sheet_name)
    return sheet_name

def get_year():
    s=daydef[制約開始日]
    year_str=s[0:4]
    return int(year_str)
def get_year_str():#DEC202024 追加
    s=daydef[制約開始日]
    year_str=s[0:4]
    return year_str

def get_month():
    s=daydef[制約開始日]
    month=int(s[5:7])
    return month

def get_day():
    s=daydef[制約開始日]
    day=int(s[8:10])
    return day


def find_address(moncal,D):
    row=0
    for r in moncal:
        if D not in r:
            row+=1
            continue
        return (row,r.index(D))
    #次月最初の日:最終行の最終列+1を出す
    tuple=find_address(moncal,D-14)
    row=tuple[0]+2
    col=tuple[1]
    
    print("find_address",row,col)
    return (row,col)
    raise IndexError

def init_row_columns():
    global start_row
    global start_column
    global row_interval
    global column_interval

    start_row=6
    start_column=2
    row_interval=6
    column_interval=2

def findcell(ws,moncal,D):
    tuple=find_address(moncal,D)
    init_row_columns()
    row=start_row+row_interval*tuple[0]
    column=start_column+column_interval*tuple[1]
    c=namedtuple("Row","Column")
    c.Row=row
    c.Column=column
    return c

def clear_cells(ws):
    init_row_columns()
    for r in range(6):
        for d in range(7):
            row=start_row+r*row_interval
            column=start_column+d*column_interval
            print(row,column)
            ws.Cells(row  ,column+1).Value=""
            ws.Cells(row  ,column).Value=""
            ws.Cells(row+1,column+1).Value=""
            ws.Cells(row+1,column).Value=""
            ws.Cells(row+2,column+1).Value=""
            ws.Cells(row+2,column).Value=""
            ws.Cells(row+3,column).Value=""
            ws.Cells(row+4,column).Value=""





def post_main():
    
    print('\n\n*********ポスト処理を実行中です。*************\n')
    import win32com.client#pywin32をインポート

#すでにExcelが起動されている場合はそのタスクが使われる
#エラー終了するとタスクは残ります
    try :
        xl = win32com.client.Dispatch("Excel.Application")
    except:
        print("can not invoke excel")
        exit()
    #動いている様子を見てみる
    xl.Visible = False
    #file=get_open_file_name("Open Excel File")
    #print(file)
    os.chdir(project_file_path)
    filename=get_year_str()+"当直拘束表.xlsx";##DEC202024 BUG FIX "2024当直拘束表.xlsx"
   
    file=os.path.join(project_file_path,filename)
    file1=file.replace("/","\\") #なぜか逆スラッシュでないと動かない
    print(file1)
    wb = xl.Workbooks.Open(file1)
    # Excelシートオブジェクト
    ws = wb.Worksheets(get_sheet_name())

    # 指定したシートを選択
    # Select()の使用前にシートのActivate()が必要
    ws.Activate()

    calendar.setfirstweekday(6)#日曜日を最初に
    mon_cal=calendar.monthcalendar(get_year(),get_month())#行列でリスト

    print(findcell(ws,mon_cal,1))

    clear_cells(ws)


    for day in 今月区間:
        D=day-制約開始日+1
        print(D)
        c=findcell(ws,mon_cal,D)
        n=findcell(ws,mon_cal,D+1)
        #print(c.Row,c.Column)
        day_solution_analysis(ws,c,n,day)

    wb.Close(True)# Trueで保存。False=Defaultでブックを保存せずにクローズ
    # Excel終了
    xl.Quit()
    print('\n\n*********ポスト処理を実行終了しました。*************\n')

 
<変更後記述>
def get_sheet_name():
    s=daydef[制約開始日]

    year_str=s[0:4]
    month=int(s[5:7])
    sheet_name=year_str+'年'+str(month)+'月'
    print("シート名=",sheet_name)
    return sheet_name

def get_year():
    s=daydef[制約開始日]
    year_str=s[0:4]
    return int(year_str)
def get_year_str():#DEC202024 追加
    s=daydef[制約開始日]
    year_str=s[0:4]
    return year_str

def get_month():
    s=daydef[制約開始日]
    month=int(s[5:7])
    return month

def get_day():
    s=daydef[制約開始日]
    day=int(s[8:10])
    return day


def find_address(moncal,D):
    row=0
    for r in moncal:
        if D not in r:
            row+=1
            continue
        return (row,r.index(D))
    #次月最初の日:最終行の最終列+1を出す
    tuple=find_address(moncal,D-14)
    row=tuple[0]+2
    col=tuple[1]
    
    print("find_address",row,col)
    return (row,col)
    raise IndexError

def init_row_columns(ws):#DEC272024
    global start_row
    global start_column
    global row_interval
    global column_interval
    test_range= ws.Range("A1:C9")#DEC272024    
    ### example if you want to find out the column of search result
    #ResultColumn= test_range.Find("Series ID").Column 
    #print(str(ResultColumn))
    sun=test_range.Find("日")#DEC
 
    print("日アドレス=",sun.Row,sun.Column)
    start_row=sun.Row+2# 6
    start_column=sun.Column#2
    row_interval=6
    column_interval=2

def findcell(ws,moncal,D):
    tuple=find_address(moncal,D)
    init_row_columns(ws)
    row=start_row+row_interval*tuple[0]
    column=start_column+column_interval*tuple[1]
    c=namedtuple("Row","Column")
    c.Row=row
    c.Column=column
    return c

def clear_cells(ws):
    init_row_columns(ws)
    for r in range(6):
        for d in range(7):
            row=start_row+r*row_interval
            column=start_column+d*column_interval
            print(row,column)
            ws.Cells(row  ,column+1).Value=""
            ws.Cells(row  ,column).Value=""
            ws.Cells(row+1,column+1).Value=""
            ws.Cells(row+1,column).Value=""
            ws.Cells(row+2,column+1).Value=""
            ws.Cells(row+2,column).Value=""
            ws.Cells(row+3,column).Value=""
            ws.Cells(row+4,column).Value=""





def post_main():
    
    print('\n\n*********ポスト処理を実行中です。*************\n')
    import win32com.client#pywin32をインポート

#すでにExcelが起動されている場合はそのタスクが使われる
#エラー終了するとタスクは残ります
    try :
        xl = win32com.client.Dispatch("Excel.Application")
    except:
        print("can not invoke excel")
        exit()
    #動いている様子を見てみる
    xl.Visible = False
    #file=get_open_file_name("Open Excel File")
    #print(file)
    os.chdir(project_file_path)
    filename=get_year_str()+"当直拘束表.xlsx";##DEC202024 BUG FIX "2024当直拘束表.xlsx"
   
    file=os.path.join(project_file_path,filename)
    file1=file.replace("/","\\") #なぜか逆スラッシュでないと動かない
    print(file1)
    wb = xl.Workbooks.Open(file1)
    # Excelシートオブジェクト
    ws = wb.Worksheets(get_sheet_name())

    # 指定したシートを選択
    # Select()の使用前にシートのActivate()が必要
    ws.Activate()

    calendar.setfirstweekday(6)#日曜日を最初に
    mon_cal=calendar.monthcalendar(get_year(),get_month())#行列でリスト

    print(findcell(ws,mon_cal,1))

    clear_cells(ws)


    for day in 今月区間:
        #D=day-制約開始日+1
        D=day-制約開始日+get_day()#DEC272024 BUG FIX
        #print(D)
        c=findcell(ws,mon_cal,D)
        n=findcell(ws,mon_cal,D+1)
        print("Day=",D,c.Row,c.Column)
        day_solution_analysis(ws,c,n,day)

    wb.Close(True)# Trueで保存。False=Defaultでブックを保存せずにクローズ
    # Excel終了
    xl.Quit()
    print('\n\n*********ポスト処理を実行終了しました。*************\n')

 
mainのDの記述を暦日に修正しました。また、init_row_columnsで、日曜日の「日」を検索・検出してそれを基準に
Excelデータを記述するように変更しました。




2024年12月27日金曜日

Q.新たに当院は、医師の労働基準法上の宿日直許可申請を 行いました。それに伴い、スケジュールナースの制約を 追加・修正する必要が生じました

 Q.具体的には、【仮セット版】宿日直パンフレットにも記載されていますが、

1 宿直勤務は週1回まで

2 日直勤務は月1回まで


の制約を、満たす必要があります。

2については、スタッフ定義の宿日直回数属性を修正すれば良いような気がしますが、1については制約の追加が必要になるのではないかと思います。一週間の定義は、日曜日をスタートとする一週間でお願いします。

Ans.

一週間の定義が難しいと思います。日曜日をスタートとする一週間の実装が過去にないか?を検索します。

本ブログで、「週あたり」で検索します。



すると次のようなブログがヒットしました。

飛び先に行って、プロジェクトファイルをダウンロードします。

この実装の曜日集合の部分を移植します。
(すみません。単純なコピペでは上手くいきません。一文づつ設定ボタンを押す必要があります。)


必要となるシフト集合を定義します。

先月部は、6日間必要となります。
表示開始日を6日前に設定します。
移植した曜日集合です。

行制約グループを新たに定義し、定義したシフト集合、曜日集合を用いて制約します。

解です。上手く制約を満足していることが分かります。
お疲れさまでした。



2024年12月25日水曜日

Q.当院では看護師はじめ、スタッフの >シフト作成システムの導入を検討しております。

 Q.貴社が提供されています「スケジュールナース」の導入も検討している次第です。ホームページ等で内容を確認致しましたが、一度システムに関して詳細をお聞きしたいと考えております。Zoom等でのご説明でも問題ありません。ご対応可能でしょうか。

Ans.

ご照会ありがとうございます。ご希望の仕様を簡易実装した上で、デモしながら説明するのが、分かり易いと思います。

簡単でよいので仕様をお送りいただければ幸いです。ZOOMのご希望日時を、ご連絡頂けますでしょうか?こちらは、25日以降、来月15日以前は、大体空いています。

ご検討の程よろしくお願いいたします。

Q.プロジェクトファイルの設定で変更をお願いしたい

 Q.下記の変更をお願い出来ませんか?

①列や行に看護師の人数の変更。

②外来看護師の人数をいれない


Ans.

ZOOM上で修正を承りたいと思います。

ご都合の良い日時をお知らせください。毎月15-25日は、サポートで混むので、出来れば、その区間を外して頂けると助かります。

2024年12月24日火曜日

Q 来月の勤務表の解を求めるとエラーになってしまいます。何が原因か診断して頂けないでしょうか?

 Ans.

赤字で表示されているので解がありません。


上の表示から、列制約レベル6のソフト制約が、範囲外にあることを示しています。

制約名は、「平日休みの数が7名を超えない金」で曜日が2025年1月3日が満足していないことを示しています。

平日休みの数が7名を超えない金

をダブルクリックすると当該制約箇所が表示されます。


当該行の曜日タイプ部でマウスミドルボタンを押すと曜日集合が表示されます。

制約を見ると、

1月3日の休みの数が最大で8名と制約しています。

ソフト制約レベル6です。

列制約レベル6の許容エラーは、現在3になっています。


これを、大きくしてみて(例えば3→6)、解が出るようにしてみます。


解は出ました。12人が休みになっていることが分かります。


制約最大8人に対して、実際は、12人ですから、許容エラーは、

12-8=4

4以上である必要があります。許容エラー3の場合、変更前ソフトエラー範囲は、

8プラス3ですから、11人までがソフトエラー範囲つまりハード制約で、12人以上はハード制約を超えるのでエラーとなります。ですから12人の場合は「解がない」エラーとなってしまいます。


これで、エラーの理由が判明しました。1月3日に大量の休みがエントリーされているということです。

ところで、制約の曜日集合名を見ると、

「祝ではない金今月」

になっています。1月3日は、確かに金曜日ですが、祝ではない..?

確かに、祝ではないですが、年始ではあります。そこで、曜日集合の定義を見てみます。


「祝でない」は、「特祝」の補集合(でない)であることが分かります。「特祝」の定義は、以下のように、年末、年始を含んでいます。

年末は、


確かに、定義されていますが、年始は定義されておりません。

従い、年始を設定すれば、このエラーは解消します。

以上のように、順を追って解析すれば、エラーは解消できます。

制約漏れではなく、単に

年始の設定忘れだった、

ということです。解がないときに、つい慌ててしまいますが、エラーメッセージに従って落ち着いて見ていけば、原因に辿り着けるのでやってみてください。

2024年12月23日月曜日

Q 平日、休日を問わず、拘束の翌日は日直、宿直、宿日直を禁止にしたい

Q 具体的には、

13日 xx先生 拘束 14日宿直

15日 xx先生 拘束 16日宿直

を解消できればと思います。


Ans.

以下の制約を追加しました。


シフト集合を追加しました。


行制約レベル3で制約しました。重みは1にしていますが、必要でしたら重みを調整してくdさい。


行制約レベル3のチェックをオフにすると、ご指摘のように拘束⇒宿直が発生していますが、

オンにすると、拘束⇒宿直は、禁止されていることが分かります。


2024年12月22日日曜日

最新SAT SOLVER性能比較

 ベンチマーク問題INRC2 n110w4_0_1-4-2-8を解く途中に出現するSAT問題を用いて性能比較しました。

ヒントの数が多ければ多いほど解くのが容易となる傾向にあります。ヒントのサイズ、Fixed Lits Sizeをパラメータとしたとき、何秒で解けるかを評価したものです。

ソルバは、CADICAL最新版、KISSAT去年、KISSAT今年、DPS去年、PRS今年のSAT competition提出版になります。DPSとPRSは、32threadsのパラレル版で、いずれもその時点のKISSAT最新版がベースになっています。



青が1位、黄色が2位でマークしています。一見して分かるのは、

■パラレル版が10倍程度高速で安定傾向、PRSが最速

■SAT Solver Single thread版でも最新になるほど性能向上が見られる

さらに考察として、

PRSに搭載されているKISSATは、去年以前のKISSATがベースですから、今年のKISSATにReplaceすれば、さらに性能向上が期待できます。

しかし、ヒントの数が少ないと、PRSをもってしても現実時間内に解くことは出来ません。(1割少なくなっただけで、もはや解くことが出来ません。ヒントなしでは論外。)

つまり、SATソルバだけでは解くことは出来ないことを示しています。一般に、解空間が広い場合(最適化の結果、残る解の個数が多い場合)SATソルバのみで解くことが出来ますが、INRC2問題のように解空間が非常に狭い最適化問題の場合は、複合技が必須となります。今回の問題で言えば、SATソルバを選ぶことに注力するより、ヒントの数を増やした方が遥かに効率的に解くことが出来ます。

今回、パラレル版で意外な性能向上が見られたので、採用しようかな、と思います。今回求めているのは、UNSAT証明能力で、SAT解探索能力と比例傾向にあるからです。





2024年12月21日土曜日

ヘルスケアのOR第20回研究会(学生発表会)

 学生さんの発表会です。ZOOMで視聴することにしました。

検査技師のシフト勤務表のモデリングについて興味深く拝聴しました。スケジュールナースでも、実装例があります。

放射線技師の勤務表

(確か、同じ神戸大学にも数年前に納入していたと思います。)


OR学会の会員ではありませんが、将来、こちらでもスケジュールナースの最近の成果についても、報告できれば、と考えています。


<特殊から一般へ>

ナーススケジューリング問題のオープンインスタンスを全てCloseするという空前絶後の壮大な計画は、難航していますが、INRC2については、形が見えつつあります。今までに得られた知見を総合すると、ナーススケジューリング問題を解く一般系というのが、少し見えてきたような気がします。特殊で困難な問題群ではありますが、それを解く努力を重ねることで、それで得た知見を基に、実務上の比較的容易/困難なインスタンス群についても、最適解をリーズナブルな時間内に提示するという、

塾講師配置問題の最適化 #数理最適化 - Qiitaの「ナーススケジューリング問題は、終わっていない」

に対する回答を示せるのではないか?と考え始めています。当初は、ただ世界記録更新だけを目的にしていたのですが、特殊なインスタンスから一般インスタンスへの応用が可能ではないか?と気づきました。言い換えると特殊解法から、一般解法への統一化です。どのような問題も、それに適した特殊なアルゴリズムを適用するのではなく、一般化した一つのアルゴリズムで解くのがスマートであることは言うまでもありません。どのような問題にせよ、それに入力すれば、最適解がリーズナブル時間内で出力される、それがあればアルゴリズムを選定する必要はありません。

現状のナーススケジューリング問題は、二つの課題があります。

<最高性能のソルバ開発>

実務のナーススケジューリング問題は、Gurobiを持ってしても厳密解をリーズナブルな時間内に解くことは難しい場合が多いというのが現状です。リーズナブルな時間内に厳密解を提示できることが、理想なことは言うまでもありません。現在でも、スケジュールナースは、世界最高性能を持つと自負しておりますが、上記課題を達成する最初の製品となるべく日夜邁進中です。

<モデリング、誰が行うのか>

もう一つの現実課題は、モデリングです。勤務表は、作って終わりということはなく、月々にアップデートしていく必要があります。時に、制約追加ということがあります。つまりメンテナンスを誰が行うか?ということが常に付きまといます。モデリングのAI化、ハード制約やソフト制約等基本となる教育も含めて、両面から考えていく必要があります。

以上二つの課題にたいして、取り組む所存です。



2024年12月20日金曜日

Q.2025当直拘束表の出力が出来ません

 Ans.申し訳ございません。

貴病院用医師当直拘束表のPython記述が2024年の固定となっておりました。Python記述のバグです。申し訳ございません。

以下のようにPython記述を修正しました。

<修正前>


<修正後>

Pythonソース修正



貴病院用ユーザマニュアル追加