From 3e23567e740e94d08f55ced7b4be17493c2836e1 Mon Sep 17 00:00:00 2001 From: Dennis Lyubyvy Date: Mon, 22 Jun 2020 14:32:58 -0400 Subject: [PATCH 01/26] Fix conflicting and duplicating default values (#18) * Fix conflicting and duplicating default values * bins default in plot_uplift_preds Co-authored-by: Dennis Lyubyvy --- sklift/viz/base.py | 67 +++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/sklift/viz/base.py b/sklift/viz/base.py index 2572a23..67b203b 100644 --- a/sklift/viz/base.py +++ b/sklift/viz/base.py @@ -15,8 +15,8 @@ def plot_uplift_preds(trmnt_preds, ctrl_preds, log=False, bins=100): Args: trmnt_preds (1d array-like): Predictions for all observations if they are treatment. ctrl_preds (1d array-like): Predictions for all observations if they are control. - log (bool, default False): Logarithm of source samples. Default is False. - bins (integer or sequence, default 100): Number of histogram bins to be used. + log (bool): Logarithm of source samples. Default is False. + bins (integer or sequence): Number of histogram bins to be used. Default is 100. If an integer is given, bins + 1 bin edges are calculated and returned. If bins is a sequence, gives bin edges, including left edge of first bin and right edge of last bin. In this case, bins is returned unmodified. Default is 100. @@ -28,7 +28,8 @@ def plot_uplift_preds(trmnt_preds, ctrl_preds, log=False, bins=100): check_consistent_length(trmnt_preds, ctrl_preds) if not isinstance(bins, int) or bins <= 0: - raise ValueError(f'Bins should be positive integer. Invalid value for bins: {bins}') + raise ValueError( + f'Bins should be positive integer. Invalid value for bins: {bins}') if log: trmnt_preds = np.log(trmnt_preds + 1) @@ -61,14 +62,15 @@ def plot_uplift_curve(y_true, uplift, treatment, random=True, perfect=True): y_true (1d array-like): Ground truth (correct) labels. uplift (1d array-like): Predicted uplift, as returned by a model. treatment (1d array-like): Treatment labels. - random (bool, default True): Draw a random curve. Default is True. - perfect (bool, default False): Draw a perfect curve. Default is True. + random (bool): Draw a random curve. Default is True. + perfect (bool): Draw a perfect curve. Default is True. Returns: Object that stores computed values. """ check_consistent_length(y_true, uplift, treatment) - y_true, uplift, treatment = np.array(y_true), np.array(uplift), np.array(treatment) + y_true, uplift, treatment = np.array( + y_true), np.array(uplift), np.array(treatment) fig, ax = plt.subplots(ncols=1, nrows=1, figsize=(8, 6)) @@ -76,7 +78,8 @@ def plot_uplift_curve(y_true, uplift, treatment, random=True, perfect=True): ax.plot(x_actual, y_actual, label='Model', color='blue') if random: - x_baseline, y_baseline = x_actual, x_actual * y_actual[-1] / len(y_true) + x_baseline, y_baseline = x_actual, x_actual * \ + y_actual[-1] / len(y_true) ax.plot(x_baseline, y_baseline, label='Random', color='black') ax.fill_between(x_actual, y_actual, y_baseline, alpha=0.2, color='b') @@ -85,7 +88,8 @@ def plot_uplift_curve(y_true, uplift, treatment, random=True, perfect=True): ax.plot(x_perfect, y_perfect, label='Perfect', color='Red') ax.legend(loc='lower right') - ax.set_title(f'Uplift curve\nuplift_auc_score={uplift_auc_score(y_true, uplift, treatment):.2f}') + ax.set_title( + f'Uplift curve\nuplift_auc_score={uplift_auc_score(y_true, uplift, treatment):.2f}') ax.set_xlabel('Number targeted') ax.set_ylabel('Gain: treatment - control') @@ -99,8 +103,8 @@ def plot_qini_curve(y_true, uplift, treatment, random=True, perfect=True, negati y_true (1d array-like): Ground truth (correct) labels. uplift (1d array-like): Predicted uplift, as returned by a model. treatment (1d array-like): Treatment labels. - random (bool, default True): Draw a random curve. Default is True. - perfect (bool, default False): Draw a perfect curve. Default is True. + random (bool): Draw a random curve. Default is True. + perfect (bool): Draw a perfect curve. Default is True. negative_effect (bool): If True, optimum Qini Curve contains the negative effects (negative uplift because of campaign). Otherwise, optimum Qini Curve will not contain the negative effects. Default is True. @@ -109,7 +113,8 @@ def plot_qini_curve(y_true, uplift, treatment, random=True, perfect=True, negati Object that stores computed values. """ check_consistent_length(y_true, uplift, treatment) - y_true, uplift, treatment = np.array(y_true), np.array(uplift), np.array(treatment) + y_true, uplift, treatment = np.array( + y_true), np.array(uplift), np.array(treatment) fig, ax = plt.subplots(ncols=1, nrows=1, figsize=(8, 6)) @@ -117,16 +122,19 @@ def plot_qini_curve(y_true, uplift, treatment, random=True, perfect=True, negati ax.plot(x_actual, y_actual, label='Model', color='blue') if random: - x_baseline, y_baseline = x_actual, x_actual * y_actual[-1] / len(y_true) + x_baseline, y_baseline = x_actual, x_actual * \ + y_actual[-1] / len(y_true) ax.plot(x_baseline, y_baseline, label='Random', color='black') ax.fill_between(x_actual, y_actual, y_baseline, alpha=0.2, color='b') if perfect: - x_perfect, y_perfect = perfect_qini_curve(y_true, treatment, negative_effect) + x_perfect, y_perfect = perfect_qini_curve( + y_true, treatment, negative_effect) ax.plot(x_perfect, y_perfect, label='Perfect', color='Red') ax.legend(loc='lower right') - ax.set_title(f'Qini curve\nqini_auc_score={qini_auc_score(y_true, uplift, treatment, negative_effect):.2f}') + ax.set_title( + f'Qini curve\nqini_auc_score={qini_auc_score(y_true, uplift, treatment, negative_effect):.2f}') ax.set_xlabel('Number targeted') ax.set_ylabel('Number of incremental outcome') @@ -182,10 +190,12 @@ def plot_uplift_by_percentile(y_true, uplift, treatment, strategy='overall', kin f' got {kind}.') if not isinstance(bins, int) or bins <= 0: - raise ValueError(f'Bins should be positive integer. Invalid value bins: {bins}') + raise ValueError( + f'Bins should be positive integer. Invalid value bins: {bins}') if bins >= n_samples: - raise ValueError(f'Number of bins = {bins} should be smaller than the length of y_true {n_samples}') + raise ValueError( + f'Number of bins = {bins} should be smaller than the length of y_true {n_samples}') df = uplift_by_percentile(y_true, uplift, treatment, strategy=strategy, std=True, total=True, bins=bins) @@ -214,19 +224,23 @@ def plot_uplift_by_percentile(y_true, uplift, treatment, strategy='overall', kin linewidth=2, color='orange', label='control\nresponse rate') axes.errorbar(percentiles, uplift_score, yerr=std_uplift, linewidth=2, color='red', label='uplift') - axes.fill_between(percentiles, response_rate_trmnt, response_rate_ctrl, alpha=0.1, color='red') + axes.fill_between(percentiles, response_rate_trmnt, + response_rate_ctrl, alpha=0.1, color='red') if np.amin(uplift_score) < 0: axes.axhline(y=0, color='black', linewidth=1) axes.set_xticks(percentiles) axes.legend(loc='upper right') - axes.set_title(f'Uplift by percentile\nweighted average uplift = {uplift_weighted_avg:.2f}') + axes.set_title( + f'Uplift by percentile\nweighted average uplift = {uplift_weighted_avg:.2f}') axes.set_xlabel('Percentile') - axes.set_ylabel('Uplift = treatment response rate - control response rate') + axes.set_ylabel( + 'Uplift = treatment response rate - control response rate') else: # kind == 'bar' delta = percentiles[0] - fig, axes = plt.subplots(ncols=1, nrows=2, figsize=(8, 6), sharex=True, sharey=True) + fig, axes = plt.subplots(ncols=1, nrows=2, figsize=( + 8, 6), sharex=True, sharey=True) fig.text(0.04, 0.5, 'Uplift = treatment response rate - control response rate', va='center', ha='center', rotation='vertical') @@ -240,7 +254,8 @@ def plot_uplift_by_percentile(y_true, uplift, treatment, strategy='overall', kin axes[0].legend(loc='upper right') axes[0].tick_params(axis='x', bottom=False) axes[0].axhline(y=0, color='black', linewidth=1) - axes[0].set_title(f'Uplift by percentile\nweighted average uplift = {uplift_weighted_avg:.2f}') + axes[0].set_title( + f'Uplift by percentile\nweighted average uplift = {uplift_weighted_avg:.2f}') axes[1].set_xticks(percentiles) axes[1].legend(loc='upper right') @@ -257,16 +272,18 @@ def plot_treatment_balance_curve(uplift, treatment, random=True, winsize=0.1): Args: uplift (1d array-like): Predicted uplift, as returned by a model. treatment (1d array-like): Treatment labels. - random (bool, default True): Draw a random curve. - winsize (float, default 0.1): Size of the sliding window to apply. Should be between 0 and 1, extremes excluded. + random (bool): Draw a random curve. Default is True. + winsize (float): Size of the sliding window to apply. Should be between 0 and 1, extremes excluded. Default is 0.1. Returns: Object that stores computed values. """ if (winsize <= 0) or (winsize >= 1): - raise ValueError('winsize should be between 0 and 1, extremes excluded') + raise ValueError( + 'winsize should be between 0 and 1, extremes excluded') - x_tb, y_tb = treatment_balance_curve(uplift, treatment, winsize=int(len(uplift)*winsize)) + x_tb, y_tb = treatment_balance_curve( + uplift, treatment, winsize=int(len(uplift) * winsize)) _, ax = plt.subplots(ncols=1, nrows=1, figsize=(14, 7)) From dc827ecd1818c6dfcdb50185635519daa7269dca Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sat, 8 Aug 2020 14:51:46 +0300 Subject: [PATCH 02/26] :green_book: Add email in CoC --- .github/CODE_OF_CONDUCT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index fac374d..be21695 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team. All +reported by contacting the project team at team@uplift-modeling.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. From a1f7b324cb28e24869117854d183ad113622f3fa Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sat, 8 Aug 2020 16:22:18 +0300 Subject: [PATCH 03/26] :green_book: Add page in user guide --- Readme.rst | 8 ++--- .../user_guide/ug_uplift_approaches.png | Bin 0 -> 112051 bytes docs/quick_start.rst | 2 +- docs/user_guide/models/classification.rst | 34 ++++++++++++++++++ docs/user_guide/models/index.rst | 1 + 5 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 docs/_static/images/user_guide/ug_uplift_approaches.png create mode 100644 docs/user_guide/models/classification.rst diff --git a/Readme.rst b/Readme.rst index 1c8e853..eec58c9 100644 --- a/Readme.rst +++ b/Readme.rst @@ -36,7 +36,7 @@ scikit-uplift =============== -**scikit-uplift** is a Python module for classic approaches for uplift modeling built on top of scikit-learn. +**scikit-uplift (sklift)** is a Python module for classic approaches for uplift modeling built on top of scikit-learn. Uplift prediction aims to estimate the causal impact of a treatment at the individual level. @@ -48,13 +48,13 @@ and `Part 2 `__. * Comfortable and intuitive style of modelling like scikit-learn; -* Applying any estimator adheres to scikit-learn conventions; +* Applying any estimator adheres to scikit-learn conventions (e.g. Xgboost, LightGBM, Catboost, etc.); * All approaches can be used in sklearn.pipeline (see example (`EN `__ |Open In Colab3|_, `RU `__ |Open In Colab4|_)); * Almost all implemented approaches solve both the problem of classification and regression; -* A lot of metrics (Such as *Area Under Uplift Curve* or *Area Under Qini Curve*) are implemented to evaluate your uplift model; +* A lot of metrics are implemented to evaluate your uplift model. Such as *Area Under Uplift Curve* (AUUC) or *Area Under Qini Curve* (Qini coefficient); * Useful graphs for analyzing the built model. @@ -149,7 +149,7 @@ See the **RetailHero tutorial notebook** (`EN YTsnUz6s0av1QIKAg zBE?GY#X?u4DDA&Fo^wCXdp+mt`|ZB2P1xC)Ju_>~n%}B(!^B9Nk?sN=1qB78t`5qK zf&v7iprBF#Qv**t&z9YwprGjq(zFipCE+}MuoV1ojX!t%QW75cz#x7&ieE~~nLrS8 z^K^C(aP|!p^TP%KkAUBO@ot_tPpsRYHc}E&5~31vz-1vR#Sd4Pkq15`rNPro31pa(x334GV}3&Q#U|AEKAr?ENkVGaCAN;pbNJHiEl zTXh1#2Wy3O(f0)Apd|yB6@w#y$5-@qEDR0!r8IzhA5U*A@J}1->Wx2nMZ+Ti?+ZN9 zl9LdV5QEFh$%;uzoV;Z2jB^g~{9oOi(1Z8FVy7EFBq`uB4N=JFR$O#pU? zO8$A#^KTwXN?|2kF!U?`1AsTKPcvCHNJt_Gh98ts29PVsD z3i8vIf+M6|FS)E?$m+ z&~%obRp7-y`Nz7NJPP#t6G>?`SMaA z6R!Fbw6tZ_@t)e&f#wzlc%r7KH{K0v>1U1g^^*(?^+E>|)IAB>Xad@U6rk_#hA=hL zGBeYcGxpRnAnGAaZ7hwX17$olaC$}-R$drAGhKOaX9+8JUpLKA8@!*ZmWH*ptcJ0- zk-MZm+SSTaMo!B;%vS=KoQ;mPgsFrN3F{hcC2QjwZ0;=MZ6$3HM$mQDCWL9rTS;SN z;6$V@+6-ai5n>jMhoi!LbX>fpgI(R+1Ks3;2&MqtHZuBVmU1X>fD|1W{SZ77jnhSX z5InH(FjpBHgul6rjC-I7&=6xI>lNf{fg{!f4kMwCG*Ql4uPJ7b!_; zH!TTkYeO$@l7*Eu($&XBiUbcawZZwAc?L-shMcg#M9W!{q+@^v?gO+f)O|f={E%iQ z-ljNXV|6(xAFr?w7hUThgomDtrj}PIMqSt125ucl3U$@C@*)t39s$l?rjjTPITc(eBNv_x2iIh&fgm;*E*G3s#5K=nWqKS_0UFH=2r!$7mJ6G1X^*9dWz&~Z1D zH!wA}kO=k39kgWdZe}=hf}513xu+Gv z#n;SKLQc!tkSJlS@9J-CWaNj`vHMBB?1ruEq{5_FgdL-V;L*FYgj1G0%33>@iuT2&PB&O$j!*rSU%VlDmY8l`tT>>dG3+k>D-? zIC)fnY^b|A$`xTDuNe~N=4)+E1STMj^8%np&qV`n6znW%BQLLh!Yp7eByYUFRj9U( z6v5Kh%3KQ*;7hOs9vi{+iSpV&6Mav#p`;N>=Y$G>LldO4z5z~;APqF}_A>U@IUxrr zYpUPMhV z3JPutU6i^7$zipCw%+j5ez)U@jNO*fIhfntJ!XDJGh|GnnVCoC6)DYPK1R1hh;FgT z9lF%FS8^U$NEzpJd)H4MZ%yr9U!VH2T9*(wsIpraKDZms)jC&LH?oOYS*;lfX@RpW{F{x|A$xIchR z$UV{@eaQbpyG|q$H1cngPf>dWErO(amW)CEg(oQ#ao`F{W&Mcgk;#btwf9xdokh#Hg62#gX!TauH{KbL_WGz}Q|oc> zV0HTHM#fZQh_Gs>+|}*nrIHm6S-(5Kw!XX(Z#{tJ$>KA4wUcz#CmWX9qE4lWJF0k& z*FL#*9{$?YUWC;ablJGj=Ov=ds!}XuD%dKMX|JrZC2gbi*kLR=V5v{{%V0sqUB>fL zsFs5t`b+NEzB{Ss;W)QnqoYxymp8xMz229`uYV_|OSwLLe@BvLHV@yDn&lXQk*|o2 z1^rq~H!cqP_Fhuf@62DU>)==8f!Rej6@r35+z9LQh}YXIBTR{7;k#4r4N>fJgrc7t zGpZ#Dy!LzdZ;GWAD252-g)XLQFE;N^n@6CF)F|a64(flv$*iQJnrapSBk=Nz34)B@ zq;<>T&V-Ju)1j$Ok=aAN9{>SDSBYB#vh;|v7~f=--z5h5a;9U9V5lN-RpKzIKVRPb z7v|{Tm!anSaxU?Sbx{$m5WMWB@Mz}xvAx&F z5~#WBXQz`r(8dTz;ABycg3jzQtqED@RH`Kswe@)bJKYree8rR>b#S;lQ;i$AuvDj+ z$eNnx_pg5-R!JplH6rLlj)IY0)z~+SzgH?lYPl_tsEz67rR@<%D+kB>DB7_X_`45YVy?Ud zrb1r-^&PoUQ9n>AgaJ#=o+#MEY4Aj zM52aip)aP0d9yyvEDs=av1UG*_aM-`t=yak_aV+kc}P$kx&J_{S`^rfc$Ol_+}bbK zjR&_V;>hJJu=06*kQfUc)O6=nZ`V4oRZydn16kRJi#(-O5{z@Di@d@m1DZt{@V9f->w77KXXX+*bd=4&UbvUmApFHV7_2_4|#gcQ1-P|J;8a2-BcA- zrFoN$c3d%Fk$joKqSC>BdH8ABVP}4v>(@)~FGK)a><1v|-iMv4cb%K>-M%2>rBj0& zDArBC?EK=!%XjMlm@Q4SAvvvmDg2}K7FA0+uxU+(lK z{D8aN>P;6g{_Vap{51Up^fEOk?4{fXIWdjI zr;fP>O&Be&{;=)kUv*KTZ3-rsPo zr3>Rgp|WkG?A&znGnc3B`%jGu6rzLrA=w4b8F3{V*x4^E8s_Ha*!JktKUOMRy>{Op zAMG1Af@a3_#hWcS;PYS}-x?Oh;N?39`x~u&_rLCAv+O^Q^1}G}H8Vvp_or5#RY(f3 zv#i^GwT+=>TpU!~FmR6^lJLuz<0`Qc-c#ONNLB@LTEsVWD^y99-&H+6_)vT1r%bkc zyv~%qrEA;EPW;)=46TpZXqCd8D2*!wX1jklQ9ZW2iZ_AIEPD7OBT@ z`uZWy!jQM2&cT)J5>AoNcMv|K+TiSLYrY#a@3VNL)d0`9#czFJ&EU8 zKJ5+YlMm|xr)bX9Hs9S_ejU%S&w=6Pi^gjI^6oh* zCQy6m)@R+;h(m)9mA}W^4!OKnzdgRY!!mCX&Mxbh?=+BizC{rdzQ!;=!D6iBtSAmy zlsJAd9ad(2x)0QtA#8Oa_toJ_X<3ET+F(yA?^2F?;j>k-u920B#=%;Kh!Ae72~6?E z!{0kRZ3|=FB8D$8j*>5uRgdcL7WvAGagIch-Ea^EW)_yU(z!2ukgXF{aljfFnWK9m z1Wkq=8d-f+zcgmGnlKEwEpoyWV9Sk5ah)2!84J0WycIc6$)eunv;IBh*Q;}mJ-212 z*?;_KJ5~6}A0lveL6m{($AHh_@9iJMw)kF}OV?(L6`uBQ&UHTY9;wK+-U7DVSfau2 z#P5Vy8iN|yFHr?;6MW_Hnio+VnPE!{C3VK#uFk17N{ zxC199$JS~UzL0nJEMR(8k+QpRvYYkMl@4^!LGT1EbkNJc^<(#?>mr0LS$G$$4Vplh z7JIV%I)=k8eSiU<-SB{r$eCYk?pLK z{QXJBqu3_e7l$6Ku**jISF&ASFmUF^ZLo`L>y7ijE+%HRi$ zuV&}DD0sgOdR<1%-iF&fo|5({h1@YY>m;2S3zpE}HYE*}Flb|u%FFX^D)~MaR+s&^ zJgAdwonyW}pVRqNH{#%3!WKpAe~VU|Wm39pL_Kmr?Pf6~>N%mM)qSY2@7@U|iV>VK zZ)yCtKb5=vMII!%EnhNX7fGd#cp5^)` zh>FOBe8z{O5JQ;#?mwe4o5}{;#Bnke>oC%4yfp+l!D}8F=KU_XA&M@~X5&5ejP_dm z9wplSF_c3M559BG@8@r)I56i;+U+YKL<>0o%SX}QfMbF#@b)pJJ6RomtDm9c}B!pxsBTlU?aPy z14$21jgY7fmg-2S+j5v>@XqKHbAeODYj|kT@Dpdoi2)5Kdp|IAbaCNI-q6q8+ig1# zcg;&HRH=L0+DVWHl--ZY8G<7vt6QB8d1fD7bcZ|7)(okza5_GNS81y0)DI|5)z5*g zoZ6nkRS-{LnkSIV1dBaq1B6wgm z8xQN57G6yEZz{*gLf7~AM72ZROxtG&cKv-RSa5sYz%69#)Ww9m>1rolwjC|sER3>| z<{luS1R`6WPyM)EUDbh2F&65XKrCh&~+pMXLX>7w>IW>Z-@fatW zAoOo~boVR=J_ckI|OOUyb`-{yxlp_$e=)@hsw;TqnIc1*L2|9e&{{mBsRe2`$uU zdR842(;a)XpUg``e(rbHyrW+jW)lbY8HIR2o`i>gUd*Q4OJgNO3?xduaFkm^N7~^tVSlaE&EX#0X_^J*qo#W3J@$+*T&R;r`_oG!< zAQu9Dgln28m&-d+ICY7tnW(t2S4n;HKMfEVAxjWZzwSmu8Trn!Y(M5x^XU5SNQf$G z@?yB2;jMFA5k!nq|KN543?;W6uOdgd%S~ETAuccf*~EYdn=p^sV=4rfJfGS4IT0Ajhc~dt`B``QxOe+0}Wa z+1o5i{N>Y|E~kb&v%GRI;FxDX}W zQrS5w@RU-9=tWFu1P}OG#8#}KFk%uNPm#o={4=E*EET|TQQ_myAGm>BPt2X4pHbv0 zy5Z%;7UI!CD7GKkIO7PuzS(4gJ>8)UofbmlI|f|*V_~SEuW#GYdsQ}#+3sXCn#~u9 z!p-Qf4T~#n&#pa^nsh8Tt3dX@U^W#*$z7#(5oG=o*F1-5FfL9B?*jWx!C9*3Viz97nLd+XCC=s$G9cj1^ z`|R)KGMeK?lY7WalK~LNj3znMv^669jRKWyKVbqv-;FIDJhJkTL#t88g(3Wjwr&=j- z)Oe<{2SVCOobOPY@Z=~p=RF|60Uz*-QsIu{O&ZBQ50Wlh)e+hNDe_Q-$GVfpLa1RH zIZ{}WxIXj8MxNbhs)6;EQ^prKyCvY9Q4I@8iUz8(H*c~6TA#5hgMFgXQFVZ&r;ob} zLn{Cb>h`MnqgdSKz1bIFPaR7a0khW~9J<)O_1Wx{93K0UBRMN4dGdi=t_E)uNdSQ5 zX)B8E4WYlFf*F?o6FXF>Jg#vsL)5xTO`rbp33D?6=DvHN&7>a!xT95iPfG$m?QAoA znV!Spaiy{<+3saqAB~dQij=|h*1Tx3(cfENh?GaA7EkUsh)jeZmnY2>IYt3YD{8Zx z>|d=uJFDPRULH&L^CAblSu+oy;>lEAaM|q)ug*l-eYEGM1($r()-2ymy;=ZjmA(w* zjRc8eus-h`-Ojn5cy`}*eaeDk6;uy4H9|MrX+-g8>to1-1vTXb|6s!68+RfyHiIU$ z@(h_0a=M?P`;7(Fy-&NBmUtGkv3Pac%?fn{H(aCCf)og{sX~H4hzzUv7KekKOd-p@ z06%o4gD7BVHhAWEFvF_n9TcwZ5!Tr67dZQJ7YPq5bX_MQt*2_%mKVMNA;&>LMH2=S z`E}beBd4@_dOxdHK^qxP<0v=Et8udlyMUP+!IXlF>HK9;94HOX@D`5)PdvPTwu%L| zig-y}Zx+IzbsQp~sJIm767LMtY2|Q3eZLijGvd< zwSIW*HXBcek-@1i?svn7{pzC!GBP3`f5C<1)9&8w%MhM;e%^o*5WX?$@@c*YqKYqe zG!o7T)(lur-Ca#;F0wzk%qD2o6;$sB#9GjF>b?D3q*=-wgm11W5$wO25+!iE@4ha% zC-Lex*_mq(E~+!UB3z86B_E<&)9-y8@CM>2QYYaKuaj`csPD)7nq;-tU{FAS3lV?T zB2SGDYSc4M4|Pf|!r*XU!V+mL;q+*7JoA-xd7svQ8+Sk8D%oL-!HPcKQHXQ zz363h345vbA)th+^%HF>i zw;vkPjRf@rvN5~-7tOR#oFMe+OQ+p7u0(ARq6z}Z&$eImwA^Qf<)>_CLZV*yWn01F z`j`J)!QLdhjg|2eY)QPMwJJ1a=H!f%!$vt2|F><;G%s*MGHOl<|!o*_6bF&%C z@2sl|P9b8KJejZVta_HbUiOH$-;@u>0>PlP;itBFMDyUxJir_&e-!Q)BF!tT3`>bN zza%*$w#_{Zc0tL>C`QqLJG_>|zUtzf-QZRBJGw2E;An*@f;eYAZ2M-t*2i;Le$-C_ zxNLO#(Fvtju@XRkq5A-xlamn4Q-K*%`9?#T!FK#;RlA=j^BWQHfsF|PW{WPGRuq)|9pGTC9)-K-$ zRQLHL91oSJ{r8j=IHTE*K=^A>67r`rNLC2OR-mhrEMXD3~ z)qh!jvBWUL@9lb?%?;(i zAA8mrB~OAT#Oa_~ER->V9GhSf=IQW^CH`b_ z-#!&b9ezCLGV{z;yOqb3Oh9xxFvC`-t4il7tWDeA-BgdE&S87k&GjsBh4FJw`lr0T zwv9OZ-{Zu!am?!hLN(nckv&f~Tq%|WfvrYwx;$9mgOn6>qb2eb1h(R&&@j67xOxHV z3sR%jw`n@s3}vSlYv~^?&Bm_>Ueu-ryG5H|Bpd|VgA$Rk%@F7Ap4FvjFRp4Kt=Oqu zF(Bw%dexJ==BzNf=keW$(pQfq_x3OvBf`+b5B%6s?m;78MZ5YFnQh_muW= z%pwpc*A2y|!SkZ{A`C6G-RA-{l?z`vz@WNJC>Y_01H0c8%8dhCE}Jy6U+L}bO;Zf< z(~iMUJb%g!kI(rw^fH-4)~FB&6=`FiUeF$Yi;SboVWVY*X(e-sZgNT0IDsJACP%F% zN?@oQ7cRpPb*cPg;>E$EeI<8`B>?rR-i$`qQ{u#S+RHD7W6EYI?r3C!%QjB+ahHg; z$O>s}+K?!h2mI0p9-O~VYq^({mm}nwiopnr8^DvvGZFiSkjQua5Nl=!4s?8$5a2G? zdZq#jylnj;+O^GJ1C(n2GUy5SSJ9@bXvxP8w(TE#tN9@KlLeo+71#vjcM5ra7Jm=Y zJJyFq&!{(k9lt_v+3NBtG2VYnId*-j(Q4M$8B6@uPguKj{eTAul5Um71LBQk8$g2Q zlemw_)2qXGKFEgdonaMsm}E{@81~s0J^{1DrLxYw-_CB9- zqVI}&xrM5!hVCM#Bf`P0Pe5itk45++po4R91{5Lt2;|SYX@++?!cB zbCiI-p&HR}3T>k8=J1EpPtI~v$Ljffa6AUQ&i+h1E z2{xyIf~kvCrMWLuX4g>7^I9Dc5+HnCW7zN;$m`Iv=jGVHRkb@OiUj?339+E!A`rRg zZnB9olY4|ZH=@LY1yn9cx}d`7>?}iTT=`n6NekZmXh0^`E;u%uk|om40ICFhK#Y3& z%wJ1S*Ueu74hK{|_;p}6lj?z!O{G0Gza2Y}4&7<~xizGkbrSSVYF}@O{8Gs#YGeKS zpkr&=HCdUhLtIsyhmv{o#AB&D@mNNIT+1%5(~yciHYHtE0~L zLn**)7_n|{KLU~=p!165(}>*Wx?3{IWSxS?C#d*sDW2i{{EOO6HTyEO=$mcdFT?cb zwt?{Xbbx*E`sCP#p%`L|n7ZSC6fu}DAKgG#OMgzxgNhEJi++??;8GWgvj4rxRr+$R zi{n$?CIBV*BzUC2v2-z%*vP#NP4^qEA zFswke#nYk7Q>Gf+|L%iVu@~Y@6XUp@|!=UL?KbJN!P8ak5TulQE zRlScTrW!210}S`Yx+vg7l4Z38EeZp%VDf$WfW<1ibJpIa(ixjlYEJXPE5jIvOIJ`u z1l+X#TGCxP`J?EW^v3mYqu!6uHG!>iaTmS%LaAy8D-KP)px6lHz-S*Iy@GV3V= zjI(}ky}P9vNd3HTx>(-zt?g$TxjWJ|5Uu`eHavDe+^L_U@d5nnIjUp((4~xs&U+B& zeEST@T-OA|1(bB1hb%b1rIr#$A#rub=gpo_3s*KRbZ=4&j6jK&)03f)*N}4dq05`N z&cK9%C$Z%IrmT7S%bSXH|V6BZfN{A z5oWqLBjdq>HO`lF{$|558m~ttEHA~@jDyj(FM~YSu0{pU8V#Ozb+s?ImTax)x(SII zU`%==hdF07oL6dqqlbPywa&7c+olB$TnU_sS5!u<6L+mAZK3(wY5HRCy3RFC1;dJ; zO#XUon0L9S*x7ya%1MA=ryy#C8AUDJndbtOfJg%}?IpxRRVWNd)AKyGwZO?Ch!vf> zM#1FqwG10tC{ev+`(kJFK;Kmj8F0o)0Pp>Ho%ckTUt0|AuJ3_ETi)4ev|MJ?24j~T zyfcK=!5-HQ0o-z%aS;?%f9^@`g&#{Bj!URKhbHvf*Ms; zqmk;E;AuK!=x(}#a%v)NC2oItJ~wxUva>3};xiw~TP|Em-`hLOOZp>X!0zgTCYeIE z{>lOwQ!K)aL{;sg!4Q=idJA@;1Rw(Fs7&bWmW*sY`YrsiG-D*^mD{t%WGQbgY9mGT zoKbcL#QB^C&*XDUBxt$6|LPN#%5;-%+Iw%8v(LtnO?rR4B=M>xlSz60NwVf(?$;Uv zxiT?fSD^;oDQ1Mlp47}N(0&!C!9M9K^=`kKRgmLF2UghX{V)p zqu;!kz*j>_1Iuf^#R$t5$y{2f$GNB>!sltxn!YrMF%mS|5lAiR33E<`zzD~h3dqNg z&cO(bF2b|>OPO=NVjv|rlW6@9nn%`W9|EB&B7vUvco^#(N%c`+$`Did)_us_N8gXKpRLcqQnb0E z1O|M7f(9FidShg%(XezM_W<``_r!UG9iY8}bym);aKSE2QeqQ4w166j49i#P5K4;rb5XumIHWi)RgZtvMv~P^t;>A4qS7y1LCpqohSM1&bM(FJ zz%gq}rG&rguj1(pd9%z|@!|PgGu04Zmxu`vEapA?=^cZkpqrXH1CY^Hk;@Sm1{4fU zV82+EGHKTOqP+wQOlsdDk%4Cu9AHjRhnP01r30$3+$58Sa_$|27!@;_FA6A-p@%)p z^Su`B%fS>$f=FKle;je=M{%!R7!IK2=oaakXztrVpS9>1t`pk7koUbM{YY!iuna3v@nk(>TDIQ z9XD~+i8hz;NKkfxO1RB*#J+D7#pf>WSeQ`wST(ke#DI%$%OvLT=GQ(RC(A1r7Bj<4 zE=_-&zJ7ejbHl5iE02d1Am95jRz>ZAkmlKSOM7>HN(O(Xi~+$4i)MkDTzFQvX?sgf zp`ATRp8NGJj0p-*yuNV(lZ-iL)QlJ0LYZoqPcjIJ&$&T!EgT{bTu#`@+vgjsG!D05xmK6P?yRz{a$K zj!%g2X|u?ov>35`YV+x*!4DviUFIuifjiqfA@oHVbje4jpW$8WobG(rCYnu~c@-AZ z$b)j?y$+;N^N93JH%9N|=6tfXY`pV;*f6`lz4GOXlp;cIl;NH@`rR)}*RMc=wdi6E zYcP5%t|*M`<(HMl60GK=CPH=XdeyXlL0>=B=IKmLiJE&F2{u}qOik2lA<@#~?m{EO zGjOzfQ37aPqyEM0-e4z_sz^h?();&Uqo3wrG@pljUhPeYgyl~-r~NA=5P=IbayNX-J|EW%NZFQIitlX5QX_p{GG?~?dp?c`IeX- zm)NV`RnyQt(N_mZK1~T1ft`b`A@5cwE}lW5r%LO8Iqi3!fHo=OoUnUyGj-lsJ9waW zV%(Ooc(W_n4hS;Vx-yYQeSjh{wqB*t1Ve_t8<05;rQcg0j8#23bbl`O=fHDEPAPG0 zzKx86D5ufRH59uzpKB0W)sXc`o1(sJzkXm{lJ7SPDt&ElX4gJf zl{p)5%aH$m=K?!)7%@HRVxDLyO!ad7p;e%iOFtt>rTz`lu3h2P?^ol^Kn1`<%QE!% zXBp=Bx@SIPQoj9r>p)eUHV6J5GVXo%)v4eS&x#Q=62teKO*j}lgZ2VxL+bw`>TFcZQr@sf#%LJ)Rd<=6Z>^F#n|;+NQ9#Gl8#LK#*ll z&rzR`A*jYsMD5ces#GMq8F@NJG|FYcA@#{~N4H5_5EF^xy#yKP`b+qORLri~K@zV{ ztvH+^wjqXw$%_k9k6&p0z8dpI!fmQ;YXnG?f9=Ct5`?23>T;_95i)Xe=;td7DQXf( z6zBY03J^nbiEPtgQsx5(c;QC7=5AlU4RT41%tN9?e#~V9VYT~tfJOPW@X}5a2zioW za|>!p0eqbf(B|-2zpuD>IS~1x%aY8gjZ;m}>J;8P1&hEsG~BlNAScCUu%v88pe7Rq z)FzU_9oSENDH(vP=*A!Z+z_Y((QFf|ps&z8V9KvUpP%fRlk&~*Z4=wO;!XxN8AT#v zN8D2m1)}y&Ms4|W_d&AriS7X~Oq!WYNkvwUfsdo(xtqbP3Gg3scay+-C;8|1%}0Q* zlQ{yT5O9fotHX^xNpOD3Sptep{ulYcbma1b&g6iUP!u_rQhfQDWi!qa#fZKW3Lis*9ptuTlMxa3BG9?pK?KO8XdraLa6_l2qjs+YZo4!H7)s`%o=H)^)64#0yTO-U~ z27N24z&bN;N&ypaC{*4l04mY`sygku5(U&PzLu`D-(LQ-lxgdC<6UZ?H84Mi%T<0; z3Wm4T5{H2LzaAiY?zX?PmX@7;<<-q|S%8PH`|iHxQuQ0LbPm6I_2rLG9)zDD1aIU_ zD15F02xGhlK$fJ@@jZ=#mP7_>RhbOg8uk9AYSsZr7<8<_dydloZw+i@0wBVhZ{41i z{Yx$Gf65h9|D`(ize7^}m$Cm9pZ}@p`R^wAFZ%o!WB;p3fbzcoLc#yvP!Rc;HoukN zU?x`VaXh%-xO}=HB~0DyI}@+|r=xVOzl@++EY(=GE&|${DXPv~&96f|(AT-sb$pzq z%+r{qeJ*Hb!Hir_Q0H4hmKNJVm{yITW2Bq;;fv9moICJ<9i^j!vj+u#hKJ06xKWG! zh2{acA^ryDK;3oD27Wsk}Xo^@H05K?nj!Itc&W2rSzTu5H!Fl#S{Y#~*4Wd$uImh?}-N#i)CU14x z1S!dk-9wz|3+_fbKN`%9#9ih8?wf$(7S!hIy(@foamZ3_D}S>z)PXFQmnI}INX}PE z7kWJOo#CO?IoLknegG!79(PERPnxV1qBl#Q&)sF*pXmbaSK79%xv$?I1g!ov)m6zd z04)E%)+a>}lor6J8#Q_Qq0@SF;ntnaVhH6>!op+or7q7zrkv`IG4Nk@XQURzeN@Zj z7~gfjFg=1UnYcQy^TXLNw&?EzX=-TZNOsdJPpXL5I_W)78J8;3ru2+jVFaDY77urR=eo@UW z)(oU>-2DI%Q&Cjg{@kV@sGyhzIKaY1Ogvd(9qs~(d2QCg^w0e2H$|xc&S#wx*o7Zy z_!tUn16fctDt~nff4;;eQL$fvOUb3*wH`wJ=M73ZRdkuiB{qVq41Y%BrP@rnl7FsU zR8U8bitSZg3gtp8%dsa$!sBli16!b{+7#w^qolVM3Pe|xE}bb`+zm@F25D6sqHh~+r6c6Sx=x5-~Z%Q|F*O#C8RxJ0@S90&?xhIPe>&~%&u zywH;?G7psaSw969`t9P}pI+pU~V$@8x@vM zl1I`!p@1KW>N}l!oMfMpe(QAVlk|bte;QU7s!iLC6{Dg+x&}SJTdHVa0z-v&rn-r} z4O?HxF@LQZur<@vl2+x54Wg>kWZkC?IIcgUhdv&B)YsyAj&JGOquZ(foRL(sfa>&% znN_PrFZT96xPXzDOh{+)F8q%`a#Oxj;-wQfJIKnKBZT5KH2S2(^ADkFRQE%*SPky8 zr=Inue8vr70k;0%>zSfXA;}9-guT!7M1rJTxGw{4>pz6XP)K=k6G5U|;8pqO+GhkV z^)*qCG5@`aB(mCEG0g0x+Bfo!oOvvmq~6ByKixXFj(MgJs?y9jNx*J;uX2aP(+yr)}1Ny z=Z!A0D4kF(lmWR23~jub%FFxLI+Hmm6QS!hSOf5GPPNuw02Q^z1G$k6g+gKMEU*YR ze(gX0@2m>t>sy3m!5Y{4?lhS{FwrGW`J%?NUJNCnsC6J_l$8G$=h~F1`X516-gNdR zV27Sf%~^F__?zxXP;pR{l$qR6gB`Jy&yxznkecs<%#C8eCY}B20hOjM(AAW-SWhqf ziF0u5|8!JByEu?x*vERGdCo1IQ?9`gQk3=|@q@@y@&CwxfT|cib+EzC6d0TZl@$Ny zR4^(@?cFunXo^hfK&r=s1!+$corgEv^68kJNrd7?A?IMf>;h6OFn^Zm1T$Hwbmh}(Nzi-N*2u0TjdP1X<+*yn zrv5G?&nS^yE#o@7MT?NnmYhBpZyx z$6Nku{%;B9gD_Kay_0ZJOb^gPTsjcoD{T7DL1Jzts*FT*rrnK(=u$pDo_Qa=QXj=0 z-t+3IIOl(~Lw~Uh4Ev2={*s z1%PkWNffbQF!Ogm29xs}!jTICe>Ms=RXcSBgALg!vd<$Mw-fc(5Zu$W5`>36sf)bT z`=b>kM>@`b%v99rD5=wZdiRQvNxzxzK>S7IEq5+Hkd5W72C{{JgY#t*0L)l*5*;ha z12nRLx$^<~sDHVWrz&LEVXXGMc(>(Ze~tzns|fciJ=UzWhx&3YNvHoRs1wOTQYb_rJ3X&o7RpSFddjv*!N;QJE$f?!?PC%Lcsfm+Gc$M0-cvPW{K{ zes9cV>OVk-2S1+OUYZ25W$s_r#r3kdP9eW(y&ds6+{HI6<-TDU8L)4%DMeSF4GKT8 zvbh6*052-(sg;+Qt(^Lg#aovu!JNHq?-*=;vJlM4Z61!Ky5!pMV&5(rBOcg1c}(px zP+FdPqiXDq7KJvPfa>hyq=m|*MLjrQJ4;(Hh$`AX=Xg_nckqb`_g;6g1C=8A#^RQU1+s>hSLy2XGyUPpBm14T zF?ac$jbV{Y=7%tjo-`!NKt%P-{4wpd-|yVVclGg`tL?ifT2!eplZPZrT>~BIr_*S% zP6Kx}_V}01w)p2285S5JqR|2~1x@&9G1lN(R_=J1w>sr|@!Cp)%)~>~6DcTGn+JBE zgpsGM*K_);o$fZ0UjnYUHz;mrR z)_*33-`k-HJQL`8#x#p#J63_s;T)h1wzAUhhbG30p*U4CglLksthGwa-Ts+e3B&Sk zet)5)=Gf{XlPb?C$LrnxS%Y^~E5q9h4s`Z}g3W<~gYfQ{H}lc$2NaL<6$J#pnDd3K z7mKXdZGDb8?>K(yHBq;x&|WV??8|a;->;L}E{}>5g-faAysb#dOG=w}7YFi;e=H81 zGmyQ5ovIN3c;-7LuSEdns{OMAS~I!it4uo>#S2@*F97A5HpH*(y*pY-L3IxdEuq@I z2d%t)hn81>J^S-OmQ}>)UNeAT=Dqs~8xbj=gok@FTVDWRl;^hcxt0VQJ;)SxG+G`m z6CSWdq?gK+UZ^;Z_`VykHw#@``(7fe#i4S&dLrGd#U*rYW5n)y9jmZuzscqNofwRo zlXO6jPWxp${F-9$+v;28wsl62T4W6o!e08fk1ew%Y^TI}^jvTY z+6E~s-AVi5laDkw1kEdM9n^gnKQDW&sode2&6b1bha;6x|DK!irJH#>pWGea@HyU_^ZVG3AvVlQ z!V`c`4(Nj>B#W1jMJe|uFN<_E(=@M@V@REk^$SzBYbrC9EiU{w-)JPi2>eJ*uSpYC z%&B`~D?KWBwoP_Q>D#<4#mYk^I^T^Q^%n1|Pt{v7|D`j4~1-q-P@a2C-4kD|B_5fZiSU)OjJPv==QE5Cn|mx*;-D|N*7U47toIG#~` zeTHidA6!u)E^{2xF!`?ZX=X-O|MbQ0xtyxR{*4711tmf3n~ah7&yp^xSfiirVYVDX ziw@I&yoW4w_Fcak@@6&XQs1SO5s&cEXKRj6Rr+W6Gs^U86m4|36gM)Yu;OSU?|63t zO;p2O!>muOhawy!l{CEI zeV)#&vOPFpCMH=S=^?P(Ux$)>Z4X}BG@~!BU9EU=-h3uK{A$Sl;acYB5YO<#z)po6 zIbzB4ig53`fQ;n;&ri$mU#{^6tTDkhQ z@J-W(&oA>9cZ18^roX@smxUdw@6=vzDtmyi)-mMfibfae%Jb$i9d+{0@=)EQgR;+m zTl^59`ARtBLBZyCQs--$LRP>C5OVC#Um=@p+kC?Ie+(%m_5MHh-YTlDrD+!qPH=(; z3y?r?0tpb@-QC^Y-92as9^Bns77KTGcXxOBXZD-@jqm2aI~Qk+^D^e(qSjh-c2{-R zta?g1e8|B0TdVq_345h8_j~Gy_bg)c2SSfh3)gY&r-r4BJ;sfyh{gj$g-qKqs78rO z!IF=M*};icMCC;q+KWqvmKR6;3p;Kyr@UU?%5TwyQHaN@B5Cf8eq@s@yPl>_oYuD1 zX+fbOKR6vzbJIT0*J)X#Y2!`_A85*t)*SgYB5rl>KH&EhHD%mVgK{l<;XaZyFH{@N zMrtx{bmKYr=C~28nbsw%cwSY$LyxcuTUDL;pdipDS_aA3VDjYE7T$D z#JQ=~?+;Lu!-~rTfwjZsD|sVYG~U(=AE4YHS9uiqx>_2mw*q&Gn5Ht{Yp*H?_4^a1 z&uCy>2f>A#5Uxgd!qt{gg@J8v{B34IsQo^(LJMxlJT3dvwb@S%4$;SQZ99#t8SB5t zbd9Wr{L8IE?8{r{B(aBIJL;@IkF71>8(wuK$z|w}#K0ll9@xJpanV0J3Pq|@_w+-*Kvgt6Z)3_h(Yfinla-#NLbcpl&_x!dVQ((Oezz-C1I zto@bOj=ZN#xyv`^aT|>BVtv?vl$Fc_lm!dTfYrU*?{9cI^D370QS)B$tWAi`?kg-k zezdt&G`hY{itc}*0J<40nQa;_EaMB+>zz4rXtyh9*OGf8pl93 zgbEC)rJglQmTcKa>0!VjBS)THsVF|$HRKkbjT~irhkP|Vs>-NNSL#%_j)&+MBtJgj z{DryS6ArAiw^A6BpL0!no9DjDN_dNfT=w&vIEsY;%C@=HCIEgYY_zz~N~3eM&8p3)`G1C*}G`CIV3nnQ+W>^y+;OYI;b7kt%@`M8#-tbe%Z?C-tI=2j& z0FzxfUOcvKzdz;a+__%wZOQ>A%dl8yVS%sRaR;R#nCI2>nR7>=fG_jT(y<8@ z2Gz^NZt3jUPN?xUsL_z2qBa{ewSh73bnkvMWGUNmu8CjY%%=_maa%RCFt@3O>be-i z^mG9Etir4FyMmXWM50?NsRb;)*9iU^O|^sd&&~4VY2fS1DhJ1v5k{=Y9jBYob2{9n zq@}v`E2n#6O(b3?4+2dL>K9Or-Wc%-FgRu$EuDgYiql;?rz-(fMArv_@Gi1T3mK|o zqygnt7R+Fkhenc>&L5i;IkwBme%K^3^vtyYK#4iz(#WzK^Zj?P&}U|wJ)Yywek)pg z6SH6QerV!HIl4-udA55;H2;}SfaWU{H}P}(=f=wY2ruuR^9=Xo2X|9gb^5>%ogae2 z_c;WvIeO8d10yO3=p6CrFVI2M0&RkX$<;iEcM2@~rSe$K&Sv?I{lR(*L!QL1T^m5iSn{d3j;#69j2yInIJoLnhMT(aE;Vdz*m5_kG z{R;*NhD=~-wKd`j|5sfhF}p)axR*R(FVZ=snYOC>Na}ZL@Jmexy_a9>v9C4gV=2*< z_YL9Y46f^L=@&5$+|@W>(+zvSnuqwWHLp#h`$L;U1d)AZ(JaIMW;Rnu`P$5J+%Q;w z*{bL^$1|r=W2ftORI4u>)jzEmagNTgA~njL+a1s9u~$K(eg&Gy{%GTW?Bt0X2DoOe z)N#d%zmUyYevfDau40b+>xq~BwZtP`ys#2lb2pB2#}PY_Sd-Yy?-2KGFin?A;1ByS zaLdqqFBdGWMp?c04zaSGmQU=n{pUdM!l>xh~_SzaY!2k8)0y{ z>Ydk12T#Ld&?V5WChgv4<4hDu#7T2q^j>p-VP$0Wc2>`WfXjg=p6+-4RpQ$von}qq zo+F@a&%Hf&+N|%?TsdiEH9IveA?^8Df7ZdwyM8-5E^I(skIlKYF$WEOXnC~BqQq#F z)$O6+h|MfjaeyqOQF~ij&o; zcgxkmyI*|^OwP0HPL{qWahW#lpCbchqbCUCs(BXec5)2Py!^WofgwP|$M95^Xb|3+ ztrLs1&hlQ^sJf}j9-%qEHqds7ndO)|_ zD@e!-x@rblec#R{XhqSl1By?;WVG(MjuV3{9 z@9_G*y=sTfO#Js&rLfIXXq|x+)EbL-zBez4{TDNXJ;p;LoN4j-P29Nz za;GDpxHJlWA=GHV&CSzfGGEAN%y@j$d0o0#8=tUp^~#=xW%Kpp*)iJ#$l_U!w$Cvv zsGd@v=;>k9j#fTs#pWY4pNdWkq9?-qehfD=^qBzR*}>ku94eU2*=jY{knQc)vqF_Z zzZ>KFL;h4?mcD#g2hfKP3Cs*Gi*3yie7{Clyc`?I(O?!hQI}}xQJGo;9&9=k@Gi2x zsBj0uQ#c3i(Z~aUe=MC$&)9c)k`1sv&X$RtTBM5F9&`s5=~-T_m!rNB!ZZJ4PGzS- zUt5}2YFFNFGuA7v##%mzTL|{1KZJI@dGB$0F~?xPYTpr>Oh6#ZlcKAX`QU#T_aj&h z;Sb}&%25)Wq8z0DFwR% zp&XA`U$Zr1tAzVSBgIydmT7zYFmn|e_8B?n7@E(;qU#H9dG)7{@LdrEKYm<*?P`%{ z6Jat1+7sN6Vv~s9Ya5gte|M=T&>+U`8nM}^RQu*@8H4}b0qQkEzMnue+Gr@-) z=%gP;cyeU%AiK(*40Bh7(L7sUJyjo8(>$-ftKMz%b#7Cw!v+4N-Q97k$L%8is|jIM znSOGTjR9)V5^i)*^|d5q{Cm~sQ^f|pfceTWA(jfMotXdYNtZ8vEG zaUSw;ZEvljTxdmARz87`3(kzCM);1?vWVKAVA@w+qJ~RY{%cguhwv->E2oFm;eZer zZYz;hnFDGF|G@#n28RbOd6>*A&l|m{{H++&i2HGt6Os5Xg6|TXq*ZzzdrVb6L%*#Y zj0LGwt0fRb-n#ZmzGy)e6i?cFZqI9(3Udto%fO#lM(Zu#y}YGFM8>KzwnT>3A2b+1?=;HB=!3FMFj(Vl_rxU3yo&pneuy0=CZ}T&uzMZx$JdjReNe63ak;AW)ynp zI9`L+vYG23Nv#Z*%y*$*oI zeLdkOPR%}3DF<}U&{Ep)3Nz{Z*I$C#=Eg0v<*Fp$a`o-cZ*+iJy`omIAG0SJ0ksd- zyXWyMM{sw))eMo7GunnhK)H);coeu##^w52y?ei{N7B$pmBLGuF=H zZ;AG0@~R9Pg>H{Bx0+N!==Siix*6VA9Zh0$GLh=S`nLKLsTrsN0X?FjNa3XGx1_oN zDrO2nh=|R|HPxU={D2;b=KD2U-Ux41Awx@XCXI)LZ`)9p#k**&4k+*`voqeNqfFD3 zkXvRi{xBex%IeNnN4$b<2-0&-L4#KgxpTXjo!F%GE|;!(yt+)ynY>JWYGSaubIDz7 zj`3BqbZ{TQrF}@+eMEfOw>&y)7pu3S*tFpfv8nc?@MK;rI9z^F zye?R|fVE0dNH(69h0uNmKQ#ytHRb<^v098jchwY_ygTEUt93x(UquJ&?%`h6lQr=C zuNl1&(jyvMXQMA%pCM zfO6!ndt8=@*Cz_VFH}kZ>Y4&EK1z?w{lKtY+k&`1?c#d$trnO<2V}t$Dwv~&RMUnT zS`;D9zyPehC973YKy|=RyLx$g0_a*4*JT06?5(Qdtj*WF5kW`fbP_4ZDjk)1f9Dz| z118>ZAVnjfP#_xLs*!gkiE-gAIhgUR#3wiqKPWaML8)1x7@_yA5E34xe2?VYri0*g z50$V_nJo2(h{7Q}XwuMe3Q4JGwm`4QLifsXCTs|*=>D!D1y#Av0??qEtOLJy_hMp| zE!|HKIhdp<=@M~J6rTkezPJN#dJ&i(3IV>@i3hURI>ZvMfK2`~cIJbk%aex(0Cq`p z$xCoJHAk`uY&sVrw!T@epid}?qnb8LbOW02+@YCN^a$d$OL%Syq(B?AS5z@OYZ&*< z_~DYnuffaz;EJ$rlw3*G^Ej*7#Q)*Slgui`;34Ae>Yd`&X*FGrBF&ZG{c9|F@j8%9 z;6oynpI8ylZ;;jfCVZ-8&IJ?Zf6ZjB^kj{TKAxTzVK;)1MDK6D#oC7o2L&`Bg2!V4 zXCN+J7{+>+covI>!2^hbtD`-R){HsLHKEGu%}S1`FSsF84F(|dgZ{12@Ls{= zhEmN&CH6d^YZq5&!dIids-n~3O6xnrXdcxr(j=x$`y_Vrvh}+#MNM499!wLf=>#By zsAsZZ#}O9$A-0iuRv-U~VQv4AX^ zig^fmX3A^LH`HZdYSDbrPIZ&3Uzt|hdZ3G1G5&nOjlVDF5r9v&z$ zZ)5Xs|V0-?8qiECrFsngN>=l1Q{t-8AgrU3%hzS>f z1QOr+ac5r(L;BydE=gAqj_s2s}z* z|42eRKSI>puB3~iEN)A_p{9DzM7aDPoFO`n3X>#j(m%E4j=-%G_H|J)3@OdGmC@*V zoES>>966jn!}>B^^0g1_{dzbX)dje{wm$rDsN&i0duV7dxQOUymuw<1jv@zitkBZ8 z>eIb32;kmsy;BkW@Y*)oSxQeG7!p}Z6ur%j>#tchU#Vud#o)HsvG@)`f}~>)=!lY? zi&Go;CnK+$G~w=Ano{71)qz(k9cW3}BN0R<5Kipag8!Io6)cMd&fdB|ImUDAs}Nd#lmQ6ydUf4# z`Y#B6q1;za6_Smy_UDNVAya-(3|AV(?8;ht=TiTi4EgV#;1`aUHA1x~bP%tJYLi*m zK2#8%H6YgMhqwPQ<5Uvg5P$)oIdrzr2;f4xI0|+U?W=W{sZ`G|8QX1t^3eItLYL7` zLkY278|I>&%M>SXClI$X=`2)i#sYQ2lV{ExIv;!}1#n;C!l^EXqwrI`I8{c1cuolB zx3Wkpfvqg_2~ugIMfigPi5`H-Ur4y?$J3*^0Fk$eJkJGjtfeS?g~o-oZ@zcvF8;(O z*;>S(PFqvsE%~i8iH1WI9;J*HBtm>x+GFR5;TeOW7mlKg#lglAzJuwfuPqLHvC%>7 zm?V00zEiZr|KihcwYzpyrVjq9KY)ZcH<{Q`Iop#QM3+@jb@$+|Nv?BsPV3J32wRk;hfJX7^P0E*HI3d*+*A*xEAE^@o?W>2Z8rgCMPVX|6+tUe!{5ELE{ zw~+73vnIS3N+pP_g<)iU8Vu&*>1na#&_X*H4;QIDg>Ib}BD7x2AAXVg`TgS<*RxC2 zmFtb{=5WR9;e|+kIFo-n0(yarBQhiZ2mXME;Nz5>l-t*Jz8g60x)>Au)#UQ4=(neL z#9!JP$VXK^%xXBt*o6~IRHYO^ z+Pkiicx}6rdF>eF&wbw1M81R`$J~fHhwuklGd-qFGQNFF8W-9tw1Z?cWW#niu1beX z0-4-+tX@CZ8^evRVQj>0{UC~fDdApNVgE>H>ZT~qKkik29rwt-bb9OD;t9?drJis4 zer%Gll7fKFgzZ84&1_Z#{8bLH?j!;0Zk9A2N{?;N^7I6>P#NIq9)7MQ*Ss0Ie#6`M_!f5Hx>3Hr_ z{S;Ff+%r15q6EU#aSTj4u3QWex0}XEOJ;6nR9un5FZy`TGDp$wfulW$NZ9qyVmjm~ z&$5_+TTNUo7n1nxSRQXolTd(5CP(f0&}@IAviL#;@jnhU9Y*5!&JO}SK0au?_Q8Lu zA_p4nt1J(>FUGfQ{SJpLy7QjpDs*&@H&YkuMK+5kAh-7{+sw&wE#Y2~KdeZt&Tom$ zH_fk3zn%S#MardKpOsb{{{yp#ZdM(j>Xq>d8U9h%f1Mk+%1*eD*pyB6Fawox&NkHoz0&^I_fb zZ(>>RVQ0wV3t-Q_d6!5xP9vE`q?M81d9F{9!y6aQGTK@d^7g#{DVzp&PNsFj_@~G9 zyz)RJ01#)!(j~9Yn_!bc&O?2$S?h+N0ak`8gAyNf8TdLXe$BN%UQD!fY`R;nxrM01$Wrz#-^GRZJ%s%aW4$Gqq-)3% zCA{{zYXPmk?h&ukXhYQp@WIv_Kw}C(wKyk<2~vxfb%kf!!T!y%6LE&G&l?At7d-o# z5)My%KX`n%w{P<_Cp%0=I%YQAD94rhD*ljOc}_UDi=WmV#s6S1-3r7@-I1O0@@@QfP-B( zRp8l}!f7uwQ}&>!9j2z0SF4m7SmA|rr1Gq3h)dKn$5%?t1}P!tVrWSYH#on z@ORjvCN5s;I9nlYIjfU(He6pG0EoZ65(2vMNJ@M%zJff0)E@qf z_wcJGT`(%K0?~S(OIwC8pnbF$3GNu30BEKa@#-b1c2jOf&`X@9 z_fAYEidyTqN1S*`L#et*h|8O9=jX%vIPKj2q`lKGh($N?avFP1JaG6tO({lFM^X)Y zuvp>bzi;$3?7SMgLz^s^!=>;WFd-LM!iL`sdB}yp2Prwk(`q;PZ4D=_;~AonCN@nj z{{Y%1_*sfx2jMyv_(9}7zfq1RmpuKMEsWAXt39Lu3%a^qU)=UK#SaQ6*11kL_Ds(o zzLz@qW`Y@glUh-^SioKgWhf+Zr}BGwWuV;UGvIVz?&dCYvh-VnpLEVU<5%s7u%D|$ z>B?k8o4FQxg1MgSp5R>9M(|Uz6yuX`pWP%0Ch#K>*XLwD2RX982Cq(TqBn4&#`H%0 zY=53EZ2ds4rB)iWH3+n%)NUSdV0yV}p3D&(thpSrnhjEAr;&b@WCrXB-g^63CisY-K)AG9iv9V}faX(=#QRF!6 zKt<{NL|7k&?iH|_MOT;6NYU;tCk3k~v>EOfkQGDPu0ia#@r~8tm4hka`_cLAV)Zx? zs**sAF7qvy(uR-fa}h2Ud;M{2-nF&k#3%lwb~3oZ)Z3NydZiB*Ybv~-gG%V&s19R+mLK^!S@jx?e%m=bCw}{cU*XaF;(bf-zByVXOZCu+zqmHF->Q8?O6 znBQ%m62BIyugK+mDOSeH?ihA$b6#(Ew~}+yV@)zKD0O|^blp$G0NBoDP>beU8=A;m zl+h@Kn~C&i4k-!zGe`!bXHA4sTr}>H$2SiQ8+EThbK)omFM*~jHvsfAAef$nsttWc zgMZo9r5As-dPcsq{S)k%3()<=PD$2Qulkd7wVR8CRPK0@_B|8HhHoTlpKuIURkOy= zD*L`Y61g0c1zT+p;2CCUpGKw9z-!@!>sp<-@(>auU#}Gi0H6|H6{Sk8@A>k|0kdJW zcI&GkI}xO8$B*`6-Mzc|uYmFOCuQ%J8AM+Yn_2*+DriKTVv07mR7PJM2p^7hsJF5A zgVEy*UVMr5H&hfZG{iK}7LTI%YttZrWD6ls7%o{O@pX-FDcqmp0&(iRab%K>^AS!M zUQ`S$N~*(ErHS z)h-WbxOxs6fgP5U6>g&$%TN?Xm(Ez~Hq4ccIn0?)tryGe2(;*}wI20myo$@r`|<&e z<7`_OYP(7;LG&>BG%n&WLe&kjnjwYDQ)qDv%zmV~o$Qj1<~t&o<=M;&ytl2vED{$u zedXP}GB7&=ppd{bm)YL9q;F@g*}rG;n5Q+}oTDkyzH#yYzP1N)izlk(=@%ooS{PRd z9)lFh%)%kCR9H5Ukvv@!4H@J${BcP_5Pk3K7^heqfj9*7p5>t39097w;ip{T$Wy+O zge2mTsmPymu2wu#Jb5Tx^&W=2JNCD6YS0 zoQ!-3xB(%Z`EuKBwm@>PWUA^1uHpyVmDZ(htgQ#~;hgT&TY!H(xM;q!r;F9%D=xY9 zrbzJ6v+j;_(1lvXkxP#DobwecdtBIwxocPZ2A2GO^r}LE6d9~jb>VE{Ecsl&A7uNx5Ug#c(3iMVM07C7 zOAOrl@NMoT>kT}EFaE66SgkmO!|jcx+v}}+v=5{GjYLxnUCO6!3L=opjb9pv7oWA( z<#^ZnK5)_)=J!vnp_nzkn}v_+wX4-DES)}HieLZv>_M{0mG&nWB2J`SzWAqiDtRI?9VD6kF49u(+;(>? zoBADLmvVMTwL+j{&M|tjJp?ZnQ@qeMl3g{d;p{x?KQnyz$>ni}Hq|pl`PnYEuN_D@ z8g-H#ABL5_u zq?cA#h}Rt6lFoSy^9js{u1@r#67Nm@=yW)B`tW?Q72W3>%_Yw)mPA!b*Pc<$kwQ7z zVg&;$vvysKiE9(M;_V`BI$?;}Pbi-!B^;goxi=~)iIb#5{h{~x<#?ao;j-r&LW@@* zWVt3fH$x3qp6Kq+pGJ>Ro1d0jT{R1t)`}9nQ}&p=Is{1fjW1$NQ}JNGSo5=?)N#XW zjn`7+?DuCscF1px`#)yE*GFKqUhr}9#~;N*2IL>wBZLPd+P9(@5pN%6xbUUW<4ZEb7$j5duWHaJUmcx2BM*p@6fRA6ZozL!0(#zo1&c|L7;dx zx^~WpvwG$^U{YuiBqvv~ybXrME0x+@_^NaDX7ua3+8_kqx#&$93wZR*j`u9*48CjF z2Slx)-s0Qa7gvH0R%levtk6FKKW7{E#YSv&iR)ZIZAx#9QDje>Fa3s%s^h9-RbbmR zW;w?v*_bZx(t}2V=HZck!v}2?K##U|Mvk)1pjOG)ZPFw6yy`hpKT&B)!PfFxA$jks z4=Z;pRO^vBXp#*BflZ$6AEB6RHAQA?@UEAOSr#fKXr>hVF5m+K?sVp;Zw(KR-pUnf0;YiH z2|0sSoZ7d7ytP`*w!0rpR!aV6tVE^S`qA@xI)U`#&RqLuysKm+EJ0>@wwn99=H}&s z(PyCYw;`R<>`4In$uslh^!ZU>61Luv2?7O*I+Z)sv~=EXCwF4IctTlQFbf|-d9SbC zGu~BQ3YH_cK-QA;)6&usgCb!-2*bCfJqz5AZ%9ayP@1Bg=8vXOjGH#(xNcezeOx<5bo4$=hs%d8LNSCi%6DR&gyz~e(qZUz{Vq>DKn09Rhup`< z8^Z@}eRcCt8svWd1oN$Qe5f#6dHUt@OZ8-eL^eFS#bw7mMs~#*4m`T9ZfvIr@$)}3 zKqQ;|D&cV1eYsko8!;h82rBYh*LN_cofknT6+pr$;j4~8Bjt->K7oPE1SBZ1$J9&6 zFXhu6lC)_&PcUezNJhLr-)K!yG@p^jS_r%I2n^@$1vd-s75(fsunByNiZKs{ufY9c z`NWW{|Gm^(MkisJ-$E(4kv?yh%4>Honl{WJ? z2ajRDlDFmSJvHc;v$|dPXH#75wUa#@9&2OL3Mtsen*Z7o{p{?!6ichj92~`ZZLRk* z$qtYH!W6IDO7R5eGP4!U?CnFXRD67O9=QTPx%#*pF*Hs-`?kgFn&NoAQj|dT@owDH z?QV_k`TB-dFbrLuVEgx1SE>Q^jL$&g_UJm9l%}=fTnPl9z#M_|)q7~3H-D~{XrMmJ zngFZj?O(!@#2dbVWZx_{v^Nm{T)t?))X?=D8qofJ!y_2@$toUo4d!3MQz}64Cf2<| z{Y$VhgWY)p@AIF_2L~NsjiA48-oJ!=_%^Z*c>`Jc|M^Naz-(4=q;1}R#>CSG_ZC>z z?Y#Uk{}QDC5GH*E>;E%q9v<>HB#@U7f1k(Grv7Ii#7Fb6|NXqpAGo6n8eI|WeEq2jB-p0bxad2l)m%h4c+c2c^bmB*+hM{#>o1057d0@zs$35?(|A0(T_U1N2`4 zEO1YTPk#puNF(r%H1OWmK>eK=$Dbd3UjmdEwC6cJ`tzv2zt5lNmAqZM?>u?~QBHAo zHC=9Hu3vzN{)Nmt@7v3&_m7ug?mH`d2?c1#5wy0;T_oCA7+-1rwfSQ2B`1A&^cgjXdl!;n9>Uvq+lAGTqa`(Iz; z1GvBkkCO0rOg{8~R*ZOdV4dIJCG=M(gsuGhjSqnMG5#MBKiqnj*}t0+A|F_kHV8Nf z{(m`R{SIJN@@A0IzXa|-gsALLu76qO3y4MH?f=}sngm#*>Ozpm|1NCQKR@7YDEL<+ z@U&3^;z;QT?D)GF){_6I!kvc9>3<1Pe+ZieS<-(Az_KBMl!yF(V>kX+9Dg?a|24|- zzvB3>IR2}w{{Kve|L%_eqq`%yyyhX@`z6zI)jHq%Kf8DwR1bSCZaa7WVQ&i<3@k%k~5t*XruwA$hY!|8n!ODY&DQG3AR ztPVOU+FRP+bhw$4mDP-BlKea{E4!?CO;NnNu#M=jJ)vs*`p%F_u4eBGx2ihdWlt2* zN;ZHog{d12CMC$_h~fOMN3qN``!^S$7sLk`r~>fq>x{whXOLfvt5UKo+BaZKPl~>y zXzrp0Qel>eI9sitYc@V@{o;1mkGb*1DFaR&$FK~+{X!b}$dNLW29*9rgc?zjzi4K< z!Mta;AEK}MtIt0ZtpF1;raR|^`e!DZL;(u4_pSr^gh znAuIuIscDWisb6c?r2wZN;^z8{5#T`rtLG&yl_GZCUPoo4=2J_iGKM!$2&D^+Rst@ z3&+9TSsXNTWtR8_{FiE3#m_;KIYP8-L4j6}9>gR9cTZ?;j&ML=ImBibdKqNN;%K^} zsK8<~#8b2^a_`;=OVXhk%U@0eU=h6wvnS9b9mr%mR=R8Z^ihR6N`joU+KuFrY0YA@ zV$Q3h$nY&qp=&NdLYuEoo1eBNvR7%P;~9~Uxjeep7vW}69kqbR;1&+Tko$^Zb z7{G)^cfxH^RKX_gL3%G9Ma-2)4}S6!lElY?6I9iTNDzE|INYP{ia{&A; zDa!Q12Y~=8tMn$kKTB-3fOq+aW*qqGg8DtXW#!4c>>*^0M3?; zgP*#Mn{EuKNpz(x=8|lxNJhq{XtjnAnc#c3sZski6p)@OqS^YP1Oc7SpC2YY0BB4m z?YDfD_tg2+GmVeQ<>4ZhME=Q>=*2$xeJ@Ee>M17x0H(=T#rE$V7HG3OXqsT!35DF9 zy+AD^v`7+jdt}6#%7=c8id-P3e?>z|d%lsl;&xh^!(`FYc%WG_j#T0uO$TKx`qqCI zDoKpxFk~D&ywmcXg``wUQ6U|9^?rR!@4T&0X2`kba(@WFTS%k2<%h+-*U4Qc8WhL@(4%g-3)swOJ`36GwlOu8oa z2IT%2Ibk5OY2(*dZ@nU?!}EdjdAo3rzW%saMEm;^y_DR;@NW|Iz(YR0ULJW z6>pI!B91JPXj0iKh5pnH({f)h*S(_4UdzknH^C@8&8x88aHbv(>(v%X@75>zkBJUn z*U3i-$MgH5fw|#s!~;$M331)P0|@OLz#g0Iseg6=>3@{+`PGjbms?|Ck9$9&rDpz? z#BdCHh)>edzX9-e{wltmmNnIqVwL@D0V@=Xg6@-O{KPtU4w&;f@WticPp>CZTsE8S zrMCw-ou$nG54JR`O(ehM^0sSE<=iHR>yh5zk%*k6Fx{JDxyd#4WECVxfDlb3=@yDN zc!OG}R!<{PW3d|3bW6N{W$B-cqr}$*VrSW3e{PPYvV;vKq)6+$b-bYmV%F8s^@QM~5eY*i*q7Y8Wl~zywAHP20jAHI8yp^;Ej+da6PBV`p zUe520A{q<6J*9M7y2GUR;tM6{K8Jy)*COI<5WhWL&YUu3noBMAp!ZTfcVT7HKC<1l zZS}lldp^H#7E9)eaWNwJa&qeb=)4CUij(hhjuH59$2K9PO$9|ldL?|AzsxP$9CX|r zf#V7OnI#w{#P5!pT}=`DvAziF^Y8SYA>8a|8T|UZ%3f1u~|t4G+6C@dr*jsCtq6vFXU?3rVHdELgW1%?w^2TiVW$V0eDI>so{Kc z!g&~+9Yl){Z!26E@2Eb${v6!B;c$&wY;w^@S>rGwCo3SC!eToopvIt|d3SVmEMMb& zEB)2;$blB(llKT>@xV7K+4OZ!hklIvX6#z^vD!fhK7u(fmt-e*<-_ez7v0`xt$Gj6 z=Zia>(vu_CD*0Q8<-L(o_8Nl`xd)D=-5Qro(qNqMZ{o9A)HvnvyoZm(vy*ouo2Jux zAULZ(pzB;$rk)mEa(xQ+utttK)M#oe*@N7;oFGlF<>YV4uhs`1`0+5ISQ$pgzvXt0 zl4{MpU(Vmumafj!(0zj_`XVeaC`D^B6EN>!v&y?%-440hpsG)4DRk+fKofd(xU_0S zcQM?^4b_8`arbsJxo-RU@%F3bki)`br&g&p2e%8+tffZqNGh9gQpvA$?KJPn-ru9RK6@mO?vT+Wt-hh*i1V4-L;`2y$95=H_3!+MsoBIzSo=n_N0?WobbX3jxfM4?wsT%Jp z8mf7GqfuUflTt1>_nMmkE9`;1cJQ6?XwP(mIk{>3F`Xn~<=|8=A zGjq9;IkO#hA8kKc2e5;upL>uG`7e#aF&tCD&v3`9pa}9PLW|A}vxtW&@4Jr(w~far zNc3(-l6ZpV3G^8hQN$*eBhmS9t_c`|PL`Ua!geIEz0cyUmv+zm_(}nR<4-l)GqKrk zgdj)h<@-b<_{~U#!uX`sD=(P)vc<%{9SOPG2o2$wfM9as&LMod?ytMh}~WFeianomClA?qL`>&^{O>R47RM@8;fByd~3#n zvvJ7yN%mniN72}9p$YN&c)<_lWR;d{Ge)gw)=FSnY&UNZXH>40uiJF|fY@XqK)1$d zo?{v0a=zNC?wu^J0<=>S1hIWZzrMz05S!}l*^pU53Z3iTQ~{f+zn`imJf5qPntf3) zWgIK$zoaye#=NbluO`}chLnO4dEByld%oFIg{H>Kq=R!@*o@>u zsTs$=1|dZXFFu(L@PTl&RwJKHE*FN?zV!^f0Gl!NB3CwYbts9Rf_Ay)qsqsd0C;Tv zgkepg6!G@7^Nt$()e+_uZzx>D3^Wox|Es~6jJ#$k6{ zG5l1yQCgRTGp1^9RM`SYUp8qZ!Y{r{S53f%J%52Y1sivW4wGnCN#U;VhM)d1#UmP_zoem*O8Ytk7dor&`{V< z|GqZ8iVbYy&S_aYUMeCYe7P4MJY)q!&}+>}X+6ee3CS;&5mcrww&q6;`@cg`)nu|i zhow@itGf9{?AXtTuAY0~Xxbrh$sLs9@9W$*Z4eQ8;gEBz5riXb`Cb`E3+<-FGQn%A zaTZN>>n=LIkoWpmd^h3uZ=K^_eU}XObs7%mOO%6;k?EDORNm^+x1J*Dgu?gze9-P( zbDMn~vsvHR1y``9@4_EMvth?lOmCtNHkx+Jmx@vc+MCa?!VD(^!u~|I!UGR$-R|M+ zCfT-YT(v7qDAg*=g85hEfkmk78u@;y^Mt9ysm(dkZ6RaqL2YZN2h&Alfi#O)6PiBUkLHpcX^hwy6eg={@iD^tz264xoXpD#d- z7!l>D{mikZe7|G=@~gYt*q4N5Sf}hfORVUqV<3d2z$i?4yxJl^m zb-N7bluvGn4;R*%hNi2b!)!7A69Jo=hnV|?A7@l6M-{`!^tu{t%-$v{!}+z>yFj;A zXkp~rDfX=OODcRqgX=SSD!XIaj&yQ)oN+mvYl0>g=@!ZvB(r{e_(tNm^`Rn#Duc}6 zIb@C4@W2LiH7C1Q&M|(SxnjvutDj4->Nnwlke^fR6^A?zUfZyi1cOR1L3TKO{Pby@ z`G5s*(1O$;BN6~#tH~j~;ABq5@o^-btK4;}Z)rf8R#;FRCs#Y|Ni2ER0{dw`5sQAo z5TD5=oLsBG(Re6cG=PWT`5xXX z_=KPUuH?y6qXWQrh$!PQlwrZUA%Az&AL*u!UY8qgJ#yRiU+{zZWBUoWY}-2I?#;F- zd9$D~ZMNAg)xmLdO#Tsz!+d`4_1e-M&F3|DX~#-DnFfAwGF2M4FXSFtw4fW1=nem> zFPY9DoalE64*TpOa zYUgHZlXs14Up8gXyugKNLS{g;3I*9TkrMNOuM2o(nJo59cPMh`Zshy{6Aosa0~URX zwGamoKO#8pG5atY4hqsBoJGxE7h{{={;oA3i+jrZb;r_-!cwLxL_;t?C$8IBw%GyY zi-U<3W<2X9gRgm58W1u3jO7Z=q z))?q?9vm%250$9Oek_r5Rgy{jW@T{}$B#_AD%s!}2f)u4`<5G$1oU$8X5|2Kfwa^D z`*}Us20NV+N5Pn!Y?5G+&Xq2rr$tI|-C(X0UbY>EHscdd_UTC<)ZhgUKKgn#cb(Ll zi35Flp900lPU+Le$N3-kcdm=SI;N0HRqQFWiqRsxy1Gy)mWfr=JJ%3jw8->UC~JDAFfys=)f9n~%XB7NJ+fR++fGt+P2Z3PH>A;`$XY`2`p1MMlg?MvLS zDk;X7w$*EP2KWM&;JgxST%>@E0!rbNr>5HwwMf_95p5NYOKR-li65#(Xak0X=%iC@ z_nI9QPnJAUcS^{F42Ff5=8O8m9 zO!A8D=>ju-Hpd~Qn&CKl9NPIUw+qv3wDw-KoA(Q0q(Vq+@yX7=UB`R!n$nADk0&#u z>U>OAGQT^XEQ;c2^`Ac=7c)=Ge%HFT`;U5&YN_@L=)SdLc<2FVz#`drjFsQ2*JTn97hi)>GPetj4J z(M_-IefF!$#kkpV6I+q01YW%*akar4>XXF($JAE<#T6}0M<9VffW--xU4mP1hu{Qv zm*DQsA_+kjcL?ro!5xCTLvYt%i@W@n_r6#4QxsKD+`Cui%sHp0yC+4S!wSWeAq!cU z5;i)UZMyt{JQg-QP6X0s2MYhJrqfnSEY43OUNp&aH-rN&Cu@i@u$Xje+%EO=Xh$Er z$7tDMZY(HH#YvXOrm}IW!NE+&1qzi?t@ON?TiKPxO!9SunhY+)2-HbsV(-pnR3uZ! zKGiDDL(Ed_W2Ms;=1~WyZ>?<%=aZTGO>yJ(4I82^@vHe;G=o6j7K&PdYGMYNvN8B4 zeDKP2!_?&XTCnk$>QZdoRG#gBiK7UZWwC#o{up=Ip_Qg(KXfIy9VS#O)Yc~-`r&hL zv)~htN%5Bwyec-7{(yG_;>(y|C2ujQLA?`-ID_s^a+evH{2MtUBYj#qGy>A17)Y_FvObGVqZ z&mk298;n#A8$?kP27W%#*5BYi;IaRi)e9rrz~=0%et5w7K$p z&R>wzS+D;3(FH+DZk|=p=?g>Yw;(T?yr7-wes%VzJDqrd-k4nEc;xCk!|SdVE?(o~ zdcnNDw^#UC{$-(Q{F;M!_8ar>_vUI7>hsJO^77n2aeC&um*QqgCwQj@i4smfU*1G> z273~nh@D%nUxOgeq=m+a^H{l(&xxGwpUV$fpG_S_V0N|3f=h2FZ?}T4(lpZ0AMagO ztt+s~G$s6zvcGg(^p`yz*#F)*c1d2um$77>>_A1+xU*bB7}J5(~aA1`7ASg;_o#JpQ5~PSr{vnnoeFl znrLM5eAw6GbxE`xiHP}e812ibF*|SE1qu!m?s|Q{q=N@Y7ao_iCg-;X&?^PA^Cnzn ztYQ!WTvf~%1@U4@0qh&Wu7U?`Hsf|U$sF0zwN75NeLR@YZtD(NA@qYqyHA>tFzve# zRfJk6?7V_b84V;UIWboI!=hrggRb4P`)@FfB0jqE^0c=I8H0Ttl|z$lNPgZ~&w0+v z^1{#0!k;c>EDNM8$;Usfg9OtfxyL*o=1Siu454GC>+7+jRI2Q~dg zwyHvY#k?BIy!Pj&VCIt9-=nO00ZCZ?3%JMEEw%EW-+_n%I+m5iVs`c-nOI;gN}&`X zxek4?*+N}^y-aJalC(JjGYZ`q!}Z)DU3r1%M~Wxf0}+&$68hj#I&l6Fu8@eGTacTWjF$#rC`?ly?Lds=B^jG3TlCVg;n={o> za#=+)6Ng3b)SeoDQ(?QFrc*lQ&^-Z7$Dle&5i7oW(UjnCtPNoYJ)<^$T5lT{vYuIfJ%--f^|ZF!nycz%d% z+n;VD4=Pb@BJN#tZ4_$bY*Cqs`y=5_T&C8@=ngpBMXOI2|A?tLtpcN{s%FK`3%IWh zU#&tbOd~#oLr@rd8X%5Q{zzzptH-F*oZ}`9MO)O)jml_2UhRD7J#crW*z}!^K)JDk z$L#9kceG$%zI1gM(0V-0p)ZapN3v{Vue(?|+ezjGnaHf*Ec50 zt9Z)6w8T}6@lj82x@p>?Mistu$=X7Rv#K%S$*?9=n`wTtS$mw7iU${~dKVXoSKchV zoTEZXqf=I7lQ3UW4pbi8UiP3cW$SSJBo+C@sCf>dsObK#oG#QBalELd{A}0yn$5Et zh-aN6!WqRy8p65*5w9o5ZkGL~<}X}!m*MJ>Zi=wgW_$s7%`onW^@cpbvhxn2)!)ql zWW*Gs2s{bZzr}`1^if+mbF>9QDqG?qd|+Sq$J6Uf^SG3u2%Y)4ltjFPuYV}N;)lhQ zI`q9*m^BX(g`pvWB-r4zMis@9 zfpHYad;5|^tyw_1$?o#gG*$k=-e8aJYaVnxms=-%T5tzWT6~O@k=)Z4px6UrFV~Wd z5ksE}2K^vm8p2dZnW2%tDTC}=0o3^SZ(!+|X#Yx$jS?{e6cKlyX0}|*b3s&a zu=zn|Wac%vW7(?W;dvVQ+T(UBgUFJUkWR%@1ZLBa-sR3H<|O|!bec0@xv|b~yT@za z@tP3Mo^*Iyd9^*n@^d*hKf^=|zgC!u#$u=;lze5*F%>J2%KI)h=3Cmh)VsmE(ycaAB%kdBtSe z!Haw_4&#;jBjiTdla&^#W_{;PXiwFhn(S)j04sO^L}a0v`poED2*O+anLI=tojqx? z2PZ}*o^)1^)xj^2!5vX`=CjmT7D6IPZtdw&hZ5`j#hGiQyZ^8d8`SJ8`Dr&cUQJ^$ zb6zH~R1EeY&kSHoln-u?CT`7%&L1oq(t-^JuV@RQO#$q~tE5XQO_^r>4qX)oAjs|# zYpRBwrn?CKnLt5Uy%)>(R_b30_c1I>Rd?K4m&)U!KJIIyufE6iGi-#v0~IHXbz2e0?aOZkRr|EAL@&Z zrw4st>Yls)G_J~Dzjt=oWWF)!I-alcTPQ1W=}KPAM*+U*I)iKC|1c3!#Tj?l2ZyAElXU0lBBD3&-nEJm(OuSJ5RT#tp1+HX(`8FYVt>}X_i z3YEIxXO7=UVY(lxDm?RlS^$Bw>`s^=|MC+r%vK~e{q;9IKYNyZh8k6v;wEb_tJIKi z`Jn-cpGEnJ&ku<&V(b!z&+Pqqx_~tHla@_bA_kc8b1H$k29UC6n+=D>wTfB<)Sjg`zTfti7=h>w~gEwLt|9t;w4Z&R1LAvfUTz9$$DAIRee5# zMLK$LaxZJk;_eaQKxmZ9zZjY(LA3zg5LTdE7UE>wN=u4bmi&oOyv!z+?v$Y}SE5Nz z2|J|UUfzUhzh4TM8d(410+_rnn5ibYAekSe!0sukfG@X_ z9VL65O4%)z`XNDi0fVQtmOxAKR@pRxAW1dUfo@}~pEUPEFeDqNj=xu#6e4c(WWEtA zv{B+dyb)x==F+fy-N{nx*E$B`P!uy@64+nY5f~xNpx|Py4Z>^k3Jjew&T%zqHzyad z>u8BBoLu+_bn)hV60VJv)F!2@UZ@x6;#eHop-cB1z@^ zW@oKN8{@vk^A<4>^EXB&MGcSJ4bb(5K*v#Ety8GAUrY5P}*Fpsec@O@r!nXQieomOGde?bK!d53W@*5~{v=2Dm>!X%a(Bc47CPGbMUY*%URRqoyJ&N1qBtiAv)eRB;xQJI zfgL-ABaNJ4bFJb?7?Gd}R}9812@p`1G=G z+^=`|oHkeaC%e0cL{}^+sp^GctF?I&xkT>N$-sk#uTn@Q2Dp%_$_9L9k;TsMnc|@l zkxu_C&BN3|*Pr1@s_|mJdM6g25LgOL7kAcU)b*a#<+qKElnRP-4Oj_ELhktnp)FKU`N&oX1XyjG2qTKIhB?tRf5iGa-xvCj4BqSSWjwlsx-Q;JRBs->vA8cTq)SE6Lr zo_?o*8@>Mf_+T&}1*bGjZai{WGP1h4-$(D)^S*ME2z`2Rb2=#DVD1l5%qKZfYQ?-< zU>qrQCgrkGJbn}zN@fc0d9URCyGZ;)@`-@YHo`PrdA-~7kPX;t3&hYLq-B*Gwo`?D zD@VAL{kG3kz+)edYvX1C5M!G){MZ*TB8@2)u^UW^c=YW*7% zvYBeWF`g8DE-uSGTL#s#r}>_8khCA!`So59w#whXMv_3HS3LyI2v+wD<3Iu}qBY2z z2H}K#$y<63Wh%2(~KT`_xz-j)ocB;HMqThlk+llcpQ;3q2?3@Y$I1aHu?KPJ{T?- z_W-%c0T!&1_WpwZ)aJ7Pdp^WO4;NC>^Szhe^aD9T0gI3y8J37_mZ|i2vu54P-AFlq zq+z2tASM>yK5vC8Vu7qwc14Rf`WLgSXlv7`$N^J+gU=ZBhfcgbSd_(FB0W4$rbe>F ztXxl(3!=S7O}f;-{DGDO&M|zYe=#)BPvg~3CM3%`;BLg39-7z>Cc=RDLif&Bgkhg| zy3;@l97h;Iv|NJ5{uWz0{PchcP6}Cv23Sb8#FTeg{s>SHgNhsl!i(DSEr+P^3e2jjVLtEelaE4OQf0mmT&*YS0ObtoP@;6=$MPM@u@-{`R0HUCRcAwFmQi`Z9zr~0;JVW=;?CLUX-B31(GTj%v^JO)A=N3J^M9RiA zesMw?$d}YpOMLvqUzTv<7a){^u~_?F@4&vo=$r^YhG}#k2f7`VYnyngyQieBGxJfjEu;6i46{m|zpA=*+W-_+&CS8>RUq|m=RK}p^UgKH>bAW4J*2!4 zhBO{g-b^?SL;r_ z)mMx@x|%x0Fx-S;T5igM#lzY5igC&~CPvd2qQ(QeD?90)0U+*D|$eoK#3fh zcEIX0%)6uz^UTMvmHIn|>B)?43xaHZ3Lsi%f!@$P^Sv7FW8}bV-H)R?J`)3dLLyf` zmI}Wve6;;2jyGb$1#t_HAJx+}>}enk_Q_7+%>IyW(Am3ow3zjYIRQROg3@_MWr>Rm zY!gt*MT-W^(+#AeFkk4QOj-|UL5 z8K4qBV;1LM`-vz%7Ycvj>MmlJ&N+jYKGbgFqD)KbKOjC-lu3yJA@a%&2y@6}5OKrY z2WkrD=RP~!XR@mR-f!Iu*;FEPtAOs_dzGR#^*OS;PS-v-rA_1vQgyuJZ2lgG*8uPp z@;Fa0&9;X8kX#;Y)P?Q8s%hRNRc^VM5znd6}g%XZpxMG(2^1sGT&UEkU3i_rMp;ZQ1b7w*`cyo z7N;Ejp-9iuA7cE|+)5p*AjM!I2m9@Mp54;WFD`xxindY>A?14VJ>fsXg&YirM7oL8 z-yU$DY7Yt_B<$RboawUP5Nx{Fdknrjxwx=QC^TRqN8z}#U69+M($C6iwDj>g3AIq6 zDZ!_THdV~nW6BthdX#%flr-k zH9&hQbqD6WX0J2N%^QV?i&(zksqg`-1HO>`MXa$;Gfkomxshz1v+v`H1^Oxyz9XNY6W@M|i}YfLJBir-kf zVS$RDXed-;Lh74S++sw-^|1njH+_J4l2M1(@khY<2bxpG-zN+#aQ0#fxKNd;MAOkB zl@1_rwIlx?PRv_IEKn5xRIBT}$CFiFYnBkv?(|6`m!*Md3d-$Z5m0r}>hq*qUv>8H zt<^#4M70i9Fedps0Lait3C|!QFRbm<@1m#%gVM1Jf)%?@KJ61S$G0r zHCL2$(D3KXU(N`Y@VS+fWC{mZcXMnL5pbG(#CgiY?+(RLI@ad*X<*RWCnQi=CkP8gYQQi!4>Kq=hbFx;f^(iJ3&>C6N(6h>BNa^wNUVO{(b6@J5bA|bvD zKMZ}N-jloG1?PM!g{+C(vwX9`t70om8*Un4+`55AL)@nWLK6ka+X_GNlFnio{JS^r z3Obo}%XHseTk+uc3jGSq6#M9VE}6Bv8r@QrIyL|U=kuZagv8SSKFYQ%nv)> zW@9Dv0HrdRM4*{cmK~odqxiDrWD3(2NU9f|QJX(a`e_=*%XoRhk2jdbz`nhO!gVi! zIg}|F{{)5G#Vp-r9Vvl>g56jyPep1Qws6u+z+w$D9*jzO=5_+qv~1ApCJUjTOrP`t z#p%3#f~OvTp`c8BKZ&N!0#)x!#oSpQ^X0T8P;JaliSdJTSS`}6kI|DnRpp0+LIU!` zi6_{MucC@$IlnqY*-femv(yXsRIFav)u!CQu#`_v()M_S4taTjv~?Tn05D89w!$aM zfSK4Zcw>0l5b3bS{IHD8z~0uRQmZym>J}jJ7W$ZWV@Wmp%#2|5B5@l6{0v>#@4NcO zg4&`4ywrWyax9?`3(AO;DNF@T9knI}dd(^kpGpKcY8B^3u@J&l9G(NcMO`&iQ{te$ zf~{;BB4W1?mPVs$*xNDb*RbKd{IVyKs5QPtDBf0M`?zCnh|G7N$pbW(yiuh#lGQa?M>+ zAWW1!)Pqb|cf>=6D_+!Z(|JF4d{R}Y`mnujNvpqlHyu(aXPO&o(To&bwRE;qIAN6W zzA0i7PtUV3&TtfUnK)jCFL(?~2w^15Z8m0wq-nL7#$8c1|lQ zh$y;8p8&streKpR0MOE2Gmn#3Y85yeOyNs7axYpaz^VAN17vu{Mh{mDF~S*!niRs6 z`ZIv~(+%u*|Nk(7`pilZCsWXC_xkgr&>3 zmbNcj=26t_X$g7UQu>lc{{lpez4^9~GAj6SrF!Q4Kc4)5A&du2euVIgs}&!m%TJQK zNOk7hr-_Y_W=9@7$H2;H*rI&KrX++IsYou=v8^ zp{?yYl_DZVh&N#>)!3zdG@dpYzgGjx)H0tZrOD@S5B-XTY=L#WFq4{DwLw1bYoJ=WTTDN&wSUtZrp1vZR{9|*)jj)!;c@q6b`6u7KhlkI_y8L% zG9wsRzKJ<-%KM54rVZUo+y6qOv}0xAcQzQvqV9OzImEdld?!XM?a#&^w($^%KGmY) zYF~J=+>1_}e^d(HZARFH9%r?z6nMung;OFWsvR{+{U>Llzd1zzX?c zafEiEvq3f5N_;oIU6U+x*JaR^((hQU#Iv)w8rjmpI8xKy@o~Q)i5#lt*b&RcH6?$)Mm8J||oHf$FRIY>A5qW$1yNV_x{* z0phk(KcI}7%flcqOVZES7#CuyLD9PrTr_P#75+0;nD5$g8OM6EAW0S*Xm-Pune8dS zr2v6PQb_4TQwmeAqx`XPm5mIqT#j}k1g!pQ=Ok)eYTt-xKZXX8RU67MuJ{PAbtapl zvO_(8y`@AUnxElCCPraq!7ZIUrNQ6-#^l>V6DowpD9xGBd!M*res+tKC*;SMHp06{ zBsqtdfDDNQ1kd^BbjLRYIXXG!kGog`k{pQ}iTu1Y7a$CKA8XXd*>BOeD z9~tLw>W6F&KfGHTGi&~}En1UUarb!qsAJ{QGhIpdIs6xA0z~{qv#D7z=m21|WLGDt<7GH{#sc@!h`l_%72LYYV^Qzs0&(E^pc`7&_%?miztSjb;g!z|CIDr(ikKsgO+iz;B{JP03v$6QzApJjlK7c zE?nOqJ+8tHdP9U0D@}4f^~YQ*_onfAQOF{#O5cJYVi~jc8}eoMT-2IQTln_08=t-( zSd937NvXG|k{Y4Irc8Up-~IFbi^s`#S1Rvy*6s-@ zUqwK560Yk=8Na+P)6NH!6|nn{bniB4A_q^WkZZ6!_XfSt=5DU^B!7L%l;MA)#xqDM zyNHT}Z4JHKexECuAW1jt9%U2XCfG{K_~_nxl47(5cC3|#h;;6KdViydc2e)K7A%2! zcNdZ%#A|@=-}MzM2qR_n$a)(7_KIYpCPeMuNj4=QH;^2NrDqYn;Wkt9hY$9f18OTW z5$@J}p0FLtvu~pjM%2;4t@!B*uFL4oH~cT#upw^h*WJBG=glt~y!N?8LvVj$ev*`Q ze}`>PLy*c7@T-&cy04))Vf2~rPi0ZP+Y!cXoa7WAV$;eP=*R2rrgy&z1JSZ~hZ_nw zmhsHf%(RER+d|isHxRqP7lk$*wb%S~Uqq@Gda1^M!APf+J4h8?u&J}Ws@vMEb-%i7 zZBIml@)h9pR1eDAgfzLG3c>$|y?9se5tYW#5JeIx0*qE85W$4CKD2uh^Ex!MfkDAUP8((@EFpESjF1g;f7tq1P(PFp>GAPB5K1~t*e7km|&Bg_Kmomt5aZm9wn z3rb!;FxIFm3-AyY<-WU)xGVIk0(^O;_OEDxYoaSjsl`hx7*qdo79fGF)(QCqy4_gU zG*9|G9w3jmRpPWwFV1hF4Kl2x2>ZHz*Z+^z0NN|MWBMc(4P_i!7wy~HaCg+Q6u9Bq z9;vk4CkJupeFfToEgxdqFjw8Y*@$}S5PmGJL0xyV{f(UnXTy?et=G#r#~1G z(t-Y#?UPQW>j8MCH(&GoU`!olaF@%DmCd%etF8Un#o>?2tAoQ>78z;!b4mV3)lw}q z`}NT&J2o{qv1(Z$21r%x!^8R8S3S+d)BCE~4k`oVYEiA2nFs>#n;`E;1uhL5jRB*s z-X@1-MEqyv;`O^{mkMS@5}W)JW!MMjWceGK8j%iuw9HlB4=LG#H$M{sRIMMs@;LwL zlo7rFZd%{(OIsOz%{?hm(R60-7s*ui?7pAv5CqCl6~``Ae5S+?td^u=ef9atfM%`} zRP+6d8ECfJ1nXjdYUSvVb0l39scSv(Z^0@*aI&LkLkpcx4;ds}OsOia*CK$GO=v&c zL&n|iE2Qs#C@GFlw@`}oO8w%sr;4~dvm~o(m-y{1z2KJ^5Kr5oF#`-vuX|Sdw3@DM zHxYhFw4C~Lji5WMTy7rO;y-T)<=IN0gUa84P>xtY1jSS)htzeCiM#P?{Cj8DAMTv=c|D9 z8n_R?AA(|d|7QmBNBr55B11R3KGXpLE?|0y1p4~xs%(EE&?;OX9z<)G5 zeH)|u-$|cMO#Ipf#S{K}Oa}>Y;rg~iW-N0F+y~9l2Dz_cxbKw$zH766vi9U@O5tTQ30j zs-pTv^+W?~dXH+1znfYK?t6UeTG;$LmS7Q2?#o7=bWV@&40@c|%|m;vdF9z2GbpQo zIIG%o4Rw8zbs}$68ZkKb9Mi!}%na*#>qL0I!Z@|wdORA?6%;vTE+H%e#xfrfo*%}G zq;$R0qb0rb(cXyjIn(rUJU#Es{lh-*Y_o1(ob@W}z*@yq<^Go79zCB2QLgB1Z0jP; zCYrEc%!~AgJTAxhY{hfhQ`630)GYp%6>9A{=VJ2LR++^cWCddyON{}neVszInZ}?$ zLnkceJ=cj@wW+<{8D0IDO@-TIv8;!T_ z)ew&FQlkh(5!r0XAcPao%e}XZ#g+NM;|x+Mo&J-Y6j%%70drl+L2d>I&*npSEL}KY1Cf=eYKJw+m->@(gaD^vsulfHEEKR{!v2v! z{p-ikVzX=%L_8DdVdLZ)fB_1A{X?rk1PDW^Yda<+Pi7#T_z679TbTTi7BWx7B4BDh ztyzycZ(uzp{ZanU*|5fuhK>zY$PXS-+vEoeyW>zyItR9KsUYA7`L>Wd5{ThXXRfX= zaLyT2#1F4t$v%6zV3&PXzz3{wfbcb7zWqiCsAX3(2$E32>yzPNKbZU`I&g*h8>%E^ zK)x|FQ(!`Jiv@;P#JY36gKYTa>hkN`trJGTDT!4wg$~&ZAVe=+5V?9j3^)g4fM4ZG z$3%B{k&I;Ca+%zyu37`0aj$cwlNGgXM};6F&2EprWjc)%(G(8S_fL;kZQm9y;cZV7 z*>59u0Xo?FlWY_>Vz(T=-e#U%7r*y4RoMHMRs^Sa&?E0&Q$V+A4d(^HIXbsB>-M?z7ciI_>4lEOTjyn z&O=ZNnP`?#VYE`{uW{l=_Ie?o=6m7le{;5@B2a_gFd6%e?%dH1F7`+5r!!MV;Hv0@ zL9`SAPCIlSKk5TzNGs($S(k4{IPtZNS6XSie;|K&ymyt1{Se8S^A~}+0eB#g4R`^vYb`^+$A2(BnudR#MTihGAJ{3GFH<*xn+#fT& zKj4lGzxPz0nSB-~8HM)vMO$P7B;PT>b!`{G1{(KIaR+E+n+ue)vyRUGk2EO#gKnWY z)?ss4&>-ptQ52YGz6?&hA6dw0*lhE|cjdqlDgWM3s7#EoW`GAfr*hSfdR zAC&G$4tEF3&)blHVnMVICjoVU+4=Wzr+ayMSu~Rqohpy1B1c*a`35i``ub5cQ!4vj za|&;GThy)wXrlnA9+fM#zkcAgiInm2_By2vBl#SFb|{czmtcNixZsU83I5<{)aA_xT-yk_os zEM_ZT`g7dNT4~hb5HI`u0))=O#XjnyG2*fyYgR%_+{3(xL|MWaD~RqvanA)FgT^mF zISHQ30!+KtJG>y{0b@zl1RUv}mp;VYT?6QmTozi`@*hzJGT?d>z zO6KobwuZDKH;F(zXxcds(Ax2E8OwjC-5I1fiy{%K^CLK+>(WeRwZz~=t4SUEGiZ&f#^VoPUT>%^QJY=%FkWn-} zw=V+lhaa~t=HSQS^?`Az2&XNM5)B~$D6B}N^$(8A!#(tnk3Se8jZ+aS;%h-H`M`t%IiG{i)dpS%auSjT^>+BhT@a54 zn#d((Yf1MT14y{gB(B_~yZG^&Ka~xGW)NB{>vryH=q9VO7v{9ntal_S?Ka`QJnaL zYxMyh<8Kv(E-I1V>Yt6Lln1+gHO;efg3K-l<{F7zpa7f1>sJ3vb`Ovz8mIsArxB`< z?|6l-WtVS4#Ag@F&30%-cG<_V-be|kDbFHTb>?xMHTOh7RXzRI_UF_FqrpYpMRV3z zGu@B{K)>v~47#A@z&H!TD@d#|oj@iyRX;29OxV_OUjJ3$rQMsNQk5$EsSl;EjJ^q3 zuxp^+MrD&ZEeRA|d$>}QKF_iD(WciK&Ucpm&lVulvMaR74%jZfo2DI`O*HKC2|V}u zGXoHh)Oq*E>VF>l{x&Ubs?I`diM=dVE$?vc>P@LUsi*?5*y@swdYEgCoM#Iy98??- zPQV!uKcZuD>)do?T?pV=>pW`pT(>DV9$amh5Etu(FT_bRn1+gg0j7a|$H1G=Uv#(! zquzJZ)z5go`QP>T+;AlCKX{|5LfMaZo!?YA7`2-&iDlP1sLNuvLX0y1>c zDlqP6BQQif?G-yD&{GDGxtJaA{%cH_986#=&`N&e^le49$+7a=xFV}%zZhY8B&n-S zW^GupmToeWql*kS(>svo==Q~V6Mavx+gh{dhOT5HcT4B!&~t?$RZ^5rl&B`lhRPtT`kVkTdAhrOU-gaHgpB^>YGW+oh)-$p0#fJ>H!we(o)IWadne>01 zKFxk}l_6S18RP?KCBi3iMn63!FIgl{QHvjVeH%@x{U_P-Uf_$)Kde!ETygcjib}w; z0O^PSdm+EhNdiQ{)tF}|##`Z!D4@M1sW`^3#>KH%@Eb%H+~CNUb8dKz?sp~~Ua5H9 zE9HwBUM=3E+>+^ByKpKvtE`0uhDgQp;0C>zjAdJ0LAPcSAF0$ z@JQVi_%a5+SZ8tqTGtbuG+%crkI-Fiuuy3ZV5zE{V+1-G^=i5V*!24~w=+k&QUp~- zV~>2zS9Y2;ZZ+2&aL5(Wgp%hd_=Tx#T5eD$g@y4=AkWixwJ@JQYP7mCcO0bT>ba^o zK!t-shto^fE@qfY7dUJtYu_(8hv@1~@nDsWmm0gyH<0ggI9FW`Q%PM8b?Nh4CX`iy z1b{Hju{K11cX_JN68QH>Q<$L2q*}ksBlH*#;Bx1_>V5}f278en*8n{)Bb%=dT^ zc4;dQ$MHAZl2jxe+>dM@$Et*=S~`Y`Vy8ie{99_Zx3NY}Mtg>8We!671|IqraZf^f+ z$G?iw{MJujb6Q5zHIx5mZ;%fu*Y5;%s#Rk5X{8@{wN^LpaIw-}RbphSDwf+@(r8wu zT<-v$M&^g7A31h}U>1I%c`WOE_PSB+tI{*35c; z%y1*Qre$&?(LR)K0UxI36w&5iRj#~h#x@sZe)#M9bW11`N#{)8D@Ko9~< zFYLo9yuPx;Jf9dCwVP4t)oVJ}dQDD$39>p)AStBrv3Q^n$pAJXxcclnqeS^QD5m2v zy}{grA@;hl#T3A!H60>%ZS9DKICEQ{@H>3t05cRWayt=zNvzz$1XCPPcc#58s`##S znk4OgLSo`I)b*<|umtCspHy`I~jE~`@)d4Abt)z->S3iBPU#`(BYg%SL`8*`JU%AXt+?6q*| zeYorN+`cy4@;{T%3(U& zb&pQ*Sz_-cd&rv32SZBQ9z7^|38zw58wTqzpfY_f>$@+XvA4_d8!g6vgXO*?s{eVl zC~8k%h;Tm=21XSKAu|pn*Qsb<+92v?SNg}|ePXQTIN%loY^`b**tt+M#P7ta-CMFu zD*HZL-P64z<)>=L3Cx%(Bs$*^!-c396sQOWDH+;^h+3YL5Ts;hyOqTzeY1PKB#j3% z_Pi~UxHOt~&VS{dU7jsAiPPY4LZm{UD&^S&epgiA`NR$=XgbDyx{Q`uY?tdLx8Bjd z<8@r9eWD|mE|L4r>a*?kfv398`01bp0U*C}*Of@|)Kb~|TGo-)_M2CjA2RRFDR8wG zd)2+1stCkn+>=b7eVuER#`?h#Ex%AjfNCUcU+=S{uPnk341L=-t2`LT3;=*3+`TK!6ptw4FZ+1n?+?bntSF< z-FETx<~mY?!|2#ggy;dkd3LX8QA9upzrX>P@Ae(=)Q@<9TM>E|(zZ}x|MtRcQYqHZ z);ALihY<#}6*cF%lVYdO)4qvaQ7*D)7DU4{7>uW-jiVwwC!EPUnRv@lwv>@(9rr#a z&pLU2lWJ{rjyB(&UKIax<0epT-HF^Kw{Ep_*KIyE&>lwCM-7or?%-0B+*087q83#v;5X}-@`*@=*m0L0slydwY%W(Yt=##mtk6i`_NqFn+1G^TyHEm%mh1o5}))5$Fkp-YUEM5Z{#tKZ-v+?!&dKc4D;!06ZPI+ z!n3xWX=kg$fU*K*a_P(;rl}I^NS-Y1{;}3nvl}6D0)=$&MmNJ<=bdYT+mZ4*i*D$X z=WXq zpABAaP~tLLg>e!T<~XAa{%%ULn`I+GP(TL?zs;|{CV$G|<6-?P#7r@wDT`A~&FG!o z@7wnn^Ml>MUqufX2SsrsrT!fyv(7($ zGEdhx0Ntjjgnwdx#Zii>0if>WzH-oTW71^=q+Bp_!~97A?hrkpM#;wr?D&;PMAIIk z?A&fjE>b^1={~+xZH;9B2eI%tA7HZ;6~Wj6y8Y=rbI7O~{Ny|#UzyI*{zR^!yt2My zPI3#Ay(#! zpH&LnXvK^HmCVv|N9r%(8I;;!7^4jv+0$+Ebx)g;+v&1N$+pfuVgsG{NPvr!5o{1SWA zm{k-JfM0@Y(BL65$b{$4GmM+$er3nzQC3g1aw&BcZUhR5$%rq{y5D^_0>qn3&{3p56ZTdr+)X#)Q%}&ve z4PS~|yF3A|4}im$S!5qeiFuMHpglMA%#_O8h%9^{vt&P=7VnNR782W$^?`ZWu>2T<2uIb$|O*n(clTc(W zHdQwtf~zYMT05~XB5nK9^H)v%+q%_uU;h_iPX7rZEwd;1kPAoEoy*DsCGyu_cU}*Y z%SR(4DTzy0M~TqUuv!t3h)1%-v%DG;=YjD#CGol5+KvZzNS1NufFv8Th}I_ws_BZq z^C6~kabqqAilZ916wJo)u(5@gyGrCoEHfmKWy|PV7#%cCC(&w z%pa>?Q>-V+;{CF^OTfzT&Lmz^%%Yp-h6>|xKE?grXBouNQx$5dOYOXlwd$*YH;7Ki zBPqNcE^9yGE7LgmHDV$q^B%w6;i&8kXDyW!c1ZeUm5T*nmR8oj6_FLJ{>5YpL)vv= zEkSFC4DM0l6iwwY;}k0Z7=g06=t%g#ojkVPKEbb@!Vx(!y+h*dNRW;pNsJvN1jyT0 zzA)Ie`)UwuEha#rB`-cM+zrz4w9Iu26th%_*&vYB$lbWyYIaGa2YPi{QTdJ`Opr&@ zM7?eV?E9IYz+gtzt&x?i!RMnB$DCan5>A$AdR7wj_+$y4;V*ke^`O+Dz(sa8${ z7~JpAk2lL_7H_k_psmUFx(clLWN$k-<(F5BDcV8l4f0A~`lrf`f zRAsSyZ}zY~5njoOAoyvksEDss=<(Zf(F&r*uxX_b#8d$}+Hw#EvqQ8oIU*$<391$G z8)x8@6WKlVB@`7oUb``&)TJFUi-*lR@_wmn^dkYC937S`f2>l*#Tq%^Zc%*am)7F` zph(>w5M?Z)y;bDxSFaMKJ52uztG3t~sXuEiwCaM~-v_U%%VT>ShMmN61hKf_?axj}9L{ z!eFrUw>Tgscg|@`#hEOua7<7$R+#n)DVF~~9fB}cl_X{5M&DTPcPvsO$3oGb3mzg} zy%9c&k4`sp=Cwpzx-T&QA5&i$7FE=?D=8o~bjKhKqIBob-3`($-Q7cXNOyO4N=SE0 zs(^Gz$JxGLoqt@gXUE!$HP3qPqQ{Xc4mcqFcJ_Ix;H`&K04?}##?{7(($X;2sjt^8 zTr&>p;3oHk-pb+h3(zVrtjNj`a*9FnY>y)E8CUMh z8$NZ@xp;Y+fupjnM~lr*Uu1gkoO+yH{oc#3dJGa$`7kUAXZv?u2B71Zu7R9F!mA1? zZiiNH1Ia*+r$2eDftGtGLPgFsxmkNZBLMmSnPhJ&Ez;z4n0N#0iLe&|t&FQBNYuvs zDIS7h`=@{&unH2V8D>l)Y`G=fKlc7M!OH~(>jh?heUuD$E?@?I^1Ae?*n-H2ZBHtL zgIM}k>qjUaJ|+Bo(rr|;np6FXl2`&a!364mCw+Nx1Okx@`Q_;yzMn)$uHW!i>6N34 zy(&m8qha;_PLsiI{f0T6dJ%AaoQOZ0#wODNiweFq4#$61Y)nj|jU!W<6aTq46Sm@a z02`QGXXd5A0Z$M54x^W#lJs61W$delOle&j)Z_UN0`B)5y+cGW>4escD79LOT9v*D zFoi-zn;{_^oO6as{c!5~N{Jf8)G*Z;mVVrPGzMt{XHNqEf|E|qF*yPckkkLoAEN(n z{)C^u2P@@Z1x086)4#Sgzev`hKip8blHk=ucd1&do++K!80Eh531bO)zv26;%Ia)r)9VaqicF zKodR{i=NTSXOu27DWGelUQs|1v@>xsUs9m!8BS=X4Jw(|>=GvTJi+pgKHYK~6C^^EVaBCkZtPTOehqE zn?;8fZ+SX{JM*_fYRI4`I4Dl=qsrk32eIZCQ(W=ST}Wjh(9T5qoeWX~Uf8Df&moLS zOi&jRjp2>bI%kCq`)lo`Ou19B)&XCEGNu6R)t6PR?}y6<10#Vvzdk010@AL(2l~u4tlm_P4E0G1ze8*IBCNw6u z3$06-vL*`Tn}$ngx6|byZ58%qS}Grdi{6Fr=Y2`)kRd`EivT*?_ES9=*HGwmuApj- znh~{roS{$+v1jNN|5dVbMfy27gCF$C>g9cfN@D{;YFUOdf8}*%5^dLiO_9o|r`})* zNC=|?oAc{|Lo5YY4hu9-i(0s4cfD2qr zSa?rcbAPDTR+Vjxoc73DF1P$WRz%VlJ;z#mH+dxUw;7~dfA-Dw2g?&7cKCeNcdVl+ zY>-s;oBn=-0ovM1_IE!YFU{o566AC-FlseF7kz~}LBfQ5l!PJ~{B-^iyBB6fg&Dii zYs1(%eHrseuewyHGq*I{|4#DHVFJJZLx{{zWbE0*2qZ`idpY$lT28CgoRodwAF^Zr zV2DB{2iz>vwy^oh$nVqiagX4M)j`|Wj$3hh&19EQWbnX}4BiL=Y{FX@5eVc-FN7A< zAAYen!WXi=OADgh2>FX=rv-7` z{igBvJW^%9OI2B8+ngJUM-! zoAR6rWBl8ZQt9TBYk(!;=HJ7V|DQRwou$E~CQXRlYr+)VVB9Z5BxKqZlt2g;&g?avUzz4y63iNP(bdX*fVCUiq2YNCK++VZCD^=pdcfi-nEN^vQJlb{n((jxss_}^WQJCJ0v2I^!MNzJ$}i* z-S1t^kA=y3{Nr|MmG-}Ozn+k)(`AUSJ};MOoyQ(dj*zSWdhc2N##@DLhCKNl0_7V3 zO(kGnuZ!FL7E!5_yIiA}$D8v02IpDG^B1TRD-Z?8?41{#?-HX4a(FwyzLk-AqX;=S zV~wfsJvWn#fk2uulI*W^n9yLt{*Y08^bpI{@zzwAspl013Wn^ND=olEQk)$*j+mWZ zLC@2az_EZ7&QQjAGty42#aPSE14;IQy{rhSbXVsqbbCCJMm8r>nDyY9d%KVxHfT%r zc*Rj|x9}zwIRTGjEc7{G)%}vUcsgF2qsk=wR)zSi&JhU==Ji2?e)3^P0)E`J;N_1% ziVnkPg#3z5P?b=+*;qXUVhV}_qBN~Mwm{e1cb|y4-CXNC$BBE#%Ij)R{wFZ~=%+grrtR0>=vQ4(oQJ5TsK9BMul9?x0%vyzd zkOG*6DTen%s>WS;f?@ry%;@uxKf3)U7|UkiY9DfU(1zrZx&Ip+H|+(BXWmg_Yo zTWVnK;pYGar`2#<-57IQON~1z(MPX0y6$x)92uZdl=K`eYQg>S+Fg6KqVgmCw7=x} z7sML2N(?&NZ7rxJ&8;wCdSRFE)94UQ%B2XLoMjfnd7$NnYlq%WngexE-@4QHy%+Qm zM>!$L@+2n!f%@`PcIxO5ErmQb=Qmc z$r=3I$)@h=E*i@$BUo_&=8*oSkhs*3?Z*(ySou!H`ow%G_i>-ztGZTwx-mkGnfDqf>R**a2c zey*;QxBuz$H&dAeRD1|&G}x0( z37**uZNxA0O+pV-$NuTP`bVf%S5Ulef3Vf0Z;^ZCH8tmL$)y5p`Sr!-=p!-JJGCS-L`igM@YkUToNQIon#9hC~ zY^Q^({pp2vS6|*X_s*S;sifXIuweNjbld&q7vlCe>qlcwW;nYyN0;KPx}+Em-uF+0 zj7RkA3!atb*41GpP$WG#FhT-uivwIeyIsG|k5PE48BsEUJTLXg!&Ksw+Q7Tt_iVH) zx%?o<+Z*{yhLrgQZwcb|?TK_ut00aK*w$X8Z!TaiX(sU&<(EeF?j?qB zeHyUGMf;CZH)S1nS(v@fK=*R&(6?q&#QKIOc};310tvJ{;Aq14T&yUlh!5h# zFyQV36M5*P6he{OF#uy<)8}PIvnZsn8v!3}1Vl+xUZQLrmnG_^5|$hZfBy$a_nLI? zQ36r1JdBh0mPa-`Ci!>^Q!_7)MfL`4n&RcoDJa5jHMS4ctIB&F3BYuwJsVR5U7h_ z4=}kZ`$8p4leA^kgLOS6`bN2y6+x&TDKnS5LCC08ce>6bwt^@kNh|W6AI(>8@Zn!5PX`j1b$)nu z={*;bn8@aom_^!N4;>V8U&X6X{vhP6)O`E0cYDk32QdtS<|piIRHZEnC*_`EvG{w114y^d_G{bw5&HN3>FQS*pBw9_14_|0?mQ~LP@%b?K zN$0_>P~)USRgR0RQD1ZF9eRhXUFK39MJ?v9Po~GHqSER-e~fSI-)NvgIMv7lYu=LI z_q)IOITK&cMlbnc+(~~b8W~pwu)HmnmCJXrnqML&NQJ2=HIB8Cxvj7ml-Px+rOGyOX5Dav7Q}Kx=FZMuKNF$Rt59>P z6ZRhmfH+VZ-Ymigoo<%C|DLEaTt)Wtbmxmm$3ZhPE&sO48^*n~DFJ5Zc+3aer94{4 z+a-PEH12wBLWm`vz!%e~o7A<6h)1lRY>ALZQ@}%cIQJpTB=gKntX^IdKPdSp*j3dMOT|?P3+8 ziABk+}j#yASu)pQ}DpeGv&-UiM9L-3WE0VX< zN=>2#o2noe&4#k!AGErk(RWMorYaz^4y5w1(O7IzwbV&$#Xp4ZBTi%3$8MXyi#l-P z2PP_rhhB*9XHhY8??Nr zW5t{0nC1-^rn1Pby=oA=P(wStS{2SvWHH`KL#tgDOD#Ucph*5qgpk#Yl8fuvNy+Dt z;Pr218r^n-(DTD@4)1o#UBuTwz51KIzSU1K7*z-mY17Gs@P_>{$qPTFclkU)p=}bt zw7X*k>dxKq?GL96n0@x(qW-=-XXVZWH8(Yzt5RmAYizt4UEv{qYCBdV*ub*L05T9r zeFXe);swms9WOcYC|H=l0O5ZV80zw`f}N9(IGE84dczqJY06MhM1OF|Y~saAF; zvyD9%J@D@s6h#Ag7kx|+=uJag=zl(oiG&2-!wz(I@8;MjPG32w`YK<=X!8LBm!And zaP#JEZohb|_3Bh884#mS73Fof?lX3yI!zLjA z+rpKsV*1{%VS3!&kXHVLxljgji=4rFD8UdIObWz^_ zKKq|X+I~j`^cPw9VRml}1z;(tIBV7*mH5>f>Yf38`aPna<)-Z_RT&{41BG2g-cZ{p zQzGu>lnqnLQSs5V zTj$2Tv#M9zHOR@P*Vpg*X1TR0D_7I57OB0o?fY_I-%Ix6q1C8rB)$;Y;ukeO8Ptc6 zEo86zC+Z<6D5+w(vgDcvSk8WQ`o=x$w>W7(P`+SPT;$;#11eX#gHvDNNZdthwIrET zWpd^*Rq^)1hF9zP0~>OWHji~#HZ3y;c|-gd^aEG^0ZbD?dcY~ zO?s^IB^yR1kp<9SbGtA6b%71PCQ6!K?~IT*YFD#zLIqU?kNCPUIP90Ro}Y{N`za~D zi5pS_uWLr`aYC93>Gi_x@sRdrzqI$2h8t!M|KZl~R}d&TwgUiy?7dd~wr@pBuQa*IR znM~DHerR{~pucSRP z-I0u_>sdwgMOmP=d{Y&! zyXEizt=?Iasa_4R@!DNJyTu>N({N3GB6e$rMZ2FS&c?T=8*=(BcMe+3ple~>31L4+4jhwDDKsFYM0LB1^ld-CsD#Z|4Y-A52Dt=qeNY$Pj6 z$GzeFdbSY>a1%r`OJo~xI8vig)Lw!V|4FeN8~8rOzCY7E8v$ME_`*mkKqu~cO|}-L zQle1)IY^2IGotP=s#N*D;#Z1Ef)N=>2EXI1W(2TAwOL_wfrJ;wf1Bl+PJd0~JQkGx z^2I3Mj6rhhjMZ!`W3N(=-FQYB8>Zf`@OPJ$_w!4u`$BJLvS2))A*Jux-yG_e%y-W zR;Bn%95}lDMv@jhK(yPld2)ESvv-UGShdniH@pvm)^$UM2oo@Yg~%bv>3%@lTB(`) zvB;`2mfdv1Wic69@&i`Q=UM7`V2argF*q8(hvdb$bMU%(upF&MjAH;N!71^khaw>} zBxD$QeGgfW$QAM}$@3QZ*X*Fo;uR}FWy<|QCT4I~xGjmZmr0OJuS>HOo6w#J^}u_` z5+xHXS#dO0{o_*3{O#)HexJPL`Fy=|i%xY(4(E2g?FMzcc)!Q*K7&liXM&^&Q1Z4n zoe2581_zzJP10rJa+*F+*z&oGkzMDqp2+cBZh@(ee{>H%xGGgl24oSP*TfW&9!G1; z56{nM$#jljnIu6pphD;?$h^S<%_Jgu5{LrHJ0&iJbJ|t3x_RJ=-~W>1to?vohl`87 zTpXV>m~O9&NR?vwp&a~c_33DKa8*n$EqH1}Q0&4FNfy7=oy>M8dQS^Z>&nBwSSnjo z+-G!o@A>?0hf3ybM?r{2#(7K$v0ME= z$GmJDn2w2qNDvz^?0`8XM)2XUaSI$FKT(-1c0y|T)I^}J$bUzm2zlBl$M{RcF5#Nf z9ZTYLNm+4oI8S>ynw4Ta6lu2%@x9w~@4)s5KL3?N>u=OqlcI=-2xK7T3;Fpgl6`Gd z-hF@LODWceW+HX1YAlmW+4zOrp!?hSJ`7-Gl9gC0b)qDc-F`56lon!{ib4L8_YW)b zx{bz}pPY0_x-;%^=Zc>}caT7D@4mU%dmDBI#30eRu3ab7=pzoYklL+Z@(cOn$w(&^ z=b;idrq5A`^yY@}o32p373`Lz3G1DW1A8ym5~9{|1_z%iS&o`Kd{QHrbh#b`* zZmCP_F%4RMn(2LIwFP=hCJ*7{ORtkQng_SHy|aoDNh)jCgmfaJt`8~7Q-slPD&&f8o$F7fvkwqJ;nCEo(PEeoTj1mYHh$Ptc=oA z(HZXeRG0u!qqOSuyW;+@&s^%Yn%P8;>;(zWlC#piNflQ>N~mICX^inytgWh~Vxc%2 z{vmqVW8W7b$%!~f$NzN-ZiPqS?QNb8noYfbujg}^jA_!sZMUwcYLwfgmD=m<7<$!4 znW8uOAD*(e&wwEYyW5P`TB99?vj*S`L;F@9@ayC;?rOGMN&TXaVH&$%lf<{055!BV zyj&y5DGFDjGnenj=Tux#j`7$$F|Bp@5eGu!nXN91{^^GPY?NX@C;z0Kzn#fxvwP;$ z;WkMCH%07G1V(z|6)utLDo=Isq{wauXwd&D-o9{NZdqvd*NpOxBWKM4BAvdki7Y0% zdM~j$DogI`d&3oN$q{fSAah@8v_XtYX!|s+W(>R^hwc8Xmy_?j>Qn;iV%LM!oB(Eq zJe+Z`-R|M)FUhpgm`8a;NzaY#3)|<5BLw4UGsd+17X3BWdRc+sL0+Q+cp!hQigkqI zQYM8_{G{Vq4z2^;MXIVnWoIm!hav-IYMpHj(A8b!_NQ$hiKyFA*v|&pkQyB^YG$=1Z+I$F*A3&~4s1?qY3k@N)t1Ic2k6hN;oAh#Fxx1@P| z@SE+-o8dSHiXoYlM$6j&MuAu@#-#z8c?rD<;w+G2x8mta)S{%05ooy&I4W9>Km39Z zm*;FUtk5(2vu2U5j~+MKf0z~UV^&1KSAEy@(hAtN=hbwFt8CaW@`(d!pA=^qImb<_ zvYemZR=StsUDMqOJ|6cS>Lzn`hcBKoWpkzAO=OHttv2oBkZyEl$M;Uvl0r9K4+WBS znof)lLipSK!yjC2?CKR}YAifih&cW5ilx2|7qB?IxZ7%v|4th*5S(qb@#l2Yj<>%& z2z0A9EbKSD7Lq5`T`~&~kIj@8dWsJS`+GJ4dGV+``w@v4n`JCgvJxyX+~#p=n!)B8 zjiR~ncBR0VUsi$-m7>JU)p`nvp*o*Iv@|Dpr7@2~a=E$N8N9WeaJ2dIkWrELT@qLc z@Kb74k|Y$eDp3pFLW5icwy}2f8(n^oW$<{uuQqI-*(Dshi>VL`!0CT<9t1=}7L11Z z#tRnb9$pTwoZc+oqCrXB79YuP(z~baT62l#0V0sPNgp2wv0)`c;#)#+=RZ1)OQ&Xo zoVZSx8yDX&ph)Vx3+R4CKUvQw5;|SAUxEC%ZS{fc!q}fAsP&==P(+?aS!gk_A4_S? z6Hsb@(i4HSk*KJm0Xe+kypJyCv9TOiC!4Xnwjk7{|SG}=hCmp-)c^vJye!J#+HU+I1OwQ z$TT>Z^UFE+uQRkqE~OhiqZAB4Y^TU<9cs0~wB8Yog2$W(HapICzc;72(EOk!J+oJ5 zPNCF+b|A6!O-a)AbJa87ZL1eE$s!mFTA5n+_wab_ zSNjFP+JBUT&dLZFnM6ymH56y2Ddq2ms)Z{X<4#vFXSUlSGU_rA7sy?fj!(aQ+XrG~ zHM&(j!2@4i%_|Le+iXBkHwa{O?{mfPuOkDVvje}?3k88V;z)7HZOJcI$rbbu^nq!J!w4T0S<0Or%o^$X6Tc)~2eCpeHE89ZIHF|3yex2C~^I zpg4^yRFU}>kLEH2J<7e2X&)2;qnu)H$Az8Vb#FDIuZ=)9>fD#YoR}Q`+&^4a)x7YDg}Qw9fB{3^j_b^HLay(t?liq0 zth3RtSi#oKW`4r{3X7Hs^ML5nm5Q9xW(tiZyR?u#(~a{pfFh^V_v zl-qHyZaN;e!R?SY^u6$V_yQUS`zC-S6aN^B6K`&p`Dxz)0PdRXM+Dzd=(t4 z9%+8T47o8ab?7^IStxQ{L5wB?1ug`(tNw04QWD^Eyeo<{CE)+^k>fG}-ZId!JMu3s z$u>pn$y`_r$AJRo$wq^$crw6in5l6=%UK9?4wDKv20C~Sm-CW}v#HIxDIcbF1e3

_4{|ZF%95zHutl__`KJg>dYJnkZAw%Xa1@iGC^w(bo+X{~Yj< z;Z!?X(ElYI_5%W@+q#*}&U(-T>QVL6^FcpV1&|kY$(7afFY`_3w#jAxw_OImQzUDv zCBE{f@EfuMfS)r{<~L_g1>pjqyo{dvEiSdGN|N<>&xfD%DdYGw70_&Zh}kzqg4{29 z-TiJq%o@8UzmPv>`AzAMD+4dvXp!GK_@Ai&O%M+fj1?|E%OV zYc|TZzt`&fSYm)+E>UJV8)>JMH|eqW<%;k3r=7PGWYj-`0GgV&NwwMNu|kXvdjUVf6?gNLtr1mhCs`m8Etn>bP>Qd$ z#$=}k$apTcZYEZqhB1;km_h~Z90Fc)%5Lcw!L^>5Q`f4AMJ@pT(&6(GJ?1%7Bji@* z=X({Ykb7R}-D*DvxKe53GpJXJ8E_EC>&kHr#63LT!dgwvwMRN*NRsW`60%v)#>r`~ za;%YeQ{Y5;YvlP0`j=FuJy`*kgJb7F*Sz#)ut?uE-C$MexCoa?C2@nzJl~H~%dQDv z0L+b3k$JYB7Z*^EV~I9WUeoMr?cK!=rfFRV+p>cCr(uS3s5Au`luN)yrNo`Z%|Kj{ zTgJ}Jz1!hf;RbCi+2_ug2CJcqe=rp* z!eGG%>qYyqoM4>tIhF00%Ioox#gJ@0PjrkN5$otCIG@Mg92FWgaZacvEJ{W1orfJy z?sj}r^!1MMsD_Y~Va%6mt1aSyOE`;sQ>)QPE0)a?@C?yl&Mu4(@Oatw_->B(xC1ze z{4tx0dtbE!Gf1lpSePB|g$o@7y-H_rv)ZPG(ZV_kiXM*RBg<04MyNJxh{+t~Bz)OI z{k%M;{N6)wXLKM-dpUL@qR=_wXaW=Tq!)C@1oW>dC$0a^wKLH2QQUPi;97l-%eC6e zphmf&uziQiI9a3LX&fki9VGw>DV(l;{kq5W%j)+xkjrgL(>jmqru74j1nO8SRoo&{ z$-ZLl48p7GPydHDm5VaBV4kHKrT^gq{QjxkA0?m~8Yv@$;|Ufua6jR_&DZmMSAX`l z%H}I-e+Iw%)mH|D2--)7F6Zg~WGY1%uM4K+1m4UgnH1#D6w`q6`^%BzyR^L^C=$L_ zH782Ak5X&@aTX9}J@>?M#l%jEH?1S5Dj{#SPf_IgSZ}z3-mpX&>fF3{=ceu_II#PV zJ;QD3v=+KSZ$kt!kANnc$<9suVoN^v;$A^x*3W;RlevtA^$DI{;F1c`?YR63}OtD?}xG_ua4S6wW+KDX4xuIy~c4JODbvrFRJ;)o`l#6(@Pv+mVrn;%!x zv<@4;Th&83h667~m)s$S)-R3MRfB%skHjhChB*SYYo6Q7Z={$oKR*$@pGC4 zMY5?h56)mW5Bo`F9f8y&;5v)LpajXD`!&Ja7#hQU>IDx@hFYprqV7l*14vHo=I{OM z10{9d5=;};_%f9@@v*ymh-zxTB&?a7$0 z2$e03=U$sr*@6=CHsPU!`t7e8(xfw3Acu#yF*y>Bdww=V_{^ZdAA;Ky#9+>~*0Sp3 zL}N+MLoewkv=VACrXL14*k9V#pf&ozJqhO#^djIt#Hs!OH!xIR$WS_TP4Y2v&Gpd2 z3$;Z7a^Q16@Dc6QxS#BU#Y7ehdKRv=UsBAaYz2T z!Xf!Bc!_e+nQnF%AoOUB@}erN4Z{&9m*|T?j``b@<}8$Itu__c2rmUFZK0&D z1fxLGtb69%m!0g42xr>5AG!jJK(c%yYu%QAZe&$J%eTH7l|Rg=e7jVSp>}ds;}yJo zXV4jf4SjI$AFfKpK^pYMldHPT!IbO=$Hw7L-AK%iPBiCDyC1J&0-zmk_e76UWVIY` zxeqqsDm1!?_{jN=)?;ne4<$zJsMbo{y$@TlWyWIDS%W4&f!TjrM*Lb&)#lEvBG(&z zO#_4L1ecHXs=>?CM#qwkN_6wmLG5BIcKr$PiR$*KZFt#j zmo!pZC#QF}H&0T2Tt+meGWz}mNR;%p>u2`M7gNPft#-7iLOK7BXg%gnWOYAu~kqI09Fljd395K$o|g5zYPILmH8Tsk$s<{0?}| zJSYT=H&LWnAV;WtSoJ_1K*7p+%y0Bb^KQ;cr?jdeD$=i@m!H!?M*$(^AW@z|6(DrZ zhqwLx$r45>g~ZMA5hCVt47%Bq$a!n2)#Y^`D^YEzViK9qzYGc@W$nR7r{qHz*0D3VsL*Ml4hbV}nxjr8+ZLEPsDwIIb zkCWre?9}f4@p3cAyj$Q!xlpjz{al!OV%v}ZqB5i7Enh-F=o!@DEZ;AyeA$&5pd84z z^zwJeAO)ShdCR*p#2l?GR(IT}FLRA5p7ZqZm`mBIRWseAL*yvnfuVpkONGjd0I9cB z#4~1jVABXCn-E?bQk6mydxLLiCx-qEJkXC85lB=Qf+08m2*^L+Vn2`=RGTT%B#h~l z&H%fyv)NO-4#osj%eT9pU7PT=7J*G^9GwGmdQHzeXWn;nMlu=vRX_7*yUv&Iw#0aI zUw&i8T@~FYl>v#(6*PR0F8PDr7`huJXXk=)9&ub2`=kYBmH4vUK#od4gw-Fyvo@+p z1bAepqjmQ90`YA%syXp#G9lFbnUO>{LJ04Uk`5L4?tr)&4`oYm4zN0xEoXLc0)%_x zQZ(AZqO#T(%V3pKsow=1d>0Z|jQ*z#F-S-%EoZ9+w{9~D@f;+GF{Dy4x}4_w;^yGp z?NB(pf`pl1m~z-bZSe=-?N_)B^jHz^Jh9#wC*gLzPqDAQNPwXb zL(fiu$Mv*m^U5A(_l&I{jh$V&6BLebP*t?r7qOX1=xhJDTE7?ZQJ^8*zR)rj2jwt0 z+xvL?3Y~OHyh!pSY*Kq6FjJRl@V*bm2H=2+TPNjEMrS3^WJ$oQXfN3&iS9QIOEOUm zS^WlExlf_qJP{#E2g~4lAxaXVbD&OfW_{`Lr+{*v2^4xWgG)upgiJ@`GyVd2kQ#s5 zd%eTa4mTGClI|%ckP!q+*k59){_96)LP`##VWHP0B*d3zqg1HFO}qFjI1~(Yya^c$HU>#*cVrY7pV0|^)2hXHAlVTf zFtrGCm#Qwm-y_;I67+j+QxTlj2E&%4%W_KN!gyqIxe8XIzF~`8a7cx2i@$LsuU-LC zvQ&l7xpK)0P8z#{WBUfp$0g={@`<zEk81i}r)r zK>6~I!v3Csx?(+qoJA3GEZ7`%7-g$f9~gE^*sm}j)wEr^AHk=FO0QLw7YXg#y3?}( zTD5F8dRcowT@(!+O_uA$!4N81R}^YD4WS@y`J&YyLYxnb>_JV9M=@grG!>1J5!T+p z2mAd3zJvG0ad`t{*o@<`)PM&VR_5C2v_T-?Tp?@g-aDC9UFXNR=bY)(+@zc*`7j@t zi_EtUQ!6B%dMW{JDktH>*j^Xo4jcaTX9bDZa&-E~a3YS^)!z>JdA;79?;lL$wXpc; zwN*lRemi~OKSL!J$65IUf2MYby!?GxED0|E_@j2*XCRfDy(q3}exfw`;74PyBB@3K zT#M5mm`z=;VIG^_l@&@F%ISn%*0u5{s_FV<0#8 zM9?iI+Z^$+iwt?S(ONd|uSB0&g}R82KPx?~K01V?-_&VfNpH8U@n~(9$MKrC5fVZo zUO4oI2y-SVKt8=b*_tEXiO=l#J{ecw$%7Ob_l`io)mH`(`$2&4Te;+k94yMKN0E=RHV?q&FWkvIs zz(fvD0G_DEoypEw#7fx^KC(pDEL__=Rb$h1c#y^IlnuO}vvyhctpkNPG7!vjI6vWg zH0<~=3XGU&TGmM{(3D{7fh+_QwB#@kNTl?^-kVghJ>JLd;hXIMkp)lWu|~-KlBBtc zk*7$0ojw1hFq*VXzLINxCI^rmkEs;!Q=i3Ki(@tm>^R@RiPkXuA}9od90AKpYbEIl zD2!OSax%$cqnJ0ka#|`@e{kKimwXY{d`p$gBts-o?`(S+tTJn7@(4!b?>AR=KN*wb z8h)!uCR-0tItpg}0x&40C-@6>eFwKqN;P^-tBaGJO2~-T;Cn#5SoH^H&hBR<(I71b zR}Af1iv;96mRct3H%sF55{~jj`WGgKVA|{JYpJ`iKbn?(5VgTz=$jpc-FM~?NY!S2 zr23bcMr=RYGJO+6Hz2emhZkQaJ++odLqt3ZUvbTjw=O|I5n$|FEPb6spjSY` z|F>L0kz$bS%Gk2k9`rCcc``?nK# zATpge_lVzQ)=T5>8}leAciTSxpBWWi{3$~?`9qbBPG)!l z`SLw6fawu}(yK%N>Tn zOvmJAJf6&vWxySOt=8?y0Sjv9>mXz_d~V2}S~$scFLV34hN_Vq49S2pR>w;kQD+Cy zdK|Zp*q?2xPR? zmn|KrL9@+vR3#JWN#j9lZ0a>M;5!-EHNW!|1b_l@4F-Ft`>MfCsoB3Nko@jr8pa|9P;3$dPpWm5Z`|zVt>&!82 zAZsXwn~AkP+cNbsHV;H}DM#uu4?8ABAfqO#2}wNds6RX3;UMM|qQP;teT^8;=mKJq z(tRmc$CwagfM{Ej3~~M2(ZWG1abB5ryP1#x z74G%mud3E&J4-zmlmz&OF?Aij)x&RUfAr->{6|%nWv{&<&!}VhzHT(CPrXL@D`02= zA7i8(0qyh9UAR%M_(U-P!#5!Tn?SG?E+nV~a~ot^<&gFCfxLw-F4BZ<=kR1P1mIYi zoqVm%*wJrB)^B$RZML^-^@ePLTs|+0xNYvnMA55ilm`(a)9rdRE`v$VAd_4D{%ytpLok65H}U2KfUk07G!{segRuObzPiD_wGn+F^=$WMM9ld`(b$?Ja}- zx1Te%1eA$%mG9p`Kbopd#)l@_ibH$=ipto@hfvI29X0vB*XA&+9JH>;`S}@4nvLIM z6@>3B6xB%V3dbl8Fce{hqcL`!J_4ue+AhO;E2wT>x@9(&%<*=fxqtto4drc zpZ3$qNi9={#ZU=cM;t0k;=e{{g^nzWqbsn7(7*DftvTYYd}{1V8|4TV$n7cAgw-Vb zs95q{9o@q=<{_N5v+UsV&oAdC90r@V_I94;8q#cR$=Y`;;NAZi8T9`^#iyzVoC)Ff zf!++TadfN&zRl04+nbT-)k0PQE#n}*nfiqw)Djz@Ur{N@?Z5GQ`|%jogiFf?K!pmG zpC#(!JeiTI#Ujfhzsg$BS>bb&3r*=R+7)w;uXXw5Kxa}hehpnc#2iabuE`8E`%p(h z=b*xBZ)MEJ^3N!6{dhViMe7ai5j}lTxfe!tm)9Z@7_FW#rH=+I<-F`zGCQMb(9@u# zKZ~bzIW$9s1ou^?x4B8Xogh5>qc2no^`7ZusAC%D6N$XBZd35LM88o=`mX{h87umR-2Dpp73Q6)-4)+%aowwW4s z1^M0YDNh00^7%+R6%d^(YG~DFcI$i-WUFy)d4+l#nd`8^^@il=cS@P;M039=qbrf|wD9U{b{%}A@!P$ZINswxjOf{E{6*Gp5jHg}{U zD|4x{x>v09dw-xvU6oytUi1kf4L5LGZ`A}v?w0^3!iG3+v_o-bKB-x@e_w*jU&+XJ z8_&fl1|p#6M6nuKN_Z=KXK$4|mNu1`Ut)ZZQ9*(6?rL7~C;#I6wqzL+;P;xELQcSSS)>C9l*{qrdl zD9%<*Do<@!M^|AAee@EtOq0e{GJHwE)f814GlWN^g}qEeMOuA(e=M5K)+XjMmUC*+ zS&)Jixn8n7O8P_6c0wWl0B_C2ls=l*CJJVmZo<4sy&a&H~P z2dEm|p4W)jVy}!-?sRLr%>N4>mh>FH+n+(b;SWr%Rr;jUVnjX^fi3ZIx97iflqifV zIbD}fkO2i%H@`FE)l@0z_dPLBnd7O!yD@SAL6zM9HhVM>1r=T-KM?8*c%J*~tzK-F zKd#)U2V-`!%l}{1`sptQu&Diyu~~{6Ye=4<>~Dp9$Jg>)v0ID&3Q|14h&uzJ-eY%+ zN(_yhZMvZFU%jsUlce|5@!X_Bjk_wD%4x7$*IRYX#p*y>ithg8s?afbgq~}A=(Gu) zULj~ye-e>{&iQ5n$AuP3gPK_uk6HGo&V1h*$V&{H+uU81wHzCzKektsIJepgQYn?6 z%-S4gHgU6R5u5 z0pX|)x&y&}P0nDJjphU&d1&AmAysK~-f?^5ywK~DEDV0_+<6Zbk_|TEBqB6$Z20KQ zHe0+dAe(H&${xYU{TvdjM;TI!mCsBSB^AehsG{1Ux4P2SG*Dn-5nH^QGel?b0lJJ z&$k)eR5qqkl1NMaKo9c9(_q#qhGtk(WdZn8TR7X%1Rm#Z+Yt!L34zbsxr&bhF!*e5 zwBjlrC)MqkH@v=2nDV5hAH>wQI&sm61my4QC(p{<&bWfs+==rn*Yb%TYpvEeDR}?hzcUYAti-Fw{%DehwcsmC6rWBy7K_iDcvC5-65fL zBi%?NAs}$)=)?2*-nH)i1MXV)*R$SiW@g9C?Afo^4(lVx%wbty37W`jYUZU>dsTdW!c(j(n9Aw%^kl`DIh+6dVHBw518o4=LP$q^ zR`W?3$!G_-R~~);kqTgcPo2>TJLr=;;RI2;&V6sLhho)vlnUN>k3{9!d)Z^6mKhAo zdZ(ow`{jl5e-bhz_A>EIhu-THhiG|S3WDMzL@gC4{8se0qS{O@Z+-_V8CE8+AUrJ_ zo=U3avTvkQ*l=}u9D?qj9jU>&3av49kBa>WcGA@pv+bDuWLsxQ0M|4qX-VT=??*HMR4A&Bmi`NB9n!8~jsq{d!sLogX-*}Hs;H*2CePKEE6 zZDo5H&;2FPKtr4wFy(MQ^Px}Lb~Db_Re7>6somiI7Y>=P^Lj!NTA5r^l7E~tUuCr! zfk)x7acC%#&=K2_NN2w@)tBvxd(V}}b>x}%lT3zn5>xDcGcFAjoXj$$ZUFmaV-x0r z<$m1Zj+|hLleQ$%(%t9dFbGl&RpNqqkUjLR0rfmnwZrcRQ(fpTa3^NwODzQ`i==Dox*!S`=LZs*p-0 zmWc~*`WIxIMu?+f!+^yScy-6~G7*C%34Bn6WN>(+zPU1@Q5W2p%6{OBg7gxG+)_aG z3XSRE?+2o_4l4;Mco|+Z&5HpCe}S2i(gNSWX7P#yhJnV@((2944nrPzRb|BWUAN|q zXm@`WwV*Pum2xs{-%?R%oN|GN`48JOntS41DYZ4aU1T7!2R zYNqRPLFc?E#aNYUP=Wgz@_;R!%A=Tj!BzYIKt+}NyAPln#IySQ=C0rGcm$n^#uL_R z?SMuHFWx1pau>Q%EMOav(ff2>oj%Vd`h`R|Q4-a~Uc5^qDT(RV$Xoy4$p%Stkp!;X z$)=Urg}%*bJ+j(YVT=JdF>Hg~c|>6*DrZBalEojsXWIk}fY6{v^v?KL$E26{|1cU$ zjE1TvSC?%*KC3k9fyACpJf{^BW#R(WOyGOZX4=X9W0CBOe&Sb;$%<(5+WiA!O%VqZ zHstpJcj`!+MsS`|3!namwmF(HW0onnA%@{w>cGULrExPHqx?v#0QV*)NgsRrVp`wQ z)LTVA!u{7ll}j4`>3 z{pI^-OB!!~&lGZRlwEUl2##jJlv2QJ@@xNCUjhG~gj2can&I3gXoKB}lP&a)WZHMz zOP}dQ-8J|OP$25i=f5s%_Qn!i8)_+yz{*%pr$ncS@|K%Ck37l zBDjC>Z&yOG7|-~-PDt$eCLK{$E`vMiV_V7$HJyZ-4R%FXUKzV+R>{qF2#x~z5Ad+# zQYzW)9li?VIk;`IC>emvc_JBjPC1QUhg)v$AK%sj+#eot*mR6#8H_vc;FMsVHGY24 znCVaQ-URc&e@;7#>vgv_R|>q<*ov794<%Q8FpbVLpU_oHEUA0r#B$!nKZd1p@|VsV z7}W0PNww9)lH{W&f>Ogdi~GZc^1Mi&?v7#?tPVC=ol=r3+?eL!%ybiVlCz4}5e77^ zJhK%%rWZ&5Q4D)*QqqRu&XWK(zZV}iL0RlrrM=dv){Q>)=WI&{_8OIBaGe}t6_&iT zZn?tSeSKxODd?GvZi3FzJ1(>F1d9)DAKzq`S#J3C6$Vy@gYnb#-$0)XY4c%bRE?js zS|(Z-15x;&0wfSc){+Uj8e{1f<`bj*S#R1|NN7R{(QUa z+6A3h4n3iooV!L7Pwx3&-zi@4({y50d?b#?v-Ux((r32Zo%dvWCc*8SgkR4Fc@?S> zH=H@5iepG`9!X4db#R9+GscX0^66E2aONN+#5-@V^BHg|Wzt#Qw z;Bmy~dp$0iiR9DIzG1iUuz!Z1X_XE?a%FkNZzz5FOQd_CBt_K6_PuV4w8MK{Lm|AOFmEv#WpCfWZ?o;` zwjdo!jU|keBxLLD?s678;$rmnGt?O=Ds&dJ;UjH;GiYDv3|gmANkt~b=pxTU-T=-Y z^3_!Kq?OL}Z(6qHY-!qVk2OvMKsC}sI@%V;;TxP_)z*X_Q03E=FuHwr{hm4N!51~} zsPzb{=i5SXc5@bk-((`FGFkNeXz2Pv^5oYKH-t3iz4K??cis96Uo|x!!C)=v*qUZo z?IU-YU!?&w+>nA)WEoD-BZBdhQIamw2#QwhaqVXl{|88R{IK`z?%@&H=Hs~*?4ARu zDCWD_AI?ghiZcIA{!3U_ZSXZx=r>M$OhPi2Q)rtIXzsKP7F;55!6ELP5Y6Y<(m7?0 z?hdvudmHiiiQNqsmE-{hC`6Bah|3@bNV5ElzPHmff|5#TV>o)M{R#2NL0yyyCmT80 zx&T|h|8Dap1X5jsWsJaC3X^GLL0)Z-{eVDTG2kunz88Q(`Sl-U8ViGAMi%@YTK0nFS>(&*+^P7Kp}mF6>T~M%XSM5lFn_ z(~Jd)*XAG>9)AG%BJS9g(WN3DVe$JN*HjXJ#uD7p>Znz{{;;R;;vb0Xut{XPuZO@Q6k80t?)wsVnR#q~W);JF1(9rW zjXytIop=)KZ|9wa*b=>?TrUBSw;|+|EpMBsi%m5vu|8u8pN}D^Dgok9?Fx{ma;){f z@=d7zAq+YrepGeCOJ)rKYx!-7a^OEGtfw&9Zv->gFGI$?zgXc6Vx&CnqoZ>b^++By z(AkLM?KfKP`@{O205*xqAo3rg7|E;08%TqGm4&*qA^>S|A(l~paws|8-G@NY`8lU9z`#Ag-KKqjjQwh zs2L0c_=8vg`4ZEsYJ%?nv$Hvk+Jb$2bS@JFp`ZrHr2bHbuFwBMele3Vf6)iRLFJE% zNe{&ZxjRz+_Z}L)e&Z~XuN6a>Gfi$3;2LZb(>q5YleJs08UJSl@IRwG8Za9r?B5)z zlQ~{d5E?l&?~V>tXVMD+-0!W|&DY1;t0bN|#!s(*3Ex~_vUJ%6_e$c-ua=2dv5FBK zh)Mi`CJW=dSS+A~5{}a%peh+KA*%Gz$&^})e8HnHT3>9T(w4^8l>{7(ST>|*1m@x=o7g5qrLf_R`{u>4FLrt1pzhNj~Le|T$jQ>o*>i~hWUh>K} zpWy)fjib(;)ld*-x?ax8{V_C{8kizEc+k;m0JeY`-zE3gj1U-ZIC1b@$m5&Kg&SI} zAS3AjfbtNITLOFj#YU3&>d>1@qKe;?j`qTE~ONmT>Ya*672q$2nGkhwO6!BUmFDfbN z`A=i%{fIyTpVQx)oZ~O7BY{d^69N~H_wQSy5#iPc%)@r;om$RUN1WzQ+oQAlVlwRK z+;i^eKKK!&R{a;Jm-Te{Mz}$KPxYe8>WfGM=#q7Lxj9Z@w=-l-IO|qA!p!0rh*49t zAP(+9%Dyaz(+&$B5?%bkO534Dw8ryE{)WwND_keLV_^p#MdRk$VW8!ff>adEn{k1l zH+1i(1`xQq{{E0kgBS!lG3J|I5QMn&>iUxQ&5!8%-H#+3vEb&_ABs-Q?%DX#sll}v z=5reuSW#dy(B-ZQMMCyU{I+wqlOtYHEHIC+YU!tQ7b*+IMUT4Z$+_x4T11=QILL-c z2v;qCGP64QQZW<|-iHD5PsyLgp_9pFDR(7Q8V1~A3=yfvy3y_tuetAx0}8SHxSZyr z5w7lg((m7!#h9{xT{<77seC;FoA4-lv69y8z+m>h44*>HF%dA!FeM2@xYyoS0UA4f_T!5 z-K`%19ilgh}it2QBbVa?vpmG4zTA9e-Ctk6GcZ7dUm z9jKKRgn*?d?+5g7?YQCEla```BB@(k_e8vDXuhfT#|xM~?*GIQP&L`8Ypc!b;Wxtd z%IU5W7R5zq+AfiUCdqv};+(A_i8CJj2DJL5maIYMg-hxd2yKlAPi;&Gg zAZPpY(A0~VEz?ayUC9@c2dlo-W?!cCn@qPFKXc_8g1XaafJL^S2iPCeX(Ur)5p$4J zB>(*Y&52lcod0JY@$)8gL~gy;R%05MqL>K_Xwezl+56mO;CbNP|C$0HnS zF=HD9nk}aDx&N3V{{b*X;~%se?VJ*-Ul)n_cCNDe6c*o|MOhtN=4_Cacr%scXzh)N zC=U@4#k)6~M!%-T_4*-wMq6$ZMx9Gm?t?RL)4k}rAj1%%M5cvaY=}(&WJxhi(p@@r zIN2Fvw@VO|hw&F_lDVBN@nqnaZ}h%^5AnDipQh^YgH6|EJa*OkH z>h5vh^P?%hSEXjppX1B#T)B8vzTML4+7hKi9wA_RJ0MT`sl5E)806DvCn?G^01$?8 zcWG}o(GIYfsi}752;|a%%TA6)N9CcY>A=|Vj%%3gbRH;Bi5?K35I{#y2k6?7A5^xu zHaQDJsJPJ}#=wl5gKwv=#!kHJ}WNxn=@kI7fsOOE}V1fDF~QBm!&& zSP%C=5))08$#=g9Bmf7hXeHXX0_cAL8w)FbRVSG^$)s}O$2kw`q~1J|l%P*$X*%Mb zaGDbr#1UEwvs?Kgrrl(bB0{^={2fDARoVXiLZkS2->TAjhIy*KYMP`}5H<~SV!>g@ zCB1p+WEyLsLOVEHc)<}YGLV2O*W7;d>Y;Gbk+;Z6M^5JJY;O0a0YgdddR4;c%4^e~ zol4M*fB77{0*PY1ok0E|Ur!mj@j?XfTpL6jl^P8wC-95pn<*9ADQVZbr;pUkH#eD{ zRSWD`hGUM#$*Q~)(K&6AmCKijK$Gm}ag_=(v4T+`4dWoH!X-b6;$!45f`lHriHqH=_2`;^`?~(X*dWzRr?kdedmy!=($J_e(`j#%}Gb|foM;I zIzd;&2o|Ik?_F!`yD15PZdO@B+4U*EClHUFt1&Q~AdY;r^A)eJX9ht;TJ+uhn?WdM z%s_5k<9~2i zq|Xv9ohECKXZpBME&Dk@A%RN8iqvcKnbiFhSriA=jsr%7LQqD5E>bAc@O?O+XWOTJ zZi@`*0;CI)WbMh^j?3)X=^6Bd(O(23#C)Q#o~xQ1$nabZs*IDTmv4NPQPdBK8L^wu zu{>ufd*a~--OK{7Z|vX7YD1BH2WQt_Myrxy>YdQzr*Gehi>8}uJbM(J5{_;}Xs7%- z-uPRMh3i7N@|a;OE$;kK#$qaYp<$u%6zJw?(hhguS(u5Y+;x2-a5vwN;u=8*q!R?L zN;(g7`@aaUQe+5I6U17+6u%eb+Z4QZ|8##=VeY$R;@wb8$fxo*86bnN{4w4vgf2_9 zhmHVyL!PW;Mz7wD8Bw**1>I)%<$stSbgx61a{xId$MJAQOJ||fhT=~pzE1N51d23? zu`Q4Upm;qqgdFx{oPcY&E)~!dZZWh<|GkP}qQG>KGtePkEUr89UzctQYR;9~ppn=; zL{G6wK&mS|^|@Pg#G`^5;)#C4xD(`|x34!B=xO>1A=Z}@AAU@~nBg@UO~)TEob1f7 zobo#}C38F6=Cu+3`pB?EBb)ZcLWW6TImcql9Qw6uIBngij7TC%!K->fSrx!1ACBi8 z?o#wlt0f8Wzx4~QJDio>UC3vNL>l$ZL^pI+W~V+Pv3tC z?uc^0x=DQ)Cy+)`^qO2>2vJ{1;l>zNR(EY6j(OXFchPsdlw%aj?x|5fa`?TM7?#6L zJE?;=iV@$$v^YE|V4VQ5fszrSU7+2V0F~eJz}WIy_%WXv#u3lqfu;6Y>y!M`=W_1| zE8lFql<{%={mfV7sgVB0ivuhO0buO^%Y;cc&T5(7SZU^>$w%c;Xm_s6VH}ec2I#c@ zy`(cE4g14|qeX^^m5ZP6>zL-=m$&x%CRAqS!UWOh2+8~BU}qZp;>`+u0-yZDGGBlf z(R>NrpT9;V)N+-4>gk?OgQz<(bN>$Hh&Fy*o+vX*Lz4o!UNj~ChC#i;1Phw@fnF-% z$q8Xi7OP73ft=n`!@Z*-)nerjb9;r_Y>C2St)P(vKkMB)3Ig6@%>?Uu@;<_=cc7T< zQSik}f0Mf~5|A&}M-*HKgqzv+-c*Wk@v>2a`pQhkUoL|Q{E=^Ei3bD;d99f_EvT>J zSD94Uq^zPn?t}KCBFW!5UZ*;23WnF8#LNs4cD@eYh|M1ozFZm7k{ZJqCWm81pbz6! z5pm_Z-V5fVY|eMFJxm8%|Kc-njk9{f7r^|e7DqTFU$WssuPT42(L&!99BVPYmHX`9oCbUb1x5s2{z2t|hx?Cc%JrC2tcV0? z*uLy54(kL5_|2ha62`%s`Q4ZP?Qy`<|JFR`a|qWgzZlZhRT2h--k8)wh6rk|U_q2- z4-Ji;Awo#{xJNkaio@4)tLxOn2}KS<`=@BHou4?bJG{oPGFOixVUvLszAW?fKi+CB z9?l6i*&lAp4UOdU&U*wi27KLR?rS21vkd~<;wyN&Ccl17=gcDyga*<;$>LF$WQ$%f zASaMN0~rcXi|-6W?i45ILKQ_0Y6##P@wW6S`Dg>^vFtamEcf%Yl5O%UG0zFJ!acL- z)vB?wWtmh59ad{P{v0b(d(!07lKpX zIR{pl*eWEt8csCpKlnv_gc^nUB9MfxEpw(vCEeA%gZ@c6WRtfxmv(~}G@xZnq4L|^ z3Xt-h5ZClWF^iLqR5n#+XFxp#^l#`a`a8cxRS<97pNRG9s^P<5yNE>Ji8QJq4u z#Q5}*Aub(bxFpS(eu zJAU->lrqdQ)(!X-0#BHCR+a%FjDhURGfq zo^0cfF>ZWTDUV3WAEh1`=A~e(HS(9qVKmXsWgJN1r%qs(fDbvm%x(UvR_wLIYeP|# z$H(K-s`2Q2=GpN^a=Mk=eiSH?@4qdqr`<#7NdY=_rgOqF1m+fc;! zTjYR{>nR>!!z?)uR4fVmEaJ=8`5Ls_^^RlR9xn^TaxQ;!qg7kC!8T3NPGJZoe{4!c z#z_rR+`JnSW^ufFo;NDlmkLSnbC8G7!ScF@&#zJ62@Yp=W0I0zh^GkUhCXczGz_rb z*>u0_^)a&d{nT3UCaZz(#J#7+NV2UW$eQp{OiAp-^zZC*IV%rqX+3J+ z&(%+#h2w2uCQdNL%X4;TdUE`@r@A6zCQ5R|yrpg{kB_$*sh;i)72M@JLoNQ8{odC` z>es7de9LhQMHLjfC9tw2yt01Rf;NZGV!!ESM3d=Ox|2V|i)wr|9sdtP1FhAlQz4kv z&ky+MaUu-JLh1@fSuffLlj+J{gra=dk`HQn)pSxi)w!M6$eMlD4}p$Jp*0+ndJi<+ z-=?h3Ivoq-06*q@B>Ctx+8vZ0iT3+j*EV`2U;fGk6IhLVp5rgW$V!YM{R+PIow=Vy zI;%Z2G97YrG6d+|q@>tFw6YC&aUff=t!PSp%DWJKMx71Ney(SQ-pM+i0;V`UB$%8{ z!%-QldnuWYc_Cg$$^43+VLoS;use(tWikn>3@2mp*T2eAPPw4c>$n^wraGIgdgnuH zN>3DX{VkG7!ZC4u`_n(z&m#UogHU^y51B4ByNT`{J4H|AHB!aYjd6c4=qX)=Kcj|m z6wadHUoi*mslR&_sB@lm)OW#^v5&H|;3VAGE(I%Wqa>LZN(nU|1k>w90g z%re$H z)Ca~WrI?4tJOwUW^Yx(3V7Se>;)ukIrbHl?81LHU*?tC}--49TAqcuCC3Sl7JUaPC zs<@IQ^c z78q0^!-%!iqP|u~gNVuCb~TQV>=RBCYP|FQ3Ah^Mt`)LJQq5dWR|)d6@|vsmMhFG2 zUoHP0ETKH>LH=XzvDB}*MUcJHKi*W9QBpV@>aV=ra$EAi#zk8ldU%pEZr*06bjMq^ zz?UlK3-MS1NwtOM=fW3~BFJ^g8{d*&C0%Y$vKUE`sMNjR7?$aYo|ZFaGL4$g*MCFh z;ma!myUZE4Hd%N)OiCp;<`9#M&Ra!dbyCjL9VycJ;YKD;o3DlqzsM#sI?U(Drsr)w z=8Gk?A(PVES^&1Shi}y){mj(X(&%ZM_&D$szV}`{CJ0j}$^Jm8qA|*2y;g=vZ$!Zi zntiLpgCI7Q^&Cu0pwo1yJS>Xy9T2Lp6a zKNM*$t?Ji!Vmw+EC$vY7(oOUOH1`Wqobq#q<+bl ze250NK1OLzKa}8Qj?ZP64ttpfZHvBboJ3>7D-$6Rf}+lfoA*)g<%T9J&y;6jf_@(u zsw_&P5bV62H)nU|mXdl2EMK+woxOPaXp2AxT~z)t9me|Np3mltNWQ(8xD*3g94(`? z-32k9|Dos?hV-W^#$lo{+9PNDfefUfZ7pbdlzEvde6?#<*e$&x*wDNXz=^VDm3~%D zJpEBti|tcqvVy@M&$_cs^IAj1cLq0gD2l;#bV4^=P*i*twb9GE<8pDetfMbK&krS^ zW4KbCVC_ka03+b>m-OWEIHgo)Ovy%hawK)>T0)+jSy9dl$WAkso%M1=)GBv|utT@l z;lCfD7sM$R(~zgh?5Fo62;^cukpD$IMu#T7J83$F$yc_O3lS0) z3ZrJ&Uzj5wE_aI(c5~=6FPJiN_*_5-eeBV{GkANkBCXvphxMXSZ}B&#D^5gQi5tw7 zpOAYOjF(aadiZo(`P`gjYoi57^4Y-LPtiinGFdEWb@75f$nw)ps+u0h9tUeR_Ux#s9_-2k#x%C?6l{9bqQeP?X+LtRfojMaa;XuX7Y7T7rG1KgC85T&3XA@7U*eq-^SvqpG*+clQK;xAq)#F0D`~cLmS4VZ z4BBzcmz%pf`k@>#nkW|eDhr63yHhqRx+AmM`l4LX9FV&`#4DO7w=v3-OQux0eDE46 zOFCKJY8VTehxLHUT9wF;1XJN6LYx=(cv80IDOQZQf>v51NeZu6CFTp;AM@2-w0DUT z-p#l?oVLB+E#v{-a@3?(*=jM43;Gosh+`pyx`G49j0(Ufzp`^ybpbqbrfmiD{alp+ zcoz|#{#r(i=603*YIvFsR=FCJI(p6fSuB=u{YQYRzT+F}$X(=_v{CW7cmbzl525oCrUXqi4SX4uKmm3Kzl0My620cP3Zh^A^p=5J{~Hn z9?|LR^9-$wMzW@ykYyt;eMWPn(Q(1%XxmzW1Gw%>AK5sGgqgd_*ztvIEiQQ67Ki?g*44g*ZGuk-NIE zL}7a4nW8~O$EWkb_Hy}j@8mu;V5ymn;S;^9JEn4S;(7DT&P~-N(L{|ZogVe7zN*tp z;QOO5GdU_xIo^*$x@={7g~sRaq|5Kzpo(iSCHp|`dh5c*Cx=JRre(NWr>QF5+ER)i zn=JHrK35YX07a#E{huiL?^OE2L3j2H(_I@rwWmhpB6}bC*^Zk8jZT{$gyN~`7<8gM z?oT(YT*CO81bUx%Ax$d>78gvKGVT?xIpI)yU_Vsz;Ou>-LfpEvAm-J|*wdJaBGsZ|I+?)@a6x(f0*Mi(iTUabKQKcBysy`Pj{iJXwVgq9>wH_~m1ymLSM$FEqPjpVLHAE0`T|NR_grWco&; z{2c3#f~N}S|L(d~`-@g)KNRf*Q0lexV-0juf05BC712S#GVXQ6`~4-p z{q-+xFHM1>&%K`?Kzb-MqC4?zsSN96DsIO6}gPx=OcK`o_sPu_m=8Yz~tRm7Ld z)aK>iHE2aHj0WGR*FWQDMC=-fKa2=ROvP&0sqgPMnCFPOo=+qh|GVC@t=eFL^@|uf zR{lM2-1;0fch|oY^F;LxM*Q>kZhZDX`w>`Pbj0|+@Ey|Ljt_PpF$aIOb>4sXtSp*V z5m;8sy(j-p;Mtu5axmU;#(NmPe{&Dguj3Fm%dcfYV|=>`d5d|p;{rnf62 z3zGbduz0^E}wVIRs?IezY;IN4alJ%x1%ZDRexWj!vevY zzA?;?FoJKrmJ-Ar7^RL~3ftdrV1fukZ06h`_|L%A5hk?VcTjt4LYfH0;`#Q<-TLfn zq$QwOSna`K*X=*I@IjbRnLNMr?f4?Rz%LQ{F;1a>zrox>xbc?do%?@00FmY~LpUI( zOxxYRi3s3;)(8jWsh<6JbO;9|1?%-~rOWug@kxR4O|6z6rv2wAh-Kcf(Q;D09gQ9M zS8WdezXSfiJ0L0Nt&O-Kb|Qgd`oX||ZNw9?;=f{VgCf|P{D3AW%CYz#-|7|u!UG(| z_p98kQeGpy1<|#gQOET^&3#A6eOF8rb{pb61+c&;q#7|Bo&W9+!eKzLtITase2p|A z0>Xp27Aon3e@Ewqg0NTQ_O+Z_Wk0)9fv_4pX2DOl<9&ya%QYDyaU0@VNfC|^R?ugA zyDz3m0Kv^vw-){EjyYmKt13he^SSj5WFQ5Kv}7}SYeey$TEH5gq2Z_gx8HIh>3vh6oOe%1+qF-UL1;Q1q4}=tP)9o-MNv zM@C%<*wSvMjc*04lh$kPxQF6^P9DObUTC#cXMJ+t+xa&BZX{SZL4QvD z8rPjS1}*Ma?e|(#YF#ex3%dRG%&#J9Ya@?nl4X(o`RnK$`~5;ad2xR@D?3O#5arna zf>H}3_RY&yJk*by&yhejNE9T7gblp1E%sLHz{^4Ph|WHF({P5NtqkW=gXc{%r(aUu zxkTGOP8NdWe9l^+n^u0SG4Ejgp^a<5$iql>X=_z}kz~k$N6Xt6HJty=(E&#Qm2IrS z5xiI5%p93m7JtvOkj$j$XY^uO{^WH=Y(Tu%$N8Tq-GKB4* z=z|UCT;(I-6p2!%SO@6A*FNS?hhL<{^;GoIp3T{;@fYfl@sQ@2`7GgZhQuWAAZPY* zz41+i$wE;J-JI45FKFa~vl5Ia=WJ%6aLP`LlB_-P1r)ACo9^=3-vbphWx1w=$!J%e zmsgkE8R8*9v`QJY8_E3c$a3Z}kP_~lBQemtlHwjlicu%xSZq*hQvi|hOv#v_Ra3Dm zb>5&~{T4N9kX(kTPY}J(;t>#F_&&Sr$Rn;h+D{)FvMLT-;@gaGE-oIlT{NtOGY053 zCvy3x9ZKO*Lh#a6J^buFe~b}Hj zb#qQB`lNLsn_rlepL3hDqjoDehFuF@x~^8yR0c-uVvhGo;`d>PL}k&!V~|;Y_z3KF z8*`Wtki%W2uD*pD&MLq=WqTH?pi{eGsJzG>k-qxL_l$)a2q< z1*bE|!|Q_dg?cCSDh52Py`M%9a+sav_kS9y-(l`XO?LPa$S7r4P<6r~Dvc5fVW4Eq z?~cF>t#@Z@gvwB%oW{Li0aVBXdrPryysFOG({nu^6@S^A@goVzmTh&$&P72I*!RTK zWbsqtHU$l6mMDXHrE(cDN~1OI_%_EE-%omdo22tCJ|_jS@X3&b8i^oP(?W|?e&Uyi za~D&Q`YQ2uh&lI?+am4e&2#SEF)vIlUpSyqUYxuUAT#_D6zKaHwdeu><;mNpd?y`? zy7JsJJD2>-@zWFoMb!Jh#6IW-a!OKmKPH1?n^wV%UDkZS*>d}I6yV3laecf$N)Vjw z2bb9xOtbIu&Iq&HVt>81?jsq)m%RSF31jyKc#VuU)Y$V7`4+D%LV^dq9)0jOxJ6r6 z?u|U0WPa80gQ@`X#7f3jGG{QH6t_T4*v||XrkUKV)ZQCXaNSimx?u&#d?@7;p4*;zuJn3g*Z52Lj7zsU6*&N&5?RVE}PQp?gu8 zr<#fNFpjqHo5R<7CbA`1@?cwU7wSpExM)Ua;DN*L;)3zm0H}t>6mYiVF}8a=TJ$6V zbID_i9wb1>W|(!;IWoFY9#2!T1dnzr1RI9RhxYtPQ9G;I87Ha*SSo`r9|I%TWPT8% z9bjN?OY%!PTH9EQKgMvV@C$#*{hs_88D`l^CqzoJcY7mikPf`3K8(i5GTDMkM#(!b z9&D}9BnfN<$hhMlp8v!*`nu!~9ziL@=y!h;9eeI&4NB=W*{K~vIL$bW>}~ywAqqwV zvD37cgm>qjLRsHL)AoF#c~HJ#(;ZT9Cw(1GHs9&HzP1>SStnrIG1oz2z5IoBlO>B7 zQpx2~z1No93U-WjrX<~@@HWo7LD7uu^Gx~|SyKaI;N&)-eU(e`Zagj0>mPEAo)Hlk z(5KbuD(mp?k7C=3=|QWgufNT371K9T^u$`uh9QY%v4n^FqV!2wfei7lS9}4Wb3OU{ z#$S>VY`90bjE}<_@7am)k6q0_stuxeV86Uql$e%;4ydgC%+(!m==9Wo2K0dJPMP`k z{n^I7FbRzPK*hWGlZ*x*b9_l$Z?4fn3JV`P1o~NYDqdhD!lO+`(iMl5t$s1E4kYt> z87n*mDR_2Z0zMVFp-Vm2x@paq7^zv07G`-L%-8xS-noYA8>Jg$AO7Al{FQvz)n%It zVFJrI81umCC%$+&&S#FeFF5M^^G$7QKaIgKoGo-JR4Xe?P97iLOQ;csbcT}req%p( zgY{ef>0Li-OnhT}jn#vmFep_Y;qeWqJ|sR9B%5bZ%E2<(yD2_F3i&LaOnuO!fe9W- z+DaB$7E{%Gee)zRcE1$Fu_zWAISscknkhw&+2)3=B;1H)$?QKm(>V_>5mxHw z%oQ{(>J47Ip8Njm*OCC}@#A+)z?NZ={eusc$CUYO@4f--rntR=MuSwL(q!RoZxmo1 z9BdCiS4^~HbVr(_F^v8;ayg{OTHH|co(uqmdzROk*OU0)nB8U0Rd5V;2M^-nZ@C?Y z2vdbzkTh75B}P{1gwqu`N_ecNi%x{D9&mf}8Iwc3TWu=vD1?a?eOw;X@6I)nqO?T0 zFQ261K=B6kyy+-%X;(JE8!~OKZ)^;$22w?A|JeE50e~tVn~bUuG+gk_~us}C27VO}Emp!?zLaNCvb zxRQ7tSLlRu5qo`3bgto{znDRVR$nGZihxs&6LY`q#^GY4!kPfX;!;zI`aDlM zDg;#IcUH2FidAf%7K)wfv;_)r7fpX`6SyC2SjqbeypKPMoZlX+WDnhWj>PT2Z86oN zmvqLyNwcny7&aZXZt}RwHicyw#tPMVQx=j{S)#*;C(XODMzvv}pPD)>&ed_jAxZDJZo`a2){G{NfAOO?1%LOkEOj9OkYM0MD2Y zCks#4?pf@)Sq^SgVCoMRn77pxY4b@~EE`djz=2Z)$Hn46L04&ik-bPlS)%Ns2fshQ zKV2>3cecU@g{^ojKXGaAgB)q+0+3D<_B2gzEyM8wb|EsxJ@b)Ct3k%%Xk1)jXREg< zHill~-ik5faGgKbK(wdQdX~rBH?eJ{=AdZbukpj{Et1T;{HW$Nmt+pj-cSeRweYmx zNb&@127Q`a!LcIR0}rIv+S=JRw4x*ozH~07xuReBE5Nqz$DsR5xmnEaqQ%M9E8xk# z`LNUDURn4`$@apgTpA0qE=%O|DN5WVMWayl-p3>kjc)VxzHHk(E%}SKYMYE;Q4U=!Bt__k%{62R5j zxDCzu>}Q~3`7CeVK4ncKQfEdTm|=dUQtL>-s9W=Zd<{-!on>3VC*nE8O?U~Q3S9!} z^6qrOuKnNbMVBe1VqC=B-1dI^l{%itTi=vU<0G!G<^mKQ7WTgz?z%lc!B)9q@q-U( zhNTKTRx`UNbY-bi6?g!9Hr@_}nN-OU{VsXclCIn)Eq)K_<0pDy$n%=en) zeqH`XG3M%De0f)j;xrTcZTG8wVZ*K&r}-`*i+h|*Z3;naamxZM2E7z zIt%l~8qu~oGviFQG`{K~v@nMXHk4B>waXUNeNP~LU^(`#gXMy6$=M641!PoYN}Qf* z@Rs>#XIi~6%PL|yn*plV_>0H3Phz^YFHwST<7?a??W-QS>E9#RW>`EaAguKTkbR$d zH=h=g%9=@YiFmMylE7H>(7I3(Rrqmp!N7X)h!o8@?pqLM`hs|$s_By3b@9P{l&?uu zTw@#$`V?MlQ=&0SMN)T{p`FF#Q>~Xm%iezJ)JJJ_(w$a}`>d!Y`(~T=L-zyP^Aqtvbj8l*Bk_UES|R_Xg%+2U?u@bEea%aMPb z{Ym8dx}F|Q)$4Q?>d$_-W+evWGX{n3#-ZCNB;OyWh{js*b{fMQog`7nDPlS9b30(z z?Qu@!VOnpf|I>#bi_Fbd-@?|gMwev&_O=$Fb+>hUSul1(rVfC9(67mJPPvp{$)LVA z-8uSjQAgi?L9n`Ah{$42RFU#0454a*gL^t=<(=H{fUT9coBU$MbG2Ol3WU_ucI}tQ z+LPl<3X<=Ct(LnR-6+Yi+>=3ps9s0SD7oAlax`K8o~K3up)dPzrA~EDL-JLF_Gu%R z)I6NKWC3=9#c7A@xfuH%l zJ&`ZCfs(&1)QoR(Ro@IQu@*NWg{XSvdz!yEv};oJQ;y%_6gAB@IW^!juXf#Lqnf6o zg1ytMi{NqC-QzSU8YB9kefdd`{R98QFBr+5Yd_P_&lr3qegdKmHKX{w?>0^)r8t z1y1p3j{8e792GQ$%C{02j=?GtvIJh*=-Cep$0m1rZJ$~8@n@4%VU}6Uvw`kvO2nDX zv=&m2<)GFXpmTsXd;j3sj5QU%xC}f^k%ISplX)Q-IxAKE=lPYLj!qDjmNKh!`Y9s3 zSjye>e1uBF)94A@__(ocpMbj6ADpNzMfyzSB27wTmO`7|7}0&HQt$_rP69*+%5*VW zEoQ7`5tI;veO3=x7)11Agu&65u+0KT=^k9Bu{s8m6&Zz`H<_owPCS8eoIkj5Q*hF? zBzx)KgrU@-J1^_y7;sV{lNH)d;6tegwEfMhvRTXw#*0lksdP3L1Zez*MBXwp)Qcxm zh%i}vE-(!rV(&JOlD~$RiMlY5Gw5DE!cBBwtHOLyBGtIbZGpvBTl=uhSLC@-7z*Ef zn%472Pgu232smnTKJ?KW;F82ODQsF+p$t|36xtLp`B?5+MUT@8$b-7vMCNf&vG{1a zxr$!;yU%F{PKth!ec+_p^OjMFQg4U?{kSW}A=nDp znPPF$E*pcJpI6qKK@}Gj&0Rmj=pHp?W6|cbW4){ovOW+u30fjuGYO{Rp>4f)F59bB z@zo~ued=@!Hq@H+uDNA{A|@ifzud1{@bA9!Rk)ud4C?X3Qi1_5+!n|8s#hA7+ssAj zjPrRVf6Jk!SVE&VBPq48lDDOf$(H=N7VPG>T3=iKW4NMGa+PN7GnD4!wx)W-(UYu~ zk0i27!tbC6A=(r;;xf^Cd4XPLb}7Bu7boKzit|mRcSXo+&#ym#avvQKz+7W>*+yep zs^#lJK;Imkd14!_*^B;d{R5t@nkb9o@73;I_k&ER(I8_Ay*k-256a?xEz>9EA}Nn- zdU}&^E0h{7tcMF@F^7xZa1&2a`58cjK2%{ZKQBCc7IEo&R2p5ZIrBu_eZe1*i|YtJ zd-Yf#!xzr0B4G&Hz#5oEGkDJKb(m?3bcXhuvpQQTD7hJy?5p`?!2yq8XJ^EfSRBp- zSwpUikt`*beILG_?(UQjkZw?zuv!TA%A$ z(vt063$&}SRNFq-0cDyo9pNyZJ4E0QID!bq`qBqx0?GNeC47 z9+ZlI_Wz;x)+L$^Tp)kg`m;^q&iJz90RhmxjuXK8sA|&1%WGt*gcShv&w0vE*R29_ z5>;3$a9}$IS~lWz3!b?$LC8K<(I?%lWQi3%tHfShOkUjT`18cL_`K7dOZobvrG9@r zL}I)BqAa|l)+>qXm*1EZ0JL=LmH4*sprxXv?S<{?=m@z@kOEV4SqMUd2c=;(uA7pc zC!-+e&tLybwgD>iS#rq5nn9^AYsbGnt~U%y9f-zTM2Z3PvxIbj9AJ zlD-9R>=jeUWC)hA#{l}i!uLDxS>(?{(`r?Cid^=8Q4J+Jw8jm+=eC@9@yBX+@I%eA z1rfXHP+q+v36@i>v=@h#FnYY`#6mCvCb|rStT39d5|wI(G8W}dK>2BjOiUu1CNO6s z_e_W)r*(K8+36|rUqUI7OT@|Qtnd2Hk@o@T;Z*Dp26!Gm{}L`zs>mFcJyt&|BX5!j z2Op*+b}P}s!`l8JUGWP(za5UemQf&66&v32R_j!zgGbo*AY1%f2j*r(>;k&Iq3qE= z3MT^qnw54_`*c6xrw^%p>r>d1D7jQL_IkTyj|(yslurdI^&J11(PSTEY!-oZ@}_Y> z49I*B)QsZhnn4gj6&*W9@47~f+YJt{BPhd-xSY$)$9LknJ+SMPGj)K6SN_UZRbgo8 z$RdT|H)E*^fvj_(2x_k4nyJ?`pTl?4jJ6;d?dcu#AroVBn_9 z{TYsp3ON=d|B7(I@die(?lsM30=zh%Sj28hZ9)^kn>~gqVfYPOo7gNRjo0~I&u-|= zxAjkZCymwl{;3r`6P_WsBEE~nV6(r}Q!y5wrxm2IXw@6C_6(9%E_E&%DdPx_L}%X9 zu+IxRB69N|%U>%eHu^*D;%uYM@L?)+qyjceVaz^lq9QKePDZtZwnBAmbuuyXaZOCC zVKKonQF+m9+j?;!NJCg>_j4>;BXP+b#SET=RV1yZ{;X{%qS zcdixn%5|L0wz8Oh^Y3Qw8zruo3QoZg(`2VDqayI!inEAGx%{vS-_|J8GR-W~8a!B7 z^3}RZgZ|;+(YZhk+IjmI4Nu^yW`w8^P2&=HvQWwuFyRl#%F@+`IZ4KSKm?8D2ue>K zD55&4jqirjw_!5@j>Lnd&id(IhQB=d`DA3^e0ScKBDCV`UB%mlM-gg(QCE& z@-Jy@;6S}mOOr19Idi=(FWEtkywxxrs0uRj!SB*L$@z$x;9V)N-S?8|Ilfa^ALPi( zxV^d+G)3i7Dp1C{sgkl@=Z}nGg9DwUKd%_4{emu_4QI`W_cTbaCb8mperCxL5?jww z)7EUjFaj1?17L?BNW_<2Ob^-;63ujHD$F7Zx|azr<~|kIPLxP@J~O1-w$tyvYU?7dgOK)GG6Y7A+}8C8 z&L;)?t?2qIQ>SK#O#6&@Fs`9CbX=KH*47FUWK|vVrr#Y}o2tX@JK(`+NEU*o@8tn{ zcKKBiJddwy;d&Fw2-(w0L_Ati^G?LC$PmLZPZ=?cX)v(nVz7v7W_RD#%Vtd;zuVqy zl=w62+6)zhAAnicxcXvhFlg=19ab-kGIG3(2pPUp`(?P?S2@2US$=By=}Y*z$~tsBp0ohY+N^AFS_l&S#cSh_m? z1DJaVh~BW=-YC0H2Rbe%W2FE^KxSY9ZvYu1T6NKKA0e+HnCTBc)v&O(EBZT1M$W@%i0e6E+|y)k_|ghCpFyU{M6MqYyy{am1JJ2X#Z_0@d0~0ZyNG3~oCU zSf?xl;=c<`7X$(0Kx2|z4J2>sl?!Z1Ex_H4b1dvJE3?4}OtDZCEgu)Xkr_msZ7$SE zjC`CzzmqE=4SMV>4rl@+w>4>2q1+*flZ#VuVO?Q>bg*yfOU=!j)8mdw2uxUT!9g$= z5=eg6!)OvJyHWPo_ehlVfI$QEF@7AJKM*TQWDDH-m&16BX`Lz_SOcEI!tKvcm@2i~ z!JHs3{~kgn_1(|BE>9S0MM!*E6O-a_o-=XosM<2(2-`M!OQ^N1CAic8t~vGjCVw=D z92ulhl~^7dr?Zn`Wh(xD-|_ zf@h)nem)>k5?gTxzk{tR-ZPdf63-Q#gc4DXMl9$fvE_^~SlUzc>s;7mf`8pn6gYF1 zVhJSR!D=e!(Z4F2ERy(qqlO^`MoDL%OTIhgOQE_`RwZ2u2QR;8X0r+-qZ?K^lonU~1 z{<^=xa5BngRj3L_i9-}n8H+f;KD?3v=bY=&;=xi$v^%Xw448@T4pGxQCUwx)+o^a4 zDfiQE1z?(lnx^2AA1}2=(uWk+)=2cBi*=Z*p&>SuVd!^3PpuXkz5H2WZbi@>A3G?6@wguN5l5boRQ)EZ*Yd0&Ux41Zdq z=pt5#IIXENEMIU>0tu)DP(#Eax+bG~G8qc`MRfB`Mx+9gMD0CelcF@o?CiQ=2wl6av z-4=M9M@|~hR%=J0%P-CdC=Nj|Rt^V$s5{=;fYZTxTx~dDPjecL0vhV(O{}VsFf!T0 zJ_eW9V&so@8QElS_vB4C@;60vVu+DoIPt0g(Eqq!lAW4EtM7$B+q(|ahCxkPMxQQ6 zZxBEvL!lNnsX`Dy2e7tcn{?%Zv$QvOzGRk(YW%SO6Q~QYKe zxXvrk&+&eIu*q(?y;<)l^%C8U9|XiN!CRkuiGuO-?YEKI!cUPszbnj8mX^b5+Wd6a zlk+X(ZaD0!87aE^2y9QUo4ZPXfHNOl3>Y{mK0B8*ekX?nhu~bADL_>s)mC;-kXQ&B z%DziXBQiZBgT=Es)hoKg7Mv*rfyMB5V0S<%c4=qyn^}1IT}2#wdl44{#AhRLH}f+e zx2GDwy+uVR*JHu}k%0Zlulv3TzrlB2!bE+7rEuO`#=d^?3BmzVM1xL%f{!{C3J> z%XzRmDtb0l&ZWFVeNeu@;1!(~wxiA$H(|9BB{~E3nWVR_I*0+GQQac7t@49y672H8 zL2wS(ud_WpBbG7DeP(phQ5irX&}^|(ttj5m{PO%mnCfL2zY#qmCzC)U_Vvq}eW0T} z+EF~oS72J;9xHQTLmn>(r`74+cmq&=ykB52sMY=|XC(;#nZ>;tCj%TCa6IN&u|wWu zVOKo%;`v2=)tuB)>Xs@bN7Cl%4;ON;J0-$iI3A3kR_JVzu+d#>KK6k#yrAaWLH~mL zz7X6iAO~H-IDdLHjJNJ}((s;Ni9~=gBM&1?sQ+<&4I}HDmLW8+`3GZJ(09Qt%3y3X zapgE~cdK%ZA9>u$cOqgNl!GpELgvobUu~-W;`yTs_pU8eFs_bP3MjdWS&i8ZmnE6>LW~A&SY>MRi0p=4USQ13$%S~^c|TmcV>R60&*IQ` zH6FN9Xup{6KGkL`JJ}DPt4?WpsrCJXDClX^84+`)L@vXI0luS29Z$ys;3~YQB&*Zva)d2%X@BVsERj?iohqk90<^A zC7F6=TaKdNLW!sSVhybT_ZhnX2-A~D2jI*Ofp6TkJq9932 zvU;hLmG&u6TnS4~D0_0yt6HFhnlDxy%8y9C&#$egimU=0$LS+f5S3dExLac|8_Gkt zALG!0Tv@iiItfoQXNSvif|70tk`Of$Os=Ws9}1{^`%Ct>U}{Jy&)1}=_?)2(6H2s; zgK>NS20*xEU&vF+>{`f+%6|(;7(WVeDUcvvXgp5!qZi~Z-6F3qeK0)Z5%M`%vFD{- z67LvH8jBo%V|A1PV5Aq zrwdSv-qR$X7)l#k{GaM7Su830wVr24v#4Ft!51`sseFmZwAz!H6oNHn)vvlGw%E!_yL0D7J-gKXAY zBB2`Jr!4|=MU<-{!H?^42+L%NJT@0idbmkXC3>%&(q+BgEU0f3!nd=Tui*zxLS2}I z_((b3@(|j_n@qxD1Dr_5)opzkbc-3Kl!>VHwtM!USq81XhY!fCCRtlE`MLcPc1a}VmHLfng?G`D zzobVKja)Fi;J3VEZnDu(%&BszIm&0gl$_eAEH(_!sQ^Wfv)RvP9ICbL*tp_S8(*8j zn_}CL%V@X|0{KtYI{`r7V_|-t!vYHW@k@>Pkl^$`nTwsPtfgodrAZ00mM+=vGy%b2 zpTfB499Zu?51w(pbDw3C_wMz_zzJyCi=vf54JSF1(!}kIl z>mkAC1yuctfkc%UZm97wJ`>C!DJ^gk{RenLQ_)36te8pP_m z)l!qDbS{0%MdlyY!%Y}>LtA0O>00EwFYIFJB!vWZs){^#()Id8=xcUnlh`&6hxsT}L7QXW z1KTn>ceGd#28#LWY@=7RCs~AT4*Dwy68-fs009L7vUGC>hi**3VLmW8P2r@VW1FZ2 zvuqEPt|W#u=z4gkcuUv@i1IsAW;5D}Xl=sE^;A67wXu^q#f6%$G5o09{Ocm? zn)^q%Fx8lIyP|;3=xa-0VY z(ZZcAl-AC^dy@gpIX_HAT1O|Z6)AdtZD2U<+6n{mh8xvAvT@Yei3nP%Vex7F;eb3Q zpbvw=jTlt$z8orc6y`cw5~KJm=&T(wi@tr@?6^!C@bbx6k=<A z5;hjflk2y+4SxmwvYY9@`=2IDne0PgsSNhql@R&LN6#Rd6Vk=!z%!Kpe$G#}01ZTS z3e_w~UkKFQ(0B9J8(33rNY={!akc;o_&B4i0JPMyJD#snLW)$?n{xztcuXBqr))d+zJ&ph8XbC98D7-H4 z9Y!+fdh7yy;nw>ui8it*WfRFI%eU4EXXmO{9RQwbLMk+h07F2@=Ir`W;QH)rQ<_pH z?!|uA?$zLSd{SytiJaj|9&3yqtJ>UsC^0WcGp8L`hg`3@JFn|SKGB6m;!DZ+Lo)(V> zE`1EGD&uwG-DL%y$N3GofDCDA$$6gRZQYL4gWuBwVyc369HXaPUDb?8IzbpRso@W} zZb`CY2@Oi9ZHphrcdj%Cy@&mKnk8>%9%81`;w#ZLpqj~h7CskI7-*GYV+c_$-a=7P?P+VPA-nk6%(0gFyqN?Sah@z$l~A+ zfyzTo;;cOkFh0uzMz^?Em7NN)gQ)VdudG-CmfHp(Hr#%WsHn-^Y`_t$3-Xr zmg|2N;j3aX6yBUZqiA-TK*xJfTHoxy9!TONVsABKqiq z{=dOjEGUH%ODV|%`UjbMnp5OMsL3x@O z&}iEHJx~?IDXjvB^?^+13@BYeK-m{tS!grRt=bCcAEgl8Q*~>-0|q(@ma3Lh515pA zbsXRe`Dm0*0sC(a$VYKXinRnv3S-GwGd<`H$pz81K@A|2J4O!L_kB1e0XjqU&L{SB zs#3~k5KrM%Nj?SkG6q@!m5g^sT*@C90GXpqHuW145ifs$13J85v%kenqSqP;?EwA9 z0#GeIo>OZRfdOe*XG1d})4+k7qzR8mo3J&6-rOG2z!>N?A(aS1MU?%Q?hccyY4o-R zopuHR3k#r!PaLCCFar6w@u}>N(@i@B(`Q|CWeblp|9oc*T|I)k^J!IU}@0H=$X@&zWRf9+$2t6Qia}$+# zB1a!a1@Sl#<7gmt3uYmBa0KBnArZ-;K@#M|BCVuPsbi7Xo4v~ZKAm&jN1Z2!J{Tj1 zDR{Q6yF-zVQ>kR?J8OP79|SpQV!i@qYDwwDT^FoNk3j0e#{^hjcHl#mEnwcteWlXo z40cvF=i;CM2f`sX4oJVZJTdy&>{OWMQ4Q`GuQfq_)eY<;Ch{HOvqYF4nbTV>k%lzs z@R()p0N;lriy`uDuA8e%qT`j$u)0nQ0GFM4+vaGClFO-JVFRdQH4SJKb2#-j@38Qs zslna&%Kt1b?mG6}cRwen2O4BDK=2PuqUUs?4rn|zEIp?l!W(vk)4t|Xi^9MGNpTJg z@;x#{5T(o)NhO%YC;jM^?2L#(fK0*Rz^#(Fc&0HeSbB*ib@hGHQ@!`Bk4eC2AZ>pg zhRe;;9+V=s>T8<=;8fU#_Ey50TcXVzwqc%n5#Cc7l{%S(o$kBY~!Pe_HF)>0ezh%eKQ?P`?9JUikVPLDs z>FMn4j%nW&Vb_`t8;#ppiuX*>po^^#&ONfM&nHTEb5L4eWzp*A(;N45KcUzAN&`Fa zp21DE?(&UddNT7L?|4t#F{ROe0r=0EN`Zpe1hbE0+_*E)PC@TffG)w++;KQy=@#i5 zm$h4=p5;g1PqBYA;dy#Qqs&zU>+z(La!FiXih$^(dB+cw%AEBrQ;TJN=Qu#{7WvzH zbq|6ffZC)3y!+Zha^&-K?QckxV2(@%W5|5ech}9+KXN#HDhvTB>>_z$VLVss?)$xE ziuQY}_Jjbg=;VH5KA4l0PKkWR$!p)=Xd@FAmi9q?U4GAK=+bmAjn3G6SuM7+98kY% z01`uI4O$hz+$+g;FBoh*r3BC_a8yA*b`uO+NJa!oU9rh4H5y>xL(csSAROV@uB&! zkl#|aqDx;9h1(J}TCZqvq$o)T6ufeY0Svi8`noRP%S;v!rRf~4!$^)mOs$X5HFFkb z!}m%462H3Q`oyk{>Pmp}Ek~)>Yuzo%Q+GOaIK2)=?z2U7+2feIw*aY>C2+*Bg647l zz3A7sKFh{Zt(Slq6Gm99esk;=T|=VcMC#K-P@C8@jjDfw`Owwzk@dyN3LyY{PW>uz z?O)lOv_JsVK^4U4z;e^%%yH*tP1l3#L-EaYslIn0yE;#n2O%8ek3MEs%^(`|*tRh< zyvskQCNq@a+QwGo4*zR~xh1dM7Qlo`H5Bh0DHg}_$bO`AzviHc9w(NrQNv+%n$#Ttc>w6Fx7LytNM-bC=0ie67%eAkj*Z_lxk*6mBMD5YG%?}r%ez}zh z$ktdhUuX{Jl@nYUvxs!`{Eq#mY;|O2AQ&5m*zR>E7d~hIv-TwtF2}=4@rYM4xnE=` zVS`oh%fkCGWB^!!b7gnE;AG5bk=d`-+^=@HJeST05V1ztq@iTu; z`lXWjt-p5+av}x}f#V7*;|m!W;4ucr$sSs@T2`3q)k%=kKw?0-QCzwX7sXeR7j@L5 zuc@RuyyeR$)yu`{&isZ%#8>d0M$r#=v{v*SoX6wmu4h2;@VkppFbNIRJyo4ahowbhr+P8DO=W<$cf>;HA+80PZh7 zg_Hwvamm#x!f_ZfdZuDke{22?(JeAJ4>F=;o)6eEgvvY+MRlmh?|mKv->sifG7id? zN{;Wx8uLEg+#=&h|887H8O{H6CFxXxmAOCOj;~-OL-fIuLlH(I)gb7PQs*IA#>pOK zWB@r&P8NNsgK8$bM6tsnA2V+Ffh)3^MESf=%Slx;$jon8pw0%ir;LKP2Z`@rFM zwnPWO9p!pm*UchX`s`ucg6pWj?&y4w^4!#6FlvgX9TkGe9|j4&zup^G$-qkQl#w{B z2e4DesAe1{ZKdfw;cagSf~;x1@FIFPU@N1xY=imMD(PBdp}459sGuruVvWH{K#?001cFJJ*f=@nlf2d-&y;_i1jO%{ds#B|nGY?lV8FT>hON zQUsI(!bVAZx&hxAMVvdiOa)9GOe&?|%EP9Of>p_4!vzd4+XSb#D!ej(piGQ$b9O%? zWkBLKD4Lh&fQcmUad%Ue7TJ#Y={0U;YR=UzOOm1IkkFS!GC{vP6gHFI?qAz=3gWgO zVi69K1Wmor_lpG)3LOGwU5in_Bt~L2zFDU4vnj^_D?j?+!Vi4@H&YRCV0QX!I2)yU~mg6Wx)m$~9c!Bm|Yh}ngiR-d4oc-Se zeQ?uo#8}1B4{4K+beSa>->3v{`w+ulP(^347Uc34|ATFs7bM>e2zfrd;2QQMv>B*t z@y>fcF?l+m`+`sKoM7ZljnzIe%E_vx%W|i#pX!EsBXH`>mwb#EntJ}nUQqP_EM_34 zNy<>FY7~`OdHuVZ{TF~oiN?ZWmo+lruf$~FjZ2Sa6)R3J&h7&?{#7qos@c`jywzJr zL#y>uaeg3Cw8mk^ZGtCV2V1+|D-45nfEK0DOM4$V-{FisZL>kPe^Daaz0f*0e{ae9 z3va5E_W%hhB_RqS7Y>_o4|e}RW|7J7wKNHUY0JlNV31QT7TL&^fQuel#93WmIL%b8uJ=<^&JdQZm3M#5k(^Q}aNtuV z*OJX15AOJMOD_R|gUYedAzqzw)nYBD3xyOfn=cFH0o~|kh zT1ZoQ9~mx49#`MdP62HBL|wGTitlK|Jgq@vbIe!C4ar5jRs(eGg<8)eb4WNL&Zc=Z zs{19O_x*LmE^K_Mnm$`lnk5oQ#P4NOi__~>4lg+0hntv|sXpUb(+`EpD=^-%w?wGL ztnh09JdHnIS83ktzl638ww&mMA9`DgnMubK7~{Kz80J}@R~gbg@1iLXUh7-!SY!F$GFj~#vX zsQq=-8D%-)vUvlXc%io(#mztnfWyY(XnIyQwf>xmv-yA<7GKhdFXEb3FsPd|ij8}> zbIln@*B}rDpbQp(Gae!42nbCEuQqhYo4&kUulN=E!nV8V^J>H&4>clJFBoCJk&iVX z^lHE}Pz6!Sbwk!wTN-%Y6sE?A>T3oGVk4O|7b%2rXQ<;UO{6bO&dXyfWAKZbkrCBf z=21|O-`|-@4w`HLj!$yBE$5!JRP?rzP|MXiq1K0~`S)dX0mg-0M3L8Yv2e-T_3+>V zwabV!US%G5xW`dvWu|SBiq&?K*RhEty!%AkGi0E!9%i|AIa&V0lxiBKOu*Ne{%sh7s<7T7(+P8M zXH=s)CCR$MLgd}&bZgdWqBjUVQD{ZV#(sm{IqiN?JccX5=xBzvQ*f4reC;O)H8ly9 zM#G_b%`Q$9O~JtYUI021nt9URzNvSA9Ahmhq?NB{q{12Kvc3^puNJ;(y*LUin2+4| zklRbmLn#}9Vv+-XiR4RHAEK4ut!Vg@`d$B`LzJ45*2~3QUMRnqpJtWc%900lcKH0p z4XMKg*}MN*24#D>*CT@q0R77A7TVHQS-RLsBp8z!^{pjT_IrI@w?buax;eZKX-$cG zh0d66FIp|Q^v+Z2Cb3&yxg>^wg5t=vYDg=3`Hz;cTfKX#I9et0B00-6*zr<~vxi5P z_WN!`hNJ5gR$KE}(&hdeqvt(Jl_HfFL$GUmTgEyRMuL$}s!M1nU&}VbfQB9XZ|o60 z)s-~aGiKtgLaUc|n9ko=5+K3@sY-qC3q=nYOWTBP$}Gd4cn~eIB^>0^D=AN+gw^(`hOf2#dLvVe@N_>OG-Dn7b<>;n|{!; z_zYfRvlu!2eBUz7>3J_ly}gcpyFtx<9s<(@PkG6Dic&cO06I~6#GQhPwhPDEF}(&% z(NXR)L!cP;sKNtq$IEZ@regN)N6-wTP~4tS{fL(a{@q+UM(x`=lFA$!IDw;_XmM01wa0x zdeL^ttm+K9Dk2~%h|7l+P1%b*??+zZCnr{Zrt*RDS~mvA_UpJ6%L!E^GfiaBaH9v< z{w1BDrmYprOF42`A`kCV=r~eH9x)h|K_0zVflV9wPS6{%Bns%|jJSXIKRP&Bofdn@ zdWAjfOsLQ^-8WEPy{~Wgr$_)N+J|+F#ZzZ{4Jfc{)({F}I>*e5zRz1Z!l{NE(q(k!HfthJs zYmMH8qzqrF==AMZndF{$!$LHx<1G7#C*WTg`x&UASy~&;v`_x^U$9L z0Kon(Pp#Z|d4Wsv(Oj)Lgsm_*@xx>>h(vH}P(rF$sc!oR?uIKOCN%~h?L~CE@@O_c z4O}?TsC{3d)_fuZRK@WwxLUrlV)-RSX? z)ON-#wJhP?lK@Pmp>XaQS%H~3Rj08m0?J;sD=3*VJd!P#LK$1mi6EgD3WEJGDJ#0FctE+h@H|$fU^wVl(qx zX~rP4Bd+@Wm+KeX^<;S7^HR#BROMuGl1UgfUV~OqB&<>w`8eizIjOCXt%YUlwPUbQ zyDBm$i8SyWvbQLkn`o)|slmg0q-v(1JGcW0l*B+Ky^v*Gb%!WBqemOQRgqd9Pp;{t zx}kX_YxH>tr9rWK^*@fTKyF*nUau`2utK0WQ{49TeSTu1ZyzYO*?u4fT@q}&*+zR{f}s@ly9+ofGETeWhs;4ar?-02z8 zX+Dgo%c&-7c^iW+J>n9$D4<{`b=#eg0c4MKGPiQegHtqI1Iv};KG-IhH1@S;jK^t@FZdldwB5)*e(`0K)r{lF@cD+ z_PCXczwo%-f0g6+aa5ip9ASV8i!QZ0!ge@$G6WPN3gAC_uw_#vn9Ci0_arp!%7N|R zi|D1f!D#(P0NI=l_5Il|^8GGPHj|p1Z4EG4f!qWV_p|s;Js3Kq3A?o_At2k5YuOnF z0Co($=AD|$v;2IN(}RqF@TkG_>WZKzoFT+Iw4Y?j#~GxEt}dl&@6IVp#r<2eo3N5n#DA?<%lj7}b*t}NBt z(llXgagmQHj}|UNS~{&fuAQ(85B5J@i9ft)(5wSCAO#*I7Bw$Fv4T#1yGv3bC^sc- z2+`na{W@zg##nK5IB|2aq;9!FeZzwlogrV9qyx}+?Npjgd8Tnzg^GO1HI&tegeU8D zO&9`h1BR1;2S>fF9*#7c3<*(>7&EXXOdblv9i{Ub97bfC`7HZ>!Tq5;V0^N|1j}ZX z5|QYjoZvsL*nQH?x}IyLj*_od{=T2~ms~3HpBaR8WF;d34X~a;Rvr$jmz8fisXf&)*bQ?w8Y~4FQCQ zNmCcQN;5K(yd~eZJQI^9ul}BcvbXMVxm~qcIqC~F_-$SxWQmCMJ zH+rI!g4#l%B8P2`86Z@=Akj?N*OwYYeF^v%%Ke?)i($vh3@EU);Ptix_=Kt!DC96Czw7P}tFB>z(O?QHH@DP=iuYXStu|jr0Ek}e z8}Zy|y~nRYS_bvUl#dd2KY(mqB&HdCz*4P~MFRIH?YiBb112?ndv%+qD91EGDK!Jx z2YeA9#8P&t4ax+stK;S8g*GgFzF~I%&9NV&LIU2CChocmM0Q*B0z~zzrAoLzLNU)#WAS2#`|&)%?~mVkva?5z zm#&``sY9}_y<=Cezq#djuI|1`wRpPHB52)F+az*lT5Uah+j5FLSbDqDW|tgY{oKuW z-~L+T;azb5IEl8%DQX$^tM zdkI5xmmDLPK1#5+Y~Kqz#Kgl(?3Q^uoIHJ_zJ2N|>=!A9C%>4+7BbfuCOG*RTSL{` zE|SsyJ0emfnO}ALTDgO)VlIcb+EIy>p3Gl#0*1k>a^nk^J(;+XTkKK7d(LGu|@a>8#4~g@jVUD*rE|ct2w!&}B zGzK;#*|q%*Gl4X z2h(A3nf+&?&|t@?%Qw;Bx9`2BoP{u2%nM2HltsYz=jk?c8R3Xiu^tK{aV=r6pyV&f+I_cb282tVMJ|(lqeyl@YE`wuJHwYY-vwNF8PX!qLg%oB=cXinm3% zwLYl8Y;3f!KVv_f#H%(;TRcB6>-wqbQ{d}6XH-*S!7nU0pV@+h_5%cq?#$ipDM5D`D!3m_Hg_G{1&shMD%3~I(kg-{U%A;)-%9oS;rn^)qwN9wLZ$9;a`fuc z0$Tw5Z^Fl~5A%^MJWAmMKbG?r(Ol%iNwm|oIpYAp#SmI`D#Nmm$TvbEG;;lX8~Z8B z{&oa?dwl7)zAYzM}6&XwM0b_voPuCVe* zoViy&M=be`p{p+{vJ)x3S-tJ8fH(`JN&4Li-qZ=GBhqnH52H|~omju$CDPJo-<5gm zqnv&0_sj8ia(5JX5b~@xY73taZZ*m3)ORsmFK6?6Qgn!PTt!%O5A=^IGlMvX#rr(h zRS{ppo#XP3S|r;kJ6DIQpeT(E+{fn`Cn8v$e|@YzPSsy@ADk|-lhg-S%vRwV_IH!s zT(oLkA14B)Dua6irJwnXqCX2c+K!?}OsR9QyP0ZS;Jb!R(LKWYNZCa=;n+ThSmdV0 zf0ynN;h@kLTJ9T)N>moozHkY%p%TKV@jO(H?uooZeX1K1w0?Lrbr_-7cHdZVLUmkm zt7jSU%&$O8xnjYI6=E1byigB_C!^&~|6X zeR0Jn_19YHgcTIH^!$FP7yAupsp=_jMBaZMn5b06hMRn~Bf#|ib0P=Ew|xRZl^N`D z|L)^A)%dHmhgPpMN2ulRQU=dA;bZx(*cY3n&`1-GSh{H2+&hdvelhUwmX#VsyuZci zQpb_E_N1SEkz}PwI_G(;ixLxUICCzSnn9~_cvu#eVA0^NdLnmfJ0)P7VJm5BbRgqr zU7V*1-b*++EX$oKc6PH+n(JXx%UVv>DybI%p`Za=sAbwW4iz{ZmAt7LI@s)C+$_&g z(O?4~OLpcdu+o_2Hw!2oh5ri}1WtedF|+vM?Ctx$+LcegVsFe&Je)jvU%iCOF!UFN z{9ZLN|EUMV(5e+Nx6UMl0fJJRdSo3Dj9hxK62W1Jz6dY};qNs@^iMo2vQM7O9oH;l zM!nA1#$`u`!+0m^FA5h64--wX*63~eh31|Px?eY7Eg5VeIyuzWavO#H68MNbS2!Ii zS+kutGnE9bl0ktlU`AL($qH^y*f?xRUM?r(>vA|Hx5I*3xg?q_Re-Pj`zBvF7x_@T z5zF3MXO~aGCi!o(_?@Nlis8uPp}&?Ir_z3}WqdPph`ctq+7pZXEns&0BZz}IngVLm_#2CI;6-4Z zrmIE$`u$y6+Rh2ziv>W--isS#8f!ufYBj|F{h)3bdH&Cn>vkhE7}Q6{HTu9XFfc0q z{`YROFLC~h$0^@(G(9PBu%Y8`2XW$?m~cRUFQzn2v|=Z804^I%dAKuNi(yCM`~Th* zr~BS>?rZ}v+|l~jA@wZ)nhyLdr7!g4_D1yNe|{C2%FBRG=8Ad&U2m@7!B%_YT!pYt z^mz)2|9yb`*WF)n+xo-Knwh_SzXc$4=)i&qB>^m`uE&_W?}eVe581DTh@PN^S1~7adxZ3V zZ&)(_uigLMkLhKM0SIp-$r9q$9nU3`K1y5u{qZjXI0FLgDm;j{LTGpL@zG<3zm>uE z>J4%I7Zu>~xe1o#KP}jjm@x7S(^06mPO-m1YnKQJ#r}MPrDs6zo45NeSi!X0V&gr@ z8DA5C+w$+bdyy@T1f{NKnpgAp=_v<#G!+lPVwgf`^i@cAOt z^5x&l3c$tQbGw!?ls441Ft`#8j#1K*+&e%(`rpuCGv`&1xp2W1e}ws-_??{~gVr#p zKKSob&Ip_W2sFWcSm-hVo5DNsEb(wHN|NP7NG>mYnWQrSRaA}VsNzM{W zV*a2Ur()*6%jL`#{Zyg;nm<+3)Bpz=f)Q0Z@K~VwXQ%_Hamf%=Z!?F7phHAcuz!Av z=q9p;6;&{u`h+OlH)x;Vi}aU&|Lh8fAgR--B~q2r@`iiJ<4{8E2^ zy^7CErT)^+mibK&2R$^UH>Jxj_6Yus89(OU;a;kpySt{8MuTYZsFd>L_zdX3aKzX& zMbSqUE6es5*+_*||Bjg*J!^+?5%q7>P+$GBb=yC|$Eg0U+n}n!g^^!}1i?IC(9kok z)6ArJ^S^sU3#O4fp6bopGhPJo>F?1%*!B%U_fK7fP{P>$-S8@0FLS48S~0uL8#F7G zk4g`K%LN)0?L?o3@vYRAyJwF6jan<_HCLUeWDIVlz0bED1H@lJ&rr+b|0cMS?9bh1 zsRY$Y%77#P5gv1kCH4QjIKF6yGlD2qW*8vKNmi{$`sd`+6=Z7qZw8ad)*9Q)!tKjE ze&t~5&>c-?ye4XT1U9_5 z+>KVi(NzDCL47>Ursg@O{{0Qkcx9GcO(kPfeA4KN&ayc~LlZ=zjOenb(36jQ22*tmy z(=W^+!S4Heqs8U=+kM}=(t*H&l6ElD5R?Wi-)nT&s5mP|&Hw!t&TRjwodg5rYG6e= zaI$kHfU*{s1R@fkQ${6Y{x3idv_-XqUA!45*QsBqj$M}#n`ELW4!wJ?5I7;qmr;oN zCstp=4N(J&b5Z{kMRwI_ZG(1r=m9^5k&*|0Qe5~821@#~7vt1_?|=_5tuO_{CZOEN z7myDL(e4dE(1ouxTfPtc_h=Ahdc}D~a>Q!d)22f6I=6><2y)3-wYxCJ#4<;x65EZ;I+3d@d-j^SE zqRq9Gd=`TgWplr{#yx-YT?^L5aefChp7oieC@9q~nq{A)rt^qjc#6g|#s^a1Oxwz8 z^5a2i)+f)0EIe}4npjGfw&dOCc%dyNz#9Z@b!fOaH1o>scDLbMzwzy&i(z(W%&)ic zyDahd3HM@A?tG%X%2SVf=g}*!;8GZz%$Q-BbB(M4t-H=soXcAT}kyB!xv) zY4fkHj(>B&L3iO6Q|>jh!Y!6ZEC9oRKz>7Uw)JB-E$& zzvo<+OPa7yzxm^}DSqIr(aI6AR;kad`^IgTIcD7!Z%^9azEKp&pBDtnI?} zk+rIig#3@FF4WJpcg$O3WN?`U7{&q7jNPs#tEU`I5?y^%EBDu?TRozDzp}119Ikoc zE88(6|B_Tk$5G)$+8QPig8nM;&))xJ+5aK?x4h+a*xkC~eIHf@dgMAuaAHLfAGl5bSxTt~=A)den=hOu+ g*&vw>NvF#{eo>uzDaL(sCNThkr>mdKI;Vst03PUvvH$=8 literal 0 HcmV?d00001 diff --git a/docs/quick_start.rst b/docs/quick_start.rst index 89b3cc9..7748f69 100644 --- a/docs/quick_start.rst +++ b/docs/quick_start.rst @@ -73,7 +73,7 @@ See the **RetailHero tutorial notebook** (`EN`_ |Open In Colab1|_, `RU`_ |Open I from sklift.viz import plot_qini_curve - plot_qini_curve(y_true=y_val, uplift=uplift_preds, treatment=treat_val) + plot_qini_curve(y_true=y_val, uplift=uplift_preds, treatment=treat_val, negative_effect=True) .. image:: _static/images/quick_start_qini.png :width: 514px diff --git a/docs/user_guide/models/classification.rst b/docs/user_guide/models/classification.rst new file mode 100644 index 0000000..e363950 --- /dev/null +++ b/docs/user_guide/models/classification.rst @@ -0,0 +1,34 @@ +*********************** +Approach classification +*********************** + +Uplift modeling techniques can be grouped into :guilabel:`data preprocessing` and :guilabel:`data processing` approaches. + +.. image:: ../../_static/images/user_guide/ug_uplift_approaches.png + :align: center + :alt: Classification of uplift modeling techniques: data preprocessing and data processing + +Data preprocessing +==================== + +In the :guilabel:`preprocessing` approaches, existing out-of-the-box learning methods are used, after pre- or post-processing of the data and outcomes. + +A popular and generic data preprocessing approach is :ref:`the flipped label approach `, also called class transformation approach. + +Other data preprocessing approaches extend the set of predictor variables to allow for the estimation of uplift. An example is :ref:`the single model with treatment as feature `. + +Data processing +==================== + +In the :guilabel:`data processing` approaches, new learning methods and methodologies are developed that aim to optimize expected uplift more directly. + +Data processing techniques include two categories: :guilabel:`indirect` and :guilabel:`direct` estimation approaches. + +:guilabel:`Indirect` estimation approaches include :ref:`the two-model model approach `. + +:guilabel:`Direct` estimation approaches are typically adaptations from decision tree algorithms. The adoptions include modified the splitting criteria and dedicated pruning techniques. + +References +========== + +1️⃣ Devriendt, Floris, Tias Guns and Wouter Verbeke. “Learning to rank for uplift modeling.” ArXiv abs/2002.05897 (2020): n. pag. diff --git a/docs/user_guide/models/index.rst b/docs/user_guide/models/index.rst index 93a4fb6..dcccfce 100644 --- a/docs/user_guide/models/index.rst +++ b/docs/user_guide/models/index.rst @@ -13,6 +13,7 @@ Models :maxdepth: 3 :caption: Contents + ./classification ./solo_model ./revert_label ./two_models \ No newline at end of file From 86db93fa8eb8617304f4300974e54f2b47f9b431 Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sat, 8 Aug 2020 16:39:37 +0300 Subject: [PATCH 04/26] :green_book: Improve page --- docs/hall_of_fame.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/hall_of_fame.rst b/docs/hall_of_fame.rst index 3e093ce..721926b 100644 --- a/docs/hall_of_fame.rst +++ b/docs/hall_of_fame.rst @@ -4,7 +4,10 @@ Hall of Fame Here are the links to the competitions, names of the winners and to their solutions, where scikit-uplift was used. -`X5 RetailHero Uplift Modeling contest `_ -============================================================================================= +`X5 Retail Hero: Uplift Modeling for Promotional Campaign `_ +======================================================================================================================== + +Predict how much the purchase probability could increase as a result of sending an advertising SMS. + 2. `Kirill Liksakov `_ `solution `_ From bd2537d935250efbf52f80807c5c35c34f5e4bf4 Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sat, 8 Aug 2020 17:18:58 +0300 Subject: [PATCH 05/26] :green_book: Fix different typos in docs --- docs/Readme.rst | 4 ++-- docs/index.rst | 16 ++++++++-------- docs/quick_start.rst | 11 ++++++++--- docs/user_guide/index.rst | 4 ++-- docs/user_guide/introduction/cate.rst | 10 +++++----- docs/user_guide/introduction/clients.rst | 6 +++--- docs/user_guide/introduction/comparison.rst | 4 ++-- docs/user_guide/introduction/data_collection.rst | 2 +- docs/user_guide/models/revert_label.rst | 4 ++-- docs/user_guide/models/two_models.rst | 4 ++-- 10 files changed, 35 insertions(+), 30 deletions(-) diff --git a/docs/Readme.rst b/docs/Readme.rst index b998e78..6ea4463 100644 --- a/docs/Readme.rst +++ b/docs/Readme.rst @@ -1,9 +1,9 @@ -.. _scikit-uplift.readthedocs.io: https://scikit-uplift.readthedocs.io/en/latest/ +.. _uplift-modeling.com: https://www.uplift-modeling.com/en/latest/index.html Documentation =============== -The full documentation is available at `scikit-uplift.readthedocs.io`_. +The full documentation is available at `uplift-modeling.com`_. Or you can build the documentation locally using `Sphinx `_ 1.4 or later: diff --git a/docs/index.rst b/docs/index.rst index f6604a5..f48b72b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,20 +10,20 @@ scikit-uplift **scikit-uplift (sklift)** is a Python module for basic approaches of uplift modeling built on top of scikit-learn. -Uplift prediction aims to estimate the causal impact of a treatment at the individual level. +Uplift prediction aims to estimate the causal impact of treatment at the individual level. -Read more about uplift modeling problem in :ref:`User Guide `, -also articles in russian on habr.com: `Part 1 `__ +Read more about the uplift modeling problem in :ref:`User Guide `, +also articles in Russian on habr.com: `Part 1 `__ and `Part 2 `__. Features ######### -- Comfortable and intuitive style of modelling like scikit-learn; +- Comfortable and intuitive style of modeling like scikit-learn; - Applying any estimator adheres to scikit-learn conventions; -- All approaches can be used in sklearn.pipeline. See example of usage: |Open In Colab3|_; +- All approaches can be used in sklearn.pipeline. See the example of usage: |Open In Colab3|_; - Almost all implemented approaches solve both the problem of classification and regression; @@ -50,8 +50,8 @@ Project info * GitHub repository: https://github.com/maks-sh/scikit-uplift * Github examples: https://github.com/maks-sh/scikit-uplift/tree/master/notebooks -* Documentation: https://scikit-uplift.readthedocs.io/en/latest/ -* Contributing guide: https://scikit-uplift.readthedocs.io/en/latest/contributing.html +* Documentation: https://www.uplift-modeling.com/en/latest/index.html +* Contributing guide: https://www.uplift-modeling.com/en/latest/contributing.html * License: `MIT `__ Community @@ -59,7 +59,7 @@ Community We welcome new contributors of all experience levels. -- Please see our `Contributing Guide `_ for more details. +- Please see our `Contributing Guide `_ for more details. - By participating in this project, you agree to abide by its `Code of Conduct `__. .. image:: https://sourcerer.io/fame/maks-sh/maks-sh/scikit-uplift/images/0 diff --git a/docs/quick_start.rst b/docs/quick_start.rst index 7748f69..92794e2 100644 --- a/docs/quick_start.rst +++ b/docs/quick_start.rst @@ -13,7 +13,10 @@ Quick Start See the **RetailHero tutorial notebook** (`EN`_ |Open In Colab1|_, `RU`_ |Open In Colab2|_) for details. -**Train and predict your uplift model** +Train and predict your uplift model +==================================== + +Use the intuitive python API to train uplift models. .. code-block:: python :linenos: @@ -38,7 +41,8 @@ See the **RetailHero tutorial notebook** (`EN`_ |Open In Colab1|_, `RU`_ |Open I # predict uplift uplift_preds = tm.predict(X_val) -**Evaluate your uplift model** +Evaluate your uplift model +=========================== .. code-block:: python :linenos: @@ -66,7 +70,8 @@ See the **RetailHero tutorial notebook** (`EN`_ |Open In Colab1|_, `RU`_ |Open I tm_wau = weighted_average_uplift(y_true=y_val, uplift=uplift_preds, treatment=treat_val) -**Vizualize the results** +Vizualize the results +====================== .. code-block:: python :linenos: diff --git a/docs/user_guide/index.rst b/docs/user_guide/index.rst index 11c433f..0b9ab78 100644 --- a/docs/user_guide/index.rst +++ b/docs/user_guide/index.rst @@ -7,7 +7,7 @@ User Guide .. image:: https://habrastorage.org/webt/hf/7i/nu/hf7inuu3agtnwl1yo0g--mznzno.jpeg :alt: Cover of User Guide for uplift modeling and causal inference -Uplift modeling estimates the effect of communication action on some customer outcome and gives an opportunity to efficiently target customers which are most likely to respond to a marketing campaign. +Uplift modeling estimates the effect of communication action on some customer outcomes and gives an opportunity to efficiently target customers which are most likely to respond to a marketing campaign. It is relatively easy to implement, but surprisingly poorly covered in the machine learning courses and literature. This guide is going to shed some light on the essentials of causal inference estimating and uplift modeling. @@ -44,5 +44,5 @@ If you find this User Guide useful for your research, please consider citing: year = {2020}, publisher = {GitHub}, journal = {GitHub repository}, - howpublished = {\url{https://scikit-uplift.readthedocs.io/en/latest/user_guide/index.html}} + howpublished = {\url{https://www.uplift-modeling.com/en/latest/user_guide/index.html}} } \ No newline at end of file diff --git a/docs/user_guide/introduction/cate.rst b/docs/user_guide/introduction/cate.rst index 4e4374b..4cb07c5 100644 --- a/docs/user_guide/introduction/cate.rst +++ b/docs/user_guide/introduction/cate.rst @@ -2,7 +2,7 @@ Causal Inference: Basics ****************************************** -In a perfect world, we want to calculate a difference in a person's reaction received communication and the reaction without receiving any communication. +In a perfect world, we want to calculate a difference in a person's reaction received communication, and the reaction without receiving any communication. But there is a problem: we can not make a communication (send an e-mail) and do not make a communication (no e-mail) at the same time. .. image:: https://habrastorage.org/webt/fl/fi/dz/flfidz416o7of5j0nmgdjqqkzfe.jpeg @@ -29,8 +29,8 @@ But we can estimate CATE or *uplift* of an object: Where: -- :math:`W_i \in {0, 1}` - a binary variable: 1 if person :math:`i` receives the treatment :guilabel:`treatment group`, and 0 if person :math:`i` receives no treatment :guilabel:`control group`; -- :math:`Y_i` - person :math:`i`’s observed outcome, which is actually equal: +- :math:`W_i \in {0, 1}` - a binary variable: 1 if person :math:`i` receives the :guilabel:`treatment group`, and 0 if person :math:`i` receives no treatment :guilabel:`control group`; +- :math:`Y_i` - person :math:`i`’s observed outcome, which is equal: .. math:: Y_i = W_i * Y_i^1 + (1 - W_i) * Y_i^0 = \ @@ -41,12 +41,12 @@ Where: This won’t identify the CATE unless one is willing to assume that :math:`W_i` is independent of :math:`Y_i^1` and :math:`Y_i^0` conditional on :math:`X_i`. This assumption is the so-called *Unconfoundedness Assumption* or the *Conditional Independence Assumption* (CIA) found in the social sciences and medical literature. This assumption holds true when treatment assignment is random conditional on :math:`X_i`. -Briefly this can be written as: +Briefly, this can be written as: .. math:: CIA : \{Y_i^0, Y_i^1\} \perp \!\!\! \perp W_i \vert X_i -Also introduce additional useful notation. +Also, introduce additional useful notation. Let us define the :guilabel:`propensity score`, :math:`p(X_i) = P(W_i = 1| X_i)`, i.e. the probability of treatment given :math:`X_i`. References diff --git a/docs/user_guide/introduction/clients.rst b/docs/user_guide/introduction/clients.rst index 0506112..cc8cf3f 100644 --- a/docs/user_guide/introduction/clients.rst +++ b/docs/user_guide/introduction/clients.rst @@ -2,7 +2,7 @@ Types of customers ****************************************** -We can determine 4 types of customers based on a response to a treatment: +We can determine 4 types of customers based on a response to treatment: .. image:: ../../_static/images/user_guide/ug_clients_types.jpg :alt: Classification of customers based on their response to a treatment @@ -10,10 +10,10 @@ We can determine 4 types of customers based on a response to a treatment: :height: 282 px :align: center -- :guilabel:`Do-Not-Disturbs` *(a.k.a. Sleeping-dogs)* have a strong negative response to a marketing communication. They are going to purchase if *NOT* treated and will *NOT* purchase *IF* treated. It is not only a wasted marketing budget but also a negative impact. For instance, customers targeted could result in rejecting current products or services. In terms of math: :math:`W_i = 1, Y_i = 0` or :math:`W_i = 0, Y_i = 1`. +- :guilabel:`Do-Not-Disturbs` *(a.k.a. Sleeping-dogs)* have a strong negative response to marketing communication. They are going to purchase if *NOT* treated and will *NOT* purchase *IF* treated. It is not only a wasted marketing budget but also a negative impact. For instance, customers targeted could result in rejecting current products or services. In terms of math: :math:`W_i = 1, Y_i = 0` or :math:`W_i = 0, Y_i = 1`. - :guilabel:`Lost Causes` will *NOT* purchase the product *NO MATTER* they are contacted or not. The marketing budget in this case is also wasted because it has no effect. In terms of math: :math:`W_i = 1, Y_i = 0` or :math:`W_i = 0, Y_i = 0`. - :guilabel:`Sure Things` will purchase *ANYWAY* no matter they are contacted or not. There is no motivation to spend the budget because it also has no effect. In terms of math: :math:`W_i = 1, Y_i = 1` or :math:`W_i = 0, Y_i = 1`. -- :guilabel:`Persuadables` will always respond *POSITIVE* to the marketing communication. They is going to purchase *ONLY* if contacted (or sometimes they purchase *MORE* or *EARLIER* only if contacted). This customer's type should be the only target for the marketing campaign. In terms of math: :math:`W_i = 0, Y_i = 0` or :math:`W_i = 1, Y_i = 1`. +- :guilabel:`Persuadables` will always respond *POSITIVE* to marketing communication. They are going to purchase *ONLY* if contacted (or sometimes they purchase *MORE* or *EARLIER* only if contacted). This customer's type should be the only target for the marketing campaign. In terms of math: :math:`W_i = 0, Y_i = 0` or :math:`W_i = 1, Y_i = 1`. Because we can't communicate and not communicate with the customer at the same time, we will never be able to observe exactly which type a particular customer belongs to. diff --git a/docs/user_guide/introduction/comparison.rst b/docs/user_guide/introduction/comparison.rst index e6ced35..a3e3b0c 100644 --- a/docs/user_guide/introduction/comparison.rst +++ b/docs/user_guide/introduction/comparison.rst @@ -9,14 +9,14 @@ There are several ways to use machine learning to select customers for a marketi :alt: Comparison with other models - :guilabel:`The Look-alike model` (or Positive Unlabeled Learning) evaluates a probability that the customer is going to accomplish a target action. A training dataset contains known positive objects (for instance, users who have installed an app) and random negative objects (a random subset of all other customers who have not installed the app). The model searches for customers who are similar to those who made the target action. -- :guilabel:`The Response model` evaluates the probability that the customer is going to accomplish the target action if there was a communication (a.k.a treatment). In this case the training dataset is data collected after some interaction with the customers. In contrast to the first approach, we have confirmed positive and negative observations at our disposal (for instance, the customer who decides to issue a credit card or to decline an offer). +- :guilabel:`The Response model` evaluates the probability that the customer is going to accomplish the target action if there was a communication (a.k.a treatment). In this case, the training dataset is data collected after some interaction with the customers. In contrast to the first approach, we have confirmed positive and negative observations at our disposal (for instance, the customer who decides to issue a credit card or to decline an offer). - :guilabel:`The Uplift model` evaluates the net effect of communication by trying to select only those customers who are going to perform the target action only when there is some advertising exposure presenting to them. The model predicts a difference between the customer's behavior when there is a treatment (communication) and when there is no treatment (no communication). When should we use uplift modeling? Uplift modeling is used when the customer's target action is likely to happen without any communication. For instance, we want to promote a popular product but we don't want to spend our marketing budget on customers who will buy the product anyway with or without communication. -If the product is not popular and it is has to be promoted to be bought, then a task turns to the response modeling task. +If the product is not popular and it has to be promoted to be bought, then a task turns to the response modeling task. References ========== diff --git a/docs/user_guide/introduction/data_collection.rst b/docs/user_guide/introduction/data_collection.rst index b614a81..5e582c0 100644 --- a/docs/user_guide/introduction/data_collection.rst +++ b/docs/user_guide/introduction/data_collection.rst @@ -11,7 +11,7 @@ There are few additional steps different from a standard data collection procedu Data collected from the marketing experiment consists of the customer's responses to the marketing offer (target). -The only difference between the experiment and the future uplift model's campaign is a fact that in the first case we choose random customers to make a promotion. In the second case the choice of a customer to communicate with is based on the predicted value returned by the uplift model. If the marketing campaign significantly differs from the experiment used to collect data, the model will be less accurate. +The only difference between the experiment and the future uplift model's campaign is a fact that in the first case we choose random customers to make a promotion. In the second case, the choice of a customer to communicate with is based on the predicted value returned by the uplift model. If the marketing campaign significantly differs from the experiment used to collect data, the model will be less accurate. There is a trick: before running the marketing campaign, it is recommended to randomly subset a small part of the customer base and divide it into a control and a treatment group again, similar to the previous experiment. Using this data, you will not only be able to accurately evaluate the effectiveness of the campaign but also collect additional data for a further model retraining. diff --git a/docs/user_guide/models/revert_label.rst b/docs/user_guide/models/revert_label.rst index b08634f..935e701 100644 --- a/docs/user_guide/models/revert_label.rst +++ b/docs/user_guide/models/revert_label.rst @@ -13,9 +13,9 @@ The main idea is to predict a slightly changed target :math:`Z_i`: .. math:: Z_i = Y_i \cdot W_i + (1 - Y_i) \cdot (1 - W_i), -* :math:`Z_i` - new target for the :math:`i` customer; +* :math:`Z_i` - a new target for the :math:`i` customer; -* :math:`Y_i` - previous target for the :math:`i` customer; +* :math:`Y_i` - a previous target for the :math:`i` customer; * :math:`W_i` - treatment flag assigned to the :math:`i` customer. diff --git a/docs/user_guide/models/two_models.rst b/docs/user_guide/models/two_models.rst index e7e210c..0a6477e 100644 --- a/docs/user_guide/models/two_models.rst +++ b/docs/user_guide/models/two_models.rst @@ -38,7 +38,7 @@ The authors of this method proposed to use the same idea to solve the problem of .. hint:: In sklift this approach corresponds to the :class:`.TwoModels` class and the **ddr_control** method. -At the beginning we train the classifier based on the control data: +At the beginning, we train the classifier based on the control data: .. math:: P^C = P(Y=1| X, W = 0), @@ -68,7 +68,7 @@ the :math:`P_C` classifier. In sklift this approach corresponds to the :class:`.TwoModels` class and the **ddr_treatment** method. There is an important remark about the data nature. -It is important to calibrate model's scores into probabilities if treatment and control data have a different nature. +It is important to calibrate the model's scores into probabilities if treatment and control data have a different nature. Model calibration techniques are well described `in the scikit-learn documentation`_. References From ad1e7b82afb78c60455c16260084cf5fa36d7efe Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sun, 6 Sep 2020 20:05:26 +0300 Subject: [PATCH 06/26] :green_book: Improve Readme and index file of docs --- Readme.rst | 27 +++++++++++++++++++-------- docs/index.rst | 35 ++++++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/Readme.rst b/Readme.rst index eec58c9..e53623d 100644 --- a/Readme.rst +++ b/Readme.rst @@ -36,27 +36,36 @@ scikit-uplift =============== -**scikit-uplift (sklift)** is a Python module for classic approaches for uplift modeling built on top of scikit-learn. +**scikit-uplift (sklift)** is an uplift modeling python package that provides fast sklearn-style models implementation, evaluation metrics and visualization tools. -Uplift prediction aims to estimate the causal impact of a treatment at the individual level. +Uplift modeling estimates a causal effect of treatment and uses it to effectively target customers that are most likely to respond to a marketing campaign. + +**Use cases for uplift modeling:** + +* Target customers in the marketing campaign. Quite useful in promotion of some popular product where there is a big part of customers who make a target action by themself without any influence. By modeling uplift you can find customers who are likely to make the target action (for instance, install an app) only when treated (for instance, received a push). + +* Combine a churn model and an uplift model to offer some bonus to a group of customers who are likely to churn. + +* Select a tiny group of customers in the campaign where a price per customer is high. Read more about uplift modeling problem in `User Guide `__, -also articles in russian on habr.com: `Part 1 `__ + +Articles in russian on habr.com: `Part 1 `__ and `Part 2 `__. **Features**: -* Comfortable and intuitive style of modelling like scikit-learn; +* Сomfortable and intuitive scikit-learn-like API; -* Applying any estimator adheres to scikit-learn conventions (e.g. Xgboost, LightGBM, Catboost, etc.); +* Applying any estimator compatible with scikit-learn (e.g. Xgboost, LightGBM, Catboost, etc.); * All approaches can be used in sklearn.pipeline (see example (`EN `__ |Open In Colab3|_, `RU `__ |Open In Colab4|_)); -* Almost all implemented approaches solve both the problem of classification and regression; +* Almost all implemented approaches solve classification and regression problem; -* A lot of metrics are implemented to evaluate your uplift model. Such as *Area Under Uplift Curve* (AUUC) or *Area Under Qini Curve* (Qini coefficient); +* More uplift metrics that you have ever seen in one place! Include brilliants like *Area Under Uplift Curve* (AUUC) or *Area Under Qini Curve* (Qini coefficient) with ideal cases; -* Useful graphs for analyzing the built model. +* Nice and useful viz for analyzing a performance model. Installation ------------- @@ -164,6 +173,8 @@ We welcome new contributors of all experience levels. - Please see our `Contributing Guide `_ for more details. - By participating in this project, you agree to abide by its `Code of Conduct `__. +If you have any questions, please contact us at team@uplift-modeling.com + Contributing ~~~~~~~~~~~~~~~ diff --git a/docs/index.rst b/docs/index.rst index f48b72b..dfe04f6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,28 +8,39 @@ scikit-uplift ************** -**scikit-uplift (sklift)** is a Python module for basic approaches of uplift modeling built on top of scikit-learn. +**scikit-uplift (sklift)** is an uplift modeling python package that provides fast sklearn-style models implementation, evaluation metrics and visualization tools. -Uplift prediction aims to estimate the causal impact of treatment at the individual level. +The main idea is to provide easy-to-use and fast python package for uplift modeling. It delivers the model interface with the familiar scikit-learn API. One can use any popular estimator (for instance, from the Catboost library). -Read more about the uplift modeling problem in :ref:`User Guide `, -also articles in Russian on habr.com: `Part 1 `__ +*Uplift modeling* estimates a causal effect of treatment and uses it to effectively target customers that are most likely to respond to a marketing campaign. + +**Use cases for uplift modeling:** + +* Target customers in the marketing campaign. Quite useful in promotion of some popular product where there is a big part of customers who make a target action by themself without any influence. By modeling uplift you can find customers who are likely to make the target action (for instance, install an app) only when treated (for instance, received a push). + +* Combine a churn model and an uplift model to offer some bonus to a group of customers who are likely to churn. + +* Select a tiny group of customers in the campaign where a price per customer is high. + +Read more about *uplift modeling* problem in `User Guide `__, + +Articles in russian on habr.com: `Part 1 `__ and `Part 2 `__. Features ######### -- Comfortable and intuitive style of modeling like scikit-learn; +- Сomfortable and intuitive scikit-learn-like API; -- Applying any estimator adheres to scikit-learn conventions; +- Applying any estimator compatible with scikit-learn (e.g. Xgboost, LightGBM, Catboost, etc.); -- All approaches can be used in sklearn.pipeline. See the example of usage: |Open In Colab3|_; +- All approaches can be used in `sklearn.pipeline`. See the example of usage: |Open In Colab3|_; -- Almost all implemented approaches solve both the problem of classification and regression; +- Almost all implemented approaches solve classification and regression problem; -- A lot of metrics (Such as *Area Under Uplift Curve* or *Area Under Qini Curve*) are implemented to evaluate your uplift model; +- More uplift metrics that you have ever seen in one place! Include brilliants like *Area Under Uplift Curve* (AUUC) or *Area Under Qini Curve* (Qini coefficient) with ideal cases; -- Useful graphs for analyzing the built model. +- Nice and useful viz for analyzing a performance model. **The package currently supports the following methods:** @@ -57,11 +68,13 @@ Project info Community ############# -We welcome new contributors of all experience levels. +Sklift is being actively maintained and welcomes new contributors of all experience levels. - Please see our `Contributing Guide `_ for more details. - By participating in this project, you agree to abide by its `Code of Conduct `__. +If you have any questions, please contact us at team@uplift-modeling.com + .. image:: https://sourcerer.io/fame/maks-sh/maks-sh/scikit-uplift/images/0 :target: https://sourcerer.io/fame/maks-sh/maks-sh/scikit-uplift/links/0 :alt: Top contributor 1 From f3b399162c17a053c3a7963397a4c7a63169bf03 Mon Sep 17 00:00:00 2001 From: Irina Elisova Date: Tue, 15 Sep 2020 11:14:01 +0300 Subject: [PATCH 07/26] =?UTF-8?q?0=EF=B8=8F=E2=83=A3=201=EF=B8=8F=E2=83=A3?= =?UTF-8?q?=20=20Add=20utils=20module=20and=20checker=20of=20treatment=20a?= =?UTF-8?q?rray=20is=20binary=20=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `utils` module added: * `check_is_binary` function in utils module * `check_is_binary` is added in models module: 3 models [fit method], metrics module and viz module * `check_is_binary` is added as a checker for the input treatment array in addition `check_consistent_lentgh` from `sklearn.utils` somewhere in metrics --- sklift/metrics/metrics.py | 44 +++++++++++++++++++++++++++++---------- sklift/models/models.py | 9 +++++--- sklift/utils/__init__.py | 3 +++ sklift/utils/utils.py | 13 ++++++++++++ sklift/viz/base.py | 20 +++++++++++++----- 5 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 sklift/utils/__init__.py create mode 100644 sklift/utils/utils.py diff --git a/sklift/metrics/metrics.py b/sklift/metrics/metrics.py index 9196637..21e4144 100644 --- a/sklift/metrics/metrics.py +++ b/sklift/metrics/metrics.py @@ -4,6 +4,8 @@ from sklearn.utils.extmath import stable_cumsum from sklearn.utils.validation import check_consistent_length +from ..utils import check_is_binary + def uplift_curve(y_true, uplift, treatment): """Compute Uplift curve. @@ -31,8 +33,10 @@ def uplift_curve(y_true, uplift, treatment): Devriendt, F., Guns, T., & Verbeke, W. (2020). Learning to rank for uplift modeling. ArXiv, abs/2002.05897. """ - # TODO: check the treatment is binary + check_consistent_length(y_true, uplift, treatment) + check_is_binary(treatment) y_true, uplift, treatment = np.array(y_true), np.array(uplift), np.array(treatment) + desc_score_indices = np.argsort(uplift, kind="mergesort")[::-1] y_true, uplift, treatment = y_true[desc_score_indices], uplift[desc_score_indices], treatment[desc_score_indices] @@ -84,7 +88,9 @@ def perfect_uplift_curve(y_true, treatment): :func:`.plot_uplift_curve`: Plot Uplift curves from predictions. """ + check_consistent_length(y_true, treatment) + check_is_binary(treatment) y_true, treatment = np.array(y_true), np.array(treatment) cr_num = np.sum((y_true == 1) & (treatment == 0)) # Control Responders @@ -121,8 +127,9 @@ def uplift_auc_score(y_true, uplift, treatment): :func:`.qini_auc_score`: Compute normalized Area Under the Qini Curve from prediction scores. """ - check_consistent_length(y_true, uplift, treatment) + check_consistent_length(y_true, uplift, treatment) + check_is_binary(treatment) y_true, uplift, treatment = np.array(y_true), np.array(uplift), np.array(treatment) x_actual, y_actual = uplift_curve(y_true, uplift, treatment) @@ -164,7 +171,9 @@ def qini_curve(y_true, uplift, treatment): Devriendt, F., Guns, T., & Verbeke, W. (2020). Learning to rank for uplift modeling. ArXiv, abs/2002.05897. """ - # TODO: check the treatment is binary + + check_consistent_length(y_true, uplift, treatment) + check_is_binary(treatment) y_true, uplift, treatment = np.array(y_true), np.array(uplift), np.array(treatment) desc_score_indices = np.argsort(uplift, kind="mergesort")[::-1] @@ -220,7 +229,9 @@ def perfect_qini_curve(y_true, treatment, negative_effect=True): :func:`.plot_qini_curves`: Plot Qini curves from predictions.. """ + check_consistent_length(y_true, treatment) + check_is_binary(treatment) n_samples = len(y_true) y_true, treatment = np.array(y_true), np.array(treatment) @@ -274,9 +285,10 @@ def qini_auc_score(y_true, uplift, treatment, negative_effect=True): Nicholas J Radcliffe. (2007). Using control groups to target on predicted lift: Building and assessing uplift model. Direct Marketing Analytics Journal, (3):14–21, 2007. """ - # ToDO: Add Continuous Outcomes - check_consistent_length(y_true, uplift, treatment) + # TODO: Add Continuous Outcomes + check_consistent_length(y_true, uplift, treatment) + check_is_binary(treatment) y_true, uplift, treatment = np.array(y_true), np.array(uplift), np.array(treatment) if not isinstance(negative_effect, bool): @@ -328,9 +340,10 @@ def uplift_at_k(y_true, uplift, treatment, strategy, k=0.3): :func:`.qini_auc_score`: Compute normalized Area Under the Qini Curve from prediction scores. """ - # ToDo: checker that treatment is binary and all groups is not empty - check_consistent_length(y_true, uplift, treatment) + # TODO: checker all groups is not empty + check_consistent_length(y_true, uplift, treatment) + check_is_binary(treatment) y_true, uplift, treatment = np.array(y_true), np.array(uplift), np.array(treatment) strategy_methods = ['overall', 'by_group'] @@ -424,12 +437,14 @@ def response_rate_by_percentile(y_true, uplift, treatment, group, strategy='over variance of the response rate at each percentile, group size at each percentile. """ - + + check_consistent_length(y_true, uplift, treatment) + check_is_binary(treatment) + group_types = ['treatment', 'control'] strategy_methods = ['overall', 'by_group'] n_samples = len(y_true) - check_consistent_length(y_true, uplift, treatment) if group not in group_types: raise ValueError(f'Response rate supports only group types in {group_types},' @@ -494,10 +509,12 @@ def weighted_average_uplift(y_true, uplift, treatment, strategy='overall', bins= float: Weighted average uplift. """ + check_consistent_length(y_true, uplift, treatment) + check_is_binary(treatment) + strategy_methods = ['overall', 'by_group'] n_samples = len(y_true) - check_consistent_length(y_true, uplift, treatment) if strategy not in strategy_methods: raise ValueError(f'Response rate supports only calculating methods in {strategy_methods},' @@ -559,10 +576,12 @@ def uplift_by_percentile(y_true, uplift, treatment, strategy='overall', bins=10, pandas.DataFrame: DataFrame where metrics are by columns and percentiles are by rows. """ + check_consistent_length(y_true, uplift, treatment) + check_is_binary(treatment) + strategy_methods = ['overall', 'by_group'] n_samples = len(y_true) - check_consistent_length(y_true, uplift, treatment) if strategy not in strategy_methods: raise ValueError(f'Response rate supports only calculating methods in {strategy_methods},' @@ -649,6 +668,9 @@ def treatment_balance_curve(uplift, treatment, winsize): Returns: array (shape = [>2]), array (shape = [>2]): Points on a curve. """ + + check_consistent_length(uplift, treatment) + check_is_binary(treatment) uplift, treatment = np.array(uplift), np.array(treatment) desc_score_indices = np.argsort(uplift, kind="mergesort")[::-1] diff --git a/sklift/models/models.py b/sklift/models/models.py index dc84c5c..b194bb7 100644 --- a/sklift/models/models.py +++ b/sklift/models/models.py @@ -1,11 +1,12 @@ import warnings - import numpy as np import pandas as pd from sklearn.base import BaseEstimator from sklearn.utils.multiclass import type_of_target from sklearn.utils.validation import check_consistent_length +from ..utils import check_is_binary + class SoloModel(BaseEstimator): """aka Treatment Dummy approach, or Single model approach, or S-Learner. @@ -92,6 +93,7 @@ def fit(self, X, y, treatment, estimator_fit_params=None): """ check_consistent_length(X, y, treatment) + check_is_binary(treatment) treatment_values = np.unique(treatment) if len(treatment_values) != 2: @@ -239,8 +241,8 @@ def fit(self, X, y, treatment, estimator_fit_params=None): object: self """ - # TODO: check the treatment is binary check_consistent_length(X, y, treatment) + check_is_binary(treatment) self._type_of_target = type_of_target(y) if self._type_of_target != 'binary': @@ -382,8 +384,9 @@ def fit(self, X, y, treatment, estimator_trmnt_fit_params=None, estimator_ctrl_f Returns: object: self """ - # TODO: check the treatment is binary + check_consistent_length(X, y, treatment) + check_is_binary(treatment) self._type_of_target = type_of_target(y) X_ctrl, y_ctrl = X[treatment == 0], y[treatment == 0] diff --git a/sklift/utils/__init__.py b/sklift/utils/__init__.py new file mode 100644 index 0000000..981a544 --- /dev/null +++ b/sklift/utils/__init__.py @@ -0,0 +1,3 @@ +from .utils import check_is_binary + +__all__ = [check_is_binary] \ No newline at end of file diff --git a/sklift/utils/utils.py b/sklift/utils/utils.py new file mode 100644 index 0000000..9aa2690 --- /dev/null +++ b/sklift/utils/utils.py @@ -0,0 +1,13 @@ +import numpy as np + +def check_is_binary(array): + """Checker if array consists of int or float binary values 0 (0.) and 1 (1.) + + Args: + array (1d array-like): Array to check. + """ + + if not np.all(np.unique(array) == np.array([0, 1])): + raise ValueError(f"Input array is not binary. " + f"Array should contain only int or float binary values 0 (or 0.) and 1 (or 1.). " + f"Got values {np.unique(array)}.") diff --git a/sklift/viz/base.py b/sklift/viz/base.py index 67b203b..c4bb1f0 100644 --- a/sklift/viz/base.py +++ b/sklift/viz/base.py @@ -2,6 +2,7 @@ import numpy as np from sklearn.utils.validation import check_consistent_length +from ..utils import check_is_binary from ..metrics import ( uplift_curve, perfect_uplift_curve, uplift_auc_score, qini_curve, perfect_qini_curve, qini_auc_score, @@ -24,8 +25,10 @@ def plot_uplift_preds(trmnt_preds, ctrl_preds, log=False, bins=100): Returns: Object that stores computed values. """ + # TODO: Add k as parameter: vertical line on plots check_consistent_length(trmnt_preds, ctrl_preds) + check_is_binary(treatment) if not isinstance(bins, int) or bins <= 0: raise ValueError( @@ -68,9 +71,10 @@ def plot_uplift_curve(y_true, uplift, treatment, random=True, perfect=True): Returns: Object that stores computed values. """ + check_consistent_length(y_true, uplift, treatment) - y_true, uplift, treatment = np.array( - y_true), np.array(uplift), np.array(treatment) + check_is_binary(treatment) + y_true, uplift, treatment = np.array(y_true), np.array(uplift), np.array(treatment) fig, ax = plt.subplots(ncols=1, nrows=1, figsize=(8, 6)) @@ -112,9 +116,10 @@ def plot_qini_curve(y_true, uplift, treatment, random=True, perfect=True, negati Returns: Object that stores computed values. """ + check_consistent_length(y_true, uplift, treatment) - y_true, uplift, treatment = np.array( - y_true), np.array(uplift), np.array(treatment) + check_is_binary(treatment) + y_true, uplift, treatment = np.array(y_true), np.array(uplift), np.array(treatment) fig, ax = plt.subplots(ncols=1, nrows=1, figsize=(8, 6)) @@ -178,8 +183,9 @@ def plot_uplift_by_percentile(y_true, uplift, treatment, strategy='overall', kin strategy_methods = ['overall', 'by_group'] kind_methods = ['line', 'bar'] - n_samples = len(y_true) check_consistent_length(y_true, uplift, treatment) + check_is_binary(treatment) + n_samples = len(y_true) if strategy not in strategy_methods: raise ValueError(f'Response rate supports only calculating methods in {strategy_methods},' @@ -278,6 +284,10 @@ def plot_treatment_balance_curve(uplift, treatment, random=True, winsize=0.1): Returns: Object that stores computed values. """ + + check_consistent_length(uplift, treatment) + check_is_binary(treatment) + if (winsize <= 0) or (winsize >= 1): raise ValueError( 'winsize should be between 0 and 1, extremes excluded') From d821be225c9477941ddef139f74e35957de0e129 Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 31 Jan 2021 16:50:30 +0700 Subject: [PATCH 08/26] =?UTF-8?q?=D1=81ontributing.md=20revisions=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs/contributing.md change * Update CONTRIBUTING.md --- .github/CONTRIBUTING.md | 8 ++++---- docs/contributing.md | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 250e4d8..75df633 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -19,8 +19,8 @@ So, please make a pull request to the ``dev`` branch. 1. Fork the [project repository](https://github.com/maks-sh/scikit-uplift). 2. Clone your fork of the scikit-uplift repo from your GitHub account to your local disk: ``` bash - $ git clone git@github.com:YourLogin/scikit-uplift.git - $ cd scikit-learn + $ git clone https://github.com/YourName/scikit-uplift + $ cd scikit-uplift ``` 3. Add the upstream remote. This saves a reference to the main scikit-uplift repository, which you can use to keep your repository synchronized with the latest changes: ``` bash @@ -36,7 +36,7 @@ So, please make a pull request to the ``dev`` branch. $ git checkout -b feature/my_new_feature ``` and start making changes. Always use a feature branch. It’s a good practice. -6. Develop the feature on your feature branch on your computer, using Git to do the version control. When you’re done editing, add changed files using ``git add`` and then ``git commit``. +6. Develop the feature on your feature branch on your computer, using Git to do the version control. When you’re done editing, add changed files using ``git add .`` and then ``git commit`` Then push the changes to your GitHub account with: ``` bash @@ -55,4 +55,4 @@ We follow the PEP8 style guide for Python. Docstrings follow google style. * Use the present tense ("Add feature" not "Added feature") * Use the imperative mood ("Move cursor to..." not "Moves cursor to...") * Limit the first line to 72 characters or less -* Reference issues and pull requests liberally after the first line \ No newline at end of file +* Reference issues and pull requests liberally after the first line diff --git a/docs/contributing.md b/docs/contributing.md index a50a828..36275e0 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -19,8 +19,8 @@ So, please make a pull request to the ``dev`` branch. 1. Fork the [project repository](https://github.com/maks-sh/scikit-uplift). 2. Clone your fork of the scikit-uplift repo from your GitHub account to your local disk: ``` bash - $ git clone git@github.com:YourLogin/scikit-uplift.git - $ cd scikit-learn + $ git clone https://github.com/YourName/scikit-uplift + $ cd scikit-uplift ``` 3. Add the upstream remote. This saves a reference to the main scikit-uplift repository, which you can use to keep your repository synchronized with the latest changes: ``` bash @@ -36,7 +36,7 @@ So, please make a pull request to the ``dev`` branch. $ git checkout -b feature/my_new_feature ``` and start making changes. Always use a feature branch. It’s a good practice. -6. Develop the feature on your feature branch on your computer, using Git to do the version control. When you’re done editing, add changed files using ``git add`` and then ``git commit``. +6. Develop the feature on your feature branch on your computer, using Git to do the version control. When you’re done editing, add changed files using ``git add .`` and then ``git commit`` Then push the changes to your GitHub account with: ``` bash From bbfc3bb626de4fd4dc39026976edfabac7bec34d Mon Sep 17 00:00:00 2001 From: Bezmen Evgeny <37982126+flashlight101@users.noreply.github.com> Date: Tue, 2 Feb 2021 13:09:43 +0300 Subject: [PATCH 09/26] Add datasets submodule Start creating datasets submodule --- sklift/datasets/datasets.py | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 sklift/datasets/datasets.py diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py new file mode 100644 index 0000000..916522d --- /dev/null +++ b/sklift/datasets/datasets.py @@ -0,0 +1,55 @@ +import os + +import requests + + +def get_data_dir(): + pass + + +def create_data_dir(path): + pass + + +def download(url, dest_path): + pass + + +def get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): + """Return the path to the dataset. + + Args: + data_home (str, unicode): The path to the folder where datasets are stored. + url (str or unicode): The URL to the dataset. + dest_subdir (str or unicode): The name of the folder in which the dataset is stored. + dest_filename (str): The name of the dataset. + download_if_missing (bool): Flag if dataset is missing. + + Returns: + The path to the dataset. + + """ + if data_home is None: + if dest_subdir is None: + data_dir = get_data_dir() + else + data_dir = os.path.join(get_data_dir(), dest_subdir) + else: + if dest_subdir is None: + data_dir = os.path.abspath(data_home) + else: + data_dir = os.path.join(os.path.abspath(data_home), dest_subdir) + + create_data_dir(data_dir) + + dest_path = os.path.join(data_dir, dest_filename) + + if not os.path.isfile(dest_path): + if download_if_missing: + download(url, dest_path) + else: + raise IOError("Dataset missing") + return dest_path + +def clear_data_dir(path): + pass \ No newline at end of file From a6fdefe3be1ab70042c2480bcd65503d4a85ca90 Mon Sep 17 00:00:00 2001 From: Viktor Urushkin Date: Tue, 2 Feb 2021 20:30:44 +0700 Subject: [PATCH 10/26] Add clear_data_dir * clear_data_dir realization, added missing ":" in get_data --- sklift/datasets/datasets.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index 916522d..e0eedc8 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -1,4 +1,5 @@ import os +import shutil import requests @@ -32,7 +33,7 @@ def get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): if data_home is None: if dest_subdir is None: data_dir = get_data_dir() - else + else: data_dir = os.path.join(get_data_dir(), dest_subdir) else: if dest_subdir is None: @@ -51,5 +52,14 @@ def get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): raise IOError("Dataset missing") return dest_path -def clear_data_dir(path): - pass \ No newline at end of file + +def clear_data_dir(path=None): + """This function deletes the file. + + Args: + path (str): File path. By default, this is the default path for datasets. + """ + if path is None: + path = get_data_dir() + if os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) From 3a700ca7f708a84e52e20f83d412d3bf4ab842c4 Mon Sep 17 00:00:00 2001 From: Polina Semenova <48150532+semenova-pd@users.noreply.github.com> Date: Tue, 2 Feb 2021 18:10:06 +0300 Subject: [PATCH 11/26] Add create_data_dir and get_data_dir funcs (#50) --- sklift/datasets/datasets.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index e0eedc8..afe3b3b 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -1,15 +1,29 @@ import os import shutil + import requests def get_data_dir(): - pass + """This function returns a directory, which stores the datasets. + + Returns: + Full path to a directory, which stores the datasets. + + """ + return os.path.join(os.path.expanduser("~"), "scikit-uplift-data") def create_data_dir(path): - pass + """This function creates a directory, which stores the datasets. + + Args: + path (str): The path to the folder where datasets are stored. + + """ + if not os.path.isdir(path): + os.makedirs(path) def download(url, dest_path): From ae458e155012c55d318f1e7ffa19977269e8baad Mon Sep 17 00:00:00 2001 From: tankudo <58089872+tankudo@users.noreply.github.com> Date: Tue, 2 Feb 2021 19:08:04 +0100 Subject: [PATCH 12/26] Add download function(#49) --- sklift/datasets/datasets.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index afe3b3b..9fcdeb0 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -27,7 +27,24 @@ def create_data_dir(path): def download(url, dest_path): - pass + '''Download the file from url and save it localy + + Args: + url: URL address, must be a string. + dest_path: Destination of the file. + + Returns: + TypeError if URL is not a string. + ''' + if isinstance(url, str): + req = requests.get(url, stream=True) + req.raise_for_status() + + with open(dest_path, "wb") as fd: + for chunk in req.iter_content(chunk_size=2**20): + fd.write(chunk) + else: + raise TypeError("URL must be a string") def get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): From 083713cfc57ad17fdc0fa056c902f8bd79cdc6b4 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 4 Feb 2021 18:45:21 +0700 Subject: [PATCH 13/26] add __init__.py for datasets submodule (#61) --- sklift/datasets/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 sklift/datasets/__init__.py diff --git a/sklift/datasets/__init__.py b/sklift/datasets/__init__.py new file mode 100644 index 0000000..d2c87c6 --- /dev/null +++ b/sklift/datasets/__init__.py @@ -0,0 +1,13 @@ +from .datasets import ( + get_data_dir, + clear_data_dir, + fetch_x5, fetch_lenta, + fetch_criteo, fetch_hillstorm +) + +__all__ = [ + get_data_dir, + clear_data_dir, + fetch_x5, fetch_lenta, + fetch_criteo, fetch_hillstorm +] \ No newline at end of file From 0c4c9c9b86cec5359950ad2f711e754f57b66435 Mon Sep 17 00:00:00 2001 From: tankudo <58089872+tankudo@users.noreply.github.com> Date: Sat, 6 Feb 2021 10:16:21 +0100 Subject: [PATCH 14/26] Add fetch_hillstorm (#63) * fetch_hillstorm function * edited dockstring, added dataset description, integrated posibitily of tulip * import Bunch is added * hillstorm.rst added * corrected the type and misstake in the dataset name * relocated hilstrom.rst, added argument to the Bunch * function fetch_hillstrom working localy * missing import is added * _init_ filr is corrected --- sklift/datasets/__init__.py | 4 +-- sklift/datasets/datasets.py | 53 +++++++++++++++++++++++++++-- sklift/datasets/descr/hillstrom.rst | 39 +++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 sklift/datasets/descr/hillstrom.rst diff --git a/sklift/datasets/__init__.py b/sklift/datasets/__init__.py index d2c87c6..f3304c6 100644 --- a/sklift/datasets/__init__.py +++ b/sklift/datasets/__init__.py @@ -2,12 +2,12 @@ get_data_dir, clear_data_dir, fetch_x5, fetch_lenta, - fetch_criteo, fetch_hillstorm + fetch_criteo, fetch_hillstrom ) __all__ = [ get_data_dir, clear_data_dir, fetch_x5, fetch_lenta, - fetch_criteo, fetch_hillstorm + fetch_criteo, fetch_hillstrom ] \ No newline at end of file diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index 9fcdeb0..2d6e569 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -1,8 +1,8 @@ import os import shutil - - +import pandas as pd import requests +from sklearn.utils import Bunch def get_data_dir(): @@ -94,3 +94,52 @@ def clear_data_dir(path=None): path = get_data_dir() if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) + + +def fetch_hillstrom(target='visit', + data_home=None, + dest_subdir=None, + download_if_missing=True, + return_X_y=False): + + """Load the hillstrom dataset. + + Args: + target : str, desfault=visit. + Can also be conversion, and spend + data_home : str, default=None + Specify another download and cache folder for the datasets. + dest_subdir : str, default=None + download_if_missing : bool, default=True + If False, raise a IOError if the data is not locally available + instead of trying to download the data from the source site. + + Returns: + Dictionary-like object, with the following attributes. + data : {ndarray, dataframe} of shape (64000, 12) + The data matrix to learn. + target : {ndarray, series} of shape (64000,) + The regression target for each sample. + treatment : {ndarray, series} of shape (64000,) + + """ + + url = 'https://hillstorm1.s3.us-east-2.amazonaws.com/hillstorm_no_indices.csv.gz' + csv_path = get_data(data_home=data_home, + url=url, + dest_subdir=dest_subdir, + dest_filename='hillstorm_no_indices.csv.gz', + download_if_missing=download_if_missing) + hillstrom = pd.read_csv(csv_path) + hillstrom_data = hillstrom.drop(columns=['segment', target]) + + module_path = os.path.dirname('__file__') + with open(os.path.join(module_path, 'descr', 'hillstrom.rst')) as rst_file: + fdescr = rst_file.read() + + if return_X_y: + return treatment, data, target + + return Bunch(treatment=hillstrom['segment'], + target=hillstrom[target], + data=hillstrom_data, DESCR=fdescr) \ No newline at end of file diff --git a/sklift/datasets/descr/hillstrom.rst b/sklift/datasets/descr/hillstrom.rst new file mode 100644 index 0000000..9479382 --- /dev/null +++ b/sklift/datasets/descr/hillstrom.rst @@ -0,0 +1,39 @@ +Kevin Hillstrom: MineThatData +=============================== +Helping CEOs Understand How Customers Interact With Advertising, Products, Brands, and Channels +------------ +**March 20, 2008** + +The MineThatData E-Mail Analytics And Data Mining Challenge +It is time to find a few smart individuals in the world of e-mail analytics and data mining! And honestly, what follows is a dataset that you can manipulate using Excel pivot tables, so you don't have to be a data mining wizard, just be clever! + +[Here is a link to the MineThatData E-Mail Analytics And Data Mining Challenge dataset:]( http://www.minethatdata.com/Kevin_Hillstrom_MineThatData_E-MailAnalytics_DataMiningChallenge_2008.03.20.csv) The dataset is in .csv format, and is about the size of a typical mp3 file. I recommend saving the file to disk, then open the file (read only' in the software tool of your choice. + +This dataset contains 64,000 customers who last purchased within twelve months. The customers were involved in an e-mail test. +* 1/3 were randomly chosen to receive an e-mail campaign featuring Mens merchandise. +* 1/3 were randomly chosen to receive an e-mail campaign featuring Womens merchandise. +* 1/3 were randomly chosen to not receive an e-mail campaign. + +During a period of two weeks following the e-mail campaign, results were tracked. Your job is to tell the world if the Mens or Womens e-mail campaign was successful. + +Historical customer attributes at your disposal include: + +* Recency: Months since last purchase. +* History_Segment: Categorization of dollars spent in the past year. +* History: Actual dollar value spent in the past year. +* Mens: 1/0 indicator, 1 = customer purchased Mens merchandise in the past year. +* Womens: 1/0 indicator, 1 = customer purchased Womens merchandise in the past year. +* Zip_Code: Classifies zip code as Urban, Suburban, or Rural. +* Newbie: 1/0 indicator, 1 = New customer in the past twelve months. +* Channel: Describes the channels the customer purchased from in the past year. + +Another variable describes the e-mail campaign the customer received: +* Segment +* Mens E-Mail +* Womens E-Mail +* No E-Mail + +Finally, we have a series of variables describing activity in the two weeks following delivery of the e-mail campaign: +* Visit: 1/0 indicator, 1 = Customer visited website in the following two weeks. +* Conversion: 1/0 indicator, 1 = Customer purchased merchandise in the following two weeks. +* Spend: Actual dollars spent in the following two weeks. \ No newline at end of file From c05ad3b04ba7c95f9394fef85a1b0dd92ede2ab9 Mon Sep 17 00:00:00 2001 From: Viktor Urushkin Date: Sat, 6 Feb 2021 16:23:40 +0700 Subject: [PATCH 15/26] Add fetch_criteo (#60) * Added fetch_criteo method. * add __init__.py * Added error checking for treatment_feature and target_column argument. Added default feature load dataset as dictionary (Bunch) * Memory optimization, docstring description. * add criteo.rst * Added DESCR in Bunch * Changed errors that can be caused if the value of treatment_feature or target_column is incorrect * Memory optimization! Now, data an target are read directly from the dataset. It should be less memory intensive. Previously, they were obtained by splitting the dataframe. So, we had a duplicate dataframe. * Added percent10 flag, if it=True, fetch only 10% of dataset. Add flag as_frame, if it=true,returns a pandas Dataframe. Removed unnecessary parameter from pd.read_csv * Changed description. Added more object in return bunch Co-authored-by: Roman --- sklift/datasets/datasets.py | 117 +++++++++++++++++++++++++++---- sklift/datasets/descr/criteo.rst | 40 +++++++++++ 2 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 sklift/datasets/descr/criteo.rst diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index 2d6e569..a1837ec 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -96,6 +96,93 @@ def clear_data_dir(path=None): shutil.rmtree(path, ignore_errors=True) + +def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, percent10=True, + treatment_feature='treatment', target_column='visit', return_X_y_t=False, as_frame=False): + """Load data from the Criteo dataset + + Args: + data_home (string): Specify a download and cache folder for the datasets. + dest_subdir (string, unicode): The name of the folder in which the dataset is stored. + download_if_missing (bool, default=True): If False, raise an IOError if the data is not locally available + instead of trying to download the data from the source site. + percent10 (bool, default=True): Whether to load only 10 percent of the data. + treatment_feature (string,'treatment' or 'exposure' default='treatment'): Selects which column from dataset + will be treatment + target_column (string, 'visit' or 'conversion', default='visit'): Selects which column from dataset + will be target + return_X_y_t (bool, default=False): If True, returns (data, target, treatment) instead of a Bunch object. + See below for more information about the data and target object. + as_frame (bool, default=False): + Returns: + ''~sklearn.utils.Bunch'': dataset + Dictionary-like object, with the following attributes. + data (ndarray, DataFrame object): Dataset without target and treatment. + target (ndarray, series): Column target by values + treatment (ndarray, series): Column treatment by values + DESCR (string): Description of the Criteo dataset. + feature_names (list): The names of the future columns + target_name (string): The name of the target column. + treatment_name (string): The name of the treatment column + tuple (data, target, treatment): tuple if return_X_y_t is True + """ + if percent10: + url = 'https://criteo-bucket.s3.eu-central-1.amazonaws.com/criteo10.csv.gz' + csv_path = get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, + dest_filename='criteo10.csv.gz', + download_if_missing=download_if_missing) + else: + url = "https://criteo-bucket.s3.eu-central-1.amazonaws.com/criteo.csv.gz" + csv_path = get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, + dest_filename='criteo.csv.gz', + download_if_missing=download_if_missing) + + if treatment_feature == 'exposure': + data = pd.read_csv(csv_path, usecols=[i for i in range(12)]) + treatment = pd.read_csv(csv_path, usecols=['exposure'], dtype={'exposure': 'Int8'}) + if as_frame: + treatment = treatment['exposure'] + elif treatment_feature == 'treatment': + data = pd.read_csv(csv_path, usecols=[i for i in range(12)]) + treatment = pd.read_csv(csv_path, usecols=['treatment'], dtype={'treatment': 'Int8'}) + if as_frame: + treatment = treatment['treatment'] + else: + raise ValueError(f"Treatment_feature value must be from {['treatment', 'exposure']}. " + f"Got value {treatment_feature}.") + feature_names = list(data.columns) + + if target_column == 'conversion': + target = pd.read_csv(csv_path, usecols=['conversion'], dtype={'conversion': 'Int8'}) + if as_frame: + target = target['conversion'] + elif target_column == 'visit': + target = pd.read_csv(csv_path, usecols=['visit'], dtype={'visit': 'Int8'}) + if as_frame: + target = target['visit'] + else: + raise ValueError(f"Target_column value must be from {['visit', 'conversion']}. " + f"Got value {target_column}.") + + if return_X_y_t: + if as_frame: + return data, target, treatment + else: + return data.to_numpy(), target.to_numpy(), treatment.to_numpy() + else: + target_name = target_column + treatment_name = treatment_feature + module_path = os.path.dirname(__file__) + with open(os.path.join(module_path, 'descr', 'criteo.rst')) as rst_file: + fdescr = rst_file.read() + if as_frame: + return Bunch(data=data, target=target, treatment=treatment, DESCR=fdescr, feature_names=feature_names, + target_name=target_name, treatment_name=treatment_name) + else: + return Bunch(data=data.to_numpy(), target=target.to_numpy(), treatment=treatment.to_numpy(), DESCR=fdescr, + feature_names=feature_names, target_name=target_name, treatment_name=treatment_name) + + def fetch_hillstrom(target='visit', data_home=None, dest_subdir=None, @@ -105,22 +192,22 @@ def fetch_hillstrom(target='visit', """Load the hillstrom dataset. Args: - target : str, desfault=visit. - Can also be conversion, and spend - data_home : str, default=None - Specify another download and cache folder for the datasets. - dest_subdir : str, default=None - download_if_missing : bool, default=True - If False, raise a IOError if the data is not locally available - instead of trying to download the data from the source site. + target : str, desfault=visit. + Can also be conversion, and spend + data_home : str, default=None + Specify another download and cache folder for the datasets. + dest_subdir : str, default=None + download_if_missing : bool, default=True + If False, raise a IOError if the data is not locally available + instead of trying to download the data from the source site. Returns: - Dictionary-like object, with the following attributes. - data : {ndarray, dataframe} of shape (64000, 12) - The data matrix to learn. - target : {ndarray, series} of shape (64000,) - The regression target for each sample. - treatment : {ndarray, series} of shape (64000,) + Dictionary-like object, with the following attributes. + data : {ndarray, dataframe} of shape (64000, 12) + The data matrix to learn. + target : {ndarray, series} of shape (64000,) + The regression target for each sample. + treatment : {ndarray, series} of shape (64000,) """ @@ -142,4 +229,4 @@ def fetch_hillstrom(target='visit', return Bunch(treatment=hillstrom['segment'], target=hillstrom[target], - data=hillstrom_data, DESCR=fdescr) \ No newline at end of file + data=hillstrom_data, DESCR=fdescr) diff --git a/sklift/datasets/descr/criteo.rst b/sklift/datasets/descr/criteo.rst new file mode 100644 index 0000000..c6138c1 --- /dev/null +++ b/sklift/datasets/descr/criteo.rst @@ -0,0 +1,40 @@ +Criteo Uplift Modeling Dataset +================================ +This is a copy of `Criteo AI Lab Uplift Prediction dataset `_. + +Data description +----------------- +This dataset is constructed by assembling data resulting from several incrementality tests, a particular randomized trial procedure where a random part of the population is prevented from being targeted by advertising. + +Fields +--------- + +Here is a detailed description of the fields (they are comma-separated in the file): + +* **f0, f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11**: feature values (dense, float) +* **treatment**: treatment group (1 = treated, 0 = control) +* **conversion**: whether a conversion occured for this user (binary, label) +* **visit**: whether a visit occured for this user (binary, label) +* **exposure**: treatment effect, whether the user has been effectively exposed (binary) + +Key figures +-------------- +* Format: CSV +* Size: 297M (compressed) 3,2GB (uncompressed) +* Rows: 13,979,592 +* Average Visit Rate: .046992 +* Average Conversion Rate: .00292 +* Treatment Ratio: .85 + + + +This dataset is released along with the paper: +“*A Large Scale Benchmark for Uplift Modeling*" +Eustache Diemert, Artem Betlei, Christophe Renaudin; (Criteo AI Lab), Massih-Reza Amini (LIG, Grenoble INP) +This work was published in: `AdKDD 2018 `_ Workshop, in conjunction with KDD 2018. + + + + + + From 98c8fbae642bf42d2f74150c32cb506b35435f44 Mon Sep 17 00:00:00 2001 From: Tim Sidorov <49714780+timfex@users.noreply.github.com> Date: Sat, 6 Feb 2021 12:31:31 +0300 Subject: [PATCH 16/26] Added new version of def 'fetch_x5' (#62) * X5_download * Revert "X5_download * Added def fetch_x5 * Added new version def 'fetch_x5' * Added 'purchases' * NewCommit * NewAdded Co-authored-by: Tim Sidorov --- sklift/datasets/datasets.py | 56 +++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index a1837ec..8df6db1 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -1,5 +1,5 @@ -import os import shutil +import os import pandas as pd import requests from sklearn.utils import Bunch @@ -41,7 +41,7 @@ def download(url, dest_path): req.raise_for_status() with open(dest_path, "wb") as fd: - for chunk in req.iter_content(chunk_size=2**20): + for chunk in req.iter_content(chunk_size=2 ** 20): fd.write(chunk) else: raise TypeError("URL must be a string") @@ -96,6 +96,54 @@ def clear_data_dir(path=None): shutil.rmtree(path, ignore_errors=True) +def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True): + """Fetch the X5 dataset. + + Args: + data_home (str, unicode): The path to the folder where datasets are stored. + dest_subdir (str, unicode): The name of the folder in which the dataset is stored. + download_if_missing (bool): Download the data if not present. Raises an IOError if False and data is missing. + + Returns: + '~sklearn.utils.Bunch': dataset + Dictionary-like object, with the following attributes. + data ('~sklearn.utils.Bunch'): Dataset without target and treatment. + target (Series object): Column target by values + treatment (Series object): Column treatment by values + DESCR (str): Description of the X5 dataset. + train (DataFrame object): Dataset with target and treatment. + """ + url_clients = 'https://timds.s3.eu-central-1.amazonaws.com/clients.csv.gz' + file_clients = 'clients.csv.gz' + csv_clients_path = get_data(data_home=data_home, url=url_clients, dest_subdir=dest_subdir, + dest_filename=file_clients, + download_if_missing=download_if_missing) + clients = pd.read_csv(csv_clients_path) + + url_train = 'https://timds.s3.eu-central-1.amazonaws.com/uplift_train.csv.gz' + file_train = 'uplift_train.csv.gz' + csv_train_path = get_data(data_home=data_home, url=url_train, dest_subdir=dest_subdir, + dest_filename=file_train, + download_if_missing=download_if_missing) + train = pd.read_csv(csv_train_path) + + url_purchases = 'https://timds.s3.eu-central-1.amazonaws.com/purchases.csv.gz' + file_purchases = 'purchases.csv.gz' + csv_purchases_path = get_data(data_home=data_home, url=url_purchases, dest_subdir=dest_subdir, + dest_filename=file_purchases, + download_if_missing=download_if_missing) + purchases = pd.read_csv(csv_purchases_path) + + target = train['target'] + treatment = train['treatment_flg'] + + module_path = os.path.dirname(__file__) + with open(os.path.join(module_path, 'descr', 'x5.rst')) as rst_file: + fdescr = rst_file.read() + + return Bunch(data=Bunch(clients=clients, train=train, purchases=purchases), + target=target, treatment=treatment, DESCR=fdescr) + def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, percent10=True, treatment_feature='treatment', target_column='visit', return_X_y_t=False, as_frame=False): @@ -188,7 +236,6 @@ def fetch_hillstrom(target='visit', dest_subdir=None, download_if_missing=True, return_X_y=False): - """Load the hillstrom dataset. Args: @@ -208,8 +255,7 @@ def fetch_hillstrom(target='visit', target : {ndarray, series} of shape (64000,) The regression target for each sample. treatment : {ndarray, series} of shape (64000,) - - """ + """ url = 'https://hillstorm1.s3.us-east-2.amazonaws.com/hillstorm_no_indices.csv.gz' csv_path = get_data(data_home=data_home, From cee2454aa381e3d1b0b0d39682ec6c1e0c1ee92f Mon Sep 17 00:00:00 2001 From: Bezmen Evgeny <37982126+flashlight101@users.noreply.github.com> Date: Sat, 6 Feb 2021 13:14:58 +0300 Subject: [PATCH 17/26] Add fetch_lenta and docs (#59) * new file: docs/api/datasets/clear_data_dir.rst new file: docs/api/datasets/create_data_dir.rst new file: docs/api/datasets/download.rst new file: docs/api/datasets/fetch_lenta.rst new file: docs/api/datasets/get_data.rst new file: docs/api/datasets/get_data_dir.rst new file: docs/api/datasets/index.rst modified: docs/api/index.rst new file: sklift/datasets/datasets.py new file: sklift/datasets/descr/lenta.rst --- docs/api/datasets/clear_data_dir.rst | 5 + docs/api/datasets/create_data_dir.rst | 5 + docs/api/datasets/download.rst | 5 + docs/api/datasets/fetch_criteo.rst | 5 + docs/api/datasets/fetch_hillstorm.rst | 5 + docs/api/datasets/fetch_lenta.rst | 6 ++ docs/api/datasets/fetch_x5.rst | 5 + docs/api/datasets/get_data.rst | 5 + docs/api/datasets/get_data_dir.rst | 5 + docs/api/datasets/index.rst | 16 +++ docs/api/index.rst | 1 + sklift/datasets/datasets.py | 49 +++++++-- sklift/datasets/descr/lenta.rst | 147 ++++++++++++++++++++++++++ 13 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 docs/api/datasets/clear_data_dir.rst create mode 100644 docs/api/datasets/create_data_dir.rst create mode 100644 docs/api/datasets/download.rst create mode 100644 docs/api/datasets/fetch_criteo.rst create mode 100644 docs/api/datasets/fetch_hillstorm.rst create mode 100644 docs/api/datasets/fetch_lenta.rst create mode 100644 docs/api/datasets/fetch_x5.rst create mode 100644 docs/api/datasets/get_data.rst create mode 100644 docs/api/datasets/get_data_dir.rst create mode 100644 docs/api/datasets/index.rst create mode 100644 sklift/datasets/descr/lenta.rst diff --git a/docs/api/datasets/clear_data_dir.rst b/docs/api/datasets/clear_data_dir.rst new file mode 100644 index 0000000..c5db1cc --- /dev/null +++ b/docs/api/datasets/clear_data_dir.rst @@ -0,0 +1,5 @@ +***************************************** +`sklift.datasets <./>`_.clear_data_dir +***************************************** + +.. autofunction:: sklift.datasets.datasets.clear_data_dir \ No newline at end of file diff --git a/docs/api/datasets/create_data_dir.rst b/docs/api/datasets/create_data_dir.rst new file mode 100644 index 0000000..d084df6 --- /dev/null +++ b/docs/api/datasets/create_data_dir.rst @@ -0,0 +1,5 @@ +***************************************** +`sklift.datasets <./>`_.create_data_dir +***************************************** + +.. autofunction:: sklift.datasets.datasets.create_data_dir \ No newline at end of file diff --git a/docs/api/datasets/download.rst b/docs/api/datasets/download.rst new file mode 100644 index 0000000..4223459 --- /dev/null +++ b/docs/api/datasets/download.rst @@ -0,0 +1,5 @@ +***************************************** +`sklift.datasets <./>`_.download +***************************************** + +.. autofunction:: sklift.datasets.datasets.download \ No newline at end of file diff --git a/docs/api/datasets/fetch_criteo.rst b/docs/api/datasets/fetch_criteo.rst new file mode 100644 index 0000000..b846a7a --- /dev/null +++ b/docs/api/datasets/fetch_criteo.rst @@ -0,0 +1,5 @@ +*********************************** +`sklift.datasets <./>`_.fetch_criteo +*********************************** + +.. autofunction:: sklift.datasets.datasets.fetch_criteo \ No newline at end of file diff --git a/docs/api/datasets/fetch_hillstorm.rst b/docs/api/datasets/fetch_hillstorm.rst new file mode 100644 index 0000000..27b39e1 --- /dev/null +++ b/docs/api/datasets/fetch_hillstorm.rst @@ -0,0 +1,5 @@ +*********************************** +`sklift.datasets <./>`_.fetch_hillstorm +*********************************** + +.. autofunction:: sklift.datasets.datasets.fetch_hillstorm \ No newline at end of file diff --git a/docs/api/datasets/fetch_lenta.rst b/docs/api/datasets/fetch_lenta.rst new file mode 100644 index 0000000..6e05e01 --- /dev/null +++ b/docs/api/datasets/fetch_lenta.rst @@ -0,0 +1,6 @@ +*********************************** +`sklift.datasets <./>`_.fetch_lenta +*********************************** + +.. autofunction:: sklift.datasets.datasets.fetch_lenta +.. include:: ../../../sklift/datasets/descr/lenta.rst \ No newline at end of file diff --git a/docs/api/datasets/fetch_x5.rst b/docs/api/datasets/fetch_x5.rst new file mode 100644 index 0000000..dc54c13 --- /dev/null +++ b/docs/api/datasets/fetch_x5.rst @@ -0,0 +1,5 @@ +*********************************** +`sklift.datasets <./>`_.fetch_x5 +*********************************** + +.. autofunction:: sklift.datasets.datasets.fetch_x5 \ No newline at end of file diff --git a/docs/api/datasets/get_data.rst b/docs/api/datasets/get_data.rst new file mode 100644 index 0000000..ff3a166 --- /dev/null +++ b/docs/api/datasets/get_data.rst @@ -0,0 +1,5 @@ +***************************************** +`sklift.datasets <./>`_.get_data +***************************************** + +.. autofunction:: sklift.datasets.datasets.get_data \ No newline at end of file diff --git a/docs/api/datasets/get_data_dir.rst b/docs/api/datasets/get_data_dir.rst new file mode 100644 index 0000000..33b7486 --- /dev/null +++ b/docs/api/datasets/get_data_dir.rst @@ -0,0 +1,5 @@ +***************************************** +`sklift.datasets <./>`_.get_data_dir +***************************************** + +.. autofunction:: sklift.datasets.datasets.get_data_dir \ No newline at end of file diff --git a/docs/api/datasets/index.rst b/docs/api/datasets/index.rst new file mode 100644 index 0000000..90159a8 --- /dev/null +++ b/docs/api/datasets/index.rst @@ -0,0 +1,16 @@ +************************ +`sklift <../>`_.datasets +************************ + +.. toctree:: + :maxdepth: 3 + + ./clear_data_dir + ./create_data_dir + ./download + ./get_data_dir + ./get_data + ./fetch_lenta + ./fetch_x5 + ./fetch_criteo + ./fetch_hillstorm \ No newline at end of file diff --git a/docs/api/index.rst b/docs/api/index.rst index 658e657..363c63d 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -15,3 +15,4 @@ This is the modules reference of scikit-uplift. ./models/index ./metrics/index ./viz/index + ./datasets/index \ No newline at end of file diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index 8df6db1..8fb53a6 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -59,7 +59,6 @@ def get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): Returns: The path to the dataset. - """ if data_home is None: if dest_subdir is None: @@ -96,22 +95,56 @@ def clear_data_dir(path=None): shutil.rmtree(path, ignore_errors=True) -def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True): - """Fetch the X5 dataset. +def fetch_lenta(return_X_y_t=False, data_home=None, dest_subdir=None, download_if_missing=True): + '''Fetch the Lenta dataset. Args: + return_X_y_t (bool): If True, returns (data, target, treatment) instead of a Bunch object. + See below for more information about the data and target object. data_home (str, unicode): The path to the folder where datasets are stored. dest_subdir (str, unicode): The name of the folder in which the dataset is stored. download_if_missing (bool): Download the data if not present. Raises an IOError if False and data is missing. Returns: + * dataset ('~sklearn.utils.Bunch'): Dictionary-like object, with the following attributes. + * data (DataFrame object): Dataset without target and treatment. + * target (Series object): Column target by values. + * treatment (Series object): Column treatment by values. + * DESCR (str): Description of the Lenta dataset. + + * (data,target,treatment): tuple if 'return_X_y_t' is True. + ''' + url='https:/winterschool123.s3.eu-north-1.amazonaws.com/lentadataset.csv.gz' + filename='lentadataset.csv.gz' + csv_path=get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, + dest_filename=filename, + download_if_missing=download_if_missing) + data = pd.read_csv(csv_path) + target=data['response_att'] + treatment=data['group'] + data=data.drop(['response_att', 'group'], axis=1) + + module_path = os.path.dirname(__file__) + with open(os.path.join(module_path, 'descr', 'lenta.rst')) as rst_file: + fdescr = rst_file.read() + + if return_X_y_t == True: + return data, target, treatment + + return Bunch(data=data, target=target, treatment=treatment, DESCR=fdescr) + + +def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True): + """Fetch the X5 dataset. + + Args: '~sklearn.utils.Bunch': dataset Dictionary-like object, with the following attributes. - data ('~sklearn.utils.Bunch'): Dataset without target and treatment. - target (Series object): Column target by values - treatment (Series object): Column treatment by values - DESCR (str): Description of the X5 dataset. - train (DataFrame object): Dataset with target and treatment. + data ('~sklearn.utils.Bunch'): Dataset without target and treatment. + target (Series object): Column target by values + treatment (Series object): Column treatment by values + DESCR (str): Description of the X5 dataset. + train (DataFrame object): Dataset with target and treatment. """ url_clients = 'https://timds.s3.eu-central-1.amazonaws.com/clients.csv.gz' file_clients = 'clients.csv.gz' diff --git a/sklift/datasets/descr/lenta.rst b/sklift/datasets/descr/lenta.rst new file mode 100644 index 0000000..6b6723a --- /dev/null +++ b/sklift/datasets/descr/lenta.rst @@ -0,0 +1,147 @@ +Description of parameters. +~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. list-table:: + :align: center + :header-rows: 1 + :widths: 5 5 + + * - Feature + - Description + * - customer + - age + * - CardHolder + - customer id + * - cheque_count_12m_g* + - number of customer receipts collected within last 12 months + before campaign. g* is a product group + * - cheque_count_3m_g* + - number of customer receipts collected within last 3 months + before campaign. g* is a product group + * - cheque_count_6m_g* + - number of customer receipts collected within last 6 months + before campaign. g* is a product group + * - children + - number of children + * - crazy_purchases_cheque_count_12m + - number of customer receipts with items purchased on "crazy" + marketing campaign collected within last 12 months before campaign + * - crazy_purchases_cheque_count_1m + - number of customer receipts with items purchased on "crazy" + marketing campaign collected within last 1 month before campaign + * - crazy_purchases_cheque_count_3m + - number of customer receipts with items purchased on "crazy" + marketing campaign collected within last 3 months before campaign + * - crazy_purchases_cheque_count_6m + - number of customer receipts with items purchased on "crazy" + marketing campaign collected within last 6 months before campaign + * - crazy_purchases_goods_count_12m + - items amount purchased on "crazy" marketing campaign collected + within last 12 months before campaign + * - crazy_purchases_goods_count_6m + - items amount purchased on "crazy" marketing campaign collected + within last 6 months before campaign + * - disc_sum_6m_g34 + - discount sum for past 6 month on a 34 product group + * - food_share_15d + - food share in customer purchases for 15 days + * - food_share_1m + - food share in customer purchases for 1 month + * - gender + - customer gender + * - group + - treatment/control group flag + * - k_var_cheque_15d + - average check coefficient of variation for 15 days + * - k_var_cheque_3m + - average check coefficient of variation for 3 months + * - k_var_cheque_category_width_15d + - coefficient of variation of the average number of purchased + categories (2nd level of the hierarchy) in one receipt for 15 days + * - k_var_cheque_group_width_15d + - coefficient of variation of the average number of purchased + groups (1st level of the hierarchy) in one receipt for 15 days + * - k_var_count_per_cheque_15d_g* + - unique product id (SKU) coefficient of variation for 15 days + for g* product group + * - k_var_count_per_cheque_1m_g* + - unique product id (SKU) coefficient of variation for 1 month + for g* product group + * - k_var_count_per_cheque_3m_g* + - unique product id (SKU) coefficient of variation for 3 months + for g* product group + * - k_var_count_per_cheque_6m_g* + - unique product id (SKU) coefficient of variation for 6 months + for g* product group + * - k_var_days_between_visits_15d + - coefficient of variation of the average period between visits + for 15 days + * - k_var_days_between_visits_1m + - coefficient of variation of the average period between visits + for 1 month + * - k_var_days_between_visits_3m + - coefficient of variation of the average period between visits + for 3 months + * - k_var_disc_per_cheque_15d + - discount sum coefficient of variation for 15 days + * - k_var_disc_share_12m_g32 + - discount amount coefficient of variation for 12 months + for g* product group + * - k_var_disc_share_15d_g* + - discount amount coefficient of variation for 15 days + for g* product group + * - k_var_disc_share_1m_g* + - discount amount coefficient of variation for 1 month + for g* product group + * - k_var_disc_share_3m_g* + - discount amount coefficient of variation for 3 months + for g* product group + * - k_var_disc_share_6m_g* + - discount amount coefficient of variation for 6 months + for g* product group + * - k_var_discount_depth_15d + - discount amount coefficient of variation for 1 month + * - k_var_discount_depth_1m + - discount amount coefficient of variation for 15 days + * - k_var_sku_per_cheque_15d + - number of unique product ids (SKU) coefficient of variation + for 15 days + * - k_var_sku_price_12m_g* + - price coefficient of variation for 15 days, 3, 6, 12 months + for g* product group + * - main_format + - store type (1 - grociery store, 0 - superstore) + * - mean_discount_depth_15d + - mean discount depth for 15 days + * - months_from_register + - number of months from a moment of register + * - perdelta_days_between_visits_15_30d + - timdelta in percent between visits during the first half + of the month and visits during second half of the month + * - promo_share_15d + - promo goods share in the customer bucket + * - response_att + - binary target variable = store visit + * - response_sms + - share of customer responses to previous SMS. + Response = store visit + * - response_viber + - share of responses to previous Viber messages. + Response = store visit + * - sale_count_12m_g* + - number of purchased items from the group * for 12 months + * - sale_count_3m_g* + - number of purchased items from the group * for 3 months + * - sale_count_6m_g* + - number of purchased items from the group * for 6 months + * - sale_sum_12m_g* + - sum of sales from the group * for 12 months + * - sale_sum_3m_g* + - sum of sales from the group * for 3 months + * - sale_sum_6m_g* + - sum of sales from the group * for 6 months + * - stdev_days_between_visits_15d + - coefficient of variation of the days between visits for 15 days + * - stdev_discount_depth_15d + - discount sum coefficient of variation for 15 days + * - stdev_discount_depth_1m + - discount sum coefficient of variation for 1 month \ No newline at end of file From 9f6a97ef23f4e2fcfe738365226f9c66aeccdf76 Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sat, 6 Feb 2021 15:32:13 +0300 Subject: [PATCH 18/26] =?UTF-8?q?=F0=9F=93=97=20Fix=20datasets=20docs=20(#?= =?UTF-8?q?66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :green_book: Fix datasets docs * :green_book: Fix order decr and func --- docs/api/datasets/create_data_dir.rst | 5 ----- docs/api/datasets/download.rst | 5 ----- docs/api/datasets/fetch_criteo.rst | 8 +++++--- docs/api/datasets/fetch_hillstorm.rst | 5 ----- docs/api/datasets/fetch_hillstrom.rst | 7 +++++++ docs/api/datasets/fetch_lenta.rst | 1 + docs/api/datasets/fetch_x5.rst | 4 +++- docs/api/datasets/get_data.rst | 5 ----- docs/api/datasets/index.rst | 5 +---- 9 files changed, 17 insertions(+), 28 deletions(-) delete mode 100644 docs/api/datasets/create_data_dir.rst delete mode 100644 docs/api/datasets/download.rst delete mode 100644 docs/api/datasets/fetch_hillstorm.rst create mode 100644 docs/api/datasets/fetch_hillstrom.rst delete mode 100644 docs/api/datasets/get_data.rst diff --git a/docs/api/datasets/create_data_dir.rst b/docs/api/datasets/create_data_dir.rst deleted file mode 100644 index d084df6..0000000 --- a/docs/api/datasets/create_data_dir.rst +++ /dev/null @@ -1,5 +0,0 @@ -***************************************** -`sklift.datasets <./>`_.create_data_dir -***************************************** - -.. autofunction:: sklift.datasets.datasets.create_data_dir \ No newline at end of file diff --git a/docs/api/datasets/download.rst b/docs/api/datasets/download.rst deleted file mode 100644 index 4223459..0000000 --- a/docs/api/datasets/download.rst +++ /dev/null @@ -1,5 +0,0 @@ -***************************************** -`sklift.datasets <./>`_.download -***************************************** - -.. autofunction:: sklift.datasets.datasets.download \ No newline at end of file diff --git a/docs/api/datasets/fetch_criteo.rst b/docs/api/datasets/fetch_criteo.rst index b846a7a..dba4c4b 100644 --- a/docs/api/datasets/fetch_criteo.rst +++ b/docs/api/datasets/fetch_criteo.rst @@ -1,5 +1,7 @@ -*********************************** +************************************** `sklift.datasets <./>`_.fetch_criteo -*********************************** +************************************** -.. autofunction:: sklift.datasets.datasets.fetch_criteo \ No newline at end of file +.. autofunction:: sklift.datasets.datasets.fetch_criteo + +.. include:: ../../../sklift/datasets/descr/criteo.rst \ No newline at end of file diff --git a/docs/api/datasets/fetch_hillstorm.rst b/docs/api/datasets/fetch_hillstorm.rst deleted file mode 100644 index 27b39e1..0000000 --- a/docs/api/datasets/fetch_hillstorm.rst +++ /dev/null @@ -1,5 +0,0 @@ -*********************************** -`sklift.datasets <./>`_.fetch_hillstorm -*********************************** - -.. autofunction:: sklift.datasets.datasets.fetch_hillstorm \ No newline at end of file diff --git a/docs/api/datasets/fetch_hillstrom.rst b/docs/api/datasets/fetch_hillstrom.rst new file mode 100644 index 0000000..145acef --- /dev/null +++ b/docs/api/datasets/fetch_hillstrom.rst @@ -0,0 +1,7 @@ +**************************************** +`sklift.datasets <./>`_.fetch_hillstrom +**************************************** + +.. autofunction:: sklift.datasets.datasets.fetch_hillstrom + +.. include:: ../../../sklift/datasets/descr/lenta.rst \ No newline at end of file diff --git a/docs/api/datasets/fetch_lenta.rst b/docs/api/datasets/fetch_lenta.rst index 6e05e01..f428a63 100644 --- a/docs/api/datasets/fetch_lenta.rst +++ b/docs/api/datasets/fetch_lenta.rst @@ -3,4 +3,5 @@ *********************************** .. autofunction:: sklift.datasets.datasets.fetch_lenta + .. include:: ../../../sklift/datasets/descr/lenta.rst \ No newline at end of file diff --git a/docs/api/datasets/fetch_x5.rst b/docs/api/datasets/fetch_x5.rst index dc54c13..126ba84 100644 --- a/docs/api/datasets/fetch_x5.rst +++ b/docs/api/datasets/fetch_x5.rst @@ -2,4 +2,6 @@ `sklift.datasets <./>`_.fetch_x5 *********************************** -.. autofunction:: sklift.datasets.datasets.fetch_x5 \ No newline at end of file +.. autofunction:: sklift.datasets.datasets.fetch_x5 + +.. include:: ../../../sklift/datasets/descr/x5.rst \ No newline at end of file diff --git a/docs/api/datasets/get_data.rst b/docs/api/datasets/get_data.rst deleted file mode 100644 index ff3a166..0000000 --- a/docs/api/datasets/get_data.rst +++ /dev/null @@ -1,5 +0,0 @@ -***************************************** -`sklift.datasets <./>`_.get_data -***************************************** - -.. autofunction:: sklift.datasets.datasets.get_data \ No newline at end of file diff --git a/docs/api/datasets/index.rst b/docs/api/datasets/index.rst index 90159a8..2103149 100644 --- a/docs/api/datasets/index.rst +++ b/docs/api/datasets/index.rst @@ -6,11 +6,8 @@ :maxdepth: 3 ./clear_data_dir - ./create_data_dir - ./download ./get_data_dir - ./get_data ./fetch_lenta ./fetch_x5 ./fetch_criteo - ./fetch_hillstorm \ No newline at end of file + ./fetch_hillstrom \ No newline at end of file From 2a6c54b892563d6027f5610da1d0c83339076380 Mon Sep 17 00:00:00 2001 From: Viktor Urushkin Date: Sun, 7 Feb 2021 00:01:03 +0700 Subject: [PATCH 19/26] fetch functions upgrade (#65) * fetch_hillstrom upgrade! Now return_X_y_t work. Add as_frame. Add target_column error check. * fetch_lenta upgrade! Add as_frame flag. * fetch_x5 upgrade! Add as_frame flag. Add names in Bunch. * create_data_dir, download, get_data are underscore --- sklift/datasets/datasets.py | 157 +++++++++++++++++++++++------------- 1 file changed, 103 insertions(+), 54 deletions(-) diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index 8fb53a6..1309a68 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -15,7 +15,7 @@ def get_data_dir(): return os.path.join(os.path.expanduser("~"), "scikit-uplift-data") -def create_data_dir(path): +def _create_data_dir(path): """This function creates a directory, which stores the datasets. Args: @@ -26,7 +26,7 @@ def create_data_dir(path): os.makedirs(path) -def download(url, dest_path): +def _download(url, dest_path): '''Download the file from url and save it localy Args: @@ -47,7 +47,7 @@ def download(url, dest_path): raise TypeError("URL must be a string") -def get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): +def _get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): """Return the path to the dataset. Args: @@ -71,13 +71,13 @@ def get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): else: data_dir = os.path.join(os.path.abspath(data_home), dest_subdir) - create_data_dir(data_dir) + _create_data_dir(data_dir) dest_path = os.path.join(data_dir, dest_filename) if not os.path.isfile(dest_path): if download_if_missing: - download(url, dest_path) + _download(url, dest_path) else: raise IOError("Dataset missing") return dest_path @@ -95,15 +95,16 @@ def clear_data_dir(path=None): shutil.rmtree(path, ignore_errors=True) -def fetch_lenta(return_X_y_t=False, data_home=None, dest_subdir=None, download_if_missing=True): - '''Fetch the Lenta dataset. +def fetch_lenta(data_home=None, dest_subdir=None, download_if_missing=True, return_X_y_t=False, as_frame=False): + """Fetch the Lenta dataset. Args: - return_X_y_t (bool): If True, returns (data, target, treatment) instead of a Bunch object. - See below for more information about the data and target object. data_home (str, unicode): The path to the folder where datasets are stored. dest_subdir (str, unicode): The name of the folder in which the dataset is stored. download_if_missing (bool): Download the data if not present. Raises an IOError if False and data is missing. + return_X_y_t (bool): If True, returns (data, target, treatment) instead of a Bunch object. + See below for more information about the data and target object. + as_frame (bool): Returns: * dataset ('~sklearn.utils.Bunch'): Dictionary-like object, with the following attributes. @@ -113,69 +114,101 @@ def fetch_lenta(return_X_y_t=False, data_home=None, dest_subdir=None, download_i * DESCR (str): Description of the Lenta dataset. * (data,target,treatment): tuple if 'return_X_y_t' is True. - ''' - url='https:/winterschool123.s3.eu-north-1.amazonaws.com/lentadataset.csv.gz' - filename='lentadataset.csv.gz' - csv_path=get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, - dest_filename=filename, - download_if_missing=download_if_missing) + """ + + url = 'https://winterschool123.s3.eu-north-1.amazonaws.com/lentadataset.csv.gz' + filename = 'lentadataset.csv.gz' + csv_path = _get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, + dest_filename=filename, + download_if_missing=download_if_missing) data = pd.read_csv(csv_path) - target=data['response_att'] - treatment=data['group'] - data=data.drop(['response_att', 'group'], axis=1) + if as_frame: + target=data['response_att'] + treatment=data['group'] + data=data.drop(['response_att', 'group'], axis=1) + feature_names = list(data.columns) + else: + target = data[['response_att']].to_numpy() + treatment = data[['group']].to_numpy() + data = data.drop(['response_att', 'group'], axis=1) + feature_names = list(data.columns) + data = data.to_numpy() module_path = os.path.dirname(__file__) with open(os.path.join(module_path, 'descr', 'lenta.rst')) as rst_file: fdescr = rst_file.read() - if return_X_y_t == True: + if return_X_y_t: return data, target, treatment - return Bunch(data=data, target=target, treatment=treatment, DESCR=fdescr) + return Bunch(data=data, target=target, treatment=treatment, DESCR=fdescr, + feature_names=feature_names, target_name='response_att', treatment_name='group') -def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True): +def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True, as_frame=False): """Fetch the X5 dataset. - Args: - '~sklearn.utils.Bunch': dataset + Args: + data_home (string): Specify a download and cache folder for the datasets. + dest_subdir (string, unicode): The name of the folder in which the dataset is stored. + download_if_missing (bool, default=True): If False, raise an IOError if the data is not locally available + instead of trying to download the data from the source site. + as_frame (bool, default=False): + + Returns: + '~sklearn.utils.Bunch': dataset Dictionary-like object, with the following attributes. - data ('~sklearn.utils.Bunch'): Dataset without target and treatment. - target (Series object): Column target by values - treatment (Series object): Column treatment by values - DESCR (str): Description of the X5 dataset. - train (DataFrame object): Dataset with target and treatment. + data ('~sklearn.utils.Bunch'): Dataset without target and treatment. + target (Series object): Column target by values + treatment (Series object): Column treatment by values + DESCR (str): Description of the X5 dataset. + train (DataFrame object): Dataset with target and treatment. + data_names ('~sklearn.utils.Bunch'): Names of features. + treatment_name (string): The name of the treatment column. """ url_clients = 'https://timds.s3.eu-central-1.amazonaws.com/clients.csv.gz' file_clients = 'clients.csv.gz' - csv_clients_path = get_data(data_home=data_home, url=url_clients, dest_subdir=dest_subdir, + csv_clients_path = _get_data(data_home=data_home, url=url_clients, dest_subdir=dest_subdir, dest_filename=file_clients, download_if_missing=download_if_missing) clients = pd.read_csv(csv_clients_path) + clients_names = list(clients.column) url_train = 'https://timds.s3.eu-central-1.amazonaws.com/uplift_train.csv.gz' file_train = 'uplift_train.csv.gz' - csv_train_path = get_data(data_home=data_home, url=url_train, dest_subdir=dest_subdir, + csv_train_path = _get_data(data_home=data_home, url=url_train, dest_subdir=dest_subdir, dest_filename=file_train, download_if_missing=download_if_missing) train = pd.read_csv(csv_train_path) + train_names = list(train.columns) url_purchases = 'https://timds.s3.eu-central-1.amazonaws.com/purchases.csv.gz' file_purchases = 'purchases.csv.gz' - csv_purchases_path = get_data(data_home=data_home, url=url_purchases, dest_subdir=dest_subdir, + csv_purchases_path = _get_data(data_home=data_home, url=url_purchases, dest_subdir=dest_subdir, dest_filename=file_purchases, download_if_missing=download_if_missing) purchases = pd.read_csv(csv_purchases_path) + purchases_names = list(purchases.columns) - target = train['target'] - treatment = train['treatment_flg'] + if as_frame: + target = train['target'] + treatment = train['treatment_flg'] + else: + target = train[['target']].to_numpy() + treatment = train[['treatment_flg']].to_numpy() + train = train.to_numpy() + clients = clients.to_numpy() + purchases = purchases.to_numpy() module_path = os.path.dirname(__file__) with open(os.path.join(module_path, 'descr', 'x5.rst')) as rst_file: fdescr = rst_file.read() return Bunch(data=Bunch(clients=clients, train=train, purchases=purchases), - target=target, treatment=treatment, DESCR=fdescr) + target=target, treatment=treatment, DESCR=fdescr, + data_names=Bunch(clients_names=clients_names, train_names=train_names, + purchases_names=purchases_names), + treatment_name='treatment_flg') def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, percent10=True, @@ -209,14 +242,14 @@ def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, per """ if percent10: url = 'https://criteo-bucket.s3.eu-central-1.amazonaws.com/criteo10.csv.gz' - csv_path = get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, + csv_path = _get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, dest_filename='criteo10.csv.gz', download_if_missing=download_if_missing) else: url = "https://criteo-bucket.s3.eu-central-1.amazonaws.com/criteo.csv.gz" - csv_path = get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, - dest_filename='criteo.csv.gz', - download_if_missing=download_if_missing) + csv_path = _get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, + dest_filename='criteo.csv.gz', + download_if_missing=download_if_missing) if treatment_feature == 'exposure': data = pd.read_csv(csv_path, usecols=[i for i in range(12)]) @@ -264,22 +297,21 @@ def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, per feature_names=feature_names, target_name=target_name, treatment_name=treatment_name) -def fetch_hillstrom(target='visit', - data_home=None, - dest_subdir=None, - download_if_missing=True, - return_X_y=False): +def fetch_hillstrom(data_home=None, dest_subdir=None, download_if_missing=True, target_column='visit', + return_X_y_t=False, as_frame=False): """Load the hillstrom dataset. Args: - target : str, desfault=visit. - Can also be conversion, and spend data_home : str, default=None Specify another download and cache folder for the datasets. dest_subdir : str, default=None download_if_missing : bool, default=True If False, raise a IOError if the data is not locally available instead of trying to download the data from the source site. + target_column (string, 'visit' or 'conversion' or 'spend', default='visit'): Selects which column from dataset + will be target + return_X_y_t (bool): + as_frame (bool): Returns: Dictionary-like object, with the following attributes. @@ -288,24 +320,41 @@ def fetch_hillstrom(target='visit', target : {ndarray, series} of shape (64000,) The regression target for each sample. treatment : {ndarray, series} of shape (64000,) + feature_names (list): The names of the future columns + target_name (string): The name of the target column. + treatment_name (string): The name of the treatment column """ url = 'https://hillstorm1.s3.us-east-2.amazonaws.com/hillstorm_no_indices.csv.gz' - csv_path = get_data(data_home=data_home, + csv_path = _get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, dest_filename='hillstorm_no_indices.csv.gz', download_if_missing=download_if_missing) - hillstrom = pd.read_csv(csv_path) - hillstrom_data = hillstrom.drop(columns=['segment', target]) + + if target_column != ('visit' or 'conversion' or 'spend'): + raise ValueError(f"Target_column value must be from {['visit', 'conversion', 'spend']}. " + f"Got value {target_column}.") + + data = pd.read_csv(csv_path, usecols=[i for i in range(8)]) + feature_names = list(data.columns) + treatment = pd.read_csv(csv_path, usecols=['segment']) + target = pd.read_csv(csv_path, usecols=[target_column]) + if as_frame: + target = target[target_column] + treatment = treatment['segment'] + else: + data = data.to_numpy() + target = target.to_numpy() + treatment = treatment.to_numpy() module_path = os.path.dirname('__file__') with open(os.path.join(module_path, 'descr', 'hillstrom.rst')) as rst_file: fdescr = rst_file.read() - if return_X_y: - return treatment, data, target - - return Bunch(treatment=hillstrom['segment'], - target=hillstrom[target], - data=hillstrom_data, DESCR=fdescr) + if return_X_y_t: + return data, target, treatment + else: + target_name = target_column + return Bunch(data=data, target=target, treatment=treatment, DESCR=fdescr, + feature_names=feature_names, target_name=target_name, treatment_name='segment') From 4b7cea87554b15aa269f925c84d4f5f43bfcea0c Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 7 Feb 2021 05:24:50 +0700 Subject: [PATCH 20/26] Feature/cicd (#67) --- .github/workflows/PyPi_upload.yml | 28 ++++++++++++++++++++++++++++ .github/workflows/ci-test.yml | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .github/workflows/PyPi_upload.yml create mode 100644 .github/workflows/ci-test.yml diff --git a/.github/workflows/PyPi_upload.yml b/.github/workflows/PyPi_upload.yml new file mode 100644 index 0000000..19fc7f9 --- /dev/null +++ b/.github/workflows/PyPi_upload.yml @@ -0,0 +1,28 @@ +name: Upload to PyPi + +on: + release: + types: [published] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml new file mode 100644 index 0000000..ed6c3ef --- /dev/null +++ b/.github/workflows/ci-test.yml @@ -0,0 +1,28 @@ +name: Python package + +on: + push: + branches: [ master ] + pull_request: + + +jobs: + build: + + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ubuntu-latest, windows-latest, macos-latest] + python-version: [3.6, 3.7, 3.8, 3.9] + fail-fast: false + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies and lints + run: pip install pytest .[tests] + - name: Run PyTest + run: pytest From 7e68075b2ca706bec4e8fb69df2e6bd0f2559e24 Mon Sep 17 00:00:00 2001 From: Irina Elisova Date: Sun, 7 Feb 2021 01:40:49 +0300 Subject: [PATCH 21/26] =?UTF-8?q?=F0=9F=93=95=20Update=20&=20fix=20dataset?= =?UTF-8?q?s=20description=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :pencil: Add total uplift and .:4f in viz * :pencil: upd lenta description * :green_book: Fix datasets description --- docs/conf.py | 5 +- docs/requirements.txt | 3 +- sklift/datasets/datasets.py | 196 +++++++++++++++++----------- sklift/datasets/descr/criteo.rst | 17 +-- sklift/datasets/descr/hillstrom.rst | 18 +-- sklift/datasets/descr/lenta.rst | 151 +++++++++------------ sklift/datasets/descr/x5.rst | 26 ++++ sklift/metrics/metrics.py | 4 +- sklift/viz/base.py | 8 +- 9 files changed, 240 insertions(+), 188 deletions(-) create mode 100644 sklift/datasets/descr/x5.rst diff --git a/docs/conf.py b/docs/conf.py index 14cdfdc..3a6f2fa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,9 +51,12 @@ def get_version(): "sphinx.ext.mathjax", "sphinx.ext.napoleon", "recommonmark", - "sphinx.ext.intersphinx" + "sphinx.ext.intersphinx", + "sphinxcontrib.bibtex" ] +bibtex_bibfiles = ['refs.bib'] + master_doc = 'index' # Add any paths that contain templates here, relative to this directory. diff --git a/docs/requirements.txt b/docs/requirements.txt index 342151a..05ffdcc 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ sphinx-autobuild sphinx_rtd_theme -recommonmark \ No newline at end of file +recommonmark +sphinxcontrib-bibtex \ No newline at end of file diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index 1309a68..40ffdf6 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -6,20 +6,24 @@ def get_data_dir(): - """This function returns a directory, which stores the datasets. + """Return the path of the scikit-uplift data dir. + + This folder is used by some large dataset loaders to avoid downloading the data several times. + + By default the data dir is set to a folder named ‘scikit_learn_data’ in the user home folder. Returns: - Full path to a directory, which stores the datasets. + string: The path to scikit-uplift data dir. """ return os.path.join(os.path.expanduser("~"), "scikit-uplift-data") def _create_data_dir(path): - """This function creates a directory, which stores the datasets. + """Creates a directory, which stores the datasets. Args: - path (str): The path to the folder where datasets are stored. + path (str): The path to scikit-uplift data dir. """ if not os.path.isdir(path): @@ -27,15 +31,13 @@ def _create_data_dir(path): def _download(url, dest_path): - '''Download the file from url and save it localy - + """Download the file from url and save it locally. + Args: url: URL address, must be a string. dest_path: Destination of the file. - Returns: - TypeError if URL is not a string. - ''' + """ if isinstance(url, str): req = requests.get(url, stream=True) req.raise_for_status() @@ -51,14 +53,16 @@ def _get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): """Return the path to the dataset. Args: - data_home (str, unicode): The path to the folder where datasets are stored. + data_home (str, unicode): The path to scikit-uplift data dir. url (str or unicode): The URL to the dataset. dest_subdir (str or unicode): The name of the folder in which the dataset is stored. dest_filename (str): The name of the dataset. - download_if_missing (bool): Flag if dataset is missing. + download_if_missing (bool): If False, raise a IOError if the data is not locally available instead of + trying to download the data from the source site. Returns: - The path to the dataset. + string: The path to the dataset. + """ if data_home is None: if dest_subdir is None: @@ -84,43 +88,59 @@ def _get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): def clear_data_dir(path=None): - """This function deletes the file. + """Delete all the content of the data home cache. Args: - path (str): File path. By default, this is the default path for datasets. - """ + path (str): The path to scikit-uplift data dir + + """ if path is None: path = get_data_dir() if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) -def fetch_lenta(data_home=None, dest_subdir=None, download_if_missing=True, return_X_y_t=False, as_frame=False): - """Fetch the Lenta dataset. - Args: - data_home (str, unicode): The path to the folder where datasets are stored. - dest_subdir (str, unicode): The name of the folder in which the dataset is stored. - download_if_missing (bool): Download the data if not present. Raises an IOError if False and data is missing. - return_X_y_t (bool): If True, returns (data, target, treatment) instead of a Bunch object. - See below for more information about the data and target object. - as_frame (bool): - - Returns: - * dataset ('~sklearn.utils.Bunch'): Dictionary-like object, with the following attributes. - * data (DataFrame object): Dataset without target and treatment. - * target (Series object): Column target by values. - * treatment (Series object): Column treatment by values. - * DESCR (str): Description of the Lenta dataset. - - * (data,target,treatment): tuple if 'return_X_y_t' is True. +def fetch_lenta(return_X_y_t=False, data_home=None, dest_subdir=None, download_if_missing=True): + """Load and return the Lenta dataset (classification). + + An uplift modeling dataset containing data about Lenta's customers grociery shopping and related marketing campaigns. + + Major columns: + + - ``group`` (str): treatment/control group flag + - ``response_att`` (binary): target + - ``gender`` (str): customer gender + - ``age`` (float): customer age + - ``main_format`` (int): store type (1 - grociery store, 0 - superstore) + + Args: + return_X_y_t (bool): If True, returns (data, target, treatment) instead of a Bunch object. + See below for more information about the data and target object. + data_home (str, unicode): The path to the folder where datasets are stored. + dest_subdir (str, unicode): The name of the folder in which the dataset is stored. + download_if_missing (bool): Download the data if not present. Raises an IOError if False and data is missing. + + Returns: + Bunch or tuple: dataset. + + By default dictionary-like object, with the following attributes: + + * ``data`` (DataFrame object): Dataset without target and treatment. + * ``target`` (Series object): Column target by values. + * ``treatment`` (Series object): Column treatment by values. + * ``DESCR`` (str): Description of the Lenta dataset. + + tuple (data, target, treatment) if `return_X_y` is True """ - url = 'https://winterschool123.s3.eu-north-1.amazonaws.com/lentadataset.csv.gz' - filename = 'lentadataset.csv.gz' - csv_path = _get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, - dest_filename=filename, - download_if_missing=download_if_missing) + url='https:/winterschool123.s3.eu-north-1.amazonaws.com/lentadataset.csv.gz' + filename='lentadataset.csv.gz' + + csv_path=_get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, + dest_filename=filename, + download_if_missing=download_if_missing) + data = pd.read_csv(csv_path) if as_frame: target=data['response_att'] @@ -145,27 +165,33 @@ def fetch_lenta(data_home=None, dest_subdir=None, download_if_missing=True, retu feature_names=feature_names, target_name='response_att', treatment_name='group') -def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True, as_frame=False): - """Fetch the X5 dataset. +def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True): + """Load the X5 dataset. + + The dataset contains raw retail customer purchaces, raw information about products and general info about customers. + + Major columns: + + - ``treatment_flg`` (binary): treatment/control group flag + - ``target`` (binary): target + - ``customer_id`` (str): customer id aka primary key for joining Args: - data_home (string): Specify a download and cache folder for the datasets. - dest_subdir (string, unicode): The name of the folder in which the dataset is stored. - download_if_missing (bool, default=True): If False, raise an IOError if the data is not locally available - instead of trying to download the data from the source site. - as_frame (bool, default=False): + data_home (str, unicode): The path to the folder where datasets are stored. + dest_subdir (str, unicode): The name of the folder in which the dataset is stored. + download_if_missing (bool): Download the data if not present. Raises an IOError if False and data is missing. Returns: - '~sklearn.utils.Bunch': dataset - Dictionary-like object, with the following attributes. - data ('~sklearn.utils.Bunch'): Dataset without target and treatment. - target (Series object): Column target by values - treatment (Series object): Column treatment by values - DESCR (str): Description of the X5 dataset. - train (DataFrame object): Dataset with target and treatment. - data_names ('~sklearn.utils.Bunch'): Names of features. - treatment_name (string): The name of the treatment column. + Bunch: dataset Dictionary-like object, with the following attributes. + + * data ('~sklearn.utils.Bunch'): Dataset without target and treatment. + * target (Series object): Column target by values + * treatment (Series object): Column treatment by values + * DESCR (str): Description of the X5 dataset. + * train (DataFrame object): Dataset with target and treatment. + """ + url_clients = 'https://timds.s3.eu-central-1.amazonaws.com/clients.csv.gz' file_clients = 'clients.csv.gz' csv_clients_path = _get_data(data_home=data_home, url=url_clients, dest_subdir=dest_subdir, @@ -213,8 +239,19 @@ def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True, as_fram def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, percent10=True, treatment_feature='treatment', target_column='visit', return_X_y_t=False, as_frame=False): - """Load data from the Criteo dataset - + """Load data from the Criteo dataset. + + This dataset is constructed by assembling data resulting from several incrementality tests, a particular randomized + trial procedure where a random part of the population is prevented from being targeted by advertising. + + Major columns: + + * ``treatment`` (binary): treatment + * ``exposure`` (binary): treatment + * ``visit`` (binary): target + * ``conversion`` (binary): target + * ``f0, ... , f11`` (float): feature values + Args: data_home (string): Specify a download and cache folder for the datasets. dest_subdir (string, unicode): The name of the folder in which the dataset is stored. @@ -227,7 +264,8 @@ def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, per will be target return_X_y_t (bool, default=False): If True, returns (data, target, treatment) instead of a Bunch object. See below for more information about the data and target object. - as_frame (bool, default=False): + as_frame (bool, default=False): If True, return as pandas.Series + Returns: ''~sklearn.utils.Bunch'': dataset Dictionary-like object, with the following attributes. @@ -300,29 +338,41 @@ def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, per def fetch_hillstrom(data_home=None, dest_subdir=None, download_if_missing=True, target_column='visit', return_X_y_t=False, as_frame=False): """Load the hillstrom dataset. + + This dataset contains 64,000 customers who last purchased within twelve months. The customers were involved in an e-mail test. + + Major columns: + + * ``Visit`` (binary): target. 1/0 indicator, 1 = Customer visited website in the following two weeks. + * ``Conversion`` (binary): target. 1/0 indicator, 1 = Customer purchased merchandise in the following two weeks. + * ``Spend`` (float): target. Actual dollars spent in the following two weeks. + * ``Segment`` (str): treatment. The e-mail campaign the customer received - Args: - data_home : str, default=None - Specify another download and cache folder for the datasets. - dest_subdir : str, default=None - download_if_missing : bool, default=True - If False, raise a IOError if the data is not locally available - instead of trying to download the data from the source site. + Args: + target : str, desfault=visit. + Can also be conversion, and spend + data_home : str, default=None + Specify another download and cache folder for the datasets. + dest_subdir : str, default=None + download_if_missing : bool, default=True + If False, raise a IOError if the data is not locally available + instead of trying to download the data from the source site. target_column (string, 'visit' or 'conversion' or 'spend', default='visit'): Selects which column from dataset - will be target + will be target return_X_y_t (bool): as_frame (bool): - Returns: - Dictionary-like object, with the following attributes. - data : {ndarray, dataframe} of shape (64000, 12) + Returns: + Dictionary-like object, with the following attributes. + data : {ndarray, dataframe} of shape (64000, 12) The data matrix to learn. - target : {ndarray, series} of shape (64000,) + target : {ndarray, series} of shape (64000,) The regression target for each sample. - treatment : {ndarray, series} of shape (64000,) - feature_names (list): The names of the future columns - target_name (string): The name of the target column. - treatment_name (string): The name of the treatment column + treatment : {ndarray, series} of shape (64000,) + feature_names (list): The names of the future columns + target_name (string): The name of the target column. + treatment_name (string): The name of the treatment column + """ url = 'https://hillstorm1.s3.us-east-2.amazonaws.com/hillstorm_no_indices.csv.gz' diff --git a/sklift/datasets/descr/criteo.rst b/sklift/datasets/descr/criteo.rst index c6138c1..8721ae7 100644 --- a/sklift/datasets/descr/criteo.rst +++ b/sklift/datasets/descr/criteo.rst @@ -1,24 +1,27 @@ Criteo Uplift Modeling Dataset ================================ -This is a copy of `Criteo AI Lab Uplift Prediction dataset `_. +This is a copy of `Criteo AI Lab Uplift Prediction dataset `_. Data description ------------------ +################ + This dataset is constructed by assembling data resulting from several incrementality tests, a particular randomized trial procedure where a random part of the population is prevented from being targeted by advertising. + Fields ---------- +################ Here is a detailed description of the fields (they are comma-separated in the file): * **f0, f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11**: feature values (dense, float) -* **treatment**: treatment group (1 = treated, 0 = control) +* **treatment**: treatment group. Flag if a company participates in the RTB auction for a particular user (binary: 1 = treated, 0 = control) +* **exposure**: treatment effect, whether the user has been effectively exposed. Flag if a company wins in the RTB auction for the user (binary) * **conversion**: whether a conversion occured for this user (binary, label) * **visit**: whether a visit occured for this user (binary, label) -* **exposure**: treatment effect, whether the user has been effectively exposed (binary) + Key figures --------------- +################ * Format: CSV * Size: 297M (compressed) 3,2GB (uncompressed) * Rows: 13,979,592 @@ -36,5 +39,3 @@ This work was published in: `AdKDD 2018 `_. + +date: March 20, 2008 This dataset contains 64,000 customers who last purchased within twelve months. The customers were involved in an e-mail test. + * 1/3 were randomly chosen to receive an e-mail campaign featuring Mens merchandise. * 1/3 were randomly chosen to receive an e-mail campaign featuring Womens merchandise. * 1/3 were randomly chosen to not receive an e-mail campaign. @@ -28,12 +28,14 @@ Historical customer attributes at your disposal include: * Channel: Describes the channels the customer purchased from in the past year. Another variable describes the e-mail campaign the customer received: + * Segment * Mens E-Mail * Womens E-Mail * No E-Mail Finally, we have a series of variables describing activity in the two weeks following delivery of the e-mail campaign: + * Visit: 1/0 indicator, 1 = Customer visited website in the following two weeks. * Conversion: 1/0 indicator, 1 = Customer purchased merchandise in the following two weeks. * Spend: Actual dollars spent in the following two weeks. \ No newline at end of file diff --git a/sklift/datasets/descr/lenta.rst b/sklift/datasets/descr/lenta.rst index 6b6723a..8b9c559 100644 --- a/sklift/datasets/descr/lenta.rst +++ b/sklift/datasets/descr/lenta.rst @@ -1,5 +1,35 @@ -Description of parameters. -~~~~~~~~~~~~~~~~~~~~~~~~~~ +Lenta Uplift Modeling Dataset +================================ + +Data description +################ + +An uplift modeling dataset containing data about Lenta's customers grociery shopping and related marketing campaigns. + +Source: **BigTarget Hackathon** hosted by Lenta and Microsoft in summer 2020. + + +Key figures +################ +* Format: CSV +* Size: 153M (compressed) 567M (uncompressed) +* Rows: 687 029 +* Response Ratio: 0.1 +* Treatment Ratio: 0.75 + + +Fields +################ + +Major features: + + * ``group`` (str): treatment/control group flag + * ``response_att`` (binary): target + * ``gender`` (str): customer gender + * ``age`` (float): customer age + * ``main_format`` (int): store type (1 - grociery store, 0 - superstore) + + .. list-table:: :align: center :header-rows: 1 @@ -7,101 +37,50 @@ Description of parameters. * - Feature - Description - * - customer - - age * - CardHolder - customer id - * - cheque_count_12m_g* - - number of customer receipts collected within last 12 months - before campaign. g* is a product group - * - cheque_count_3m_g* - - number of customer receipts collected within last 3 months - before campaign. g* is a product group - * - cheque_count_6m_g* - - number of customer receipts collected within last 6 months - before campaign. g* is a product group + * - customer + - age * - children - number of children - * - crazy_purchases_cheque_count_12m - - number of customer receipts with items purchased on "crazy" - marketing campaign collected within last 12 months before campaign - * - crazy_purchases_cheque_count_1m - - number of customer receipts with items purchased on "crazy" - marketing campaign collected within last 1 month before campaign - * - crazy_purchases_cheque_count_3m - - number of customer receipts with items purchased on "crazy" - marketing campaign collected within last 3 months before campaign - * - crazy_purchases_cheque_count_6m + * - cheque_count_[3,6,12]m_g* + - number of customer receipts collected within last 3, 6, 12 months + before campaign. g* is a product group + * - crazy_purchases_cheque_count_[1,3,6,12]m - number of customer receipts with items purchased on "crazy" - marketing campaign collected within last 6 months before campaign - * - crazy_purchases_goods_count_12m - - items amount purchased on "crazy" marketing campaign collected - within last 12 months before campaign - * - crazy_purchases_goods_count_6m + marketing campaign collected within last 1, 3, 6, 12 months before campaign + * - crazy_purchases_goods_count_[6,12]m - items amount purchased on "crazy" marketing campaign collected - within last 6 months before campaign + within last 6, 12 months before campaign * - disc_sum_6m_g34 - discount sum for past 6 month on a 34 product group - * - food_share_15d - - food share in customer purchases for 15 days - * - food_share_1m - - food share in customer purchases for 1 month + * - food_share_[15d,1m] + - food share in customer purchases for 15 days, 1 month * - gender - customer gender * - group - treatment/control group flag - * - k_var_cheque_15d - - average check coefficient of variation for 15 days - * - k_var_cheque_3m - - average check coefficient of variation for 3 months + * - k_var_cheque_[15d,3m] + - average check coefficient of variation for 15 days, 3 months * - k_var_cheque_category_width_15d - coefficient of variation of the average number of purchased categories (2nd level of the hierarchy) in one receipt for 15 days * - k_var_cheque_group_width_15d - coefficient of variation of the average number of purchased groups (1st level of the hierarchy) in one receipt for 15 days - * - k_var_count_per_cheque_15d_g* - - unique product id (SKU) coefficient of variation for 15 days + * - k_var_count_per_cheque_[15d,1m,3m,6m]_g* + - unique product id (SKU) coefficient of variation for 15 days, 1, 3 ,6 months for g* product group - * - k_var_count_per_cheque_1m_g* - - unique product id (SKU) coefficient of variation for 1 month - for g* product group - * - k_var_count_per_cheque_3m_g* - - unique product id (SKU) coefficient of variation for 3 months - for g* product group - * - k_var_count_per_cheque_6m_g* - - unique product id (SKU) coefficient of variation for 6 months - for g* product group - * - k_var_days_between_visits_15d - - coefficient of variation of the average period between visits - for 15 days - * - k_var_days_between_visits_1m - - coefficient of variation of the average period between visits - for 1 month - * - k_var_days_between_visits_3m + * - k_var_days_between_visits_[15d,1m,3m] - coefficient of variation of the average period between visits - for 3 months + for 15 days, 1 month, 3 months * - k_var_disc_per_cheque_15d - discount sum coefficient of variation for 15 days - * - k_var_disc_share_12m_g32 - - discount amount coefficient of variation for 12 months - for g* product group - * - k_var_disc_share_15d_g* - - discount amount coefficient of variation for 15 days - for g* product group - * - k_var_disc_share_1m_g* - - discount amount coefficient of variation for 1 month - for g* product group - * - k_var_disc_share_3m_g* - - discount amount coefficient of variation for 3 months + * - k_var_disc_share_[15d,1m,3m,6m,12m]_g* + - discount amount coefficient of variation for 15 days, 1 month, 3 months, 6 months, 12 months for g* product group - * - k_var_disc_share_6m_g* - - discount amount coefficient of variation for 6 months - for g* product group - * - k_var_discount_depth_15d - - discount amount coefficient of variation for 1 month - * - k_var_discount_depth_1m - - discount amount coefficient of variation for 15 days + * - k_var_discount_depth_[15d,1m] + - discount amount coefficient of variation for 15 days, 1 month * - k_var_sku_per_cheque_15d - number of unique product ids (SKU) coefficient of variation for 15 days @@ -127,21 +106,13 @@ Description of parameters. * - response_viber - share of responses to previous Viber messages. Response = store visit - * - sale_count_12m_g* - - number of purchased items from the group * for 12 months - * - sale_count_3m_g* - - number of purchased items from the group * for 3 months - * - sale_count_6m_g* - - number of purchased items from the group * for 6 months - * - sale_sum_12m_g* - - sum of sales from the group * for 12 months - * - sale_sum_3m_g* - - sum of sales from the group * for 3 months - * - sale_sum_6m_g* - - sum of sales from the group * for 6 months + * - sale_count_[3,6,12]m_g* + - number of purchased items from the group * for 3, 6, 12 months + * - sale_sum_[3,6,12]m_g* + - sum of sales from the group * for 3, 6, 12 months * - stdev_days_between_visits_15d - coefficient of variation of the days between visits for 15 days - * - stdev_discount_depth_15d - - discount sum coefficient of variation for 15 days - * - stdev_discount_depth_1m - - discount sum coefficient of variation for 1 month \ No newline at end of file + * - stdev_discount_depth_[15d,1m] + - discount sum coefficient of variation for 15 days, 1 month + + diff --git a/sklift/datasets/descr/x5.rst b/sklift/datasets/descr/x5.rst new file mode 100644 index 0000000..b3c83e4 --- /dev/null +++ b/sklift/datasets/descr/x5.rst @@ -0,0 +1,26 @@ +X5 RetailHero Uplift Modeling Dataset +===================================== + +The dataset is provided by X5 Retail Group at the RetailHero hackaton hosted in winter 2019. + +The dataset contains raw retail customer purchaces, raw information about products and general info about customers. + +`Hackaton website `_. + +Data description +################ + +Data contains several parts: + +* train.csv: a subset of clients for training. The column *treatment_flg* indicates if there was a communication. The column *target* shows if there was a purchase afterward; +* clients.csv: general info about clients; +* products.csv: general info about stock items; +* purchases.csv: clients’ purchase history prior to communication. + +Fields +################ + +* treatment_flg (binary): information on performed communication +* target (binary): customer purchasing + + diff --git a/sklift/metrics/metrics.py b/sklift/metrics/metrics.py index 21e4144..e40a913 100644 --- a/sklift/metrics/metrics.py +++ b/sklift/metrics/metrics.py @@ -631,10 +631,8 @@ def uplift_by_percentile(y_true, uplift, treatment, strategy='overall', bins=10, response_rate_ctrl_total, variance_ctrl_total, n_ctrl_total = response_rate_by_percentile( y_true, uplift, treatment, strategy=strategy, group='control', bins=1) - weighted_avg_uplift = 1 / n_trmnt_total * np.dot(n_trmnt, uplift_scores) - df.loc[-1, :] = ['total', n_trmnt_total, n_ctrl_total, response_rate_trmnt_total, - response_rate_ctrl_total, weighted_avg_uplift] + response_rate_ctrl_total, response_rate_trmnt_total - response_rate_ctrl_total] if std: std_treatment = np.sqrt(variance_trmnt) diff --git a/sklift/viz/base.py b/sklift/viz/base.py index c4bb1f0..14340ee 100644 --- a/sklift/viz/base.py +++ b/sklift/viz/base.py @@ -93,7 +93,7 @@ def plot_uplift_curve(y_true, uplift, treatment, random=True, perfect=True): ax.legend(loc='lower right') ax.set_title( - f'Uplift curve\nuplift_auc_score={uplift_auc_score(y_true, uplift, treatment):.2f}') + f'Uplift curve\nuplift_auc_score={uplift_auc_score(y_true, uplift, treatment):.4f}') ax.set_xlabel('Number targeted') ax.set_ylabel('Gain: treatment - control') @@ -139,7 +139,7 @@ def plot_qini_curve(y_true, uplift, treatment, random=True, perfect=True, negati ax.legend(loc='lower right') ax.set_title( - f'Qini curve\nqini_auc_score={qini_auc_score(y_true, uplift, treatment, negative_effect):.2f}') + f'Qini curve\nqini_auc_score={qini_auc_score(y_true, uplift, treatment, negative_effect):.4f}') ax.set_xlabel('Number targeted') ax.set_ylabel('Number of incremental outcome') @@ -238,7 +238,7 @@ def plot_uplift_by_percentile(y_true, uplift, treatment, strategy='overall', kin axes.set_xticks(percentiles) axes.legend(loc='upper right') axes.set_title( - f'Uplift by percentile\nweighted average uplift = {uplift_weighted_avg:.2f}') + f'Uplift by percentile\nweighted average uplift = {uplift_weighted_avg:.4f}') axes.set_xlabel('Percentile') axes.set_ylabel( 'Uplift = treatment response rate - control response rate') @@ -261,7 +261,7 @@ def plot_uplift_by_percentile(y_true, uplift, treatment, strategy='overall', kin axes[0].tick_params(axis='x', bottom=False) axes[0].axhline(y=0, color='black', linewidth=1) axes[0].set_title( - f'Uplift by percentile\nweighted average uplift = {uplift_weighted_avg:.2f}') + f'Uplift by percentile\nweighted average uplift = {uplift_weighted_avg:.4f}') axes[1].set_xticks(percentiles) axes[1].legend(loc='upper right') From bf948428563db7d2bc62d6159d2bbca418098263 Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sun, 7 Feb 2021 02:05:09 +0300 Subject: [PATCH 22/26] :arrows_counterclockwise: Add check docs (#69) --- .github/workflows/ci-test.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index ed6c3ef..13566c7 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -7,8 +7,8 @@ on: jobs: - build: - + test: + name: Check tests runs-on: ${{ matrix.operating-system }} strategy: matrix: @@ -26,3 +26,23 @@ jobs: run: pip install pytest .[tests] - name: Run PyTest run: pytest + + check_sphinx_build: + name: Check Sphinx build for docs + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8] + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Update pip + run: python -m pip install --upgrade pip + - name: Install dependencies + run: pip install -r docs/requirements.txt + - name: Run Sphinx + run: sphinx-build -b html docs /tmp/_docs_build \ No newline at end of file From 161c1b74bd977b18243275db276580d4b04e142b Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sun, 7 Feb 2021 03:17:16 +0300 Subject: [PATCH 23/26] :rocket: Fix docs and decsr (#70) --- docs/api/datasets/fetch_criteo.rst | 2 + docs/api/datasets/fetch_hillstrom.rst | 4 +- docs/api/datasets/fetch_lenta.rst | 2 + docs/api/datasets/fetch_x5.rst | 2 + requirements.txt | 3 +- sklift/datasets/datasets.py | 270 +++++++++++++++----------- sklift/datasets/descr/hillstrom.rst | 20 +- sklift/datasets/descr/lenta.rst | 18 +- sklift/datasets/descr/x5.rst | 6 +- 9 files changed, 192 insertions(+), 135 deletions(-) diff --git a/docs/api/datasets/fetch_criteo.rst b/docs/api/datasets/fetch_criteo.rst index dba4c4b..b3f72da 100644 --- a/docs/api/datasets/fetch_criteo.rst +++ b/docs/api/datasets/fetch_criteo.rst @@ -1,3 +1,5 @@ +.. _Criteo: + ************************************** `sklift.datasets <./>`_.fetch_criteo ************************************** diff --git a/docs/api/datasets/fetch_hillstrom.rst b/docs/api/datasets/fetch_hillstrom.rst index 145acef..d71d722 100644 --- a/docs/api/datasets/fetch_hillstrom.rst +++ b/docs/api/datasets/fetch_hillstrom.rst @@ -1,7 +1,9 @@ +.. _Hillstrom: + **************************************** `sklift.datasets <./>`_.fetch_hillstrom **************************************** .. autofunction:: sklift.datasets.datasets.fetch_hillstrom -.. include:: ../../../sklift/datasets/descr/lenta.rst \ No newline at end of file +.. include:: ../../../sklift/datasets/descr/hillstrom.rst \ No newline at end of file diff --git a/docs/api/datasets/fetch_lenta.rst b/docs/api/datasets/fetch_lenta.rst index f428a63..dd2f225 100644 --- a/docs/api/datasets/fetch_lenta.rst +++ b/docs/api/datasets/fetch_lenta.rst @@ -1,3 +1,5 @@ +.. _Lenta: + *********************************** `sklift.datasets <./>`_.fetch_lenta *********************************** diff --git a/docs/api/datasets/fetch_x5.rst b/docs/api/datasets/fetch_x5.rst index 126ba84..cb42b2f 100644 --- a/docs/api/datasets/fetch_x5.rst +++ b/docs/api/datasets/fetch_x5.rst @@ -1,3 +1,5 @@ +.. _X5: + *********************************** `sklift.datasets <./>`_.fetch_x5 *********************************** diff --git a/requirements.txt b/requirements.txt index 11e054c..806e482 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ scikit-learn>=0.21.0 numpy>=1.16 pandas -matplotlib \ No newline at end of file +matplotlib +requests \ No newline at end of file diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index 40ffdf6..173f4c0 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -1,5 +1,6 @@ -import shutil import os +import shutil + import pandas as pd import requests from sklearn.utils import Bunch @@ -34,8 +35,8 @@ def _download(url, dest_path): """Download the file from url and save it locally. Args: - url: URL address, must be a string. - dest_path: Destination of the file. + url (str): URL address, must be a string. + dest_path (str): Destination of the file. """ if isinstance(url, str): @@ -53,9 +54,9 @@ def _get_data(data_home, url, dest_subdir, dest_filename, download_if_missing): """Return the path to the dataset. Args: - data_home (str, unicode): The path to scikit-uplift data dir. - url (str or unicode): The URL to the dataset. - dest_subdir (str or unicode): The name of the folder in which the dataset is stored. + data_home (str): The path to scikit-uplift data dir. + url (str): The URL to the dataset. + dest_subdir (str): The name of the folder in which the dataset is stored. dest_filename (str): The name of the dataset. download_if_missing (bool): If False, raise a IOError if the data is not locally available instead of trying to download the data from the source site. @@ -100,11 +101,11 @@ def clear_data_dir(path=None): shutil.rmtree(path, ignore_errors=True) - -def fetch_lenta(return_X_y_t=False, data_home=None, dest_subdir=None, download_if_missing=True): +def fetch_lenta(data_home=None, dest_subdir=None, download_if_missing=True, return_X_y_t=False, as_frame=True): """Load and return the Lenta dataset (classification). - An uplift modeling dataset containing data about Lenta's customers grociery shopping and related marketing campaigns. + An uplift modeling dataset containing data about Lenta's customers grociery shopping and + related marketing campaigns. Major columns: @@ -114,38 +115,47 @@ def fetch_lenta(return_X_y_t=False, data_home=None, dest_subdir=None, download_i - ``age`` (float): customer age - ``main_format`` (int): store type (1 - grociery store, 0 - superstore) + Read more in the :ref:`docs `. + Args: - return_X_y_t (bool): If True, returns (data, target, treatment) instead of a Bunch object. - See below for more information about the data and target object. - data_home (str, unicode): The path to the folder where datasets are stored. - dest_subdir (str, unicode): The name of the folder in which the dataset is stored. + data_home (str): The path to the folder where datasets are stored. + dest_subdir (str): The name of the folder in which the dataset is stored. download_if_missing (bool): Download the data if not present. Raises an IOError if False and data is missing. + return_X_y_t (bool): If True, returns (data, target, treatment) instead of a Bunch object. + as_frame (bool): If True, returns a pandas Dataframe or Series for the data, target and treatment objects + in the Bunch returned object; Bunch return object will also have a frame member. Returns: Bunch or tuple: dataset. + Bunch: By default dictionary-like object, with the following attributes: - * ``data`` (DataFrame object): Dataset without target and treatment. + * ``data`` (ndarray or DataFrame object): Dataset without target and treatment. * ``target`` (Series object): Column target by values. * ``treatment`` (Series object): Column treatment by values. * ``DESCR`` (str): Description of the Lenta dataset. + * ``feature_names`` (list): Names of the features. + * ``target_name`` (str): Name of the target. + * ``treatment_name`` (str): Name of the treatment. + + Tuple: + tuple (data, target, treatment) if `return_X_y` is True - tuple (data, target, treatment) if `return_X_y` is True """ - url='https:/winterschool123.s3.eu-north-1.amazonaws.com/lentadataset.csv.gz' - filename='lentadataset.csv.gz' + url = 'https:/winterschool123.s3.eu-north-1.amazonaws.com/lentadataset.csv.gz' + filename = 'lentadataset.csv.gz' - csv_path=_get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, - dest_filename=filename, - download_if_missing=download_if_missing) + csv_path = _get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, + dest_filename=filename, + download_if_missing=download_if_missing) data = pd.read_csv(csv_path) if as_frame: - target=data['response_att'] - treatment=data['group'] - data=data.drop(['response_att', 'group'], axis=1) + target = data['response_att'] + treatment = data['group'] + data = data.drop(['response_att', 'group'], axis=1) feature_names = list(data.columns) else: target = data[['response_att']].to_numpy() @@ -165,8 +175,8 @@ def fetch_lenta(return_X_y_t=False, data_home=None, dest_subdir=None, download_i feature_names=feature_names, target_name='response_att', treatment_name='group') -def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True): - """Load the X5 dataset. +def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True, as_frame=True): + """Load and return the X5 RetailHero dataset (classification). The dataset contains raw retail customer purchaces, raw information about products and general info about customers. @@ -176,43 +186,57 @@ def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True): - ``target`` (binary): target - ``customer_id`` (str): customer id aka primary key for joining + Read more in the :ref:`docs `. + Args: data_home (str, unicode): The path to the folder where datasets are stored. dest_subdir (str, unicode): The name of the folder in which the dataset is stored. download_if_missing (bool): Download the data if not present. Raises an IOError if False and data is missing. + as_frame (bool): If True, returns a pandas Dataframe or Series for the data, target and treatment objects + in the Bunch returned object; Bunch return object will also have a frame member. Returns: - Bunch: dataset Dictionary-like object, with the following attributes. - - * data ('~sklearn.utils.Bunch'): Dataset without target and treatment. - * target (Series object): Column target by values - * treatment (Series object): Column treatment by values - * DESCR (str): Description of the X5 dataset. - * train (DataFrame object): Dataset with target and treatment. + Bunch: dataset. + + Dictionary-like object, with the following attributes. + + * ``data`` (Bunch object): dictionary-like object without target and treatment: + * ``clients`` (ndarray or DataFrame object): General info about clients. + * ``train`` (ndarray or DataFrame object): A subset of clients for training. + * ``purchases`` (ndarray or DataFrame object): clients’ purchase history prior to communication. + * ``target`` (Series object): Column target by values. + * ``treatment`` (Series object): Column treatment by values. + * ``DESCR`` (str): Description of the Lenta dataset. + * ``feature_names`` (Bunch object): Names of the features. + * ``target_name`` (str): Name of the target. + * ``treatment_name`` (str): Name of the treatment. + + References: + https://ods.ai/competitions/x5-retailhero-uplift-modeling/data """ url_clients = 'https://timds.s3.eu-central-1.amazonaws.com/clients.csv.gz' file_clients = 'clients.csv.gz' csv_clients_path = _get_data(data_home=data_home, url=url_clients, dest_subdir=dest_subdir, - dest_filename=file_clients, - download_if_missing=download_if_missing) + dest_filename=file_clients, + download_if_missing=download_if_missing) clients = pd.read_csv(csv_clients_path) clients_names = list(clients.column) url_train = 'https://timds.s3.eu-central-1.amazonaws.com/uplift_train.csv.gz' file_train = 'uplift_train.csv.gz' csv_train_path = _get_data(data_home=data_home, url=url_train, dest_subdir=dest_subdir, - dest_filename=file_train, - download_if_missing=download_if_missing) + dest_filename=file_train, + download_if_missing=download_if_missing) train = pd.read_csv(csv_train_path) train_names = list(train.columns) url_purchases = 'https://timds.s3.eu-central-1.amazonaws.com/purchases.csv.gz' file_purchases = 'purchases.csv.gz' csv_purchases_path = _get_data(data_home=data_home, url=url_purchases, dest_subdir=dest_subdir, - dest_filename=file_purchases, - download_if_missing=download_if_missing) + dest_filename=file_purchases, + download_if_missing=download_if_missing) purchases = pd.read_csv(csv_purchases_path) purchases_names = list(purchases.columns) @@ -226,20 +250,21 @@ def fetch_x5(data_home=None, dest_subdir=None, download_if_missing=True): clients = clients.to_numpy() purchases = purchases.to_numpy() + data = Bunch(clients=clients, train=train, purchases=purchases) + data_names = Bunch(clients_names=clients_names, train_names=train_names, + purchases_names=purchases_names) + module_path = os.path.dirname(__file__) with open(os.path.join(module_path, 'descr', 'x5.rst')) as rst_file: fdescr = rst_file.read() - return Bunch(data=Bunch(clients=clients, train=train, purchases=purchases), - target=target, treatment=treatment, DESCR=fdescr, - data_names=Bunch(clients_names=clients_names, train_names=train_names, - purchases_names=purchases_names), - treatment_name='treatment_flg') + return Bunch(data=data, target=target, treatment=treatment, DESCR=fdescr, + data_names=data_names, target_name='target', treatment_name='treatment_flg') -def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, percent10=True, - treatment_feature='treatment', target_column='visit', return_X_y_t=False, as_frame=False): - """Load data from the Criteo dataset. +def fetch_criteo(target_col='visit', treatment_col='treatment', data_home=None, dest_subdir=None, + download_if_missing=True, percent10=True, return_X_y_t=False, as_frame=True): + """Load and return the Criteo Uplift Prediction Dataset (classification). This dataset is constructed by assembling data resulting from several incrementality tests, a particular randomized trial procedure where a random part of the population is prevented from being targeted by advertising. @@ -252,69 +277,80 @@ def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, per * ``conversion`` (binary): target * ``f0, ... , f11`` (float): feature values + Read more in the :ref:`docs `. + Args: + target_col (string, 'visit' or 'conversion', default='visit'): Selects which column from dataset + will be target. + treatment_col (string,'treatment' or 'exposure' default='treatment'): Selects which column from dataset + will be treatment. data_home (string): Specify a download and cache folder for the datasets. - dest_subdir (string, unicode): The name of the folder in which the dataset is stored. + dest_subdir (string): The name of the folder in which the dataset is stored. download_if_missing (bool, default=True): If False, raise an IOError if the data is not locally available - instead of trying to download the data from the source site. + instead of trying to download the data from the source site. percent10 (bool, default=True): Whether to load only 10 percent of the data. - treatment_feature (string,'treatment' or 'exposure' default='treatment'): Selects which column from dataset - will be treatment - target_column (string, 'visit' or 'conversion', default='visit'): Selects which column from dataset - will be target return_X_y_t (bool, default=False): If True, returns (data, target, treatment) instead of a Bunch object. - See below for more information about the data and target object. - as_frame (bool, default=False): If True, return as pandas.Series + as_frame (bool): If True, returns a pandas Dataframe or Series for the data, target and treatment objects + in the Bunch returned object; Bunch return object will also have a frame member. Returns: - ''~sklearn.utils.Bunch'': dataset - Dictionary-like object, with the following attributes. - data (ndarray, DataFrame object): Dataset without target and treatment. - target (ndarray, series): Column target by values - treatment (ndarray, series): Column treatment by values - DESCR (string): Description of the Criteo dataset. - feature_names (list): The names of the future columns - target_name (string): The name of the target column. - treatment_name (string): The name of the treatment column - tuple (data, target, treatment): tuple if return_X_y_t is True + Bunch or tuple: dataset. + + Bunch: + By default dictionary-like object, with the following attributes: + + * ``data`` (ndarray or DataFrame object): Dataset without target and treatment. + * ``target`` (Series object): Column target by values. + * ``treatment`` (Series object): Column treatment by values. + * ``DESCR`` (str): Description of the Lenta dataset. + * ``feature_names`` (list): Names of the features. + * ``target_name`` (str): Name of the target. + * ``treatment_name`` (str): Name of the treatment. + + Tuple: + tuple (data, target, treatment) if `return_X_y` is True + + References: + “A Large Scale Benchmark for Uplift Modeling” + Eustache Diemert, Artem Betlei, Christophe Renaudin; (Criteo AI Lab), Massih-Reza Amini (LIG, Grenoble INP) """ if percent10: url = 'https://criteo-bucket.s3.eu-central-1.amazonaws.com/criteo10.csv.gz' csv_path = _get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, - dest_filename='criteo10.csv.gz', - download_if_missing=download_if_missing) + dest_filename='criteo10.csv.gz', + download_if_missing=download_if_missing) else: url = "https://criteo-bucket.s3.eu-central-1.amazonaws.com/criteo.csv.gz" csv_path = _get_data(data_home=data_home, url=url, dest_subdir=dest_subdir, - dest_filename='criteo.csv.gz', - download_if_missing=download_if_missing) + dest_filename='criteo.csv.gz', + download_if_missing=download_if_missing) - if treatment_feature == 'exposure': + if treatment_col == 'exposure': data = pd.read_csv(csv_path, usecols=[i for i in range(12)]) treatment = pd.read_csv(csv_path, usecols=['exposure'], dtype={'exposure': 'Int8'}) if as_frame: treatment = treatment['exposure'] - elif treatment_feature == 'treatment': + elif treatment_col == 'treatment': data = pd.read_csv(csv_path, usecols=[i for i in range(12)]) treatment = pd.read_csv(csv_path, usecols=['treatment'], dtype={'treatment': 'Int8'}) if as_frame: treatment = treatment['treatment'] else: - raise ValueError(f"Treatment_feature value must be from {['treatment', 'exposure']}. " - f"Got value {treatment_feature}.") + raise ValueError(f"treatment_col value must be from {['treatment', 'exposure']}. " + f"Got value {treatment_col}.") feature_names = list(data.columns) - if target_column == 'conversion': + if target_col == 'conversion': target = pd.read_csv(csv_path, usecols=['conversion'], dtype={'conversion': 'Int8'}) if as_frame: target = target['conversion'] - elif target_column == 'visit': + elif target_col == 'visit': target = pd.read_csv(csv_path, usecols=['visit'], dtype={'visit': 'Int8'}) if as_frame: target = target['visit'] else: - raise ValueError(f"Target_column value must be from {['visit', 'conversion']}. " - f"Got value {target_column}.") + raise ValueError(f"target_col value must be from {['visit', 'conversion']}. " + f"Got value {target_col}.") if return_X_y_t: if as_frame: @@ -322,11 +358,13 @@ def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, per else: return data.to_numpy(), target.to_numpy(), treatment.to_numpy() else: - target_name = target_column - treatment_name = treatment_feature - module_path = os.path.dirname(__file__) - with open(os.path.join(module_path, 'descr', 'criteo.rst')) as rst_file: - fdescr = rst_file.read() + target_name = target_col + treatment_name = treatment_col + + module_path = os.path.dirname(__file__) + with open(os.path.join(module_path, 'descr', 'criteo.rst')) as rst_file: + fdescr = rst_file.read() + if as_frame: return Bunch(data=data, target=target, treatment=treatment, DESCR=fdescr, feature_names=feature_names, target_name=target_name, treatment_name=treatment_name) @@ -335,11 +373,12 @@ def fetch_criteo(data_home=None, dest_subdir=None, download_if_missing=True, per feature_names=feature_names, target_name=target_name, treatment_name=treatment_name) -def fetch_hillstrom(data_home=None, dest_subdir=None, download_if_missing=True, target_column='visit', - return_X_y_t=False, as_frame=False): - """Load the hillstrom dataset. +def fetch_hillstrom(target_col='visit', data_home=None, dest_subdir=None, download_if_missing=True, + return_X_y_t=False, as_frame=True): + """Load and return Kevin Hillstrom Dataset MineThatData (classification or regression). - This dataset contains 64,000 customers who last purchased within twelve months. The customers were involved in an e-mail test. + This dataset contains 64,000 customers who last purchased within twelve months. + The customers were involved in an e-mail test. Major columns: @@ -347,31 +386,38 @@ def fetch_hillstrom(data_home=None, dest_subdir=None, download_if_missing=True, * ``Conversion`` (binary): target. 1/0 indicator, 1 = Customer purchased merchandise in the following two weeks. * ``Spend`` (float): target. Actual dollars spent in the following two weeks. * ``Segment`` (str): treatment. The e-mail campaign the customer received - + + Read more in the :ref:`docs `. + Args: - target : str, desfault=visit. - Can also be conversion, and spend - data_home : str, default=None - Specify another download and cache folder for the datasets. - dest_subdir : str, default=None - download_if_missing : bool, default=True - If False, raise a IOError if the data is not locally available - instead of trying to download the data from the source site. - target_column (string, 'visit' or 'conversion' or 'spend', default='visit'): Selects which column from dataset + target_col (string, 'visit' or 'conversion' or 'spend', default='visit'): Selects which column from dataset will be target - return_X_y_t (bool): - as_frame (bool): + data_home (str): The path to the folder where datasets are stored. + dest_subdir (str): The name of the folder in which the dataset is stored. + download_if_missing (bool): Download the data if not present. Raises an IOError if False and data is missing. + return_X_y_t (bool, default=False): If True, returns (data, target, treatment) instead of a Bunch object. + as_frame (bool): If True, returns a pandas Dataframe for the data, target and treatment objects + in the Bunch returned object; Bunch return object will also have a frame member. Returns: - Dictionary-like object, with the following attributes. - data : {ndarray, dataframe} of shape (64000, 12) - The data matrix to learn. - target : {ndarray, series} of shape (64000,) - The regression target for each sample. - treatment : {ndarray, series} of shape (64000,) - feature_names (list): The names of the future columns - target_name (string): The name of the target column. - treatment_name (string): The name of the treatment column + Bunch or tuple: dataset. + + Bunch: + By default dictionary-like object, with the following attributes: + + * ``data`` (ndarray or DataFrame object): Dataset without target and treatment. + * ``target`` (Series object): Column target by values. + * ``treatment`` (Series object): Column treatment by values. + * ``DESCR`` (str): Description of the Lenta dataset. + * ``feature_names`` (list): Names of the features. + * ``target_name`` (str): Name of the target. + * ``treatment_name`` (str): Name of the treatment. + + Tuple: + tuple (data, target, treatment) if `return_X_y` is True + + References: + https://blog.minethatdata.com/2008/03/minethatdata-e-mail-analytics-and-data.html """ @@ -382,16 +428,16 @@ def fetch_hillstrom(data_home=None, dest_subdir=None, download_if_missing=True, dest_filename='hillstorm_no_indices.csv.gz', download_if_missing=download_if_missing) - if target_column != ('visit' or 'conversion' or 'spend'): - raise ValueError(f"Target_column value must be from {['visit', 'conversion', 'spend']}. " - f"Got value {target_column}.") + if target_col != ('visit' or 'conversion' or 'spend'): + raise ValueError(f"target_col value must be from {['visit', 'conversion', 'spend']}. " + f"Got value {target_col}.") data = pd.read_csv(csv_path, usecols=[i for i in range(8)]) feature_names = list(data.columns) treatment = pd.read_csv(csv_path, usecols=['segment']) - target = pd.read_csv(csv_path, usecols=[target_column]) + target = pd.read_csv(csv_path, usecols=[target_col]) if as_frame: - target = target[target_column] + target = target[target_col] treatment = treatment['segment'] else: data = data.to_numpy() @@ -405,6 +451,6 @@ def fetch_hillstrom(data_home=None, dest_subdir=None, download_if_missing=True, if return_X_y_t: return data, target, treatment else: - target_name = target_column + target_name = target_col return Bunch(data=data, target=target, treatment=treatment, DESCR=fdescr, feature_names=feature_names, target_name=target_name, treatment_name='segment') diff --git a/sklift/datasets/descr/hillstrom.rst b/sklift/datasets/descr/hillstrom.rst index 178d691..98fed87 100644 --- a/sklift/datasets/descr/hillstrom.rst +++ b/sklift/datasets/descr/hillstrom.rst @@ -4,17 +4,20 @@ Kevin Hillstrom Dataset: MineThatData Data description ################ -This is a copy of `MineThatData E-Mail Analytics And Data Mining Challenge dataset `_. +This is a copy of `MineThatData E-Mail Analytics And Data Mining Challenge dataset `_. -date: March 20, 2008 - -This dataset contains 64,000 customers who last purchased within twelve months. The customers were involved in an e-mail test. +This dataset contains 64,000 customers who last purchased within twelve months. +The customers were involved in an e-mail test. * 1/3 were randomly chosen to receive an e-mail campaign featuring Mens merchandise. * 1/3 were randomly chosen to receive an e-mail campaign featuring Womens merchandise. * 1/3 were randomly chosen to not receive an e-mail campaign. -During a period of two weeks following the e-mail campaign, results were tracked. Your job is to tell the world if the Mens or Womens e-mail campaign was successful. +During a period of two weeks following the e-mail campaign, results were tracked. +Your job is to tell the world if the Mens or Womens e-mail campaign was successful. + +Fields +################ Historical customer attributes at your disposal include: @@ -30,9 +33,10 @@ Historical customer attributes at your disposal include: Another variable describes the e-mail campaign the customer received: * Segment -* Mens E-Mail -* Womens E-Mail -* No E-Mail + + * Mens E-Mail + * Womens E-Mail + * No E-Mail Finally, we have a series of variables describing activity in the two weeks following delivery of the e-mail campaign: diff --git a/sklift/datasets/descr/lenta.rst b/sklift/datasets/descr/lenta.rst index 8b9c559..e5c28ff 100644 --- a/sklift/datasets/descr/lenta.rst +++ b/sklift/datasets/descr/lenta.rst @@ -8,16 +8,6 @@ An uplift modeling dataset containing data about Lenta's customers grociery shop Source: **BigTarget Hackathon** hosted by Lenta and Microsoft in summer 2020. - -Key figures -################ -* Format: CSV -* Size: 153M (compressed) 567M (uncompressed) -* Rows: 687 029 -* Response Ratio: 0.1 -* Treatment Ratio: 0.75 - - Fields ################ @@ -115,4 +105,12 @@ Major features: * - stdev_discount_depth_[15d,1m] - discount sum coefficient of variation for 15 days, 1 month +Key figures +################ + +* Format: CSV +* Size: 153M (compressed) 567M (uncompressed) +* Rows: 687 029 +* Response Ratio: 0.1 +* Treatment Ratio: 0.75 diff --git a/sklift/datasets/descr/x5.rst b/sklift/datasets/descr/x5.rst index b3c83e4..8fff6e7 100644 --- a/sklift/datasets/descr/x5.rst +++ b/sklift/datasets/descr/x5.rst @@ -3,9 +3,10 @@ X5 RetailHero Uplift Modeling Dataset The dataset is provided by X5 Retail Group at the RetailHero hackaton hosted in winter 2019. -The dataset contains raw retail customer purchaces, raw information about products and general info about customers. +The dataset contains raw retail customer purchases, raw information about products and general info about customers. -`Hackaton website `_. + +`Machine learning competition website `_. Data description ################ @@ -14,7 +15,6 @@ Data contains several parts: * train.csv: a subset of clients for training. The column *treatment_flg* indicates if there was a communication. The column *target* shows if there was a purchase afterward; * clients.csv: general info about clients; -* products.csv: general info about stock items; * purchases.csv: clients’ purchase history prior to communication. Fields From 68610bf5c438595b6d417d8c66759638e4a1b98c Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sun, 7 Feb 2021 03:36:29 +0300 Subject: [PATCH 24/26] :rocket: Add changelog (#71) --- docs/changelog.md | 98 +++++++++++++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 38 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 309cedc..4eee210 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,68 +8,90 @@ * 🔨 something that previously didn’t work as documentated – or according to reasonable expectations – should now work. * ❗️ you will need to change your code to have the same effect in the future; or a feature will be removed in the future. +## Version 0.3.0 + +### [sklift.datasets](https://www.uplift-modeling.com/en/latest/en/latest/api/datasets/index.html) + +* 🔥 Add [sklift.datasets](https://www.uplift-modeling.com/en/latest/en/latest/user_guide/index.html) by [@ElisovaIra](https://github.com/ElisovaIra), [@RobbStarkk](https://github.com/RobbStarkk), [@acssar](https://github.com/acssar), [@tankudo](https://github.com/tankudo), [@flashlight101](https://github.com/flashlight101), [@semenova-pd](https://github.com/semenova-pd), [@timfex](https://github.com/timfex) + +### [sklift.models](https://www.uplift-modeling.com/en/latest/en/latest/api/models.html) + +* 📝 Add different checkers by [@ElisovaIra](https://github.com/ElisovaIra) + +### [sklift.metrics](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics.html) + +* 📝 Add different checkers by [@ElisovaIra](https://github.com/ElisovaIra) + +### [sklift.viz](https://www.uplift-modeling.com/en/latest/en/latest/api/viz.html) + +* 📝 Fix conflicting and duplicating default values by [@denniskorablev](https://github.com/denniskorablev) + +### [User Guide](https://www.uplift-modeling.com/en/latest/en/latest/user_guide/index.html) + +* 📝 Fix typos + ## Version 0.2.0 -### [User Guide](https://scikit-uplift.readthedocs.io/en/latest/user_guide/index.html) +### [User Guide](https://www.uplift-modeling.com/en/latest/en/latest/user_guide/index.html) -* 🔥 Add [User Guide](https://scikit-uplift.readthedocs.io/en/latest/user_guide/index.html) +* 🔥 Add [User Guide](https://www.uplift-modeling.com/en/latest/en/latest/user_guide/index.html) -### [sklift.models](https://scikit-uplift.readthedocs.io/en/latest/api/models.html) +### [sklift.models](https://www.uplift-modeling.com/en/latest/en/latest/api/models.html) -* 💥 Add `treatment interaction` method to [SoloModel](https://scikit-uplift.readthedocs.io/en/latest/api/models/SoloModel.html) approach by [@AdiVarma27](https://github.com/AdiVarma27). +* 💥 Add `treatment interaction` method to [SoloModel](https://www.uplift-modeling.com/en/latest/en/latest/api/models/SoloModel.html) approach by [@AdiVarma27](https://github.com/AdiVarma27). -### [sklift.metrics](https://scikit-uplift.readthedocs.io/en/latest/api/metrics.html) +### [sklift.metrics](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics.html) -* 💥 Add [uplift_by_percentile](https://scikit-uplift.readthedocs.io/en/latest/api/metrics/uplift_by_percentile.html) function by [@ElisovaIra](https://github.com/ElisovaIra). -* 💥 Add [weighted_average_uplift](https://scikit-uplift.readthedocs.io/en/latest/api/metrics/weighted_average_uplift.html) function by [@ElisovaIra](https://github.com/ElisovaIra). -* 💥 Add [perfect_uplift_curve](https://scikit-uplift.readthedocs.io/en/latest/api/metrics/perfect_uplift_curve.html) function. -* 💥 Add [perfect_qini_curve](https://scikit-uplift.readthedocs.io/en/latest/api/metrics/perfect_qini_curve.html) function. -* 🔨 Add normalization in [uplift_auc_score](https://scikit-uplift.readthedocs.io/en/latest/api/metrics/uplift_auc_score.html) and [qini_auc_score](https://scikit-uplift.readthedocs.io/en/latest/api/metrics/qini_auc_score.html) functions. -* ❗ Remove metrics `auuc` and `auqc`. In exchange for them use respectively [uplift_auc_score](https://scikit-uplift.readthedocs.io/en/latest/api/metrics/uplift_auc_score.html) and [qini_auc_score](https://scikit-uplift.readthedocs.io/en/latest/api/metrics/qini_auc_score.html) +* 💥 Add [uplift_by_percentile](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics/uplift_by_percentile.html) function by [@ElisovaIra](https://github.com/ElisovaIra). +* 💥 Add [weighted_average_uplift](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics/weighted_average_uplift.html) function by [@ElisovaIra](https://github.com/ElisovaIra). +* 💥 Add [perfect_uplift_curve](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics/perfect_uplift_curve.html) function. +* 💥 Add [perfect_qini_curve](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics/perfect_qini_curve.html) function. +* 🔨 Add normalization in [uplift_auc_score](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics/uplift_auc_score.html) and [qini_auc_score](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics/qini_auc_score.html) functions. +* ❗ Remove metrics `auuc` and `auqc`. In exchange for them use respectively [uplift_auc_score](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics/uplift_auc_score.html) and [qini_auc_score](https://www.uplift-modeling.com/en/latest/en/latest/api/metrics/qini_auc_score.html) -### [sklift.viz](https://scikit-uplift.readthedocs.io/en/latest/api/viz.html) +### [sklift.viz](https://www.uplift-modeling.com/en/latest/en/latest/api/viz.html) -* 💥 Add [plot_uplift_curve](https://scikit-uplift.readthedocs.io/en/latest/api/viz/plot_uplift_curve.html) function. -* 💥 Add [plot_qini_curve](https://scikit-uplift.readthedocs.io/en/latest/api/viz/plot_qini_curve.html) function. +* 💥 Add [plot_uplift_curve](https://www.uplift-modeling.com/en/latest/en/latest/api/viz/plot_uplift_curve.html) function. +* 💥 Add [plot_qini_curve](https://www.uplift-modeling.com/en/latest/en/latest/api/viz/plot_qini_curve.html) function. * ❗ Remove `plot_uplift_qini_curves`. ### Miscellaneous * 💥 Add contributors in main Readme and in main page of docs. -* 💥 Add [contributing guide](https://scikit-uplift.readthedocs.io/en/latest/contributing.html). +* 💥 Add [contributing guide](https://www.uplift-modeling.com/en/latest/en/latest/contributing.html). * 💥 Add [code of conduct](https://github.com/maks-sh/scikit-uplift/blob/master/.github/CODE_OF_CONDUCT.md). -* 📝 Reformat [Tutorials](https://scikit-uplift.readthedocs.io/en/latest/tutorials.html) page. +* 📝 Reformat [Tutorials](https://www.uplift-modeling.com/en/latest/en/latest/tutorials.html) page. * 📝 Add github buttons in docs. * 📝 Add logo compatibility with pypi. ## Version 0.1.2 -### [sklift.models](https://scikit-uplift.readthedocs.io/en/v0.1.2/api/models.html) +### [sklift.models](https://www.uplift-modeling.com/en/latest/en/v0.1.2/api/models.html) -* 🔨 Fix bugs in [TwoModels](https://scikit-uplift.readthedocs.io/en/v0.1.2/api/models.html#sklift.models.models.TwoModels) for regression problem. +* 🔨 Fix bugs in [TwoModels](https://www.uplift-modeling.com/en/latest/en/v0.1.2/api/models.html#sklift.models.models.TwoModels) for regression problem. * 📝 Minor code refactoring. -### [sklift.metrics](https://scikit-uplift.readthedocs.io/en/v0.1.2/api/metrics.html) +### [sklift.metrics](https://www.uplift-modeling.com/en/latest/en/v0.1.2/api/metrics.html) * 📝 Minor code refactoring. -### [sklift.viz](https://scikit-uplift.readthedocs.io/en/v0.1.2/api/viz.html) +### [sklift.viz](https://www.uplift-modeling.com/en/latest/en/v0.1.2/api/viz.html) -* 💥 Add bar plot in [plot_uplift_by_percentile](https://scikit-uplift.readthedocs.io/en/v0.1.2/api/viz.html#sklift.viz.base.plot_uplift_by_percentile) by [@ElisovaIra](https://github.com/ElisovaIra). -* 🔨 Fix bug in [plot_uplift_by_percentile](https://scikit-uplift.readthedocs.io/en/v0.1.2/api/viz.html#sklift.viz.base.plot_uplift_by_percentile). +* 💥 Add bar plot in [plot_uplift_by_percentile](https://www.uplift-modeling.com/en/latest/en/v0.1.2/api/viz.html#sklift.viz.base.plot_uplift_by_percentile) by [@ElisovaIra](https://github.com/ElisovaIra). +* 🔨 Fix bug in [plot_uplift_by_percentile](https://www.uplift-modeling.com/en/latest/en/v0.1.2/api/viz.html#sklift.viz.base.plot_uplift_by_percentile). * 📝 Minor code refactoring. ## Version 0.1.1 -### [sklift.viz](https://scikit-uplift.readthedocs.io/en/v0.1.1/api/viz.html) +### [sklift.viz](https://www.uplift-modeling.com/en/latest/en/v0.1.1/api/viz.html) -* 💥 Add [plot_uplift_by_percentile](https://scikit-uplift.readthedocs.io/en/v0.1.1/api/viz.html#sklift.viz.base.plot_uplift_by_percentile) by [@ElisovaIra](https://github.com/ElisovaIra). -* 🔨 Fix bug with import [plot_treatment_balance_curve](https://scikit-uplift.readthedocs.io/en/v0.1.1/api/viz.html#sklift.viz.base.plot_treatment_balance_curve). +* 💥 Add [plot_uplift_by_percentile](https://www.uplift-modeling.com/en/latest/en/v0.1.1/api/viz.html#sklift.viz.base.plot_uplift_by_percentile) by [@ElisovaIra](https://github.com/ElisovaIra). +* 🔨 Fix bug with import [plot_treatment_balance_curve](https://www.uplift-modeling.com/en/latest/en/v0.1.1/api/viz.html#sklift.viz.base.plot_treatment_balance_curve). -### [sklift.metrics](https://scikit-uplift.readthedocs.io/en/v0.1.1/api/metrics.html) +### [sklift.metrics](https://www.uplift-modeling.com/en/latest/en/v0.1.1/api/metrics.html) -* 💥 Add [response_rate_by_percentile](https://scikit-uplift.readthedocs.io/en/v0.1.1/api/viz.html#sklift.metrics.metrics.response_rate_by_percentile) by [@ElisovaIra](https://github.com/ElisovaIra). -* 🔨 Fix bug with import [uplift_auc_score](https://scikit-uplift.readthedocs.io/en/v0.1.1/api/metrics.html#sklift.metrics.metrics.uplift_auc_score) and [qini_auc_score](https://scikit-uplift.readthedocs.io/en/v0.1.1/metrics.html#sklift.metrics.metrics.qini_auc_score). +* 💥 Add [response_rate_by_percentile](https://www.uplift-modeling.com/en/latest/en/v0.1.1/api/viz.html#sklift.metrics.metrics.response_rate_by_percentile) by [@ElisovaIra](https://github.com/ElisovaIra). +* 🔨 Fix bug with import [uplift_auc_score](https://www.uplift-modeling.com/en/latest/en/v0.1.1/api/metrics.html#sklift.metrics.metrics.uplift_auc_score) and [qini_auc_score](https://www.uplift-modeling.com/en/latest/en/v0.1.1/metrics.html#sklift.metrics.metrics.qini_auc_score). * 📝 Fix typos in docstrings. ### Miscellaneous @@ -79,25 +101,25 @@ ## Version 0.1.0 -### [sklift.models](https://scikit-uplift.readthedocs.io/en/v0.1.0/api/models.html) +### [sklift.models](https://www.uplift-modeling.com/en/latest/en/v0.1.0/api/models.html) -* 📝 Fix typo in [TwoModels](https://scikit-uplift.readthedocs.io/en/v0.1.0/api/models.html#sklift.models.models.TwoModels) docstring by [@spiaz](https://github.com/spiaz). +* 📝 Fix typo in [TwoModels](https://www.uplift-modeling.com/en/latest/en/v0.1.0/api/models.html#sklift.models.models.TwoModels) docstring by [@spiaz](https://github.com/spiaz). * 📝 Improve docstrings and add references to all approaches. -### [sklift.metrics](https://scikit-uplift.readthedocs.io/en/v0.1.0/api/metrics.html) +### [sklift.metrics](https://www.uplift-modeling.com/en/latest/en/v0.1.0/api/metrics.html) -* 💥 Add [treatment_balance_curve](https://scikit-uplift.readthedocs.io/en/v0.1.0/api/metrics.html#sklift.metrics.metrics.treatment_balance_curve) by [@spiaz](https://github.com/spiaz). -* ❗️ The metrics `auuc` and `auqc` are now respectively renamed to [uplift_auc_score](https://scikit-uplift.readthedocs.io/en/v0.1.0/api/metrics.html#sklift.metrics.metrics.uplift_auc_score) and [qini_auc_score](https://scikit-uplift.readthedocs.io/en/v0.1.0/metrics.html#sklift.metrics.metrics.qini_auc_score). So, `auuc` and `auqc` will be removed in 0.2.0. -* ❗️ Add a new parameter `startegy` in [uplift_at_k](https://scikit-uplift.readthedocs.io/en/v0.1.0/metrics.html#sklift.metrics.metrics.uplift_at_k). +* 💥 Add [treatment_balance_curve](https://www.uplift-modeling.com/en/latest/en/v0.1.0/api/metrics.html#sklift.metrics.metrics.treatment_balance_curve) by [@spiaz](https://github.com/spiaz). +* ❗️ The metrics `auuc` and `auqc` are now respectively renamed to [uplift_auc_score](https://www.uplift-modeling.com/en/latest/en/v0.1.0/api/metrics.html#sklift.metrics.metrics.uplift_auc_score) and [qini_auc_score](https://www.uplift-modeling.com/en/latest/en/v0.1.0/metrics.html#sklift.metrics.metrics.qini_auc_score). So, `auuc` and `auqc` will be removed in 0.2.0. +* ❗️ Add a new parameter `startegy` in [uplift_at_k](https://www.uplift-modeling.com/en/latest/en/v0.1.0/metrics.html#sklift.metrics.metrics.uplift_at_k). -### [sklift.viz](https://scikit-uplift.readthedocs.io/en/v0.1.0/api/viz.html) +### [sklift.viz](https://www.uplift-modeling.com/en/latest/en/v0.1.0/api/viz.html) -* 💥 Add [plot_treatment_balance_curve](https://scikit-uplift.readthedocs.io/en/v0.1.0/api/viz.html#sklift.viz.base.plot_treatment_balance_curve) by [@spiaz](https://github.com/spiaz). -* 📝 fix typo in [plot_uplift_qini_curves](https://scikit-uplift.readthedocs.io/en/v0.1.0/api/viz.html#sklift.viz.base.plot_uplift_qini_curves) by [@spiaz](https://github.com/spiaz). +* 💥 Add [plot_treatment_balance_curve](https://www.uplift-modeling.com/en/latest/en/v0.1.0/api/viz.html#sklift.viz.base.plot_treatment_balance_curve) by [@spiaz](https://github.com/spiaz). +* 📝 fix typo in [plot_uplift_qini_curves](https://www.uplift-modeling.com/en/latest/en/v0.1.0/api/viz.html#sklift.viz.base.plot_uplift_qini_curves) by [@spiaz](https://github.com/spiaz). ### Miscellaneous * ❗️ Remove sklift.preprocess submodule. * 💥 Add compatibility of tutorials with colab and add colab buttons by [@ElMaxuno](https://github.com/ElMaxuno). * 💥 Add Changelog. -* 📝 Change the documentation structure. Add next pages: [Tutorials](https://scikit-uplift.readthedocs.io/en/v0.1.0/tutorials.html), [Release History](https://scikit-uplift.readthedocs.io/en/v0.1.0/changelog.html) and [Hall of fame](https://scikit-uplift.readthedocs.io/en/v0.1.0/hall_of_fame.html). \ No newline at end of file +* 📝 Change the documentation structure. Add next pages: [Tutorials](https://www.uplift-modeling.com/en/latest/en/v0.1.0/tutorials.html), [Release History](https://www.uplift-modeling.com/en/latest/en/v0.1.0/changelog.html) and [Hall of fame](https://www.uplift-modeling.com/en/latest/en/v0.1.0/hall_of_fame.html). \ No newline at end of file From 1811218606bcef5951f7eff0da5f2eac3026fdaa Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sun, 7 Feb 2021 04:05:31 +0300 Subject: [PATCH 25/26] Fix nbk (#72) * :rocket: bump pipeline nbk * :green_book: Add PR template --- .../pull_request_template.md | 0 notebooks/pipeline_usage_EN.ipynb | 132 ++++++----------- notebooks/pipeline_usage_RU.ipynb | 136 ++++++------------ sklift/datasets/datasets.py | 2 +- 4 files changed, 88 insertions(+), 182 deletions(-) rename .github/{PULL_REQUEST_TEMPLATE => }/pull_request_template.md (100%) diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from .github/PULL_REQUEST_TEMPLATE/pull_request_template.md rename to .github/pull_request_template.md diff --git a/notebooks/pipeline_usage_EN.ipynb b/notebooks/pipeline_usage_EN.ipynb index 018c9e7..da90436 100644 --- a/notebooks/pipeline_usage_EN.ipynb +++ b/notebooks/pipeline_usage_EN.ipynb @@ -51,8 +51,8 @@ "execution_count": 1, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:38:40.696778Z", - "start_time": "2020-05-30T22:38:40.692482Z" + "end_time": "2021-02-07T01:01:39.897817Z", + "start_time": "2021-02-07T01:01:39.890409Z" } }, "outputs": [], @@ -60,32 +60,6 @@ "!pip install scikit-uplift xgboost==1.0.2 category_encoders==2.1.0 -U" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Secondly, load the data:" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "ExecuteTime": { - "end_time": "2020-05-30T22:38:40.705782Z", - "start_time": "2020-05-30T22:38:40.701316Z" - } - }, - "outputs": [], - "source": [ - "import urllib.request\n", - "\n", - "\n", - "csv_path = '/content/Hilstorm.csv'\n", - "url = 'http://www.minethatdata.com/Kevin_Hillstrom_MineThatData_E-MailAnalytics_DataMiningChallenge_2008.03.20.csv'\n", - "urllib.request.urlretrieve(url, csv_path)" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -99,20 +73,21 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:38:41.739525Z", - "start_time": "2020-05-30T22:38:40.711390Z" - } + "end_time": "2021-02-07T01:01:42.438253Z", + "start_time": "2021-02-07T01:01:39.901510Z" + }, + "scrolled": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Shape of the dataset before processing: (64000, 12)\n", - "Shape of the dataset after processing: (42693, 10)\n" + "Shape of the dataset before processing: (64000, 8)\n", + "Shape of the dataset after processing: (42693, 8)\n" ] }, { @@ -144,8 +119,6 @@ " zip_code\n", " newbie\n", " channel\n", - " visit\n", - " treatment\n", " \n", " \n", " \n", @@ -159,8 +132,6 @@ " Surburban\n", " 0\n", " Phone\n", - " 0\n", - " 1\n", " \n", " \n", " 1\n", @@ -172,8 +143,6 @@ " Rural\n", " 1\n", " Web\n", - " 0\n", - " 0\n", " \n", " \n", " 2\n", @@ -185,8 +154,6 @@ " Surburban\n", " 1\n", " Web\n", - " 0\n", - " 1\n", " \n", " \n", " 4\n", @@ -198,8 +165,6 @@ " Urban\n", " 0\n", " Web\n", - " 0\n", - " 1\n", " \n", " \n", " 5\n", @@ -211,49 +176,46 @@ " Surburban\n", " 0\n", " Phone\n", - " 1\n", - " 1\n", " \n", " \n", "\n", "" ], "text/plain": [ - " recency history_segment history mens womens zip_code newbie channel \\\n", - "0 10 2) $100 - $200 142.44 1 0 Surburban 0 Phone \n", - "1 6 3) $200 - $350 329.08 1 1 Rural 1 Web \n", - "2 7 2) $100 - $200 180.65 0 1 Surburban 1 Web \n", - "4 2 1) $0 - $100 45.34 1 0 Urban 0 Web \n", - "5 6 2) $100 - $200 134.83 0 1 Surburban 0 Phone \n", - "\n", - " visit treatment \n", - "0 0 1 \n", - "1 0 0 \n", - "2 0 1 \n", - "4 0 1 \n", - "5 1 1 " + " recency history_segment history mens womens zip_code newbie channel\n", + "0 10 2) $100 - $200 142.44 1 0 Surburban 0 Phone\n", + "1 6 3) $200 - $350 329.08 1 1 Rural 1 Web\n", + "2 7 2) $100 - $200 180.65 0 1 Surburban 1 Web\n", + "4 2 1) $0 - $100 45.34 1 0 Urban 0 Web\n", + "5 6 2) $100 - $200 134.83 0 1 Surburban 0 Phone" ] }, - "execution_count": 3, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import pandas as pd\n", + "from sklift.datasets import fetch_hillstrom\n", "\n", "\n", "%matplotlib inline\n", "\n", - "dataset = pd.read_csv(csv_path)\n", + "bunch = fetch_hillstrom(target_col='visit')\n", + "\n", + "dataset, target, treatment = bunch['data'], bunch['target'], bunch['treatment']\n", + "\n", "print(f'Shape of the dataset before processing: {dataset.shape}')\n", - "dataset = dataset[dataset['segment']!='Mens E-Mail']\n", - "dataset.loc[:, 'treatment'] = dataset['segment'].map({\n", + "\n", + "# Selecting two segments\n", + "dataset = dataset[treatment!='Mens E-Mail']\n", + "target = target[treatment!='Mens E-Mail']\n", + "treatment = treatment[treatment!='Mens E-Mail'].map({\n", " 'Womens E-Mail': 1,\n", " 'No E-Mail': 0\n", "})\n", "\n", - "dataset = dataset.drop(['segment', 'conversion', 'spend'], axis=1)\n", "print(f'Shape of the dataset after processing: {dataset.shape}')\n", "dataset.head()" ] @@ -267,11 +229,11 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:38:42.307545Z", - "start_time": "2020-05-30T22:38:41.743319Z" + "end_time": "2021-02-07T01:01:42.579775Z", + "start_time": "2021-02-07T01:01:42.442595Z" } }, "outputs": [], @@ -279,15 +241,9 @@ "from sklearn.model_selection import train_test_split\n", "\n", "\n", - "Xyt_tr, Xyt_val = train_test_split(dataset, test_size=0.5, random_state=42)\n", - "\n", - "X_tr = Xyt_tr.drop(['visit', 'treatment'], axis=1)\n", - "y_tr = Xyt_tr['visit']\n", - "treat_tr = Xyt_tr['treatment']\n", - "\n", - "X_val = Xyt_val.drop(['visit', 'treatment'], axis=1)\n", - "y_val = Xyt_val['visit']\n", - "treat_val = Xyt_val['treatment']" + "X_tr, X_val, y_tr, y_val, treat_tr, treat_val = train_test_split(\n", + " dataset, target, treatment, test_size=0.5, random_state=42\n", + ")" ] }, { @@ -299,11 +255,11 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:38:42.330862Z", - "start_time": "2020-05-30T22:38:42.310277Z" + "end_time": "2021-02-07T01:01:42.600915Z", + "start_time": "2021-02-07T01:01:42.585066Z" } }, "outputs": [ @@ -329,11 +285,11 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:38:42.430704Z", - "start_time": "2020-05-30T22:38:42.333721Z" + "end_time": "2021-02-07T01:01:42.703537Z", + "start_time": "2021-02-07T01:01:42.603875Z" } }, "outputs": [], @@ -363,11 +319,11 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:38:43.630594Z", - "start_time": "2020-05-30T22:38:42.433041Z" + "end_time": "2021-02-07T01:01:44.020040Z", + "start_time": "2021-02-07T01:01:42.707311Z" } }, "outputs": [ @@ -402,11 +358,11 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:38:43.777122Z", - "start_time": "2020-05-30T22:38:43.632881Z" + "end_time": "2021-02-07T01:01:44.184968Z", + "start_time": "2021-02-07T01:01:44.047865Z" } }, "outputs": [ diff --git a/notebooks/pipeline_usage_RU.ipynb b/notebooks/pipeline_usage_RU.ipynb index be4a3e9..16892f5 100644 --- a/notebooks/pipeline_usage_RU.ipynb +++ b/notebooks/pipeline_usage_RU.ipynb @@ -45,44 +45,13 @@ "execution_count": 1, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:40:55.967561Z", - "start_time": "2020-05-30T22:40:55.963558Z" + "end_time": "2021-02-07T01:01:58.302718Z", + "start_time": "2021-02-07T01:01:58.298524Z" } }, "outputs": [], "source": [ - "!pip install scikit-uplift xgboost==1.0.2 category_encoders==2.1.0 -U" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "ExecuteTime": { - "end_time": "2020-04-26T14:28:36.188277Z", - "start_time": "2020-04-26T14:28:36.106561Z" - } - }, - "source": [ - "Загрузим данные:" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "ExecuteTime": { - "end_time": "2020-05-30T22:40:55.981317Z", - "start_time": "2020-05-30T22:40:55.976955Z" - } - }, - "outputs": [], - "source": [ - "import urllib.request\n", - "\n", - "\n", - "csv_path = '/content/Hilstorm.csv'\n", - "url = 'http://www.minethatdata.com/Kevin_Hillstrom_MineThatData_E-MailAnalytics_DataMiningChallenge_2008.03.20.csv'\n", - "urllib.request.urlretrieve(url, csv_path)" + "# !pip install scikit-uplift xgboost==1.0.2 category_encoders==2.1.0 -U" ] }, { @@ -98,11 +67,11 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:40:56.877657Z", - "start_time": "2020-05-30T22:40:55.985275Z" + "end_time": "2021-02-07T01:01:59.884250Z", + "start_time": "2021-02-07T01:01:58.315398Z" } }, "outputs": [ @@ -110,8 +79,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Размер датасета до обработки: (64000, 12)\n", - "Размер датасета после обработки: (42693, 10)\n" + "Размер датасета до обработки: (64000, 8)\n", + "Размер датасета после обработки: (42693, 8)\n" ] }, { @@ -143,8 +112,6 @@ " zip_code\n", " newbie\n", " channel\n", - " visit\n", - " treatment\n", " \n", " \n", " \n", @@ -158,8 +125,6 @@ " Surburban\n", " 0\n", " Phone\n", - " 0\n", - " 1\n", " \n", " \n", " 1\n", @@ -171,8 +136,6 @@ " Rural\n", " 1\n", " Web\n", - " 0\n", - " 0\n", " \n", " \n", " 2\n", @@ -184,8 +147,6 @@ " Surburban\n", " 1\n", " Web\n", - " 0\n", - " 1\n", " \n", " \n", " 4\n", @@ -197,8 +158,6 @@ " Urban\n", " 0\n", " Web\n", - " 0\n", - " 1\n", " \n", " \n", " 5\n", @@ -210,49 +169,46 @@ " Surburban\n", " 0\n", " Phone\n", - " 1\n", - " 1\n", " \n", " \n", "\n", "" ], "text/plain": [ - " recency history_segment history mens womens zip_code newbie channel \\\n", - "0 10 2) $100 - $200 142.44 1 0 Surburban 0 Phone \n", - "1 6 3) $200 - $350 329.08 1 1 Rural 1 Web \n", - "2 7 2) $100 - $200 180.65 0 1 Surburban 1 Web \n", - "4 2 1) $0 - $100 45.34 1 0 Urban 0 Web \n", - "5 6 2) $100 - $200 134.83 0 1 Surburban 0 Phone \n", - "\n", - " visit treatment \n", - "0 0 1 \n", - "1 0 0 \n", - "2 0 1 \n", - "4 0 1 \n", - "5 1 1 " + " recency history_segment history mens womens zip_code newbie channel\n", + "0 10 2) $100 - $200 142.44 1 0 Surburban 0 Phone\n", + "1 6 3) $200 - $350 329.08 1 1 Rural 1 Web\n", + "2 7 2) $100 - $200 180.65 0 1 Surburban 1 Web\n", + "4 2 1) $0 - $100 45.34 1 0 Urban 0 Web\n", + "5 6 2) $100 - $200 134.83 0 1 Surburban 0 Phone" ] }, - "execution_count": 3, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import pandas as pd\n", + "from sklift.datasets import fetch_hillstrom\n", "\n", "\n", "%matplotlib inline\n", "\n", - "dataset = pd.read_csv(csv_path)\n", + "bunch = fetch_hillstrom(target_col='visit')\n", + "\n", + "dataset, target, treatment = bunch['data'], bunch['target'], bunch['treatment']\n", + "\n", "print(f'Размер датасета до обработки: {dataset.shape}')\n", - "dataset = dataset[dataset['segment']!='Mens E-Mail']\n", - "dataset.loc[:, 'treatment'] = dataset['segment'].map({\n", + "\n", + "# Selecting two segments\n", + "dataset = dataset[treatment!='Mens E-Mail']\n", + "target = target[treatment!='Mens E-Mail']\n", + "treatment = treatment[treatment!='Mens E-Mail'].map({\n", " 'Womens E-Mail': 1,\n", " 'No E-Mail': 0\n", "})\n", "\n", - "dataset = dataset.drop(['segment', 'conversion', 'spend'], axis=1)\n", "print(f'Размер датасета после обработки: {dataset.shape}')\n", "dataset.head()" ] @@ -266,11 +222,11 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:40:57.345775Z", - "start_time": "2020-05-30T22:40:56.881856Z" + "end_time": "2021-02-07T01:01:59.976727Z", + "start_time": "2021-02-07T01:01:59.889576Z" } }, "outputs": [], @@ -278,15 +234,9 @@ "from sklearn.model_selection import train_test_split\n", "\n", "\n", - "Xyt_tr, Xyt_val = train_test_split(dataset, test_size=0.5, random_state=42)\n", - "\n", - "X_tr = Xyt_tr.drop(['visit', 'treatment'], axis=1)\n", - "y_tr = Xyt_tr['visit']\n", - "treat_tr = Xyt_tr['treatment']\n", - "\n", - "X_val = Xyt_val.drop(['visit', 'treatment'], axis=1)\n", - "y_val = Xyt_val['visit']\n", - "treat_val = Xyt_val['treatment']" + "X_tr, X_val, y_tr, y_val, treat_tr, treat_val = train_test_split(\n", + " dataset, target, treatment, test_size=0.5, random_state=42\n", + ")" ] }, { @@ -298,11 +248,11 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:40:57.360026Z", - "start_time": "2020-05-30T22:40:57.348343Z" + "end_time": "2021-02-07T01:02:00.003357Z", + "start_time": "2021-02-07T01:01:59.983254Z" } }, "outputs": [ @@ -328,11 +278,11 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:40:57.422245Z", - "start_time": "2020-05-30T22:40:57.365310Z" + "end_time": "2021-02-07T01:02:00.079199Z", + "start_time": "2021-02-07T01:02:00.009314Z" } }, "outputs": [], @@ -367,11 +317,11 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:40:58.579795Z", - "start_time": "2020-05-30T22:40:57.424949Z" + "end_time": "2021-02-07T01:02:01.332880Z", + "start_time": "2021-02-07T01:02:00.085047Z" } }, "outputs": [ @@ -401,11 +351,11 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": { "ExecuteTime": { - "end_time": "2020-05-30T22:40:58.719049Z", - "start_time": "2020-05-30T22:40:58.581922Z" + "end_time": "2021-02-07T01:02:01.476617Z", + "start_time": "2021-02-07T01:02:01.335371Z" } }, "outputs": [ diff --git a/sklift/datasets/datasets.py b/sklift/datasets/datasets.py index 173f4c0..0451482 100644 --- a/sklift/datasets/datasets.py +++ b/sklift/datasets/datasets.py @@ -444,7 +444,7 @@ def fetch_hillstrom(target_col='visit', data_home=None, dest_subdir=None, downlo target = target.to_numpy() treatment = treatment.to_numpy() - module_path = os.path.dirname('__file__') + module_path = os.path.dirname(os.path.abspath(__file__)) with open(os.path.join(module_path, 'descr', 'hillstrom.rst')) as rst_file: fdescr = rst_file.read() From b346fb3e30ea8f259850514d624928a1e66379af Mon Sep 17 00:00:00 2001 From: Maksim Shevchenko Date: Sun, 7 Feb 2021 04:10:10 +0300 Subject: [PATCH 26/26] :rocket: Bump version to 0.3.0 --- sklift/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sklift/__init__.py b/sklift/__init__.py index 7fd229a..0404d81 100644 --- a/sklift/__init__.py +++ b/sklift/__init__.py @@ -1 +1 @@ -__version__ = '0.2.0' +__version__ = '0.3.0'