From 3a02e691b3dfdfe3c4c27a27a1b31eb35675f74f Mon Sep 17 00:00:00 2001 From: nvkevlu <55759229+nvkevlu@users.noreply.github.com> Date: Wed, 5 Feb 2025 07:36:34 -0800 Subject: [PATCH] Add content for survival analysis for DLI (#3204) Add content for survival analysis for DLI. ### Description Add content for KM survival analysis for DLI. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Quick tests passed locally by running `./runtest.sh`. - [ ] In-line docstrings updated. - [ ] Documentation updated. --- .../code/figs/km_curve_baseline.png | Bin 0 -> 28709 bytes .../code/figs/km_curve_fl.png | Bin 0 -> 16831 bytes .../code/figs/km_curve_fl_he.png | Bin 0 -> 17041 bytes .../code/km_job.py | 115 ++++++++ .../code/requirements.txt | 3 + .../code/src/kaplan_meier_train.py | 152 ++++++++++ .../code/src/kaplan_meier_train_he.py | 195 +++++++++++++ .../code/src/kaplan_meier_wf.py | 83 ++++++ .../code/src/kaplan_meier_wf_he.py | 131 +++++++++ .../code/utils/baseline_kaplan_meier.py | 82 ++++++ .../code/utils/prepare_data.py | 89 ++++++ .../code/utils/prepare_he_context.py | 62 ++++ .../convert_survival_analysis_to_fl.ipynb | 270 +++++++++++++++++- 13 files changed, 1177 insertions(+), 5 deletions(-) create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/figs/km_curve_baseline.png create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/figs/km_curve_fl.png create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/figs/km_curve_fl_he.png create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/km_job.py create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/requirements.txt create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_train.py create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_train_he.py create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_wf.py create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_wf_he.py create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/baseline_kaplan_meier.py create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/prepare_data.py create mode 100644 examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/prepare_he_context.py diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/figs/km_curve_baseline.png b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/figs/km_curve_baseline.png new file mode 100644 index 0000000000000000000000000000000000000000..9ff1fcdb4c52e01d8a60c108b00246354aa72dfa GIT binary patch literal 28709 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 P@JIT#yhM(ep4a~YH!?K~ literal 0 HcmV?d00001 diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/figs/km_curve_fl_he.png b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/figs/km_curve_fl_he.png new file mode 100644 index 0000000000000000000000000000000000000000..b1610c41832c943b08cc9bdaaced332632df98fc GIT binary patch literal 17041 zcmbWe2|QM9*EYUgBx8mql`(|MJVvI7l%bN$bID9xC^Ho*Lnve}A~PZL5QQ?2mwCwS zLdiV;*RK11p6C6(=l{I#`~801H}w!AtvZ=)mBL7YaMM?U5Byv;iuaa;G<%d9M>+(h#^j7Fj*yN>R& zLXz%d=K9aBz0!Hd88jOGOu_ui0a3oa^Q0+463POC)9VMD#ln@>v|E}UN@btJy&7~h zn~IN(C@+s(w-KbMrG`13uVus^f}hN#B;N4jm>fccAh~oHW(4v6LX8l>9exN2f*A1= z$-w8&Sr80@Xi*Trv(+a5gGVCxT^98wr>E;nPZ<|^?R)lFi`Wbn^Y`czP;ByG zoEKide$78a>21%9VEA6;ch`yWfsf?5+(gWDk@-%vT7ePkZ_DND%#nn`%;< zcN%cZo9;Rlu7#=+3qtRblQ~62HJ7JbabEk|BZ>H5%gatnUykGZ?j>~h=9#{Vj!qxh zTPq4nraUDwQ@-4yuBsZW#}i3LMpodw(C^&pwY^ZZT0_pek$SyVa$RqKTv%ARbSm0P z=)q5;5`ytIK?Gu;g1))2-XLBc_^OCq+j`8|<7R&^o~iOa1QS z$J^cA-Lf`)eSKlc2N@s8I8HU)xN#$Nv3yw-hP7DeFN_wlfOYDh92s32bZnjNxW=m? zE9-Oh>eZEm{T;>Sspd{t%#p?2sn&#?pi`oj!{rZ4x8^bm?M9?_r$npTv-QhXzMaeA z&9mM0d@P_;8OuI9RzhKRZ z?ccCjtj=|p&ZTF(jE~Qbu`N{!dl?gRPRY{Jvg5*ZY*MS#wlcbwFIePO<}z|DXFJ}B z+mCwR&N1Mgoq0blTDe47vGc1@C)dbFRdV%~u%9y(1W}I(kTbc)wZw8a8+dE^0=KqYSLS}H zxRwOzs?K#nBZENHxP`5HW+uYbY;lz~10vmE7j=OrWRq?j@|PR055d9n>`Dg@(YGxQ zl@;Fk8kVR+Hl5&A2}ak|+dE_DHlJ7j5)Q#~tF-itFgZE-E7**Mfg=2`M&9+J1UPDE z&z`l!6;(TV7Zw&;T3EEBdvh;=$dsS(y4SWX_4BVcV2KAa*8y7&z%=GH#bZ7 z_qGQL{ZHOFK+S|A#@)f<`z)e9?ms^r3`<^ak>H(J77!3%Q%Z3E^_0zGf(0>@aMkeI?;GyR>@+0rsq>0PW!{KXRe?MGBnbicO1Y@U=e%Ius*FahLQ zF)WM^MhPY-Ckx#MXXiJi?@1-7UAS<;b>@x#C8=SN7fc+RHW6%LN5sWDEg)KR*tJH1Jj(a@|>0!xuLE7Iv&i|IRxymVSMRI7*FZU>4z8N6KMqv zax~Wm9n~UtiM(aNZUe|qoH&6ZgjsX+JHghA>La_0E>5r~X>Z?NYQ4A|2(SBoWM@h= zEIBNUR^_$85^=rEi;X&VQ z?(^rn`U|aA^AmQJ;rOBW+#>PgqQ2Mes_R@@Di*-p3PAVl`SZ6-O_PRQ@!Y7>>aX&u z`jDS5>@;^vC06XlwTScWd%N2j*RLmX#6LLAIiqmN#>U1UPsl}0`8iF?PL3F<-FQpU zgwLrOo^={1vJ-vw>{(z_lQJti`xiQK?0eLUjt7YjlawyDdhO-khi^W~6WPeg$;lW0 z;5(^lOY8;NyGA-gE+ZZa#>Q_#qoXZXevFKW?XGkNow6P&M`Vuqt1Q@@v`; zYHE_JFYni#xJSpu#iiisS$Tgt&gCQ?mm|t{VtZ|2*${<2<<|mVW=5*2Sh(J^DSLQS zS#>LshI`wGR@p>+iCzX`+c@25(Ei;++WNIrYDENiGer?edE!@~! zXr&qQt@p~}AimrrIO$L#WZlb$1`^+QAzlg4ga?9xf=&uszJv*MX5rTt22#1yQ)V&@ zchjAJ3};zomncZ@j8Vz;bVB^%eP>R;S|mmV&Utc*N=(m~u;}oEr1*zZCl>nh>qpjx zet2qvL+megvO3~QiXha6-z%xH+S=M>#Fkd6A(ayyTobNGHf%p z%-hYf<}r#Ml$HY@ZDP(SM^Ps+LTnfnGm9~jlY4|NF`Tk%;yP1z*B6J<-@ku>#cnyT z&G+r$kDu2{43XZ`0%&^#ftlox+m8y@m3Bp^g+g52+ikFclzXMfM%^hp^QI{NlY(Y} zY-z>CTBdb@?Y|})fBUJQ{<2EqOP>#~HVL+x!vxTM_S|eVoO7w#j)M&`>F|PK@&$)Z zRy(#zYR0`fL*ewTzGON+L%+=Va;_$P__P)aF%X!_`F|7IrVJ372>a`nTiUy^DXEsJ za47}TtwYeuCXO)*tXS0!8qP_NWP4W<8!dD`h8jLN{fujr&QW~+stY^US7Xia0oIV5 zaau7Lf2^9sDDa&a{Z$ny@$b0rt6z#m!6L}$2>xebHDBEzV?uDjgGt+^Z+!0w(60&+ z_vUwQPAc0SNq9M=HXRpzs;mXR=$a7`AMwp_`jdie8T#Ax-9h4m--A5;!I0kZVdx0K zy1jPx#B7EfWoExBedx2%`Dyepv+MxG^S%fjD}2n8DwoaXZ~akg9r7G4QuS&W^nV-v zpK(m?OC>H+R1~9yBto8q!zZfAYCC<>LHy;EgJnmq^*go;CPVB9^1_J3`%HLzEL&gl znIc8T1RDGz%#=tT5qkadjz5K*<`afhFwLGx$ZsoMy1^^yM_%U>> zuwfQFsXFCS!a_wG1G*PqdVytzet~^RX1gF{q4n{il$=U4qgNU3Uc=WhCdhSsNxb>H zT$^Hsj*SPSOKD6-_n*@l{pvI0dxi`$wTa?FU_%s`#IJhGGqy?{N`1T7;k1&x1quYY zaz&XSM#eHs&eZSDfkF?xq4%}i!@XG4j)W8tm1?v6*^m*Y)GW6{oL?7zhF}1Qx+vrl)kMMAAUGMDo{0pq2}O6&r#{eEN<}Vu#g}+rTi;jSxX- z55&m89RAz9&-5EoY}R}dlDl*4?3dBFvL#MJqQVm*_>&NT-Bec>hM-H|ZJ|)yVdBb5 zS8^0cm5(D#4<~4j9zSmN<=Ii!-4aBQc*leHpEb9N-@fk0c;!ootQ%G09(Z|JeAf%@D? zL(dX4+xm3iI2!%x2cJYU*H>v6BS**lZ;g$@4}ab9iW#!SL42xl`}VP47w*M5MZgEU zK0n4oqYUS$iuJi> z(UGphY8T(!i+`v934yMEt9B_i4Z^5h!vxPQiz9AeSk$^HCkdKbS|V!4u4`y$TzAvZ zc=hYouS-(*`aax^7PgA;+^A)`8it0c!XhGb`+Gn3v7IWSUv2FY?(6@C`3-59y9_;F zH;EDWZZdLm4IQ01JIGT3(z_p%9=;@%3bC+$gQFOI7+WphX?Q*oWm&`g=V?6%7#Z^~x=RB5MfKAoKgS&U@8ZeR%D{Mc^7|$n#3!yg1l$D|$UGQ@fqnX4(l;zKVot?<`2Atn685h& zwZnkS4=y2rf``gff|?m$dE8pCyXJU7*g{K$1!x~EIRRe_o5aG&sMlw5T3@jJg(o{d z?!j}})X4bt4}uS7&7R|qC&i;9eLjQoOwWyp_+)drzzbrJ!}EFQZ!1R#_=1Zi6>(c7 zvChEuC|Q#Ma!V>Afvn_==OfvI{w$O4B_n2$Cooh?F6rk9J=HRWi8U(A0bPwBOq4Ln z3>ScF3xtSYx-cb@Qt)q|lNqetjs_X`Zm!D)KS3>}OaQ?4-=pdurmKrU2zCz^#JFg6 zro=xcL<(n->IY+zC?G;CsY5A3k?Jb8A^UnGb!H*KDvnN@dj3Nx@cTEZ^oV~A6Lw&S z0GivtmVa$fC@Cr~qj#)(NxPisv%pn12|e`i8)VFh9%S6d!x~*7lIfgl4AB3u{QO;9 z7Ha@p%IXF5{`c;%mxwlXFrHV$r5Ig2$&AC`XfmH8bg9QKYAx=2%G-KnMd`?pzzPl3 z9I>ZYa_$#CUF21^R-v&f%jyeb$HA5a_gagIO=9lk?`(;D;Cl;`)37(fsb{AVwT|GBIGCX~n}Mt9mkDXi93{X_UX zV4#HjWEo0fat;q))D% z$Xh%H-dCNdT1+6%Tu5DIkc-Bj)pgl?5AQ>RS8siMVe; zWljj8&ZQh{SXlbHu(YX20G5lPK@JBt*PWq)^}3+0N|2+SuK)zb zRVAhNjivEI$LSW;oSd|@_L?W;!ba5y(06g}zrOZ=xO?qy=O~KVXkZO_58*GqC#w^E z2+$RrqXlIAkda!rc!2L}k=}QooS2vaCJvV<`)K7WM*=707h7>Ku>eBIgizEys;T$p z;qsJHSWlL2D(YMyx5I_8U+dSe^4xVr)8(AXt#n{T*_2?4?eh+Uq?zP&0VjRnm^}K2 zk1T|Y@4xy;G;f6PKk*6q@i|_v&F3i4>U(ZZmM+zunzVm>ND!Q*ZPCEKqrSfW1L**z zKdA0IharBe04J!&C|%eaNXTEV72lehDJ@%@n>FscV_K6B&j#d%u@EPevW=J-laEtHD6$5WES0_ue4AZM(=D1hs1~`h0MERY@Z|{1^?g*IAQtK^(>tWBHKX=;SUB^>-Z9E$GSl35+GD~agl^BVvoC?_bbuHC?57TeGlvIu`blU1B?8 zQdczK0Sa8ar!E0#8Hv>Lo!{52{4X9ev#c|XmD`|V$G8yC%}dubUy)F-)A!P7=&_tB z8+ZM%0^`LrDphzSGkB3unh^ljKGgvx)krj@&o#x;0gl^QV0C%Uns3puK3(oRKyKyeUc*% zoJbpt5RBfJm`dVJ%6zlTmrB%&KWn~Vpx1)}k@00l-o{m%j_Ld}+mpfkb&c)55Cov4 zen`NJ{&?=e(ogWkpQYzem#;;^>V0f<&7I$M8uj=$H~=%xu_8Lv;H`H@{_^*(M=`oC z2l83z5bq5IMEN?zAmGYy!|7XzC@MR+xl5AS+x+Vte=uev)oX~+u@_a6*^|^lK)tiIy{M zZ%Q}5KVJn#NHW&Wher&6tt1u_Fo4PSDyx{TdE;*Yv+gX`1V15P?|yRJ;5~3xR$vV} zKx`!?fT1b?JJI!wsqHq1UnP1ze8~%fXVn2K_*}& ze0y*9F3R2kVOBg$Trsg_s9)Vu&_gmG43;5WED1?FHu=k`U>-rK{Nn;Yg&YuaGv2#^3d4>Iqnt?nFOPpPRW z(-oiuXX0Fjmx=()ETF1Tx;YiI{p&Vg=*PQvIVmU+8TVOrWU$E2uy~6g+&u2I^MwU# zVgiI&_Hr^c*6IQcMt;XapS&U12u`e~7P;*HQixaAY-&PwSJy2lOU(rewzLsaU8HU) zk|BEB&oUU!02a(#`PhcYzB^q5jjNAw>m)xt%yBi$z;p91w^ptUpls<{zg6w(ZIb+I zh8oE}8b~F+tM_6?p^BDNXoa&x>6wq$cuM3`Cw>lGO5fvVK38;q1xRVSV`3ck; zwnzJ3p_Mc~Gaoxz?PFg-2{yLHX-!}cG$2ai3=~TLYG6Gn<^BNn20rsR@{wy)*S6$x zXQ~?RIdA2RA>jtpgWeYu2;Jd><04}XQ~n8ze>dccuj1menqx$*fZO}l($WK0R-5fS z1@-IzP`#zd8cB?;zq z-l-}vDKMHpe$ohI8=|JDQ!d`|eKldRBlN%6$vZ}{lVOs9GcU$!{qa4zcHID|Z)<8U z{Vt{~fnkd4**)PTJx?;!-fsVYhrmt;2oS!s+ym7k`+Mu(zJ0s-@L|cf#z$&lxqa3U zn6$f|AN+lwdy>Zb{Yt^b0Pan!GBB`U-O4Q;+$NTsq)Z@+GU0$l9I5o!`=OgkJ`jBD z(+(!&NqARQ(pXsBr*ot_<*_Ufg}i=ZLz#%h9#?`CO&}t(}i;uaJ%gf^P z>>+b1i8bC$fHC=ea)S^a!%96g0MO9Xe@A-w%#|>hBNnK(bfD0ShFz_ShtWc}2lYp! z*m*$*rMjc}P5r7C!mjbOqrjh$UHS}(LUdA}=&MR}0zLOR4_-DR;#d%a#7BKMAeS#P zs=;RScaq9OHW-YNNvWsM27A}$<>M8}mLq4a%+^#B$ z`dkE>e(RfNot_Fkx{exF(9#8|I^TKYi;6%MQV+gL^)q} zB_@xRP&O_J30i1^)*c$mji@|_hDD~{BB z{+(vt3LhBHX~y-=w^uh6F#5|t@+4Lxet^PJNy)(+SgQRrdjFV;v)k|cy(|^k7;L4) z=Vl57T}cq{K5FE!UO}zqK)%m~ufxNLfoIOhL5l6I+^Ig{z#t=Tg#N@$1CP#=+ zhmd$5qfO^AN?hzBN#+E83DDh~#RiWder6T~bEVcLl6n60%Z$F&U#JyDKtN5?`SGt| zo4k<#=B!ilrTx?$3_h3N=o-fShBih5>ac!0>e&{*7mMcG?!UjvjY#GS*)VgBAGjyl zQv}qWnT>=<^+_?*lUAE3pF&_8lXiC8p=@owv$51);&`tpzR05U3}k02F(TX_802#p zY}e91EwqP5v~h&NIc*TQi?~mOD&Er5%RGAS_6$mByuXoj3#v*`<#OIC-`!rvm*TTK zq2kT(F8(bCrm^PYzU?c6z1(P^Qe`Nj1pWh^m?UL+QpmcXHA+ zhRI@S?#gpbKN{END^}W3Sj{j91FzP=VzC05+c7wp`RkVhw|;5Yh*{A9%Je|B&uODv z*RDbXYI9qks5}!R4P3cS>RdY1_lOYc{B?foCoS{ywu**4sAf@$K0=^3uv>{Xm zB4{s~<7yl){T(WDrm_-AUmCT~_TJZ6@2a*(kFq(RKSbOiE|7||zK7r84|_st_a z8Gl_V{@5S!Dm}C1w%1w@oGiyQHW$WsF8Notjlj8tu1^Ivbbnty=HD?rqe5D zl-RQle$+z|kq;p{_9UWf0mX~zi-25t@!7THnS{MV;j-YXC#Vsc`#!5knVSH3mjHTI zjI8`)@uTu{eP5Yv{)T~LcDUmJx`+5N0RbczkILUnP$jm|J?0k%6JMbOpgNqbF8lY} zSF$3fd59vOSS)Rcu@ixlXGTuXfva)LXN^+71ew1ChWTx_6St9_Gm+y}_)?F09wYh7 z@AW@b4AI2S7n~FBd((BtUr$wG_Yd(ybJVBIcl!hCVOAIjsBaw3{vF!ek$97k7fnCc zFF91kZI0ss-*_Cuy!8)nkaX-v`>H#TTa-=8_}e5Cv4G}pLy!Qeza0Y4;GeW(VdUMw z(H2tJ?yaa)xdbR@yj*lRJ2ZLA(gA0+bNaq2yyc9P0z%_7c98>}1U&&YKg}Y;Cm+Sy z1K*>>5TqYoIs1wDNgnF@&jd&tpmIt_r=#xxBjxcO9s0uuflncliMndnLb{kas0^%` zkYv+K7!@wc;Il^|FWCFo!|=(2)ap%&|7+BQ5R$_IT^Bytr2hVMloO1D<>oX|Q5>VK z1Ur4etTE!P0Xs*Nb&oJ+R?o$b z=GhN%lXt$#MsGGcKqT{6zc}SOq3ENPW0>o#)W3(3*RxWQNwaez&`o_(TgWO#Eezbt?MsOK=YlL! z&NMan0Eba9QRMr_b)p`kmt7g9-dSb7>$khN2XtJB{N04IGfw&_QxQUG0t~7fXLO|a zoS40$J=-&Rm&fqFGH+Rsnw3inWWi>k<|+OJ$WzGCifJsxwVn;Wv}X=}L5Q%?{p#b+(g;_fIfA%l<{O#pYn6XYzzdn+uVQyvk_y=J5vf!JZgCEYo0ud9*d{#D;yROLbi0D}z7Yl<$nbZqmAE;!)+-wUa zPq1AdmmFc}#fO)io;m&)_vjz;f*sVTeKg-#4Ftn@@(uZV7D% zkKN9sB0}UJSrCpwX#tDVcS!@zBS|5s15FzMI-iuI90m^3BSol|D~8?D(t7{uoTl>w z$;{L;2|v>(t3HUgxxi$JZpg)70YGOIg4m`4TnJjVMXTq);k>~8=*^Q8G;2boOhD(S ze0p>cm1wkqi0Ig4JZmgaafd{ib}bi;h78mSR7s0=9bS)al=Q6n%w{J$8!8==z!MT6 z#z(iS+*vaJLotZSb%q7hWzrqsE1Xb=hE|N-r4p`QyOuQQ_*f3__u^QN8s*|K;bPsD zAN?u0k7Wd}IrqPco;P2g{LS@|@OG*Yh>D74l$YyWi{x1?>RWIIF_1PSdPA;3DJf?P z3kqhg8E)N7E`3Tuw$6Y5>02icW<2nL9(PL-Eq#6N99+iW;9%m~;GiCa4{q?(DZBC; zga;3GLFGwKYSFn2q&vIPxbE)N%G!Vctpl-IuL5S32UdMD4-z3OuOMzP9J*i6&|hE? z?mXZ72114xxWD5DhgNDT6YbL}tUjxRX~#(rl7pRD|E5+X?y-mpHPB&PLAr(oXCRc4Q35qXPwWLpA?JB5 z&Y}%Zi6)C?74I7O0%=GSJl)) z`le+bJl>8fzAi2<%=Y51vDJyIXFF5zpr68lVn9P%JFPuQ(Fz#Uy&x7KPd^M)ZH!kz zvLh@iivQ5aL+%=f2CAvjyB0!a!6X5n`R@-M<>d$f*7h*UWmQF&{~uh|BUPbdN!Puh zv+Nh<_g3x4;%RLg`2u-T{Y9I09d2Ppe@6w};y=3d-jykDj@SVaF=ORC#!K@3>cFg# z63uJcLtSyRW9ImorqS%{a?}Vj4MKA-(}1lsL-0}AB}!SuTON_8|KRX|G?TPfy^ER% zDFAVqF?BO7zsKu2+c=Sfh!%wFB#$1PrRj`WeU!;S)LgdOdeD(9n-Cbs@g9OZZ z)QFG4Tr=tEyoo2#;(ZfG`5Z|7lo6G`^Lfsq4$-A{wN)O~>B z$m~fUhFNphUS;(f$?o=k*`9+KnH>PM?7}Q^I?f1T%z!G@K~>WFnCzZIAEc;}?^FcT z_j58vX~ZPyXSv2?7*wbf^Y18&VLv**fYVQ%K;nHxD^E-$TjG1#9p#J`PeZ^I?+plv zY6Lp-?l0XINJ%YK*L_jwix@Ey$*>i)spqzj@DIj>@%uE1OAdzj`(_>`LYA448Ud3E z)8bTiQ_O28*W$9m1KQ_EkSPd%?nXJ)+n1|Rxrp-R-=;>I9w8*x_)Wd%7FX;~ljxmm zh?YSD7%;x8w9 zC$3)13g{gDP}1mL|K6U~Uw0qbW~gBx)nke>#}lxlcV~h&&&kf;UOivYz;N4}cDDV~ zVkT9fXeKFy!eE1ooi>BZKJ|82Xp7GVjAFPu=pPh$ovgI2nxTC08=fuu< ztpP!5LJ*SAZ-#A&pVrd~fNL^sr={l5xmL4s*^2Z8;$6s(A$cq-GVhf+j@^g zBO&B5Apv#3i;tbPX3?~ZLBR@`+Cr(^vNutGKk9oasQ9vJy?rtE-hxp<&q3n37M)M_ zCPOUV^03@**%(`oELtxZsaX)!P_o9hn(*vT@g!p zR#tYKFILNigqa5Rgmdm|@R-b!xFkKNp8A)(4awX^YSi6h&b%=1to0F(K0Ae_`IoJ> zTx-BPMp+8wroOw2NqsiHfDJXbyEscJl7YghB%Zt7MNZ{+RSVH{DkzL-cx?rWke?nE-ohn#Nl=AJX_m8Jh-g=sz; z8>7*yjA4_)FjFg*rXTM;nm^CHXt7C$<<|TCi59xlta07s^H&F4eIL6{+G_rAR1yuueo010Ta1J>@%J8ozRQ zzy3Tv#f4$^R&^3_q7R-O`*_z_oK}k`MMC|{*quU)dp)ig$>YDrM@qZ8&2?SoXq`ng z20K6SwP&AdC=mR;>upw&ecJR3Y2WQl`cKHX5ZT5HY^PAes0R4OmhabvN!&^wCYQm8 zpSjqPQr501u6#Q(*xH%!#hvkEmdUxItiE{{W!d`ihumr{t&S4po0mO2%P8^$PM^Hq zKxmD5&oPR~2Xv%&vQAS=*zBA3%*R|pci?nagZ}6gjUc1w@kS^AicdP1e{U_d(^xcZ zaDi5)#C(u_WP|b96X^CJH;${R*W~HB^qv}vN;g9X`}*Zb><; zua<7E5%Yy#zZ*5kZE(VGGyNoyfDNLTZx*yziswNk5J0n(PRF|m*$x&T%JBltb%AxC z0Llr}`cvUhANri9l=GP+F*RlTUJhO>NPJB}OyTT!);y)r8J}w$s^1n%NCXkL-K_hG7 zvI{AGcW>|e)CAA|k+oMj?w6AkLwZV`@TKlmpqspFW|j_J44@Jo+h#_n*@5JB-K@0) zJ(GuL78KwsyIMiZ#6mrDsFrVH^~6<&{zoZ~U4~bLCs)*-Hn^<`mqpnU<(8CaLupS3 zbT(DZwnbLICcZ+50kqDC4+5Y|I#`GnT!E7goj;32DHJQ5jgge9&|WfxZ*j=Lmj)ui zmb+9OwG;UVvOvw0@s}9r)J400ih*D}IRis&?GwLT|5AyTz_dHsg(#8RQ!lX!svj;& zIjZUsn03uB%_i)R81JrE`A@Drm~<3X3QONtPddWNefmdVw6?26s8uOKNBNghw7-yw z@uC=U1px>eX9~JQy+WW@0TyL#Cw%%QfBvv`G?Jk8De(M?CtmVnY zdb+zUAQVSqKW!^>+QGw)-z%f2)<4G<^G+Skax0;$PJ#K2hH2#}K3Vt!?Mm6P0d;04Dkw7e;ZQQ+P~zMF;lqcT_Fdbr4Gp9F@6Xgq z@^dUc-?Ue2db({Q)n3d|abnX7;$%)GpsS!&Be*V@Ap?>Ov<(ERo?Yb+m)Vr^@3r(5DY5Lt?kyTwUxzu4YXDXmbSqf?~lTCgEL6pC~{Qp%8B`IFM{A8YB zlC(t}e_nFuXDwQ%Sq)Fvkw?!AM51Y^{#@_I7AR(8S-(M0N1^@>qP-;q@=>-zVdvK2QUJi=6fs_yIruPdR1M9jKap zYi-p89=|SxDJM?CrLOcEX$BlBC{sm(Ue^l>`{oen_m{cYwFxT*)1zVmCpg<@B_;KN zv9u^N`3~k8EpYc)M@I)*>ITWU0b>Y9G!wfq8r?%Lm29Cz2}j-%@U63MBir%8l51LM z2?_QY2Z_g+*B~DSHxvm#;eXxIGX0ENd?vI%FwY1dVh~S$EqL!NE2|txoquzretv%D zUN0YyLQx*D1#Pmrs-n^jYG+jZe}G~Ai{#UCfP)*rLUvdC5Q5aH07M6g^|ek^d#gEK zvrtjK4eyE`ap)hK4&5`CWx~*Wggs-uaG_7D%Cll=ZNTmnD&-4(_3G{|4UI3QR-|d~ z-#0*g=~3RjRz_2h6W3inu78K(gurKWbMv2u`9_~GreL@V78Yr+@pg2Eo8L48^IOCl z_lxVt`2meAl;bbgT%)gW)q0 z*qImi(S{w+X9yd(EeMZ;`cDKp7PQA}siap79iexH4zyr(U>-rBaZjj&0;5SRtVJDE zkalr;55}I<0plI$wKL9C2fZwJC{BrhlG#)NzH+b(+QfoPLnM11Y|W+yK~>^Jh~!#S zXnefwpvU^qo>ehNXc5eEJSE0f09EqKg)Kqf4zVO-u-kQAU8{+ysgr{DT7;lfXSy@1 z<|P2C${@61?8aswQ8g!>sBLVX{n(}bR)ZWIotS;%jhi=VYYnvPA)lsHsw3wuH=^@1 zhQ2U5B_*Xgn8sAq6!ZuPvz>tS)z6rISGsD*Rus@`7niAD*3;0?z&B{?f|ZuRq~+v@ z`cX02S`U0|7K09>9fB8bw5L#+JikRp@*$@6O?L%QmEQ+`U~>T4vnV1P@BW@3MJ?a( z!)XS1k1c%8b`IJ)X9gmpacG}=t~Y%cJ+pz(xw0*5GqTlF6bJ@)((jYx(i!A2I`-L{o%=hMPp=UidmOki&pu+lySJnO!>>6+&0yCA+2WAOn5!rS2bLXx> z#=B}L2&Dxe1S+7=+P=+4`aLth#9y47#u;3u<7rcQ#c zwcSfV`R?6!Vd3Eear?0#KmGRY>b~%sMQyz4ObNK;S+oFy_K_^|FDhB1vKq9Z256Yy zFqc!cSDIb9Wy#LLVFf)Sdm~owN8Cm|p#O!`b#N9t2Sf(#`n%)MjQJK8D$d~>7gQcV ztWye0ChRz+tn@uTN~U;G7G|J&ap|*jPWx6be?9%-(7o;L!|j>}O{~Xio}k4!s6tO_ zxqRu6+FeoSHYnEuzn~6ngqdZJ_MylCeV3WHv-P-?P`N+Yc9Ijm#{%ZVzZfShxiyo7 zHczemtfj&ibgFhjlbREBT$n=vdhqb$qw3W(5x;dh?Y5$(f0VE$UFWQ{3d}>}K{0DH zQmJ3h)Eio*Z9+ zMnoOeRP~aQlEH8~w9gN^;e7kiE<%l4x2|k`U+?d$GJN>(p*Y$|HV*n;(BOQpT&Y>b z8RoEGi~)|q?uQ)m#@^ZzWgK*2`pRYU~ZQ^7y4 z{T=ke&yR6;gsVxVp>23W)M%9zq*=a;U!kI-apT5V702N-%56}+I=bRD3O<&Ll{IOI zNg2C3IurCb6N^|Um&V9*swk@`;!#3@l*A@uZ*qZ;JE%*@Qhp+J%ZD6n@lG&KS5 z(`!sGW?#JzjbIa1W}kcy)IlR>AiVSN188mvgmN_Ev2t@aZ$SU7 z1uPHqjMLhW5j%Hi=@UiKetzD1yk2@Quf3y#c2X_8xSv=C4)zYXS7FrGK{PV{QeBmQ z5vp^E^&V0QdElv8MMZJ;C3G+Xw_XsVqz=%95o{=h2jK^32VXUtJ-3-g68&TI7C~N+G%;}pg8DC|r;xUD zAPbc_4&C1w#|<9HLQ7l`h&$n*i0vSq(1G&%;|hSnv!Jf&0TX^15s}i!Q>LtJcWF0@A=la&&wY@6rBm z5Ljfe%>pNC>!Po&9_|TyXw{jblAe=;E2>=O_JEuRZOumeKGBva2VXR#)#N-KrFQiVZaR?ttVK1v~R|2EFkZlD$ zxX`2s#oy~n9d{%3Q_${iXcYj;R1^BQD?zAa32l9-9uiu23!v2*Kn(@duw;C8nJP3b zcl%MEDsY(mHsf&xi2!ENn@F%a=ZE}RXG*KvNRTlF7~S@Y zs_LQsa+*VMBtvodZirW$pkvs7wU8iDa2`YKZQIb=inGe8NCEi(bjrgWNQDQ9uZSlhY8CE QI5&vWWmWm?OGdu`AK>trx&QzG literal 0 HcmV?d00001 diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/km_job.py b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/km_job.py new file mode 100644 index 0000000000..6a3e79f164 --- /dev/null +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/km_job.py @@ -0,0 +1,115 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import os + +from src.kaplan_meier_wf import KM +from src.kaplan_meier_wf_he import KM_HE + +from nvflare import FedJob +from nvflare.job_config.script_runner import ScriptRunner + + +def main(): + args = define_parser() + # Default paths + data_root = "/tmp/nvflare/dataset/km_data" + he_context_path = "/tmp/nvflare/he_context/he_context_client.txt" + + # Set the script and config + if args.encryption: + job_name = "KM_HE" + train_script = "src/kaplan_meier_train_he.py" + script_args = f"--data_root {data_root} --he_context_path {he_context_path}" + else: + job_name = "KM" + train_script = "src/kaplan_meier_train.py" + script_args = f"--data_root {data_root}" + + # Set the number of clients and threads + num_clients = args.num_clients + if args.num_threads: + num_threads = args.num_threads + else: + num_threads = num_clients + + # Set the output workspace and job directories + workspace_dir = os.path.join(args.workspace_dir, job_name) + job_dir = args.job_dir + + # Create the FedJob + job = FedJob(name=job_name, min_clients=num_clients) + + # Define the KM controller workflow and send to server + if args.encryption: + controller = KM_HE(min_clients=num_clients, he_context_path=he_context_path) + else: + controller = KM(min_clients=num_clients) + job.to_server(controller) + + # Define the ScriptRunner and send to all clients + runner = ScriptRunner( + script=train_script, + script_args=script_args, + params_exchange_format="raw", + launch_external_process=False, + ) + job.to_clients(runner, tasks=["train"]) + + # Export the job + print("job_dir=", job_dir) + job.export_job(job_dir) + + # Run the job + print("workspace_dir=", workspace_dir) + print("num_threads=", num_threads) + job.simulator_run(workspace_dir, n_clients=num_clients, threads=num_threads) + + +def define_parser(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--workspace_dir", + type=str, + default="/tmp/nvflare/jobs/km/workdir", + help="work directory, default to '/tmp/nvflare/jobs/km/workdir'", + ) + parser.add_argument( + "--job_dir", + type=str, + default="/tmp/nvflare/jobs/km/jobdir", + help="directory for job export, default to '/tmp/nvflare/jobs/km/jobdir'", + ) + parser.add_argument( + "--encryption", + action=argparse.BooleanOptionalAction, + help="whether to enable encryption, default to False", + ) + parser.add_argument( + "--num_clients", + type=int, + default=5, + help="number of clients to simulate, default to 5", + ) + parser.add_argument( + "--num_threads", + type=int, + help="number of threads to use for FL simulation, default to the number of clients if not specified", + ) + return parser.parse_args() + + +if __name__ == "__main__": + main() diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/requirements.txt b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/requirements.txt new file mode 100644 index 0000000000..e6d18ba9a3 --- /dev/null +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/requirements.txt @@ -0,0 +1,3 @@ +lifelines +tenseal +scikit-survival diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_train.py b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_train.py new file mode 100644 index 0000000000..d8d7e55d28 --- /dev/null +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_train.py @@ -0,0 +1,152 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import json +import os + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from lifelines import KaplanMeierFitter +from lifelines.utils import survival_table_from_events + +# (1) import nvflare client API +import nvflare.client as flare +from nvflare.app_common.abstract.fl_model import FLModel, ParamsType + + +# Client code +def details_save(kmf): + # Get the survival function at all observed time points + survival_function_at_all_times = kmf.survival_function_ + # Get the timeline (time points) + timeline = survival_function_at_all_times.index.values + # Get the KM estimate + km_estimate = survival_function_at_all_times["KM_estimate"].values + # Get the event count at each time point + event_count = kmf.event_table.iloc[:, 0].values # Assuming the first column is the observed events + # Get the survival rate at each time point (using the 1st column of the survival function) + survival_rate = 1 - survival_function_at_all_times.iloc[:, 0].values + # Return the results + results = { + "timeline": timeline.tolist(), + "km_estimate": km_estimate.tolist(), + "event_count": event_count.tolist(), + "survival_rate": survival_rate.tolist(), + } + file_path = os.path.join(os.getcwd(), "km_global.json") + print(f"save the details of KM analysis result to {file_path} \n") + with open(file_path, "w") as json_file: + json.dump(results, json_file, indent=4) + + +def plot_and_save(kmf): + # Plot and save the Kaplan-Meier survival curve + plt.figure() + plt.title("Federated") + kmf.plot_survival_function() + plt.ylim(0, 1) + plt.ylabel("prob") + plt.xlabel("time") + plt.legend("", frameon=False) + plt.tight_layout() + file_path = os.path.join(os.getcwd(), "km_curve_fl.png") + print(f"save the curve plot to {file_path} \n") + plt.savefig(file_path) + + +def main(): + parser = argparse.ArgumentParser(description="KM analysis") + parser.add_argument("--data_root", type=str, help="Root path for data files") + args = parser.parse_args() + + flare.init() + + site_name = flare.get_site_name() + print(f"Kaplan-meier analysis for {site_name}") + + # get local data + data_path = os.path.join(args.data_root, site_name + ".csv") + data = pd.read_csv(data_path) + event_local = data["event"] + time_local = data["time"] + + while flare.is_running(): + # receives global message from NVFlare + global_msg = flare.receive() + curr_round = global_msg.current_round + print(f"current_round={curr_round}") + + if curr_round == 1: + # First round: + # Empty payload from server, send local histogram + # Convert local data to histogram + event_table = survival_table_from_events(time_local, event_local) + hist_idx = event_table.index.values.astype(int) + hist_obs = {} + hist_cen = {} + for idx in range(max(hist_idx)): + hist_obs[idx] = 0 + hist_cen[idx] = 0 + # Assign values + idx = event_table.index.values.astype(int) + observed = event_table["observed"].to_numpy() + censored = event_table["censored"].to_numpy() + for i in range(len(idx)): + hist_obs[idx[i]] = observed[i] + hist_cen[idx[i]] = censored[i] + # Send histograms to server + response = FLModel(params={"hist_obs": hist_obs, "hist_cen": hist_cen}, params_type=ParamsType.FULL) + flare.send(response) + + elif curr_round == 2: + # Get global histograms + hist_obs_global = global_msg.params["hist_obs_global"] + hist_cen_global = global_msg.params["hist_cen_global"] + # Unfold histogram to event list + time_unfold = [] + event_unfold = [] + for i in hist_obs_global.keys(): + for j in range(hist_obs_global[i]): + time_unfold.append(i) + event_unfold.append(True) + for k in range(hist_cen_global[i]): + time_unfold.append(i) + event_unfold.append(False) + time_unfold = np.array(time_unfold) + event_unfold = np.array(event_unfold) + + # Perform Kaplan-Meier analysis on global aggregated information + # Create a Kaplan-Meier estimator + kmf = KaplanMeierFitter() + + # Fit the model + kmf.fit(durations=time_unfold, event_observed=event_unfold) + + # Plot and save the KM curve + plot_and_save(kmf) + + # Save details of the KM result to a json file + details_save(kmf) + + # Send a simple response to server + response = FLModel(params={}, params_type=ParamsType.FULL) + flare.send(response) + + print(f"finish send for {site_name}, complete") + + +if __name__ == "__main__": + main() diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_train_he.py b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_train_he.py new file mode 100644 index 0000000000..1ff9c69dbb --- /dev/null +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_train_he.py @@ -0,0 +1,195 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import base64 +import json +import os + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import tenseal as ts +from lifelines import KaplanMeierFitter +from lifelines.utils import survival_table_from_events + +# (1) import nvflare client API +import nvflare.client as flare +from nvflare.app_common.abstract.fl_model import FLModel, ParamsType + + +# Client code +def read_data(file_name: str): + with open(file_name, "rb") as f: + data = f.read() + return base64.b64decode(data) + + +def details_save(kmf): + # Get the survival function at all observed time points + survival_function_at_all_times = kmf.survival_function_ + # Get the timeline (time points) + timeline = survival_function_at_all_times.index.values + # Get the KM estimate + km_estimate = survival_function_at_all_times["KM_estimate"].values + # Get the event count at each time point + event_count = kmf.event_table.iloc[:, 0].values # Assuming the first column is the observed events + # Get the survival rate at each time point (using the 1st column of the survival function) + survival_rate = 1 - survival_function_at_all_times.iloc[:, 0].values + # Return the results + results = { + "timeline": timeline.tolist(), + "km_estimate": km_estimate.tolist(), + "event_count": event_count.tolist(), + "survival_rate": survival_rate.tolist(), + } + file_path = os.path.join(os.getcwd(), "km_global.json") + print(f"save the details of KM analysis result to {file_path} \n") + with open(file_path, "w") as json_file: + json.dump(results, json_file, indent=4) + + +def plot_and_save(kmf): + # Plot and save the Kaplan-Meier survival curve + plt.figure() + plt.title("Federated HE") + kmf.plot_survival_function() + plt.ylim(0, 1) + plt.ylabel("prob") + plt.xlabel("time") + plt.legend("", frameon=False) + plt.tight_layout() + file_path = os.path.join(os.getcwd(), "km_curve_fl_he.png") + print(f"save the curve plot to {file_path} \n") + plt.savefig(file_path) + + +def main(): + parser = argparse.ArgumentParser(description="KM analysis") + parser.add_argument("--data_root", type=str, help="Root path for data files") + parser.add_argument("--he_context_path", type=str, help="Path for the HE context file") + args = parser.parse_args() + + flare.init() + + site_name = flare.get_site_name() + print(f"Kaplan-meier analysis for {site_name}") + + # get local data + data_path = os.path.join(args.data_root, site_name + ".csv") + data = pd.read_csv(data_path) + event_local = data["event"] + time_local = data["time"] + + # HE context + # In real-life application, HE context is prepared by secure provisioning + he_context_serial = read_data(args.he_context_path) + he_context = ts.context_from(he_context_serial) + + while flare.is_running(): + # receives global message from NVFlare + global_msg = flare.receive() + curr_round = global_msg.current_round + print(f"current_round={curr_round}") + + if curr_round == 1: + # First round: + # Empty payload from server, send max index back + # Condense local data to histogram + event_table = survival_table_from_events(time_local, event_local) + hist_idx = event_table.index.values.astype(int) + # Get the max index to be synced globally + max_hist_idx = max(hist_idx) + + # Send max to server + print(f"send max hist index for site = {flare.get_site_name()}") + model = FLModel(params={"max_idx": max_hist_idx}, params_type=ParamsType.FULL) + flare.send(model) + + elif curr_round == 2: + # Second round, get global max index + # Organize local histogram and encrypt + max_idx_global = global_msg.params["max_idx_global"] + print("Global Max Idx") + print(max_idx_global) + # Convert local table to uniform histogram + hist_obs = {} + hist_cen = {} + for idx in range(max_idx_global): + hist_obs[idx] = 0 + hist_cen[idx] = 0 + # assign values + idx = event_table.index.values.astype(int) + observed = event_table["observed"].to_numpy() + censored = event_table["censored"].to_numpy() + for i in range(len(idx)): + hist_obs[idx[i]] = observed[i] + hist_cen[idx[i]] = censored[i] + # Encrypt with tenseal using BFV scheme since observations are integers + hist_obs_he = ts.bfv_vector(he_context, list(hist_obs.values())) + hist_cen_he = ts.bfv_vector(he_context, list(hist_cen.values())) + # Serialize for transmission + hist_obs_he_serial = hist_obs_he.serialize() + hist_cen_he_serial = hist_cen_he.serialize() + # Send encrypted histograms to server + response = FLModel( + params={"hist_obs": hist_obs_he_serial, "hist_cen": hist_cen_he_serial}, params_type=ParamsType.FULL + ) + flare.send(response) + + elif curr_round == 3: + # Get global histograms + hist_obs_global_serial = global_msg.params["hist_obs_global"] + hist_cen_global_serial = global_msg.params["hist_cen_global"] + # Deserialize + hist_obs_global = ts.bfv_vector_from(he_context, hist_obs_global_serial) + hist_cen_global = ts.bfv_vector_from(he_context, hist_cen_global_serial) + # Decrypt + hist_obs_global = hist_obs_global.decrypt() + hist_cen_global = hist_cen_global.decrypt() + # Unfold histogram to event list + time_unfold = [] + event_unfold = [] + for i in range(max_idx_global): + for j in range(hist_obs_global[i]): + time_unfold.append(i) + event_unfold.append(True) + for k in range(hist_cen_global[i]): + time_unfold.append(i) + event_unfold.append(False) + time_unfold = np.array(time_unfold) + event_unfold = np.array(event_unfold) + + # Perform Kaplan-Meier analysis on global aggregated information + # Create a Kaplan-Meier estimator + kmf = KaplanMeierFitter() + + # Fit the model + kmf.fit(durations=time_unfold, event_observed=event_unfold) + + # Plot and save the KM curve + plot_and_save(kmf) + + # Save details of the KM result to a json file + details_save(kmf) + + # Send a simple response to server + response = FLModel(params={}, params_type=ParamsType.FULL) + flare.send(response) + + print(f"finish send for {site_name}, complete") + + +if __name__ == "__main__": + main() diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_wf.py b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_wf.py new file mode 100644 index 0000000000..54fa1d384c --- /dev/null +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_wf.py @@ -0,0 +1,83 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from typing import Dict + +from nvflare.app_common.abstract.fl_model import FLModel, ParamsType +from nvflare.app_common.workflows.model_controller import ModelController + + +# Controller Workflow +class KM(ModelController): + def __init__(self, min_clients: int): + super(KM, self).__init__() + self.logger = logging.getLogger(self.__class__.__name__) + self.min_clients = min_clients + self.num_rounds = 2 + + def run(self): + hist_local = self.start_fl_collect_hist() + hist_obs_global, hist_cen_global = self.aggr_hist(hist_local) + _ = self.distribute_global_hist(hist_obs_global, hist_cen_global) + + def start_fl_collect_hist(self): + self.logger.info("send initial message to all sites to start FL \n") + model = FLModel(params={}, start_round=1, current_round=1, total_rounds=self.num_rounds) + + results = self.send_model_and_wait(data=model) + return results + + def aggr_hist(self, sag_result: Dict[str, Dict[str, FLModel]]): + self.logger.info("aggregate histogram \n") + + if not sag_result: + raise RuntimeError("input is None or empty") + + hist_idx_max = 0 + for fl_model in sag_result: + hist = fl_model.params["hist_obs"] + if hist_idx_max < max(hist.keys()): + hist_idx_max = max(hist.keys()) + hist_idx_max += 1 + + hist_obs_global = {} + hist_cen_global = {} + for idx in range(hist_idx_max + 1): + hist_obs_global[idx] = 0 + hist_cen_global[idx] = 0 + + for fl_model in sag_result: + hist_obs = fl_model.params["hist_obs"] + hist_cen = fl_model.params["hist_cen"] + for i in hist_obs.keys(): + hist_obs_global[i] += hist_obs[i] + for i in hist_cen.keys(): + hist_cen_global[i] += hist_cen[i] + + return hist_obs_global, hist_cen_global + + def distribute_global_hist(self, hist_obs_global, hist_cen_global): + self.logger.info("send global accumulated histograms within HE to all sites \n") + + model = FLModel( + params={"hist_obs_global": hist_obs_global, "hist_cen_global": hist_cen_global}, + params_type=ParamsType.FULL, + start_round=1, + current_round=2, + total_rounds=self.num_rounds, + ) + + results = self.send_model_and_wait(data=model) + return results diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_wf_he.py b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_wf_he.py new file mode 100644 index 0000000000..12acf51f4b --- /dev/null +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/src/kaplan_meier_wf_he.py @@ -0,0 +1,131 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import logging +from typing import Dict + +import tenseal as ts + +from nvflare.app_common.abstract.fl_model import FLModel, ParamsType +from nvflare.app_common.workflows.model_controller import ModelController + +# Controller Workflow + + +class KM_HE(ModelController): + def __init__(self, min_clients: int, he_context_path: str): + super(KM_HE, self).__init__() + self.logger = logging.getLogger(self.__class__.__name__) + self.min_clients = min_clients + self.he_context_path = he_context_path + self.num_rounds = 3 + + def run(self): + max_idx_results = self.start_fl_collect_max_idx() + global_res = self.aggr_max_idx(max_idx_results) + enc_hist_results = self.distribute_max_idx_collect_enc_stats(global_res) + hist_obs_global, hist_cen_global = self.aggr_he_hist(enc_hist_results) + _ = self.distribute_global_hist(hist_obs_global, hist_cen_global) + + def read_data(self, file_name: str): + with open(file_name, "rb") as f: + data = f.read() + return base64.b64decode(data) + + def start_fl_collect_max_idx(self): + self.logger.info("send initial message to all sites to start FL \n") + model = FLModel(params={}, start_round=1, current_round=1, total_rounds=self.num_rounds) + + results = self.send_model_and_wait(data=model) + return results + + def aggr_max_idx(self, sag_result: Dict[str, Dict[str, FLModel]]): + self.logger.info("aggregate max histogram index \n") + + if not sag_result: + raise RuntimeError("input is None or empty") + + max_idx_global = [] + for fl_model in sag_result: + max_idx = fl_model.params["max_idx"] + max_idx_global.append(max_idx) + # actual time point as index, so plus 1 for storage + return max(max_idx_global) + 1 + + def distribute_max_idx_collect_enc_stats(self, result: int): + self.logger.info("send global max_index to all sites \n") + + model = FLModel( + params={"max_idx_global": result}, + params_type=ParamsType.FULL, + start_round=1, + current_round=2, + total_rounds=self.num_rounds, + ) + + results = self.send_model_and_wait(data=model) + return results + + def aggr_he_hist(self, sag_result: Dict[str, Dict[str, FLModel]]): + self.logger.info("aggregate histogram within HE \n") + + # Load HE context + he_context_serial = self.read_data(self.he_context_path) + he_context = ts.context_from(he_context_serial) + + if not sag_result: + raise RuntimeError("input is None or empty") + + hist_obs_global = None + hist_cen_global = None + for fl_model in sag_result: + site = fl_model.meta.get("client_name", None) + hist_obs_he_serial = fl_model.params["hist_obs"] + hist_obs_he = ts.bfv_vector_from(he_context, hist_obs_he_serial) + hist_cen_he_serial = fl_model.params["hist_cen"] + hist_cen_he = ts.bfv_vector_from(he_context, hist_cen_he_serial) + + if not hist_obs_global: + print(f"assign global hist with result from {site}") + hist_obs_global = hist_obs_he + else: + print(f"add to global hist with result from {site}") + hist_obs_global += hist_obs_he + + if not hist_cen_global: + print(f"assign global hist with result from {site}") + hist_cen_global = hist_cen_he + else: + print(f"add to global hist with result from {site}") + hist_cen_global += hist_cen_he + + # return the two accumulated vectors, serialized for transmission + hist_obs_global_serial = hist_obs_global.serialize() + hist_cen_global_serial = hist_cen_global.serialize() + return hist_obs_global_serial, hist_cen_global_serial + + def distribute_global_hist(self, hist_obs_global_serial, hist_cen_global_serial): + self.logger.info("send global accumulated histograms within HE to all sites \n") + + model = FLModel( + params={"hist_obs_global": hist_obs_global_serial, "hist_cen_global": hist_cen_global_serial}, + params_type=ParamsType.FULL, + start_round=1, + current_round=3, + total_rounds=self.num_rounds, + ) + + results = self.send_model_and_wait(data=model) + return results diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/baseline_kaplan_meier.py b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/baseline_kaplan_meier.py new file mode 100644 index 0000000000..0bd37b0bb1 --- /dev/null +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/baseline_kaplan_meier.py @@ -0,0 +1,82 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse + +import matplotlib.pyplot as plt +import numpy as np +from lifelines import KaplanMeierFitter +from sksurv.datasets import load_veterans_lung_cancer + + +def args_parser(): + parser = argparse.ArgumentParser(description="Kaplan Meier Survival Analysis Baseline") + parser.add_argument( + "--output_curve_path", + type=str, + default="/tmp/nvflare/baseline/km_curve_baseline.png", + help="save path for the output curve", + ) + return parser + + +def prepare_data(bin_days: int = 7): + data_x, data_y = load_veterans_lung_cancer() + total_data_num = data_x.shape[0] + event = data_y["Status"] + time = data_y["Survival_in_days"] + # Categorize data to a bin, default is a week (7 days) + time = np.ceil(time / bin_days).astype(int) * bin_days + return event, time + + +def main(): + parser = args_parser() + args = parser.parse_args() + + # Set parameters + output_curve_path = args.output_curve_path + + # Set plot + plt.figure() + plt.title("Baseline") + + # Fit and plot Kaplan Meier curve with lifelines + + # Generate data with binning + event, time = prepare_data(bin_days=7) + kmf = KaplanMeierFitter() + # Fit the survival data + kmf.fit(time, event) + # Plot and save the Kaplan-Meier survival curve + kmf.plot_survival_function(label="Binned Weekly") + + # Generate data without binning + event, time = prepare_data(bin_days=1) + kmf = KaplanMeierFitter() + # Fit the survival data + kmf.fit(time, event) + # Plot and save the Kaplan-Meier survival curve + kmf.plot_survival_function(label="No binning - Daily") + + plt.ylim(0, 1) + plt.ylabel("prob") + plt.xlabel("time") + plt.tight_layout() + plt.legend() + plt.savefig(output_curve_path) + + +if __name__ == "__main__": + main() diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/prepare_data.py b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/prepare_data.py new file mode 100644 index 0000000000..0517ad6274 --- /dev/null +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/prepare_data.py @@ -0,0 +1,89 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import os + +import numpy as np +import pandas as pd +from sksurv.datasets import load_veterans_lung_cancer + +np.random.seed(77) + + +def data_split_args_parser(): + parser = argparse.ArgumentParser(description="Generate data split for dataset") + parser.add_argument("--site_num", type=int, default=5, help="Total number of sites, default is 5") + parser.add_argument( + "--site_name_prefix", + type=str, + default="site-", + help="Site name prefix, default is site-", + ) + parser.add_argument("--bin_days", type=int, default=1, help="Bin days for categorizing data") + parser.add_argument("--out_path", type=str, help="Output root path for split data files") + return parser + + +def prepare_data(data, site_num, bin_days): + # Get total data count + total_data_num = data.shape[0] + print(f"Total data count: {total_data_num}") + # Get event and time + event = data["Status"] + time = data["Survival_in_days"] + # Categorize data to a bin, default is a week (7 days) + time = np.ceil(time / bin_days).astype(int) * bin_days + # Shuffle data + idx = np.random.permutation(total_data_num) + # Split data to clients + event_clients = {} + time_clients = {} + for i in range(site_num): + start = int(i * total_data_num / site_num) + end = int((i + 1) * total_data_num / site_num) + event_i = event[idx[start:end]] + time_i = time[idx[start:end]] + event_clients["site-" + str(i + 1)] = event_i + time_clients["site-" + str(i + 1)] = time_i + return event_clients, time_clients + + +def main(): + parser = data_split_args_parser() + args = parser.parse_args() + + # Load data + # For this KM analysis, we use full timeline and event label only + _, data = load_veterans_lung_cancer() + + # Prepare data + event_clients, time_clients = prepare_data(data=data, site_num=args.site_num, bin_days=args.bin_days) + + # Save data to csv files + if not os.path.exists(args.out_path): + os.makedirs(args.out_path, exist_ok=True) + for site in range(args.site_num): + output_file = os.path.join(args.out_path, f"{args.site_name_prefix}{site + 1}.csv") + df = pd.DataFrame( + { + "event": event_clients["site-" + str(site + 1)], + "time": time_clients["site-" + str(site + 1)], + } + ) + df.to_csv(output_file, index=False) + + +if __name__ == "__main__": + main() diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/prepare_he_context.py b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/prepare_he_context.py new file mode 100644 index 0000000000..ceedf4c9a4 --- /dev/null +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/code/utils/prepare_he_context.py @@ -0,0 +1,62 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import base64 +import os + +import tenseal as ts + + +def data_split_args_parser(): + parser = argparse.ArgumentParser(description="Generate HE context") + parser.add_argument("--scheme", type=str, default="BFV", help="HE scheme, default is BFV") + parser.add_argument("--poly_modulus_degree", type=int, default=4096, help="Poly modulus degree, default is 4096") + parser.add_argument("--out_path", type=str, help="Output root path for HE context files for client and server") + return parser + + +def write_data(file_name: str, data: bytes): + data = base64.b64encode(data) + with open(file_name, "wb") as f: + f.write(data) + + +def main(): + parser = data_split_args_parser() + args = parser.parse_args() + if args.scheme == "BFV": + scheme = ts.SCHEME_TYPE.BFV + # Generate HE context + context = ts.context(scheme, poly_modulus_degree=args.poly_modulus_degree, plain_modulus=1032193) + elif args.scheme == "CKKS": + scheme = ts.SCHEME_TYPE.CKKS + # Generate HE context, CKKS does not need plain_modulus + context = ts.context(scheme, poly_modulus_degree=args.poly_modulus_degree) + else: + raise ValueError("HE scheme not supported") + + # Save HE context to file for client + if not os.path.exists(args.out_path): + os.makedirs(args.out_path, exist_ok=True) + context_serial = context.serialize(save_secret_key=True) + write_data(os.path.join(args.out_path, "he_context_client.txt"), context_serial) + + # Save HE context to file for server + context_serial = context.serialize(save_secret_key=False) + write_data(os.path.join(args.out_path, "he_context_server.txt"), context_serial) + + +if __name__ == "__main__": + main() diff --git a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/convert_survival_analysis_to_fl.ipynb b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/convert_survival_analysis_to_fl.ipynb index f53ad94e0f..3626b93cc5 100644 --- a/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/convert_survival_analysis_to_fl.ipynb +++ b/examples/tutorials/self-paced-training/part-1_federated_learning_introduction/chapter-2_develop_federated_learning_applications/02.3_convert_machine_learning_to_federated_learning/02.3.3_convert_survival_analysis_to_federated_learning/convert_survival_analysis_to_fl.ipynb @@ -1,19 +1,279 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "d40828dd", + "metadata": {}, + "source": [ + "# Secure Federated Kaplan-Meier Analysis via Time-Binning and Homomorphic Encryption" + ] + }, + { + "cell_type": "markdown", + "id": "c0937cf5", + "metadata": {}, + "source": [ + "This example illustrates two features:\n", + "* How to perform Kaplan-Meier survival analysis in federated setting without and with secure features via time-binning and Homomorphic Encryption (HE).\n", + "* How to use the FLARE ModelController API to contract a workflow to facilitate HE under simulator mode." + ] + }, + { + "cell_type": "markdown", + "id": "da8644ba", + "metadata": {}, + "source": [ + "## Basics of Kaplan-Meier Analysis\n", + "Kaplan-Meier survival analysis is a non-parametric statistic used to estimate the survival function from lifetime data. It is used to analyze the time it takes for an event of interest to occur. For example, during a clinical trial, the Kaplan-Meier estimator can be used to estimate the proportion of patients who survive a certain amount of time after treatment. \n", + "\n", + "The Kaplan-Meier estimator takes into account the time of the event (e.g. \"Survival Days\") and whether the event was observed or censored. An event is observed if the event of interest (e.g. \"death\") occurred at the end of the observation process. An event is censored if the event of interest did not occur (i.e. patient is still alive) at the end of the observation process.\n", + "\n", + "One example dataset used here for Kaplan-Meier analysis is the `veterans_lung_cancer` dataset. This dataset contains information about the survival time of veterans with advanced lung cancer. Below we provide some samples of the dataset:\n", + "\n", + "| ID | Age | Celltype | Karnofsky | Diagtime | Prior | Treat | Status | Survival Days |\n", + "|----|-----|------------|------------|----------|-------|-----------|--------|---------------|\n", + "| 1 | 64 | squamous | 70 | 5 | yes | standard | TRUE | 411 |\n", + "| 20 | 55 | smallcell | 40 | 3 | no | standard | FALSE | 123 |\n", + "| 45 | 61 | adeno | 20 | 19 | yes | standard | TRUE | 8 |\n", + "| 63 | 62 | large | 90 | 2 | no | standard | FALSE | 182 |\n", + "\n", + "To perform the analysis, in this data, we have:\n", + "- Time `Survival Days`: days passed from the beginning of the observation till the end\n", + "- Event `Status`: whether event (i.e. death) happened at the end of the observation, or not\n", + "\n", + "Based on the above understanding, we can interpret the data as follows:\n", + "- Patient #1 goes through an observation period of 411 days, and passes away at Day 411\n", + "- Patient #20 goes through an observation period of 123 days, and is still alive when the observation stops at Day 123 \n", + "\n", + "The purpose of Kaplan-Meier analysis is to estimate the survival function, which is the probability that a patient survives beyond a certain time. Naturally, it will be a monotonic decreasing function, since the probability of surviving will decrease as time goes by." + ] + }, + { + "cell_type": "markdown", + "id": "06986478", + "metadata": {}, + "source": [ + "## Secure Multi-party Kaplan-Meier Analysis\n", + "As described above, Kaplan-Meier survival analysis is a one-shot (non-iterative) analysis performed on a list of events (`Status`) and their corresponding time (`Survival Days`). In this example, we use [lifelines](https://zenodo.org/records/10456828) to perform this analysis. \n", + "\n", + "Essentially, the estimator needs to get access to this event list, and under the setting of federated analysis, the aggregated event list from all participants.\n", + "\n", + "However, this poses a data security concern - the event list is equivalent to the raw data. If it gets exposed to external parties, it essentially breaks the core value of federated analysis.\n", + "\n", + "Therefore, we would like to design a secure mechanism to enable collaborative Kaplan-Meier analysis without the risk of exposing the raw information from a participant, the targeted protection includes:\n", + "- Prevent clients from getting RAW data from each other;\n", + "- Prevent the aggregation server to access ANY information from participants' submissions.\n", + "\n", + "This is achieved by two techniques:\n", + "- Condense the raw event list to two histograms (one for observed events and the other for censored event) using binning at certain interval (e.g. a week)\n", + "- Perform the aggregation of the histograms using Homomorphic Encryption (HE)\n", + "\n", + "With time-binning, the above event list will be converted to histograms: if using a week as interval:\n", + "- Patient #1 will contribute 1 to the 411/7 = 58th bin of the observed event histogram\n", + "- Patient #20 will contribute 1 to the 123/7 = 17th bin of the censored event histogram\n", + "\n", + "In this way, events happened within the same bin from different participants can be aggregated and will not be distinguishable for the final aggregated histograms. Note that coarser binning will lead to higher protection, but also lower resolution of the final Kaplan-Meier curve.\n", + "\n", + "Local histograms will then be encrypted as one single vector before sending to server, and the global aggregation operation at server side will be performed entirely within encryption space with HE. This will not cause any information loss, while the server will not be able to access any plain-text information.\n", + "\n", + "With these two settings, the server will have no access to any knowledge regarding local submissions, and participants will only receive global aggregated histograms that will not contain distinguishable information regarding any individual participants (client number >= 3 - if only two participants, one can infer the other party's info by subtracting its own histograms).\n", + "\n", + "The final Kaplan-Meier survival analysis will be performed locally on the global aggregated event list, recovered from decrypted global histograms." + ] + }, + { + "cell_type": "markdown", + "id": "f75beeb3", + "metadata": {}, + "source": [ + "## Install requirements\n", + "Make sure to install the required packages:" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "348e87c0-2e9f-4852-9d6a-1a9db5cb5dde", + "id": "56133db2", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "%pip install -r code/requirements.txt" + ] + }, + { + "cell_type": "markdown", + "id": "d4b57b15", + "metadata": {}, + "source": [ + "## Baseline Kaplan-Meier Analysis\n", + "We first illustrate the baseline centralized Kaplan-Meier analysis without any secure features. We used veterans_lung_cancer dataset by\n", + "`from sksurv.datasets import load_veterans_lung_cancer`, and used `Status` as the event type and `Survival_in_days` as the event time to construct the event list.\n", + "\n", + "To run the baseline script, simply execute:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41206a7d", + "metadata": { + "vscode": { + "languageId": "plaintext" + } + }, + "outputs": [], + "source": [ + "! python3 utils/baseline_kaplan_meier.py" + ] + }, + { + "cell_type": "markdown", + "id": "31ab94be", + "metadata": {}, + "source": [ + "By default, this will generate a KM curve image `km_curve_baseline.png` under `/tmp` directory. The resutling KM curve is shown below:\n", + "\n", + "![KM survival baseline](code/figs/km_curve_baseline.png)\n", + "\n", + "Here, we show the survival curve for both daily (without binning) and weekly binning. The two curves aligns well with each other, while the weekly-binned curve has lower resolution." + ] + }, + { + "cell_type": "markdown", + "id": "a42f69c0", + "metadata": {}, + "source": [ + "## Federated Kaplan-Meier Analysis without and with Homomorphic Encryption\n", + "We make use of the FLARE ModelController API to implement the federated Kaplan-Meier analysis, both without and with HE.\n", + "\n", + "The FLARE ModelController API (`ModelController`) provides the functionality of flexible FLModel payloads for each round of federated analysis. This gives us the flexibility of transmitting various information needed by our scheme at different stages of federated learning.\n", + "\n", + "Our [existing HE examples](https://github.com/NVIDIA/NVFlare/tree/main/examples/advanced/cifar10/cifar10-real-world) use a data filter mechanism for HE, provisioning the HE context information (specs and keys) for both client and server of the federated job under the [CKKS](https://github.com/NVIDIA/NVFlare/blob/main/nvflare/app_opt/he/model_encryptor.py) scheme. In this example, we would like to illustrate ModelController's capability in supporting customized needs beyond the existing HE functionalities (designed mainly for encrypting deep learning models):\n", + "- different HE schemes (BFV) rather than CKKS\n", + "- different content at different rounds of federated learning, and only specific payloads need to be encrypted\n", + "\n", + "With the ModelController API, such experiments become easy. In this example, the federated analysis pipeline includes 2 rounds without HE or 3 rounds with HE.\n", + "\n", + "For the federated analysis without HE, the detailed steps are as follows:\n", + "1. Server sends the simple start message without any payload.\n", + "2. Clients submit the local event histograms to server. Server aggregates the histograms with varying lengths by adding event counts of the same slot together, and sends the aggregated histograms back to clients.\n", + "\n", + "For the federated analysis with HE, we need to ensure proper HE aggregation using BFV, and the detailed steps are as follows:\n", + "1. Server sends the simple start message without any payload. \n", + "2. Clients collect the information of the local maximum bin number (for event time) and send to the server, where the server aggregates the information by selecting the maximum among all clients. The global maximum number is then distributed back to the clients. This step is necessary because we would like to standardize the histograms generated by all clients, such that they will have the exact same length and can be encrypted as vectors of same size, which will be addable.\n", + "3. Clients condense their local raw event lists into two histograms with the global length received, encrypt the histogram value vectors, and send to the server. The server aggregates the received histograms by adding the encrypted vectors together, and sends the aggregated histograms back to the clients.\n", + "\n", + "After these rounds, the federated work is completed. Then at each client, the aggregated histograms will be decrypted and converted back to an event list, and Kaplan-Meier analysis can be performed on the global information." + ] + }, + { + "cell_type": "markdown", + "id": "302c4285", + "metadata": {}, + "source": [ + "## Run the job\n", + "First, we prepare data for a 5-client federated job. We split and generate the data files for each client with binning interval of 7 days." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a354d0d", + "metadata": { + "vscode": { + "languageId": "plaintext" + } + }, + "outputs": [], + "source": [ + "! python3 code/utils/prepare_data.py --site_num 5 --bin_days 7 --out_path \"/tmp/nvflare/dataset/km_data\"" + ] + }, + { + "cell_type": "markdown", + "id": "40d6fa4e", + "metadata": {}, + "source": [ + "Then, we prepare the HE context for the clients and the server. Note that this step is done by secure provisioning for real-life applications, but in this study experimenting with BFV scheme, we use this script to distribute the HE context." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b12b162d", + "metadata": { + "vscode": { + "languageId": "plaintext" + } + }, + "outputs": [], + "source": [ + "! python3 code/utils/prepare_he_context.py --out_path \"/tmp/nvflare/he_context\"" + ] + }, + { + "cell_type": "markdown", + "id": "7cc4d792", + "metadata": {}, + "source": [ + "Next, we run the federated training using the NVFlare Simulator via the [JobAPI](https://nvflare.readthedocs.io/en/main/programming_guide/fed_job_api.html), both without and with HE:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4c91649", + "metadata": { + "vscode": { + "languageId": "plaintext" + } + }, + "outputs": [], + "source": [ + "! python3 code/km_job.py" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c24c50a", + "metadata": { + "vscode": { + "languageId": "plaintext" + } + }, + "outputs": [], + "source": [ + "! python3 code/km_job.py --encryption" + ] + }, + { + "cell_type": "markdown", + "id": "e31897b5", + "metadata": {}, + "source": [ + "By default, this will generate a KM curve image `km_curve_fl.png` and `km_curve_fl_he.png` under each client's directory." + ] + }, + { + "cell_type": "markdown", + "id": "e12cde9e", + "metadata": {}, + "source": [ + "## Display Result\n", + "\n", + "By comparing the two curves, we can observe that all curves are identical:\n", + "\n", + "![KM survival fl](code/figs/km_curve_fl.png)\n", + "![KM survival fl_he](code/figs/km_curve_fl_he.png)\n" + ] } ], "metadata": { "kernelspec": { - "display_name": "nvflare_example", + "display_name": "Python 3", "language": "python", - "name": "nvflare_example" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -25,7 +285,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.2" + "version": "3.10.12" } }, "nbformat": 4,