From e9dff0f47af5e90d5600b38d2c1f62afaf3bac92 Mon Sep 17 00:00:00 2001 From: jsarni <45796640+jsarni@users.noreply.github.com> Date: Sat, 3 Jul 2021 17:35:24 +0200 Subject: [PATCH] Release CaraML - 1.0.0 (#29) * Added Unit Tests trait * Added Unit Tests trait (#1) * Started work on YAML Parser * WIP - Yaml parser * extract stages from yamls * development of parser * TAG: Parser with annotations * yaml parser stages creation * cleaned dataset stage * fixed cara parser file * Add LogisticRegression Class with building lr model * Finalized LogisticRegression Class and move GetMethode function to the trait class CaraStage * Merged LogisticRegressionStage and cleaned * Buildin spark ml pipelines and unit tests * Refactored CaraParser and added parse method + updated tests * yaml_parser: update unit tests for CaraYaml * Updated CaraParser adding try, update tests * Feature/model schema (#3) * LogisticRegressionTest contains error to clear * Finalize LogisticRegression's class and tests * refactor names to caml case and correct spaces Co-authored-by: merzouk * Started model training * Added Evaluator parser * Evolution of parser * Feature/dataset parser (#6) * first implementation of HashingTF, IDF,Tokenizer,Word2Vec * add new Dataset Features + Fix build function * fix CountVectorizerModel + handle Model classes * build edited, in progress for * add tests for all classes -- must review CountVModel to fix tests * fixed CountVectorizerModel Test * getMethode completed + all class and tests ok + indentation ok * fixed PR changes * Feature/yaml parser (#7) * Added Evaluator parser * Evolution of parser * Added tuner parser * Feature/yaml parser (#8) * Added Evaluator parser * Evolution of parser * Added tuner parser * Added companion object to CaraParser * Feature/yaml parser (#9) * Added Evaluator parser * Evolution of parser * Added tuner parser * Added companion object to CaraParser * Added tuner to CaraPipeline * skeleton for CaraModel * Renamed CaraYaml class to CaraYaml Reader * Created CaraModel Pipeline skeleton for train * first commit branch * finish generateModel method and add CaraModelTest class * review cara_pipine_model test * Feature/cara pipeline model (#10) * first commit branch * finish generateModel method and add CaraModelTest class * review cara_pipine_model test Co-authored-by: merzouk * Changed datasetPath to dataset itself * finilize class LinearRegression plus tests (#11) Co-authored-by: merzouk * Added evaluation method * updated cara model * Feature/model schema (#13) * LogisticRegressionTest contains error to clear * Finalize LogisticRegression's class and tests * refactor names to caml case and correct spaces * Adjust LogisticRegretion format code and add DecisionTreeClassifier model class's and test's * Add GBTClassifier model class's and tests * tests not ended * finilize tests new models classes * CarastageMapper update * update caraMapperModel Co-authored-by: merzouk * Feature/model schema (#15) * LogisticRegressionTest contains error to clear * Finalize LogisticRegression's class and tests * refactor names to caml case and correct spaces * Adjust LogisticRegretion format code and add DecisionTreeClassifier model class's and test's * Add GBTClassifier model class's and tests * tests not ended * finilize tests new models classes * CarastageMapper update * update caraMapperModel * Add Kmeans, LDA and NaiveBayes models and class's tests Co-authored-by: merzouk * added MulticlassClassificationEvaluator (#16) * Overwrite save * Fixed the case where no tuner is specified * Removed sparksession from CaraModel * Feature/model schema (#19) * LogisticRegressionTest contains error to clear * Finalize LogisticRegression's class and tests * refactor names to caml case and correct spaces * Adjust LogisticRegretion format code and add DecisionTreeClassifier model class's and test's * Add GBTClassifier model class's and tests * tests not ended * finilize tests new models classes * CarastageMapper update * update caraMapperModel * Add Kmeans, LDA and NaiveBayes models and class's tests * add decisionTreeRegressor class and test's * Add RandomForestRegressor class and test's * Add GBTRegressor class and test's Co-authored-by: merzouk * Global refactoring (#20) * Made build method for stages generic * Code review on source code * Added some scaladoc * Started reviewing tests * Refacto on unit tests * Renamed packages * Added father package * Publish to repository * Feature/readme documentation (#22) * Set readme plan * Update ReadMe * Update README.md * ReadMe Updates * ReadMe updates * updates ReadMe * ReadMe updates * Update README.md * Updates ReadMe * Updates ReadMe * Update README.md * ReadMe Updates * ReadMe updates * Update README.md * Update README.md * Add Schema * Update README.md * Update README.md * Update README.md * Update ReadMe add CaraML jar link * update readme Co-authored-by: merzouk * fix ReadMe (#23) Co-authored-by: merzouk * Fix readme requirements (#24) * update readme * update readme * update readme * update readme * update readme Co-authored-by: merzouk * Changed build version for release * Feature/generate report (#28) * generateReport fixed + modelEvaluate * fixed Resources files * Generate Report finished * code clean generateReport Co-authored-by: merzouk Co-authored-by: SAI-Aghylas <55828644+SAI-Aghylas@users.noreply.github.com> Co-authored-by: merzouk13 <57535044+merzouk13@users.noreply.github.com> --- PA.PNG | Bin 0 -> 103172 bytes README.md | 186 ++++++++- build.sbt | 24 +- project/plugins.sbt | 2 + src/main/resources/body_part1.txt | 3 + src/main/resources/body_part2.txt | 11 + src/main/resources/body_part3.txt | 5 + src/main/resources/caraML_logo_200x100.png | Bin 0 -> 12711 bytes src/main/resources/header.txt | 35 ++ .../io/github/jsarni/caraml/CaraModel.scala | 354 ++++++++++++++++++ .../jsarni/caraml/carastage/CaraStage.scala | 81 ++++ .../carastage/CaraStageDescription.scala | 3 + .../caraml/carastage/CaraStageMapper.scala | 90 +++++ .../carastage/datasetstage/Binarizer.scala | 35 ++ .../BucketedRandomProjectionLSH.scala | 35 ++ .../BucketedRandomProjectionLSHModel.scala | 35 ++ .../carastage/datasetstage/Bucketizer.scala | 41 ++ .../carastage/datasetstage/CaraDataset.scala | 8 + .../datasetstage/ChiSqSelector.scala | 48 +++ .../datasetstage/ChiSqSelectorModel.scala | 51 +++ .../datasetstage/CountVectorizer.scala | 40 ++ .../datasetstage/CountVectorizerModel.scala | 45 +++ .../carastage/datasetstage/HashingTF.scala | 31 ++ .../caraml/carastage/datasetstage/IDF.scala | 27 ++ .../datasetstage/RegexTokenizer.scala | 36 ++ .../carastage/datasetstage/Tokenizer.scala | 24 ++ .../carastage/datasetstage/Word2Vec.scala | 46 +++ .../carastage/modelstage/CaraModel.scala | 7 + .../modelstage/DecisionTreeClassifier.scala | 67 ++++ .../modelstage/DecisionTreeRegressor.scala | 59 +++ .../carastage/modelstage/GBTClassifier.scala | 81 ++++ .../carastage/modelstage/GBTRegressor.scala | 78 ++++ .../caraml/carastage/modelstage/KMeans.scala | 43 +++ .../caraml/carastage/modelstage/LDA.scala | 48 +++ .../modelstage/LinearRegression.scala | 61 +++ .../modelstage/LogisticRegression.scala | 62 +++ .../carastage/modelstage/NaiveBayes.scala | 47 +++ .../modelstage/RandomForestClassifier.scala | 78 ++++ .../modelstage/RandomForestRegressor.scala | 66 ++++ .../tuningstage/TuningStageDescription.scala | 3 + .../caraml/carayaml/CaraYamlReader.scala | 21 ++ .../caraml/pipelineparser/CaraParser.scala | 144 +++++++ .../caraml/pipelineparser/CaraPipeline.scala | 7 + src/test/resources/cara.yaml | 11 + src/test/resources/cara_for_build.yaml | 10 + src/test/resources/cara_two_evaluator.yaml | 8 + src/test/resources/cara_zero_evaluator.yaml | 8 + src/test/resources/incorrect_cara.yaml | 8 + .../io/github/jsarni/CaraModelTest.scala | 93 +++++ .../scala/io/github/jsarni/TestBase.scala | 15 + .../datasetstage/BinarizerTest.scala | 49 +++ .../BucketedRandomProjectionLSHTest.scala | 46 +++ .../datasetstage/BucketizerTest.scala | 79 ++++ .../datasetstage/ChiSqSelectorTest.scala | 101 +++++ .../CountVectorizerModelTest.scala | 49 +++ .../datasetstage/CountVectorizerTest.scala | 52 +++ .../datasetstage/HashingTFTest.scala | 50 +++ .../carastage/datasetstage/IDFTest.scala | 37 ++ .../datasetstage/RegexTokenizerTest.scala | 60 +++ .../datasetstage/TokenizerTest.scala | 21 ++ .../carastage/datasetstage/Word2VecTest.scala | 73 ++++ .../DecisionTreeClassifierTest.scala | 105 ++++++ .../DecisionTreeRegressorTest.scala | 90 +++++ .../modelstage/GBTClassifierTest.scala | 107 ++++++ .../modelstage/GBTRegressorTest.scala | 98 +++++ .../carastage/modelstage/KMeansTest.scala | 66 ++++ .../caraml/carastage/modelstage/LDATest.scala | 73 ++++ .../modelstage/LinearRegressionTest.scala | 77 ++++ .../modelstage/LogisticRegressionTest.scala | 82 ++++ .../carastage/modelstage/NaiveBayesTest.scala | 67 ++++ .../RandomForestClassifierTest.scala | 113 ++++++ .../RandomForestRegressorTest.scala | 99 +++++ .../caraml/carayaml/CaraYamlReaderTest.scala | 34 ++ .../pipelineparser/CaraParserTest.scala | 239 ++++++++++++ 74 files changed, 4183 insertions(+), 5 deletions(-) create mode 100644 PA.PNG create mode 100644 project/plugins.sbt create mode 100644 src/main/resources/body_part1.txt create mode 100644 src/main/resources/body_part2.txt create mode 100644 src/main/resources/body_part3.txt create mode 100644 src/main/resources/caraML_logo_200x100.png create mode 100644 src/main/resources/header.txt create mode 100644 src/main/scala/io/github/jsarni/caraml/CaraModel.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/CaraStage.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/CaraStageDescription.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/CaraStageMapper.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Binarizer.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSH.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSHModel.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Bucketizer.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CaraDataset.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelector.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelectorModel.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizer.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerModel.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/HashingTF.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/IDF.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/RegexTokenizer.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Tokenizer.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Word2Vec.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/CaraModel.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeClassifier.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeRegressor.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/GBTClassifier.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/GBTRegressor.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/KMeans.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LDA.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LinearRegression.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LogisticRegression.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/NaiveBayes.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestClassifier.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestRegressor.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carastage/tuningstage/TuningStageDescription.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/carayaml/CaraYamlReader.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/pipelineparser/CaraParser.scala create mode 100644 src/main/scala/io/github/jsarni/caraml/pipelineparser/CaraPipeline.scala create mode 100644 src/test/resources/cara.yaml create mode 100644 src/test/resources/cara_for_build.yaml create mode 100644 src/test/resources/cara_two_evaluator.yaml create mode 100644 src/test/resources/cara_zero_evaluator.yaml create mode 100644 src/test/resources/incorrect_cara.yaml create mode 100644 src/test/scala/io/github/jsarni/CaraModelTest.scala create mode 100644 src/test/scala/io/github/jsarni/TestBase.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BinarizerTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSHTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketizerTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelectorTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerModelTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/HashingTFTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/IDFTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/RegexTokenizerTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/TokenizerTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/Word2VecTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeClassifierTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeRegressorTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/GBTClassifierTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/GBTRegressorTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/KMeansTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LDATest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LinearRegressionTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LogisticRegressionTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/NaiveBayesTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestClassifierTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestRegressorTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/carayaml/CaraYamlReaderTest.scala create mode 100644 src/test/scala/io/github/jsarni/caraml/pipelineparser/CaraParserTest.scala diff --git a/PA.PNG b/PA.PNG new file mode 100644 index 0000000000000000000000000000000000000000..181f2c4f59a72b377058d9a89c2a4877ccb02885 GIT binary patch literal 103172 zcmd?Qg;$hY`#&s7NF&`SiZFCZr<62EH`3C>Fmx#0NHZ|Bl$3OL2@HdTz|aiR-HpFF z&-ZxN`#-#E!D0=w?tAv!dtcZ7)Q;3pQ^3ce#(DJU5x(L(+4qkgJ%K-Z^!U|NOw@nI z+TEU_{-A;0E4+PF38&pgy+F5>R+WDAs5&0^?gIwuHMZ+J1JI*K1ikG%%d0b zP^srHFrP&bd9uH|`_ukM5V8FQ^Qj2rd$yS#bFJ;XO33|Q{nGgZ1-dlR-=8KXMtIQk zr~m%A(#S$F{(Ey(AVLWDzc+iFh=AmOFCz+>e~x7Td)f1+jnaJv|J{?wIXI&Bzk5Dk zzb+q{P)?dYBD0(li#t7mMW#vt$ofj zmC_?Lmw&Y4i}pje3j6O|h_T8-Ly3oC9ar>;k8Xjv5RRWmuACB{Kl(`npKnlqVS@bk z!0Dec!rw-|6cnuD_m$DFOx!S@s)x&E$u!&L{=RN3_@wpN)g-e1?_~89Nc9=80IhyM zLZkTntT>rlE911kIos!MnrgNr`yQSVNpR+&T#$3t7DVM-SY@BO%lhPshO73dJ*5x8m$@V}CV@^^k1+a))+U z{$)6g9ny<#S$MNc9H6I;x`*Nz?6b=inWKA*ylYI2=ie2tB#!z92IdVyH>});Dx8M1 z8SnVyAV??!15bHPijZx7>7-8GNmB?{ywwLIF9J@$aX|8K*7^9(FL7Xk@$|Yk=(d(} ze~WSzh?*a6T=q$V7IZwJgy@0rKQMNRh|_NN^K=R@p>^)Vsyn}DbC9~2*7)3uC$*1j zz?b{9^<0xtP64U_TPq42%jTQI>>rvmIdN@4&vt{qA4v%jb1H@N{ySVRT2ztK1Cn)> z*hlIJ2OpWy4av336iq-YteZPV@#(;Xg$18S!Tg>bF{uX>$ECJ7hC;@J=7;XITB4C- zKi;+w8{z=v1>DI+4h%UND}N*R){W8Ggl$7i(HJK`p)<{={Stv*>^NhHXdyb3rC0;9 z{c&#NnSW6JU9es%{|taCdA`g*YVS=KC;8SPHve}x*O=~cLZVHob)Z#BWSEEEarLeo zKe#O>tl{Goj1Su^J8E!~IqB!ZHqKm*O8?KFR^`e~5nmjXa$SBuKYB|*Aa7%N3~`J( z9Uk~(iP!=sCj@tA3#b213byWU{vHXJ`&nrGs(2IGQsWhMm$j0x7^0z<=8J^j$CVWYG(r1Eop@1gS_Gye(s9u|3=XaV7YG{I~&C?I;Q*&vN@fXDf z-K%VN>)9ddas36cdhByI3TqBIly$<8PcBa^j%3XFeZu-a^fYV79JK~{z(3kK`QndO zR22;6x$}#pQ{ba!vl{$5gC5>D*Z{felOWkQ;p1MCr1MaudaC!WzIundNn~r;&;ze=9Cz5FItrJee1SrS)hj}AxR!Rt+1xu zSN2Y-tU!@9aeVG2Mn!yK{`s-CpARF3Q(Mxni=i!|FCnJ3Nk;P%f~3w*RrZH)D_f*_TN@g8A%4in4q;WtYC-hN2( z&+6ig;~TQ19Y=8kX%akFbMXj!GPLTe4R_i(-p9zJ`arRc6Xh;J3B{|?Ka3mD_B=RW zm62d(9K=mroJL?Na;tiluvHuh+%~j%VEfzel08s8H(V{O=BK6NPsGMeQEXS8&}OUy zoMdy?&M}ZmmG*s#5OZGlXG(43mGDg;I_X$&huu1HTznQ%!}gDy_Iv=DPGeEtT!V5+ z2M5M4fq~WDH>)KLoTOk6x%_I?HsW3MI#nV!TQg&7~dU;5fc^;K_oVc+THP`!pVl5#A#c zuBBdGcJBVi*v~#EIBZ(jFc+cP`jF?yFP0wr8mso_o%w4+S``J{vR%6tLHzn@W@Icd zz^kG7rM>f0sh7KyQw!c`I*rEN;h9yr!viBFZ3mB+j50pW3}CGeIUjh{l+i4uQ^pMM za;?bJo7V5oXjsmGrxrj@>Mx24rX}R%1FZ6=AMRFWSgG}`S1C4CXd__qhBnzklQ~IT zw&p|KPyGZJad`kU2```g*XF8GaKdX#gGU&owPkI7jxfd(ONg#ipc38!iD4DAgTC`~ z)7&z;xw5~&>KH<}M!~y;ie-~Cw<0%hh4SVy%%>KzOG2)RC1%~JbOemw7S=0eq_KRY zr-%vsvlA8Q?ye0)AG;8ZywrQeR;?S00;&qcU4kn@1G=s+miRQWE!hiR?IvF^XtK=V&)=!St#@VBm0{NJN> zYBPORDiRjg8ICnJ9)-~mj@npII9q)v@ML6-yX;~MHr51La_<~OOIKt?mNdMALH{Ah=dvXhBYuhM$G^X1)kES@EF&>y{Qu~N(A-0{@QnZs;B5{d1k{wmG9Y1rWlxPqwMzT}yVaqme z4<4(;zx49|iuJAh>Dy~zG_Qtu(d=DcQik$K+1Lu~l4d@tdXM7#izVEaQvLg23KV)` z&6s5>XtpFaY-U~ojS2aAvtl~k=ezrXS#ff5BHlV&+PQ02Rb6z(^fzd3iH%{Q`omfC zUqE(r^&Dv&l!v$61blLQd@SUB^6Fv_5iA!?{$b*)VmhxiPAZ2{nE&m?_Kbz4CGh^{ z{Qb+H9|dcwPwi;G;R}c9h27@&Aocy4B&3I2@$3(uDx?%XcvW{s!}3EdDGi%tbL-1+8lU^;d4{6CSq4jui zm;+6fE>UA<=kYg}&XBoz&iwlWQg6PW&Gy(3kyA@x$<_g6?lGUvhF1Kp?5&gWU7Z zgLP|1C98@(?#3$X(Qw)qPR?16 z-n?yEIUgU?aJe+#mmm#$EwE~5Pm{+$!+;}K5S(Y{Sdl$1Ul3^`Mm_&3FAvPR!_XEX zqMN4aFh({t3&hZAH0{Qs;aUp@Q)v1dh}p+n>LB-`bGP`dfJ6;{>oOiiIGBi(K<@7R z0?rF1q0LGutX`Lkp1Aprh#C=wOzrcqULwoAuv} zuU0WJ9mS%V1Aqg&Yr&cUKrvQ!<+s;z$6l5b!eZn7bYph`k6qLW2YsX0l2sH|>#K{8 zwLLxCc<)HRmB-2d!mlnFzRsE%MBW!ZV>uoC*e2Y~a0nNP9(F)#f=A@EwStVnMy5}A z=!~j%!Yq8gmDN*d0(Eefvgkquv9h~daPPmk^3Wl6?*39QFFzt6=ZEi8BErH3%AnJY z(fa3|r*KijI5xe?7!mMJ?#Ia@k)7#^vQS7BFw1Ryc-v{Z!l-yqav5isZ5?X8f@f25 zSll;1Yv$wA62Pt$*Zc=be=4ubNh`xuaBeJlcT2(CCcY1KzUaTsScZ^=(!Dns&W<;mUIq`H2GpD-;Q9k|@MnRq$y zPdmAN2efT%ZLW7$K&XU2H4hI@%PJ*JEE&0( zO;wd~bAD!GVNuaellyj}X-@8%Sl(4RnA@wosCOzAZd`=7NLOYP$q-V8k4qR zQhq~p-!92xO%D&E;n9;p(cncME155FfoQ>hd3n!TXaop5D3OYoI4I$z!jg8j>c@kH z=33{+@`~2XPQxsGlL~RP(x|5aZh#=RPu52YKJJ>tPo1b8z6>!lB(ezsknz z9rXMjt~U+gk`GswekViBcFP^&12I$umuZQKZb^4F!iXRY+@ssW&H|%GXA${$dVFFy+sleu_PD7~V&29NrJbeA6$PWM8eCc3J z5jLloB!K~?Q?FlTdNZ*D)0HM=wG#`H{le_*c#Ex`GgX~`9&Sav)M(Yk{jOZ6$_=y* z*9zk8EmwO&4coj`XlQ7d=KVD~rDe2B^r;S$IpRG>bh<>>mu8L+d{Z>)s1;17a!)O|(f<;V z&+p0C5L^BulzjjuwJr)@xi1{|^7IfgrTiWH#~s-1P=J7l5+8El43@n_=)jR~=*m9pkWw>5N%h`u94sMKPZg*f`k&Q)}_M!>O}vy z9zO>u@hr7~*;utpa^;edFt>dII+5p$jQP)T9dQQ9Zf|aOVvg!QP96<11e$NaAhY!j zX`JmFq&m!M(4R<$y|kLU)o_OVfcqN}R^4)aS1}y-OaZ5rot)xSZVaOS?z+XL1 z$sx4`o_`+h$D4p_ch-mBtie;>?6QuIv|_(_0&kj0SoN+SSub%Ehg^$mKjgCRys-<3 z-w}}NXVp3OK;U@YE=IAKm11_>mf{*OoDPq?e9w2hJ88JZ+fISNn8xcTcuqU{~-jw~dZT4jvS zW`51wtj1~{cy+{+EamtT$6jDL3^|v-YTdYbbUz1br_%(VQ;BoAk2)okv~c1a!ttiF znhlmlF)duw)RHPJPqC{q;>}^6~wC<1(`A^An%8I-Y&9%?N=whaV!L*SbiR(&A5n_vbU| zZyd`)fT^~{q)-f zhLbB|8ixZuU4EOA8w+^px;?CeK4{iUIP$hmsFNSbzm!E1D_9&t9-|XrJMAr=16u#3 zSkm?yJ4Ls@U~jM1t0kPxl+u;-a8xwdv*>1Js4~v$V+r%Oa-nd;`2ua(f`MtuMZ3Lo z;6sb$WtpW~!CeofwtmqCk05JRw?2hn8~*|dMMQTS1%|AZnhqY%g(G_Tx{LBGTAb?85uM6lGhe*7D#naK$cdjo#pl_3 z8*wuO7~6mnQ!0`O=45`!`7vd|2cJOenrusm<_B-$rEKz|!kg_SCAUE=WV1x})tF1A zm;YO$B{TYXRtGH&YZDySvXZ96-SAy`3-xoc%0j5y!(5X&ScOn*ei_$Al~AEsu6m~) zWH0WSOtl?~CU6|lmcL~AOu+Gnn~x1aVwKhNR~M{sX4Ap?GvC(L7yoM6Z-@TD!R8UC zUtY-I{GR$DRO@iQ-v@Cs(x$8e((>%wkIGrevFIM&&Ob?bfeT zT2i+W6o2v&SkobU6#AM`|R)vU8=MVP6J6scstIK0c5KYXKHQXVv~(P2hU^@%wE z)XV3uwi24x;bo|Jmb4g;j8%M5^8FPxS(?J?z9fp*3EFByeIApDStPiWVqt7 zwcbQd)U$JpTyzZS+xye635J_TgYY;_+8c>||8hws0u;PPx3LG5SPsT~9)qZCtI4q<# zQ!rkKQYqlQ8?U3I(|ECqB)|Ox={lSCTZrdKfeNZ&H~Pu76(IL(4$Cx<#&Jkv3Q3Rc z@GqcMArfzFNv^YAwkO8!;k(1ikB|KR6nGWQXtwd4LOI7OYdCcxk1(`NebEl9l%ydB z30^gWJa=mf3U_5mxDUS&7%pn~zY^>>oO2^T9KGn_6OYT?Fy?}tMh?dA(Q?3GoP3#3FCIGS@sC38 zyw5BJG1hhg%*P0Hb(JL#^5VB#t3F{?e`SvDiDy907aDGWCS}z2r)XtBJR0L_VPTQ5 z28F2FG@sv`?^Zh^c5az|nC!pUL5`;>1bI>i#y{LXQxT~M|ehk*rH zS>AWu`xrC7~(RR+N3&z2?U}!R-D0h#C2&f{h{~asMfWR$>j10`E5` z#b2k~diwdVyuJ%Q90$`qT%SH`6YF6hOC4}%esG<%)?W0%_|;HYy0T`iJ@2D9wrFgv zinnA&8H01`r|C3G#XlQJuIi+%U$(X<7F;tKw}xvcjiE#jc;3s z`hc)|kl=&eHtFY7_~v*IfOwQP|7RB~v5p?r>Y}7t{(=gFwh=?#ZxMR%B}z~1w&Jf* zlqP9?#V?u|fzlGkdzi>MO!B8Lp7{ih?!!w5HSWP$nXXgIG;xHwS^XouYAemol~)$q z!h093AxXAe9}1mb`EXtgHU@eD3@&;fm$^2vMiNAK7AZF%#8lk>VqYbc@WS*|O}HP} zA1arY^Zd6lQz06({pli`k1A0 znoNxL7}nVVCq+DoZKDL@M`IcRVjB%k^BTJab6$;(!s2u~*Eol3w+sP&00y(K0EjKQ z-^ld-Rmp)_zpzH+>ChMLx~u@4std_T&xfbIr%&3BayTnl_})BXaEh1jmEo}qctu@d zTyPZW`$|IChrYt(< zk#>F_(0K*6&vCl9(bATgLQ}O$QWbQHSFZky!|=#1zV`UFZ8+SHx1aFIOsJivjg2>~ z$QE_>3rDd3_V-Xylh<+nLB2_6zTW{t>Dr@WQ?yL zvbzv1q}7L_xdqB~_`raww{q@x+qO(_2_Xim$RyJxO3?t~r7~?Ym)f$1NTD2Tf?x%6 z-i{nF4nypv+^FyeucA$!MYP+BkE0=0Tstoz4Ap&-@dX}w2GRcf z(8Lwp%5kNPf)tIxl}XELr7t>O1KU?;USfvLW#?H*4`Vm`wd;keb^$EzN%F^1Ck+yVc|EhvX%lNgh4Xis}I z%$(Gsz$#OE+v(RgvKII4e7OeK0`Ww7XJ)IRvKph$iO?X`F@TnnTs~N#jR+I+tt*vl9qkyZ%($x zGx0h)+RNVfZ#+%pD#Sl3Md<8xenV1A%z6G#xZcRdo0=Vcrx0thT>_u=mchF0_1D$8 zwZS^@w2?z@qjc}GvX?FFXZ_DQtrqu`c}McBa z8ZVslM&2!Q`LAq)FfWRc$0K59F-v~rrxgXq3odXk>#2`zjjyD!_vQSWggMMIstRu6 z#I`wT-*}YH`pZP}S_`YEpEB}Ep5!=0>Q*abH0s<=5_g>AuvREZ)q8hSwu#~eXzIC& zXHBqA|MmalX;Ae*G)y(faC?3I;o0M^V5V3ZOty=Ul4`!<_#u-M5*NQsw%QvXY|!YwZeCT(btJaJ1ksIV}|O_YGuhL@0;#vHUi19xx?^F;KQ}8J4%#VYytOv|ImBpH=OLKhOgr_ z%o>-uVLXz&GG3sthMMfj`5Nuata?;ijqB=?T7>u>t#pfo5zs{mgEqDt-{d{5J-zsm z@DiNFJAh)(M5Lr&$Evvpc%1h4czj^`?%Q+tlAb#;%Pk&*CF5(~9WG62B?BI_=~Z-p ze`d*TH%o~}_l62LwIcWsqA{DLBoz)>+N$Xye4?8z-Cmu2<>o7taTu?V@al1g*D9loPB{m3gyg8@x_r4 zeHPEvX6gR2x~lEv_^mrFZBo?>zHq^p+P^#1{{lvOLRlyW`Y=oFU_wHIkncq~XHHyn z^dw>(nM_Dyuvb#T(#}B>OqLydE!}Hrsx=rB8QYHlZ4wp64Q&bwH%ctKTNiVpm0tM_ zEAlgd@#t*4Ec;OHnm}bhDo5WBEcRaT&VE1P6L}@TZo>;Zc5hN+d4qb#z)TOgwjl(K z^AXt5l=C-Y8uCy|q)U0#YAVkbAANOa#oKYFDrLkpxQscFy9lB5Bk78eH~Pckw@(vl z=0{vDuqP74wZXV*$XRK%!5B=O75HClCfOPFg`P&S@D0OGP7-)cXg>;YH*}^s9b(as zoTWF4I@{RsVGzM580tT5|XVI6m}eR{a9 z@3+rTB`fsI?CRC_)dZI&hq6Xhf7y95EmnWNziYYA?-}p0=C%r-du65{Ie>(RvyjA( z4D}szwls(6{NSdTBBh>rhM_*I97mMXCY_tOmF&J^m?~n+G~Pz>oRQ<-m0C!GN^i&} zRNzfXB6&Nq{T{3P+g4zRu-vdZvxv5TNx;AS?H+U19^?Td_bpn%bzvIpz`hcBZaTun zH<jS$99QJQe;cGj=t^ACPS-cMiN;5 z=C+ncBt7K;sZA^)9Dr7lY$fQaI=MS7o*p_djktnIU`e)am@kwXJK|ymaY_$2{hLwA zOGYJI7RcC}H~95P4J?P*IQh7hs!wGF|L`y?RE+G`oC)~Bf$#wb`Rk@?li_u|^6L1v zIo4>dN2+h-Q+YK>uJ||XIWMxuq&qEz?R;O+j1t-w9DH{4vr1vWFxxDt`a=yST+ba^ zrS)^n3AIt1IUb6PwxdY@bYZ)8wAeh`_B2vv{u6qgL;yDazOcNAjDk4RLVb$RuI2B; zs-g-uHY*=hdrMTk@7eLqqQr6}wZ|^l(gz zDM3-JIOq81KQ^hTs9LHK{n2S>Yv;RyQzBH}y9-Tu4eGt?BYIydr$;p0M&0(=W@^9q z0Zf!nD{30OALH+d=0ra;6}H<`V(8o+557sIVMas}HE99G8M~3#IH7QEC+degThVJ#csS2Iq#9HkGJY$e#Q8L2b-rF_)cR?E5+E>cGLRnY0bnn7T4nrS?`Vv zj90}^UZjCO&}Bu3er~3$lx^v>B?yqrj$V#mH}8rxjyZw%`<>5yU~1S*-eq3Yh@YwX z&cTG=+rx=;7~`)ydDpwSL~&H@OKs?D+QZDunb%=bl)5n&uALs?wrXdd6%9XV{*H<% z{U5drc^?{4tKrBp2z#xS0T_dpL4shL#@Pu8rKu(?KV(6|g(4jL-vR3+nv|$TL_$%OYSx7kUFx`(ISliE{i%~({=}I^p!_6=Yh7+D#kkKn_#k8y+{D>9 zqosCCujE>l6XA&1n!CY*+OM(QD&8>i7qQOyL(-ywMd zN;L*?{%e?x%|#dxkcgS)?dl1C{URCDtY22p150x@{%rqxe!By?A>Am+CZ>lYZEEa5 z+_v2gjMqu)Bm7rSxix~5Vp(JpIaAO~31+3x)ogw@0&}o>{a3BoM&+Tr_7q1RU$Cv- zF92vbQiK}CQp0>23`+dpVV~sDPSp-g#~Wsx*BJAQL8BYgnkfscyggsJ@-k5XXHGZ% z^FQ<4QDrPP&CLh(dZO=5ZfBkrYg0KhKDCayGsmK-8)U3Kr8lwAO*TuJE!dE;1~Ir9 z^%qB4HUH-_K0>+w)nOpcTa!m&-kbRctF%mG2BJI-OsXNG z=HIK5eNYQ`Fr!Xo#hm}=5|vU^UP!Z@fz5Yp{odZLDh0GO^ljA6@_*1W9v5Yd6VSNJ zDJZP%A!;$6(bHEJe`Zy6<+PhDt)eGY6#T^0Err`E2JA3F4fU@13HKU5kv~TjTjTSE zNGOzc@uLJg`(Dwq7N=#c_=8w&9(`cP^{rpkk>@L3-uH*mx9f*EmqY9S_h~)%FOZJi zN66X1K`a#d>WSv+i1^1!EHP^2M3K7G&eV08Ei&uNEf34F8l5SFCy7WAYsJ{V9+T}K*F`FH!V6~&eG;3hk(A@r6BS4Gyx6MEiEk0cmU*`_ZOJ~3ym&0rk=ye z0H66dI?;TT&Hj{t03O)rNmv{w0!G{NHjb=4&l#U3-$J78MZeEkJvTNUg3*kj?2?mB zohG9wVq1c$tL@^PvoSuGSEJeDMt}THF~?iLyGQG3W;@FUy76Lv?oQJMoHA0)mzvzb z;9okg@9x(RyYghh&qiN8I8!5GV3c25da0Oa+Umj4afQTr9wqnT zPSR5loA59`x}O%6FFTC)qUbZ#Ep{VM-!=Kk*oM?5e_D?JPQBR&bJKrMt=kfsKZ2=V zXYe~{V<#QoW_A~Jbqd{^5}qp<+Ix9r^_g#u%X6?V7)^*gwY+v5_7)WA7R$VVjhf;C zB3n};b*PZ0ZFD-7O;IYvEa@i7wWmWRFvb&y@rb!5yruWo*9(I!hNIm()PP05ooByG zyUaJbn6;sN|L@h+pHZZ`Re8;_QOAvvcj<5JyL+r$!~9>FxphDyV9gp zU}0h5L^9Ghn32|?<)^`EWPt8L|6T{_HQXgxB5)P33gt+HE1X+w!{ zzr18XGS@IzH9wA6hqFrfmweUWibt|j<|MGi>c*7!r*Yc+_q$tUJ)WZ`?*rm3g}=(0 zkX%;Mi*bhIHk~Pr-Q(Sx9cb9zg|usrHOH#FV)OF_od27YNgg-~_DxfavF`>RM~zRM>~KZ8P^zz4s3-w_<*hdV9DSTM?_Iy*$} z_cdFOq~3Ub3BwQ2J>T9K}^8-I%{aybMBOu!szWcX0E5;BM-qXD6dYjMR_hQjm5 z*|9h>NwsWTB(rxT8k85^i#(wzlH@{RTd>3To1k=ie@t<~oGFNjO(CEv*}!?;t0k&0H{W5$x4N6;BsJB#vY~ipIx^WlGm4KZ2vAPC z0nZZMjkd9Y9LPfezI|4X`TpB+Hk?Ln*ItarC_vf(@eu^sJ8pNaCO?l*3dol;GV zjEr{Q|5TT}*EXw5LPqc{Zzicrl#VD~zo7vvav2Cq&61g`+GTTt0&J8{!4P{TFDXi- z;~Z^_wDkIw%@T~YQmA&K;B6?5*LsQ`PQYb`pbG)&o=_nmvH=P6uTcuNUMxbwa7Yfq zZ&5p^W4DC&TC`;r3af_<;^MGkoVf2X;VrHiJ%@mc=kw|lCzl(2KBV}v_~mo$ytlO} zG}wW~a4?|!kB^tlef)bzQB!LpEirM5OgwGt@YA!k{}W;RYIgY=41E<1>@<(sk21D6Fo*vFDR8I zy;X2TY8~U^;tX?hypY&;D5s#s7>eMrDTk~hZ&xlB1TPiQSd^fETXEluKGgjD)84TX z!5caKcuZrZ+fuUBM1gcZVzqber1Ye6DfXG~lZ8$_69v;}uSO;(wFp&L4zE5x!8&s& z-^q2S!S8B{7mWp?yq(j_TGdQ}*=oyh`cw8veN^&LtV82HX<)zKVpQyagfQe)|w?=0m}3_?2GK&Ou3uT96P+-ypOF%3HhAs6XhM|FvtY&Qd+ zpOeb>A%dEXfE%IQ$|8{M^&6BYAX3}M&dt>l%?5A#zTvJ%8w``TbX<^oVM7I2-JL8} zpIKjn@oFU_(6BR&&cYl?oky{^xPpANFAl|6vy2j@_3WSh)rY1>5l-E8N zQ#p`>3J-Xr;)pFbLE=wl-^Dy0J^p#uPATm675=VvIQZ5kN$i5Ai2}HBNaOMj1y+^P zY)`%J?$-{*0x$np(od9_m;@NtSuqb!#u3IiOf;YH=htwnPD?SUkVuONw%R6`dAuZ1 zE)x`i3{%opI6RfHd!HfUtm*1&X=D=#7=;R3ygw4MV) zy7|52>uUAbpD+XEkm^S9bCn>_(rbos?L9_{uHN@uzq#ynq~sfv4~t^#;%s-kkbAZf zDpHtX;(NXD=>!H^lb{xG)IXw5)A}xKld(A(efjgJhQFV{bIV4vCGM3bd$rw{PnoD} zfCw_*ovBhoKiuABnn`b{@Ig-E2#xYy$XKS4CNxn zn}kX8>XFNPACBcyNYltxdw-cZZ3E8+#A1}a;W_31!XC-bSvra=|7V!4YD#Ik3ulH@ zr8aE?J7GN!Z5NalTqZLUew~TK`w(#(0DtYEx0W1Yr^tlX*SN_l)2vqZ_Df)Sw!Bh& zf-aBiAYf7FR@cD3r`^>zTW~!MVL8dYL^!h=;Fr&tdynI}c%S)p3{WS8RiEsLTlDY8 z3!UYebq4r64YuBoe$ndZE}u3jP!HWiU*3ww2(phTdhxZW+uASd zwuR0(oY&_Si}Y9-49`Ud+;*)H1YDOdT1rF1xSTjwIa@xc2!(XNw--3#jq_x?5*Zn8 z7U!ET-D&5!qNfr|<$0aKJqh2ia3t3CcQdX19vb(;hn2I^EmH)nPqSnBP$23ac$OKtDBtO<-fCnt2}RQHFYQ_V0-L?)V0qj2N?u&K(c9E4j*-QPZJCb z)v)@H`$_fTi)~WkC%gUj5E+ff?<9+&yl03oB=LNh;(P zpC3ZudHrI=ZJcvEP?j1}`=0xgg~?w&$>x*hX-?T@Nd^jMv{O*^n_4;ejlX8pJcsAZ zX4k&Avlz2Y58^&E2W@7R)bMr+#91uH$2N9O*|Qe;->F2g*BXxijmgqB34jt9CE=X6 zQoNImO`4WE5P~+NjzYc4ysb`rY^VrFvSR?M-H;Gst{6{{aWw3?JHZNJmKtvOS4?TQ z{lFch(ML(#CE32m?Ls=Cb$i zNK@MZ*88TzrQ#8XRo19bDIabh;*-H)T$%XUt;Nx6wD{6>g9?WQQX=mFvHy=W^b{k$ zW>AH*FuCR(8bL%m`V2_%yD&-k6D1Z;FFE_j3W4kI$09$C4YT%V{KRznESk zc*=pwM;tSL2E2xSPdx*1->RdJhJjwtcDAkRhrne|p@8MPjBXXakx_{720mKcuZ`G8 z*?h${Eh$3b%x3*1{sA{dk3_vd2Om4I0^~hxk*o9>F}YVGBDHYPsw!4MgBWwd_C)-D z^O4M(Zcvo_{$Q3WbFp;L0ApqF*WgjV1;t`%OTU=HqHA=`kZ>XZj&Cbsz8m>YJax8< z$kqmWEwbrtW9ty-l5I`#b&yp{JCnuRNrKrW5bvP#{4ulmdXi?%=*ojo|F7Bm2N6pX z1t-brVFuuiSTlXF#p1_(V7p6Yubb@ZGRZQQm1TD+Z;r ztRNEbDUS_8E?Zd=CE8$as>ek}O1r)zQoHt9D+w2+rHk)^!_|;zA?@wr#*4FO}^> zJZ=iO7wPHkT?v@N7xi=*%ot_karuR(V#<$Muz}a}WxLaIWs08iIdGZB)m|Mh2f?tm zNjMl|omB03&*+G##twHP=;i7nL>&S7@u%|tq_AY#Eql`UHj;o(4YtU-DV`)1fPhjy zAj2`We?t$J9njoI=0x6_d;WMci2YdYkcA@`bAd~_4dEL7Snb1|cEwn3vK!T)kd}uq zeL7-cZ580N3@(}n06V;lfJMpnKwZWFHlV2*Q1_OT9S)uTwwapL;x{)d$+vH$2Ax?; z@t$090Mm=4@-F=JElW#v%?=0n#giV8gXq8;(8G9ycL!mBF3j=t^h0Xqi7_Y&=e+sE z0j}!L&u@?xqLm$cO8UR7Mf%i~HahfTu;c#ryKBEOUgiWt0Xace)2}Doz*DKaYTKta z284rR#hUtC67op2zQ;4;=}w}=lb3|Ozny}4oEIO(m`D)aGoSS(U$0er@4lwJ6f3HI zVE;^v+L_U%S+F%816nteUE$S)x^A{r){yrK2F{&5CW7;Z2h=#Xx#;5XYrkO~6JAxt z!mXOG-bE%vEq`LBn;lWWoE`GqzzHotV1J#K;u;(G;uqH93-v9kkI1LaqDYENQ29_@Y-y7yUj zTJpK6yqP?eu+6sIy;f^^hPa+8yTN?BdXJkLi^_8{2?&t?!x*N#Nqz?U5ylu!>qVYu zaCpdJ=k)dBFXd8p20X%4-3`^83^!lTSU)C|ZAPP@i5%hD zvC&Nti!di5IAB$PIvl(32`c>Xqxq;-38tI-XV#Rmu}IQ}^`p^>Zrx8svAOh3A)KT} z!hs`Uf1ZyULQQnw{(6U*lTJ_&Q&Lh=FpZ$|mJ*>ZX-H`33x0lWBt0V|<6|HY*upF$ zk16_fST6Ya)1Ka-Knp;!V^KfB1PpaNTUf0zjaccn3q6aYs0(O9cRiocV_l?4*=~G4 zMl|1(_rNw=`8DFSz003cll11zxnwrhGS*0|FxGke^`!oBt*xGm0NWa+>m;n*9D#}& zbGP}o^VlA%;AM>~@WPmcHt4BB$472I{JiRzt@ou!OiDWK3=U!ft`C!;V_~hK2#Kq! zD=J-!_1$GzVsEY<7p2w=q7>p+2QRJ=$9xN|o?NKxH###jGm0TTUR_;vD^MgtUqyTS z+43TZX@G<gqBj5e5-O5c`b#ZelDk>iQ-9ezFrf^pekLezjNYiFF>+a3g zfP0_C=4?SWPLr0ewRb4I)zdD6R#*o#=A<#y%s}j43o^O#P*iQt;Px`f^#}h365BpW zwXXH`3fJ+*M_je9P`al6u>GCp|9=a+`}8aG4gP+trt*zN2mftNNxHYI5T?rZX9UPb zd80X`m)8+>mSS(YlOa>kW!hrU(N05y2)VWfo^em5P!?-iv8AV@!#6WC(>F4*L}{Td zflvD9P(fYW`+qQLYdG})aF8AApzXmco1aZ_y=|!bI1Oz3aq{*$%nEeJtK104DC!14 z)7u65&H}u?BE?3~#07HL?XLQ{FS>X|6p_s91bR+I>RB~NQ3th2?6F7 z=jQtwr)$*0cqC+YbOe;)_OK3wd8SS{D#u)N7L41f=sC_dNyl%KDv=`)2%iPs{#XO` zRIA&oE7F+W6fbw_$c@oxI#G`;yw2$8Xq8ixbK^>=u~ND4qlbL#)W-*qilxPf_ys|X z^=L#P#2mw%a_MoM`lQI7ctCqtm?~dj>rMHjGQsyKA9gX-LrTttm##dz3FhSn3foo| zrsQ2sxAehrc-d?W-k=xC{u0;48n49QD)Y}x1jd?k1NFhA9-oh!?0LBw%pnm%nGnY%f>Ziyaq-&O_M}KJOr^N9Gce&T^ReB_7cbE0 z>FHOLL8Pm5 zHi^n>@5IiHTmZ@Lve40|%9LsBCNX(|XQMQe#vw6^`noCzVakGHf?h3 zUcWL-wayXsJm7lF{9(x)Taa&zeRTt$U}@_gUibglddsLNyDx6|HUL3FNs$r|0VRY% z7+OT&mJWjskpU!!p(PaQRHPX|KuVgSyGLc1k?s~oI)={o!2N&T^?Z0-i%$-$IoEa0 zK6~$9Y-S(`A0*Wm_uBSSd=LGIIA(1X znNuRao&`_3t)xykz`4C9EAspq=emr$g{(aA98IzT=B-OD36j2%F}BH!WVgEBD1#Yf9w} zgr1fBlJ6pAEu>=I8r`_AUxLI&VyVBQ)!+;6^J5_p zjrF&l^rG(+|Jw0jWedf#W$`4B0u3JmDkjX2(4n3tM3EW4+hiWHo742DbLw=@_b%qv znb$1a{^1_If{!m5dYqGU^XNCLL>=#CQ&uPd-c(>oBjR;DdS7_-T1s&RWM3Z>Y(P3N zGpH%W+iDXTNGFn!v7f5Z;MoY`y?oLAYNP(_;bRC^O7@re(_b(6 zn}>wU?4u7zc(Ir}ZlDnzUq~1aqp3G@oL9W(W8`Z%<(S}m_i37-Ea$Jnn;0b8s5V`t zIdO9V08d>b=ZTM#|C-+uOF5AmOJzjIs?yKl7rxiXtO`Ixf{;8D} zPo@3D_qtSo=>PE*I}d`bn&Uj5ue0Em0!^7S$6=qt?44a6@C0BoDotjJa#S*Gb%+mi z%|j#TEki{*w#*23Iz|#Am7~}Pi^mz~I_0Y(QuXT@BvJ;}>q`2!G*bGRq*~3-Qgbgak%#0+yykq!*k|hjU%)W(h6QIHsN(J zfrHD7R_WIW-VM|?nhl&fJr2oYjmERzAp?Az+0aV8l?;{Y6VN5yVHJ%EgCZ~@O=8UQ zjks6sBY;OPdZix9EbVhQ+m_8~th8G(ArzritRhvPu=?;gwLMYD{nt8BS>?P*VPn_R z(}TLM+!D1NzB7U1hwe-NiHK@$Za!Ww0Ck6HD7%kMR3JmlTa=3HA3Nm=UL=w_q}5aX z*`n>yofP;QYCuJW75{NThC--;#qi^@)*UsKKH*wcO%Hr~0kT)5kEp zFFqhhOx^MJC?d zxu!DsI&M8qtVN;Il^Lf(YMWa*Q?8Q!%(_;^O>2yfDC(x~q6E^9ru67Jt*hr0vu!)GK(mg<7eoj7anui~X+b;p8lF|pF zxXg7?!HGNJ1S7(NyjB__GH!J_!TonNuW6W^Bq1kLdzqm$#FQN@`OleAflE)tYi@QnG)9QDixeAxjeZyD5;w&T4qej| zYnA%w$V-yybQq=MxwlkzP1KqSugYG7!v;Z?G&P71;U;oxXEiwn#F+ymXKjH;Tj@1q zNJDtZw|w6aM2Iy23H@5?ytbJ!Flr#KSDXtTTxHiZ& zcFcFZjp<5#dXIf#oqcU5c9g*U#8EEAVN=#D{Vup1h!^;iB1mmW%o+(PXfX)52^sb{ zvEgZwXW@W!d$FsYfvjq<*rXT|s}PdlZ$VV|7PXS9hxhmQKVCnlWSBnoRh{!{;l^g} zcyae5-)3?-jr}C(UnZPv34-&~mbiPXc8mcZ-McNy$0Z=#FN*ng`{)}K!lVLUkm_ZM z3|JQGHSD}+&Wz|*-DU_g>T%5*GnoY)t=e{^j-1xNDjCoqV2Tpl;B(keLgP`)Z@h$X z9o&-YzNcv!doYsFsxz1H7xelL8(yT@ex%HgY+X~WPra;CVhdLD54u5pLcClzqU9C` zb|LJD4qw!Cb`Q}j!!bTFQR+0W%>Vm*Rr3KsK|ulyc;xtb*4nMP$@8v`7(NZ9)_^I5g@z45T&#r^K0E2&!7@qhAb(3X zaKN7x&M~>j#Xr(5PpvQ^hA;3u2pC4ODMhByND-mc9(H zTc9m988er|ULMbJ`6#H{9mPU2#T1U_*Y<7M>s-gIt;vIHzD1$EJ@50bH7-7t+f9g~ zg{#9AJSufse`q_&`>TB%LoM&_D~-QMPA<#4J)dG;evsgt9|=S17$zN#AJ6l{!nte}nX4%7owDzJbsRgH%~|4oKWlk8&_x zPj7X9UOGak#|~@rdGse|Oz%heNZeN{}vd zKKbg)w;*xVJktA9>RwU{o>Gpq+DlqT1*+0dwcl}0cHkczXLI0Cm2uX0qr|I`r8@(N zhnUX@$xt29>Gg_J(X6pco~*)`H9r^O{v7OJMn~oYfc@>%Hz|-NWOW8yXZY+ib6H!P zz_BBQ$wrCa$|nVUgSfMa^X`S}3>`61r1_^rqEn~Re5||nw63Ds!MLN;lk2JQN3BX^ zJVcu>wMMriUb>CU z>Q57J98BDSA(&mV6XqA{Nn7@;{uPZv^tn6GA#)PfKaczo-)+%vJz(5K;C<@sq%NH$ zsCb%;M`=pj07o*84@*0mSSc?Avk@fM-+XW=xVEkNx;a|?R&Dl!Y<-G$!nvdCMY1PKz=^tu=2$*F81t!z(LtZ0B7LA+CxaJ1RDyyft}6UGn`g zII;T=>RZ45)m|t#e*OFDPhq0Sh&&$bN!7c#=YNR!s{@J^_m5u!yjhgF3xg@hgEIYf z88|zo_7I$+Muie0RQa(OO4QLKeCC4X(@p*xc(0N9v+I*ztaIn3sEU@IM76}8nu}M# z+x)oJiY!6cxq1`d3vxEblBe+OYAk{^pj0_QGUDiVcVqs?+ib5|gCZu|NT}Gqix$0? zNb?CU_I3E+qx{X1N6*r=E*J4G;o_BIOzVTR?^T4c%h)Bos> z|0#EVlkn!fhe8J1b*0HQvT0#^H%WLSOXn_TRQnEZX5e;`3etm;)>2e@PKMhctyAvo z)`U^={9hWXNj_}VN1F}9{YmJfffDIP{l*BwYR$o2zgfX$L?dDHs>ASoQc9U_xa^|y@mOGLmtZ27(@I$3i0m*+wh2B!RSm|H#xH_WO z=${BE~RA!!^-f(AJW}NM%Zp4+;Y?e^H@WM z^Fse8MdMnKOA>~Q@X^0tr)H9IPlSih`GwT9CyqI>t;7Lw=$;yPkG!-nbb?-%A(ays z#dbwypxENga<~ixPcDOZIvH7D`$RP^@Y36dbN6s@|Z^E{P3g#YPeO>nh(n5{?E$wopV{d?LchmAFZO_KbG9M`nVm+F1Mdn}yJ zzfdDJ>SPBFe6x0kIPbh)+#lAlsJRpC!Cc4?WrG_t+RbJ{6F$m*{H~q#cyF3TA9=8k zo2k=0jgsH@thnwo;!I1yFq*kk(B{ch_x@V@y)0S}W)(p12We!+Oe_&9Gsj#oeeOP0 zKk{{EK*U7K^)jSK{@VmCSo{iGe=T!Vmy4c0lH0>o-+sg}T>D@5#n-`*A#)mjfW5wz z^`O>${)g1kc*6#d3emS6@e1obz0*E&<7KF@q>otA{MQaKUVbB$1~{dHkq2C|5xeCw zdDqm^k#ip@Y!Bq@{ZMs}%;-f5^p;;r+rhEBd#HV9=MMQkoh&#HzPhXFUGs@2Y?WiL z+-~9Vg1W$z64p%J;jv$9&!e;GB6TwRW#4t0JHbMh`ODsp(s~U`kHxPlDv?@^Gj%qH z%C?8(Mtwx<1AHI3)81XSt9{gAnDbd7b*c^Uue!kBN$f5fUdhL)Yt+lMkex|7Gn_gM z3m7Gx{P`ihfAlG3-+>I#D8dwlkwQ5iEo!~npsF}M)6sJMB}YDvcp`T5fn7?~g+pX$ zU_3o@aUEtJnJNa{A0EiLkSqUgHQ4|4F?y9bIv6CB83SJ2Fn;~P3&0ORYCeX)1#mNN~_TgJ-A=bp;PhRiH%AHSrgSIR<$vDaqqJxZf1MC^T3hBjmyu|X@=;mTD!J>KD?9_12&PN?HnC?L<&`#93=eBnET0{XXKM1XF9?s z{?S&-TfY6A9Z6l+i@SzK3!FRUrW^^ukf_9jV3nDf!_prvYeE4afVDQvS!;9IQ-gUUtY<_&wXN-jW$Kjp&TR<3-mDoTfLF`@D*OP;bQx2?gsx?9 z_kx84t?vw^*?_jg{@e!RL_~7hqb68fZLYZ^WBxxrFVjf!|Jfg5R2$t-{6&{Oe_r13 z=^%Z(81OlpW8YjoLwQ73JhL#@_m9p_1W)(YeczdGw)dSqA*?T(h7SD0g>BHP_<5v{ zG&p{40t{WLRl1>RVb9tf3?gS3xjM5sj@`I0f)Qg;w^4Q*BTn32b)7FlO0UZ#*#r&U z)3yEpBTiaAt)gCxN3Y7`R?+Zd+we84QU4~ZGEZUyEmN3^kWh84w`7^kV1E?Dm+0-X zu)IB4m$-8{x78$l7>?Nr34YGs5F-gQ7Gu6X^!n$}b@Th!)mT&ggN~)}M_ga#;=_JH zQQL`qkF>maoB6`*3$7l%>~U(O5q+0BWYLf5lp%-1_R)s@efFh!40S;ebL?FIi?o)# zr3PJ+cDnkgcP-D6ybE1M@-D=lU!$EwPm*9X?w|~-40&a58mJ#M#h?h_rfcDK1yf?O zuUdr9>kN8+Y%%bfm9}(g4s(zSJ3CD~>pUkNN|)@TY0iS~P5Joq9v#`hi|zqI&n584 z(A-(3r=R=W<$sp2xAOJ)xc$0xTsCqgf81}H_7*Ab_pR!y3a=ZKuGKz}D-!9xea75q zP>drNQK`sb-c<;u-URJldvMKf;}wr3b67o&n>dZ%dC=)+wrPwZzvaPlVungZ><{he#B__f?`$zoeQSml zRNTzq_oOHlV&~B@Gcoy#RTQu!4`#CYwqH<&VAQDKlq8xx=UY}AC?K&CgcBCB;FB;Y z0m8dBa4U+f=xJg^BM(+8>hsd^ej;3Tzr0NuIr4AeHTcWqCB*m)U6x86FIKi__qP~N zwagyB?mZCi9=9Lto-3XBqr^e`l-Q~M{MUS>pBLGD-M4(#*^+!7T!B{H*4HCdoS{HjK7GD z%^Tl+b|+(BmPEVJJ9UZ_Z+x~$ozgtXMqYfs(~T|qYq!qvzXRLx#jh62S^w2AF>#28 zE48Y2)>KC@QUgYKXx|oSf)xEZ9B1)k@^c8!z~~u(pV)sda=uLXBgL37a3Vdj)^QyMK}C z?JTk9tFyd$WD_W2U?6>3^J8aS_QQJkjEDXZ{$!+uH(R3x|y;$n}SKL__SCQv<1*m~J zK`>YT2*v4Kmc;+Kb__SYb8mI^?6LXQ>7@iS$W?-oqFHz}0;q}9b>pBW?aV>{O#JS; z0pm<%LTQ|2G6(1S|KX^5#33xh_+Z>m(=M)C?Pz*vU0o)4(bXq7Sfh72`}>>%clNaE z8!0c&Sr-p?t&x>?WRZy7ttW~{yFd09n7)k+-^nzRd{g;0pi4$Z!2uEfzxYFbX<=Kl zkO7)mNo^@w`1;E*4}oHKIB z=Yi=VQ8)Y0XDvN!FpW{VVYt z9HEm70`lj+_A|wrjXY*CLXl}%NUasuw9v*(T5TTQulW;g+wK!Lm6779L)z_$c-%=z zOp=Zi9i^x+M7XsR51QbzM$-j3!LveZ+l-8kENh5uA6e7J>g-ET^E-B zpjt2Z>ZSPYn{~}MUF}|;mLU-AcL#erMxYw3ZMs9(j2-gM$jVv_cXK-u@eFauKw|^- zrNuHHtoh#2GBw;g@?e=XF6ewoYO_)}3tCWPz7nE^tgZ>d@=Oo*ycoECAN+51k4Ok) zVQp7!*rbCHS&M_psf6T@d1#Gz@tLmRrD)mD;!4P66(3X?aXJJpt*=$tSNF*4o>VSWL%I^iC4Ku*{ba zTa-Y1Gf;~DBbnw&*Vb>@UbYNq^pai(DMX*RlJn3O0E$XZb*m?bJ|6iKAhWnenR-Oi8ha9!roQQysLQ5sJ!tKK@u9HJ!+_Fvo1N`f{+3J{ z;TXL&xW=gd%ZSgbh*yxZ>4L5bql#~(BSSwLeDrf2T;|R0V*wSnX8hC3KFzbmYNqN& zu3!jS1=Qx)n)>9g}(2cW_Z`9qy%I|Emlm?=lKcdzlbW+i%yL zQz9-lH--;hcUq`fxn$s8H*2%UVsXDAkc_P%lgleL+KKfQ$Xh8vuAzAMJ}=C5pP#F! zQ+K}UL22Pe&?8S<*~ki|k7i7ctOCk$t1ZG(>vejB>U&h!)+OWF)CSe*_ zmbG;p;+oco82^YzuXNCG?oO}EyUm{Q{yfER)bH0F9y-WxRn8AvsegRo&?sHt)%rR5 z@Y=%r#;}Wx&vV;Y@1eo3&GNZ|}?Al^^YI!@>#t;p|vj%1(Gs%+E(kZ6#A; z#4mlsNOBxz`_+~Fs6S;y>Wp_W7Fm)4#CUgPx5Y{vN2&%B|BLaSF@2dj0`qyf@;Wsp z&iEUYAiepn^v9y@>2Y`Ek~dom*^s7T<7d5%Nun+V(7%acg|h}yw#g`E)y{7icMH20 zcX}&!yKR`wk^L22l{C7HoeKr=utmEgk#XMasD3k=+DQ4~N5(gyYk)=j+c8~Qq5vTu z&(Hcd*S~s^gQQ;i;C=MeguXj)nb8SxD_%`afGr0ts6_sac4!c!JJaw!+1fwJl^LV& zW?<_newAzy#(-Y!yWG9Q(`V>2EO_~uGLr4D^NT&kixX27SQeKWTZqa&=ZRTFWQ@4x z8vht*Bwc{DEaqb+EQ!(y3P~U$VVllJtoDLb~b85jUn`)qq?z_xla392o z=*0v69wa$15Saboe0+NGhJ<5^eYz-l@yV}|1R!MsT#m(jKu=ZtdN>X}8JIv;XRXbw zDtq=nsDh-dL0sfWet=$+IIxxZ5FtJUdKz7Tp^WGwFd#66dF714i#HO!qQ$5t=RdL^2$ego?Dk}h>lEVd zNFBV*qp@uCk>)gruSBWrt@=&WdU&PQE%I(0WGyr5t^ly!qvM|4F{`7DwW~B<*Sg5$ z7)7sq|NOobc|_oaq=nyO;%zT1lnDZ+6V^%0(@rV0yeUFi)zn-$%hk19)P{FR8 zh=Q`4ih7Ug9ApdM9Hh0m+K9tCYx?d{@xjGEPF1=CH@Pv|mH7TfiTl=*^DdO^J*2@e zo-oHF{hD*#S2QnA7(d%Zy)}w_w*Vh2avf0Xf(|B{e-D#bS7P~ZZv@5P!tmwXFEzm5 zEUJ_B6&ZNhMZkv{K0Nr%=+Bk4=%V|&fKyUX{+BwgRN0ibG~9|SR&m3lz*G9Pm%=C& zvl{m9Q5&M{D*T?;^iciFxkD*2PN!Q3T?e&`5*QB1nD4z)YT^}W0AP{~)Qm16PMk`^ z1ibMS6m!zo;{dhz{Qks27+Y*B|+k+>>o)LWe2~_=Cq7RJvC9t{TWAy79 zlKz6P>i$xW&`=#s&v7X^6DM;RdsjxV%Cl(Y8i+Ez59qx2I=#<&Vj%hGbPPmrdyGuO z*$Cz7wJ?507SLq`oP{;=VhE2F25Aqa|3KWg>VkTh`6i*eZTL^<1ZqSsNv)LUci^2Z zo6yoVfkOWz;qH?Y-UtsrJws5E*^tiKlBraE0o+F12NNc)LBsecsr7n+alLELs{Iy4 zEuG^`EE97^|Kg6Xb9FPb`6zft8aCz5UHd z>ku=WTGPh=Km6XuVkFX{G+OP8HOVLN0gSGvC7V&rm!=%UWgN-Y?<1pkjTmn zMO_c`v%H5Zd!%iAT-D~3sjX7ze;TCmRtv)xuus4r>q?dOa@M!g3LR0VFM3BonLjy5 z8f2n2WtToP{%v`u!SV3^jNn)T=S8L-O41R@s0oV}XX)L05Cv|>SZCoQOtGFaW`&1q z8^AT=@?YRmQbg)4A;W8QA&iwD{#I^YrI$<@aMGofqJ)$NIwl9QhX%d@Z3L{O;^b+K zY@g}9l&%`-;`;p`^Br`SsdFO9kI$9NAZ0Basl}x?7yug)n$LHab<)VG)cqh$IhgV){jC#d+!NbW?yx& z5Kdg<)5V3JH;efyrNo`x%*{dB(i7tO{rmo(=M!pmBeTFhDw(C6x7_X})I81Y7RTI| zn<<~d?{HQ5yp;^zsKShN6rf`^yk_BxX%W z(L=Dl!-q4r;TK%`td@L=P|mD)g1Cl^^KHBMnnmi@>Je|=q#qz#mC_e7<76wsiyvuU zuI}Z3-eqFuF5V{87o)FU>U@7|VI!h37<3YdE*luaVPYGzp%9`JirUEgi#eF2z*58q8S{_?|`OT_$VFT_~q{>y%T&Kf=k88qWm z4@v#zBIrvM{MikQ#on@*kFev$&VF)$Mg~4z_DC7IG$zSMFJ1L+=>RECoTRtp%QI3W ziKEP)_*jvU?D{La0Oj?r;@yq!-IYCM-8(k8infiDNvP93?Wh1zY3~08>4_E<%3oY6 z5#j-~0nsBmSZ-C1Z+v){DJ+e=d;-PIH}R_mA&`LhCz8gb>r~40i36gub#!a{&qDId z9sS~fiV2?pgn0<&M|q>|YOS9?&6&B} zNdwKmeqc0V4T@rlg`!lT9f|0M6e#Luk`M`mXyk^~9K^ijIE8BjAYnG?vK}%D@i4-; zyK2qSgOqc!Mz2U`V&aS*W!ttcp5!JXCigZ13WyE?vznvr2Y#Sr!#h!hw8C&V3ihsb z(sN;GC#Kw3(PB1+Lhi-!SP?W2FKp0b^7B9CMk!*VN!=X@3O?o;be^jl{mTmX?k@8o z{HSMx_wT0It(EO0q>_c1BNKa0y>qw?KXSOxltv44#a>))4J|5E%FGs#gzF^jhJK(T zE|9(*m=}aI{bA3xJ-HMB>X@(S1sbdGSF1+9wbdTG#~FPRI1nHY=o3855O}uW8KXtE z4K(Q6UVyZUXYSpMclf(G2)O(^hCh(FVIUH}xml0BAWAe6fkq9=IvXmyb6=uZHkBKz zY>Xc!Syq*pACX+M64` zPaIh64G$&=5?tD?519p{1s#H|( zW$zr*sud!85A*mi;Boc@&SN)=`B%N>E956&dzi)=+!~&BW92K7DCf zSQ;yLxB3*9#r^w%|2^Zjd@3>)h1jwmLcjSCuq%8pH{RlO<%hiQG5bE@N9!$h5P3|u9&rZ7KW{kim1wm#~vaDEgPXmYa+q+ml4 zsfRnYeS|kg8WZ1)EjRWX`o-aCkHAzWfiOI_(}>nGRHox(V-ae^(SYN=ZNd*9uoy)8 zCh0kV zPSN~zjwORu887C`?za3?fGnLjH)7^i{g#-jtklz=h#oZ0`6RPh&D#*H?d{6pd2$)O zHUC4x>bCBmP-W*c$m=M3+TFCzby^;WX65<|!Eq`qJ!TC|adpJ6S9?s$pLd<6%BIlG zAFTc)+1_0l1|FYGf=#IqVTveprRpOv9X&Pqt*4h6o_I8)Yb~=SuJT>H zGNBdYy_Jx9lh;ao>Y0iVss*VW)ueQcZV24J$me35>ImN6MP~%|J^)>bl7v zLZ+mAiKDvhJXk3RsF|ih*`l23*&|VJljEc`67(BH6UGU(l01T#2rx`xsN((frY|A@ z4Tk1HG?O}^Znux<&VgxVm#&OiyA16Kr`$6I7jnHFgO>gISwO1LWL8dr=3fg9Rf<|g zZ_W;Td-_S`}^VIt|P%lL`fI^E)@6QBLMTe%c=H zG%1&#WmWpZ`i-SBD-Uw?)4HInhbBulYiD^{@3x_Db0D8D61u3HilUFfE>;Ow0QA@@@clVyz1X?5Vn0s)0*}pU~#ahG` z_nUlejta7{FcafYTZ-idZ!_Sv57kn75;ZBOEX1x9_N&rWX}B%d|qB-ahz+sE;IK|bwqZ$ zs8bvu_#NpYA&9r9!!z1j3Bh*VLG9{pew^N#5Q1_dG^S*D$WvC%p~aC>}(*r0`;I&L0&2C zmA;nK-}oggfiQynuMcVU)v7G-WfzzZA(AvQen83YX;=!UlP{G{^Hhtsib8Fn6Ha`@ zI%4E=W>`Gry54GOJ`s2I_&lfApA2-pG^p`0b;VOI8r7a`XB6)(T!1jHfG|k&*|UE8 zNzM%;WBc2Pm5-o91HC*aFcs6#$RhP^ZNrZ4D^u!=WG~&Qu!Z48gKi6csnm-*mjeqz zX%t=}vKjgMYL;7Lz6c2ot5nuAY!Kbniqxla9s zACsdD<(C3)EuSU1W2K*voV44GIHu(&V)GO-QF;VrsK4FgtHf(EAx=6}GgHc7(u&iK zk1>X6P=@@Rlv$q^3beIiLYROdam8k z){zdP5h7e87=dK6l1s$_Jb04h*PA084E)`+FGNpXi`j&hEyBi06jDa!Df6Yae%0oT zPA_NKJ4(j;cP;HpQL!jHGSV86xx>5iJyaW`vapsec!x#@%01!D>Z==3L(eUq{lWLl!06&a+fIE}fK zWCuUCT!8r3*>Cg}S#-as*Z3SuXwg{YBFsER?44;cUeBAa?=jpQ!jCc8Cj4w!Oh|T|&qKu^Z zA~evcp~2bF`}P;4?ybQ=-~&?i1`i{yM$G-8)MRd~205-v0?~-C{w6zQff9tpNtVMUCG;I zppiOcR07OVM5|KQ{VP~!JR7b0O1lHnnIXy2fDIazxloBAchl_c_Q>O%(Em#qYNCcsVjbG6PCAsYBd;*Ias72#0_fb^hUe};a?=CsznrJWN!Fxlb@bz^>vo*+{@0#&?prn7zrX9q0|f40@C*>-5H!m>WQ1aln$qS9 zxDcKGuzHnYNFR(}-r3TbDOHZle7N`TKezeIhvwqM)dotujiEjQ3)!})EYPh6eVojI zc)l3i1FWPW2B)LB=Wm*JBNk*SEUWzG^iZzb-rN)_Th;LYE(i=!b#qe4(Q4e<_` z$>x%wySmWQI5hB3S64W$7AYU}0`N&M%}H;L56>izX(zd4>j{kcb6oKAc(9zJ&PpRx zQ|*0%$0Q~@r?((>L6a}sR^E|f_oS@!EbH?lkNOE7Xz%BV_MoWd|JZvMOa2QYY3VlN zq>3BBEM`MV5UVwnf9nr;h*xAn*uD)dD4zl(Dd-uW`Os!IAAMH}_Y@ISh!$XTW9 zZOwM$7|pR3QqEOWG62UnX_B z0h_L8x(6MzTM_jI0$dfO?H!qNGSfi zHYjgKc!yS&QVeAUAE6CULUDf z)UCa87C!vZvLAt+IL72kdyX^(uJ>gQ_#m`4M4YS)i z$f}w~YUe(J5H^IONfj5;LU?1j)^l_M{~=4y`>M}Hug35@soRtParVlY0*iFUGF7vN zHZfru8QKj_R^w}&UdQIr<@=?lSE8bPbAu$(UJT67@bzWaYiF~$#??8VQ}(h1=DcP& zIA*^Ho&B(u)_~yO*w?h*oX=rymyLajx;fmZjFe6AxTQuvx*TY_I#~UhuzBOD!Q-5j zI^fU+WEF&#e1_2PpS@vvqAE#%32ipaYT|6TnvP~fj0-_(nB6d#t#}nf7kbZZyBmPM z=_s)q%_`P?GVZ`DQZ+ALdIgG>PSd{J(D#7s97s_G9Y1760Ril7j4-0;uewc?IiN15 z=8sB?FqXy_&FOch&6N|)sDta}k_xRQ%rl4OcDU<_LHd(Iw~gav+-!%~^ZJ8v`7peR zH*s;SH|b7OZsg77y5*X$VG>nApbdTTQi++|QzD-E8#Q}OkdDo4dytPT2bAehSzFr! zcoSzo|B3n~3=x!?~ zAES)c(o{+N15bfBu%MCgWjPZvxZ5~)N4)tL@kR;gOd0km+l$vL->?C4&vqr!LV+x7 zd*23oh9QSo=n3Hb>*W4>laRi;W*3ocHrDX9zNWs2+S@)auDHk+XJ;&3iSo4jp8m6n zZD#C4p^zs+10yax``I%2za`OH5_2UOm5VbVR9_449{GPjJoK#{C!M28ZTh@x(@{7)(YiTZjfQ4lM1T; zA-_bQT<@-`V;thA_(Y!iX~=4%s_#>xAW@B*mX{^ZOxvO59r(o>4)}5>j`l8 zYYXC0B4Hw+)d0c>0Di@+g z2Ll&9FODV1v!emMoDtYKA;AwhP~g#aYFG$-<}9 z)YLOo@T-3Rk6+!WzQMv00Zh=?g@vib#l;U)h~#*9&({U`df=FJJ#MerzNpi)Qh>># zuAZJ5Fbf>6hG!l((D+k_{$GDm8EA_#tbj}5{?3reh2`7tuW}n38^0Npye5DLgo%j4 z)2GK4V?lI48m$XF{nUXkydx{u1r*0qxUrrT5kUb=)!ws8drkv~@U}dioKL_Ry(#d{ zH7@W~AmAyApVO0L-oUd(igimbb1Fr)A0H5QhQ4rX4B{pxCUye)&OJ39CZ(Rw#T$eO?#)_jUqX#Oc3(bI&OERz_&R95_x1=fCfP&mp_0 zsHiW$+zn-r1^^<^h!Y4?!aP4`F^J`{DnWWdW5Z8cyL)>|#l{WJ^rI~;a+c}9Qt<=P zb^7V?8iZ%K0dNW3ae{FiA0K~8O8U-)jGm}LST=Zp@l}p+xZIN`esMFv;(R(M&F6eZ z5qxF1lmqe;I34zkIk6b$>KPbZ(21ok1J-=DL&7OA^7{*Bs(pCK1mE87uey3*e*Ob5FE7W#ZEJEy;iuQv!RWRUw{_zN8LkA5 zlPJsUxDsBFS%Z`m6^Yf6&<|i-*I%2*rww9sFyb?N!j!Nz*}ZFA=K>H- zKpjo(;vqK}qnpUCc(utwT)0n1Kq}GKtTkNSd*o##2ADH-x?SsB3*r&&!4wlhCmWwQ zOq$})_qTExRL4*}(tWT%Z~t()xMD^43x<$Ij$rB2wu5CJ$P63BGQyO%f5kqyNlWVw zw#)0q-Yor?!~j?bn+0;jA~I@D2B^8FS5^wLh9@VDRXk@9uQi9o2lX#{BQuXl7bsLG zug*{XNdva^f~~ggU=oxgm{NDl4^BbIc^MFW4;!=ap-Uojvx+TU`QzS(T-)C7a&Nn1 znRPVDJsfH!DBUhC&T7-V;c}@FAa8&&^J!+~qs_5DuD&A+MXh$^*(|sH?=4A_zw9;n zBe8emJcvJ|x8zJ%%=*VE9D4x`uO`Xp-45R1&T@5#Hpg@hCyVZhumla+NE;1K^gNlkWOs~377l!bWQ5DO!Bf+(AdmS!2 zo@1a^h5LB%{+v~1gps-edxaKZ<#k}R;PT3b3^*lXL=pF+XWD{nJdAGiR?BGzZs@WMx*!4>eZqJQfDy;BFG{ngc3>`82w{%Qcu2!bvA_1a| z;w|JWG_H6XOK&QVOoW@h8d&Vhv6)%$#M~idgsQbwQ=$eiZ>no*7CVKR=T!4x#ABb{ z#@Qn#=Fm|#Gkjj^o#GeQfz&Ns{&uD~PQ~!wI#l#Z2@$4WegLu6pY>O4?KSUDq3|j3o5o>OFX*cP&kxQcy;%PJZH3rBBV77trfyYdaujJ zmd6(!(tF|-b5SEE+g3$;;?@qn;p4yM)2{Cc&K~b;M*}q91ltF435EE%WJ%YR++?@X zEO+SArLGErZGJ6$}-#c_6b+x(n;wgEJOb#9`&6A8zs)= z9ggZzNY=KE(L&Y4YbkaUrhvzx{ZdEt8i?@AJGNk!)4Q$R-9fb`)x9=F;d3Y6IOWJM z3(K_5>Ts7FVLRd>%fY0?6@#BH&#s+EU}X-z!UYPIN!r!H!ZD-ksGBPaEgryslIP$& zc2U3hUf5o9(yXzuNws7D_NpRpN_iFAz4hhdc!Vu{pp(uy(YZsyVzW0HM;8nsa{s+0 zrI%&pBfAZ$KblF-&G>V8GP*w7kYqsyMAbv^t1|1O&*S}=x_Ew1Q5!C4zC zlLcl{?WJ`=qa-?7087Qe(~?b)BydD{=4;#oIZbPdDgHIqyKCwS=RWES$VZww z)0W4J4yaA$Box$0+Aa99x-ppkWg{>)sOYx%=F~s-T_;oBO(cf#I*b$DuAQask!}b+ zo$$Sc*jSgTqx*In4nhxHpeJl+tPH7P2ljpt&W+nM_C9eyA<1@54|wy4gvsbr&pZ^? zfSeyDAzq?hOGWu;I!Vz|G!=zlX-1cJxL1}kiE2xrkTKwL8(QqMmQ>}?7Y@lRf#OLW zWuIH`r%p!B6^)Q%en7#M>^2L(VfpJHl{&p7Bjkcf78hvdOSYsFAG_^Lot=uMdhhD7 z%>Rn*Q`_Oe#Y_U%Y<>wqsvsldct`H|>rRnDLVNpD3~={+^naro1_0kvOY%P#^9u6I9M|u!(?jx(liLUjzo}Bg{*gYD z3Hmi2^^mE>`n#^s-D`dp7kfDx3gHP_J*;|?uTJ3ts~KZkb)m0qdG*M-GjYUJS%fIM zSjXMu!BMpRf{RY4c#~?~B*Q?(o3fR|AhWd0*Q;(j2^MqizCWS(Fi&s$9RCFL`qxTx zak1oxkpGsCM}q=6KvE74!@URovWxiRcPa-KZBoA=3ZKm0AR~&^fKqX(ERPlP-nLQc zGb}E)%&I`1eLj;N8>9S-&&2X&9QslBeI{H@T6wt$vy{8$9{cn|(#B52j8iJfp7|S= zh}G(FLJi@bo*7(pu*P%ey9_J-Y`$@SQZ=dzuh^MXjF6r?zGi}#2}ex3f^IOxL40fvu^zOwdTPZXm$++8*JMn0uhU0pr?oRJ;WKES_|JVpc#v+`j5t#27E9)&=y zFI!nJ*w8))L%@b)Yz^fsQ(e%j9Vy$cwo4tDheIjr5@Zc{*ZyVUzH!9Mpk!l9N3sX@ zIO5lx!g1k1I`(lgG7)A!WcJtcRznSU=lV)!a+KDz_`PKF!aCKHmzLGMOEEsF%c^zh z`qkogv87W-ARqrfxwIoUSr{aJy43QYzxiR( zp2%V2;7~j@_5RY8jivnJB>^lz9Bia~L&f$6z^LTotu8_L<-)4+dX#nmdKTZhStAt$ z#;h(*iIEipYS_O&_#~N3N=fO@uui$xk{PxVm;oX5ZgD85oaUWPj-7n<&acqoI zy4n=))+aqCvVX8e9A(IM&2Ro<<7PlrXaSMt|I{rr>a<)~T5&R#|BtP&j*9Z@+E$TH z1wo`Nx*0k}6n~VUC?O3pL)XwLAxJAy14D_FAT>xxx56-#2t$W-Gjx7ueBSr{o;PMK z*K+xXGxxdoIcM){U;DZ&5j}ep&h8!_!wTB*`eYo7&tMWhPuX9;`qI;($=8`C>s5{+ z*{Laz)Ydz4l!{#k`TYDx#LA~OFQ8n0Tw`4z{?`5Ua2r2TY#bZVI9lac(w=nXY*15S znL7gL+kd;Hy-OYmnd$_p{zWi~cYnA^4oh?M zVbG$ZBO^=0)ocwN8H0d;8IZ{RS>fUowGr35CYS+1tS&XoJ!zmh61=X( zu06@bL`Ii;1olk_o%6l{e2_(NSzU37xg$gb1l4AUx$b~}sffT_x>zQOr;Q%z1dk&+ z7h&)!z`(RmOvm{lHVP}mNg!`G5te6sC4#DPvjxvGT_xn{~Jt zR_!TJN}S<3GO}k|HWdQIH=wCw&1c(CI>&!x*LuST&{O@u$8B}0(4aY;lzFsiigIQf z&(-{&Z(Pwiw*s&7PtF^l1LQ54T`0=#{D^-B9R$iNnQs$U%VZIE|2g_IJRZtkfxbKS z6sdo==lScd`)?79=Bh$!9~(hQ>Gz=rz62b_10|WB_n_$y6zoSu`Ha552&lK{q>=-4 zkcRi~Y4~w;AAnz2TJwUN5{QG%4p2vbiS4z(eVV*!VaJ21%hLL0^OE0|?)v2GOm!e< z*??BY`DwkEa!(BkkeEdaV*r~lN^FXD81gwiL&>`1P*$V6LTd8Jow7A5BQ@@70Ok-^ z@3#8;$}>xPgn}+}r~T_pui((tor8B5lRF2Yt3ZFU3s5HyPdcVJi#wTs+Nn~tz~h9m zoy8AhH!1%dLfSL@z&^F#zr)3cIZbI)7+2^GUoB6%eDL#(@dkUx_d07Rb7Uh-SsA-4 zcJ7Po2m`tb)H?QC~MVDulbP)pkMTmJb0u4_=dKY%o?s%IIR zPeicIOSm`E&pmm;o6UDCFwv;Eb)L@LJtCLSX%b4tC7jlYU|6vudHf8P%?DkLinJzc zK_ejBqqZf>B32Q?!`V(+w?~0i4y5N4n;S25@&jk8Yfr{&HptDI2klAu?wVpbKTbT~ z_J-k5R>Gkd$FH3mg}Ls+0tA-1Ie^T%G+$1!R#uQd8*{1}*Kedw_+pdAGvuMZR!s7? zg?P&1EzrsL`5=v121F*PnhJM#`dIV#K1)AMb(Z~~MJqCh*TRsC{Voc9Ln z3l&`OC*UbeD#0s+w|qD|Y0q$WzrJ{4Gg6whalJQ{_q2YdvHrAXTGD6IaMm5jl|KZ3 z`f@dx{94;-EY1zf4CEY-w-!?*t{vhYsmw>`f&zJ!jP#uE20iRtrisXHT)|OioB2?z zk-E;TeM;~=>ef#8)KMb8!OC12Y37}kqU$p~;tjt6XfxrKK8v1WJ4fTB>y7KZrI~t} zlw^zFp&tAn*xY#ps8-cep`XZ=u1!nYLW8(zTY>{6NIio?T&#H7>7ZSVyljUakyosa zrI`w!Ek&VWDlb}!^hche>E4>$Ry7#uNLaoo+!!6tf0FGy%WZFCozd)^`?XBRhn zQn%N=a3w-db${sE1}0Um4q@>E25w1NP5+p9lbFH99)U@U*&bn4F+zk1>PnCcdX+#` z%F&%Mf!;UzdC#!t$EXvFdk-clX1umS8DiU0ZNRozxYnxzfRO)nngPiIItFNy9*12@ zxB!J3j9Vn0^jhtjr^iJJ|$^T{;*J{$K4d?K((CN zg!3!236h|ej1RibKg;)jftKuqWH(V=%#HRuJ$zCN)doz|RNT4Qnj&WFsyyl$&A9NX4Ka6$rG2y5IW0uoz)6(>3eo=~UP9t7 zu}9`K@KK7}pSQ2^beE8;q3~+cret^3;??f9UxSJFSYX92zFJ}x*gy8-b>`_sJ}0pZ zGxAMA5#kCAiOz6YVfG};5k5qNuJeM}<%3Q;@hUTGxhb{`Tpd?`-DUGSuvqf?@)$TQ z=TdBUK(MD$hzozt=#~F{e}UYDGA9{shdb_hks=kEnD$L$zL-nzm8MztTEe4;Av-uzVmA+u0~tV?L`3)N5%n}KBL$H;qiAy`i*0OuS~ub{R7GOA7O2#h zRVyUwJC0XBb8Iblp>oXJ)ege0rzt`$2 zymk94vM|-MHdIvmSkK>ZylgIhGtQg8sSJzG)qWR^mV%&j;Sg;}DRD&FI7p*?_!IcP z=7zXXxfXY;3%^Y@(SBG%d7u(LKLsZfiEX{^n5{l$<`lTy^7|!#P)RdUaSu>qY61676JcxX+LGcfQ-D?(_^}6*DBR zsdrb@AUmhaJk|ftyD^UJq8P%|5vL7plsr$c%w0=H4|Ba>X6PdKgtk^kF-+Upv}M>^ zAj$;g7j5hECCcsIR+%jf=waNd9cJd}zZq70B-?-f;p`7pO2KX;CX#bzQf|u{|La2qO0S3k;;Y#vGxd_Rl|k^ItXP_UI=44vdV-RSOmx zsePka!ZJY7A(tT%uz`IPvD0mz?tg3@&u^fUl=QrVdlfzF-0&L1s8KMdr!KI#NuJ8& zEQJa-6c}e>?(DXXQqMsQI~5lfA1jC5CW&SEH_)8MS)d-!1ma(CVVI{>q!yCZK&B(l z-Nq+lS+ESUknB`yXCrZDb1YZw@eYtwim|NWoku9l@ljhYq*#_9WfHGA*xP?ieO4Bq z*sCvq-cDiu*tMNG&cL3Q)5fPJmUsdtn41?|(!-n%N=1`!6u5=pJ3RnN8MbUWwIfvY zJpJECTh`}?j0?%CPc!ZSAH5W<=fbMrq$KzQbht5V576o&?FfyCk+k8P+2}POvc!k8 zOfpXNM9~VfMP6SFr{$deFMz?aE53iuzD6OY%)b8<;iUhGy`Q?c*sN5tKwcZXM#~on z@#N(tizGnBlgyPfGIF9A8A3YoFubYCY+aMRC}?iA72HL)O-h>~!19yf8DU3*5 zk~VX=D0#~qel)7|sAg;KDPtA_3_AWj?Q`bP?Avl_al?z8x39NY^n*$h>n zqG|y`5_;3G%CEix4VnKRTV2d^$PB5jutss zAJc^iBjgsC%lRmQoyqUP11CdNQc@B|6*ZokgXLp`X7fa_r0P&IiK`37f^G_&0*(}e zOukLh?t3fKN@R}=NM^_84xnp%q&ko}TfmQG62JY_fOuh0r^9MAzz?UhHRT^5R7>U( zJdE4A9UC)ek4`AQ#fk_{@3{zO8}3y-E71_EU^^rw;yWVLrl$DSV*E=AfoCiHM}GHE z<*f+1#sn)HVOj53zn7Sv`wn3Yjp80!%hhn^Fzl$Y(04Iv@bv=eTi*1y>S{?Nhn``Z zU-?<{2qA5aI}0v}#whjF6A2^Cj(t~o84^B6Z!9p*nq9`!S(vH{X$;_-fQFg?bsl`L zHb^pdY+I&bsh^^x;pA?**S1RC!+!n?kx9?ZgcGujx%sN}&c^gp&hyQT3u;Zd3pSSC z`W;b#lU!DaIleOIvHxsGw)_Xq``3SqoKQ))uL#o{(nL~6t5Te2Z@S!`9yqk5FbK=t zyFEew;#I}*VZ7K!?``9fOSar|hPO^_dowYxm;Gi-i%Z8(d|WfmC;SN2D#pNkhQw6B z4Ndujt6wBaLXJ&zI(@{tr}f!;Ex*(in`Bhyu`SdlE8zJ4J$BLbwE!IFj=W!Ji_nVD zf8M()qHWEp;9qxMB?Ey#0yIF^{~8RX^{E}}CVDO@N}@43+<_8Q$U!wRhsh4QFA-Qz zw>zVibUYO2kLR~$Q+sFd%?vvY-Ba-bmM>z3`7s55YIaufV~xpBxsf%Cbi9bpe>QGq z`6PSAWMVx)Pwznr=+zYD-S`-46ZF?Wh5~dg7Y=8I4SbfJd(h$mJGS?t#?DU&y3d~@ z!@jOZ@KAQyIy*>YigBzjhcSvP^}(7oU;War?BP4mVjo!$z|#L)!*@#v2tp58GyxOw z&MVdnH{G5tvJdc)JbZ?SedG8s_k>+6sDIf#V-&30=L0abFc`ZIJEG>o$!L;FKCV;7 zsd}Cd!TM(Njsbj?NB?U%ENmJe{L5v1cB~zIyL=~Rk2e;df%z@~EdHza970T48;4S= zDG^%pQL3Se@(>JN<#D!&&J{%pP?*o)amTA%gx`Y zwNu#MZQSbRJkOe&V_R9aF8hma+-gVQA~W zwFJMx32UZ`Q-acj=RMSY0DLhiDd`X3J8Pm1_yAUa6GFK0Z$#R6Vu8(~ybPOPl^iw! zJ9p%5&H|~PJ{z)FH~=lX7Y5a|InR)D3HRBgXu~aw1Cv*$W7#ozX0$YcIMq*)uNo`< zw&oAiUc8_K<(k_^QHV~FUSM+a%@0sTnh5-w(Kuts$%IqimJRNM)?Glyhik9&YQ~WR zU|g<}0_eISjGn4$JdW_LbDH^t;UQLLH*MOWJ+vz3|AH=6{wh_8rwBh$`n0>s@XMXM zfZ=`7Cs9}yex|FMEm_03)bz5n$A<4mQ9;9M8lc|y1B1cfA8}^?Z+@~k7BY5|9n&f- z91y_+PqfCcxV<}i{UD9C2ymU z2>@f(lqF9~TY$Ds;Ki*9f{W}wN9|=NTl1<6>Sf#QS2_om8pmiG?YH|Z$HCmP>wZoC zoSqB5<>n-4SnqwqpvvL#f1Y}IPtzg*Q1KcHT`1uOTSfNoJIC?2XoP<`6Ov29MLNod z5t;mM%GZz+p0a$=zp4=Q_mwi2&gLR z+C^cyf(%q}GQ*u@2sSqTRYk|s>(pP=#P@1sye#o!&ZRSYH(9m6B#;eyXdEyGj0F^c zPZpgqgac^n{L{0u$0^_)0!9iDEX46Y49BV0npuriv8VejbN{l@e+h>$_paEa@E4}sxsRI|Ek80AK(f?vb0 zXb*Z|^*iHGR#nxRsTk}KxA5WKlUbk_`vZJT)If-A{3|`HB8fn)YfV_5FtTIB40M1k z3z7Nb;tLWy@1pcq6zby0lOLlQHR7iYJ!(w2ePMCvP$%l3(4{H&8Pd0@f$HF$J6IL& zrL!7z%V4QzP$=>kHmcSZr1SgF$0w4Ikf30bX9gq>i$GEBPr#KiaCdk2^cxT)++h7z zm_%~j$0&E`x9L~YCQ5f8aFAFqI-dl-&7*-tI|30{EHlMgU&+>~?h{~yksG^n>C8Q? zkFpgA!f3)$qW7lKT`y%*d(OGOuEV@$aO1--e3oc)0NG)QqGJBVJRQ}yx-F4CljdV; z0zYG(@|Rnli4l2uoXWAR(HZ{7ATRRjnV+2v0{n}`5^%6O0a_1W;4B(+f}_|)LJ3rCmpOKKzU8MJ10hO+Vz%0^D~lgl}C?! z%N_16IEyRyuATxFJO1AIg$vd?0!MCVR?B(Hsdt|Db6ivl2egYp>8vLm$_(SW*ARz@ z{KHi9t|2iJo&>+=s>t36|*_qv}E=jS<)dhvv#-Nb2yEQialJ+ajc@K z&QC5M5$5HuJLHx2Y_VKUmdSs`b9hLYZ#{#bjg3t%6#F8j)@_x@*4Ear#)Tcvt_ksZ z@)t#%l9u##`_uz@xyac)A+R&%p;h8TC-@vUF^a?(EN zGBg`TvM2|EookDK!QSjOwk0gg?&!aGLp2?%wU*I}Q?1KjtYJJ&y>Y!JcSv^F|SwWo{e%JCdPXKWw)OnIp-EL0tDVK01se7V2Jk6#^%c@6Syr1 z{)M4WNz+%)Tc&l|t-rMnBPy6n`8(4L4rvu6g3_HbqUDVoXM$+$bWb>Og^>0eDgUG4 zo+o?b=%1=7k)!F_Pt!=E6*G*|rL}w3&*fb?CzGvmYHg`U{GO9~UA^v5R-Xf|15?s1 zLD_C_!a$WJmz4z`3PL_23m-`l)B1tV^v|uNeT0=wTA(zsh?czx5zpq&2X7~qJsowW zy9&9gaJ|!Tfs$%YmX|}{zYU3Ne1&Yk9VMPm8K~dqgA96^r+dojk_x^r#sn_9s)GYI@TM>ZRkgc8f6dXp&c>p7R6B2Uu&!2n= za8hnv`gbl;n#u4;m*$T?8tZ%u3RiVeNhOJ8+HO@yFs~yWt zFFFM>1XEI>F(3V((6$7_YW6>-XEk%^Oy|OY$=?th~j6cg&`G&#|)M$gQMC( z=Ab1Ne~P0<)|nw%mX6Y|#-OwBufrM`)Mys-^SRLJawp*S;xlPr?(gsauN(9n57WdJ zN@|BNO`Es#)tasNqIrAqowA&89EFU6+}rqm8Z-%JzOGA-r2lBZr|{j2l9J~; zMt+s+8YiY<>mnbiO;bh#9{I)@C(TcS(f!gL(pH^bc8ALi}#Ii^e^@izIuP3C+U1svG*AyJSkMAQ2l(H#g++y zCCodjHPk$$z)+5Sx#l0;MfE@()@Zstq{ff$EIlp6VD9C|)FjZ(2^iBUF^&d)E93Wj zP|%)%3)a_`mh<>5VQczt?59d<%b)NcGwwAO#Z>V&gyIJPhP$Wk{E~L8zu>v$Z$zu4 za~>-7B$U@n!25#wUs=_4SclmKiL2gbj5lt5&#))nm~5-@$y$eFs+`j)m|H3lurWx~ z&!C9DoR{nU%@nLnop>`D<*##T$DJnCgWH~A?RU9&E))B;_B#jHrthr${NSVIzM{B0 zITLOVrab5VNbNy3VjpAjW>}fu9{!1w5CAkfE)^{f>^t5a8-bX%c~j^EaepVw0~Yqe zcpb8(?Dz8qgx%k#e)K*Z9?uALS6#^CDyRzosd$)B7cxSohWAR2SQfI=|7@gxhZv(>jljQqCC;xwNMxBE5V znwhvv=GMoQv0of#ut^y$c!5_yi8pJ($U_l{s`xZQ)R`(;Z9sp@QXtR@HNdF3;4uXK zSYiXQcU4X?`t~uI5Hl5uHSKcMa=ijnZnL%@5>Kc{VQt)p&h6ep@Df9V29!V(ApE!d zs|fC&%$oM1w=EKDXI*W58(K*1e~Euv82R*Jba;s%ufi^=qM!(r32~P$f*=;Dt^`|8 zf3~NfuuEQ|ig@HXR#nR1NVvsQac`5u6}H& ze)qg|ZxCxP7mJ%_k>*9mtq{iK&D>(n$;+7a_WkohyW6lgbKOR_E?yxPG6x1wEBG9x zS%VsY3XMf2xYs}O?CO$?KTx(YvE2|Ig~x=42?6L;WwJ3qO?_t#mI zf$H(t?L1KkpCXclnOVrHFEjNs-XnEue_t7V&R+q>G&M1PEvnx^P(6Tw*#{R>Y=nK-Ehef2PvrnHoP6D#W*$q9$GF z_T@+~{RK=hBHzA!V}#uxnVgsqft^190`2z>#$-g!UC!B!kQ3wMLcWJir-yT~gahi~ z`@6f3yuH2u_1M&ZL*N~%pJoj(3->(dlO}zsSe{h$!y&8Ic{_h$k`91SIgbT&#-9;X zXfZ`MLAjdGb4_?bN(uLzPh;2~OcvZLZ|U^@EBy>alu^6K>aXiq1JG-NguhA1_~#FL zRCK;$b<@1ufmn{R2O6q8`W{N8_RZvGE!$LyM<*SVf^XS?zNT726`MHoSY+(N=qDVM zI%;Gy`kKzZ|%+i%38D8P8?{hoFD-o!&%dS4_@l-sQP=WoKu_|yszcXDrPr(fi<9wD)2 zgY&+pC-1=l7HP7wtW@|S#ao| zeA@8!k$akQVxc0@U5^#wdqC=^@&N{f@1C_b{*GS&wr!UZqs=%WZU|J4C1O)t75sqX zWAl~}QVA6}V3eEL+ehQsVEE!-YYTsC=77>&INeh}yp&nfU4A%R%6;N@?X~Cq5e}MBSJxfeMMO)PY;tQ}=xY40>G}(kN9q zYa?`Pv!W{5+=tt7SL8{j>@}o5d#z@MeEzF%Hj<*cCB`h|^nCAagC-4L@+9!)Os^3g zNlOkEqEBDb?|E;2^ti83V|PGs(Fh!es#-&Y0U>QAT%ZG65;@x3f7 zM2`kGGsT35Rj=*ZUm3UxERe{dKlinBC~qE!&iRx`g#f#ZPk(-0s?e*`{bH`0R@s%QFTrJ-#Q`U<_+08Um!uE@45-j3f!kXS(&e!?+N>UVc_A81J z;5KT7Zg<7gwYAg2oD4I0 z$B_^=y6!`L-`9I`60Ya==X#7uP4vAaNAs?j!MLeYa|42JL`c#WL}*E8$X8DGw$u*) zKJtT%)?Vlsk$>%n>fVS_`j`H3i-v7Yhc=MK;INBHK?9?&3jU-u9mkO#DJQp`Xpze~ zQ0k?3W7Vy6$d|>}TTzaumSIr(%W4g6J~)JhdHPih2!r;%ET#5ZJ4J{+po{Go0Ur9u zKMATiE@^bgiQ6VbuuyB=P2=mN0q;038J4RU?;4L9Mg&so4JtpZnDV~R_>4)Q}t zoukN9C$!DjOKFCrK8H}t;bj9I+3uOFsc+?T6R}guEw8t)Hj#eA#M}|rXvWI?>6uoq zJ(Hj_)5XChc<)T%aSR?>V1 za=+vrZA?5>yHA`lt^NjMpXoVsjw6b@NGn12+3&|;WL<*oT1%bi*(?eR^=o^lcfQtM zjCQc(P8YenU8=3y2M3IeEvcJzF+6DzV?Gk8o05WZ!)wH6`@|QI%e-su$B&Kf zdm(~Q9zVvKr9R1tZuw##Y$N4heQraF1)&obdv|`Cu~y8Gco)k@{T$O|eAB?5>9@PX z?`q_wbu1Au?uMYdZXqw)TB&b+l6*ADv)^8`Derlkbu}G|DLF2p>x16d6w$6SU%(7X z7PmLQaQF{MNbyKIxEm(?P%<*|+7GPK(JJBozSd9rvG*5zM7S(<=D}5`g$o?0WFG^p_xy4Xm@`UT z_0`hS`d0we;{#}la1nFmC*c>%_OOh`$jt$chpkyRTIE`=!WMWM_ZJFXo>KJYa!UL9 z;2^w?oGKYNFZ?zsUo7u@-1S=UxVm^8%yYZ>AY-mxbE;W0dWTE0uw~wMT0J}Iu&+IJ zRyZAYDdi4UNrF{rhBWnq-SjQGv zq}Qn*9w@o5`ran_ivIpx=E^gF_-(SAnLSpODh+K9b{_)Mr!|=|w@?j)C(j@`0(T>A%)87QdDmR72u(E0ladjadD*+0f&aKNP)BSgR|e zJyWKRmiSNKAK7l-hRCL#+`ma}I7nn^Kp)@GYvPLwuP7`(mWIcS-w;ua!FeoS&)OF9 zF3&!fi7Mch^a=CrpF?Y{ScVB?4$b=IU*H5pjjErxBKe~Qgy_2-x+a*T=m%qo!&a5K_0Txh*3)7xd4d$PdmneP2bu#}&vR5~`o_N+NH9yeJj&9~dF~bR_jnVe zQq~lTJu)KrVUiQ&c&Qp0Vn{tUt|;4Q*txH*ulD(a;k@`q+w7r`Tlv5DIDJz&vi>Ne zHsQ6_ZiM-yKZ?qGY{u3|jlNjR|4fa+2)F;DL)I(F6Jt6s#*O?!Xs1;{Z$71#NnXOB zspX2Bn>T;V$8Dh#wJEAFqUHqOOQhd@NR7ESd@~>r>La#6IFKGOmd9K{&rMXK7&OVd zm52xi9#fylqo<-YIt6(p3ui<06i+?}{+i(o`rz@EGMr+%BlIBA*wi~DK+o>ud2a&2 zmHweio4fGlEs0FuH8Yapvl|h_v4IK{)beCkSW_*^-j1|(@Xvj3j6Sn&2(aofmKdWc zmPnO?rb*EFagn^NBD!0P?evJWdk1S8#%@0v8Yjh^pPySzeBV>ZtN{~ql1|f??8FU5 zxbQbb34RGRi5Fu~D`CM9rB6Qm*7W@USYFKQ(AubKLR;XK&#n6{9h4}jm+pJ@w683- zBu)9L|VQhyhvS&O~|U9;uuBMlE*B<4otyX0^tlb(hW`=JnVT3ek(Ixw{3SERBm~q9SwH zTpm=Q75$0%H#?oVTxSZop}322=pAF*MjAFm4ejnLvp2rn*O>s8ZW{aIaN#_SB}qoT zS$|KqjmBQ5FWu+PO?L6o_sM;0S2?mR#?xm8O$2?TcDwEK&EI$(hTlG%B56iUJ#;?U z)Cx4cB66T9G@mx3;z7LX6EiP<)du6d={SuSs?~XoZC<#L;YYu|)oq?i^oQw8keNCRbn&SJQl`o%$}>O=Nh?5D-*_j8`ZPXPuRw zJ>T};ZYEEzoT~_N)9;SyDz2>&bcIUOBR|NTx~J2r^y7vQf%~N^=%kakuOj$#2_Ck z>lB*Wv?dL?IGR%0iVW29j^)>ip?sU{(`BJFuqI_6cJZL(>LM5l`2yjuT26F%CNtY{9PaNaluLybzJgLn&`J6vIKX%}B zhVe73^I}+yF?v{^;W3BX=>A--8~*e1&AJ(`PaVNeRC zDyhnr-F3Z+i@G&i+)v&S@8d7%WfikgQ9{^Dy9izLP%Ac{n><+M{1%C;c2=IhVT`kFZ9+R8}LiIR(KicJkZS#c11g;Uf+Z7tUG zh96(N{7UGsES#H%NGN68H~Q}7zuc+rwBS$D$-HPaGlgKkoZjBH^Z{zJ^r~LXqVKr! zY3#|r(wd~EqtL)vQUlF97l#_xRnKf{AJ5vKdu{4---JeI!;Vj*Yi}|+T`3?c`9NbA zYLtZdPN`j)@vy0Y_qoc+?zgc4Z=1v1xgSy;tdnVOe~2iA-}tSpuvp8uolFvp*Enw8 zS@+~iHwCx!2%X|SX6n7~T+1rK`khhXU2%wLYUxiNY*CEd!V4j)aq163E1hOdr?PEh zq^JAH+Skj7EK@RH;mgmC(SoGlQM+v3- zFC6bYLAd4*6^h=XD5j~Q{BoPs;h|)G&v*dp*-N7Ve;)9gK@LKSbe!~i!zk06Ir5qR zuwqPPTM}G_L@4*P=Dpg3R&tLr%OUKlhmZ@-c^T01OicC-1Iq_1&3*>rs^|J55ih?l zzie=tqrxQH-;DEHO-Jyq`DlXT^QB$*jW48C1Tmuqn%C$oW^>p-a~PX`Z0PFoAT~c5 zGBrGdyjTdBoaxz3M&&cMHtT;)opeYij^+NeJ^smJyS2${?;FkO##Q9eht;AMsBk}+ z4-v8(5qeQ#EXSN;4UX?lK-<5@Z08@&0|F)sA24;7k=A@99S;rBkBdj=Ipb}ONGuI* zMEyGSi)PJPYj3MO?S+iPywvEvrqX5v?3H_@IcQHq-db_)qV5C-E>!vimmcZ7 zd(q9&C%;)J>v*q`IP`AQ8VXzdK#Isa)k`3;EQh|e(wa|aUjZf8%&K-_vmYYxy*bov zSn0Dy;yq?u=WSd@BgOE!LxV-Wr)%#4;Nsns^R5VaE%Olf?%F*tR!;a93YKU4@A42L z8Ot^Fa)y)rk`X58yJjC!j?(A!enpPG!U@4rYI={pK$|_RY44bod&Z$oMJ+TJPlw7? zWq2?%l6i&d_~KD*THRt&7#C;TJNEXVmhZzCtjckTc!ozrnAb68aA9gen4m}IeSR+) z=`vJsQQnq{TompD!+B@p_lMHe`nkpta_h5dGzY6bOzTuMTUUHemFX32ieo5A6fKvL z^V>!HkTayjB#B~1tD=?Nbj@VUgI{7gFrV8tOoiY0X~d#mPThKEV-Tz*cAMrUy7%Rk zAT2MoOwWS$LwNE_GL#L}aFIZ9BtU^K7Of&RS?L+GcCWs9&8O!Z&2}lyQt?ImMN*zd zYN)i^$|*q#G3Big2n=(Zi;K(dvnt35&Gl1>X0Xhqh2xZ_($Iy*wd|s zYLgyiXC=I5a#*!9)=kD&kHbv}ub*LplaG#;$3A}%AN!|%(*GJz*^FTeD}J2#f$4)w z-z(qIw5)ORd7sLRm6~H3fRN0&K6Mns?~iwHYPX(~s2&qXgbgv$Dg3dsMfq9l7-!TUK+S}TU4yWPsl`;=gwHb*&f3}Z$>PT9QXuA z@jz-0yfTrJ&Q}5h1I_LMJgN(YPlwvYoc5uA?_E~Oiy&=X$L#q}euuf6JZ+obt^kan zjW{i7dXFmd>Zd7DwNNWm01if!IY?~~do49)rX{-e>Ef=Awv&!rAxTwU0Hxw^u;M=~ zN{%=NXzJ%k)<_AD_92>auh(GPIWSQz4O}Z3#u!uj3?dUWe*S# zi<2*$jGaPsGR!H_&L$q`RPDVC!Oz%l(-b6)e!e~Wes15~s*>xGu1TZ6QAD8BzRkw( zuc;N;8LU7F{;X$P5PW!CdPBDhEoC`%>LB5no>#7a8relkwevkEQyG9;Y&!TUD zI*Xcu4mU)wv{x>UM$iO60uVm+@!)qop@^D5+)R7gEP-DCAXnKwpKzGe44 zzjiZXUNx9;}@!&__^8+m;IZAB- zVgQp9cUoDIAt~7uhZ_!`leO0gSv|GRL}ab(j_8hD=Sp8S$=31%fX+$gkpCCIll_s# zfX0U?)tnt^e9{GyNDDH|ht$2`|Bz*Y8oGokv2LQ=5`>76V<^hn(cj!foDb1*R0T^ik(e z*2TxyV!viN$a^=)CESasHSaD|YYa2R5Xy+=kpx4}<(JAdQKx06@`qrwMD1#*MTU$A z)m%q(E07x5I^3Qsb;Kt`KKO(P+()t8k}?dvi%b34Rxk3%I5gAO+&%XnJLO&rLTsvM za~{3F{0AYUX@O3p)pK6Df|;tmH&xaM=^4&M=apQE049z1LqfOKbNk8hP*skrf(eZ>~RsJWZ6wBn9(WbN;0_Zxc9+A$;@nj%5w*9oFQp=dtzjYc-g zT)1fIUDhA;oee)p5`|k2V3*517bLBx_`UfOP}Gd39)yxO@yDCOywF~0HohBG+I+%| z5^(j4=eTXxc>C1la7F4I+6k9LzDibjnuQ@LXs|0kT`qG zyLMPq+H5p~>BMZ?LUf8Y#K=SG^D8&kN17s$-u;L0*B);!0^y}0FqxLKVIt(}rm4yN zk7h(wV*UoNh%;69pt=?Gnph}IVcnx+6FX@;4|0`amYf$nFPXvbVgk`Dmpi>?7&A>l zfC*Vd5v8A0lcwK5`VG?|Gc;nR(HFXcnq86l&X>|j-#@2YE3GHIp%j_coB_GVH!x;s z<(C=42cbjY9>1h0Y&h}Y+MgtE#>m_mAV8R|*pUU{>>-Vw$h0@`36?-2As#7E)6=^MK#BfV;G1`FcOWZ(mBt#922wFCGF<@D^N2WzV32!s*Bro;#rVcL(Ja*af`WKmD_(J6mwx6oa~01?XE-+xdLu}*P( z7vlZAQDSSvMzFp}jUY-dw$>YXKjE)x5FBJ<@0Q|`)kC#Y>Y4V-cwRtcBh zQzIGOB!9TWesL8>4=Jn^qe*oTZj`2r?eTmPD-z&uiqieF&>+o95@)!Us(Hx`W=^;s zE%8um__X+b9+p|@MLmupFi-bW)&#xvsR_>9vb>Tf z#rTsO83nG&@gz&qRpC)fcDFrQn*`BAmcRt#&C}Hp zB3_b{jknXk?}bK<#V!V%kUf5aDk*}RSSQLUqb350J_i$4NOABSkE#E7zkmI_((0fl zMq!F0-D}_VoWIZC5TC^s;?pz$Wp&CDA|0n1qWyf_y<^rV!{^oL&&iS2S^iQ^E=b!M z{bsC3k3rMX!BjwJ!ADNy#l@_0s*AA+jlcp^)NtuYQzSqV3Q9KTt+f zu5C2XUZ3CBX2vBk`utJkI%XlI-{J@AySHpElPo@Yla_rbka!90LsTq1ciJq^Av|5F14`VZPru>f{2{5SL~ryqMe$em-JnOHR?LA znmn|6*x31_frln-pV*=~#}t5=eCabmtJsnw;Rf+OCVfv=SyZ|f(l>m9+_<-CJz2Z? z`uW7gKQv^;CyNc&5oKP(qe6c?X)vezy<1y9+BWe$uIA35t999fm60`bCaGstSf-Fx z>WB9Y4e*E6%l|yA^4{QD-jbJT_I{eb){f^mA7X7_^s~jfCOcuEp_@~j0 z>n;8EPEIfU^DauN2k!<$yL~Cs?TIEU$H^3P-aNRh0&8MG_|-Rs4y0Zw+wdki7i|p9 zH?Bbt_mnP&$^?I|PPBMzbNQ75lSol%)=21sZ5lX5ptSM*rZub6bW`~@SLMIBk<1JfTrl+hozr9~qKyQ}_FB*D(qzvzImRnC|<_~1-onow? z=|4)tA2(J1c{k!jo!s~PR(~$=c<#S;;t*w%^QHsUt};+i_#o+Q{--N}9B`Db>v7~i zMTii#hEaC{B4ZlnTBjrrHCnfgd*2aXG>{n_g zaM*>Q=lAXEyjz~>%LH3!!!fDF;17=WyF00*k$f>j4kkh|7uR`)%CdR9-Ir%9Z*1}r zS&_EKY*_~i)^~?2c|N-Yg((Yhc>ZL5qZKZ>)@G!y*$NDVyi%_af% zU+Xd+Edgl24Wxx)!zE0`MKkW7lDCVH#?ViD#tRZpSkA3>rxPb$m(cXQpIeG}Tc%fG zi%l@KzL)T8O+kA4oUh|dh_C*0WBtMI4T94C^+hz##JPl$R>vuor0xqJ6F9SCDt}qB z0j5$Pe@aLGEv6Zf#Ew>Bsw@$A9TU($blJ&Q_65Uc45X!4nApdD6Z$1qJt%-4Jqt@p5d>eu;zD4WM6jSGkogjZkQSwi+h5ldk?RSuigBhGf|Be3*82Ur)CcOB zED{b#&y<=qFplCGK|08;me^KdJ$TjE%jgc>U~7-i77^twyq?|_WK3tPFi_oU0zLFT z&}dm3{ie2`1G3?A7i2vP(t47r*N*t5twH9W3u%J-A8WOb#g|olmjYR&vw4jdmhI~S z9F?0+nP`LUU%$gbd$0QXF|Mf0>{qxcX-&-2O=~sT{XythN9~pQ9Hly|2LPt>%k&6& zc~=+gXp?x5?ELvUt!^bSR(^X&2NPw!=YsvJ=jpL*hTCe|H zGw8e_Lby00_QaEeuY+FJ$J<%xmQp_HceHa1n}Ymz*X8RQf$mpf-icoF%$oU@4x(mB zrQ=|PzzM8TJBy5aJPtav$Mf%m6(`her&YpoGWcQY6>JMKLe^}G&D4s z)2C58>`u99snhjab%BqIPuIOVFt-IoEF8ApWGhzh`k-;HHQ4M-E#_*rAOOR>_f^^b z;@#Os@-@Vto`bylMudsI@M<2j`G)qI!Nk-l)kp`gxla)%quj}3iL2)AL#C(K4($$P zK=!=z0$-R-l3?ytxhY0E4}IX+q_$G1cR2iBscm!5He7T`ys@lkwYDzDGj=(;c)UO| z^sp+)d4fSyac_hD*;fP7qnJyRj+VTxX;UQDIQCWv1&#+3m*<6Kry1*pdz2i!nFaO;uGFP~jic`gGIgCxb6ZoCvooBD^Z%jnq60^De|JZu(c&h(5d^|Iv z$d;8Yd(UG;r7{~s-wsXjKtdsS7>hu15-rwKv z`}@=5k-Q$yaX;_-x~}`Wod!SNekXKU?O=w4@|VC31-D6-3Rs`d1q}6m9J55GiYXXznj`bhfb`6Ea4T1Jq(X=_o| zVzevVZ5;S0-^U$#K}oHNKRaML6=hTu)o;b9ULS*nsD9$Kn)-A?L5j7cxRvuvo-ay3 zk-iF+MRVEX--Ggc2IAta&G03WzJm?hNpZf0qcNBF%B7+xOPw^k%|AW|U+!_*OJrQS zxw3d8IV&#sHiBBK$=PGV+Q9i^&5@5$nm51*xXJa0U%F*#367QZjIzUaHA<=y( ziatS#zTO_&t+1>%j5Kx|yer5{ERyFYs$8jPdIb)E7v_Ty1os3J(ArEk1qZVlY{;OV ztxuX+r_7E~7YVq~xA$eJ2wK|uX9LV#A?+q=sZ2l=?>kqhk;yY=g3t60-j}%z+B$e>5F3m+O-jYPDw@r>{Qe2X)zc9cl`fBW+^y3uKZq@f+%; zeK`GOqioeBIPTVx{q9CPmCKwGG)GK)ylK3w$jH8P!|cnU;Hida1KqZ6QW4krNS+yE zihR-=>S&}`RcA)I$%yg)ZX8%hdo2~BS@IVmipQC?-QhFuloGOg7k;wTR#t>ass3n4 zk!SWek)4fP`s2+@{p$x2AOWR4GIToS*d8;E>*RJAo_;>nw*X3=K9uI`#|R|zkpIJW z+a@I&7_0XgG4TchuvQ|Au${qNo!tCAV5LLqX7(}0?m#srN-8#;R;TAJZ{)*;M!zi{ zEG9!Fq&|?~MW{6`vz-&e61>@raaeZJEr7CxGc$TaIJG^nFmdP6;@9B+u?2=@7cf%q zO2Y83Szng<`DqoJFB5`EqEmj}6MXU3|4YMSqD*s)BdLLLb8*>l=7%08{XQP!3h1-n zlg`9hOS{;h`QB>E>~fU%7roHWb-Wa>eaLb$&B|{xPe7it<(O2+@LK0tLfdBSt}C7f z+>Z77ddz%COH2h0jfStWhG58+OW(v|HmhOTzRy}v|k*H@R%Txb6|Z6NigYy$TW z5FX%!MCXlN_%K!D$$NoOn2*yTLMKJ&o@S?%Z2HUAPx=|cILGS;@#-f+Im6V;P(^w3 z`(=!bZB64(gv%veuk7~_mddI|1|IZwPebCOve(=mc=%+Q_1Cw%gXY0)@~yi)5C?zP z503`Y*4|N+6z!4DjDx`jM27Sr*U430l&-ir#C<2xoU#u*iIA+Csy?fx_8^~~bnYE7 z!qj&++F!sFE4aUeMw|SMBcEdN=P>e!gLMj++qRX`x72JcEi-;0$TbW$krVr#L=ag< zz_h9wHUoq@GcP2M7(&QlhaWn!+_grG_@^lZQIqHf34Qd^Vh@)yw+`QgJFD z@WNv;UU2O?C{^lB&Po-NunH+nz5qp>aMtxdibGx|-=n%SqA7RE9~&)W$==lZJ#jKi z`eG{lW7&kMO~Ln}N8J3(5hMo@O*O-A0wiXYw;V0!a{G0+?oPN_8GhA_MR1{wAEI>{ zPY=4R?~tXh5VXvNs(w*+tWve4ddagSzSfhTh?fj8f99MW;%A>TOlsv{GrsSK*He+z z_KBH!;-5`3MRUDJHS~V&;T_ywuVz58Y$y^Xb8I0A&7|oBm~h##Pn2I`){bq6)qt~F zhfBKiZsM%^`>lGGb&T4`zuU?P2*s}7s&{_|D312MOl!D%OyW0gn{Rvg{`4R&b2&36 zJqJk3?#rdm{^EcQYxiu)txk(QiN4jgI<2(cLw}aXufersK**>A@QyOx$MKV2u4{vW zQ1=w{N<;q64}G~!ovT4Vhn2rmXPYEh&XlK<-1B%21V}wi;e#_!XG&*s`k-P_v^HjRT@N$OJFB9+icm&i^{vU7-Wt8w61nvN#%ShOMwdt z*go+qcyHoALZDqRXA{EBx~OAfaUIE~0KfyWEdC4!*@k+=IiG={*b9!I82%zokL?=I z^)i%vEuvObA*uTwJA=+2*}9@RALR5y(FP)bklB+MfSaP|?Yfdq-NoaN%H>~I$087_pV2cUe2 zR~L`MpWl>SuOqmT!9cQH_diJEB9dUZe$A(4EMvJgLoIxWqBrHDtbtrV1gE6=cS*6X zGxY&}M0ux8HA6w^89mFVACd_dWFOQIc9J4xioe=Kgy4s+CtJg@qlT0^CqbSI+~b&7 zk2|HG^8D9!X^$>&yi2E2x1IH`=oc&JRu~lq_$}l1dd)}+~A2kWkXyv3V~{?w`bbV zXQGvhml1I>N8PX+kW^8Oym1++WqS_u+Y=-njbi@?YBSW12UXruQ!}DJa6kk9vAFmJ z6r8;?Oltf=rPne~-b|oYc5P@V)=Z7$?Bt1F!ZNJ#NL{;BH5uUqd-47uyQn1J!)8QL zN~#Jve{x4zn+)lcFke3vK}N2>JR6kecX)NRxRx_aIz1CP?}8W}9WQsks5m;^C2w?( z1V>=jG8|+75C&+G`^N_V-93N>E0Mr7W$>AT-&oSF{R3hqhTz`>hsor?%V;#umSp1j zb>bNPSd{vzT9D@7Z7G?&Z!}@~P(=C- z)}M)&%}bPmIXGU^$Q6G~;%z~%e_p-v=8qJ-D=}WF*OTE9R}_z|x67jZdE4(>*2qIK zzPlYQ*!EIIQ>u%@Uyhk|zwmyZo}$(Ue3q zb0gqy6txL{osF4a&`vU++<$D^+RP-1=A832L>O@P2?%Mky$CBna+%LW7q8wUIkKjK*`P zy2hfaHt$RNOkccwE_jfzRe&N#n%O|&LpPT8Jl(!LKMOE4I{(7O;@Tcn+3@=GYy@D~ z9-qPON@uG)+y6(6>TJt1Xh>BD=Gg}3SZR-gw(O`OSHhix)i{vX_KZ(@&aIWm->%th zLp7_C~vkmY_^hAH~!cYj??m znPd_c9N?8TA2pZzOPW}L$Z!5N?L*2^lcqRoui6t4{PUJwd1|@rx$~2*%=T6bGO-6(WKS||Tw1BK)GjDNF&RFkg_bI_zS6LT z*~6g~lc!D!f^Rt(oIPF7{~JLWkQqJ1xXhw_8yDFAzaP(|*tc#@FGDW713!C?IQ)?} z>sGHWQ*ZKILGkYe#0u1fef<4ZtX<7S_r@mVL`O*ZN{IczeqtPVCSl#2q>pTbtos1Y z99Fh$9JS*-<*&jJBOV#0{U`6WJl|vc^Fz0F)QA2CLA zLYsgD!Mukl(@kM+U)xQU$qosrAP^SdtbFFntJIQfKCxcAMX z-@S0PRw1nf1Y3Pah=^)q9}o79ff8Hnq%%p@?=pT>@#PiAN>>^>NLN7}M;CVt=)nY*!|uR1_0uj(=PU85Z?p9B10-tM z!w%!uGB;AAnj7}rD4rnfQatu44*e6h#tuY>q@d(w zc1`Ga$K9*)$?#mgXxm@@J3E#b1G+;<))D^=uTN%=C&n9*tlUX&Tv$Vug=e}w!$&^V zh4GoB5eB4`g526@HyljDKpy<3N!`CU4|%brO=#!SSrzGihTHr@QvLe}CpN<0Khekz zzobG^|2_6N9`T`v*v6nh?U-+zT9GT*@jNWTKxM+jC5;U8+K2p3kStYf2L-bCmMFFS z?;DckCDDy2HPjQfBXP3%>gH0)v+4@2mp&t|KDPXO6kVRZ4>rB}nBm)Bn3{aoR& zT4(W83-qHRU!b35zHDJiUvqZ3c%z(upy2MY=w7W8Zp=XE6W0f8QZr@TYzCTFe+9s|KFwN2) zR*FMYgcLgdvWC&_O;zlB$Nf%9&-s-XR2J~9qVVe-8!_!oz8~d%TccEKZ&;1oi$8d( zxe4M$OC@+Q^nHORo`{QL`=mBmN|D;vbBH?(=!p)0bL9-`3;*&ugv8<1y8NgE?@6v% zP`I&~1}7hRoPlEY|Hj-Y?!Qkl%;+hjeaL?Ev7zjk4ULiydB9uNoMcWDe@inVk$g_` z2tlrTF58gdb=Q+YA_!%#i`Y#XMSKWVNp<`kmzfmHWx?yRHVCE-m`bdm`8Eq+VQ@D3 z_-gFYoRW)sa?P8p2j9D)hA;uG=YuN$X#ZwhakUT#T>P0qP69m9lBYn zWF%}wn>DSesYwe4cxcCS-l5W;iU)4^k>_bR&t||R?jB{@?ZW&1=3*VB$kXZ5!{7rf zD*SeWT3K3*m}j&EBU07kEF%OG$fal#^9bsFCHC+iB1>LO^eFT zxMF8|&aK=1+NGsdX0i_%zBDtkhZMm*VT<6PX(g1-$9Mn2wagm=pG#2`1BZm_YYnTm zkUw!MQX+Rh-G}ENtTM5%2<`Kz!Omv00D)8c=4_jkWB=7yb|pH{4Br9VNj4)|82U zPmsxkjPs@uh9=x2Db7`dJ-Zg1{!530D0}|Z{#XPL8}UG(FD)o}I{}Mavl;MtL85|| znJ5dFI@T;w4U_m?Ox|zCibQcHsTh=oIvexg%0M@fJ&oTD-mwZsCaoU^r}>0l;pxj+ z6E5;>Zd3J?!NI{h%R}b@e12nlp=)ayG#5AQPqo3ao<{&%m=8b_&I0k1ta6_g&vb09 zQrjnhn(0^eQW(Bj&b8m;_RW$ie^n8s#~WfJ_BR};fy3MKH0}2IPLBKR&l!^$rMDDD8d&^H1?Ep}g^DF?8^X^NUcARtWCB=VWD* zCO-{)k6=yoEaEZ!4UYH^Gv72~m04pmd^J4@@rFlF?6MpKCPXl0czp_GE{X)sZq{C@ z>M43Y-1F&G5#icS_U%XnY-^GB-IV?QJCzghOz0MR%d4rPk8C~;t56VlgGu&G7PYP} z=RDGWX1_dY70e+~ze#+8&pHifQ*R<*(+|J_7$~*TDtuIV!FPS~USGxE5;9s`ap;!Y z+Ul@;YnQUS3@kV$?`+=}oMr-X>yIrO16hGzcgxyD^%gi+n;V5$9HU=ux`C^Z8U@FQ zjtqB)@TtDXWJ^eYKqV=44Ofxdf=r31=d)!?*H{xvO|EwB{_NGKV_e~&s`NM2`rZ6rCVr5)hv?7<) zr5y-E?2ZWWhv7Oz=g9!N8Y|U5&)M-S{~>45%#8IGy9DsI03_O(&-#&sb)2I_;{nfH zM@%Fb)@0WfaWUZ#J?WU_env?~Qz{m~^S~{*1v>LYnaX{q)podK8F*2#iOA1&X!+G{NJ01*bqv+Ul z-f0B>ts7M4QkNxa6;3a7e3WVV-l&{5`x7cklrUTdGJw`x_4k+FGc>g9bCMixMBe=_ z>z(BqVVLoYOi`k(n)Nr@!xc4ezqsnJLfi}Ce(T>*d#p;*J;~fKiWJEoZq`CeyqeK> z_a4_1{m9hNILdz5W*e|xrOE~I7pn&k14Gv6$)6Pozz9(RrZJ^-_4Kgmv?5RUiUzhX z+%Zf#n`uME( zowY4V4ZDZ#S(a zICL|7X)dyaO(%fYmtnN|tV3~0u7vTAsN>pBfE7?ckA5F&9uHo*^!2w2`8<iao9ceIdjhOtMWo zgF3|MTCYV-An6+b_SLeE{pZXopdDxQu3@j|jz-hrdfjJ}5>7zy71v_a&M0vq@rWn% znNFBSyaNEXUqdL7lzwbgF4{d)AEex7p|PwAmNQBHcUbnOm*Rcae%b)%Te|KJJDk72 zUtfl*Ag2|v2$5097*T=}&FlHvOuL$|Bc5D=4=`It=vpN%ZiHPegTZ#ugmlD;{i(^C zof4SUp~SvFe<0H^SWG=`>|xyTFuW}?UQ6oKmf}K5HTQJ})%?|>BXD= z^&9KnxQabOM?%>|{h!aX3gwo)_W?}T{V#`3Gwd8&KJC#Hq*H%Xh~L`2(!-38Z}3R- z+T$H=G<|5H`^1r9=#$j_paUj1H4R0lhJpu(ih3wUH-7s(S6~Q`fY^bjh`7DpfKx4y z1(>7V%!I%vZCUA4hN`zQq4Mes{$K^^{m;*@4 z{qo@jfT#xlA;e_43e9o}5e8O?imDZp zB%=DB1ddMv&k;V|RKpO&)gv$#ghww;mPPsL=SeO^9r}%%rqo6fdzP%zphrJe+H9T?hyDY5JbLFV$hn^H-1D0)0of`Wm>H7r zWSn}@hoL2PxSEPVC}h+!K(84wAyrrxu)j{1o7)%C$ou!+g?UfdkRy*lTbDh@7%ILG!D2u=lX3RaYm6+lbd1gvkVhLC)B~aA>F#$@qvovML;=cdQOtJH(LZ*7^5XRm8KJkoeZLfR+2&31_YJA-qDsp8L0%pAU3}wHZ-1s8 zTbc!La*)tHsg`MaIrp9rh|<7MV-K=+CH*+(e#zs->gdAKCWv#7jp~;)D_HxY=U4Bk zt^`WDsi7pn^&5WAjM(qO_NMDNR*_*gnob+*m#{i+{^=aMKsILwUOXW%R$$?V`Hb%7 z`dHbANf~h6e9x|1M;AM6PjiGFHtd}s7%Jn6Yt&kj^dSDD_b6n*4n^rfciZgO&uvJ_ zquRSuPewoA({lTMmSeZPIN!#dY)4TzG8i_#`HXpu&`0`c1-7E%96+p+!lX!FikL^2 zA$3MxzMh3Sn0Sd=$d}=%pj{AQSMRHZ%i4t6`u;OnU1V7bZKPdv00%$ zk5f`P;e2mP3Nt=*v%!u%S0D{edzH0&@UW5pB;W%snYO#tlvGcgfLI|kk|#ho7&$R6WyV1zm!0`6Gc>!E2tI(KCSSWcGkYDX3z8eS4ND&Y0=nn7?!e z2@bO3YJe=g_-$S&zW6wDAgEb!hh2_-zSDqqiSG^FqgwkK{7XFZ0#pSQA+k??-2S5` zZeArinW(G5-#j>vjvf!UD8?y+(}bJtW7nDAXJWyt1Xb;Iq$}8@>J;;OsKm|6Ps-0V zQVvZ-1B#l6*|^mwNL+47VShC_HN>#&~4eS~khZ{lz|4gt9kMP>uO=I6TWPN^U~W zObJ6eCu&x1J^1mCm%-O@mu<}uTz5*J%i|hlN<6HvGJCkR!xeuA)AqRl%*BMYFUL{B9gUHD$7d3|;;j z5orgK*17%3NRX-)x`G%mCD6JoN0R>l6Ml92rv{Nm7;nn8i?S6~+uzu@YhcYItkhnN zz1!?;my(!_sBxcs|1gs@`<{{)r$3i$|Fa;Gqv)YdI^h;+4eC_UM!GOFoR;MJLrHQD zs?LxPEvW%5mT%Lh7IFL$`KPv29r`4_Ej;7=IKHIKHS#^LnjTJf}YDdk5E7Vv(r)!$Mcm(Tk9R{e1-9H(#!_! zEDapYMsl6pGdcuws<`ga`vfY1dHT)kPu7JKKY%xJYncb!bzx54Mt$Mvp z;};Z5l-eoskqe}J+VtrK@mc8Km^+|aS%qBr&CbkNR`>Z0%lZI*i~t|x%} z!>6BBt}$SmE$^KKzJr}11AdcKo1W>QLoqKLMr>b+n1AEd&e&kj3gsMJ@*{G;ZEGGi z59%rEeMR~x;3(<9cl*sbP+K#^%WjN=f-uEDwclBz$<@SVDzBy8j{(@eBgTo9;z z299ABQ5^pye2CLJw;^NMb?LDXJS3Jw;d=B_GqR>7Cv5r?|4ag6`f?bkN3SW^M!3>& zLQM1Bhw!?lxu{e)dNM*l(%Cvy!LQ3xKFcZGvl!>+|CxYj2DXR>LDvFCK!{z5IZbMAv#-IF*8_-Mcwu5ozpqM&2Ci?(4}^oN1Ng;Ms5xU4wTR1LA_8AQHd0N+HScUE zs@6&CgBH*AeQ}C=>0d>~^Z$jlF6#?~6e;`(g3jrO)JUKXj zlLuSl5t+~_7_=|*wbx?zov3X#_j>NDPKy*n2MD7)R$@Jxwf0x^iyz+aFIN2 zl$>WpIH1HK!p|IlV|tFUmM%FP=dq?>RIVmI}(~S5d zug9a#T{Owb(ua7%8V?)YEVak$Dmy#hR+L_Gy8Ac6rxzL`au#=zD|{;|@K^chv*DZ; zkL(J)W0)m4!3cr*uf)~ee~kkE&%j~0%vGwTS$U|R8oxC!7*QR|^Cp^kthr*?Ke{22 z@=KuK)sJ)x;}eLER#OOiTfp1ARh$T^6PL@JHq8O?Fw|T)_=HplUVmYC5HPE@RErI& zS=d9xbEO=6>$l3HJi7WU`qqFAR}9d8Uudp)%%nABN=Qc)bHb9UqnlB0)=2i(rc5;} zzGhrFYn_vthVOJ)Z;jAFmc;~EWF*qW5OXih6t52PXyow*dQHTNxGV+mv1oHe%t?5& zmwCcw)?T7|-njdh(x_9k0BquOqa3Z%#Th~WTaG7lu=ILS7Ec})Sg>6{NhwFc+}8t( zHchL-q3ec6NBF+B>j}4-KMoA7q7Q)^Jl6+M4i2&*lD<4XMz$YcPCGrq8`N%Go?Dj6 zV;&C+UV#+O`$EoZ&-=Tzy>X53k}xa3v2OKe6&G;!2Fjw8)>O?IZifWUar_ywwcepY!sQpZ1;a8<_sJMF`w z`(#R93mWgkDc?3jbW1BE{K2az9^rC&nc@ga!pTPQ6sx+(e;7)HE6WSZ)ibJR3CnXh zYZ@xMc+rQHvJ?fCbLCXm$~$AjDo3^Ymo;cF6w$QP*uW=A{b48uMqUp=vjdj3J#w4< z3|hQOfLacSzKKSmX-OX*e7w!g-OY4+5dR1G0_d3Tfr;V#lHJMraT?uHJF9!YhjlDR zzDYBu_Sx@voq|c!36W@TA;avD1$%r=q1{4ENcEJz4rE#O1noWJ-{Ut08FgtVR}c(DpII_j)T*PdCqXS zi8!;UX$XLabyldVwr1KWNAXZy7Oam23J;C4=%VAN-N5&~m2);0wft&|#3DssI($8qA5(_pn*p@~<3W?o1qDHAR9`-vB_|Fo?yMGXi268D|b7;~4OIeeFH zNu088d|H|BRP8i$tNEP`n$$q#E27z~b+SnwsAH$N2=JK0nKFE4t2omt^ewj*dDr7l zu<^@j(+c~IVD7~Sw_o3E0l<;K1B1|L%BL6)$Ykr33IW0LK(Z^NSO zoa=|AtPQ{4DiSOZDE%~@(=JR*U1mC!R;NVL`vYhI5aXWMp{B2@Z6F)p(H|PpZHAhf z4)Wc3Bd5c3l&V}Ap0+ksY2N7p!BsW~uKlbe?f`>AZoIs}SZYg%-W)Mu@0qs_xaeO> zI3Bfn&UmSBBmF!tllc6WvdRYT%zw={D#c|_Ez_5q1<JJ;XXD$OL_2uD_Lf1)qP*4zYa&k3dQ+Le%WV^<59*k-asx8^#7klvB zFPO*UQOMoz`iTM;K`*DMgnib21sx7X<}nqCnEC-mF2e5|Vbk4Dc&XEIZThz$?99tFcrYVxLV=N)t zZ&9Vo8hCoV?}f)*%ot4u0(;xfG&giAXz@wxjG}4yJjViLSt$*SQU9 z4+krU#HvT4GmIOop$KT7b5N|Vi0hIs9|ejl;q8R7x&DJWs-*VxX z#kNe-JWJ03LTNnEa;9hY`!)jZ0uk^ZBv`b}W8of_jiNI#SkRYpr%boe5eqBMV^+yW z6>nSq1zj#9C#YS8Hi3BdKbYs=gt|b%MYDpFSJHOMUpGzpvGtJf7=KZ&_~QgN33dD@ z^gK>E2vzD9mm%#C9U_zSRsj~|{`&D($IEw*9ujvY@N0p-hTJ#IT*{`pg^q7|zQ?aX zuAe)Xuyp6f^@s1I62o1%)O{=2?`<#kIOt!QMc_S7cbM$a zsrQ;8kH0+yUVV8!uB<5Ur~hEez(;#5{$k~yY3l1Q#X|2}_psW7{drFfhrTVl0!*-m z&q;XaF?wKBi@V`t>~bn0s>g!bJtm(j~9bov3wBNeZAZ%Q@)OAe#6X zvzj^US9T69i)pDH(=X_4yz0`>8@Osn1W)Rw<9@f#hZU@lqYq8I)hBj%1%FfAs?>|U z1C|ZO187;2%Fv_|FeCERD^UIw(DF?=_Nf1m&r>ZdpR8Qeuj}yYgt8aslnkL2Xwntq zIu9vTXAHVw&?QBDC46JC$F7mRG-ON~_6DDn(!C64_5+6kD92< zR3qDKWg5-+dliyCN2FVG+gSaXugT#L#o^w0CS!l%)bT1zOSbw_Z4(EySK+-T#kOFD z=fw1#+}2$uzIK34p7sa2b`FM(182|Cy+Tu|FFxHt{nTYp%u}GU=U+{?V>RxKWZaAW z1ZxJ~X09yxz31PL6g{H+EYn`6gTr{SKVsKiT$y7`w;8Bgk9d#WyBg@)tiWS`3&oe-+ zC%yFc6}|o<)3W!g=}F(rzdDAN*`e-UlG!LgCOR2FEdA>#*LM=kYe&MNSSP$nTv;Z6 zCi!N5jo?49n+0%u82t|J22-hBJj3}FRX5Ivcou$3i`Hu?;npaAF0})WhCv{F%_x#A z1r0CVddT6*@%eH0My`D0FVXL=J$Ro)^s^yguX}GYdCD=ugFZQi&GDpL!+kj4Xwknd z@`8UlK_JtvaGq5yuqM{5DX4*g`@{L88p3`zkKl9={$ez`QeXz884@?oB6 zoklTSLXP#$+@t9&nZ8KtGmxrF@xx*W=kk-5EK zZ`n#~z~7xVK|GEF^wrPwOv0{&?%olYFLZ6Kig%5H?QJzEp}L7e4+ypt;>WY~P0t?M z6ESi}NgRxErAQnwWIxvqH=PRHYes9hRQ@h;{a2{BkadWa5@vA_gtEioQP5S8q zuNQ=ew9xQWyH2} z&|;XHd`^}`JUCyf1H#Zx_iz5~{`_z9%}I%Q-f4Z5P?ReDJfa>^9g?`|0!I=MnYJfv ztco62FD&#))b?}*9b8XV^2WZa{!MLTH+&E4EG3%mKCL9!&}_UU8;teGp!gfQL{~eG zDx~Z>qefE?zd|P1QCVg-=>+S*@7y=??jq=Nb&}{x%s7h77aAj4TF*)wKpr zZ%YPQFda}Hs$+T4Ov({@cSOpp^O^BwNle=}E_5PR^`ZB+(Y?24s-^#e6b1mKC{m$T zjPDHRpOAFu6ot=Y>^?@Oa*vUYR=_Y9Zs1=-xC9=go+qgaw!L0TNiPs}KK1280H25{ z_+pjmuM#$1d6&`1wj{eBJKe86TvamD?vpM^^HNj$Y~Poxr;%p)1mqYEPqz}jezDb; z!6OKJs?YKtrYd^A_oF!QXt%>11@Q@0SN?Km@CU)dDbHqlqR#u2`gy84IIX2^sH(0Z%I1<$`$Xl+TIrZ0xG z8{d{sL7QRysfxwsj{wp0RztO65m;Ux#$sg(-V6VKYr6W^L@NFDxZAO5O`M5&ddxJE z6S@N;y0rX$<(l%`@04s;0}z6~Zr47ydY7F$gNqkQ1ZJxMy-ZgOg=Z+ysJ0%BZwujb zo&i2=P&-j6!lCEg%~n#%_5$>7GHz@;#QOx)KzE!)uyVe#GZ-JE5hQzGR_(=?S;$d_r0%IYmp(fj zH({#bhS){Svj}4XIBbGi!pIV|dxOm_`ZhRF6@DxtelizgBogGZfavZlFHM_g_XUns za@#jN+rMPSpmVo zEQ5RF*J1~HIiec1!^)2JDcsa;`<@WrAG?EkFB$^ zO$CpYFCXtu(?mjB#jd9^RxUGs${XFLP|*~5y|S$ODYC}CDl?IjBC zeI!v3wvFu2V*5`^By)rpy_*s!p0Xa66+Nz#Pm_0w*3VGBV}6n8zRhiUuGXf= znO2-&Y;t{bOYvsWFh8^^#9aN^ZEavGSqfc^{y#?$V0#|o{*47qmnxnW_PgCeYFoz zaddZ$=kl^XR`aX&6h6|sjomlya8Wb$=vo{9fCxdC%D2jE4Y9Q9ZAfdCEx+Ydw*Or3 zifdA7RouTrD?nQ9^y?O9ps8Ohjd);`*wzEeKp3Yz&_4-!Z@NMbOQaWE$NFuEmjY)N zAx!-;78B#O>cDQ(xb{IXX@o8C-(#4j6;ged5&A`tEtC*LkPocrk60}r8F*qq%Vc$_ zb9`EJQ=697#Z9lcUPURwLz^rc{_8=APs0J)wql%5HS*ex*b4`sz~^fk3xkR;Kh{r` z%$YP8^NBsK8;?W;n^a}kexz?nDMi~_6y~<%kF5@&Ybnt?f4qV%w%vP!c?0kDq;wql zwo>GmJM?s))T&dBu@3egZ#VYYRc2PyEYugST;Gr*n)n%!>2^{#-(VaQ`o)&#Ea%3& zd;)?~7~VVijKW>PG&n+=Ye#Fc$dJpd0!Q06Avr&HeoN=2x(^j48yv;W7xHrni^j`v z$r2EfdP3{t6?yf?z=5S9{7czQM(#BC-;_PFQqz4Bnd_6U_Nl3y}ee~b2gh;K`RS!husRbzUly<=!9vY zI$My1n^tAf^;8dK7GSG0H^Limxs+C6D*63qw` zQw{#y-k)^8gQU6{WUFnYTJ;FFPI(^DnpV37dE*t4h$qgTl3*F9TXGW4TLAO7S4Sro z^QOfEV*#T5-CDGZC%t(WcgfDe74peXUA@zUijy~k2)mvBf-eOQyt0tTVhl|`XA=5X zKS!VIU11_h8<4rsUnW6CN*OI0(Xk?2l2;#x-#7UF^=f=)_jv4mTOu*04O^BfJ2@9D zwah}=uF_cRn+(7-z0OdCILMm1`m{iN5AW?nU%tg}XaT~K&VF%HMR1ChBKb0}CZ zV~;$3k4R`gHuLuC^5%2Fff*V{W0aG|pXE1-AjaBeO@iz3)_o}*kdsuFjZuizNf1Yl z<>K)_d~g+3apfi+-aQY&kR#Kda)fd6!faP-D7_Lt3HkvG7$K`-_QpTIMc^xXru^?L z72Qh`^h#viYb>_0I=8VT=ckXnyWj`sTGpyu#>0Bk6(X4#65(iH>3FRI;UH`R-bz(47CsY z0*tq*`IteM6K7;tKk&nOk}frFd^vk8+7o>jHtsnP;NJx9l1vwLT*H&ZI$GzXwHJt`~d|rG2*3*m(mPNvj_ZxOb^h z#EkA$=mJyxi+!P1nVgi3-yUe2OO(vk1DO`Hj(Lqt}kPqk~9#0M8 zZC=uLpTFhrMcI5g=EW9yUtc}J>kcm|Ud0V$M{!fuZhc$ml;m6jT8h*Sqb&)5Gn}6r zDw0oJi3^{G(9&~+5=lq643YFk(mGRD3$`n(?Bjs*y|8ce)c6M)t%0Y$w2d+vE}@dD zJ*>u%&gKuI<&0qunj&n0$0HiT9BUl#!q6k*;H0?SRtMtaH=NIiQCgy-VCrj;#F}g3uubQmw4z zS*#CoVp{<3`~CbSHH|WU;3rOFNF6_K?yVMR#_5vz;lKE8UKl#}5bd7hSSLZDE?Pxd z7-VBDw>XG$EE6tLL+LP}AE-_{ zHM>ViG`s5Dft3e(gQK%uMt!IN=@_ai3h9+*19GRFCmL75xPsP?LoH(-ZI`xuPa_tj zs3Zf%*94W%_1Z>U=s#r&jtqx3?yy7rO3z7~OG(uuIL-a7oY1GoU@lYcgeKr63_t0z zcA5KtYcemtHP=D!;qd3u#`)saP~9cI&-l#tC^4!f)+)U>0t?$?Peux_mEP@>&Pl^* ze8>86st>(kaqS=!+TjvTeNqGT3{o~< zSjB4ApR}))M|}P?NTmOVw9}P*T<#_Ih{YqbCQt>6$#CDN1;WDYI^RCaEpmEGj)tZq z1Wokh)FbWe#RN`g?RXh=Z)((b0H#l=HglyWLyiWw2=o6+*=XU#4$udp?_tlx2_CJq z9ubH#S4MO5%Lhv5^;9wWHGC2B^bvI&)O0k`HYwZ_4hm$zVUCvQVXIupCOaELYnW|w z1FE@pdW|p;l%grQU@H0ON`G+;zoE6lF~AEY%4StanvokKByF#pg2-drVzZj0r=Qoo z>32<}T}2kS3c2q6ax|g+!+xnV);oOBnmvFeIz9O1oNrqkb=Mv_4SuNoP=F|Hlw&U| z_=-lwnAPaHM|;H^m=k$&Q=0*GI$M$VXaGZiv?szLD7jgrwZ&` zJ{iU)`|iO42`BeRm<^SO*v#VpL)cq}Rk^Lt zqI7qsguo<3x^vRqdB#N7+H38z&-eY~@`v+{@s9C4_j89T6QfS0XlYx^*%$I4M(dUR z?q!Bja)Hq*%L(}NV@AHD*%}YYV~B?>dX)`2R@L#3GoDf4N4=yQjg?_;&`!~p6g~+; zKo)|Wmm77x?VAUwzN&T54~KuU3&qHgMVa@7*EjO3bgrSTzP6A$=W4Ug*YlVO5xAtn zQ@09`N-yLg^kqGN$KpzQYSF|jCEBvF1uXtddq_6RtHK|aV+l}%09`Qh2p6Ec_*|qf zpf*{CZKX5fc_H6m&3VXR!zV=M=<7_A-+!No#_i+IJd)(Q*-uo@Mu@x&l5 z%+!iy1_aP@PuhM{eiYY@agq1Q3}gb+^gFi-xxe8Fc%xQwAN%%+P~&l<5b`9WK5RMW zwy#A*r~EGapS=&&%EF`z^#jKY5cB!Ndr&;jtgT!4Pi;2O4hY`~bmF?BwvV`v76K=2 z{DX+DT?@DBXVAs{^Fme|`?SBG*&XN30t_8L)*iYe55Ej(KLyEmYhgJZa)|A6wdDs4uIZ?QNap@_ zH-?DY31@{@#Pmvq;UsZ0PW==!GTE4E)1{M~2M3mAsbVOR#GW$!Cl-kFO>sP{!h}y4Ur8;si z!V=xfWcJI%{wGg$GW*}p{MMHLW&u1uJwUPsba)QsLbU)jw3zg^fgGZ$cckYS#D0F# zDmp)co{iO8aE(|_`9db0^dTLPv0?+#k#nn-pGs!Pl`4hP-pdRs`q#egr!FlId#_y{ z#1xHF%`(5OMCgIo9@QSH*0>go_q&>NOtPoCPG=UBYSi@2_STS_aUnMg=>%+=2|%?= z?wO5Peb0617%A**rS2(dHE+^AP*$sQd@mFee^tnNk=it{IcfFal7P9LjUsmZ=B1ta zMsLzoe~QPQ_QkEu%Uee9Q~_u{Sr;|#1Cn|A`G|0SHHojAA-T^p-UVy4uf#uP;ylTE zV^kdVWo@4?x`sV?Z0D|lu-x?y9#2;i{yg;|-u_7|f=d1qne4~)*fa$TT8Ftm5xW=E zKH}06^cOthw|m->Fk?DWtphk{j&j?0;XX?BYLLVfC}=^Y!%Yx4wD0bct<6&(vS;xC zN;$&#(~ch(AIpqbuj}TTuvWOd4j^3fO#x$U*OsVfb}PNu)}v67ofsH&P8j_D=)4A> z#3^_2FEN8cvx`DAr~R4`nOGKcON0w4i|(?ZJ-Hx8Luc>z2s$(t#~IU*lsX*!mBQA* zcxl$Du>q4{7M+EDB5sq$%PF_-c=oG&2#mGIl35~)EXJjjD2Wyqp^5K(a98xYzNWG| z|I|QFtd(CO;`As{V`?iVHe@>V+mlg;t>z-uh9YIy(q z&x>w~j;AT@vxpn2`5HAi0~}*HEA~2H zqjkY}B#|?wC|*q}&GpXK0q@$|F?JMBA4&=s4uJUt>s-CV9bQbL z_qui?dWVtZai!NR{(WliKOKzdmZu7#^lSLR55;77#bKn>kRU=zXP4T+Aj2<&luf_J zXDi2e$Oz9hG%+~6OeNleOKfv~ht~&OdZ+bAL>&6~D6-lg;fGWLZL}Y) z6toYhF|hWUbD77C&;I(h`pi?%HRMX|*dlbG$Qc#W} zs~!ZeK%gI?CS-qXdYD{(SFEF-r3so0q+i?_O}?Tw3$gd}o3J1D2w^y_QBT{wgD=N# zC0Bt@Xu9J|e8Ew6pP&Jvyvim3n)#Uaj!@F&w=bJ%+*7!1L~Eb0JRjee#XVF-&j*3_ zk%eM&faOUIW1n*O-=*il9q2VH7Jb`*rCiFU)Kz$KhPQSs0-ehS01UCV4^8oHup=9>TEA4 z#m!ftFJ|3@e8M%-8>==U67=My>5U5q`<32=hS00jIym+f#_y*lWvu4C&ksr2aKt)f zu}OIy=_OW$Ca`m7NL3NXOQgZBi&v_1(HE3QXfBeA?)y<7JTL?*I zwQA8L6Y|smQeva;1I8dpBCaOHGO)7y*@6dWEgYnz5n6?l*Q@syNk+M7Mcw*35qQj@Iqrp4 zm+_<+CWRlAu5&ify2CA9_I21&Do0y3CPT##gd_<)c~Y*qQ*g<=Y`pZp?( zONi)lYXyWYBsf%aBrf0z0$d&WOOXsZq1Y+x4Sk^`&mu+j2)N4%6ad-$o8QkN{>m99 zAZfDymm+4U`Rw$g0uh%1fg!W1Pr;>bkW?GR*q42WkKM30qI>+1Lp^;0a2DEtqhs3zAWGWB0_OZ<{H&g623q7+V2UGd zN-$a~TJZ?it14+j4U*h11yc(35g(bU*vbh$|8&Va3K^Q5MTOAD?1$%`{e(dKDZ+E5}rjbk`Nha^*Sh zW0z~B$-sriyEDCvl`BCxHWz?WSXXctC^+ePVGXW3e3GQ)R!9r!M8@dCiZNFO6A5PF z2UE?LY34nmz=2N-FDDV#&(uJ&g=AbFxK}O}8CD^buaC=T8#>|@CL^|QKqYcA&t8{k zzW%kXNx;~lC^_m9%A(^yiatB_nt!k=+x9$OTu8){Xpzbhw(MkzSDYjriQ;+T61mF!0%PG~qc%^I?4Ui2Z>IqGDLfYj% z1`>o=!G33D{He zw>IymRagwV$ck~0DPkKW5I!%oCH_PYJ&{V&}`LB+$iaT#{3TS_c zrha2&F;}>QI5qQ>*wLnFiVVjSw1X<&|y(<)=^nBNQun!q68 zi0~Z9m)&}TmT=|<9AteIXP7jqiW>!h6^!p9KgCKgiW9ke9Z{oN7>nfjvUB={r z$!4>%*TYCLWq8u>t=>?7zmwV!Fgoi#47VGM(0X$wT|ZaR;I z;}+pHIR5%Xo{`k_i2i+F8L1wyYt%v>fQdeQ%XSiAOV>4}6fR*GPszIxSlZ&kC(IZM zZm3OG8V`!r*JFzux&+Q_0XQlHB;1@XMai|=B{QkcW^XIGC^Yt? zY0FEK+R~6pS?1fLpVU1=hBpu`Dnn~8OYkm79;Cb^{OEa#Gyd|c_;4RJQd)o|N0Siq z-9=>}L;T}T0U}wK?hc?4GKG}fN!Yr1YE@Pt(;kFB2%Kg&XPdP7bNdxO$X1fR`^2)s z+jMh%fsn0?l*X?epUPO~odDN!-47T?EuS7qKHlL5GnQ-JeT)@{0-(RO9RDAE(UKrwWc=kN*Hv97t}mui^<}#2iAr?50Xbe zQJL_gVp*{iKRjo$qMl#u3!7Ddjkx|8hU6#~enuWZpASP7W_KDJoHQqfu1?jkhfxU7 z*L3wu^LzGrZBOyQEC9f~tp#u!f?zT+;n;{*af^`@M*eo}l)MJrpCfk~EG9W&QB|_3 z>drDLARKXj1;Q``HfVF!XMM3kUY_x(>Dz@s_WFB2;U`ZDFgFg*e3bjpC>}lVWSG`@ z3!;J?m#leT!^EBaY@vsFVLE(2aJi1nB^SIgF0n*Sax8>$L96@1(%$P_6Zk__Vdvvl{h)OG%K!nS zlhVyJq4F!=J&A%#5HHMCDY`D&Aq%xgKR@0I1}li$;s{TD!G`^5lI~=04F#|Lll75; zV4$aaahD4qhWtbckORGqd?CP={@BleG=cp{Vh-qL?^HJ#hXuoxiFDp!31-8~pfx?Qc0iz`MeUYxYP#UC|l(zG~O^j&uAY_Evte&iIR0 z{dP_G1^jGK;2Hm)m6Ad=)0u%V@VW%#lj#kliM)BY=^{vJZ7KTDC+A3pNRA&ub3}mz25QGhm(-egYy;Y^^BS z4MUc6hRpf|hX+COcH>l1fXh@;?7=^NzUIen?oY!Yzv`r`{+*8BV?>z+uzO9BHjSH-NW!c@)bFb2}jN)`x z(0z=q*)QbTnJ+TJ1%oPW_>>Vq#D>Qy0q^#%`!*EcqhKVp?hWOG08JP{0dHpCz6K`=wY;|uxSaDv z(#X)ax7|&5mk7{w}`*o&P4R6!PW zVU@sjnT`Zzr;~ddLWvALJX4U;7Bae7bSvExJq1~auvF&%`-ha@c_e0-^|mGR*kyWG zXLT#hxnG?-xO`->nBYJiG;A_4n*9E&x(JZE^nq*U{QNQOR$xWazM{2kB3I2N*7l~C zyBw$Q!9{9)@CtA`Uj$bG3)BEULs|5k^^mZR7-e8W5KTXt@89@UML_#tDgcGZf2JcINtUP-Du31 zs>e`?AmpUD0=I*#Nr4|=6Y~S%T>5~7_{L*+JT7Y-*mW;GSOQg}Y}1M2ZM~Y3J$B5= zN5Wta(?{eAx<$HUC#_8bb?D!}m)V-L_p@haD5$#>X5}y!#Vqu^$L3j5N-Q5R-h8(I z*qVmOX>|Zz%pd(zP;&3k7wk*t_l}j2p^&*LkVNAW5k$w$t`dU955^3jpxxwkKP}c! zLDi^V;R1HQ$`5Kiug-0+9+|rY;U#5Qflre9_b1Hb6`k*Vu*J0tykRcAJmo3~F*d!D z+2AK+3Ksub!?eB!#ky_QKxF6T(PT9%Xi`)H%-nrML~X#vixdPzeN>a4$Kyr|-$%uCT0EcpNuI0I}UXVoS}T9L!tC%8bH=pHcGS(8@lYg#L)S1N`nkgM_VW7er_q z`Me95e|051%M0mAayMdz{ zndWr1Z;%9Hhk(D&cc2KS77hF)_@NB=RHOspLS&mp&Kv5iw9VeJ1yeu@DP@+6@U=Sx z&-1nyaG~cOT~&TL!CTT0Rd#bGFg)YD;jr{1nnHkp!@*T+8>B^!b|p5)&jPqWAvbH^ z$kz(6AbKx^(nZY;8%J&)+H8;2 zY9^d@4@wYt33KiK>stqMdw_am+1gdwCfO}_Q)9h!_g;7p5ASkweTC42ODl`lW!Ogs zNyq}XhA?)B2|ORq;F`ApCo~>Q-~GuWZa#lg&^~1{7Coa}h*PLC9(?#Jjz#3S-9v(( z%jvc|+NZ{rLPSLT=EWk?U8{wQSENz%aMY|3mIFYlT&VF8Q0Ka?LYWn7WZo^fAfQ{ zON9=tw%^M4lR6)t0J9UQZ_So0c=|qQy2hinBB}a5piElbgT%8NahrqoO8Z`rIDQiG zPt7tznfj9%w^9MCF)~u@B$a}<)D(uUtlW15sDL`M6Y$fKlTZ&*BGC$93v?l2bb%YUBK+arhhG0@at9!$l zYD%Sd(21zHHb9~D!|{WPrPE3;@sC)2ro$orK4oW4%9zvU!6I!Ty0>i>zFL+xjF1(La{8lxfCki zKcU?ddfvxIJfLTefvz=*iKtS68(#BQwJ?Ly7baeRw}Gq^e6j2|VGuD?A@-9Rz#pSk+q`$H9pI;j95 zM_XV)@}QMC!>=_3%=nPma0!LC4?dV8uddc;ysmmwHqM$<$^*oDB#pZRHzkB{ZW|3;drJGKU7CWp>qpGtGr-dSG)9&P zdjNHM<(WhYugidqp+^>US@w?v*yQPe_(0iyB4gP-uR-Us%6l$6g=LkCP9rDSoo^S7 zu{(JtXKY9dgR7aD$UD`9+%L`2%BfS<3dHL^dG zK1b#OVhb5zXkf~{zC$J>`pZXe%QF<;5;Oow+K>3gthSz}?9E8OP16W&J$=wt zWz5r4fKP2_PBMqo<)?9Bfgavtyluh*#1GPqd<&6Q%mY zZfWQFcETmmy|2MsO&9$)SCY+%JUt(*{dK+Yc0A}_#jv@(*<*^e(nOMxR1|jtk`DGx zF)a4*0O5X+B-eh;D~{lvF!zXqQek@S&#v&>o$vOOHWzv3w5@ULzrs@4!t?seb3oi@ z>G=}%&xQEhV63Tr`1U8+R@Siidl7sX zN`#k_j0?+%=JD)0#AtkxEDska5Q92$NH!!WeaO1tafG@+dq$@HEin8sfK+nHUbIgp z7ugbgHcilWWgBXnK7w|vHsw|H*)ofS4jFL%qY+k8lZ&j zZ5YVWmeKaWRaz2#d`xRDCc2uh-z1PT$~5CgAbn(jMusGv;@TlDeFd}szQw&@C3AJW zXg)m2ib87nIwxWdvNtcN;{6&|m9|0Dhxb{r%*}g)x`3LD%>arWdh%d2V=qJDse6Ps zr@>f*wi+0JeF=Zpu=KXTK=%QJxE>Z}pE~`I%bj_tw%egzBrU?^|GH`ayywmRrv}H5 zpS)qn@@8tOIQdC{!wIoAVfd_W8-}sU_BD}0&r`_QLVXVt*ZHEroVp|RPQK-PA4387 z_&FJ=7*XvJjgC|HgM6OKjrtB_-ctJRDyES9_93aTXDun>ZP$<5+q9l9yxSXed`bR4 zu9EKd3{ql^hGA-tM$aK6a&J6!qT zKMsh0Fog~^9OLqdcli~@!(WuR_(gZhhKr1BIp{wkk}*D9mTq$%w0 zAdV&*KD)ocT?RiPR}|o=upyOVdoVcsSffsPhb?<5o#-V;cR+nK+as~`&r%83W@anAv{CFwV&@}RsD5d{ng#R#IN~%6z0bL=?x779gYgAziG4C1tcpO zzB4XYZ1r5GhTp83@xrC_yrvN3+D&*e9xD~e#QNr)v~Rgf)C-@Ir|m;j>Obt?exVn60yH%UyZr17;xyAtYSloq3(+w3 z<$e&)a#`;2vsy^h4m2`@=I@??gPkIyl1J!wh4ue&0X}&XsNRn8lXt%*EOk1W;1KV6 zDC{RCYobr-elT_Y5+9sg@cbKG~LTZ6S3=9qJcH9JjT5 zZr8Wl;|kc3!Q3%GI~i~2H=rXdo)ZQM8r{t4<&GC%z0D??=cqH0C4 zyQynj{7Dk`S!|aU8+on+Bb`9TxQgrPO4urI73)jRUgma4Qu3dUP|>>4{)9p~rOEeW zXTR=cNg0tBhi#&9BXo>U7tXz1cxy_5kaeDJal$Mwbv?81`~2_(3Xh6iq+Y zDZPRF4=%%=Y_3^P&3o?~p87SS%ag)ocB=Nmu{QjIX>0XaS^TweBT?s{Qgqy#&M&<3 z=qUPj?)Xu{<#m-dkBJF&bA8Pt3CoSA^Go~`$0^r>SEHx=gS5tinYDix829=-jhKMR zQR3eCC5BY3bD2UQ^&yRBDKcfRdiZ0*-bw~8>mlKV>I|d3npp|8RKv3oFAF2J1@Xk) zbmA!eBh1YPM}^ilQY)d54``QreyKul80{JvPZF)un|ig`=irVtn{sZyeJvVf=qg}# z(PzSjMF3?ST9FBhMc?(@Uk>=m8>F?85}e@YmWr&3QGKjEOri;Wh9h4vo>7j6)1$%@ zT%P(eo)NY2iuIH8xAFJ!@>e#OSi6ISJHaJ)IK&!-VJHtwf{6x zBcG$mI_^}L1u(?#U5%n3fM<96R&bGz4PqYA1zq148} zB8)Mdz(k_BWcN$5%+%8?8Gc7h8HiIVN%cy#chAcF(lug;pe;&j|7@UKNGP{G$J}=pB&j^X%^;kPcFer7sOnvapp;SSh)#{ z^ZoZ$h>ooAF)NN@9_;sb=*notb25=Zi{fzj*8GmDzZ9;{*8RyB`{@@?%}j)AStn!* z-veO$i`LD4_DsQJ`bvhNQpS7tRAbGPu&7CvqYTyzk>wj5bQRYW$tqlwxY6Guy?$mVee zE19%4!I;*ptarlvIFkK}!jPS2xh&IHW{}OYM~KkN+;F)d6!fV?Nf~xXZ9zN!!Z+wZ z#1E_k(fEAZ{;A3knL*uI?1`8*!=L21`hMYNPybldqAN8!ce>pO3XL%?w6PhW+X1jYjN(ez9& z|GjLDTs_ttuJBR(A05ZMF1jlPYZ}H^5`Uq7dU{YL6-7(ThW~=Gz0o$d zKM+(m(t9k*8vsM{d)4)tMgWE>93sEDn@=lq6Ycz2?Nx|C;Nsn>TOM;Jp15sV}vsO9bWD&t(YLRI!a ztve5?nr-%)EtyR0m~p;vK)uN{x>!~jukD_xq~~o&t6}at(ch*Vc;~EyK0W8tB=_5W z?!8@tfjaB_n3@J>v1^-fNrq;%9adCWs@p?u%32$=(Vgj=(aLf|KFmiVkLNU;uPn{$ z?J-7^%-z;U$bXHy$^%Fkgdl-%X&6TIcPWXbHjCYM$@kK4Q_sOpde{`f+TMEvU!u#f z(C++_Zopce8eLzQ z{nw>x6pHI|kZUQ?>*J=3zs&uyOaDZbk+e}J`M#CcuC~e5k|`DkGcVFgKaR)KhD-*h zCrnCJ@llX^L;u)VlebbMJbZruxSYBAqkV^8=-l4?W{y;&>&bHE;Eyl<#BS#!|Xo zXZo-0rHnu>cxstVI-T*@*ig=()dZ_4Gkdg&|DJL`6U8d1#R9ozTs7+l`Vqgxt3a$b zRkS>acRWuCaA+@Z)PlZ!3YxT_9d7X+wrIT&#A315E|7llwfkD&txWdc^QJ6cc7e{i zqM7dtWwFI`y-^k)iOViX&|JpR0;iw#grcMn@)DB{a^IjLPMx$?8ZaIC2dk8<_8Md@ zl3OgFF+}c=VJDoOnXGVMy8v3p|GJ5xWoaL|#>=~YrYpjo@khuBs9A^WnBxOltyPI( z*+I69Xb+6W(NVrMwa3o7`#T9?=J{TAOV5ipB4wxIOvnhYRNu}Q#B+XLvCX)i$iO%f zr!-2U@1Dfrh?_#67o->r=(-B*d8-S{S%Zc*+P(AoXDaPE5~Nen=`U0qlu=yo5M}|) z{pB67akJ)?vk$#!aAMBAXkcC}ut1%CO5r%1?-i+8XU5EJ*m8cr_6J$~uMaQ_l1K)2 zM?j@dnuj+YDTT&RnNL6Yg=+nXA-qyy zJq^%eh{_8Y{jEw654tQgA7|C{B>86#WIpliI>nfAzxZb{0s@#@=f!{st{7jv5{Vzs z1g67=vidI(_4G|)F6rCTZjgGpS>{g7MtAYbx|>}>Ivmc8C6`zHj>_~tl7^R_eK}}j zCrNf0zc+5%|N3~68iil(U6y|Pb&=Z0a23Z;&g9D(WtIU>*ZCQ(o<-?Eit0=ZS7wU` zRFeP};UIadR^J>wK`0i#g1o2DR*wK+7Io!%Ps`7yk(A5ogc21@)Q{1VU@%3dO5z1c zeLA^+{1x_yL8`9S&bFtFyq7TYQr>4|04F09HJs%-*9cu+dXy98foQkooH_T^0bToz z3AE)NCG0%;#$1I(r)Y-Z<`1X})?fnc)!UMIeszKM@JPc>Kwvnmxxo?Q%3(T2ww%1# zGuR!I6XK7Nptm)F!FETizHXDp{}hy>^Zgs&zErX6qvc`so@g^pe88*=CzR(Wf-HFaUrx6`hIXj+lYeSegvR(pbLNd`5znX&;`a~MeZ-jEhFX0*@AU7mOG=j~zE$x~^6OqT zpc!kr?3Tt^ml=?YcuXuPz_cinO120hB6)WE3mon)S339l!; z7D|qS!Sk)R7~iEW0+ufj2J6!)1>N!L9Jg9lAn;?=Hc=&z-ILi6kaMj+J9EYDvX57& zS;w@9+AJ0jB0Q2`1N=wkt)Dm<0`soU@Emqxoz$n3N zR3j1pG?FYxID#OyVU&P*lfm2a+D zt(}f@TSpktst$6%HecU6cv9?`Ro?Zz3PvVO`GOT zz;%}N{Fpe<-lU8d8A_s#$z4z>GwHNNLvJ~ebzA;h#{5@V`+l@atVY?dz3!D-;-Em7mQsK0t(5NkeJwZ#ILh&*1pA`-gpfPrKr-dHb=6n1e!8uCqAWvKW09kmO+iRST886-FapNS&A#1CJrO9AA0wYL&2Y#k;|nZtM| zT<;ISSg^!)+4RRV*_5Y@wkut z@-$;JwGQh15Ttc5lz*vYQFsHbZwk6o=aA>v^+|wof0@DeM^x7Qd1BI7Ef=21ikqwS*sQum+nE$mcfXwtme%MsQ;XFnR z@I?0w<|)B^yg(9aXj0~{YB`n z95yPi#xKT`mAJsv|GfgO+>7h73d(DK&#A|FPvtc_Ac=h16jG-GEx;Y)AtLl8N{ADw zaHI1H1(P_-jr6E|hgZ?7(!gy;{*;n%m72C{v^R*;vu69`4$|69`Nu#yDwK-@apZ(%@c8& z2*giI{;iuT%|z>z}4AAb_GQNO=mi+`baAcV3M8$OxUT87kwRjJ6!S|ZyoxG<7_K9SmR^x4YB1BT96N`usR=5ce+%c;71yqDT~%NU2e0x$DHcBabm>FQN8k%_$}p!Wa%1DE!j zmt8mB8U`GpPwH%Iv8WE$k`FbTemcvFh|~5j^6@fgbfq%8Q7`MH7DfORknYmo4o@KT zf-mFw;FoVp&Ixf>7E`OtbM6}e+Y@pm7+10A4DUgRIH!wog2;vlxuRhGekhFpdMN5G z;5US>}gk(nMbg^~zJr%QxKX3sP{&UIkGw!DPR z`hIOH-2-9sB3C{u^B&Ilo5>*=YxUd?IatPDg35R;B)bs!v-gn zS|c5b=v+&4@>C zoo2Jldx!_ZH?sP+^l%-&WMGl3%}--+unk~6V2R*u1A}}Ls@+vin5aH>_*`Z zUO!e{W%g@oR24qBsfyn@z2XYy%J>o121J|yYnpoe&yxpG8gGqyXW;tW@9MNL`I|Vc z|B}&(-B7!#^=fMA(o3sytD1QOlE_yy#_C;yhve70%%{d)%+K`(cmqa6x@q*=HzDSW z{xb7+$-E9E;JfVn;wNphHF4zCYje3`!GUfj8ErgI^G>4%V_bRXG`0j^N%Y_fdcgH1 zs4@c#{^+az1$P5l<1Mhp<;~vEEQW$5IaBY)YS`=i!)(PBb*OL{uEv(2c|lxyu-4&1 z6QEH*tvQVrFUm^o#xVA(odt6wb*DZ0 zmlk8e&Yst|;-H+Ve*wM!co;}amYQzqIxP!vTvmeHGZiP6f}WQjmiVC%1tJh|A~{(} zu8*8lWBDt)x`%tbOsg=zF4(jYPnt;p=v?Ip==3%DU92c^~;6UKGo^;}&mwLZ{36XX37? zx(Jv1B~Sr7@;pNK-$_tm}7)*hIi#6yEjs>00*{RL6EGeTXRN^47`%x7^pizG>3tv!CC3 zR%iqy;Nlx%3iLGdJ*Ue}%$B-R05iK+B2T$Qfh>e#D~566p)_kp(w;$)SrY({Y!o1h@0c92(A#Pc7jnmEy6CzZ%%^#KHf`t3P3JiyD9a8>r zcU2hK=Me2cbG3uJ1N-nFHi|Us{B0I#9tQg?8=|CbjGFnrinkSH=iZrggIn%a7K#6f zk9{A|4B{7oP{w!#GvdoTY^W39weN*7aD5FruDM$|zwqqyM78AavM?WTD|-{UeSPu1#tym=E_6Kr6=D9J-}%R{48?jyeTz}O z@}bwY9*QvoTD2{?kM%`mawN2OBJjwpB42!KyFun~Q0m2@Hk18uds4 zGnwYn&a?HY_PzUGYM3~^ypn>wqF|&mk4>P?Kj&Xvh7Az^@w&<%NrrvVZOe>=ZbGYs zTeDEh4iUfxM-HkZ;h&FlBA7Il&Bx}LwM_jrB-=YeD46H?jpyV6{~DzDDT#ni58b&C z6AP^ge0IE!n>38MnR2Q4jWcY==7#gdV5*5d+fS*@JoTZ7(4v(Kdy_#QwU@|q)c68e z;sRLOHVOoOF3qUaJKNNstt6J9cqRpC&5`lg>VU|#Q}D9d@2-gl6SVW~)Z^w|EVu*B zPt=CMPB6NeR*fz7J)j2~)%FrlJ^}U^Ru8B9V0 z)vQF$n{UOyJNhFgc(=D|YViI1)}<0y18EdJjck&y`S|`9gC56f_p{QeV$?=>_{rD# z#>AJqSK(3z`iAV5^p)nA%_H6xuS;cm7D?gi}&N5}vhz_oiDmj>HyD#-q2*l^g+rwp7Ont7y~MU)2I% zVs87@``6bORzIc%K@Qz!a7`!D#Ml^NczhFWpWFH`Iv=%dKydajo+ziop?USB6PNVj z;VwIlH_rn4olzr^oqw%!0&ALVkh_EnU^IzOt<<0iUf=zqIa)N^T%N=JiQNePt5gam zG8&%b+8g}GlJRMz@k%)wxL_rS_mQ1_;#VU97#uD@mBb1XFdkQp@6)s66{r+>vRUq? z&2xLr%#4ojof@lb&vd45R24(lA+(bfiQM*!1ck(_;oOque}Vsk+V_o7J%KJwG6;`u z7epB>!s0>eb{a_^UCh15LQ>9C%<%=D`aKQ4-m;7R8>2;%4X#IDrN$&FJ(ldlvL|3c zcU0;b5*Q#vbDi?y9ISw1n?;FVfI{o8-EXUP*}y`U*txy4!>l_2mI#6y(28cit)Oqb z*yiJ5GO~Lb||gj`C? z*#sSOL$b9BDqY~zaJ9Tcu#kFU3-InU?MW`F^v_$0kbVF<6ox)d5c z0-JW@Plk)Oz)F@W6b>P}6N7TbKQ-OcXMR#Od_D7Gp8!Mt_ye zix^DE*!~0e*xIr(>#glE4v7Wd2UGRqjP)q<13`{yo4e9|8YA)+uuUrcX|X+rD2+gW zz4CsFGb}tuRC4t0EUj!(1Yyx(Bn}g*2lh$(uA%6&wq*TUX3I`%a{@&D`KOY`l_3{U zr>W7kp)8x-Igu&~SncjAUghX+vLNL&+KCO*HsQp?;I zgblGN(E8N*G7hHvrDB%V@vP!VD_mm<9);j!)UsWg^NnP=1lB&a4r^ z*!X5kGQegS@kgtlw0yoX9sH77*~-f>lKE%}t6hKvfk_B0;;6WM_MBX{YI;I5r(Ikk zr->g5M#2|~quK@WzUVS4`na6{EJ_P1mn!}b)Nlm6Rk+i#0+!9(z_@ zP1NvO%L2E&7xE+psB91?rq<+j-rIC}$fhxO`IobrXWbP{AHE#fK2R7`L>SUgDK!z? zlQp7`A(_4;(SI&XYtL#IZSop!xHg)_&NIB#!8TAO&JOcv$Gi5b=LhFyp;U6x{`B0@ z7A#L0=&Ti1tk`Qf{VlcwK}YK6)?{}03^*9poIUC|fY3%*>jpJDXNrErQfWrVM?-jcc;FGg;=}>dcqKu0jQc7-=I|uW4klUP zD%f!fg?fn*h-Nq?>MqdlM8IgJGIQoGw(Nv*jJp$NHtaq_m&2uBuG8-A*Sv%-JW*t* zgP)0fqRLKc8{JQ}!6W#h_B+t6Q}`IQI<-5Bv%C{?D<{)Ngi($y^6crt$P9)u98=j4 z<_|567@`Rr&ZEWU*yRB0G@?#a;fT7ra%{E`7gyi}<(Ppk$M5Dn~ra5-8=G``%n*=EOmH0=La(_j#SXH+1dYtDI zxd|LxfDT-*9+8`iMdy_@Q-uwda&n!ofQpswPKDP*D|@WG{gvhSg9FKB#@nL#sJ=Zm zy}qB-s|Lha!_o{B2uW}JFV*`}1k}F_D8h2rxw?j0&g$$x?n1&*zqchmyl|3=XaDFN z1_(ur!msA2AEPN=5NC7FO&F_E@3A?>y_T!*>t(0mrtLp25N2!WVC+zqltt`nh~W_1 zF<|_j_^N-exxxZD$OOqq)cGTg%d#_yRob1}ug$06pILNdNEO%R75Qg8Vym8i>~MMA zD5Hx$`AejJ7Fcs|Ew9bF?`;dfcv;9JTcTASoq%p=i~1fMWKor`a}m&8CoEt{%Wj2*v49Vqs?MwFPHwgIUP;MLwx|Xu%$I1fQ|dA(^;8eYtl&P z>zsyu4}4OsgJk`3*yP}`X5Gi2#N%6fUv%*I`f(t>kybLPRMc~$dA9TH8bhri39i9x zw6M6Tw0r25HLM=SE>eE-a|NLr+?}}{V#}l6Q^30V@SgJlpNW6beW4#l*+SX%aO?uZ z=q)Q_eRd>NWUP#g7A#dDH)W~=4i`Ltf1;Nkv)IkG!ovigdMdbgx~s5c7w+Tp-6gxg($f8a7@ zmYmN~qdDFWEGNjLny&ny=`1HN_!;okOWzoY{-5UFJRHjRe;-a{d8-&%BJ0?aElZ3& zjD3qpLb487$G!|I#n=s^VNiAvSt>(lv4)W?*~>O|Z-(qV*Yx>4$M^F*e?7T6uj_T5uk$)zQlbw)x}~E$U;`{a4dNa_YL+lA)m3tyiu%U>fNDFJ3p6_1 zi|CI35Jgk`(Z>sfoGYM9kkEb9rq;)QztBIt?{Rcx%Nr|cr|O0;+PX;I45l(>IZv9- z5m=<-bVi^2uG>$g+rIbGcKVTf@-w4;2b%z=tLYlbJzqcgYpm$KOPNBg!^%~cFJ2X% zLWHK32kz71zd6&=5FK{>UghrR9tyw)3k)US-%!)62hLlU<(me<ui>wz0;pSCeT=#3j)GPkSrR$~JqgLOYRD z^j0r!8QoF(AHd9iC&cSV1v@xO8yCDe^Bc4eyiIbLolZF}A0@;OH(LB>?O1*a83J9M zYb(xeS@c40cHdevrjDblCDVYlQo|{-nhvsD78Y9=t07SYejAig*{{`!J~=yFVZ1+APA;HO~I8kZ@fiG-!Cf~FS;1Zer=)~R9o0r|EzHF8)yvi-O%Li zX?riVYnc7sTT`>TZpeMQvZo97nmYMDuks3Yfg@9YZI7Q-jgtZ>&Pcu~H4^|j6+93V z*5=8Gx{XwrG7OgwtMB_-U?lBKHzoWG2Zh9?K;fXQK1bE|4v64-{{VLBuck4crG$Mz z$^Hu!V7K509OWS&5_#0P!CC-j@si|z)^jqyJbqZ=yTulsL&t-d0&Q#b?mxB(#j!W% zG&*P(etT}N@o_0Y=)C+DTch?8xfQEh39o)v6dExCG|heKYb*PY9NBOOx;2;jpzN-9 z0%}m5@5}ROvfdN*biG;cHBL}%tyfSdN6&nM--TtFIJ}^Es39}1 z-kYVsHd1veiObsm2FhOWEFPIxt{Ah+*B{U}_^(S$AvBQ3p+?{9k@R(*;}Bo%HX~zug(i&fy#eLo{1k{y0_6wjSba+ict|qwx*-gr~vA^*X5O6c#pz9>B z$vH%5%IL+{JFQt}PrFAiYk>A43K42UElp>tS&DnV*gd@`+JW!ty!QL|u#MWlY7F1c zUTw?gI}Z3d^V&3Jo|)kn26NVjNGY6m&q|U#RcUI&b28+di;{Dh{w-=+%FV)hc?LG!uB{?qMQi1{OrhP5w z(l;z|)s<=$$WMUvN0HomTby5Jb@xqa_0j$#W4oc(V`Nnsxc_wW{U@Iz-j$`t)n*!t z4!_MwjHM^%$@nWcy7Key#_E4ZexIn(vUa1Jr|s+pHYa* z&;^2C$nXX97wTw%OLi=fk>`q!akcE3`Qu&v&cNPBk@whZRk8cIzicf2k}*Jc$w^KP znAtvI?I({dKd^ego##r5|6d~oB>SSA`$KiV#z=KiwIg-bh3~8)Hqz&_5C&SSXg{l2 zKqv996-v8wCV@4>XW~W75cHGSgqdW^-S#b{BY^Uutk3;{Pkvsto)jfO4KE(1+XuQl zJ|aC+3hH<3GetGIUTU`ab!u+V;6#6KU0E!=EGI|DL}<2fLY~-nn<_8IvSLpPG5h4-qx{jy7^vOZ-a&+( zv5fkS85>BVBkeDf+QVI*BX{fd@zI}XZIjyBz@T1L$(VP2q4|>y@dhpvq-{BHjuhxi z!PE(PLY_HQR76kPLJRYvi&{mDF9xOB3%CBHE)00ZJ%0!0QCamDx%j6R9z2JBYLet) z$w_1wk;~a4fM!{Z6tp$<&017as`&$xW1^IHVmoWpxkjedINr1WP()8D4Jg6< k- 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 753f9c8..8c1a6ec 100644 --- a/README.md +++ b/README.md @@ -1 +1,185 @@ -# CaraML \ No newline at end of file +# CaraML + +## *Presentation* + +CaraML is a Scala/Apache Spark framework for distributed Machine Learning programs, using the Apache Spark MLlib in the simplest possible way. No need to write hundreds or thousands code lines, just discribing pipline of models and/or transformations. The purpose is to do "Machine Learning as Code" + + + +## *Requirements* + +To use CaraML framework, you must satisfy the following requirements: + +- Scala version >= 2.12.13 +- Spark version >= 3.1 +- Java 11 + + + +## *Installation* + + - Spark : [Download here](https://spark.apache.org/downloads.html) + - Scala : [Download here](https://www.scala-lang.org/download/) + - CaraML library : [CaraML](https://s01.oss.sonatype.org/content/repositories/snapshots/io/github/jsarni/caraml_2.12/1.0.0-SNAPSHOT/) + + + +## *Usage* + +To use CaraML, you can add the framework dependency in your Spark application + +- Sbt + +```scala + libraryDependencies += "io.github.jsarni" %% "caraml" % "1.0.0" +``` + +- Gradle + +```scala + compile group: 'io.github.jsarni"', name: 'caraml', version: '1.0.0' +``` +- Maven + +```scala + + io.github.jsarni" + caraml + 1.0.0 + +``` + +CaraML needs the following information + +- Prepared dataset that will be used to transform and train models +- Path where to save the final trained model and its metrics +- Path of the CaraYaml file, where the user will declare and set the pipeline with stages of SparkML models and/or SparkML transformations + +The Yaml file will be used to describe a pipeline of stages, each stage could be a SparkML model or a Spark ML method of data preprocessing. +All CaraYaml files must start with "CaraPipeline:" keyword and could contain the following keywords + +### *CaraPipeline* +* **"CaraPipeline:"** : keyword that must be set in the beginning of each CaraYaml file + + +### *Stage* +* **"- stage:"** Is a keyword used to declare and describe a stage. It could be an Estimator or a Transformer : + * **SparkML Estimator** : Which is the name of the SparkML model that you want to use in the stage. + * **SparkML Transformer** : Is the name of SparkML feature transformation that you want to apply to your dataset (preprocessing) + + +Each stage will be followed by "params:" keyword, which contain one or many parameters/hyperparameters of the stage and their values. + +```yaml + params: + - "Param1 name" : "Param value" + - "Param2 name" : "Param value" + - .... + - "Paramn name" : "Param value" +``` + +### *Evaluator* +* **"- evaluator:"** Which is used to evaluate model output and returns scalar metrics + + +### Tuner +* **"- tuner:"** Which is used for tuning ML algorithms that allow users to optimize hyperparameters in algorithms and Pipelines + +Each tuner will be followed by "params:" keyword, which contain one or many parameters/hyperparameters of the tuner and their values. + +```yaml + params: + - "Param1 name" : "Param value" + - "Param2 name" : "Param value" + - .... + - "Paramn name" : "Param value" +``` + +### **CaraYaml example** +```yaml +CaraPipeline: +- stage: LogisticRegression + params: + - MaxIter: 5 + - RegParam: 0.3 + - ElasticNetParam: 0.8 +- stage: Tokenizer + params: + - InputCol: Input + - OutputCol: ResCol + + +- evaluator: MulticlassClassificationEvaluator +- tuner: TrainValidationSplit + params: + - TrainRatio: 0.8 + + +``` + +**For more details and documentation you can refer to the Spark [MLlib](https://spark.apache.org/docs/3.1.2/ml-guide.html) documentation** + + + +## *SparkML available components in CaraML* + +This section lists all available SparkML components that you can use with CaraML framework + +### *Models* + +* **Classification** + + - LogisticRegression [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-classification-regression.html#logistic-regression) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/classification/LogisticRegression.html) + - DecisionTreeClassifier [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-classification-regression.html#decision-tree-classifier) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/classification/DecisionTreeClassifier.html) + - GBTClassifier (Gradient-boosted tree classifier) [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-classification-regression.html#gradient-boosted-tree-classifier) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/classification/GBTClassifier.html) + - NaiveBayes [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-classification-regression.html#naive-bayes) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/classification/NaiveBayes.html) + - RandomForestClassifier [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-classification-regression.html#random-forest-classifier) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/classification/RandomForestClassifier.html) + +* **Regression** + + - LinearRegression [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-classification-regression.html#linear-regression) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/regression/LinearRegression.html) + - DecisionTreeRegressor [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-classification-regression.html#decision-tree-regressionhttps://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/regression/DecisionTreeRegressor.html) and [Documontation]() + - RandomForestRegressor [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-classification-regression.html#random-forest-regression) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/regression/RandomForestRegressor.html) + - GBTRegressor (Gradient-boosted tree Regressor) [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-classification-regression.html#gradient-boosted-tree-regression) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/regression/GBTRegressor.html) + + +* **Clustering** + + - K-means [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-clustering.html#k-means) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/clustering/KMeans.html) + - LDA (Latent Dirichlet allocation) [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-clustering.html#latent-dirichlet-allocation-lda) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/clustering/LDA.html) + +### *Dataset operation* + +- Binarizer [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#binarizer) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/Binarizer.html) +- BucketedRandomProjectionLSH [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#bucketed-random-projection-for-euclidean-distance) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/BucketedRandomProjectionLSH.html) +- Bucketizer [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#bucketizer) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/Bucketizer.html) +- ChiSqSelector [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#chisqselector) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/ChiSqSelector.html) +- CountVectorizer [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#countvectorizer) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/CountVectorizer.html) +- HashingTF [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#tf-idf) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/HashingTF.html) +- IDF [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#tf-idf) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/IDF.html) +- RegexTokenizer [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#tokenizer) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/RegexTokenizer.html) +- Tokenizer [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#tokenizer) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/Tokenizer.html) +- Word2Vec [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-features.html#word2vec) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/feature/Word2Vec.html) + + + +### *Tuner* + +- CrossValidator [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-tuning.html#cross-validation) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/tuning/CrossValidator.html) +- TrainValidationSplit [Spark MLlib example](https://spark.apache.org/docs/3.1.2/ml-tuning.html#train-validation-split) and [Documontation](https://spark.apache.org/docs/3.1.2/api/scala/org/apache/spark/ml/tuning/TrainValidationSplit.html) + + +### *Evaluator* + +- RegressionEvaluator [Documontation](https://spark.apache.org/docs/latest/mllib-evaluation-metrics.html) +- MulticlassClassificationEvaluator [Documontation](https://spark.apache.org/docs/latest/mllib-evaluation-metrics.html) + + +## *CamaML schema* +![schema](PA.PNG?raw=true) + + +## *Example* + +For practical example you can refer to this [Link](https://github.com/jsarni/CaraMLTest), which is a github project that contain a project using the CaraML framework. + diff --git a/build.sbt b/build.sbt index 2d9335c..6a994f5 100644 --- a/build.sbt +++ b/build.sbt @@ -7,22 +7,38 @@ version := "1.0.0" organization := "io.github.jsarni" homepage := Some(url("https://github.com/jsarni/CaraML")) scmInfo := Some(ScmInfo(url("https://github.com/jsarni/CaraML"), "git@github.com:jsarni/CaraML.git")) -developers := - List( +developers := List( Developer("Juba", "SARNI", "juba.sarni@gmail.com", url("https://github.com/jsarni")), Developer("Merzouk", "OUMEDDAH", "merzoukoumeddah@gmail.com ", url("https://github.com/merzouk13")), Developer("Aghylas", "SAI", "aghilassai@gmail.com", url("https://github.com/SAI-Aghylas")) - ) +) +licenses += ("Apache-2.0", url("http://www.apache.org/licenses/LICENSE-2.0")) +publishMavenStyle := true + +publishTo := Some( + if (isSnapshot.value) + "Sonatype Snapshots Nexus" at "https://s01.oss.sonatype.org/content/repositories/snapshots" + else + "Sonatype Releases Nexus" at "https://s01.oss.sonatype.org/content/repositories/releases" +) // Dependencies val scalaTest = "org.scalatest" %% "scalatest" % "3.2.7" % Test val mockito = "org.mockito" %% "mockito-scala" % "1.16.37" % Test val spark = "org.apache.spark" %% "spark-mllib" % "3.1.1" +val snakeYaml = "org.yaml" % "snakeyaml" % "1.28" +val jacksonCore = "com.fasterxml.jackson.core" % "jackson-core" % "2.10.5" +val jacksonDataformat = "com.fasterxml.jackson.dataformat" % "jackson-dataformat-yaml" % "2.10.5" +val jacksonAnnotation = "com.fasterxml.jackson.core" % "jackson-annotations" % "2.10.5" lazy val caraML = (project in file(".")) .settings( name := "CaraML", libraryDependencies += scalaTest, libraryDependencies += mockito, - libraryDependencies += spark + libraryDependencies += spark, + libraryDependencies += snakeYaml, + libraryDependencies += jacksonCore, + libraryDependencies += jacksonDataformat, + libraryDependencies += jacksonAnnotation ) \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..59030e5 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.3") +addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.1") \ No newline at end of file diff --git a/src/main/resources/body_part1.txt b/src/main/resources/body_part1.txt new file mode 100644 index 0000000..a866c2a --- /dev/null +++ b/src/main/resources/body_part1.txt @@ -0,0 +1,3 @@ +
+
+

\ No newline at end of file diff --git a/src/main/resources/body_part2.txt b/src/main/resources/body_part2.txt new file mode 100644 index 0000000..763158e --- /dev/null +++ b/src/main/resources/body_part2.txt @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/body_part3.txt b/src/main/resources/body_part3.txt new file mode 100644 index 0000000..4bf48a7 --- /dev/null +++ b/src/main/resources/body_part3.txt @@ -0,0 +1,5 @@ + + + + + \n \n \n \n") + } + + fileBufferWriter.write("\n
Metric Value
\ No newline at end of file diff --git a/src/main/resources/caraML_logo_200x100.png b/src/main/resources/caraML_logo_200x100.png new file mode 100644 index 0000000000000000000000000000000000000000..11be84ff02a12459079c405df5c70253572c357a GIT binary patch literal 12711 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 + + + + + CaraML Report + + + + + + + + + + +
+

+
+ +
+
+

CaraML : Pipeline Metrics Report

+
+
+ +

This is the Fit report of your PipelineModel    

+ +

Summary

+ +
+

Models and Metrics

+
+ + \ No newline at end of file diff --git a/src/main/scala/io/github/jsarni/caraml/CaraModel.scala b/src/main/scala/io/github/jsarni/caraml/CaraModel.scala new file mode 100644 index 0000000..985d7c4 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/CaraModel.scala @@ -0,0 +1,354 @@ +package io.github.jsarni.caraml + +import java.io._ +import java.sql.Timestamp +import java.time.Instant + +import io.github.jsarni.caraml.carayaml.CaraYamlReader +import io.github.jsarni.caraml.pipelineparser.{CaraParser, CaraPipeline} +import org.apache.spark.ml.classification._ +import org.apache.spark.ml.regression.{DecisionTreeRegressionModel, GBTRegressionModel, LinearRegressionModel, RandomForestRegressionModel} +import org.apache.spark.ml.tuning.{CrossValidator, ParamGridBuilder, TrainValidationSplit} +import org.apache.spark.ml.{Pipeline, PipelineModel, Transformer} +import org.apache.spark.mllib.clustering.{KMeansModel, LDAModel} +import org.apache.spark.sql.functions.{col, mean, when} +import org.apache.spark.sql.{DataFrame, Dataset} + +import scala.collection.mutable +import scala.io.Source +import scala.util.Try + + +final class CaraModel(yamlPath: String, dataset: Dataset[_], savePath: String, overwrite: Boolean = true) { + + val yaml = CaraYamlReader(yamlPath) + val parser = CaraParser(yaml) + + def run(): Try[Unit] = for { + caraPipeline <- parser.build() + sparkPipeline <- generateModel(caraPipeline) + fittedModel <- train(sparkPipeline, dataset) + _ <- generateReport(fittedModel) + _ <- save(fittedModel) + } yield () + + def computeAccuracy(prediction: Dataset[_]): DataFrame = { + prediction.withColumn("is_success", when(col("label") === col("prediction"), 1).otherwise(0)) + .agg(mean("is_success").as("train_dataset_accuracy")) + } + private def evaluateReport(model : PipelineModel, dataset: Dataset[_]): String = { + + // Get 10 first Values to show + val evals = computeAccuracy(model.transform(dataset)) + + // Prepare the Html Table Skeleton + val bluePart = "
\n

Model Evaluation

\n
\n" + val columnPart = "\n\n\n" + val partOne = "\n \n".mkString, + row.toSeq.map( field => s"$partTwo $field \n").mkString, + "\n").mkString + }.mkString + // Generate Final Skeleton + val finalSkeleton : String = s"$hearderPart $columnList \n\n\n$valuesPart \n
" + val partTwo = "" + // Inject Results to Html + val hearderPart : String = s"$bluePart$columnPart" + val columnList : String = evals.columns.map( col => s"$partOne ${col.capitalize} \n" ).mkString + val valuesPart : String = evals.collect().map { row => + List("
\n


\n \n " + + finalSkeleton + + } + + private def writeHTMLFile(filename: String, ArrayStages: Array[mutable.SortedMap[String,Any]], evalModel : PipelineModel): Unit = { + + // Set Paths and File Name + + val reportDirectory = new File(filename.split('/').dropRight(1).mkString("/")+"/Report Model") + if (reportDirectory.mkdirs() == false) { + reportDirectory.listFiles().map(file => file.delete()) + reportDirectory.delete() + reportDirectory.mkdirs() + } + + //Create the HTML file + val fileHTML = new File(reportDirectory + s"/ModelMetrics.html") + val fileBufferWriter = new BufferedWriter(new FileWriter(fileHTML)) + + // Load HTML Component from existing Resources + val headHTML = Source.fromResource("header.txt").mkString + val bodyPartOne = Source.fromResource("body_part1.txt").mkString + val bodyPartTwo = Source.fromResource("body_part2.txt").mkString + + val firstPart ="
" + val secondPart = "" + + // Write HTML Skeleton + fileBufferWriter.write(headHTML) + + for (lines <- ArrayStages) { + + // Get the Model Name + val modelName = lines.map(line => if (line._1 == "Model Name") line._2 else new String("")) + .toList + .filter(p => p.asInstanceOf[String].length > 0) + .mkString + + // If the Stage doesn't have Metrics : print only the Stage Name + if (modelName.endsWith("display")) { + fileBufferWriter.write(bodyPartOne + s"$modelName

\n") + + } + else { + // Inject Model Metrics + fileBufferWriter.write(bodyPartOne + s"$modelName

\n" + bodyPartTwo) + for ((field, value) <- lines) { + + fileBufferWriter.write(s"$firstPart $field $secondPart $value
\n

\n


\n") + } + } + + //Write the Evaluate Model Results + val evaluationResults : String = evaluateReport(evalModel,dataset) + fileBufferWriter.write(evaluationResults) + + fileBufferWriter.close() + + + } + + private def getStageMetrics(stage: Transformer) : Try[mutable.SortedMap[String,Any]] =Try { + + val rapportDate: String= Timestamp.from(Instant.now()).toString.replace(" ","-") + val modelName : String= stage.getClass.getName.split('.').last.replace("Model","") + + stage match { + // Classification Models + case m: DecisionTreeClassificationModel => { + mutable.SortedMap( + "Model Name" -> modelName, + "Raport Date Generation" -> rapportDate, + "Feature Importances" -> m.featureImportances.toArray.toList.mkString, + "Number of Features" -> m.numFeatures, + "features Column" -> m.featuresCol.name + ) + } + + case m: GBTClassificationModel => { + mutable.SortedMap( + "Model Name" -> modelName, + "Raport Date Generation" -> rapportDate, + "Feature Importances" -> m.featureImportances.toArray.toList.mkString, + "Number of Features" -> m.numFeatures, + "Features Column" -> m.featuresCol.name, + "Number of Trees" -> m.getNumTrees + ) + } + + case m:RandomForestClassificationModel => { + val summ = m.summary + mutable.SortedMap( + "Model Name" -> modelName, + "Report Date Generation" -> rapportDate, + "Accuracy" -> summ.accuracy, + "Weighted True Positive Rate" -> summ.weightedTruePositiveRate, + "Total Iterations" -> summ.totalIterations, + "Objective History" -> summ.objectiveHistory.toList.mkString, + "Recall By Label" -> summ.recallByLabel.toList.mkString, + "Weighted Recall" -> summ.weightedRecall, + "Precision By Label" -> summ.precisionByLabel.toList.mkString, + "Labels" -> summ.labels.toList.mkString, + "Label Column" -> summ.labelCol, + "Prediction Column" -> summ.predictionCol, + "False Positive Rate By Label" -> summ.falsePositiveRateByLabel.toList.mkString, + "Weight Column" -> summ.weightCol, + "FMeasure By Label" -> summ.fMeasureByLabel.toList.mkString, + "Weighted FMeasure" -> summ.weightedFMeasure, + "Weighted Precision" -> summ.weightedPrecision + ) + } + //Regression Models + case m : DecisionTreeRegressionModel => { + mutable.SortedMap( + "Model Name" -> modelName, + "Report Date Generation" -> rapportDate, + "Feature Importances" -> m.featureImportances.toArray.mkString("\n"), + "Features Number" -> m.numFeatures, + "Features Columns" -> m.featuresCol.name + ) + } + + case m : RandomForestRegressionModel => { + mutable.SortedMap( + "Model Name" -> modelName, + "Report Date Generation" -> rapportDate, + "Features Column" -> m.featuresCol, + "Feature Importances" -> m.featureImportances.toArray.mkString("\n"), + "Features Number" -> m.numFeatures + ) + } + + case m : GBTRegressionModel => { + mutable.SortedMap( + "Model Name" -> modelName, + "Report Date Generation" -> rapportDate, + "Features Column" -> m.featuresCol, + "Feature Importances" -> m.featureImportances.toArray.mkString("\n"), + "Features Number" -> m.numFeatures + ) + } + + case m: LogisticRegressionModel => { + val summ=m.summary + mutable.SortedMap( + "Model Name" -> modelName, + "Report Date Generation" -> rapportDate, + "Accuracy" -> summ.accuracy, + "Features Columns" -> summ.featuresCol, + "FMeasure By Label" -> summ.fMeasureByLabel.toList.mkString(" \n"), + "False Positive Rate By Label" -> summ.falsePositiveRateByLabel.toList.mkString(" \n"), + "Label Column" -> summ.labelCol, + "Labels" -> summ.labels.toList.mkString(" \n"), + "Objective History" -> summ.objectiveHistory.toList.mkString(" \n"), + "Probability Column" -> summ.probabilityCol.mkString, + "True Positive Rate By Label" -> summ.truePositiveRateByLabel.toList.mkString(" \n"), + "Total Iterations" -> summ.totalIterations, + "Prediction Column" -> summ.predictionCol, + "Precision By Label" -> summ.precisionByLabel.toList.mkString(" \n"), + "Recall By Label" -> summ.recallByLabel.toList.mkString(" \n"), + "Weighted Recall" -> summ.weightedRecall, + "Weighted True Positive Rate" -> summ.weightedTruePositiveRate, + "Weight Column" -> summ.weightCol, + "Weighted False Positive Rate" -> summ.weightedFalsePositiveRate, + "Weighted FMeasure" -> summ.weightedFMeasure + ) + } + + case m: LinearRegressionModel => { + val summ = m.summary + mutable.SortedMap( + "Model Name" -> modelName, + "Report Date Generation" -> rapportDate, + "Objective History" -> summ.objectiveHistory.toList.mkString, + "Total Iterations" -> summ.totalIterations, + "Coefficient Standard Errors" -> summ.coefficientStandardErrors.toList.mkString, + "Degrees Of Freedom" -> summ.degreesOfFreedom, + "Deviance Residuals" -> summ.devianceResiduals.toList.mkString, + "Explained Variance" -> summ.explainedVariance, + "Features Column" -> summ.featuresCol, + "Label Column" -> summ.labelCol, + "Mean Absolute Error" -> summ.meanAbsoluteError, + "Mean Squared Error" -> summ.meanSquaredError, + "Number of Instances" -> summ.numInstances, + "Prediction Column" -> summ.predictionCol, + "PValues" -> summ.pValues.toList.mkString, + "R2" -> summ.r2, + "R2adj" -> summ.r2adj, + "Root Mean Squared Error" -> summ.rootMeanSquaredError, + "TValues" -> summ.tValues.toList.mkString + ) + } + + //Clustering Models + case m: KMeansModel => { + mutable.SortedMap( + "Model Name" -> modelName, + "Report Date Generation" -> rapportDate, + "Cluster Centers" -> m.clusterCenters, + "Distance Measure" -> m.distanceMeasure + ) + } + + case m : LDAModel => { + mutable.SortedMap( + "Model Name" -> modelName, + "Report Date Generation" -> rapportDate, + "k" -> m.k + ) + } + + case m : NaiveBayesModel => { + mutable.SortedMap( + "Model Name" -> modelName, + "Report Date Generation" -> rapportDate, + "Pi" -> m.pi.toArray.toList.mkString, + "Sigma" -> m.sigma.toArray.toList.mkString, + "Theta" -> m.theta.toArray.toList.mkString + ) + } + //Default + case m => { + mutable.SortedMap("Model Name" -> s"$modelName : This Stage has no Metrics to display") + } + } + } + + private def generateReport(model: PipelineModel) : Try[Unit] = Try{ + + val StageMetrics = model.stages.map(stage => getStageMetrics(stage).get ) + writeHTMLFile(savePath ,StageMetrics,model) + } + + def evaluate(dataset: Dataset[_]): Dataset[_] = { + val model = PipelineModel.load(savePath) + model.transform(dataset) + } + + private def generateModel(caraPipeline: CaraPipeline) : Try[Pipeline] = Try { + val pipeline = caraPipeline.pipeline + val evaluator = caraPipeline.evaluator + + caraPipeline.tuner match { + case Some(tuner) => + val methodeName = "set" + tuner.paramName + tuner.tuningStage match { + case "CrossValidator" => + val paramValue = tuner.paramValue.toInt + val crossValidatorModel = new CrossValidator() + .setEstimator(pipeline) + .setEvaluator(evaluator) + .setEstimatorParamMaps(new ParamGridBuilder().build()) + .setParallelism(2) + .setCollectSubModels(true) + + crossValidatorModel.getClass.getMethod(methodeName, paramValue.getClass) + .invoke(crossValidatorModel,paramValue.asInstanceOf[java.lang.Integer]) + + new Pipeline().setStages(Array(crossValidatorModel)) + + case "TrainValidationSplit" => + val paramValue = tuner.paramValue.toDouble + val validationSplitModel = new TrainValidationSplit() + .setEstimator(pipeline) + .setEvaluator(evaluator) + .setEstimatorParamMaps(new ParamGridBuilder().build()) + .setParallelism(2) + .setCollectSubModels(true) + + validationSplitModel.getClass.getMethod(methodeName, paramValue.getClass) + .invoke(validationSplitModel,paramValue.asInstanceOf[java.lang.Double]) + + new Pipeline().setStages(Array(validationSplitModel)) + } + case None => + pipeline + } + + } + + private def train(pipeline: Pipeline , dataset: Dataset[_]): Try[PipelineModel] = Try { + pipeline.fit(dataset) + } + + private def save(model: PipelineModel) : Try[Unit] = Try { + if (overwrite) { + model.write.overwrite().save(savePath) + } else + model.write.save(savePath) + } + +} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/CaraStage.scala b/src/main/scala/io/github/jsarni/caraml/carastage/CaraStage.scala new file mode 100644 index 0000000..43e0604 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/CaraStage.scala @@ -0,0 +1,81 @@ +package io.github.jsarni.caraml.carastage + +import org.apache.spark.ml.PipelineStage + +import java.lang.reflect.Method +import scala.reflect.ClassTag +import scala.util.Try + +abstract class CaraStage[T <: PipelineStage](implicit classTag: ClassTag[T]) { + + final private[this] def newInstance(): PipelineStage = { + classTag + .runtimeClass + .getConstructors + .filter(_.getParameterCount == 0) + .head + .newInstance() + .asInstanceOf[PipelineStage] + } + + def build(): Try[PipelineStage] = for { + allFields <- Try(this.getClass.getDeclaredFields) + _ = allFields.map(_.setAccessible(true)) + + pipelineStage = newInstance() + + definedFields = allFields.filter(_.get(this).asInstanceOf[Option[Any]].isDefined) + names = definedFields.map(_.getName) + values = definedFields.map(field => field.get(this).asInstanceOf[Some[field.type]]) + zipFields = names zip values + + _ = zipFields.map { f => + val fieldName = f._1 + val fieldValue = f._2 + getMethode(pipelineStage, fieldValue.get, fieldName) + .invoke(pipelineStage, fieldValue.get) + } + } yield pipelineStage + + + def getMethode(stage : PipelineStage, field : Any, fieldName : String): Method = { + val methodeName = "set" + fieldName + field match { + case _ : Any if field.getClass == Array[Array[Boolean]]().getClass => + stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Array[Boolean]]].getClass ) + case _ : Any if field.getClass == Array[Array[Double]]().getClass => + stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Array[Double]]].getClass ) + case _ : Any if field.getClass == Array[Array[String]]().getClass => + stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Array[String]]].getClass ) + case _ : Any if field.getClass == Array[Array[Float]]().getClass => + stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Array[Float]]].getClass ) + case _ : Any if field.getClass == Array[Array[Short]]().getClass => + stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Array[Short]]].getClass ) + case _ : Any if field.getClass == Array[Array[Char]]().getClass => + stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Array[Char]]].getClass ) + case _ : Any if field.getClass == Array[Array[Byte]]().getClass => + stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Array[Byte]]].getClass ) + case _ : Any if field.getClass == Array[Array[Long]]().getClass => + stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Array[Long]]].getClass ) + case _ : Any if field.getClass == Array[Boolean]().getClass => stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Boolean]].getClass ) + case _ : Any if field.getClass == Array[Double]().getClass => stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Double]].getClass ) + case _ : Any if field.getClass == Array[String]().getClass => stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[String]].getClass ) + case _ : Any if field.getClass == Array[Float]().getClass => stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Float]].getClass ) + case _ : Any if field.getClass == Array[Short]().getClass => stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Short]].getClass ) + case _ : Any if field.getClass == Array[Char]().getClass => stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Char]].getClass ) + case _ : Any if field.getClass == Array[Byte]().getClass => stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Byte]].getClass ) + case _ : Any if field.getClass == Array[Long]().getClass => stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Long]].getClass ) + case _ : Any if field.getClass == Array[Int]().getClass => stage.getClass.getMethod(methodeName, field.asInstanceOf[Array[Int]].getClass ) + case _ : java.lang.Boolean => stage.getClass.getMethod(methodeName, field.asInstanceOf[Boolean].getClass ) + case _ : java.lang.Double => stage.getClass.getMethod(methodeName, field.asInstanceOf[Double].getClass ) + case _ : java.lang.Float => stage.getClass.getMethod(methodeName, field.asInstanceOf[Float].getClass ) + case _ : java.lang.Short => stage.getClass.getMethod(methodeName, field.asInstanceOf[Short].getClass ) + case _ : java.lang.Character => stage.getClass.getMethod(methodeName, field.asInstanceOf[Char].getClass ) + case _ : java.lang.Byte => stage.getClass.getMethod(methodeName, field.asInstanceOf[Byte].getClass ) + case _ : java.lang.Long => stage.getClass.getMethod(methodeName, field.asInstanceOf[Long].getClass) + case _ : java.lang.Integer => stage.getClass.getMethod(methodeName, field.asInstanceOf[Int].getClass) + case _ : java.lang.String => stage.getClass.getMethod(methodeName, field.getClass ) + } + } + +} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/CaraStageDescription.scala b/src/main/scala/io/github/jsarni/caraml/carastage/CaraStageDescription.scala new file mode 100644 index 0000000..724c595 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/CaraStageDescription.scala @@ -0,0 +1,3 @@ +package io.github.jsarni.caraml.carastage + +case class CaraStageDescription(stageName: String, params: Map[String, String]) diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/CaraStageMapper.scala b/src/main/scala/io/github/jsarni/caraml/carastage/CaraStageMapper.scala new file mode 100644 index 0000000..b8a6b5c --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/CaraStageMapper.scala @@ -0,0 +1,90 @@ +package io.github.jsarni.caraml.carastage + +import io.github.jsarni.caraml.carastage.datasetstage.CaraDataset +import io.github.jsarni.caraml.carastage.modelstage._ +import io.github.jsarni.caraml.carastage.tuningstage.TuningStageDescription +import org.apache.spark.ml.PipelineStage +import org.apache.spark.ml.evaluation._ + +import scala.util.{Failure, Success, Try} + +trait CaraStageMapper { + + def mapStage(stageDescription: CaraStageDescription): Try[CaraStage[_ <: PipelineStage]] = Try { + Try(mapModelStage(stageDescription)).getOrElse(mapDatasetStage(stageDescription)) + } + + def mapModelStage(stageDescription: CaraStageDescription): CaraModel[_ <: PipelineStage] = { + stageDescription.stageName match { + case "LogisticRegression" => + LogisticRegression(stageDescription.params) + case "RandomForestClassifier" => + RandomForestClassifier(stageDescription.params) + case "LinearRegression" => + LinearRegression(stageDescription.params) + case "GBTClassifier" => + GBTClassifier(stageDescription.params) + case "DecisionTreeClassifier" => + DecisionTreeClassifier(stageDescription.params) + case "KMeans" => + KMeans(stageDescription.params) + case "LDA" => + LDA(stageDescription.params) + case "NaiveBayes" => + NaiveBayes(stageDescription.params) + case "DecisionTreeRegressor" => + DecisionTreeRegressor(stageDescription.params) + case "RandomForestRegressor" => + RandomForestRegressor(stageDescription.params) + case "GBTRegressor" => + GBTRegressor(stageDescription.params) + case _ => throw + new Exception(s"${stageDescription.stageName} is not a valid Cara Stage name. Please verify your Yaml File") + } + } + + def mapDatasetStage(stageDescription: CaraStageDescription): CaraDataset[_ <: PipelineStage] = { + stageDescription.stageName match { + case _ => + throw + new Exception(s"${stageDescription.stageName} is not a valid Cara Stage name. Please verify your Yaml File") + } + } + + def mapEvaluator(evaluatorName: String): Evaluator = { + evaluatorName match { + case "RegressionEvaluator" => new RegressionEvaluator() + case "MulticlassClassificationEvaluator" => new MulticlassClassificationEvaluator() + case _ => + throw + new Exception(s"${evaluatorName} is not a valid SparkML Validator name. Please verify your Yaml File") + } + } + + def mapTuner(tuningStageDesc: TuningStageDescription): TuningStageDescription = { + tuningStageDesc.tuningStage match { + case "CrossValidator" => + if (!tuningStageDesc.paramName.equals("NumFolds")) + throw new IllegalArgumentException("The only parameter available for CrossValidator is NumFolds") + Try(tuningStageDesc.paramValue.toInt) match { + case Success(_) => + tuningStageDesc + case Failure(_) => + throw new IllegalArgumentException("The NumFolds parameter value must be an Integer") + } + case "TrainValidationSplit" => + if (!tuningStageDesc.paramName.equals("TrainRatio")) + throw new IllegalArgumentException("The only parameter available for TrainValidationSplit is TrainRatio") + Try(tuningStageDesc.paramValue.toDouble) match { + case Success(value) => + if (value < 1 || value > 0) + tuningStageDesc + else + throw new IllegalArgumentException("The TrainRation parameter value must be a Double between 0 and 1") + case Failure(_) => + throw new IllegalArgumentException("The TrainRation parameter value must be a Double between 0 and 1") + } + } + } + +} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Binarizer.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Binarizer.scala new file mode 100644 index 0000000..4f0b396 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Binarizer.scala @@ -0,0 +1,35 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{Binarizer => fromSparkML} + +/** + * @param InputCol + * @param InputCols + * @param OutputCol + * @param OutputCols + * @param Threshold + * @param Thresholds + */ +case class Binarizer(InputCol: Option[String], + InputCols: Option[Array[String]], + OutputCol: Option[String], + OutputCols: Option[Array[String]], + Threshold: Option[Double], + Thresholds: Option[Array[Double]]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("InputCol"), + params.get("InputCols").map(_.split(',').map(_.trim)), + params.get("OutputCol"), + params.get("OutputCols").map(_.split(',').map(_.trim)), + params.get("Threshold").map(_.toDouble), + params.get("Thresholds").map(_.split(",").map(_.toDouble)) + ) + } +} + +object Binarizer{ + def apply(params: Map[String,String]): Binarizer = new Binarizer(params) +} \ No newline at end of file diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSH.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSH.scala new file mode 100644 index 0000000..a08eb2e --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSH.scala @@ -0,0 +1,35 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{BucketedRandomProjectionLSH => fromSparkML} + +/** + * @param BucketLength + * @param InputCol + * @param NumHashTables + * @param OutputCol + * @param Seed + */ +case class BucketedRandomProjectionLSH(BucketLength: Option[Double], + InputCol: Option[String], + NumHashTables: Option[Int], + OutputCol: Option[String], + Seed: Option[Long]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("BucketLength").map(_.toDouble), + params.get("InputCol"), + params.get("NumHashTables").map(_.toInt), + params.get("OutputCol"), + params.get("Seed").map(_.toLong) + ) + } + +} + +object BucketedRandomProjectionLSH { + def apply(params: Map[String,String]): BucketedRandomProjectionLSH = new BucketedRandomProjectionLSH(params) +} + + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSHModel.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSHModel.scala new file mode 100644 index 0000000..f552a96 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSHModel.scala @@ -0,0 +1,35 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{BucketedRandomProjectionLSH => fromSparkML} + +/** + * @param BucketLength + * @param InputCol + * @param NumHashTables + * @param OutputCol + * @param Seed + */ +case class BucketedRandomProjectionLSHModel(BucketLength: Option[Double], + InputCol: Option[String], + NumHashTables: Option[Int], + OutputCol: Option[String], + Seed: Option[Long]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("BucketLength").map(_.toDouble), + params.get("InputCol"), + params.get("NumHashTables").map(_.toInt), + params.get("OutputCol"), + params.get("Seed").map(_.toLong) + ) + } + +} + +object BucketedRandomProjectionLSHModel { + def apply(params: Map[String,String]): BucketedRandomProjectionLSHModel = new BucketedRandomProjectionLSHModel(params) +} + + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Bucketizer.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Bucketizer.scala new file mode 100644 index 0000000..ed6f02f --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Bucketizer.scala @@ -0,0 +1,41 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{Bucketizer => fromSparkML} + +/** + * @param HandleInvalid + * @param InputCol + * @param InputCols + * @param OutputCol + * @param OutputCols + * @param Splits + * @param SplitsArray + */ +case class Bucketizer(HandleInvalid: Option[String] = Option("error"), + InputCol: Option[String], + InputCols: Option[Array[String]], + OutputCol: Option[String], + OutputCols: Option[Array[String]], + Splits: Option[Array[Double]], + SplitsArray: Option[Array[Array[Double]]]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("HandleInvalid"), + params.get("InputCol"), + params.get("InputCols").map(_.split(',').map(_.trim)), + params.get("OutputCol"), + params.get("OutputCols").map(_.split(',').map(_.trim)), + params.get("Splits").map(_.split(",").map(_.toDouble)), + params.get("SplitsArray").map(_.split(';').map(_.split(',').map(_.toDouble))) + ) + } + +} + +object Bucketizer{ + def apply(params: Map[String,String]): Bucketizer = new Bucketizer(params) +} + + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CaraDataset.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CaraDataset.scala new file mode 100644 index 0000000..5719415 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CaraDataset.scala @@ -0,0 +1,8 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.caraml.carastage.CaraStage +import org.apache.spark.ml.PipelineStage + +import scala.reflect.ClassTag + +abstract class CaraDataset[T <: PipelineStage](implicit classTag: ClassTag[T]) extends CaraStage[T]{} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelector.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelector.scala new file mode 100644 index 0000000..312dcb3 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelector.scala @@ -0,0 +1,48 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{ChiSqSelector => fromSparkML} + +/** + * @param Fdr + * @param FeaturesCol + * @param Fpr + * @param Fwe + * @param LabelCol + * @param NumTopFeatures + * @param OutputCol + * @param Percentile + * @param SelectorType + */ +case class ChiSqSelector(Fdr: Option[Double], + FeaturesCol:Option[String], + Fpr: Option[Double], + Fwe: Option[Double], + LabelCol:Option[String], + NumTopFeatures: Option[Int], + OutputCol: Option[String], + Percentile:Option[Double], + SelectorType:Option[String] +) extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("Fdr").map(_.toDouble), + params.get("FeaturesCol"), + params.get("Fpr").map(_.toDouble), + params.get("Fwe").map(_.toDouble), + params.get("LabelCol"), + params.get("NumTopFeatures").map(_.toInt), + params.get("OutputCol"), + params.get("Percentile").map(_.toDouble), + params.get("SelectorType") + ) + } + +} + +object ChiSqSelector{ + def apply(params: Map[String,String]): ChiSqSelector = new ChiSqSelector(params) +} + + + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelectorModel.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelectorModel.scala new file mode 100644 index 0000000..6e18a07 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelectorModel.scala @@ -0,0 +1,51 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{ChiSqSelector => fromSparkML} + +/** + * @param Fdr + * @param FeaturesCol + * @param Fpr + * @param Fwe + * @param LabelCol + * @param NumTopFeatures + * @param OutputCol + * @param Percentile + * @param SelectorType + * @param SelectedFeatures + */ +case class ChiSqSelectorModel(Fdr: Option[Double], + FeaturesCol: Option[String], + Fpr: Option[Double], + Fwe: Option[Double], + LabelCol: Option[String], + NumTopFeatures: Option[Int], + OutputCol: Option[String], + Percentile: Option[Double], + SelectorType: Option[String], + SelectedFeatures: Option[Array[Int]]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("Fdr").map(_.toDouble), + params.get("FeaturesCol"), + params.get("Fpr").map(_.toDouble), + params.get("Fwe").map(_.toDouble), + params.get("LabelCol"), + params.get("NumTopFeatures").map(_.toInt), + params.get("OutputCol"), + params.get("Percentile").map(_.toDouble), + params.get("SelectorType"), + params.get("SelectedFeatures").map(_.split(",").map(_.toInt)) + ) + } + +} + +object ChiSqSelectorModel { + def apply(params : Map[String,String]): ChiSqSelectorModel = new ChiSqSelectorModel(params) +} + + + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizer.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizer.scala new file mode 100644 index 0000000..b9fc96f --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizer.scala @@ -0,0 +1,40 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{CountVectorizer => fromSparkML} + +/** + * @param Binary + * @param InputCol + * @param MaxDF + * @param MinDF + * @param MinTF + * @param OutputCol + * @param VocabSize + */ +case class CountVectorizer(Binary: Option[Boolean], + InputCol: Option[String], + MaxDF: Option[Double], + MinDF: Option[Double], + MinTF: Option[Double], + OutputCol: Option[String], + VocabSize: Option[Int]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("Binary").map(_.toBoolean), + params.get("InputCol"), + params.get("MaxDF").map(_.toDouble), + params.get("MinDF").map(_.toDouble), + params.get("MinTF").map(_.toDouble), + params.get("OutputCol"), + params.get("VocabSize").map(_.toInt) + ) + } + +} + +object CountVectorizer { + def apply(params: Map[String,String]): CountVectorizer = new CountVectorizer(params) +} + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerModel.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerModel.scala new file mode 100644 index 0000000..0f5f6da --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerModel.scala @@ -0,0 +1,45 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{CountVectorizerModel => fromSparkML} + +/** + * @param Binary + * @param InputCol + * @param MaxDF + * @param MinDF + * @param MinTF + * @param OutputCol + * @param VocabSize + * @param Vocabulary + */ +case class CountVectorizerModel(Binary: Option[Boolean], + InputCol: Option[String], + MaxDF: Option[Double], + MinDF: Option[Double], + MinTF: Option[Double], + OutputCol: Option[String], + VocabSize: Option[Int], + Vocabulary: Option[Array[String]]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("Binary").map(_.toBoolean), + params.get("InputCol"), + params.get("MaxDF").map(_.toDouble), + params.get("MinDF").map(_.toDouble), + params.get("MinTF").map(_.toDouble), + params.get("OutputCol"), + params.get("VocabSize").map(_.toInt), + params.get("Vocabulary").map(_.split(',')) + ) + } + +} + +object CountVectorizerModel { + def apply(params: Map[String,String]): CountVectorizerModel = new CountVectorizerModel(params) +} + + + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/HashingTF.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/HashingTF.scala new file mode 100644 index 0000000..aab185d --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/HashingTF.scala @@ -0,0 +1,31 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{HashingTF => fromSparkML} + +/** + * @param Binary + * @param InputCol + * @param NumFeatures + * @param OutputCol + */ +case class HashingTF(Binary: Option[Boolean], + InputCol: Option[String], + NumFeatures: Option[Int], + OutputCol: Option[String]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("Binary").map(_.toBoolean), + params.get("InputCol"), + params.get("NumFeatures").map(_.toInt), + params.get("OutputCol") + ) + } + +} + +object HashingTF { + def apply(params: Map[String, String]): HashingTF = new HashingTF(params) +} + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/IDF.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/IDF.scala new file mode 100644 index 0000000..0c1fa35 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/IDF.scala @@ -0,0 +1,27 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{IDF => fromSparkML} + +/** + * @param InputCol + * @param MinDocFreq + * @param OutputCol + */ +case class IDF(InputCol: Option[String], + MinDocFreq: Option[Int], + OutputCol: Option[String]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("InputCol"), + params.get("MinDocFreq").map(_.toInt), + params.get("OutputCol") + ) + } + +} + +object IDF { + def apply(params: Map[String, String]): IDF = new IDF(params) +} \ No newline at end of file diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/RegexTokenizer.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/RegexTokenizer.scala new file mode 100644 index 0000000..b0083cc --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/RegexTokenizer.scala @@ -0,0 +1,36 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{RegexTokenizer => fromSparkML} + +/** + * @param Gaps + * @param InputCol + * @param MinTokenLength + * @param OutputCol + * @param Pattern + * @param ToLowercase + */ +case class RegexTokenizer(Gaps: Option[Boolean], + InputCol: Option[String], + MinTokenLength: Option[Int], + OutputCol: Option[String], + Pattern: Option[String], + ToLowercase : Option[Boolean]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("Gaps").map(_.toBoolean), + params.get("InputCol"), + params.get("MinTokenLength").map(_.toInt), + params.get("OutputCol"), + params.get("Pattern"), + params.get("ToLowercase").map(_.toBoolean) + ) + } + +} + +object RegexTokenizer { + def apply(params: Map[String, String]): RegexTokenizer = new RegexTokenizer(params) +} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Tokenizer.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Tokenizer.scala new file mode 100644 index 0000000..d26f03c --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Tokenizer.scala @@ -0,0 +1,24 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{Tokenizer => fromSparkML} + +/** + * @param InputCol + * @param OutputCol + */ +case class Tokenizer(InputCol: Option[String], + OutputCol: Option[String]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("InputCol"), + params.get("OutputCol") + ) + } + +} + +object Tokenizer { + def apply(params: Map[String, String]): Tokenizer = new Tokenizer(params) +} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Word2Vec.scala b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Word2Vec.scala new file mode 100644 index 0000000..fd2d3ac --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/datasetstage/Word2Vec.scala @@ -0,0 +1,46 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import org.apache.spark.ml.feature.{Word2Vec => fromSparkML} + +/** + * @param InputCol + * @param MaxIter + * @param MaxSentenceLength + * @param NumPartitions + * @param OutputCol + * @param Seed + * @param StepSize + * @param VectorSize + * @param MinCount + */ +case class Word2Vec (InputCol: Option[String], + MaxIter: Option[Int], + MaxSentenceLength: Option[Int], + NumPartitions: Option[Int], + OutputCol: Option[String], + Seed: Option[Long], + StepSize: Option[Double], + VectorSize: Option[Int], + MinCount: Option[Int]) + extends CaraDataset[fromSparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("InputCol"), + params.get("MaxIter").map(_.toInt), + params.get("MaxSentenceLength").map(_.toInt), + params.get("NumPartitions").map(_.toInt), + params.get("OutputCol"), + params.get("Seed").map(_.toLong), + params.get("StepSize").map(_.toDouble), + params.get("VectorSize").map(_.toInt), + params.get("MinCount").map(_.toInt) + ) + } + +} + +object Word2Vec { + def apply(params: Map[String, String]): Word2Vec = new Word2Vec(params) +} + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/CaraModel.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/CaraModel.scala new file mode 100644 index 0000000..eb0d37e --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/CaraModel.scala @@ -0,0 +1,7 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import io.github.jsarni.caraml.carastage.CaraStage +import org.apache.spark.ml.PipelineStage +import scala.reflect.ClassTag + +abstract class CaraModel[T<: PipelineStage](implicit classTag: ClassTag[T]) extends CaraStage[T]{} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeClassifier.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeClassifier.scala new file mode 100644 index 0000000..2c5be9f --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeClassifier.scala @@ -0,0 +1,67 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.classification.{DecisionTreeClassifier => SparkML} + +/** + * @param CheckpointInterval + * @param FeaturesCol + * @param Impurity + * @param LabelCol + * @param LeafCol + * @param MaxBins + * @param MaxDepth + * @param MinInfoGain + * @param MinInstancesPerNode + * @param MinWeightFractionPerNode + * @param PredictionCol + * @param ProbabilityCol + * @param RawPredictionCol + * @param Seed + * @param Thresholds + * @param WeightCol + */ +case class DecisionTreeClassifier(CheckpointInterval: Option[Int], + FeaturesCol: Option[String], + Impurity: Option[String], + LabelCol: Option[String], + LeafCol: Option[String], + MaxBins: Option[Int], + MaxDepth: Option[Int], + MinInfoGain: Option[Double], + MinInstancesPerNode: Option[Int], + MinWeightFractionPerNode: Option[Double], + PredictionCol: Option[String], + ProbabilityCol: Option[String], + RawPredictionCol: Option[String], + Seed: Option[Long], + Thresholds: Option[Array[Double]], + WeightCol: Option[String]) + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("CheckpointInterval").map(_.toInt), + params.get("FeaturesCol"), + params.get("Impurity"), + params.get("LabelCol"), + params.get("LeafCol"), + params.get("MaxBins").map(_.toInt), + params.get("MaxDepth").map(_.toInt), + params.get("MinInfoGain").map(_.toDouble), + params.get("MinInstancesPerNode").map(_.toInt), + params.get("MinWeightFractionPerNode").map(_.toDouble), + params.get("PredictionCol"), + params.get("ProbabilityCol"), + params.get("RawPredictionCol"), + params.get("Seed").map(_.toLong), + params.get("Thresholds").map(_.split(",").map(_.toDouble)), + params.get("WeightCol") + ) + } + +} + +object DecisionTreeClassifier { + def apply(params: Map[String, String]): DecisionTreeClassifier = new DecisionTreeClassifier(params) +} + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeRegressor.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeRegressor.scala new file mode 100644 index 0000000..c062eb0 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeRegressor.scala @@ -0,0 +1,59 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.regression.{DecisionTreeRegressor => SparkML} + +/** + * @param CheckpointInterval + * @param FeaturesCol + * @param Impurity + * @param LabelCol + * @param LeafCol + * @param MaxBins + * @param MaxDepth + * @param MinInfoGain + * @param MinInstancesPerNode + * @param MinWeightFractionPerNode + * @param PredictionCol + * @param Seed + * @param VarianceCol + * @param WeightCol + */ +case class DecisionTreeRegressor(CheckpointInterval: Option[Int], + FeaturesCol: Option[String], + Impurity: Option[String], + LabelCol: Option[String], + LeafCol: Option[String], + MaxBins: Option[Int], + MaxDepth: Option[Int], + MinInfoGain: Option[Double], + MinInstancesPerNode: Option[Int], + MinWeightFractionPerNode: Option[Double], + PredictionCol: Option[String], + Seed: Option[Long], + VarianceCol: Option[String], + WeightCol: Option[String]) + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("CheckpointInterval").map(_.toInt), + params.get("FeaturesCol"), + params.get("Impurity"), + params.get("LabelCol"), + params.get("LeafCol"), + params.get("MaxBins").map(_.toInt), + params.get("MaxDepth").map(_.toInt), + params.get("MinInfoGain").map(_.toDouble), + params.get("MinInstancesPerNode").map(_.toInt), + params.get("MinWeightFractionPerNode").map(_.toDouble), + params.get("PredictionCol"), + params.get("Seed").map(_.toLong), + params.get("VarianceCol"), + params.get("WeightCol") + ) + } + +} +object DecisionTreeRegressor { + def apply(params: Map[String, String]): DecisionTreeRegressor = new DecisionTreeRegressor(params) +} \ No newline at end of file diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/GBTClassifier.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/GBTClassifier.scala new file mode 100644 index 0000000..430c716 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/GBTClassifier.scala @@ -0,0 +1,81 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.classification.{GBTClassifier => SparkML} + +/** + * @param CheckpointInterval + * @param FeaturesCol + * @param LabelCol + * @param LeafCol + * @param MaxBins + * @param MaxDepth + * @param MinInfoGain + * @param MinInstancesPerNode + * @param MinWeightFractionPerNode + * @param PredictionCol + * @param ProbabilityCol + * @param RawPredictionCol + * @param Seed + * @param Thresholds + * @param WeightCol + * @param FeatureSubsetStrategy + * @param SubsamplingRate + * @param LossType + * @param MaxIter + * @param StepSize + * @param ValidationIndicatorCol + */ +case class GBTClassifier(CheckpointInterval: Option[Int], + FeaturesCol: Option[String], + LabelCol: Option[String], + LeafCol: Option[String], + MaxBins: Option[Int], + MaxDepth: Option[Int], + MinInfoGain: Option[Double], + MinInstancesPerNode: Option[Int], + MinWeightFractionPerNode: Option[Double], + PredictionCol: Option[String], + ProbabilityCol: Option[String], + RawPredictionCol: Option[String], + Seed: Option[Long], + Thresholds: Option[Array[Double]], + WeightCol: Option[String], + FeatureSubsetStrategy: Option[String], + SubsamplingRate: Option[Double], + LossType: Option[String], + MaxIter: Option[Int], + StepSize: Option[Double], + ValidationIndicatorCol: Option[String]) + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("CheckpointInterval").map(_.toInt), + params.get("FeaturesCol"), + params.get("LabelCol"), + params.get("LeafCol"), + params.get("MaxBins").map(_.toInt), + params.get("MaxDepth").map(_.toInt), + params.get("MinInfoGain").map(_.toDouble), + params.get("MinInstancesPerNode").map(_.toInt), + params.get("MinWeightFractionPerNode").map(_.toDouble), + params.get("PredictionCol"), + params.get("ProbabilityCol"), + params.get("RawPredictionCol"), + params.get("Seed").map(_.toLong), + params.get("Thresholds").map(_.split(",").map(_.toDouble)), + params.get("WeightCol"), + params.get("FeatureSubsetStrategy"), + params.get("SubsamplingRate").map(_.toDouble), + params.get("LossType"), + params.get("MaxIter").map(_.toInt), + params.get("StepSize").map(_.toDouble), + params.get("ValidationIndicatorCol") + ) + } + +} + +object GBTClassifier { + def apply(params: Map[String, String]): GBTClassifier = new GBTClassifier(params) +} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/GBTRegressor.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/GBTRegressor.scala new file mode 100644 index 0000000..69ec847 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/GBTRegressor.scala @@ -0,0 +1,78 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.regression.{GBTRegressor => SparkML} + +/** + * @param CheckpointInterval + * @param FeaturesCol + * @param LabelCol + * @param LeafCol + * @param MaxBins + * @param MaxDepth + * @param MinInfoGain + * @param MinInstancesPerNode + * @param MinWeightFractionPerNode + * @param PredictionCol + * @param Seed + * @param WeightCol + * @param FeatureSubsetStrategy + * @param SubsamplingRate + * @param LossType + * @param MaxIter + * @param StepSize + * @param ValidationIndicatorCol + * @param ValidationTol + * @param Impurity + */ +case class GBTRegressor(CheckpointInterval: Option[Int], + FeaturesCol: Option[String], + LabelCol: Option[String], + LeafCol: Option[String], + MaxBins: Option[Int], + MaxDepth: Option[Int], + MinInfoGain: Option[Double], + MinInstancesPerNode: Option[Int], + MinWeightFractionPerNode: Option[Double], + PredictionCol: Option[String], Seed: Option[Long], + WeightCol: Option[String], + FeatureSubsetStrategy: Option[String], + SubsamplingRate: Option[Double], + LossType: Option[String], + MaxIter: Option[Int], + StepSize: Option[Double], + ValidationIndicatorCol: Option[String], + ValidationTol: Option[Double], + Impurity: Option[String]) + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("CheckpointInterval").map(_.toInt), + params.get("FeaturesCol"), + params.get("LabelCol"), + params.get("LeafCol"), + params.get("MaxBins").map(_.toInt), + params.get("MaxDepth").map(_.toInt), + params.get("MinInfoGain").map(_.toDouble), + params.get("MinInstancesPerNode").map(_.toInt), + params.get("MinWeightFractionPerNode").map(_.toDouble), + params.get("PredictionCol"), + params.get("Seed").map(_.toLong), + params.get("WeightCol"), + params.get("FeatureSubsetStrategy"), + params.get("SubsamplingRate").map(_.toDouble), + params.get("LossType"), + params.get("MaxIter").map(_.toInt), + params.get("StepSize").map(_.toDouble), + params.get("ValidationIndicatorCol"), + params.get("ValidationTol").map(_.toDouble), + params.get("Impurity") + ) + } + +} + +object GBTRegressor { + def apply(params: Map[String, String]): GBTRegressor = new GBTRegressor(params) +} + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/KMeans.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/KMeans.scala new file mode 100644 index 0000000..2faaf3a --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/KMeans.scala @@ -0,0 +1,43 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.clustering.{KMeans => SparkML} + +/** + * @param DistanceMeasure + * @param FeaturesCol + * @param K + * @param MaxIter + * @param PredictionCol + * @param Seed + * @param Tol + * @param WeightCol + */ +case class KMeans(DistanceMeasure: Option[String], + FeaturesCol: Option[String], + K: Option[Int], + MaxIter: Option[Int], + PredictionCol: Option[String], + Seed: Option[Long], + Tol: Option[Double], + WeightCol: Option[String]) + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("DistanceMeasure"), + params.get("FeaturesCol"), + params.get("K").map(_.toInt), + params.get("MaxIter").map(_.toInt), + params.get("PredictionCol"), + params.get("Seed").map(_.toLong), + params.get("Tol").map(_.toDouble), + params.get("WeightCol") + + ) + } + +} + +object KMeans { + def apply(params: Map[String, String]): KMeans = new KMeans(params) +} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LDA.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LDA.scala new file mode 100644 index 0000000..2fc157f --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LDA.scala @@ -0,0 +1,48 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.clustering.{LDA => SparkML} + +/** + * @param CheckpointInterval + * @param DocConcentration + * @param FeaturesCol + * @param K + * @param MaxIter + * @param Optimizer + * @param Seed + * @param SubsamplingRate + * @param TopicConcentration + * @param TopicDistributionCol + */ +case class LDA(CheckpointInterval: Option[Int], + DocConcentration: Option[Array[Double]], + FeaturesCol: Option[String], + K: Option[Int], + MaxIter: Option[Int], + Optimizer: Option[String], + Seed: Option[Long], + SubsamplingRate: Option[Double], + TopicConcentration: Option[Double], + TopicDistributionCol: Option[String]) + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("CheckpointInterval").map(_.toInt), + params.get("DocConcentration").map(_.split(",").map(_.toDouble)), + params.get("FeaturesCol"), + params.get("K").map(_.toInt), + params.get("MaxIter").map(_.toInt), + params.get("Optimizer"), + params.get("Seed").map(_.toLong), + params.get("SubsamplingRate").map(_.toDouble), + params.get("TopicConcentration").map(_.toDouble), + params.get("TopicDistributionCol") + ) + } + +} + +object LDA { + def apply(params: Map[String, String]): LDA = new LDA(params) +} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LinearRegression.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LinearRegression.scala new file mode 100644 index 0000000..f6f9ca5 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LinearRegression.scala @@ -0,0 +1,61 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.regression.{LinearRegression => SparkLR} + +/** + * @param MaxIter + * @param RegParam + * @param ElasticNetParam + * @param LabelCol + * @param Loss + * @param FitIntercept + * @param PredictionCol + * @param FeaturesCol + * @param Solver + * @param Standardization + * @param Tol + * @param WeightCol + */ +case class LinearRegression(MaxIter: Option[Int], + RegParam: Option[Double], + ElasticNetParam: Option[Double], + LabelCol:Option[String], + Loss: Option[String], + FitIntercept: Option[Boolean], + PredictionCol: Option[String], + FeaturesCol: Option[String], + Solver: Option[String], + Standardization: Option[Boolean], + Tol: Option[Double], + WeightCol: Option[String]) + + extends CaraModel[SparkLR] { + + def this(params: Map[String, String]) = { + this( + params.get("MaxIter").map(_.toInt), + params.get("RegParam").map(_.toDouble), + params.get("ElasticNetParam").map(_.toDouble), + params.get("LabelCol"), + params.get("Loss"), + params.get("FitIntercept").map(_.toBoolean), + params.get("PredictionCol"), + params.get("FeaturesCol"), + params.get("Solver"), + params.get("Standardization").map(_.toBoolean), + params.get("Tol").map(_.toDouble), + params.get("WeightCol") + + ) + } + +} + +object LinearRegression { + def apply(params: Map[String, String]): LinearRegression = new LinearRegression(params) +} + + + + + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LogisticRegression.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LogisticRegression.scala new file mode 100644 index 0000000..1d6e0bf --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/LogisticRegression.scala @@ -0,0 +1,62 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.classification.{LogisticRegression => SparkML} + +/** + * @param MaxIter + * @param RegParam + * @param ElasticNetParam + * @param Family + * @param FeaturesCol + * @param FitIntercept + * @param PredictionCol + * @param ProbabilityCol + * @param RawPredictionCol + * @param Standardization + * @param Thresholds + * @param Tol + * @param WeightCol + */ +case class LogisticRegression(MaxIter: Option[Int], + RegParam: Option[Double], + ElasticNetParam: Option[Double], + Family:Option[String], + FeaturesCol: Option[String], + FitIntercept: Option[Boolean], + PredictionCol: Option[String], + ProbabilityCol: Option[String], + RawPredictionCol: Option[String], + Standardization: Option[Boolean], + Thresholds: Option[Array[Double]], + Tol: Option[Double], + WeightCol: Option[String]) + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("MaxIter").map(_.toInt), + params.get("RegParam").map(_.toDouble), + params.get("ElasticNetParam").map(_.toDouble), + params.get("Family"), + params.get("FeaturesCol"), + params.get("FitIntercept").map(_.toBoolean), + params.get("PredictionCol"), + params.get("ProbabilityCol"), + params.get("RawPredictionCol"), + params.get("Standardization").map(_.toBoolean), + params.get("Thresholds").map(_.split(",").map(_.toDouble)), + params.get("Tol").map(_.toDouble), + params.get("WeightCol") + ) + } + +} + +object LogisticRegression { + def apply(params: Map[String, String]): LogisticRegression = new LogisticRegression(params) +} + + + + + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/NaiveBayes.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/NaiveBayes.scala new file mode 100644 index 0000000..30fdae3 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/NaiveBayes.scala @@ -0,0 +1,47 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.classification.{NaiveBayes => SparkML} + +/** + * @param FeaturesCol + * @param LabelCol + * @param ModelType + * @param PredictionCol + * @param ProbabilityCol + * @param RawPredictionCol + * @param Smoothing + * @param Thresholds + * @param WeightCol + */ +case class NaiveBayes(FeaturesCol: Option[String], + LabelCol: Option[String], + ModelType: Option[String], + PredictionCol: Option[String], + ProbabilityCol: Option[String], + RawPredictionCol: Option[String], + Smoothing: Option[Double], + Thresholds: Option[Array[Double]], + WeightCol: Option[String]) + + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("FeaturesCol"), + params.get("LabelCol"), + params.get("ModelType"), + params.get("PredictionCol"), + params.get("ProbabilityCol"), + params.get("RawPredictionCol"), + params.get("Smoothing").map(_.toDouble), + params.get("Thresholds").map(_.split(",").map(_.toDouble)), + params.get("WeightCol") + + ) + } + +} + +object NaiveBayes { + def apply(params: Map[String, String]): NaiveBayes = new NaiveBayes(params) +} diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestClassifier.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestClassifier.scala new file mode 100644 index 0000000..03f40cb --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestClassifier.scala @@ -0,0 +1,78 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.classification.{RandomForestClassifier => SparkML} + +/** + * @param CheckpointInterval + * @param FeaturesCol + * @param Impurity + * @param LabelCol + * @param LeafCol + * @param MaxBins + * @param MaxDepth + * @param MinInfoGain + * @param MinInstancesPerNode + * @param MinWeightFractionPerNode + * @param PredictionCol + * @param ProbabilityCol + * @param RawPredictionCol + * @param Seed + * @param Thresholds + * @param WeightCol + * @param FeatureSubsetStrategy + * @param SubsamplingRate + * @param NumTrees + */ +case class RandomForestClassifier(CheckpointInterval: Option[Int], + FeaturesCol: Option[String], + Impurity: Option[String], + LabelCol: Option[String], + LeafCol: Option[String], + MaxBins: Option[Int], + MaxDepth: Option[Int], + MinInfoGain: Option[Double], + MinInstancesPerNode: Option[Int], + MinWeightFractionPerNode: Option[Double], + PredictionCol: Option[String], + ProbabilityCol: Option[String], + RawPredictionCol: Option[String], + Seed: Option[Long], + Thresholds: Option[Array[Double]], + WeightCol: Option[String], + FeatureSubsetStrategy: Option[String], + SubsamplingRate: Option[Double], + NumTrees: Option[Int] + ) + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("CheckpointInterval").map(_.toInt), + params.get("FeaturesCol"), + params.get("Impurity"), + params.get("LabelCol"), + params.get("LeafCol"), + params.get("MaxBins").map(_.toInt), + params.get("MaxDepth").map(_.toInt), + params.get("MinInfoGain").map(_.toDouble), + params.get("MinInstancesPerNode").map(_.toInt), + params.get("MinWeightFractionPerNode").map(_.toDouble), + params.get("PredictionCol"), + params.get("ProbabilityCol"), + params.get("RawPredictionCol"), + params.get("Seed").map(_.toLong), + params.get("Thresholds").map(_.split(",").map(_.toDouble)), + params.get("WeightCol"), + params.get("FeatureSubsetStrategy"), + params.get("SubsamplingRate").map(_.toDouble), + params.get("NumTrees").map(_.toInt) + ) + } + +} + +object RandomForestClassifier { + def apply(params: Map[String, String]): RandomForestClassifier = new RandomForestClassifier(params) +} + + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestRegressor.scala b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestRegressor.scala new file mode 100644 index 0000000..c5ee605 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestRegressor.scala @@ -0,0 +1,66 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.regression.{RandomForestRegressor => SparkML} + +/** + * @param CheckpointInterval + * @param FeaturesCol + * @param Impurity + * @param LabelCol + * @param LeafCol + * @param MaxBins + * @param MaxDepth + * @param MinInfoGain + * @param MinInstancesPerNode + * @param MinWeightFractionPerNode + * @param PredictionCol + * @param Seed + * @param WeightCol + * @param FeatureSubsetStrategy + * @param SubsamplingRate + * @param NumTrees + */ +case class RandomForestRegressor(CheckpointInterval: Option[Int], + FeaturesCol: Option[String], + Impurity: Option[String], + LabelCol: Option[String], + LeafCol: Option[String], + MaxBins: Option[Int], + MaxDepth: Option[Int], + MinInfoGain: Option[Double], + MinInstancesPerNode: Option[Int], + MinWeightFractionPerNode: Option[Double], + PredictionCol: Option[String], + Seed: Option[Long], + WeightCol: Option[String], + FeatureSubsetStrategy: Option[String], + SubsamplingRate: Option[Double], + NumTrees: Option[Int]) + extends CaraModel[SparkML] { + + def this(params: Map[String, String]) = { + this( + params.get("CheckpointInterval").map(_.toInt), + params.get("FeaturesCol"), + params.get("Impurity"), + params.get("LabelCol"), + params.get("LeafCol"), + params.get("MaxBins").map(_.toInt), + params.get("MaxDepth").map(_.toInt), + params.get("MinInfoGain").map(_.toDouble), + params.get("MinInstancesPerNode").map(_.toInt), + params.get("MinWeightFractionPerNode").map(_.toDouble), + params.get("PredictionCol"), + params.get("Seed").map(_.toLong), + params.get("WeightCol"), + params.get("FeatureSubsetStrategy"), + params.get("SubsamplingRate").map(_.toDouble), + params.get("NumTrees").map(_.toInt) + ) + } + +} +object RandomForestRegressor { + def apply(params: Map[String, String]): RandomForestRegressor = new RandomForestRegressor(params) +} + diff --git a/src/main/scala/io/github/jsarni/caraml/carastage/tuningstage/TuningStageDescription.scala b/src/main/scala/io/github/jsarni/caraml/carastage/tuningstage/TuningStageDescription.scala new file mode 100644 index 0000000..6e3e853 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carastage/tuningstage/TuningStageDescription.scala @@ -0,0 +1,3 @@ +package io.github.jsarni.caraml.carastage.tuningstage + +case class TuningStageDescription(tuningStage: String, paramName: String, paramValue: String) diff --git a/src/main/scala/io/github/jsarni/caraml/carayaml/CaraYamlReader.scala b/src/main/scala/io/github/jsarni/caraml/carayaml/CaraYamlReader.scala new file mode 100644 index 0000000..004a7e5 --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/carayaml/CaraYamlReader.scala @@ -0,0 +1,21 @@ +package io.github.jsarni.caraml.carayaml + +import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper} +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import org.yaml.snakeyaml.Yaml + +import java.io.{File, FileInputStream} +import scala.util.Try + +final case class CaraYamlReader(yamlPath: String){ + + def loadFile(): Try[JsonNode] = for { + ios <- Try(new FileInputStream(new File(yamlPath))) + yaml = new Yaml() + mapper = new ObjectMapper().registerModules(DefaultScalaModule) + yamlObj = yaml.loadAs(ios, classOf[Any]) + jsonString = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(yamlObj) + jsonObj = mapper.readTree(jsonString) + } yield jsonObj + +} diff --git a/src/main/scala/io/github/jsarni/caraml/pipelineparser/CaraParser.scala b/src/main/scala/io/github/jsarni/caraml/pipelineparser/CaraParser.scala new file mode 100644 index 0000000..f2fbc9e --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/pipelineparser/CaraParser.scala @@ -0,0 +1,144 @@ +package io.github.jsarni.caraml.pipelineparser + +import com.fasterxml.jackson.databind.JsonNode +import io.github.jsarni.caraml.carastage.tuningstage.TuningStageDescription +import io.github.jsarni.caraml.carastage.{CaraStage, CaraStageDescription, CaraStageMapper} +import io.github.jsarni.caraml.carayaml.CaraYamlReader +import org.apache.spark.ml.evaluation.Evaluator +import org.apache.spark.ml.{Pipeline, PipelineStage} + +import scala.collection.JavaConverters._ +import scala.util.{Failure, Success, Try} + +class CaraParser(caraYaml: CaraYamlReader) extends CaraStageMapper{ + + val contentTry = caraYaml.loadFile() + + def build(): Try[CaraPipeline] = { + for { + pipeline <- parsePipeline() + evaluator <- parseEvaluator() + hasTuner <- hasTuner() + tunerDescOpt = if (!hasTuner) None else Some(parseTuner().get) + } yield CaraPipeline(pipeline, evaluator, tunerDescOpt) + } + + private[pipelineparser] def parsePipeline(): Try[Pipeline] = { + for { + content <- contentTry + stagesDescriptions <- extractStages(content) + caraStages <- parseStages(stagesDescriptions) + sparkStages <- buildStages(caraStages) + pipeline <- buildPipeline(sparkStages) + } yield pipeline + } + + private[pipelineparser] def parseEvaluator(): Try[Evaluator] = { + for { + content <- contentTry + evaluatorName <- extractEvaluator(content) + evaluator = mapEvaluator(evaluatorName) + } yield evaluator + } + + private[pipelineparser] def parseTuner(): Try[TuningStageDescription] = { + for { + content <- contentTry + tunerDesc <- extractTuner(content) + validatedTunerDesc = mapTuner(tunerDesc) + } yield validatedTunerDesc + } + + + + private[pipelineparser] def extractStages(fileContent: JsonNode): Try[List[CaraStageDescription]] = Try { + val stagesList = + fileContent.at(s"/CaraPipeline").iterator().asScala.toList.filter(_.has("stage")) + + stagesList.map{ + stageDesc => + val name = stageDesc.at("/stage").asText() + + val paramsMap = + if (stageDesc.has("params")) { + val paramsJson = stageDesc.at("/params") + val paramList = paramsJson.iterator().asScala.toList + val paramNames = paramList.flatMap{ r =>r.fieldNames().asScala.toList} + + val paramsZip = paramNames zip paramList + paramsZip.map { + paramTuple => + val name = paramTuple._1 + val value = paramTuple._2.at(s"/$name").asText() + (name, value) + }.toMap + } else { + Map.empty[String, String] + } + + CaraStageDescription(name, paramsMap) + } + } + + private[pipelineparser] def extractEvaluator(fileContent: JsonNode): Try[String] = Try { + val stagesList = fileContent.at(s"/CaraPipeline").iterator().asScala.toList.filter(_.has("evaluator")) + val evaluatorList = stagesList.map{ stageDesc =>stageDesc.at("/evaluator").asText()} + + evaluatorList.length match { + case 1 => evaluatorList.head + case _ => + throw new Exception("Error: You must define exactly one SparkML Evaluator") + } + } + + private[pipelineparser] def hasTuner(): Try[Boolean] = + for { + content <- contentTry + hasTuner = content.at(s"/CaraPipeline").iterator().asScala.toList.filter(_.has("tuner")).nonEmpty + } yield hasTuner + + private[pipelineparser] def extractTuner(fileContent: JsonNode): Try[TuningStageDescription] = { + val tunersList = fileContent.at(s"/CaraPipeline").iterator().asScala.toList.filter(_.has("tuner")) + + tunersList.length match { + case l if l == 1 => + val tunerJson = tunersList.head + val tunerName = tunerJson.at("/tuner").textValue() + + val paramsJson = tunerJson.at("/params") + val paramList = paramsJson.iterator().asScala.toList + paramList.length match { + case 1 => + val paramName = paramList.flatMap { r => r.fieldNames().asScala.toList }.head + val paramValue = paramList.head.at(s"/$paramName").asText() + + Success(TuningStageDescription(tunerName, paramName, paramValue)) + case _ => + Failure(new IllegalArgumentException("Tuners must have exactly one param")) + } + case _ => + Failure(new IllegalArgumentException("Error: You must define exactly one SparkML Evaluator")) + } + } + + private[pipelineparser] def parseSingleStageMap(stageDescription: CaraStageDescription): Try[CaraStage[_]] = { + mapStage(stageDescription) + } + + private[pipelineparser] def parseStages(stagesDescriptionsList: List[CaraStageDescription]): Try[List[CaraStage[_]]] = { + Try(stagesDescriptionsList.map(parseSingleStageMap(_).get)) + } + + private[pipelineparser] def buildStages(stagesList: List[CaraStage[_]]): Try[List[PipelineStage]] = { + Try(stagesList.map(_.build().get)) + } + + private[pipelineparser] def buildPipeline(mlStages: List[PipelineStage]): Try[Pipeline] = { + Try(new Pipeline().setStages(mlStages.toArray)) + } + +} + +object CaraParser { + def apply(caraYaml: CaraYamlReader): CaraParser = new CaraParser(caraYaml) +} diff --git a/src/main/scala/io/github/jsarni/caraml/pipelineparser/CaraPipeline.scala b/src/main/scala/io/github/jsarni/caraml/pipelineparser/CaraPipeline.scala new file mode 100644 index 0000000..687292f --- /dev/null +++ b/src/main/scala/io/github/jsarni/caraml/pipelineparser/CaraPipeline.scala @@ -0,0 +1,7 @@ +package io.github.jsarni.caraml.pipelineparser + +import io.github.jsarni.caraml.carastage.tuningstage.TuningStageDescription +import org.apache.spark.ml.Pipeline +import org.apache.spark.ml.evaluation.Evaluator + +case class CaraPipeline(pipeline: Pipeline, evaluator: Evaluator, tuner: Option[TuningStageDescription]) diff --git a/src/test/resources/cara.yaml b/src/test/resources/cara.yaml new file mode 100644 index 0000000..02a176b --- /dev/null +++ b/src/test/resources/cara.yaml @@ -0,0 +1,11 @@ +CaraPipeline: +- stage: LogisticRegression + params: + - MaxIter: 10 + - RegParam: 0.3 + - ElasticNetParam: 0.1 +- stage: FeatureSelection + params: + - Param1: "S" + - Param2: 0.5 + - Param3: false diff --git a/src/test/resources/cara_for_build.yaml b/src/test/resources/cara_for_build.yaml new file mode 100644 index 0000000..d365e40 --- /dev/null +++ b/src/test/resources/cara_for_build.yaml @@ -0,0 +1,10 @@ +CaraPipeline: +- stage: LogisticRegression + params: + - MaxIter: 10 + - RegParam: 0.3 + - ElasticNetParam: 0.1 +- evaluator: RegressionEvaluator +- tuner: CrossValidator + params: + - NumFolds: 3 diff --git a/src/test/resources/cara_two_evaluator.yaml b/src/test/resources/cara_two_evaluator.yaml new file mode 100644 index 0000000..c37f531 --- /dev/null +++ b/src/test/resources/cara_two_evaluator.yaml @@ -0,0 +1,8 @@ +CaraPipeline: +- stage: LogisticRegression + params: + - MaxIter: 10 + - RegParam: 0.3 + - ElasticNetParam: 0.1 +- tuner: Tuner 1 +- tuner: Tuner 2 \ No newline at end of file diff --git a/src/test/resources/cara_zero_evaluator.yaml b/src/test/resources/cara_zero_evaluator.yaml new file mode 100644 index 0000000..da473f5 --- /dev/null +++ b/src/test/resources/cara_zero_evaluator.yaml @@ -0,0 +1,8 @@ +CaraPipeline: +- stage: LogisticRegression + params: + - MaxIter: 10 + - RegParam: 0.3 + - ElasticNetParam: 0.1 +- evaluator: RegressionEvaluator +- evaluator: OtherEvaluator diff --git a/src/test/resources/incorrect_cara.yaml b/src/test/resources/incorrect_cara.yaml new file mode 100644 index 0000000..a79e383 --- /dev/null +++ b/src/test/resources/incorrect_cara.yaml @@ -0,0 +1,8 @@ +CaraPipeline: +- stage: LogisticRegression + params: + - MaxIter: 10 +RegParam: 0.3 + - ElasticNetParam: + - _1: 0.8 +- _2: 0.0 \ No newline at end of file diff --git a/src/test/scala/io/github/jsarni/CaraModelTest.scala b/src/test/scala/io/github/jsarni/CaraModelTest.scala new file mode 100644 index 0000000..edc48ee --- /dev/null +++ b/src/test/scala/io/github/jsarni/CaraModelTest.scala @@ -0,0 +1,93 @@ +package io.github.jsarni + +import io.github.jsarni.caraml.CaraModel +import io.github.jsarni.caraml.carastage.modelstage.LogisticRegression +import io.github.jsarni.caraml.carastage.tuningstage.TuningStageDescription +import io.github.jsarni.caraml.pipelineparser.CaraPipeline +import org.apache.spark.ml.Pipeline +import org.apache.spark.ml.evaluation.{BinaryClassificationEvaluator, RegressionEvaluator} +import org.apache.spark.ml.feature.{StringIndexer, VectorAssembler} +import org.apache.spark.ml.regression.LinearRegression +import org.apache.spark.ml.tuning.{CrossValidator, TrainValidationSplit} +import org.apache.spark.sql.SparkSession + +import scala.util.Try + +class CaraModelTest extends TestBase { + "generateModel" should "Return validation model with the right method and params" in { + val lr = new LinearRegression() + .setMaxIter(10) + + val crossEvaluator = new BinaryClassificationEvaluator + val crossTuner = TuningStageDescription("CrossValidator", "NumFolds", "2") + val splitEvaluator = new RegressionEvaluator + val splitTuner = TuningStageDescription("TrainValidationSplit", "TrainRatio", "0.6") + + implicit val spark: SparkSession = + SparkSession.builder() + .appName("CaraML") + .master("local[1]") + .getOrCreate() + + val caraModel = new CaraModel("YamlPath", spark.emptyDataFrame, "savePath") + val pipeline = new Pipeline().setStages(Array(lr)) + val crossCaraPipeline = CaraPipeline(pipeline, crossEvaluator, Some(crossTuner)) + val splitCaraPipeline = CaraPipeline(pipeline, splitEvaluator, Some(splitTuner)) + val method = PrivateMethod[Try[Pipeline]]('generateModel) + + val crossModel = caraModel.invokePrivate(method(crossCaraPipeline)) + val splitModel = caraModel.invokePrivate(method(splitCaraPipeline)) + + crossModel.isSuccess shouldBe true + crossModel.get.getStages.length shouldBe 1 + crossModel.get.getStages.head.isInstanceOf[CrossValidator] shouldBe true + crossModel.get.getStages.head.asInstanceOf[CrossValidator].getNumFolds shouldBe 2 + + splitModel.isSuccess shouldBe true + splitModel.get.getStages.length shouldBe 1 + splitModel.get.getStages.head.isInstanceOf[TrainValidationSplit] shouldBe true + splitModel.get.getStages.head.asInstanceOf[TrainValidationSplit].getTrainRatio shouldBe 0.6 + } + "generateReport" should "return the Pipeline metrics" in { + + implicit val spark: SparkSession = + SparkSession.builder() + .appName("CaraML") + .master("local[1]") + .getOrCreate() + + val Data = spark.createDataFrame( + Seq( + (0.0, 0.21, 0.66), + (0.0, 0.38, 0.78), + (1.0, 0.55, 0.25), + (1.0, 0.70, 0.10), + (1.0, 0.91, 0.06), + (0.0, 0.27, 0.70) + ) + ).toDF("labels", "values1","values2") + + val cols = Array( "values1","values2") + val assembler = new VectorAssembler() + .setInputCols(cols) + .setOutputCol("features") + + val indexer = new StringIndexer() + .setInputCol("labels") + .setOutputCol("label") + + val logi = LogisticRegression(Map("MaxIter"->"10")).build().get + + val pipeline = new Pipeline() + .setStages(Array(assembler,indexer,logi)) + + val method = PrivateMethod[Try[Unit]]('generateReport) + val fitedPipeline = pipeline.fit(Data) + + val caraModel = new CaraModel("/home/aghylassai/Bureau/test_PA/test_file.yaml", Data, "/home/aghylassai/Bureau/test_PA/model.cml") + + val reportModel = caraModel. invokePrivate(method(fitedPipeline)) + reportModel.isSuccess shouldBe true + } + +} diff --git a/src/test/scala/io/github/jsarni/TestBase.scala b/src/test/scala/io/github/jsarni/TestBase.scala new file mode 100644 index 0000000..c4f702d --- /dev/null +++ b/src/test/scala/io/github/jsarni/TestBase.scala @@ -0,0 +1,15 @@ +package io.github.jsarni + +import org.mockito.MockitoSugar +import org.scalatest.{GivenWhenThen, OptionValues, PrivateMethodTester, TryValues} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +abstract class TestBase + extends AnyFlatSpec + with MockitoSugar + with GivenWhenThen + with PrivateMethodTester + with Matchers + with OptionValues + with TryValues {} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BinarizerTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BinarizerTest.scala new file mode 100644 index 0000000..534360e --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BinarizerTest.scala @@ -0,0 +1,49 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{Binarizer => fromSparkML} + +class BinarizerTest extends TestBase { + + "Binarizer build Success" should "build new binarizer with parametres given on the Map and be the same with SparkMl Binarizer" in { + + val CaraDsFeature = Binarizer( + Map( + "InputCol"->"Input", + "InputCols"->"col1 , col2 ,col3, col4", + "OutputCol" ->"Output", + "Threshold" -> "10.0", + "OutputCols" -> "Col10 , Col11 ,Col_12, Col_vector_1", + "Thresholds" -> "10.0 , 12.0 , 13.0" + ) + ) + + val SparkFeature = new fromSparkML() + .setInputCol("Input") + .setInputCols(Array("col1" , "col2" ,"col3", "col4")) + .setOutputCol("Output") + .setOutputCols(Array("Col10" , "Col11" ,"Col_12", "Col_vector_1")) + .setThreshold(10.0) + .setThresholds(Array(10.0 , 12.0 , 13.0)) + + val CaraDsParams= CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "Binarizer build Failure" should "Throw NumberFormatException " in { + + an [NumberFormatException] should be thrownBy Binarizer( + Map( + "InputCol" -> "Input", + "InputCols" -> "col1 , col2 ,col3, col4", + "OutputCol" -> "Output", + "Threshold" -> "wrong_param", + "OutputCols" -> "Col10 , Col11 ,Col_12, Col_vector_1", + "Thresholds" -> "10.0 , 12.0 , 13.0" + ) + ) + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSHTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSHTest.scala new file mode 100644 index 0000000..235ef6c --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketedRandomProjectionLSHTest.scala @@ -0,0 +1,46 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{BucketedRandomProjectionLSH => fromSparkML} + +class BucketedRandomProjectionLSHTest extends TestBase { + + "BucketedRandomProjectionLSH build Success" should + "Build BucketedRandomProjectionLSH with the parametres given and be the same of Spark ML BucketedRandomProjectionLSH" in { + + val CaraDsFeature = new BucketedRandomProjectionLSH( + Map( + "BucketLength" -> "10.0", + "InputCol" -> "Col_Input", + "NumHashTables" -> "5", + "OutputCol" -> "Col_Output", + "Seed" -> "10" + ) + ) + + val SparkFeature=new fromSparkML() + .setBucketLength(10) + .setInputCol("Col_Input") + .setNumHashTables(5) + .setOutputCol("Col_Output") + .setSeed(10) + + val CaraDsParams= CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "BucketedRandomProjectionLSHTest build Failure" should "Throw NumberFormatException " in { + + an [NumberFormatException] should be thrownBy BucketedRandomProjectionLSH( + Map( + "BucketLength" -> "10.0", + "InputCol" -> "Col_Input", + "NumHashTables" -> "wrong_value", + "OutputCol" -> "Col_Output", + "Seed" -> "10" + ) + ) + } +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketizerTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketizerTest.scala new file mode 100644 index 0000000..b45c9cf --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/BucketizerTest.scala @@ -0,0 +1,79 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{Bucketizer => fromSparkML} + +import java.lang.reflect.InvocationTargetException + +class BucketizerTest extends TestBase { + + "Bucketizer build Success" should + "build new Bucketizer with parametres given on the Map and be the same with SparkMl Bucketizer" in { + + val CaraDsFeature=Bucketizer( + Map( + "HandleInvalid" -> "skip", + "InputCol" -> "Input", + "InputCols" -> "col1 , col2 ,col3, col4", + "OutputCol" -> "Output", + "OutputCols" -> "Col10 , Col11,Col_12,Col_vector_1", + "Splits" -> "-0.5,0.0,0.5", + "SplitsArray" -> "1.0 , 4.0 , 8.0" + ) + ) + + val SparkFeature = new fromSparkML() + .setHandleInvalid("skip") + .setInputCol("Input") + .setInputCols(Array("col1" , "col2" ,"col3", "col4")) + .setOutputCol("Output") + .setOutputCols(Array("Col10" , "Col11" ,"Col_12", "Col_vector_1")) + .setSplits(Array(-0.5,0.0,0.5)) + .setSplitsArray(Array(Array(1.0 , 4.0 , 8.0))) + + val CaraDsParams= CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "Bucketizer build Failure" should "fail to build new Bucketizer when wrong parameter is set" in { + + an [InvocationTargetException] must be thrownBy Bucketizer( + Map( + "HandleInvalid" -> "OK", + "InputCol" -> "Input", + "InputCols" -> "col1 , col2 ,col3, col4", + "OutputCol" -> "Output", + "OutputCols" -> "Col10 , Col11,Col_12,Col_vector_1", + "Splits" -> "-0.5,0.0,0.5", + "SplitsArray" -> "1.0 , 4.0 , 8.0" + ) + ).build().get + + an [InvocationTargetException] must be thrownBy Bucketizer( + Map( + "HandleInvalid" -> "error", + "InputCol" -> "Input", + "InputCols" -> "col1 , col2 ,col3, col4", + "OutputCol" -> "Output", + "OutputCols" -> "Col10 , Col11,Col_12,Col_vector_1", + "Splits" -> "-0.5,0.0,0.5", + "SplitsArray" -> "1.0 , 8.0" + ) + ).build().get + + an [InvocationTargetException] must be thrownBy Bucketizer( + Map( + "HandleInvalid" -> "error", + "InputCol" -> "Input", + "InputCols" -> "col1 , col2 ,col3, col4", + "OutputCol" -> "Output", + "OutputCols" -> "Col10 , Col11,Col_12,Col_vector_1", + "Splits" -> "-0.5,0.5", + "SplitsArray" -> "1.0 ,3.0, 8.0" + ) + ).build().get + } + +} \ No newline at end of file diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelectorTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelectorTest.scala new file mode 100644 index 0000000..991aee7 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/ChiSqSelectorTest.scala @@ -0,0 +1,101 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{ChiSqSelector => fromSparkML} + +import java.lang.reflect.InvocationTargetException + +class ChiSqSelectorTest extends TestBase { + "ChiSqSelector build Success" should + "build new ChiSqSelector from given parameters, and must be the same with SparkML ChiSqSelector" in { + + val CaraDsFeature=ChiSqSelector( + Map( + "Fdr" -> "0.01", + "FeaturesCol" -> "Input", + "Fpr" -> "0.2", + "Fwe" -> "0.2", + "LabelCol" -> "col1", + "OutputCol" -> "Output", + "NumTopFeatures" -> "40", + "Percentile" -> "0.1", + "SelectorType"-> "fpr" + ) + ) + + val SparkFeature=new fromSparkML() + .setFdr(0.01) + .setFeaturesCol("Input") + .setFpr(0.2) + .setFwe(0.2) + .setLabelCol("col1") + .setOutputCol("Output") + .setNumTopFeatures(40) + .setPercentile(0.1) + .setSelectorType("fpr") + + val CaraDsParams= CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "ChiSqSelector build Failure" should "fail to build new ChiSqSelector with wrong parameters" in { + + an [InvocationTargetException] must be thrownBy ChiSqSelector( + Map( + "Fdr" -> "10.0", + "FeaturesCol" -> "Input", + "Fpr" -> "0.2", + "Fwe" -> "0.2", + "LabelCol" -> "col1", + "OutputCol" -> "Output", + "NumTopFeatures" -> "40", + "Percentile" -> "0.1", + "SelectorType"-> "fpr" + ) + ).build().get + } + + an [InvocationTargetException] must be thrownBy ChiSqSelector( + Map( + "Fdr" -> "1.0", + "FeaturesCol" -> "Input", + "Fpr" -> "20.0", + "Fwe" -> "0.2", + "LabelCol" -> "col1", + "OutputCol" -> "Output", + "NumTopFeatures" -> "40", + "Percentile" -> "0.1", + "SelectorType"-> "fpr" + ) + ).build().get + + an [InvocationTargetException] must be thrownBy ChiSqSelector( + Map( + "Fdr" -> "1.0", + "FeaturesCol" -> "Input", + "Fpr" -> "0.2", + "Fwe" -> "20.2", + "LabelCol" -> "col1", + "OutputCol" -> "Output", + "NumTopFeatures" -> "40", + "Percentile" -> "0.1", + "SelectorType"-> "fpr" + ) + ).build().get + + an [InvocationTargetException] must be thrownBy ChiSqSelector( + Map( + "Fdr" -> "1.0", + "FeaturesCol" -> "Input", + "Fpr" -> "0.2", + "Fwe" -> "0.2", + "LabelCol" -> "col1", + "OutputCol" -> "Output", + "NumTopFeatures" -> "40", + "Percentile" -> "10.1", + "SelectorType"-> "fpr" + ) + ).build().get +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerModelTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerModelTest.scala new file mode 100644 index 0000000..10c9533 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerModelTest.scala @@ -0,0 +1,49 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{CountVectorizerModel => fromSparkML} + +class CountVectorizerModelTest extends TestBase { + "CountVectorizerModel build Success" should + "build new CountVectorizerModel from given parameters and return the same args as SparkML CountVectorizerModel" in { + + val CaraDsFeature =CountVectorizer( + Map( + "Binary" -> "true", + "InputCol" -> "Input", + "VocabSize" -> "3", + "OutputCol" -> "Col10", + "MinDF" -> "1.0", + "MinTF" -> "10.0", + "MaxDF" -> "9.223372036854776E18", + "Vocabulary" -> "a, b, c" + ) + ) + + val SparkFeature = new fromSparkML(Array("a","b","c")) + .setBinary(true) + .setInputCol("Input") + .setMinTF(10.0) + .setOutputCol("Col10") + + val CaraDsParams = CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "CountVectorizer build Failure" should "fail to build CountVectorizer with wrong parameters" in { + + an [IllegalArgumentException] must be thrownBy CountVectorizer( + Map( + "Binary" -> "OK", + "InputCol" -> "Input", + "MaxDF" -> "15.0", + "MinDF" -> "8.0", + "OutputCol" -> "Col10", + "MinTF" -> "10.0", + "VocabSize" -> "4" + ) + ).build().get + } +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerTest.scala new file mode 100644 index 0000000..389b36b --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/CountVectorizerTest.scala @@ -0,0 +1,52 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{CountVectorizer => fromSparkML} + +class CountVectorizerTest extends TestBase { + "CountVectorizer build Success" should + "build new CountVectorizer from given parameters and return the same args as SparkML CountVectorizer" in { + + val CaraDsFeature = CountVectorizer( + Map( + "Binary" -> "true", + "InputCol" -> "Input", + "MaxDF" -> "15.0", + "MinDF" -> "8.0", + "OutputCol" -> "Col10", + "MinTF" -> "10.0", + "VocabSize" -> "4" + ) + ) + + val SparkFeature = new fromSparkML() + .setBinary(true) + .setInputCol("Input") + .setMaxDF(15.0) + .setMinDF(8.0) + .setMinTF(10.0) + .setOutputCol("Col10") + .setVocabSize(4) + + val CaraDsParams = CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "CountVectorizer build Failure" should "fail to build CountVectorizer with wrong parameters" in { + + an [IllegalArgumentException] must be thrownBy CountVectorizer( + Map( + "Binary" -> "OK", + "InputCol"->"Input", + "MaxDF"->"15.0", + "MinDF" ->"8.0", + "OutputCol" -> "Col10", + "MinTF" -> "10.0", + "VocabSize" -> "4" + ) + ).build().get + + } +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/HashingTFTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/HashingTFTest.scala new file mode 100644 index 0000000..f41e3fd --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/HashingTFTest.scala @@ -0,0 +1,50 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{HashingTF => fromSparkML} + +class HashingTFTest extends TestBase { + + "HashingTF build Success" should "build new HashingTF from given parameters and return the same args as SparkML HashingTF" in { + + val CaraDsFeature=HashingTF( + Map( + "Binary" -> "true", + "InputCol" -> "Input", + "OutputCol" -> "Col10", + "NumFeatures" -> "2" + ) + ) + val SparkFeature=new fromSparkML() + .setBinary(true) + .setInputCol("Input") + .setNumFeatures(2) + .setOutputCol("Col10") + + val CaraDsParams= CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "HashingTF build Failure" should "fail to build HashingTF with wrong parameters" in { + an [IllegalArgumentException] must be thrownBy HashingTF( + Map( + "Binary" -> "OK", + "InputCol" -> "Input", + "OutputCol" -> "Col10", + "NumFeatures" -> "2" + ) + ).build().get + + an [IllegalArgumentException] must be thrownBy HashingTF( + Map( + "Binary" -> "OK", + "InputCol" -> "Input", + "OutputCol" -> "Col10", + "NumFeatures" -> "0" + ) + ).build().get + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/IDFTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/IDFTest.scala new file mode 100644 index 0000000..df43ac1 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/IDFTest.scala @@ -0,0 +1,37 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{IDF => fromSparkML} +import java.lang.reflect.InvocationTargetException + +class IDFTest extends TestBase { + + "IDF build Success" should "build new IDF from given parameters and return the same args as SparkML IDF" in { + val CaraDsFeature=IDF( + Map( + "InputCol" -> "Input", + "OutputCol" -> "Col10", + "MinDocFreq" -> "4" + ) + ) + val SparkFeature=new fromSparkML() + .setInputCol("Input") + .setOutputCol("Col10") + .setMinDocFreq(4) + val CaraDsParams= CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "IDF build Failure" should "fail to build IDF with wrong parameters" in { + + an [InvocationTargetException] must be thrownBy IDF( + Map( + "InputCol" -> "Input", + "OutputCol" -> "Col10", + "MinDocFreq" -> "-6" + ) + ).build().get + } +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/RegexTokenizerTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/RegexTokenizerTest.scala new file mode 100644 index 0000000..b58bfc7 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/RegexTokenizerTest.scala @@ -0,0 +1,60 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{RegexTokenizer => fromSparkML} + +import java.lang.reflect.InvocationTargetException + +class RegexTokenizerTest extends TestBase { + "RegexTokenizer build Success" should + "build new RegexTokenizer from given parameters and return the same args as SparkML RegexTokenizer" in { + + val CaraDsFeature=RegexTokenizer( + Map( + "Gaps" -> "true", + "InputCol" -> "Input", + "MinTokenLength" -> "1", + "Pattern" -> " * ", + "OutputCol" -> "Col10", + "ToLowercase" -> "false" + ) + ) + + val SparkFeature=new fromSparkML() + .setGaps(true) + .setInputCol("Input") + .setMinTokenLength(1) + .setPattern(" * ") + .setToLowercase(false) + .setOutputCol("Col10") + + val CaraDsParams= CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "RegexTokenizer build Failure" should "fail to build RegexTokenizer with wrong parameters" in { + + an [IllegalArgumentException] must be thrownBy RegexTokenizer( + Map( + "Gaps" -> "12", + "InputCol" -> "Input", + "MinTokenLength" -> "1", + "Pattern" -> " * ", + "OutputCol" -> "Col10", + "ToLowercase" -> "false" + )).build().get + + an [InvocationTargetException] must be thrownBy RegexTokenizer( + Map( + "Gaps" -> "true", + "InputCol" -> "Input", + "MinTokenLength" -> "-1", + "Pattern" -> " * ", + "OutputCol" -> "Col10", + "ToLowercase" -> "false" + )).build().get + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/TokenizerTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/TokenizerTest.scala new file mode 100644 index 0000000..52d5672 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/TokenizerTest.scala @@ -0,0 +1,21 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{Tokenizer => fromSparkML} + +class TokenizerTest extends TestBase { + + "Tokenizer build Success" should "build new Tokenizer from given parameters and return the same args as SparkML Tokenizer" in { + val CaraDsFeature = Tokenizer(Map("InputCol" -> "Input", "OutputCol" -> "Col10")) + + val SparkFeature = new fromSparkML() + .setInputCol("Input") + .setOutputCol("Col10") + + val CaraDsParams = CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/Word2VecTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/Word2VecTest.scala new file mode 100644 index 0000000..4f0b859 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/datasetstage/Word2VecTest.scala @@ -0,0 +1,73 @@ +package io.github.jsarni.caraml.carastage.datasetstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.feature.{Word2Vec => fromSparkML} + +import java.lang.reflect.InvocationTargetException + +class Word2VecTest extends TestBase { + + "Word2Vec build Success" should "build new Word2Vec from given parameters and return the same args as SparkML Word2Vec" in { + + val CaraDsFeature = Word2Vec( + Map( + "InputCol" -> "Input", + "MaxIter" -> "15", + "MaxSentenceLength" -> "8", + "NumPartitions" -> "5", + "OutputCol" -> "Col10", + "StepSize" -> "10.0", + "VectorSize" -> "4", + "MinCount"->"3", + "Seed" -> "10" + ) + ) + + val SparkFeature=new fromSparkML() + .setInputCol("Input") + .setMaxIter(15) + .setMaxSentenceLength(8) + .setNumPartitions(5) + .setOutputCol("Col10") + .setStepSize(10.0) + .setVectorSize(4) + .setMinCount(3) + .setSeed(10) + + val CaraDsParams= CaraDsFeature.build().get.extractParamMap.toSeq.map(_.value).toList + val SparkParams = SparkFeature.extractParamMap().toSeq.map(_.value).toList + + CaraDsParams should contain theSameElementsAs SparkParams + } + + "Word2Vec build Failure" should "fail to build Word2Vec with wrong parameters" in { + + an [InvocationTargetException] must be thrownBy Word2Vec( + Map ("InputCol"->"Input", + "MaxIter"->"15", + "MaxSentenceLength" ->"8", + "NumPartitions"->"5", + "OutputCol" -> "Col10" + ,"StepSize" -> "0" + ,"VectorSize" -> "4" + ,"MinCount"->"3" + ,"Seed" -> "10" + ) + ).build().get + + an [InvocationTargetException] must be thrownBy Word2Vec( + Map( + "InputCol"->"Input", + "MaxIter"->"-15", + "MaxSentenceLength" ->"8", + "NumPartitions"->"5", + "OutputCol" -> "Col10", + "StepSize" -> "0", + "VectorSize" -> "4", + "MinCount"->"3", + "Seed" -> "10" + ) + ).build().get + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeClassifierTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeClassifierTest.scala new file mode 100644 index 0000000..c6b0c47 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeClassifierTest.scala @@ -0,0 +1,105 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.classification.{DecisionTreeClassifier => SparkML} +import io.github.jsarni.TestBase + +class DecisionTreeClassifierTest extends TestBase { + + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "Impurity" -> "entropy", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "ProbabilityCol" -> "ProbabilityCol", + "RawPredictionCol" -> "RawPredictionCol", + "Seed" -> "124555", + "Thresholds" -> "0.2, 0.04", + "WeightCol" -> "1.2" + ) + val dTree = DecisionTreeClassifier(params) + val dTreeWithTwoParams = new SparkML() + .setCheckpointInterval(5) + .setMaxDepth(10) + + val expectedResult = List( + new SparkML() + .setCheckpointInterval(10) + .setFeaturesCol("FeatureCol") + .setImpurity("entropy") + .setLabelCol("LabelCol") + .setLeafCol("LeafCol") + .setMaxBins(10) + .setMaxDepth(5) + .setMinInfoGain(0.02) + .setMinInstancesPerNode(2) + .setMinWeightFractionPerNode(0.03) + .setPredictionCol("PredictionCol") + .setProbabilityCol("ProbabilityCol") + .setRawPredictionCol("RawPredictionCol") + .setSeed(124555.toLong) + .setWeightCol("1.2") + .setThresholds(Array(0.2, 0.04)) + ) + + dTree.build().isSuccess shouldBe true + + val res = List(dTree.build().get) + + val resParameters = + res.flatMap { elem => + val values = elem.extractParamMap().toSeq.map(_.value) + values.flatMap { value => + if (value.isInstanceOf[Array[_]]) value.asInstanceOf[Array[_]].toList else List(value) + } + } + val expectedParameters = + expectedResult.flatMap { elem => + val values = elem.extractParamMap().toSeq.map(_.value) + values.flatMap { value => + if (value.isInstanceOf[Array[_]]) value.asInstanceOf[Array[_]].toList else List(value) + } + } + + resParameters should contain theSameElementsAs expectedParameters + + dTreeWithTwoParams.getImpurity shouldBe "gini" + dTreeWithTwoParams.getMaxBins shouldBe 32 + dTreeWithTwoParams.getMinInfoGain shouldBe 0.0 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "Impurity" -> "entropy", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "ProbabilityCol" -> "ProbabilityCol", + "RawPredictionCol" -> "RawPredictionCol", + "Seed" -> "124555", + "Thresholds" -> "0.2, 0.04", + "WeightCol" -> "1.2" + ) + val caraLr = DecisionTreeClassifier(params) + val model =caraLr.build().get.asInstanceOf[SparkML] + + caraLr.getMethode(model,10.toLong,"Seed").getName shouldBe "setSeed" + caraLr.getMethode(model,"PredictCol","PredictionCol").getName shouldBe "setPredictionCol" + caraLr.getMethode(model, 10 ,"CheckpointInterval").getName shouldBe "setCheckpointInterval" + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeRegressorTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeRegressorTest.scala new file mode 100644 index 0000000..bc43cc3 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/DecisionTreeRegressorTest.scala @@ -0,0 +1,90 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.regression.{DecisionTreeRegressor => SparkML} +import io.github.jsarni.TestBase + +class DecisionTreeRegressorTest extends TestBase { + + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "Impurity" -> "variance", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "Seed" -> "124555", + "VarianceCol" -> "VarCol", + "WeightCol" -> "1.2" + ) + val dTree = DecisionTreeRegressor(params) + val dTreeWithTwoParams = new SparkML() + .setCheckpointInterval(5) + .setMaxDepth(10) + + val expectedResult = List( + new SparkML() + .setCheckpointInterval(10) + .setFeaturesCol("FeatureCol") + .setImpurity("variance") + .setLabelCol("LabelCol") + .setLeafCol("LeafCol") + .setMaxBins(10) + .setMaxDepth(5) + .setMinInfoGain(0.02) + .setMinInstancesPerNode(2) + .setMinWeightFractionPerNode(0.03) + .setPredictionCol("PredictionCol") + .setSeed(124555.toLong) + .setVarianceCol("VarCol") + .setWeightCol("1.2") + + ) + dTree.build().isSuccess shouldBe true + + val res = List(dTree.build().get) + val resParameters = res.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + val resContain = resParameters(0).toList + val expectedContain = expectedParameters(0).toList + + resContain should contain theSameElementsAs expectedContain + + dTreeWithTwoParams.getImpurity shouldBe "variance" + dTreeWithTwoParams.getMaxBins shouldBe 32 + dTreeWithTwoParams.getMinInfoGain shouldBe 0.0 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "Impurity" -> "variance", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "Seed" -> "124555", + "VarianceCol" -> "VarCol", + "WeightCol" -> "1.2" + ) + val caraLr = DecisionTreeRegressor(params) + val model =caraLr.build().get.asInstanceOf[SparkML] + + caraLr.getMethode(model,10.toLong,"Seed").getName shouldBe "setSeed" + caraLr.getMethode(model,"PredictCol","PredictionCol").getName shouldBe "setPredictionCol" + caraLr.getMethode(model, 10 ,"CheckpointInterval").getName shouldBe "setCheckpointInterval" + } + +} + diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/GBTClassifierTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/GBTClassifierTest.scala new file mode 100644 index 0000000..c34b61a --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/GBTClassifierTest.scala @@ -0,0 +1,107 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.classification.{GBTClassifier => SparkML} + +class GBTClassifierTest extends TestBase { + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "LabelCol" -> "LabelCol", + "FeaturesCol" -> "FeatureColname", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "ProbabilityCol" -> "ProbabilityCol", + "RawPredictionCol" -> "RawPredictionCol", + "Seed" -> "124555", + "Thresholds" -> "0.2, 0.04", + "WeightCol" -> "1.2", + "FeatureSubsetStrategy" -> "auto" , + "SubsamplingRate" -> "0.5", + "LossType" -> "logistic", + "MaxIter" -> "20", + "StepSize" -> "0.3" , + "ValidationIndicatorCol" -> "ValidationIndicatorCol" + ) + val gbt = GBTClassifier(params) + val gbtWithTwoParams = new SparkML() + .setCheckpointInterval(5) + .setMaxDepth(10) + + val expectedResult = List( + new SparkML() + .setCheckpointInterval(10) + .setFeaturesCol("FeatureCol") + .setLabelCol("LabelCol") + .setFeaturesCol("FeatureColname") + .setLeafCol("LeafCol") + .setMaxBins(10) + .setMaxDepth(5) + .setMinInfoGain(0.02) + .setMinInstancesPerNode(2) + .setMinWeightFractionPerNode(0.03) + .setPredictionCol("PredictionCol") + .setProbabilityCol("ProbabilityCol") + .setRawPredictionCol("RawPredictionCol") + .setSeed(124555.toLong) + .setWeightCol("1.2") + .setThresholds(Array(0.2, 0.04)) + .setFeatureSubsetStrategy("auto") + .setSubsamplingRate(0.5) + .setLossType("logistic") + .setMaxIter(20) + .setStepSize(0.3) + .setValidationIndicatorCol("ValidationIndicatorCol") + ) + gbt.build().isSuccess shouldBe true + + val res = List(gbt.build().get) + val resParameters = res.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + resParameters.head should contain theSameElementsAs expectedParameters.head + + gbtWithTwoParams.getMinInstancesPerNode shouldBe 1 + gbtWithTwoParams.getLossType shouldBe "logistic" + gbtWithTwoParams.getStepSize shouldBe 0.1 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "LabelCol" -> "LabelCol", + "FeaturesCol" -> "FeatureColname", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "ProbabilityCol" -> "ProbabilityCol", + "RawPredictionCol" -> "RawPredictionCol", + "Seed" -> "124555", + "Thresholds" -> "0.2, 0.04", + "WeightCol" -> "1.2", + "LossType" -> "logistic", + "MaxIter" -> "20", + "StepSize" -> "0.3" , + "ValidationIndicatorCol" -> "ValidationIndicatorCol" + ) + val caraLr = GBTClassifier(params) + val model =caraLr.build().get.asInstanceOf[SparkML] + + caraLr.getMethode(model,10.toLong,"Seed").getName shouldBe "setSeed" + caraLr.getMethode(model,"PredictCol","PredictionCol").getName shouldBe "setPredictionCol" + caraLr.getMethode(model, 10 ,"CheckpointInterval").getName shouldBe "setCheckpointInterval" + } + +} + diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/GBTRegressorTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/GBTRegressorTest.scala new file mode 100644 index 0000000..e95198d --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/GBTRegressorTest.scala @@ -0,0 +1,98 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.regression.{GBTRegressor => SparkML} +import io.github.jsarni.TestBase + +class GBTRegressorTest extends TestBase { + + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "Seed" -> "124555", + "WeightCol" -> "1.2", + "FeatureSubsetStrategy" -> "auto" , + "SubsamplingRate" -> "0.5", + "LossType" -> "absolute", + "MaxIter" -> "20", + "StepSize" -> "0.3" , + "ValidationIndicatorCol" -> "ValidationIndicatorCol" + ) + val gbt = GBTRegressor(params) + val gbtWithTwoParams = new SparkML() + .setCheckpointInterval(5) + .setMaxDepth(10) + + val expectedResult = List( + new SparkML() + .setCheckpointInterval(10) + .setFeaturesCol("FeatureCol") + .setLabelCol("LabelCol") + .setLeafCol("LeafCol") + .setMaxBins(10) + .setMaxDepth(5) + .setMinInfoGain(0.02) + .setMinInstancesPerNode(2) + .setMinWeightFractionPerNode(0.03) + .setPredictionCol("PredictionCol") + .setSeed(124555.toLong) + .setWeightCol("1.2") + .setFeatureSubsetStrategy("auto") + .setSubsamplingRate(0.5) + .setLossType("absolute") + .setMaxIter(20) + .setStepSize(0.3) + .setValidationIndicatorCol("ValidationIndicatorCol") + ) + gbt.build().isSuccess shouldBe true + + val res = List(gbt.build().get) + val resParameters = res.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + resParameters.head should contain theSameElementsAs expectedParameters.head + + gbtWithTwoParams.getMinInstancesPerNode shouldBe 1 + gbtWithTwoParams.getLossType shouldBe "squared" + gbtWithTwoParams.getStepSize shouldBe 0.1 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "Seed" -> "124555", + "WeightCol" -> "1.2", + "FeatureSubsetStrategy" -> "auto" , + "SubsamplingRate" -> "0.5", + "LossType" -> "absolute", + "MaxIter" -> "20", + "StepSize" -> "0.3" , + "ValidationIndicatorCol" -> "ValidationIndicatorCol" + ) + val caraLr = GBTRegressor(params) + val model =caraLr.build().get.asInstanceOf[SparkML] + + caraLr.getMethode(model,10.toLong,"Seed").getName shouldBe "setSeed" + caraLr.getMethode(model,"PredictCol","PredictionCol").getName shouldBe "setPredictionCol" + caraLr.getMethode(model, 10 ,"CheckpointInterval").getName shouldBe "setCheckpointInterval" + } + +} + diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/KMeansTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/KMeansTest.scala new file mode 100644 index 0000000..26ab4e1 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/KMeansTest.scala @@ -0,0 +1,66 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.clustering.{KMeans => SparkML} +import io.github.jsarni.TestBase + +class KMeansTest extends TestBase { + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "DistanceMeasure" -> "euclidean", + "FeaturesCol" -> "FeaturesCol", + "K" -> "5", + "MaxIter" -> "12", + "PredictionCol" -> "PredictionCol", + "Seed" -> "1214151", + "Tol" -> "0.2", + "WeightCol" -> "WeightColname" + ) + + val Kmeans = KMeans(params) + val KmeansWithTwoParams = new SparkML() + .setTol(0.3) + .setDistanceMeasure("euclidean") + + val expectedResult = List( + new SparkML() + .setDistanceMeasure("euclidean") + .setFeaturesCol("FeaturesCol") + .setK(5) + .setMaxIter(12) + .setPredictionCol("PredictionCol") + .setSeed(1214151) + .setTol(0.2) + .setWeightCol("WeightColname") + ) + Kmeans.build().isSuccess shouldBe true + + val res = List(Kmeans.build().get) + val resParameters = res.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + resParameters.head should contain theSameElementsAs expectedParameters.head + + KmeansWithTwoParams.getMaxIter shouldBe 20 + KmeansWithTwoParams.getK shouldBe 2 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "DistanceMeasure" -> "euclidean", + "FeaturesCol" -> "FeaturesCol", + "K" -> "5", + "MaxIter" -> "12", + "PredictionCol" -> "PredictionCol", + "Seed" -> "1214151", + "Tol" -> "0.2", + "WeightCol" -> "WeightColname" + ) + val caraKmeans = KMeans(params) + val model =caraKmeans.build().get.asInstanceOf[SparkML] + + caraKmeans.getMethode(model,10,"MaxIter").getName shouldBe "setMaxIter" + caraKmeans.getMethode(model,2,"K").getName shouldBe "setK" + caraKmeans.getMethode(model, "euclidean" ,"DistanceMeasure").getName shouldBe "setDistanceMeasure" + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LDATest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LDATest.scala new file mode 100644 index 0000000..5843e07 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LDATest.scala @@ -0,0 +1,73 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.clustering.{LDA => SparkML} +import io.github.jsarni.TestBase + +class LDATest extends TestBase { + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "CheckpointInterval" -> "3", + "DocConcentration" -> "1.02, 1.5, 12.4", + "FeaturesCol" -> "FeaturesCol", + "K" -> "6", + "MaxIter" -> "15", + "Optimizer" -> "online", + "Seed" -> "12454535", + "SubsamplingRate" -> "0.066", + "TopicConcentration" -> "0.23", + "TopicDistributionCol" -> "gamma" + ) + + val LDAModel = LDA(params) + val LDAWithTwoParams = new SparkML() + .setSeed(6464845) + .setTopicDistributionCol("gamma") + + val expectedResult = List( + new SparkML() + .setCheckpointInterval(3) + .setDocConcentration(Array(1.02, 1.5, 12.4)) + .setFeaturesCol("FeaturesCol") + .setK(6) + .setMaxIter(15) + .setOptimizer("online") + .setSeed(12454535) + .setSubsamplingRate(0.066) + .setTopicConcentration(0.23) + .setTopicDistributionCol("gamma") + ) + LDAModel.build().isSuccess shouldBe true + + val res = List(LDAModel.build().get) + val resParameters = res.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + resParameters.head should contain theSameElementsAs expectedParameters.head + + LDAWithTwoParams.getMaxIter shouldBe 20 + LDAWithTwoParams.getK shouldBe 10 + LDAWithTwoParams.getSubsamplingRate shouldBe 0.05 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "CheckpointInterval" -> "3", + "DocConcentration" -> "1.02, 1.5, 12.4", + "FeaturesCol" -> "FeaturesCol", + "K" -> "6", + "MaxIter" -> "15", + "Optimizer" -> "online", + "Seed" -> "12454535", + "SubsamplingRate" -> "0.066", + "TopicConcentration" -> "0.23", + "TopicDistributionCol" -> "gamma" + ) + val caraLDA = LDA(params) + val model =caraLDA.build().get.asInstanceOf[SparkML] + + caraLDA.getMethode(model,10,"MaxIter").getName shouldBe "setMaxIter" + caraLDA.getMethode(model,2,"K").getName shouldBe "setK" + caraLDA.getMethode(model, "gamma" ,"TopicDistributionCol").getName shouldBe "setTopicDistributionCol" + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LinearRegressionTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LinearRegressionTest.scala new file mode 100644 index 0000000..7b71f37 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LinearRegressionTest.scala @@ -0,0 +1,77 @@ +package io.github.jsarni.caraml.carastage.modelstage +import org.apache.spark.ml.regression.{LinearRegression => SparkLR} +import io.github.jsarni.TestBase + +class LinearRegressionTest extends TestBase { + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "MaxIter" -> "10", + "RegParam" -> "0.3", + "ElasticNetParam" -> "0.1", + "FeaturesCol" -> "FeatureColname", + "FitIntercept" -> "True", + "PredictionCol" -> "Age", + "Standardization" -> "True", + "Tol" -> "0.13", + "WeightCol" -> "WeightColname", + "Loss" -> "huber", + "Solver" -> "normal", + "LabelCol" -> "LabelCol" + ) + val lr = LinearRegression(params) + val lrWithTwoParams = new SparkLR() + .setRegParam(0.8) + .setStandardization(false) + + val expectedResult = List( + new SparkLR() + .setMaxIter(10) + .setRegParam(0.3) + .setElasticNetParam(0.1) + .setFeaturesCol("FeatureColname") + .setFitIntercept(true) + .setPredictionCol("Age") + .setStandardization(true) + .setTol(0.13) + .setWeightCol("WeightColname") + .setLoss("huber") + .setSolver("normal") + .setLabelCol("LabelCol") + ) + lr.build().isSuccess shouldBe true + + val res = List(lr.build().get) + val resParameters = res.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + resParameters.head should contain theSameElementsAs expectedParameters.head + + lrWithTwoParams.getMaxIter shouldBe 100 + lrWithTwoParams.getLoss shouldBe "squaredError" + lrWithTwoParams.getTol shouldBe 0.000001 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "MaxIter" -> "10", + "RegParam" -> "0.3", + "ElasticNetParam" -> "0.1", + "FeaturesCol" -> "FeatureColname", + "FitIntercept" -> "True", + "PredictionCol" -> "Age", + "Standardization" -> "True", + "Tol" -> "0.13", + "WeightCol" -> "WeightColname", + "Loss" -> "huber", + "Solver" -> "normal", + "LabelCol" -> "LabelCol" + ) + val caraLr = LinearRegression(params) + val model =caraLr.build().get.asInstanceOf[SparkLR] + + caraLr.getMethode(model,10,"MaxIter").getName shouldBe "setMaxIter" + caraLr.getMethode(model,0.0,"RegParam").getName shouldBe "setRegParam" + caraLr.getMethode(model, false ,"Standardization").getName shouldBe "setStandardization" + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LogisticRegressionTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LogisticRegressionTest.scala new file mode 100644 index 0000000..08e1c2b --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/LogisticRegressionTest.scala @@ -0,0 +1,82 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.classification.{LogisticRegression => SparkLR} + + +class LogisticRegressionTest extends TestBase { + + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "MaxIter" -> "10", + "RegParam" -> "0.3", + "ElasticNetParam" -> "0.1", + "Family" -> "multinomial", + "FeaturesCol" -> "FeatureColname", + "FitIntercept" -> "True", + "PredictionCol" -> "Age", + "ProbabilityCol" -> "ProbaColname", + "RawPredictionCol"-> "RawPredictColname", + "Standardization" -> "True", + "Tol" -> "0.13", + "WeightCol" -> "WeightColname", + "Thresholds" -> "0.2, 0.4, .05" + ) + val lr = LogisticRegression(params) + val lrWithTwoParams = new SparkLR() + .setRegParam(0.8) + .setStandardization(false) + + val expectedResult = List( + new SparkLR() + .setMaxIter(10) + .setRegParam(0.3) + .setElasticNetParam(0.1) + .setFamily("multinomial") + .setFeaturesCol("FeatureColname") + .setFitIntercept(true) + .setPredictionCol("Age") + .setProbabilityCol("ProbaColname") + .setRawPredictionCol("RawPredictColname") + .setStandardization(true).setTol(0.13) + .setWeightCol("WeightColname") + .setThresholds(Array(0.2, 0.4, .05)) + ) + lr.build().isSuccess shouldBe true + + val res = List(lr.build().get) + val resParameters = res.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + resParameters.head should contain theSameElementsAs expectedParameters.head + + lrWithTwoParams.getMaxIter shouldBe 100 + lrWithTwoParams.getFamily shouldBe "auto" + lrWithTwoParams.getTol shouldBe 0.000001 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "MaxIter" -> "10", + "RegParam" -> "0.3", + "ElasticNetParam" -> "0.1", + "Family" -> "multinomial", + "FeaturesCol" -> "FeatureColname", + "FitIntercept" -> "True", + "PredictionCol" -> "Age", + "ProbabilityCol" -> "ProbaColname", + "RawPredictionCol"-> "RawPredictColname", + "Standardization" -> "True", + "Tol" -> "0.13", + "WeightCol" -> "WeightColname", + "Thresholds" -> "0.2, 0.4, .05" + ) + val caraLr = LogisticRegression(params) + val model =caraLr.build().get.asInstanceOf[SparkLR] + + caraLr.getMethode(model,10,"MaxIter").getName shouldBe "setMaxIter" + caraLr.getMethode(model,0.0,"RegParam").getName shouldBe "setRegParam" + caraLr.getMethode(model, false ,"Standardization").getName shouldBe "setStandardization" + } +} + diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/NaiveBayesTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/NaiveBayesTest.scala new file mode 100644 index 0000000..babfeba --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/NaiveBayesTest.scala @@ -0,0 +1,67 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.classification.{NaiveBayes => SparkML} +import io.github.jsarni.TestBase + +class NaiveBayesTest extends TestBase { + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "FeaturesCol" -> "FeaturesCol", + "LabelCol" -> "LabelCol", + "ModelType" -> "gaussian", + "PredictionCol" -> "PredictionCol", + "ProbabilityCol" -> "ProbabilityCol", + "RawPredictionCol" -> "RawPredictionCol", + "Smoothing" -> "0.8", + "Thresholds" -> "0.2, 0.4, 1.05", + "WeightCol" -> "WeightCol" + ) + val NBayes = NaiveBayes(params) + val NBayesWithTwoParams = new SparkML() + .setFeaturesCol("FeaturesCol") + .setThresholds(Array(0.5, 1.44)) + + val expectedResult = List( + new SparkML() + .setFeaturesCol("FeaturesCol") + .setLabelCol("LabelCol") + .setModelType("gaussian") + .setPredictionCol("PredictionCol") + .setProbabilityCol("ProbabilityCol") + .setRawPredictionCol("RawPredictionCol") + .setSmoothing(0.8) + .setThresholds(Array(0.2, 0.4, 1.05)) + .setWeightCol("WeightCol") + ) + NBayes.build().isSuccess shouldBe true + + val res = List(NBayes.build().get) + val resParameters = res.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + resParameters.head should contain theSameElementsAs expectedParameters.head + + NBayesWithTwoParams.getSmoothing shouldBe 1.0 + NBayesWithTwoParams.getModelType shouldBe "multinomial" + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "FeaturesCol" -> "FeaturesCol", + "LabelCol" -> "LabelCol", + "ModelType" -> "gaussian", + "PredictionCol" -> "PredictionCol", + "ProbabilityCol" -> "ProbabilityCol", + "RawPredictionCol" -> "RawPredictionCol", + "Smoothing" -> "0.8", + "Thresholds" -> "0.2, 0.4, .05", + "WeightCol" -> "WeightCol" + ) + val caraNaivebayes = NaiveBayes(params) + val model =caraNaivebayes.build().get.asInstanceOf[SparkML] + + caraNaivebayes.getMethode(model,"String","FeaturesCol").getName shouldBe "setFeaturesCol" + caraNaivebayes.getMethode(model,0.0,"Smoothing").getName shouldBe "setSmoothing" + caraNaivebayes.getMethode(model, Array(1.0,0.2) ,"Thresholds").getName shouldBe "setThresholds" + } +} \ No newline at end of file diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestClassifierTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestClassifierTest.scala new file mode 100644 index 0000000..d76d228 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestClassifierTest.scala @@ -0,0 +1,113 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import io.github.jsarni.TestBase +import org.apache.spark.ml.classification.{RandomForestClassifier => SparkML} + +class RandomForestClassifierTest extends TestBase { + + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "Impurity" -> "entropy", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "ProbabilityCol" -> "ProbabilityCol", + "RawPredictionCol" -> "RawPredictionCol", + "Seed" -> "124555", + "Thresholds" -> "0.2, 0.04", + "WeightCol" -> "1.2", + "FeatureSubsetStrategy" -> "auto" , + "SubsamplingRate" -> "0.5", + "NumTrees" -> "12" + ) + val rdForest = RandomForestClassifier(params) + val rdForestWithTwoParams = new SparkML() + .setCheckpointInterval(5) + .setMaxDepth(10) + + val expectedResult = List( + new SparkML() + .setCheckpointInterval(10) + .setFeaturesCol("FeatureCol") + .setImpurity("entropy") + .setLabelCol("LabelCol") + .setLeafCol("LeafCol") + .setMaxBins(10) + .setMaxDepth(5) + .setMinInfoGain(0.02) + .setMinInstancesPerNode(2) + .setMinWeightFractionPerNode(0.03) + .setPredictionCol("PredictionCol") + .setProbabilityCol("ProbabilityCol") + .setRawPredictionCol("RawPredictionCol") + .setSeed(124555.toLong) + .setWeightCol("1.2") + .setThresholds(Array(0.2, 0.04)) + .setFeatureSubsetStrategy("auto") + .setSubsamplingRate(0.5) + .setNumTrees(12) + ) + rdForest.build().isSuccess shouldBe true + + val res = List(rdForest.build().get) + val resParameters = + res.flatMap { elem => + val values = elem.extractParamMap().toSeq.map(_.value) + values.flatMap { value => + if (value.isInstanceOf[Array[_]]) value.asInstanceOf[Array[_]].toList else List(value) + } + } + + val expectedParameters = expectedResult.flatMap { elem => + val values = elem.extractParamMap().toSeq.map(_.value) + values.flatMap { value => + if (value.isInstanceOf[Array[_]]) value.asInstanceOf[Array[_]].toList else List(value) + } + } + + resParameters should contain theSameElementsAs expectedParameters + + rdForestWithTwoParams.getImpurity shouldBe "gini" + rdForestWithTwoParams.getMaxBins shouldBe 32 + rdForestWithTwoParams.getNumTrees shouldBe 20 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "Impurity" -> "entropy", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "ProbabilityCol" -> "ProbabilityCol", + "RawPredictionCol" -> "RawPredictionCol", + "Seed" -> "124555", + "Thresholds" -> "0.2, 0.04", + "WeightCol" -> "1.2", + "FeatureSubsetStrategy" -> "auto" , + "SubsamplingRate" -> "0.5", + "NumTrees" -> "12" + ) + val caraLr = RandomForestClassifier(params) + val model =caraLr.build().get.asInstanceOf[SparkML] + + caraLr.getMethode(model,10.toLong,"Seed").getName shouldBe "setSeed" + caraLr.getMethode(model,"PredictCol","PredictionCol").getName shouldBe "setPredictionCol" + caraLr.getMethode(model, 10 ,"CheckpointInterval").getName shouldBe "setCheckpointInterval" + + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestRegressorTest.scala b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestRegressorTest.scala new file mode 100644 index 0000000..bbbea9a --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carastage/modelstage/RandomForestRegressorTest.scala @@ -0,0 +1,99 @@ +package io.github.jsarni.caraml.carastage.modelstage + +import org.apache.spark.ml.regression.{RandomForestRegressor => SparkML} +import io.github.jsarni.TestBase + +class RandomForestRegressorTest extends TestBase { + + "build" should "Create an lr model and set all parameters with there args values or set default ones" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "Impurity" -> "variance", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "Seed" -> "124555", + "WeightCol" -> "1.2", + "FeatureSubsetStrategy" -> "auto" , + "SubsamplingRate" -> "0.5", + "NumTrees" -> "12" + ) + val rdForest = RandomForestRegressor(params) + val rdForestWithTwoParams = new SparkML() + .setCheckpointInterval(5) + .setMaxDepth(10) + + val expectedResult = List( + new SparkML() + .setCheckpointInterval(10) + .setFeaturesCol("FeatureCol") + .setImpurity("variance") + .setLabelCol("LabelCol") + .setLeafCol("LeafCol") + .setMaxBins(10) + .setMaxDepth(5) + .setMinInfoGain(0.02) + .setMinInstancesPerNode(2) + .setMinWeightFractionPerNode(0.03) + .setPredictionCol("PredictionCol") + .setSeed(124555.toLong) + .setWeightCol("1.2") + .setFeatureSubsetStrategy("auto") + .setSubsamplingRate(0.5) + .setNumTrees(12) + + ) + rdForest.build().isSuccess shouldBe true + + val res = List(rdForest.build().get) + val resParameters = res.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + val resContain = resParameters(0).toList + val expectedContain = expectedParameters(0).toList + + resContain should contain theSameElementsAs expectedContain + + rdForestWithTwoParams.getImpurity shouldBe "variance" + rdForestWithTwoParams.getMaxBins shouldBe 32 + rdForestWithTwoParams.getNumTrees shouldBe 20 + } + + "GetMethode" should "Return the appropriate methode by it's name" in { + val params = Map( + "CheckpointInterval" -> "10", + "FeaturesCol" -> "FeatureCol", + "Impurity" -> "variance", + "LabelCol" -> "LabelCol", + "LeafCol" -> "LeafCol", + "MaxBins" -> "10", + "MaxDepth" -> "5", + "MinInfoGain"-> "0.02", + "MinInstancesPerNode" -> "2", + "MinWeightFractionPerNode" -> "0.03", + "PredictionCol" -> "PredictionCol", + "ProbabilityCol" -> "ProbabilityCol", + "RawPredictionCol" -> "RawPredictionCol", + "Seed" -> "124555", + "Thresholds" -> "0.2, 0.04", + "WeightCol" -> "1.2", + "FeatureSubsetStrategy" -> "auto" , + "SubsamplingRate" -> "0.5", + "NumTrees" -> "12" + ) + val caraLr = RandomForestRegressor(params) + val model =caraLr.build().get.asInstanceOf[SparkML] + + caraLr.getMethode(model,10.toLong,"Seed").getName shouldBe "setSeed" + caraLr.getMethode(model,"PredictCol","PredictionCol").getName shouldBe "setPredictionCol" + caraLr.getMethode(model, 10 ,"CheckpointInterval").getName shouldBe "setCheckpointInterval" + } + +} + diff --git a/src/test/scala/io/github/jsarni/caraml/carayaml/CaraYamlReaderTest.scala b/src/test/scala/io/github/jsarni/caraml/carayaml/CaraYamlReaderTest.scala new file mode 100644 index 0000000..e9997e6 --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/carayaml/CaraYamlReaderTest.scala @@ -0,0 +1,34 @@ +package io.github.jsarni.caraml.carayaml + +import java.io.FileNotFoundException + +import io.github.jsarni.TestBase +import org.yaml.snakeyaml.scanner.ScannerException + +class CaraYamlReaderTest extends TestBase { + + "loadFile" should "return parse the yaml description file to a json object" in { + val caraPath = getClass.getResource("/cara.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + + val result = caraYaml.loadFile() + + result.isSuccess shouldBe true + } + it should "Return an exception if the file does not exist" in { + val caraPath = "/inexisting_model.yaml" + val caraYaml = CaraYamlReader(caraPath) + + val result = caraYaml.loadFile() + an [FileNotFoundException] should be thrownBy result.get + } + it should "Return an exception if the file format is not correct" in { + val caraPath = getClass.getResource("/incorrect_cara.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + + val result = caraYaml.loadFile() + + an [ScannerException] should be thrownBy result.get + } + +} diff --git a/src/test/scala/io/github/jsarni/caraml/pipelineparser/CaraParserTest.scala b/src/test/scala/io/github/jsarni/caraml/pipelineparser/CaraParserTest.scala new file mode 100644 index 0000000..7b45bca --- /dev/null +++ b/src/test/scala/io/github/jsarni/caraml/pipelineparser/CaraParserTest.scala @@ -0,0 +1,239 @@ +package io.github.jsarni.caraml.pipelineparser + +import io.github.jsarni.caraml.carastage.{CaraStage, CaraStageDescription} +import io.github.jsarni.caraml.carastage.modelstage.LogisticRegression +import io.github.jsarni.caraml.carastage.tuningstage.TuningStageDescription +import io.github.jsarni.TestBase +import io.github.jsarni.caraml.carayaml.CaraYamlReader +import org.apache.spark.ml.{Pipeline, PipelineStage} +import org.apache.spark.ml.classification.{LogisticRegression => SparkLR} +import org.apache.spark.ml.evaluation.RegressionEvaluator +import org.apache.spark.ml.evaluation.Evaluator + +import scala.util.Try + +class CaraParserTest extends TestBase { + + "extractTuner" should "return parse the yaml description file to a json object" in { + val caraPath = getClass.getResource("/cara.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + val myJson = caraYaml.loadFile() + + val extractStages = PrivateMethod[Try[List[CaraStageDescription]]]('extractStages) + val result = caraParser.invokePrivate(extractStages(myJson.get)) + + val expectedResult = Seq( + CaraStageDescription("LogisticRegression", Map("MaxIter" -> "10", "RegParam" -> "0.3", "ElasticNetParam" -> "0.1")), + CaraStageDescription("FeatureSelection", Map("Param1" -> "S", "Param2" -> "0.5", "Param3" -> "false")) + ) + + result.isSuccess shouldBe true + result.get should contain theSameElementsAs expectedResult + } + + "parseSingleStageMap" should "parse a CaraStageDescription to a carastage " in { + val caraPath = getClass.getResource("/cara.yaml").getPath + val caraParser = CaraParser(CaraYamlReader(caraPath)) + + val params = Map("MaxIter" -> "10", "RegParam" -> "0.3", "ElasticNetParam" -> "0.1") + val stageDesc = CaraStageDescription("LogisticRegression", params) + val parseSingleStageMap = PrivateMethod[Try[CaraStage[_ <: PipelineStage]]]('parseSingleStageMap) + + val res = caraParser.invokePrivate(parseSingleStageMap(stageDesc)) + + res.isSuccess shouldBe true + res.get.isInstanceOf[LogisticRegression] shouldBe true + res.get.asInstanceOf[LogisticRegression].MaxIter shouldBe params.get("MaxIter").map(_.toInt) + res.get.asInstanceOf[LogisticRegression].RegParam shouldBe params.get("RegParam").map(_.toDouble) + res.get.asInstanceOf[LogisticRegression].ElasticNetParam shouldBe params.get("ElasticNetParam").map(_.toDouble) + } + + "parseStages" should "parse a list of CaraStageDescription to the corresponding list of carastage" in { + val caraPath = getClass.getResource("/cara.yaml").getPath + val caraParser = CaraParser(CaraYamlReader(caraPath)) + + val params1 = Map("MaxIter" -> "10", "RegParam" -> "0.3", "ElasticNetParam" -> "0.1") + val params2 = Map("MaxIter" -> "20", "FitIntercept" -> "False", "ProbabilityCol" -> "col1") + val stagesDesc = List( + CaraStageDescription("LogisticRegression", params1), + CaraStageDescription("LogisticRegression", params2) + ) + + val expectedResult = List(LogisticRegression(params1), LogisticRegression(params2)) + + val parseStages = PrivateMethod[Try[List[CaraStage[_ <: PipelineStage]]]]('parseStages) + val res = caraParser.invokePrivate(parseStages(stagesDesc)) + + res.isSuccess shouldBe true + res.get should contain theSameElementsAs expectedResult + } + + "buildStages" should "build a list PipelineStages out of a list of CaraStages" in { + val caraPath = getClass.getResource("/cara.yaml").getPath + val caraParser = CaraParser(CaraYamlReader(caraPath)) + + val params1 = Map("MaxIter" -> "10", "RegParam" -> "0.3", "ElasticNetParam" -> "0.1") + val params2 = Map("MaxIter" -> "20", "FitIntercept" -> "False", "ProbabilityCol" -> "col1") + val stagesList = List( + LogisticRegression(params1), LogisticRegression(params2) + ) + + val expectedResult = List( + new SparkLR().setMaxIter(10).setRegParam(0.3).setElasticNetParam(0.1), + new SparkLR().setMaxIter(20).setFitIntercept(false).setProbabilityCol("col1") + ) + + val buildStages = PrivateMethod[Try[List[PipelineStage]]]('buildStages) + val res = caraParser.invokePrivate(buildStages(stagesList)) + + res.isSuccess shouldBe true + + val resParameters = res.get.map(_.extractParamMap().toSeq.map(_.value)) + val expectedParameters = expectedResult.map(_.extractParamMap().toSeq.map(_.value)) + + resParameters.head should contain theSameElementsAs expectedParameters.head + resParameters(1) should contain theSameElementsAs expectedParameters(1) + } + + "buildPipeline" should "build a Spark ML Pipeline out of a list of PipelineStages" in { + val caraPath = getClass.getResource("/cara.yaml").getPath + val caraParser = CaraParser(CaraYamlReader(caraPath)) + + val stagesList = List( + new SparkLR().setMaxIter(10).setRegParam(0.3).setElasticNetParam(0.1) + ) + + val buildPipeline = PrivateMethod[Try[Pipeline]]('buildPipeline) + val res = caraParser.invokePrivate(buildPipeline(stagesList)) + + res.isSuccess shouldBe true + res.get.getStages shouldBe new Pipeline().setStages(stagesList.toArray).getStages + } + + "parsePipeline" should "build the described Pipeline of the Yaml File" in { + val caraPath = getClass.getResource("/cara_for_build.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + + val parsePipeline = PrivateMethod[Try[Pipeline]]('parsePipeline) + val res = caraParser.invokePrivate(parsePipeline()) + val exprectedRes = new Pipeline().setStages(Array(new SparkLR().setMaxIter(10).setRegParam(0.3).setElasticNetParam(0.1))) + + res.isSuccess shouldBe true + res.get.getStages.map(_.extractParamMap().toSeq.map(_.value)).head should contain theSameElementsAs + exprectedRes.getStages.map(_.extractParamMap().toSeq.map(_.value)).head + } + + "extractTuner" should "get the correct Evaluator Name from the Yaml File" in { + val caraPath = getClass.getResource("/cara_for_build.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + val myJson = caraYaml.loadFile() + + val extractEvaluator = PrivateMethod[Try[String]]('extractEvaluator) + val result = caraParser.invokePrivate(extractEvaluator(myJson.get)) + + result.isSuccess shouldBe true + result.get shouldBe "RegressionEvaluator" + } + + it should "Raise an exception if there is no evaluator specified" in { + val caraPath = getClass.getResource("/cara_zero_evaluator.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + val myJson = caraYaml.loadFile() + + val extractEvaluator = PrivateMethod[Try[String]]('extractEvaluator) + val result = caraParser.invokePrivate(extractEvaluator(myJson.get)) + + result.isFailure shouldBe true + } + + it should "Raise an exception if there is more than one evaluator specified" in { + val caraPath = getClass.getResource("/cara_two_evaluator.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + val myJson = caraYaml.loadFile() + + val extractEvaluator = PrivateMethod[Try[String]]('extractEvaluator) + val result = caraParser.invokePrivate(extractEvaluator(myJson.get)) + + result.isFailure shouldBe true + } + + "parseEvaluator" should "build the described evaluator of the Yaml File" in { + val caraPath = getClass.getResource("/cara_for_build.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + val parseEvaluator = PrivateMethod[Try[Evaluator]]('parseEvaluator) + val res = caraParser.invokePrivate(parseEvaluator()) + + res.isSuccess shouldBe true + res.get.isInstanceOf[RegressionEvaluator] shouldBe true + } + + "extractTuner" should "get the correct Tuner Description from the Yaml File" in { + val caraPath = getClass.getResource("/cara_for_build.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + val myJson = caraYaml.loadFile() + + val extractTuner = PrivateMethod[Try[TuningStageDescription]]('extractTuner) + val result = caraParser.invokePrivate(extractTuner(myJson.get)) + + result.isSuccess shouldBe true + result.get shouldBe TuningStageDescription("CrossValidator", "NumFolds", "3") + } + + it should "raise an exception ilf there is more than one tuner in the Yaml File" in { + val caraPath = getClass.getResource("/cara_two_evaluator.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + val myJson = caraYaml.loadFile() + + val extractTuner = PrivateMethod[Try[TuningStageDescription]]('extractTuner) + val result = caraParser.invokePrivate(extractTuner(myJson.get)) + + result.isFailure shouldBe true + an [IllegalArgumentException] should be thrownBy result.get + } + + "parseTuner" should "build the described Tuner of the Yaml File" in { + val caraPath = getClass.getResource("/cara_for_build.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + val myJson = caraYaml.loadFile() + + val extractTuner = PrivateMethod[Try[TuningStageDescription]]('extractTuner) + val result = caraParser.invokePrivate(extractTuner(myJson.get)) + + result.isSuccess shouldBe true + result.get shouldBe TuningStageDescription("CrossValidator", "NumFolds", "3") + } + + "build" should "build the described Pipeline of the Yaml File" in { + val caraPath = getClass.getResource("/cara_for_build.yaml").getPath + val caraYaml = CaraYamlReader(caraPath) + val caraParser = CaraParser(caraYaml) + + val res = caraParser.build() + + val exprectedRes = new Pipeline().setStages(Array(new SparkLR().setMaxIter(10).setRegParam(0.3).setElasticNetParam(0.1))) + + res.isSuccess shouldBe true + res.get.evaluator.isInstanceOf[RegressionEvaluator] shouldBe true + res.get.pipeline.getStages.map(_.extractParamMap().toSeq.map(_.value)).head should contain theSameElementsAs + exprectedRes.getStages.map(_.extractParamMap().toSeq.map(_.value)).head + res.get.tuner shouldBe Some(TuningStageDescription("CrossValidator", "NumFolds", "3")) + } +}